flyteplugins-agents-claude 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/claude/__init__.py +18 -0
- flyteplugins/agents/claude/_durable.py +164 -0
- flyteplugins/agents/claude/_memory.py +123 -0
- flyteplugins/agents/claude/_run.py +230 -0
- flyteplugins/agents/claude/_tools.py +99 -0
- flyteplugins_agents_claude-2.5.9.dist-info/METADATA +124 -0
- flyteplugins_agents_claude-2.5.9.dist-info/RECORD +9 -0
- flyteplugins_agents_claude-2.5.9.dist-info/WHEEL +5 -0
- flyteplugins_agents_claude-2.5.9.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Claude Agent SDK adapter for Flyte.
|
|
2
|
+
|
|
3
|
+
Bring your own ``claude-agent-sdk`` agent and run it durably on Flyte. Tools you
|
|
4
|
+
expose are Flyte tasks (so each tool call is a durable child action with its own
|
|
5
|
+
container/resources, retries and caching); the agent loop itself runs in the
|
|
6
|
+
Claude Code runtime, and its timeline is rendered into the Flyte task report.
|
|
7
|
+
|
|
8
|
+
- :func:`tool` — turn an ``@env.task`` into a Claude in-process MCP tool.
|
|
9
|
+
- :func:`run_agent` — run the agent loop inside your task and return the answer.
|
|
10
|
+
|
|
11
|
+
The ``claude-agent-sdk`` wheel bundles the native ``claude`` CLI (no separate Node.js
|
|
12
|
+
install needed); set an Anthropic API key in the environment.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from ._run import run_agent
|
|
16
|
+
from ._tools import tool
|
|
17
|
+
|
|
18
|
+
__all__ = ["run_agent", "tool"]
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Durable Claude sessions — make ``durable=True`` real via the SDK's session store.
|
|
2
|
+
|
|
3
|
+
The Claude Agent SDK runs the model loop inside the Claude Code CLI subprocess, so
|
|
4
|
+
there is no in-process model-call seam to wrap in ``flyte.trace`` for per-turn replay.
|
|
5
|
+
Instead we use the SDK's own session mirror + resume: the CLI mirrors the running
|
|
6
|
+
transcript to a ``SessionStore`` we provide, and on a retry it resumes from that store
|
|
7
|
+
rather than starting over.
|
|
8
|
+
|
|
9
|
+
We back that store with :class:`flyte.Checkpoint` — the native, retry-surviving
|
|
10
|
+
durable prefix the runtime hands each task (``save`` writes this attempt's blob,
|
|
11
|
+
``load`` restores the previous attempt's). So a crashed attempt's conversation is
|
|
12
|
+
restored on the next attempt instead of replayed from scratch, without us owning
|
|
13
|
+
the loop.
|
|
14
|
+
|
|
15
|
+
Mapping to Flyte primitives:
|
|
16
|
+
- session id is derived deterministically from the task's ``ActionID``, so every
|
|
17
|
+
retry of the same action targets the same session;
|
|
18
|
+
- persistence is ``flyte.Checkpoint`` — durable across container restarts.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import asyncio
|
|
24
|
+
import json
|
|
25
|
+
import pathlib
|
|
26
|
+
import typing
|
|
27
|
+
import uuid
|
|
28
|
+
|
|
29
|
+
from flyte._logging import logger
|
|
30
|
+
|
|
31
|
+
# Fixed namespace so derived session ids are stable across processes and retries.
|
|
32
|
+
_SESSION_NS = uuid.UUID("4e1b8a2c-6c2e-5f7a-9b3d-2a1f0c7d4e55")
|
|
33
|
+
_PAYLOAD = "payload"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def deterministic_session_id(task_context: typing.Any) -> str:
|
|
37
|
+
"""A stable, valid-UUID session id for the current action (same across retries).
|
|
38
|
+
|
|
39
|
+
Uses ``task_action`` when present (it stays pinned to the real running task even
|
|
40
|
+
inside a ``@trace`` pseudo-action), falling back to ``action``.
|
|
41
|
+
"""
|
|
42
|
+
action = getattr(task_context, "task_action", None) or task_context.action
|
|
43
|
+
seed = f"{action.run_name}/{action.name}"
|
|
44
|
+
return str(uuid.uuid5(_SESSION_NS, seed))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _skey(key: typing.Mapping[str, typing.Any]) -> str:
|
|
48
|
+
"""Flatten a Claude ``SessionKey`` to a single storage key.
|
|
49
|
+
|
|
50
|
+
Keyed by ``session_id`` (+ optional subagent ``subpath``); ``project_key`` is
|
|
51
|
+
intentionally ignored — our session ids are already globally unique per action.
|
|
52
|
+
"""
|
|
53
|
+
return f"{key['session_id']}::{key.get('subpath') or ''}"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _read_payload(local: typing.Any) -> dict | None:
|
|
57
|
+
"""Parse the checkpoint blob written by a previous attempt, or ``None``.
|
|
58
|
+
|
|
59
|
+
Kept sync (local, already-downloaded file IO) so the async store stays free of
|
|
60
|
+
blocking ``pathlib`` calls.
|
|
61
|
+
"""
|
|
62
|
+
payload = pathlib.Path(local)
|
|
63
|
+
if payload.is_dir():
|
|
64
|
+
payload = payload / _PAYLOAD
|
|
65
|
+
if not payload.is_file():
|
|
66
|
+
return None
|
|
67
|
+
try:
|
|
68
|
+
return json.loads(payload.read_text())
|
|
69
|
+
except (ValueError, OSError):
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class CheckpointSessionStore:
|
|
74
|
+
"""A duck-typed Claude ``SessionStore`` persisted via :class:`flyte.Checkpoint`.
|
|
75
|
+
|
|
76
|
+
The SDK requires only ``append`` and ``load`` and probes for the optional methods
|
|
77
|
+
(``list_sessions``/``delete``/...), so we deliberately omit them. The whole store —
|
|
78
|
+
every session/subagent transcript seen this run — is serialized to a single
|
|
79
|
+
checkpoint blob; the SDK materializes it back into the CLI on resume.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
def __init__(self, checkpoint: typing.Any) -> None:
|
|
83
|
+
self._ckpt = checkpoint
|
|
84
|
+
self._state: dict[str, list[dict]] = {}
|
|
85
|
+
self._seen: dict[str, set[str]] = {}
|
|
86
|
+
self._lock = asyncio.Lock()
|
|
87
|
+
|
|
88
|
+
async def seed_from_prev(self) -> bool:
|
|
89
|
+
"""Restore the previous attempt's checkpoint into memory.
|
|
90
|
+
|
|
91
|
+
Returns ``True`` if a prior attempt's session existed (i.e. this is a retry
|
|
92
|
+
with state to resume), ``False`` otherwise.
|
|
93
|
+
"""
|
|
94
|
+
local = await self._ckpt.load()
|
|
95
|
+
if local is None:
|
|
96
|
+
return False
|
|
97
|
+
data = _read_payload(local)
|
|
98
|
+
if not data:
|
|
99
|
+
return False
|
|
100
|
+
self._state = {k: list(v) for k, v in data.items()}
|
|
101
|
+
self._seen = {k: {e["uuid"] for e in v if "uuid" in e} for k, v in self._state.items()}
|
|
102
|
+
return bool(self._state)
|
|
103
|
+
|
|
104
|
+
async def _persist(self) -> None:
|
|
105
|
+
await self._ckpt.save(json.dumps(self._state).encode("utf-8"))
|
|
106
|
+
|
|
107
|
+
async def append(self, key: typing.Mapping[str, typing.Any], entries: list[dict]) -> None:
|
|
108
|
+
"""Mirror a batch of transcript entries, then persist to the checkpoint."""
|
|
109
|
+
skey = _skey(key)
|
|
110
|
+
async with self._lock:
|
|
111
|
+
buf = self._state.setdefault(skey, [])
|
|
112
|
+
seen = self._seen.setdefault(skey, set())
|
|
113
|
+
changed = False
|
|
114
|
+
for entry in entries:
|
|
115
|
+
uid = entry.get("uuid")
|
|
116
|
+
if uid is not None and uid in seen:
|
|
117
|
+
continue # idempotent: the mirror may re-send entries on retry
|
|
118
|
+
buf.append(entry)
|
|
119
|
+
changed = True
|
|
120
|
+
if uid is not None:
|
|
121
|
+
seen.add(uid)
|
|
122
|
+
if changed:
|
|
123
|
+
await self._persist()
|
|
124
|
+
|
|
125
|
+
async def load(self, key: typing.Mapping[str, typing.Any]) -> list[dict] | None:
|
|
126
|
+
"""Return the full transcript for ``key`` (for resume), or ``None`` if unseen."""
|
|
127
|
+
async with self._lock:
|
|
128
|
+
buf = self._state.get(_skey(key))
|
|
129
|
+
return list(buf) if buf else None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
async def wire_durable_session(options: typing.Any, *, durable: bool) -> CheckpointSessionStore | None:
|
|
133
|
+
"""Attach a resume-backed session store to ``options`` when durable and able.
|
|
134
|
+
|
|
135
|
+
First attempt pins a deterministic ``session_id``; a retry (whose previous
|
|
136
|
+
checkpoint exists) sets ``resume`` to that same id and seeds the store from the
|
|
137
|
+
prior attempt — so completed turns and tool results are restored from the
|
|
138
|
+
checkpoint instead of recomputed. Returns the store (or ``None`` when durability
|
|
139
|
+
is off / unavailable). Never raises: durability is best-effort and must not break
|
|
140
|
+
a run.
|
|
141
|
+
"""
|
|
142
|
+
if not durable:
|
|
143
|
+
return None
|
|
144
|
+
try:
|
|
145
|
+
import flyte
|
|
146
|
+
|
|
147
|
+
task_context = flyte.ctx()
|
|
148
|
+
if task_context is None:
|
|
149
|
+
return None
|
|
150
|
+
checkpoint = task_context.checkpoint
|
|
151
|
+
if checkpoint is None:
|
|
152
|
+
return None
|
|
153
|
+
store = CheckpointSessionStore(checkpoint)
|
|
154
|
+
had_prior = await store.seed_from_prev()
|
|
155
|
+
session_id = deterministic_session_id(task_context)
|
|
156
|
+
if had_prior:
|
|
157
|
+
options.resume = session_id
|
|
158
|
+
else:
|
|
159
|
+
options.session_id = session_id
|
|
160
|
+
options.session_store = store
|
|
161
|
+
return store
|
|
162
|
+
except Exception: # pragma: no cover - durability must never break the run
|
|
163
|
+
logger.warning("Could not wire a durable Claude session; continuing without resume.")
|
|
164
|
+
return None
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Cross-run Claude memory — a ``SessionStore`` backed by a keyed ``MemoryStore``.
|
|
2
|
+
|
|
3
|
+
Claude's session resume materializes a ``SessionStore``'s transcript into the
|
|
4
|
+
CLI. The per-run crash-resume store (see :mod:`._durable`) is keyed by the action
|
|
5
|
+
and backed by a ``flyte.Checkpoint`` (ephemeral, per-run). This store is keyed by a
|
|
6
|
+
stable ``memory_key`` (a user/thread id) and backed by a durable, cross-run
|
|
7
|
+
``MemoryStore`` — so a later run with the same key resumes the prior
|
|
8
|
+
conversation.
|
|
9
|
+
|
|
10
|
+
Because the memory store survives retries too, it subsumes crash-resume: when a
|
|
11
|
+
``memory_key`` is given, the adapter uses this store instead of the checkpoint one.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import asyncio
|
|
17
|
+
import typing
|
|
18
|
+
import uuid
|
|
19
|
+
|
|
20
|
+
from flyte._logging import logger
|
|
21
|
+
from flyteplugins.agents.core import resolve_memory
|
|
22
|
+
|
|
23
|
+
from ._durable import _skey
|
|
24
|
+
|
|
25
|
+
# Fixed namespace so a memory key maps to a stable Claude session id across runs.
|
|
26
|
+
_SESSION_NS = uuid.UUID("9a7c1e30-2b44-5d96-8a1f-3c6b2e0d7f44")
|
|
27
|
+
|
|
28
|
+
# Path-addressed slot holding the thread's Claude transcript inside the MemoryStore.
|
|
29
|
+
_TRANSCRIPT_PATH = "claude/transcript.json"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def memory_session_id(memory_key: str) -> str:
|
|
33
|
+
"""A stable, valid-UUID Claude session id for a memory key (same across runs)."""
|
|
34
|
+
return str(uuid.uuid5(_SESSION_NS, memory_key))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class MemorySessionStore:
|
|
38
|
+
"""Duck-typed Claude ``SessionStore`` persisted in a keyed ``MemoryStore``.
|
|
39
|
+
|
|
40
|
+
Only ``append``/``load`` are required by the SDK. The whole transcript (keyed by
|
|
41
|
+
the SDK's session/subagent key) is stored as one path-addressed JSON entry; the
|
|
42
|
+
SDK materializes it back into the CLI on resume.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(self, store: typing.Any) -> None:
|
|
46
|
+
self._store = store
|
|
47
|
+
self._state: dict[str, list[dict]] = {}
|
|
48
|
+
self._seen: dict[str, set[str]] = {}
|
|
49
|
+
self._lock = asyncio.Lock()
|
|
50
|
+
|
|
51
|
+
async def seed(self) -> bool:
|
|
52
|
+
"""Load the thread's prior transcript into memory; returns whether any existed."""
|
|
53
|
+
data = await self._store.read_json.aio(_TRANSCRIPT_PATH)
|
|
54
|
+
if not data:
|
|
55
|
+
return False
|
|
56
|
+
self._state = {k: list(v) for k, v in data.items()}
|
|
57
|
+
self._seen = {k: {e["uuid"] for e in v if "uuid" in e} for k, v in self._state.items()}
|
|
58
|
+
return bool(self._state)
|
|
59
|
+
|
|
60
|
+
async def _persist(self) -> None:
|
|
61
|
+
await self._store.write_json.aio(_TRANSCRIPT_PATH, self._state, actor="claude-agent")
|
|
62
|
+
await self._store.save.aio()
|
|
63
|
+
|
|
64
|
+
async def append(self, key: typing.Mapping[str, typing.Any], entries: list[dict]) -> None:
|
|
65
|
+
skey = _skey(key)
|
|
66
|
+
async with self._lock:
|
|
67
|
+
buf = self._state.setdefault(skey, [])
|
|
68
|
+
seen = self._seen.setdefault(skey, set())
|
|
69
|
+
changed = False
|
|
70
|
+
for entry in entries:
|
|
71
|
+
uid = entry.get("uuid")
|
|
72
|
+
if uid is not None and uid in seen:
|
|
73
|
+
continue # idempotent: the mirror may re-send entries
|
|
74
|
+
buf.append(entry)
|
|
75
|
+
changed = True
|
|
76
|
+
if uid is not None:
|
|
77
|
+
seen.add(uid)
|
|
78
|
+
if changed:
|
|
79
|
+
await self._persist()
|
|
80
|
+
|
|
81
|
+
async def load(self, key: typing.Mapping[str, typing.Any]) -> list[dict] | None:
|
|
82
|
+
async with self._lock:
|
|
83
|
+
buf = self._state.get(_skey(key))
|
|
84
|
+
return list(buf) if buf else None
|
|
85
|
+
|
|
86
|
+
async def list_subkeys(self, key: typing.Mapping[str, typing.Any]) -> list[str]:
|
|
87
|
+
"""List the subagent subpaths under a session so resume restores them too.
|
|
88
|
+
|
|
89
|
+
Without this the SDK only materializes the main transcript on resume; with it,
|
|
90
|
+
subagent transcripts (mirrored under ``subpath`` keys) come back as well.
|
|
91
|
+
Scoped to ``session_id``; the main transcript (empty subpath) is excluded.
|
|
92
|
+
"""
|
|
93
|
+
prefix = f"{key['session_id']}::"
|
|
94
|
+
async with self._lock:
|
|
95
|
+
return [skey[len(prefix) :] for skey in self._state if skey.startswith(prefix) and skey != prefix]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
async def wire_memory_session(options: typing.Any, *, memory_key: str | None) -> MemorySessionStore | None:
|
|
99
|
+
"""Attach a cross-run, memory-backed resume to ``options`` for ``memory_key``.
|
|
100
|
+
|
|
101
|
+
First run for the key pins a deterministic ``session_id``; a later run (whose
|
|
102
|
+
transcript already exists) sets ``resume`` and seeds from the store — so the
|
|
103
|
+
conversation continues. Returns the store, or ``None`` when memory is off /
|
|
104
|
+
unavailable. Never raises.
|
|
105
|
+
"""
|
|
106
|
+
if not memory_key:
|
|
107
|
+
return None
|
|
108
|
+
try:
|
|
109
|
+
store = await resolve_memory(memory_key)
|
|
110
|
+
if store is None:
|
|
111
|
+
return None
|
|
112
|
+
session = MemorySessionStore(store)
|
|
113
|
+
had_prior = await session.seed()
|
|
114
|
+
session_id = memory_session_id(memory_key)
|
|
115
|
+
if had_prior:
|
|
116
|
+
options.resume = session_id
|
|
117
|
+
else:
|
|
118
|
+
options.session_id = session_id
|
|
119
|
+
options.session_store = session
|
|
120
|
+
return session
|
|
121
|
+
except Exception: # pragma: no cover - memory is best-effort, never fatal
|
|
122
|
+
logger.warning("Could not wire Claude cross-run memory for key %r; continuing without it.", memory_key)
|
|
123
|
+
return None
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""``run_agent`` — run a Claude Agent SDK agent on Flyte.
|
|
2
|
+
|
|
3
|
+
The Claude Agent SDK owns the loop (it drives the model via the Claude Code
|
|
4
|
+
runtime). ``run_agent`` runs that loop inside your ``@env.task``: it builds an
|
|
5
|
+
in-process MCP server from the tools, points the SDK at it, streams the run, and
|
|
6
|
+
renders the timeline into the Flyte report.
|
|
7
|
+
|
|
8
|
+
Durability: tool calls are durable Flyte child actions (see
|
|
9
|
+
:func:`flyteplugins.agents.claude.tool`). Per-turn model replay is not
|
|
10
|
+
available here — the model loop runs in the Claude Code runtime (a subprocess
|
|
11
|
+
Flyte doesn't intercept), so a model turn can't be a ``flyte.trace`` leaf the way
|
|
12
|
+
it is for client-side SDKs. Instead, ``durable=True`` wires the SDK's own session
|
|
13
|
+
mirror + resume onto a :class:`flyte.Checkpoint` (see :mod:`._durable`), so a
|
|
14
|
+
crashed attempt's conversation is restored on retry rather than restarted. Tool
|
|
15
|
+
durability, retries and caching apply regardless.
|
|
16
|
+
|
|
17
|
+
Observability: beyond streaming the assistant turns, ``PostToolUse`` /
|
|
18
|
+
``PostToolUseFailure`` hooks record each tool's outcome (result or error) into
|
|
19
|
+
the report timeline.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import typing
|
|
26
|
+
|
|
27
|
+
from claude_agent_sdk import (
|
|
28
|
+
AssistantMessage,
|
|
29
|
+
ClaudeAgentOptions,
|
|
30
|
+
HookMatcher,
|
|
31
|
+
ResultMessage,
|
|
32
|
+
SdkMcpTool,
|
|
33
|
+
TextBlock,
|
|
34
|
+
ToolUseBlock,
|
|
35
|
+
create_sdk_mcp_server,
|
|
36
|
+
query,
|
|
37
|
+
)
|
|
38
|
+
from flyte._task import TaskTemplate
|
|
39
|
+
from flyteplugins.agents.core import ReportTimeline, abbrev, flush_report
|
|
40
|
+
|
|
41
|
+
from ._durable import wire_durable_session
|
|
42
|
+
from ._memory import wire_memory_session
|
|
43
|
+
from ._tools import tool
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _coerce_tool(t: typing.Any) -> SdkMcpTool:
|
|
47
|
+
if isinstance(t, SdkMcpTool):
|
|
48
|
+
return t
|
|
49
|
+
if isinstance(t, TaskTemplate):
|
|
50
|
+
return tool(t)
|
|
51
|
+
return t
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
async def run_agent(
|
|
55
|
+
input: str,
|
|
56
|
+
*,
|
|
57
|
+
tools: typing.Sequence[typing.Any] = (),
|
|
58
|
+
model: str | None = "claude-sonnet-4-5",
|
|
59
|
+
instructions: str | None = None,
|
|
60
|
+
max_turns: int | None = None,
|
|
61
|
+
durable: bool = True,
|
|
62
|
+
observability: bool = True,
|
|
63
|
+
options: ClaudeAgentOptions | None = None,
|
|
64
|
+
server_name: str = "flyte_tools",
|
|
65
|
+
memory_key: str | None = None,
|
|
66
|
+
) -> str:
|
|
67
|
+
"""Run a Claude agent with the given tools and prompt; return the final text.
|
|
68
|
+
|
|
69
|
+
Call this from inside an ``@env.task`` — that task is the durable parent,
|
|
70
|
+
and each tool the agent calls runs as a durable Flyte child action. Pass a
|
|
71
|
+
fully-built ``ClaudeAgentOptions`` via ``options`` to keep SDK-native config
|
|
72
|
+
(subagents, permissions, hooks, session resume); ``tools``/``model``/
|
|
73
|
+
``instructions``/``max_turns`` are layered on top.
|
|
74
|
+
|
|
75
|
+
With ``durable=True`` (and a checkpoint-capable task context) the SDK's session
|
|
76
|
+
mirror + resume is wired onto a ``flyte.Checkpoint``, so a retry resumes the
|
|
77
|
+
conversation instead of restarting it. With ``observability=True`` the run
|
|
78
|
+
timeline — assistant turns plus per-tool outcomes (via hooks) — is rendered into
|
|
79
|
+
the task report.
|
|
80
|
+
|
|
81
|
+
Set ``memory_key`` (a user/thread id) for cross-run memory: the transcript is
|
|
82
|
+
persisted to a durable, keyed ``MemoryStore`` and resumed on a later run with the
|
|
83
|
+
same key (this also covers crash-resume, so it takes precedence over the per-run
|
|
84
|
+
``durable`` checkpoint).
|
|
85
|
+
|
|
86
|
+
The ``claude-agent-sdk`` wheel bundles the native ``claude`` CLI, so the runtime
|
|
87
|
+
image needs no separate Node.js install — just an Anthropic API key.
|
|
88
|
+
"""
|
|
89
|
+
sdk_tools = [_coerce_tool(t) for t in tools]
|
|
90
|
+
opts = options or ClaudeAgentOptions()
|
|
91
|
+
|
|
92
|
+
if sdk_tools:
|
|
93
|
+
server = create_sdk_mcp_server(server_name, tools=sdk_tools)
|
|
94
|
+
opts.mcp_servers = {**(opts.mcp_servers or {}), server_name: server}
|
|
95
|
+
allowed = [f"mcp__{server_name}__{t.name}" for t in sdk_tools]
|
|
96
|
+
opts.allowed_tools = [*(opts.allowed_tools or []), *allowed]
|
|
97
|
+
if instructions is not None:
|
|
98
|
+
opts.system_prompt = instructions
|
|
99
|
+
if model is not None:
|
|
100
|
+
opts.model = model
|
|
101
|
+
if max_turns is not None:
|
|
102
|
+
opts.max_turns = max_turns
|
|
103
|
+
|
|
104
|
+
if memory_key:
|
|
105
|
+
await wire_memory_session(opts, memory_key=memory_key)
|
|
106
|
+
else:
|
|
107
|
+
await wire_durable_session(opts, durable=durable)
|
|
108
|
+
|
|
109
|
+
timeline = ReportTimeline() if observability else None
|
|
110
|
+
if timeline is not None:
|
|
111
|
+
timeline.heading("Claude agent")
|
|
112
|
+
_install_tool_hooks(opts, timeline)
|
|
113
|
+
|
|
114
|
+
final = ""
|
|
115
|
+
async for message in query(prompt=input, options=opts):
|
|
116
|
+
if isinstance(message, AssistantMessage):
|
|
117
|
+
if timeline is not None:
|
|
118
|
+
_render_assistant(timeline, message)
|
|
119
|
+
elif isinstance(message, ResultMessage):
|
|
120
|
+
final = message.result or final
|
|
121
|
+
if timeline is not None:
|
|
122
|
+
_render_result(timeline, message)
|
|
123
|
+
|
|
124
|
+
if observability:
|
|
125
|
+
await flush_report()
|
|
126
|
+
return final or ""
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _render_assistant(timeline: ReportTimeline, message: AssistantMessage) -> None:
|
|
130
|
+
for block in message.content:
|
|
131
|
+
if isinstance(block, TextBlock):
|
|
132
|
+
if block.text.strip():
|
|
133
|
+
timeline.row(icon="💬", label="assistant", detail=abbrev(block.text, 200))
|
|
134
|
+
elif isinstance(block, ToolUseBlock):
|
|
135
|
+
timeline.row(icon="🛠️", label=block.name, meta="tool", detail=abbrev(block.input, 160))
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _compact(n: int) -> str:
|
|
139
|
+
"""Compact a token count, e.g. 5000 -> '5.0k'."""
|
|
140
|
+
return f"{n / 1000:.1f}k" if n >= 1000 else str(n)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _fmt_usage(usage: dict[str, typing.Any] | None) -> str:
|
|
144
|
+
"""A compact, auditable token breakdown from the result's ``usage`` dict.
|
|
145
|
+
|
|
146
|
+
These are the counts that drive ``total_cost_usd`` (the SDK's cost estimate), so
|
|
147
|
+
surfacing them lets you sanity-check the dollar figure at a glance. Keys are passed
|
|
148
|
+
through from the Claude Code CLI, so both snake_case and camelCase are accepted.
|
|
149
|
+
"""
|
|
150
|
+
if not usage:
|
|
151
|
+
return ""
|
|
152
|
+
|
|
153
|
+
def pick(*keys: str) -> int:
|
|
154
|
+
for key in keys:
|
|
155
|
+
value = usage.get(key)
|
|
156
|
+
if isinstance(value, (int, float)) and value:
|
|
157
|
+
return int(value)
|
|
158
|
+
return 0
|
|
159
|
+
|
|
160
|
+
fields = [
|
|
161
|
+
("in", pick("input_tokens", "inputTokens")),
|
|
162
|
+
("out", pick("output_tokens", "outputTokens")),
|
|
163
|
+
("cache read", pick("cache_read_input_tokens", "cacheReadInputTokens")),
|
|
164
|
+
("cache write", pick("cache_creation_input_tokens", "cacheCreationInputTokens")),
|
|
165
|
+
]
|
|
166
|
+
return " · ".join(f"{label} {_compact(n)}" for label, n in fields if n)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _render_result(timeline: ReportTimeline, message: ResultMessage) -> None:
|
|
170
|
+
parts = []
|
|
171
|
+
if message.num_turns:
|
|
172
|
+
parts.append(f"{message.num_turns} turns")
|
|
173
|
+
if message.duration_ms:
|
|
174
|
+
parts.append(f"{message.duration_ms} ms")
|
|
175
|
+
if message.total_cost_usd:
|
|
176
|
+
parts.append(f"${message.total_cost_usd:.4f}")
|
|
177
|
+
timeline.row(
|
|
178
|
+
icon="✅",
|
|
179
|
+
label="result",
|
|
180
|
+
meta=" · ".join(parts),
|
|
181
|
+
detail=_fmt_usage(message.usage),
|
|
182
|
+
error="error" if message.is_error else None,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _stringify(value: typing.Any) -> str:
|
|
187
|
+
if isinstance(value, str):
|
|
188
|
+
return value
|
|
189
|
+
try:
|
|
190
|
+
return json.dumps(value, default=str)
|
|
191
|
+
except (TypeError, ValueError):
|
|
192
|
+
return str(value)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _install_tool_hooks(opts: ClaudeAgentOptions, timeline: ReportTimeline) -> None:
|
|
196
|
+
"""Record each tool's outcome into the report via PostToolUse hooks.
|
|
197
|
+
|
|
198
|
+
The streamed assistant turns already show the tool request (``ToolUseBlock``);
|
|
199
|
+
these hooks add the result/error the message stream doesn't surface. They observe
|
|
200
|
+
only — each returns an empty decision — and are merged into any user-provided
|
|
201
|
+
``opts.hooks`` rather than replacing them.
|
|
202
|
+
"""
|
|
203
|
+
|
|
204
|
+
async def _post_tool(input_data: dict, tool_use_id: str | None, context: typing.Any) -> dict:
|
|
205
|
+
timeline.row(
|
|
206
|
+
icon="🔧",
|
|
207
|
+
label=input_data.get("tool_name", ""),
|
|
208
|
+
meta="tool result",
|
|
209
|
+
detail=abbrev(_stringify(input_data.get("tool_response", "")), 160),
|
|
210
|
+
)
|
|
211
|
+
return {}
|
|
212
|
+
|
|
213
|
+
async def _post_tool_failure(input_data: dict, tool_use_id: str | None, context: typing.Any) -> dict:
|
|
214
|
+
timeline.row(
|
|
215
|
+
icon="❌",
|
|
216
|
+
label=input_data.get("tool_name", ""),
|
|
217
|
+
meta="tool error",
|
|
218
|
+
detail=abbrev(_stringify(input_data.get("error", "")), 160),
|
|
219
|
+
error="error",
|
|
220
|
+
)
|
|
221
|
+
return {}
|
|
222
|
+
|
|
223
|
+
additions = {
|
|
224
|
+
"PostToolUse": [HookMatcher(hooks=[_post_tool])],
|
|
225
|
+
"PostToolUseFailure": [HookMatcher(hooks=[_post_tool_failure])],
|
|
226
|
+
}
|
|
227
|
+
merged = dict(opts.hooks or {})
|
|
228
|
+
for event, matchers in additions.items():
|
|
229
|
+
merged[event] = [*(merged.get(event) or []), *matchers]
|
|
230
|
+
opts.hooks = merged
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Turn Flyte tasks into Claude Agent SDK tools that execute as durable actions.
|
|
2
|
+
|
|
3
|
+
The Claude Agent SDK exposes custom tools as in-process MCP tools (``@tool`` /
|
|
4
|
+
``SdkMcpTool``). :func:`tool` wraps a Flyte ``@env.task`` as one whose
|
|
5
|
+
handler dispatches to the task via ``task.aio()`` — so when Claude calls the
|
|
6
|
+
tool, it runs as a durable Flyte child action (its own container/resources, with
|
|
7
|
+
retries and caching) rather than inline in the agent process.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import inspect
|
|
13
|
+
import json
|
|
14
|
+
import typing
|
|
15
|
+
from functools import partial
|
|
16
|
+
|
|
17
|
+
from claude_agent_sdk import SdkMcpTool
|
|
18
|
+
from claude_agent_sdk import tool as claude_tool
|
|
19
|
+
from flyte._task import AsyncFunctionTaskTemplate
|
|
20
|
+
from flyte.models import NativeInterface
|
|
21
|
+
from flyteplugins.agents.core import attach_tool_resolver, coerce_tool_args, task_json_schema
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def tool(
|
|
25
|
+
func: AsyncFunctionTaskTemplate | typing.Callable | None = None,
|
|
26
|
+
*,
|
|
27
|
+
name: str | None = None,
|
|
28
|
+
description: str | None = None,
|
|
29
|
+
) -> SdkMcpTool | typing.Callable:
|
|
30
|
+
"""Convert a Flyte task (or plain callable) into a Claude Agent SDK tool.
|
|
31
|
+
|
|
32
|
+
- For an ``@env.task``: returns an ``SdkMcpTool`` whose handler runs the task
|
|
33
|
+
as a durable Flyte child action when Claude calls it. The input schema is
|
|
34
|
+
derived from the task via the Flyte type engine. The backing task is wired
|
|
35
|
+
to :class:`~flyteplugins.agents.core.ToolTaskResolver` and exposed via
|
|
36
|
+
``__wrapped_task__`` so it resolves to itself on the worker (no recursion).
|
|
37
|
+
- For a plain (async) callable: returns an ``SdkMcpTool`` that runs it inline.
|
|
38
|
+
|
|
39
|
+
Usable bare, parametrized, or as a direct call::
|
|
40
|
+
|
|
41
|
+
@tool
|
|
42
|
+
@env.task
|
|
43
|
+
async def get_weather(city: str) -> str: ...
|
|
44
|
+
"""
|
|
45
|
+
if func is None:
|
|
46
|
+
return partial(tool, name=name, description=description)
|
|
47
|
+
if isinstance(func, AsyncFunctionTaskTemplate):
|
|
48
|
+
return _task_to_tool(func, name=name, description=description)
|
|
49
|
+
return _callable_to_tool(func, name=name, description=description)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _task_to_tool(
|
|
53
|
+
task: AsyncFunctionTaskTemplate,
|
|
54
|
+
*,
|
|
55
|
+
name: str | None = None,
|
|
56
|
+
description: str | None = None,
|
|
57
|
+
) -> SdkMcpTool:
|
|
58
|
+
tool_name = name or task.func.__name__
|
|
59
|
+
desc = (description or task.func.__doc__ or f"Run {tool_name}").strip()
|
|
60
|
+
|
|
61
|
+
async def handler(args: dict[str, typing.Any]) -> dict[str, typing.Any]:
|
|
62
|
+
# In a Flyte task context this submits a durable child action; locally it
|
|
63
|
+
# runs inline. ``coerce_tool_args`` relaxes LLM int->float args so Flyte's
|
|
64
|
+
# type engine doesn't reject e.g. ``amount_usd=42`` for a ``float`` param.
|
|
65
|
+
result = await task.aio(**coerce_tool_args(task, args or {}))
|
|
66
|
+
return _as_content(result)
|
|
67
|
+
|
|
68
|
+
sdk_tool = claude_tool(tool_name, desc, task_json_schema(task))(handler)
|
|
69
|
+
|
|
70
|
+
# The tool shadows the task at module scope, so wire the shared resolver and
|
|
71
|
+
# expose the real task for it to recover on the worker.
|
|
72
|
+
attach_tool_resolver(task)
|
|
73
|
+
sdk_tool.__wrapped_task__ = task
|
|
74
|
+
sdk_tool.task = task
|
|
75
|
+
return sdk_tool
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _callable_to_tool(
|
|
79
|
+
func: typing.Callable,
|
|
80
|
+
*,
|
|
81
|
+
name: str | None = None,
|
|
82
|
+
description: str | None = None,
|
|
83
|
+
) -> SdkMcpTool:
|
|
84
|
+
tool_name = name or getattr(func, "__name__", "tool")
|
|
85
|
+
desc = (description or func.__doc__ or f"Run {tool_name}").strip()
|
|
86
|
+
schema = NativeInterface.from_callable(func).json_schema
|
|
87
|
+
|
|
88
|
+
async def handler(args: dict[str, typing.Any]) -> dict[str, typing.Any]:
|
|
89
|
+
out = func(**(args or {}))
|
|
90
|
+
if inspect.isawaitable(out):
|
|
91
|
+
out = await out
|
|
92
|
+
return _as_content(out)
|
|
93
|
+
|
|
94
|
+
return claude_tool(tool_name, desc, schema)(handler)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _as_content(result: typing.Any) -> dict[str, typing.Any]:
|
|
98
|
+
text = result if isinstance(result, str) else json.dumps(result, default=str)
|
|
99
|
+
return {"content": [{"type": "text", "text": text}]}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: flyteplugins-agents-claude
|
|
3
|
+
Version: 2.5.9
|
|
4
|
+
Summary: Run Claude Agent 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: claude-agent-sdk
|
|
10
|
+
|
|
11
|
+
# flyteplugins-agents-claude
|
|
12
|
+
|
|
13
|
+
Run [Claude Agent SDK](https://github.com/anthropics/claude-agent-sdk-python)
|
|
14
|
+
agents on Flyte. You keep writing Claude agents; Flyte is the runtime underneath.
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pip install flyteplugins-agents-claude
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
import flyte
|
|
22
|
+
from flyteplugins.agents.claude import tool, run_agent
|
|
23
|
+
|
|
24
|
+
env = flyte.TaskEnvironment(
|
|
25
|
+
"claude-agent",
|
|
26
|
+
secrets=[flyte.Secret(key="anthropic_api_key", as_env_var="ANTHROPIC_API_KEY")],
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
@tool
|
|
30
|
+
@env.task(cache="auto", retries=3)
|
|
31
|
+
async def get_weather(city: str) -> str:
|
|
32
|
+
"""Get the current weather for a city."""
|
|
33
|
+
return f"The weather in {city} is sunny, 22°C."
|
|
34
|
+
|
|
35
|
+
@env.task(report=True, retries=3)
|
|
36
|
+
async def city_agent(question: str) -> str:
|
|
37
|
+
return await run_agent(question, tools=[get_weather], model="claude-sonnet-4-5")
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## How it maps to Flyte
|
|
41
|
+
|
|
42
|
+
- Tools are in-process MCP tools (the SDK's own `@tool`/`SdkMcpTool`); our `tool`
|
|
43
|
+
wraps an `@env.task` so that when Claude calls it, the task runs as a durable
|
|
44
|
+
Flyte child action (its own container/resources, retries, caching). The input
|
|
45
|
+
schema is derived via the Flyte type engine.
|
|
46
|
+
- The loop runs in the Claude Code runtime. `run_agent` runs that loop inside
|
|
47
|
+
your `@env.task`, streams the messages, and renders the timeline (assistant
|
|
48
|
+
turns, tool calls, cost) into the task report.
|
|
49
|
+
|
|
50
|
+
## Durability
|
|
51
|
+
|
|
52
|
+
Two layers, both real:
|
|
53
|
+
|
|
54
|
+
- Tool calls are durable Flyte child actions (own container/resources, retries,
|
|
55
|
+
caching) — always, regardless of `durable`.
|
|
56
|
+
- The conversation survives a crash. With `durable=True`, `run_agent` wires the
|
|
57
|
+
Claude SDK's own session mirror + resume onto a `flyte.Checkpoint`: a
|
|
58
|
+
deterministic `session_id` (derived from the task's action, so it's stable across
|
|
59
|
+
retries) is pinned on the first attempt, and on a retry the prior attempt's
|
|
60
|
+
transcript is restored from the checkpoint and the run resumes instead of
|
|
61
|
+
restarting.
|
|
62
|
+
|
|
63
|
+
We delegate to the SDK's resume because the model loop runs in the Claude Code
|
|
64
|
+
runtime (a subprocess Flyte doesn't intercept), so a model turn can't be a
|
|
65
|
+
`flyte.trace` leaf the way it is for other client-side SDKs. Session
|
|
66
|
+
resume is the coarser-grained equivalent — whole-session, not per-turn — and it
|
|
67
|
+
no-ops cleanly when there's no checkpoint context (e.g. local runs).
|
|
68
|
+
|
|
69
|
+
## Observability
|
|
70
|
+
|
|
71
|
+
`run_agent` renders a timeline into the task report (`report=True`): the assistant
|
|
72
|
+
turns from the streamed messages, plus each tool's outcome — `PostToolUse` /
|
|
73
|
+
`PostToolUseFailure` hooks record the result or error the message stream doesn't
|
|
74
|
+
surface. If you pass your own `ClaudeAgentOptions(hooks=...)`, ours are merged in,
|
|
75
|
+
not substituted.
|
|
76
|
+
|
|
77
|
+
## Runtime
|
|
78
|
+
|
|
79
|
+
The `claude-agent-sdk` wheel bundles the native `claude` CLI (a per-platform
|
|
80
|
+
binary, ~250 MB — including the `manylinux` wheel), so `pip install
|
|
81
|
+
flyteplugins-agents-claude` is all the runtime image needs.
|
|
82
|
+
|
|
83
|
+
## Memory
|
|
84
|
+
|
|
85
|
+
Pass `memory_key` (a user/thread id) for cross-run memory — the agent resumes the
|
|
86
|
+
same conversation across separate runs:
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
await run_agent(message, model="claude-sonnet-4-5", memory_key="user-alice")
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
The transcript is persisted to a durable, keyed `MemoryStore` and resumed via the
|
|
93
|
+
SDK's session-mirror on the next run with the same key — which also covers
|
|
94
|
+
crash-resume, so it supersedes the per-run `durable` checkpoint.
|
|
95
|
+
|
|
96
|
+
## Examples
|
|
97
|
+
|
|
98
|
+
See [`examples/`](examples/):
|
|
99
|
+
|
|
100
|
+
- [`claude_durable_agent.py`](examples/claude_durable_agent.py) — a single durable
|
|
101
|
+
agent: tools as Flyte tasks, tool outcomes + assistant turns in the report.
|
|
102
|
+
- [`claude_crash_resume.py`](examples/claude_crash_resume.py) — crash & resume:
|
|
103
|
+
the task crashes on its first attempt; on retry the conversation resumes from the
|
|
104
|
+
`flyte.Checkpoint`-backed session and completed tool calls are cache hits. Run on a
|
|
105
|
+
backend to see resume.
|
|
106
|
+
- [`claude_multi_agent.py`](examples/claude_multi_agent.py) — multi-agent
|
|
107
|
+
orchestration: a planner agent decomposes a topic, researcher agents fan out in
|
|
108
|
+
parallel, an editor agent synthesizes — each agent its own durable action.
|
|
109
|
+
- [`claude_hitl.py`](examples/claude_hitl.py) — human-in-the-loop: a sensitive
|
|
110
|
+
`issue_refund` tool pauses on a Flyte condition (`flyte.new_condition`) for a human
|
|
111
|
+
to approve before it runs — a durable gate the agent SDK has no equivalent for.
|
|
112
|
+
- [`claude_memory.py`](examples/claude_memory.py) — cross-run memory: two
|
|
113
|
+
separate runs share a `memory_key`; the agent learns a fact in run 1 and recalls
|
|
114
|
+
it in run 2.
|
|
115
|
+
- [`claude_handoffs.py`](examples/claude_handoffs.py) — native subagent delegation: a
|
|
116
|
+
triage prompt delegates to a billing or technical-support subagent, the whole run
|
|
117
|
+
durable on Flyte.
|
|
118
|
+
|
|
119
|
+
## Conformance
|
|
120
|
+
|
|
121
|
+
This adapter passes the shared `flyteplugins.agents.core.testing.assert_adapter_conforms`
|
|
122
|
+
check — the same one every adapter runs — so it follows the common format
|
|
123
|
+
(`tool` + `run_agent`, tool tasks wired to the resolver) despite a very
|
|
124
|
+
different underlying SDK shape.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
flyteplugins/agents/claude/__init__.py,sha256=G6lSAnXrFocmcDLZSTgnnkHVKIKFZVMcAx4WS_981QM,757
|
|
2
|
+
flyteplugins/agents/claude/_durable.py,sha256=pUDjp6FTBUx-XRX6piF-nekzNqtpeGGQ7SFSj4PBCMk,6602
|
|
3
|
+
flyteplugins/agents/claude/_memory.py,sha256=6sagwpl-G0oBQl74D_rl2Z-XCMGhtQk-p7rG1G1CJcU,5132
|
|
4
|
+
flyteplugins/agents/claude/_run.py,sha256=ff1txOV06lPa4n5mrPt4OJq37KfeNYeHij6DQYVw0T8,8648
|
|
5
|
+
flyteplugins/agents/claude/_tools.py,sha256=Sb_c_M3tW7VeQ0rFb3LFcnKKwdVkydVZwKmy6nQ15d8,3853
|
|
6
|
+
flyteplugins_agents_claude-2.5.9.dist-info/METADATA,sha256=LVl7Wpl4Mfjpwx3k05fEWAn-p_E4neDl8_OnETMJpaY,5240
|
|
7
|
+
flyteplugins_agents_claude-2.5.9.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
8
|
+
flyteplugins_agents_claude-2.5.9.dist-info/top_level.txt,sha256=cgd779rPu9EsvdtuYgUxNHHgElaQvPn74KhB5XSeMBE,13
|
|
9
|
+
flyteplugins_agents_claude-2.5.9.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
flyteplugins
|