saf-sdk 0.0.1__tar.gz

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 @@
1
+ .ref/
saf_sdk-0.0.1/PKG-INFO ADDED
@@ -0,0 +1,41 @@
1
+ Metadata-Version: 2.4
2
+ Name: saf-sdk
3
+ Version: 0.0.1
4
+ Summary: SDK for wrapping third-party agent frameworks (Google ADK, etc.) for LangSmith deployment.
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: langgraph>=0.4.10
7
+ Provides-Extra: google-adk
8
+ Requires-Dist: google-adk>=1.0.0; extra == 'google-adk'
9
+ Requires-Dist: uuid-utils>=0.6; extra == 'google-adk'
10
+ Requires-Dist: wrapt>=1.14; extra == 'google-adk'
11
+ Description-Content-Type: text/markdown
12
+
13
+ # saf-sdk
14
+
15
+ > **Note:** `saf-sdk` is a temporary package name. It will be renamed to `deployments-wrap-sdk` later.
16
+
17
+ SDK for wrapping third-party agent frameworks (Google ADK, Strands, etc.) for deployment on LangSmith.
18
+
19
+ ## Installation
20
+
21
+ ```bash
22
+ pip install saf-sdk[google-adk]
23
+ ```
24
+
25
+ ## Quick Start
26
+
27
+ ```python
28
+ from google.adk.agents import Agent
29
+ from google.adk.runners import Runner
30
+ from saf_sdk.adk import LangsmithSessionService, wrap
31
+
32
+ agent = wrap(
33
+ Runner(
34
+ agent=Agent(name="my_agent", model="gemini-2.0-flash", instruction="..."),
35
+ app_name="my_app",
36
+ session_service=LangsmithSessionService(),
37
+ )
38
+ )
39
+ ```
40
+
41
+ Export `agent` from your graph module and deploy with `langgraph dev` or LangSmith.
@@ -0,0 +1,29 @@
1
+ # saf-sdk
2
+
3
+ > **Note:** `saf-sdk` is a temporary package name. It will be renamed to `deployments-wrap-sdk` later.
4
+
5
+ SDK for wrapping third-party agent frameworks (Google ADK, Strands, etc.) for deployment on LangSmith.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ pip install saf-sdk[google-adk]
11
+ ```
12
+
13
+ ## Quick Start
14
+
15
+ ```python
16
+ from google.adk.agents import Agent
17
+ from google.adk.runners import Runner
18
+ from saf_sdk.adk import LangsmithSessionService, wrap
19
+
20
+ agent = wrap(
21
+ Runner(
22
+ agent=Agent(name="my_agent", model="gemini-2.0-flash", instruction="..."),
23
+ app_name="my_app",
24
+ session_service=LangsmithSessionService(),
25
+ )
26
+ )
27
+ ```
28
+
29
+ Export `agent` from your graph module and deploy with `langgraph dev` or LangSmith.
@@ -0,0 +1,85 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ # NOTE: "saf-sdk" is a temporary name. Will be renamed to "deployments-wrap-sdk" later.
7
+ name = "saf-sdk"
8
+ dynamic = ["version"]
9
+ description = "SDK for wrapping third-party agent frameworks (Google ADK, etc.) for LangSmith deployment."
10
+ readme = "README.md"
11
+ requires-python = ">=3.11"
12
+ dependencies = [
13
+ "langgraph>=0.4.10",
14
+ ]
15
+
16
+ [project.optional-dependencies]
17
+ google-adk = [
18
+ "google-adk>=1.0.0",
19
+ # wrapt is an undeclared transitive dep of langsmith's configure_google_adk;
20
+ # pinned here so users don't need to install it themselves.
21
+ "wrapt>=1.14",
22
+ "uuid-utils>=0.6",
23
+ ]
24
+
25
+ [tool.hatch.version]
26
+ path = "saf_sdk/__init__.py"
27
+
28
+ [tool.hatch.build]
29
+ include = ["saf_sdk"]
30
+ exclude = ["tests/", "docs/"]
31
+
32
+ [tool.hatch.build.targets.sdist]
33
+ exclude = [
34
+ "tests/",
35
+ "docs/",
36
+ ".github/",
37
+ "dist/",
38
+ "dist*/**",
39
+ ".venv*/**",
40
+ ]
41
+
42
+ [tool.ruff]
43
+ exclude = ["venv", "build", "dist"]
44
+
45
+ [tool.ruff.lint]
46
+ select = [
47
+ "E",
48
+ "F",
49
+ "I",
50
+ "TID251",
51
+ "TID252",
52
+ "T20",
53
+ "TC",
54
+ "UP",
55
+ "B",
56
+ "SIM",
57
+ "RUF",
58
+ "S101",
59
+ "PLC0415",
60
+ ]
61
+ ignore = [
62
+ "E501",
63
+ "B006",
64
+ "B904",
65
+ "SIM102",
66
+ ]
67
+ per-file-ignores = {"tests/**" = ["S101", "B017"]}
68
+
69
+ [dependency-groups]
70
+ dev = [
71
+ "ruff>=0.11.12",
72
+ "pytest>=8.3.5",
73
+ "pytest-asyncio>=0.25.0",
74
+ "pytest-watcher>=0.4.3",
75
+ "saf-sdk[google-adk]",
76
+ ]
77
+
78
+ [tool.pytest-watcher]
79
+ now = true
80
+ delay = 3
81
+ patterns = ["*.py"]
82
+
83
+ [tool.pytest.ini_options]
84
+ addopts = "--strict-markers --strict-config --durations=5 -vv"
85
+ asyncio_mode = "auto"
@@ -0,0 +1,8 @@
1
+ """SAF SDK (temporary name — will be renamed to deployments-wrap-sdk).
2
+
3
+ Provides lightweight wrappers for deploying agents from popular frameworks
4
+ (Google ADK, Strands, etc.) to LangSmith without requiring knowledge of
5
+ LangGraph internals.
6
+ """
7
+
8
+ __version__ = "0.0.1"
@@ -0,0 +1,331 @@
1
+ """Google ADK integration for LangSmith Deployments."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextvars
6
+ from typing import TYPE_CHECKING, Annotated, Any, Required, TypedDict
7
+
8
+ from langchain_core.messages import (
9
+ AIMessage,
10
+ AIMessageChunk,
11
+ AnyMessage,
12
+ HumanMessage,
13
+ )
14
+ from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, LLMResult
15
+ from langchain_core.runnables.config import (
16
+ RunnableConfig,
17
+ get_async_callback_manager_for_config,
18
+ )
19
+ from langgraph.config import get_config
20
+ from langgraph.func import entrypoint
21
+ from langgraph.graph.message import add_messages
22
+ from uuid_utils.compat import uuid7
23
+
24
+ try:
25
+ from langsmith.integrations.google_adk import configure_google_adk
26
+ from langsmith.utils import tracing_is_enabled
27
+ except ImportError:
28
+ configure_google_adk = None # type: ignore[assignment, misc]
29
+ tracing_is_enabled = None # type: ignore[assignment]
30
+
31
+ if TYPE_CHECKING:
32
+ from google.adk.agents.run_config import RunConfig, StreamingMode
33
+ from google.adk.runners import Runner
34
+ from google.adk.sessions import BaseSessionService, Session
35
+ from google.adk.sessions.base_session_service import ListSessionsResponse
36
+ from google.genai.types import Content, Part
37
+ from langgraph.pregel import Pregel
38
+
39
+ # Runtime import guard — gives a clear error when google-adk is missing.
40
+ try:
41
+ from google.adk.agents.run_config import RunConfig, StreamingMode
42
+ from google.adk.sessions import BaseSessionService, Session
43
+ from google.adk.sessions.base_session_service import (
44
+ ListSessionsResponse,
45
+ )
46
+ from google.genai.types import Content, Part
47
+ except ImportError as _err:
48
+ raise ImportError(
49
+ "google-adk is required for ADK integration. "
50
+ "Install it with: pip install saf-sdk[google-adk]"
51
+ ) from _err
52
+
53
+ __all__ = ["ADKInput", "LangsmithSessionService", "SessionData", "wrap"]
54
+
55
+ _DEFAULT_USER_ID = "anonymous"
56
+
57
+
58
+ class ADKInput(TypedDict, total=False):
59
+ """Default input schema for ADK agents wrapped with :func:`wrap`.
60
+
61
+ ``messages`` is required; ``state_delta`` is optional and passed directly
62
+ to ``runner.run_async(state_delta=...)`` to update ADK session state.
63
+ """
64
+
65
+ messages: Required[Annotated[list[AnyMessage], add_messages]]
66
+ state_delta: dict[str, Any]
67
+
68
+
69
+ # Per-task session storage: each asyncio task (i.e. each concurrent invocation)
70
+ # gets its own isolated session so concurrent requests don't clobber each other.
71
+ _task_session: contextvars.ContextVar[Session | None] = contextvars.ContextVar(
72
+ "langsmith_adk_session", default=None
73
+ )
74
+
75
+
76
+ class SessionData(TypedDict, total=False):
77
+ """Serialized ADK session stored in the LangGraph checkpoint."""
78
+
79
+ id: str
80
+ app_name: str
81
+ user_id: str
82
+ state: dict[str, Any]
83
+ events: list[dict[str, Any]]
84
+ last_update_time: float
85
+
86
+
87
+ class LangsmithSessionService(BaseSessionService):
88
+ """ADK SessionService backed by LangSmith's checkpoint system.
89
+
90
+ Use this as the ``session_service`` in your ``Runner``. Session state is
91
+ persisted automatically by the deployment's storage backend and survives
92
+ server restarts.
93
+
94
+ Example::
95
+
96
+ from saf_sdk.adk import LangsmithSessionService, wrap
97
+
98
+ agent = wrap(Runner(
99
+ agent=my_agent,
100
+ app_name="my_app",
101
+ session_service=LangsmithSessionService(),
102
+ ))
103
+ """
104
+
105
+ # -- checkpoint bridge (called by wrap()) --------------------------------
106
+
107
+ def load(self, session_data: SessionData | None) -> None:
108
+ """Restore session for the current invocation from checkpoint state."""
109
+ if session_data:
110
+ _task_session.set(Session.model_validate(session_data))
111
+ else:
112
+ _task_session.set(None)
113
+
114
+ def dump(self) -> SessionData | None:
115
+ """Serialize the current session to a dict for checkpointing."""
116
+ session = _task_session.get()
117
+ if session is None:
118
+ return None
119
+ return session.model_dump(mode="json")
120
+
121
+ # -- BaseSessionService interface ----------------------------------------
122
+
123
+ async def get_session(
124
+ self,
125
+ *,
126
+ app_name: str,
127
+ user_id: str,
128
+ session_id: str,
129
+ config: Any = None,
130
+ ) -> Session | None:
131
+ session = _task_session.get()
132
+ if (
133
+ session is not None
134
+ and session.app_name == app_name
135
+ and session.user_id == user_id
136
+ and session.id == session_id
137
+ ):
138
+ return session
139
+ return None
140
+
141
+ async def create_session(
142
+ self,
143
+ *,
144
+ app_name: str,
145
+ user_id: str,
146
+ session_id: str | None = None,
147
+ state: dict | None = None,
148
+ ) -> Session:
149
+ session = Session(
150
+ id=session_id or "",
151
+ app_name=app_name,
152
+ user_id=user_id,
153
+ state=state or {},
154
+ events=[],
155
+ )
156
+ _task_session.set(session)
157
+ return session
158
+
159
+ async def delete_session(
160
+ self, *, app_name: str, user_id: str, session_id: str
161
+ ) -> None:
162
+ session = _task_session.get()
163
+ if (
164
+ session is not None
165
+ and session.app_name == app_name
166
+ and session.user_id == user_id
167
+ and session.id == session_id
168
+ ):
169
+ _task_session.set(None)
170
+
171
+ async def list_sessions(
172
+ self, *, app_name: str, user_id: str | None = None
173
+ ) -> ListSessionsResponse:
174
+ session = _task_session.get()
175
+ if (
176
+ session is not None
177
+ and session.app_name == app_name
178
+ and (user_id is None or session.user_id == user_id)
179
+ ):
180
+ return ListSessionsResponse(sessions=[session])
181
+ return ListSessionsResponse(sessions=[])
182
+
183
+
184
+ async def _stream_runner_events(
185
+ runner: Runner,
186
+ *,
187
+ app_name: str,
188
+ user_id: str,
189
+ session_id: str,
190
+ new_message: Content,
191
+ user_msg_text: str,
192
+ state_delta: dict[str, Any] | None,
193
+ config: RunnableConfig,
194
+ ) -> tuple[str, str]:
195
+ """Run the ADK agent and stream token events through LangGraph's callback system.
196
+
197
+ Bridges ADK's event stream into ``stream_mode="messages"`` so tokens appear
198
+ in useStream / Studio. Returns ``(response_text, msg_id)``.
199
+ """
200
+ cm = get_async_callback_manager_for_config(config)
201
+ msg_id = str(uuid7())
202
+ llm_runs = await cm.on_chat_model_start(
203
+ serialized={"name": app_name},
204
+ messages=[[HumanMessage(content=user_msg_text)]],
205
+ run_id=uuid7(),
206
+ )
207
+
208
+ _run_config = RunConfig(streaming_mode=StreamingMode.SSE)
209
+
210
+ response_parts: list[str] = []
211
+ final_parts: list[str] = []
212
+ async for event in runner.run_async(
213
+ user_id=user_id,
214
+ session_id=session_id,
215
+ new_message=new_message,
216
+ state_delta=state_delta,
217
+ run_config=_run_config,
218
+ ):
219
+ if not event.content or not event.content.parts:
220
+ continue
221
+ if event.partial:
222
+ for part in event.content.parts:
223
+ if part.text:
224
+ response_parts.append(part.text)
225
+ chunk = ChatGenerationChunk(
226
+ message=AIMessageChunk(content=part.text, id=msg_id)
227
+ )
228
+ for llm_run in llm_runs:
229
+ await llm_run.on_llm_new_token(part.text, chunk=chunk)
230
+ else:
231
+ for part in event.content.parts:
232
+ if part.text:
233
+ final_parts.append(part.text)
234
+
235
+ response_text = "".join(response_parts) or "".join(final_parts)
236
+
237
+ for llm_run in llm_runs:
238
+ await llm_run.on_llm_end(
239
+ LLMResult(
240
+ generations=[
241
+ [
242
+ ChatGeneration(
243
+ message=AIMessageChunk(content=response_text, id=msg_id)
244
+ )
245
+ ]
246
+ ]
247
+ )
248
+ )
249
+
250
+ return response_text, msg_id
251
+
252
+
253
+ def wrap(runner: Runner) -> Pregel:
254
+ """Wrap a Google ADK Runner for deployment on LangSmith.
255
+
256
+ The runner's ``session_service`` must be a ``LangsmithSessionService``.
257
+
258
+ Args:
259
+ runner: A configured ``google.adk.runners.Runner`` instance.
260
+
261
+ Returns:
262
+ A LangGraph-compatible agent ready to be exported from your graph module.
263
+ """
264
+ if tracing_is_enabled is not None and tracing_is_enabled():
265
+ configure_google_adk()
266
+
267
+ if not isinstance(runner.session_service, LangsmithSessionService):
268
+ raise TypeError(
269
+ f"runner.session_service must be a LangsmithSessionService, "
270
+ f"got {type(runner.session_service).__name__}. "
271
+ f"Replace it with: session_service=LangsmithSessionService()"
272
+ )
273
+
274
+ app_name = runner.app_name
275
+ output_key: str | None = getattr(runner.agent, "output_key", None)
276
+
277
+ async def _run(
278
+ payload: ADKInput,
279
+ previous: dict | None = None,
280
+ ) -> entrypoint.final[dict, dict]:
281
+ config = get_config()
282
+ configurable = config.get("configurable", {})
283
+ session_id = configurable.get("thread_id", "default")
284
+ user_id = configurable.get("langgraph_auth_user_id") or _DEFAULT_USER_ID
285
+
286
+ runner.session_service.load(previous or {})
287
+
288
+ session = await runner.session_service.get_session(
289
+ app_name=app_name, user_id=user_id, session_id=session_id
290
+ )
291
+ if session is None:
292
+ await runner.session_service.create_session(
293
+ app_name=app_name, user_id=user_id, session_id=session_id
294
+ )
295
+
296
+ if not payload["messages"]:
297
+ raise ValueError("messages must not be empty")
298
+ user_msg = HumanMessage(content=payload["messages"][-1]["content"]).text
299
+ new_message = Content(role="user", parts=[Part(text=user_msg)])
300
+
301
+ response_text, msg_id = await _stream_runner_events(
302
+ runner,
303
+ app_name=app_name,
304
+ user_id=user_id,
305
+ session_id=session_id,
306
+ new_message=new_message,
307
+ user_msg_text=user_msg,
308
+ state_delta=payload.get("state_delta"),
309
+ config=config,
310
+ )
311
+
312
+ session_data = runner.session_service.dump()
313
+ value: dict[str, Any] = {
314
+ "messages": [AIMessage(content=response_text, id=msg_id)],
315
+ "_adk_session": session_data,
316
+ }
317
+
318
+ if output_key and session_data:
319
+ output_val = session_data.get("state", {}).get(output_key)
320
+ if output_val is not None:
321
+ value[output_key] = output_val
322
+
323
+ return entrypoint.final(
324
+ value=value,
325
+ save=session_data,
326
+ )
327
+
328
+ _run.__name__ = app_name
329
+ _run.__annotations__["payload"] = ADKInput
330
+ _run.__annotations__["return"] = entrypoint.final[dict, dict]
331
+ return entrypoint(name=app_name)(_run)