nighthawk-python 0.12.0__py3-none-any.whl → 0.13.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 +23 -9
- nighthawk/backends/claude_code_cli.py +1 -1
- nighthawk/backends/claude_code_sdk.py +1 -1
- nighthawk/backends/codex.py +1 -1
- nighthawk/backends/mcp_boundary.py +2 -2
- nighthawk/composition.py +59 -0
- nighthawk/configuration.py +17 -6
- nighthawk/errors.py +10 -2
- nighthawk/natural/decorator.py +50 -24
- nighthawk/natural/transform.py +2 -4
- nighthawk/oversight.py +49 -56
- nighthawk/resilience/_budget.py +2 -2
- nighthawk/runtime/runner.py +135 -94
- nighthawk/runtime/scoping.py +181 -144
- nighthawk/runtime/step_context.py +1 -1
- nighthawk/runtime/step_executor.py +25 -17
- nighthawk/runtime/step_result.py +49 -0
- nighthawk/tools/declarations.py +110 -0
- {nighthawk_python-0.12.0.dist-info → nighthawk_python-0.13.0.dist-info}/METADATA +2 -2
- {nighthawk_python-0.12.0.dist-info → nighthawk_python-0.13.0.dist-info}/RECORD +22 -20
- nighthawk/tools/registry.py +0 -217
- {nighthawk_python-0.12.0.dist-info → nighthawk_python-0.13.0.dist-info}/WHEEL +0 -0
- {nighthawk_python-0.12.0.dist-info → nighthawk_python-0.13.0.dist-info}/licenses/LICENSE +0 -0
nighthawk/__init__.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
from . import oversight, resilience
|
|
4
|
+
from .composition import UNSET, Extend, Merge, UnsetType
|
|
4
5
|
from .configuration import (
|
|
5
6
|
StepContextLimits,
|
|
6
7
|
StepExecutorConfiguration,
|
|
@@ -8,31 +9,41 @@ from .configuration import (
|
|
|
8
9
|
)
|
|
9
10
|
from .errors import (
|
|
10
11
|
ExecutionError,
|
|
12
|
+
NameConflictError,
|
|
11
13
|
NaturalParseError,
|
|
12
14
|
NighthawkError,
|
|
15
|
+
ToolDeclarationError,
|
|
13
16
|
ToolEvaluationError,
|
|
14
|
-
|
|
17
|
+
ToolNameConflictError,
|
|
15
18
|
ToolValidationError,
|
|
16
19
|
)
|
|
17
20
|
from .json_renderer import JsonableValue, to_jsonable_value
|
|
18
|
-
from .natural.decorator import natural_function
|
|
21
|
+
from .natural.decorator import get_transformed_function, natural_function
|
|
19
22
|
from .runtime.scoping import (
|
|
20
23
|
ExecutionRef,
|
|
21
24
|
UsageMeter,
|
|
22
|
-
|
|
25
|
+
get_capabilities,
|
|
23
26
|
get_execution_ref,
|
|
24
27
|
get_implicit_references,
|
|
28
|
+
get_oversight,
|
|
25
29
|
get_step_executor,
|
|
26
30
|
get_system_prompt_suffix_fragments,
|
|
31
|
+
get_tools,
|
|
32
|
+
get_usage_meter,
|
|
27
33
|
get_user_prompt_suffix_fragments,
|
|
28
34
|
run,
|
|
29
35
|
scope,
|
|
30
36
|
)
|
|
31
|
-
from .runtime.step_context import StepContext,
|
|
37
|
+
from .runtime.step_context import StepContext, get_step_context
|
|
32
38
|
from .runtime.step_executor import AgentStepExecutor, StepExecutor
|
|
33
|
-
from .tools.registry import tool
|
|
34
39
|
|
|
35
40
|
__all__ = [
|
|
41
|
+
"UNSET",
|
|
42
|
+
"Extend",
|
|
43
|
+
"Merge",
|
|
44
|
+
"UnsetType",
|
|
45
|
+
"NameConflictError",
|
|
46
|
+
"ToolNameConflictError",
|
|
36
47
|
"AgentStepExecutor",
|
|
37
48
|
"ExecutionError",
|
|
38
49
|
"ExecutionRef",
|
|
@@ -45,21 +56,24 @@ __all__ = [
|
|
|
45
56
|
"StepExecutorConfiguration",
|
|
46
57
|
"StepPromptTemplates",
|
|
47
58
|
"ToolEvaluationError",
|
|
48
|
-
"
|
|
59
|
+
"ToolDeclarationError",
|
|
49
60
|
"ToolValidationError",
|
|
50
61
|
"UsageMeter",
|
|
51
|
-
"
|
|
52
|
-
"
|
|
62
|
+
"get_capabilities",
|
|
63
|
+
"get_step_context",
|
|
64
|
+
"get_usage_meter",
|
|
53
65
|
"get_execution_ref",
|
|
54
66
|
"get_implicit_references",
|
|
67
|
+
"get_oversight",
|
|
55
68
|
"get_step_executor",
|
|
69
|
+
"get_tools",
|
|
56
70
|
"get_system_prompt_suffix_fragments",
|
|
57
71
|
"get_user_prompt_suffix_fragments",
|
|
58
72
|
"oversight",
|
|
59
73
|
"natural_function",
|
|
74
|
+
"get_transformed_function",
|
|
60
75
|
"resilience",
|
|
61
76
|
"run",
|
|
62
77
|
"scope",
|
|
63
78
|
"to_jsonable_value",
|
|
64
|
-
"tool",
|
|
65
79
|
]
|
|
@@ -15,7 +15,7 @@ from pydantic_ai.profiles import ModelProfile
|
|
|
15
15
|
from pydantic_ai.settings import ModelSettings
|
|
16
16
|
from pydantic_ai.usage import RequestUsage
|
|
17
17
|
|
|
18
|
-
from ..tools.
|
|
18
|
+
from ..tools.declarations import get_visible_tools
|
|
19
19
|
from .base import BackendModelBase, append_text_projected_tool_result_preview_prompt
|
|
20
20
|
from .claude_code_settings import ClaudeCodeModelSettings
|
|
21
21
|
from .mcp_server import mcp_server_if_needed
|
|
@@ -15,7 +15,7 @@ from pydantic_ai.settings import ModelSettings
|
|
|
15
15
|
from pydantic_ai.usage import RequestUsage
|
|
16
16
|
|
|
17
17
|
from ..json_renderer import to_jsonable_value
|
|
18
|
-
from ..tools.
|
|
18
|
+
from ..tools.declarations import get_visible_tools
|
|
19
19
|
from .base import BackendModelBase, append_text_projected_tool_result_preview_prompt
|
|
20
20
|
from .claude_code_settings import ClaudeCodeModelSettings
|
|
21
21
|
from .mcp_boundary import call_tool_for_claude_code_sdk
|
nighthawk/backends/codex.py
CHANGED
|
@@ -16,7 +16,7 @@ from pydantic_ai.profiles.openai import OpenAIJsonSchemaTransformer
|
|
|
16
16
|
from pydantic_ai.settings import ModelSettings
|
|
17
17
|
from pydantic_ai.usage import RequestUsage
|
|
18
18
|
|
|
19
|
-
from ..tools.
|
|
19
|
+
from ..tools.declarations import get_visible_tools
|
|
20
20
|
from .base import BackendModelBase, BackendModelSettings, append_text_projected_tool_result_preview_prompt
|
|
21
21
|
from .mcp_server import mcp_server_if_needed
|
|
22
22
|
from .text_projection import TextProjectedRequest, resolve_text_projection_staging_root_directory
|
|
@@ -13,7 +13,7 @@ from pydantic_ai.messages import BinaryContent, FileUrl, TextContent, UploadedFi
|
|
|
13
13
|
from ..runtime.step_context import (
|
|
14
14
|
DEFAULT_TOOL_RESULT_RENDERING_POLICY,
|
|
15
15
|
ToolResultRenderingPolicy,
|
|
16
|
-
|
|
16
|
+
get_step_context,
|
|
17
17
|
resolve_tool_result_rendering_policy,
|
|
18
18
|
)
|
|
19
19
|
from ..tools._tool_return_content import resolve_tool_return_segments
|
|
@@ -38,7 +38,7 @@ _logger = logging.getLogger("nighthawk")
|
|
|
38
38
|
|
|
39
39
|
def _resolve_tool_result_rendering_policy() -> ToolResultRenderingPolicy:
|
|
40
40
|
try:
|
|
41
|
-
step_context =
|
|
41
|
+
step_context = get_step_context()
|
|
42
42
|
except Exception:
|
|
43
43
|
return DEFAULT_TOOL_RESULT_RENDERING_POLICY
|
|
44
44
|
return resolve_tool_result_rendering_policy(step_context.tool_result_rendering_policy)
|
nighthawk/composition.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Explicit inheritance and composition operations for execution scopes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping, Sequence
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from types import MappingProxyType
|
|
8
|
+
from typing import ClassVar
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class UnsetType:
|
|
12
|
+
"""The singleton type of :data:`UNSET`, representing an omitted argument."""
|
|
13
|
+
|
|
14
|
+
__slots__ = ()
|
|
15
|
+
_instance: ClassVar[UnsetType | None] = None
|
|
16
|
+
|
|
17
|
+
def __new__(cls) -> UnsetType:
|
|
18
|
+
if cls._instance is None:
|
|
19
|
+
cls._instance = super().__new__(cls)
|
|
20
|
+
return cls._instance
|
|
21
|
+
|
|
22
|
+
def __repr__(self) -> str:
|
|
23
|
+
return "UNSET"
|
|
24
|
+
|
|
25
|
+
def __copy__(self) -> UnsetType:
|
|
26
|
+
return self
|
|
27
|
+
|
|
28
|
+
def __deepcopy__(self, identity_to_copy: dict[int, object]) -> UnsetType:
|
|
29
|
+
return self
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
UNSET = UnsetType()
|
|
33
|
+
"""Inherit the enclosing scope value; also marks omitted rewrite fields."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True, init=False)
|
|
37
|
+
class Extend[T]:
|
|
38
|
+
"""Append a sequence to an inherited ordered collection, preserving entry identity."""
|
|
39
|
+
|
|
40
|
+
values: tuple[T, ...]
|
|
41
|
+
|
|
42
|
+
def __init__(self, values: Sequence[T]) -> None:
|
|
43
|
+
if not isinstance(values, Sequence) or isinstance(values, (str, bytes, bytearray)):
|
|
44
|
+
raise TypeError("Extend requires a sequence of entries")
|
|
45
|
+
object.__setattr__(self, "values", tuple(values))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True, init=False)
|
|
49
|
+
class Merge[T]:
|
|
50
|
+
"""Merge named references with inherited references, checking conflicts by identity."""
|
|
51
|
+
|
|
52
|
+
name_to_value: Mapping[str, T]
|
|
53
|
+
|
|
54
|
+
def __init__(self, name_to_value: Mapping[str, T]) -> None:
|
|
55
|
+
if not isinstance(name_to_value, Mapping):
|
|
56
|
+
raise TypeError("Merge requires a name-to-value mapping")
|
|
57
|
+
if any(not isinstance(name, str) for name in name_to_value):
|
|
58
|
+
raise TypeError("Merge names must be strings")
|
|
59
|
+
object.__setattr__(self, "name_to_value", MappingProxyType(dict(name_to_value)))
|
nighthawk/configuration.py
CHANGED
|
@@ -3,7 +3,9 @@ from __future__ import annotations
|
|
|
3
3
|
from typing import Any
|
|
4
4
|
|
|
5
5
|
import tiktoken
|
|
6
|
-
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
6
|
+
from pydantic import BaseModel, ConfigDict, Field, InstanceOf, field_serializer, field_validator
|
|
7
|
+
from pydantic_ai.models import Model
|
|
8
|
+
from pydantic_core import PydanticSerializationError
|
|
7
9
|
|
|
8
10
|
from .json_renderer import JsonRendererStyle
|
|
9
11
|
|
|
@@ -113,7 +115,10 @@ class StepExecutorConfiguration(BaseModel):
|
|
|
113
115
|
"""Configuration for a step executor.
|
|
114
116
|
|
|
115
117
|
Attributes:
|
|
116
|
-
model:
|
|
118
|
+
model: Provider-qualified identifier or borrowed Pydantic AI Model instance.
|
|
119
|
+
Instances retain identity; the host owns client lifetime. Serialization
|
|
120
|
+
must explicitly exclude a live model field, including in nested records.
|
|
121
|
+
Configuration representations omit the model to avoid traversing credentials.
|
|
117
122
|
model_settings: Provider-specific model settings. Accepts a dict or a
|
|
118
123
|
backend-specific BaseModel instance (auto-converted to dict).
|
|
119
124
|
prompts: Prompt templates for step execution.
|
|
@@ -128,7 +133,7 @@ class StepExecutorConfiguration(BaseModel):
|
|
|
128
133
|
|
|
129
134
|
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
130
135
|
|
|
131
|
-
model: str = "openai-responses:gpt-5.
|
|
136
|
+
model: str | InstanceOf[Model] = Field(default="openai-responses:gpt-5.6-luna", repr=False)
|
|
132
137
|
model_settings: dict[str, Any] | BaseModel | None = None
|
|
133
138
|
|
|
134
139
|
@field_validator("model_settings", mode="before")
|
|
@@ -147,8 +152,14 @@ class StepExecutorConfiguration(BaseModel):
|
|
|
147
152
|
|
|
148
153
|
@field_validator("model")
|
|
149
154
|
@classmethod
|
|
150
|
-
def _validate_model(cls, value: str) -> str:
|
|
151
|
-
return _validate_model_identifier(value)
|
|
155
|
+
def _validate_model(cls, value: str | Model) -> str | Model:
|
|
156
|
+
return _validate_model_identifier(value) if isinstance(value, str) else value
|
|
157
|
+
|
|
158
|
+
@field_serializer("model")
|
|
159
|
+
def _serialize_model(self, value: str | Model) -> str:
|
|
160
|
+
if not isinstance(value, str):
|
|
161
|
+
raise PydanticSerializationError("A runtime Model cannot be serialized; exclude the model field explicitly")
|
|
162
|
+
return value
|
|
152
163
|
|
|
153
164
|
def resolve_token_encoding(self) -> tiktoken.Encoding:
|
|
154
165
|
"""Return the tiktoken encoding for this configuration.
|
|
@@ -160,7 +171,7 @@ class StepExecutorConfiguration(BaseModel):
|
|
|
160
171
|
if self.tokenizer_encoding is not None:
|
|
161
172
|
return tiktoken.get_encoding(self.tokenizer_encoding)
|
|
162
173
|
|
|
163
|
-
|
|
174
|
+
model_name = self.model.split(":", 1)[1] if isinstance(self.model, str) else self.model.model_name
|
|
164
175
|
|
|
165
176
|
try:
|
|
166
177
|
return tiktoken.encoding_for_model(model_name)
|
nighthawk/errors.py
CHANGED
|
@@ -21,5 +21,13 @@ class ToolValidationError(NighthawkError):
|
|
|
21
21
|
"""Raised when tool input validation fails."""
|
|
22
22
|
|
|
23
23
|
|
|
24
|
-
class
|
|
25
|
-
"""Raised when tool
|
|
24
|
+
class ToolDeclarationError(NighthawkError):
|
|
25
|
+
"""Raised when a tool declaration is invalid."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class NameConflictError(NighthawkError):
|
|
29
|
+
"""Raised when distinct declarations claim the same name."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ToolNameConflictError(ToolDeclarationError, NameConflictError):
|
|
33
|
+
"""Raised when tool declarations claim the same or a reserved name."""
|
nighthawk/natural/decorator.py
CHANGED
|
@@ -12,8 +12,7 @@ from typing import Any, cast
|
|
|
12
12
|
from ..runtime.runner import Runner, StepEnvelope
|
|
13
13
|
from ..runtime.scoping import get_step_executor
|
|
14
14
|
from ..runtime.step_context import python_cell_scope, python_name_scope
|
|
15
|
-
from
|
|
16
|
-
from .blocks import find_natural_blocks
|
|
15
|
+
from .blocks import extract_program, find_natural_blocks
|
|
17
16
|
from .transform import transform_module_ast
|
|
18
17
|
|
|
19
18
|
type NaturalFunctionCallable = Callable[..., Any]
|
|
@@ -239,33 +238,37 @@ def natural_function(func: NaturalFunctionCallable | None = None) -> NaturalFunc
|
|
|
239
238
|
|
|
240
239
|
filename = inspect.getsourcefile(func) or "<nighthawk>"
|
|
241
240
|
|
|
241
|
+
# Runtime helpers referenced by the transformed body travel through the factory closure, not through
|
|
242
|
+
# module globals. The transformed function therefore shares ``func.__globals__`` with the original
|
|
243
|
+
# function: names defined in the module after decoration stay visible, and the module namespace is
|
|
244
|
+
# never polluted with helper names.
|
|
245
|
+
helper_name_to_value: dict[str, object] = {
|
|
246
|
+
"__nighthawk_runner__": _RunnerProxy(),
|
|
247
|
+
"__nh_extract_program__": extract_program,
|
|
248
|
+
"__nh_python_cell_scope__": python_cell_scope,
|
|
249
|
+
}
|
|
250
|
+
factory_name_to_value: dict[str, object] = {**name_to_value, **helper_name_to_value}
|
|
251
|
+
|
|
242
252
|
factory_module = _build_transformed_factory_module(
|
|
243
253
|
transformed_module=transformed_module,
|
|
244
254
|
function_name=func.__name__,
|
|
245
|
-
name_to_value=
|
|
255
|
+
name_to_value=factory_name_to_value,
|
|
246
256
|
)
|
|
247
257
|
code = compile(factory_module, filename, "exec")
|
|
248
258
|
|
|
249
|
-
globals_namespace: dict[str, object] = dict(func.__globals__)
|
|
250
|
-
globals_namespace["__nighthawk_runner__"] = _RunnerProxy()
|
|
251
|
-
from .blocks import extract_program as _nh_extract_program
|
|
252
|
-
|
|
253
|
-
globals_namespace["__nh_extract_program__"] = _nh_extract_program
|
|
254
|
-
globals_namespace["__nh_python_cell_scope__"] = python_cell_scope
|
|
255
|
-
|
|
256
259
|
module_namespace: dict[str, object] = {}
|
|
257
|
-
exec(code,
|
|
260
|
+
exec(code, func.__globals__, module_namespace)
|
|
258
261
|
|
|
259
262
|
factory = module_namespace.get("__nh_factory__")
|
|
260
263
|
if not callable(factory):
|
|
261
264
|
raise RuntimeError("Transformed factory not found after compilation")
|
|
262
265
|
|
|
263
|
-
transformed = factory(
|
|
266
|
+
transformed = factory(factory_name_to_value)
|
|
264
267
|
if not callable(transformed):
|
|
265
268
|
raise RuntimeError("Transformed function not found after factory execution")
|
|
266
269
|
|
|
267
270
|
transformed_freevar_name_set = set(transformed.__code__.co_freevars)
|
|
268
|
-
captured_name_set = set(
|
|
271
|
+
captured_name_set = set(factory_name_to_value.keys())
|
|
269
272
|
|
|
270
273
|
unexpected_freevar_name_set = transformed_freevar_name_set - captured_name_set
|
|
271
274
|
allowed_unexpected_freevar_name_set = {func.__name__}
|
|
@@ -274,7 +277,7 @@ def natural_function(func: NaturalFunctionCallable | None = None) -> NaturalFunc
|
|
|
274
277
|
f"Transformed function freevars do not match captured names. freevars={transformed.__code__.co_freevars!r} captured={tuple(sorted(name_to_value.keys()))!r}"
|
|
275
278
|
)
|
|
276
279
|
|
|
277
|
-
if transformed.__closure__ is None and
|
|
280
|
+
if transformed.__closure__ is None and transformed_freevar_name_set:
|
|
278
281
|
raise RuntimeError("Transformed function closure is missing for captured names")
|
|
279
282
|
|
|
280
283
|
if inspect.iscoroutinefunction(func):
|
|
@@ -282,20 +285,43 @@ def natural_function(func: NaturalFunctionCallable | None = None) -> NaturalFunc
|
|
|
282
285
|
|
|
283
286
|
@wraps(func)
|
|
284
287
|
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
return await transformed_async(*args, **kwargs)
|
|
288
|
+
if name_to_value:
|
|
289
|
+
with python_name_scope(name_to_value):
|
|
290
|
+
return await transformed_async(*args, **kwargs)
|
|
291
|
+
return await transformed_async(*args, **kwargs)
|
|
290
292
|
|
|
293
|
+
async_wrapper.__nighthawk_transformed_function__ = transformed # type: ignore[attr-defined]
|
|
291
294
|
return cast(NaturalFunctionCallable, async_wrapper) # type: ignore[return-value]
|
|
292
295
|
|
|
293
296
|
@wraps(func)
|
|
294
297
|
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
return transformed(*args, **kwargs)
|
|
298
|
+
if name_to_value:
|
|
299
|
+
with python_name_scope(name_to_value):
|
|
300
|
+
return transformed(*args, **kwargs)
|
|
301
|
+
return transformed(*args, **kwargs)
|
|
300
302
|
|
|
303
|
+
wrapper.__nighthawk_transformed_function__ = transformed # type: ignore[attr-defined]
|
|
301
304
|
return cast(NaturalFunctionCallable, wrapper) # type: ignore[return-value]
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def get_transformed_function(function: Callable[..., object]) -> Callable[..., object]:
|
|
308
|
+
"""Find a Natural function's compiled body through conventional wrapper chains.
|
|
309
|
+
|
|
310
|
+
Standard inspect.unwrap follows the original source. This accessor exposes the
|
|
311
|
+
transformed body for inspection; normal execution should use the decorated callable
|
|
312
|
+
to establish its runtime context. Invalid inputs and wrapper cycles raise TypeError.
|
|
313
|
+
"""
|
|
314
|
+
current: object = function
|
|
315
|
+
seen_identity_set: set[int] = set()
|
|
316
|
+
while id(current) not in seen_identity_set:
|
|
317
|
+
seen_identity_set.add(id(current))
|
|
318
|
+
if isinstance(current, (staticmethod, classmethod)) or inspect.ismethod(current):
|
|
319
|
+
current = current.__func__
|
|
320
|
+
continue
|
|
321
|
+
transformed = getattr(current, "__nighthawk_transformed_function__", None)
|
|
322
|
+
if callable(transformed):
|
|
323
|
+
return transformed
|
|
324
|
+
current = getattr(current, "__wrapped__", None)
|
|
325
|
+
if current is None:
|
|
326
|
+
break
|
|
327
|
+
raise TypeError("Expected a Natural function or a conventional wrapper around one")
|
nighthawk/natural/transform.py
CHANGED
|
@@ -326,11 +326,9 @@ def build_runtime_call_and_assignments(
|
|
|
326
326
|
statements.extend(ast.parse(f'if "{name}" in __nh_bindings__:\n {name} = __nh_bindings__["{name}"]\n').body)
|
|
327
327
|
|
|
328
328
|
# Add outcome extraction and dispatch.
|
|
329
|
-
statements.extend(ast.parse('__nh_step_outcome__ = __nh_envelope__["
|
|
329
|
+
statements.extend(ast.parse('__nh_step_outcome__ = __nh_envelope__["outcome"]').body)
|
|
330
330
|
|
|
331
|
-
outcome_source =
|
|
332
|
-
'if __nh_step_outcome__ is not None:\n if __nh_step_outcome__.kind == "return":\n return __nh_envelope__["return_value"]\n'
|
|
333
|
-
)
|
|
331
|
+
outcome_source = 'if __nh_step_outcome__ is not None:\n if __nh_step_outcome__.kind == "return":\n return __nh_step_outcome__.value\n'
|
|
334
332
|
if is_in_loop:
|
|
335
333
|
outcome_source += (
|
|
336
334
|
' if __nh_step_outcome__.kind == "break":\n break\n if __nh_step_outcome__.kind == "continue":\n continue\n'
|
nighthawk/oversight.py
CHANGED
|
@@ -5,37 +5,16 @@ from dataclasses import dataclass
|
|
|
5
5
|
from types import MappingProxyType
|
|
6
6
|
|
|
7
7
|
from opentelemetry.trace import get_current_span
|
|
8
|
-
from pydantic import BaseModel
|
|
9
8
|
|
|
9
|
+
from .composition import UNSET, UnsetType
|
|
10
10
|
from .errors import NighthawkError
|
|
11
11
|
from .runtime.scoping import ExecutionRef
|
|
12
|
-
from .runtime.step_contract import StepKind
|
|
12
|
+
from .runtime.step_contract import StepKind
|
|
13
|
+
from .runtime.step_result import Break, Continue, Pass, Raise, Return, StepResult
|
|
13
14
|
|
|
14
15
|
|
|
15
|
-
def
|
|
16
|
-
|
|
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]
|
|
16
|
+
def _reference_mapping(name_to_value: Mapping[str, object]) -> Mapping[str, object]:
|
|
17
|
+
return MappingProxyType(dict(name_to_value))
|
|
39
18
|
|
|
40
19
|
|
|
41
20
|
class OversightRejectedError(NighthawkError):
|
|
@@ -53,17 +32,25 @@ class ToolCall:
|
|
|
53
32
|
object.__setattr__(
|
|
54
33
|
self,
|
|
55
34
|
"argument_name_to_value",
|
|
56
|
-
|
|
35
|
+
_reference_mapping(self.argument_name_to_value),
|
|
57
36
|
)
|
|
58
37
|
|
|
59
38
|
|
|
60
39
|
@dataclass(frozen=True)
|
|
61
|
-
class
|
|
40
|
+
class StepCommit:
|
|
41
|
+
"""Validated step result presented to ``Oversight.inspect_step_commit`` before it is committed.
|
|
42
|
+
|
|
43
|
+
``binding_name_to_value`` holds the write bindings after Pydantic validation and coercion.
|
|
44
|
+
``outcome`` is a resolved variant. Values retain type and identity in shallow
|
|
45
|
+
read-only mapping views. Trusted hooks must not mutate these references; use
|
|
46
|
+
Rewrite for changes and copy or serialize explicitly for durable history.
|
|
47
|
+
"""
|
|
48
|
+
|
|
62
49
|
execution_ref: ExecutionRef
|
|
63
50
|
processed_natural_program: str
|
|
64
51
|
input_binding_name_to_value: Mapping[str, object]
|
|
65
|
-
|
|
66
|
-
|
|
52
|
+
outcome: StepResult
|
|
53
|
+
binding_name_to_value: Mapping[str, object]
|
|
67
54
|
allowed_step_kinds: tuple[StepKind, ...]
|
|
68
55
|
output_binding_name_set: frozenset[str]
|
|
69
56
|
binding_name_to_type: Mapping[str, object]
|
|
@@ -72,17 +59,12 @@ class StepCommitProposal:
|
|
|
72
59
|
object.__setattr__(
|
|
73
60
|
self,
|
|
74
61
|
"input_binding_name_to_value",
|
|
75
|
-
|
|
76
|
-
)
|
|
77
|
-
object.__setattr__(
|
|
78
|
-
self,
|
|
79
|
-
"proposed_step_outcome",
|
|
80
|
-
_snapshot_value(self.proposed_step_outcome),
|
|
62
|
+
_reference_mapping(self.input_binding_name_to_value),
|
|
81
63
|
)
|
|
82
64
|
object.__setattr__(
|
|
83
65
|
self,
|
|
84
|
-
"
|
|
85
|
-
|
|
66
|
+
"binding_name_to_value",
|
|
67
|
+
_reference_mapping(self.binding_name_to_value),
|
|
86
68
|
)
|
|
87
69
|
object.__setattr__(
|
|
88
70
|
self,
|
|
@@ -92,7 +74,7 @@ class StepCommitProposal:
|
|
|
92
74
|
object.__setattr__(
|
|
93
75
|
self,
|
|
94
76
|
"binding_name_to_type",
|
|
95
|
-
|
|
77
|
+
_reference_mapping(self.binding_name_to_type),
|
|
96
78
|
)
|
|
97
79
|
|
|
98
80
|
|
|
@@ -108,24 +90,29 @@ class Reject:
|
|
|
108
90
|
|
|
109
91
|
@dataclass(frozen=True)
|
|
110
92
|
class Rewrite:
|
|
111
|
-
|
|
112
|
-
|
|
93
|
+
"""Replace supplied commit fields; UNSET inherits and explicit None is a return.
|
|
94
|
+
|
|
95
|
+
A mapping replaces all writes. A return_value patch requires an existing Return.
|
|
96
|
+
Use outcome=Return(value=...) to change another allowed outcome into a return.
|
|
97
|
+
Replacements are validated before commit without replaying return expressions.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
outcome: StepResult | UnsetType = UNSET
|
|
101
|
+
binding_name_to_value: Mapping[str, object] | UnsetType = UNSET
|
|
102
|
+
return_value: object = UNSET
|
|
113
103
|
reason: str | None = None
|
|
114
104
|
|
|
115
105
|
def __post_init__(self) -> None:
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
)
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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")
|
|
106
|
+
if not isinstance(self.outcome, (UnsetType, Pass, Return, Break, Continue, Raise)):
|
|
107
|
+
raise TypeError("Rewrite outcome must be a resolved StepResult or UNSET")
|
|
108
|
+
if self.outcome is not UNSET and self.return_value is not UNSET:
|
|
109
|
+
raise ValueError("Rewrite cannot supply both outcome and return_value")
|
|
110
|
+
if not isinstance(self.binding_name_to_value, UnsetType):
|
|
111
|
+
if not isinstance(self.binding_name_to_value, Mapping):
|
|
112
|
+
raise TypeError("Rewrite binding_name_to_value must be a mapping or UNSET")
|
|
113
|
+
object.__setattr__(self, "binding_name_to_value", _reference_mapping(self.binding_name_to_value))
|
|
114
|
+
if self.outcome is UNSET and self.binding_name_to_value is UNSET and self.return_value is UNSET:
|
|
115
|
+
raise ValueError("Rewrite must change outcome, binding_name_to_value, or return_value")
|
|
129
116
|
|
|
130
117
|
|
|
131
118
|
type ToolCallDecision = Accept | Reject
|
|
@@ -135,7 +122,7 @@ type StepCommitDecision = Accept | Reject | Rewrite
|
|
|
135
122
|
@dataclass(frozen=True)
|
|
136
123
|
class Oversight:
|
|
137
124
|
inspect_tool_call: Callable[[ToolCall], ToolCallDecision] | None = None
|
|
138
|
-
inspect_step_commit: Callable[[
|
|
125
|
+
inspect_step_commit: Callable[[StepCommit], StepCommitDecision] | None = None
|
|
139
126
|
|
|
140
127
|
|
|
141
128
|
def record_oversight_decision(
|
|
@@ -168,13 +155,19 @@ def record_oversight_decision(
|
|
|
168
155
|
|
|
169
156
|
|
|
170
157
|
__all__ = [
|
|
158
|
+
"Pass",
|
|
159
|
+
"Return",
|
|
160
|
+
"Break",
|
|
161
|
+
"Continue",
|
|
162
|
+
"Raise",
|
|
163
|
+
"StepResult",
|
|
171
164
|
"Accept",
|
|
172
165
|
"Oversight",
|
|
173
166
|
"OversightRejectedError",
|
|
174
167
|
"Reject",
|
|
175
168
|
"Rewrite",
|
|
169
|
+
"StepCommit",
|
|
176
170
|
"StepCommitDecision",
|
|
177
|
-
"StepCommitProposal",
|
|
178
171
|
"ToolCall",
|
|
179
172
|
"ToolCallDecision",
|
|
180
173
|
]
|
nighthawk/resilience/_budget.py
CHANGED
|
@@ -11,7 +11,7 @@ from opentelemetry.trace import get_current_span
|
|
|
11
11
|
from pydantic_ai.usage import RunUsage
|
|
12
12
|
|
|
13
13
|
from ..errors import NighthawkError
|
|
14
|
-
from ..runtime.scoping import
|
|
14
|
+
from ..runtime.scoping import _optional_usage_meter
|
|
15
15
|
|
|
16
16
|
_logger = logging.getLogger("nighthawk.resilience")
|
|
17
17
|
|
|
@@ -43,7 +43,7 @@ class BudgetExceededError(NighthawkError):
|
|
|
43
43
|
|
|
44
44
|
|
|
45
45
|
def _get_usage_snapshot() -> RunUsage | None:
|
|
46
|
-
usage_meter =
|
|
46
|
+
usage_meter = _optional_usage_meter()
|
|
47
47
|
if usage_meter is None:
|
|
48
48
|
return None
|
|
49
49
|
return usage_meter.snapshot()
|