flyteplugins-agents-openai 2.5.9__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/openai/__init__.py +35 -0
- flyteplugins/agents/openai/_durable.py +111 -0
- flyteplugins/agents/openai/_memory.py +47 -0
- flyteplugins/agents/openai/_observability.py +106 -0
- flyteplugins/agents/openai/_run.py +99 -0
- flyteplugins/agents/openai/_tools.py +124 -0
- flyteplugins_agents_openai-2.5.9.dist-info/METADATA +145 -0
- flyteplugins_agents_openai-2.5.9.dist-info/RECORD +10 -0
- flyteplugins_agents_openai-2.5.9.dist-info/WHEEL +5 -0
- flyteplugins_agents_openai-2.5.9.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""OpenAI Agents SDK adapter for Flyte.
|
|
2
|
+
|
|
3
|
+
Bring your own ``openai-agents`` ``Agent`` (tools, handoffs, guardrails) and run
|
|
4
|
+
it durably on Flyte. The adapter provides three things, each independently
|
|
5
|
+
usable:
|
|
6
|
+
|
|
7
|
+
- :func:`tool` — turn a Flyte ``@env.task`` into an OpenAI Agents tool
|
|
8
|
+
that executes as a durable child action (own container/GPU, retries,
|
|
9
|
+
caching) when the agent calls it.
|
|
10
|
+
- :class:`FlyteModelProvider` — a ``ModelProvider`` wrapper that records each
|
|
11
|
+
model turn through ``flyte.trace`` so a crashed/retried run replays
|
|
12
|
+
completed turns instead of re-calling (and re-billing) the LLM.
|
|
13
|
+
- :class:`FlyteTracingProcessor` — forwards the OpenAI Agents trace (turns, tool
|
|
14
|
+
calls, handoffs, token usage) into the Flyte task report for observability.
|
|
15
|
+
|
|
16
|
+
:func:`run_agent` wires all three together for the common case. For full control,
|
|
17
|
+
use them directly with ``Runner.run`` and a ``RunConfig``.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from ._durable import FlyteModel, FlyteModelProvider
|
|
21
|
+
from ._memory import FlyteSession
|
|
22
|
+
from ._observability import FlyteTracingProcessor, install_flyte_tracing
|
|
23
|
+
from ._run import run_agent
|
|
24
|
+
from ._tools import FunctionTool, tool
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"FlyteModel",
|
|
28
|
+
"FlyteModelProvider",
|
|
29
|
+
"FlyteSession",
|
|
30
|
+
"FlyteTracingProcessor",
|
|
31
|
+
"FunctionTool",
|
|
32
|
+
"install_flyte_tracing",
|
|
33
|
+
"run_agent",
|
|
34
|
+
"tool",
|
|
35
|
+
]
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Durable, replayable model turns for the OpenAI Agents SDK.
|
|
2
|
+
|
|
3
|
+
The OpenAI Agents ``Runner`` owns the agent loop. To make that loop durable we
|
|
4
|
+
swap in a :class:`FlyteModelProvider`: it wraps the real model so every
|
|
5
|
+
``get_response`` (one model turn) is recorded through the shared
|
|
6
|
+
:func:`~flyteplugins.agents.core.durable_step` (a ``flyte.trace`` leaf). Inside a
|
|
7
|
+
Flyte task this means a crashed/retried run replays completed turns from
|
|
8
|
+
their recorded outputs instead of re-calling (and re-billing) the model. Tool
|
|
9
|
+
calls run as durable child actions (see :func:`flyteplugins.agents.openai.tool`),
|
|
10
|
+
so the whole agent run becomes crash-resilient and self-healing when the enclosing task
|
|
11
|
+
carries ``retries=...``.
|
|
12
|
+
|
|
13
|
+
The turn is recorded as JSON (pydantic round-trips the SDK's ``ModelResponse``
|
|
14
|
+
faithfully and stays readable in the Flyte UI). The non-serializable real call is
|
|
15
|
+
captured in a closure passed to ``durable_step``.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import typing
|
|
21
|
+
from collections.abc import AsyncIterator
|
|
22
|
+
|
|
23
|
+
from agents.items import ModelResponse
|
|
24
|
+
from agents.models.interface import Model, ModelProvider
|
|
25
|
+
from agents.models.multi_provider import MultiProvider
|
|
26
|
+
from flyteplugins.agents.core import durable_step, fingerprint, jsonable
|
|
27
|
+
from pydantic import TypeAdapter
|
|
28
|
+
|
|
29
|
+
# pydantic round-trips ModelResponse (and its nested OpenAI types) faithfully;
|
|
30
|
+
# storing JSON keeps the recorded turn human-readable in the Flyte UI.
|
|
31
|
+
_RESPONSE_ADAPTER: TypeAdapter[ModelResponse] = TypeAdapter(ModelResponse)
|
|
32
|
+
|
|
33
|
+
# Positional parameters of ``Model.get_response`` used for fingerprinting
|
|
34
|
+
# (``tracing`` and the keyword-only ids are intentionally ignored).
|
|
35
|
+
_GET_RESPONSE_PARAMS = (
|
|
36
|
+
"system_instructions",
|
|
37
|
+
"input",
|
|
38
|
+
"model_settings",
|
|
39
|
+
"tools",
|
|
40
|
+
"output_schema",
|
|
41
|
+
"handoffs",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _bind(args: tuple[typing.Any, ...], kwargs: dict[str, typing.Any]) -> dict[str, typing.Any]:
|
|
46
|
+
bound = dict(kwargs)
|
|
47
|
+
for name, value in zip(_GET_RESPONSE_PARAMS, args):
|
|
48
|
+
bound.setdefault(name, value)
|
|
49
|
+
return bound
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _request_fingerprint(args: tuple[typing.Any, ...], kwargs: dict[str, typing.Any]) -> str:
|
|
53
|
+
"""Deterministic memo key for a model turn — tool/handoff names, not callables."""
|
|
54
|
+
bound = _bind(args, kwargs)
|
|
55
|
+
return fingerprint(
|
|
56
|
+
{
|
|
57
|
+
"system": bound.get("system_instructions"),
|
|
58
|
+
"input": jsonable(bound.get("input")),
|
|
59
|
+
"model_settings": jsonable(bound.get("model_settings")),
|
|
60
|
+
"tools": sorted(getattr(t, "name", str(t)) for t in (bound.get("tools") or [])),
|
|
61
|
+
"handoffs": sorted(
|
|
62
|
+
getattr(h, "agent_name", getattr(h, "tool_name", str(h))) for h in (bound.get("handoffs") or [])
|
|
63
|
+
),
|
|
64
|
+
"output_schema": getattr(bound.get("output_schema"), "name", None),
|
|
65
|
+
}
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class FlyteModel(Model):
|
|
70
|
+
"""Wrap a :class:`~agents.models.interface.Model` so each turn is durable.
|
|
71
|
+
|
|
72
|
+
``get_response`` is recorded/replayed via ``durable_step``. ``stream_response``
|
|
73
|
+
is delegated unchanged: streamed turns are not memoized in this version (tool
|
|
74
|
+
calls remain durable regardless).
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
def __init__(self, inner: Model):
|
|
78
|
+
self._inner = inner
|
|
79
|
+
|
|
80
|
+
async def get_response(self, *args: typing.Any, **kwargs: typing.Any) -> typing.Any:
|
|
81
|
+
return await durable_step(
|
|
82
|
+
_request_fingerprint(args, kwargs),
|
|
83
|
+
lambda: self._inner.get_response(*args, **kwargs),
|
|
84
|
+
name="model_turn",
|
|
85
|
+
dumps=lambda response: _RESPONSE_ADAPTER.dump_json(response).decode("utf-8"),
|
|
86
|
+
loads=_RESPONSE_ADAPTER.validate_json,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
def stream_response(self, *args: typing.Any, **kwargs: typing.Any) -> AsyncIterator[typing.Any]:
|
|
90
|
+
return self._inner.stream_response(*args, **kwargs)
|
|
91
|
+
|
|
92
|
+
async def close(self) -> None:
|
|
93
|
+
await self._inner.close()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class FlyteModelProvider(ModelProvider):
|
|
97
|
+
"""Wrap a ``ModelProvider`` so every model it returns produces durable turns.
|
|
98
|
+
|
|
99
|
+
Pass an explicit ``inner`` provider to keep custom routing (Azure, a gateway,
|
|
100
|
+
a local OpenAI-compatible server); defaults to the SDK's ``MultiProvider``.
|
|
101
|
+
Set it on ``RunConfig.model_provider`` (``run_agent`` does this for you).
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
def __init__(self, inner: ModelProvider | None = None):
|
|
105
|
+
self._inner = inner or MultiProvider()
|
|
106
|
+
|
|
107
|
+
def get_model(self, model_name: str | None) -> Model:
|
|
108
|
+
return FlyteModel(self._inner.get_model(model_name))
|
|
109
|
+
|
|
110
|
+
async def aclose(self) -> None:
|
|
111
|
+
await self._inner.aclose()
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""OpenAI Agents ``Session`` backed by a keyed Flyte ``MemoryStore``.
|
|
2
|
+
|
|
3
|
+
The OpenAI Agents SDK reads and writes conversation history through a ``Session``
|
|
4
|
+
(``get_items`` / ``add_items`` / ``pop_item`` / ``clear_session``).
|
|
5
|
+
:class:`FlyteSession` implements that protocol over a durable, cross-run
|
|
6
|
+
:class:`~flyte.ai.agents.memory.MemoryStore` (keyed by a ``memory_key``), so
|
|
7
|
+
passing ``session=FlyteSession(store)`` to ``Runner.run`` gives the agent memory
|
|
8
|
+
that survives across runs and workers — backed by object storage rather than the
|
|
9
|
+
SDK's default local SQLite (which doesn't persist on a distributed backend).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import typing
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class FlyteSession:
|
|
18
|
+
"""An ``agents`` ``Session`` whose items live in a keyed Flyte ``MemoryStore``.
|
|
19
|
+
|
|
20
|
+
The store's ``messages`` transcript is the session item list; ``add_items``
|
|
21
|
+
persists durably (an object-store upload) so the next run for the same key
|
|
22
|
+
resumes the conversation.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, store: typing.Any) -> None:
|
|
26
|
+
self._store = store
|
|
27
|
+
|
|
28
|
+
async def get_items(self, limit: int | None = None) -> list[dict[str, typing.Any]]:
|
|
29
|
+
items = list(self._store.messages)
|
|
30
|
+
return items[-limit:] if limit else items
|
|
31
|
+
|
|
32
|
+
async def add_items(self, items: list[dict[str, typing.Any]]) -> None:
|
|
33
|
+
if not items:
|
|
34
|
+
return
|
|
35
|
+
self._store.extend(items)
|
|
36
|
+
await self._store.save.aio()
|
|
37
|
+
|
|
38
|
+
async def pop_item(self) -> dict[str, typing.Any] | None:
|
|
39
|
+
if not self._store.messages:
|
|
40
|
+
return None
|
|
41
|
+
item = self._store.messages.pop()
|
|
42
|
+
await self._store.save.aio()
|
|
43
|
+
return item
|
|
44
|
+
|
|
45
|
+
async def clear_session(self) -> None:
|
|
46
|
+
self._store.messages.clear()
|
|
47
|
+
await self._store.save.aio()
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Forward the OpenAI Agents trace into the Flyte task report.
|
|
2
|
+
|
|
3
|
+
The OpenAI Agents SDK emits a structured trace of every run (agent spans, model
|
|
4
|
+
``generation``/``response`` turns, ``function`` tool calls, ``handoff`` and
|
|
5
|
+
``guardrail`` spans). :class:`FlyteTracingProcessor` is a ``TracingProcessor``
|
|
6
|
+
that maps those spans onto the shared :class:`~flyteplugins.agents.core.ReportTimeline`,
|
|
7
|
+
rendering an in-run timeline (timings, token usage, tool inputs/outputs) into a tab of
|
|
8
|
+
the enclosing task's Flyte report, alongside the tool tasks that already show up as
|
|
9
|
+
Flyte actions.
|
|
10
|
+
|
|
11
|
+
It is best-effort by contract: a processor must never raise into the agent loop,
|
|
12
|
+
and report writes are skipped silently when there is no active report.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import typing
|
|
18
|
+
|
|
19
|
+
from agents import add_trace_processor, set_trace_processors
|
|
20
|
+
from agents.tracing.processor_interface import TracingProcessor
|
|
21
|
+
from flyte._logging import logger
|
|
22
|
+
from flyteplugins.agents.core import ReportTimeline, abbrev, duration_ms
|
|
23
|
+
|
|
24
|
+
_ICONS = {
|
|
25
|
+
"agent": "🤖",
|
|
26
|
+
"generation": "🧠",
|
|
27
|
+
"response": "🧠",
|
|
28
|
+
"function": "🛠️",
|
|
29
|
+
"handoff": "🔀",
|
|
30
|
+
"guardrail": "🛡️",
|
|
31
|
+
"mcp_tools": "🔌",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _summarize(kind: str, export: dict[str, typing.Any]) -> str:
|
|
36
|
+
"""Render the OpenAI-specific span payload as a compact HTML detail string."""
|
|
37
|
+
if kind == "function":
|
|
38
|
+
return f"<code>{abbrev(export.get('input'), 160)}</code> → <code>{abbrev(export.get('output'), 160)}</code>"
|
|
39
|
+
if kind in ("generation", "response"):
|
|
40
|
+
usage = export.get("usage") or {}
|
|
41
|
+
if usage:
|
|
42
|
+
parts = ", ".join(f"{k}={v}" for k, v in usage.items())
|
|
43
|
+
return f'<span style="opacity:.7">{abbrev(parts)}</span>'
|
|
44
|
+
return ""
|
|
45
|
+
if kind == "handoff":
|
|
46
|
+
return f"{abbrev(export.get('from_agent'))} → {abbrev(export.get('to_agent'))}"
|
|
47
|
+
return ""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class FlyteTracingProcessor(TracingProcessor):
|
|
51
|
+
"""Map OpenAI Agents spans onto the shared :class:`ReportTimeline`."""
|
|
52
|
+
|
|
53
|
+
def __init__(self, tab_name: str = "Agent"):
|
|
54
|
+
self._timeline = ReportTimeline(tab_name)
|
|
55
|
+
|
|
56
|
+
def on_trace_start(self, trace: typing.Any) -> None:
|
|
57
|
+
self._timeline.heading("OpenAI agent")
|
|
58
|
+
|
|
59
|
+
def on_trace_end(self, trace: typing.Any) -> None:
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
def on_span_start(self, span: typing.Any) -> None:
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
def on_span_end(self, span: typing.Any) -> None:
|
|
66
|
+
try:
|
|
67
|
+
self._render(span)
|
|
68
|
+
except Exception: # pragma: no cover - observability must never break the loop
|
|
69
|
+
logger.debug("FlyteTracingProcessor failed to render a span", exc_info=True)
|
|
70
|
+
|
|
71
|
+
def shutdown(self) -> None:
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
def force_flush(self) -> None:
|
|
75
|
+
pass
|
|
76
|
+
|
|
77
|
+
def _render(self, span: typing.Any) -> None:
|
|
78
|
+
data = getattr(span, "span_data", None)
|
|
79
|
+
if data is None:
|
|
80
|
+
return
|
|
81
|
+
kind = getattr(data, "type", "custom")
|
|
82
|
+
export = data.export() if hasattr(data, "export") else {}
|
|
83
|
+
duration = duration_ms(getattr(span, "started_at", None), getattr(span, "ended_at", None))
|
|
84
|
+
self._timeline.row(
|
|
85
|
+
icon=_ICONS.get(kind, "•"),
|
|
86
|
+
label=export.get("name") or kind,
|
|
87
|
+
meta=" · ".join(p for p in (kind, duration) if p),
|
|
88
|
+
detail=_summarize(kind, export),
|
|
89
|
+
error=getattr(span, "error", None),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def install_flyte_tracing(*, exclusive: bool = True, tab_name: str = "Agent") -> FlyteTracingProcessor:
|
|
94
|
+
"""Install a :class:`FlyteTracingProcessor` as a global trace processor.
|
|
95
|
+
|
|
96
|
+
With ``exclusive=True`` (default) it replaces all processors, so traces are
|
|
97
|
+
rendered only into the Flyte report and nothing is uploaded to OpenAI's
|
|
98
|
+
tracing backend. Set ``exclusive=False`` to keep the SDK's default processors
|
|
99
|
+
(e.g. to also export to the OpenAI dashboard) and add Flyte alongside.
|
|
100
|
+
"""
|
|
101
|
+
processor = FlyteTracingProcessor(tab_name=tab_name)
|
|
102
|
+
if exclusive:
|
|
103
|
+
set_trace_processors([processor])
|
|
104
|
+
else:
|
|
105
|
+
add_trace_processor(processor)
|
|
106
|
+
return processor
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""``run_agent`` — the one-call path to run an OpenAI agent durably on Flyte."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typing
|
|
6
|
+
|
|
7
|
+
from agents import Agent, RunConfig, Runner
|
|
8
|
+
from flyte._task import TaskTemplate
|
|
9
|
+
from flyteplugins.agents.core import flush_report, resolve_memory
|
|
10
|
+
|
|
11
|
+
from ._durable import FlyteModelProvider
|
|
12
|
+
from ._memory import FlyteSession
|
|
13
|
+
from ._observability import install_flyte_tracing
|
|
14
|
+
from ._tools import tool
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _normalize_tools(tools: typing.Sequence[typing.Any]) -> list[typing.Any]:
|
|
18
|
+
"""Accept bare ``@env.task`` templates as tools by wrapping them on the fly."""
|
|
19
|
+
normalized: list[typing.Any] = []
|
|
20
|
+
for t in tools or ():
|
|
21
|
+
normalized.append(tool(t) if isinstance(t, TaskTemplate) else t)
|
|
22
|
+
return normalized
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
async def run_agent(
|
|
26
|
+
input: str | list[typing.Any],
|
|
27
|
+
*,
|
|
28
|
+
agent: Agent | None = None,
|
|
29
|
+
tools: typing.Sequence[typing.Any] = (),
|
|
30
|
+
model: str = "gpt-4.1",
|
|
31
|
+
instructions: str | None = None,
|
|
32
|
+
name: str = "flyte-agent",
|
|
33
|
+
max_turns: int = 10,
|
|
34
|
+
durable: bool = True,
|
|
35
|
+
observability: bool = True,
|
|
36
|
+
run_config: RunConfig | None = None,
|
|
37
|
+
memory_key: str | None = None,
|
|
38
|
+
) -> str:
|
|
39
|
+
"""Run an OpenAI Agents SDK agent with Flyte providing the runtime.
|
|
40
|
+
|
|
41
|
+
Call this from inside an ``@env.task`` — that task is the durable parent.
|
|
42
|
+
Within it, each model turn is recorded via ``flyte.trace`` (replayed on
|
|
43
|
+
retry) and each tool call runs as a durable Flyte child action. Give the
|
|
44
|
+
enclosing task ``retries=...`` for self-healing and ``report=True`` to see
|
|
45
|
+
the agent timeline.
|
|
46
|
+
|
|
47
|
+
Provide either a fully-built ``agent`` (keeping its handoffs/guardrails), or
|
|
48
|
+
``tools`` + ``instructions`` + ``model`` to have one built for you. ``tools``
|
|
49
|
+
may be :func:`tool`-wrapped tools or bare ``@env.task`` templates
|
|
50
|
+
(wrapped automatically).
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
input: The user prompt (or a list of input items).
|
|
54
|
+
agent: A pre-built ``agents.Agent``. Mutually exclusive with ``tools``.
|
|
55
|
+
tools: Tools to expose (when ``agent`` is not given).
|
|
56
|
+
model: Model name (when ``agent`` is not given).
|
|
57
|
+
instructions: System instructions (when ``agent`` is not given).
|
|
58
|
+
name: Agent name (when ``agent`` is not given).
|
|
59
|
+
max_turns: Maximum model to tool turns and vice versa before the SDK raises.
|
|
60
|
+
durable: Record/replay each model turn via ``flyte.trace``.
|
|
61
|
+
observability: Render the run timeline into the Flyte task report.
|
|
62
|
+
run_config: A custom ``RunConfig``; ``model_provider`` is wrapped for
|
|
63
|
+
durability unless ``durable=False``.
|
|
64
|
+
memory_key: Stable id (e.g. a user/thread id) for cross-run memory.
|
|
65
|
+
When set, conversation history is loaded from and saved to a durable,
|
|
66
|
+
keyed ``MemoryStore`` (via the SDK's ``Session``), so a later run with
|
|
67
|
+
the same key continues the conversation. ``None`` disables memory.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
The agent's final output as a string.
|
|
71
|
+
"""
|
|
72
|
+
if agent is not None and tools:
|
|
73
|
+
raise ValueError("Pass either `agent` (with its own tools) or `tools`, not both.")
|
|
74
|
+
|
|
75
|
+
if agent is None:
|
|
76
|
+
agent = Agent(
|
|
77
|
+
name=name,
|
|
78
|
+
instructions=instructions or "You are a helpful assistant.",
|
|
79
|
+
model=model,
|
|
80
|
+
tools=_normalize_tools(tools),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
config = run_config or RunConfig()
|
|
84
|
+
if durable:
|
|
85
|
+
config.model_provider = FlyteModelProvider(config.model_provider)
|
|
86
|
+
if observability:
|
|
87
|
+
install_flyte_tracing()
|
|
88
|
+
|
|
89
|
+
store = await resolve_memory(memory_key)
|
|
90
|
+
session = FlyteSession(store) if store is not None else None
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
result = await Runner.run(agent, input, max_turns=max_turns, run_config=config, session=session)
|
|
94
|
+
finally:
|
|
95
|
+
if observability:
|
|
96
|
+
await flush_report()
|
|
97
|
+
|
|
98
|
+
final = result.final_output
|
|
99
|
+
return final if isinstance(final, str) else str(final)
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Turn Flyte tasks into OpenAI Agents tools that execute as durable actions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
import json
|
|
7
|
+
import typing
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from functools import partial
|
|
10
|
+
|
|
11
|
+
from agents import FunctionTool as OpenAIFunctionTool
|
|
12
|
+
from agents import function_tool as openai_function_tool
|
|
13
|
+
from agents.function_schema import function_schema
|
|
14
|
+
from agents.tool_context import ToolContext
|
|
15
|
+
from flyte._task import AsyncFunctionTaskTemplate, TaskTemplate
|
|
16
|
+
from flyte.models import NativeInterface
|
|
17
|
+
from flyteplugins.agents.core import attach_tool_resolver
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class FunctionTool(OpenAIFunctionTool):
|
|
22
|
+
"""An OpenAI Agents ``FunctionTool`` backed by a Flyte task.
|
|
23
|
+
|
|
24
|
+
Behaves exactly like ``agents.FunctionTool`` from the SDK's perspective, but
|
|
25
|
+
when the agent invokes it the call is dispatched to the underlying Flyte task
|
|
26
|
+
— so it runs as a durable child action (its own container/resources, with
|
|
27
|
+
retries and caching) rather than inline in the agent's process.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
task: TaskTemplate | None = None
|
|
31
|
+
native_interface: NativeInterface | None = None
|
|
32
|
+
report: bool = False
|
|
33
|
+
|
|
34
|
+
async def execute(self, *args: typing.Any, **kwargs: typing.Any) -> typing.Any:
|
|
35
|
+
"""Run the wrapped task directly (a durable child action in a task context)."""
|
|
36
|
+
if self.task is None: # pragma: no cover - defensive
|
|
37
|
+
raise RuntimeError("FunctionTool has no backing task to execute.")
|
|
38
|
+
return await self.task.aio(*args, **kwargs)
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def __wrapped_task__(self) -> typing.Any:
|
|
42
|
+
"""The backing :class:`TaskTemplate` so :class:`ToolTaskResolver` can recover it.
|
|
43
|
+
|
|
44
|
+
Stacking ``@tool`` on ``@env.task`` rebinds the module attribute
|
|
45
|
+
to this tool, hiding the task from the default resolver;
|
|
46
|
+
``flyteplugins.agents.core.ToolTaskResolver`` uses this hook to recover the
|
|
47
|
+
real task on the worker (so the task runs its own body instead of
|
|
48
|
+
re-dispatching itself, which would otherwise recurse indefinitely).
|
|
49
|
+
"""
|
|
50
|
+
return self.task if isinstance(self.task, TaskTemplate) else None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def tool(
|
|
54
|
+
func: AsyncFunctionTaskTemplate | typing.Callable | None = None,
|
|
55
|
+
**kwargs: typing.Any,
|
|
56
|
+
) -> FunctionTool | OpenAIFunctionTool:
|
|
57
|
+
"""Flyte-aware replacement for ``agents.function_tool`` — named ``tool`` for consistency.
|
|
58
|
+
|
|
59
|
+
- For an ``@env.task`` (an ``AsyncFunctionTaskTemplate``): returns a
|
|
60
|
+
:class:`FunctionTool` whose invocation runs the task as a durable Flyte
|
|
61
|
+
action. The tool's JSON schema, name and description are derived by the
|
|
62
|
+
OpenAI Agents SDK from the task's function signature, so strict-mode tool
|
|
63
|
+
calling works unchanged.
|
|
64
|
+
- For a plain callable or a ``@flyte.trace`` helper: forwards to the native
|
|
65
|
+
``agents.function_tool`` (runs inline; ``@flyte.trace`` helpers are still
|
|
66
|
+
recorded for observability when inside a task).
|
|
67
|
+
|
|
68
|
+
``**kwargs`` (e.g. ``name_override``, ``description_override``) are forwarded
|
|
69
|
+
to ``agents.function_tool`` in both cases.
|
|
70
|
+
|
|
71
|
+
Usable as a bare decorator, a parametrized decorator, or a direct call::
|
|
72
|
+
|
|
73
|
+
@tool
|
|
74
|
+
@env.task
|
|
75
|
+
async def get_weather(city: str) -> str: ...
|
|
76
|
+
|
|
77
|
+
weather = tool(get_weather, name_override="weather")
|
|
78
|
+
"""
|
|
79
|
+
if func is None:
|
|
80
|
+
return partial(tool, **kwargs)
|
|
81
|
+
|
|
82
|
+
if isinstance(func, AsyncFunctionTaskTemplate):
|
|
83
|
+
return _task_to_tool(func, **kwargs)
|
|
84
|
+
|
|
85
|
+
# Plain callables and @flyte.trace-decorated functions use the native path.
|
|
86
|
+
return openai_function_tool(func, **kwargs)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _task_to_tool(task: AsyncFunctionTaskTemplate, **kwargs: typing.Any) -> FunctionTool:
|
|
90
|
+
"""Build a :class:`FunctionTool` from a Flyte task.
|
|
91
|
+
|
|
92
|
+
Only the stable, public fields of ``agents.FunctionTool`` are copied from a
|
|
93
|
+
base tool built by ``agents.function_tool`` — we deliberately do not reflect
|
|
94
|
+
over private fields, so this stays robust across SDK versions.
|
|
95
|
+
"""
|
|
96
|
+
base = openai_function_tool(task.func, **kwargs)
|
|
97
|
+
|
|
98
|
+
async def _on_invoke_tool(ctx: ToolContext[typing.Any], input: str) -> typing.Any:
|
|
99
|
+
data: dict[str, typing.Any] = json.loads(input) if input else {}
|
|
100
|
+
schema = function_schema(task.func)
|
|
101
|
+
parsed = schema.params_pydantic_model(**data) if data else schema.params_pydantic_model()
|
|
102
|
+
args, kwargs_ = schema.to_call_args(parsed)
|
|
103
|
+
# In a Flyte task context, calling the task submits a durable child
|
|
104
|
+
# action through the controller; locally it runs inline. Sync tasks
|
|
105
|
+
# return a value directly, async tasks return an awaitable.
|
|
106
|
+
out = task(*args, **kwargs_)
|
|
107
|
+
if inspect.isawaitable(out):
|
|
108
|
+
out = await out
|
|
109
|
+
return out
|
|
110
|
+
|
|
111
|
+
# The returned tool shadows the task at module scope, so point the task at the
|
|
112
|
+
# shared resolver that recovers it on the worker via ``__wrapped_task__``.
|
|
113
|
+
attach_tool_resolver(task)
|
|
114
|
+
|
|
115
|
+
return FunctionTool(
|
|
116
|
+
name=base.name,
|
|
117
|
+
description=base.description,
|
|
118
|
+
params_json_schema=base.params_json_schema,
|
|
119
|
+
on_invoke_tool=_on_invoke_tool,
|
|
120
|
+
strict_json_schema=base.strict_json_schema,
|
|
121
|
+
task=task,
|
|
122
|
+
native_interface=task.native_interface,
|
|
123
|
+
report=task.report,
|
|
124
|
+
)
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: flyteplugins-agents-openai
|
|
3
|
+
Version: 2.5.9
|
|
4
|
+
Summary: Run OpenAI Agents SDK agents on Flyte.
|
|
5
|
+
Author-email: Samhita Alla <samhita@union.ai>
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: flyteplugins-agents-core
|
|
9
|
+
Requires-Dist: openai-agents
|
|
10
|
+
|
|
11
|
+
# flyteplugins-agents-openai
|
|
12
|
+
|
|
13
|
+
Run [OpenAI Agents SDK](https://openai.github.io/openai-agents-python/) agents on
|
|
14
|
+
Flyte. You keep writing agents in the SDK's own idioms; Flyte is the durable
|
|
15
|
+
orchestration runtime underneath — replay, automatic retries / self-healing,
|
|
16
|
+
per-tool containerized execution (CPU/GPU, caching), and observability.
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pip install flyteplugins-agents-openai
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
import flyte
|
|
24
|
+
from flyteplugins.agents.openai import tool, run_agent
|
|
25
|
+
|
|
26
|
+
env = flyte.TaskEnvironment(
|
|
27
|
+
"openai-agent",
|
|
28
|
+
secrets=[flyte.Secret(key="openai_api_key", as_env_var="OPENAI_API_KEY")],
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# A tool that is also a durable, cached Flyte task.
|
|
32
|
+
@tool
|
|
33
|
+
@env.task(cache="auto", retries=3)
|
|
34
|
+
async def get_weather(city: str) -> str:
|
|
35
|
+
"""Get the current weather for a city."""
|
|
36
|
+
return f"The weather in {city} is sunny, 22°C."
|
|
37
|
+
|
|
38
|
+
# The durable parent. retries=3 -> self-healing; report=True -> agent timeline.
|
|
39
|
+
@env.task(report=True, retries=3)
|
|
40
|
+
async def city_agent(question: str) -> str:
|
|
41
|
+
return await run_agent(
|
|
42
|
+
question,
|
|
43
|
+
tools=[get_weather],
|
|
44
|
+
instructions="You are a concise assistant. Use the tools to answer.",
|
|
45
|
+
model="gpt-4.1",
|
|
46
|
+
)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## How it maps to Flyte
|
|
50
|
+
|
|
51
|
+
- The SDK owns the loop — we don't reimplement it. `run_agent` drives the
|
|
52
|
+
OpenAI Agents `Runner`; the agent run is your `@env.task` (the durable
|
|
53
|
+
parent) and the SDK runs its agent loop inside it.
|
|
54
|
+
- Tools as durable child actions. `tool` wraps an `@env.task` so that
|
|
55
|
+
when the agent calls a tool, the task runs as a durable Flyte child action
|
|
56
|
+
(its own container/resources, retries, caching) — not inline in the agent
|
|
57
|
+
process. The SDK derives the tool's JSON schema, name and description from the
|
|
58
|
+
task signature, so strict tool-calling works unchanged.
|
|
59
|
+
- Durable, replayable model turns. Each model turn is recorded via
|
|
60
|
+
`flyte.trace` by tracing the seam below the loop — a `FlyteModelProvider`
|
|
61
|
+
set on `RunConfig.model_provider`. If the task crashes and Flyte retries it,
|
|
62
|
+
completed turns and tool calls replay from their recorded outputs instead of
|
|
63
|
+
re-calling (and re-billing) the model.
|
|
64
|
+
- Self-healing. `retries=...` on the agent task plus per-turn / per-tool replay
|
|
65
|
+
means transient failures recover automatically without redoing completed work.
|
|
66
|
+
- Observability. The OpenAI Agents trace (turns, tool calls, handoffs, token
|
|
67
|
+
usage) renders into the task report (`report=True`); `install_flyte_tracing()`
|
|
68
|
+
replaces the OpenAI exporter (`exclusive=True`) so nothing is uploaded
|
|
69
|
+
externally.
|
|
70
|
+
|
|
71
|
+
The API key is read from the environment. Wire it as a Flyte secret.
|
|
72
|
+
|
|
73
|
+
### Bring your own agent
|
|
74
|
+
|
|
75
|
+
Already wrote an `agents.Agent` with handoffs and guardrails? Pass it through:
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from agents import Agent
|
|
79
|
+
|
|
80
|
+
triage = Agent(name="triage", handoffs=[...], input_guardrails=[...])
|
|
81
|
+
|
|
82
|
+
@env.task(report=True, retries=3)
|
|
83
|
+
async def run(goal: str) -> str:
|
|
84
|
+
return await run_agent(goal, agent=triage)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### Power-user building blocks
|
|
88
|
+
|
|
89
|
+
`run_agent` wires three independently usable pieces; reach for them directly when
|
|
90
|
+
driving `Runner.run` yourself:
|
|
91
|
+
|
|
92
|
+
- `tool` — turn a Flyte task into an OpenAI Agents tool.
|
|
93
|
+
- `FlyteModelProvider` — set on `RunConfig.model_provider` to make model turns
|
|
94
|
+
durable (the seam below the loop).
|
|
95
|
+
- `install_flyte_tracing()` / `FlyteTracingProcessor` — render the trace into the
|
|
96
|
+
report (`exclusive=True` by default replaces the OpenAI exporter so nothing is
|
|
97
|
+
uploaded externally).
|
|
98
|
+
|
|
99
|
+
## Memory
|
|
100
|
+
|
|
101
|
+
Pass `memory_key` (a user/thread id) for cross-run memory — the agent continues
|
|
102
|
+
the same conversation across separate runs, workers and restarts:
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
await run_agent(message, model="gpt-4.1", memory_key="user-alice")
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
It backs the OpenAI Agents SDK `Session` with a durable, keyed `MemoryStore` (object
|
|
109
|
+
storage), so unlike the SDK's default local-SQLite session, it persists on a
|
|
110
|
+
distributed backend. The same store also holds path-addressed facts for long-term
|
|
111
|
+
`remember` / `recall` memory.
|
|
112
|
+
|
|
113
|
+
## Examples
|
|
114
|
+
|
|
115
|
+
See [`examples/`](examples/):
|
|
116
|
+
|
|
117
|
+
- [`openai_durable_agent.py`](examples/openai_durable_agent.py) — a single durable
|
|
118
|
+
agent: tools as Flyte tasks, traced model turns, agent timeline in the report.
|
|
119
|
+
- [`openai_multi_agent.py`](examples/openai_multi_agent.py) — multi-agent
|
|
120
|
+
orchestration: a planner agent decomposes a topic, researcher agents fan out
|
|
121
|
+
in parallel, an editor agent synthesizes — each agent its own durable action.
|
|
122
|
+
- [`openai_handoffs.py`](examples/openai_handoffs.py) — handoffs + HITL: a
|
|
123
|
+
triage agent hands off to billing / technical specialists inside one
|
|
124
|
+
`Runner.run`; durability spans the handoff (a mid-chain crash replays both
|
|
125
|
+
agents' turns), a sensitive `issue_refund` tool is gated on a human-approval
|
|
126
|
+
form, and a diagnostic tool runs in a higher-CPU environment.
|
|
127
|
+
- [`openai_crash_resume.py`](examples/openai_crash_resume.py) — crash &
|
|
128
|
+
resume: the task crashes on its first attempt after doing real work; on retry
|
|
129
|
+
the completed model turns replay from their `flyte.trace` records and the tool
|
|
130
|
+
calls are cache hits, so it finishes without re-calling the model. Run on a
|
|
131
|
+
backend to see the replay.
|
|
132
|
+
- [`openai_memory.py`](examples/openai_memory.py) — cross-run memory: two
|
|
133
|
+
separate runs share a `memory_key`; the agent learns a fact in run 1 and recalls
|
|
134
|
+
it in run 2.
|
|
135
|
+
|
|
136
|
+
## Notes
|
|
137
|
+
|
|
138
|
+
- Streamed runs (`Runner.run_streamed`) are not memoized per-turn in this
|
|
139
|
+
version; tool calls remain durable regardless.
|
|
140
|
+
|
|
141
|
+
## Conformance
|
|
142
|
+
|
|
143
|
+
This adapter passes the shared `flyteplugins.agents.core.testing.assert_adapter_conforms`
|
|
144
|
+
check, so it follows the common format (`tool` + `run_agent`, tool tasks wired
|
|
145
|
+
to the resolver), shared with the Claude and Mistral adapters.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
flyteplugins/agents/openai/__init__.py,sha256=bovIL0Nb3f6wB_AjkYmN9tmtSK2vBtbUoUglCmgwgaA,1323
|
|
2
|
+
flyteplugins/agents/openai/_durable.py,sha256=vjQzhHUu8Uauknht99tOY35cDz4cWO1rz7YkR8gqdWI,4503
|
|
3
|
+
flyteplugins/agents/openai/_memory.py,sha256=tTF74A3jVxcfw1NQAQfaXWqPJs6rprC_tjkX8-pCnNw,1762
|
|
4
|
+
flyteplugins/agents/openai/_observability.py,sha256=4Ba7qvvZahmPmxVECvO7IOqa041fQzunW_yLYPlxA-w,4054
|
|
5
|
+
flyteplugins/agents/openai/_run.py,sha256=2xsxk9hrfj9SRIlZ5EiccpAqVa03dzcSMAyC3DoN44M,3897
|
|
6
|
+
flyteplugins/agents/openai/_tools.py,sha256=TVhMKuzi-8ExVNWGQ8GZ71y-ur-Wi2dvFlfWxTiJ6q8,5234
|
|
7
|
+
flyteplugins_agents_openai-2.5.9.dist-info/METADATA,sha256=JCQwCKUs14X9JrP7WhYfjMBZkL9yrHvDbzGJuGQPeVs,5970
|
|
8
|
+
flyteplugins_agents_openai-2.5.9.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
9
|
+
flyteplugins_agents_openai-2.5.9.dist-info/top_level.txt,sha256=cgd779rPu9EsvdtuYgUxNHHgElaQvPn74KhB5XSeMBE,13
|
|
10
|
+
flyteplugins_agents_openai-2.5.9.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
flyteplugins
|