agentic-composite-memory 0.1.0__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,250 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ from typing import Any, Sequence
6
+
7
+ import httpx
8
+
9
+ from .models import AppMemoryManifest, ReasoningTraceStep
10
+ from .service import BaseMemoryService, Event, MemoryEntry, SearchMemoryResponse, Session, types
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ class RemoteCompositeMemoryService(BaseMemoryService):
16
+ """
17
+ Lightweight HTTP Client implementing Google ADK's `BaseMemoryService` contract.
18
+ Designed for platforms hosting 100s of ADK agent apps:
19
+ - Agents do NOT need direct Postgres/OpenSearch/Neptune credentials or connection pools.
20
+ - Plugs directly into `google.adk.runners.Runner(memory_service=RemoteCompositeMemoryService(...))`.
21
+ """
22
+
23
+ def __init__(
24
+ self,
25
+ base_url: str = "http://localhost:8080",
26
+ api_key: str | None = None,
27
+ timeout_sec: float = 10.0,
28
+ client: httpx.AsyncClient | None = None,
29
+ ):
30
+ self.base_url = base_url.rstrip("/")
31
+ self.api_key = api_key
32
+ self.timeout_sec = timeout_sec
33
+ self._external_client = client
34
+
35
+ def _headers(self) -> dict[str, str]:
36
+ headers = {"Content-Type": "application/json"}
37
+ if self.api_key:
38
+ headers["Authorization"] = f"Bearer {self.api_key}"
39
+ return headers
40
+
41
+ async def _request(self, method: str, path: str, json_body: dict[str, Any] | None = None) -> dict[str, Any]:
42
+ if self._external_client is not None:
43
+ resp = await self._external_client.request(
44
+ method, f"{self.base_url}{path}", json=json_body, headers=self._headers()
45
+ )
46
+ resp.raise_for_status()
47
+ return resp.json()
48
+
49
+ async with httpx.AsyncClient(timeout=self.timeout_sec) as client:
50
+ resp = await client.request(
51
+ method, f"{self.base_url}{path}", json=json_body, headers=self._headers()
52
+ )
53
+ resp.raise_for_status()
54
+ return resp.json()
55
+
56
+ async def register_manifest(self, manifest: AppMemoryManifest) -> dict[str, Any]:
57
+ """Registers an ADK app's ontology manifest with the remote Memory Platform Service."""
58
+ return await self._request("POST", "/v1/apps/register", manifest.model_dump(mode="json"))
59
+
60
+ async def add_session_to_memory(self, session: Session) -> None:
61
+ await self.add_events_to_memory(
62
+ app_name=session.app_name,
63
+ user_id=session.user_id,
64
+ events=session.events,
65
+ session_id=session.id,
66
+ )
67
+
68
+ async def add_events_to_memory(
69
+ self,
70
+ *,
71
+ app_name: str,
72
+ user_id: str,
73
+ events: Sequence[Event],
74
+ session_id: str | None = None,
75
+ custom_metadata: dict[str, Any] | None = None,
76
+ ) -> None:
77
+ serialized_events = []
78
+ for ev in events:
79
+ if not getattr(ev, "content", None) or not getattr(ev.content, "parts", None):
80
+ continue
81
+ text_parts = [p.text for p in ev.content.parts if getattr(p, "text", None)]
82
+ if text_parts:
83
+ serialized_events.append(
84
+ {
85
+ "author": getattr(ev, "author", None) or "agent",
86
+ "text": "\n".join(text_parts),
87
+ }
88
+ )
89
+ if not serialized_events:
90
+ return
91
+
92
+ await self._request(
93
+ "POST",
94
+ "/v1/memory/events",
95
+ {
96
+ "app_name": app_name,
97
+ "user_id": user_id,
98
+ "session_id": session_id or "default_session",
99
+ "events": serialized_events,
100
+ "custom_metadata": custom_metadata or {},
101
+ },
102
+ )
103
+
104
+ async def add_memory(
105
+ self,
106
+ *,
107
+ app_name: str,
108
+ user_id: str,
109
+ memories: Sequence[MemoryEntry],
110
+ custom_metadata: dict[str, Any] | None = None,
111
+ ) -> None:
112
+ synthetic_events = [
113
+ Event(author=m.author or "user", content=m.content)
114
+ for m in memories
115
+ if getattr(m, "content", None)
116
+ ]
117
+ await self.add_events_to_memory(
118
+ app_name=app_name,
119
+ user_id=user_id,
120
+ events=synthetic_events,
121
+ custom_metadata=custom_metadata,
122
+ )
123
+
124
+ async def search_memory(
125
+ self,
126
+ *,
127
+ app_name: str,
128
+ user_id: str,
129
+ query: str,
130
+ as_of_iso: str | None = None,
131
+ max_hops: int = 2,
132
+ ) -> SearchMemoryResponse:
133
+ data = await self._request(
134
+ "POST",
135
+ "/v1/memory/search",
136
+ {
137
+ "app_name": app_name,
138
+ "user_id": user_id,
139
+ "query": query,
140
+ "as_of_iso": as_of_iso,
141
+ "max_hops": max_hops,
142
+ },
143
+ )
144
+ entries: list[MemoryEntry] = []
145
+ for item in data.get("memories", []):
146
+ entries.append(
147
+ MemoryEntry(
148
+ author=item.get("author", "memory"),
149
+ timestamp=item.get("timestamp"),
150
+ content=types.Content(
151
+ role="user",
152
+ parts=[types.Part.from_text(text=item.get("text", ""))],
153
+ ),
154
+ )
155
+ )
156
+ return SearchMemoryResponse(memories=entries)
157
+
158
+ async def record_decision_trace(
159
+ self,
160
+ *,
161
+ app_name: str,
162
+ user_id: str,
163
+ run_id: str,
164
+ session_id: str,
165
+ steps: Sequence[ReasoningTraceStep],
166
+ ) -> dict[str, Any]:
167
+ return await self._request(
168
+ "POST",
169
+ "/v1/traces",
170
+ {
171
+ "app_name": app_name,
172
+ "user_id": user_id,
173
+ "run_id": run_id,
174
+ "session_id": session_id,
175
+ "steps": [s.model_dump(mode="json") for s in steps],
176
+ },
177
+ )
178
+
179
+
180
+ # ============================================================================
181
+ # DROP-IN GOOGLE ADK CALLBACKS & EXPLICIT AGENT TOOLS
182
+ # ============================================================================
183
+ async def non_blocking_memory_commit_callback(callback_context: Any) -> None:
184
+ """
185
+ Google ADK `after_agent_callback`.
186
+ Commits the latest user + agent turn delta asynchronously off the critical user response path.
187
+ """
188
+ session = getattr(callback_context, "session", None)
189
+ if not session or not getattr(session, "events", None):
190
+ return None
191
+ recent_events = session.events[-2:]
192
+ if recent_events and hasattr(callback_context, "add_events_to_memory"):
193
+ asyncio.create_task(callback_context.add_events_to_memory(events=recent_events))
194
+ return None
195
+
196
+
197
+ def get_adk_memory_tools(
198
+ memory_service: BaseMemoryService,
199
+ default_app_name: str = "adk_agent",
200
+ default_user_id: str = "default_user",
201
+ ) -> list[Any]:
202
+ """
203
+ Returns ready-to-attach Google ADK tool functions (`recall_memory_context`,
204
+ `commit_memory_episode`, `record_agent_decision_trace`) backed by either
205
+ `RemoteCompositeMemoryService` (no DB credentials needed) or in-process `CompositeMemoryService`.
206
+ Can be added alongside `PreloadMemoryTool()` in `LlmAgent(tools=[...])`.
207
+ """
208
+
209
+ async def recall_memory_context(
210
+ query: str,
211
+ as_of_timestamp: str | None = None,
212
+ app_name: str = default_app_name,
213
+ user_id: str = default_user_id,
214
+ ) -> dict[str, Any]:
215
+ """Search composite memory (Bi-Temporal Knowledge Graph + Episodic Memory), optionally at a historical point in time (`as_of_timestamp`)."""
216
+ resp = await memory_service.search_memory( # type: ignore[attr-defined]
217
+ app_name=app_name,
218
+ user_id=user_id,
219
+ query=query,
220
+ as_of_iso=as_of_timestamp,
221
+ )
222
+ blocks = []
223
+ for m in resp.memories:
224
+ if getattr(m, "content", None) and getattr(m.content, "parts", None):
225
+ text = "\n".join(
226
+ p.text for p in m.content.parts if getattr(p, "text", None)
227
+ )
228
+ blocks.append({"author": m.author, "timestamp": m.timestamp, "text": text})
229
+ return {"query": query, "as_of": as_of_timestamp, "memories": blocks}
230
+
231
+ async def commit_memory_episode(
232
+ fact_or_observation: str,
233
+ app_name: str = default_app_name,
234
+ user_id: str = default_user_id,
235
+ ) -> dict[str, Any]:
236
+ """Explicitly commit an important observation or domain fact into Episodic Memory and the Bi-Temporal Knowledge Graph."""
237
+ ev = Event(
238
+ author="agent",
239
+ content=types.Content(
240
+ role="user", parts=[types.Part.from_text(text=fact_or_observation)]
241
+ ),
242
+ )
243
+ await memory_service.add_events_to_memory( # type: ignore[attr-defined]
244
+ app_name=app_name,
245
+ user_id=user_id,
246
+ events=[ev],
247
+ )
248
+ return {"status": "committed", "app_name": app_name}
249
+
250
+ return [recall_memory_context, commit_memory_episode]
@@ -0,0 +1,380 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import ssl
5
+ import time
6
+ from dataclasses import dataclass
7
+ from typing import Any, Awaitable, Callable, Protocol, runtime_checkable
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class EnterpriseTlsConfig:
12
+ """
13
+ Enterprise TLS / mTLS configuration for cloud-managed databases
14
+ (AWS RDS/Aurora, ElastiCache, MemoryDB, GCP Cloud SQL/AlloyDB, Azure Postgres, Neo4j Aura).
15
+ """
16
+
17
+ enabled: bool = True
18
+ verify_hostname: bool = True
19
+ ca_file: str | None = None
20
+ client_cert_file: str | None = None
21
+ client_key_file: str | None = None
22
+ min_tls_version: int = ssl.TLSVersion.TLSv1_2
23
+
24
+ def build_ssl_context(self) -> ssl.SSLContext | bool:
25
+ if not self.enabled:
26
+ return False
27
+ ctx = ssl.create_default_context(
28
+ purpose=ssl.Purpose.SERVER_AUTH,
29
+ cafile=self.ca_file,
30
+ )
31
+ ctx.minimum_version = ssl.TLSVersion(self.min_tls_version)
32
+ if self.verify_hostname:
33
+ ctx.check_hostname = True
34
+ ctx.verify_mode = ssl.CERT_REQUIRED
35
+ else:
36
+ ctx.check_hostname = False
37
+ ctx.verify_mode = ssl.CERT_NONE
38
+ if self.client_cert_file:
39
+ ctx.load_cert_chain(
40
+ certfile=self.client_cert_file,
41
+ keyfile=self.client_key_file,
42
+ )
43
+ return ctx
44
+
45
+
46
+ @runtime_checkable
47
+ class CloudAuthProvider(Protocol):
48
+ """
49
+ Protocol for enterprise cloud database authentication (IAM tokens, Entra ID,
50
+ GCP Workload Identity, Vault dynamic secrets, or rotating Secrets Manager credentials).
51
+ """
52
+
53
+ async def get_credentials(self) -> tuple[str, str]:
54
+ """Returns `(username, password_or_token)` with automatic caching & pre-expiry refresh."""
55
+ ...
56
+
57
+ async def get_username(self) -> str:
58
+ """Returns the current database principal/username."""
59
+ ...
60
+
61
+ async def get_password(self) -> str:
62
+ """
63
+ Returns the current database password or short-lived IAM/OAuth token.
64
+ Directly compatible with `asyncpg.create_pool(..., password=auth_provider.get_password)`.
65
+ """
66
+ ...
67
+
68
+ def invalidate(self) -> None:
69
+ """Forces immediate token/secret rotation on next connection attempt."""
70
+ ...
71
+
72
+
73
+ class CachedTokenAuthProviderBase:
74
+ """
75
+ Base class providing concurrency-safe token caching and proactive pre-expiry rotation
76
+ for short-lived cloud IAM tokens (typically 15–60 minutes TTL).
77
+ """
78
+
79
+ def __init__(
80
+ self,
81
+ username: str,
82
+ token_ttl_sec: int = 900,
83
+ refresh_margin_sec: int = 120,
84
+ ):
85
+ self._username = username
86
+ self.token_ttl_sec = max(10, token_ttl_sec)
87
+ self.refresh_margin_sec = min(refresh_margin_sec, self.token_ttl_sec // 2)
88
+ self._cached_username: str | None = None
89
+ self._cached_token: str | None = None
90
+ self._expires_at: float = 0.0
91
+ self._refresh_count: int = 0
92
+ self._lock = asyncio.Lock()
93
+
94
+ @property
95
+ def refresh_count(self) -> int:
96
+ return self._refresh_count
97
+
98
+ def invalidate(self) -> None:
99
+ self._cached_token = None
100
+ self._expires_at = 0.0
101
+
102
+ async def _mint_credentials(self) -> tuple[str, str]:
103
+ raise NotImplementedError
104
+
105
+ async def get_credentials(self) -> tuple[str, str]:
106
+ now = time.monotonic()
107
+ if (
108
+ self._cached_token is not None
109
+ and self._cached_username is not None
110
+ and now < (self._expires_at - self.refresh_margin_sec)
111
+ ):
112
+ return self._cached_username, self._cached_token
113
+
114
+ async with self._lock:
115
+ now = time.monotonic()
116
+ if (
117
+ self._cached_token is not None
118
+ and self._cached_username is not None
119
+ and now < (self._expires_at - self.refresh_margin_sec)
120
+ ):
121
+ return self._cached_username, self._cached_token
122
+
123
+ user, token = await self._mint_credentials()
124
+ self._cached_username = user
125
+ self._cached_token = token
126
+ self._expires_at = time.monotonic() + self.token_ttl_sec
127
+ self._refresh_count += 1
128
+ return user, token
129
+
130
+ async def get_username(self) -> str:
131
+ user, _ = await self.get_credentials()
132
+ return user
133
+
134
+ async def get_password(self) -> str:
135
+ _, token = await self.get_credentials()
136
+ return token
137
+
138
+
139
+ class StaticOrEnvAuthProvider(CachedTokenAuthProviderBase):
140
+ """
141
+ Static or environment-backed credentials provider implementing `CloudAuthProvider`.
142
+ """
143
+
144
+ def __init__(self, username: str, password: str):
145
+ super().__init__(username=username, token_ttl_sec=86400 * 365, refresh_margin_sec=0)
146
+ self._password = password
147
+
148
+ async def _mint_credentials(self) -> tuple[str, str]:
149
+ return self._username, self._password
150
+
151
+
152
+ class AwsIamDatabaseAuthProvider(CachedTokenAuthProviderBase):
153
+ """
154
+ Enterprise AWS IAM Authentication Provider for:
155
+ - Amazon RDS / Aurora PostgreSQL (`service="rds"`, uses `generate_db_auth_token`)
156
+ - Amazon ElastiCache / MemoryDB for Redis (`service="elasticache"` or `"memorydb"`, SigV4 presigned token)
157
+ Supports optional cross-account IAM Role assumption (`assume_role_arn` + `external_id`)
158
+ and custom token generators for offline/unit testing.
159
+ """
160
+
161
+ def __init__(
162
+ self,
163
+ host: str,
164
+ port: int,
165
+ username: str,
166
+ region: str = "us-east-1",
167
+ service: str = "rds",
168
+ cluster_name: str | None = None,
169
+ assume_role_arn: str | None = None,
170
+ external_id: str | None = None,
171
+ token_ttl_sec: int = 900,
172
+ refresh_margin_sec: int = 120,
173
+ custom_token_generator: Callable[[], str | Awaitable[str]] | None = None,
174
+ ):
175
+ super().__init__(
176
+ username=username,
177
+ token_ttl_sec=token_ttl_sec,
178
+ refresh_margin_sec=refresh_margin_sec,
179
+ )
180
+ self.host = host
181
+ self.port = port
182
+ self.region = region
183
+ self.service = service.lower()
184
+ self.cluster_name = cluster_name or host
185
+ self.assume_role_arn = assume_role_arn
186
+ self.external_id = external_id
187
+ self.custom_token_generator = custom_token_generator
188
+
189
+ def get_boto3_session(self) -> Any:
190
+ import boto3
191
+
192
+ if not self.assume_role_arn:
193
+ return boto3.Session(region_name=self.region)
194
+
195
+ sts = boto3.client("sts", region_name=self.region)
196
+ assume_kwargs: dict[str, Any] = {
197
+ "RoleArn": self.assume_role_arn,
198
+ "RoleSessionName": "adk-memory-platform-db-auth",
199
+ "DurationSeconds": max(900, self.token_ttl_sec),
200
+ }
201
+ if self.external_id:
202
+ assume_kwargs["ExternalId"] = self.external_id
203
+ creds = sts.assume_role(**assume_kwargs)["Credentials"]
204
+ return boto3.Session(
205
+ aws_access_key_id=creds["AccessKeyId"],
206
+ aws_secret_access_key=creds["SecretAccessKey"],
207
+ aws_session_token=creds["SessionToken"],
208
+ region_name=self.region,
209
+ )
210
+
211
+ def _generate_sync(self) -> str:
212
+ session = self.get_boto3_session()
213
+ if self.service == "rds":
214
+ rds = session.client("rds", region_name=self.region)
215
+ return rds.generate_db_auth_token(
216
+ DBHostname=self.host,
217
+ Port=self.port,
218
+ DBUsername=self. _username,
219
+ Region=self.region,
220
+ )
221
+ # ElastiCache / MemoryDB IAM auth uses a SigV4 presigned URL token
222
+ from botocore.signers import RequestSigner
223
+
224
+ credentials = session.get_credentials().get_frozen_credentials()
225
+ signer = RequestSigner(
226
+ service_name=self.service,
227
+ region_name=self.region,
228
+ signing_name=self.service,
229
+ signature_version="v4",
230
+ credentials=credentials,
231
+ event_emitter=session.events,
232
+ )
233
+ url = signer.generate_presigned_url(
234
+ request_dict={
235
+ "url_path": "/",
236
+ "query_string": {"Action": "connect", "User": self._username},
237
+ "headers": {"Host": self.cluster_name},
238
+ "method": "GET",
239
+ "body": b"",
240
+ },
241
+ region_name=self.region,
242
+ expires_in=self.token_ttl_sec,
243
+ operation_name="connect",
244
+ )
245
+ return url.replace("https://", "").replace("http://", "")
246
+
247
+ async def _mint_credentials(self) -> tuple[str, str]:
248
+ if self.custom_token_generator is not None:
249
+ res = self.custom_token_generator()
250
+ token = await res if asyncio.iscoroutine(res) else str(res)
251
+ return self._username, token
252
+ token = await asyncio.to_thread(self._generate_sync)
253
+ return self._username, token
254
+
255
+
256
+ class GcpCloudSqlIamAuthProvider(CachedTokenAuthProviderBase):
257
+ """
258
+ Enterprise Google Cloud IAM Database Authentication Provider for:
259
+ - Cloud SQL for PostgreSQL / AlloyDB (`https://www.googleapis.com/auth/cloud-platform`)
260
+ - Cloud Memorystore for Redis IAM Auth
261
+ Uses Application Default Credentials (ADC) / Workload Identity Federation.
262
+ """
263
+
264
+ def __init__(
265
+ self,
266
+ username: str,
267
+ scopes: list[str] | None = None,
268
+ target_service_account: str | None = None,
269
+ token_ttl_sec: int = 3300,
270
+ refresh_margin_sec: int = 300,
271
+ custom_token_generator: Callable[[], str | Awaitable[str]] | None = None,
272
+ ):
273
+ super().__init__(
274
+ username=username,
275
+ token_ttl_sec=token_ttl_sec,
276
+ refresh_margin_sec=refresh_margin_sec,
277
+ )
278
+ self.scopes = scopes or ["https://www.googleapis.com/auth/cloud-platform"]
279
+ self.target_service_account = target_service_account
280
+ self.custom_token_generator = custom_token_generator
281
+
282
+ def _generate_sync(self) -> str:
283
+ import google.auth # type: ignore
284
+ from google.auth.transport.requests import Request # type: ignore
285
+
286
+ creds, _ = google.auth.default(scopes=self.scopes)
287
+ if self.target_service_account:
288
+ from google.auth import impersonated_credentials # type: ignore
289
+
290
+ creds = impersonated_credentials.Credentials(
291
+ source_credentials=creds,
292
+ target_principal=self.target_service_account,
293
+ target_scopes=self.scopes,
294
+ lifetime=self.token_ttl_sec,
295
+ )
296
+ creds.refresh(Request())
297
+ return str(creds.token)
298
+
299
+ async def _mint_credentials(self) -> tuple[str, str]:
300
+ if self.custom_token_generator is not None:
301
+ res = self.custom_token_generator()
302
+ token = await res if asyncio.iscoroutine(res) else str(res)
303
+ return self._username, token
304
+ token = await asyncio.to_thread(self._generate_sync)
305
+ return self._username, token
306
+
307
+
308
+ class AzureEntraDatabaseAuthProvider(CachedTokenAuthProviderBase):
309
+ """
310
+ Enterprise Microsoft Entra ID (Azure AD / Managed Identity) Authentication Provider for:
311
+ - Azure Database for PostgreSQL Flexible Server (`scope="https://ossrdbms-aad.database.windows.net/.default"`)
312
+ - Azure Managed Redis / Azure Cache for Redis (`scope="https://redis.azure.com/.default"`)
313
+ """
314
+
315
+ POSTGRES_SCOPE = "https://ossrdbms-aad.database.windows.net/.default"
316
+ REDIS_SCOPE = "https://redis.azure.com/.default"
317
+
318
+ def __init__(
319
+ self,
320
+ username: str,
321
+ scope: str = POSTGRES_SCOPE,
322
+ managed_identity_client_id: str | None = None,
323
+ token_ttl_sec: int = 3300,
324
+ refresh_margin_sec: int = 300,
325
+ custom_token_generator: Callable[[], str | Awaitable[str]] | None = None,
326
+ ):
327
+ super().__init__(
328
+ username=username,
329
+ token_ttl_sec=token_ttl_sec,
330
+ refresh_margin_sec=refresh_margin_sec,
331
+ )
332
+ self.scope = scope
333
+ self.managed_identity_client_id = managed_identity_client_id
334
+ self.custom_token_generator = custom_token_generator
335
+
336
+ def _generate_sync(self) -> str:
337
+ from azure.identity import DefaultAzureCredential # type: ignore
338
+
339
+ cred = DefaultAzureCredential(
340
+ managed_identity_client_id=self.managed_identity_client_id
341
+ )
342
+ token_obj = cred.get_token(self.scope)
343
+ return str(token_obj.token)
344
+
345
+ async def _mint_credentials(self) -> tuple[str, str]:
346
+ if self.custom_token_generator is not None:
347
+ res = self.custom_token_generator()
348
+ token = await res if asyncio.iscoroutine(res) else str(res)
349
+ return self._username, token
350
+ token = await asyncio.to_thread(self._generate_sync)
351
+ return self._username, token
352
+
353
+
354
+ class VaultOrSecretManagerAuthProvider(CachedTokenAuthProviderBase):
355
+ """
356
+ Dynamic Secret Rotation Provider for HashiCorp Vault Database Secrets Engine,
357
+ AWS Secrets Manager, or GCP Secret Manager.
358
+ Accepts an async or sync callable `secret_fetcher` returning `(username, password)`.
359
+ """
360
+
361
+ def __init__(
362
+ self,
363
+ secret_fetcher: Callable[[], tuple[str, str] | Awaitable[tuple[str, str]]],
364
+ token_ttl_sec: int = 1800,
365
+ refresh_margin_sec: int = 180,
366
+ ):
367
+ super().__init__(
368
+ username="dynamic",
369
+ token_ttl_sec=token_ttl_sec,
370
+ refresh_margin_sec=refresh_margin_sec,
371
+ )
372
+ self.secret_fetcher = secret_fetcher
373
+
374
+ async def _mint_credentials(self) -> tuple[str, str]:
375
+ res = self.secret_fetcher()
376
+ if asyncio.iscoroutine(res):
377
+ user, pwd = await res
378
+ else:
379
+ user, pwd = res # type: ignore
380
+ return str(user), str(pwd)