feat: release v1.0.0-beta.1

This commit is contained in:
chrishuan
2026-05-29 17:33:12 +08:00
commit 36b0537676
290 changed files with 72303 additions and 0 deletions
@@ -0,0 +1,6 @@
"""TencentDB Agent Memory v2 Python SDK."""
from .client import AsyncMemoryClient, MemoryClient
from .errors import ParamError, TDAMError
__all__ = ["MemoryClient", "AsyncMemoryClient", "TDAMError", "ParamError"]
+151
View File
@@ -0,0 +1,151 @@
"""Low-level HTTP transport for the TencentDB Agent Memory v2 API.
Provides Bearer-token authentication, response-envelope unwrapping
(``code == 0`` → ``data``; otherwise raise ``TDAMError``), and trace-id
propagation via the ``x-trace-id`` response header.
"""
from __future__ import annotations
import logging
from abc import ABC, abstractmethod
from typing import Any, Dict, Optional
import httpx
from .errors import TDAMError
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Stub abstraction
# ---------------------------------------------------------------------------
class Stub(ABC):
"""Base transport interface."""
@abstractmethod
def post(self, path: str, body: dict, timeout: Optional[float] = None) -> dict:
...
@abstractmethod
def close(self) -> None:
...
class HttpStub(Stub):
"""Synchronous HTTP transport backed by :mod:`httpx`.
Parameters
----------
endpoint : str
Base URL of the memory service, e.g.
``https://memory.tencentyun.com``.
api_key : str
Bearer token sent via ``Authorization`` header.
service_id : str
Memory instance ID (sent via ``x-tdai-service-id`` header).
timeout : float
Default request timeout in seconds.
"""
def __init__(
self,
endpoint: str,
api_key: str,
service_id: str,
timeout: float = 30,
verify: bool = False,
client: Optional[httpx.Client] = None,
) -> None:
self.endpoint = endpoint.rstrip("/")
self.client = client or httpx.Client(timeout=timeout, verify=verify)
self.headers: Dict[str, str] = {
"Authorization": f"Bearer {api_key}",
"x-tdai-service-id": service_id,
"Content-Type": "application/json",
}
def post(self, path: str, body: dict, timeout: Optional[float] = None) -> dict:
url = f"{self.endpoint}{path}"
logger.debug("Request %s %s", path, body)
resp = self.client.post(
url=url,
json=body,
headers=self.headers,
timeout=timeout or self.client.timeout,
)
logger.debug("Response %s %s", path, resp.text)
resp.raise_for_status()
data = resp.json()
if data.get("code") != 0:
req_id = resp.headers.get("x-qcloud-transaction-id", data.get("request_id", ""))
raise TDAMError(
code=data.get("code", -1),
message=data.get("message", "unknown error"),
request_id=req_id,
)
result: dict = data.get("data", {})
trace_id = resp.headers.get("x-trace-id")
if trace_id:
result["trace_id"] = trace_id
return result
def close(self) -> None:
if isinstance(self.client, httpx.Client):
self.client.close()
# ---------------------------------------------------------------------------
# Async variant
# ---------------------------------------------------------------------------
class AsyncHttpStub:
"""Asynchronous HTTP transport backed by :mod:`httpx`."""
def __init__(
self,
endpoint: str,
api_key: str,
service_id: str,
timeout: float = 30,
verify: bool = False,
client: Optional[httpx.AsyncClient] = None,
) -> None:
self.endpoint = endpoint.rstrip("/")
self.client = client or httpx.AsyncClient(timeout=timeout, verify=verify)
self.headers: Dict[str, str] = {
"Authorization": f"Bearer {api_key}",
"x-tdai-service-id": service_id,
"Content-Type": "application/json",
}
async def post(self, path: str, body: dict, timeout: Optional[float] = None) -> dict:
url = f"{self.endpoint}{path}"
logger.debug("Request %s %s", path, body)
resp = await self.client.post(
url=url,
json=body,
headers=self.headers,
timeout=timeout or self.client.timeout,
)
logger.debug("Response %s %s", path, resp.text)
resp.raise_for_status()
data = resp.json()
if data.get("code") != 0:
req_id = resp.headers.get("x-qcloud-transaction-id", data.get("request_id", ""))
raise TDAMError(
code=data.get("code", -1),
message=data.get("message", "unknown error"),
request_id=req_id,
)
result: dict = data.get("data", {})
trace_id = resp.headers.get("x-trace-id")
if trace_id:
result["trace_id"] = trace_id
return result
async def close(self) -> None:
if isinstance(self.client, httpx.AsyncClient):
await self.client.aclose()
+461
View File
@@ -0,0 +1,461 @@
"""TencentDB Agent Memory v2 Python SDK — synchronous + asynchronous clients.
Exposes the v2 data-plane API (14 routes) over a Bearer-token authenticated
HTTP transport.
"""
from __future__ import annotations
import logging
from typing import Any, Dict, List, Optional
from ._http import AsyncHttpStub, HttpStub, Stub
from .cos import AsyncMemoryFileReader, AsyncStsCredentialManager, MemoryFileReader, StsCredentialManager
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
_V2 = "/v2"
def _strip_none(d: Dict[str, Any]) -> Dict[str, Any]:
"""Return a copy of *d* with ``None`` values removed."""
return {k: v for k, v in d.items() if v is not None}
# ---------------------------------------------------------------------------
# Synchronous client
# ---------------------------------------------------------------------------
class MemoryClient:
"""Synchronous client for the TencentDB Agent Memory v2 data-plane API.
Example::
from tencentdb_agent_memory import MemoryClient
client = MemoryClient(
endpoint="https://memory.tencentyun.com",
api_key="sk-xxxxxxxx",
service_id="mem-xxxxxxxx",
)
result = client.add_conversation("sess-1", [
{"role": "user", "content": "hello"},
])
print(result) # {"accepted_ids": [...], "total_count": 1}
Parameters
----------
endpoint : str
Base URL of the memory service.
api_key : str
Bearer token.
service_id : str
Memory instance ID (sent via ``x-tdai-service-id`` header).
timeout : float
Request timeout in seconds.
stub : Stub | None
Inject a custom transport (useful for testing).
"""
def __init__(
self,
endpoint: str = "",
api_key: str = "",
service_id: Optional[str] = None,
*,
timeout: float = 30,
verify: bool = False,
stub: Optional[Stub] = None,
) -> None:
if stub is not None:
self._stub = stub
else:
if not service_id:
raise ValueError("service_id must be provided")
self._stub = HttpStub(endpoint, api_key, service_id, timeout=timeout, verify=verify)
# Memory file reader (lazy init on first read_file call)
self._cos_reader: Optional[MemoryFileReader] = None
self._sts_manager: Optional[StsCredentialManager] = None
# -- L0 Conversation ---------------------------------------------------
def add_conversation(
self,
session_id: str,
messages: List[Dict[str, Any]],
) -> Dict[str, Any]:
"""``POST /conversation/add``"""
return self._stub.post(
f"{_V2}/conversation/add",
{"session_id": session_id, "messages": messages},
)
def query_conversation(
self,
*,
session_id: Optional[str] = None,
limit: Optional[int] = None,
offset: Optional[int] = None,
time_start: Optional[str] = None,
time_end: Optional[str] = None,
) -> Dict[str, Any]:
"""``POST /conversation/query``"""
return self._stub.post(
f"{_V2}/conversation/query",
_strip_none({
"session_id": session_id,
"limit": limit,
"offset": offset,
"time_start": time_start,
"time_end": time_end,
}),
)
def search_conversation(
self,
query: str,
*,
limit: Optional[int] = None,
session_id: Optional[str] = None,
time_start: Optional[str] = None,
time_end: Optional[str] = None,
) -> Dict[str, Any]:
"""``POST /conversation/search``"""
return self._stub.post(
f"{_V2}/conversation/search",
_strip_none({
"query": query,
"limit": limit,
"session_id": session_id,
"time_start": time_start,
"time_end": time_end,
}),
)
def delete_conversation(
self,
*,
message_ids: Optional[List[str]] = None,
session_id: Optional[str] = None,
) -> Dict[str, Any]:
"""``POST /conversation/delete`` — *message_ids* 和 *session_id* 二选一。"""
return self._stub.post(
f"{_V2}/conversation/delete",
_strip_none({
"message_ids": message_ids,
"session_id": session_id,
}),
)
# -- L1 Atomic ---------------------------------------------------------
def update_atomic(self, id: str, content: str, *, background: Optional[str] = None) -> Dict[str, Any]:
"""``POST /atomic/update``"""
return self._stub.post(
f"{_V2}/atomic/update",
_strip_none({"id": id, "content": content, "background": background}),
)
def query_atomic(
self,
*,
type: Optional[str] = None,
limit: Optional[int] = None,
offset: Optional[int] = None,
time_start: Optional[str] = None,
time_end: Optional[str] = None,
) -> Dict[str, Any]:
"""``POST /atomic/query``"""
return self._stub.post(
f"{_V2}/atomic/query",
_strip_none({
"type": type,
"limit": limit,
"offset": offset,
"time_start": time_start,
"time_end": time_end,
}),
)
def search_atomic(
self,
query: str,
*,
limit: Optional[int] = None,
type: Optional[str] = None,
time_start: Optional[str] = None,
time_end: Optional[str] = None,
) -> Dict[str, Any]:
"""``POST /atomic/search``"""
return self._stub.post(
f"{_V2}/atomic/search",
_strip_none({
"query": query,
"limit": limit,
"type": type,
"time_start": time_start,
"time_end": time_end,
}),
)
def delete_atomic(self, ids: List[str]) -> Dict[str, Any]:
"""``POST /atomic/delete``"""
return self._stub.post(f"{_V2}/atomic/delete", {"ids": ids})
# -- L2 Scenario -------------------------------------------------------
def list_scenarios(
self,
*,
path_prefix: Optional[str] = None,
) -> Dict[str, Any]:
"""``POST /scenario/ls``"""
return self._stub.post(
f"{_V2}/scenario/ls",
_strip_none({
"path_prefix": path_prefix,
}),
)
def read_scenario(self, path: str) -> Dict[str, Any]:
"""``POST /scenario/read``
Returns dict with ``content``, ``created_at``, ``updated_at``.
If the file does not exist, ``content`` will be ``None``.
"""
return self._stub.post(f"{_V2}/scenario/read", {"path": path})
def write_scenario(self, path: str, content: str, *, summary: Optional[str] = None) -> Dict[str, Any]:
"""``POST /scenario/write``"""
return self._stub.post(
f"{_V2}/scenario/write",
_strip_none({"path": path, "content": content, "summary": summary}),
)
def rm_scenario(self, path: str) -> Dict[str, Any]:
"""``POST /scenario/rm``"""
return self._stub.post(f"{_V2}/scenario/rm", {"path": path})
# -- L3 Core -----------------------------------------------------------
def read_core(self) -> Dict[str, Any]:
"""``POST /core/read``
Returns dict with ``content``, ``created_at``, ``updated_at``.
If core memory has not been generated yet, ``content`` will be ``None``.
"""
return self._stub.post(f"{_V2}/core/read", {})
def write_core(self, content: str) -> Dict[str, Any]:
"""``POST /core/write``"""
return self._stub.post(f"{_V2}/core/write", {"content": content})
# -- File read (memory pipeline artifacts) -----------------------------
def read_file(self, path: str) -> str:
"""Read a memory pipeline artifact (e.g. ``persona.md``,
``scene_blocks/*.md``) by relative path.
Parameters
----------
path : str
Relative path within the memory space, e.g.
``"scene_blocks/cooking-recipes.md"`` or ``"persona.md"``.
Returns
-------
str
File content.
Raises
------
TDAMError
On 404 (not found), 403 (auth failure after retry), or other errors.
"""
if self._cos_reader is None:
self._sts_manager = StsCredentialManager(
endpoint=self._stub.endpoint,
api_key=self._stub.headers["Authorization"].removeprefix("Bearer "),
service_id=self._stub.headers["x-tdai-service-id"],
)
self._cos_reader = MemoryFileReader(self._sts_manager)
return self._cos_reader.read(path)
# -- lifecycle ---------------------------------------------------------
def close(self) -> None:
if self._cos_reader is not None:
self._cos_reader.close()
self._stub.close()
def __enter__(self) -> "MemoryClient":
return self
def __exit__(self, *exc: Any) -> None:
self.close()
# ---------------------------------------------------------------------------
# Asynchronous client
# ---------------------------------------------------------------------------
class AsyncMemoryClient:
"""Asynchronous client for the TencentDB Agent Memory v2 data-plane API.
Same API surface as :class:`MemoryClient` but all methods are coroutines.
"""
def __init__(
self,
endpoint: str = "",
api_key: str = "",
service_id: Optional[str] = None,
*,
timeout: float = 30,
verify: bool = False,
) -> None:
if not service_id:
raise ValueError("service_id must be provided")
self._stub = AsyncHttpStub(endpoint, api_key, service_id, timeout=timeout, verify=verify)
# Memory file reader (lazy init)
self._cos_reader: Optional[AsyncMemoryFileReader] = None
self._sts_manager: Optional[AsyncStsCredentialManager] = None
# -- L0 Conversation ---------------------------------------------------
async def add_conversation(
self, session_id: str, messages: List[Dict[str, Any]],
) -> Dict[str, Any]:
return await self._stub.post(
f"{_V2}/conversation/add",
{"session_id": session_id, "messages": messages},
)
async def query_conversation(
self, *, session_id: Optional[str] = None, limit: Optional[int] = None,
offset: Optional[int] = None, time_start: Optional[str] = None,
time_end: Optional[str] = None,
) -> Dict[str, Any]:
return await self._stub.post(
f"{_V2}/conversation/query",
_strip_none({"session_id": session_id, "limit": limit, "offset": offset,
"time_start": time_start, "time_end": time_end}),
)
async def search_conversation(
self, query: str, *, limit: Optional[int] = None,
session_id: Optional[str] = None, time_start: Optional[str] = None,
time_end: Optional[str] = None,
) -> Dict[str, Any]:
return await self._stub.post(
f"{_V2}/conversation/search",
_strip_none({"query": query, "limit": limit, "session_id": session_id,
"time_start": time_start, "time_end": time_end}),
)
async def delete_conversation(
self, *, message_ids: Optional[List[str]] = None,
session_id: Optional[str] = None,
) -> Dict[str, Any]:
return await self._stub.post(
f"{_V2}/conversation/delete",
_strip_none({"message_ids": message_ids, "session_id": session_id}),
)
# -- L1 Atomic ---------------------------------------------------------
async def update_atomic(self, id: str, content: str, *, background: Optional[str] = None) -> Dict[str, Any]:
return await self._stub.post(
f"{_V2}/atomic/update",
_strip_none({"id": id, "content": content, "background": background}),
)
async def query_atomic(
self, *, type: Optional[str] = None, limit: Optional[int] = None,
offset: Optional[int] = None, time_start: Optional[str] = None,
time_end: Optional[str] = None,
) -> Dict[str, Any]:
return await self._stub.post(
f"{_V2}/atomic/query",
_strip_none({"type": type, "limit": limit, "offset": offset,
"time_start": time_start, "time_end": time_end}),
)
async def search_atomic(
self, query: str, *, limit: Optional[int] = None,
type: Optional[str] = None, time_start: Optional[str] = None,
time_end: Optional[str] = None,
) -> Dict[str, Any]:
return await self._stub.post(
f"{_V2}/atomic/search",
_strip_none({"query": query, "limit": limit, "type": type,
"time_start": time_start, "time_end": time_end}),
)
async def delete_atomic(self, ids: List[str]) -> Dict[str, Any]:
return await self._stub.post(f"{_V2}/atomic/delete", {"ids": ids})
# -- L2 Scenario -------------------------------------------------------
async def list_scenarios(
self, *, path_prefix: Optional[str] = None,
) -> Dict[str, Any]:
return await self._stub.post(
f"{_V2}/scenario/ls",
_strip_none({"path_prefix": path_prefix}),
)
async def read_scenario(self, path: str) -> Dict[str, Any]:
"""``POST /scenario/read`` — returns ``content: None`` if file does not exist."""
return await self._stub.post(f"{_V2}/scenario/read", {"path": path})
async def write_scenario(self, path: str, content: str, *, summary: Optional[str] = None) -> Dict[str, Any]:
return await self._stub.post(
f"{_V2}/scenario/write", _strip_none({"path": path, "content": content, "summary": summary}),
)
async def rm_scenario(self, path: str) -> Dict[str, Any]:
return await self._stub.post(f"{_V2}/scenario/rm", {"path": path})
# -- L3 Core -----------------------------------------------------------
async def read_core(self) -> Dict[str, Any]:
"""``POST /core/read`` — returns ``content: None`` if not yet generated."""
return await self._stub.post(f"{_V2}/core/read", {})
async def write_core(self, content: str) -> Dict[str, Any]:
return await self._stub.post(f"{_V2}/core/write", {"content": content})
# -- lifecycle ---------------------------------------------------------
# -- File read (memory pipeline artifacts) -----------------------------
async def read_file(self, path: str) -> str:
"""Read a memory pipeline artifact (async)."""
if self._cos_reader is None:
self._sts_manager = AsyncStsCredentialManager(
endpoint=self._stub.endpoint,
api_key=self._stub.headers["Authorization"].removeprefix("Bearer "),
service_id=self._stub.headers["x-tdai-service-id"],
)
self._cos_reader = AsyncMemoryFileReader(self._sts_manager)
return await self._cos_reader.read(path)
# -- lifecycle ---------------------------------------------------------
async def close(self) -> None:
if self._cos_reader is not None:
await self._cos_reader.close()
await self._stub.close()
async def __aenter__(self) -> "AsyncMemoryClient":
return self
async def __aexit__(self, *exc: Any) -> None:
await self.close()
+461
View File
@@ -0,0 +1,461 @@
"""Memory file reader — direct read of memory pipeline artifacts (persona.md,
scene_blocks/*.md) from object storage with STS credential management.
The SDK user calls ``client.read_file(path)`` and gets the file content back.
Under the hood, we:
1. Fetch STS temporary credentials from the platform (``POST /v2/cos/secret``)
2. Cache credentials until they expire (auto-refresh)
3. Sign a COS V5 GET request with the STS credentials
4. Return the file content as a string
Storage backend (currently COS) is an implementation detail — the public API
is intentionally storage-agnostic.
"""
from __future__ import annotations
import hashlib
import hmac
import logging
import re
import threading
import time
from typing import Any, Dict, Optional
from urllib.parse import urlparse
import httpx
from .errors import TDAMError
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# COS URL parser
# ---------------------------------------------------------------------------
def _parse_cos_url(cos_url: str) -> tuple[str, str]:
"""Parse CosUrl like ``https://bucket.cos.region.myqcloud.com`` → (bucket, region)."""
host = urlparse(cos_url).hostname or ""
# Pattern: {bucket}.cos.{region}.myqcloud.com
m = re.match(r"^(.+?)\.cos\.(.+?)\.myqcloud\.com$", host)
if m:
return m.group(1), m.group(2)
raise TDAMError(
code=-1,
message=f"Cannot parse CosUrl: {cos_url!r} (expected {{bucket}}.cos.{{region}}.myqcloud.com)",
)
# ---------------------------------------------------------------------------
# STS Credential
# ---------------------------------------------------------------------------
class StsCredential:
"""Parsed STS credential from ``POST /v2/cos/secret``.
Platform response format::
{
"CosUrl": "https://{bucket}.cos.{region}.myqcloud.com",
"TmpSecretId": "...",
"TmpSecretKey": "...",
"TmpToken": "...",
"ExpirationTime": "2026-05-15T16:44:49+08:00",
"PathPrefix": "memory_v2/cos_data/mem-xxx"
}
"""
__slots__ = (
"tmp_secret_id", "tmp_secret_key", "token",
"bucket", "region", "prefix", "expires_at_epoch",
)
def __init__(self, data: Dict[str, Any]) -> None:
self.tmp_secret_id: str = data["TmpSecretId"]
self.tmp_secret_key: str = data["TmpSecretKey"]
self.token: str = data.get("TmpToken", "")
# Parse bucket + region from CosUrl
self.bucket: str
self.region: str
self.bucket, self.region = _parse_cos_url(data["CosUrl"])
# PathPrefix — ensure trailing slash for key concatenation
prefix = data.get("PathPrefix", "")
self.prefix: str = prefix if prefix.endswith("/") else f"{prefix}/"
# Parse ISO 8601 → epoch seconds
expires_str = data.get("ExpirationTime", "")
if expires_str:
from datetime import datetime, timezone
try:
dt = datetime.fromisoformat(expires_str.replace("Z", "+00:00"))
self.expires_at_epoch = dt.timestamp()
except Exception:
self.expires_at_epoch = time.time() + 1800 # 30 min fallback
else:
self.expires_at_epoch = time.time() + 1800
def is_valid(self, buffer_seconds: float = 120) -> bool:
"""Check if credential is still valid (with 2-minute buffer)."""
return time.time() < (self.expires_at_epoch - buffer_seconds)
@property
def cos_host(self) -> str:
return f"{self.bucket}.cos.{self.region}.myqcloud.com"
# ---------------------------------------------------------------------------
# STS Credential Manager
# ---------------------------------------------------------------------------
class StsCredentialManager:
"""Thread-safe STS credential cache with auto-refresh.
- Fetches STS from platform ``POST /v2/cos/secret``
- Caches until expiry (with 2-minute buffer)
- Coalesces concurrent refresh requests
"""
def __init__(
self,
endpoint: str,
api_key: str,
service_id: str,
buffer_seconds: float = 120,
timeout: float = 30,
) -> None:
self._endpoint = endpoint.rstrip("/")
self._api_key = api_key
self._service_id = service_id
self._buffer = buffer_seconds
self._timeout = timeout
self._credential: Optional[StsCredential] = None
self._lock = threading.Lock()
self._client: Optional[httpx.Client] = None
def get_credential(self) -> StsCredential:
"""Get a valid STS credential (cached or freshly fetched)."""
if self._credential and self._credential.is_valid(self._buffer):
return self._credential
with self._lock:
# Double-check after acquiring lock
if self._credential and self._credential.is_valid(self._buffer):
return self._credential
return self._refresh()
def invalidate(self) -> None:
"""Force invalidate cached credential (e.g. on 403)."""
with self._lock:
self._credential = None
def _refresh(self) -> StsCredential:
logger.debug("[cos] Refreshing STS credential via POST /v2/cos/secret ...")
if self._client is None:
self._client = httpx.Client(timeout=self._timeout)
url = f"{self._endpoint}/v2/cos/secret"
headers = {
"Authorization": f"Bearer {self._api_key}",
"x-tdai-service-id": self._service_id,
"Content-Type": "application/json",
}
resp = self._client.post(url, json={}, headers=headers)
resp.raise_for_status()
data = resp.json()
cred = StsCredential(data)
self._credential = cred
logger.debug("[cos] STS refreshed: bucket=%s prefix=%s expires=%.0f",
cred.bucket, cred.prefix, cred.expires_at_epoch)
return cred
def close(self) -> None:
if self._client is not None:
self._client.close()
# ---------------------------------------------------------------------------
# Async STS Credential Manager
# ---------------------------------------------------------------------------
class AsyncStsCredentialManager:
"""Async variant of StsCredentialManager."""
def __init__(
self,
endpoint: str,
api_key: str,
service_id: str,
buffer_seconds: float = 120,
timeout: float = 30,
) -> None:
self._endpoint = endpoint.rstrip("/")
self._api_key = api_key
self._service_id = service_id
self._buffer = buffer_seconds
self._timeout = timeout
self._credential: Optional[StsCredential] = None
import asyncio
self._lock = asyncio.Lock()
self._client: Optional[httpx.AsyncClient] = None
async def get_credential(self) -> StsCredential:
if self._credential and self._credential.is_valid(self._buffer):
return self._credential
async with self._lock:
if self._credential and self._credential.is_valid(self._buffer):
return self._credential
return await self._refresh()
def invalidate(self) -> None:
self._credential = None
async def _refresh(self) -> StsCredential:
logger.debug("[cos] Refreshing STS credential (async) via POST /v2/cos/secret ...")
if self._client is None:
self._client = httpx.AsyncClient(timeout=self._timeout)
url = f"{self._endpoint}/v2/cos/secret"
headers = {
"Authorization": f"Bearer {self._api_key}",
"x-tdai-service-id": self._service_id,
"Content-Type": "application/json",
}
resp = await self._client.post(url, json={}, headers=headers)
resp.raise_for_status()
data = resp.json()
cred = StsCredential(data)
self._credential = cred
return cred
async def close(self) -> None:
if self._client is not None:
await self._client.aclose()
# ---------------------------------------------------------------------------
# COS V5 Signature
# ---------------------------------------------------------------------------
def _cos_v5_sign(
secret_id: str,
secret_key: str,
method: str,
path: str,
host: str,
start_time: Optional[int] = None,
end_time: Optional[int] = None,
) -> str:
"""Generate COS V5 Authorization header value for a GET request.
References:
https://cloud.tencent.com/document/product/436/7778
"""
now = int(time.time())
q_sign_time = f"{start_time or (now - 60)};{end_time or (now + 600)}"
q_key_time = q_sign_time
# Step 1: SignKey
sign_key = hmac.new(
secret_key.encode("utf-8"),
q_key_time.encode("utf-8"),
hashlib.sha1,
).hexdigest()
# Step 2: HttpString
# For GET with no query params and only host header
http_string = f"{method.lower()}\n{path}\n\nhost={host}\n"
# Step 3: StringToSign
sha1_http_string = hashlib.sha1(http_string.encode("utf-8")).hexdigest()
string_to_sign = f"sha1\n{q_sign_time}\n{sha1_http_string}\n"
# Step 4: Signature
signature = hmac.new(
sign_key.encode("utf-8"),
string_to_sign.encode("utf-8"),
hashlib.sha1,
).hexdigest()
return (
f"q-sign-algorithm=sha1"
f"&q-ak={secret_id}"
f"&q-sign-time={q_sign_time}"
f"&q-key-time={q_key_time}"
f"&q-header-list=host"
f"&q-url-param-list="
f"&q-signature={signature}"
)
# ---------------------------------------------------------------------------
# Memory File Reader (sync)
# ---------------------------------------------------------------------------
class MemoryFileReader:
"""Sync memory file reader with STS auto-management.
Reads memory pipeline artifacts (persona.md, scene_blocks/*.md, …) from
object storage. The storage backend is COS today but the public API is
storage-agnostic.
Usage::
reader = MemoryFileReader(sts_manager)
content = reader.read("scene_blocks/cooking-recipes.md")
"""
def __init__(
self,
sts_manager: StsCredentialManager,
timeout: float = 30,
client: Optional[httpx.Client] = None,
) -> None:
self._sts = sts_manager
self._client = client or httpx.Client(timeout=timeout)
def read(self, path: str) -> str:
"""Read a memory file by relative path.
The final COS key is: ``{prefix}{path}``
Returns file content as UTF-8 string.
Raises ``TDAMError`` on failure.
"""
cred = self._sts.get_credential()
full_key = f"{cred.prefix}{path}"
cos_path = f"/{full_key}"
host = cred.cos_host
auth = _cos_v5_sign(
secret_id=cred.tmp_secret_id,
secret_key=cred.tmp_secret_key,
method="GET",
path=cos_path,
host=host,
)
headers: Dict[str, str] = {
"Host": host,
"Authorization": auth,
}
if cred.token:
headers["x-cos-security-token"] = cred.token
url = f"https://{host}{cos_path}"
logger.debug("[cos] GET %s", url)
resp = self._client.get(url, headers=headers)
if resp.status_code == 403:
# Invalidate and retry once
logger.warning("[cos] 403 on GET %s — invalidating STS and retrying", path)
self._sts.invalidate()
cred = self._sts.get_credential()
full_key = f"{cred.prefix}{path}"
cos_path = f"/{full_key}"
host = cred.cos_host
auth = _cos_v5_sign(
secret_id=cred.tmp_secret_id,
secret_key=cred.tmp_secret_key,
method="GET",
path=cos_path,
host=host,
)
headers = {"Host": host, "Authorization": auth}
if cred.token:
headers["x-cos-security-token"] = cred.token
url = f"https://{host}{cos_path}"
resp = self._client.get(url, headers=headers)
if resp.status_code == 404:
raise TDAMError(code=404, message=f"File not found: {path}")
if resp.status_code != 200:
raise TDAMError(
code=resp.status_code,
message=f"COS GET failed: HTTP {resp.status_code}{resp.text[:200]}",
)
return resp.text
def close(self) -> None:
if isinstance(self._client, httpx.Client):
self._client.close()
# ---------------------------------------------------------------------------
# Async Memory File Reader
# ---------------------------------------------------------------------------
class AsyncMemoryFileReader:
"""Async memory file reader with STS auto-management."""
def __init__(
self,
sts_manager: AsyncStsCredentialManager,
timeout: float = 30,
client: Optional[httpx.AsyncClient] = None,
) -> None:
self._sts = sts_manager
self._client = client or httpx.AsyncClient(timeout=timeout)
async def read(self, path: str) -> str:
cred = await self._sts.get_credential()
full_key = f"{cred.prefix}{path}"
cos_path = f"/{full_key}"
host = cred.cos_host
auth = _cos_v5_sign(
secret_id=cred.tmp_secret_id,
secret_key=cred.tmp_secret_key,
method="GET",
path=cos_path,
host=host,
)
headers: Dict[str, str] = {
"Host": host,
"Authorization": auth,
}
if cred.token:
headers["x-cos-security-token"] = cred.token
url = f"https://{host}{cos_path}"
resp = await self._client.get(url, headers=headers)
if resp.status_code == 403:
self._sts.invalidate()
cred = await self._sts.get_credential()
full_key = f"{cred.prefix}{path}"
cos_path = f"/{full_key}"
host = cred.cos_host
auth = _cos_v5_sign(
secret_id=cred.tmp_secret_id,
secret_key=cred.tmp_secret_key,
method="GET",
path=cos_path,
host=host,
)
headers = {"Host": host, "Authorization": auth}
if cred.token:
headers["x-cos-security-token"] = cred.token
url = f"https://{host}{cos_path}"
resp = await self._client.get(url, headers=headers)
if resp.status_code == 404:
raise TDAMError(code=404, message=f"File not found: {path}")
if resp.status_code != 200:
raise TDAMError(
code=resp.status_code,
message=f"COS GET failed: HTTP {resp.status_code}{resp.text[:200]}",
)
return resp.text
async def close(self) -> None:
if isinstance(self._client, httpx.AsyncClient):
await self._client.aclose()
@@ -0,0 +1,25 @@
"""TencentDB Agent Memory SDK error types."""
from __future__ import annotations
class TDAMError(Exception):
"""Raised when the API returns a non-zero business code."""
def __init__(self, code: int, message: str, request_id: str = "") -> None:
super().__init__()
self.code = code
self.message = message
self.request_id = request_id
def __str__(self) -> str:
if self.request_id:
return (
f"<TDAMError: (code={self.code}, "
f"message={self.message}, request_id={self.request_id})>"
)
return f"<TDAMError: (code={self.code}, message={self.message})>"
class ParamError(Exception):
"""Raised when caller-supplied parameters are invalid."""