metaflow-durability 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.
- metaflow_durability/__init__.py +21 -0
- metaflow_durability/_version.py +24 -0
- metaflow_durability/agent_decorator.py +346 -0
- metaflow_durability/dispatch.py +155 -0
- metaflow_durability/durability.py +115 -0
- metaflow_durability/journal.py +177 -0
- metaflow_durability/journal_card.py +262 -0
- metaflow_durability/journal_decorator.py +158 -0
- metaflow_durability-0.1.0.dist-info/METADATA +249 -0
- metaflow_durability-0.1.0.dist-info/RECORD +14 -0
- metaflow_durability-0.1.0.dist-info/WHEEL +4 -0
- metaflow_extensions/anaconda/config/mfextinit_anaconda.py +7 -0
- metaflow_extensions/anaconda/plugins/cards/__init__.py +183 -0
- metaflow_extensions/anaconda/plugins/mfextinit_anaconda.py +19 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from typing import TYPE_CHECKING
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
try:
|
|
5
|
+
from metaflow_durability._version import version as __version__
|
|
6
|
+
except ImportError:
|
|
7
|
+
__version__ = "unknown"
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from metaflow_durability.durability import MetaflowDurability
|
|
11
|
+
|
|
12
|
+
__all__ = ["MetaflowDurability"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def __getattr__(name: str) -> Any:
|
|
16
|
+
# Lazy so that importing metaflow_durability does not require pydantic-ai.
|
|
17
|
+
if name == "MetaflowDurability":
|
|
18
|
+
from metaflow_durability.durability import MetaflowDurability
|
|
19
|
+
|
|
20
|
+
return MetaflowDurability
|
|
21
|
+
raise AttributeError(f"module 'metaflow_durability' has no attribute {name!r}")
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.1.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 1, 0)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
"""AgentDecorator — Metaflow StepDecorator for the @agent pattern.
|
|
2
|
+
|
|
3
|
+
Runs the full LLM loop within one Metaflow step. Tools are user-provided
|
|
4
|
+
callables; schemas are auto-generated via openai-function-calling.
|
|
5
|
+
|
|
6
|
+
LLM resolution (in priority order):
|
|
7
|
+
1. ``client=`` parameter — user provides an OpenAI-compatible client
|
|
8
|
+
2. Co-stacked inference decorator (``@llamacpp``, ``@vllm``) — detected
|
|
9
|
+
at runtime via the step's decorator list
|
|
10
|
+
3. ``model=`` parameter — passed to litellm with any extra kwargs
|
|
11
|
+
|
|
12
|
+
This module is imported lazily by Metaflow's extension loader. Users
|
|
13
|
+
access the decorator via ``from metaflow import agent``.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
from collections.abc import Callable
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from metaflow.decorators import StepDecorator
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _build_tool_schemas(
|
|
26
|
+
tools: list[Callable[..., Any]],
|
|
27
|
+
) -> list[dict[str, Any]]:
|
|
28
|
+
"""Generate OpenAI function-calling schemas from typed callables."""
|
|
29
|
+
from openai_function_calling import FunctionInferrer
|
|
30
|
+
|
|
31
|
+
schemas: list[dict[str, Any]] = []
|
|
32
|
+
for tool in tools:
|
|
33
|
+
if hasattr(tool, "schema"):
|
|
34
|
+
schemas.append(tool.schema)
|
|
35
|
+
else:
|
|
36
|
+
inferred = FunctionInferrer.infer_from_function_reference(tool)
|
|
37
|
+
schemas.append({"type": "function", "function": inferred.to_json_schema()})
|
|
38
|
+
return schemas
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _build_tool_registry(
|
|
42
|
+
tools: list[Callable[..., Any]],
|
|
43
|
+
) -> dict[str, Callable[..., Any]]:
|
|
44
|
+
"""Map function names to callables for dispatch."""
|
|
45
|
+
return {tool.__name__: tool for tool in tools}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _resolve_completion_fn(
|
|
49
|
+
attributes: dict[str, Any],
|
|
50
|
+
step_decorators: list[Any],
|
|
51
|
+
) -> Callable[..., Any]:
|
|
52
|
+
"""Resolve the LLM completion function from available sources.
|
|
53
|
+
|
|
54
|
+
Priority:
|
|
55
|
+
1. Explicit client object → client.chat.completions.create
|
|
56
|
+
2. Co-stacked inference decorator → decorator's OpenAI client
|
|
57
|
+
3. litellm model string → litellm.completion with kwargs
|
|
58
|
+
"""
|
|
59
|
+
client = attributes.get("client")
|
|
60
|
+
if client is not None:
|
|
61
|
+
|
|
62
|
+
def _client_completion(
|
|
63
|
+
messages: list[dict[str, Any]],
|
|
64
|
+
tools: list[dict[str, Any]] | None = None,
|
|
65
|
+
**kwargs: Any,
|
|
66
|
+
) -> Any:
|
|
67
|
+
call_kwargs: dict[str, Any] = {"messages": messages}
|
|
68
|
+
if tools:
|
|
69
|
+
call_kwargs["tools"] = tools
|
|
70
|
+
return client.chat.completions.create(**call_kwargs)
|
|
71
|
+
|
|
72
|
+
return _client_completion
|
|
73
|
+
|
|
74
|
+
# Check for co-stacked inference decorators (@llamacpp, @vllm)
|
|
75
|
+
for deco in step_decorators:
|
|
76
|
+
if hasattr(deco, "get_openai_client"):
|
|
77
|
+
infer_client = deco.get_openai_client()
|
|
78
|
+
|
|
79
|
+
def _infer_completion(
|
|
80
|
+
messages: list[dict[str, Any]],
|
|
81
|
+
tools: list[dict[str, Any]] | None = None,
|
|
82
|
+
_c: Any = infer_client,
|
|
83
|
+
**kwargs: Any,
|
|
84
|
+
) -> Any:
|
|
85
|
+
call_kwargs: dict[str, Any] = {"messages": messages}
|
|
86
|
+
if tools:
|
|
87
|
+
call_kwargs["tools"] = tools
|
|
88
|
+
return _c.chat.completions.create(**call_kwargs)
|
|
89
|
+
|
|
90
|
+
return _infer_completion
|
|
91
|
+
|
|
92
|
+
model = attributes.get("model")
|
|
93
|
+
if model is not None:
|
|
94
|
+
import litellm
|
|
95
|
+
|
|
96
|
+
litellm.suppress_debug_info = True
|
|
97
|
+
litellm_kwargs = dict(attributes.get("litellm_kwargs") or {})
|
|
98
|
+
|
|
99
|
+
def _litellm_completion(
|
|
100
|
+
messages: list[dict[str, Any]],
|
|
101
|
+
tools: list[dict[str, Any]] | None = None,
|
|
102
|
+
**kwargs: Any,
|
|
103
|
+
) -> Any:
|
|
104
|
+
call_kwargs: dict[str, Any] = {
|
|
105
|
+
"model": model,
|
|
106
|
+
"messages": messages,
|
|
107
|
+
**litellm_kwargs,
|
|
108
|
+
}
|
|
109
|
+
if tools:
|
|
110
|
+
call_kwargs["tools"] = tools
|
|
111
|
+
return litellm.completion(**call_kwargs)
|
|
112
|
+
|
|
113
|
+
return _litellm_completion
|
|
114
|
+
|
|
115
|
+
raise ValueError(
|
|
116
|
+
"@agent requires one of: model= (litellm string), client= (OpenAI-compatible), "
|
|
117
|
+
"or a co-stacked inference decorator (@llamacpp, @vllm)."
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _run_agent_loop(
|
|
122
|
+
flow: Any,
|
|
123
|
+
completion_fn: Callable[..., Any],
|
|
124
|
+
system_prompt: str,
|
|
125
|
+
tool_schemas: list[dict[str, Any]],
|
|
126
|
+
tool_registry: dict[str, Callable[..., Any]],
|
|
127
|
+
max_iterations: int,
|
|
128
|
+
) -> None:
|
|
129
|
+
"""Run the full agent loop: LLM → tools → LLM → ... → done."""
|
|
130
|
+
from metaflow_durability.dispatch import InProcessExecutor
|
|
131
|
+
|
|
132
|
+
messages: list[dict[str, Any]] = list(flow.messages)
|
|
133
|
+
all_dispatched: list[dict[str, Any]] = list(getattr(flow, "dispatched_tools", []))
|
|
134
|
+
|
|
135
|
+
if not messages or messages[0].get("role") != "system":
|
|
136
|
+
messages = [{"role": "system", "content": system_prompt}, *messages]
|
|
137
|
+
|
|
138
|
+
executor = InProcessExecutor(tool_registry)
|
|
139
|
+
|
|
140
|
+
for iteration in range(max_iterations):
|
|
141
|
+
response = completion_fn(
|
|
142
|
+
messages=messages,
|
|
143
|
+
tools=tool_schemas if tool_schemas else None,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
choice = response.choices[0]
|
|
147
|
+
message = choice.message
|
|
148
|
+
|
|
149
|
+
assistant_msg: dict[str, Any] = {"role": "assistant"}
|
|
150
|
+
if message.content:
|
|
151
|
+
assistant_msg["content"] = message.content
|
|
152
|
+
if message.tool_calls:
|
|
153
|
+
assistant_msg["tool_calls"] = [
|
|
154
|
+
{
|
|
155
|
+
"id": tc.id,
|
|
156
|
+
"type": "function",
|
|
157
|
+
"function": {
|
|
158
|
+
"name": tc.function.name,
|
|
159
|
+
"arguments": tc.function.arguments,
|
|
160
|
+
},
|
|
161
|
+
}
|
|
162
|
+
for tc in message.tool_calls
|
|
163
|
+
]
|
|
164
|
+
|
|
165
|
+
messages = [*messages, assistant_msg]
|
|
166
|
+
|
|
167
|
+
if not message.tool_calls:
|
|
168
|
+
flow.messages = messages
|
|
169
|
+
flow.final_response = message.content
|
|
170
|
+
flow.iteration = iteration
|
|
171
|
+
flow.dispatched_tools = all_dispatched
|
|
172
|
+
return
|
|
173
|
+
|
|
174
|
+
parsed_calls: list[dict[str, Any]] = []
|
|
175
|
+
for tc in message.tool_calls:
|
|
176
|
+
try:
|
|
177
|
+
args = json.loads(tc.function.arguments)
|
|
178
|
+
except (json.JSONDecodeError, TypeError):
|
|
179
|
+
args = {}
|
|
180
|
+
|
|
181
|
+
parsed_calls.append(
|
|
182
|
+
{
|
|
183
|
+
"id": tc.id,
|
|
184
|
+
"name": tc.function.name,
|
|
185
|
+
"arguments": args,
|
|
186
|
+
"tier": "default",
|
|
187
|
+
}
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
dispatch_result = executor.execute(parsed_calls)
|
|
191
|
+
messages = [*messages, *dispatch_result.tool_messages]
|
|
192
|
+
all_dispatched.extend(dispatch_result.to_dicts())
|
|
193
|
+
|
|
194
|
+
tc_names = [d["tool_name"] for d in dispatch_result.to_dicts()]
|
|
195
|
+
print(f" iter {iteration}: [LLM -> {', '.join(tc_names)}]")
|
|
196
|
+
|
|
197
|
+
flow.messages = messages
|
|
198
|
+
flow.final_response = None
|
|
199
|
+
flow.iteration = max_iterations
|
|
200
|
+
flow.dispatched_tools = all_dispatched
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class AgentDecorator(StepDecorator):
|
|
204
|
+
"""Metaflow step decorator that runs the full agent loop within one step.
|
|
205
|
+
|
|
206
|
+
LLM source (pick one):
|
|
207
|
+
- ``model`` — litellm model string (e.g. "anthropic/claude-sonnet-4")
|
|
208
|
+
- ``client`` — pre-configured OpenAI-compatible client object
|
|
209
|
+
- neither — auto-detect from co-stacked @llamacpp / @vllm decorator
|
|
210
|
+
|
|
211
|
+
For litellm, pass provider-specific kwargs via ``litellm_kwargs``::
|
|
212
|
+
|
|
213
|
+
@agent(
|
|
214
|
+
model="bedrock/us.anthropic.claude-sonnet-4-20250514-v1:0",
|
|
215
|
+
litellm_kwargs={"aws_region_name": "us-west-2"},
|
|
216
|
+
tools=[search],
|
|
217
|
+
)
|
|
218
|
+
"""
|
|
219
|
+
|
|
220
|
+
name = "agent"
|
|
221
|
+
|
|
222
|
+
defaults: dict[str, Any] = {
|
|
223
|
+
"model": None,
|
|
224
|
+
"client": None,
|
|
225
|
+
"system_prompt": "You are a helpful assistant with access to tools.",
|
|
226
|
+
"tools": None,
|
|
227
|
+
"tool_schemas": None,
|
|
228
|
+
"litellm_kwargs": None,
|
|
229
|
+
"max_iterations": 10,
|
|
230
|
+
} # type: ignore[assignment]
|
|
231
|
+
|
|
232
|
+
_tool_schemas: list[dict[str, Any]] = []
|
|
233
|
+
_tool_registry: dict[str, Callable[..., Any]] = {}
|
|
234
|
+
_step_decorators: list[Any] = []
|
|
235
|
+
|
|
236
|
+
def step_init(
|
|
237
|
+
self,
|
|
238
|
+
flow,
|
|
239
|
+
graph,
|
|
240
|
+
step_name,
|
|
241
|
+
decorators,
|
|
242
|
+
environment,
|
|
243
|
+
flow_datastore,
|
|
244
|
+
logger,
|
|
245
|
+
):
|
|
246
|
+
"""Resolve tool schemas/registry and capture sibling decorators."""
|
|
247
|
+
tools = self.attributes.get("tools")
|
|
248
|
+
tool_schemas = self.attributes.get("tool_schemas")
|
|
249
|
+
|
|
250
|
+
if tools:
|
|
251
|
+
self._tool_schemas = (
|
|
252
|
+
tool_schemas if tool_schemas else _build_tool_schemas(tools)
|
|
253
|
+
)
|
|
254
|
+
self._tool_registry = _build_tool_registry(tools)
|
|
255
|
+
elif tool_schemas:
|
|
256
|
+
self._tool_schemas = tool_schemas
|
|
257
|
+
self._tool_registry = {}
|
|
258
|
+
else:
|
|
259
|
+
self._tool_schemas = []
|
|
260
|
+
self._tool_registry = {}
|
|
261
|
+
|
|
262
|
+
self._step_decorators = list(decorators)
|
|
263
|
+
|
|
264
|
+
# Auto-attach @card(type="agent") if not already present
|
|
265
|
+
try:
|
|
266
|
+
from metaflow.plugins.cards.card_decorator import CardDecorator
|
|
267
|
+
|
|
268
|
+
has_agent_card = any(
|
|
269
|
+
isinstance(d, CardDecorator) and d.attributes.get("type") == "agent"
|
|
270
|
+
for d in decorators
|
|
271
|
+
)
|
|
272
|
+
if not has_agent_card:
|
|
273
|
+
card_attrs = CardDecorator.defaults.copy()
|
|
274
|
+
card_attrs["type"] = "agent"
|
|
275
|
+
card_attrs["id"] = "agent"
|
|
276
|
+
card_deco = CardDecorator(attributes=card_attrs)
|
|
277
|
+
decorators.append(card_deco)
|
|
278
|
+
card_deco.step_init(
|
|
279
|
+
flow,
|
|
280
|
+
graph,
|
|
281
|
+
step_name,
|
|
282
|
+
decorators,
|
|
283
|
+
environment,
|
|
284
|
+
flow_datastore,
|
|
285
|
+
logger,
|
|
286
|
+
)
|
|
287
|
+
except ImportError:
|
|
288
|
+
pass
|
|
289
|
+
|
|
290
|
+
def task_pre_step(
|
|
291
|
+
self,
|
|
292
|
+
step_name,
|
|
293
|
+
task_datastore,
|
|
294
|
+
metadata,
|
|
295
|
+
run_id,
|
|
296
|
+
task_id,
|
|
297
|
+
flow,
|
|
298
|
+
graph,
|
|
299
|
+
retry_count,
|
|
300
|
+
max_user_code_retries,
|
|
301
|
+
ubf_context,
|
|
302
|
+
inputs,
|
|
303
|
+
):
|
|
304
|
+
"""Initialize artifacts before the step runs."""
|
|
305
|
+
if not hasattr(flow, "messages") or flow.messages is None:
|
|
306
|
+
flow.messages = []
|
|
307
|
+
if not hasattr(flow, "iteration"):
|
|
308
|
+
flow.iteration = 0
|
|
309
|
+
if not hasattr(flow, "dispatched_tools"):
|
|
310
|
+
flow.dispatched_tools = []
|
|
311
|
+
if not hasattr(flow, "final_response"):
|
|
312
|
+
flow.final_response = None
|
|
313
|
+
|
|
314
|
+
def task_decorate(
|
|
315
|
+
self,
|
|
316
|
+
step_func,
|
|
317
|
+
flow,
|
|
318
|
+
graph,
|
|
319
|
+
retry_count,
|
|
320
|
+
max_user_code_retries,
|
|
321
|
+
ubf_context,
|
|
322
|
+
):
|
|
323
|
+
"""Wrap step to run the full agent loop, then the user's step body."""
|
|
324
|
+
|
|
325
|
+
def _wrapped_step():
|
|
326
|
+
step_func()
|
|
327
|
+
completion_fn = _resolve_completion_fn(
|
|
328
|
+
self.attributes, self._step_decorators
|
|
329
|
+
)
|
|
330
|
+
_run_agent_loop(
|
|
331
|
+
flow=flow,
|
|
332
|
+
completion_fn=completion_fn,
|
|
333
|
+
system_prompt=self.attributes["system_prompt"],
|
|
334
|
+
tool_schemas=self._tool_schemas,
|
|
335
|
+
tool_registry=self._tool_registry,
|
|
336
|
+
max_iterations=self.attributes["max_iterations"],
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
return _wrapped_step
|
|
340
|
+
|
|
341
|
+
def task_post_step(
|
|
342
|
+
self, step_name, flow, graph, retry_count, max_user_code_retries
|
|
343
|
+
):
|
|
344
|
+
"""Ensure dispatched_tools artifact exists."""
|
|
345
|
+
if not hasattr(flow, "dispatched_tools"):
|
|
346
|
+
flow.dispatched_tools = []
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Dispatch records — structured artifacts for tool execution within agent steps.
|
|
2
|
+
|
|
3
|
+
Each tool call dispatched by the @agent decorator produces a DispatchRecord.
|
|
4
|
+
Records are collected per-iteration as self.dispatched_tools on the agent step's
|
|
5
|
+
task, making them queryable via the Client API without adding graph nodes.
|
|
6
|
+
|
|
7
|
+
The in-process executor runs tools sequentially in the same Python process.
|
|
8
|
+
The interface is designed so that swapping in Runner-based dispatch (child flow
|
|
9
|
+
runs) or native self.dispatch() (graph sub-tasks) requires changing only the
|
|
10
|
+
executor, not the record format.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import time
|
|
17
|
+
from collections.abc import Callable
|
|
18
|
+
from dataclasses import asdict
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from dataclasses import field
|
|
21
|
+
from typing import Any
|
|
22
|
+
from typing import Protocol
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class DispatchRecord:
|
|
27
|
+
"""One tool execution's full record.
|
|
28
|
+
|
|
29
|
+
Stored as a list on self.dispatched_tools per iteration. Designed to be
|
|
30
|
+
stable across executor backends: in-process, Runner, or graph-node.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
tool_call_id: str
|
|
34
|
+
tool_name: str
|
|
35
|
+
arguments: dict[str, Any]
|
|
36
|
+
tier: str
|
|
37
|
+
result: str
|
|
38
|
+
started_at: float
|
|
39
|
+
finished_at: float
|
|
40
|
+
error: str | None = None
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def duration_ms(self) -> float:
|
|
44
|
+
return (self.finished_at - self.started_at) * 1000
|
|
45
|
+
|
|
46
|
+
def to_dict(self) -> dict[str, Any]:
|
|
47
|
+
d = asdict(self)
|
|
48
|
+
d["duration_ms"] = self.duration_ms
|
|
49
|
+
return d
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def from_dict(cls, d: dict[str, Any]) -> DispatchRecord:
|
|
53
|
+
d = dict(d)
|
|
54
|
+
d.pop("duration_ms", None)
|
|
55
|
+
return cls(**d)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class DispatchResult:
|
|
60
|
+
"""Aggregated result of dispatching all tool calls in one iteration.
|
|
61
|
+
|
|
62
|
+
Contains the individual records plus the tool messages ready to append
|
|
63
|
+
to the conversation history.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
records: list[DispatchRecord] = field(default_factory=list)
|
|
67
|
+
tool_messages: list[dict[str, Any]] = field(default_factory=list)
|
|
68
|
+
|
|
69
|
+
def to_dicts(self) -> list[dict[str, Any]]:
|
|
70
|
+
return [r.to_dict() for r in self.records]
|
|
71
|
+
|
|
72
|
+
@classmethod
|
|
73
|
+
def from_dicts(cls, dicts: list[dict[str, Any]]) -> DispatchResult:
|
|
74
|
+
records = [DispatchRecord.from_dict(d) for d in dicts]
|
|
75
|
+
return cls(records=records)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class ToolExecutor(Protocol):
|
|
79
|
+
"""Protocol for tool execution backends.
|
|
80
|
+
|
|
81
|
+
In-process, Runner, and eventual graph-node executors all implement this.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
def execute(
|
|
85
|
+
self,
|
|
86
|
+
tool_calls: list[dict[str, Any]],
|
|
87
|
+
) -> DispatchResult: ...
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class InProcessExecutor:
|
|
91
|
+
"""Execute tool calls sequentially in the current process.
|
|
92
|
+
|
|
93
|
+
Tools are plain callables. The executor calls each with ``**arguments``
|
|
94
|
+
from the LLM's tool_call and converts the return value to a string
|
|
95
|
+
for the tool message.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
def __init__(self, tool_registry: dict[str, Callable[..., Any]]) -> None:
|
|
99
|
+
self._registry = tool_registry
|
|
100
|
+
|
|
101
|
+
def execute(
|
|
102
|
+
self,
|
|
103
|
+
tool_calls: list[dict[str, Any]],
|
|
104
|
+
) -> DispatchResult:
|
|
105
|
+
records: list[DispatchRecord] = []
|
|
106
|
+
tool_messages: list[dict[str, Any]] = []
|
|
107
|
+
|
|
108
|
+
for tc in tool_calls:
|
|
109
|
+
tool_name = tc["name"]
|
|
110
|
+
arguments = tc["arguments"]
|
|
111
|
+
tool_call_id = tc["id"]
|
|
112
|
+
tier = tc.get("tier", "default")
|
|
113
|
+
|
|
114
|
+
started_at = time.monotonic()
|
|
115
|
+
error: str | None = None
|
|
116
|
+
result_str: str
|
|
117
|
+
|
|
118
|
+
fn = self._registry.get(tool_name)
|
|
119
|
+
if fn is None:
|
|
120
|
+
result_str = f"Unknown tool: {tool_name}"
|
|
121
|
+
error = result_str
|
|
122
|
+
else:
|
|
123
|
+
try:
|
|
124
|
+
result = fn(**arguments)
|
|
125
|
+
result_str = (
|
|
126
|
+
result if isinstance(result, str) else json.dumps(result)
|
|
127
|
+
)
|
|
128
|
+
except Exception as exc:
|
|
129
|
+
result_str = f"Error executing {tool_name}: {exc}"
|
|
130
|
+
error = str(exc)
|
|
131
|
+
|
|
132
|
+
finished_at = time.monotonic()
|
|
133
|
+
|
|
134
|
+
records.append(
|
|
135
|
+
DispatchRecord(
|
|
136
|
+
tool_call_id=tool_call_id,
|
|
137
|
+
tool_name=tool_name,
|
|
138
|
+
arguments=arguments,
|
|
139
|
+
tier=tier,
|
|
140
|
+
result=result_str,
|
|
141
|
+
started_at=started_at,
|
|
142
|
+
finished_at=finished_at,
|
|
143
|
+
error=error,
|
|
144
|
+
)
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
tool_messages.append(
|
|
148
|
+
{
|
|
149
|
+
"role": "tool",
|
|
150
|
+
"tool_call_id": tool_call_id,
|
|
151
|
+
"content": result_str,
|
|
152
|
+
}
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
return DispatchResult(records=records, tool_messages=tool_messages)
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""pydantic-ai durable execution backend for Metaflow.
|
|
2
|
+
|
|
3
|
+
Attach `MetaflowDurability()` to a pydantic-ai `Agent`. Inside a running Metaflow task,
|
|
4
|
+
every durable operation (model request, tool call, tool discovery, ...) is journaled to
|
|
5
|
+
the task's `agent_journal` artifact. On `@retry`, operations recorded by the previous
|
|
6
|
+
attempt are replayed from the journal instead of re-executed. Outside a Metaflow task
|
|
7
|
+
the capability is transparent.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import time
|
|
13
|
+
from collections.abc import Awaitable
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from pydantic_ai.durable_exec import JSON_CODEC
|
|
18
|
+
from pydantic_ai.durable_exec import BaseDurabilityCapability
|
|
19
|
+
from pydantic_ai.durable_exec import DurabilityEngineSpec
|
|
20
|
+
from pydantic_ai.durable_exec import DurableOperationId
|
|
21
|
+
from pydantic_ai.durable_exec import JournalCallableOperationBackend
|
|
22
|
+
from pydantic_ai.durable_exec import RoleBasedOperationConfig
|
|
23
|
+
|
|
24
|
+
from metaflow_durability import journal
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class MetaflowSerializationError(Exception):
|
|
28
|
+
"""A durable operation's result could not be encoded for the journal."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class _MetaflowBackend(JournalCallableOperationBackend[None]):
|
|
32
|
+
def __init__(self, agent_name: str, default_model_id: str | None) -> None:
|
|
33
|
+
super().__init__(
|
|
34
|
+
agent_name=agent_name,
|
|
35
|
+
default_model_id=default_model_id,
|
|
36
|
+
config=RoleBasedOperationConfig(
|
|
37
|
+
model=None, event=None, capability=None, tool=None
|
|
38
|
+
),
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
async def execute(
|
|
42
|
+
self,
|
|
43
|
+
*,
|
|
44
|
+
operation_id: DurableOperationId,
|
|
45
|
+
name: str,
|
|
46
|
+
body: Callable[[], Awaitable[object]],
|
|
47
|
+
cache_key: tuple[object, ...],
|
|
48
|
+
config: None,
|
|
49
|
+
) -> object:
|
|
50
|
+
run = journal.current_run.get()
|
|
51
|
+
if run is None:
|
|
52
|
+
return await body()
|
|
53
|
+
kind = type(operation_id).__name__
|
|
54
|
+
input_hash = journal.input_hash(cache_key)
|
|
55
|
+
prior = run.lookup(name, input_hash)
|
|
56
|
+
if prior is not None:
|
|
57
|
+
run.record(
|
|
58
|
+
journal.JournalEntry(
|
|
59
|
+
name=name,
|
|
60
|
+
kind=kind,
|
|
61
|
+
input_hash=input_hash,
|
|
62
|
+
payload=prior["payload"],
|
|
63
|
+
duration_ms=prior["duration_ms"],
|
|
64
|
+
replayed=True,
|
|
65
|
+
)
|
|
66
|
+
)
|
|
67
|
+
return prior["payload"]
|
|
68
|
+
started = time.monotonic()
|
|
69
|
+
payload = await body()
|
|
70
|
+
run.record(
|
|
71
|
+
journal.JournalEntry(
|
|
72
|
+
name=name,
|
|
73
|
+
kind=kind,
|
|
74
|
+
input_hash=input_hash,
|
|
75
|
+
payload=payload,
|
|
76
|
+
duration_ms=(time.monotonic() - started) * 1000,
|
|
77
|
+
replayed=False,
|
|
78
|
+
)
|
|
79
|
+
)
|
|
80
|
+
return payload
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class MetaflowDurability(BaseDurabilityCapability[Any]):
|
|
84
|
+
"""Journal and replay a pydantic-ai agent's operations inside a Metaflow step."""
|
|
85
|
+
|
|
86
|
+
engine_spec = DurabilityEngineSpec(
|
|
87
|
+
engine_name="Metaflow",
|
|
88
|
+
durable_unit_noun="operation",
|
|
89
|
+
durable_container_noun="task",
|
|
90
|
+
codec=JSON_CODEC,
|
|
91
|
+
serialization_failure=lambda exc: MetaflowSerializationError(str(exc)),
|
|
92
|
+
toolset_lifecycles={
|
|
93
|
+
"function": "enter-always",
|
|
94
|
+
"mcp": "enter-in-durable-unit",
|
|
95
|
+
"dynamic": "enter-never",
|
|
96
|
+
},
|
|
97
|
+
sequential_tools_in_durable_context=True,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def in_durable_context(self) -> bool:
|
|
102
|
+
return journal.active() is not None
|
|
103
|
+
|
|
104
|
+
def get_durable_operation_backend(self) -> _MetaflowBackend:
|
|
105
|
+
return _MetaflowBackend(self.name, self.default_model_id)
|
|
106
|
+
|
|
107
|
+
async def wrap_run(self, ctx: Any, *, handler: Any) -> Any:
|
|
108
|
+
task_journal = journal.active()
|
|
109
|
+
if task_journal is None:
|
|
110
|
+
return await super().wrap_run(ctx, handler=handler)
|
|
111
|
+
token = journal.current_run.set(task_journal.start_run(self.name, self.agent))
|
|
112
|
+
try:
|
|
113
|
+
return await super().wrap_run(ctx, handler=handler)
|
|
114
|
+
finally:
|
|
115
|
+
journal.current_run.reset(token)
|