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.
@@ -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)