flyteplugins-agents-deepagents 2.5.15__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.
- flyteplugins/agents/deepagents/__init__.py +27 -0
- flyteplugins/agents/deepagents/_durable.py +133 -0
- flyteplugins/agents/deepagents/_memory.py +93 -0
- flyteplugins/agents/deepagents/_run.py +207 -0
- flyteplugins/agents/deepagents/_tools.py +176 -0
- flyteplugins_agents_deepagents-2.5.15.dist-info/METADATA +64 -0
- flyteplugins_agents_deepagents-2.5.15.dist-info/RECORD +9 -0
- flyteplugins_agents_deepagents-2.5.15.dist-info/WHEEL +5 -0
- flyteplugins_agents_deepagents-2.5.15.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Deep Agents adapter for Flyte.
|
|
2
|
+
|
|
3
|
+
Bring your own `Deep Agent <https://docs.langchain.com/oss/python/deepagents/overview>`_
|
|
4
|
+
— LangChain's agent harness with built-in planning, a virtual filesystem, and
|
|
5
|
+
subagents — and run it durably on Flyte. The adapter provides:
|
|
6
|
+
|
|
7
|
+
- :func:`tool` — turn a Flyte ``@env.task`` into a LangChain ``StructuredTool``
|
|
8
|
+
(a ``BaseTool``) that executes as a durable child action (own container/GPU,
|
|
9
|
+
retries, caching). Attach it to the main agent or to a subagent.
|
|
10
|
+
- :func:`run_agent` — run the deep agent (a compiled ``create_deep_agent``
|
|
11
|
+
graph) inside your task and return the final answer. Either pass a pre-built
|
|
12
|
+
``agent`` or let it build one from ``tools`` + ``model`` + ``instructions``
|
|
13
|
+
(Deep-Agents options like ``subagents=`` pass through).
|
|
14
|
+
- :class:`DurableChatModel` — wrap any LangChain chat model so each model turn
|
|
15
|
+
is recorded/replayed via ``flyte.trace``; use it when building your own agent
|
|
16
|
+
with ``create_deep_agent(model=DurableChatModel(inner=...))``.
|
|
17
|
+
|
|
18
|
+
Each tool call runs as a durable Flyte child action, and the run timeline is
|
|
19
|
+
rendered into the Flyte task report. ``memory_key`` persists the conversation
|
|
20
|
+
*and* the agent's virtual filesystem across runs.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from ._durable import DurableChatModel
|
|
24
|
+
from ._run import run_agent, run_agent_sync
|
|
25
|
+
from ._tools import tool
|
|
26
|
+
|
|
27
|
+
__all__ = ["DurableChatModel", "run_agent", "run_agent_sync", "tool"]
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Durable, replayable model turns for Deep Agents chat models.
|
|
2
|
+
|
|
3
|
+
A deep agent (``create_deep_agent``) is a compiled LangGraph graph whose loop
|
|
4
|
+
calls the chat model once per iteration (a "model turn").
|
|
5
|
+
:class:`DurableChatModel` wraps any LangChain ``BaseChatModel`` so every turn is
|
|
6
|
+
recorded through the shared :func:`~flyteplugins.agents.core.durable_step` (a
|
|
7
|
+
``flyte.trace`` leaf). Inside a Flyte task this means a crashed/retried run
|
|
8
|
+
replays completed turns from their recorded outputs instead of re-calling (and
|
|
9
|
+
re-billing) the model. Tool calls run as durable child actions (see
|
|
10
|
+
:func:`flyteplugins.agents.deepagents.tool`), so the whole agent run becomes
|
|
11
|
+
crash-resilient when the enclosing task carries ``retries=...``.
|
|
12
|
+
|
|
13
|
+
The turn is recorded as JSON: the generated messages of the model's
|
|
14
|
+
``ChatResult`` are serialized with ``message_to_dict`` and rebuilt with
|
|
15
|
+
``messages_from_dict``, which keeps the recorded turn human-readable in the
|
|
16
|
+
Flyte UI.
|
|
17
|
+
|
|
18
|
+
Tool-calling still works because :meth:`DurableChatModel.bind_tools` delegates to
|
|
19
|
+
the inner model to format the tools, then re-binds the resulting kwargs to *this*
|
|
20
|
+
wrapper — so the deep agent's bound runnable still routes generation through the
|
|
21
|
+
durable override. This also covers subagents: pass the wrapped model as a
|
|
22
|
+
``SubAgent``'s ``model`` and its turns are durable too.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
import typing
|
|
29
|
+
|
|
30
|
+
from flyteplugins.agents.core import durable_step, fingerprint
|
|
31
|
+
from langchain_core.language_models.chat_models import BaseChatModel
|
|
32
|
+
|
|
33
|
+
if typing.TYPE_CHECKING:
|
|
34
|
+
from langchain_core.messages import BaseMessage
|
|
35
|
+
from langchain_core.outputs import ChatResult
|
|
36
|
+
from langchain_core.runnables import Runnable
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _dumps_result(result: "ChatResult") -> str:
|
|
40
|
+
"""Serialize a ``ChatResult``'s generated messages to JSON (readable in the UI)."""
|
|
41
|
+
from langchain_core.messages import message_to_dict
|
|
42
|
+
|
|
43
|
+
return json.dumps([message_to_dict(gen.message) for gen in result.generations])
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _loads_result(payload: str) -> "ChatResult":
|
|
47
|
+
"""Rebuild a ``ChatResult`` from the JSON written by :func:`_dumps_result`."""
|
|
48
|
+
from langchain_core.messages import messages_from_dict
|
|
49
|
+
from langchain_core.outputs import ChatGeneration, ChatResult
|
|
50
|
+
|
|
51
|
+
messages = messages_from_dict(json.loads(payload))
|
|
52
|
+
return ChatResult(generations=[ChatGeneration(message=m) for m in messages])
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class DurableChatModel(BaseChatModel):
|
|
56
|
+
"""Wrap a ``BaseChatModel`` so each model turn is durable and replayable.
|
|
57
|
+
|
|
58
|
+
``_agenerate`` (async) delegates to the inner model and records the turn via
|
|
59
|
+
``durable_step``. Pass an instance as the deep agent's model —
|
|
60
|
+
``create_deep_agent(model=DurableChatModel(inner=model), ...)`` — or as a
|
|
61
|
+
subagent's ``model``; ``bind_tools`` and other capabilities are delegated to
|
|
62
|
+
the inner model so tool-calling behaves exactly as the inner model does.
|
|
63
|
+
|
|
64
|
+
Durability is best-effort: if anything in the durable path raises, the turn
|
|
65
|
+
falls back to a direct inner call so a run is never broken by it.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
inner: BaseChatModel
|
|
69
|
+
"""The wrapped chat model that actually generates responses."""
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def _llm_type(self) -> str:
|
|
73
|
+
return f"durable-{self.inner._llm_type}"
|
|
74
|
+
|
|
75
|
+
def _turn_key(self, messages: typing.Sequence["BaseMessage"], **kwargs: typing.Any) -> str:
|
|
76
|
+
"""Deterministic memo key for a model turn — serialized messages + bound tool names."""
|
|
77
|
+
from langchain_core.messages import messages_to_dict
|
|
78
|
+
|
|
79
|
+
tools = kwargs.get("tools") or []
|
|
80
|
+
tool_names = sorted(
|
|
81
|
+
(t.get("function", {}).get("name") or t.get("name") or str(t)) if isinstance(t, dict) else str(t)
|
|
82
|
+
for t in tools
|
|
83
|
+
)
|
|
84
|
+
return fingerprint(
|
|
85
|
+
{
|
|
86
|
+
"type": self._llm_type,
|
|
87
|
+
"messages": messages_to_dict(list(messages)),
|
|
88
|
+
"tools": tool_names,
|
|
89
|
+
"stop": kwargs.get("stop"),
|
|
90
|
+
}
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
async def _agenerate(self, messages, stop=None, run_manager=None, **kwargs) -> "ChatResult": # type: ignore[override]
|
|
94
|
+
async def _call() -> "ChatResult":
|
|
95
|
+
return await self.inner._agenerate(messages, stop=stop, run_manager=run_manager, **kwargs)
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
key = self._turn_key(messages, stop=stop, **kwargs)
|
|
99
|
+
return await durable_step(
|
|
100
|
+
key,
|
|
101
|
+
_call,
|
|
102
|
+
name="model_turn",
|
|
103
|
+
dumps=_dumps_result,
|
|
104
|
+
loads=_loads_result,
|
|
105
|
+
)
|
|
106
|
+
except Exception: # pragma: no cover - durability must never break a run
|
|
107
|
+
return await _call()
|
|
108
|
+
|
|
109
|
+
def _generate(self, messages, stop=None, run_manager=None, **kwargs) -> "ChatResult": # type: ignore[override]
|
|
110
|
+
# ``durable_step`` is async; the sync path delegates straight through so
|
|
111
|
+
# sync callers keep working (durability applies to the async agent loop,
|
|
112
|
+
# which is the one Flyte tasks drive).
|
|
113
|
+
return self.inner._generate(messages, stop=stop, run_manager=run_manager, **kwargs)
|
|
114
|
+
|
|
115
|
+
def bind_tools(self, tools: typing.Sequence[typing.Any], **kwargs: typing.Any) -> "Runnable":
|
|
116
|
+
"""Format tools via the inner model, but bind them to *this* wrapper.
|
|
117
|
+
|
|
118
|
+
The inner model knows how to convert tools into its provider format; we
|
|
119
|
+
reuse that, then re-bind the resulting kwargs to ``self`` so the runnable
|
|
120
|
+
the deep agent invokes still routes generation through the durable
|
|
121
|
+
override (rather than the inner model directly).
|
|
122
|
+
"""
|
|
123
|
+
bound = self.inner.bind_tools(tools, **kwargs)
|
|
124
|
+
bound_kwargs = dict(getattr(bound, "kwargs", {}) or {})
|
|
125
|
+
return self.bind(**bound_kwargs)
|
|
126
|
+
|
|
127
|
+
def get_num_tokens(self, text: str) -> int:
|
|
128
|
+
return self.inner.get_num_tokens(text)
|
|
129
|
+
|
|
130
|
+
def get_num_tokens_from_messages(self, messages, tools=None) -> int: # type: ignore[override]
|
|
131
|
+
if tools is not None:
|
|
132
|
+
return self.inner.get_num_tokens_from_messages(messages, tools=tools)
|
|
133
|
+
return self.inner.get_num_tokens_from_messages(messages)
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Cross-run Deep Agents memory — a thin bridge over Flyte's keyed ``MemoryStore``.
|
|
2
|
+
|
|
3
|
+
A deep agent is driven with a messages state (``graph.ainvoke({"messages":
|
|
4
|
+
[...]})``) and also carries a virtual filesystem (the ``files`` state its
|
|
5
|
+
built-in filesystem tools read and write). By default neither survives the run.
|
|
6
|
+
This module bridges both: it resolves a keyed :class:`MemoryStore`, loads the
|
|
7
|
+
prior conversation and files from path-addressed JSON slots, and writes them
|
|
8
|
+
back after the run — so a later run with the same ``memory_key`` continues the
|
|
9
|
+
conversation *and* sees the same virtual filesystem.
|
|
10
|
+
|
|
11
|
+
The transcript is stored as ``messages_to_dict(...)`` output (rebuilt with
|
|
12
|
+
``messages_from_dict``); the files state is stored as its plain
|
|
13
|
+
``{path: contents}`` dict. All operations are best-effort: any failure leaves
|
|
14
|
+
the run untouched (memory never breaks a run).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import typing
|
|
20
|
+
|
|
21
|
+
from flyte._logging import logger
|
|
22
|
+
from flyteplugins.agents.core import resolve_memory as _resolve_memory
|
|
23
|
+
|
|
24
|
+
if typing.TYPE_CHECKING:
|
|
25
|
+
from langchain_core.messages import BaseMessage
|
|
26
|
+
|
|
27
|
+
# Path-addressed memory slots inside the MemoryStore.
|
|
28
|
+
_HISTORY_PATH = "deepagents/history.json"
|
|
29
|
+
_FILES_PATH = "deepagents/files.json"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
async def resolve_memory(memory_key: str | None) -> typing.Any | None:
|
|
33
|
+
"""Resolve a keyed MemoryStore for Deep Agents cross-run memory, or ``None``.
|
|
34
|
+
|
|
35
|
+
Best-effort: returns ``None`` when ``memory_key`` is falsy or no durable
|
|
36
|
+
store can be resolved, so memory never breaks a run.
|
|
37
|
+
"""
|
|
38
|
+
if not memory_key:
|
|
39
|
+
return None
|
|
40
|
+
return await _resolve_memory(memory_key)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
async def load_history(store: typing.Any) -> list["BaseMessage"]:
|
|
44
|
+
"""Load and deserialize the prior conversation from ``store``.
|
|
45
|
+
|
|
46
|
+
Returns an empty list when there is no prior history or on any error.
|
|
47
|
+
"""
|
|
48
|
+
if store is None:
|
|
49
|
+
return []
|
|
50
|
+
try:
|
|
51
|
+
from langchain_core.messages import messages_from_dict
|
|
52
|
+
|
|
53
|
+
raw = await store.read_json.aio(_HISTORY_PATH, [])
|
|
54
|
+
return messages_from_dict(raw) if raw else []
|
|
55
|
+
except Exception: # pragma: no cover - memory is best-effort, never fatal
|
|
56
|
+
logger.warning("Could not load Deep Agents memory; continuing without prior history.")
|
|
57
|
+
return []
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
async def load_files(store: typing.Any) -> dict[str, typing.Any]:
|
|
61
|
+
"""Load the agent's prior virtual filesystem (``{path: contents}``) from ``store``.
|
|
62
|
+
|
|
63
|
+
Returns an empty dict when there are no prior files or on any error.
|
|
64
|
+
"""
|
|
65
|
+
if store is None:
|
|
66
|
+
return {}
|
|
67
|
+
try:
|
|
68
|
+
return dict(await store.read_json.aio(_FILES_PATH, {}) or {})
|
|
69
|
+
except Exception: # pragma: no cover - memory is best-effort, never fatal
|
|
70
|
+
logger.warning("Could not load Deep Agents files; continuing without prior files.")
|
|
71
|
+
return {}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
async def save_state(
|
|
75
|
+
store: typing.Any,
|
|
76
|
+
messages: typing.Sequence["BaseMessage"],
|
|
77
|
+
files: typing.Mapping[str, typing.Any] | None = None,
|
|
78
|
+
) -> None:
|
|
79
|
+
"""Persist the conversation transcript and virtual filesystem to ``store``.
|
|
80
|
+
|
|
81
|
+
Best-effort: logs and returns on any error so memory never breaks a run.
|
|
82
|
+
"""
|
|
83
|
+
if store is None or not messages:
|
|
84
|
+
return
|
|
85
|
+
try:
|
|
86
|
+
from langchain_core.messages import messages_to_dict
|
|
87
|
+
|
|
88
|
+
await store.write_json.aio(_HISTORY_PATH, messages_to_dict(list(messages)))
|
|
89
|
+
if files:
|
|
90
|
+
await store.write_json.aio(_FILES_PATH, dict(files))
|
|
91
|
+
await store.save.aio()
|
|
92
|
+
except Exception: # pragma: no cover - memory is best-effort, never fatal
|
|
93
|
+
logger.warning("Could not save Deep Agents memory; conversation will not be resumed.")
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""``run_agent`` — run a LangChain Deep Agent on Flyte.
|
|
2
|
+
|
|
3
|
+
Deep Agents (LangChain's agent harness) owns the loop: ``create_deep_agent``
|
|
4
|
+
returns a compiled LangGraph graph with built-in planning (todos), a virtual
|
|
5
|
+
filesystem, and subagents. ``run_agent`` runs that loop inside your
|
|
6
|
+
``@env.task``: it builds a deep agent with Flyte-task tools, drives it, and
|
|
7
|
+
returns the final answer. Each tool call runs as a durable Flyte child action
|
|
8
|
+
(its own container/resources, with retries and caching).
|
|
9
|
+
|
|
10
|
+
The graph is driven with a messages state: ``await graph.ainvoke({"messages":
|
|
11
|
+
[{"role": "user", "content": input}]})``, and the final text is
|
|
12
|
+
``result["messages"][-1].content``. The result state also carries ``files`` —
|
|
13
|
+
the agent's virtual filesystem — which ``memory_key`` persists across runs
|
|
14
|
+
alongside the conversation.
|
|
15
|
+
|
|
16
|
+
Observability: the run timeline is rendered into the Flyte task report.
|
|
17
|
+
|
|
18
|
+
The adapter minimizes delta between native Deep Agents code and Flyte
|
|
19
|
+
integration by exposing tools that are drop-in ``BaseTool`` instances.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import typing
|
|
25
|
+
|
|
26
|
+
from flyteplugins.agents.core import ReportTimeline, flush_report, sync_variant
|
|
27
|
+
|
|
28
|
+
from ._durable import DurableChatModel
|
|
29
|
+
from ._memory import load_files, load_history, resolve_memory, save_state
|
|
30
|
+
from ._tools import _coerce_tool
|
|
31
|
+
|
|
32
|
+
# Module-level alias for test monkeypatching: tests substitute the graph builder
|
|
33
|
+
# without importing a real provider model.
|
|
34
|
+
_create_deep_agent = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _resolve_chat_model(model: typing.Any) -> typing.Any:
|
|
38
|
+
"""Return a LangChain chat model. A model instance passes through; a
|
|
39
|
+
``provider:model`` string resolves via ``init_chat_model``. ``None`` is an
|
|
40
|
+
error — the caller must choose a model."""
|
|
41
|
+
if model is None:
|
|
42
|
+
raise ValueError(
|
|
43
|
+
"Provide `model=` when building the agent (or pass a pre-built `agent=`). "
|
|
44
|
+
'For example: `model="anthropic:claude-sonnet-4-6"` or a chat-model instance.'
|
|
45
|
+
)
|
|
46
|
+
from langchain_core.language_models.chat_models import BaseChatModel
|
|
47
|
+
|
|
48
|
+
if isinstance(model, BaseChatModel):
|
|
49
|
+
return model
|
|
50
|
+
from langchain.chat_models import init_chat_model
|
|
51
|
+
|
|
52
|
+
return init_chat_model(model)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _wrap_durable(model: typing.Any) -> typing.Any:
|
|
56
|
+
"""Wrap a chat model in :class:`DurableChatModel` when possible.
|
|
57
|
+
|
|
58
|
+
Best-effort: only ``BaseChatModel`` instances are wrappable; anything else
|
|
59
|
+
(or any failure) is returned unchanged so durability never breaks a run.
|
|
60
|
+
"""
|
|
61
|
+
try:
|
|
62
|
+
from langchain_core.language_models.chat_models import BaseChatModel
|
|
63
|
+
|
|
64
|
+
if isinstance(model, BaseChatModel) and not isinstance(model, DurableChatModel):
|
|
65
|
+
return DurableChatModel(inner=model)
|
|
66
|
+
except Exception: # pragma: no cover - durability is best-effort, never fatal
|
|
67
|
+
pass
|
|
68
|
+
return model
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _final_text(result: typing.Any) -> str:
|
|
72
|
+
"""Extract the agent's final text from a compiled-graph result.
|
|
73
|
+
|
|
74
|
+
Deep agent graphs return a messages state ``{"messages": [...], "files":
|
|
75
|
+
{...}}``; the final answer is the content of the last message. Falls back
|
|
76
|
+
gracefully for other shapes.
|
|
77
|
+
"""
|
|
78
|
+
if isinstance(result, dict):
|
|
79
|
+
messages = result.get("messages")
|
|
80
|
+
if messages:
|
|
81
|
+
content = getattr(messages[-1], "content", messages[-1])
|
|
82
|
+
return content if isinstance(content, str) else str(content)
|
|
83
|
+
return ""
|
|
84
|
+
return str(result)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _result_messages(result: typing.Any) -> list[typing.Any]:
|
|
88
|
+
"""Extract the message list from a compiled-graph result (empty on other shapes)."""
|
|
89
|
+
if isinstance(result, dict):
|
|
90
|
+
messages = result.get("messages")
|
|
91
|
+
if messages:
|
|
92
|
+
return list(messages)
|
|
93
|
+
return []
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
async def run_agent(
|
|
97
|
+
input: str,
|
|
98
|
+
*,
|
|
99
|
+
tools: typing.Sequence[typing.Any] = (),
|
|
100
|
+
model: typing.Any = None,
|
|
101
|
+
instructions: str | None = None,
|
|
102
|
+
agent: typing.Any = None,
|
|
103
|
+
name: str = "deep-agent",
|
|
104
|
+
durable: bool = True,
|
|
105
|
+
observability: bool = True,
|
|
106
|
+
memory_key: str | None = None,
|
|
107
|
+
**agent_kwargs: typing.Any,
|
|
108
|
+
) -> str:
|
|
109
|
+
"""Run a Deep Agent with the given tools and prompt; return the final text.
|
|
110
|
+
|
|
111
|
+
Await this from an async task as ``await run_agent(...)``; from a sync task
|
|
112
|
+
use :func:`run_agent_sync` instead.
|
|
113
|
+
|
|
114
|
+
Call this from inside an ``@env.task`` — that task is the durable parent.
|
|
115
|
+
Within it, each tool call runs as a durable Flyte child action. Give the
|
|
116
|
+
enclosing task ``retries=...`` for self-healing and ``report=True`` to see
|
|
117
|
+
the agent timeline.
|
|
118
|
+
|
|
119
|
+
Provide either a pre-built ``agent`` (a compiled graph from
|
|
120
|
+
``create_deep_agent``) or ``tools`` + ``model`` to have one built for you.
|
|
121
|
+
Deep-Agents-specific options — ``subagents=``, ``skills=``, ``backend=``,
|
|
122
|
+
``interrupt_on=``, … — pass through ``**agent_kwargs`` on the builder path.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
input: The user prompt.
|
|
126
|
+
tools: ``tool``-wrapped tools or bare ``@env.task`` templates.
|
|
127
|
+
model: A LangChain chat model instance or a ``provider:model`` string
|
|
128
|
+
(e.g. ``"anthropic:claude-sonnet-4-6"``). Required when ``agent``
|
|
129
|
+
is not given.
|
|
130
|
+
instructions: System prompt for the built agent.
|
|
131
|
+
agent: A pre-built deep agent (a compiled ``create_deep_agent`` graph).
|
|
132
|
+
Mutually exclusive with ``tools``. To get durable model turns on this
|
|
133
|
+
path, build it with ``create_deep_agent(model=DurableChatModel(inner=...))``.
|
|
134
|
+
name: Agent name (for debugging/observability).
|
|
135
|
+
durable: Record/replay each model turn via ``flyte.trace``. Applies when
|
|
136
|
+
the agent is being built — the resolved model is wrapped in
|
|
137
|
+
:class:`DurableChatModel`. A fully pre-built compiled ``agent`` cannot
|
|
138
|
+
be rewrapped (wrap its model yourself, see above); its tool calls
|
|
139
|
+
remain durable regardless.
|
|
140
|
+
observability: Render the run timeline into the Flyte task report.
|
|
141
|
+
memory_key: Stable id (e.g. a user/thread id) for cross-run memory.
|
|
142
|
+
When set, the conversation *and* the agent's virtual filesystem are
|
|
143
|
+
persisted to a keyed ``MemoryStore`` and resumed on a later run with
|
|
144
|
+
the same key.
|
|
145
|
+
**agent_kwargs: Additional kwargs forwarded to ``create_deep_agent``
|
|
146
|
+
(``subagents=``, ``skills=``, ``backend=``, ...).
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
The agent's final output as a string.
|
|
150
|
+
"""
|
|
151
|
+
timeline = ReportTimeline() if observability else None
|
|
152
|
+
if timeline is not None:
|
|
153
|
+
timeline.heading("Deep agent")
|
|
154
|
+
|
|
155
|
+
if agent is not None and tools:
|
|
156
|
+
raise ValueError("Pass either `agent` (with its own tools) or `tools`, not both.")
|
|
157
|
+
|
|
158
|
+
# Build the agent (a compiled graph) if not provided.
|
|
159
|
+
if agent is None:
|
|
160
|
+
if _create_deep_agent is None:
|
|
161
|
+
from deepagents import create_deep_agent as create_deep_agent_fn
|
|
162
|
+
else:
|
|
163
|
+
create_deep_agent_fn = _create_deep_agent
|
|
164
|
+
|
|
165
|
+
chat_model = _resolve_chat_model(model)
|
|
166
|
+
# Wrap the chat model so each model turn is durable/replayable. We can
|
|
167
|
+
# only do this on the builder path (a fully pre-built compiled ``agent``
|
|
168
|
+
# owns its own model, which we cannot reach to rewrap).
|
|
169
|
+
if durable:
|
|
170
|
+
chat_model = _wrap_durable(chat_model)
|
|
171
|
+
|
|
172
|
+
tool_objs = [_coerce_tool(t) for t in tools]
|
|
173
|
+
system_prompt = instructions or f"You are a helpful assistant named {name}."
|
|
174
|
+
agent = create_deep_agent_fn(
|
|
175
|
+
model=chat_model,
|
|
176
|
+
tools=tool_objs,
|
|
177
|
+
system_prompt=system_prompt,
|
|
178
|
+
**agent_kwargs,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
# Cross-run memory: load the prior conversation and virtual filesystem and
|
|
182
|
+
# seed the run with both, then persist the updated state back after.
|
|
183
|
+
store = await resolve_memory(memory_key)
|
|
184
|
+
prior = await load_history(store)
|
|
185
|
+
files = await load_files(store)
|
|
186
|
+
|
|
187
|
+
state: dict[str, typing.Any] = {"messages": [*prior, {"role": "user", "content": input}]}
|
|
188
|
+
if files:
|
|
189
|
+
state["files"] = files
|
|
190
|
+
|
|
191
|
+
result = await agent.ainvoke(state)
|
|
192
|
+
|
|
193
|
+
await save_state(
|
|
194
|
+
store,
|
|
195
|
+
_result_messages(result),
|
|
196
|
+
files=result.get("files") if isinstance(result, dict) else None,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
final = _final_text(result)
|
|
200
|
+
|
|
201
|
+
if observability:
|
|
202
|
+
await flush_report()
|
|
203
|
+
|
|
204
|
+
return final or ""
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
run_agent_sync = sync_variant(run_agent)
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Turn Flyte tasks into Deep Agents tools that execute as durable actions.
|
|
2
|
+
|
|
3
|
+
Deep Agents (LangChain's agent harness) accepts LangChain ``BaseTool`` instances
|
|
4
|
+
as tools — both on the main agent (``create_deep_agent(tools=[...])``) and on
|
|
5
|
+
subagents (``SubAgent(tools=[...])``). :func:`tool` wraps a Flyte ``@env.task``
|
|
6
|
+
as a LangChain ``StructuredTool`` whose async coroutine dispatches to the task
|
|
7
|
+
via ``task.aio()`` — so when the agent calls the tool, it runs as a durable
|
|
8
|
+
Flyte child action (its own container/resources, with retries and caching)
|
|
9
|
+
rather than inline in the agent's process.
|
|
10
|
+
|
|
11
|
+
The returned object is a real ``StructuredTool`` (a ``BaseTool``), so it drops
|
|
12
|
+
straight into ``create_deep_agent(tools=[...])`` or a subagent's tool list. It
|
|
13
|
+
additionally exposes ``__wrapped_task__`` and ``task`` (via direct attribute
|
|
14
|
+
assignment, which ``StructuredTool`` permits) and wires the backing task to
|
|
15
|
+
:class:`~flyteplugins.agents.core.ToolTaskResolver` so it resolves to itself on
|
|
16
|
+
the worker (no recursion).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import inspect
|
|
22
|
+
import json
|
|
23
|
+
import typing
|
|
24
|
+
from functools import partial
|
|
25
|
+
|
|
26
|
+
from flyte._task import AsyncFunctionTaskTemplate
|
|
27
|
+
from flyteplugins.agents.core import attach_tool_resolver, coerce_tool_args
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def tool(
|
|
31
|
+
func: AsyncFunctionTaskTemplate | typing.Callable | None = None,
|
|
32
|
+
*,
|
|
33
|
+
name: str | None = None,
|
|
34
|
+
description: str | None = None,
|
|
35
|
+
) -> typing.Any:
|
|
36
|
+
"""Convert a Flyte task (or plain callable) into a LangChain ``StructuredTool``.
|
|
37
|
+
|
|
38
|
+
- For an ``@env.task``: returns a ``StructuredTool`` whose async coroutine runs
|
|
39
|
+
the task as a durable Flyte child action when the agent invokes it. The input
|
|
40
|
+
schema is derived from the task's typed signature. The backing task is wired to
|
|
41
|
+
:class:`~flyteplugins.agents.core.ToolTaskResolver` and exposed via
|
|
42
|
+
``__wrapped_task__`` so it resolves to itself on the worker (no recursion).
|
|
43
|
+
- For a plain (async) callable: returns a ``StructuredTool`` that runs it inline.
|
|
44
|
+
|
|
45
|
+
Usable bare, parametrized, or as a direct call::
|
|
46
|
+
|
|
47
|
+
@tool
|
|
48
|
+
@env.task
|
|
49
|
+
async def get_weather(city: str) -> str: ...
|
|
50
|
+
"""
|
|
51
|
+
if func is None:
|
|
52
|
+
return partial(tool, name=name, description=description)
|
|
53
|
+
if isinstance(func, AsyncFunctionTaskTemplate):
|
|
54
|
+
return _task_to_tool(func, name=name, description=description)
|
|
55
|
+
return _callable_to_tool(func, name=name, description=description)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _task_to_tool(
|
|
59
|
+
task: AsyncFunctionTaskTemplate,
|
|
60
|
+
*,
|
|
61
|
+
name: str | None = None,
|
|
62
|
+
description: str | None = None,
|
|
63
|
+
) -> typing.Any:
|
|
64
|
+
"""Build a LangChain ``StructuredTool`` from a Flyte task."""
|
|
65
|
+
from langchain_core.tools import StructuredTool
|
|
66
|
+
|
|
67
|
+
tool_name = name or task.func.__name__
|
|
68
|
+
desc = (description or task.func.__doc__ or f"Run {tool_name}").strip()
|
|
69
|
+
|
|
70
|
+
async def _arun(**kwargs: typing.Any) -> str:
|
|
71
|
+
# In a Flyte task context this submits a durable child action; locally it
|
|
72
|
+
# runs inline. ``coerce_tool_args`` relaxes LLM int->float args so Flyte's
|
|
73
|
+
# type engine doesn't reject e.g. ``amount_usd=42`` for a ``float`` param.
|
|
74
|
+
result = await task.aio(**coerce_tool_args(task, kwargs or {}))
|
|
75
|
+
return _as_content(result)
|
|
76
|
+
|
|
77
|
+
# Wire the shared resolver so the task resolves to itself on the worker.
|
|
78
|
+
attach_tool_resolver(task)
|
|
79
|
+
|
|
80
|
+
# Derive an explicit args schema from the task's typed signature. The coroutine
|
|
81
|
+
# above is ``**kwargs``-only, so LangChain's own inference would produce a single
|
|
82
|
+
# ``kwargs`` object param — we build the real pydantic model instead.
|
|
83
|
+
args_schema = _args_schema_from_callable(task.func, tool_name)
|
|
84
|
+
|
|
85
|
+
structured = StructuredTool.from_function(
|
|
86
|
+
func=None,
|
|
87
|
+
coroutine=_arun,
|
|
88
|
+
name=tool_name,
|
|
89
|
+
description=desc,
|
|
90
|
+
args_schema=args_schema,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# Expose the real task and a convenient ``task`` alias so callers/tests can reach
|
|
94
|
+
# the backing task. ``StructuredTool`` is a pydantic model whose ``__setattr__``
|
|
95
|
+
# rejects non-field names, so set them through ``object.__setattr__`` (dunders
|
|
96
|
+
# like ``__wrapped_task__`` would bypass it, but keep both paths uniform).
|
|
97
|
+
object.__setattr__(structured, "__wrapped_task__", task)
|
|
98
|
+
object.__setattr__(structured, "task", task)
|
|
99
|
+
return structured
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _callable_to_tool(
|
|
103
|
+
func: typing.Callable,
|
|
104
|
+
*,
|
|
105
|
+
name: str | None = None,
|
|
106
|
+
description: str | None = None,
|
|
107
|
+
) -> typing.Any:
|
|
108
|
+
"""Build a LangChain ``StructuredTool`` from a plain callable."""
|
|
109
|
+
from langchain_core.tools import StructuredTool
|
|
110
|
+
|
|
111
|
+
tool_name = name or getattr(func, "__name__", "tool")
|
|
112
|
+
desc = (description or func.__doc__ or f"Run {tool_name}").strip()
|
|
113
|
+
|
|
114
|
+
async def _arun(**kwargs: typing.Any) -> str:
|
|
115
|
+
out = func(**(kwargs or {}))
|
|
116
|
+
if inspect.isawaitable(out):
|
|
117
|
+
out = await out
|
|
118
|
+
return _as_content(out)
|
|
119
|
+
|
|
120
|
+
args_schema = _args_schema_from_callable(func, tool_name)
|
|
121
|
+
|
|
122
|
+
return StructuredTool.from_function(
|
|
123
|
+
func=None,
|
|
124
|
+
coroutine=_arun,
|
|
125
|
+
name=tool_name,
|
|
126
|
+
description=desc,
|
|
127
|
+
args_schema=args_schema,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _args_schema_from_callable(func: typing.Callable, tool_name: str) -> typing.Any | None:
|
|
132
|
+
"""Build a pydantic ``args_schema`` from a callable's typed signature.
|
|
133
|
+
|
|
134
|
+
Returns ``None`` (letting LangChain infer) if the signature can't be resolved.
|
|
135
|
+
The model's fields mirror the callable's parameters, with annotations and
|
|
136
|
+
defaults preserved so the LLM sees a correct tool schema.
|
|
137
|
+
"""
|
|
138
|
+
from pydantic import create_model
|
|
139
|
+
|
|
140
|
+
try:
|
|
141
|
+
hints = typing.get_type_hints(func)
|
|
142
|
+
sig = inspect.signature(func)
|
|
143
|
+
except Exception: # pragma: no cover - unresolved annotations; let LangChain infer
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
fields: dict[str, typing.Any] = {}
|
|
147
|
+
for pname, param in sig.parameters.items():
|
|
148
|
+
if pname == "self" or param.kind in (
|
|
149
|
+
inspect.Parameter.VAR_POSITIONAL,
|
|
150
|
+
inspect.Parameter.VAR_KEYWORD,
|
|
151
|
+
):
|
|
152
|
+
continue
|
|
153
|
+
annotation = hints.get(pname, typing.Any)
|
|
154
|
+
default = ... if param.default is inspect.Parameter.empty else param.default
|
|
155
|
+
fields[pname] = (annotation, default)
|
|
156
|
+
|
|
157
|
+
if not fields:
|
|
158
|
+
return None
|
|
159
|
+
return create_model(f"{tool_name}Args", **fields)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _as_content(result: typing.Any) -> str:
|
|
163
|
+
"""Convert a tool result to a string for LangChain's ToolMessage."""
|
|
164
|
+
if isinstance(result, str):
|
|
165
|
+
return result
|
|
166
|
+
try:
|
|
167
|
+
return json.dumps(result, default=str)
|
|
168
|
+
except (TypeError, ValueError):
|
|
169
|
+
return str(result)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _coerce_tool(t: typing.Any) -> typing.Any:
|
|
173
|
+
"""Coerce a bare ``@env.task`` into a LangChain tool; pass everything else through."""
|
|
174
|
+
if isinstance(t, AsyncFunctionTaskTemplate):
|
|
175
|
+
return tool(t)
|
|
176
|
+
return t
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: flyteplugins-agents-deepagents
|
|
3
|
+
Version: 2.5.15
|
|
4
|
+
Summary: Run LangChain Deep Agents on Flyte.
|
|
5
|
+
Author-email: Niels Bantilan <niels@union.ai>
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: flyteplugins-agents-core
|
|
9
|
+
Requires-Dist: deepagents
|
|
10
|
+
|
|
11
|
+
# flyteplugins-agents-deepagents
|
|
12
|
+
|
|
13
|
+
Run [Deep Agents](https://docs.langchain.com/oss/python/deepagents/overview) —
|
|
14
|
+
LangChain's agent harness with built-in planning, a virtual filesystem, and
|
|
15
|
+
subagents — durably on Flyte.
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pip install flyteplugins-agents-deepagents
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
You keep writing Deep Agents code; Flyte is the durable runtime underneath:
|
|
22
|
+
|
|
23
|
+
- **Tools are Flyte tasks.** Stack `@tool` on `@env.task` and each tool call the
|
|
24
|
+
agent (or any of its subagents) makes runs as a durable child action — its own
|
|
25
|
+
container/resources, retries, and caching.
|
|
26
|
+
- **Model turns are replayable.** On the builder path (or by wrapping your own
|
|
27
|
+
model in `DurableChatModel`), every model turn is recorded via `flyte.trace`,
|
|
28
|
+
so a crashed/retried run replays completed turns instead of re-calling (and
|
|
29
|
+
re-billing) the model.
|
|
30
|
+
- **Memory spans runs.** `run_agent(..., memory_key=...)` persists the
|
|
31
|
+
conversation *and* the agent's virtual filesystem to a durable keyed store, so
|
|
32
|
+
a later run with the same key picks up both.
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
import flyte
|
|
36
|
+
from flyteplugins.agents.deepagents import run_agent, tool
|
|
37
|
+
|
|
38
|
+
env = flyte.TaskEnvironment("deep-agent")
|
|
39
|
+
|
|
40
|
+
@tool
|
|
41
|
+
@env.task(cache="auto", retries=3)
|
|
42
|
+
async def search_web(query: str) -> str:
|
|
43
|
+
"""Search the web for a query."""
|
|
44
|
+
...
|
|
45
|
+
|
|
46
|
+
@env.task(report=True, retries=3)
|
|
47
|
+
async def research_agent(question: str) -> str:
|
|
48
|
+
return await run_agent(
|
|
49
|
+
question,
|
|
50
|
+
tools=[search_web],
|
|
51
|
+
instructions="You are an expert researcher.",
|
|
52
|
+
model="anthropic:claude-sonnet-4-6",
|
|
53
|
+
subagents=[{
|
|
54
|
+
"name": "critic",
|
|
55
|
+
"description": "Critiques draft answers.",
|
|
56
|
+
"system_prompt": "You are a ruthless critic.",
|
|
57
|
+
}],
|
|
58
|
+
)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
To bring your own agent, build it with `create_deep_agent` (attaching
|
|
62
|
+
`@tool`-wrapped tasks natively) and pass it as `run_agent(agent=...)`; wrap the
|
|
63
|
+
model in `DurableChatModel(inner=...)` to keep durable model turns on that path.
|
|
64
|
+
See [examples/](examples/) for the full patterns.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
flyteplugins/agents/deepagents/__init__.py,sha256=oUTjQAuCAcgvFBDkXNuo5cScllc0lgjRYhmyRg14MZA,1406
|
|
2
|
+
flyteplugins/agents/deepagents/_durable.py,sha256=MfliWgoUVBPcj4zF3bZ6DDWxrC5xVhoq3__wlY17gq8,6068
|
|
3
|
+
flyteplugins/agents/deepagents/_memory.py,sha256=403PfaIPl14YvdYcxJMUc2W1BpbZGmCRVPWPX2FjsKs,3636
|
|
4
|
+
flyteplugins/agents/deepagents/_run.py,sha256=rgS_OwdROsDpUxKrl0NCBsB_iCjjp6e_HN52uhNbwtY,8324
|
|
5
|
+
flyteplugins/agents/deepagents/_tools.py,sha256=KSo3m0yOdJB4RjU_aomBJGMI6nQ80oFM-ava07kIcj8,6794
|
|
6
|
+
flyteplugins_agents_deepagents-2.5.15.dist-info/METADATA,sha256=ORAMIZ2NSrgJz8rxD8hID7mydEMdg8WikmlLb2iBCU8,2304
|
|
7
|
+
flyteplugins_agents_deepagents-2.5.15.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
8
|
+
flyteplugins_agents_deepagents-2.5.15.dist-info/top_level.txt,sha256=cgd779rPu9EsvdtuYgUxNHHgElaQvPn74KhB5XSeMBE,13
|
|
9
|
+
flyteplugins_agents_deepagents-2.5.15.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
flyteplugins
|