chatsee-google-adk 0.1.0__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,97 @@
1
+ Metadata-Version: 2.4
2
+ Name: chatsee-google-adk
3
+ Version: 0.1.0
4
+ Summary: ChatSee connector for Google ADK — capture every agent run with one plugin.
5
+ Maintainer-email: ChatSee <contact@chatsee.ai>
6
+ License: MIT
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: chatsee-ai>=0.14.0
10
+ Requires-Dist: google-adk>=2.4
11
+ Provides-Extra: dev
12
+ Requires-Dist: pytest>=8.0; extra == "dev"
13
+ Requires-Dist: chatsee-connector-testing; extra == "dev"
14
+
15
+ # chatsee-google-adk
16
+
17
+ ChatSee connector for Google ADK (Agent Development Kit).
18
+
19
+ ```bash
20
+ pip install chatsee-google-adk
21
+ ```
22
+
23
+ ```python
24
+ from google.adk.apps import App
25
+ from chatsee_google_adk import instrument_google_adk
26
+
27
+ chatsee = instrument_google_adk(agent_id="...", tenant_id="...")
28
+ app = App(name="support", root_agent=agent, plugins=[chatsee.plugin])
29
+ ```
30
+
31
+ That is the whole integration. The handle's `.plugin` is an ADK `BasePlugin`,
32
+ and it works the same on a `Runner(plugins=[...])`. `chatsee-ai` is installed
33
+ for you as a dependency.
34
+
35
+ ## What it captures
36
+
37
+ Per turn: the user's message, the agent's answer, the root agent's
38
+ instruction as the system prompt, every tool call with its arguments, result
39
+ and duration, and **one model call per model call** with the usage ADK
40
+ reported for it.
41
+
42
+ ## No per-agent wiring
43
+
44
+ ADK fires plugin callbacks globally — every agent, every sub-agent, every
45
+ model call and tool in the run goes past this one plugin. A multi-agent app
46
+ needs no extra registration, and adding a sub-agent later changes nothing.
47
+
48
+ Every callback returns `None`, which is ADK's "carry on" — the plugin only
49
+ reads what goes past and never alters a response, a tool result or the flow.
50
+
51
+ ## The part worth knowing
52
+
53
+ **One ADK run is one ChatSee turn**, emitted from `after_run_callback` — one
54
+ `runner.run_async` call, one user message. Not `after_agent_callback`, which
55
+ fires once per sub-agent and would split a single turn into several.
56
+
57
+ Two consequences worth knowing:
58
+
59
+ * **A failed run still produces a turn.** ADK skips `after_run` when a run
60
+ raises, so the connector also emits from `on_run_error_callback`, with the
61
+ error attached. Otherwise a failing agent would simply go quiet.
62
+ * **Streaming does not inflate the model-call count.** `after_model` fires
63
+ once per chunk; partial responses are ignored and only the final aggregated
64
+ response counts, so one call stays one call rather than dozens.
65
+
66
+ ## Sessions and users
67
+
68
+ By default each turn uses ADK's own `session.id` and `user_id`, so a reused
69
+ ADK session is one ChatSee conversation — usually what you want.
70
+
71
+ To override, pass `user_id=` / `conversation_id=` to `instrument_google_adk`,
72
+ or call `chatsee.set_session(conversation_id)` to pin every later turn to one
73
+ conversation. `chatsee.stats()` reports turns emitted, capture failures and
74
+ runs in flight.
75
+
76
+ ## Guarantees
77
+
78
+ * **Fails open.** Every callback is wrapped; a failure inside capture is
79
+ logged and swallowed. ADK re-raises plugin exceptions into the run, so this
80
+ matters more here than elsewhere — ChatSee can never break your agent.
81
+ * **Idempotent.** Instrumenting twice with the same identity returns the first
82
+ handle rather than registering a second plugin.
83
+ * **Concurrency-safe.** Runs are keyed by `invocation_id`, so one Runner
84
+ serving several users at once does not mix them up, and the send happens off
85
+ the event loop so one turn's POST does not stall the others.
86
+
87
+ All three are enforced by the contract suite, not just intended.
88
+
89
+ ## Tests
90
+
91
+ No network, no API key, no dev tenant — ADK is stubbed, ChatSee is not.
92
+
93
+ ```bash
94
+ pip install -e python/connectors/_shared
95
+ pip install -e python/connectors/frameworks/google-adk
96
+ pytest python/connectors/frameworks/google-adk/tests -q
97
+ ```
@@ -0,0 +1,83 @@
1
+ # chatsee-google-adk
2
+
3
+ ChatSee connector for Google ADK (Agent Development Kit).
4
+
5
+ ```bash
6
+ pip install chatsee-google-adk
7
+ ```
8
+
9
+ ```python
10
+ from google.adk.apps import App
11
+ from chatsee_google_adk import instrument_google_adk
12
+
13
+ chatsee = instrument_google_adk(agent_id="...", tenant_id="...")
14
+ app = App(name="support", root_agent=agent, plugins=[chatsee.plugin])
15
+ ```
16
+
17
+ That is the whole integration. The handle's `.plugin` is an ADK `BasePlugin`,
18
+ and it works the same on a `Runner(plugins=[...])`. `chatsee-ai` is installed
19
+ for you as a dependency.
20
+
21
+ ## What it captures
22
+
23
+ Per turn: the user's message, the agent's answer, the root agent's
24
+ instruction as the system prompt, every tool call with its arguments, result
25
+ and duration, and **one model call per model call** with the usage ADK
26
+ reported for it.
27
+
28
+ ## No per-agent wiring
29
+
30
+ ADK fires plugin callbacks globally — every agent, every sub-agent, every
31
+ model call and tool in the run goes past this one plugin. A multi-agent app
32
+ needs no extra registration, and adding a sub-agent later changes nothing.
33
+
34
+ Every callback returns `None`, which is ADK's "carry on" — the plugin only
35
+ reads what goes past and never alters a response, a tool result or the flow.
36
+
37
+ ## The part worth knowing
38
+
39
+ **One ADK run is one ChatSee turn**, emitted from `after_run_callback` — one
40
+ `runner.run_async` call, one user message. Not `after_agent_callback`, which
41
+ fires once per sub-agent and would split a single turn into several.
42
+
43
+ Two consequences worth knowing:
44
+
45
+ * **A failed run still produces a turn.** ADK skips `after_run` when a run
46
+ raises, so the connector also emits from `on_run_error_callback`, with the
47
+ error attached. Otherwise a failing agent would simply go quiet.
48
+ * **Streaming does not inflate the model-call count.** `after_model` fires
49
+ once per chunk; partial responses are ignored and only the final aggregated
50
+ response counts, so one call stays one call rather than dozens.
51
+
52
+ ## Sessions and users
53
+
54
+ By default each turn uses ADK's own `session.id` and `user_id`, so a reused
55
+ ADK session is one ChatSee conversation — usually what you want.
56
+
57
+ To override, pass `user_id=` / `conversation_id=` to `instrument_google_adk`,
58
+ or call `chatsee.set_session(conversation_id)` to pin every later turn to one
59
+ conversation. `chatsee.stats()` reports turns emitted, capture failures and
60
+ runs in flight.
61
+
62
+ ## Guarantees
63
+
64
+ * **Fails open.** Every callback is wrapped; a failure inside capture is
65
+ logged and swallowed. ADK re-raises plugin exceptions into the run, so this
66
+ matters more here than elsewhere — ChatSee can never break your agent.
67
+ * **Idempotent.** Instrumenting twice with the same identity returns the first
68
+ handle rather than registering a second plugin.
69
+ * **Concurrency-safe.** Runs are keyed by `invocation_id`, so one Runner
70
+ serving several users at once does not mix them up, and the send happens off
71
+ the event loop so one turn's POST does not stall the others.
72
+
73
+ All three are enforced by the contract suite, not just intended.
74
+
75
+ ## Tests
76
+
77
+ No network, no API key, no dev tenant — ADK is stubbed, ChatSee is not.
78
+
79
+ ```bash
80
+ pip install -e python/connectors/_shared
81
+ pip install -e python/connectors/frameworks/google-adk
82
+ pytest python/connectors/frameworks/google-adk/tests -q
83
+ ```
@@ -0,0 +1,387 @@
1
+ """
2
+ ChatSee connector for Google ADK (Agent Development Kit).
3
+
4
+ One line in the customer's startup, one argument on their App or Runner:
5
+
6
+ from chatsee_google_adk import instrument_google_adk
7
+
8
+ chatsee = instrument_google_adk(agent_id="...", tenant_id="...")
9
+ app = App(name="support", root_agent=agent, plugins=[chatsee.plugin])
10
+
11
+ The handle's ``.plugin`` is an ADK ``BasePlugin``. ADK fires plugin callbacks
12
+ globally -- every agent, sub-agent, model call and tool in the run -- so there
13
+ is no per-agent wiring. The plugin returns None from every callback, so ADK's
14
+ own behaviour is never altered; it only reads what goes past.
15
+
16
+ Turn boundary: one ADK run (one ``runner.run_async`` call, one user message)
17
+ is one ChatSee turn, emitted from ``after_run_callback``. Not
18
+ ``after_agent_callback``, which fires once per sub-agent.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import asyncio
23
+ import logging
24
+ import threading
25
+ import time
26
+ import weakref
27
+ from typing import Any, Dict, Optional
28
+
29
+ from ._capture import (
30
+ FRAMEWORK,
31
+ ModelCall,
32
+ Run,
33
+ RunStore,
34
+ ToolCall,
35
+ content_text,
36
+ elapsed_ms,
37
+ safe_json,
38
+ split_model,
39
+ system_instruction,
40
+ )
41
+
42
+ __version__ = "0.1.0"
43
+ __all__ = ["instrument_google_adk", "ChatseeGoogleADK", "ChatseeADKPlugin", "__version__"]
44
+
45
+ logger = logging.getLogger(__name__)
46
+
47
+ try:
48
+ from google.adk.plugins.base_plugin import BasePlugin
49
+ except ImportError: # pragma: no cover - google-adk is a declared dependency
50
+ # Only so the offline contract tests can run without the framework. A
51
+ # customer always has google-adk; the Runner needs a real BasePlugin.
52
+ class BasePlugin: # type: ignore[no-redef]
53
+ def __init__(self, name: str) -> None:
54
+ self.name = name
55
+
56
+
57
+ PLUGIN_NAME = "chatsee"
58
+
59
+
60
+ def _framework_version() -> str:
61
+ try:
62
+ from google.adk import version
63
+
64
+ return getattr(version, "__version__", "unknown")
65
+ except Exception:
66
+ return "unknown"
67
+
68
+
69
+ def _invocation_id(ctx: Any) -> Optional[str]:
70
+ """CallbackContext / ToolContext / InvocationContext all expose it."""
71
+ return getattr(ctx, "invocation_id", None)
72
+
73
+
74
+ def _call_key(ctx: Any) -> str:
75
+ """Which agent made this model call, so its start and end pair up."""
76
+ return str(getattr(ctx, "agent_name", None) or "")
77
+
78
+
79
+ class ChatseeADKPlugin(BasePlugin):
80
+ """The ADK plugin. Pass it to ``App(plugins=[...])`` or ``Runner(plugins=[...])``.
81
+
82
+ Every callback is wrapped so a failure inside ChatSee is logged and
83
+ swallowed. ADK re-raises plugin exceptions into the run, so an unguarded
84
+ bug here would break the customer's agent.
85
+ """
86
+
87
+ def __init__(self, handle: "ChatseeGoogleADK") -> None:
88
+ super().__init__(name=PLUGIN_NAME)
89
+ self._handle = handle
90
+
91
+ # -- run lifecycle -----------------------------------------------------
92
+
93
+ async def on_user_message_callback(self, *, invocation_context: Any,
94
+ user_message: Any) -> None:
95
+ try:
96
+ invocation_id = _invocation_id(invocation_context)
97
+ text = content_text(user_message)
98
+ if not invocation_id or not text:
99
+ # Nothing identifies this run, or nothing was asked. Recording
100
+ # it would attach its calls to no turn.
101
+ return None
102
+ session = getattr(invocation_context, "session", None)
103
+ agent = getattr(invocation_context, "agent", None)
104
+ self._handle._store.open(invocation_id, Run(
105
+ user_message=text,
106
+ session_id=getattr(session, "id", None),
107
+ user_id=getattr(invocation_context, "user_id", None),
108
+ bot_id=getattr(agent, "name", None),
109
+ ))
110
+ except Exception as exc: # noqa: BLE001 - ChatSee must never break the agent
111
+ self._handle._capture_failed("on_user_message", exc)
112
+ return None
113
+
114
+ async def after_run_callback(self, *, invocation_context: Any) -> None:
115
+ try:
116
+ run = self._handle._store.take(_invocation_id(invocation_context))
117
+ if run is not None:
118
+ await self._handle._emit(run)
119
+ except Exception as exc: # noqa: BLE001
120
+ self._handle._capture_failed("after_run", exc)
121
+ return None
122
+
123
+ async def on_run_error_callback(self, *, invocation_context: Any,
124
+ error: Exception) -> None:
125
+ # ADK skips after_run when the run raises. Emit the turn here instead,
126
+ # with the error, so a failed turn is visible rather than missing.
127
+ try:
128
+ run = self._handle._store.take(_invocation_id(invocation_context))
129
+ if run is not None:
130
+ await self._handle._emit(run, exception=error)
131
+ except Exception as exc: # noqa: BLE001
132
+ self._handle._capture_failed("on_run_error", exc)
133
+ return None
134
+
135
+ # -- model calls -------------------------------------------------------
136
+
137
+ async def before_model_callback(self, *, callback_context: Any,
138
+ llm_request: Any) -> None:
139
+ try:
140
+ run = self._handle._store.get(_invocation_id(callback_context))
141
+ if run is not None:
142
+ if run.system_prompt is None:
143
+ # The first model call is the root agent's; its
144
+ # instruction is the turn's system prompt.
145
+ run.system_prompt = system_instruction(llm_request)
146
+ key = _call_key(callback_context)
147
+ run.model_started[key] = time.monotonic()
148
+ run.model_requested[key] = getattr(llm_request, "model", None)
149
+ except Exception as exc: # noqa: BLE001
150
+ self._handle._capture_failed("before_model", exc)
151
+ return None
152
+
153
+ async def after_model_callback(self, *, callback_context: Any,
154
+ llm_response: Any) -> None:
155
+ try:
156
+ run = self._handle._store.get(_invocation_id(callback_context))
157
+ if run is None:
158
+ return None
159
+ if getattr(llm_response, "partial", None):
160
+ # Streaming fires this once per chunk. Only the final,
161
+ # aggregated response is a model call; counting chunks would
162
+ # turn one call into dozens.
163
+ return None
164
+ key = _call_key(callback_context)
165
+ requested = run.model_requested.pop(key, None)
166
+ _, provider = split_model(requested)
167
+ model, version_provider = split_model(
168
+ getattr(llm_response, "model_version", None) or requested)
169
+ # One log_model_call per model call. A tool turn fires this twice
170
+ # (the tool request, then the answer); do NOT sum them -- the SDK
171
+ # sums, and the per-call grain keeps spend attributable.
172
+ run.model_calls.append(ModelCall(
173
+ model=model,
174
+ provider=provider or version_provider,
175
+ usage=getattr(llm_response, "usage_metadata", None),
176
+ duration_ms=elapsed_ms(run.model_started.pop(key, None)),
177
+ ))
178
+ text = content_text(getattr(llm_response, "content", None))
179
+ if text:
180
+ run.bot_message = text
181
+ except Exception as exc: # noqa: BLE001
182
+ self._handle._capture_failed("after_model", exc)
183
+ return None
184
+
185
+ async def on_model_error_callback(self, *, callback_context: Any,
186
+ llm_request: Any, error: Exception) -> None:
187
+ try:
188
+ run = self._handle._store.get(_invocation_id(callback_context))
189
+ if run is not None:
190
+ key = _call_key(callback_context)
191
+ run.model_started.pop(key, None)
192
+ run.model_requested.pop(key, None)
193
+ run.exception = str(error)
194
+ except Exception as exc: # noqa: BLE001
195
+ self._handle._capture_failed("on_model_error", exc)
196
+ return None
197
+
198
+ # -- tool calls --------------------------------------------------------
199
+
200
+ async def before_tool_callback(self, *, tool: Any, tool_args: Dict[str, Any],
201
+ tool_context: Any) -> None:
202
+ try:
203
+ run = self._handle._store.get(_invocation_id(tool_context))
204
+ if run is not None:
205
+ run.tool_started[_tool_key(tool, tool_context)] = time.monotonic()
206
+ except Exception as exc: # noqa: BLE001
207
+ self._handle._capture_failed("before_tool", exc)
208
+ return None
209
+
210
+ async def after_tool_callback(self, *, tool: Any, tool_args: Dict[str, Any],
211
+ tool_context: Any, result: Any) -> None:
212
+ self._record_tool(tool, tool_args, tool_context, result=result)
213
+ return None
214
+
215
+ async def on_tool_error_callback(self, *, tool: Any, tool_args: Dict[str, Any],
216
+ tool_context: Any, error: Exception) -> None:
217
+ self._record_tool(tool, tool_args, tool_context, error=str(error) or type(error).__name__)
218
+ return None
219
+
220
+ def _record_tool(self, tool: Any, tool_args: Any, tool_context: Any,
221
+ result: Any = None, error: Optional[str] = None) -> None:
222
+ try:
223
+ run = self._handle._store.get(_invocation_id(tool_context))
224
+ if run is None:
225
+ return
226
+ run.tool_calls.append(ToolCall(
227
+ tool_name=getattr(tool, "name", None) or "unknown",
228
+ arguments=safe_json(tool_args) or {},
229
+ result=safe_json(result),
230
+ error=error,
231
+ duration_ms=elapsed_ms(run.tool_started.pop(_tool_key(tool, tool_context), None)),
232
+ ))
233
+ except Exception as exc: # noqa: BLE001
234
+ self._handle._capture_failed("tool", exc)
235
+
236
+
237
+ def _tool_key(tool: Any, tool_context: Any) -> str:
238
+ """Pairs a tool's before and after. Parallel calls differ by function_call_id."""
239
+ return "%s:%s" % (getattr(tool, "name", None),
240
+ getattr(tool_context, "function_call_id", None))
241
+
242
+
243
+ class ChatseeGoogleADK:
244
+ """Handle returned by :func:`instrument_google_adk`.
245
+
246
+ ``.plugin`` is the only thing a customer needs. ``.set_session(id)`` pins
247
+ every turn to one ChatSee conversation; ``.stats()`` reports what the
248
+ connector emitted.
249
+ """
250
+
251
+ def __init__(self, tracker: Any, user_id: Optional[str] = None,
252
+ conversation_id: Optional[str] = None) -> None:
253
+ self._tracker = tracker
254
+ self._user_id = user_id
255
+ self._conversation_id = conversation_id
256
+ self._store = RunStore()
257
+ # The tracker holds one turn at a time; a run's start..end replay must
258
+ # not interleave with another run's.
259
+ self._emit_lock = threading.Lock()
260
+ self._emitted = 0
261
+ self._failures = 0
262
+ self.plugin = ChatseeADKPlugin(self)
263
+
264
+ # -- public ------------------------------------------------------------
265
+
266
+ def set_session(self, conversation_id: str) -> None:
267
+ """Send every later turn under this conversation id.
268
+
269
+ Without it, each turn uses ADK's own ``session.id``, so a reused ADK
270
+ session is one ChatSee conversation -- usually what you want.
271
+ """
272
+ self._conversation_id = conversation_id
273
+
274
+ def stats(self) -> Dict[str, int]:
275
+ return {
276
+ "turns_emitted": self._emitted,
277
+ "capture_failures": self._failures,
278
+ "in_flight": self._store.in_flight,
279
+ }
280
+
281
+ # -- internals ---------------------------------------------------------
282
+
283
+ def _metadata(self, run: Run) -> Dict[str, Any]:
284
+ metadata: Dict[str, Any] = {
285
+ "framework": FRAMEWORK,
286
+ "framework_version": _framework_version(),
287
+ "connector_version": __version__,
288
+ }
289
+ if run.bot_id:
290
+ # The agent's name in the customer's code -- never the ChatSee agent_id.
291
+ metadata["bot_id"] = run.bot_id
292
+ return metadata
293
+
294
+ async def _emit(self, run: Run, exception: Optional[BaseException] = None) -> None:
295
+ # end_turn POSTs synchronously. Off the event loop, so one run's send
296
+ # does not stall every other run this Runner is serving.
297
+ await asyncio.to_thread(self._replay, run, exception)
298
+
299
+ def _replay(self, run: Run, exception: Optional[BaseException] = None) -> None:
300
+ """Hand one finished run to the tracker as start_turn .. end_turn."""
301
+ try:
302
+ with self._emit_lock:
303
+ tracker = self._tracker
304
+ session_id = self._conversation_id or run.session_id
305
+ if session_id:
306
+ tracker.session_id = session_id
307
+
308
+ tracker.start_turn(run.user_message,
309
+ metadata=self._metadata(run),
310
+ system_prompt=run.system_prompt,
311
+ user_id=self._user_id or run.user_id)
312
+ for call in run.tool_calls:
313
+ tracker.log_tool_call(call.tool_name, call.arguments,
314
+ result=call.result, error=call.error,
315
+ duration_ms=call.duration_ms)
316
+ for call in run.model_calls:
317
+ tracker.log_model_call(model=call.model, provider=call.provider,
318
+ usage=call.usage, duration_ms=call.duration_ms)
319
+ error = exception if exception is not None else run.exception
320
+ if error is not None:
321
+ tracker.log_exception(error)
322
+ tracker.end_turn(run.bot_message)
323
+ self._emitted += 1
324
+ except Exception as exc: # noqa: BLE001
325
+ self._capture_failed("emit", exc)
326
+
327
+ def _capture_failed(self, where: str, exc: BaseException) -> None:
328
+ self._failures += 1
329
+ logger.error("chatsee-google-adk capture failed in %s: %s", where, exc, exc_info=True)
330
+
331
+
332
+ # One handle per identity, so instrumenting twice is a no-op rather than a
333
+ # second plugin capturing every turn again.
334
+ _HANDLES: "weakref.WeakValueDictionary[Any, ChatseeGoogleADK]" = weakref.WeakValueDictionary()
335
+ _HANDLES_LOCK = threading.Lock()
336
+
337
+
338
+ def instrument_google_adk(
339
+ agent_id: Optional[str] = None,
340
+ tenant_id: Optional[str] = None,
341
+ user_id: Optional[str] = None,
342
+ conversation_id: Optional[str] = None,
343
+ api_base_url: Optional[str] = None,
344
+ _tracker: Any = None,
345
+ ) -> ChatseeGoogleADK:
346
+ """Capture every ADK run as one ChatSee turn.
347
+
348
+ ``agent_id`` and ``tenant_id`` say which ChatSee agent the data belongs to,
349
+ and are all that is required. Register the returned handle's ``.plugin``
350
+ on your ``App`` (or ``Runner``).
351
+
352
+ ``user_id`` overrides ADK's own ``user_id`` for every turn;
353
+ ``conversation_id`` overrides ADK's ``session.id``. Leave both unset and
354
+ the connector uses what ADK already knows.
355
+
356
+ ``_tracker`` is private and exists so the contract tests can inject a
357
+ fake. Customers never pass it.
358
+
359
+ Calling this twice with the same arguments is a no-op and returns the
360
+ first handle.
361
+ """
362
+ if _tracker is not None:
363
+ key: Any = ("tracker", id(_tracker))
364
+ else:
365
+ if not agent_id or not tenant_id:
366
+ raise ValueError("agent_id and tenant_id are required.")
367
+ key = ("identity", agent_id, tenant_id, user_id, conversation_id, api_base_url)
368
+
369
+ with _HANDLES_LOCK:
370
+ handle = _HANDLES.get(key)
371
+ if handle is not None:
372
+ return handle
373
+
374
+ if _tracker is None:
375
+ from chatsee import ChatseeTracker
376
+
377
+ _tracker = ChatseeTracker(
378
+ agent_id=agent_id,
379
+ tenant_id=tenant_id,
380
+ user_id=user_id,
381
+ session_id=conversation_id,
382
+ api_base_url=api_base_url,
383
+ )
384
+
385
+ handle = ChatseeGoogleADK(_tracker, user_id=user_id, conversation_id=conversation_id)
386
+ _HANDLES[key] = handle
387
+ return handle