flyteplugins-agents-crewai 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/crewai/__init__.py +18 -0
- flyteplugins/agents/crewai/_durable.py +166 -0
- flyteplugins/agents/crewai/_memory.py +70 -0
- flyteplugins/agents/crewai/_run.py +158 -0
- flyteplugins/agents/crewai/_tools.py +229 -0
- flyteplugins_agents_crewai-2.5.15.dist-info/METADATA +9 -0
- flyteplugins_agents_crewai-2.5.15.dist-info/RECORD +9 -0
- flyteplugins_agents_crewai-2.5.15.dist-info/WHEEL +5 -0
- flyteplugins_agents_crewai-2.5.15.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""CrewAI adapter for Flyte.
|
|
2
|
+
|
|
3
|
+
Bring your own CrewAI ``Agent`` and run it durably on Flyte. The adapter
|
|
4
|
+
provides:
|
|
5
|
+
|
|
6
|
+
- :func:`tool` — turn a Flyte ``@env.task`` into a CrewAI tool that executes as
|
|
7
|
+
a durable child action (own container/GPU, retries, caching).
|
|
8
|
+
- :func:`run_agent` — run the CrewAI agent loop inside your task and return the
|
|
9
|
+
final answer.
|
|
10
|
+
|
|
11
|
+
Each tool call runs as a durable Flyte child action, and the run timeline is
|
|
12
|
+
rendered into the Flyte task report.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from ._run import run_agent, run_agent_sync
|
|
16
|
+
from ._tools import tool
|
|
17
|
+
|
|
18
|
+
__all__ = ["run_agent", "run_agent_sync", "tool"]
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Durable, replayable model turns for CrewAI.
|
|
2
|
+
|
|
3
|
+
CrewAI owns the agent loop and drives the model itself: during
|
|
4
|
+
``Agent.kickoff_async`` it calls its ``crewai.LLM`` once per turn. To make that
|
|
5
|
+
loop durable we swap in a durable ``LLM``: it records each turn through the
|
|
6
|
+
shared :func:`~flyteplugins.agents.core.durable_step` (a ``flyte.trace`` leaf),
|
|
7
|
+
so inside a Flyte task a crashed/retried run replays completed turns from their
|
|
8
|
+
recorded completions instead of re-calling (and re-billing) the model. Tool
|
|
9
|
+
calls run as durable child actions (see :func:`flyteplugins.agents.crewai.tool`),
|
|
10
|
+
so the whole run becomes crash-resilient when the enclosing task carries
|
|
11
|
+
``retries=...``.
|
|
12
|
+
|
|
13
|
+
Two CrewAI facts shape the implementation:
|
|
14
|
+
|
|
15
|
+
- ``crewai.LLM(model=...)`` is a *factory*: ``LLM.__new__`` dispatches on the
|
|
16
|
+
model name and returns a provider-specific subclass (``OpenAICompletion``,
|
|
17
|
+
``AnthropicCompletion``, ...). A plain ``class D(LLM)`` therefore never sees the
|
|
18
|
+
provider's real ``call``. We instead subclass the *concrete* provider class of a
|
|
19
|
+
probe instance (``type(LLM(model=...))``) and instantiate through it, so the
|
|
20
|
+
durable overrides sit directly on top of the real completion methods.
|
|
21
|
+
- ``kickoff_async`` invokes the **synchronous** ``call`` (verified on 1.15.2),
|
|
22
|
+
not ``acall``; we override both so durability applies whichever path runs.
|
|
23
|
+
|
|
24
|
+
The turn result is the completion — a string, or an arbitrary object for
|
|
25
|
+
structured outputs. Strings round-trip as-is; anything else is coerced to a
|
|
26
|
+
string for the trace record (structured turns are recorded but rebuilt as text,
|
|
27
|
+
which is a lossy but safe fallback and never the case for the default text loop).
|
|
28
|
+
The concrete provider class is a heavy import, so the durable subclass is built
|
|
29
|
+
lazily on first use.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import typing
|
|
35
|
+
|
|
36
|
+
from flyteplugins.agents.core import durable_step, fingerprint, jsonable
|
|
37
|
+
|
|
38
|
+
# Sentinel prefix marking a completion we serialized as JSON (non-string result).
|
|
39
|
+
# Plain string completions are stored verbatim; on load we only JSON-decode a
|
|
40
|
+
# value carrying this prefix, so ordinary strings never get mis-parsed.
|
|
41
|
+
_JSON_PREFIX = "\x00json\x00"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _messages_fingerprint(
|
|
45
|
+
messages: typing.Any,
|
|
46
|
+
tools: typing.Any,
|
|
47
|
+
extra: typing.Mapping[str, typing.Any] | None = None,
|
|
48
|
+
) -> str:
|
|
49
|
+
"""Deterministic memo key for a model turn — message content + tool names.
|
|
50
|
+
|
|
51
|
+
Fingerprints on serializable request identity only: the messages (a str or a
|
|
52
|
+
list of ``{"role", "content"}`` dicts) and the tool *names* — never callables
|
|
53
|
+
or live tool/agent objects.
|
|
54
|
+
"""
|
|
55
|
+
payload: dict[str, typing.Any] = {
|
|
56
|
+
"messages": jsonable(messages),
|
|
57
|
+
"tools": sorted(_tool_names(tools)),
|
|
58
|
+
}
|
|
59
|
+
if extra:
|
|
60
|
+
payload.update(extra)
|
|
61
|
+
return fingerprint(payload)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _tool_names(tools: typing.Any) -> list[str]:
|
|
65
|
+
"""Extract stable tool names from CrewAI's ``[{name: BaseTool}, ...]`` (or list)."""
|
|
66
|
+
names: list[str] = []
|
|
67
|
+
for entry in tools or []:
|
|
68
|
+
if isinstance(entry, dict):
|
|
69
|
+
names.extend(str(k) for k in entry.keys())
|
|
70
|
+
else:
|
|
71
|
+
names.append(str(getattr(entry, "name", entry)))
|
|
72
|
+
return names
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _dumps(result: typing.Any) -> str:
|
|
76
|
+
"""Serialize a turn result: strings verbatim, everything else JSON-tagged.
|
|
77
|
+
|
|
78
|
+
JSON-native structures (dict/list/scalars) serialize directly; SDK objects
|
|
79
|
+
are coerced via ``jsonable`` first. ``default=str`` catches any residue so
|
|
80
|
+
serialization never fails a turn.
|
|
81
|
+
"""
|
|
82
|
+
if isinstance(result, str):
|
|
83
|
+
return result
|
|
84
|
+
import json
|
|
85
|
+
|
|
86
|
+
if isinstance(result, (dict, list, int, float, bool)) or result is None:
|
|
87
|
+
payload = result
|
|
88
|
+
else:
|
|
89
|
+
payload = jsonable(result)
|
|
90
|
+
return _JSON_PREFIX + json.dumps(payload, default=str)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _loads(recorded: str) -> typing.Any:
|
|
94
|
+
"""Rebuild a turn result from its recorded string form."""
|
|
95
|
+
if recorded.startswith(_JSON_PREFIX):
|
|
96
|
+
import json
|
|
97
|
+
|
|
98
|
+
return json.loads(recorded[len(_JSON_PREFIX) :])
|
|
99
|
+
return recorded
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _make_durable_llm_class(model: str) -> type:
|
|
103
|
+
"""Build a durable ``LLM`` subclass over the concrete provider for ``model``.
|
|
104
|
+
|
|
105
|
+
Imported/derived lazily: ``crewai`` and its provider classes are heavy, and
|
|
106
|
+
the concrete class depends on the model name (provider dispatch).
|
|
107
|
+
"""
|
|
108
|
+
from crewai import LLM
|
|
109
|
+
|
|
110
|
+
probe = LLM(model=model)
|
|
111
|
+
concrete_cls = type(probe)
|
|
112
|
+
|
|
113
|
+
class DurableLLM(concrete_cls): # type: ignore[valid-type, misc]
|
|
114
|
+
"""A ``crewai.LLM`` whose every model turn is recorded via ``durable_step``.
|
|
115
|
+
|
|
116
|
+
``call`` (used by ``kickoff_async``) and ``acall`` both route the real
|
|
117
|
+
completion through the shared durable step so retries replay recorded
|
|
118
|
+
turns. Durability is guarded: if the trace layer misbehaves the real
|
|
119
|
+
call still runs, so it never breaks a run.
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
def call(self, messages: typing.Any, *args: typing.Any, **kwargs: typing.Any) -> typing.Any:
|
|
123
|
+
from flyte._utils.asyn import run_sync
|
|
124
|
+
|
|
125
|
+
tools = args[0] if args else kwargs.get("tools")
|
|
126
|
+
try:
|
|
127
|
+
return run_sync(
|
|
128
|
+
durable_step,
|
|
129
|
+
_messages_fingerprint(messages, tools),
|
|
130
|
+
lambda: _as_awaitable(super(DurableLLM, self).call(messages, *args, **kwargs)),
|
|
131
|
+
name="model_turn",
|
|
132
|
+
dumps=_dumps,
|
|
133
|
+
loads=_loads,
|
|
134
|
+
)
|
|
135
|
+
except Exception: # pragma: no cover - durability never breaks a run
|
|
136
|
+
return super().call(messages, *args, **kwargs)
|
|
137
|
+
|
|
138
|
+
async def acall(self, messages: typing.Any, *args: typing.Any, **kwargs: typing.Any) -> typing.Any:
|
|
139
|
+
tools = args[0] if args else kwargs.get("tools")
|
|
140
|
+
try:
|
|
141
|
+
return await durable_step(
|
|
142
|
+
_messages_fingerprint(messages, tools),
|
|
143
|
+
lambda: _as_awaitable(super(DurableLLM, self).acall(messages, *args, **kwargs)),
|
|
144
|
+
name="model_turn",
|
|
145
|
+
dumps=_dumps,
|
|
146
|
+
loads=_loads,
|
|
147
|
+
)
|
|
148
|
+
except Exception: # pragma: no cover - durability never breaks a run
|
|
149
|
+
return await _as_awaitable(super().acall(messages, *args, **kwargs))
|
|
150
|
+
|
|
151
|
+
return DurableLLM
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
async def _as_awaitable(value: typing.Any) -> typing.Any:
|
|
155
|
+
"""Normalize a maybe-coroutine to an awaited value (``call`` returns eagerly)."""
|
|
156
|
+
import inspect
|
|
157
|
+
|
|
158
|
+
if inspect.isawaitable(value):
|
|
159
|
+
return await value
|
|
160
|
+
return value
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def make_durable_llm(model: str) -> typing.Any:
|
|
164
|
+
"""Construct a durable ``crewai.LLM`` instance for the given ``model`` name."""
|
|
165
|
+
cls = _make_durable_llm_class(model)
|
|
166
|
+
return cls(model=model)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Cross-run CrewAI memory — a thin handle over Flyte's keyed ``MemoryStore``.
|
|
2
|
+
|
|
3
|
+
CrewAI keeps conversation state in-memory and ``kickoff_async`` does not thread a
|
|
4
|
+
prior transcript back out (its ``LiteAgentOutput.messages`` holds only the
|
|
5
|
+
system+user turns of the current call, not the assistant reply). So this module
|
|
6
|
+
maintains the transcript itself: it resolves a keyed ``MemoryStore`` and stores
|
|
7
|
+
the running conversation — a list of ``{"role", "content"}`` dicts — in a
|
|
8
|
+
path-addressed JSON slot. A later run with the same ``memory_key`` loads that
|
|
9
|
+
transcript and prepends it to the new prompt, continuing the conversation.
|
|
10
|
+
|
|
11
|
+
Everything here is best-effort: a store that can't be resolved, or a read/write
|
|
12
|
+
that fails, degrades to a memoryless run rather than breaking it.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import typing
|
|
18
|
+
|
|
19
|
+
from flyte._logging import logger
|
|
20
|
+
from flyteplugins.agents.core import resolve_memory as _resolve_memory
|
|
21
|
+
|
|
22
|
+
# Path-addressed memory slot holding a thread's conversation transcript
|
|
23
|
+
# (a list of ``{"role", "content"}`` message dicts) inside the MemoryStore.
|
|
24
|
+
_HISTORY_PATH = "crewai/history.json"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
async def resolve_memory(memory_key: str | None) -> typing.Any | None:
|
|
28
|
+
"""Resolve a keyed MemoryStore for CrewAI cross-run memory, or ``None``.
|
|
29
|
+
|
|
30
|
+
Best-effort: returns ``None`` when ``memory_key`` is falsy or no durable
|
|
31
|
+
store can be resolved, so memory never breaks a run.
|
|
32
|
+
"""
|
|
33
|
+
if not memory_key:
|
|
34
|
+
return None
|
|
35
|
+
return await _resolve_memory(memory_key)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
async def load_history(store: typing.Any) -> list[dict[str, typing.Any]]:
|
|
39
|
+
"""Load the stored conversation transcript, or ``[]`` if none/unavailable."""
|
|
40
|
+
if store is None:
|
|
41
|
+
return []
|
|
42
|
+
try:
|
|
43
|
+
history = await store.read_json.aio(_HISTORY_PATH, default=[])
|
|
44
|
+
except Exception: # pragma: no cover - memory is best-effort, never fatal
|
|
45
|
+
logger.warning("Could not load CrewAI conversation history; continuing without it.")
|
|
46
|
+
return []
|
|
47
|
+
return history if isinstance(history, list) else []
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def build_input(history: list[dict[str, typing.Any]], user_input: str) -> list[dict[str, typing.Any]]:
|
|
51
|
+
"""Build the kickoff message list from prior transcript + the new user turn."""
|
|
52
|
+
return [*history, {"role": "user", "content": user_input}]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
async def save_turn(store: typing.Any, user_input: str, assistant_output: str) -> None:
|
|
56
|
+
"""Append the user + assistant turns to the transcript and persist it.
|
|
57
|
+
|
|
58
|
+
Best-effort: any failure is logged and swallowed so a run never breaks on
|
|
59
|
+
memory persistence.
|
|
60
|
+
"""
|
|
61
|
+
if store is None:
|
|
62
|
+
return
|
|
63
|
+
try:
|
|
64
|
+
history = await load_history(store)
|
|
65
|
+
history.append({"role": "user", "content": user_input})
|
|
66
|
+
history.append({"role": "assistant", "content": assistant_output})
|
|
67
|
+
await store.write_json.aio(_HISTORY_PATH, history)
|
|
68
|
+
await store.save.aio()
|
|
69
|
+
except Exception: # pragma: no cover - memory is best-effort, never fatal
|
|
70
|
+
logger.warning("Could not persist CrewAI conversation history; continuing.")
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""``run_agent`` — run a CrewAI agent on Flyte.
|
|
2
|
+
|
|
3
|
+
CrewAI owns the agent loop (it drives the model + tools). ``run_agent`` runs that
|
|
4
|
+
loop inside your ``@env.task``: it builds an agent with Flyte-task tools, drives
|
|
5
|
+
the agent via ``Agent.kickoff_async``, and returns the final answer. Each tool
|
|
6
|
+
call runs as a durable Flyte child action (its own container/resources, with
|
|
7
|
+
retries and caching).
|
|
8
|
+
|
|
9
|
+
Observability: the run timeline — tool calls and AI message turns — is rendered
|
|
10
|
+
into the Flyte task report.
|
|
11
|
+
|
|
12
|
+
The adapter minimizes delta between native CrewAI code and Flyte integration by
|
|
13
|
+
exposing tools that are drop-in ``crewai.tools.BaseTool`` instances.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import typing
|
|
19
|
+
|
|
20
|
+
from flyteplugins.agents.core import ReportTimeline, flush_report, sync_variant
|
|
21
|
+
|
|
22
|
+
from . import _memory
|
|
23
|
+
from ._durable import make_durable_llm
|
|
24
|
+
from ._tools import _coerce_tool
|
|
25
|
+
|
|
26
|
+
if typing.TYPE_CHECKING:
|
|
27
|
+
from crewai import Agent as _CrewAgent
|
|
28
|
+
else:
|
|
29
|
+
_CrewAgent = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _extract_text(result: typing.Any) -> str:
|
|
33
|
+
"""Pull the final answer out of a CrewAI ``LiteAgentOutput`` (or fallback)."""
|
|
34
|
+
if result is None:
|
|
35
|
+
return ""
|
|
36
|
+
raw = getattr(result, "raw", None)
|
|
37
|
+
if isinstance(raw, str):
|
|
38
|
+
return raw
|
|
39
|
+
return str(result)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
async def run_agent(
|
|
43
|
+
input: str,
|
|
44
|
+
*,
|
|
45
|
+
tools: typing.Sequence[typing.Any] = (),
|
|
46
|
+
model: str | None = None,
|
|
47
|
+
instructions: str | None = None,
|
|
48
|
+
agent: typing.Any = None,
|
|
49
|
+
name: str = "crewai-agent",
|
|
50
|
+
durable: bool = True,
|
|
51
|
+
observability: bool = True,
|
|
52
|
+
memory_key: str | None = None,
|
|
53
|
+
**run_kwargs: typing.Any,
|
|
54
|
+
) -> str:
|
|
55
|
+
"""Run a CrewAI agent with the given tools and prompt; return the final text.
|
|
56
|
+
|
|
57
|
+
Await this from an async task as ``await run_agent(...)``; from a sync task
|
|
58
|
+
use :func:`run_agent_sync` instead.
|
|
59
|
+
|
|
60
|
+
Call this from inside an ``@env.task`` — that task is the durable parent.
|
|
61
|
+
Within it, each tool call runs as a durable Flyte child action. Give the
|
|
62
|
+
enclosing task ``retries=...`` for self-healing and ``report=True`` to see
|
|
63
|
+
the agent timeline.
|
|
64
|
+
|
|
65
|
+
Provide either a pre-built ``agent`` (with its own tools already attached) or
|
|
66
|
+
``tools`` + ``model`` to have one built for you — not both.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
input: The user prompt.
|
|
70
|
+
tools: ``tool``-wrapped tools or bare ``@env.task`` templates. Attached
|
|
71
|
+
natively to the built ``Agent(tools=...)``. Ignored when ``agent`` is
|
|
72
|
+
given (a pre-built agent carries its own tools).
|
|
73
|
+
model: Model name (e.g. ``"gpt-4o"``) for the built agent. Required on
|
|
74
|
+
the builder path (no default is assumed — the adapter is provider
|
|
75
|
+
agnostic); ignored when a pre-built ``agent`` is given.
|
|
76
|
+
instructions: Extra guidance folded into the built agent's backstory.
|
|
77
|
+
agent: A pre-built CrewAI ``Agent``. Mutually exclusive with ``tools``.
|
|
78
|
+
name: Agent name (for debugging/observability).
|
|
79
|
+
durable: Record/replay each model turn via ``flyte.trace``. Applied only
|
|
80
|
+
when ``run_agent`` builds the agent (the builder sets a durable
|
|
81
|
+
``llm``); a pre-built ``agent`` keeps its own ``llm`` and is not
|
|
82
|
+
rewrapped, so its turns are not durable.
|
|
83
|
+
observability: Render the run timeline into the Flyte task report.
|
|
84
|
+
memory_key: Stable id (e.g. a user/thread id) for cross-run memory.
|
|
85
|
+
When set, conversation history is persisted to a keyed ``MemoryStore``
|
|
86
|
+
and resumed on a later run with the same key.
|
|
87
|
+
**run_kwargs: Additional kwargs forwarded to ``Agent.kickoff_async``.
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
The agent's final output as a string.
|
|
91
|
+
"""
|
|
92
|
+
timeline = ReportTimeline() if observability else None
|
|
93
|
+
if timeline is not None:
|
|
94
|
+
timeline.heading("CrewAI agent")
|
|
95
|
+
|
|
96
|
+
if agent is not None and tools:
|
|
97
|
+
raise ValueError("Pass either `agent` (with its own tools) or `tools`, not both.")
|
|
98
|
+
|
|
99
|
+
# Build the agent if not provided, attaching the Flyte-task tools natively.
|
|
100
|
+
if agent is None:
|
|
101
|
+
if model is None:
|
|
102
|
+
raise ValueError(
|
|
103
|
+
'No model was given. Provide `model=` (e.g. "gpt-4o") when building the agent '
|
|
104
|
+
"(or pass a pre-built `agent=`)."
|
|
105
|
+
)
|
|
106
|
+
if _CrewAgent is None:
|
|
107
|
+
from crewai import Agent as _LocalAgent
|
|
108
|
+
else:
|
|
109
|
+
_LocalAgent = _CrewAgent
|
|
110
|
+
|
|
111
|
+
backstory = f"You are a helpful assistant named {name}."
|
|
112
|
+
if instructions:
|
|
113
|
+
backstory = f"{backstory}\n\n{instructions}"
|
|
114
|
+
|
|
115
|
+
# When durable, drive the agent with a durable ``LLM`` so every model
|
|
116
|
+
# turn is recorded via ``flyte.trace`` and replayed on retry. For a
|
|
117
|
+
# PREBUILT agent we cannot safely rewrap its ``llm``, so durability is
|
|
118
|
+
# only applied on this builder path (see the module docstring / limitation).
|
|
119
|
+
if durable:
|
|
120
|
+
llm: typing.Any = make_durable_llm(model)
|
|
121
|
+
else:
|
|
122
|
+
llm = model
|
|
123
|
+
|
|
124
|
+
agent = _LocalAgent(
|
|
125
|
+
role="Assistant",
|
|
126
|
+
goal="Answer the user's question accurately and concisely.",
|
|
127
|
+
backstory=backstory,
|
|
128
|
+
tools=[_coerce_tool(t) for t in tools],
|
|
129
|
+
llm=llm,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
# Cross-run memory: load the prior transcript (if any) and prepend it to the
|
|
133
|
+
# new user turn, so ``kickoff_async`` continues the conversation. Best-effort.
|
|
134
|
+
store = await _memory.resolve_memory(memory_key)
|
|
135
|
+
if store is not None:
|
|
136
|
+
history = await _memory.load_history(store)
|
|
137
|
+
kickoff_input: typing.Any = _memory.build_input(history, input)
|
|
138
|
+
else:
|
|
139
|
+
kickoff_input = input
|
|
140
|
+
|
|
141
|
+
# Drive the agent loop. ``kickoff_async`` takes the prompt (a str, or a list
|
|
142
|
+
# of ``{"role", "content"}`` message dicts when resuming memory) as its input;
|
|
143
|
+
# a pre-built agent already carries its own tools, so none are injected here.
|
|
144
|
+
result = await agent.kickoff_async(kickoff_input, **run_kwargs)
|
|
145
|
+
|
|
146
|
+
final = _extract_text(result)
|
|
147
|
+
|
|
148
|
+
# Persist this turn to the transcript for the next run with the same key.
|
|
149
|
+
if store is not None:
|
|
150
|
+
await _memory.save_turn(store, input, final)
|
|
151
|
+
|
|
152
|
+
if observability:
|
|
153
|
+
await flush_report()
|
|
154
|
+
|
|
155
|
+
return final or ""
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
run_agent_sync = sync_variant(run_agent)
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Turn Flyte tasks into CrewAI tools that execute as durable actions.
|
|
2
|
+
|
|
3
|
+
CrewAI requires tools attached to an ``Agent(tools=[...])`` to be
|
|
4
|
+
``crewai.tools.BaseTool`` instances — plain callables are rejected by pydantic
|
|
5
|
+
validation. :func:`tool` therefore wraps a Flyte ``@env.task`` as a ``BaseTool``
|
|
6
|
+
subclass whose execution dispatches to the task via ``task.aio()`` — so when the
|
|
7
|
+
agent calls the tool, it runs as a durable Flyte child action (its own
|
|
8
|
+
container/resources, with retries and caching) rather than inline in the agent's
|
|
9
|
+
process.
|
|
10
|
+
|
|
11
|
+
Sync/async bridge: CrewAI invokes tools synchronously (``BaseTool.run`` ->
|
|
12
|
+
``_run``; the agent loop routes through ``CrewStructuredTool.invoke``, which calls
|
|
13
|
+
``self.func`` — our ``_run`` — and, if it returns a coroutine, ``asyncio.run``s it).
|
|
14
|
+
``asyncio.run`` explodes inside the already-running loop of a Flyte task, so we make
|
|
15
|
+
``_run`` a *synchronous* method that bridges to ``task.aio()`` via
|
|
16
|
+
:func:`flyte._utils.asyn.run_sync`, which drives the coroutine on a dedicated
|
|
17
|
+
background-thread loop and works from within a running loop. ``_arun`` awaits the
|
|
18
|
+
task directly for CrewAI's native async path.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import functools
|
|
24
|
+
import inspect
|
|
25
|
+
import json
|
|
26
|
+
import typing
|
|
27
|
+
from functools import partial
|
|
28
|
+
|
|
29
|
+
from flyte._task import AsyncFunctionTaskTemplate
|
|
30
|
+
from flyte._utils.asyn import run_sync
|
|
31
|
+
from flyte.models import NativeInterface
|
|
32
|
+
from flyteplugins.agents.core import attach_tool_resolver, coerce_tool_args, task_json_schema
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def tool(
|
|
36
|
+
func: AsyncFunctionTaskTemplate | typing.Callable | None = None,
|
|
37
|
+
*,
|
|
38
|
+
name: str | None = None,
|
|
39
|
+
description: str | None = None,
|
|
40
|
+
) -> typing.Any:
|
|
41
|
+
"""Convert a Flyte task (or plain callable) into a CrewAI ``BaseTool``.
|
|
42
|
+
|
|
43
|
+
- For an ``@env.task``: returns a ``BaseTool`` whose execution runs the task as
|
|
44
|
+
a durable Flyte child action when the agent invokes it. The input schema is
|
|
45
|
+
derived from the task via the Flyte type engine. The backing task is wired
|
|
46
|
+
to :class:`~flyteplugins.agents.core.ToolTaskResolver` and exposed via
|
|
47
|
+
``__wrapped_task__`` so it resolves to itself on the worker (no recursion).
|
|
48
|
+
- For a plain (async) callable: returns a ``BaseTool`` that runs it inline.
|
|
49
|
+
|
|
50
|
+
The returned object is a native ``crewai.tools.BaseTool`` instance, so it can be
|
|
51
|
+
attached directly to ``Agent(tools=[...])``.
|
|
52
|
+
|
|
53
|
+
Usable bare, parametrized, or as a direct call::
|
|
54
|
+
|
|
55
|
+
@tool
|
|
56
|
+
@env.task
|
|
57
|
+
async def get_weather(city: str) -> str: ...
|
|
58
|
+
"""
|
|
59
|
+
if func is None:
|
|
60
|
+
return partial(tool, name=name, description=description)
|
|
61
|
+
if isinstance(func, AsyncFunctionTaskTemplate):
|
|
62
|
+
return _task_to_tool(func, name=name, description=description)
|
|
63
|
+
return _callable_to_tool(func, name=name, description=description)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _args_model_from_signature(fn: typing.Callable, model_name: str) -> type:
|
|
67
|
+
"""Build a pydantic model describing ``fn``'s parameters for CrewAI's args schema.
|
|
68
|
+
|
|
69
|
+
CrewAI derives a tool's args schema from ``BaseTool.args_schema`` (or, failing
|
|
70
|
+
that, from the ``_run`` signature). Our ``_run`` takes ``**kwargs``, so we hand
|
|
71
|
+
CrewAI an explicit model built from the wrapped callable's annotations.
|
|
72
|
+
"""
|
|
73
|
+
from pydantic import create_model
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
hints = typing.get_type_hints(fn)
|
|
77
|
+
except Exception: # pragma: no cover - unresolved annotations
|
|
78
|
+
hints = {}
|
|
79
|
+
fields: dict[str, typing.Any] = {}
|
|
80
|
+
for pname, param in inspect.signature(fn).parameters.items():
|
|
81
|
+
if pname in ("self", "return"):
|
|
82
|
+
continue
|
|
83
|
+
if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
|
|
84
|
+
continue
|
|
85
|
+
fallback = param.annotation if param.annotation is not inspect.Parameter.empty else typing.Any
|
|
86
|
+
annotation = hints.get(pname, fallback)
|
|
87
|
+
if param.default is inspect.Parameter.empty:
|
|
88
|
+
fields[pname] = (annotation, ...)
|
|
89
|
+
else:
|
|
90
|
+
fields[pname] = (annotation, param.default)
|
|
91
|
+
return create_model(model_name, **fields)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _make_base_tool_class() -> type:
|
|
95
|
+
"""Define the ``FlyteCrewAITool`` ``BaseTool`` subclass (imported lazily).
|
|
96
|
+
|
|
97
|
+
CrewAI is an optional heavy import, so the class is built on first use rather
|
|
98
|
+
than at module import time.
|
|
99
|
+
"""
|
|
100
|
+
from crewai.tools import BaseTool
|
|
101
|
+
|
|
102
|
+
class FlyteCrewAITool(BaseTool):
|
|
103
|
+
"""A CrewAI ``BaseTool`` backed by a Flyte task (or plain callable).
|
|
104
|
+
|
|
105
|
+
``_run`` is synchronous by design: CrewAI's structured-tool path calls it
|
|
106
|
+
and ``asyncio.run``s any returned coroutine, which would fail inside a
|
|
107
|
+
Flyte task's running loop. We instead bridge to the async dispatcher via
|
|
108
|
+
:func:`run_sync` (a background-thread loop) and return a plain string.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
# Pydantic model config: allow the non-field private attributes below.
|
|
112
|
+
_dispatch: typing.Callable[..., typing.Awaitable[typing.Any]]
|
|
113
|
+
_wrapped_task: typing.Any
|
|
114
|
+
|
|
115
|
+
def _run(self, **kwargs: typing.Any) -> str:
|
|
116
|
+
return run_sync(self._dispatch, **kwargs)
|
|
117
|
+
|
|
118
|
+
async def _arun(self, **kwargs: typing.Any) -> str:
|
|
119
|
+
return await self._dispatch(**kwargs)
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def __wrapped_task__(self) -> typing.Any:
|
|
123
|
+
return self._wrapped_task
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def task(self) -> typing.Any:
|
|
127
|
+
return self._wrapped_task
|
|
128
|
+
|
|
129
|
+
return FlyteCrewAITool
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@functools.lru_cache(maxsize=1)
|
|
133
|
+
def _base_tool_class() -> type:
|
|
134
|
+
return _make_base_tool_class()
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _build_tool(
|
|
138
|
+
*,
|
|
139
|
+
tool_name: str,
|
|
140
|
+
desc: str,
|
|
141
|
+
args_model: type,
|
|
142
|
+
dispatch: typing.Callable[..., typing.Awaitable[typing.Any]],
|
|
143
|
+
wrapped_task: typing.Any,
|
|
144
|
+
) -> typing.Any:
|
|
145
|
+
cls = _base_tool_class()
|
|
146
|
+
instance = cls(name=tool_name, description=desc, args_schema=args_model)
|
|
147
|
+
# Private attrs live outside pydantic validation; set them directly.
|
|
148
|
+
object.__setattr__(instance, "_dispatch", dispatch)
|
|
149
|
+
object.__setattr__(instance, "_wrapped_task", wrapped_task)
|
|
150
|
+
return instance
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _task_to_tool(
|
|
154
|
+
task: AsyncFunctionTaskTemplate,
|
|
155
|
+
*,
|
|
156
|
+
name: str | None = None,
|
|
157
|
+
description: str | None = None,
|
|
158
|
+
) -> typing.Any:
|
|
159
|
+
"""Build a CrewAI ``BaseTool`` from a Flyte task."""
|
|
160
|
+
tool_name = name or task.func.__name__
|
|
161
|
+
desc = (description or task.func.__doc__ or f"Run {tool_name}").strip()
|
|
162
|
+
task_json_schema(task) # validate schema at construction time
|
|
163
|
+
args_model = _args_model_from_signature(task.func, f"{tool_name}_args")
|
|
164
|
+
|
|
165
|
+
async def _dispatch(**kwargs: typing.Any) -> str:
|
|
166
|
+
# In a Flyte task context this submits a durable child action; locally it
|
|
167
|
+
# runs inline. ``coerce_tool_args`` relaxes LLM int->float args so Flyte's
|
|
168
|
+
# type engine doesn't reject e.g. ``amount_usd=42`` for a ``float`` param.
|
|
169
|
+
result = await task.aio(**coerce_tool_args(task, kwargs or {}))
|
|
170
|
+
return _as_content(result)
|
|
171
|
+
|
|
172
|
+
# Wire the shared resolver so the task resolves to itself on the worker.
|
|
173
|
+
attach_tool_resolver(task)
|
|
174
|
+
|
|
175
|
+
return _build_tool(
|
|
176
|
+
tool_name=tool_name,
|
|
177
|
+
desc=desc,
|
|
178
|
+
args_model=args_model,
|
|
179
|
+
dispatch=_dispatch,
|
|
180
|
+
wrapped_task=task,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _callable_to_tool(
|
|
185
|
+
func: typing.Callable,
|
|
186
|
+
*,
|
|
187
|
+
name: str | None = None,
|
|
188
|
+
description: str | None = None,
|
|
189
|
+
) -> typing.Any:
|
|
190
|
+
"""Build a CrewAI ``BaseTool`` from a plain callable."""
|
|
191
|
+
tool_name = name or getattr(func, "__name__", "tool")
|
|
192
|
+
desc = (description or func.__doc__ or f"Run {tool_name}").strip()
|
|
193
|
+
NativeInterface.from_callable(func).json_schema # validate schema at construction time
|
|
194
|
+
args_model = _args_model_from_signature(func, f"{tool_name}_args")
|
|
195
|
+
|
|
196
|
+
async def _dispatch(**kwargs: typing.Any) -> str:
|
|
197
|
+
out = func(**(kwargs or {}))
|
|
198
|
+
if inspect.isawaitable(out):
|
|
199
|
+
out = await out
|
|
200
|
+
return _as_content(out)
|
|
201
|
+
|
|
202
|
+
return _build_tool(
|
|
203
|
+
tool_name=tool_name,
|
|
204
|
+
desc=desc,
|
|
205
|
+
args_model=args_model,
|
|
206
|
+
dispatch=_dispatch,
|
|
207
|
+
wrapped_task=None,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _as_content(result: typing.Any) -> str:
|
|
212
|
+
"""Convert a tool result to a string for CrewAI."""
|
|
213
|
+
if isinstance(result, str):
|
|
214
|
+
return result
|
|
215
|
+
try:
|
|
216
|
+
return json.dumps(result, default=str)
|
|
217
|
+
except (TypeError, ValueError):
|
|
218
|
+
return str(result)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _coerce_tool(t: typing.Any) -> typing.Any:
|
|
222
|
+
"""Coerce a tool to a CrewAI-compatible ``BaseTool``.
|
|
223
|
+
|
|
224
|
+
Bare ``@env.task`` templates are wrapped on the fly; already-wrapped tools
|
|
225
|
+
(``BaseTool`` instances) and other objects pass through unchanged.
|
|
226
|
+
"""
|
|
227
|
+
if isinstance(t, AsyncFunctionTaskTemplate):
|
|
228
|
+
return tool(t)
|
|
229
|
+
return t
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: flyteplugins-agents-crewai
|
|
3
|
+
Version: 2.5.15
|
|
4
|
+
Summary: Run CrewAI agents on Flyte.
|
|
5
|
+
Author-email: Niels Bantilan <niels@union.ai>
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: flyteplugins-agents-core
|
|
9
|
+
Requires-Dist: crewai
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
flyteplugins/agents/crewai/__init__.py,sha256=A1LW9r9gjoreQxQmmh0YHX9V2XUW_ZE8vykTisWHQOI,598
|
|
2
|
+
flyteplugins/agents/crewai/_durable.py,sha256=f3onOoWpINXQoZVa0onwp7uCY-sgLEVBlPwhVeHzEyI,6738
|
|
3
|
+
flyteplugins/agents/crewai/_memory.py,sha256=CfcveVuJRIDBEt7B3SK_k2I5j0yI71LRlNIZQ8Kinsw,3041
|
|
4
|
+
flyteplugins/agents/crewai/_run.py,sha256=_tIw3juVDtd6UxYyAyBLv5SpJNI4ZE7xTw4jPJq8AUc,6236
|
|
5
|
+
flyteplugins/agents/crewai/_tools.py,sha256=YRJgraQn7IPh86JoX3Ln2RQTgc36i0I5WYefS7GcqNM,8641
|
|
6
|
+
flyteplugins_agents_crewai-2.5.15.dist-info/METADATA,sha256=vaonWVyB0GMQ_LT2H-TFXWyzbyM3TiIZIc5xBreYGHY,280
|
|
7
|
+
flyteplugins_agents_crewai-2.5.15.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
8
|
+
flyteplugins_agents_crewai-2.5.15.dist-info/top_level.txt,sha256=cgd779rPu9EsvdtuYgUxNHHgElaQvPn74KhB5XSeMBE,13
|
|
9
|
+
flyteplugins_agents_crewai-2.5.15.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
flyteplugins
|