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 +6 -3
- nighthawk/backends/mcp_server.py +2 -1
- nighthawk/backends/tool_bridge.py +12 -10
- nighthawk/oversight.py +180 -0
- nighthawk/resilience/__init__.py +2 -3
- nighthawk/resilience/_vote.py +6 -5
- nighthawk/runtime/prompt.py +4 -4
- nighthawk/runtime/runner.py +209 -78
- nighthawk/runtime/scoping.py +65 -31
- nighthawk/runtime/step_context.py +8 -1
- nighthawk/runtime/step_executor.py +6 -6
- nighthawk/testing.py +11 -16
- nighthawk/tools/assignment.py +7 -4
- nighthawk/tools/contracts.py +7 -8
- nighthawk/tools/execution.py +96 -19
- {nighthawk_python-0.9.0.dist-info → nighthawk_python-0.10.0.dist-info}/METADATA +1 -1
- {nighthawk_python-0.9.0.dist-info → nighthawk_python-0.10.0.dist-info}/RECORD +19 -18
- {nighthawk_python-0.9.0.dist-info → nighthawk_python-0.10.0.dist-info}/WHEEL +0 -0
- {nighthawk_python-0.9.0.dist-info → nighthawk_python-0.10.0.dist-info}/licenses/LICENSE +0 -0
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
|
|
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
|
-
"
|
|
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",
|
nighthawk/backends/mcp_server.py
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
140
|
-
error=
|
|
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
|
+
]
|
nighthawk/resilience/__init__.py
CHANGED
|
@@ -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
|
-
|
|
12
|
-
|
|
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
|
nighthawk/resilience/_vote.py
CHANGED
|
@@ -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):
|
nighthawk/runtime/prompt.py
CHANGED
|
@@ -18,7 +18,7 @@ from .scoping import (
|
|
|
18
18
|
RUN_ID,
|
|
19
19
|
SCOPE_ID,
|
|
20
20
|
STEP_ID,
|
|
21
|
-
|
|
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
|
-
|
|
473
|
-
log_attributes[RUN_ID] =
|
|
474
|
-
log_attributes[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)
|