nighthawk-python 0.1.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 +48 -0
- nighthawk/backends/__init__.py +0 -0
- nighthawk/backends/base.py +95 -0
- nighthawk/backends/claude_code_cli.py +342 -0
- nighthawk/backends/claude_code_sdk.py +325 -0
- nighthawk/backends/codex.py +352 -0
- nighthawk/backends/mcp_boundary.py +129 -0
- nighthawk/backends/mcp_server.py +226 -0
- nighthawk/backends/tool_bridge.py +240 -0
- nighthawk/configuration.py +193 -0
- nighthawk/errors.py +25 -0
- nighthawk/identifier_path.py +35 -0
- nighthawk/json_renderer.py +216 -0
- nighthawk/natural/__init__.py +0 -0
- nighthawk/natural/blocks.py +279 -0
- nighthawk/natural/decorator.py +302 -0
- nighthawk/natural/transform.py +346 -0
- nighthawk/runtime/__init__.py +0 -0
- nighthawk/runtime/async_bridge.py +50 -0
- nighthawk/runtime/prompt.py +344 -0
- nighthawk/runtime/runner.py +462 -0
- nighthawk/runtime/scoping.py +288 -0
- nighthawk/runtime/step_context.py +171 -0
- nighthawk/runtime/step_contract.py +231 -0
- nighthawk/runtime/step_executor.py +360 -0
- nighthawk/runtime/tool_calls.py +99 -0
- nighthawk/tools/__init__.py +0 -0
- nighthawk/tools/assignment.py +246 -0
- nighthawk/tools/contracts.py +72 -0
- nighthawk/tools/execution.py +83 -0
- nighthawk/tools/provided.py +80 -0
- nighthawk/tools/registry.py +212 -0
- nighthawk_python-0.1.0.dist-info/METADATA +111 -0
- nighthawk_python-0.1.0.dist-info/RECORD +36 -0
- nighthawk_python-0.1.0.dist-info/WHEEL +4 -0
- nighthawk_python-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Protocol, cast, runtime_checkable
|
|
4
|
+
|
|
5
|
+
from pydantic import TypeAdapter
|
|
6
|
+
from pydantic_ai import Agent, StructuredDict
|
|
7
|
+
from pydantic_ai.toolsets.function import FunctionToolset
|
|
8
|
+
|
|
9
|
+
from ..configuration import StepExecutorConfiguration
|
|
10
|
+
from ..errors import ExecutionError
|
|
11
|
+
from ..tools.execution import ToolResultWrapperToolset
|
|
12
|
+
from ..tools.registry import get_visible_tools
|
|
13
|
+
from .async_bridge import run_coroutine_synchronously
|
|
14
|
+
from .prompt import build_user_prompt, extract_references_and_program
|
|
15
|
+
from .scoping import (
|
|
16
|
+
RUN_ID,
|
|
17
|
+
SCOPE_ID,
|
|
18
|
+
STEP_ID,
|
|
19
|
+
get_execution_context,
|
|
20
|
+
get_system_prompt_suffix_fragments,
|
|
21
|
+
scope,
|
|
22
|
+
span,
|
|
23
|
+
)
|
|
24
|
+
from .step_context import (
|
|
25
|
+
_MISSING,
|
|
26
|
+
StepContext,
|
|
27
|
+
ToolResultRenderingPolicy,
|
|
28
|
+
resolve_name_in_step_context,
|
|
29
|
+
step_context_scope,
|
|
30
|
+
)
|
|
31
|
+
from .step_contract import (
|
|
32
|
+
STEP_KINDS,
|
|
33
|
+
StepFinalResult,
|
|
34
|
+
StepKind,
|
|
35
|
+
StepOutcome,
|
|
36
|
+
build_step_json_schema,
|
|
37
|
+
build_step_system_prompt_suffix_fragment,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@runtime_checkable
|
|
42
|
+
class AsyncExecutionAgent(Protocol):
|
|
43
|
+
"""Protocol for agents that provide async step execution via ``run``."""
|
|
44
|
+
|
|
45
|
+
async def run(self, *args: Any, **kwargs: Any) -> Any: ...
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@runtime_checkable
|
|
49
|
+
class SyncExecutionAgent(Protocol):
|
|
50
|
+
"""Protocol for agents that provide sync step execution via ``run_sync``."""
|
|
51
|
+
|
|
52
|
+
def run_sync(self, *args: Any, **kwargs: Any) -> Any: ...
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
type StepExecutionAgent = AsyncExecutionAgent | SyncExecutionAgent
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@runtime_checkable
|
|
59
|
+
class SyncStepExecutor(Protocol):
|
|
60
|
+
"""Step executor that provides synchronous execution."""
|
|
61
|
+
|
|
62
|
+
def run_step(
|
|
63
|
+
self,
|
|
64
|
+
*,
|
|
65
|
+
processed_natural_program: str,
|
|
66
|
+
step_context: StepContext,
|
|
67
|
+
binding_names: list[str],
|
|
68
|
+
allowed_step_kinds: tuple[str, ...],
|
|
69
|
+
) -> tuple[StepOutcome, dict[str, object]]: ...
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@runtime_checkable
|
|
73
|
+
class AsyncStepExecutor(Protocol):
|
|
74
|
+
"""Step executor that provides asynchronous execution."""
|
|
75
|
+
|
|
76
|
+
async def run_step_async(
|
|
77
|
+
self,
|
|
78
|
+
*,
|
|
79
|
+
processed_natural_program: str,
|
|
80
|
+
step_context: StepContext,
|
|
81
|
+
binding_names: list[str],
|
|
82
|
+
allowed_step_kinds: tuple[str, ...],
|
|
83
|
+
) -> tuple[StepOutcome, dict[str, object]]: ...
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
type StepExecutor = SyncStepExecutor | AsyncStepExecutor
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _new_agent_step_executor(
|
|
90
|
+
configuration: StepExecutorConfiguration,
|
|
91
|
+
) -> StepExecutionAgent:
|
|
92
|
+
model_identifier = configuration.model
|
|
93
|
+
provider, provider_model_name = model_identifier.split(":", 1)
|
|
94
|
+
|
|
95
|
+
match provider:
|
|
96
|
+
case "claude-code-sdk":
|
|
97
|
+
from ..backends.claude_code_sdk import ClaudeCodeSdkModel
|
|
98
|
+
|
|
99
|
+
model: object = ClaudeCodeSdkModel(model_name=(provider_model_name if provider_model_name != "default" else None))
|
|
100
|
+
case "claude-code-cli":
|
|
101
|
+
from ..backends.claude_code_cli import ClaudeCodeCliModel
|
|
102
|
+
|
|
103
|
+
model = ClaudeCodeCliModel(model_name=(provider_model_name if provider_model_name != "default" else None))
|
|
104
|
+
case "codex":
|
|
105
|
+
from ..backends.codex import CodexModel
|
|
106
|
+
|
|
107
|
+
model = CodexModel(model_name=(provider_model_name if provider_model_name != "default" else None))
|
|
108
|
+
case _:
|
|
109
|
+
model = model_identifier
|
|
110
|
+
|
|
111
|
+
constructor_arguments: dict[str, Any] = {}
|
|
112
|
+
if configuration.model_settings is not None:
|
|
113
|
+
constructor_arguments["model_settings"] = configuration.model_settings
|
|
114
|
+
|
|
115
|
+
agent = Agent(
|
|
116
|
+
model=model,
|
|
117
|
+
output_type=StepFinalResult,
|
|
118
|
+
deps_type=StepContext,
|
|
119
|
+
system_prompt=configuration.prompts.step_system_prompt_template,
|
|
120
|
+
**constructor_arguments,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
@agent.system_prompt(dynamic=True)
|
|
124
|
+
def _system_prompt_suffixes() -> str | None: # pyright: ignore[reportUnusedFunction]
|
|
125
|
+
suffix_fragments = (
|
|
126
|
+
*configuration.system_prompt_suffix_fragments,
|
|
127
|
+
*get_system_prompt_suffix_fragments(),
|
|
128
|
+
)
|
|
129
|
+
if not suffix_fragments:
|
|
130
|
+
return None
|
|
131
|
+
return "\n\n".join(suffix_fragments)
|
|
132
|
+
|
|
133
|
+
return agent
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class AgentStepExecutor:
|
|
137
|
+
"""Step executor that delegates Natural block execution to a Pydantic AI agent.
|
|
138
|
+
|
|
139
|
+
Attributes:
|
|
140
|
+
configuration: The step executor configuration.
|
|
141
|
+
agent: The underlying agent instance. If not provided, one is created
|
|
142
|
+
from the configuration.
|
|
143
|
+
token_encoding: The tiktoken encoding resolved from the configuration.
|
|
144
|
+
tool_result_rendering_policy: Policy for rendering tool results.
|
|
145
|
+
agent_is_managed: Whether the agent was created internally from
|
|
146
|
+
the configuration (True) or provided externally (False).
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
def __init__(
|
|
150
|
+
self,
|
|
151
|
+
configuration: StepExecutorConfiguration | None = None,
|
|
152
|
+
agent: StepExecutionAgent | None = None,
|
|
153
|
+
) -> None:
|
|
154
|
+
self.configuration = configuration or StepExecutorConfiguration()
|
|
155
|
+
self.agent_is_managed = agent is None
|
|
156
|
+
self.agent = agent if agent is not None else _new_agent_step_executor(self.configuration)
|
|
157
|
+
self.token_encoding = self.configuration.resolve_token_encoding()
|
|
158
|
+
self.tool_result_rendering_policy = ToolResultRenderingPolicy(
|
|
159
|
+
tokenizer_encoding_name=self.token_encoding.name,
|
|
160
|
+
tool_result_max_tokens=(self.configuration.context_limits.tool_result_max_tokens),
|
|
161
|
+
json_renderer_style=self.configuration.json_renderer_style,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
@classmethod
|
|
165
|
+
def from_agent(
|
|
166
|
+
cls,
|
|
167
|
+
*,
|
|
168
|
+
agent: StepExecutionAgent,
|
|
169
|
+
configuration: StepExecutorConfiguration | None = None,
|
|
170
|
+
) -> AgentStepExecutor:
|
|
171
|
+
"""Create an executor wrapping an existing agent.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
agent: A pre-configured agent to use for step execution.
|
|
175
|
+
configuration: Optional configuration. Defaults to
|
|
176
|
+
StepExecutorConfiguration().
|
|
177
|
+
"""
|
|
178
|
+
return cls(configuration=configuration, agent=agent)
|
|
179
|
+
|
|
180
|
+
@classmethod
|
|
181
|
+
def from_configuration(
|
|
182
|
+
cls,
|
|
183
|
+
*,
|
|
184
|
+
configuration: StepExecutorConfiguration,
|
|
185
|
+
) -> AgentStepExecutor:
|
|
186
|
+
"""Create an executor from a configuration, building a managed agent internally."""
|
|
187
|
+
return cls(configuration=configuration)
|
|
188
|
+
|
|
189
|
+
async def _run_agent(
|
|
190
|
+
self,
|
|
191
|
+
*,
|
|
192
|
+
user_prompt: str,
|
|
193
|
+
step_context: StepContext,
|
|
194
|
+
toolset: ToolResultWrapperToolset,
|
|
195
|
+
structured_output_type: object,
|
|
196
|
+
) -> Any:
|
|
197
|
+
if self.agent is None:
|
|
198
|
+
raise ExecutionError("AgentStepExecutor.agent is not initialized")
|
|
199
|
+
|
|
200
|
+
if isinstance(self.agent, AsyncExecutionAgent):
|
|
201
|
+
return await self.agent.run(
|
|
202
|
+
user_prompt,
|
|
203
|
+
deps=step_context,
|
|
204
|
+
toolsets=[toolset],
|
|
205
|
+
output_type=structured_output_type,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
if isinstance(self.agent, SyncExecutionAgent):
|
|
209
|
+
return self.agent.run_sync(
|
|
210
|
+
user_prompt,
|
|
211
|
+
deps=step_context,
|
|
212
|
+
toolsets=[toolset],
|
|
213
|
+
output_type=structured_output_type,
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
raise ExecutionError("AgentStepExecutor requires an agent with run(...) or run_sync(...)")
|
|
217
|
+
|
|
218
|
+
def _build_structured_output_and_prompt_fragment(
|
|
219
|
+
self,
|
|
220
|
+
*,
|
|
221
|
+
processed_natural_program: str,
|
|
222
|
+
step_context: StepContext,
|
|
223
|
+
allowed_step_kinds: tuple[str, ...],
|
|
224
|
+
) -> tuple[object, str]:
|
|
225
|
+
"""Build the structured output type and system prompt fragment for a step."""
|
|
226
|
+
unknown_kinds = set(allowed_step_kinds).difference(STEP_KINDS)
|
|
227
|
+
if unknown_kinds:
|
|
228
|
+
raise ExecutionError(f"Internal error: allowed_step_kinds contains unknown kinds: {tuple(sorted(unknown_kinds))}")
|
|
229
|
+
|
|
230
|
+
allowed_kinds_deduplicated = tuple(dict.fromkeys(allowed_step_kinds))
|
|
231
|
+
allowed_kinds_typed = cast(tuple[StepKind, ...], allowed_kinds_deduplicated)
|
|
232
|
+
|
|
233
|
+
referenced_names, _ = extract_references_and_program(processed_natural_program)
|
|
234
|
+
|
|
235
|
+
error_type_candidate_names: set[str] = set(referenced_names)
|
|
236
|
+
for name, value in step_context.step_locals.items():
|
|
237
|
+
if isinstance(value, type) and issubclass(value, BaseException) and value.__name__ == name:
|
|
238
|
+
error_type_candidate_names.add(name)
|
|
239
|
+
|
|
240
|
+
error_type_binding_name_list: list[str] = []
|
|
241
|
+
for name in sorted(error_type_candidate_names):
|
|
242
|
+
value = resolve_name_in_step_context(step_context, name)
|
|
243
|
+
if value is _MISSING:
|
|
244
|
+
continue
|
|
245
|
+
if not isinstance(value, type) or not issubclass(value, BaseException) or value.__name__ != name:
|
|
246
|
+
continue
|
|
247
|
+
error_type_binding_name_list.append(name)
|
|
248
|
+
|
|
249
|
+
raise_error_type_binding_names = tuple(error_type_binding_name_list)
|
|
250
|
+
|
|
251
|
+
step_system_prompt_fragment = build_step_system_prompt_suffix_fragment(
|
|
252
|
+
allowed_kinds=allowed_kinds_typed,
|
|
253
|
+
raise_error_type_binding_names=raise_error_type_binding_names,
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
outcome_json_schema = build_step_json_schema(
|
|
257
|
+
allowed_kinds=allowed_kinds_typed,
|
|
258
|
+
raise_error_type_binding_names=raise_error_type_binding_names,
|
|
259
|
+
)
|
|
260
|
+
structured_output_type = StructuredDict(outcome_json_schema, name="StepFinalResult")
|
|
261
|
+
|
|
262
|
+
return structured_output_type, step_system_prompt_fragment
|
|
263
|
+
|
|
264
|
+
def _parse_agent_result(
|
|
265
|
+
self,
|
|
266
|
+
result: Any,
|
|
267
|
+
) -> StepOutcome:
|
|
268
|
+
"""Parse the agent result into a StepOutcome."""
|
|
269
|
+
try:
|
|
270
|
+
raw_output = result.output
|
|
271
|
+
if isinstance(raw_output, StepFinalResult):
|
|
272
|
+
return raw_output.result
|
|
273
|
+
if isinstance(raw_output, dict) and "result" in raw_output:
|
|
274
|
+
return TypeAdapter(StepOutcome).validate_python(raw_output["result"])
|
|
275
|
+
return TypeAdapter(StepOutcome).validate_python(raw_output)
|
|
276
|
+
except Exception as e:
|
|
277
|
+
raise ExecutionError(f"Step produced invalid step outcome: {e}") from e
|
|
278
|
+
|
|
279
|
+
def _extract_bindings(
|
|
280
|
+
self,
|
|
281
|
+
*,
|
|
282
|
+
binding_names: list[str],
|
|
283
|
+
step_context: StepContext,
|
|
284
|
+
) -> dict[str, object]:
|
|
285
|
+
"""Extract committed bindings from the step context."""
|
|
286
|
+
bindings: dict[str, object] = {}
|
|
287
|
+
for name in binding_names:
|
|
288
|
+
if name in step_context.assigned_binding_names:
|
|
289
|
+
bindings[name] = step_context.step_locals[name]
|
|
290
|
+
return bindings
|
|
291
|
+
|
|
292
|
+
async def run_step_async(
|
|
293
|
+
self,
|
|
294
|
+
*,
|
|
295
|
+
processed_natural_program: str,
|
|
296
|
+
step_context: StepContext,
|
|
297
|
+
binding_names: list[str],
|
|
298
|
+
allowed_step_kinds: tuple[str, ...],
|
|
299
|
+
) -> tuple[StepOutcome, dict[str, object]]:
|
|
300
|
+
if step_context.tool_result_rendering_policy is None:
|
|
301
|
+
step_context.tool_result_rendering_policy = self.tool_result_rendering_policy
|
|
302
|
+
|
|
303
|
+
user_prompt = build_user_prompt(
|
|
304
|
+
processed_natural_program=processed_natural_program,
|
|
305
|
+
step_context=step_context,
|
|
306
|
+
configuration=self.configuration,
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
visible_tool_list = get_visible_tools()
|
|
310
|
+
toolset = ToolResultWrapperToolset(FunctionToolset(visible_tool_list))
|
|
311
|
+
|
|
312
|
+
structured_output_type, step_system_prompt_fragment = self._build_structured_output_and_prompt_fragment(
|
|
313
|
+
processed_natural_program=processed_natural_program,
|
|
314
|
+
step_context=step_context,
|
|
315
|
+
allowed_step_kinds=allowed_step_kinds,
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
with scope(system_prompt_suffix_fragment=step_system_prompt_fragment):
|
|
319
|
+
execution_context = get_execution_context()
|
|
320
|
+
with (
|
|
321
|
+
span(
|
|
322
|
+
"nighthawk.step_executor",
|
|
323
|
+
**{
|
|
324
|
+
RUN_ID: execution_context.run_id,
|
|
325
|
+
SCOPE_ID: execution_context.scope_id,
|
|
326
|
+
STEP_ID: step_context.step_id,
|
|
327
|
+
},
|
|
328
|
+
),
|
|
329
|
+
step_context_scope(step_context),
|
|
330
|
+
):
|
|
331
|
+
result = await self._run_agent(
|
|
332
|
+
user_prompt=user_prompt,
|
|
333
|
+
step_context=step_context,
|
|
334
|
+
toolset=toolset,
|
|
335
|
+
structured_output_type=structured_output_type,
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
step_outcome = self._parse_agent_result(result)
|
|
339
|
+
bindings = self._extract_bindings(binding_names=binding_names, step_context=step_context)
|
|
340
|
+
return step_outcome, bindings
|
|
341
|
+
|
|
342
|
+
def run_step(
|
|
343
|
+
self,
|
|
344
|
+
*,
|
|
345
|
+
processed_natural_program: str,
|
|
346
|
+
step_context: StepContext,
|
|
347
|
+
binding_names: list[str],
|
|
348
|
+
allowed_step_kinds: tuple[str, ...],
|
|
349
|
+
) -> tuple[StepOutcome, dict[str, object]]:
|
|
350
|
+
return cast(
|
|
351
|
+
tuple[StepOutcome, dict[str, object]],
|
|
352
|
+
run_coroutine_synchronously(
|
|
353
|
+
lambda: self.run_step_async(
|
|
354
|
+
processed_natural_program=processed_natural_program,
|
|
355
|
+
step_context=step_context,
|
|
356
|
+
binding_names=binding_names,
|
|
357
|
+
allowed_step_kinds=allowed_step_kinds,
|
|
358
|
+
)
|
|
359
|
+
),
|
|
360
|
+
)
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import uuid
|
|
5
|
+
from collections.abc import Awaitable, Callable, Iterator
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from pydantic_ai import RunContext
|
|
10
|
+
from pydantic_ai._instrumentation import InstrumentationNames
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def generate_tool_call_id() -> str:
|
|
14
|
+
return str(uuid.uuid4())
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _resolve_instrumentation_names(*, run_context: RunContext[Any]) -> InstrumentationNames:
|
|
18
|
+
return InstrumentationNames.for_version(run_context.instrumentation_version)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _resolve_trace_include_content(*, run_context: RunContext[Any]) -> bool:
|
|
22
|
+
return run_context.trace_include_content
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _build_tool_span_attributes(
|
|
26
|
+
*,
|
|
27
|
+
tool_name: str,
|
|
28
|
+
tool_call_id: str | None,
|
|
29
|
+
arguments: dict[str, Any],
|
|
30
|
+
instrumentation_names: InstrumentationNames,
|
|
31
|
+
include_content: bool,
|
|
32
|
+
) -> dict[str, Any]:
|
|
33
|
+
attributes: dict[str, Any] = {
|
|
34
|
+
"gen_ai.tool.name": tool_name,
|
|
35
|
+
"logfire.msg": f"running tool: {tool_name}",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if tool_call_id is not None:
|
|
39
|
+
attributes["gen_ai.tool.call.id"] = tool_call_id
|
|
40
|
+
|
|
41
|
+
if include_content:
|
|
42
|
+
attributes[instrumentation_names.tool_arguments_attr] = json.dumps(arguments, default=str)
|
|
43
|
+
attributes["logfire.json_schema"] = json.dumps(
|
|
44
|
+
{
|
|
45
|
+
"type": "object",
|
|
46
|
+
"properties": {
|
|
47
|
+
instrumentation_names.tool_arguments_attr: {"type": "object"},
|
|
48
|
+
instrumentation_names.tool_result_attr: {"type": "object"},
|
|
49
|
+
"gen_ai.tool.name": {},
|
|
50
|
+
"gen_ai.tool.call.id": {},
|
|
51
|
+
},
|
|
52
|
+
}
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
return attributes
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@contextmanager
|
|
59
|
+
def _start_tool_span(
|
|
60
|
+
*,
|
|
61
|
+
tool_name: str,
|
|
62
|
+
attributes: dict[str, Any],
|
|
63
|
+
instrumentation_names: InstrumentationNames,
|
|
64
|
+
run_context: RunContext[Any],
|
|
65
|
+
) -> Iterator[Any]:
|
|
66
|
+
span_name = instrumentation_names.get_tool_span_name(tool_name)
|
|
67
|
+
with run_context.tracer.start_as_current_span(span_name, attributes=attributes) as span:
|
|
68
|
+
yield span
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
async def run_tool_instrumented(
|
|
72
|
+
*,
|
|
73
|
+
tool_name: str,
|
|
74
|
+
arguments: dict[str, Any],
|
|
75
|
+
call: Callable[[], Awaitable[str]],
|
|
76
|
+
run_context: RunContext[Any],
|
|
77
|
+
tool_call_id: str | None,
|
|
78
|
+
) -> str:
|
|
79
|
+
instrumentation_names = _resolve_instrumentation_names(run_context=run_context)
|
|
80
|
+
include_content = _resolve_trace_include_content(run_context=run_context)
|
|
81
|
+
|
|
82
|
+
span_attributes = _build_tool_span_attributes(
|
|
83
|
+
tool_name=tool_name,
|
|
84
|
+
tool_call_id=tool_call_id,
|
|
85
|
+
arguments=arguments,
|
|
86
|
+
instrumentation_names=instrumentation_names,
|
|
87
|
+
include_content=include_content,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
with _start_tool_span(
|
|
91
|
+
tool_name=tool_name,
|
|
92
|
+
attributes=span_attributes,
|
|
93
|
+
instrumentation_names=instrumentation_names,
|
|
94
|
+
run_context=run_context,
|
|
95
|
+
) as span:
|
|
96
|
+
result_text = await call()
|
|
97
|
+
if include_content and span.is_recording():
|
|
98
|
+
span.set_attribute(instrumentation_names.tool_result_attr, result_text)
|
|
99
|
+
return result_text
|
|
File without changes
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import inspect
|
|
5
|
+
import types
|
|
6
|
+
from typing import Any, NoReturn
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, TypeAdapter
|
|
9
|
+
|
|
10
|
+
from ..errors import ToolEvaluationError
|
|
11
|
+
from ..identifier_path import parse_identifier_path
|
|
12
|
+
from ..json_renderer import to_jsonable_value
|
|
13
|
+
from ..runtime.async_bridge import run_awaitable_value_synchronously
|
|
14
|
+
from ..runtime.step_context import StepContext
|
|
15
|
+
from .contracts import ToolBoundaryError
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _compile_expression(expression: str) -> types.CodeType:
|
|
19
|
+
"""Compile a Python expression with top-level await support."""
|
|
20
|
+
return compile(
|
|
21
|
+
expression,
|
|
22
|
+
"<nighthawk-eval>",
|
|
23
|
+
"eval",
|
|
24
|
+
flags=ast.PyCF_ALLOW_TOP_LEVEL_AWAIT,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def eval_expression_async(step_context: StepContext, expression: str) -> object:
|
|
29
|
+
"""Evaluate a Python expression inside the step execution environment (async canonical).
|
|
30
|
+
|
|
31
|
+
Security note: This uses ``eval()`` with ``ast.PyCF_ALLOW_TOP_LEVEL_AWAIT``
|
|
32
|
+
to execute LLM-generated expressions against ``step_globals`` and ``step_locals``.
|
|
33
|
+
Natural DSL sources are trusted, repository-managed assets.
|
|
34
|
+
Do not wire untrusted user input into expressions evaluated here.
|
|
35
|
+
"""
|
|
36
|
+
try:
|
|
37
|
+
compiled_expression = _compile_expression(expression)
|
|
38
|
+
value = eval(compiled_expression, step_context.step_globals, step_context.step_locals)
|
|
39
|
+
if inspect.isawaitable(value):
|
|
40
|
+
return await value
|
|
41
|
+
return value
|
|
42
|
+
except Exception as e:
|
|
43
|
+
raise ToolEvaluationError(str(e)) from e
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def eval_expression(step_context: StepContext, expression: str) -> object:
|
|
47
|
+
"""Evaluate a Python expression inside the step execution environment (sync wrapper)."""
|
|
48
|
+
try:
|
|
49
|
+
compiled_expression = _compile_expression(expression)
|
|
50
|
+
value = eval(compiled_expression, step_context.step_globals, step_context.step_locals)
|
|
51
|
+
return run_awaitable_value_synchronously(value)
|
|
52
|
+
except Exception as e:
|
|
53
|
+
raise ToolEvaluationError(str(e)) from e
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _raise_invalid_input(*, message: str, guidance: str) -> NoReturn:
|
|
57
|
+
raise ToolBoundaryError(kind="invalid_input", message=message, guidance=guidance)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _raise_resolution(*, message: str, guidance: str) -> NoReturn:
|
|
61
|
+
raise ToolBoundaryError(kind="resolution", message=message, guidance=guidance)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _raise_execution(*, message: str, guidance: str) -> NoReturn:
|
|
65
|
+
raise ToolBoundaryError(kind="execution", message=message, guidance=guidance)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _get_pydantic_field_type(model: BaseModel, field_name: str) -> object | None:
|
|
69
|
+
model_fields = getattr(type(model), "model_fields", None)
|
|
70
|
+
if model_fields is None:
|
|
71
|
+
return None
|
|
72
|
+
field = model_fields.get(field_name)
|
|
73
|
+
if field is None:
|
|
74
|
+
return None
|
|
75
|
+
return field.annotation
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _assign_value_to_target_path(
|
|
79
|
+
*,
|
|
80
|
+
step_context: StepContext,
|
|
81
|
+
target_path: str,
|
|
82
|
+
parsed_target_path: tuple[str, ...],
|
|
83
|
+
value: object,
|
|
84
|
+
) -> dict[str, Any]:
|
|
85
|
+
if len(parsed_target_path) == 1:
|
|
86
|
+
name = parsed_target_path[0]
|
|
87
|
+
|
|
88
|
+
if name in step_context.read_binding_names:
|
|
89
|
+
_raise_invalid_input(
|
|
90
|
+
message=f"Cannot rebind read binding '{name}' with nh_assign.",
|
|
91
|
+
guidance=f"'{name}' is a read binding (<{name}>). To mutate it in-place, use nh_exec (e.g. {name}.update(...)). To rebind, declare it as a write binding (<:{name}>).",
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
expected_type = step_context.binding_name_to_type.get(name)
|
|
95
|
+
|
|
96
|
+
if expected_type is not None:
|
|
97
|
+
try:
|
|
98
|
+
adapted = TypeAdapter(expected_type)
|
|
99
|
+
value = adapted.validate_python(value)
|
|
100
|
+
except Exception as e:
|
|
101
|
+
_raise_invalid_input(
|
|
102
|
+
message=str(e),
|
|
103
|
+
guidance="Fix the value to match the expected type and retry.",
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
step_context.record_assignment(name, value)
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
"target_path": target_path,
|
|
110
|
+
"step_locals_revision": step_context.step_locals_revision,
|
|
111
|
+
"updates": [{"path": target_path, "value": to_jsonable_value(value)}],
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
root_name = parsed_target_path[0]
|
|
115
|
+
attribute_path = parsed_target_path[1:]
|
|
116
|
+
|
|
117
|
+
if root_name not in step_context.step_locals:
|
|
118
|
+
_raise_resolution(
|
|
119
|
+
message=f"Unknown root name: {root_name}",
|
|
120
|
+
guidance="Fix the target path so the referenced root name exists, then retry.",
|
|
121
|
+
)
|
|
122
|
+
root_object = step_context.step_locals[root_name]
|
|
123
|
+
|
|
124
|
+
current_object = root_object
|
|
125
|
+
for attribute in attribute_path[:-1]:
|
|
126
|
+
try:
|
|
127
|
+
current_object = getattr(current_object, attribute)
|
|
128
|
+
except Exception as e:
|
|
129
|
+
_raise_resolution(
|
|
130
|
+
message=f"Failed to resolve attribute {attribute!r}: {e}",
|
|
131
|
+
guidance="Fix the target path so the referenced attributes exist, then retry.",
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
final_attribute = attribute_path[-1]
|
|
135
|
+
|
|
136
|
+
expected_type: object | None = None
|
|
137
|
+
if isinstance(current_object, BaseModel):
|
|
138
|
+
expected_type = _get_pydantic_field_type(current_object, final_attribute)
|
|
139
|
+
if expected_type is None:
|
|
140
|
+
_raise_invalid_input(
|
|
141
|
+
message=f"Unknown field on {type(current_object).__name__}: {final_attribute}",
|
|
142
|
+
guidance="Fix the target path so the referenced field exists, then retry.",
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
if expected_type is not None:
|
|
146
|
+
try:
|
|
147
|
+
adapted = TypeAdapter(expected_type)
|
|
148
|
+
value = adapted.validate_python(value)
|
|
149
|
+
except Exception as e:
|
|
150
|
+
_raise_invalid_input(
|
|
151
|
+
message=str(e),
|
|
152
|
+
guidance="Fix the value to match the expected type and retry.",
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
try:
|
|
156
|
+
setattr(current_object, final_attribute, value)
|
|
157
|
+
except Exception as e:
|
|
158
|
+
_raise_resolution(
|
|
159
|
+
message=f"Failed to set attribute {final_attribute!r}: {e}",
|
|
160
|
+
guidance="Fix the target path so the referenced attributes are assignable, then retry.",
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
# Dotted mutation bypasses record_assignment because commit selection is
|
|
164
|
+
# controlled only by <:name> bindings (top-level names). See design.md
|
|
165
|
+
# Section 8.3 "Commit and mutation notes" for the distinction.
|
|
166
|
+
step_context.step_locals_revision += 1
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
"target_path": target_path,
|
|
170
|
+
"step_locals_revision": step_context.step_locals_revision,
|
|
171
|
+
"updates": [{"path": target_path, "value": to_jsonable_value(value)}],
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _resolve_value_for_assignment(step_context: StepContext, expression: str) -> object:
|
|
176
|
+
try:
|
|
177
|
+
return eval_expression(step_context, expression)
|
|
178
|
+
except Exception as e:
|
|
179
|
+
_raise_execution(
|
|
180
|
+
message=str(e),
|
|
181
|
+
guidance="Fix the expression and retry.",
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
async def _resolve_value_for_assignment_async(step_context: StepContext, expression: str) -> object:
|
|
186
|
+
try:
|
|
187
|
+
return await eval_expression_async(step_context, expression)
|
|
188
|
+
except Exception as e:
|
|
189
|
+
_raise_execution(
|
|
190
|
+
message=str(e),
|
|
191
|
+
guidance="Fix the expression and retry.",
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _validated_target_path(target_path: str) -> tuple[str, ...]:
|
|
196
|
+
parsed = parse_identifier_path(target_path)
|
|
197
|
+
if parsed is None:
|
|
198
|
+
_raise_invalid_input(
|
|
199
|
+
message="Invalid target_path; expected name(.field)* with ASCII identifiers",
|
|
200
|
+
guidance="Fix target_path to match name(.field)* with ASCII identifiers and retry.",
|
|
201
|
+
)
|
|
202
|
+
return parsed
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def assign_tool(
|
|
206
|
+
step_context: StepContext,
|
|
207
|
+
target_path: str,
|
|
208
|
+
expression: str,
|
|
209
|
+
) -> dict[str, Any]:
|
|
210
|
+
"""Assign a computed value to a dotted target_path.
|
|
211
|
+
|
|
212
|
+
Target grammar:
|
|
213
|
+
- target_path := name ("." field)*
|
|
214
|
+
|
|
215
|
+
Notes:
|
|
216
|
+
- Any segment starting with "__" is forbidden.
|
|
217
|
+
- On success, returns a JSON-serializable payload with keys `target_path`, `step_locals_revision`, and `updates`.
|
|
218
|
+
- On failure, raises ToolBoundaryError.
|
|
219
|
+
- The operation is atomic: on any failure, no updates are performed.
|
|
220
|
+
"""
|
|
221
|
+
parsed_target_path = _validated_target_path(target_path)
|
|
222
|
+
value = _resolve_value_for_assignment(step_context, expression)
|
|
223
|
+
|
|
224
|
+
return _assign_value_to_target_path(
|
|
225
|
+
step_context=step_context,
|
|
226
|
+
target_path=target_path,
|
|
227
|
+
parsed_target_path=parsed_target_path,
|
|
228
|
+
value=value,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
async def assign_tool_async(
|
|
233
|
+
step_context: StepContext,
|
|
234
|
+
target_path: str,
|
|
235
|
+
expression: str,
|
|
236
|
+
) -> dict[str, Any]:
|
|
237
|
+
"""Async version of assign_tool."""
|
|
238
|
+
parsed_target_path = _validated_target_path(target_path)
|
|
239
|
+
value = await _resolve_value_for_assignment_async(step_context, expression)
|
|
240
|
+
|
|
241
|
+
return _assign_value_to_target_path(
|
|
242
|
+
step_context=step_context,
|
|
243
|
+
target_path=target_path,
|
|
244
|
+
parsed_target_path=parsed_target_path,
|
|
245
|
+
value=value,
|
|
246
|
+
)
|