flyteplugins-agents-hermes 2.5.17__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,19 @@
1
+ """Hermes agent adapter for Flyte.
2
+
3
+ Bring your own Hermes agent (the ``hermes-agent`` package's ``AIAgent``) and
4
+ run it durably on Flyte. The adapter provides:
5
+
6
+ - :func:`tool` — turn a Flyte ``@env.task`` into a Hermes tool that executes as
7
+ a durable child action (own container/GPU, retries, caching), registered in
8
+ the Hermes tool registry under the :data:`FLYTE_TOOLSET` toolset.
9
+ - :func:`run_agent` — run the Hermes agent loop inside your task and return the
10
+ final answer.
11
+
12
+ Each tool call runs as a durable Flyte child action, and the run timeline is
13
+ rendered into the Flyte task report.
14
+ """
15
+
16
+ from ._run import run_agent, run_agent_sync
17
+ from ._tools import FLYTE_TOOLSET, tool
18
+
19
+ __all__ = ["FLYTE_TOOLSET", "run_agent", "run_agent_sync", "tool"]
@@ -0,0 +1,53 @@
1
+ """Cross-run Hermes memory — a thin bridge over Flyte's keyed ``MemoryStore``.
2
+
3
+ Hermes keeps conversation state in-memory. This module persists the conversation
4
+ transcript to a durable, keyed :class:`~flyte.ai.agents.memory.MemoryStore` (an
5
+ object-store slot addressed by ``memory_key``) so a later run with the same key
6
+ continues the conversation — across workers and restarts.
7
+
8
+ The transcript is a plain list of ``{"role": ..., "content": ...}`` turns, stored
9
+ via ``read_json`` / ``write_json``.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import typing
15
+
16
+ from flyte._logging import logger
17
+ from flyteplugins.agents.core import resolve_memory as _resolve_memory
18
+
19
+ # Path-addressed slot holding the conversation transcript inside the MemoryStore.
20
+ _MEMORY_HISTORY_PATH = "hermes/history.json"
21
+
22
+
23
+ async def resolve_memory(memory_key: str | None) -> typing.Any | None:
24
+ """Resolve a keyed MemoryStore for Hermes cross-run memory, or ``None``.
25
+
26
+ Best-effort: returns ``None`` when ``memory_key`` is falsy or no durable
27
+ store can be resolved, so memory never breaks a run.
28
+ """
29
+ if not memory_key:
30
+ return None
31
+ return await _resolve_memory(memory_key)
32
+
33
+
34
+ async def load_transcript(store: typing.Any) -> list[dict[str, typing.Any]]:
35
+ """Load the prior conversation transcript (empty list if none)."""
36
+ if store is None:
37
+ return []
38
+ try:
39
+ return list(await store.read_json.aio(_MEMORY_HISTORY_PATH, []))
40
+ except Exception: # pragma: no cover - memory is best-effort, never fatal
41
+ logger.warning("Could not load Hermes memory; continuing without prior history.")
42
+ return []
43
+
44
+
45
+ async def save_transcript(store: typing.Any, transcript: typing.Sequence[dict[str, typing.Any]]) -> None:
46
+ """Persist the conversation transcript back to the keyed store."""
47
+ if store is None:
48
+ return
49
+ try:
50
+ await store.write_json.aio(_MEMORY_HISTORY_PATH, list(transcript))
51
+ await store.save.aio()
52
+ except Exception: # pragma: no cover - memory is best-effort, never fatal
53
+ logger.warning("Could not persist Hermes memory; continuing.")
@@ -0,0 +1,196 @@
1
+ """``run_agent`` — run a Hermes (``hermes-agent``) agent on Flyte.
2
+
3
+ Hermes owns the agent loop: its ``AIAgent`` (the ``run_agent`` top-level module
4
+ of the ``hermes-agent`` package) drives the model and dispatches tools from a
5
+ process-global registry. ``run_agent`` runs that loop inside your ``@env.task``:
6
+ it builds an ``AIAgent`` scoped to exactly the Flyte-task tools you pass (via a
7
+ custom Hermes toolset), drives ``AIAgent.run_conversation`` (a blocking call,
8
+ bridged off the event loop with ``asyncio.to_thread``), and returns the final
9
+ answer. Each tool call runs as a durable Flyte child action (its own
10
+ container/resources, with retries and caching).
11
+
12
+ Observability: the run timeline is rendered into the Flyte task report.
13
+
14
+ The adapter minimizes delta between native Hermes code and Flyte integration:
15
+ bring your own pre-configured ``AIAgent`` (with ``enabled_toolsets`` including
16
+ :data:`~flyteplugins.agents.hermes.FLYTE_TOOLSET`) or let ``run_agent`` build
17
+ one from ``tools`` + ``model``.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import asyncio
23
+ import inspect
24
+ import os
25
+ import re
26
+ import typing
27
+
28
+ from flyteplugins.agents.core import ReportTimeline, flush_report, sync_variant
29
+
30
+ from ._memory import load_transcript, resolve_memory, save_transcript
31
+ from ._tools import tool
32
+
33
+ if typing.TYPE_CHECKING:
34
+ from run_agent import AIAgent as _AIAgent # hermes-agent's top-level module
35
+ else:
36
+ _AIAgent = None
37
+
38
+ _OPENAI_BASE_URL = "https://api.openai.com/v1"
39
+
40
+
41
+ def _coerce_tool(t: typing.Any) -> typing.Any:
42
+ """Route anything not already Hermes-registered through :func:`tool`."""
43
+ if getattr(t, "__hermes_registered__", False):
44
+ return t
45
+ return tool(t)
46
+
47
+
48
+ def _scoped_toolset(agent_name: str, registered: typing.Sequence[typing.Any]) -> str:
49
+ """Create (or refresh) a Hermes toolset holding exactly this agent's tools.
50
+
51
+ :func:`tool` registers every tool under the shared ``FLYTE_TOOLSET``; scoping
52
+ each built agent to a named subset keeps two agents in one process from
53
+ seeing each other's tools.
54
+ """
55
+ from toolsets import create_custom_toolset # hermes-agent
56
+
57
+ toolset = f"flyte-{re.sub(r'[^A-Za-z0-9_-]+', '-', agent_name).strip('-') or 'agent'}"
58
+ names = [getattr(t, "__name__", str(t)) for t in registered]
59
+ create_custom_toolset(toolset, f"Flyte tools for agent {agent_name!r}", tools=names)
60
+ return toolset
61
+
62
+
63
+ async def run_agent(
64
+ input: str,
65
+ *,
66
+ tools: typing.Sequence[typing.Any] = (),
67
+ model: str | None = None,
68
+ instructions: str | None = None,
69
+ agent: typing.Any = None,
70
+ name: str = "hermes-agent",
71
+ durable: bool = True,
72
+ observability: bool = True,
73
+ memory_key: str | None = None,
74
+ **agent_kwargs: typing.Any,
75
+ ) -> str:
76
+ """Run a Hermes agent with the given tools and prompt; return the final text.
77
+
78
+ Await this from an async task as ``await run_agent(...)``; from a sync task
79
+ use :func:`run_agent_sync` instead.
80
+
81
+ Call this from inside an ``@env.task`` — that task is the durable parent.
82
+ Within it, each tool call runs as a durable Flyte child action. Give the
83
+ enclosing task ``retries=...`` for self-healing and ``report=True`` to see
84
+ the agent timeline.
85
+
86
+ Provide either a pre-built ``agent`` (an ``AIAgent`` with its own
87
+ ``enabled_toolsets``) or ``tools`` + ``model`` to have one built for you.
88
+
89
+ Args:
90
+ input: The user prompt.
91
+ tools: ``tool``-wrapped tools or bare ``@env.task`` templates.
92
+ agent: A pre-built Hermes ``AIAgent``. Mutually exclusive with ``tools``.
93
+ model: Model name for the built agent. Required when ``agent`` is not
94
+ given (there is no default model).
95
+ instructions: System prompt. On the builder path this becomes the
96
+ agent's ``ephemeral_system_prompt``; with a pre-built agent it is
97
+ passed as this run's ``system_message``.
98
+ name: Agent name (used for the scoped toolset and observability).
99
+ durable: Accepted for the shared adapter contract, but currently a
100
+ no-op for Hermes: ``hermes-agent`` exposes no per-model-turn hook
101
+ (the model client is buried inside ``AIAgent``), so completed model
102
+ turns cannot be recorded/replayed via ``flyte.trace`` the way the
103
+ openai/langchain adapters do. Tool calls are durable regardless —
104
+ each runs as a Flyte child action with retries and caching — so a
105
+ retried task still self-heals at tool granularity.
106
+ observability: Render the run timeline into the Flyte task report.
107
+ memory_key: Stable id (e.g. a user/thread id) for cross-run memory.
108
+ When set, conversation history is persisted to a keyed ``MemoryStore``
109
+ and resumed on a later run with the same key (passed to Hermes as
110
+ ``conversation_history``).
111
+ **agent_kwargs: Extra keyword arguments for the built ``AIAgent``
112
+ (e.g. ``api_key=``, ``base_url=``, ``provider=``,
113
+ ``max_iterations=``). Only valid on the builder path. When none of
114
+ ``api_key``/``base_url``/``provider`` are given and
115
+ ``OPENAI_API_KEY`` is set, the built agent is pointed at OpenAI
116
+ with that key (Hermes otherwise only reads credentials from its own
117
+ ``hermes setup`` config, which a fresh container doesn't have).
118
+
119
+ Returns:
120
+ The agent's final output as a string.
121
+ """
122
+ timeline = ReportTimeline() if observability else None
123
+ if timeline is not None:
124
+ timeline.heading("Hermes agent")
125
+
126
+ if agent is not None and tools:
127
+ raise ValueError("Pass either `agent` (with its own tools) or `tools`, not both.")
128
+ if agent is not None and agent_kwargs:
129
+ raise ValueError("`**agent_kwargs` configure the built agent; don't pass them with a pre-built `agent=`.")
130
+
131
+ # Cross-run memory: load the prior transcript (if any) and resume the thread.
132
+ store = await resolve_memory(memory_key)
133
+ history = await load_transcript(store)
134
+
135
+ system_message: str | None = None
136
+ if agent is None:
137
+ if model is None:
138
+ raise ValueError("Provide `model=` when building the agent (or pass a pre-built `agent=`).")
139
+
140
+ if _AIAgent is None:
141
+ from run_agent import AIAgent as _HermesAgent # hermes-agent's top-level module
142
+ else:
143
+ _HermesAgent = _AIAgent
144
+
145
+ enabled_toolsets: list[str] = []
146
+ if tools:
147
+ registered = [_coerce_tool(t) for t in tools]
148
+ enabled_toolsets = [_scoped_toolset(name, registered)]
149
+
150
+ has_credentials = any(k in agent_kwargs for k in ("api_key", "base_url", "provider"))
151
+ if not has_credentials and os.environ.get("OPENAI_API_KEY"):
152
+ agent_kwargs["api_key"] = os.environ["OPENAI_API_KEY"]
153
+ agent_kwargs["base_url"] = _OPENAI_BASE_URL
154
+
155
+ agent = _HermesAgent(
156
+ model=model,
157
+ ephemeral_system_prompt=instructions or f"You are a helpful assistant named {name}.",
158
+ enabled_toolsets=enabled_toolsets,
159
+ quiet_mode=True,
160
+ **agent_kwargs,
161
+ )
162
+ else:
163
+ # A pre-built agent keeps its own prompt; explicit instructions ride
164
+ # along as this run's system message.
165
+ system_message = instructions
166
+
167
+ # Drive the agent. ``run_conversation`` is synchronous (Hermes bridges its
168
+ # async tool handlers internally), so run it off the event loop;
169
+ # ``to_thread`` propagates the Flyte task context into the worker thread.
170
+ call_kwargs: dict[str, typing.Any] = {}
171
+ if system_message:
172
+ call_kwargs["system_message"] = system_message
173
+ if history:
174
+ call_kwargs["conversation_history"] = list(history)
175
+
176
+ result = await asyncio.to_thread(lambda: agent.run_conversation(input, **call_kwargs))
177
+ if inspect.isawaitable(result): # tolerate async fakes/wrappers
178
+ result = await result
179
+
180
+ final = result.get("final_response") if isinstance(result, dict) else result
181
+ final = str(final) if final is not None else ""
182
+
183
+ # Persist the updated transcript for the next run with this memory_key.
184
+ if store is not None:
185
+ await save_transcript(
186
+ store,
187
+ [*history, {"role": "user", "content": input}, {"role": "assistant", "content": final}],
188
+ )
189
+
190
+ if observability:
191
+ await flush_report()
192
+
193
+ return final
194
+
195
+
196
+ run_agent_sync = sync_variant(run_agent)
@@ -0,0 +1,122 @@
1
+ """Turn Flyte tasks into Hermes tools that execute as durable actions.
2
+
3
+ Hermes (the ``hermes-agent`` package) does not accept tool callables on the
4
+ agent object. Tools live in a process-global registry (``tools.registry``),
5
+ keyed by name and grouped into *toolsets*; an ``AIAgent`` exposes whatever its
6
+ ``enabled_toolsets`` resolve to. :func:`tool` therefore does two things:
7
+
8
+ 1. wraps the Flyte ``@env.task`` with the shared core wrapper
9
+ (:func:`flyteplugins.agents.core.tool`) so a call dispatches to
10
+ ``task.aio()`` — a durable Flyte child action (its own container/resources,
11
+ with retries and caching) — and the backing task resolves to itself on the
12
+ worker;
13
+ 2. registers that wrapper in the Hermes tool registry under the
14
+ :data:`FLYTE_TOOLSET` toolset, with an OpenAI-format schema derived from the
15
+ task via the Flyte type engine.
16
+
17
+ ``run_agent`` then scopes each built agent to exactly the requested tools via a
18
+ custom toolset (see ``_run``); a bring-your-own ``AIAgent`` opts in with
19
+ ``enabled_toolsets=[FLYTE_TOOLSET]``.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import inspect
25
+ import json
26
+ import typing
27
+ from functools import partial
28
+
29
+ from flyte._task import AsyncFunctionTaskTemplate
30
+ from flyte.models import NativeInterface
31
+ from flyteplugins.agents.core import task_json_schema
32
+ from flyteplugins.agents.core import tool as core_tool
33
+
34
+ FLYTE_TOOLSET = "flyte"
35
+ """The Hermes toolset every Flyte ``tool`` registers under."""
36
+
37
+
38
+ def tool(
39
+ func: AsyncFunctionTaskTemplate | typing.Callable | None = None,
40
+ *,
41
+ name: str | None = None,
42
+ description: str | None = None,
43
+ ) -> typing.Any:
44
+ """Convert a Flyte task (or plain callable) into a Hermes tool.
45
+
46
+ - For an ``@env.task``: returns the shared core tool wrapper (a plain async
47
+ function dispatching to the task as a durable Flyte child action, with
48
+ ``__wrapped_task__`` and the resolver wired), *and* registers it in the
49
+ Hermes tool registry under :data:`FLYTE_TOOLSET` so an ``AIAgent`` can
50
+ call it by name. The input schema is derived from the task via the Flyte
51
+ type engine.
52
+ - For a plain (sync or async) callable: registers it as an inline Hermes
53
+ tool, deriving the schema from its signature.
54
+
55
+ Usable bare, parametrized, or as a direct call::
56
+
57
+ @tool
58
+ @env.task
59
+ async def get_weather(city: str) -> str: ...
60
+ """
61
+ if func is None:
62
+ return partial(tool, name=name, description=description)
63
+
64
+ wrapper = core_tool(func, name=name, description=description)
65
+ tool_name = getattr(wrapper, "__name__", None) or type(wrapper).__name__
66
+
67
+ if isinstance(func, AsyncFunctionTaskTemplate):
68
+ desc = (description or func.func.__doc__ or f"Run {tool_name}").strip()
69
+ parameters = task_json_schema(func)
70
+ else:
71
+ desc = (description or getattr(func, "__doc__", None) or f"Run {tool_name}").strip()
72
+ parameters = NativeInterface.from_callable(func).json_schema
73
+
74
+ _register_hermes_tool(tool_name, desc, parameters, wrapper)
75
+ try:
76
+ wrapper.__hermes_registered__ = True
77
+ except (AttributeError, TypeError): # pragma: no cover - slotted/immutable callable
78
+ pass
79
+ return wrapper
80
+
81
+
82
+ def _register_hermes_tool(
83
+ tool_name: str,
84
+ description: str,
85
+ parameters: dict[str, typing.Any],
86
+ wrapper: typing.Callable,
87
+ ) -> None:
88
+ """Register ``wrapper`` in the Hermes tool registry under :data:`FLYTE_TOOLSET`.
89
+
90
+ The handler receives the model's arguments as a dict (Hermes dispatches
91
+ ``handler(args, **context)``) and returns a string for the model. Re-registering
92
+ the same name replaces the previous entry, so module reloads are safe.
93
+ """
94
+ from tools.registry import registry # hermes-agent's process-global tool registry
95
+
96
+ async def _handler(args: dict[str, typing.Any] | None = None, **_: typing.Any) -> str:
97
+ out = wrapper(**(args or {}))
98
+ if inspect.isawaitable(out):
99
+ out = await out
100
+ return _as_content(out)
101
+
102
+ registry.register(
103
+ name=tool_name,
104
+ toolset=FLYTE_TOOLSET,
105
+ schema={
106
+ "type": "function",
107
+ "function": {"name": tool_name, "description": description, "parameters": parameters},
108
+ },
109
+ handler=_handler,
110
+ is_async=True,
111
+ description=description,
112
+ )
113
+
114
+
115
+ def _as_content(result: typing.Any) -> str:
116
+ """Convert a tool result to a string for the model."""
117
+ if isinstance(result, str):
118
+ return result
119
+ try:
120
+ return json.dumps(result, default=str)
121
+ except (TypeError, ValueError):
122
+ return str(result)
@@ -0,0 +1,9 @@
1
+ Metadata-Version: 2.4
2
+ Name: flyteplugins-agents-hermes
3
+ Version: 2.5.17
4
+ Summary: Run Hermes agent 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: hermes-agent
@@ -0,0 +1,8 @@
1
+ flyteplugins/agents/hermes/__init__.py,sha256=lDU18HA4WedhsEJ_KKZGRaVVmPXDQVfcHCx-KSbm5Ws,759
2
+ flyteplugins/agents/hermes/_memory.py,sha256=NHTnsdrZ44XhZO2gs6V3mYaaP--fAnhupj1cp1izwMQ,2112
3
+ flyteplugins/agents/hermes/_run.py,sha256=eVfs3YZnmQDYkxC_OunaYrcj_ofyd4Mql9y0uZfb24M,8394
4
+ flyteplugins/agents/hermes/_tools.py,sha256=MnDvPFUh0Tx_mPYjV0TPWyaeveCkREPuwoVKjGkclZE,4594
5
+ flyteplugins_agents_hermes-2.5.17.dist-info/METADATA,sha256=RpbKppFN0p49egiOOT0iQeVR3QBLYDGc_YaG3jORFCI,285
6
+ flyteplugins_agents_hermes-2.5.17.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
7
+ flyteplugins_agents_hermes-2.5.17.dist-info/top_level.txt,sha256=cgd779rPu9EsvdtuYgUxNHHgElaQvPn74KhB5XSeMBE,13
8
+ flyteplugins_agents_hermes-2.5.17.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ flyteplugins