flyteplugins-agents-mistral 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.
@@ -0,0 +1,16 @@
1
+ """Mistral Agents adapter for Flyte (mistralai 2.x).
2
+
3
+ Bring your own Mistral agent and run it durably on Flyte. Tools you
4
+ expose are Flyte tasks (each call a durable child action), and each model turn is
5
+ recorded via ``flyte.trace`` (the turns are in-process HTTP calls, so we can trace
6
+ the seam below the SDK's loop) for per-turn replay on retry.
7
+
8
+ - :func:`tool` — turn an ``@env.task`` into a Mistral run-framework tool.
9
+ - :func:`run_agent` — run the SDK's agent loop inside your task; return the answer.
10
+ """
11
+
12
+ from flyteplugins.agents.core import tool
13
+
14
+ from ._run import run_agent
15
+
16
+ __all__ = ["run_agent", "tool"]
@@ -0,0 +1,265 @@
1
+ """``run_agent`` — run a Mistral agent.
2
+
3
+ Durability via the seam below the loop: ``run_async`` makes each model turn by
4
+ calling ``self.start_async``/``self.append_async`` on the conversations object.
5
+ When ``durable=True`` we wrap those two methods so each turn is recorded via
6
+ ``durable_step`` (a ``flyte.trace`` leaf) — so a crash/retry replays completed
7
+ turns from their recorded ``ConversationResponse`` and completed tool calls are
8
+ cache hits.
9
+
10
+ The same seam also drives observability: the SDK's ``RunResult`` exposes no token
11
+ usage, but each turn's ``ConversationResponse`` does — so the wrapper tallies the
12
+ model-turn count + token usage and we render it as a summary row in the report. On a
13
+ retry, turns served from their durable record are counted as cached tokens so the row
14
+ shows they weren't re-billed (rather than passing a free replay off as fresh spend).
15
+
16
+ The API key is read from the environment — wire it as a Flyte secret.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import os
22
+ import typing
23
+
24
+ from flyte._logging import logger
25
+ from flyte._task import AsyncFunctionTaskTemplate
26
+ from flyteplugins.agents.core import (
27
+ ReportTimeline,
28
+ abbrev,
29
+ durable_step,
30
+ fingerprint,
31
+ flush_report,
32
+ jsonable,
33
+ resolve_memory,
34
+ tool,
35
+ )
36
+
37
+ # Semantic fields of a start/append call used to key the durable turn.
38
+ _TURN_KEY_FIELDS = ("inputs", "model", "agent_id", "instructions", "tools", "conversation_id")
39
+
40
+ # Path-addressed memory slot holding a thread's server-side conversation id.
41
+ _MEMORY_CONV_PATH = "mistral/conversation_id"
42
+
43
+
44
+ def _coerce_tool(t: typing.Any) -> typing.Callable:
45
+ return tool(t) if isinstance(t, AsyncFunctionTaskTemplate) else t
46
+
47
+
48
+ def _entry_type(entry: typing.Any) -> str | None:
49
+ return getattr(entry, "type", None)
50
+
51
+
52
+ def _entry_text(entry: typing.Any) -> str:
53
+ content = getattr(entry, "content", "")
54
+ if isinstance(content, str):
55
+ return content
56
+ if isinstance(content, list):
57
+ return "".join(c if isinstance(c, str) else (getattr(c, "text", "") or "") for c in content)
58
+ return getattr(content, "text", "") or ""
59
+
60
+
61
+ def _final_text(output_entries: list[typing.Any]) -> str:
62
+ return "".join(_entry_text(e) for e in output_entries if _entry_type(e) == "message.output")
63
+
64
+
65
+ def _render(timeline: ReportTimeline, output_entries: list[typing.Any]) -> None:
66
+ for entry in output_entries:
67
+ kind = _entry_type(entry)
68
+ if kind == "function.call":
69
+ timeline.row(
70
+ icon="🛠️",
71
+ label=getattr(entry, "name", ""),
72
+ meta="tool",
73
+ detail=abbrev(getattr(entry, "arguments", ""), 160),
74
+ )
75
+ elif kind == "message.output":
76
+ timeline.row(icon="💬", label="assistant", detail=abbrev(_entry_text(entry), 200))
77
+
78
+
79
+ class _UsageSink:
80
+ """Tally model-turn count + token usage across the SDK's loop.
81
+
82
+ ``RunResult`` surfaces no usage, but each model turn's ``ConversationResponse``
83
+ carries ``.usage`` — and every turn flows through our start/append wrapper, so we
84
+ tally it there. Tokens for a turn served from a durable record on a retry (no real
85
+ model call) are counted as ``cached``, so the row shows they weren't re-billed.
86
+ """
87
+
88
+ def __init__(self) -> None:
89
+ self.turns = self.prompt = self.completion = self.total = self.connector = self.cached = 0
90
+
91
+ def add(self, response: typing.Any, *, cached: bool = False) -> None:
92
+ self.turns += 1
93
+ usage = getattr(response, "usage", None)
94
+ turn_total = (getattr(usage, "total_tokens", 0) or 0) if usage is not None else 0
95
+ if usage is not None:
96
+ self.prompt += getattr(usage, "prompt_tokens", 0) or 0
97
+ self.completion += getattr(usage, "completion_tokens", 0) or 0
98
+ self.total += turn_total
99
+ self.connector += getattr(usage, "connector_tokens", 0) or 0
100
+ if cached:
101
+ self.cached += turn_total
102
+
103
+ def detail(self) -> str:
104
+ out = (
105
+ f"{self.turns} model turns · {self.prompt} prompt · "
106
+ f"{self.completion} completion · {self.total} total tokens"
107
+ )
108
+ if self.connector:
109
+ out += f" · {self.connector} connector"
110
+ if self.cached:
111
+ out += f" · {self.cached} cached"
112
+ return out
113
+
114
+
115
+ def _install_turn_hooks(conversations: typing.Any, *, durable: bool, usage: _UsageSink | None) -> None:
116
+ """Wrap ``run_async``'s internal ``start``/``append`` for durability + usage.
117
+
118
+ ``run_async`` calls ``self.start_async``/``self.append_async`` for each model
119
+ turn, so shadowing those instance methods lets us (a) record/replay each turn via
120
+ ``durable_step`` (the seam below the SDK's loop) when ``durable``, and (b) tally
121
+ tokens from each turn's ``ConversationResponse`` when ``usage`` is given — neither
122
+ of which the SDK's ``RunResult`` exposes. The response round-trips through pydantic
123
+ JSON (verified faithful, incl. the polymorphic outputs).
124
+ """
125
+
126
+ from mistralai.client.models import ConversationResponse
127
+
128
+ original = {"start": conversations.start_async, "append": conversations.append_async}
129
+
130
+ def _wrap(phase: str):
131
+ orig = original[phase]
132
+
133
+ async def _turn(**kwargs: typing.Any) -> typing.Any:
134
+ cached = False
135
+ if durable:
136
+ # ``durable_step`` invokes ``run`` only on a fresh execution; on a retry
137
+ # it returns the recorded result without calling it. Flip a flag inside
138
+ # the closure so we know per-turn whether the model was actually called
139
+ # (fresh) or the turn came from its record (counted as cached, not re-billed).
140
+ fired = False
141
+
142
+ async def _do() -> typing.Any:
143
+ nonlocal fired
144
+ fired = True
145
+ return await orig(**kwargs)
146
+
147
+ key = fingerprint(
148
+ {
149
+ "phase": phase,
150
+ **{k: jsonable(kwargs[k]) for k in _TURN_KEY_FIELDS if kwargs.get(k) is not None},
151
+ }
152
+ )
153
+ response = await durable_step(
154
+ key,
155
+ _do,
156
+ name=f"conversation_{phase}",
157
+ dumps=lambda r: r.model_dump_json(),
158
+ loads=ConversationResponse.model_validate_json,
159
+ )
160
+ cached = not fired
161
+ else:
162
+ response = await orig(**kwargs)
163
+ if usage is not None:
164
+ usage.add(response, cached=cached)
165
+ return response
166
+
167
+ return _turn
168
+
169
+ conversations.start_async = _wrap("start")
170
+ conversations.append_async = _wrap("append")
171
+
172
+
173
+ async def run_agent(
174
+ input: str,
175
+ *,
176
+ tools: typing.Sequence[typing.Any] = (),
177
+ model: str | None = "mistral-large-latest",
178
+ instructions: str | None = None,
179
+ timeout_ms: int | None = None,
180
+ durable: bool = True,
181
+ observability: bool = True,
182
+ agent_id: str | None = None,
183
+ api_key_env_var: str = "MISTRAL_API_KEY",
184
+ memory_key: str | None = None,
185
+ ) -> str:
186
+ """Run a Mistral agent with the given tools and prompt; return the final text.
187
+
188
+ Call this from inside an ``@env.task`` — that task is the durable parent.
189
+ The Mistral SDK runs the agent loop; each tool the agent calls runs as a
190
+ durable Flyte child action, and (with ``durable=True``) each model turn is
191
+ recorded for replay. Pass ``agent_id`` to drive a pre-created server-side
192
+ agent instead of an inline ``model``.
193
+
194
+ Args:
195
+ input: The user prompt.
196
+ tools: ``tool``-wrapped tools or bare ``@env.task`` templates.
197
+ model: Model for an inline run (when ``agent_id`` is not given).
198
+ instructions: System instructions.
199
+ timeout_ms: Per-turn request timeout (ms), applied by the SDK to each model
200
+ call inside its loop; ``None`` uses the SDK default. This bounds a single
201
+ hung turn — it is not a whole-run cap (Mistral exposes no turn-count
202
+ limit). To bound the entire agent run, set ``timeout=`` on the enclosing
203
+ ``@env.task`` (the durable parent), which caps all turns + tool calls.
204
+ durable: Record/replay each conversation turn via ``flyte.trace``.
205
+ observability: Render the run timeline into the Flyte task report.
206
+ agent_id: Reuse an existing server-side agent (instead of ``model``).
207
+ api_key_env_var: Env var holding the Mistral API key (wire as a secret).
208
+ memory_key: Stable id (e.g. a user/thread id) for cross-run memory.
209
+ When set, the thread's server-side ``conversation_id`` is persisted in a
210
+ keyed ``MemoryStore`` and reused, so a later run with the same key
211
+ continues the conversation. ``None`` disables memory.
212
+ """
213
+
214
+ from mistralai.client import Mistral
215
+ from mistralai.extra.run.context import RunContext
216
+
217
+ api_key = os.environ.get(api_key_env_var)
218
+ if not api_key:
219
+ raise ValueError(
220
+ f"Mistral API key not found in '{api_key_env_var}'. Provide it as a Flyte secret, e.g. "
221
+ f'flyte.Secret(key="mistral_api_key", as_env_var="{api_key_env_var}").'
222
+ )
223
+
224
+ client = Mistral(api_key=api_key)
225
+ conversations = client.beta.conversations
226
+
227
+ timeline = ReportTimeline() if observability else None
228
+ usage = _UsageSink() if observability else None
229
+ if timeline is not None:
230
+ timeline.heading("Mistral agent")
231
+
232
+ # Wrap the per-turn seam for durable replay (``durable``) and/or token tallying
233
+ # (``usage``) — the SDK's ``RunResult`` surfaces neither, but every turn flows here.
234
+ if durable or usage is not None:
235
+ try:
236
+ _install_turn_hooks(conversations, durable=durable, usage=usage)
237
+ except Exception: # pragma: no cover - never break the run over hook wiring
238
+ logger.warning("Could not install conversation-turn hooks; continuing without replay/usage.")
239
+
240
+ # Cross-run memory: Mistral keeps the transcript server-side, so we persist just
241
+ # the conversation id and continue that conversation when the key recurs.
242
+ store = await resolve_memory(memory_key)
243
+ conversation_id = await store.read_json.aio(_MEMORY_CONV_PATH) if store is not None else None
244
+
245
+ run_ctx_kwargs = {"agent_id": agent_id} if agent_id is not None else {"model": model}
246
+ if conversation_id:
247
+ run_ctx_kwargs["conversation_id"] = conversation_id
248
+
249
+ async with RunContext(**run_ctx_kwargs) as run_ctx:
250
+ for fn in (_coerce_tool(t) for t in tools):
251
+ run_ctx.register_func(fn)
252
+
253
+ result = await conversations.run_async(run_ctx, inputs=input, instructions=instructions, timeout_ms=timeout_ms)
254
+
255
+ if store is not None and result.conversation_id:
256
+ await store.write_json.aio(_MEMORY_CONV_PATH, result.conversation_id, actor="mistral-agent")
257
+ await store.save.aio()
258
+
259
+ output_entries = list(result.output_entries or [])
260
+ if timeline is not None:
261
+ _render(timeline, output_entries)
262
+ if usage is not None and usage.turns:
263
+ timeline.row(icon="📊", label="usage", meta="model", detail=usage.detail())
264
+ await flush_report()
265
+ return _final_text(output_entries)
@@ -0,0 +1,103 @@
1
+ Metadata-Version: 2.4
2
+ Name: flyteplugins-agents-mistral
3
+ Version: 2.5.9
4
+ Summary: Run Mistral Agents (Conversations API) 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: mistralai[agents]>=2.0
10
+
11
+ # flyteplugins-agents-mistral
12
+
13
+ Run [Mistral Agents](https://docs.mistral.ai/agents/agents_introduction/) (the
14
+ Conversations API, `mistralai` 2.x) on Flyte. You keep writing Mistral agents;
15
+ Flyte is the runtime underneath.
16
+
17
+ ```bash
18
+ pip install flyteplugins-agents-mistral
19
+ ```
20
+
21
+ ```python
22
+ import flyte
23
+ from flyteplugins.agents.mistral import tool, run_agent
24
+
25
+ env = flyte.TaskEnvironment(
26
+ "mistral-agent",
27
+ secrets=[flyte.Secret(key="mistral_api_key", as_env_var="MISTRAL_API_KEY")],
28
+ )
29
+
30
+ @tool
31
+ @env.task(cache="auto", retries=3)
32
+ async def get_weather(city: str) -> str:
33
+ """Get the current weather for a city."""
34
+ return f"The weather in {city} is sunny, 22°C."
35
+
36
+ @env.task(report=True, retries=3)
37
+ async def city_agent(question: str) -> str:
38
+ return await run_agent(question, tools=[get_weather], model="mistral-large-latest")
39
+ ```
40
+
41
+ ## How it maps to Flyte
42
+
43
+ - The SDK owns the loop — we don't reimplement it. Mistral's own runner
44
+ (`conversations.run_async` + `RunContext`) drives the agent loop and executes
45
+ the tools; we just register Flyte-task-backed tools with it. `tool`
46
+ produces the Python function the runner calls, and its body dispatches to
47
+ `task.aio()`, so each tool call is a durable Flyte child action.
48
+ - Both per-turn and per-tool durability. The runner makes each model turn by
49
+ calling `start_async`/`append_async` (in-process HTTP), so with `durable=True`
50
+ we wrap those two methods — the seam below the loop — and record each turn
51
+ via `flyte.trace` (the `ConversationResponse` round-trips through pydantic JSON).
52
+ On a crash/retry, completed turns replay and completed tool calls are cache
53
+ hits — all while the SDK still owns the loop. (Same idea as swapping OpenAI's
54
+ `ModelProvider`: trace the model-call seam, not the loop.)
55
+ - Observability: the turns, tool calls and final answer render into the task
56
+ report.
57
+
58
+ The API key is read from the environment. Wire it as a Flyte secret.
59
+
60
+ ## Memory
61
+
62
+ Pass `memory_key` (a user/thread id) for cross-run memory — the agent continues
63
+ the same conversation across separate runs:
64
+
65
+ ```python
66
+ await run_agent(message, model="mistral-large-latest", memory_key="user-alice")
67
+ ```
68
+
69
+ Mistral keeps the transcript server-side, so Flyte durably persists the thread's
70
+ `conversation_id` (in a keyed `MemoryStore`) and continues that conversation when the
71
+ key recurs.
72
+
73
+ ## Examples
74
+
75
+ See [`examples/`](examples/):
76
+
77
+ - [`mistral_durable_agent.py`](examples/mistral_durable_agent.py) — a single durable
78
+ agent: tools as Flyte tasks, per-turn traced conversation, agent timeline in the
79
+ report.
80
+ - [`mistral_crash_resume.py`](examples/mistral_crash_resume.py) — crash & resume:
81
+ the task crashes on its first attempt after doing real work; on retry the completed
82
+ conversation turns replay from their `flyte.trace` records and the tool calls are
83
+ cache hits. Run on a backend to see the replay.
84
+ - [`mistral_multi_agent.py`](examples/mistral_multi_agent.py) — multi-agent
85
+ orchestration: a planner agent decomposes a topic, researcher agents fan out in
86
+ parallel, an editor agent synthesizes — each agent its own durable action.
87
+ - [`mistral_agent_id.py`](examples/mistral_agent_id.py) — drive a pre-created
88
+ server-side agent by `agent_id` (instead of an inline model) while its tool calls
89
+ still run as durable Flyte actions.
90
+ - [`mistral_memory.py`](examples/mistral_memory.py) — cross-run memory: two
91
+ separate runs share a `memory_key`; the agent learns a fact in run 1 and recalls
92
+ it in run 2.
93
+ - [`mistral_handoffs.py`](examples/mistral_handoffs.py) — native handoffs: a triage
94
+ agent hands the conversation off to a billing or technical-support agent (by id),
95
+ the whole multi-agent run durable on Flyte. The specialist can pause on a Flyte
96
+ condition (`flyte.new_condition`) to have a human share a detail, then resume with it.
97
+
98
+ ## Conformance
99
+
100
+ This adapter passes the shared `flyteplugins.agents.core.testing.assert_adapter_conforms`
101
+ check — the same one every adapter runs — so it follows the common format despite
102
+ a server-side, conversation-based SDK shape very different from OpenAI's or
103
+ Claude's.
@@ -0,0 +1,6 @@
1
+ flyteplugins/agents/mistral/__init__.py,sha256=kcPvFbQ6At4gdUwjsipQQM_yIlumjuZHe6wpj6tlEHg,620
2
+ flyteplugins/agents/mistral/_run.py,sha256=G3JRI79TUCKlmzDO8kX7a31sGE5pMbK9XNr4EM2vCiA,11271
3
+ flyteplugins_agents_mistral-2.5.9.dist-info/METADATA,sha256=-ST0wUViMN4dVE-CHQMl-NwCQaWtDaf5TH1KNTM7nRM,4433
4
+ flyteplugins_agents_mistral-2.5.9.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
5
+ flyteplugins_agents_mistral-2.5.9.dist-info/top_level.txt,sha256=cgd779rPu9EsvdtuYgUxNHHgElaQvPn74KhB5XSeMBE,13
6
+ flyteplugins_agents_mistral-2.5.9.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