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.
@@ -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,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