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.
- chatsee_google_adk-0.1.0/PKG-INFO +97 -0
- chatsee_google_adk-0.1.0/README.md +83 -0
- chatsee_google_adk-0.1.0/chatsee_google_adk/__init__.py +387 -0
- chatsee_google_adk-0.1.0/chatsee_google_adk/_capture.py +187 -0
- chatsee_google_adk-0.1.0/chatsee_google_adk.egg-info/PKG-INFO +97 -0
- chatsee_google_adk-0.1.0/chatsee_google_adk.egg-info/SOURCES.txt +11 -0
- chatsee_google_adk-0.1.0/chatsee_google_adk.egg-info/dependency_links.txt +1 -0
- chatsee_google_adk-0.1.0/chatsee_google_adk.egg-info/requires.txt +6 -0
- chatsee_google_adk-0.1.0/chatsee_google_adk.egg-info/top_level.txt +1 -0
- chatsee_google_adk-0.1.0/pyproject.toml +30 -0
- chatsee_google_adk-0.1.0/setup.cfg +4 -0
- chatsee_google_adk-0.1.0/tests/test_contract.py +382 -0
- chatsee_google_adk-0.1.0/tests/test_real_runner.py +135 -0
|
@@ -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
|