nighthawk-python 0.9.0__py3-none-any.whl → 0.10.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
nighthawk/__init__.py CHANGED
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from . import oversight, resilience
3
4
  from .configuration import (
4
5
  StepContextLimits,
5
6
  StepExecutorConfiguration,
@@ -15,15 +16,15 @@ from .errors import (
15
16
  )
16
17
  from .json_renderer import JsonableValue, to_jsonable_value
17
18
  from .natural.decorator import natural_function
18
- from .runtime.scoping import ExecutionContext, UsageMeter, get_current_usage_meter, get_execution_context, get_step_executor, run, scope
19
+ from .runtime.scoping import ExecutionRef, UsageMeter, get_current_usage_meter, get_execution_ref, get_step_executor, run, scope
19
20
  from .runtime.step_context import StepContext, get_current_step_context
20
21
  from .runtime.step_executor import AgentStepExecutor, StepExecutor
21
22
  from .tools.registry import tool
22
23
 
23
24
  __all__ = [
24
25
  "AgentStepExecutor",
25
- "ExecutionContext",
26
26
  "ExecutionError",
27
+ "ExecutionRef",
27
28
  "JsonableValue",
28
29
  "NaturalParseError",
29
30
  "NighthawkError",
@@ -38,9 +39,11 @@ __all__ = [
38
39
  "UsageMeter",
39
40
  "get_current_step_context",
40
41
  "get_current_usage_meter",
41
- "get_execution_context",
42
+ "get_execution_ref",
42
43
  "get_step_executor",
44
+ "oversight",
43
45
  "natural_function",
46
+ "resilience",
44
47
  "run",
45
48
  "scope",
46
49
  "to_jsonable_value",
@@ -23,6 +23,7 @@ from pydantic_ai import RunContext
23
23
  from pydantic_ai.exceptions import UnexpectedModelBehavior
24
24
  from pydantic_ai.tools import ToolDefinition
25
25
 
26
+ from ..errors import NighthawkError
26
27
  from ..runtime.step_context import (
27
28
  DEFAULT_TOOL_RESULT_RENDERING_POLICY,
28
29
  StepContext,
@@ -203,7 +204,7 @@ async def mcp_server_if_needed(
203
204
  parent_otel_context = otel_context.get_current()
204
205
  parent_run_context = get_current_run_context()
205
206
  if parent_run_context is None:
206
- raise RuntimeError("Codex MCP tool server requires an active RunContext")
207
+ raise NighthawkError("Codex MCP tool server requires an active RunContext")
207
208
  typed_parent_run_context = cast(RunContext[StepContext], parent_run_context)
208
209
  tool_result_rendering_policy = typed_parent_run_context.deps.tool_result_rendering_policy
209
210
  if tool_result_rendering_policy is None:
@@ -20,13 +20,10 @@ from pydantic_ai.messages import RetryPromptPart
20
20
  from pydantic_ai.models import ModelRequestParameters
21
21
  from pydantic_ai.toolsets.function import FunctionToolset
22
22
 
23
+ from ..errors import NighthawkError
23
24
  from ..runtime.step_context import DEFAULT_TOOL_RESULT_RENDERING_POLICY, StepContext, ToolResultRenderingPolicy
24
25
  from ..runtime.tool_calls import generate_tool_call_id, run_tool_instrumented
25
- from ..tools.contracts import (
26
- ToolBoundaryError,
27
- ToolResult,
28
- render_tool_result_json_text,
29
- )
26
+ from ..tools.contracts import ToolBoundaryError, render_tool_result_json_text
30
27
  from ..tools.execution import ToolResultWrapperToolset
31
28
 
32
29
  type ToolHandler = Callable[[dict[str, Any]], Awaitable[str]]
@@ -44,7 +41,7 @@ def get_current_run_context_required() -> RunContext[StepContext]:
44
41
 
45
42
  run_context = get_current_run_context()
46
43
  if run_context is None:
47
- raise RuntimeError("Nighthawk tool boundaries require an active RunContext")
44
+ raise NighthawkError("Nighthawk tool boundaries require an active RunContext")
48
45
  return cast(RunContext[StepContext], run_context)
49
46
 
50
47
 
@@ -71,6 +68,7 @@ async def execute_tool_call(
71
68
  *,
72
69
  tool_name: str,
73
70
  tool: Any,
71
+ toolset: ToolResultWrapperToolset,
74
72
  arguments: dict[str, Any],
75
73
  run_context: RunContext[StepContext],
76
74
  tool_call_id: str,
@@ -102,7 +100,7 @@ async def execute_tool_call(
102
100
  ).model_response()
103
101
 
104
102
  try:
105
- tool_result = await tool.toolset.call_tool(tool_name, validated_arguments, run_context, tool)
103
+ tool_result = await toolset.call_tool(tool_name, validated_arguments, run_context, tool)
106
104
  except (ModelRetry, CallDeferred, ApprovalRequired):
107
105
  raise
108
106
  except ToolBoundaryError as exception:
@@ -121,6 +119,9 @@ async def execute_tool_call(
121
119
  encoding=encoding,
122
120
  style=rendering_policy.json_renderer_style,
123
121
  )
122
+ except NighthawkError:
123
+ # Preserve host-invariant failures as Python exceptions; these are not LLM-recoverable tool-call errors and must propagate to the host.
124
+ raise
124
125
  except Exception as exception:
125
126
  return render_tool_result_json_text(
126
127
  value=None,
@@ -134,10 +135,10 @@ async def execute_tool_call(
134
135
  style=rendering_policy.json_renderer_style,
135
136
  )
136
137
 
137
- if isinstance(tool_result, ToolResult):
138
+ if isinstance(tool_result, dict) and set(tool_result.keys()) == {"value", "error"}:
138
139
  return render_tool_result_json_text(
139
- value=tool_result.value,
140
- error=None,
140
+ value=tool_result["value"],
141
+ error=tool_result["error"],
141
142
  max_tokens=rendering_policy.tool_result_max_tokens,
142
143
  encoding=encoding,
143
144
  style=rendering_policy.json_renderer_style,
@@ -193,6 +194,7 @@ async def build_tool_name_to_handler(
193
194
  return await execute_tool_call(
194
195
  tool_name=tool_name,
195
196
  tool=tool,
197
+ toolset=toolset,
196
198
  arguments=arguments,
197
199
  run_context=tool_run_context,
198
200
  tool_call_id=tool_call_id,
nighthawk/oversight.py ADDED
@@ -0,0 +1,180 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable, Mapping
4
+ from dataclasses import dataclass
5
+ from types import MappingProxyType
6
+
7
+ from opentelemetry.trace import get_current_span
8
+ from pydantic import BaseModel
9
+
10
+ from .errors import NighthawkError
11
+ from .runtime.scoping import ExecutionRef
12
+ from .runtime.step_contract import StepKind, StepOutcome
13
+
14
+
15
+ def _snapshot_value(value: object) -> object:
16
+ if isinstance(value, BaseModel):
17
+ return value.model_copy(deep=True)
18
+ if isinstance(value, Mapping):
19
+ return MappingProxyType({key: _snapshot_value(item) for key, item in value.items()})
20
+ if isinstance(value, tuple):
21
+ return tuple(_snapshot_value(item) for item in value)
22
+ if isinstance(value, list):
23
+ return tuple(_snapshot_value(item) for item in value)
24
+ if isinstance(value, set):
25
+ return frozenset(_snapshot_value(item) for item in value)
26
+ if isinstance(value, frozenset):
27
+ return frozenset(_snapshot_value(item) for item in value)
28
+ return value
29
+
30
+
31
+ def _snapshot_mapping(name_to_value: Mapping[str, object]) -> Mapping[str, object]:
32
+ return MappingProxyType({name: _snapshot_value(value) for name, value in name_to_value.items()})
33
+
34
+
35
+ def _snapshot_optional_step_outcome(step_outcome: StepOutcome | None) -> StepOutcome | None:
36
+ if step_outcome is None:
37
+ return None
38
+ return _snapshot_value(step_outcome) # type: ignore[return-value]
39
+
40
+
41
+ class OversightRejectedError(NighthawkError):
42
+ """Raised when oversight rejects a tool call or step commit."""
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class ToolCall:
47
+ execution_ref: ExecutionRef
48
+ tool_name: str
49
+ argument_name_to_value: Mapping[str, object]
50
+ processed_natural_program: str
51
+
52
+ def __post_init__(self) -> None:
53
+ object.__setattr__(
54
+ self,
55
+ "argument_name_to_value",
56
+ _snapshot_mapping(self.argument_name_to_value),
57
+ )
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class StepCommitProposal:
62
+ execution_ref: ExecutionRef
63
+ processed_natural_program: str
64
+ input_binding_name_to_value: Mapping[str, object]
65
+ proposed_step_outcome: StepOutcome
66
+ proposed_binding_name_to_value: Mapping[str, object]
67
+ allowed_step_kinds: tuple[StepKind, ...]
68
+ output_binding_name_set: frozenset[str]
69
+ binding_name_to_type: Mapping[str, object]
70
+
71
+ def __post_init__(self) -> None:
72
+ object.__setattr__(
73
+ self,
74
+ "input_binding_name_to_value",
75
+ _snapshot_mapping(self.input_binding_name_to_value),
76
+ )
77
+ object.__setattr__(
78
+ self,
79
+ "proposed_step_outcome",
80
+ _snapshot_value(self.proposed_step_outcome),
81
+ )
82
+ object.__setattr__(
83
+ self,
84
+ "proposed_binding_name_to_value",
85
+ _snapshot_mapping(self.proposed_binding_name_to_value),
86
+ )
87
+ object.__setattr__(
88
+ self,
89
+ "output_binding_name_set",
90
+ frozenset(self.output_binding_name_set),
91
+ )
92
+ object.__setattr__(
93
+ self,
94
+ "binding_name_to_type",
95
+ _snapshot_mapping(self.binding_name_to_type),
96
+ )
97
+
98
+
99
+ @dataclass(frozen=True)
100
+ class Accept:
101
+ reason: str | None = None
102
+
103
+
104
+ @dataclass(frozen=True)
105
+ class Reject:
106
+ reason: str
107
+
108
+
109
+ @dataclass(frozen=True)
110
+ class Rewrite:
111
+ rewritten_step_outcome: StepOutcome | None = None
112
+ rewritten_binding_name_to_value: Mapping[str, object] | None = None
113
+ reason: str | None = None
114
+
115
+ def __post_init__(self) -> None:
116
+ object.__setattr__(
117
+ self,
118
+ "rewritten_step_outcome",
119
+ _snapshot_optional_step_outcome(self.rewritten_step_outcome),
120
+ )
121
+ if self.rewritten_binding_name_to_value is not None:
122
+ object.__setattr__(
123
+ self,
124
+ "rewritten_binding_name_to_value",
125
+ _snapshot_mapping(self.rewritten_binding_name_to_value),
126
+ )
127
+ if self.rewritten_step_outcome is None and self.rewritten_binding_name_to_value is None:
128
+ raise ValueError("Rewrite must change rewritten_step_outcome or rewritten_binding_name_to_value")
129
+
130
+
131
+ type ToolCallDecision = Accept | Reject
132
+ type StepCommitDecision = Accept | Reject | Rewrite
133
+
134
+
135
+ @dataclass(frozen=True)
136
+ class Oversight:
137
+ inspect_tool_call: Callable[[ToolCall], ToolCallDecision] | None = None
138
+ inspect_step_commit: Callable[[StepCommitProposal], StepCommitDecision] | None = None
139
+
140
+
141
+ def record_oversight_decision(
142
+ *,
143
+ subject: str,
144
+ verdict: str,
145
+ execution_ref: ExecutionRef,
146
+ tool_name: str | None = None,
147
+ reason: str | None = None,
148
+ ) -> None:
149
+ current_span = get_current_span()
150
+ if not current_span.is_recording():
151
+ return
152
+ if execution_ref.step_id is None:
153
+ raise NighthawkError("Oversight decision events require ExecutionRef.step_id")
154
+
155
+ attributes: dict[str, str] = {
156
+ "run.id": execution_ref.run_id,
157
+ "scope.id": execution_ref.scope_id,
158
+ "step.id": execution_ref.step_id,
159
+ "nighthawk.oversight.subject": subject,
160
+ "nighthawk.oversight.verdict": verdict,
161
+ }
162
+ if tool_name:
163
+ attributes["tool.name"] = tool_name
164
+ if reason:
165
+ attributes["nighthawk.oversight.reason"] = reason
166
+
167
+ current_span.add_event("nighthawk.oversight.decision", attributes)
168
+
169
+
170
+ __all__ = [
171
+ "Accept",
172
+ "Oversight",
173
+ "OversightRejectedError",
174
+ "Reject",
175
+ "Rewrite",
176
+ "StepCommitDecision",
177
+ "StepCommitProposal",
178
+ "ToolCall",
179
+ "ToolCallDecision",
180
+ ]
@@ -8,9 +8,8 @@ Import directly from this module::
8
8
 
9
9
  from nighthawk.resilience import retrying, fallback, vote, timeout, budget, circuit_breaker
10
10
 
11
- These primitives are **not** re-exported from the top-level ``nighthawk``
12
- namespace. See [Patterns: Resilience patterns](https://kurusugawa-computer.github.io/nighthawk-python/patterns/#resilience-patterns)
13
- for usage patterns and composition examples.
11
+ The ``nighthawk.resilience`` module is available as ``nh.resilience`` after ``import nighthawk as nh``. Individual resilience primitives are **not** re-exported from the top-level ``nighthawk`` namespace.
12
+ See [Patterns: Resilience patterns](https://kurusugawa-computer.github.io/nighthawk-python/patterns/#resilience-patterns) for usage patterns and composition examples.
14
13
  """
15
14
 
16
15
  from __future__ import annotations
@@ -53,12 +53,9 @@ def vote(
53
53
  ):
54
54
  """Create a majority voting transformer.
55
55
 
56
- Calls the wrapped function *count* times and aggregates results
57
- using the *decide* function.
56
+ Calls the wrapped function *count* times and aggregates results using the *decide* function.
58
57
 
59
- For async functions, all calls execute concurrently via
60
- :func:`asyncio.gather`. For sync functions, calls execute
61
- sequentially.
58
+ For async functions, all calls execute concurrently via :func:`asyncio.gather`. For sync functions, calls execute sequentially.
62
59
 
63
60
  Args:
64
61
  count: Number of times to call the function.
@@ -80,6 +77,10 @@ def vote(
80
77
  raise ValueError("vote count must be at least 1")
81
78
 
82
79
  effective_min_success = min_success if min_success is not None else math.ceil(count / 2)
80
+ if effective_min_success < 1:
81
+ raise ValueError("vote min_success must be at least 1")
82
+ if effective_min_success > count:
83
+ raise ValueError("vote min_success must be less than or equal to count")
83
84
 
84
85
  def decorator[**P, R](function: Callable[P, R]) -> Callable[P, R]:
85
86
  if inspect.iscoroutinefunction(function):
@@ -18,7 +18,7 @@ from .scoping import (
18
18
  RUN_ID,
19
19
  SCOPE_ID,
20
20
  STEP_ID,
21
- get_execution_context,
21
+ get_execution_ref,
22
22
  get_user_prompt_suffix_fragments,
23
23
  )
24
24
  from .step_context import _MISSING, StepContext, resolve_name_in_step_context
@@ -469,9 +469,9 @@ def _render_reference_and_value_list_section(
469
469
  "nighthawk.prompt_context.max_tokens": section_max_tokens,
470
470
  }
471
471
  try:
472
- execution_context = get_execution_context()
473
- log_attributes[RUN_ID] = execution_context.run_id
474
- log_attributes[SCOPE_ID] = execution_context.scope_id
472
+ execution_ref = get_execution_ref()
473
+ log_attributes[RUN_ID] = execution_ref.run_id
474
+ log_attributes[SCOPE_ID] = execution_ref.scope_id
475
475
  except Exception:
476
476
  pass
477
477
  logging.getLogger("nighthawk").info("prompt_context_truncated %s", log_attributes)