nighthawk-python 0.16.0__py3-none-any.whl → 0.17.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.
@@ -1,7 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import logging
4
- from typing import cast
4
+ from typing import TYPE_CHECKING, cast
5
5
 
6
6
  import tiktoken
7
7
  from opentelemetry import context as otel_context
@@ -29,6 +29,9 @@ from ._text_fallback import (
29
29
  )
30
30
  from .tool_bridge import ToolHandler
31
31
 
32
+ if TYPE_CHECKING:
33
+ from mcp.types import CallToolResult
34
+
32
35
  _logger = logging.getLogger("nighthawk")
33
36
 
34
37
 
@@ -187,27 +190,51 @@ def tool_handler_result_to_low_level_mcp_content(
187
190
  return [mcp_types.TextContent(type="text", text=render_preview())]
188
191
 
189
192
 
193
+ def tool_handler_result_to_low_level_mcp_result(
194
+ *,
195
+ tool_name: str,
196
+ tool_handler_result: ToolHandlerResult,
197
+ rendering_policy: ToolResultRenderingPolicy | None = None,
198
+ ) -> CallToolResult:
199
+ from mcp import types as mcp_types
200
+
201
+ content = tool_handler_result_to_low_level_mcp_content(
202
+ tool_name=tool_name, tool_handler_result=tool_handler_result, rendering_policy=rendering_policy
203
+ )
204
+ error = tool_handler_result["tool_outcome"]["error"] if tool_handler_result["kind"] == "tool_result" else None
205
+ return mcp_types.CallToolResult(
206
+ content=cast(list[mcp_types.ContentBlock], content),
207
+ is_error=tool_handler_result["kind"] != "tool_result" or error is not None,
208
+ structured_content={"error": dict(error)} if error is not None else None,
209
+ )
210
+
211
+
190
212
  def _tool_handler_result_to_claude_code_sdk_content(
191
213
  *,
192
214
  tool_name: str,
193
215
  tool_handler_result: ToolHandlerResult,
194
216
  rendering_policy: ToolResultRenderingPolicy | None = None,
195
217
  ) -> dict[str, object]:
196
- content_block_list = tool_handler_result_to_low_level_mcp_content(
218
+ result = tool_handler_result_to_low_level_mcp_result(
197
219
  tool_name=tool_name,
198
220
  tool_handler_result=tool_handler_result,
199
221
  rendering_policy=rendering_policy,
200
222
  )
201
223
 
202
224
  dumped_content_block_list: list[dict[str, object]] = []
203
- for content_block in content_block_list:
225
+ for content_block in result.content:
204
226
  model_dump = getattr(content_block, "model_dump", None)
205
227
  if callable(model_dump):
206
228
  dumped_content_block_list.append(cast(dict[str, object], model_dump(mode="json", by_alias=True, exclude_none=True)))
207
229
  continue
208
230
  raise TypeError(f"Unsupported MCP content block: {type(content_block).__name__}")
209
231
 
210
- return {"content": dumped_content_block_list}
232
+ response: dict[str, object] = {"content": dumped_content_block_list}
233
+ if result.is_error:
234
+ response["isError"] = True
235
+ if result.structured_content is not None:
236
+ response["structuredContent"] = result.structured_content
237
+ return response
211
238
 
212
239
 
213
240
  async def _get_tool_handler_result(
@@ -252,14 +279,14 @@ async def call_tool_for_low_level_mcp_server(
252
279
  tool_handler: ToolHandler,
253
280
  parent_otel_context: OtelContext,
254
281
  rendering_policy: ToolResultRenderingPolicy | None = None,
255
- ) -> list[object]:
282
+ ) -> CallToolResult:
256
283
  tool_handler_result = await _get_tool_handler_result(
257
284
  tool_name=tool_name,
258
285
  arguments=arguments,
259
286
  tool_handler=tool_handler,
260
287
  parent_otel_context=parent_otel_context,
261
288
  )
262
- return tool_handler_result_to_low_level_mcp_content(
289
+ return tool_handler_result_to_low_level_mcp_result(
263
290
  tool_name=tool_name,
264
291
  tool_handler_result=tool_handler_result,
265
292
  rendering_policy=rendering_policy,
@@ -8,7 +8,7 @@ import time
8
8
  from collections.abc import AsyncIterator, Awaitable, Callable
9
9
  from contextlib import asynccontextmanager
10
10
  from contextvars import copy_context
11
- from typing import Any, cast
11
+ from typing import Any
12
12
 
13
13
  from opentelemetry import context as otel_context
14
14
  from pydantic_ai.exceptions import UnexpectedModelBehavior
@@ -19,7 +19,7 @@ from ..runtime.step_context import (
19
19
  resolve_tool_result_rendering_policy,
20
20
  )
21
21
  from ..tools.contracts import ToolHandlerResult, ToolOutcome, build_tool_result_observation
22
- from .mcp_boundary import call_tool_for_low_level_mcp_server, tool_handler_result_to_low_level_mcp_content
22
+ from .mcp_boundary import call_tool_for_low_level_mcp_server, tool_handler_result_to_low_level_mcp_result
23
23
  from .tool_bridge import get_current_step_run_context_required
24
24
 
25
25
 
@@ -95,21 +95,19 @@ class McpServer:
95
95
  "error": {"kind": "resolution", "message": f"Unknown tool: {name}", "guidance": "Choose a visible tool name and retry."},
96
96
  }
97
97
  tool_handler_result: ToolHandlerResult = build_tool_result_observation(tool_outcome=tool_outcome)
98
- content_block_list = tool_handler_result_to_low_level_mcp_content(
98
+ return tool_handler_result_to_low_level_mcp_result(
99
99
  tool_name=name,
100
100
  tool_handler_result=tool_handler_result,
101
101
  rendering_policy=self._tool_result_rendering_policy,
102
102
  )
103
- return mcp_types.CallToolResult(content=cast(list[mcp_types.ContentBlock], content_block_list))
104
103
 
105
- content_block_list = await call_tool_for_low_level_mcp_server(
104
+ return await call_tool_for_low_level_mcp_server(
106
105
  tool_name=name,
107
106
  arguments=arguments,
108
107
  tool_handler=handler,
109
108
  parent_otel_context=self._parent_otel_context,
110
109
  rendering_policy=self._tool_result_rendering_policy,
111
110
  )
112
- return mcp_types.CallToolResult(content=cast(list[mcp_types.ContentBlock], content_block_list))
113
111
 
114
112
  mcp_server = McpLowLevelServer(
115
113
  "nighthawk",
@@ -154,8 +154,10 @@ async def execute_tool_call(
154
154
  try:
155
155
  value = await manager.handle_call(ToolCallPart(tool_name, arguments, tool_call_id), wrap_validation_errors=False)
156
156
  except ValidationError as exception:
157
- if run_context.deps.operation_failure is exception:
158
- raise
157
+ if run_context.deps.operation_failure is not None:
158
+ raise run_context.deps.operation_failure from run_context.deps.operation_failure.__cause__
159
+ if observed_toolset.outcome is not None and (observed_toolset.outcome["error"] or {}).get("status") == "rejected":
160
+ return build_tool_result_observation(tool_outcome=observed_toolset.outcome)
159
161
  retry_text = RetryPromptPart(
160
162
  tool_name=tool_name,
161
163
  content=exception.errors(include_url=False, include_context=False),
@@ -163,9 +165,20 @@ async def execute_tool_call(
163
165
  ).model_response()
164
166
  return _build_retry_prompt_observation(retry_text=retry_text)
165
167
  except ModelRetry as exception:
168
+ if run_context.deps.operation_failure is not None:
169
+ raise run_context.deps.operation_failure from run_context.deps.operation_failure.__cause__
170
+ if observed_toolset.outcome is not None and (observed_toolset.outcome["error"] or {}).get("status") == "rejected":
171
+ return build_tool_result_observation(tool_outcome=observed_toolset.outcome)
166
172
  return _build_retry_prompt_observation(retry_text=exception.message)
167
173
 
168
- if isinstance(value, ToolDenied):
174
+ if run_context.deps.operation_failure is not None:
175
+ raise run_context.deps.operation_failure
176
+ recorded_error = observed_toolset.outcome["error"] if observed_toolset.outcome is not None else None
177
+ if recorded_error is not None and recorded_error.get("status") == "rejected":
178
+ # A capability may replace a return value, but cannot turn a refused body into a success.
179
+ assert observed_toolset.outcome is not None
180
+ tool_outcome = observed_toolset.outcome
181
+ elif isinstance(value, ToolDenied):
169
182
  tool_outcome: ToolOutcome = {"payload": None, "error": {"kind": "oversight", "message": value.message, "guidance": None}}
170
183
  elif observed_toolset.outcome is not None and value is observed_toolset.return_value:
171
184
  tool_outcome = observed_toolset.outcome
nighthawk/lifecycle.py CHANGED
@@ -31,7 +31,7 @@ type FailureStage = Literal[
31
31
  "return_validation",
32
32
  "commit_inspection",
33
33
  "rewrite_validation",
34
- "oversight_rejection",
34
+ "oversight_abort",
35
35
  "raise_construction",
36
36
  "binding_assignment",
37
37
  ]
@@ -82,7 +82,7 @@ class StepFailed(_StepFinished):
82
82
  attempted_outcome: StepResult | None = None
83
83
  validated_binding_name_to_value: Mapping[str, object] | None = None
84
84
  inspection_subject: Literal["step_commit", "operation"] | None = None
85
- rejection_reason: str | None = None
85
+ abort_reason: str | None = None
86
86
  kind: Literal["failed"] = field(default="failed", init=False)
87
87
 
88
88
 
nighthawk/oversight.py CHANGED
@@ -18,8 +18,8 @@ def _reference_mapping(name_to_value: Mapping[str, object]) -> Mapping[str, obje
18
18
  return MappingProxyType(dict(name_to_value))
19
19
 
20
20
 
21
- class OversightRejectedError(NighthawkError):
22
- """Raised when oversight explicitly rejects an execution boundary."""
21
+ class StepAbortedError(NighthawkError):
22
+ """An explicit host decision ending the inspected step, without rollback."""
23
23
 
24
24
  def __init__(self, reason: str, *, subject: str = "operation") -> None:
25
25
  self.reason = reason
@@ -75,9 +75,29 @@ class Accept:
75
75
 
76
76
 
77
77
  @dataclass(frozen=True)
78
- class Reject:
78
+ class RejectOperation:
79
+ """Do not execute this operation; expose reason and guidance to its caller or model."""
80
+
81
+ reason: str
82
+ guidance: str | None = None
83
+
84
+ def __post_init__(self) -> None:
85
+ if not isinstance(self.reason, str) or not self.reason.strip():
86
+ raise ValueError("RejectOperation requires a nonempty string reason")
87
+ if self.guidance is not None and not isinstance(self.guidance, str):
88
+ raise TypeError("RejectOperation guidance must be a string or None")
89
+
90
+
91
+ @dataclass(frozen=True)
92
+ class AbortStep:
93
+ """End the current step; catching its exception cannot authorize completion."""
94
+
79
95
  reason: str
80
96
 
97
+ def __post_init__(self) -> None:
98
+ if not isinstance(self.reason, str) or not self.reason.strip():
99
+ raise ValueError("AbortStep requires a nonempty string reason")
100
+
81
101
 
82
102
  @dataclass(frozen=True)
83
103
  class Rewrite:
@@ -106,12 +126,13 @@ class Rewrite:
106
126
  raise ValueError("Rewrite Raise cannot include output bindings")
107
127
 
108
128
 
109
- type StepCommitDecision = Accept | Reject | Rewrite
129
+ type OperationDecision = Accept | RejectOperation | AbortStep
130
+ type StepCommitDecision = Accept | Rewrite | AbortStep
110
131
 
111
132
 
112
133
  @dataclass(frozen=True)
113
134
  class Oversight:
114
- inspect_operation: Callable[[OperationRequest], Accept | Reject | Awaitable[Accept | Reject]] | None = None
135
+ inspect_operation: Callable[[OperationRequest], OperationDecision | Awaitable[OperationDecision]] | None = None
115
136
  inspect_step_commit: Callable[[StepCommit], StepCommitDecision | Awaitable[StepCommitDecision]] | None = None
116
137
 
117
138
 
@@ -153,9 +174,11 @@ __all__ = [
153
174
  "Raise",
154
175
  "StepResult",
155
176
  "Accept",
177
+ "AbortStep",
178
+ "OperationDecision",
156
179
  "Oversight",
157
- "OversightRejectedError",
158
- "Reject",
180
+ "RejectOperation",
181
+ "StepAbortedError",
159
182
  "Rewrite",
160
183
  "StepCommit",
161
184
  "StepCommitDecision",
@@ -18,7 +18,14 @@ if TYPE_CHECKING:
18
18
  from .environment import StepEnvironment
19
19
  from .expression import eval_expression, eval_expression_async
20
20
  from .inspection import inspect_value
21
- from .operation_contract import OperationError, OperationFinished, OperationRequest
21
+ from .operation_contract import (
22
+ OperationDeliveryError,
23
+ OperationError,
24
+ OperationFinished,
25
+ OperationRejectedError,
26
+ OperationRejection,
27
+ OperationRequest,
28
+ )
22
29
  from .step_definition import StepDefinition
23
30
  from .step_request import StepCandidate, StepRequest
24
31
  from .step_result import Break, Continue, Pass, Raise, Return, StepResult
@@ -48,6 +55,9 @@ _name_to_module = {
48
55
  "StepEnvironment": "environment",
49
56
  "inspect_value": "inspection",
50
57
  "OperationError": "operation_contract",
58
+ "OperationDeliveryError": "operation_contract",
59
+ "OperationRejectedError": "operation_contract",
60
+ "OperationRejection": "operation_contract",
51
61
  "OperationFinished": "operation_contract",
52
62
  "OperationRequest": "operation_contract",
53
63
  "Pass": "step_result",
@@ -82,6 +92,9 @@ __all__ = [
82
92
  "StepEnvironment",
83
93
  "inspect_value",
84
94
  "OperationError",
95
+ "OperationDeliveryError",
96
+ "OperationRejectedError",
97
+ "OperationRejection",
85
98
  "OperationFinished",
86
99
  "OperationRequest",
87
100
  "Pass",
@@ -0,0 +1,43 @@
1
+ """A response-local rejection barrier, independent of model retry budgets."""
2
+
3
+ from collections.abc import Iterator
4
+ from contextlib import contextmanager
5
+ from contextvars import ContextVar
6
+ from dataclasses import dataclass
7
+
8
+ from .operation_contract import OperationRejection
9
+ from .step_context import StepContext
10
+
11
+
12
+ @dataclass
13
+ class ModelResponseExecution:
14
+ step_context: StepContext
15
+ rejection: OperationRejection | None = None
16
+
17
+
18
+ _model_response_execution: ContextVar[ModelResponseExecution | None] = ContextVar("nighthawk_model_response_execution", default=None)
19
+
20
+
21
+ @contextmanager
22
+ def model_response_scope(step_context: StepContext, *, reuse: bool = False) -> Iterator[ModelResponseExecution]:
23
+ current = _model_response_execution.get()
24
+ if reuse and current is not None and current.step_context is step_context:
25
+ yield current
26
+ return
27
+ execution = ModelResponseExecution(step_context)
28
+ token = _model_response_execution.set(execution)
29
+ try:
30
+ yield execution
31
+ finally:
32
+ _model_response_execution.reset(token)
33
+
34
+
35
+ def get_response_rejection(step_context: StepContext) -> OperationRejection | None:
36
+ execution = _model_response_execution.get()
37
+ return execution.rejection if execution is not None and execution.step_context is step_context else None
38
+
39
+
40
+ def record_response_rejection(step_context: StepContext, rejection: OperationRejection) -> None:
41
+ execution = _model_response_execution.get()
42
+ if execution is not None and execution.step_context is step_context:
43
+ execution.rejection = rejection
@@ -11,8 +11,9 @@ from ..errors import NighthawkError
11
11
  from ..tools.contracts import ToolBoundaryError
12
12
  from .execution_reference import ExecutionReference
13
13
 
14
- type OperationKind = Literal["evaluation", "assignment", "return", "native_tool", "inspection"]
14
+ type OperationKind = Literal["evaluation", "assignment", "return", "native_tool", "inspection", "output"]
15
15
  type OperationPhase = Literal["admission", "inspection", "evaluation", "target_resolution", "validation", "assignment", "completed"]
16
+ type OperationStatus = Literal["succeeded", "rejected", "aborted", "skipped", "failed", "interrupted"]
16
17
 
17
18
 
18
19
  class OperationRequest(BaseModel):
@@ -33,14 +34,44 @@ class OperationRequest(BaseModel):
33
34
  return MappingProxyType(dict(value))
34
35
 
35
36
 
37
+ @dataclass(frozen=True)
38
+ class OperationRejection:
39
+ """One refused operation and the host's deliberately model-visible feedback."""
40
+
41
+ request: OperationRequest
42
+ reason: str
43
+ guidance: str | None = None
44
+
45
+ def model_feedback(self) -> dict[str, object]:
46
+ """Expose correlation and feedback, without serializing live arguments or diagnostics."""
47
+ return {
48
+ "status": "rejected",
49
+ "operation_id": self.request.operation_id,
50
+ "step_execution_id": self.request.execution_reference.step_execution_id,
51
+ "reason": self.reason,
52
+ "guidance": self.guidance,
53
+ "evaluation_started": False,
54
+ }
55
+
56
+
57
+ class OperationRejectedError(NighthawkError):
58
+ """The operation did not start; the same step can perform a different operation."""
59
+
60
+ def __init__(self, rejection: OperationRejection) -> None:
61
+ self.rejection = rejection
62
+ super().__init__(rejection.reason)
63
+
64
+
36
65
  @dataclass(frozen=True)
37
66
  class OperationFinished:
38
67
  request: OperationRequest
39
- status: Literal["succeeded", "rejected", "failed", "interrupted"]
68
+ status: OperationStatus
40
69
  phase: OperationPhase
41
70
  evaluation_started: bool
42
71
  value: object = None
43
72
  original_exception: BaseException | None = None
73
+ rejection: OperationRejection | None = None
74
+ blocked_by_operation_id: str | None = None
44
75
 
45
76
 
46
77
  class OperationError(ToolBoundaryError):
@@ -10,11 +10,21 @@ from dataclasses import dataclass
10
10
  from pydantic_ai.exceptions import ApprovalRequired, CallDeferred, ModelRetry
11
11
 
12
12
  from ..errors import NighthawkError
13
- from ..oversight import Accept, OversightRejectedError, Reject, record_oversight_decision
13
+ from ..oversight import AbortStep, Accept, RejectOperation, StepAbortedError, record_oversight_decision
14
14
  from ..ulid import generate_ulid
15
15
  from .interruption import chain_cleanup_errors
16
16
  from .notifications import await_notification
17
- from .operation_contract import OperationDeliveryError, OperationError, OperationFinished, OperationKind, OperationPhase, OperationRequest
17
+ from .operation_contract import (
18
+ OperationDeliveryError,
19
+ OperationError,
20
+ OperationFinished,
21
+ OperationKind,
22
+ OperationPhase,
23
+ OperationRejectedError,
24
+ OperationRejection,
25
+ OperationRequest,
26
+ OperationStatus,
27
+ )
18
28
  from .step_context import StepContext
19
29
 
20
30
  _active_operation_context: ContextVar[StepContext | None] = ContextVar("nighthawk_active_operation_context", default=None)
@@ -34,6 +44,7 @@ async def execute_operation_async(
34
44
  admit: Callable[[], None],
35
45
  execute: Callable[[OperationExecution], Awaitable[object]],
36
46
  recoverable_admission_error: BaseException | None = None,
47
+ blocked_by: OperationRejection | None = None,
37
48
  ) -> object:
38
49
  from .scoping import _lifecycle_var, _oversight_var
39
50
 
@@ -46,52 +57,71 @@ async def execute_operation_async(
46
57
  token = _active_operation_context.set(step_context)
47
58
  outcome: object = None
48
59
  original_exception: BaseException | None = None
49
- status = "succeeded"
60
+ status: OperationStatus = "succeeded"
61
+ rejection_error: OperationRejectedError | None = None
62
+ abort_error: StepAbortedError | None = None
50
63
  try:
51
64
  try:
52
- admit()
65
+ if blocked_by is not None:
66
+ status = "skipped"
67
+ else:
68
+ admit()
53
69
  execution.phase = "inspection"
54
70
  oversight = _oversight_var.get()
55
- if oversight is not None and oversight.inspect_operation is not None:
71
+ if blocked_by is None and oversight is not None and oversight.inspect_operation is not None:
56
72
  decision = oversight.inspect_operation(request)
57
73
  if inspect.isawaitable(decision):
58
74
  decision = await decision
59
- if not isinstance(decision, (Accept, Reject)):
60
- raise NighthawkError("Operation inspection must return Accept or Reject")
75
+ if not isinstance(decision, (Accept, RejectOperation, AbortStep)):
76
+ raise NighthawkError("Operation inspection must return Accept, RejectOperation, or AbortStep")
61
77
  record_oversight_decision(
62
78
  subject="operation",
63
- verdict="reject" if isinstance(decision, Reject) else "accept",
79
+ verdict="reject" if isinstance(decision, RejectOperation) else "abort" if isinstance(decision, AbortStep) else "accept",
64
80
  execution_reference=request.execution_reference,
65
81
  tool_name=request.tool_name,
66
82
  reason=decision.reason,
67
83
  )
68
- if isinstance(decision, Reject):
69
- raise OversightRejectedError(decision.reason, subject="operation")
70
- execution.phase = "evaluation"
71
- execution.evaluation_started = True
72
- outcome = await execute(execution)
73
- execution.phase = "completed"
84
+ if isinstance(decision, RejectOperation):
85
+ rejection_error = OperationRejectedError(OperationRejection(request, decision.reason, decision.guidance))
86
+ raise rejection_error
87
+ if isinstance(decision, AbortStep):
88
+ abort_error = StepAbortedError(decision.reason, subject="operation")
89
+ step_context.operation_abort = abort_error
90
+ raise abort_error
91
+ if blocked_by is None:
92
+ execution.phase = "evaluation"
93
+ execution.evaluation_started = True
94
+ outcome = await execute(execution)
95
+ execution.phase = "completed"
96
+ else:
97
+ execution.phase = "admission"
74
98
  except BaseException as exception:
75
99
  original_exception = exception
76
- if isinstance(exception, OversightRejectedError):
100
+ if exception is rejection_error:
77
101
  status = "rejected"
102
+ elif exception is abort_error:
103
+ status = "aborted"
78
104
  elif isinstance(exception, Exception):
79
105
  status = "failed"
80
106
  else:
81
107
  status = "interrupted"
82
108
  recoverable_admission = execution.phase == "admission" and exception is recoverable_admission_error
83
- if not recoverable_admission and (
84
- not isinstance(exception, (OperationError, ModelRetry, ApprovalRequired, CallDeferred)) or execution.phase == "inspection"
109
+ if (
110
+ exception is not rejection_error
111
+ and not recoverable_admission
112
+ and (not isinstance(exception, (OperationError, ModelRetry, ApprovalRequired, CallDeferred)) or execution.phase == "inspection")
85
113
  ):
86
114
  step_context.operation_failure = exception
87
115
  step_context.operation_failure_stage = "operation_inspection" if execution.phase == "inspection" else "operation_execution"
88
116
  finished = OperationFinished(
89
117
  request=request,
90
- status=status, # type: ignore[arg-type]
91
- phase=execution.phase, # type: ignore[arg-type]
118
+ status=status,
119
+ phase=execution.phase,
92
120
  evaluation_started=execution.evaluation_started,
93
121
  value=outcome,
94
122
  original_exception=original_exception,
123
+ rejection=rejection_error.rejection if rejection_error is not None else None,
124
+ blocked_by_operation_id=blocked_by.request.operation_id if blocked_by is not None else None,
95
125
  )
96
126
  lifecycle = _lifecycle_var.get()
97
127
  if lifecycle is not None and lifecycle.on_operation_finished is not None:
@@ -15,10 +15,10 @@ from ..composition import UnsetType
15
15
  from ..errors import ExecutionError, NighthawkError
16
16
  from ..lifecycle import FailureStage, StepCompleted, StepFailed, StepFinished, StepInterrupted, StepLifecycle, StepRaised, StepStarted
17
17
  from ..oversight import (
18
+ AbortStep,
18
19
  Accept,
19
- OversightRejectedError,
20
- Reject,
21
20
  Rewrite,
21
+ StepAbortedError,
22
22
  StepCommit,
23
23
  record_oversight_decision,
24
24
  )
@@ -103,6 +103,7 @@ class Runner:
103
103
  self.attempted_outcome: StepResult | None = None
104
104
  self.outcome: StepResult | None = None
105
105
  self.raise_exception: BaseException | None = None
106
+ self.commit_abort: StepAbortedError | None = None
106
107
  self.context_stack = ExitStack()
107
108
 
108
109
  @contextmanager
@@ -130,19 +131,25 @@ class Runner:
130
131
  self.failure_stage = self.preparation.step_context.operation_failure_stage
131
132
  finished: StepFinished
132
133
  if isinstance(original_exception, Exception):
133
- rejection = original_exception if isinstance(original_exception, OversightRejectedError) else None
134
+ operation_abort = self.preparation.step_context.operation_abort if self.preparation is not None else None
135
+ rejection = (
136
+ original_exception
137
+ if isinstance(original_exception, StepAbortedError)
138
+ and (original_exception is operation_abort or original_exception is self.commit_abort)
139
+ else None
140
+ )
134
141
  finished = StepFailed(
135
142
  execution_reference=execution_reference,
136
143
  processed_natural_program=self.preparation_state.processed_natural_program,
137
144
  input_binding_name_to_value=self.preparation_state.input_binding_name_to_value,
138
145
  allowed_step_kinds=self.preparation_state.allowed_step_kinds,
139
146
  assigned_binding_name_to_value=self.assigned_binding_name_to_value,
140
- failure_stage="oversight_rejection" if rejection else self.failure_stage,
147
+ failure_stage="oversight_abort" if rejection else self.failure_stage,
141
148
  original_exception=original_exception,
142
149
  attempted_outcome=self.attempted_outcome,
143
150
  validated_binding_name_to_value=self.validated_binding_name_to_value,
144
151
  inspection_subject=cast(typing.Literal["step_commit", "operation"], rejection.subject) if rejection else None,
145
- rejection_reason=rejection.reason if rejection else None,
152
+ abort_reason=rejection.reason if rejection else None,
146
153
  )
147
154
  _record_internal_step_failure(step_span=step_span, exception=original_exception)
148
155
  elif original_exception is not None:
@@ -201,6 +208,8 @@ class Runner:
201
208
  raise delivery_exception from original_exception
202
209
  raise
203
210
  if isinstance(finished, StepFailed):
211
+ if finished.original_exception is self.commit_abort:
212
+ raise finished.original_exception
204
213
  if self.preparation is not None and self.preparation.step_context.operation_failure is finished.original_exception:
205
214
  raise finished.original_exception
206
215
  raise ExecutionError(finished, description=self.failure_description) from finished.original_exception
@@ -341,14 +350,15 @@ class Runner:
341
350
  if inspect.isawaitable(decision):
342
351
  decision = await decision
343
352
 
344
- if isinstance(decision, Reject):
353
+ if isinstance(decision, AbortStep):
345
354
  record_oversight_decision(
346
355
  subject="step_commit",
347
- verdict="reject",
356
+ verdict="abort",
348
357
  execution_reference=execution_reference,
349
358
  reason=decision.reason,
350
359
  )
351
- raise OversightRejectedError(decision.reason, subject="step_commit")
360
+ self.commit_abort = StepAbortedError(decision.reason, subject="step_commit")
361
+ raise self.commit_abort
352
362
 
353
363
  if isinstance(decision, Accept):
354
364
  record_oversight_decision(
@@ -360,7 +370,7 @@ class Runner:
360
370
  return outcome, bindings
361
371
 
362
372
  if not isinstance(decision, Rewrite):
363
- raise NighthawkError("Oversight inspect_step_commit must return Accept, Reject, or Rewrite")
373
+ raise NighthawkError("Oversight inspect_step_commit must return Accept, Rewrite, or AbortStep")
364
374
 
365
375
  record_oversight_decision(
366
376
  subject="step_commit",
@@ -15,6 +15,7 @@ from .execution_reference import ExecutionReference
15
15
 
16
16
  if TYPE_CHECKING:
17
17
  from ..lifecycle import FailureStage
18
+ from ..oversight import StepAbortedError
18
19
 
19
20
  _MISSING: object = object()
20
21
  """Sentinel indicating a name could not be resolved. Distinct from None."""
@@ -72,6 +73,7 @@ class StepContext:
72
73
  operation_lock: asyncio.Lock = field(default_factory=asyncio.Lock, init=False, repr=False)
73
74
  operation_failure: BaseException | None = field(default=None, init=False, repr=False)
74
75
  operation_failure_stage: FailureStage | None = field(default=None, init=False)
76
+ operation_abort: StepAbortedError | None = field(default=None, init=False, repr=False)
75
77
 
76
78
  def __post_init__(self, task_name_to_value: Mapping[str, object]) -> None:
77
79
  self.binding_name_to_type = MappingProxyType(dict(self.binding_name_to_type))
@@ -1,12 +1,15 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import json
3
4
  from typing import Any, Protocol, cast, runtime_checkable
4
5
 
5
6
  from pydantic import TypeAdapter
6
7
  from pydantic_ai import Agent, RunContext, StructuredDict
7
8
  from pydantic_ai.capabilities import AbstractCapability
9
+ from pydantic_ai.exceptions import ModelRetry
8
10
  from pydantic_ai.messages import UserContent
9
11
  from pydantic_ai.models import ModelRequestContext
12
+ from pydantic_ai.output import OutputContext
10
13
  from pydantic_ai.settings import ModelSettings
11
14
  from pydantic_ai.toolsets.function import FunctionToolset
12
15
 
@@ -20,6 +23,8 @@ from ..tools.execution import ToolResultWrapperToolset
20
23
  from .async_bridge import run_coroutine_synchronously
21
24
  from .context import DefaultContextBuilder, build_context_content, build_context_request
22
25
  from .model_candidate import resolve_model_candidate
26
+ from .model_response import get_response_rejection, record_response_rejection
27
+ from .operation_contract import OperationRejectedError
23
28
  from .prompt import build_system_prompt, extract_references_and_program, resolve_step_system_prompt_template_text
24
29
  from .scoping import (
25
30
  _context_builder_var,
@@ -54,10 +59,45 @@ class _OperationFailureGuard(AbstractCapability[StepContext]):
54
59
  # Pydantic AI can classify a body ValidationError as retryable or let a capability supply a substitute result.
55
60
  # Neither clears our operation failure, and neither may cause another model request for this step.
56
61
  if ctx.deps.operation_failure is not None:
57
- raise ctx.deps.operation_failure
62
+ raise ctx.deps.operation_failure from ctx.deps.operation_failure.__cause__
58
63
  return request_context
59
64
 
60
65
 
66
+ class _StepOutputBoundary(_OperationFailureGuard):
67
+ """Resolve candidate operations while the agent can still receive feedback."""
68
+
69
+ def __init__(self, request: StepRequest) -> None:
70
+ super().__init__()
71
+ self.request = request
72
+
73
+ async def after_output_process(self, ctx: RunContext[StepContext], *, output_context: OutputContext, output: Any) -> Any:
74
+ if ctx.partial_output:
75
+ return output
76
+ if ctx.deps.operation_failure is not None:
77
+ raise ctx.deps.operation_failure from ctx.deps.operation_failure.__cause__
78
+ rejection = get_response_rejection(ctx.deps)
79
+ if rejection is not None:
80
+ raise ModelRetry(json.dumps(rejection.model_feedback(), ensure_ascii=False))
81
+ try:
82
+ return await resolve_model_candidate(self.request, _parse_step_outcome(output))
83
+ except OperationRejectedError as exception:
84
+ if ctx.deps.operation_failure is not None:
85
+ raise ctx.deps.operation_failure from ctx.deps.operation_failure.__cause__
86
+ record_response_rejection(ctx.deps, exception.rejection)
87
+ raise ModelRetry(json.dumps(exception.rejection.model_feedback(), ensure_ascii=False)) from exception
88
+
89
+
90
+ def _parse_step_outcome(raw_output: Any) -> StepOutcome:
91
+ try:
92
+ if isinstance(raw_output, StepFinalResult):
93
+ return raw_output.result
94
+ if isinstance(raw_output, dict) and "result" in raw_output:
95
+ return TypeAdapter(StepOutcome).validate_python(raw_output["result"])
96
+ return TypeAdapter(StepOutcome).validate_python(raw_output)
97
+ except Exception as exception:
98
+ raise ExecutionError(f"Step produced invalid step outcome: {exception}") from exception
99
+
100
+
61
101
  @runtime_checkable
62
102
  class AsyncExecutionAgent(Protocol):
63
103
  async def run(self, *args: Any, **kwargs: Any) -> Any: ...
@@ -236,7 +276,7 @@ class AgentStepExecutor:
236
276
  self,
237
277
  *,
238
278
  user_prompt: tuple[UserContent, ...],
239
- step_context: StepContext,
279
+ request: StepRequest,
240
280
  toolset: ToolResultWrapperToolset,
241
281
  structured_output_type: object,
242
282
  ) -> Any:
@@ -246,12 +286,13 @@ class AgentStepExecutor:
246
286
  normalized_user_prompt: str | tuple[UserContent, ...] = (
247
287
  user_prompt[0] if len(user_prompt) == 1 and isinstance(user_prompt[0], str) else user_prompt
248
288
  )
249
- capabilities = [*_current_capabilities(), _OperationFailureGuard(), ToolAdmissionCapability()]
289
+ # First means last for after_output_process: resolve the output after application hooks.
290
+ capabilities = [_StepOutputBoundary(request), *_current_capabilities(), ToolAdmissionCapability()]
250
291
 
251
292
  if isinstance(self.agent, AsyncExecutionAgent):
252
293
  return await self.agent.run(
253
294
  normalized_user_prompt,
254
- deps=step_context,
295
+ deps=request.step_context,
255
296
  toolsets=[toolset],
256
297
  output_type=structured_output_type,
257
298
  capabilities=capabilities,
@@ -260,7 +301,7 @@ class AgentStepExecutor:
260
301
  if isinstance(self.agent, SyncExecutionAgent):
261
302
  return self.agent.run_sync(
262
303
  normalized_user_prompt,
263
- deps=step_context,
304
+ deps=request.step_context,
264
305
  toolsets=[toolset],
265
306
  output_type=structured_output_type,
266
307
  capabilities=capabilities,
@@ -313,20 +354,6 @@ class AgentStepExecutor:
313
354
 
314
355
  return structured_output_type, step_system_prompt_fragment
315
356
 
316
- def _parse_agent_result(
317
- self,
318
- result: Any,
319
- ) -> StepOutcome:
320
- try:
321
- raw_output = result.output
322
- if isinstance(raw_output, StepFinalResult):
323
- return raw_output.result
324
- if isinstance(raw_output, dict) and "result" in raw_output:
325
- return TypeAdapter(StepOutcome).validate_python(raw_output["result"])
326
- return TypeAdapter(StepOutcome).validate_python(raw_output)
327
- except Exception as e:
328
- raise ExecutionError(f"Step produced invalid step outcome: {e}") from e
329
-
330
357
  async def run_step_async(
331
358
  self,
332
359
  *,
@@ -361,7 +388,7 @@ class AgentStepExecutor:
361
388
  try:
362
389
  result = await self._run_agent(
363
390
  user_prompt=user_prompt,
364
- step_context=step_context,
391
+ request=request,
365
392
  toolset=toolset,
366
393
  structured_output_type=structured_output_type,
367
394
  )
@@ -373,14 +400,16 @@ class AgentStepExecutor:
373
400
  raise failure from failure.__cause__
374
401
 
375
402
  if step_context.operation_failure is not None:
376
- raise step_context.operation_failure
403
+ raise step_context.operation_failure from step_context.operation_failure.__cause__
377
404
 
378
405
  usage_meter = get_usage_meter()
379
406
  if usage_meter is not None and hasattr(result, "usage"):
380
407
  usage_meter.record(result.usage, kind="step")
381
408
 
382
- step_outcome = self._parse_agent_result(result)
383
- return await resolve_model_candidate(request, step_outcome)
409
+ if isinstance(result.output, StepCandidate):
410
+ return result.output
411
+ # Protocol agents that do not execute Pydantic AI capabilities own their model loop.
412
+ return await resolve_model_candidate(request, _parse_step_outcome(result.output))
384
413
 
385
414
  def run_step(
386
415
  self,
@@ -1,30 +1,40 @@
1
- """Keep Pydantic AI argument validation inside ordered model-tool dispatch.
1
+ """Keep validation and refusal barriers inside ordered model-tool dispatch.
2
2
 
3
3
  Pydantic AI 2.37 validates function batches before its execution barriers.
4
- These scoped compatibility hooks delegate validation, execution, retries,
5
- deferrals, and output selection to Pydantic AI; they only move validation to
6
- the corresponding sequential dispatch. Ordinary Pydantic AI runs are unchanged.
4
+ These scoped compatibility hooks move validation to sequential dispatch,
5
+ stop later calls after a refusal, and discard candidates from that response.
6
+ Pydantic AI still owns execution, retry limits, and output selection among
7
+ permitted candidates. Ordinary Pydantic AI runs are unchanged.
7
8
  """
8
9
 
9
10
  from __future__ import annotations
10
11
 
12
+ import json
11
13
  from collections.abc import AsyncIterator, Iterator
12
14
  from contextlib import contextmanager
13
15
  from contextvars import ContextVar
14
16
  from typing import Any
15
17
 
16
- from pydantic_ai import _tool_execution
18
+ from pydantic_ai import _agent_graph, _tool_execution
17
19
  from pydantic_ai.exceptions import UnexpectedModelBehavior
18
- from pydantic_ai.messages import FunctionToolCallEvent, HandleResponseEvent, ToolCallPart
20
+ from pydantic_ai.messages import FunctionToolCallEvent, FunctionToolResultEvent, HandleResponseEvent, RetryPromptPart, ToolCallPart, ToolReturnPart
19
21
  from pydantic_ai.tool_manager import ToolManager, ValidatedToolCall
20
22
  from pydantic_ai.tools import ToolApproved
21
23
 
24
+ from ..runtime.model_response import get_response_rejection, model_response_scope
25
+ from ..runtime.operations import OperationExecution, execute_operation_async, new_operation_request
22
26
  from ..runtime.step_context import StepContext
23
27
 
24
28
  _ordered_dispatch: ContextVar[bool] = ContextVar("nighthawk_ordered_model_dispatch", default=False)
25
29
  _original_function_calls = _tool_execution._ToolCallProcessor._run_function_calls
26
30
  _original_validation = _tool_execution._ExhaustiveProcessor._validate_function_calls
27
31
  _original_call_tool = _tool_execution._ExhaustiveProcessor._call_tool
32
+ _original_run = _tool_execution._ToolCallProcessor.run
33
+ _original_output_call = _tool_execution._ToolCallProcessor._run_output_tool_call
34
+ _original_run_output = _tool_execution._ToolCallProcessor._run_output
35
+ _original_finalize_deferred = _tool_execution._ToolCallProcessor._finalize_deferred
36
+ _original_early_strategy = _tool_execution._EarlyProcessor._run_strategy
37
+ _original_handle_tool_calls = _agent_graph.CallToolsNode._handle_tool_calls
28
38
 
29
39
 
30
40
  def _requires_ordered_validation(processor: Any) -> bool:
@@ -32,13 +42,157 @@ def _requires_ordered_validation(processor: Any) -> bool:
32
42
  return _ordered_dispatch.get() and context is not None and isinstance(context.deps, StepContext)
33
43
 
34
44
 
45
+ def _rejection(processor: Any) -> Any:
46
+ return get_response_rejection(processor.tool_manager.ctx.deps) if _requires_ordered_validation(processor) else None
47
+
48
+
49
+ async def _skip_call(processor: Any, call: ToolCallPart, *, output: bool = False) -> ToolReturnPart:
50
+ context = processor.tool_manager.ctx.deps
51
+ rejection = get_response_rejection(context)
52
+ assert rejection is not None
53
+ tool_definition = processor.tool_manager.get_tool_def(call.tool_name)
54
+ kind = (tool_definition.metadata or {}).get("nighthawk.operation_kind") if tool_definition is not None else None
55
+ arguments = call.args if isinstance(call.args, dict) else {"unvalidated_arguments": call.args}
56
+ expression = arguments.get("expression")
57
+ target_path = arguments.get("target_path")
58
+ request = new_operation_request(
59
+ context,
60
+ kind="output" if output else kind if kind in ("evaluation", "assignment", "inspection") else "native_tool",
61
+ tool_name=call.tool_name,
62
+ argument_name_to_value=arguments,
63
+ expression=expression if isinstance(expression, str) else None,
64
+ target_path=target_path if isinstance(target_path, str) else None,
65
+ )
66
+
67
+ async def unreachable(execution: OperationExecution) -> object:
68
+ raise AssertionError("A skipped operation cannot execute")
69
+
70
+ await execute_operation_async(context, request, admit=lambda: None, execute=unreachable, blocked_by=rejection)
71
+ return ToolReturnPart(
72
+ tool_name=call.tool_name,
73
+ tool_call_id=call.tool_call_id,
74
+ outcome="failed",
75
+ content={
76
+ "status": "skipped",
77
+ "operation_id": request.operation_id,
78
+ "step_execution_id": request.execution_reference.step_execution_id,
79
+ "blocked_by_operation_id": rejection.request.operation_id,
80
+ "evaluation_started": False,
81
+ "message": "Not executed after an operation rejection. Choose the next operations in a new response.",
82
+ },
83
+ )
84
+
85
+
86
+ async def _run(self: Any) -> AsyncIterator[HandleResponseEvent]:
87
+ if not _requires_ordered_validation(self):
88
+ async for event in _original_run(self):
89
+ yield event
90
+ return
91
+ with model_response_scope(self.tool_manager.ctx.deps, reuse=True) as execution:
92
+ if execution.rejection is not None:
93
+ # Native output under `early` was inspected before tool dispatch.
94
+ # Pydantic AI suppresses its retry; preserve both feedback and accounting.
95
+ self.ctx.state.consume_output_retry(self.ctx.deps.max_output_retries)
96
+ self.output_parts.append(RetryPromptPart(content=json.dumps(execution.rejection.model_feedback())))
97
+ async for event in _original_run(self):
98
+ yield event
99
+ if execution.rejection is not None:
100
+ # An earlier accepted output cannot mask a later refusal in the same response.
101
+ self.final_result = None
102
+ if self.winning_output_part is not None:
103
+ self.winning_output_part.content = {
104
+ "status": "discarded",
105
+ "blocked_by_operation_id": execution.rejection.request.operation_id,
106
+ }
107
+
108
+
109
+ async def _handle_tool_calls(
110
+ self: Any, ctx: Any, tool_calls: list[ToolCallPart], *, response_output: Any = None
111
+ ) -> AsyncIterator[HandleResponseEvent]:
112
+ if _ordered_dispatch.get() and isinstance(ctx.deps.user_deps, StepContext):
113
+ with model_response_scope(ctx.deps.user_deps):
114
+ async for event in _original_handle_tool_calls(self, ctx, tool_calls, response_output=response_output):
115
+ yield event
116
+ else:
117
+ async for event in _original_handle_tool_calls(self, ctx, tool_calls, response_output=response_output):
118
+ yield event
119
+
120
+
35
121
  async def _run_function_calls(self: Any, calls: list[ToolCallPart]) -> AsyncIterator[HandleResponseEvent]:
36
122
  batches = ([call] for call in calls) if _requires_ordered_validation(self) else (calls,)
37
123
  for batch in batches:
124
+ if _rejection(self) is not None:
125
+ for call in batch:
126
+ part = await _skip_call(self, call)
127
+ self.output_parts.append(part)
128
+ yield FunctionToolCallEvent(call, args_valid=None)
129
+ yield FunctionToolResultEvent(part)
130
+ continue
38
131
  async for event in _original_function_calls(self, batch):
39
132
  yield event
40
133
 
41
134
 
135
+ async def _call_tool(self: Any, tool_call: Any, *, tool_call_result: Any) -> Any:
136
+ before = _rejection(self)
137
+ result = await _original_call_tool(self, tool_call, tool_call_result=tool_call_result)
138
+ if _requires_ordered_validation(self):
139
+ context = self.tool_manager.ctx.deps
140
+ if context.operation_failure is not None:
141
+ raise context.operation_failure
142
+ rejection = get_response_rejection(context)
143
+ if before is None and rejection is not None:
144
+ call = tool_call.call if isinstance(tool_call, ValidatedToolCall) else tool_call
145
+ return [
146
+ ToolReturnPart(tool_name=call.tool_name, tool_call_id=call.tool_call_id, content=rejection.model_feedback(), outcome="denied")
147
+ ], None
148
+ return result
149
+
150
+
151
+ async def _run_output_tool_call(self: Any, call: ToolCallPart) -> Any:
152
+ if _rejection(self) is not None:
153
+ part = await _skip_call(self, call, output=True)
154
+ # No output validator or retry budget is consumed by an unattempted candidate.
155
+ return _tool_execution._OutputCallResult(
156
+ call=call,
157
+ retry_part=RetryPromptPart(tool_name=call.tool_name, tool_call_id=call.tool_call_id, content=json.dumps(part.content)),
158
+ )
159
+ result = await _original_output_call(self, call)
160
+ if _rejection(self) is not None and result.raise_exc is not None:
161
+ # Exhaustive output settlement must not hide exhausted retry limits behind an earlier candidate.
162
+ raise result.raise_exc
163
+ return result
164
+
165
+
166
+ async def _run_output(self: Any, call: ToolCallPart) -> AsyncIterator[HandleResponseEvent]:
167
+ if _rejection(self) is not None:
168
+ part = await _skip_call(self, call, output=True)
169
+ for event in self._record_output_part(call, part, args_valid=None):
170
+ yield event
171
+ return
172
+ async for event in _original_run_output(self, call):
173
+ yield event
174
+
175
+
176
+ async def _finalize_deferred(self: Any) -> AsyncIterator[HandleResponseEvent]:
177
+ if _rejection(self) is not None:
178
+ settled = {part.tool_call_id for part in self.output_parts if isinstance(part, (ToolReturnPart, RetryPromptPart))}
179
+ for call in self.tool_calls:
180
+ if call.tool_call_id not in settled:
181
+ part = await _skip_call(self, call)
182
+ self.output_parts.append(part)
183
+ yield FunctionToolCallEvent(call, args_valid=None)
184
+ yield FunctionToolResultEvent(part)
185
+ return
186
+ async for event in _original_finalize_deferred(self):
187
+ yield event
188
+
189
+
190
+ async def _run_early_strategy(self: Any) -> AsyncIterator[HandleResponseEvent]:
191
+ strategy = _tool_execution._GracefulProcessor._run_strategy if _requires_ordered_validation(self) else _original_early_strategy
192
+ async for event in strategy(self):
193
+ yield event
194
+
195
+
42
196
  async def _validate_exhaustive_calls(
43
197
  self: Any, calls: list[ToolCallPart], *, validated_calls: dict[str, ValidatedToolCall[Any]]
44
198
  ) -> AsyncIterator[HandleResponseEvent]:
@@ -54,6 +208,9 @@ async def _validate_exhaustive_calls(
54
208
 
55
209
 
56
210
  async def _call_exhaustive_tool(self: Any, tool_call: Any, *, tool_call_result: Any) -> Any:
211
+ if _rejection(self) is not None:
212
+ call = tool_call.call if isinstance(tool_call, ValidatedToolCall) else tool_call
213
+ return [await _skip_call(self, call)], None
57
214
  if (
58
215
  _requires_ordered_validation(self)
59
216
  and isinstance(tool_call, ToolCallPart)
@@ -68,14 +225,21 @@ async def _call_exhaustive_tool(self: Any, tool_call: Any, *, tool_call_result:
68
225
  except UnexpectedModelBehavior:
69
226
  self.ctx.state.check_incomplete_tool_call()
70
227
  raise
71
- return await _original_call_tool(self, tool_call, tool_call_result=tool_call_result)
228
+ return await _call_tool(self, tool_call, tool_call_result=tool_call_result)
72
229
 
73
230
 
74
231
  # Install dispatchers once under Python's import lock. Context variables scope
75
232
  # their behavior across asynchronous tasks and supported thread bridges.
76
233
  _tool_execution._ToolCallProcessor._run_function_calls = _run_function_calls
234
+ _tool_execution._ToolCallProcessor.run = _run
235
+ _tool_execution._ToolCallProcessor._call_tool = _call_tool
236
+ _tool_execution._ToolCallProcessor._run_output_tool_call = _run_output_tool_call
237
+ _tool_execution._ToolCallProcessor._run_output = _run_output
238
+ _tool_execution._ToolCallProcessor._finalize_deferred = _finalize_deferred
239
+ _tool_execution._EarlyProcessor._run_strategy = _run_early_strategy
77
240
  _tool_execution._ExhaustiveProcessor._validate_function_calls = _validate_exhaustive_calls
78
241
  _tool_execution._ExhaustiveProcessor._call_tool = _call_exhaustive_tool
242
+ _agent_graph.CallToolsNode._handle_tool_calls = _handle_tool_calls
79
243
 
80
244
 
81
245
  @contextmanager
@@ -23,6 +23,9 @@ class ToolError(TypedDict):
23
23
  guidance: str | None
24
24
  phase: NotRequired[str]
25
25
  evaluation_started: NotRequired[bool]
26
+ operation_id: NotRequired[str]
27
+ step_execution_id: NotRequired[str | None]
28
+ status: NotRequired[Literal["rejected", "skipped"]]
26
29
 
27
30
 
28
31
  class ToolOutcome(TypedDict):
@@ -77,6 +80,9 @@ def render_tool_handler_result_preview_text(
77
80
  ) -> str:
78
81
  if tool_handler_result["kind"] == "tool_result":
79
82
  tool_outcome = tool_handler_result["tool_outcome"]
83
+ if tool_outcome["error"] is not None and tool_outcome["error"].get("status") in ("rejected", "skipped"):
84
+ # Control feedback is not a lossy value preview. Preserve its identity and host-supplied reason.
85
+ return json.dumps({"value": None, "error": tool_outcome["error"]}, ensure_ascii=False)
80
86
  return render_tool_result_json_text(
81
87
  value=tool_outcome["payload"],
82
88
  error=tool_outcome["error"],
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import json
5
6
  from collections.abc import Awaitable, Callable
6
7
  from typing import Any
7
8
 
@@ -9,6 +10,7 @@ from pydantic import TypeAdapter
9
10
  from pydantic_ai import RunContext
10
11
  from pydantic_ai.exceptions import ApprovalRequired, CallDeferred, ModelRetry
11
12
  from pydantic_ai.messages import CachePoint, TextContent, ToolReturnContent, is_multi_modal_content
13
+ from pydantic_ai.tools import ToolDenied
12
14
  from pydantic_ai.toolsets.abstract import ToolsetTool
13
15
  from pydantic_ai.toolsets.wrapper import WrapperToolset
14
16
  from pydantic_core import PydanticSerializationError
@@ -16,7 +18,8 @@ from pydantic_core import PydanticSerializationError
16
18
  from ..json_renderer import project_discovery_value, to_jsonable_value
17
19
  from ..runtime._user_content import is_top_level_sequence_payload
18
20
  from ..runtime.discovery import DiscoveryResult, contains_discovery_result, map_discovery_containers
19
- from ..runtime.operation_contract import OperationError
21
+ from ..runtime.model_response import record_response_rejection
22
+ from ..runtime.operation_contract import OperationError, OperationRejectedError, OperationRejection
20
23
  from ..runtime.operations import OperationExecution, execute_operation_async, new_operation_request
21
24
  from ..runtime.step_context import StepContext
22
25
  from .contracts import ErrorKind, ToolError, ToolOutcome
@@ -77,20 +80,45 @@ def _normalize_tool_failure(*, kind: ErrorKind, message: str, guidance: str | No
77
80
  def _build_standard_tool_return_value(*, tool_outcome: ToolOutcome) -> object:
78
81
  if tool_outcome["error"] is None:
79
82
  return tool_outcome["payload"]
83
+ if tool_outcome["error"].get("status") == "rejected":
84
+ return ToolDenied(json.dumps(tool_outcome["error"], ensure_ascii=False))
80
85
  return {
81
86
  "value": tool_outcome["payload"],
82
87
  "error": tool_outcome["error"],
83
88
  }
84
89
 
85
90
 
86
- async def _run_tool_and_normalize(tool_call: Callable[[], Awaitable[object]]) -> ToolOutcome:
91
+ def rejection_tool_outcome(rejection: OperationRejection) -> ToolOutcome:
92
+ return {
93
+ "payload": None,
94
+ "error": {
95
+ "kind": "oversight",
96
+ "status": "rejected",
97
+ "operation_id": rejection.request.operation_id,
98
+ "step_execution_id": rejection.request.execution_reference.step_execution_id,
99
+ "message": rejection.reason,
100
+ "guidance": rejection.guidance,
101
+ "phase": "inspection",
102
+ "evaluation_started": False,
103
+ },
104
+ }
105
+
106
+
107
+ async def _run_tool_and_normalize(tool_call: Callable[[], Awaitable[object]], step_context: StepContext) -> ToolOutcome:
87
108
  """Exceptions from Pydantic AI toolset plumbing are handled in ``tool_bridge.execute_tool_call``."""
88
109
 
89
110
  try:
90
111
  value = await tool_call()
112
+ except OperationRejectedError as exception:
113
+ if step_context.operation_failure is not None:
114
+ raise step_context.operation_failure from step_context.operation_failure.__cause__
115
+ record_response_rejection(step_context, exception.rejection)
116
+ return rejection_tool_outcome(exception.rejection)
91
117
  except (ModelRetry, CallDeferred, ApprovalRequired):
92
118
  raise
93
119
  except OperationError as exception:
120
+ if step_context.operation_failure is not None:
121
+ raise step_context.operation_failure from step_context.operation_failure.__cause__
94
122
  outcome = _normalize_tool_failure(
95
123
  kind=exception.kind,
96
124
  message=str(exception),
@@ -125,7 +153,7 @@ class ToolResultWrapperToolset(WrapperToolset[StepContext]):
125
153
 
126
154
  return await execute_operation_async(run_context.deps, request, admit=lambda: None, execute=execute)
127
155
 
128
- tool_result = await _run_tool_and_normalize(tool_call)
156
+ tool_result = await _run_tool_and_normalize(tool_call, run_context.deps)
129
157
  return tool_result
130
158
 
131
159
  # DEPENDENCY: Pydantic AI's WrapperToolset calls self.call_tool() rather
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: nighthawk-python
3
- Version: 0.16.0
3
+ Version: 0.17.0
4
4
  Summary: A Python library where Python controls flow and LLMs or coding agents reason within constrained Natural blocks.
5
5
  Project-URL: Repository, https://github.com/kurusugawa-computer/nighthawk-python
6
6
  Project-URL: Documentation, https://kurusugawa-computer.github.io/nighthawk-python/
@@ -5,8 +5,8 @@ nighthawk/errors.py,sha256=U9ogl_0MLA1xXe_bU2RCA1gRWN0ev5E4a3FiBLgpilI,1282
5
5
  nighthawk/function_call.py,sha256=o_i86cbsRxC0kMsTXCWvijA7SJmC7oNrcEghTNvpWJE,6745
6
6
  nighthawk/identifier_path.py,sha256=QRbDte7j0mubwg8ebs1GlxW7QeA5V_ow29MpVPCg6Ck,626
7
7
  nighthawk/json_renderer.py,sha256=QT4-kyKcr_rcga5hg2irKZ4a2OH6XJ8c0_b1zBlPjcE,10501
8
- nighthawk/lifecycle.py,sha256=pHCTZCMbG7iTVmLOWuwspi4DeM5p2Y5BskONFF_beTY,5513
9
- nighthawk/oversight.py,sha256=xCuJ5kARG0P2KS0eaf4pHqvkUYVgSULNqB-QLXEKxGU,5762
8
+ nighthawk/lifecycle.py,sha256=X8y_xNjROcoFTr3jErPdH666sebnyNl8BRTpNcSItxw,5505
9
+ nighthawk/oversight.py,sha256=e_qOpHV_dEX0gJAge3YC20t0kv_42A_ko_Ym50zx2vQ,6691
10
10
  nighthawk/testing.py,sha256=yJE2ru0igXknjN2CCpLur2XGSCGI-ww_58Y8XK4h4iw,8045
11
11
  nighthawk/ulid.py,sha256=CBpGLXDGm2j69Yk15hEq4Z5V8StWOY4714ow4BZn5tc,546
12
12
  nighthawk/backends/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -16,11 +16,11 @@ nighthawk/backends/claude_code_cli.py,sha256=QntuxO2hJBstLJTmoA7Wk2SWdI-_1RnTEoC
16
16
  nighthawk/backends/claude_code_sdk.py,sha256=v_KkBV-gSjh0VJ0Y1551GlxrsGOIBErbkPjZ4c8ippQ,13470
17
17
  nighthawk/backends/claude_code_settings.py,sha256=wjHf8P38yrOvbhqUVT3uZ1qFjLlMIQ9de8DKHcRJn1g,1061
18
18
  nighthawk/backends/codex.py,sha256=6uYw7FGQYkVtwe34TseOw12Ip8tGN78RZ4N2F9rgdVE,15101
19
- nighthawk/backends/mcp_boundary.py,sha256=TZT1XF7Xknk1cbnrYtwN1KuILUt3EgnZbAIgXH5Nnv0,9444
20
- nighthawk/backends/mcp_server.py,sha256=6RL2OZOmfdDb-C2B6FYrAkPI_y5tWJ0P58FHKiODQzQ,8315
19
+ nighthawk/backends/mcp_boundary.py,sha256=9A3MtJKocm4NNwKCAIs2I9VFyBVtnxjwEm33dKHddTI,10518
20
+ nighthawk/backends/mcp_server.py,sha256=stAqpAOvY7Mkte1cDmet0_rbIEjMERacg5hRi-zB6CM,8059
21
21
  nighthawk/backends/process_management.py,sha256=ExPDyyJ7qFDzNe_1qEfiTM_s1ByAL9XXAm1yEwLFld0,776
22
22
  nighthawk/backends/text_projection.py,sha256=4qhugWdDXuwGNpTTuAEZge-8PMPKtAMypo66iziDB0I,12034
23
- nighthawk/backends/tool_bridge.py,sha256=2EhhmYIXNE35j7sltG6TZc-Iq_0nK8vdzAWOHSkmBAc,10530
23
+ nighthawk/backends/tool_bridge.py,sha256=x2fXovrbgEYiSg_UmtT09hjkkvRIq7MfBJrL0gCCv4I,11741
24
24
  nighthawk/backends/tool_transport.py,sha256=iknOFt6tgi47Pfru2vQmQJuA8UPmyoCiFL1kO9J2sS0,3537
25
25
  nighthawk/natural/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
26
26
  nighthawk/natural/_names.py,sha256=zWXgVLbsFqzxDnQdlGtJamt0zUL8GlZckvECi5FEm70,916
@@ -35,7 +35,7 @@ nighthawk/resilience/_fallback.py,sha256=oqmmmGKe2BgQUWGEdBuMYTEFalnjUllYDHwSBa-
35
35
  nighthawk/resilience/_retry.py,sha256=9Rq8EpdtW_3B_-QmEzn7nJ2G4QdUJE7KXuvuMNRY14k,8462
36
36
  nighthawk/resilience/_timeout.py,sha256=j1FPvnQB3XtjH-7UVu7Cp4ujDtF5qQOWFcO_YOrth5I,4024
37
37
  nighthawk/resilience/_vote.py,sha256=rV2x8AkmoZJLAjUOfOV5SUa45NeZYo0kYic8lPuugXE,4675
38
- nighthawk/runtime/__init__.py,sha256=Z7aw3s29yxhz5yRXbJ2LQy1jmKN6z_Aq0z3YwoFFIBA,3093
38
+ nighthawk/runtime/__init__.py,sha256=SipxZoD-LoQIGhiacTKWK_NfMtRwWaSlBiIWaFquCnM,3456
39
39
  nighthawk/runtime/_user_content.py,sha256=Vnt5Nbbk0I02m06ng8OtooE0Ph0Qqc3twzqbjsDUA6Q,3122
40
40
  nighthawk/runtime/annotations.py,sha256=TPjBz8-DZ9BjXSrRLlUqKVq4mGTvKBffPwHoHWdN8EA,3029
41
41
  nighthawk/runtime/assignment.py,sha256=hCMrLpfN7Z3CuE56co1uemJn0nhMwO0tDwYnlQTPqcs,6486
@@ -51,30 +51,31 @@ nighthawk/runtime/expression.py,sha256=421HuL53GGEKW1Tuag9VRRcJffmPShpJ0rTRmZYkx
51
51
  nighthawk/runtime/inspection.py,sha256=06zWKw_u61585qc-Wp1QmvKAekydWoCcmaDEU22Hdbw,3454
52
52
  nighthawk/runtime/interruption.py,sha256=9B3hjMbXXtjf1sP2qVAf10dWk0KDS_UttZwqaix1tFU,679
53
53
  nighthawk/runtime/model_candidate.py,sha256=X0aJvlk9zSDTwU6KCnYLBnOuiKab-E4IqVEeypvljhs,3122
54
+ nighthawk/runtime/model_response.py,sha256=CcdJE440LJgvbmS3YpE6dv9BHvkWnl_9zpUdwgqsUKc,1565
54
55
  nighthawk/runtime/notifications.py,sha256=khLidCGr9lc2aed_GwGiWaUOx6jNQepGqY0_a6flJWo,1449
55
- nighthawk/runtime/operation_contract.py,sha256=zTZTQXrv6xpUgJxUIGB3xVFDQZ0NglgmdU30aFbOf9Q,2471
56
- nighthawk/runtime/operations.py,sha256=OQ15NKnSs2NSUgRxFALzS6bRCWPgbzNrjXCOOW6GH7A,6706
56
+ nighthawk/runtime/operation_contract.py,sha256=h_wDoz780Jrr9lJbTyZHWRaq_RjP_qk3h3-3arceT_U,3596
57
+ nighthawk/runtime/operations.py,sha256=5IkgJJAX3nwv4t_AKAVyT-oFUjCPjUTYzaqdDVTc7J0,8010
57
58
  nighthawk/runtime/preparation.py,sha256=zLrnP53k7PrKv4gTYf_RJDB2dOEbI_KxB7JSqPzG0EM,11396
58
59
  nighthawk/runtime/prompt.py,sha256=ihKOoMEWiTKX6SsyXiAvXydPfgnIAQBrBhDsBib2_ak,29750
59
- nighthawk/runtime/runner.py,sha256=I5A2CnqzxcWAmIQlGO46h5p460RB_ibHxnLktDj0g7c,27097
60
+ nighthawk/runtime/runner.py,sha256=prIcoi6rZrSfA09YkO_ajDoXSqN1FmIJ2fD46rhvlLQ,27587
60
61
  nighthawk/runtime/scoping.py,sha256=94l8lW9UWDSAh4e1-lG1GOt3l7vlm_KMfAcCTbGGCM0,21623
61
- nighthawk/runtime/step_context.py,sha256=DM7mHCiiFhulUAqS6qK7TAZLHfOe-ZqRtZHkEwj22OE,5836
62
+ nighthawk/runtime/step_context.py,sha256=k5oy_0GgWS4XeLiY7C-UQ9IVnQ7JSZEoR3Iyz-7pUp0,5972
62
63
  nighthawk/runtime/step_contract.py,sha256=xIQcpD1PDY_gqrTqSUZ_5yZoMQt1EJOU33fpn-Da_28,6737
63
64
  nighthawk/runtime/step_definition.py,sha256=f64IVSAfpbxUpFVdIKBuWW43m2sQRQDvVmFgBrQeKbE,1913
64
- nighthawk/runtime/step_executor.py,sha256=Q7Teu6mUrForLi1q6Q570ZRkP44KZ-9KoodUUXnJEXs,16089
65
+ nighthawk/runtime/step_executor.py,sha256=QP23Cv91Xc_EC7iWIcGinb1219amaX-zWUg6VuSJuns,17818
65
66
  nighthawk/runtime/step_request.py,sha256=qs_kJZ_SqvYSD4yTs-5jSoHVrTbVT27YxWUFHeLHLjM,4508
66
67
  nighthawk/runtime/step_result.py,sha256=mCdteQgQMMwoYTvhCJLuwcctljw-rjjyoFy2awfiRRg,1678
67
68
  nighthawk/runtime/tool_calls.py,sha256=IZQWk2IH8rPF6hjVBzBDn0cpGDkG9k368dDFpmquYOg,3318
68
69
  nighthawk/tools/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
69
70
  nighthawk/tools/_admission.py,sha256=T4W3n8Y36Q4CnAE01W85ftbd16Z1W7xjfqwn2qMXZFs,4075
70
- nighthawk/tools/_ordering.py,sha256=BhAU0oYcTWfDeF0lA0HY-04drzD5AMlax8uyYry4nOc,3997
71
+ nighthawk/tools/_ordering.py,sha256=rTPKMIN4664EUeyAj-ouf3kjUY3sIATdXUOYBGCWKGU,12270
71
72
  nighthawk/tools/_tool_return_content.py,sha256=HlDDufHvMvqHfw3-L5fqQYM3s4qxaWz8GFjgNkHcg8s,2111
72
73
  nighthawk/tools/assignment.py,sha256=Uai4UCw0IICssmNRJvUkdPTX9QWXnyLIASVbw8c3PFk,1254
73
- nighthawk/tools/contracts.py,sha256=X-YixPwBun0FmjiyPPNCXPW2pJf7CpmWR7jyTVJQvsM,5325
74
+ nighthawk/tools/contracts.py,sha256=xMrNjIducxIzcMn9o3zPoZN-Q8FPTSceCqZO-zk5qwA,5784
74
75
  nighthawk/tools/declarations.py,sha256=goPDT7iVaXez11R_9z8e4czeeA7wevmZPs8sO85hqdA,3986
75
- nighthawk/tools/execution.py,sha256=IeZs32tUSLJkj7_M94vrWh0Xt1CkJgvoUike9iIKe1I,6720
76
+ nighthawk/tools/execution.py,sha256=8knipGL3SpjY9X-wPrGzC6_-USpWjXJtv0-JrzcnX-M,8068
76
77
  nighthawk/tools/provided.py,sha256=H17PoX8ztUmtgrBkB-bEdfDkK5OSULIl_tyMgzEc3g4,1958
77
- nighthawk_python-0.16.0.dist-info/METADATA,sha256=g2-niQ9VaMFvMMN3Rn0advG6wBR-Oume9kwYaC_UvjI,6147
78
- nighthawk_python-0.16.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
79
- nighthawk_python-0.16.0.dist-info/licenses/LICENSE,sha256=R8tjrX79o5Pbi_l_aFPPWDbTZkmz4is0CxmzMMs3f0A,1076
80
- nighthawk_python-0.16.0.dist-info/RECORD,,
78
+ nighthawk_python-0.17.0.dist-info/METADATA,sha256=I7pUiaTPLhW00Q032Wa1R1REheYK9DBejKQw6PVIj98,6147
79
+ nighthawk_python-0.17.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
80
+ nighthawk_python-0.17.0.dist-info/licenses/LICENSE,sha256=R8tjrX79o5Pbi_l_aFPPWDbTZkmz4is0CxmzMMs3f0A,1076
81
+ nighthawk_python-0.17.0.dist-info/RECORD,,