diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d3626383e..772b965e4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,7 +26,7 @@ jobs: run: | python -m pip install --upgrade pip pip install -r requirements-test.txt - pip install build twine flake8 yapf + python -m pip install --upgrade build "twine>=7.0.0" flake8 yapf - name: Validate tag version run: | diff --git a/docs/mkdocs/en/memory.md b/docs/mkdocs/en/memory.md index aab852406..96dce630e 100644 --- a/docs/mkdocs/en/memory.md +++ b/docs/mkdocs/en/memory.md @@ -29,6 +29,7 @@ Based on the implementation in [trpc_agent_sdk/memory/](../../../trpc_agent_sdk/ - **RedisMemoryService**: Stored in a Redis List (JSON format) - **SqlMemoryService**: Stored in the `mem_events` table in MySQL/PostgreSQL - **MempalaceMemoryService**: Stored as MemPalace drawers in a local ChromaDB-backed palace +- **TencentDBMemoryService**: Sent to TencentDB Agent Memory as L0 conversations and asynchronously extracted memories **Code Example**: ```python @@ -55,7 +56,7 @@ async def store_session(self, session: Session, agent_context: Optional[AgentCon **Function**: Searches for related historical memories based on query keywords. -**Search Method**: Built-in InMemory/Redis/SQL services use **keyword matching**; semantic memory services such as MemPalace and Mem0 use vector / semantic retrieval. +**Search Method**: Built-in InMemory/Redis/SQL services use **keyword matching**; semantic memory services such as TencentDB Agent Memory, MemPalace, and Mem0 use extracted or vector / semantic retrieval. **Implementation Logic** (using `InMemoryMemoryService` as an example): ```python @@ -587,6 +588,60 @@ python3 run_agent.py --- +## Integrating TencentDB Agent Memory + +`TencentDBMemoryService` connects the framework to a +[TencentDB Agent Memory](https://github.com/TencentCloud/TencentDB-Agent-Memory) +V3 gateway. After each completed turn, the framework incrementally sends new +text events to `/v3/conversation/add`. An Agent equipped with +`load_memory_tool` searches extracted L1 atomic memories through +`/v3/atomic/search`. + +```python +from trpc_agent_sdk.memory.tencentdb_memory_service import ( + TencentDBMemoryService, + TencentDBMemoryServiceConfig, +) +from trpc_agent_sdk.tools import load_memory_tool + +memory_service = TencentDBMemoryService( + TencentDBMemoryServiceConfig( + enabled=True, + endpoint="http://127.0.0.1:8420", + api_key="local", + service_id="memory-service-id", + team_id="team-id", + agent_id="assistant-id", + ) +) + +# Add load_memory_tool to LlmAgent.tools and pass memory_service to Runner. +``` + +Isolation is mapped as follows: + +- `service_id` is sent in the `x-tdai-service-id` header. +- `team_id` and `agent_id` come from the service configuration. +- `user_id` comes from the framework session/search key. +- `session_id` is sent only when writing L0 conversations; searches omit it + to support cross-session recall for the same user. + +Operational notes: + +- L1 extraction is asynchronous, so a memory may not be searchable immediately + after a successful write. +- Successfully accepted event IDs are checkpointed in process. Delivery is + at-least-once across restarts. +- TencentDB Agent Memory controls retention; framework TTL settings do not + apply to this service. +- Deploy the V3 gateway and extraction pipeline before using the integration. + +See the +[complete runnable example](../../../examples/memory_service_with_tencentdb/README.md) +for environment variables and a cross-session demonstration. + +--- + ## Integrating Mem0 ### What is Mem0? diff --git a/docs/mkdocs/zh/memory.md b/docs/mkdocs/zh/memory.md index 7cfd11df0..35715aa60 100644 --- a/docs/mkdocs/zh/memory.md +++ b/docs/mkdocs/zh/memory.md @@ -28,6 +28,7 @@ - **InMemoryMemoryService**:存储在进程内存的字典中 - **RedisMemoryService**:存储在 Redis List 中(JSON 格式) - **SqlMemoryService**:存储在 MySQL/PostgreSQL 的 `mem_events` 表中 +- **TencentDBMemoryService**:写入 TencentDB Agent Memory L0 对话,并由服务端异步提取长期记忆 **代码示例**: ```python @@ -54,7 +55,7 @@ async def store_session(self, session: Session, agent_context: Optional[AgentCon **功能**:根据查询关键词搜索相关的历史记忆。 -**搜索方式**:**关键词匹配**(非语义搜索) +**搜索方式**:InMemory/Redis/SQL 使用**关键词匹配**;TencentDB Agent Memory 等扩展服务可提供记忆提取或语义检索。 **实现逻辑**(以 `InMemoryMemoryService` 为例): ```python @@ -541,6 +542,54 @@ python3 run_agent.py ## 扩展 MemoryService 实现 +### 集成 TencentDB Agent Memory + +`TencentDBMemoryService` 用于对接 +[TencentDB Agent Memory](https://github.com/TencentCloud/TencentDB-Agent-Memory) +V3 网关。每轮对话结束后,框架会将新增文本事件增量写入 +`/v3/conversation/add`;配置了 `load_memory_tool` 的 Agent 会通过 +`/v3/atomic/search` 检索异步提取出的 L1 原子记忆。 + +```python +from trpc_agent_sdk.memory.tencentdb_memory_service import ( + TencentDBMemoryService, + TencentDBMemoryServiceConfig, +) +from trpc_agent_sdk.tools import load_memory_tool + +memory_service = TencentDBMemoryService( + TencentDBMemoryServiceConfig( + enabled=True, + endpoint="http://127.0.0.1:8420", + api_key="local", + service_id="memory-service-id", + team_id="team-id", + agent_id="assistant-id", + ) +) + +# 将 load_memory_tool 加入 LlmAgent.tools,并把 memory_service 传给 Runner。 +``` + +隔离字段映射如下: + +- `service_id` 通过 `x-tdai-service-id` 请求头传递。 +- `team_id` 和 `agent_id` 来自服务配置。 +- `user_id` 来自框架 Session 或搜索 key。 +- `session_id` 仅在写入 L0 对话时传递;搜索时不传,以支持同一用户跨会话召回。 + +使用时需要注意: + +- L1 记忆提取是异步的,写入成功后不保证立即可以搜索到。 +- 进程内会记录已成功写入的事件 ID;进程重启后采用至少一次投递语义。 +- 记忆保留策略由 TencentDB Agent Memory 管理,框架 TTL 配置不适用于该服务。 +- 使用前需要部署 V3 网关和记忆提取流水线。 + +环境变量和跨会话运行流程见 +[完整示例](../../../examples/memory_service_with_tencentdb/README.md)。 + +--- + ### 集成 Mempalace #### 什么是 Mempalace? diff --git a/examples/memory_service_with_tencentdb/.env b/examples/memory_service_with_tencentdb/.env new file mode 100644 index 000000000..c9f666731 --- /dev/null +++ b/examples/memory_service_with_tencentdb/.env @@ -0,0 +1,15 @@ +# LLM configuration +TRPC_AGENT_API_KEY=your-llm-api-key +TRPC_AGENT_BASE_URL=https://your-llm-endpoint +TRPC_AGENT_MODEL_NAME=your-model-name + +# TencentDB Agent Memory V3 gateway +TENCENTDB_MEMORY_ENDPOINT=http://127.0.0.1:8420 +TENCENTDB_MEMORY_API_KEY=local +TENCENTDB_MEMORY_SERVICE_ID=your-memory-service-id +TENCENTDB_MEMORY_TEAM_ID=your-team-id +TENCENTDB_MEMORY_AGENT_ID=weather-assistant + +# L1 extraction is asynchronous. Increase this for slower deployments. +TENCENTDB_MEMORY_PIPELINE_WAIT_SECONDS=5 +MEMORY_PROMPT_MODE=chat diff --git a/examples/memory_service_with_tencentdb/.env.local b/examples/memory_service_with_tencentdb/.env.local new file mode 100644 index 000000000..c9f666731 --- /dev/null +++ b/examples/memory_service_with_tencentdb/.env.local @@ -0,0 +1,15 @@ +# LLM configuration +TRPC_AGENT_API_KEY=your-llm-api-key +TRPC_AGENT_BASE_URL=https://your-llm-endpoint +TRPC_AGENT_MODEL_NAME=your-model-name + +# TencentDB Agent Memory V3 gateway +TENCENTDB_MEMORY_ENDPOINT=http://127.0.0.1:8420 +TENCENTDB_MEMORY_API_KEY=local +TENCENTDB_MEMORY_SERVICE_ID=your-memory-service-id +TENCENTDB_MEMORY_TEAM_ID=your-team-id +TENCENTDB_MEMORY_AGENT_ID=weather-assistant + +# L1 extraction is asynchronous. Increase this for slower deployments. +TENCENTDB_MEMORY_PIPELINE_WAIT_SECONDS=5 +MEMORY_PROMPT_MODE=chat diff --git a/examples/memory_service_with_tencentdb/.env.remote b/examples/memory_service_with_tencentdb/.env.remote new file mode 100644 index 000000000..fbad813f1 --- /dev/null +++ b/examples/memory_service_with_tencentdb/.env.remote @@ -0,0 +1,19 @@ +# 示例 Agent 使用的 LLM,与 Memory 实例凭证无关 +TRPC_AGENT_API_KEY=sk-****************** +TRPC_AGENT_BASE_URL=http://****************** +TRPC_AGENT_MODEL_NAME=****************** + +# 腾讯云控制台提供的完整 Gateway 地址,必须包含 https:// +TENCENTDB_MEMORY_ENDPOINT=https://****************** + +# 腾讯云控制台提供的 Gateway API Key,不是 sk-mem-* User Key +TENCENTDB_MEMORY_API_KEY=****************** + +# 腾讯云 Memory 实例 ID,例如 mem-xxxxxxxx +TENCENTDB_MEMORY_SERVICE_ID=mem-******* + +# 在该实例中创建的真实 Team 和 Agent ID +TENCENTDB_MEMORY_TEAM_ID=team-******* +TENCENTDB_MEMORY_AGENT_ID=agt-******* + +TENCENTDB_MEMORY_PIPELINE_WAIT_SECONDS=15 diff --git a/examples/memory_service_with_tencentdb/README.md b/examples/memory_service_with_tencentdb/README.md new file mode 100644 index 000000000..68a872e7a --- /dev/null +++ b/examples/memory_service_with_tencentdb/README.md @@ -0,0 +1,361 @@ +# TencentDB Agent Memory Service 使用示例 + +本示例演示如何通过 `TencentDBMemoryService` 将 tRPC-Agent 接入 +[TencentDB Agent Memory](https://github.com/TencentCloud/TencentDB-Agent-Memory), +在一个 Session 中写入用户偏好,并在另一个 Session 中召回服务端提取的 L1 长期记忆。 + +## 工作流程 + +```text +第一轮对话结束 + -> Runner 自动调用 TencentDBMemoryService.store_session() + -> POST /v3/conversation/add 写入 L0 原始对话 + -> TencentDB Agent Memory 异步提取 L1 记忆 + +第二轮对话 + -> Agent 调用 load_memory + -> TencentDBMemoryService.search_memory() + -> POST /v3/atomic/search 搜索 L1 记忆 + -> L1 无结果时,POST /v3/conversation/search 搜索 L0 原始对话 +``` + +写入记忆由框架自动完成,不需要给 Agent 添加 `save_memory` 工具。Agent 只需通过 +`load_memory` 工具主动检索已经提取的长期记忆。 + +> **本地部署注意:必须在 Memory Core 服务端正确配置 +> `MEMORY_PROMPT_MODE`。** +> +> 该参数虽然不由 tRPC-Agent 客户端读取,但会决定 TencentDB Agent Memory +> 服务端使用哪一组提示词执行 L1/L2/L3 记忆提取。配置不匹配时,L0 对话仍会 +> 写入成功,但服务端可能提取出 0 条 L1 记忆,因此后续搜索无法召回。 +> +> - `chat`:个人偏好、用户画像、对话经历、教学和通用助手场景。 +> - `code`:项目事实、工程任务、技术决策、SOP 和团队协作场景。 +> +> 本示例写入“喜欢的颜色”。本地部署需要在 Memory Core 服务端选择 `chat` +> 模式。腾讯云托管实例的提取策略由产品服务端管理,本客户端不会读取或发送 +> `MEMORY_PROMPT_MODE`。 + +## 选择部署方式 + +本示例支持两种互斥的接入方式: + +- **本地部署**:自行下载并启动 TencentDB Agent Memory,需要 Docker,并可配置 + Memory Core 的提取模式。按照“方案一”操作。 +- **腾讯云托管实例**:不需要下载或启动服务端,直接使用控制台提供的 Gateway + 地址和凭证。按照“方案二”操作。 + +两种方式只共用最后的“运行示例”步骤。不要把本地服务端配置、Panel 登录密钥和 +腾讯云 Gateway 凭证混用。 + +## 公共环境要求 + +- Python 3.10+ +- 一个 OpenAI 兼容的 LLM 服务 +- tRPC-Agent Python 源码及其依赖环境 + +## 方案一:本地部署 + +本地部署还需要 Docker。以下命令都在 +`TencentDB-Agent-Memory/deploy/global-images` 目录执行。 + +### 1. 下载 TencentDB Agent Memory + +建议使用包含 V3 API 的 `feat/server_team` 分支: + +```bash +git clone -b feat/server_team \ + https://github.com/TencentCloud/TencentDB-Agent-Memory.git + +cd TencentDB-Agent-Memory/deploy/global-images +``` + +### 2. 配置并启动服务端 + +先复制服务端配置文件: + +```bash +cp .env.example .env +``` + +本示例保存的是“喜欢的颜色”这类个人偏好,服务端必须使用 `chat` 提取模式。在 +`TencentDB-Agent-Memory/deploy/global-images/.env` 中确认: + +```dotenv +MEMORY_PROMPT_MODE=chat +``` + +然后运行启动脚本。脚本会交互式要求填写 Memory 和 Proxy 使用的 LLM 地址、 +API Key、模型名称及协议: + +```bash +./start-all.sh +``` + +如果提示无法连接 Docker daemon,请先启动 Docker: + +```bash +sudo systemctl start docker +``` + +`code` 模式面向工程事实、任务和决策,可能会把个人偏好判断为无须沉淀的闲聊, +从而产生 `extracted=0`。 + +如果服务已经以 `code` 模式启动,修改 `.env` 后执行下面的命令重新创建 +Memory Core 容器,已有数据卷会保留: + +```bash +./start-memory-core.sh +``` + +启动成功后默认端口如下: + +- Memory Core V3 API:`http://127.0.0.1:8420` +- Memory Panel:`http://127.0.0.1:8125` +- Knowledge Service:`http://127.0.0.1:8424` +- Memory Proxy:`http://127.0.0.1:8096` + +可以检查容器状态: + +```bash +docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}' +curl http://127.0.0.1:8420/health +``` + +### 3. 创建 Team、User 和 Agent + +获取首次启动时生成的管理员登录密钥: + +```bash +cd TencentDB-Agent-Memory/deploy/global-images +cat .admin-key +``` + +打开 `http://localhost:8125`,使用 `.admin-key` 中的 `sk-mem-*` 登录,然后: + +1. 点击左上角 Team 切换器,选择“新建团队”,例如创建 `trpc-agent-test`。 +2. 进入该 Team 的“成员管理”,选择“添加成员”。 +3. 选择“新建用户并加入团队”,例如创建 `test_user`。创建时完整 + `user_key` 只显示一次,请妥善保存。 +4. 进入该 Team 的 “Agents 管理”,创建 `memory-assistant`。 +5. 记录创建结果中的真实 `team_id` 和 `agent_id`,配置示例时需要使用 ID, + 不能填写 Team 或 Agent 的显示名称。 + +如果服务部署在远程主机,推荐从本地建立 SSH 隧道: + +```bash +ssh -L 8125:127.0.0.1:8125 @ +``` + +然后在本地浏览器访问 `http://localhost:8125`。不要直接将管理面板暴露到公网。 + +这里有两种不同的 User 概念: + +- Panel User:用于登录管理面板、管理 Team 和 Agent,拥有 `sk-mem-*` User Key。 +- Memory User:框架运行时传入的业务用户 ID。本示例在 `run_agent.py` 中使用 + `user_id="alice"`,同一业务用户的不同 Session 共享长期记忆。 + +Panel User Key 不是 LLM API Key,也不是本示例中的 Gateway API Key。 + +### 4. 配置本地客户端 + +回到 tRPC-Agent Python 仓库,复制本地模板: + +```bash +cp examples/memory_service_with_tencentdb/.env.local \ + examples/memory_service_with_tencentdb/.env +``` + +然后编辑 [`examples/memory_service_with_tencentdb/.env`](./.env): + +```dotenv +# 示例 Agent 自身调用的 OpenAI 兼容 LLM +TRPC_AGENT_API_KEY= +TRPC_AGENT_BASE_URL= +TRPC_AGENT_MODEL_NAME=<模型名称> + +# 直连 Memory Core V3 API,不是 8096 Proxy +TENCENTDB_MEMORY_ENDPOINT=http://127.0.0.1:8420 + +# 对应服务端 MEMORY_CORE_GATEWAY_API_KEY。 +# 本地部署默认未开启 Gateway Bearer 鉴权时可使用 local。 +TENCENTDB_MEMORY_API_KEY=local + +# 本地部署的实例 ID 固定为 default +TENCENTDB_MEMORY_SERVICE_ID=default + +# 填写 Panel 中创建出的真实 ID,不要填写显示名称 +TENCENTDB_MEMORY_TEAM_ID= +TENCENTDB_MEMORY_AGENT_ID= + +# L1 提取是异步的,首次验证建议设置为 15 秒 +TENCENTDB_MEMORY_PIPELINE_WAIT_SECONDS=15 +``` + +如果 tRPC-Agent 与 Memory Core 不在同一台机器,需要把 +`TENCENTDB_MEMORY_ENDPOINT` 改成 tRPC-Agent 进程能够访问的 Memory Core 地址。 + +> `MEMORY_PROMPT_MODE=chat` 应配置在 TencentDB Agent Memory 服务端的 +> `deploy/global-images/.env` 中,而不是 tRPC-Agent 客户端的 `.env` 中。 + +## 方案二:腾讯云托管实例 + +如果使用腾讯云上的 TencentDB Agent Memory 产品,不需要运行本地 +`start-all.sh`,也不需要访问本地 Memory Panel。请先从腾讯云控制台获取: + +- Gateway 地址 +- Gateway API Key +- Memory 实例 ID +- Team ID +- Agent ID +- 运行时使用的业务 User ID + +复制远端模板: + +```bash +cp examples/memory_service_with_tencentdb/.env.remote \ + examples/memory_service_with_tencentdb/.env +``` + +然后编辑 `.env`: + +```dotenv +TRPC_AGENT_API_KEY= +TRPC_AGENT_BASE_URL= +TRPC_AGENT_MODEL_NAME=<模型名称> + +TENCENTDB_MEMORY_ENDPOINT=https://<控制台提供的 Gateway 地址> +TENCENTDB_MEMORY_API_KEY= +TENCENTDB_MEMORY_SERVICE_ID= +TENCENTDB_MEMORY_TEAM_ID= +TENCENTDB_MEMORY_AGENT_ID= +TENCENTDB_MEMORY_PIPELINE_WAIT_SECONDS=15 +``` + +腾讯云托管实例的 L1 提取策略由产品服务端管理,不属于 +`TencentDBMemoryService` 的数据面接入范围。当前实例不适合个人偏好场景时,本示例 +可能表现为: + +```text +conversation/add 写入成功 +L1 extracted=0 +atomic/search 返回空结果 +``` + +`TENCENTDB_MEMORY_API_KEY` 是 Gateway API Key,不是 Panel 的 `sk-mem-*` +User Key;Gateway 地址必须支持 `/v3/conversation/add` 和 +`/v3/atomic/search`。 + +此外,请确认 `run_agent.py` 中的 `user_id` 是当前实例使用的业务用户 ID。同一个 +业务用户的不同 Session 才能共享记忆。 + +## 公共步骤:运行示例 + +在 tRPC-Agent Python 仓库根目录运行: + +```bash +python3 examples/memory_service_with_tencentdb/run_agent.py +``` + +示例执行以下流程: + +1. `session-write` 告诉 Agent:“My favorite color is blue.” +2. 第一轮结束后,Runner 自动将新增事件写入 L0。 +3. Memory Core 异步将该偏好提取为 L1 记忆。 +4. 等待提取完成后,`session-recall` 在另一个 Session 中询问喜欢的颜色。 +5. Agent 调用 `load_memory`,跨 Session 搜索 `alice` 的长期记忆。 + +## 测试结果 + +一次成功运行的关键输出如下,模型的具体措辞可能不同: + +```text +User (session-write): My favorite color is blue. Please remember it. +Assistant: ... I've noted that your favorite color is blue. +Waiting s for asynchronous memory extraction... + +User (session-recall): What is my favorite color? +Assistant: Your favorite color is blue! +``` + +实际写入发生在对话结束后的 Runner 阶段,对 Agent 是透明的。第二个 Session 能 +回答 `blue`,说明记忆检索链路可用;但由于当前实现会在 L1 无结果时搜索 L0 原始 +对话,仅凭回答正确不能证明 L1 已成功提取。 + +## 故障排查 + +### 写入返回 HTTP 400 + +查看 tRPC-Agent 日志中的服务端错误信息。V3 API 要求消息时间为带时区的 +RFC 3339 格式,例如 `2026-09-28T02:34:29Z`。 + +### 本地部署:写入成功但搜索不到记忆 + +检查 Memory Core 日志: + +```bash +docker logs --since 10m tdai-memory-core +``` + +如果看到下面的日志: + +```text +promptMode=code +Total extracted memories: 0 +l1-empty reason=empty_scenes +``` + +说明服务端仍在使用 `code` 模式。设置 `MEMORY_PROMPT_MODE=chat` 并重新执行 +`./start-memory-core.sh`。 + +### 腾讯云:L1 一直为空 + +腾讯云托管实例的提取模式不能通过本示例的 `.env` 修改。请确认: + +1. Gateway 地址、实例 ID、Team ID 和 Agent ID 来自同一个实例。 +2. `run_agent.py` 的 `user_id` 与查询时使用的业务用户一致。 +3. 当前实例的服务端提取策略适合测试内容。默认编码场景可能不会沉淀“喜欢的颜色” + 这类个人偏好。 +4. 已等待足够时间;L0 写入成功不代表 L1 已经生成。 + +如果仍然无法生成或检索 L1,请参考腾讯云官方文档检查服务端配置: + +- [创建 Memory Prompt](https://cloud.tencent.com/document/product/1813/137169) +- [应用 Memory Prompt](https://cloud.tencent.com/document/product/1813/137172) +- [查询生效的 Memory Prompt](https://cloud.tencent.com/document/product/1813/137170) +- [L1 记忆语义检索](https://cloud.tencent.com/document/product/1813/135156) + +这些属于腾讯云产品侧配置,不会由 `TencentDBMemoryService` 自动创建或修改。 + +如果只是异步提取尚未完成,可增大等待时间: + +```dotenv +TENCENTDB_MEMORY_PIPELINE_WAIT_SECONDS=30 +``` + +### 本地部署:查看写入和提取状态 + +```bash +docker logs --since 10m tdai-memory-core | \ + grep -E 'conversation/add|L1 complete|extracted|atomic/search' +``` + +预期可以看到: + +```text +POST /v3/conversation/add status=200 +L1 complete: extracted=1, stored=1 +POST /v3/atomic/search status=200 +``` + +## 实现说明 + +- `service_id/team_id/agent_id/user_id` 共同构成记忆隔离边界。 +- `session_id` 仅在写入时发送;搜索时不限定 Session,因此支持跨 Session 召回。 +- `/v3/atomic/search` 是 L1 语义检索接口;如果 L1 没有命中,当前实现会使用 + `/v3/conversation/search` 对 L0 原始对话做语义兜底。 +- 服务只发送当前进程中尚未成功写入的事件。 +- 进程重启后采用至少一次投递语义,因为 V3 写入接口没有调用方提供的幂等键。 +- L1 提取是异步的,写入成功不代表记忆可以立即搜索。 +- 记忆保留和删除由 TencentDB Agent Memory 管理,框架 TTL 不适用于该服务。 + diff --git a/examples/memory_service_with_tencentdb/agent/__init__.py b/examples/memory_service_with_tencentdb/agent/__init__.py new file mode 100644 index 000000000..9d8133d33 --- /dev/null +++ b/examples/memory_service_with_tencentdb/agent/__init__.py @@ -0,0 +1,2 @@ +"""TencentDB Agent Memory example agent.""" + diff --git a/examples/memory_service_with_tencentdb/agent/agent.py b/examples/memory_service_with_tencentdb/agent/agent.py new file mode 100644 index 000000000..dca76fccb --- /dev/null +++ b/examples/memory_service_with_tencentdb/agent/agent.py @@ -0,0 +1,32 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Agent used by the TencentDB Agent Memory example.""" + +from trpc_agent_sdk.agents import LlmAgent +from trpc_agent_sdk.models import OpenAIModel +from trpc_agent_sdk.tools import load_memory_tool + +from .config import get_model_config + + +def create_agent() -> LlmAgent: + """Create an assistant that can recall cross-session memory.""" + api_key, base_url, model_name = get_model_config() + return LlmAgent( + name="memory_assistant", + description="An assistant with TencentDB Agent Memory.", + model=OpenAIModel( + model_name=model_name, + api_key=api_key, + base_url=base_url, + ), + instruction=("Use load_memory before answering questions about information the " + "user may have shared in earlier conversations."), + tools=[load_memory_tool], + ) + + +root_agent = create_agent() diff --git a/examples/memory_service_with_tencentdb/agent/config.py b/examples/memory_service_with_tencentdb/agent/config.py new file mode 100644 index 000000000..b9336e43c --- /dev/null +++ b/examples/memory_service_with_tencentdb/agent/config.py @@ -0,0 +1,19 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Configuration helpers for the TencentDB Agent Memory example.""" + +import os + + +def get_model_config() -> tuple[str, str, str]: + """Return the configured model API key, endpoint, and model name.""" + api_key = os.getenv("TRPC_AGENT_API_KEY", "") + base_url = os.getenv("TRPC_AGENT_BASE_URL", "") + model_name = os.getenv("TRPC_AGENT_MODEL_NAME", "") + if not api_key or not base_url or not model_name: + raise ValueError("TRPC_AGENT_API_KEY, TRPC_AGENT_BASE_URL, and " + "TRPC_AGENT_MODEL_NAME must be configured", ) + return api_key, base_url, model_name diff --git a/examples/memory_service_with_tencentdb/run_agent.py b/examples/memory_service_with_tencentdb/run_agent.py new file mode 100644 index 000000000..2486c1bad --- /dev/null +++ b/examples/memory_service_with_tencentdb/run_agent.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 + +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Run the TencentDB Agent Memory cross-session example.""" + +import asyncio +import os +import sys +from pathlib import Path + +from dotenv import load_dotenv +from trpc_agent_sdk.context import AgentContext +from trpc_agent_sdk.memory.tencentdb_memory_service import ( + TencentDBMemoryService, + TencentDBMemoryServiceConfig, +) +from trpc_agent_sdk.runners import Runner +from trpc_agent_sdk.sessions import InMemorySessionService +from trpc_agent_sdk.types import Content +from trpc_agent_sdk.types import Part + +load_dotenv() +sys.path.append(str(Path(__file__).parent)) + + +def _required_env(name: str) -> str: + value = os.getenv(name, "").strip() + if not value: + raise ValueError(f"{name} must be configured") + return value + + +def create_memory_service() -> TencentDBMemoryService: + """Create the V3 gateway-backed memory service.""" + config = TencentDBMemoryServiceConfig( + enabled=True, + endpoint=os.getenv( + "TENCENTDB_MEMORY_ENDPOINT", + "http://127.0.0.1:8420", + ), + api_key=os.getenv("TENCENTDB_MEMORY_API_KEY", "local"), + service_id=_required_env("TENCENTDB_MEMORY_SERVICE_ID"), + team_id=_required_env("TENCENTDB_MEMORY_TEAM_ID"), + agent_id=_required_env("TENCENTDB_MEMORY_AGENT_ID"), + ) + return TencentDBMemoryService(config) + + +async def _run_turn( + runner: Runner, + *, + user_id: str, + session_id: str, + text: str, +) -> None: + print(f"\nUser ({session_id}): {text}") + print("Assistant: ", end="", flush=True) + message = Content(parts=[Part.from_text(text=text)]) + async for event in runner.run_async( + agent_context=AgentContext(), + user_id=user_id, + session_id=session_id, + new_message=message, + ): + if event.partial and event.content and event.content.parts: + for part in event.content.parts: + if part.text: + print(part.text, end="", flush=True) + print() + + +async def main() -> None: + """Write a fact in one session and recall it from another.""" + from agent.agent import root_agent + + memory_service = create_memory_service() + runner = Runner( + app_name="tencentdb_memory_demo", + agent=root_agent, + session_service=InMemorySessionService(), + memory_service=memory_service, + ) + user_id = "alice" + + try: + await _run_turn( + runner, + user_id=user_id, + session_id="session-write", + text="My favorite color is blue. Please remember it.", + ) + + wait_seconds = float(os.getenv("TENCENTDB_MEMORY_PIPELINE_WAIT_SECONDS", "5"), ) + print(f"Waiting {wait_seconds:g}s for asynchronous memory extraction...", ) + await asyncio.sleep(wait_seconds) + + await _run_turn( + runner, + user_id=user_id, + session_id="session-recall", + text="What is my favorite color?", + ) + finally: + await runner.close() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tests/memory/test_tencentdb_memory_service.py b/tests/memory/test_tencentdb_memory_service.py new file mode 100644 index 000000000..55aee5014 --- /dev/null +++ b/tests/memory/test_tencentdb_memory_service.py @@ -0,0 +1,394 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Tests for the TencentDB Agent Memory V3 adapter.""" + +from __future__ import annotations + +import json +from typing import Any + +import httpx +import pytest + +from trpc_agent_sdk.events import Event +from trpc_agent_sdk.memory.tencentdb_memory_service import ( + TencentDBMemoryError, + TencentDBMemoryService, + TencentDBMemoryServiceConfig, +) +from trpc_agent_sdk.sessions import Session +from trpc_agent_sdk.types import Content +from trpc_agent_sdk.types import Part + + +def _event( + event_id: str, + text: str, + *, + author: str = "user", + timestamp: float = 1_700_000_000.0, +) -> Event: + return Event( + id=event_id, + invocation_id=f"inv-{event_id}", + author=author, + content=Content(parts=[Part.from_text(text=text)]), + timestamp=timestamp, + ) + + +def _session(events: list[Event]) -> Session: + return Session( + id="session-1", + app_name="memory-demo", + user_id="user-1", + save_key="memory-demo/user-1", + events=events, + ) + + +def _config() -> TencentDBMemoryServiceConfig: + config = TencentDBMemoryServiceConfig( + enabled=True, + endpoint="https://memory.example.com/", + api_key="secret", + service_id="service-1", + team_id="team-1", + agent_id="agent-1", + ) + config.clean_ttl_config() + return config + + +def _json_response(data: dict[str, Any], *, code: int = 0) -> httpx.Response: + return httpx.Response( + 200, + json={ + "code": code, + "message": "ok" if code == 0 else "failed", + "request_id": "request-1", + "data": data, + }, + ) + + +@pytest.mark.asyncio +async def test_store_session_uses_v3_identity_and_only_sends_new_events(): + requests: list[httpx.Request] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + body = json.loads(request.content) + count = len(body["messages"]) + return _json_response({ + "accepted_ids": [f"message-{index}" for index in range(count)], + "accepted_versions": ["v1"] * count, + "total_count": count, + }) + + client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + service = TencentDBMemoryService(_config(), client=client) + session = _session([ + _event("event-1", "My name is Alice."), + _event("event-2", "Nice to meet you.", author="assistant"), + ]) + + await service.store_session(session) + await service.store_session(session) + session.events.append(_event("event-3", "My favorite color is blue.")) + await service.store_session(session) + + assert len(requests) == 2 + first = requests[0] + assert first.url == "https://memory.example.com/v3/conversation/add" + assert first.headers["authorization"] == "Bearer secret" + assert first.headers["x-tdai-service-id"] == "service-1" + + first_body = json.loads(first.content) + assert first_body["session_id"] == "session-1" + assert first_body["team_id"] == "team-1" + assert first_body["agent_id"] == "agent-1" + assert first_body["user_id"] == "user-1" + assert first_body["messages"] == [ + { + "role": "user", + "content": "My name is Alice.", + "timestamp": "2023-11-14T22:13:20Z", + }, + { + "role": "assistant", + "content": "Nice to meet you.", + "timestamp": "2023-11-14T22:13:20Z", + }, + ] + + second_body = json.loads(requests[1].content) + assert [message["content"] for message in second_body["messages"]] == [ + "My favorite color is blue.", + ] + await client.aclose() + + +@pytest.mark.asyncio +async def test_store_session_retries_events_after_gateway_error(): + calls = 0 + + async def handler(_request: httpx.Request) -> httpx.Response: + nonlocal calls + calls += 1 + if calls == 1: + return _json_response({}, code=5001) + return _json_response({ + "accepted_ids": ["message-1"], + "accepted_versions": ["v1"], + "total_count": 1, + }) + + client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + service = TencentDBMemoryService(_config(), client=client) + session = _session([_event("event-1", "Remember this.")]) + + await service.store_session(session) + await service.store_session(session) + + assert calls == 2 + await client.aclose() + + +@pytest.mark.asyncio +async def test_post_preserves_gateway_http_error_details(): + + async def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response( + 400, + json={ + "code": 400, + "message": "messages.0.timestamp: Invalid ISO datetime", + "request_id": "request-1", + }, + ) + + client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + service = TencentDBMemoryService(_config(), client=client) + + with pytest.raises( + TencentDBMemoryError, + match="messages.0.timestamp: Invalid ISO datetime", + ): + await service._post("/v3/conversation/add", {}) + + await client.aclose() + + +@pytest.mark.asyncio +async def test_store_session_retries_after_incomplete_success_response(): + calls = 0 + + async def handler(_request: httpx.Request) -> httpx.Response: + nonlocal calls + calls += 1 + if calls == 1: + return _json_response({"total_count": 1}) + return _json_response({ + "accepted_ids": ["message-1"], + "accepted_versions": ["v1"], + "total_count": 1, + }) + + client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + service = TencentDBMemoryService(_config(), client=client) + session = _session([_event("event-1", "Remember this.")]) + + await service.store_session(session) + await service.store_session(session) + + assert calls == 2 + await client.aclose() + + +@pytest.mark.asyncio +async def test_store_session_batches_at_v3_limit(): + batch_sizes: list[int] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + body = json.loads(request.content) + count = len(body["messages"]) + batch_sizes.append(count) + return _json_response({ + "accepted_ids": [f"message-{index}" for index in range(count)], + "accepted_versions": ["v1"] * count, + "total_count": count, + }) + + client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + service = TencentDBMemoryService(_config(), client=client) + session = _session([_event(f"event-{index}", f"message {index}") for index in range(101)]) + + await service.store_session(session) + + assert batch_sizes == [100, 1] + await client.aclose() + + +@pytest.mark.asyncio +async def test_store_session_skips_empty_partial_and_error_events(): + captured_messages: list[dict[str, str]] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + body = json.loads(request.content) + captured_messages.extend(body["messages"]) + return _json_response({ + "accepted_ids": ["message-1"], + "accepted_versions": ["v1"], + "total_count": 1, + }) + + partial = _event("partial", "partial") + partial.partial = True + error = _event("error", "error") + error.error_code = "failed" + empty = Event(id="empty", invocation_id="inv-empty", author="user") + + client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + service = TencentDBMemoryService(_config(), client=client) + await service.store_session(_session([ + partial, + error, + empty, + _event("valid", "valid"), + ])) + + assert [message["content"] for message in captured_messages] == ["valid"] + await client.aclose() + + +@pytest.mark.asyncio +async def test_search_memory_maps_v3_atomic_items_across_sessions(): + captured_body: dict[str, Any] = {} + + async def handler(request: httpx.Request) -> httpx.Response: + captured_body.update(json.loads(request.content)) + return _json_response({ + "items": [ + { + "id": "atomic-1", + "type": "persona", + "content": "The user's favorite color is blue.", + "score": 0.97, + "created_at": "2026-09-24T10:00:00Z", + "updated_at": "2026-09-24T10:01:00Z", + }, + { + "id": "atomic-2", + "type": "episodic", + "content": "", + }, + ], + }) + + client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + service = TencentDBMemoryService(_config(), client=client) + + result = await service.search_memory( + key="memory-demo/user-1", + query="favorite color", + limit=5, + ) + + assert captured_body == { + "query": "favorite color", + "limit": 5, + "team_id": "team-1", + "agent_id": "agent-1", + "user_id": "user-1", + } + assert len(result.memories) == 1 + assert result.memories[0].content.parts[0].text == ("The user's favorite color is blue.") + assert result.memories[0].author == "persona" + assert result.memories[0].timestamp == "2026-09-24T10:01:00Z" + await client.aclose() + + +@pytest.mark.asyncio +async def test_search_memory_falls_back_to_semantic_conversation_search(): + requests: list[httpx.Request] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + if request.url.path == "/v3/atomic/search": + return _json_response({"items": []}) + return _json_response({ + "messages": [{ + "id": "message-1", + "role": "user", + "content": "My favorite color is blue.", + "timestamp": "2026-09-24T10:00:00Z", + "score": 0.91, + }], + }) + + client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + service = TencentDBMemoryService(_config(), client=client) + + result = await service.search_memory( + key="memory-demo/user-1", + query="What color do I like?", + limit=5, + ) + + assert [request.url.path for request in requests] == [ + "/v3/atomic/search", + "/v3/conversation/search", + ] + assert json.loads(requests[1].content)["query"] == "What color do I like?" + assert result.memories[0].content.parts[0].text == "My favorite color is blue." + assert result.memories[0].author == "user" + assert result.memories[0].timestamp == "2026-09-24T10:00:00Z" + await client.aclose() + + +@pytest.mark.asyncio +async def test_search_memory_returns_empty_response_on_gateway_error(): + + async def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response(503, text="unavailable") + + client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + service = TencentDBMemoryService(_config(), client=client) + + result = await service.search_memory( + key="memory-demo/user-1", + query="anything", + ) + + assert result.memories == [] + await client.aclose() + + +def test_truncate_utf16_does_not_split_surrogate_pair(): + text = ("a" * 8191) + "😀" + "tail" + + truncated = TencentDBMemoryService._truncate_utf16(text, 8192) + + assert truncated == "a" * 8191 + assert len(truncated.encode("utf-16-le")) // 2 <= 8192 + + +@pytest.mark.asyncio +async def test_close_only_closes_owned_client(): + injected = httpx.AsyncClient(transport=httpx.MockTransport(lambda _: _json_response({}))) + injected_service = TencentDBMemoryService(_config(), client=injected) + + await injected_service.close() + + assert injected.is_closed is False + await injected.aclose() + + owned_service = TencentDBMemoryService(_config()) + owned_client = owned_service._client + await owned_service.close() + + assert owned_client.is_closed is True diff --git a/trpc_agent_sdk/memory/tencentdb_memory_service.py b/trpc_agent_sdk/memory/tencentdb_memory_service.py new file mode 100644 index 000000000..6ce3203d5 --- /dev/null +++ b/trpc_agent_sdk/memory/tencentdb_memory_service.py @@ -0,0 +1,365 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""TencentDB Agent Memory V3 integration.""" + +from __future__ import annotations + +import asyncio +from datetime import datetime +from datetime import timezone +from typing import Any +from typing import Optional +from typing_extensions import override + +import httpx +from pydantic import ConfigDict +from pydantic import Field + +from trpc_agent_sdk.abc import MemoryServiceABC as BaseMemoryService +from trpc_agent_sdk.abc import MemoryServiceConfig +from trpc_agent_sdk.context import AgentContext +from trpc_agent_sdk.events import Event +from trpc_agent_sdk.log import logger +from trpc_agent_sdk.sessions import Session +from trpc_agent_sdk.types import Content +from trpc_agent_sdk.types import MemoryEntry +from trpc_agent_sdk.types import Part +from trpc_agent_sdk.types import SearchMemoryResponse +from trpc_agent_sdk.types import Ttl + +from ._utils import event_to_text + +_CONVERSATION_ADD_PATH = "/v3/conversation/add" +_CONVERSATION_SEARCH_PATH = "/v3/conversation/search" +_ATOMIC_SEARCH_PATH = "/v3/atomic/search" +_MAX_MESSAGES_PER_REQUEST = 100 +_MAX_MESSAGE_UTF16_UNITS = 8192 + + +class TencentDBMemoryServiceConfig(MemoryServiceConfig): + """Configuration for :class:`TencentDBMemoryService`. + + ``service_id``, ``team_id`` and ``agent_id`` define the stable V3 + isolation boundary. The framework session supplies ``user_id`` and + ``session_id`` at runtime. + """ + + model_config = ConfigDict(str_strip_whitespace=True) + + ttl: Ttl = Field( + default_factory=lambda: Ttl( + enable=False, + ttl_seconds=0, + cleanup_interval_seconds=0, + ), + description=("Framework TTL is disabled because retention is managed by " + "TencentDB Agent Memory."), + ) + endpoint: str = Field( + default="http://127.0.0.1:8420", + min_length=1, + description="TencentDB Agent Memory gateway endpoint.", + ) + api_key: str = Field( + default="local", + description="Bearer token used by the gateway.", + ) + service_id: str = Field( + min_length=1, + description="TencentDB Agent Memory service/instance ID.", + ) + team_id: str = Field( + min_length=1, + description="Team isolation ID.", + ) + agent_id: str = Field( + min_length=1, + description="Agent isolation ID.", + ) + timeout: float = Field( + default=10.0, + gt=0, + description="HTTP request timeout in seconds.", + ) + verify_ssl: bool = Field( + default=True, + description="Whether to verify the gateway TLS certificate.", + ) + + +class TencentDBMemoryError(RuntimeError): + """Raised when the TencentDB Agent Memory gateway rejects a request.""" + + def __init__(self, code: int, message: str, request_id: str = "") -> None: + super().__init__(f"TencentDB Agent Memory request failed: code={code}, " + f"message={message}, request_id={request_id}") + self.code = code + self.message = message + self.request_id = request_id + + +class TencentDBMemoryService(BaseMemoryService): + """Store and retrieve long-term memory through the TencentDB V3 API. + + The Runner passes the complete session to ``store_session`` after every + turn. This service tracks successfully accepted event IDs and sends only + new text events during the current process lifetime. Delivery is + at-least-once across process restarts because the V3 API does not accept a + caller-provided idempotency key. + + L0 writes use ``/v3/conversation/add`` with session isolation. Searches + use ``/v3/atomic/search`` without a session ID so memories can be recalled + across sessions for the same user. + """ + + def __init__( + self, + config: TencentDBMemoryServiceConfig, + client: Optional[httpx.AsyncClient] = None, + ) -> None: + super().__init__(memory_service_config=config) + self._config = config + self._owns_client = client is None + self._client = client or httpx.AsyncClient( + timeout=config.timeout, + verify=config.verify_ssl, + ) + self._stored_event_ids: dict[tuple[str, str], set[str]] = {} + self._store_locks: dict[tuple[str, str], asyncio.Lock] = {} + + if config.ttl.need_ttl_expire(): + logger.warning( + "TencentDBMemoryService does not apply framework TTL settings; " + "configure memory retention in TencentDB Agent Memory.", ) + + @override + async def store_session( + self, + session: Session, + agent_context: Optional[AgentContext] = None, + ) -> None: + """Send new text events in ``session`` to the V3 conversation API.""" + del agent_context + session_key = (session.save_key, session.id) + lock = self._store_locks.setdefault(session_key, asyncio.Lock()) + + async with lock: + stored_ids = self._stored_event_ids.setdefault(session_key, set()) + pending = self._pending_messages(session.events, stored_ids) + if not pending: + return + + for start in range(0, len(pending), _MAX_MESSAGES_PER_REQUEST): + batch = pending[start:start + _MAX_MESSAGES_PER_REQUEST] + body = { + "session_id": session.id, + "messages": [message for _, message in batch], + **self._isolation_body(session.user_id), + } + try: + data = await self._post(_CONVERSATION_ADD_PATH, body) + self._validate_capture_response(data, len(batch)) + except Exception as exc: # pylint: disable=broad-except + logger.warning( + "Failed to store session in TencentDB Agent Memory. " + "save_key=%s, session_id=%s, err=%s", + session.save_key, + session.id, + exc, + ) + return + stored_ids.update(event_id for event_id, _ in batch) + + @override + async def search_memory( + self, + key: str, + query: str, + limit: int = 10, + agent_context: Optional[AgentContext] = None, + ) -> SearchMemoryResponse: + """Search V3 L1 atomic memories across the user's sessions.""" + del agent_context + response = SearchMemoryResponse() + user_id = self._user_id_from_key(key) + body = { + "query": query, + "limit": limit, + **self._isolation_body(user_id), + } + try: + # search atomic memories for the user L1 + data = await self._post(_ATOMIC_SEARCH_PATH, body) + items = data.get("items", []) + if not items: + # search conversation memories for the user L0 + data = await self._post(_CONVERSATION_SEARCH_PATH, body) + items = data.get("messages", []) + if not isinstance(items, list): + raise ValueError("TencentDB Agent Memory search response must contain a list") + for item in items: + entry = self._to_memory_entry(item) + if entry is not None: + response.memories.append(entry) + except Exception as exc: # pylint: disable=broad-except + logger.warning( + "Failed to search TencentDB Agent Memory. key=%s, query=%s, err=%s", + key, + query, + exc, + ) + return response + + @override + async def close(self) -> None: + """Release the owned HTTP client and local delivery checkpoints.""" + self._stored_event_ids.clear() + self._store_locks.clear() + if self._owns_client: + await self._client.aclose() + + async def _post( + self, + path: str, + body: dict[str, Any], + ) -> dict[str, Any]: + headers = { + "Authorization": f"Bearer {self._config.api_key}", + "x-tdai-service-id": self._config.service_id, + "Content-Type": "application/json", + } + + response = await self._client.post( + f"{self._config.endpoint.rstrip('/')}{path}", + json=body, + headers=headers, + timeout=self._config.timeout, + ) + try: + payload = response.json() + except ValueError: + response.raise_for_status() + raise ValueError("TencentDB Agent Memory response must be valid JSON") + if not isinstance(payload, dict): + response.raise_for_status() + raise ValueError("TencentDB Agent Memory response must be a JSON object") + + code = payload.get("code") + if response.is_error or code != 0: + request_id = (response.headers.get("x-qcloud-transaction-id") or payload.get("request_id") or "") + raise TencentDBMemoryError( + code=code if isinstance(code, int) else response.status_code, + message=str(payload.get("message", response.reason_phrase)), + request_id=str(request_id), + ) + + data = payload.get("data", {}) + if data is None: + return {} + if not isinstance(data, dict): + raise ValueError("TencentDB Agent Memory response 'data' must be an object") + return data + + def _isolation_body(self, user_id: str) -> dict[str, str]: + return { + "team_id": self._config.team_id, + "agent_id": self._config.agent_id, + "user_id": user_id, + } + + @classmethod + def _pending_messages( + cls, + events: list[Event], + stored_ids: set[str], + ) -> list[tuple[str, dict[str, str]]]: + pending: list[tuple[str, dict[str, str]]] = [] + for event in events: + if event.id in stored_ids or event.partial or event.is_error(): + continue + text = event_to_text(event) + if not text: + continue + text = cls._truncate_utf16(text, _MAX_MESSAGE_UTF16_UNITS) + pending.append(( + event.id, + { + "role": "user" if event.author == "user" else "assistant", + "content": text, + "timestamp": cls._format_timestamp(event.timestamp), + }, + )) + return pending + + @staticmethod + def _format_timestamp(timestamp: float) -> str: + """Format an event timestamp as an RFC 3339 UTC datetime.""" + return datetime.fromtimestamp(timestamp, tz=timezone.utc).isoformat().replace("+00:00", "Z") + + @staticmethod + def _truncate_utf16(text: str, max_units: int) -> str: + encoded = text.encode("utf-16-le") + max_bytes = max_units * 2 + if len(encoded) <= max_bytes: + return text + + truncated = encoded[:max_bytes] + while truncated: + try: + return truncated.decode("utf-16-le") + except UnicodeDecodeError: + truncated = truncated[:-2] + return "" + + @staticmethod + def _validate_capture_response(data: dict[str, Any], expected: int) -> None: + total_count = data.get("total_count") + accepted_ids = data.get("accepted_ids") + if total_count != expected: + raise ValueError( + "TencentDB Agent Memory accepted an unexpected number of messages: " + f"expected={expected}, actual={total_count}", ) + if not isinstance(accepted_ids, list) or len(accepted_ids) != expected: + raise ValueError( + "TencentDB Agent Memory returned an incomplete accepted_ids list: " + f"expected={expected}, actual={len(accepted_ids) if isinstance(accepted_ids, list) else 'missing'}", ) + + @staticmethod + def _user_id_from_key(key: str) -> str: + _, separator, user_id = key.partition("/") + resolved = user_id if separator else key + if not resolved: + raise ValueError("Memory search key must contain a user ID") + return resolved + + @staticmethod + def _to_memory_entry(item: Any) -> Optional[MemoryEntry]: + if not isinstance(item, dict): + return None + memory_text = item.get("content") + if not isinstance(memory_text, str) or not memory_text: + return None + + memory_type = item.get("type") or item.get("role") + author = memory_type if isinstance(memory_type, str) and memory_type else "memory" + timestamp = item.get("updated_at") or item.get("created_at") or item.get("timestamp") + if not isinstance(timestamp, str): + timestamp = None + return MemoryEntry( + content=Content( + parts=[Part.from_text(text=memory_text)], + role="user", + ), + author=author, + timestamp=timestamp, + ) + + +__all__ = [ + "TencentDBMemoryError", + "TencentDBMemoryService", + "TencentDBMemoryServiceConfig", +]