From 040226ba68c9279e8a0966f161ccb1c5e467d746 Mon Sep 17 00:00:00 2001 From: xiayu Date: Thu, 18 Jun 2026 16:56:09 +0800 Subject: [PATCH 01/52] =?UTF-8?q?feat:=20release=20ksadk=200.6.6=20?= =?UTF-8?q?=E2=80=94=20unified=20model=20policy,=20fallback,=20attachments?= =?UTF-8?q?,=20session=20pagination?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add unified model policy (model.policy.v1): primary=glm-5.2, multimodal=kimi-k2.7-code (temperature=1), fallback=deepseek-v4-pro - Add fallback support in conversation runtime for recoverable model errors (timeout, 5xx, rate limit, model unavailable, quota/permission) - Add request-level model hot-switch via contextvars - Add model_options passthrough (temperature, top_p, max_tokens, max_completion_tokens) - Update all CLI commands (hermes, openclaw, config, create) to glm-5.2 - Remove hardcoded kimi-k2.6 fallback from Hermes - Add ae-upload:// attachment support with hosted download and local cache - Add session list pagination (Total/Page/PageSize) and event windowing (Offset/Limit/Total) with count_sessions/count_events - Add shared terminal exec allowlist policy - Fix accepted memory saves treated as completed tool receipts - Update docs: CHANGELOG, README, remote agent runtime interface docs - Bump version to 0.6.6 - Requires ksadk-web@0.2.10 (published on npm) --- CHANGELOG.md | 104 +- README.md | 190 +- ...45\345\217\243\350\257\264\346\230\216.md" | 2034 +++++++++++++++++ ksadk/api/__init__.py | 4 +- ksadk/api/client.py | 332 ++- ksadk/cli/cmd_config.py | 42 +- ksadk/cli/cmd_create.py | 43 +- ksadk/cli/cmd_hermes.py | 66 +- ksadk/cli/cmd_openclaw.py | 78 +- ksadk/configs/__init__.py | 12 +- ksadk/configs/global_config.py | 2 +- ksadk/configs/settings.py | 131 +- ksadk/conversations/attachment_storage.py | 379 +++ ksadk/conversations/attachments.py | 14 +- ksadk/conversations/model_options.py | 7 + ksadk/conversations/runtime.py | 723 +++++- ksadk/model_policy.py | 219 ++ ksadk/server/app.py | 549 ++++- ksadk/sessions/base.py | 14 + ksadk/sessions/in_memory.py | 23 +- ksadk/sessions/local_service.py | 79 +- ksadk/sessions/postgres_service.py | 41 +- ksadk/version.py | 2 +- pyproject.toml | 19 +- tests/snapshots/help_snapshots.txt | 350 +++ tests/test_attachment_storage.py | 169 ++ tests/test_cli_dry_run.py | 7 +- tests/test_client_workspace_files.py | 473 ++++ tests/test_cmd_config_wizard.py | 108 + tests/test_cmd_hermes.py | 82 +- tests/test_conversation_runtime.py | 922 +++++++- tests/test_model_policy.py | 56 + tests/test_openclaw_env_vars.py | 447 ++++ tests/test_patch_langchain.py | 89 + tests/test_public_release_positioning.py | 29 +- tests/test_server_session_app.py | 1053 +++++++++ tests/test_unified_agent_ui_local.py | 291 +-- uv.lock | 312 +-- 38 files changed, 8603 insertions(+), 892 deletions(-) create mode 100644 "docs/\350\277\234\347\250\213Agent\350\277\220\350\241\214\346\227\266\346\216\245\345\217\243\350\257\264\346\230\216.md" create mode 100644 ksadk/conversations/attachment_storage.py create mode 100644 ksadk/model_policy.py create mode 100644 tests/snapshots/help_snapshots.txt create mode 100644 tests/test_attachment_storage.py create mode 100644 tests/test_client_workspace_files.py create mode 100644 tests/test_cmd_config_wizard.py create mode 100644 tests/test_model_policy.py create mode 100644 tests/test_openclaw_env_vars.py create mode 100644 tests/test_patch_langchain.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c98db6c..8c7976f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,66 +5,80 @@ 格式参考 [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 版本遵循 [Semantic Versioning](https://semver.org/spec/v2.0.0.html)。 -## [0.6.5] - 2026-06-15 - -### 重点 +## [0.6.6] - 2026-06-18 -- **UI 真源收敛到 npm 包**:`@kingsoftcloud/ksadk-web@0.2.8` 已作为 npm `latest` 发布;`agentengine web` 静态资源默认从该 npm 包的 `dist-ksadk` 同步,`ksadk-python` 源码分支不再跟踪生成的 `ksadk/server/static/**` 或旧 `ksadk/server/web-ui/**` 副本。 -- **PyPI Trusted Publishing**:新增 GitHub Actions PyPI Trusted Publishing workflow,发布前同步 KSADK Web static、运行 `make public-preflight`,再通过 OIDC 上传到 PyPI,不依赖长期 PyPI token。 -- **发布包自包含 static**:PyPI wheel 构建前会同步 `dist-ksadk` 并把 `ksadk/server/static/index.html` 与静态资源打入 wheel;源码 checkout 缺少 static 时只提示运行同步命令,不在运行时联网拉 npm。 -- **知识库/长期记忆 SDK 更新**:`ksadk[kb]` 中的 `kingsoftcloud-sdk-python` 依赖下限提升到 `1.5.8.94`,用于适配最新知识库和长期记忆 SDK 返回结构。 -- **长期记忆查询兼容修复**:内置 `load_memory` 查询解析兼容 SDK 返回的 `Memory` 字段,避免记忆已写入但查询结果为空。 -- **Sandbox 稳定性兜底**:E2B sandbox 创建后增加命令与文件系统 readiness 探测,并对短暂 `NotFoundException` / `FileNotFoundException` 做指数退避重试,降低首次调用 `run_code` / `run_command` 的偶发失败。 -- **会话连续性与长任务恢复增强**:runner payload 增加 `invocation_id`,LangGraph checkpoint resume 保留 `checkpoint_ns`,checkpoint event 透传业务阶段、摘要、下一步动作和状态,便于 UI 恢复长任务语义。 -- **部署环境变量边界修复**:`agentengine deploy` 和 `agentengine launch` 支持 `--env` / `--env-file` 显式传入运行时环境变量;真实 `.env` / `.env.local` 不再进入 Code、Container 或 MCP 构建上下文,模板文件继续保留。 +### 亮点 -### 修复 +- **统一模型策略 v1**:新增 `AGENTENGINE_MODEL_POLICY_JSON` 运行时策略契约,默认主模型为 `glm-5.2`,多模态模型为 `kimi-k2.7-code`,fallback 模型为 `deepseek-v4-pro`,为 Hermes、OpenClaw 和通用 Agent 提供同一套默认模型语义。 +- **通用 Agent fallback**:conversation runtime 对超时、限流、5xx、模型不可用、权限/配额等可恢复模型错误支持 fallback 重试;普通 400 参数错误、业务错误和 tool 错误不会被吞掉。 +- **运行时附件与 Hosted 附件打通**:本地 `ksadk-upload://` 与服务端 `ae-upload://` 上传文件统一解析,支持通过 KOP Action 下载 Hosted 附件内容、恢复本地缓存,并在会话/浏览器刷新后继续读取文件。 +- **会话列表与事件分页增强**:Session service 新增 `count_sessions` / `count_events`,`ListSessions` 返回 `Total/Page/PageSize`,`ListSessionEvents` 支持 `Offset/Limit/Total`,便于 UI 恢复长任务和历史事件。 +- **Hermes 终端执行策略收敛**:抽出共享 terminal exec allowlist policy,OpenClaw/Hermes 终端命令校验共用同一匹配逻辑,简化 allowlist 配置并降低误放行风险。 -- `/v1/responses`、`/v1/chat/completions` 和 `RunAgentAction` 支持透传 `account_id` / `AccountId`,并写入 `PlatformInvocationContext`,便于运行时能力按账号边界读取当前调用上下文。 -- 新增 `get_current_invocation_context_or_default()`、`get_current_user_id()` 和 `get_current_account_id()`,工具或业务代码可在当前 turn 内安全读取用户和账号上下文;无调用上下文时返回显式默认值。 -- 修复 LangGraph checkpoint resume 丢失 namespace、checkpoint 列表缺少业务阶段字段的问题。 -- 修复 Python 3.10 环境中 workspace router 误用 Python 3.11 `datetime.UTC` 的兼容性问题。 +### 变更 -### 构建与发布 +- 默认模型从 `glm-5.1` 升级为 `glm-5.2`,配置向导、项目模板、README、dry-run/help snapshot 和 `DEFAULT_MODEL_NAME` 同步更新。 +- `kimi-k2.7-code` 默认模型参数补齐 `temperature=1`,并透传到 Chat Completions / Responses 的 `temperature`、`top_p`、`max_tokens`、`max_completion_tokens` 等 model options。 +- OpenClaw 默认 catalog 包含 `glm-5.2`、`kimi-k2.7-code` 和 `deepseek-v4-pro`;图像场景默认优先使用 `kimi-k2.7-code`,显式 `OPENCLAW_MODEL_CATALOG_JSON` 仍保持 catalog 首项优先。 +- Hermes deploy 与 OpenClaw deploy 均会注入同一模型策略 env,保留 `OPENAI_MODEL_NAME`、`MODEL_NAME`、`OPENCLAW_DEFAULT_MODEL`、`HERMES_DEFAULT_MODEL`、`HERMES_FALLBACK_MODEL` 等显式覆盖。 +- Hermes 本地默认主模型更新为 `glm-5.2`,不再按 KSPMAS / `glm-5.1` 硬编码 fallback 到 `kimi-k2.6`,fallback 改由统一策略或显式 env 决定。 +- OpenClaw provider catalog 合并逻辑支持在已有 `OPENCLAW_MODEL_CATALOG_JSON` 上补齐 provider metadata,避免请求级 catalog 被平台默认值覆盖。 +- `AgentEngineClient` 新增 `AttachmentContent` 与 `download_attachment_content()`,并修正 `list_sessions()` 请求字段为 `PageSize`。 +- runtime 上传附件会持久化 metadata、本地路径和 MIME 信息;Hosted 附件下载后会写回本地 cache,供 runner、workspace preview 和会话恢复复用。 -- `make sync-ksadk-web-static` 默认改为 `npm pack @kingsoftcloud/ksadk-web@latest`,并保留 `KSADK_WEB_RELEASE_URL` 显式 tarball 兜底。 -- `public-build-check` 在 `uv build` 和 `twine check` 之间增加 wheel 内容检查,确保旧 `ksadk/server/web-ui/` 源码、`node_modules` 和历史构建产物不会混入发布包。 -- Code、Container、MCP 构建统一排除真实 `.env*`,只保留 `.env.example` / `.env.sample` / `.env.template` 这类模板文件。 +### 修复 -### 发布治理 +- 修复长期记忆 save 返回 `accepted` 时没有被视为完成 tool receipt,导致 conversation runtime 误判工具调用未完成的问题。 +- 修复 ADK 短期记忆与运行时附件连续性,避免上传文件、memory context 和 runner payload 在多轮会话中丢失。 +- 修复终端执行 allowlist 匹配过复杂、容易误判的问题,统一按共享策略做命令匹配与错误提示。 +- 修复 Hosted UI 上传文件在本地 runtime 中只能看到 `ae-upload://` 引用、无法读取真实内容的问题。 +- 修复 session/event 列表缺少总数和分页字段,导致 UI 无法稳定展示历史会话、历史事件或长任务恢复状态的问题。 -- 0.6.5 通过 GitHub Release 和 PyPI Trusted Publishing 发布,发布包在构建时同步 `@kingsoftcloud/ksadk-web@latest` 静态资源。 -- 公开 CHANGELOG 不记录非公开环境名、内网 endpoint、真实账号、真实 Skill Space ID 或临时凭证;这些只允许出现在内部联调记录里。 +### 测试与发布 -## [0.6.4] - 2026-06-10 +- 新增模型策略、fallback、流式 fallback、OpenClaw env、Hermes env、LangChain patch、附件恢复、session 分页、Hosted UI 上传文件和终端 allowlist 覆盖测试。 +- 公开发布版本从 `0.6.5` 升级到 `0.6.6`,发布包继续通过 `make public-preflight` 同步 `@kingsoftcloud/ksadk-web@latest` 静态资源并执行 wheel 内容检查;本次发布应先完成 `@kingsoftcloud/ksadk-web@0.2.10` 的 npm release。 +- `make public-preflight` 已覆盖 secret audit、public path audit、全量 pytest、sdist/wheel build 和 `twine check`;0.6.6 wheel/sdist 检查通过。 +- 这是 Hermes/OpenClaw 默认镜像重建前置版本;镜像构建应固定 `KSADK_PACKAGE_SPEC=ksadk==0.6.6`,再走 staging E2E、GitHub Actions / PyPI Trusted Publishing 和环境门禁。 -### 重点 - -- **项目定位重构**:将 README、文档首页和包元数据统一为 Agent Runtime Platform 口径,突出统一运行、浏览器调试、OpenAI-Compatible API、Sandbox、部署和可观测价值。 -- **中文优先首页**:默认 README 与中文文档首页使用中文主叙述,英文 README 作为补充入口保留,避免公开首页变成英文优先材料。 -- **README 简洁化**:README 聚焦项目定位、30 秒上手、真实截图/GIF、核心能力和贡献入口;版本变更迁回 CHANGELOG 与 GitHub Releases,避免首页承担发布公告职责。 -- **真实视觉资产**:首页首屏使用真实浅色 CLI 截图,30 秒体验后展示真实本地 Web UI 截图和 GIF;演示由本地 deterministic LangGraph Runner 生成,不依赖外部模型或云环境。 -- **文档信息架构调整**:MkDocs 导航改为 Getting Started / Build / Run / Deploy / Observe / Extend / Reference,并新增 Why KsADK、Architecture、Comparison 三个认知入口页。 -- **Samples 场景入口对齐**:`ksadk-samples` 根 README 改为场景优先,真实映射 Knowledge Assistant、Workflow Agent、Tool-Using Agent 和 Memory-aware Agent;尚未实现的场景只进入 Roadmap。 -- **默认线上地域说明**:公开 README 和文档首页使用 `KSYUN_REGION=cn-beijing-6` 作为线上默认 region 示例,避免用户把非公开或内网配置照搬到公开 demo。 +## [0.6.5] - 2026-06-15 -### 修复 +### 亮点 -- 新增 `ksadk.markdown.repair_markdown(text, enabled=True)` 可选业务侧 Markdown 形态修复工具,覆盖未闭合 fenced code block、列表/表格/代码块周边空行和换行归一化;默认关闭,运行时不自动改写 raw LLM output。用法见文档 [Agent 最佳实践 / Markdown 输出修复](public-docs/guides/agent-best-practices.md#markdown-输出修复)。 -- 清理 README、CHANGELOG、文档首页和 runtime product 文档中的环境特定表述,避免公开页面出现内部环境名、内部 header 或私有 endpoint 示例。 -- 将公开定位、视觉资产存在性、中文优先标题、敏感词扫描和 README 场景入口要求纳入本地门禁,降低后续发布材料回退风险。 -- 将 `agentengine-sdk-python` 别名包版本占用检查纳入 `public-publish-check`,避免主包与别名包发布状态不一致。 +- **公开定位与正式发布收敛**:将项目首页、README、PyPI metadata 和公开发布说明统一为 Agent Runtime Platform 口径,并将正式发布版本推进到 `0.6.5`。 +- **Hosted/local UI 真源收敛**:`@kingsoftcloud/ksadk-web@0.2.8` 已发布到 npm,本地 `agentengine web` 静态资源默认从 `@kingsoftcloud/ksadk-web@latest` 的 `dist-ksadk` 同步;共享 UI 源码只在 `ksadk-web` 维护,`ksadk-python` 源码分支不再跟踪生成的 `ksadk/server/static/**`。 +- **部署环境变量边界修复**:`agentengine deploy` 和 `agentengine launch` 新增 `--env` / `--env-file`,显式传入的运行时环境变量进入部署 payload;真实 `.env` / `.env.local` 不再打入 Code、Container 或 MCP 构建上下文,`.env.example` 继续保留。 +- **Sandbox 稳定性兜底**:E2B sandbox 创建后增加命令与文件系统 readiness 探测,默认对短暂 `NotFoundException` / `FileNotFoundException` 做指数退避重试,降低 Pod 内首次调用 `run_code` / `run_command` 的偶发失败。 +- **会话连续性与长任务恢复增强**:runner payload 增加 `invocation_id`,LangGraph checkpoint resume 保留 `checkpoint_ns`,checkpoint event 透传业务阶段、摘要、下一步动作和状态,便于 Hosted/local UI 恢复长任务语义。 +- **长期记忆查询兼容修复**:SDK LTM 查询解析兼容上游返回的 `Memory` 字段,避免记忆已写入但 `load_memory` 查询为空。 +- **镜像仓库凭证语义修复**:个人版 KCR 继续允许 `KSYUN_ACCOUNT_ID` 兜底用户名;企业版 KCR 和第三方 registry 必须显式设置 `KCR_USERNAME` / `KCR_PASSWORD`,避免错误把云账号 ID 当企业镜像用户名。 +- **公开门禁增强**:发布构建新增 wheel 内容检查,禁止旧 `ksadk/server/web-ui/` 源码或构建产物残留进入 PyPI wheel;源码仓库同样不再跟踪本地 UI 副本,并继续执行公开定位、敏感词扫描、PyPI metadata、README 和测试门禁。 -### 发布治理 +### 发布说明 -- 这是修复 0.6.3 公开页面和 PyPI 元数据口径的补丁版本;已发布到 PyPI 的 0.6.3 元数据不可覆盖,因此通过新版本修复。 -- GitHub Release 页面必须保留历史版本 `v0.6.1`、`v0.6.2` 和 `v0.6.3`;0.6.4 只能新增 release,不能清理历史条目。 -- 公开 CHANGELOG 不记录非公开环境名、内网 endpoint、真实账号、真实 Skill Space ID 或临时凭证;这些只允许出现在内部联调记录里。 +- 这是修复 0.6.3 公开页面和 PyPI 元数据口径的正式补丁版本;已发布到 PyPI 的 0.6.3 元数据不可覆盖,因此通过 0.6.5 发布修复。 +- 0.6.5 通过 GitHub Release 和 PyPI Trusted Publishing 发布,发布包在构建时同步 `@kingsoftcloud/ksadk-web@latest` 静态资源。 +- `@kingsoftcloud/ksadk-web@0.2.8` 已作为 npm `latest` 发布;`agentengine-hosted-ui` 和 `ksadk-python` 后续默认从 npm release 消费,不再维护共享 UI 源码或本地 tarball。 ### 运行时修复 - `/v1/responses`、`/v1/chat/completions` 和 `RunAgentAction` 支持透传 `account_id` / `AccountId`,并写入 `PlatformInvocationContext`,便于 Skill、Workspace、Sandbox、Memory 等运行时能力按账号边界读取当前调用上下文。 - 新增 `get_current_invocation_context_or_default()`、`get_current_user_id()` 和 `get_current_account_id()`,工具或业务代码可在当前 turn 内安全读取用户和账号上下文;无调用上下文时返回显式默认值。 +- 修复 dashboard/open/share 在全局配置注入 `KSYUN_REGION` 时可能覆盖当前 `.agentengine.state` region 的问题;命令行显式 region 仍优先。 +- 修复 LangGraph checkpoint resume 丢失 namespace、checkpoint 列表缺少业务阶段字段的问题。 +- 修复 LTM SDK 查询解析不能识别 `Memory` 字段的问题。 +- 修复 E2B sandbox 创建后立即执行命令或写文件时可能遇到短暂 NotFound 的问题。 +- 修复 Python 3.10 环境中 workspace router 误用 Python 3.11 `datetime.UTC` 的兼容性问题。 + +### 构建与发布修复 + +- `make sync-ksadk-web-static` 默认改为 `npm pack @kingsoftcloud/ksadk-web@latest`,并保留 `KSADK_WEB_RELEASE_URL` 显式 tarball 兜底。 +- 新增 GitHub Actions PyPI Trusted Publishing workflow:发布前同步 KSADK Web static、执行 `make public-preflight`,再通过 OIDC 上传到 PyPI,不再依赖长期 PyPI token。 +- PyPI wheel 构建前会同步 `dist-ksadk` 并把 `ksadk/server/static/index.html` 与静态资源打入 wheel;源码 checkout 缺少 static 时只提示运行同步命令,不在运行时联网拉 npm。 +- `public-build-check` 在 `uv build` 和 `twine check` 之间增加 wheel 内容检查,确保旧 `ksadk/server/web-ui/` 源码、`node_modules` 和历史构建产物不会混入发布包。 +- 知识库/长期记忆 SDK 依赖下限提升到 `kingsoftcloud-sdk-python>=1.5.8.94`,用于适配最新 SDK LTM/KB 返回结构与运行时打包要求。 +- Code、Container、MCP 构建统一排除真实 `.env*`,只保留 `.env.example` / `.env.sample` / `.env.template` 这类模板文件。 +- Container、MCP、OpenClaw、Serverless 镜像凭证解析统一企业版/个人版/第三方 registry 边界,避免生成错误鉴权 payload。 ## [0.6.3] - 2026-06-09 @@ -251,8 +265,8 @@ - Dashboard private 链接服务端默认有效期调整为 24 小时;CLI 在未显式传入 `--expires-seconds` 时交给服务端默认处理,以兼容尚未升级的控制面,OpenClaw / Hermes 默认仍进入 `/chat`。 - `agentengine create` 的快速开始提示按 Windows PowerShell / cmd.exe 与 POSIX shell 分别生成可复制命令,包含空格的项目目录会被正确 quoting。 - Docker daemon 未运行时,Windows 环境提示用户启动 Docker Desktop,不再输出 Linux `systemctl` 提示。 -- Hermes 默认镜像更新为 `ghcr.io/kingsoftcloud/hermes-agent:2026.5.16-ksadk-v1`,上游 Hermes ref 保持 `v2026.5.16`。 -- OpenClaw 默认镜像更新为 `ghcr.io/kingsoftcloud/openclaw:2026.5.20`,基础镜像 pin 到 `ghcr.io/openclaw/openclaw:2026.5.20-slim@sha256:db199be23add581ef18ca8c8a866af84db13586d5bfcd566c8ac73d8d106eebb`。 +- Hermes 默认镜像更新为 `hub.kce.ksyun.com/agentengine-public/hermes-agent:2026.5.16-ksadk-v1`,上游 Hermes ref 保持 `v2026.5.16`。 +- OpenClaw 默认镜像更新为 `hub.kce.ksyun.com/agentengine-public/openclaw:2026.5.20`,基础镜像 pin 到 `ghcr.io/openclaw/openclaw:2026.5.20-slim@sha256:db199be23add581ef18ca8c8a866af84db13586d5bfcd566c8ac73d8d106eebb`。 - `deploy/openclaw-user-template` 及示例模板默认基础镜像同步升级到 OpenClaw `2026.5.20-slim`。 - 文档补充 hosted UI 生产路由与独立部署说明;`ksadk-python` 继续保留本地 SDK UI 静态资源,生产 hosted UI 由独立服务发布。 @@ -440,7 +454,7 @@ - OpenClaw 部署新增 `OPENCLAW_CHANNEL_BOOTSTRAP_JSON`、Agentspace bootstrap 配置与 `OPENCLAW_BROWSER_SSRF_POLICY_JSON` 透传,便于渠道预配置和内网访问策略收口。 - 新增 `agentengine openclaw repair`,并支持 `agentengine openclaw gateway doctor --fix` 通过控制面直接触发 `doctor-fix` 修复动作。 - code mode 构建新增 Linux Runtime 兼容性 / ABI 校验,关键原生扩展不兼容时会在打包阶段提前失败。 -- 默认 Hermes 共享 runtime 镜像更新为 `ghcr.io/kingsoftcloud/hermes-agent:2026.4.23`,并把构建默认 `HERMES_AGENT_REF` 同步到上游 `v2026.4.23`。 +- 默认 Hermes 共享 runtime 镜像更新为 `hub.kce.ksyun.com/agentengine-public/hermes-agent:2026.4.23`,并把构建默认 `HERMES_AGENT_REF` 同步到上游 `v2026.4.23`。 - 默认 OpenClaw 基础镜像 pin 到官方 `ghcr.io/openclaw/openclaw:2026.4.24@sha256:7c4370ff8777555d4c9fe5ab821aaaad7c87188d389a6cf761270725d96ec3e9`,同步刷新自定义镜像模板和一键部署文档。 ### 修复 diff --git a/README.md b/README.md index 80e94f5e..e5ff1d79 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,29 @@ -

KsADK

+# KsADK -

一次构建 Agent,到处运行。Build agents once. Run them anywhere.

+[![zread](https://img.shields.io/badge/Ask_Zread-_.svg?style=flat&color=00b0aa&labelColor=000000&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTQuOTYxNTYgMS42MDAxSDIuMjQxNTZDMS44ODgxIDEuNjAwMSAxLjYwMTU2IDEuODg2NjQgMS42MDE1NiAyLjI0MDFWNC45NjAxQzEuNjAxNTYgNS4zMTM1NiAxLjg4ODEgNS42MDAxIDIuMjQxNTYgNS42MDAxSDQuOTYxNTZDNS4zMTUwMiA1LjYwMDEgNS42MDE1NiA1LjMxMzU2IDUuNjAxNTYgNC45NjAxVjIuMjQwMUM1LjYwMTU2IDEuODg2NjQgNS4zMTUwMiAxLjYwMDEgNC45NjE1NiAxLjYwMDFaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00Ljk2MTU2IDEwLjM5OTlIMi4yNDE1NkMxLjg4ODEgMTAuMzk5OSAxLjYwMTU2IDEwLjY4NjQgMS42MDE1NiAxMS4wMzk5VjEzLjc1OTlDMS42MDE1NiAxNC4xMTM0IDEuODg4MSAxNC4zOTk5IDIuMjQxNTYgMTQuMzk5OUg0Ljk2MTU2QzUuMzE1MDIgMTQuMzk5OSA1LjYwMTU2IDE0LjExMzQgNS42MDE1NiAxMy43NTk5VjExLjAzOTlDNS42MDE1NiAxMC42ODY0IDUuMzE1MDIgMTAuMzk5OSA0Ljk2MTU2IDEwLjM5OTlaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik0xMy43NTg0IDEuNjAwMUgxMS4wMzg0QzEwLjY4NSAxLjYwMDEgMTAuMzk4NCAxLjg4NjY0IDEwLjM5ODQgMi4yNDAxVjQuOTYwMUMxMC4zOTg0IDUuMzEzNTYgMTAuNjg1IDUuNjAwMSAxMS4wMzg0IDUuNjAwMUgxMy43NTg0QzE0LjExMTkgNS42MDAxIDE0LjM5ODQgNS4zMTM1NiAxNC4zOTg0IDQuOTYwMVYyLjI0MDFDMTQuMzk4NCAxLjg4NjY0IDE0LjExMTkgMS42MDAxIDEzLjc1ODQgMS42MDAxWiIgZmlsbD0iI2ZmZiIvPgo8cGF0aCBkPSJNNCAxMkwxMiA0TDQgMTJaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00IDEyTDEyIDQiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPgo8L3N2Zz4K&logoColor=ffffff)](https://zread.ai/kingsoftcloud/ksadk-python) -

- KsADK 是面向 AI Agent 的运行时平台(Agent Runtime Platform)。 - 继续使用 Google ADK、LangGraph、LangChain 或 DeepAgents 编写业务 Agent,再用统一 CLI、Web UI、OpenAI-Compatible API、工具运行时、沙箱、部署和可观测链路把它跑起来。 -

+Build agents once. Run them anywhere. -

简体中文(默认) · English

+KsADK 是面向 AI Agent 的 Agent Runtime Platform。你可以继续使用 Google ADK、LangGraph、LangChain 或 DeepAgents 编写业务 Agent,再用 KsADK 获得统一的本地运行、浏览器调试、OpenAI-Compatible API、沙箱执行、部署和可观测体验。 -

- Docs - PyPI - Ask Zread - License -

+发布版本:`0.6.6`(通过 GitHub Release 和 PyPI Trusted Publishing 发布)。 -

KsADK 真实 CLI 截图:agentengine -h

+## Why KsADK + +Most agent frameworks solve agent development. + +KsADK solves agent runtime. + +KsADK 不替换你已经选择的 Agent 框架,而是在框架之上补齐运行时平台层: + +- Development:统一 `agentengine init`、`agentengine config`、`agentengine run`。 +- Debugging:本地 Web UI、会话、附件、workspace 文件和流式输出。 +- Runtime:统一 Runner、OpenAI-Compatible API 和多框架入口。 +- Sandbox:Skill Runtime、Workspace 和 sandbox backend 的隔离执行边界。 +- Deployment:Serverless、Hermes、OpenClaw 和远端 AgentEngine 入口。 +- Observability:OpenTelemetry-first tracing,可对接多种观测后端。 + +Keep using your preferred framework. Get a complete runtime platform. ## 30 秒快速体验 @@ -31,59 +38,150 @@ agentengine config set OPENAI_API_KEY=your-api-key OPENAI_MODEL_NAME=gpt-4o-mini agentengine run -i ``` -如果需要调用金山云 AgentEngine、Skill Service、知识库或长期记忆等线上能力,建议显式设置线上默认地域: +打开本地浏览器调试界面: ```bash -agentengine config set KSYUN_REGION=cn-beijing-6 +agentengine web . --no-open ``` -启动本地调试 Web UI: +如果你的模型服务不是默认 OpenAI endpoint,再额外配置: ```bash -agentengine web . --no-open +agentengine config set OPENAI_BASE_URL=https://api.example.com/v1 +``` + +如果需要调用金山云 AgentEngine、Skill Service、知识库或长期记忆等线上能力,建议显式设置线上默认地域: + +```bash +agentengine config set KSYUN_REGION=cn-beijing-6 ``` -

KsADK 真实 Web UI 调试截图

+## Architecture + +```text +Agent Code + ADK / LangGraph / LangChain / DeepAgents + | + v +KsADK SDK + runner adapters / config / toolsets + | + v +Unified Runtime + CLI / Web UI / OpenAI-Compatible API + | + +-- Skill Runtime + +-- Workspace Tools + +-- Sandbox Runtime + +-- Memory & Knowledge + | + v +AgentEngine + Serverless / Hermes / OpenClaw Runtime +``` -

KsADK 真实本地 Web UI 演示

+## Supported Frameworks -## 为什么需要 KsADK +| Framework | KsADK 负责什么 | +| --- | --- | +| Google ADK | 项目模板、Runner 适配、本地运行、Web UI 调试和部署入口。 | +| LangGraph | 图状态入口、工具调用、streaming、Skill Runtime 和 workspace toolsets。 | +| LangChain | Runnable/chain 适配、本地 OpenAI-Compatible API 和 tracing。 | +| DeepAgents | 项目入口、运行时包装、浏览器调试和部署制品。 | -大多数 Agent 框架解决“如何开发 Agent”。KsADK 解决“如何运行、调试、部署和观测 Agent”。 +## Comparison -- 本地开发:`agentengine init`、`agentengine run`、`agentengine web`。 -- 统一调试:浏览器 Web UI、streaming、附件、workspace 文件、工具调用和会话。 -- 统一协议:本地 `/v1/responses` 与 `/v1/chat/completions`。 -- 工具边界:Skill Runtime、Workspace、Sandbox、Memory、Knowledge。 -- 工程链路:打包、部署、OpenTelemetry 可观测。 +| Capability | ADK | LangGraph | OpenAI Agents SDK | KsADK | +| --- | --- | --- | --- | --- | +| Agent Development | Yes | Yes | Yes | Yes | +| Browser Debugging UI | No | No | No | Yes | +| Unified CLI | No | No | No | Yes | +| OpenAI Compatible API | No | No | Partial | Yes | +| Sandbox Runtime | No | No | No | Yes | +| Deployment Workflow | No | No | No | Yes | +| Multi Runtime Backend | No | No | No | Yes | -## 架构 +这张表只比较“项目自带的统一运行时平台能力”。KsADK 的设计目标不是替代这些框架,而是把它们放进同一套运行、调试、部署和观测体验里。 -

KsADK Agent Runtime Platform 架构

+## Core Capabilities -## 文档与样例 +- `agentengine init`:创建或导入 Agent 项目。 +- `agentengine config`:管理 `.env` 和 `agentengine.yaml`。 +- `agentengine run`:本地终端运行和交互调试。 +- `agentengine web`:启动本地 Web UI,验证 streaming、附件、workspace、工具调用和会话。 +- `/v1/responses` 与 `/v1/chat/completions`:提供 OpenAI-Compatible API。 +- `ksadk.toolsets`:提供 Skill、Workspace、Platform、Sandbox 内置工具。 +- Skill Runtime:发现、下载、校验、加载并隔离执行 Skill workflow。 +- Sandbox Runtime:通过可配置后端隔离执行命令或代码。 +- Hermes & OpenClaw:面向更完整 runtime 后端的部署和更新路径。 -- 文档: -- 快速开始: -- 为什么需要 KsADK: -- 架构: -- 生态定位对比: -- 可观测: -- 样例仓库: +## Examples -## 相关项目 +样例仓库按场景组织,而不是只按技术框架分类: -- KsADK 仓库: -- Web UI 仓库: -- Wiki: -- PyPI: +- [KSADK Samples](https://github.com/kingsoftcloud/ksadk-samples) +- Knowledge Assistant:知识库问答和 RAG。 +- Workflow Agent:LangGraph + AgentEngine toolsets。 +- Tool-Using Agent:自定义工具调用。 +- Memory-aware Agent:短期记忆和长期记忆接入。 -## 参与贡献 +每个公开 demo 都应包含中文 README、运行命令、环境变量说明、降级行为和验证问题。 -欢迎通过 issue、PR、样例和文档改进参与贡献。提交前建议运行: +## Deployment + +KsADK 支持本地优先的开发路径,也提供经过审核后可使用的部署入口: + +```bash +agentengine build . +agentengine launch . --target serverless +agentengine dashboard open +``` + +Hermes 和 OpenClaw 更新已有实例时默认保留服务端已有 env、storage、network、memory 配置,只在显式传入对应 CLI 参数时覆盖,避免升级镜像时误改用户配置。 + +## Observability + +KsADK is OpenTelemetry-native. ```bash -make public-preflight +OTEL_EXPORTER_OTLP_ENDPOINT=https://otel.example.com +OTEL_EXPORTER_OTLP_HEADERS=Authorization=Bearer%20token ``` -开源协议:Apache-2.0。 +Compatible with: + +- Langfuse +- Arize +- Datadog +- Grafana +- Phoenix + +Export once. Observe anywhere. + +## 0.6.6 重点 + +- 统一模型策略 v1:默认主模型 `glm-5.2`、多模态模型 `kimi-k2.7-code`、fallback 模型 `deepseek-v4-pro`,Hermes、OpenClaw 和通用 Agent 使用同一套默认语义。 +- Hosted 附件内容恢复:本地 runtime 现在可以直接消费服务端 `ae-upload://...` 文件引用,并通过 `AttachmentContent` 恢复真实文件内容与本地缓存。 +- 会话与历史事件增强:`ListSessions` / `ListSessionEvents` 补齐分页与总数字段,本地 Web UI 支持更长历史会话列表和按需回加载旧事件。 +- 将公开定位从普通 SDK 调整为 Agent Runtime Platform,首页补齐 Why KsADK、30 秒体验、架构说明、Deployment、Observability 和 Community。 +- 清理 README、CHANGELOG 和后续 PyPI 元数据中的环境特定表述,避免公开页面出现内部环境名、内部 header 或私有 endpoint 示例。 +- 为公开定位、敏感词和 PyPI metadata 增加回归测试,防止后续回退。 +- 本地 Web UI static 默认从 `@kingsoftcloud/ksadk-web@latest` 的 `dist-ksadk` 同步;共享 UI 源码只在 `ksadk-web` 维护。 +- PyPI 发布默认走 GitHub Actions Trusted Publishing,发布前同步最新 KSADK Web static 并执行 `make public-preflight`。 + +## Documentation + +- 文档: +- 中文文档: +- English documentation: +- 命令行参考: +- OpenAI-Compatible API: + +## Community + +- 仓库: +- Wiki: +- 示例仓库: +- Web UI 仓库: +- PyPI: +- 开源协议:Apache-2.0 diff --git "a/docs/\350\277\234\347\250\213Agent\350\277\220\350\241\214\346\227\266\346\216\245\345\217\243\350\257\264\346\230\216.md" "b/docs/\350\277\234\347\250\213Agent\350\277\220\350\241\214\346\227\266\346\216\245\345\217\243\350\257\264\346\230\216.md" new file mode 100644 index 00000000..f3ff3266 --- /dev/null +++ "b/docs/\350\277\234\347\250\213Agent\350\277\220\350\241\214\346\227\266\346\216\245\345\217\243\350\257\264\346\230\216.md" @@ -0,0 +1,2034 @@ +# 远程Agent运行时接口说明 + +本文档基于当前 `master` 分支的真实代码实现整理,目标是说明: + +- Agent 部署到远程 K8s / Serverless Pod 之后,最终通过 `PublicEndpoint` 对外暴露哪些接口 +- 不同运行时类型的接口差异:通用 Agent、Hermes、OpenClaw +- 公共鉴权、公共 Header、流式行为、WebSocket 约束 +- 各接口的请求体 / 响应体 shape + +本文档只把当前代码里可以确认的 contract 写出来;对仓库中未完整定义、但依赖上游项目的 OpenClaw 原生接口,不做超出代码证据的推断。 + +## 1. 事实来源 + +本文档主要依据以下代码与文档: + +- `agentengine-server/app/api/v1/actions/agent_actions.py` +- `agentengine-server/app/api/v1/actions/chat_actions.py` +- `agentengine-server/app/api/v1/actions/feedback_actions.py` +- `agentengine-server/app/gateway/api.py` +- `agentengine-server/app/gateway/router_service.py` +- `agentengine-server/docs/技术设计.md` +- `agentengine-server/docs/网关鉴权说明.md` +- `ksadk-python/ksadk/server/app.py` +- `ksadk-python/ksadk/server/api_models.py` +- `ksadk-python/ksadk/conversations/runtime.py` +- `ksadk-python/ksadk_runtime_common/workspace_files/*.py` +- `ksadk-python/deploy/hermes/runtime/app.py` +- `ksadk-python/deploy/hermes/README.md` +- `ksadk-python/deploy/openclaw/bootstrap.sh` +- `ksadk-python/deploy/openclaw-user-template/Dockerfile` + +## 2. 入口模型 + +### 2.1 公网入口 + +远程 Agent 部署成功后,控制面 `GetAgent` 会返回: + +- `QuickAccess.PublicEndpoint` + +这个地址就是外部调用运行时接口时应使用的根地址。例如: + +```text +http://ar-20260506162108-d30283cd.agent-pre.kspmas.ksyun.com +``` + +说明: + +- 对外看到的是 `PublicEndpoint` +- 实际请求先进入 Ingress / Gateway,再由 `agentengine-server` 的 router 做鉴权和转发 +- 因此“部署后暴露的接口”应以公网入口经过网关后可访问的路径为准,而不是简单把 Pod 内部监听端口当成外部 contract + +### 2.2 内网入口 + +`GetAgent` 也可能返回: + +- `QuickAccess.PrivateEndpoint` + +这类地址用于内网访问,不作为本文主线。本文默认描述通过 `PublicEndpoint` 暴露的接口。 + +## 3. 鉴权与公共 Header + +## 3.1 外部访问鉴权 + +当前数据面统一通过网关校验,外部调用主要有两种认证方式: + +1. `Authorization: Bearer ` +2. `ae_ui_session` Cookie + +其中: + +- API/SDK/CLI 直连运行时接口时,使用 `Authorization: Bearer ` +- 浏览器经 dashboard share link 或 hosted UI 访问时,通常使用 `ae_ui_session` Cookie + +代码证据: + +- `agentengine-server/docs/网关鉴权说明.md` +- `agentengine-server/app/gateway/api.py` + +### 3.1.1 Bearer Token 的含义 + +Bearer Token 有两种来源: + +1. AgentEngine 为该 Agent 签发的 API Key,通常是 `ak-...` 或 `sk-...` +2. OpenClaw 在 `token` 模式下使用的 shared secret + +对绝大多数自动化调用,推荐理解为: + +```http +Authorization: Bearer +``` + +### 3.1.2 Cookie 会话的适用场景 + +`ae_ui_session` 主要用于: + +- `https:///chat` +- `https:///` +- share link 跳转后的浏览器会话 + +它不是给通用脚本调用运行时 API 设计的主接口。 + +## 3.2 公共请求 Header + +### 3.2.1 通用 HTTP Header + +建议按以下方式构造: + +| Header | 是否必填 | 说明 | +| --- | --- | --- | +| `Authorization: Bearer ` | 外部 API 调用必填 | 由网关校验 | +| `Content-Type: application/json` | JSON 请求推荐 | `POST /v1/*`、`POST /agentengine/api/v1/*` 常用 | +| `Accept: application/json` | 非流式请求推荐 | 返回 JSON | +| `Accept: text/event-stream` | 流式请求推荐 | `stream=true` 时推荐显式声明 | + +说明: + +- 对于 `multipart/form-data` 上传,如 `UploadFile` / `AddWorkspaceFile`,`Content-Type` 由客户端自动生成 boundary +- 运行时应用本身没有在 `ksadk.server.app` 内显式校验 Bearer;鉴权发生在网关层 + +### 3.2.2 WebSocket Header + +Hermes 终端 WebSocket 额外要求: + +| Header | 是否必填 | 说明 | +| --- | --- | --- | +| `Authorization: Bearer ` | 公网访问建议携带 | 网关鉴权 | +| `Sec-WebSocket-Protocol: ks-terminal.v1` | 必填 | Hermes 终端子协议 | + +如果缺少 `ks-terminal.v1`,Hermes runtime 会直接拒绝连接。 + +## 3.3 内部 Header 与外部调用边界 + +以下 Header 会在网关和运行时之间使用,但**不应由外部调用方手工构造**: + +| Header | 用途 | +| --- | --- | +| `X-Auth-Agent-Id` | 网关鉴权后注入的 Agent ID | +| `X-Auth-Account-Id` | 网关鉴权后注入的账号 ID | +| `X-Auth-Framework` | 网关鉴权后注入的 framework | +| `X-Auth-Openclaw-Gateway-Mode` | OpenClaw 模式透传 | +| `X-Forwarded-Host` | 原始 Host 透传 | +| `x-forwarded-user` | OpenClaw trusted-proxy / workspace 代理链路使用 | +| `X-Hermes-Session-Token` | Hermes dashboard 内部 fetch shim 使用 | + +外部用户应只关心: + +- Bearer API Key +- Cookie Session +- WebSocket 子协议 + +## 4. 运行时类型矩阵 + +当前主线下,公网可见接口按运行时分为三类: + +| 运行时类型 | 典型 framework | 主入口实现 | 对外特征 | +| --- | --- | --- | --- | +| 通用 Agent 运行时 | `adk` / `langchain` / `langgraph` / `deepagents` | `ksadk.server.app` | `/v1/*` + workspace files;公网 `/chat` 由独立 hosted UI 服务承载并调用 Hosted UI action 接口 | +| Hermes 托管运行时 | `hermes` | `deploy/hermes/runtime/app.py` 外层 wrapper | `/` dashboard、`/v1/*`、`/_ksadk/terminal/ws`、workspace files;公网 `/chat` 同样由独立 hosted UI 服务承载 | +| OpenClaw 托管运行时 | `openclaw` | OpenClaw gateway + ksadk 补丁 | 以 OpenClaw gateway 为主,平台额外挂出 workspace files | + +## 5. 公网暴露范围总览 + +### 5.1 通用 Agent 运行时 + +公网入口可确认的主路径: + +- `GET /health` +- `POST /v1/responses` +- `POST /v1/chat/completions` +- `GET /chat` +- `GET /build` +- `GET /deploy` +- `GET /agentengine/api/v1/AttachmentContent` +- `GET /agentengine/api/v1/GetWorkspaceFileContent` +- `POST /agentengine/api/v1/GetAgentUiBootstrap` +- `POST /agentengine/api/v1/CreateSession` +- `POST /agentengine/api/v1/GetSession` +- `POST /agentengine/api/v1/ListSessions` +- `POST /agentengine/api/v1/DeleteSession` +- `POST /agentengine/api/v1/ListSessionEvents` +- `GET /agentengine/api/v1/SubscribeRunEvents` +- `POST /agentengine/api/v1/RunAgent` +- `POST /agentengine/api/v1/ListSessionCheckpoints` +- `POST /agentengine/api/v1/PreviewCheckpointResume` +- `POST /agentengine/api/v1/ListToolReceipts` +- `POST /agentengine/api/v1/ResumeRun` +- `POST /agentengine/api/v1/CancelRun` +- `POST /agentengine/api/v1/UploadFile` +- `POST /agentengine/api/v1/ListWorkspaceFiles` +- `POST /agentengine/api/v1/AddWorkspaceFile` +- `POST /agentengine/api/v1/DeleteWorkspaceFile` +- `POST /agentengine/api/v1/ListAgentModels` +- `GET /agentengine/api/v1/ExportWorkspaceZip` +- `POST /run_sse` +- `GET/POST/DELETE /apps/{app_name}/users/{user_id}/sessions*` + +注意: + +- 并不是所有 `/agentengine/api/v1/*` 都会通过公网数据面暴露 +- 网关只放行 Hosted UI 所需的那一小组 action +- 对 `PublicEndpoint` 而言,`POST /agentengine/api/v1/*` 这组 Hosted UI action 实际会被 router 代理回 `agentengine-server`,不是直接命中 runtime pod 的本地同名路由 + +### 5.2 Hermes 运行时 + +公网入口可确认的主路径: + +- `GET /` +- `GET /health` +- `GET/POST/PUT/PATCH/DELETE/OPTIONS /v1/{path}` +- `GET/POST/PUT/PATCH/DELETE/OPTIONS /{path}` + 这部分本质是 Hermes dashboard 与其 API 的代理入口 +- `GET/HEAD/POST/DELETE /_ksadk/workspace/v1/*` +- `WS /_ksadk/terminal/ws` +- `GET /chat` + +### 5.3 OpenClaw 运行时 + +当前代码中可以**准确确认**的平台追加 contract 只有: + +- `/_ksadk/workspace/v1/*`:通过 ksadk sidecar / proxy 增加的文件接口 + +此外还可以确认: + +- OpenClaw gateway 默认跑在 `8080` +- 鉴权模式支持 `trusted-proxy | token | none` +- 健康检查使用的是上游 gateway 的 `/healthz` + +但 OpenClaw gateway 原生完整 API 面不是本仓当前代码独立定义的,因此本文不把其所有原生端点逐条列为平台 contract。 + +## 6. 通用 Agent 运行时详细接口 + +本节适用于原始 runtime 服务本身: + +- `adk` +- `langchain` +- `langgraph` +- `deepagents` + +底层实现:`ksadk-python/ksadk/server/app.py` + +重要边界: + +- 本节里的 `/v1/*`、`/health`、`/run_sse`、`/apps/.../sessions*` 是 runtime pod 自身实现 +- 但对公网 `PublicEndpoint` 来说,`/agentengine/api/v1/*` Hosted UI action 以 `agentengine-server` facade 为准 +- 因此本文后续会把“runtime 原始接口”和“公网 Hosted facade”拆开写 + +## 6.1 健康检查 + +### `GET /health` + +用途: + +- 检查运行时是否启动 +- 返回当前 runner 识别出的 framework 和 agent 名 + +请求示例: + +```bash +curl -H "Authorization: Bearer " \ + "https:///health" +``` + +响应示例: + +```json +{ + "status": "ok", + "framework": "langgraph", + "agent": "demo-agent" +} +``` + +## 6.2 OpenAI Responses 兼容接口 + +### `POST /v1/responses` + +说明: + +- 非流式返回 OpenAI Responses 风格 JSON +- 流式返回 `text/event-stream` + +请求体字段: + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `input` | `string | array` | 是 | 用户输入;字符串或 KOP 风格消息数组 | +| `model` | `string` | 否 | 本次调用显式模型 | +| `model_metadata` | `object` | 否 | 模型元数据 | +| `instructions` | `string` | 否 | 额外系统指令 | +| `metadata` | `object` | 否 | 请求级 metadata | +| `conversation` | `string | object` | 否 | OpenAI Responses 会话绑定字段;可传 `"conv_xxx"` 或 `{ "id": "conv_xxx" }`,runtime 会映射为内部会话 ID | +| `previous_response_id` | `string` | 否 | OpenAI Responses 上一轮 response id;不能和 `conversation` 同时使用 | +| `safety_identifier` | `string` | 否 | OpenAI 推荐的最终用户稳定标识;runtime 会映射为内部 user id 和 Langfuse UserID,建议传 hash 后值 | +| `prompt_cache_key` | `string` | 否 | OpenAI prompt cache 路由提示;runtime 当前保留到请求 metadata,不作为用户身份 | +| `user` | `string` | 否 | OpenAI deprecated 用户字段;仅在未传 `safety_identifier` 时作为兼容兜底 | +| `store` | `boolean` | 否 | OpenAI Responses 存储开关;runtime 当前保留到请求 metadata | +| `stream` | `boolean` | 否 | 是否流式 | +| `session_id` | `string` | 否 | ksadk legacy extension;兼容旧客户端。新接入应优先使用 `conversation` | + +最小请求示例: + +```json +{ + "input": "你好", + "stream": false +} +``` + +带会话与模型示例: + +```json +{ + "input": [ + { + "role": "user", + "content": [ + { + "text": "请总结一下这份设计" + } + ] + } + ], + "model": "glm-5.1", + "stream": true, + "conversation": "conv_customer_001", + "safety_identifier": "hash_user_001" +} +``` + +会话字段边界: + +- 官方兼容路径:连续对话传 `conversation`;最终用户标识传 `safety_identifier`。 +- `previous_response_id` 只表达 Responses 链式上下文,不能和 `conversation` 同时使用。 +- `session_id` 是 ksadk 早期扩展字段,仅为旧客户端保留;不要在新代码中把它当作 OpenAI 官方字段。 +- 不要通过 `metadata.user_id`、`metadata.session_id` 或其他私有 metadata 约定传用户身份和会话身份。 + +推荐请求示例: + +```json +{ + "model": "deepseek-v4-pro", + "input": "帮我分析这张账单", + "conversation": "conv_bill_20260525_001", + "safety_identifier": "user_hash_001", + "stream": false +} +``` + +图片与附件输入: + +推荐写法: + +- `/v1/responses` 推荐使用 OpenAI Responses content blocks:`input_text` / `input_image` / `input_file` +- runner 业务代码推荐读取 `payload["input_content"]` / `payload["input_messages"]`,这是 KsADK 默认 canonical 输入 +- 判断当前轮是否传了图片或文件,推荐使用 `payload["has_current_files"]` 和 `payload["current_attachments"]` +- 读取当前轮 OCR、文档抽取、压缩包摘要,推荐使用 `payload["current_attachment_results"]` + +兼容写法: + +- 老客户端仍可使用 KsADK 兼容扩展 part 数组:`text` / `inlineData` / `fileData` +- runner 里仍保留 `payload["input_parts"]`,用于兼容已有 `text / inlineData / fileData` 业务代码 +- `payload["attachments"]` / `payload["attachment_results"]` 仍保留,但语义是最近有效附件上下文,可能来自历史 fallback;不要用它判断当前最新 user turn 是否上传了文件 +- `/v1/chat/completions` 对外仍保持 Chat Completions 语义,官方图片块使用 `text` / `image_url`;`inlineData` / `fileData` 在 Chat 入口只属于 KsADK 兼容扩展,不是 OpenAI Chat 官方能力 + +字段细节: + +- `input_image.image_url` 支持远程图片 URL 或 `data:image/...;base64,...`,运行时会归一化为内部附件上下文 +- `input_file.file_data` 会归一化为内部 `inlineData`;`input_file.file_url` / `input_file.file_id` 会归一化为内部 `fileData` 引用 +- `inlineData` 适合旧客户端直接内联 base64 内容 +- `fileData` 适合旧客户端先调用 `UploadFile`,再引用返回的 `ksadk-upload://...` +- 远程图片 URL 会作为引用保留,并可在支持原生图片输入的 LangGraph 路径下继续传给模型;KsADK 不会主动拉取远程图片或远程文件做 OCR / 文本提取。需要平台提取、OCR 或本地附件内容时,请使用 data URL、`file_data`、`inlineData` 或 `fileData` + +图片示例(OpenAI Responses 风格 data URL): + +```json +{ + "input": [ + { + "role": "user", + "content": [ + { + "type": "input_text", + "text": "请分析这张图片" + }, + { + "type": "input_image", + "image_url": "data:image/png;base64," + } + ] + } + ], + "model": "glm-5.1", + "stream": false +} +``` + +业务代码获取图片信息: + +```python +def ksadk_prepare_input(payload, session_context): + # 当前轮是否真的上传了图片/文件。不要用 attachments 判断当前轮, + # attachments 可能是历史最近一次有效附件上下文。 + has_current_files = payload.get("has_current_files", False) + current_attachments = payload.get("current_attachments", []) + + images = [ + item + for item in current_attachments + if str(item.get("mime_type", "")).startswith("image/") + ] + + # OpenAI Responses canonical content,适合直接转给支持原生多模态的模型。 + input_content = payload.get("input_content", []) + image_blocks = [ + block + for block in input_content + if block.get("type") == "input_image" + ] + + return { + "input": payload.get("input", ""), + "images": images, + "image_blocks": image_blocks, + } +``` + +如果业务 agent 使用 LangGraph / LangChain 并且模型支持原生多模态,优先从 `input_content` 或 `input_messages` 读取 `input_image`,按底层模型 SDK 需要的消息格式继续传递;如果需要读取平台归一化后的附件元信息、OCR / 文档抽取结果,则读取 `current_attachments` 和 `current_attachment_results`。`input_parts`、`inlineData`、`fileData` 是 legacy/internal 兼容输入,仍可作为老客户端兜底。 + +多模态模型“看图”和平台 OCR 是两条不同链路:推荐让支持图片的模型直接消费 `input_image` / `input_content`,这样不需要在代码包里安装本地 OCR 依赖。平台本地 OCR 只用于需要把图片预先转成 `current_attachment_results[*].text` 的场景;源码构建默认不打包 OCR 二进制栈,如需启用请在构建环境设置 `KSADK_BUILD_ENABLE_ATTACHMENT_OCR=true`,或在项目 `requirements.txt` 中显式加入 OCR 相关依赖。 + +图片 data URL 或 `inlineData.data` 本身就是 base64 字符串,payload 可能很大,这是内联传图时的正常现象。业务日志不要直接打印完整 `payload`、`input_content`、`input_parts` 或 `current_attachments`;建议只记录字段摘要,例如文件名、MIME、大小、transport、data URL 前缀和长度: + +```python +def summarize_attachment(item): + data = item.get("data") or "" + return { + "display_name": item.get("display_name"), + "mime_type": item.get("mime_type"), + "transport": item.get("transport"), + "file_uri": item.get("file_uri"), + "size_bytes": item.get("size_bytes"), + "has_inline_data": bool(data), + "inline_data_length": len(data), + } + +logger.info( + "ksadk_prepare_state attachments=%s has_current_files=%s", + [summarize_attachment(item) for item in payload.get("current_attachments", [])], + payload.get("has_current_files", False), +) +``` + +旧客户端图片示例(先上传,再引用): + +```json +{ + "input": [ + { + "role": "user", + "content": [ + { + "text": "请分析这张图片" + }, + { + "fileData": { + "fileUri": "ksadk-upload://abc123.png", + "displayName": "diagram.png", + "mimeType": "image/png" + } + } + ] + } + ], + "model": "glm-5.1", + "stream": false +} +``` + +旧客户端图片示例(直接内联): + +```json +{ + "input": [ + { + "role": "user", + "content": [ + { + "text": "请分析这张图片" + }, + { + "inlineData": { + "data": "", + "displayName": "diagram.png", + "mimeType": "image/png" + } + } + ] + } + ] +} +``` + +当前附件类型支持矩阵: + +| 类型 | 典型扩展名 / MIME | 传输支持 | 平台提取支持 | 原生多模态直通 | +| --- | --- | --- | --- | --- | +| 文本 | `.txt` `.md` `.json` `.yaml` `.yml` `.csv` `.tsv` `.log` | 支持 | 支持 | 不适用 | +| 文档 | `.pdf` `.docx` `.pptx` `.xlsx` `.html` `.htm` | 支持 | 部分支持:文本提取 / OCR | 不适用 | +| 图片 | `.png` `.jpg` `.jpeg` `.webp` / `image/*` | 支持 | 元信息提取默认支持;OCR 需构建时显式启用 | 部分支持,见下方框架差异 | +| 压缩包 | `.zip` | 支持 | 支持:目录/可读文件抽样提取 | 不适用 | +| 其他二进制 | 其他后缀或 `application/octet-stream` | 支持 | 通常仅保留为附件引用 | 不支持 | + +框架差异: + +- `ADK` + - 图片附件会优先以 bytes 形式构造成底层 SDK `Part` + - 若底层模型支持原生多模态,可直接消费图片 +- `LangGraph` + - 简化输入路径下,若模型支持图片输入,图片附件会自动转换为多模态 `HumanMessage.content` blocks + - 非图片附件仍保留为普通附件上下文 +- `LangChain` + - 当前没有对所有 agent 统一做“自动图片直通” + - 如需原生多模态,建议在 `ksadk_prepare_input(payload, session_context)` 中优先消费 `input_content / input_messages`,必要时再兼容 `input_parts / current_attachments / attachments` + - 判断当前轮是否传文件用 KsADK runner payload 扩展字段 `has_current_files`;该字段不是 OpenAI Responses API 官方字段 + +模型能力判断优先级: + +1. 请求里显式传入的 `model_metadata` +2. runtime 通过 `OPENAI_BASE_URL` / `OPENAI_API_KEY` 查询上游 `/v1/models` 返回的 `architecture.input_modalities` +3. 本地默认兜底(按文本模型处理) + +多轮会话历史: + +- `/v1/responses` 本身不要求客户端每轮重传完整历史 +- 新客户端应持续传同一个 `conversation`,runtime 会从服务端会话存储里恢复该会话的历史 transcript +- 旧客户端只传 `session_id` 时仍可恢复同一会话,但这是 ksadk legacy extension +- 进入 runner 前,`ksadk` 会把历史、附件上下文、知识库上下文和长期记忆上下文统一重建成标准运行输入 +- `safety_identifier` 会作为内部 user id,并用于 Langfuse UserID;未传时 deprecated `user` 字段可作为兜底 +- `previous_response_id` 按 OpenAI Responses 语义接收并保留;当使用 `conversation` 时不要同时传 `previous_response_id` + +### Responses approval / interrupt 恢复 + +如果流式执行遇到工具审批或人工确认,runtime 不会把本轮包装成 completed,而是返回 incomplete: + +- `status`: `incomplete` +- `incomplete_details.reason`: `approval_required` +- MCP/tool approval 场景会输出 `mcp_approval_request` +- 非 MCP 的通用 interrupt 会输出 `response.ksadk.approval_request` + +#### MCP approval 恢复 + +MCP/tool approval 场景按 OpenAI Responses 标准语义恢复。客户端应传同一个 `conversation` 或 legacy `session_id`,并把 `input` 写成 `mcp_approval_response`: + +```json +{ + "conversation": "conv_customer_001", + "input": [ + { + "type": "mcp_approval_response", + "id": "mcprsp_123", + "approval_request_id": "appr_123", + "approve": true, + "reason": "approved by user" + } + ], + "stream": true +} +``` + +运行时处理方式: + +- 记录一条 `approval_response` 会话事件 +- 向 runner 传入 `resume=True` +- `input` 原样保留为 `mcp_approval_response` +- LangGraphRunner 在内部转换成 `Command(resume=...)` + +调用方不需要、也不应该直接传 Python `Command`。 + +#### 通用 interrupt 恢复 + +如果 interrupt 不是 MCP/tool approval,而是普通人工确认、补充信息或业务分支选择,客户端可以使用平台扩展 `ksadk_resume`: + +```json +{ + "conversation": "conv_customer_001", + "input": [ + { + "type": "ksadk_resume", + "interrupt_id": "intr_123", + "value": { + "approved": true, + "answer": "继续" + } + } + ], + "stream": true +} +``` + +这类事件属于 `ksadk` 扩展,不伪装成 OpenAI MCP approval。 + +### Agent 开发者如何在业务代码中拿到上下文 + +这部分不属于远程 API 调用 contract。不同框架的业务代码接入方式已经内化到框架专属文档: + +- LangGraph: [LangGraph开发最佳实践](./frameworks/LangGraph开发最佳实践.md) +- 平台公共上下文总览: [Agent 开发者上下文接入指南](./Agent 开发者上下文接入指南.md) + +调用方只需要理解: + +- `/v1/responses` 不要求每轮重传完整历史 +- 同一会话应持续传同一个 `conversation`;旧客户端传 `session_id` 也能继续兼容 +- runtime 会在进入 runner 前重建历史、附件、知识库和长期记忆上下文 +- 框架业务代码如何消费这些上下文,由对应框架最佳实践文档说明 + +### 历史压缩(compaction)是怎么做的 + +长会话不会无限把所有历史原样塞进模型。 + +当前策略是: + +1. transcript 按 API round / `invocation_id` 分组 +2. 保留最近若干轮原始消息 +3. 把更早历史压成一条 `context_checkpoint` +4. 后续模型看到的是: + - 一条 `Earlier conversation summary: ...` + - 最近若干轮原始 user / assistant 消息 + +重要特性: + +- 原始事件不会物理删除,compaction 是 append-only +- 工具调用、审批请求、附件引用等关键信息不会简单丢弃,会以 summary 或占位文本形式保留 +- 压缩阈值会结合 `model_metadata` 的上下文窗口能力自动调整 + +非流式响应字段: + +| 字段 | 说明 | +| --- | --- | +| `id` | response ID | +| `object` | 固定 `response` | +| `created_at` | Unix 时间戳 | +| `status` | 默认 `completed` | +| `model` | 模型名 | +| `output` | 输出条目数组 | +| `output_text` | 文本聚合结果 | +| `usage` | 简化 token 统计 | +| `session_id` | ksadk 返回的内部会话 ID;当请求传了 `conversation` 时与其 id 一致 | + +非流式响应示例: + +```json +{ + "id": "resp_123", + "object": "response", + "created_at": 1710000000, + "status": "completed", + "error": null, + "incomplete_details": null, + "instructions": null, + "metadata": {}, + "model": "glm-5.1", + "parallel_tool_calls": true, + "temperature": null, + "top_p": null, + "tools": [], + "output": [ + { + "id": "msg_abc", + "type": "message", + "status": "completed", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "你好,我可以帮你分析代码。" + } + ] + } + ], + "output_text": "你好,我可以帮你分析代码。", + "usage": { + "input_tokens": 0, + "output_tokens": 12, + "total_tokens": 12 + }, + "session_id": "conv_customer_001" +} +``` + +流式行为: + +- `Content-Type: text/event-stream` +- 每个事件格式为: + +```text +event: +data: + +``` + +当前可能出现的主要事件: + +- `response.created` +- `response.in_progress` +- `response.output_text.delta` +- `response.reasoning.delta` +- `response.tool_call` +- `response.tool_result` +- `response.output_item.added` / `response.output_item.done`:MCP approval request 等结构化 output item +- `response.ksadk.approval_request`:非 MCP 的通用 interrupt 扩展事件 +- `response.compaction.start` +- `response.compaction.done` +- `response.incomplete` +- `response.completed` + +## 6.3 OpenAI Chat Completions 兼容接口 + +### `POST /v1/chat/completions` + +请求体字段: + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `messages` | `array` | 是 | OpenAI 风格消息数组 | +| `model` | `string` | 否 | 模型名 | +| `model_metadata` | `object` | 否 | 模型元数据 | +| `stream` | `boolean` | 否 | 是否流式 | +| `session_id` | `string` | 否 | 会话 ID | +| `temperature` | `number` | 否 | 当前代码接受,但不保证下游一定使用 | +| `max_tokens` | `integer` | 否 | 当前代码接受,但不保证下游一定使用 | + +`messages[].content` 支持: + +1. 字符串 +2. OpenAI Chat content parts:`text` / `image_url` +3. KsADK 兼容扩展 part 数组:`text` / `inlineData` / `fileData` + +OpenAI Chat 图片块示例: + +```json +[ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "请分析这张图片" + }, + { + "type": "image_url", + "image_url": { + "url": "data:image/png;base64," + } + } + ] + } +] +``` + +KsADK 兼容扩展附件示例: + +```json +[ + { + "role": "user", + "content": [ + { + "text": "请分析附件" + }, + { + "fileData": { + "fileUri": "ksadk-upload://abc123.txt", + "displayName": "report.txt", + "mimeType": "text/plain" + } + } + ] + } +] +``` + +非流式响应示例: + +```json +{ + "id": "chatcmpl-123", + "object": "chat.completion", + "created": 1710000000, + "model": "glm-5.1", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "这是分析结果。" + }, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 0, + "completion_tokens": 6, + "total_tokens": 6 + }, + "session_id": "sess-123" +} +``` + +内部转换规则: + +- 字符串消息会转换为 runner `input_content: [{ "type": "input_text", ... }]` +- Chat 官方 `text` / `image_url` 会转换为 runner `input_text` / `input_image` +- `inlineData` / `fileData` 只作为 KsADK 兼容扩展处理,不声明为 OpenAI Chat 官方能力 +- 响应对象仍保持 Chat Completions 语义,非流式 `object` 为 `chat.completion` + +KsADK 扩展图片引用示例: + +```json +[ + { + "role": "user", + "content": [ + { + "text": "请分析这张图片" + }, + { + "fileData": { + "fileUri": "ksadk-upload://abc123.png", + "displayName": "diagram.png", + "mimeType": "image/png" + } + } + ] + } +] +``` + +流式说明: + +- 返回仍然是 SSE +- 事件名沿用 ksadk 统一事件,不是 OpenAI 官方 `chat.completion.chunk` +- 因此客户端若按 OpenAI 官方 chunk parser 逐字节兼容,需要先确认是否接受该事件形态 + +## 6.4 公网 Hosted UI Facade 说明 + +通过 `PublicEndpoint` 访问 `POST /agentengine/api/v1/*` 时,应以 `agentengine-server` 的 facade 为准,而不是以 runtime pod 本地 `ksadk.server.app` 的同名实现为准。 + +当前网关公开放行的 Hosted UI action 白名单包括: + +- `GetAgentUiBootstrap` +- `CreateSession` +- `GetSession` +- `ListSessions` +- `DeleteSession` +- `ListSessionEvents` +- `SubscribeRunEvents` +- `GetResponseFeedback` +- `UpsertResponseFeedback` +- `DeleteResponseFeedback` +- `RunAgent` +- `ListSessionCheckpoints` +- `PreviewCheckpointResume` +- `ListToolReceipts` +- `ResumeRun` +- `CancelRun` +- `UploadFile` +- `ListWorkspaceFiles` +- `AddWorkspaceFile` +- `DeleteWorkspaceFile` +- `ListAgentModels` + +另外两个 GET 下载路径也会通过 Hosted/UI 侧转发: + +- `GET /agentengine/api/v1/AttachmentContent` +- `GET /agentengine/api/v1/GetWorkspaceFileContent` + +本地 runtime 还提供 `ExportWorkspaceZip`、`/agentengine/api/v1/ws/{agent_id}/{file_path}` 等 UI 辅助接口。公网 `PublicEndpoint` 是否放行这些接口,以 `agentengine-gateway` 的 Hosted UI 白名单和独立 facade 实现为准;不要把任意 runtime 本地路由都当成公网稳定 contract。 + +长任务恢复相关 action 的公网链路是: + +`agentengine-hosted-ui / ksadk-web -> agentengine-gateway 白名单 -> agentengine-server Hosted facade -> runtime/router -> runtime 本地同名 action` + +因此,公网 contract 以 gateway 白名单和 `agentengine-server` facade 为准;runtime 本地实现是最终执行方,但不是浏览器直接依赖的入口。 + +能力门控以 `GetAgentUiBootstrap.Data.Capabilities.RunLifecycle` 为准。`RunLifecycle.Resume` 只表示普通运行生命周期可继续交互;checkpoint 恢复必须同时看到 `RunLifecycle.Checkpoints=true` 和 `RunLifecycle.CheckpointResume=true`。当前 `adk`、`langchain`、`langgraph`、`deepagents` 可声明 checkpoint lifecycle;`hermes` 虽然有 Hosted Chat、原生 dashboard 和 terminal,但其 Hermes runtime 壳只代理 `/v1/*` 与原生管理路由,不提供 `ListSessionCheckpoints` / `ResumeRun` / `CancelRun` 本地同名 action,因此不应默认点亮 checkpoint 恢复能力。 + +## 6.5 Hosted UI Bootstrap + +### `POST /agentengine/api/v1/GetAgentUiBootstrap` + +说明: + +- 这是 hosted chat / hosted workbench 初始化时的核心 bootstrap 接口 +- 对公网数据面,这个 action 会被网关显式放行 + +请求体字段: + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `AgentId` | `string` | 否 | 与 `Name` 二选一,优先使用 | +| `Name` | `string` | 否 | Agent 名称 | +| `SessionId` | `string` | 否 | 当前会话 ID | + +响应外层统一包裹: + +```json +{ + "Code": 0, + "Message": "Success", + "RequestId": "req-xxxxxxxxxxxx", + "Action": "GetAgentUiBootstrap", + "Data": { "...": "..." } +} +``` + +`Data` 关键字段: + +| 字段 | 说明 | +| --- | --- | +| `Agent.AgentId` | Agent ID | +| `Agent.Name` | Agent 名 | +| `Agent.Framework` | framework 名 | +| `Modules` | 当前固定 `["Chat","Build","Deploy"]` | +| `Capabilities.Attachments` | 固定 `true` | +| `Capabilities.WorkspaceFiles` | 是否开启 workspace | +| `Capabilities.Approval` | 当前公网 Hosted facade 为 `false` | +| `Capabilities.Thinking` | 固定 `true` | +| `Capabilities.HostedRuntime` | 当前公网 Hosted facade 为 `true` | +| `Capabilities.SlashCommands` | 当前固定 `["/new","/clear","/stop","/help","/attach"]` | +| `WorkspaceFiles` | 工作区能力描述 | +| `AccessMode` | `Owner / Private / Share` | +| `SharePermissions.DefaultPath` | 默认 UI 路径;通常为 `/chat`,Hermes 管理页可为 `/` | +| `SharePermissions.SharePath` | 分享默认路径 | +| `ApiFormats` | `hermes` 为 `["chat_completions"]`,其余通常为 `["responses","chat_completions"]` | +| `Stream` | 当前固定 `true` | +| `SessionId` | 请求传入的会话 ID | +| `HostedRuntime` | runtime 摘要对象,可能为 `null` | +| `Model` | 当前模型摘要,可能为 `null` | + +`WorkspaceFiles` 字段在启用时结构为: + +```json +{ + "Enabled": true, + "MaxUploadBytes": 104857600, + "SupportsDelete": true, + "RootLabel": "workspace", + "EntryAction": "ListWorkspaceFiles", + "UploadAction": "AddWorkspaceFile", + "ContentPath": "/agentengine/api/v1/GetWorkspaceFileContent" +} +``` + +重要限制: + +- share link 场景下,`WorkspaceFiles.Enabled` 会被关闭 +- 当前服务端只对 `adk / langchain / langgraph / deepagents / hermes` 开启 workspace files + +## 6.6 会话 Action 接口 + +### `POST /agentengine/api/v1/CreateSession` + +请求体: + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `AgentId` | `string` | 是 | Agent ID | +| `UserId` | `string` | 否 | 可选用户 ID | +| `SessionId` | `string` | 否 | 显式指定 session ID | +| `ExpiresHours` | `integer` | 否 | 兼容旧字段,当前忽略 | + +### `POST /agentengine/api/v1/ListSessions` + +请求体: + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `AgentId` | `string` | 是 | Agent ID | +| `UserId` | `string` | 否 | 可选用户 ID | +| `Page` | `integer` | 否 | 默认 `1` | +| `PageSize` | `integer` | 否 | 默认 `20`,最大 `200` | + +### `POST /agentengine/api/v1/GetSession` + +请求体: + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `SessionId` | `string` | 条件 | 与 `Id` 二选一 | +| `Id` | `string` | 条件 | 兼容旧字段 | + +### `POST /agentengine/api/v1/DeleteSession` + +请求体: + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `SessionId` | `string` | 条件 | 与 `Id` 二选一 | +| `Id` | `string` | 条件 | 兼容旧字段 | + +### `POST /agentengine/api/v1/ListSessionEvents` + +请求体: + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `SessionId` | `string` | 是 | Session ID | +| `Offset` | `integer` | 否 | 起始偏移,`>= 0` | +| `Limit` | `integer` | 否 | 返回条数,`>= 1` | + +会话响应中 `Session` 的主要字段: + +| 字段 | 说明 | +| --- | --- | +| `SessionId` | 会话 ID | +| `AgentId` | Agent ID | +| `UserId` | 用户 ID | +| `Title` | 当前标题 | +| `TitleSource` | 标题来源 | +| `Summary` | 摘要 | +| `FirstPrompt` | 第一条 prompt | +| `LastPrompt` | 最近一条 prompt | +| `State` | 会话状态字典 | +| `CreatedAt` | 创建时间 | +| `UpdatedAt` | 更新时间 | +| `Version` | 版本号 | + +事件响应中 `Events[]` 的主要字段: + +| 字段 | 说明 | +| --- | --- | +| `EventId` | 事件 ID | +| `SessionId` | 会话 ID | +| `Author` | 作者 | +| `EventType` | 事件类型 | +| `Content` | 事件内容 | +| `Timestamp` | 时间戳 | +| `SeqId` | 序号 | +| `Metadata` | 元数据 | +| `InvocationId` | 可选,本轮运行 ID | + +分页返回补充: + +- `ListSessions` 的 `Data` 额外包含 `Total` +- `ListSessions` 的 `Data` 还会包含服务端回显的 `Page` 和 `PageSize` +- `ListSessionEvents` 的 `Data` 额外包含请求透传的 `Offset` 和 `Limit` +- `ListSessionEvents` 的 `Data` 还会包含 `Total`,便于客户端按需回加载更早的事件窗口 + +### `GET /agentengine/api/v1/SubscribeRunEvents` + +说明: + +- 这是 AgentEngine Hosted UI / 本地 Web UI 的运行生命周期扩展接口,用于刷新页面、SSE 断开或切换会话后,按同一个 `SessionId + InvocationId` 继续订阅已经持久化的运行事件 +- 它不是 OpenAI Responses API 或 Chat Completions 官方接口,不改变 `/v1/responses`、`/v1/chat/completions` 的对外协议语义 +- 订阅返回的是 SSE,事件内容与 `ListSessionEvents.Events[]` 的事件 payload 形态一致 +- 当前本地 runtime 订阅窗口为 5 分钟;如果订阅期间看到 terminal `run_status`,服务端会发送 `data: [DONE]` 并结束流 + +查询参数: + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `SessionId` | `string` | 是 | 会话 ID | +| `InvocationId` | `string` | 是 | 本轮运行 ID,通常来自已回放事件的 `InvocationId` | +| `AfterSeqId` | `integer` | 否 | 只推送 `SeqId > AfterSeqId` 且 `InvocationId` 匹配的事件,默认 `0` | + +请求示例: + +```http +GET /agentengine/api/v1/SubscribeRunEvents?SessionId=sess-123&InvocationId=inv-abc&AfterSeqId=12 +Accept: text/event-stream +``` + +SSE 数据示例: + +```text +data: {"EventId":"evt-13","SessionId":"sess-123","EventType":"assistant_delta","SeqId":13,"InvocationId":"inv-abc","Content":{"text":"继续输出"}} + +data: {"EventId":"evt-14","SessionId":"sess-123","EventType":"run_status","SeqId":14,"InvocationId":"inv-abc","Content":{"status":"completed"}} + +data: [DONE] +``` + +## 6.7 文件上传与附件内容 + +### `POST /agentengine/api/v1/UploadFile` + +请求: + +- `multipart/form-data` +- 表单字段:`file` + +响应示例: + +```json +{ + "Code": 0, + "Message": "Success", + "RequestId": "req-xxxx", + "Action": "UploadFile", + "Data": { + "FileData": { + "fileUri": "ksadk-upload://abc123.txt", + "displayName": "report.txt", + "mimeType": "text/plain", + "sizeBytes": 1024 + } + } +} +``` + +### `GET /agentengine/api/v1/AttachmentContent?FileUri=` + +请求参数: + +| 参数 | 必填 | 说明 | +| --- | --- | --- | +| `FileUri` | 是 | `UploadFile` 返回的 `ksadk-upload://...` URI,或 Hosted/runtime 持久化的 `ae-upload://...` URI | + +返回: + +- 原始文件内容 +- `Content-Type` 依据文件类型推断 +- `Content-Disposition: inline` +- 当 `FileUri` 是 `ae-upload://...` 时,服务端会先解析 Hosted 上传元数据,再返回原始文件内容 + +## 6.8 Workspace Files Action 接口 + +这组接口是对 runtime 内部 `/_ksadk/workspace/v1/*` 的 action 包装。 + +重要限制: + +- share link 场景下,这组接口会被拒绝,返回 `403` +- 这些接口会先根据 `AgentId` 或 `Name` 解析目标 Agent,再由 `agentengine-server` 代理到对应 runtime + +### `POST /agentengine/api/v1/ListWorkspaceFiles` + +请求体: + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `AgentId` | `string` | 否 | 与 `Name` 二选一,优先用于解析 Agent | +| `Name` | `string` | 否 | 与 `AgentId` 二选一 | +| `Path` | `string` | 否 | 默认 `"."` | +| `Recursive` | `boolean` | 否 | 默认 `false` | + +响应 `Data` 示例: + +```json +{ + "Root": "workspace", + "Path": ".", + "Entries": [ + { + "Name": "outputs", + "Path": "outputs", + "Type": "directory", + "SizeBytes": null, + "MimeType": null, + "ModifiedAt": "2026-04-27T10:00:00Z" + } + ] +} +``` + +### `POST /agentengine/api/v1/AddWorkspaceFile` + +请求: + +- `multipart/form-data` +- 表单字段: + - `file` + - `Path` + - `AgentId`(可选) + - `Name`(可选) + +成功响应 `Data` 示例: + +```json +{ + "Entry": { + "Name": "report.txt", + "Path": "uploads/report.txt", + "Type": "file", + "SizeBytes": 1024, + "MimeType": "text/plain", + "ModifiedAt": "2026-04-27T10:00:00Z" + } +} +``` + +### `POST /agentengine/api/v1/DeleteWorkspaceFile` + +请求体: + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `AgentId` | `string` | 否 | 与 `Name` 二选一 | +| `Name` | `string` | 否 | 与 `AgentId` 二选一 | +| `Path` | `string` | 是 | 待删除文件相对路径 | + +响应: + +```json +{ + "Deleted": true +} +``` + +### `GET /agentengine/api/v1/ExportWorkspaceZip?Path=&AgentId=` + +说明: + +- 这是本地 Web UI / Workspace 面板使用的目录导出辅助接口 +- 它会读取指定 workspace 目录及其子文件,并返回 zip 文件 +- share link 场景和公网数据面是否可用,以 Hosted UI facade / gateway 白名单为准 + +请求参数: + +| 参数 | 必填 | 说明 | +| --- | --- | --- | +| `Path` | 否 | 待导出的 workspace 相对目录,默认 `"."` | +| `AgentId` | 否 | 与 `Name` 二选一 | +| `Name` | 否 | 与 `AgentId` 二选一 | + +返回: + +- `application/zip` +- 文件名通常为 `workspace.zip` + +### `GET /agentengine/api/v1/GetWorkspaceFileContent?FilePath=&AgentId=` + +请求参数: + +| 参数 | 必填 | 说明 | +| --- | --- | --- | +| `FilePath` | 是 | 文件相对路径 | +| `AgentId` | 否 | 与 `Name` 二选一 | +| `Name` | 否 | 与 `AgentId` 二选一 | + +返回: + +- 原始文件内容 +- 透传上游 runtime 的响应 Header(会过滤掉 `content-encoding` / `transfer-encoding` / `connection` / `content-length`) +- `Content-Type` 透传自 runtime + +### `GET /agentengine/api/v1/ws/{agent_id}/{file_path}` + +说明: + +- 这是 Workspace HTML 预览和相对资源解析使用的本地辅助路径,不是 WebSocket +- HTML 文件会注入预览运行所需的 base href / CSP,便于页面内相对 CSS、JS、图片资源继续从 workspace 读取 +- 它不建议作为业务 API 直接依赖;公网可用性以 Hosted UI facade / gateway 白名单为准 + +## 6.9 模型目录 + +### `POST /agentengine/api/v1/ListAgentModels` + +请求体: + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `AgentId` | `string` | 否 | 与 `Name` 二选一 | +| `Name` | `string` | 否 | 与 `AgentId` 二选一 | + +响应 `Data` 结构: + +```json +{ + "Models": [ + { + "id": "glm-5.1", + "display_name": "glm-5.1" + } + ], + "Current": "glm-5.1", + "Source": "OPENAI_MODEL_NAME" +} +``` + +说明: + +- 服务端会优先尝试请求 runtime 侧模型目录 `GET /v1/models` +- 若失败,则回退到当前 Agent 的模型配置推断结果 + +## 6.10 响应反馈 Action 接口 + +这组接口用于 hosted UI 或自研 WebUI 对某条 assistant 输出做通用点赞 / 点踩反馈。 + +重要边界: + +- 当前正式 contract 是 Hosted Action,不是 runtime 原生 `POST /v1/responses/{response_id}/feedback` +- 调用地址为 `https:///agentengine/api/v1/` +- 主反馈事实源是平台的 `response_feedback` 表 +- Langfuse score 是异步镜像链路,不能作为业务主存储或业务主键 +- 客户端不需要也不应该持有 Langfuse key + +### 如何绑定一次回复 + +自研 WebUI 调用 Agent 后,需要保存同一轮回复的两个字段: + +| 字段 | 来源 | 说明 | +| --- | --- | --- | +| `SessionId` | `/v1/responses` 请求中传入的 `conversation` 或 legacy `session_id`,或响应中返回的 `session_id`;`RunAgent` 则使用 `SessionId` | 会话 ID。连续对话和反馈查询都应使用同一个值 | +| `ResponseId` | Responses payload 的 `id` | assistant 回复对应的 `resp_xxx` | + +不同入口的取值方式: + +- 直接调用 `/v1/responses` + - 非流式:使用响应 JSON 顶层 `id` 和 `session_id` + - 流式:从 `response.created` 或 `response.completed` 事件的 `data.id` 取 `ResponseId`;`SessionId` 使用请求里传入的 `conversation` 或 legacy `session_id` +- 调用 `RunAgent` + - 建议 `ApiFormat=responses` + - 非流式:外层是 `ActionResponse`,使用 `Data.id` / `Data.session_id` + - 流式:解析 Responses 风格 SSE,使用事件里的 `data.id`;`SessionId` 使用请求里传入的 `SessionId` + +只有已落库的 assistant message 才能反馈。服务端会校验: + +- `SessionId` 属于当前账号和 `AgentId` +- `ResponseId` 能匹配该会话里的 assistant event metadata `response_id` +- 如传入 `EventId`,还会校验该 event 与 `ResponseId` 一致 + +### `POST /agentengine/api/v1/UpsertResponseFeedback` + +创建或更新当前 response 的反馈。 + +请求体字段: + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `AgentId` | `string` | 是 | Agent ID | +| `SessionId` | `string` | 是 | 会话 ID | +| `ResponseId` | `string` | 是 | `/v1/responses` 的 response ID,通常为 `resp_xxx` | +| `Rating` | `string` | 是 | `up` 或 `down` | +| `Comment` | `string` | 否 | 文字反馈,点踩时建议填写 | +| `EventId` | `string` | 否 | 内部 assistant event ID;通常不用传 | +| `TraceId` | `string` | 否 | 可选 trace 覆盖值;通常不用传 | +| `RootSpanId` | `string` | 否 | 可选 root span 覆盖值;通常不用传 | + +请求示例: + +```bash +curl -X POST "https:///agentengine/api/v1/UpsertResponseFeedback" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "AgentId": "ar-demo", + "SessionId": "sess-123", + "ResponseId": "resp_123", + "Rating": "down", + "Comment": "太啰嗦" + }' +``` + +成功响应: + +```json +{ + "Code": 0, + "Message": "Success", + "RequestId": "req-xxxx", + "Action": "UpsertResponseFeedback", + "Data": { + "Feedback": { + "AgentId": "ar-demo", + "SessionId": "sess-123", + "ResponseId": "resp_123", + "EventId": "evt-123", + "Rating": "down", + "Comment": "太啰嗦", + "TraceId": "79b770fc81ad583640721b288462f1bd", + "RootSpanId": "", + "CreatedAt": "2026-05-08T10:00:00Z", + "UpdatedAt": "2026-05-08T10:00:00Z" + } + } +} +``` + +说明: + +- 再次提交同一个 `AgentId + SessionId + ResponseId` 会覆盖原反馈 +- 点赞可以不传 `Comment` +- 点踩建议传 `Comment` +- 如果该回复已有 trace metadata,服务端会 best-effort 写入 Langfuse `hosted_ui_feedback` score +- 如果 trace 还不可用,反馈仍会先落平台表;服务端日志会记录 score 镜像跳过或失败原因 + +### `POST /agentengine/api/v1/GetResponseFeedback` + +查询某条 response 当前反馈。 + +请求体字段: + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `AgentId` | `string` | 是 | Agent ID | +| `SessionId` | `string` | 是 | 会话 ID | +| `ResponseId` | `string` | 是 | response ID | + +请求示例: + +```bash +curl -X POST "https:///agentengine/api/v1/GetResponseFeedback" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "AgentId": "ar-demo", + "SessionId": "sess-123", + "ResponseId": "resp_123" + }' +``` + +返回: + +- `Data.Feedback` 为反馈对象 +- 没有反馈时 `Data.Feedback` 为 `null` + +### `POST /agentengine/api/v1/DeleteResponseFeedback` + +删除某条 response 的反馈。 + +请求体字段: + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `AgentId` | `string` | 是 | Agent ID | +| `SessionId` | `string` | 是 | 会话 ID | +| `ResponseId` | `string` | 是 | response ID | + +请求示例: + +```bash +curl -X POST "https:///agentengine/api/v1/DeleteResponseFeedback" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "AgentId": "ar-demo", + "SessionId": "sess-123", + "ResponseId": "resp_123" + }' +``` + +成功响应: + +```json +{ + "Code": 0, + "Message": "Success", + "RequestId": "req-xxxx", + "Action": "DeleteResponseFeedback", + "Data": { + "Deleted": true + } +} +``` + +### 自研 WebUI 推荐调用顺序 + +1. 创建或复用一个 `SessionId` +2. 调用 `/v1/responses`,或调用 `RunAgent` 且设置 `ApiFormat=responses` +3. 从本轮 assistant 回复拿到 `ResponseId` +4. 渲染点赞 / 点踩按钮 +5. 页面刷新或历史回放时,对每条 assistant 回复调用 `GetResponseFeedback` 回显状态 +6. 用户点赞或点踩时调用 `UpsertResponseFeedback` +7. 用户取消反馈时调用 `DeleteResponseFeedback` + +## 6.11 Hosted 运行入口 + +### `POST /agentengine/api/v1/RunAgent` + +说明: + +- 这是 hosted UI 直接调用的运行入口 +- 它内部会根据 `ApiFormat` 转到: + - `responses` + - `chat_completions` + +请求体字段: + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `AgentId` | `string` | 是 | Agent ID | +| `Messages` | `array` | 否 | 兼容旧 UI / 旧客户端的消息数组 | +| `ResponsesInput` | `string \| array` | 否 | `ApiFormat=responses` 时优先使用的 OpenAI Responses 风格输入;Hosted UI 默认使用它 | +| `SessionId` | `string` | 否 | 会话 ID | +| `ApiFormat` | `string` | 否 | 默认 `responses`;可选 `responses` / `chat_completions` | +| `Stream` | `boolean` | 否 | 是否流式 | +| `Model` | `string` | 否 | 本次显式模型 | +| `ModelMetadata` | `object` | 否 | 模型元数据 | + +请求示例: + +```json +{ + "AgentId": "ar-demo", + "SessionId": "sess-123", + "ApiFormat": "responses", + "Stream": true, + "ResponsesInput": [ + { + "role": "user", + "content": [ + { + "type": "input_text", + "text": "帮我总结今天的变更" + } + ] + } + ], + "Messages": [ + { + "role": "user", + "content": [ + { + "type": "input_text", + "text": "帮我总结今天的变更" + } + ] + } + ] +} +``` + +流式返回: + +- `ApiFormat=responses` 时:Responses 风格 SSE +- `ApiFormat=chat_completions` 时:透传 runtime 的流式返回,实践中通常仍是 ksadk 统一 SSE 事件 + +## 6.12 长任务恢复与运行时取消 Action + +这组接口用于 Hosted UI / 本地 Web UI 展示 checkpoint、预览恢复、恢复运行和取消运行。公网 `PublicEndpoint` 调用时,请求先经过 `agentengine-gateway` Hosted UI action 白名单,再由 `agentengine-server` 按 `AgentId` 解析目标 runtime 并代理到 runtime/router。前端是否展示入口必须依赖 bootstrap capability,不要仅凭 action 是否在白名单内判断可用性。 + +公网链路验收应使用 `scripts/validate_hosted_long_task_e2e.py`,而不是只跑本地 runtime / ASGI 脚本。该脚本不需要 PG DSN,只访问 `PublicEndpoint`: + +```bash +python scripts/validate_hosted_long_task_e2e.py \ + --endpoint "https://" \ + --agent-id "" \ + --api-key "$AGENTENGINE_RUNTIME_API_KEY" +``` + +如果通过 private/share 短链接打开 Hosted UI,也可以传入 `--cookie "ae_ui_session="`。脚本默认覆盖 bootstrap capability、`RunAgent`、`ListSessionCheckpoints`、`ResumeRun(Stream=true)` 和 `ListSessionEvents`;运行时取消可用 `--mode cancel-active --session-id --invocation-id ` 对仍活跃的流式 run 验证 `CancelRun`。 + +### `POST /agentengine/api/v1/ListSessionCheckpoints` + +请求体: + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `AgentId` | `string` | 是 | Agent ID | +| `SessionId` | `string` | 是 | 会话 ID | +| `RunId` | `string` | 否 | 只返回指定 run 的 checkpoint | + +响应 `Data.Checkpoints` 为 checkpoint 列表。checkpoint 来自 runtime session event 中的 `run_checkpoint`,不是客户端传入的状态。 + +### `POST /agentengine/api/v1/PreviewCheckpointResume` + +请求体: + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `AgentId` | `string` | 是 | Agent ID | +| `SessionId` | `string` | 是 | 会话 ID | +| `RunId` | `string` | 是 | 原 run ID | +| `CheckpointId` | `string` | 是 | 要恢复的 checkpoint ID | + +响应 `Data.Preview` 返回恢复预览信息,用于 UI 在真正恢复前展示将从哪个 checkpoint 继续、可能涉及哪些 tool receipt。 + +### `POST /agentengine/api/v1/ListToolReceipts` + +请求体: + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `AgentId` | `string` | 是 | Agent ID | +| `SessionId` | `string` | 是 | 会话 ID | +| `RunId` | `string` | 否 | 只返回指定 run 的 tool receipt | +| `CheckpointId` | `string` | 否 | 只返回指定 checkpoint 关联的 tool receipt | + +响应 `Data.ToolReceipts` 为已记录的工具执行 receipt,用于恢复时展示和幂等治理。 + +### `POST /agentengine/api/v1/ResumeRun` + +请求体: + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `AgentId` | `string` | 是 | Agent ID | +| `SessionId` | `string` | 是 | 会话 ID | +| `RunId` | `string` | 是 | 原 run ID。恢复语义是同一 run 续跑,不是新建 run | +| `CheckpointId` | `string` | 是 | 要恢复的 checkpoint ID | +| `ResumeAttemptId` | `string` | 否 | 本次恢复尝试 ID;不传由 runtime 生成 | +| `InvocationId` | `string` | 否 | 本次流式恢复的 invocation ID;用于 `SubscribeRunEvents` / `CancelRun` | +| `Stream` | `boolean` | 否 | 是否流式返回 | +| `Model` | `string` | 否 | 可选模型名 | +| `ModelMetadata` | `object` | 否 | 可选模型 metadata | +| `ModelOptions` | `object` | 否 | 可选模型调用参数 | + +`Stream=true` 时返回 SSE,gateway 和 server 都按流式代理处理。runtime 只信任服务端已保存的 checkpoint 事件来解析 `framework_ref`,不会信任客户端传入的 framework 状态。 + +### `POST /agentengine/api/v1/CancelRun` + +说明: + +- 这是 Hosted UI / 本地 Web UI 的运行取消接口 +- 公网 `PublicEndpoint` 调用时由 gateway 放行到 `agentengine-server`,再代理到 runtime 本地同名 action +- runtime 会尝试调用当前 active runner 的 `request_cancel(InvocationId)`,并取消 detached streaming task +- 如果 runner 不支持真正取消,接口仍可能返回 `Cancelled=true`,语义是“已请求取消”;前端仍应以后续 `run_status` 或事件流终态为准 + +请求体: + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `AgentId` | `string` | 是 | Agent ID | +| `InvocationId` | `string` | 是 | 需要取消的运行 ID | + +响应示例: + +```json +{ + "Code": 0, + "Message": "Success", + "RequestId": "req-xxxx", + "Action": "CancelRun", + "Data": { + "Cancelled": true + } +} +``` + +非流式返回: + +- 外层仍是 `ActionResponse` +- `Data` 直接放 runtime 返回的 payload +- 服务端会补齐 `session_id` + +## 6.13 Legacy ADK Web 兼容接口 + +### `POST /run_sse` + +请求体模型来自 `ksadk/server/api_models.py`: + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `appName` | `string` | 是 | app 名 | +| `userId` | `string` | 是 | 用户 ID | +| `sessionId` | `string` | 否 | 会话 ID | +| `newMessage` | `object` | 是 | 新消息 | +| `streaming` | `boolean` | 否 | 是否流式 | +| `invocationId` | `string` | 否 | 调用 ID | +| `stateDelta` | `object` | 否 | 状态增量 | +| `functionCallEventId` | `string` | 否 | 函数调用事件 ID | +| `model` | `string` | 否 | 模型 | + +`newMessage` 结构: + +```json +{ + "role": "user", + "parts": [ + { + "text": "hello" + } + ] +} +``` + +### `/apps/{app_name}/users/{user_id}/sessions*` + +这组接口是 legacy session 兼容层,主要面向 ADK Web。 + +## 6.14 Runtime 本地前端壳路径 + +### `GET /chat` + +- 在 SDK 本地 `agentengine web` 或 runtime 镜像内置静态文件场景下,返回统一 Agent UI 的 `index.html` +- 生产公网 `PublicEndpoint` 的 `/chat` 不再由 `agentengine-server` 或 runtime 本地静态文件承载;Ingress 会优先路由到独立 `agentengine-hosted-ui` Service +- 前端仍通过 `/agentengine/api/v1/*` 调用 `agentengine-server` 的 Hosted UI action 接口 + +### `GET /build` + +- SDK 本地前端壳路径,返回同一前端壳 + +### `GET /deploy` + +- SDK 本地前端壳路径,返回同一前端壳 + +### `GET /` + +- 当静态资源存在时,挂载整个静态目录 + +说明: + +- 这些路径只有在 runtime 镜像内静态资源已构建并同步时才可用 +- 生产 hosted UI 的源码、镜像和发布节奏归属 `agentengine-hosted-ui` 独立仓库;`ksadk-python` 中的静态资源只作为 SDK 本地 UI 副本保留 + +## 7. Hermes 运行时详细接口 + +底层实现:`ksadk-python/deploy/hermes/runtime/app.py` + +Hermes 不是直接把 `ksadk.server.app` 暴露出去,而是在容器内再包一层 wrapper: + +- `/v1/*` 代理到内部 API server +- `/` 代理到内部 dashboard +- `/_ksadk/terminal/ws` 由 wrapper 自己实现 +- workspace files 由 wrapper 直接挂载 + +## 7.1 路径总览 + +| 路径 | 方法 | 说明 | +| --- | --- | --- | +| `/` | GET 等 | Hermes dashboard 管理 UI | +| `/chat` | GET | AgentEngine hosted chat UI | +| `/health` | GET | wrapper 健康检查 | +| `/v1/{path}` | 全方法 | OpenAI-compatible API 透传 | +| `/_ksadk/workspace/v1/*` | GET/HEAD/POST/DELETE | workspace files | +| `/_ksadk/terminal/ws` | WebSocket | 终端 / connect / exec / pairing | + +## 7.2 健康检查 + +### `GET /health` + +响应示例: + +```json +{ + "ok": true, + "checks": { + "api": { + "name": "api", + "ok": true, + "status_code": 200, + "url": "http://127.0.0.1:8642/health" + }, + "dashboard": { + "name": "dashboard", + "ok": true, + "status_code": 200, + "url": "http://127.0.0.1:9119/" + } + } +} +``` + +## 7.3 `/v1/*` + +Hermes 外层 wrapper 对外暴露整个 `/v1/{path}`,本质是透传到内部 `API_SERVER_PORT=8642`。 + +文档上应理解为: + +- 至少提供 `/v1/chat/completions` +- 其余 `/v1/*` 只要内部 API server 存在,也会通过 wrapper 暴露 + +SSE 要点: + +- wrapper 明确要求对 `/v1/*` 保持真流式转发 +- 不应把上游流读取完后再一次性回包 + +## 7.4 Workspace Files + +Hermes 直接复用通用的 `/_ksadk/workspace/v1/*` contract。 + +可用路径与通用 runtime 完全一致: + +- `GET /_ksadk/workspace/v1/healthz` +- `GET /_ksadk/workspace/v1/entries` +- `HEAD /_ksadk/workspace/v1/files/{path}` +- `GET /_ksadk/workspace/v1/files/{path}` +- `POST /_ksadk/workspace/v1/files/{path}` +- `DELETE /_ksadk/workspace/v1/files/{path}` + +## 7.5 终端 WebSocket + +### `WS /_ksadk/terminal/ws` + +连接要求: + +- 必须带 `Sec-WebSocket-Protocol: ks-terminal.v1` +- 公网访问时应带 `Authorization: Bearer ` + +建立连接后,客户端首帧必须是 JSON 文本: + +```json +{ + "type": "start", + "mode": "tui", + "argv": [], + "cwd": ".", + "rows": 24, + "cols": 80 +} +``` + +`mode` 支持: + +- `tui` +- `exec` +- `pairing` +- `connect` + +其中: + +- `tui` 会执行 `hermes chat` +- `exec` 走只读命令白名单 +- `pairing` 走 `hermes pairing` +- `connect` 走 `hermes gateway setup` + +服务端可能返回的文本消息: + +```json +{"type":"ready"} +``` + +```json +{"type":"exit","code":0} +``` + +```json +{"type":"error","message":"..."} +``` + +控制帧示例: + +```json +{"type":"resize","rows":40,"cols":120} +``` + +```json +{"type":"signal","signal":"SIGINT"} +``` + +```json +{"type":"stdin_eof"} +``` + +另外: + +- PTY 输出主要通过 WebSocket binary frame 回传 +- 如果首帧不是 `type=start`,服务端会报错 + +## 8. OpenClaw 运行时可确认接口 + +当前主线代码里,对 OpenClaw 可以准确写入文档的只有“平台补充 contract”,不要把上游 OpenClaw 原生全部接口误写成 ksadk/AgentEngine contract。 + +## 8.1 运行模式 + +OpenClaw gateway 主要有三种鉴权模式: + +- `trusted-proxy` +- `token` +- `none` + +默认建议模式: + +- `trusted-proxy` + +说明: + +- 公网经 AgentEngine 网关访问时,主路径仍是 trusted-proxy 设计 +- 自管或本地直连示例里,也支持 `token` 模式 + +## 8.2 健康检查 + +OpenClaw 运行镜像健康探针使用: + +- `GET /healthz` + +但这属于 OpenClaw gateway 原生健康接口,不是 ksadk 额外实现。 + +## 8.3 Workspace Files 平台补充接口 + +OpenClaw 会额外起一个本地 `workspace_files_app` sidecar,然后由 gateway 代理: + +- `/_ksadk/workspace/v1/*` + +可确认的外部 contract 与通用 runtime 一致: + +- `GET /_ksadk/workspace/v1/healthz` +- `GET /_ksadk/workspace/v1/entries` +- `HEAD /_ksadk/workspace/v1/files/{path}` +- `GET /_ksadk/workspace/v1/files/{path}` +- `POST /_ksadk/workspace/v1/files/{path}` +- `DELETE /_ksadk/workspace/v1/files/{path}` + +说明: + +- sidecar 自身监听 `127.0.0.1:${WORKSPACE_FILES_PORT}` +- 公网访问时看到的是经 OpenClaw gateway 代理后的同一路径 + +## 9. 哪些接口能通过公网数据面直接访问 + +这个点很容易误判,这里单独说明。 + +### 9.1 一定可作为公网 contract 使用的接口 + +- `/v1/responses` +- `/v1/chat/completions` +- `/chat` +- `/_ksadk/workspace/v1/*` +- Hermes 的 `/_ksadk/terminal/ws` +- `GET /agentengine/api/v1/AttachmentContent` +- `GET /agentengine/api/v1/GetWorkspaceFileContent` +- Hosted UI action 白名单: + - `GetAgentUiBootstrap` + - `CreateSession` + - `GetSession` + - `ListSessions` + - `DeleteSession` + - `ListSessionEvents` + - `SubscribeRunEvents` + - `RunAgent` + - `ListSessionCheckpoints` + - `PreviewCheckpointResume` + - `ListToolReceipts` + - `ResumeRun` + - `CancelRun` + - `GetResponseFeedback` + - `UpsertResponseFeedback` + - `DeleteResponseFeedback` + - `UploadFile` + - `ListWorkspaceFiles` + - `AddWorkspaceFile` + - `DeleteWorkspaceFile` + - `ListAgentModels` + +### 9.2 不应假设公网可调用的接口 + +不要假设下列内容一定是公网 contract: + +- 任意 `/agentengine/api/v1/*` 路径 +- runtime 本地存在但未进入 Hosted UI action 白名单的 UI 辅助路径,例如 `ExportWorkspaceZip`、Workspace HTML 预览路径 +- `/debug/*`、`/builder/*`、`/traces`、`eval_sets`、`eval_results` 等开发 / 调试 / 内部辅助入口 +- 任意 Pod 内部监听端口 +- OpenClaw 上游项目的全部原生 API +- Hermes dashboard 内部 `/api/*` 的所有未文档化子路径 + +## 10. 调用示例 + +## 10.1 通用 Agent:调用 `/v1/chat/completions` + +```bash +curl -X POST "https:///v1/chat/completions" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -H "Accept: application/json" \ + -d '{ + "messages": [ + { + "role": "user", + "content": "你好" + } + ], + "stream": false + }' +``` + +## 10.2 Hosted UI:调用 `RunAgent` + +```bash +curl -X POST "https:///agentengine/api/v1/RunAgent" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -H "Accept: text/event-stream" \ + -d '{ + "AgentId": "ar-demo", + "SessionId": "sess-123", + "ApiFormat": "responses", + "Stream": true, + "Messages": [ + { + "role": "user", + "content": [ + { + "type": "input_text", + "text": "继续" + } + ] + } + ] + }' +``` + +## 10.3 Workspace:列目录 + +```bash +curl "https:///_ksadk/workspace/v1/entries?path=.&recursive=false" \ + -H "Authorization: Bearer " +``` + +## 10.4 Hermes:连接终端 + +```bash +wscat \ + -H "Authorization: Bearer " \ + -s "ks-terminal.v1" \ + -c "wss:///_ksadk/terminal/ws" +``` + +首帧: + +```json +{"type":"start","mode":"tui","rows":24,"cols":80} +``` + +## 11. 结论 + +当前 `master` 下可以稳定对外承诺的核心运行时 contract 是: + +### 通用 Agent + +- `/v1/responses` +- `/v1/chat/completions` +- 公网 `/chat` 入口由 `agentengine-hosted-ui` 承载;runtime 本地 `/chat` 只用于 SDK 本地 UI 或内置静态资源场景 +- `/_ksadk/workspace/v1/*` +- Hosted UI action 白名单 + +### Hermes + +- `/` +- 公网 `/chat` 入口由 `agentengine-hosted-ui` 承载 +- `/v1/*` +- `/_ksadk/terminal/ws` +- `/_ksadk/workspace/v1/*` +- `/health` + +### OpenClaw + +- OpenClaw gateway 原生入口 +- 平台额外挂出的 `/_ksadk/workspace/v1/*` +- 可配置的 `trusted-proxy | token | none` 鉴权模式 + +如果后续要继续扩展文档,建议按两个方向增量补充: + +1. 基于真实镜像再验证 OpenClaw 原生 gateway 的稳定可见路由 +2. 为 Hosted UI action 补充逐接口完整示例响应 diff --git a/ksadk/api/__init__.py b/ksadk/api/__init__.py index 28311337..3d7152de 100644 --- a/ksadk/api/__init__.py +++ b/ksadk/api/__init__.py @@ -1,3 +1,3 @@ -from .client import AgentEngineAPIError, AgentEngineClient, DryRunExit +from .client import AgentEngineAPIError, AgentEngineClient, AttachmentContent, DryRunExit -__all__ = ["AgentEngineClient", "AgentEngineAPIError", "DryRunExit"] +__all__ = ["AgentEngineClient", "AgentEngineAPIError", "DryRunExit", "AttachmentContent"] diff --git a/ksadk/api/client.py b/ksadk/api/client.py index 431787c4..62259c37 100644 --- a/ksadk/api/client.py +++ b/ksadk/api/client.py @@ -12,9 +12,10 @@ import logging import mimetypes from contextlib import contextmanager +from dataclasses import dataclass from pathlib import Path from typing import Optional, Dict, Any, Sequence, Callable, Iterator -from urllib.parse import quote, urlparse +from urllib.parse import quote, unquote, urlparse import requests import urllib3 @@ -27,6 +28,13 @@ HttpErrorLogSuppressor = Callable[..., bool] +@dataclass(frozen=True) +class AttachmentContent: + data: bytes + content_type: str + display_name: str + + class DryRunExit(Exception): """DryRun 模式退出异常。""" @@ -71,6 +79,8 @@ class AgentEngineClient: """ _DEFAULT_PERMISSION_ROLE = "KsyunAgentEngineDefaultRole" + _PUBLIC_AICP_BASE_URL = "https://aicp.api.ksyun.com" + _INNER_AICP_BASE_URL = "http://aicp.inner.api.ksyun.com" _PERMISSION_PROBE_ACTIONS = {"CreateAgentProduct", "CreateAgent", "ListAgents", "GetAgent"} _permission_probe_cache: dict[tuple[str, str, str], bool] = {} @@ -156,8 +166,19 @@ def _is_connectable(url: str, timeout: float = 1.0) -> bool: return False def _detect_default_base_url(self) -> str: - """默认使用公开控制面地址。""" - return "https://aicp.api.ksyun.com" + """默认地址自动探测: inner 优先,失败回落公网。""" + inner_url = self._INNER_AICP_BASE_URL + public_url = self._PUBLIC_AICP_BASE_URL + + if self._is_connectable(inner_url): + logger.info(f"AgentEngineClient endpoint selected (inner): {inner_url}") + return inner_url + + logger.warning( + f"AgentEngineClient endpoint fallback to public: {public_url} " + f"(inner unreachable: {inner_url})" + ) + return public_url def _get_session(self) -> requests.Session: if self._session is None: @@ -309,6 +330,52 @@ def _is_auth_related_error_details(details: Dict[str, Any]) -> bool: ) return any(marker in remote_message for marker in markers) + @staticmethod + def _is_inner_account_intranet_error(details: Dict[str, Any]) -> bool: + remote_code = str(details.get("remote_error_code") or "").strip().lower() + remote_message = str(details.get("remote_error_message") or details.get("message") or "").strip().lower() + return ( + remote_code == "inneraccountcanonlyaccessthroughintranet" + or "inner account can only access through intranet" in remote_message + or "只能通过内网" in remote_message + ) + + def _can_retry_with_inner_aicp_endpoint(self, details: Dict[str, Any]) -> bool: + if not self._is_inner_account_intranet_error(details): + return False + parsed = urlparse(self.base_url or "") + return parsed.scheme == "https" and parsed.netloc.lower() == "aicp.api.ksyun.com" + + def _switch_to_inner_aicp_endpoint(self) -> None: + logger.warning( + "AgentEngineClient switching to intranet endpoint after inner-account access error: %s", + self._INNER_AICP_BASE_URL, + ) + self.base_url = self._INNER_AICP_BASE_URL + + def _build_action_request_target(self, path: str, action: str) -> tuple[bool, Dict[str, str], str]: + kop_mode = self._is_kop_mode() + headers = self._build_headers(action=action, kop_mode=kop_mode) + if kop_mode: + version = os.getenv("AGENTENGINE_API_VERSION", "2024-06-12") + full_url = f"{self.base_url.rstrip('/')}/?Action={action}&Version={version}" + else: + full_url = f"{self.base_url}{path}" + return kop_mode, headers, full_url + + def _build_raw_action_request_target(self, action: str, accept: str, has_files: bool) -> tuple[bool, Dict[str, str], str]: + kop_mode = self._is_kop_mode() + headers = self._build_headers(action=action, kop_mode=kop_mode) + headers["Accept"] = accept + if has_files: + headers.pop("Content-Type", None) + full_url = ( + f"{self.base_url.rstrip('/')}/?Action={action}&Version={os.getenv('AGENTENGINE_API_VERSION', '2024-06-12')}" + if kop_mode + else f"{self.base_url}/agentengine/api/v1/{action}" + ) + return kop_mode, headers, full_url + @contextmanager def suppress_http_error_logging( self, @@ -451,13 +518,7 @@ def _request( ) -> Dict[str, Any]: """同步 HTTP 请求""" action = path.rstrip("/").split("/")[-1] if path else "" - kop_mode = self._is_kop_mode() - headers = self._build_headers(action=action, kop_mode=kop_mode) - if kop_mode: - version = os.getenv("AGENTENGINE_API_VERSION", "2024-06-12") - full_url = f"{self.base_url.rstrip('/')}/?Action={action}&Version={version}" - else: - full_url = f"{self.base_url}{path}" + _kop_mode, headers, full_url = self._build_action_request_target(path, action) body_str = json.dumps(body, ensure_ascii=False) if body else "" # DryRun 模式 @@ -491,26 +552,35 @@ def _request( }, ) - logger.debug(f"Request: {method} {full_url}") - session = self._get_session() - - response = session.request( - method=method, - url=full_url, - data=body_str.encode("utf-8") if body_str else None, - headers=headers, - auth=self._auth.get_auth(), # AWS V4 签名 - timeout=self.timeout, - verify=self._ssl_verify_enabled(), - ) - - logger.debug(f"Response: {response.status_code}") - if response.status_code >= 400: + retried_inner_endpoint = False + while True: + logger.debug(f"Request: {method} {full_url}") + response = session.request( + method=method, + url=full_url, + data=body_str.encode("utf-8") if body_str else None, + headers=headers, + auth=self._auth.get_auth(), # AWS V4 签名 + timeout=self.timeout, + verify=self._ssl_verify_enabled(), + ) + + logger.debug(f"Response: {response.status_code}") + + if response.status_code < 400: + break + resp_text = response.text or "" details = self._extract_http_error_details(resp_text) details.setdefault("http_status", response.status_code) + if not retried_inner_endpoint and self._can_retry_with_inner_aicp_endpoint(details): + retried_inner_endpoint = True + self._switch_to_inner_aicp_endpoint() + _kop_mode, headers, full_url = self._build_action_request_target(path, action) + continue + self._log_http_error( method=method, full_url=full_url, @@ -614,6 +684,60 @@ def _parse_container_image_ref(image_ref: str) -> tuple[str, str, str]: return namespace or "default", repo, tag + @staticmethod + def _enterprise_instance_from_image_ref(image_ref: str) -> str | None: + """Infer enterprise KCR instance name from a full image reference.""" + image = (image_ref or "").strip() + for prefix in ("http://", "https://"): + if image.startswith(prefix): + image = image[len(prefix):] + break + host = image.split("/", 1)[0].strip() + if host.endswith("-vpc.ksyunkcr.com"): + return host[: -len("-vpc.ksyunkcr.com")] or None + if host.endswith(".ksyunkcr.com") and not host.endswith("-vpc.ksyunkcr.com"): + return host.split(".", 1)[0] or None + return None + + @classmethod + def _build_container_config_payload( + cls, + *, + artifact: str, + image_credential: Optional[Dict[str, Any]] = None, + container: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + container = container or {} + artifact = str(artifact or container.get("image_addr") or "").strip() + img_ns, img_repo, img_ver = cls._parse_container_image_ref(artifact) + + inferred_enterprise_instance = cls._enterprise_instance_from_image_ref(artifact) + image_type = ( + container.get("image_type") + or ("Enterprise" if inferred_enterprise_instance else "Personal") + ) + params: Dict[str, Any] = { + "ImageType": image_type, + "NameSpace": container.get("name_space") or img_ns, + "ImageRepo": container.get("image_repo") or img_repo, + "ImageVersion": container.get("image_version") or img_ver, + "ImageAddr": container.get("image_addr") or artifact, + } + + enterprise_instance = container.get("enterprise_instance") or inferred_enterprise_instance + if enterprise_instance: + params["EnterpriseInstance"] = enterprise_instance + if container.get("enterprise_instance_id"): + params["EnterpriseInstanceId"] = container.get("enterprise_instance_id") + + ic = image_credential or {} + username = (container.get("username") or ic.get("username") or "").strip() + password = (container.get("password") or ic.get("password") or "").strip() + if username and password: + params["UserName"] = username + params["Password"] = password + return params + @staticmethod def _normalize_framework_name(framework: Optional[str]) -> str: """规范化 framework 名称,默认 langgraph。""" @@ -766,32 +890,42 @@ def _action_raw_request( files: Dict[str, Any] | None = None, accept: str = "application/json", ) -> requests.Response: - kop_mode = self._is_kop_mode() - headers = self._build_headers(action=action, kop_mode=kop_mode) - headers["Accept"] = accept - if files is not None: - headers.pop("Content-Type", None) - full_url = ( - f"{self.base_url.rstrip('/')}/?Action={action}&Version={os.getenv('AGENTENGINE_API_VERSION', '2024-06-12')}" - if kop_mode - else f"{self.base_url}/agentengine/api/v1/{action}" + _kop_mode, headers, full_url = self._build_raw_action_request_target( + action, + accept, + files is not None, ) session = self._get_session() - response = session.request( - method=method, - url=full_url, - params=self._compact_params(params), - data=self._compact_params(data) if data is not None else None, - files=files, - headers=headers, - auth=self._auth.get_auth(), - timeout=self.timeout, - verify=self._ssl_verify_enabled(), - ) - if response.status_code >= 400: + + retried_inner_endpoint = False + while True: + response = session.request( + method=method, + url=full_url, + params=self._compact_params(params), + data=self._compact_params(data) if data is not None else None, + files=files, + headers=headers, + auth=self._auth.get_auth(), + timeout=self.timeout, + verify=self._ssl_verify_enabled(), + ) + if response.status_code < 400: + return response + resp_text = response.text or "" details = self._extract_http_error_details(resp_text) details.setdefault("http_status", response.status_code) + if not retried_inner_endpoint and self._can_retry_with_inner_aicp_endpoint(details): + retried_inner_endpoint = True + self._switch_to_inner_aicp_endpoint() + _kop_mode, headers, full_url = self._build_raw_action_request_target( + action, + accept, + files is not None, + ) + continue + self._log_http_error( method=method, full_url=full_url, @@ -804,7 +938,6 @@ def _action_raw_request( or resp_text ) raise AgentEngineAPIError(response.status_code, message, details=details or None) - return response async def _resolve_workspace_runtime_access( self, @@ -883,6 +1016,19 @@ def _annotate_workspace_payload(payload: Dict[str, Any], *, transport_mode: str) annotated["transport_mode"] = transport_mode return annotated + @staticmethod + def _display_name_from_content_disposition(value: str) -> str: + header = str(value or "").strip() + if not header: + return "" + filename_star_match = re.search(r"filename\*=UTF-8''([^;]+)", header, flags=re.IGNORECASE) + if filename_star_match: + return Path(unquote(filename_star_match.group(1).strip().strip('"'))).name + filename_match = re.search(r'filename="?([^";]+)"?', header, flags=re.IGNORECASE) + if filename_match: + return Path(filename_match.group(1).strip()).name + return "" + def _action(self, action: str, params: Dict[str, Any] = None) -> Dict[str, Any]: """通用 Action API 调用""" body = params or {} @@ -908,6 +1054,27 @@ def _action(self, action: str, params: Dict[str, Any] = None) -> Dict[str, Any]: return data + def download_attachment_content(self, file_uri: str) -> AttachmentContent: + """Download hosted attachment bytes through the signed KOP action API.""" + normalized_uri = str(file_uri or "").strip() + if not normalized_uri: + raise AgentEngineAPIError(400, "FileUri is required") + response = self._action_raw_request( + "GET", + "AttachmentContent", + params={"FileUri": normalized_uri}, + accept="application/octet-stream", + ) + return AttachmentContent( + data=response.content, + content_type=str(response.headers.get("content-type") or "application/octet-stream"), + display_name=self._display_name_from_content_disposition( + str(response.headers.get("content-disposition") or "") + ) + or Path(normalized_uri.removeprefix("ae-upload://")).name + or "uploaded_file", + ) + # ===== Agent Actions ===== @staticmethod @@ -1083,23 +1250,11 @@ async def create_agent(self, data: Dict[str, Any]) -> Dict[str, Any]: } else: ic = data.get("image_credential", {}) or {} - image_username = (ic.get("username") or "").strip() - image_password = (ic.get("password") or "").strip() artifact = (data.get("artifact_path", "") or "").strip() - img_ns, img_repo, img_ver = self._parse_container_image_ref(artifact) - - container_config = { - "ImageType": "Personal", - "NameSpace": img_ns, - "ImageRepo": img_repo, - "ImageVersion": img_ver, - "ImageAddr": artifact, - } - # 仅在用户名和密码同时存在时才传鉴权,避免对公共镜像误触发失败鉴权重试。 - if image_username and image_password: - container_config["UserName"] = image_username - container_config["Password"] = image_password - params["ContainerConfig"] = container_config + params["ContainerConfig"] = self._build_container_config_payload( + artifact=artifact, + image_credential=ic, + ) env_vars = [] envs = data.get("env_vars") or data.get("environment_variables") @@ -1172,16 +1327,17 @@ async def create_dashboard_access_link( agent_id: Optional[str] = None, name: Optional[str] = None, link_type: str = "private", - path: str = "/", + path: Optional[str] = None, expires_seconds: Optional[int] = None, force_new: bool = False, ) -> Dict[str, Any]: """创建 Dashboard 短链接。""" params: Dict[str, Any] = { "LinkType": link_type, - "Path": path, "ForceNew": bool(force_new), } + if path is not None: + params["Path"] = path if expires_seconds is not None: params["ExpiresSeconds"] = int(expires_seconds) if agent_id: @@ -1339,20 +1495,10 @@ async def update_agent(self, agent_id: str, data: Dict[str, Any]) -> Dict[str, A artifact = (data.get("artifact_path", "") or "").strip() if (data.get("artifact_type") or "").lower() == "container": ic = data.get("image_credential", {}) or {} - image_username = (ic.get("username") or "").strip() - image_password = (ic.get("password") or "").strip() - img_ns, img_repo, img_ver = self._parse_container_image_ref(artifact) - container_config = { - "ImageType": "Personal", - "NameSpace": img_ns, - "ImageRepo": img_repo, - "ImageVersion": img_ver, - "ImageAddr": artifact, - } - if image_username and image_password: - container_config["UserName"] = image_username - container_config["Password"] = image_password - params["ContainerConfig"] = container_config + params["ContainerConfig"] = self._build_container_config_payload( + artifact=artifact, + image_credential=ic, + ) else: ks3 = data.get("ks3", {}) params["CodeConfig"] = { @@ -1444,7 +1590,7 @@ async def get_session(self, session_id: str) -> Dict[str, Any]: async def list_sessions(self, agent_id: str, page: int = 1, size: int = 20) -> Dict[str, Any]: """列出会话""" - return self._action("ListSessions", {"AgentId": agent_id, "Page": page, "Size": size}) + return self._action("ListSessions", {"AgentId": agent_id, "Page": page, "PageSize": size}) async def delete_session(self, session_id: str) -> bool: """删除会话""" @@ -1727,27 +1873,11 @@ def _build_mcp_container_config(self, data: Dict[str, Any]) -> Dict[str, Any]: container = data.get("container_config") or {} artifact = str(data.get("artifact_path") or container.get("image_addr") or "").strip() ic = data.get("image_credential", {}) or {} - img_ns, img_repo, img_ver = self._parse_container_image_ref(artifact) - - image_type = container.get("image_type") or "Personal" - params: Dict[str, Any] = { - "ImageType": image_type, - "NameSpace": container.get("name_space") or img_ns, - "ImageRepo": container.get("image_repo") or img_repo, - "ImageVersion": container.get("image_version") or img_ver, - "ImageAddr": container.get("image_addr") or artifact, - } - if container.get("enterprise_instance"): - params["EnterpriseInstance"] = container.get("enterprise_instance") - if container.get("enterprise_instance_id"): - params["EnterpriseInstanceId"] = container.get("enterprise_instance_id") - - username = (container.get("username") or ic.get("username") or "").strip() - password = (container.get("password") or ic.get("password") or "").strip() - if username and password: - params["UserName"] = username - params["Password"] = password - return params + return self._build_container_config_payload( + artifact=artifact, + image_credential=ic, + container=container, + ) def _build_mcp_resource(self, data: Dict[str, Any]) -> Dict[str, Any]: resources = data.get("resources", {}) or {} diff --git a/ksadk/cli/cmd_config.py b/ksadk/cli/cmd_config.py index 82c84d28..b4897adb 100644 --- a/ksadk/cli/cmd_config.py +++ b/ksadk/cli/cmd_config.py @@ -1,5 +1,6 @@ import os from pathlib import Path +import re import click from dotenv import dotenv_values import questionary @@ -137,6 +138,13 @@ def _stringify_value(value) -> str: return str(value) +_ENV_VAR_KEY_PATTERN = re.compile(r"^[A-Z_][A-Z0-9_]*$") + + +def _is_env_assignment_key(key: str) -> bool: + return bool(_ENV_VAR_KEY_PATTERN.fullmatch(key)) + + def _parse_set_items(set_items: tuple) -> tuple[dict, dict, list[str]]: """Parse KEY=VALUE assignments into project/env updates.""" updates_yaml = {} @@ -152,7 +160,7 @@ def _parse_set_items(set_items: tuple) -> tuple[dict, dict, list[str]]: key = key.strip() value = value.strip() - if key.startswith("OPENAI_") or key.startswith("KSYUN_"): + if _is_env_assignment_key(key): updates_env[key] = value if key == "KSYUN_REGION": updates_yaml["region"] = value @@ -170,7 +178,7 @@ def _apply_set_command(set_items: tuple, output_path: Path, env_path: Path, is_g if not updates_yaml and not updates_env and invalid_items: raise usage_error( "至少提供一个有效的 KEY=VALUE 配置项。", - hints=["示例: `agentengine config set region=cn-beijing-6 OPENAI_MODEL_NAME=glm-5.1`"], + hints=["示例: `agentengine config set region=cn-beijing-6 OPENAI_MODEL_NAME=glm-5.2`"], ) result = { @@ -303,7 +311,7 @@ def _run_config_set_command(*, set_items: tuple, output_path: Path, env_path: Pa if not set_items: raise usage_error( "请至少提供一个 KEY=VALUE 配置项。", - hints=["示例: `agentengine config set region=cn-beijing-6 OPENAI_MODEL_NAME=glm-5.1`"], + hints=["示例: `agentengine config set region=cn-beijing-6 OPENAI_MODEL_NAME=glm-5.2`"], ) return _apply_set_command(set_items, output_path, env_path, is_global) @@ -432,7 +440,7 @@ def _ask_or_exit(question): )) new_env['OPENAI_MODEL_NAME'] = _ask_or_exit(questionary.text( - "模型名称 (OPENAI_MODEL_NAME) [选填,默认使用金山云星流平台glm-5.1]:", + "模型名称 (OPENAI_MODEL_NAME) [选填,默认使用金山云星流平台glm-5.2]:", default=existing_env.get('OPENAI_MODEL_NAME', ''), style=_questionary_style() )) @@ -527,30 +535,32 @@ def _ask_or_exit(question): )) if should_config_registry: - # 密码 (必填) + new_env['KCR_USERNAME'] = _ask_or_exit(questionary.text( + "KCR 用户名 (企业版请填写访问凭证用户名):", + default=existing_env.get('KCR_USERNAME', ''), + style=_questionary_style() + )) + new_env['KCR_PASSWORD'] = _ask_or_exit(questionary.password( - "KCR 临时密码:", + "KCR 密码或 Token:", default=existing_env.get('KCR_PASSWORD', ''), style=_questionary_style() )) - - # 仓库地址 (选填,默认使用公开占位 registry) + default_registry = existing_env.get('KCR_REGISTRY', '') - auto_registry = "ghcr.io/kingsoftcloud/agentengine" - custom_registry = _ask_or_exit(questionary.text( - f"镜像仓库地址 [选填,默认: {auto_registry}]:", + "镜像仓库地址 [选填,如: agenthzzqy-vpc.ksyunkcr.com/testagent-pub]:", default=default_registry, style=_questionary_style() )) if custom_registry: new_env['KCR_REGISTRY'] = custom_registry - # 不填则不写入,运行时自动根据 KSYUN_REGION 生成 - + print_info("提示:") - print_info("用户名自动使用 KSYUN_ACCOUNT_ID (无需配置)") - print_info("KCR 临时密码获取: https://kcr.console.ksyun.com/ → 访问凭证") + print_info("个人版 KCR 可留空 KCR_USERNAME,运行时使用 KSYUN_ACCOUNT_ID 作为用户名兜底") + print_info("企业版 KCR 和第三方镜像仓库必须配置 KCR_USERNAME + KCR_PASSWORD") + print_info("KCR 访问凭证获取: https://kcr.console.ksyun.com/ → 访问凭证") print_rule() @@ -705,7 +715,7 @@ def config_set(set_items: tuple, is_global: bool, output_mode: str | None): \b 示例: agentengine config set region=cn-beijing-6 - agentengine config set OPENAI_MODEL_NAME=glm-5.1 OPENAI_BASE_URL=https://example.com/v1 + agentengine config set OPENAI_MODEL_NAME=glm-5.2 OPENAI_BASE_URL=https://example.com/v1 agentengine config set KSYUN_REGION=cn-beijing-6 --global """ _ = output_mode diff --git a/ksadk/cli/cmd_create.py b/ksadk/cli/cmd_create.py index 17b6a72c..4469c697 100644 --- a/ksadk/cli/cmd_create.py +++ b/ksadk/cli/cmd_create.py @@ -74,7 +74,7 @@ def _print_quick_start_commands(project_name: str, commands: list[str]) -> None: from google.adk.models.lite_llm import LiteLlm model = LiteLlm( - model=f"openai/{{os.getenv('OPENAI_MODEL_NAME', 'glm-5.1')}}", + model=f"openai/{{os.getenv('OPENAI_MODEL_NAME', 'glm-5.2')}}", api_base=os.getenv("OPENAI_BASE_URL"), api_key=os.getenv("OPENAI_API_KEY"), stream=True, # 启用流式输出 @@ -118,7 +118,7 @@ def hello(name: str) -> dict: from langchain_core.output_parsers import StrOutputParser llm = ChatOpenAI( - model=os.getenv("OPENAI_MODEL_NAME", "glm-5.1"), + model=os.getenv("OPENAI_MODEL_NAME", "glm-5.2"), base_url=os.getenv("OPENAI_BASE_URL"), api_key=os.getenv("OPENAI_API_KEY"), streaming=True, @@ -149,7 +149,7 @@ def hello(name: str) -> dict: import operator llm = ChatOpenAI( - model=os.getenv("OPENAI_MODEL_NAME", "glm-5.1"), + model=os.getenv("OPENAI_MODEL_NAME", "glm-5.2"), base_url=os.getenv("OPENAI_BASE_URL"), api_key=os.getenv("OPENAI_API_KEY"), streaming=True, @@ -189,7 +189,7 @@ def chat(state: State): from langchain_openai import ChatOpenAI llm = ChatOpenAI( - model=os.getenv("OPENAI_MODEL_NAME", "glm-5.1"), + model=os.getenv("OPENAI_MODEL_NAME", "glm-5.2"), base_url=os.getenv("OPENAI_BASE_URL"), api_key=os.getenv("OPENAI_API_KEY"), streaming=True, @@ -2019,7 +2019,7 @@ def create(project_name: str, framework: str, from_agent_path: str): if model_name: env_content += f"OPENAI_MODEL_NAME={model_name}\n" else: - env_content += "# OPENAI_MODEL_NAME=glm-5.1\n" + env_content += "# OPENAI_MODEL_NAME=glm-5.2\n" elif framework == "hermes": env_content = f"""# ====================== # Hermes 标准部署最小配置 @@ -2044,7 +2044,7 @@ def create(project_name: str, framework: str, from_agent_path: str): if model_name: env_content += f"OPENAI_MODEL_NAME={model_name}\n" else: - env_content += "# OPENAI_MODEL_NAME=glm-5.1\n" + env_content += "# OPENAI_MODEL_NAME=glm-5.2\n" env_content += """ # Hermes runtime @@ -2055,8 +2055,31 @@ def create(project_name: str, framework: str, from_agent_path: str): HERMES_DASHBOARD_PORT=9119 PORT=8080 # HERMES_CONTEXT_LENGTH=200000 -# HERMES_FALLBACK_MODEL=kimi-k2.6 -# HERMES_IMAGE=ghcr.io/kingsoftcloud/hermes-agent:2026.5.16 +# HERMES_FALLBACK_MODEL=deepseek-v4-pro +# HERMES_IMAGE=hub.kce.ksyun.com/agentengine-public/hermes-agent:2026.5.29.2-ksadk-v1 +""" + env_example_content = """# ====================== +# Hermes 标准部署最小配置示例 +# ====================== +KSYUN_ACCESS_KEY=your-access-key +KSYUN_SECRET_KEY=your-secret-key +KSYUN_REGION=cn-beijing-6 +# KSYUN_ACCOUNT_ID=your-account-id + +OPENAI_API_KEY=your-model-api-key +OPENAI_BASE_URL=https://kspmas.ksyun.com/v1/ +OPENAI_MODEL_NAME=glm-5.2 + +# Hermes runtime +API_SERVER_ENABLED=true +API_SERVER_HOST=127.0.0.1 +API_SERVER_PORT=8642 +HERMES_DASHBOARD_HOST=127.0.0.1 +HERMES_DASHBOARD_PORT=9119 +PORT=8080 +# HERMES_CONTEXT_LENGTH=200000 +# HERMES_FALLBACK_MODEL=deepseek-v4-pro +# HERMES_IMAGE=hub.kce.ksyun.com/agentengine-public/hermes-agent:2026.5.29.2-ksadk-v1 """ else: langfuse_public = global_env.get("LANGFUSE_PUBLIC_KEY", "") @@ -2078,7 +2101,7 @@ def create(project_name: str, framework: str, from_agent_path: str): if model_name: env_content += f"OPENAI_MODEL_NAME={model_name}\n" else: - env_content += "# OPENAI_MODEL_NAME=glm-5.1\n" + env_content += "# OPENAI_MODEL_NAME=glm-5.2\n" env_content += """ # ====================== @@ -2124,6 +2147,8 @@ def create(project_name: str, framework: str, from_agent_path: str): # 使用 utf-8-sig 编码 (带 BOM),确保 Windows 程序正确识别为 UTF-8 (project_path / ".env").write_text(env_content, encoding="utf-8-sig") + if framework == "hermes": + (project_path / ".env.example").write_text(env_example_content, encoding="utf-8-sig") if framework == "openclaw": print_success("项目创建成功") diff --git a/ksadk/cli/cmd_hermes.py b/ksadk/cli/cmd_hermes.py index 586bd681..3c47ca5d 100644 --- a/ksadk/cli/cmd_hermes.py +++ b/ksadk/cli/cmd_hermes.py @@ -50,6 +50,7 @@ ) from ksadk.deployment.state import clear_state, load_state, save_state from ksadk.cli.model_catalog import fetch_provider_model_metadata +from ksadk.model_policy import build_runtime_model_policy_env from ksadk.hermes_terminal import ( run_hermes_terminal_session, validate_hermes_exec_argv, @@ -61,10 +62,7 @@ DEFAULT_HERMES_CONTEXT_LENGTHS = ( ("glm-5.1", "200000"), ) -DEFAULT_HERMES_FALLBACK_MODELS = ( - ("glm-5.1", "kimi-k2.6"), -) -DEFAULT_HERMES_MODEL_NAME = "glm-5.1" +DEFAULT_HERMES_MODEL_NAME = "glm-5.2" DEFAULT_HERMES_PUBLIC_BASE_URL = "https://kspmas.ksyun.com/v1/" DEFAULT_HERMES_RUNTIME_BASE_URL = DEFAULT_HERMES_PUBLIC_BASE_URL KSPMAS_PUBLIC_BASES = ( @@ -266,17 +264,6 @@ def _default_context_length_for_model(model: str | None) -> str: return "" -def _default_fallback_model_for_model(model: str | None, base_url: str | None) -> str: - normalized_model = str(model or "").strip().lower() - normalized_base_url = str(base_url or "").strip().lower() - for model_fragment, fallback_model in DEFAULT_HERMES_FALLBACK_MODELS: - if model_fragment in normalized_model: - return fallback_model - if "kspmas" in normalized_base_url: - return "kimi-k2.6" - return "" - - def _build_hermes_env_vars( *, model_base_url: str | None = None, @@ -299,10 +286,6 @@ def _build_hermes_env_vars( or metadata_context_length or _default_context_length_for_model(resolved_default_model) ) - fallback_model = ( - _env_value("HERMES_FALLBACK_MODEL", "OPENAI_FALLBACK_MODEL_NAME") - or _default_fallback_model_for_model(resolved_default_model, resolved_model_base_url) - ) ui_locale = _normalize_hermes_ui_locale(_env_value("HERMES_UI_LOCALE", "LANG", "LC_ALL")) raw = { "OPENAI_API_KEY": model_api_key or _env_value("OPENAI_API_KEY"), @@ -318,6 +301,7 @@ def _build_hermes_env_vars( } if context_length: raw["HERMES_CONTEXT_LENGTH"] = context_length + fallback_model = _env_value("HERMES_FALLBACK_MODEL", "OPENAI_FALLBACK_MODEL_NAME") if fallback_model: raw["HERMES_FALLBACK_PROVIDER"] = _env_value("HERMES_FALLBACK_PROVIDER") or "custom" raw["HERMES_FALLBACK_MODEL"] = fallback_model @@ -356,6 +340,10 @@ def _build_hermes_env_vars( value = _env_value(key) if value: raw[key] = value + raw = build_runtime_model_policy_env(raw, runtime="hermes") + if raw.get("HERMES_FALLBACK_MODEL"): + raw.setdefault("HERMES_FALLBACK_PROVIDER", _env_value("HERMES_FALLBACK_PROVIDER") or "custom") + raw.setdefault("HERMES_FALLBACK_BASE_URL", _env_value("HERMES_FALLBACK_BASE_URL") or resolved_model_base_url) return [ {"Key": key, "Value": str(value), "IsSensitive": any(token in key for token in ("KEY", "TOKEN", "SECRET"))} for key, value in raw.items() @@ -385,6 +373,14 @@ def _normalize_hermes_runtime_base_url(base_url: str | None) -> str: return normalized +_FAILURE_STATUSES = {"FAILED", "ERROR", "TERMINATED"} + + +def _diagnostic_field_style(status_value: str) -> str: + """非 RUNNING 诊断行样式:失败用红色,其余用黄色。""" + return "bold #f85149" if status_value in _FAILURE_STATUSES else "bold #d29922" + + def _flatten_agent_detail(agent: dict[str, Any]) -> dict[str, Any]: basic = agent.get("basic") if isinstance(agent.get("basic"), dict) else {} deployment = agent.get("deployment") if isinstance(agent.get("deployment"), dict) else {} @@ -393,6 +389,10 @@ def _flatten_agent_detail(agent: dict[str, Any]) -> dict[str, Any]: "agent_id": basic.get("agent_id") or agent.get("agent_id"), "name": basic.get("name") or agent.get("name"), "status": basic.get("status") or agent.get("status") or "UNKNOWN", + "phase": basic.get("phase") or "", + "message": basic.get("message") or "", + "replicas": basic.get("replicas"), + "ready_replicas": basic.get("ready_replicas"), "framework": deployment.get("framework") or basic.get("framework") or agent.get("framework"), "region": basic.get("region") or agent.get("region"), "endpoint": quick.get("public_endpoint") or quick.get("private_endpoint") or agent.get("endpoint"), @@ -866,18 +866,32 @@ async def _status(): async with AgentEngineClient(region=region, dry_run=dry_run) as client: detail = await _get_hermes_detail_with_client(client, resolved) status_value = str(detail.get("status") or "UNKNOWN").upper() - render_descriptor_status( - HERMES_RESOURCE, - subtitle=str(detail.get("name") or resolved), - fields=[ + fields: list[tuple[str, str, str | None]] = [ ("ID", str(detail.get("agent_id") or "-"), "#58a6ff"), ("状态", status_value, status_rich_style(status_value)), + ] + message = str(detail.get("message") or "").strip() + if status_value != "RUNNING": + replicas = detail.get("replicas") + ready = detail.get("ready_replicas") + if replicas is not None or ready is not None: + replicas_text = f"{ready if ready is not None else '-'}/{replicas if replicas is not None else '-'}" + replica_style = _diagnostic_field_style(status_value) + fields.append(("副本", replicas_text, replica_style)) + if message: + message_style = _diagnostic_field_style(status_value) + fields.append(("消息", message, message_style)) + fields.extend([ ("框架", str(detail.get("framework") or "-"), None), ("区域", str(detail.get("region") or region), None), ("Endpoint", str(detail.get("endpoint") or "-"), "#58a6ff"), ("Langfuse", str(detail.get("langfuse_url") or "-"), "#58a6ff" if detail.get("langfuse_url") else None), ("镜像", str(detail.get("artifact_path") or "-"), None), - ], + ]) + render_descriptor_status( + HERMES_RESOURCE, + subtitle=str(detail.get("name") or resolved), + fields=fields, item={ "id": str(detail.get("agent_id") or "-"), "name": str(detail.get("name") or resolved), @@ -887,6 +901,10 @@ async def _status(): "endpoint": str(detail.get("endpoint") or "-"), "langfuse_url": str(detail.get("langfuse_url") or ""), "image": str(detail.get("artifact_path") or "-"), + "message": message, + "phase": str(detail.get("phase") or ""), + "replicas": detail.get("replicas"), + "ready_replicas": detail.get("ready_replicas"), }, ) diff --git a/ksadk/cli/cmd_openclaw.py b/ksadk/cli/cmd_openclaw.py index 0be4376e..c2dfd0f0 100644 --- a/ksadk/cli/cmd_openclaw.py +++ b/ksadk/cli/cmd_openclaw.py @@ -74,7 +74,13 @@ from ksadk.deployment.agent_access import get_latest_agent_access from ksadk.cli.model_catalog import fetch_provider_model_catalog, find_model_in_catalog from ksadk.conversations.model_context import normalize_model_metadata +from ksadk.model_policy import build_runtime_model_policy_env +from ksadk.builders.container_builder import ( + registry_kind_label, + resolve_registry_credentials, +) from ksadk.openclaw_gateway import OpenClawGatewayClient, OpenClawGatewayError, OpenClawGatewayRequestError +from ksadk.terminal_exec_policy import OPENCLAW_TERMINAL_EXEC_POLICY from ksadk.terminal_client import run_terminal_session console = get_console() @@ -366,6 +372,12 @@ def _resolve_env(*keys: str, default: Optional[str] = None) -> Optional[str]: return default +def _openclaw_registry_env() -> dict[str, str]: + env = {str(k): str(v) for k, v in os.environ.items()} + env.update(_get_global_env()) + return env + + def _resolve_model_base_url(cli_value: Optional[str]) -> Optional[str]: """解析模型 Base URL,缺失时回退到 settings.model.api_base(KSPMAS 自动探测)。""" if cli_value and str(cli_value).strip(): @@ -519,7 +531,7 @@ def _strip_provider_prefix(provider_id: str, model_id: str) -> str: def _default_openclaw_model_inputs(provider_id: str, model_id: str) -> list[str]: - if str(provider_id or "").strip().lower() == "ksyun" and str(model_id or "").strip().lower() == "glm-5.1": + if str(provider_id or "").strip().lower() == "ksyun" and str(model_id or "").strip().lower() in {"glm-5.1", "glm-5.2"}: return ["text"] return ["text", "image"] @@ -597,13 +609,28 @@ def _apply_openclaw_provider_model_catalog( env: Dict[str, str], raw_models: list[Any], ) -> bool: - if not raw_models or str(env.get("OPENCLAW_MODEL_CATALOG_JSON") or "").strip(): + if not raw_models: return False provider_id = str(env.get("OPENCLAW_MODEL_PROVIDER_ID") or "ksyun").strip() or "ksyun" provider_api = str(env.get("OPENCLAW_MODEL_API") or "openai-completions").strip() or "openai-completions" + raw_catalog = str(env.get("OPENCLAW_MODEL_CATALOG_JSON") or "").strip() catalog: list[Dict[str, Any]] = [] + if raw_catalog: + try: + parsed_catalog = json.loads(raw_catalog) + except Exception: + parsed_catalog = [] + if isinstance(parsed_catalog, list): + catalog = [dict(item) for item in parsed_catalog if isinstance(item, dict)] + seen: set[str] = set() + for item in catalog: + model_key = str(item.get("id") or item.get("name") or "").strip() + if model_key: + seen.add(model_key) + + changed = False for raw_model in raw_models: if not isinstance(raw_model, dict): raw_model = {"id": str(raw_model or "").strip()} @@ -615,12 +642,25 @@ def _apply_openclaw_provider_model_catalog( if not item: continue model_key = str(item["id"]) - if model_key in seen: + existing_index = next( + ( + index + for index, existing in enumerate(catalog) + if str(existing.get("id") or existing.get("name") or "").strip() == model_key + ), + None, + ) + if existing_index is not None: + merged = {**catalog[existing_index], **item} + if merged != catalog[existing_index]: + catalog[existing_index] = merged + changed = True continue seen.add(model_key) catalog.append(item) + changed = True - if not catalog: + if not catalog or not changed: return False env["OPENCLAW_MODEL_CATALOG_JSON"] = json.dumps( catalog, @@ -716,7 +756,7 @@ def _build_openclaw_env_vars( model_api_key or _resolve_env("OPENCLAW_MODEL_API_KEY", "OPENAI_API_KEY", "LLM_API_KEY", "MODEL_API_KEY") ) - model = model_preference or "glm-5.1" + model = model_preference or "glm-5.2" explicit_provider_id = model_provider_id or _resolve_env("OPENCLAW_MODEL_PROVIDER_ID") inferred_provider_id = explicit_provider_id if not inferred_provider_id and model and "/" in model: @@ -906,7 +946,8 @@ def _build_openclaw_env_vars( if passthrough_value: env[passthrough_key] = passthrough_value - return _normalize_openclaw_gateway_auth_env(env) + env = _normalize_openclaw_gateway_auth_env(env) + return build_runtime_model_policy_env(env, runtime="openclaw") def _normalize_allowed_origins(raw: str) -> str: @@ -1487,6 +1528,7 @@ async def _run_weixin_remote_cli_login( api_key=_openclaw_terminal_api_key(detail), mode="exec", argv=WEIXIN_REMOTE_LOGIN_ARGV, + exec_policy=OPENCLAW_TERMINAL_EXEC_POLICY, ) if exit_code: raise OpenClawGatewayError(f"微信远端登录流程执行失败,exit_code={exit_code}") @@ -3416,21 +3458,31 @@ async def _deploy_openclaw( if network_payload: request_data["network"] = network_payload - # KCR 凭证:仅在显式提供用户名+密码时注入,避免公共镜像触发无效鉴权重试。 + # 镜像凭证:按目标镜像地址判断仓库类型,避免企业版/第三方误用 KSYUN_ACCOUNT_ID。 image_credential = None - kcr_username = _resolve_env("KCR_USERNAME", "KSYUN_ACCOUNT_ID") - kcr_password = _resolve_env("KCR_PASSWORD") + kcr_username, kcr_password, registry_kind = resolve_registry_credentials( + image_ref, + environ=_openclaw_registry_env(), + ) if kcr_username and kcr_password: image_credential = { "username": kcr_username, "password": kcr_password, } request_data["image_credential"] = image_credential - elif kcr_password and not kcr_username: - print_warn("检测到 KCR_PASSWORD 但缺少 KCR_USERNAME,已忽略镜像凭证") + elif kcr_password and not kcr_username and registry_kind != "personal_kcr": + print_warn( + f"检测到 KCR_PASSWORD 但缺少 KCR_USERNAME,已忽略{registry_kind_label(registry_kind)}镜像凭证;" + "企业版 KCR 和第三方镜像仓库必须配置 KCR_USERNAME + KCR_PASSWORD" + ) elif "/agentengine-public/" not in image_ref: - print_warn("未配置 KCR_PASSWORD,私有镜像可能无法拉取 (公共镜像可忽略)") - print_info("获取方式: https://kcr.console.ksyun.com/ → 访问凭证") + if registry_kind == "personal_kcr": + print_warn("未配置个人版 KCR 镜像凭证 (KSYUN_ACCOUNT_ID/KCR_PASSWORD),私有镜像可能无法拉取") + else: + print_warn( + f"未配置{registry_kind_label(registry_kind)}镜像凭证 " + "(KCR_USERNAME/KCR_PASSWORD),私有镜像可能无法拉取" + ) if dry_run: async with AgentEngineClient(region=region, dry_run=True) as client: diff --git a/ksadk/configs/__init__.py b/ksadk/configs/__init__.py index 7c75af7e..729cf76b 100644 --- a/ksadk/configs/__init__.py +++ b/ksadk/configs/__init__.py @@ -12,8 +12,8 @@ Agent: AGENT_ID > AGENT_RUNTIME_ID 默认值: - OPENAI_API_BASE: 金山云模型服务公开地址 - MODEL_NAME: glm-5.1 + OPENAI_API_BASE: 自动检测内网/外网,使用金山云模型服务 + MODEL_NAME: glm-5.2 """ from ksadk.configs.settings import ( @@ -27,8 +27,12 @@ KingsoftCloudConfig, CodeModeConfig, OTelConfig, + # 通用网络检测工具 + check_endpoint_reachable, # KSPMAS 服务 get_kspmas_api_base, + KSPMAS_INTERNAL_HOST, + KSPMAS_INTERNAL_URL, KSPMAS_PUBLIC_URL, DEFAULT_MODEL_NAME, setup_environment, @@ -45,8 +49,12 @@ "KingsoftCloudConfig", "CodeModeConfig", "OTelConfig", + # 网络工具 (通用) + "check_endpoint_reachable", # KSPMAS 服务 "get_kspmas_api_base", + "KSPMAS_INTERNAL_HOST", + "KSPMAS_INTERNAL_URL", "KSPMAS_PUBLIC_URL", "DEFAULT_MODEL_NAME", "setup_environment", diff --git a/ksadk/configs/global_config.py b/ksadk/configs/global_config.py index 10be673e..753b68e5 100644 --- a/ksadk/configs/global_config.py +++ b/ksadk/configs/global_config.py @@ -9,7 +9,7 @@ "model": { "OPENAI_API_KEY": "sk-xxx", "OPENAI_BASE_URL": "http://kspmas.ksyun.com/v1", - "OPENAI_MODEL_NAME": "glm-5.1" + "OPENAI_MODEL_NAME": "glm-5.2" }, "cloud": { "KSYUN_ACCESS_KEY": "AKxxx", diff --git a/ksadk/configs/settings.py b/ksadk/configs/settings.py index a82092bb..54a0e607 100644 --- a/ksadk/configs/settings.py +++ b/ksadk/configs/settings.py @@ -39,32 +39,134 @@ def _get_env(*keys: str, default: str = None) -> Optional[str]: return default +# ============================================================================= +# 网络检测工具 (通用) +# ============================================================================= + +# 缓存检测结果 {host: is_reachable} +_endpoint_cache: Dict[str, bool] = {} + + +def _is_internal_runtime_env() -> bool: + return any( + os.getenv(key) + for key in ( + "AGENT_RUNTIME_ID", + "K_SERVICE", + "KUBERNETES_SERVICE_HOST", + ) + ) + + +def _is_public_kspmas_url(url: Optional[str]) -> bool: + value = (url or "").strip().rstrip("/") + if not value: + return False + return "kspmas.ksyun.com/v1" in value + + +def check_endpoint_reachable(host: str, port: int = 80, timeout: float = 1.0) -> bool: + """检测指定端点是否可达 + + 通用函数,可用于检测任意服务的内网地址是否可达。 + 结果会被缓存,相同 host 只检测一次。 + + Args: + host: 主机名或 IP 地址 + port: 端口号 (默认 80) + timeout: 超时时间 (默认 1.0 秒) + + Returns: + True 如果可达, False 否则 + + Usage: + # KSPMAS 内网检测 + if check_endpoint_reachable("kspmas-internal.sdns.ksyun.com"): + api_base = "http://kspmas-internal.sdns.ksyun.com/v1" + + # KS3 内网检测 + if check_endpoint_reachable("ks3-cn-beijing-internal.ksyuncs.com"): + ks3_endpoint = "http://ks3-cn-beijing-internal.ksyuncs.com" + """ + if host in _endpoint_cache: + return _endpoint_cache[host] + + try: + import socket + # 尝试建立 TCP 连接 (比 ping 更可靠,且不依赖 ICMP) + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(timeout) + s.connect((host, port)) + s.close() + + _endpoint_cache[host] = True + logger.debug(f"Endpoint reachable: {host}:{port}") + except (socket.timeout, socket.error, OSError): + _endpoint_cache[host] = False + logger.debug(f"Endpoint not reachable: {host}:{port}") + + return _endpoint_cache[host] + + # ============================================================================= # 金山云模型服务 (KSPMAS) # ============================================================================= # 服务地址 +KSPMAS_INTERNAL_HOST = "kspmas-internal.sdns.ksyun.com" +KSPMAS_INTERNAL_URL = f"http://{KSPMAS_INTERNAL_HOST}/v1" KSPMAS_PUBLIC_URL = "https://kspmas.ksyun.com/v1" # 默认模型 -DEFAULT_MODEL_NAME = "glm-5.1" +DEFAULT_MODEL_NAME = "glm-5.2" def optimize_kspmas_url(url: str) -> str: """优化 KSPMAS URL - 公开 SDK 默认保留用户配置的公开地址。托管环境如需使用专有内网地址, - 应通过运行时环境变量显式注入,不在开源代码中写死内部 endpoint。 + 如果是 KSPMAS 的公网地址,且检测到内网可达(或在 Serverless 环境), + 将其替换为内网地址以提高速度和稳定性。 """ + if not url: + return url + + # 仅优化 KSPMAS 域名 + if "kspmas.ksyun.com" in url: + # 检测是否应该使用内网 + use_internal = False + + # 1. 托管 / 集群环境优先使用内网 + if _is_internal_runtime_env(): + use_internal = True + # 2. 自动检测内网可达性 + elif check_endpoint_reachable(KSPMAS_INTERNAL_HOST): + use_internal = True + + if use_internal: + # 替换域名 (保持协议和路径不变) + # https://kspmas.ksyun.com/v1 -> http://kspmas-internal.sdns.ksyun.com/v1 + # 注意: 内网通常是 http + return url.replace("https://kspmas.ksyun.com", f"http://{KSPMAS_INTERNAL_HOST}") \ + .replace("http://kspmas.ksyun.com", f"http://{KSPMAS_INTERNAL_HOST}") + return url def get_kspmas_api_base() -> str: """获取 KSPMAS 模型服务的 API Base URL - 默认使用公开地址;如需专有网络 endpoint,请设置 OPENAI_BASE_URL / - OPENAI_API_BASE / LLM_API_BASE / MODEL_API_BASE。 + 自动检测内网可达性,优先使用内网地址。 + **特殊逻辑**: 如果在 Serverless 托管环境 (AGENT_RUNTIME_ID 存在),强制使用内网地址。 """ + # 1. 托管 / 集群环境强制使用内网 + if _is_internal_runtime_env(): + return KSPMAS_INTERNAL_URL + + # 2. 自动检测内网可达性 + if check_endpoint_reachable(KSPMAS_INTERNAL_HOST): + return KSPMAS_INTERNAL_URL + + # 3. 默认使用公网 return KSPMAS_PUBLIC_URL @@ -93,8 +195,8 @@ class ModelConfig: OPENAI_API_BASE - API Base URL 别名 默认值: - OPENAI_BASE_URL: 金山云模型服务公开地址 - MODEL_NAME: glm-5.1 + OPENAI_BASE_URL: 自动检测内网/外网,使用金山云模型服务 + MODEL_NAME: glm-5.2 """ @property @@ -110,7 +212,8 @@ def api_key(self) -> Optional[str]: def api_base(self) -> str: """获取模型 API Base URL - 如果未配置,默认使用金山云模型服务公开地址。 + 如果未配置,默认使用金山云模型服务地址(自动检测内网)。 + 如果已配置且为 KSPMAS 公网地址,也会尝试优化为内网地址。 """ configured = _get_env( "OPENAI_BASE_URL", # OpenAI 标准 (优先) @@ -128,7 +231,7 @@ def api_base(self) -> str: def model_name(self) -> str: """获取模型名称 - 如果未配置,默认使用 glm-5.1。 + 如果未配置,默认使用 glm-5.2。 """ configured = _get_env( "OPENAI_MODEL_NAME", # OpenAI 格式 (优先) @@ -145,7 +248,7 @@ def is_configured(self) -> bool: @property def is_using_internal_network(self) -> bool: """是否使用内网地址""" - return False + return KSPMAS_INTERNAL_URL in self.api_base def to_dict(self) -> dict: """转换为字典 (用于 LiteLLM 等)""" @@ -546,6 +649,14 @@ def setup_environment(agent_path: "Path"): # override=False: 保留通过 API/Serverless 平台注入的环境变量 (优先级高) load_dotenv(env_file, override=False) + # 1.5. 托管运行时里不要保留公网 KSPMAS 地址,否则用户代码优先读取 OPENAI_BASE_URL + # 会绕开后续的内网自动探测,导致 Serverless Pod 访问公网模型网关超时。 + if _is_internal_runtime_env(): + for env_key in ("OPENAI_BASE_URL", "OPENAI_API_BASE"): + raw_value = os.getenv(env_key, "") + if _is_public_kspmas_url(raw_value): + os.environ.pop(env_key, None) + # 2. Coze SDK 兼容映射 # 某些 Coze 导出项目(tool 内使用 coze_coding_dev_sdk)会强依赖 COZE_* 环境变量, # 本地通常只配置了 OPENAI_*,这里做一次非覆盖式映射。 diff --git a/ksadk/conversations/attachment_storage.py b/ksadk/conversations/attachment_storage.py new file mode 100644 index 00000000..d2e5a4a9 --- /dev/null +++ b/ksadk/conversations/attachment_storage.py @@ -0,0 +1,379 @@ +from __future__ import annotations + +import asyncio +import json +import logging +import mimetypes +import os +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from typing import Any, Optional + +from ksadk.common.constants import get_ks3_endpoints +from ksadk.sessions.local_service import resolve_local_session_dir + +logger = logging.getLogger(__name__) + +UPLOAD_URI_SCHEME = "ksadk-upload://" +HOSTED_UPLOAD_URI_SCHEME = "ae-upload://" +UPLOAD_METADATA_SUFFIX = ".meta.json" + + +@dataclass(frozen=True) +class AttachmentBytes: + data: bytes + display_name: str + mime_type: str + local_path: Optional[Path] = None + + +def uploads_dir() -> Path: + path = resolve_local_session_dir() / "files" + path.mkdir(parents=True, exist_ok=True) + return path + + +def sanitize_name(raw_name: str | None, *, fallback: str) -> str: + candidate = Path(str(raw_name or "").strip()).name + return candidate or fallback + + +def guess_mime_type(display_name: str) -> str: + guessed, _ = mimetypes.guess_type(display_name) + return guessed or "application/octet-stream" + + +def parse_file_id(file_uri: str) -> str: + normalized = str(file_uri or "").strip() + if normalized.startswith(UPLOAD_URI_SCHEME): + return normalized.removeprefix(UPLOAD_URI_SCHEME).strip("/") + if normalized.startswith(HOSTED_UPLOAD_URI_SCHEME): + return normalized.removeprefix(HOSTED_UPLOAD_URI_SCHEME).strip("/") + return "" + + +def is_runtime_upload_uri(file_uri: str) -> bool: + return str(file_uri or "").strip().startswith(UPLOAD_URI_SCHEME) + + +def is_hosted_upload_uri(file_uri: str) -> bool: + return str(file_uri or "").strip().startswith(HOSTED_UPLOAD_URI_SCHEME) + + +def _content_type_without_params(value: str | None) -> str: + return str(value or "").split(";", 1)[0].strip() or "application/octet-stream" + + +def _download_hosted_attachment(file_uri: str) -> AttachmentBytes | None: + if not is_hosted_upload_uri(file_uri): + return None + try: + from ksadk.api.client import AgentEngineClient + + content = AgentEngineClient().download_attachment_content(file_uri) + except Exception as exc: + logger.warning("Hosted attachment download failed: %s", exc) + return None + file_id = parse_file_id(file_uri) + display_name = sanitize_name(content.display_name, fallback=file_id or "uploaded_file") + return AttachmentBytes( + data=content.data, + display_name=display_name, + mime_type=( + _content_type_without_params(content.content_type) + or guess_mime_type(display_name) + ), + ) + + +def default_bucket_name() -> str: + explicit = os.getenv("KS3_BUCKET", "").strip() + if explicit: + return explicit + account_id = os.getenv("KSYUN_ACCOUNT_ID", "").strip() + region = os.getenv("KS3_REGION") or os.getenv("KSYUN_REGION") or "cn-beijing-6" + if account_id: + return f"agentengine-{account_id}-{region}" + return "" + + +def _region() -> str: + return os.getenv("KS3_REGION") or os.getenv("KSYUN_REGION") or "cn-beijing-6" + + +class AttachmentStorageService: + def __init__(self, *, root_dir: Path | None = None): + self.root_dir = root_dir or uploads_dir() + + async def store( + self, + *, + data: bytes, + file_id: str, + display_name: str | None, + mime_type: str | None, + ) -> tuple[str, Path]: + return await asyncio.to_thread( + self.store_sync, + data=data, + file_id=file_id, + display_name=display_name, + mime_type=mime_type, + ) + + def store_sync( + self, + *, + data: bytes, + file_id: str, + display_name: str | None, + mime_type: str | None, + ) -> tuple[str, Path]: + self.root_dir.mkdir(parents=True, exist_ok=True) + safe_name = sanitize_name(display_name, fallback=file_id or "uploaded_file") + resolved_mime = str(mime_type or "").strip() or guess_mime_type(safe_name) + local_path = self._local_path(file_id=file_id, display_name=safe_name) + local_path.write_bytes(data) + + bucket = default_bucket_name() + object_key = self._object_key(file_id=file_id, display_name=safe_name) + metadata = { + "file_id": file_id, + "backend": "local", + "bucket": bucket, + "object_key": object_key, + "region": _region(), + "local_path": str(local_path), + "display_name": safe_name, + "mime_type": resolved_mime, + "size_bytes": len(data), + "fallback_reason": "", + } + try: + self._run_async_sync( + self._put_ks3_object( + bucket=bucket, + object_key=object_key, + data=data, + mime_type=resolved_mime, + ) + ) + metadata["backend"] = "ks3" + except Exception as exc: + logger.warning("KS3 attachment upload failed, using local fallback: %s", exc) + metadata["fallback_reason"] = str(exc) + + self._write_metadata(file_id, metadata) + return f"{UPLOAD_URI_SCHEME}{file_id}", local_path + + def read(self, file_uri: str) -> AttachmentBytes | None: + file_id = parse_file_id(file_uri) + if not file_id: + return None + if is_hosted_upload_uri(file_uri): + hosted = _download_hosted_attachment(file_uri) + if hosted is None: + return None + metadata = { + "file_id": file_id, + "backend": "hosted", + "display_name": hosted.display_name, + "mime_type": hosted.mime_type, + "size_bytes": len(hosted.data), + "local_path": str( + self._local_path(file_id=file_id, display_name=hosted.display_name) + ), + } + local_path = self._restore_local_cache(file_id, metadata, hosted.data) + self._write_metadata(file_id, metadata | {"local_path": str(local_path)}) + return AttachmentBytes( + data=hosted.data, + display_name=hosted.display_name, + mime_type=hosted.mime_type, + local_path=local_path, + ) + metadata = self._read_metadata(file_id) + if metadata: + if metadata.get("backend") == "ks3": + try: + raw = self._run_async_sync( + self._read_ks3_object( + bucket=str(metadata.get("bucket") or ""), + object_key=str(metadata.get("object_key") or ""), + ) + ) + return AttachmentBytes( + data=raw, + display_name=str(metadata.get("display_name") or file_id), + mime_type=str(metadata.get("mime_type") or guess_mime_type(file_id)), + local_path=self._restore_local_cache(file_id, metadata, raw), + ) + except Exception as exc: + logger.warning("KS3 attachment read failed, trying local cache: %s", exc) + + local_path = Path(str(metadata.get("local_path") or "")) + if local_path.is_file(): + try: + raw = local_path.read_bytes() + except OSError: + raw = None + if raw is not None: + return AttachmentBytes( + data=raw, + display_name=str(metadata.get("display_name") or local_path.name), + mime_type=str(metadata.get("mime_type") or guess_mime_type(local_path.name)), + local_path=local_path, + ) + + legacy = self.resolve_legacy_local_path(file_id) + if legacy is None: + return None + try: + raw = legacy.read_bytes() + except OSError: + return None + return AttachmentBytes( + data=raw, + display_name=legacy.name, + mime_type=guess_mime_type(legacy.name), + local_path=legacy, + ) + + def ensure_local_path(self, file_uri: str) -> Path | None: + loaded = self.read(file_uri) + if loaded is None: + return None + if loaded.local_path and loaded.local_path.is_file(): + return loaded.local_path + file_id = parse_file_id(file_uri) + metadata = self._read_metadata(file_id) + return self._restore_local_cache(file_id, metadata, loaded.data) if metadata else None + + def resolve_legacy_local_path(self, file_id: str) -> Path | None: + direct = self.root_dir / file_id + if direct.is_file(): + return direct.resolve() + for candidate in sorted(self.root_dir.glob(f"{file_id}*")): + if candidate.is_file() and not candidate.name.endswith(UPLOAD_METADATA_SUFFIX): + return candidate.resolve() + return None + + async def _put_ks3_object( + self, + *, + bucket: str, + object_key: str, + data: bytes, + mime_type: str, + ) -> None: + await asyncio.to_thread(self._put_ks3_object_sync, bucket, object_key, data, mime_type) + + async def _read_ks3_object(self, *, bucket: str, object_key: str) -> bytes: + return await asyncio.to_thread(self._read_ks3_object_sync, bucket, object_key) + + def _put_ks3_object_sync(self, bucket: str, object_key: str, data: bytes, mime_type: str) -> None: + if not bucket: + raise ValueError("KS3 bucket is not available") + from ks3.connection import Connection + + ak = os.getenv("KSYUN_ACCESS_KEY") or os.getenv("KS3_ACCESS_KEY") + sk = os.getenv("KSYUN_SECRET_KEY") or os.getenv("KS3_SECRET_KEY") + if not ak or not sk: + raise ValueError("KS3 credentials are not configured") + conn = Connection(ak, sk, host=self._endpoint()) + ks3_bucket = self._ensure_bucket(conn, bucket) + key = ks3_bucket.new_key(object_key) + key.set_contents_from_string( + data, + headers={"Content-Type": mime_type} if mime_type else None, + ) + + def _read_ks3_object_sync(self, bucket: str, object_key: str) -> bytes: + if not bucket or not object_key: + raise FileNotFoundError(object_key) + from ks3.connection import Connection + + ak = os.getenv("KSYUN_ACCESS_KEY") or os.getenv("KS3_ACCESS_KEY") + sk = os.getenv("KSYUN_SECRET_KEY") or os.getenv("KS3_SECRET_KEY") + if not ak or not sk: + raise ValueError("KS3 credentials are not configured") + conn = Connection(ak, sk, host=self._endpoint()) + key = conn.get_bucket(bucket).get_key(object_key) + if key is None: + raise FileNotFoundError(object_key) + return key.get_contents_as_string() + + @staticmethod + def _ensure_bucket(conn: Any, bucket_name: str): + try: + bucket = conn.get_bucket(bucket_name) + list(bucket.list(max_keys=1)) + return bucket + except Exception as exc: + text = str(exc) + if "NoSuchBucket" not in text and "404" not in text: + raise + return conn.create_bucket(bucket_name) + + @staticmethod + def _endpoint() -> str: + _public, internal = get_ks3_endpoints(_region()) + return internal + + def _metadata_path(self, file_id: str) -> Path: + return self.root_dir / f"{file_id}{UPLOAD_METADATA_SUFFIX}" + + def _write_metadata(self, file_id: str, metadata: dict[str, Any]) -> None: + self._metadata_path(file_id).write_text( + json.dumps(metadata, ensure_ascii=False), + encoding="utf-8", + ) + + def _read_metadata(self, file_id: str) -> dict[str, Any]: + if not file_id: + return {} + metadata_path = self._metadata_path(file_id) + if not metadata_path.is_file(): + return {} + try: + payload = json.loads(metadata_path.read_text(encoding="utf-8")) + except Exception: + return {} + return payload if isinstance(payload, dict) else {} + + def _local_path(self, *, file_id: str, display_name: str) -> Path: + suffix = Path(display_name).suffix + local_name = file_id if suffix and file_id.endswith(suffix) else f"{file_id}{suffix}" + return self.root_dir / local_name + + def _restore_local_cache(self, file_id: str, metadata: dict[str, Any], data: bytes) -> Path: + local_path = Path(str(metadata.get("local_path") or "")) + if not local_path: + local_path = self._local_path( + file_id=file_id, + display_name=str(metadata.get("display_name") or file_id), + ) + local_path.parent.mkdir(parents=True, exist_ok=True) + local_path.write_bytes(data) + return local_path + + @staticmethod + def _object_key(*, file_id: str, display_name: str) -> str: + suffix = Path(display_name).suffix + stored_name = file_id if suffix and file_id.endswith(suffix) else f"{file_id}{suffix}" + day = datetime.utcnow().strftime("%Y/%m/%d") + return f"agents/_runtime/attachments/{day}/{stored_name}" + + @staticmethod + def _run_async_sync(coro): + try: + loop = asyncio.get_running_loop() + except RuntimeError: + return asyncio.run(coro) + if loop.is_running(): + import concurrent.futures + + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor: + return executor.submit(lambda: asyncio.run(coro)).result() + return loop.run_until_complete(coro) diff --git a/ksadk/conversations/attachments.py b/ksadk/conversations/attachments.py index 51958ced..be16a9c4 100644 --- a/ksadk/conversations/attachments.py +++ b/ksadk/conversations/attachments.py @@ -9,6 +9,12 @@ from typing import Any, Dict, List, Mapping, Optional from xml.etree import ElementTree as ET +from ksadk.conversations.attachment_storage import ( + AttachmentStorageService, + is_hosted_upload_uri, + is_runtime_upload_uri, + parse_file_id, +) from ksadk.sessions.local_service import resolve_local_session_dir _TEXT_MIME_PREFIXES = ("text/",) @@ -136,11 +142,15 @@ def resolve_attachment_storage_path(file_uri: str) -> Optional[Path]: return None return resolved - if normalized_uri.startswith(_UPLOAD_URI_SCHEME): - file_id = normalized_uri.removeprefix(_UPLOAD_URI_SCHEME).strip("/") + if is_runtime_upload_uri(normalized_uri) or is_hosted_upload_uri(normalized_uri): + file_id = parse_file_id(normalized_uri) if not file_id: return None + restored = AttachmentStorageService().ensure_local_path(normalized_uri) + if restored is not None and restored.is_file(): + return restored.resolve() + for candidate in sorted(resolve_uploads_dir().glob(f"{file_id}*")): if candidate.is_file(): return candidate.resolve() diff --git a/ksadk/conversations/model_options.py b/ksadk/conversations/model_options.py index bc6b99f7..8f2604ee 100644 --- a/ksadk/conversations/model_options.py +++ b/ksadk/conversations/model_options.py @@ -5,6 +5,7 @@ _VALID_REASONING_EFFORTS = {"none", "minimal", "low", "medium", "high", "xhigh"} _CHAT_COMPLETIONS_REASONING_EFFORTS = {"low", "medium", "high"} +_PASSTHROUGH_OPTION_KEYS = {"temperature", "top_p", "max_tokens", "max_completion_tokens"} def _normalized_effort(value: Any) -> str | None: @@ -86,6 +87,9 @@ def normalize_model_options(model_options: Mapping[str, Any] | None) -> dict[str def model_options_for_chat_completions(model_options: Mapping[str, Any] | None) -> dict[str, Any]: normalized = normalize_model_options(model_options) payload: dict[str, Any] = {} + for key in _PASSTHROUGH_OPTION_KEYS: + if key in normalized: + payload[key] = normalized[key] reasoning = normalized.get("reasoning") if isinstance(reasoning, Mapping): effort = _normalized_effort(reasoning.get("effort")) @@ -103,6 +107,9 @@ def model_options_for_chat_completions(model_options: Mapping[str, Any] | None) def model_options_for_responses(model_options: Mapping[str, Any] | None) -> dict[str, Any]: normalized = normalize_model_options(model_options) payload: dict[str, Any] = {} + for key in _PASSTHROUGH_OPTION_KEYS: + if key in normalized: + payload[key] = normalized[key] reasoning = normalized.get("reasoning") if isinstance(reasoning, Mapping): effort = _normalized_effort(reasoning.get("effort")) diff --git a/ksadk/conversations/runtime.py b/ksadk/conversations/runtime.py index a2d2e6ac..40dca362 100644 --- a/ksadk/conversations/runtime.py +++ b/ksadk/conversations/runtime.py @@ -35,6 +35,7 @@ compact_attachment_for_session, normalize_kop_messages, ) +from ksadk.conversations.reasoning_markup import strip_reasoning_markup from ksadk.conversations.semantic_summary import ( extract_pinned_state, find_pinned_group_indexes, @@ -52,9 +53,13 @@ ) from ksadk.knowledge_base.service import KnowledgeBaseService from ksadk.memory.service import LongTermMemoryService +from ksadk.model_policy import fallback_model_for_exception, model_policy_options_for_model from ksadk.runtime_context import PlatformInvocationContext, platform_invocation_scope from ksadk.sessions import Session, SessionEvent, resolve_session_service -from ksadk.tools.gateway import approval_interrupt_info_from_result +from ksadk.tools.gateway import ( + approval_interrupt_info_from_result, + build_tool_receipt_idempotency_key, +) AUTOCOMPACT_KEEP_TAIL_GROUPS = 4 PTL_RETRY_KEEP_TAIL_GROUPS = 2 @@ -354,6 +359,19 @@ def extract_responses_resume_input(input_payload: Any) -> dict[str, Any] | None: for item in candidates: item_type = str(item.get("type") or "").strip() + if item_type == "agentengine.resume_checkpoint": + resume_input = {"type": "agentengine.resume_checkpoint"} + for key in ( + "run_id", + "checkpoint_id", + "resume_attempt_id", + "framework", + "framework_ref", + ): + if key in item: + resume_input[key] = item.get(key) + return resume_input + if item_type == "mcp_approval_response": resume_input: dict[str, Any] = {"type": "mcp_approval_response"} if item.get("id"): @@ -857,12 +875,20 @@ def _build_runner_request_payload( "platform_context": runtime_context.to_payload(), "kb_context": runtime_context.kb_context, "memory_context": runtime_context.memory_context, + "invocation_id": prepared.invocation_id, } if prepared.instructions: payload["instructions"] = prepared.instructions if prepared.resume_input is not None: - payload["input"] = prepared.resume_input - payload["resume"] = True + if _is_checkpoint_resume_input(prepared.resume_input): + payload["input"] = prepared.resume_input + payload["checkpoint_resume"] = True + payload["run_id"] = str(prepared.resume_input.get("run_id") or "") + payload["checkpoint_id"] = str(prepared.resume_input.get("checkpoint_id") or "") + payload["framework_ref"] = dict(prepared.resume_input.get("framework_ref") or {}) + else: + payload["input"] = prepared.resume_input + payload["resume"] = True previous_response_id = prepared.request_metadata.get("previous_response_id") if previous_response_id: payload["previous_response_id"] = str(previous_response_id) @@ -935,7 +961,7 @@ def _memory_turn_event_strings( ) ) - assistant_text = str(output_text or "").strip() + assistant_text = strip_reasoning_markup(str(output_text or "")).strip() if assistant_text: event_strings.append( json.dumps( @@ -1094,6 +1120,19 @@ def _pending_approval_events(events: Sequence[SessionEvent]) -> list[SessionEven return pending +def _approval_request_events(events: Sequence[SessionEvent]) -> list[SessionEvent]: + return [ + event + for event in events + if canonical_event_type( + event.event_type, + author=event.author, + role=str((event.content or {}).get("role") or ""), + ) + == "approval_request" + ] + + def _parse_approval_arguments(value: Any) -> dict[str, Any]: if isinstance(value, Mapping): return dict(value) @@ -1134,6 +1173,8 @@ def _approval_decision_from_resume(resume_input: Mapping[str, Any]) -> dict[str, def _normalize_approval_resume_input( resume_input: Mapping[str, Any], events: Sequence[SessionEvent], + *, + include_resolved: bool = False, ) -> dict[str, Any]: normalized = dict(resume_input) if not _is_approval_resume_input(normalized): @@ -1144,7 +1185,7 @@ def _normalize_approval_resume_input( or normalized.get("interrupt_id") or "" ) - pending_events = _pending_approval_events(events) + pending_events = _approval_request_events(events) if include_resolved else _pending_approval_events(events) matched_event = None for event in reversed(pending_events): if not approval_request_id or _approval_request_id_from_event(event) == approval_request_id: @@ -1205,6 +1246,123 @@ def _builtin_tool_callable(tool_name: str): return None +def _tool_receipt_metadata( + *, + session_id: str, + run_id: str, + tool_name: str, + tool_args: Mapping[str, Any], + tool_call_id: str | None = None, + checkpoint_id: str | None = None, + framework: str | None = None, + framework_ref: Mapping[str, Any] | None = None, + status: str = "completed", +) -> dict[str, Any]: + idempotency_key = build_tool_receipt_idempotency_key( + session_id=session_id, + run_id=run_id, + checkpoint_id=checkpoint_id, + tool_call_id=tool_call_id, + tool_name=tool_name, + tool_args=tool_args, + ) + return { + "receipt_id": f"tr_{idempotency_key.removeprefix('tool_receipt:')[:24]}", + "idempotency_key": idempotency_key, + "tool_name": tool_name, + "tool_call_id": tool_call_id or run_id, + "run_id": run_id, + "checkpoint_id": checkpoint_id or "", + "framework": framework or "", + "framework_ref": dict(framework_ref or {}), + "status": status, + "created_at": time.time(), + } + + +def _tool_receipt_status_from_output(output: Any) -> str: + if not isinstance(output, Mapping): + return "failed" + status = str(output.get("status") or "").strip().lower() + if status == "accepted_not_extracted": + return "completed" + return "completed" if output.get("ok") is not False else "failed" + + +def _tool_resume_run_id(resume_input: Mapping[str, Any]) -> str: + return str( + resume_input.get("run_id") + or resume_input.get("call_id") + or resume_input.get("approval_request_id") + or resume_input.get("interrupt_id") + or "" + ) + + +def _tool_receipt_idempotency_key_for_resume( + *, + session_id: str, + resume_input: Mapping[str, Any], +) -> str | None: + tool_name = str(resume_input.get("tool_name") or "").strip() + if not tool_name: + return None + tool_args = resume_input.get("tool_args") + if not isinstance(tool_args, Mapping): + return None + run_id = _tool_resume_run_id(resume_input) + if not run_id: + return None + return build_tool_receipt_idempotency_key( + session_id=session_id, + run_id=run_id, + tool_call_id=run_id, + tool_name=tool_name, + tool_args=dict(tool_args), + ) + + +def _find_tool_receipt_event_by_key( + events: Sequence[SessionEvent], + idempotency_key: str, +) -> SessionEvent | None: + for event in reversed(events): + if event.event_type != "tool_result": + continue + metadata = event.metadata or {} + receipt = metadata.get("tool_receipt") + if not isinstance(receipt, Mapping): + continue + if str(receipt.get("idempotency_key") or "") == idempotency_key: + return event + return None + + +def _latest_checkpoint_metadata_for_run( + events: Sequence[SessionEvent], + run_id: str, +) -> dict[str, Any]: + normalized_run_id = str(run_id or "").strip() + if not normalized_run_id: + return {} + for event in reversed(events): + if event.event_type != "run_checkpoint": + continue + metadata = event.metadata or {} + if str(metadata.get("run_id") or "").strip() != normalized_run_id: + continue + checkpoint_id = str(metadata.get("checkpoint_id") or "").strip() + if not checkpoint_id: + continue + framework_ref = metadata.get("framework_ref") + return { + "checkpoint_id": checkpoint_id, + "framework": str(metadata.get("framework") or ""), + "framework_ref": dict(framework_ref) if isinstance(framework_ref, Mapping) else {}, + } + return {} + + async def _execute_approved_builtin_tool_resume( *, session_id: str, @@ -1224,18 +1382,65 @@ async def _execute_approved_builtin_tool_resume( if not isinstance(tool_args, Mapping): return None call_args = dict(tool_args) + run_id = _tool_resume_run_id(resume_input) + service = session_service_provider() + existing_events = await service.get_events(session_id) + checkpoint_metadata = _latest_checkpoint_metadata_for_run(existing_events, run_id) + receipt = _tool_receipt_metadata( + session_id=session_id, + run_id=run_id, + tool_call_id=run_id, + tool_name=tool_name, + tool_args=call_args, + checkpoint_id=checkpoint_metadata.get("checkpoint_id"), + framework=checkpoint_metadata.get("framework"), + framework_ref=checkpoint_metadata.get("framework_ref"), + ) + existing_event = _find_tool_receipt_event_by_key( + existing_events, + receipt["idempotency_key"], + ) + if existing_event is not None: + existing_metadata = existing_event.metadata or {} + output = existing_metadata.get("tool_output", "") + if isinstance(output, Mapping): + output = {**dict(output), "replayed": True} + replayed_receipt = { + **dict((existing_metadata.get("tool_receipt") or receipt)), + "replayed": True, + "replayed_from_event_id": existing_event.id, + } + await append_conversation_event( + session_id=session_id, + author="tool", + role="user", + text=str(output), + invocation_id=invocation_id, + event_type="tool_result", + session_service_provider=session_service_provider, + metadata={ + "tool_name": tool_name, + "tool_args": call_args, + "tool_output": output, + "run_id": run_id, + "approval_request_id": resume_input.get("approval_request_id") + or resume_input.get("interrupt_id"), + "tool_receipt": replayed_receipt, + "replayed": True, + }, + ) + return { + "type": "function_call_output", + "call_id": run_id, + "output": output, + } + try: output = tool_func(**call_args) except Exception as exc: output = {"ok": False, "error_type": type(exc).__name__, "error_message": str(exc)} - run_id = str( - resume_input.get("run_id") - or resume_input.get("call_id") - or resume_input.get("approval_request_id") - or resume_input.get("interrupt_id") - or "" - ) + receipt["status"] = _tool_receipt_status_from_output(output) await append_conversation_event( session_id=session_id, author="tool", @@ -1251,6 +1456,7 @@ async def _execute_approved_builtin_tool_resume( "run_id": run_id, "approval_request_id": resume_input.get("approval_request_id") or resume_input.get("interrupt_id"), + "tool_receipt": receipt, }, ) return { @@ -1268,6 +1474,138 @@ def _is_approval_resume_input(resume_input: Mapping[str, Any]) -> bool: } +def _is_checkpoint_resume_input(resume_input: Mapping[str, Any]) -> bool: + return str(resume_input.get("type") or "").strip() == "agentengine.resume_checkpoint" + + +def _normalize_checkpoint_resume_input(resume_input: Mapping[str, Any]) -> dict[str, Any]: + run_id = str(resume_input.get("run_id") or "").strip() + if not run_id: + raise ValueError("Checkpoint resume requires run_id") + + checkpoint_id = str(resume_input.get("checkpoint_id") or "").strip() + if not checkpoint_id: + raise ValueError("Checkpoint resume requires checkpoint_id") + + framework = str(resume_input.get("framework") or "langgraph").strip() or "langgraph" + raw_framework_ref = resume_input.get("framework_ref") + framework_ref = dict(raw_framework_ref) if isinstance(raw_framework_ref, Mapping) else {} + raw_framework_detail = framework_ref.get(framework) + framework_detail = dict(raw_framework_detail) if isinstance(raw_framework_detail, Mapping) else {} + framework_detail.setdefault("checkpoint_id", checkpoint_id) + if resume_input.get("thread_id") and not framework_detail.get("thread_id"): + framework_detail["thread_id"] = str(resume_input.get("thread_id")) + framework_ref[framework] = framework_detail + + resume_attempt_id = str(resume_input.get("resume_attempt_id") or "").strip() + if not resume_attempt_id: + resume_attempt_id = f"resume_{uuid.uuid4().hex}" + + return { + "type": "agentengine.resume_checkpoint", + "run_id": run_id, + "checkpoint_id": checkpoint_id, + "resume_attempt_id": resume_attempt_id, + "framework": framework, + "framework_ref": framework_ref, + } + + +def _agentengine_resume_metadata(resume_input: Mapping[str, Any] | None) -> dict[str, Any]: + if not resume_input or not _is_checkpoint_resume_input(resume_input): + return {} + return { + "agentengine": { + "action": "resume_checkpoint", + "run_id": str(resume_input.get("run_id") or ""), + "checkpoint_id": str(resume_input.get("checkpoint_id") or ""), + "resume_attempt_id": str(resume_input.get("resume_attempt_id") or ""), + "framework": str(resume_input.get("framework") or ""), + "framework_ref": dict(resume_input.get("framework_ref") or {}), + } + } + + +def _extract_agentengine_metadata(result: Mapping[str, Any] | None) -> dict[str, Any]: + if not result: + return {} + metadata = result.get("metadata") + if not isinstance(metadata, Mapping): + return {} + agentengine = metadata.get("agentengine") + if not isinstance(agentengine, Mapping): + return {} + return {"agentengine": dict(agentengine)} + + +def _checkpoint_event_args_from_agentengine_metadata( + agentengine_metadata: Mapping[str, Any] | None, + *, + fallback_run_id: str, +) -> dict[str, Any] | None: + if not isinstance(agentengine_metadata, Mapping): + return None + framework = str(agentengine_metadata.get("framework") or "langgraph").strip() or "langgraph" + raw_framework_ref = agentengine_metadata.get("framework_ref") + if not isinstance(raw_framework_ref, Mapping): + return None + framework_ref = dict(raw_framework_ref) + raw_framework_detail = framework_ref.get(framework) + if not isinstance(raw_framework_detail, Mapping): + return None + framework_detail = dict(raw_framework_detail) + checkpoint_id = str(framework_detail.get("checkpoint_id") or "").strip() + if not checkpoint_id: + return None + run_id = str(agentengine_metadata.get("run_id") or fallback_run_id or "").strip() + if not run_id: + return None + phase = str(agentengine_metadata.get("phase") or "").strip() + display_metadata = { + key: value + for key, value in agentengine_metadata.items() + if key + not in { + "run_id", + "checkpoint_id", + "framework", + "framework_ref", + "phase", + } + } + return { + "run_id": run_id, + "checkpoint_id": checkpoint_id, + "framework": framework, + "framework_ref": framework_ref, + "phase": phase, + "metadata": display_metadata, + } + + +def _merge_agentengine_metadata( + *metadata_items: Mapping[str, Any] | None, +) -> dict[str, Any]: + merged: dict[str, Any] = {} + for metadata in metadata_items: + if not isinstance(metadata, Mapping): + continue + agentengine = metadata.get("agentengine") + if not isinstance(agentengine, Mapping): + continue + next_agentengine = dict(agentengine) + merged.update(next_agentengine) + if isinstance(agentengine.get("framework_ref"), Mapping): + existing_framework_ref = ( + merged.get("framework_ref") if isinstance(merged.get("framework_ref"), Mapping) else {} + ) + merged["framework_ref"] = { + **dict(existing_framework_ref), + **dict(agentengine.get("framework_ref") or {}), + } + return {"agentengine": merged} if merged else {} + + def _format_resume_response_text(resume_input: Mapping[str, Any]) -> str: item_type = str(resume_input.get("type") or "resume") if item_type == "mcp_approval_response": @@ -1424,7 +1762,7 @@ async def _update_session_metadata_after_assistant_turn( assistant_text: str, model: str | None, ) -> None: - summary = _truncate_text(assistant_text, SESSION_SUMMARY_MAX_CHARS) + summary = _truncate_text(strip_reasoning_markup(assistant_text), SESSION_SUMMARY_MAX_CHARS) if summary: await service.update_session_metadata(session_id, summary=summary) @@ -2062,6 +2400,90 @@ async def append_run_status_event( ) +async def append_run_checkpoint_event( + *, + session_id: str, + author: str, + run_id: str, + checkpoint_id: str, + framework: str, + framework_ref: Mapping[str, Any], + phase: str = "", + invocation_id: Optional[str] = None, + metadata: Optional[dict[str, Any]] = None, + session_service_provider: Callable[[], Any] | None = None, +) -> SessionEvent: + event_metadata = dict(metadata or {}) + event_metadata.update( + { + "run_id": str(run_id), + "checkpoint_id": str(checkpoint_id), + "framework": str(framework), + "framework_ref": dict(framework_ref), + "phase": str(phase or ""), + } + ) + return await append_conversation_event( + session_id=session_id, + author=author, + role="model", + text="checkpoint saved", + invocation_id=invocation_id, + event_type="run_checkpoint", + content={ + "status": "checkpointed", + "run_id": str(run_id), + "checkpoint_id": str(checkpoint_id), + "framework": str(framework), + **({"phase": str(phase)} if phase else {}), + }, + metadata=event_metadata, + session_service_provider=session_service_provider, + ) + + +async def append_run_resume_event( + *, + session_id: str, + author: str, + run_id: str, + checkpoint_id: str, + resume_attempt_id: str, + framework: str, + framework_ref: Mapping[str, Any], + invocation_id: Optional[str] = None, + metadata: Optional[dict[str, Any]] = None, + session_service_provider: Callable[[], Any] | None = None, +) -> SessionEvent: + event_metadata = dict(metadata or {}) + event_metadata.update( + { + "run_id": str(run_id), + "checkpoint_id": str(checkpoint_id), + "resume_attempt_id": str(resume_attempt_id), + "framework": str(framework), + "framework_ref": dict(framework_ref), + } + ) + return await append_conversation_event( + session_id=session_id, + author=author, + role="model", + text="checkpoint resume requested", + invocation_id=invocation_id, + event_type="run_resume", + content={ + "status": "resuming", + "run_id": str(run_id), + "checkpoint_id": str(checkpoint_id), + "resume_attempt_id": str(resume_attempt_id), + "framework": str(framework), + }, + metadata=event_metadata, + session_service_provider=session_service_provider, + ) + + async def append_reasoning_event( *, session_id: str, @@ -2243,7 +2665,11 @@ async def build_run_input( model_metadata=model_metadata, ) normalized_request_metadata = dict(request_metadata or {}) - normalized_model_options = normalize_model_options(model_options) + policy_model = model or os.getenv("OPENAI_MODEL_NAME") or os.getenv("MODEL_NAME") + normalized_model_options = { + **normalize_model_options(model_options), + **model_policy_options_for_model(policy_model), + } normalized_instructions = str(instructions or "").strip() if resume_input is not None: @@ -2251,9 +2677,65 @@ async def build_run_input( raise ValueError("Responses resume input requires session_id") existing_events = await service.get_events(resolved_session_id) normalized_resume_input = dict(resume_input) + if _is_checkpoint_resume_input(normalized_resume_input): + normalized_resume_input = _normalize_checkpoint_resume_input(normalized_resume_input) + await append_run_resume_event( + session_id=resolved_session_id, + author=agent_id, + run_id=str(normalized_resume_input["run_id"]), + checkpoint_id=str(normalized_resume_input["checkpoint_id"]), + resume_attempt_id=str(normalized_resume_input["resume_attempt_id"]), + framework=str(normalized_resume_input["framework"]), + framework_ref=normalized_resume_input["framework_ref"], + invocation_id=resolved_invocation_id, + session_service_provider=provider, + ) + history = build_history_from_events(await service.get_events(resolved_session_id)) + return PreparedConversationTurn( + session_id=resolved_session_id, + invocation_id=resolved_invocation_id, + user_input="", + user_display_input="", + history=history, + input_content=[], + input_messages=[], + user_parts=[], + attachments=[], + attachment_results=[], + current_attachments=[], + current_attachment_results=[], + has_current_files=False, + model_metadata=resolved_model_metadata, + model_options=normalized_model_options, + instructions=normalized_instructions, + request_metadata={ + **normalized_request_metadata, + **_agentengine_resume_metadata(normalized_resume_input), + }, + resume_input=normalized_resume_input, + ) + is_approval_resume = _is_approval_resume_input(normalized_resume_input) + existing_tool_receipt_event = None if is_approval_resume and not _has_pending_approval(existing_events): - raise ValueError("Responses resume input requires a pending approval_request") + replay_candidate = _normalize_approval_resume_input( + normalized_resume_input, + existing_events, + include_resolved=True, + ) + receipt_key = _tool_receipt_idempotency_key_for_resume( + session_id=resolved_session_id, + resume_input=replay_candidate, + ) + if receipt_key: + existing_tool_receipt_event = _find_tool_receipt_event_by_key( + existing_events, + receipt_key, + ) + if existing_tool_receipt_event is not None: + normalized_resume_input = replay_candidate + else: + raise ValueError("Responses resume input requires a pending approval_request") if is_approval_resume: normalized_resume_input = _normalize_approval_resume_input( normalized_resume_input, @@ -2430,6 +2912,7 @@ async def invoke_conversation_once( resume_input: Mapping[str, Any] | None = None, response_id: str | None = None, account_id: str | None = None, + invocation_id: Optional[str] = None, session_service_provider: Callable[[], Any] | None = None, ) -> tuple[str, dict[str, Any]]: """非流式 turn 编排入口。 @@ -2451,6 +2934,7 @@ async def invoke_conversation_once( instructions=instructions, request_metadata=request_metadata, resume_input=resume_input, + invocation_id=invocation_id, session_service_provider=provider, ) ambient_contexts = _build_runner_ambient_contexts( @@ -2501,8 +2985,10 @@ async def invoke_conversation_once( ) result: dict[str, Any] | None = None + last_invoke_error: Exception | None = None for attempt in range(2): try: + last_invoke_error = None runtime_context.history = list(prepared.history) with platform_invocation_scope(runtime_context): result = await runner.invoke( @@ -2513,6 +2999,16 @@ async def invoke_conversation_once( ) ) break + except asyncio.CancelledError: + await append_run_status_event( + session_id=prepared.session_id, + author=runner_name, + status="cancelled", + invocation_id=prepared.invocation_id, + detail="cancel_requested", + session_service_provider=provider, + ) + raise except Exception as exc: if attempt == 0 and _is_prompt_too_long_error(exc): checkpoint = await compact_conversation_history( @@ -2530,6 +3026,25 @@ async def invoke_conversation_once( prepared = await _refresh_history(prepared, session_service_provider=provider) runtime_context.history = list(prepared.history) continue + fallback_model = fallback_model_for_exception(exc, current_model=model) + if attempt == 0 and fallback_model: + model = fallback_model + runtime_context.model = fallback_model + runtime_context.model_options = { + **prepared.model_options, + **model_policy_options_for_model(fallback_model), + } + prepare_runner(runner, fallback_model) + await append_run_status_event( + session_id=prepared.session_id, + author=runner_name, + status="in_progress", + invocation_id=prepared.invocation_id, + detail=f"fallback_model:{fallback_model}", + session_service_provider=provider, + ) + continue + last_invoke_error = exc await append_run_status_event( session_id=prepared.session_id, author=runner_name, @@ -2538,16 +3053,45 @@ async def invoke_conversation_once( detail=str(exc), session_service_provider=provider, ) - raise + break + if last_invoke_error is not None: + raise last_invoke_error result = result or {} - output_text = str(result.get("output", "")) + output_text = strip_reasoning_markup(str(result.get("output", ""))) _set_conversation_output_attributes(span, output_text) - assistant_metadata: dict[str, Any] = dict(trace_metadata) + result_agentengine_metadata = _extract_agentengine_metadata(result) + assistant_metadata: dict[str, Any] = { + **trace_metadata, + **_merge_agentengine_metadata(prepared.request_metadata, result_agentengine_metadata), + } if prepared.request_metadata: - assistant_metadata["request_metadata"] = prepared.request_metadata + request_metadata_without_agentengine = { + key: value + for key, value in prepared.request_metadata.items() + if key != "agentengine" + } + if request_metadata_without_agentengine: + assistant_metadata["request_metadata"] = request_metadata_without_agentengine if response_id: assistant_metadata["response_id"] = response_id + checkpoint_args = _checkpoint_event_args_from_agentengine_metadata( + assistant_metadata.get("agentengine") if isinstance(assistant_metadata, Mapping) else None, + fallback_run_id=prepared.invocation_id, + ) + if checkpoint_args: + await append_run_checkpoint_event( + session_id=prepared.session_id, + author=runner_name, + run_id=checkpoint_args["run_id"], + checkpoint_id=checkpoint_args["checkpoint_id"], + framework=checkpoint_args["framework"], + framework_ref=checkpoint_args["framework_ref"], + phase=checkpoint_args.get("phase") or "completed", + invocation_id=prepared.invocation_id, + metadata=checkpoint_args.get("metadata"), + session_service_provider=provider, + ) await append_conversation_event( session_id=prepared.session_id, author=runner_name, @@ -2582,7 +3126,15 @@ async def invoke_conversation_once( result_payload = { "output_text": output_text, "model": model, - "metadata": {**trace_metadata, **prepared.request_metadata}, + "metadata": { + **trace_metadata, + **{ + key: value + for key, value in prepared.request_metadata.items() + if key != "agentengine" + }, + **_merge_agentengine_metadata(prepared.request_metadata, result_agentengine_metadata), + }, } if response_id: result_payload["response_id"] = response_id @@ -2610,6 +3162,7 @@ async def _iter_conversation_turn_events( resume_input: Mapping[str, Any] | None = None, response_id: str | None = None, account_id: str | None = None, + invocation_id: Optional[str] = None, session_service_provider: Callable[[], Any] | None = None, ) -> AsyncIterator[dict[str, Any]]: """Internal semantic event stream shared by protocol serializers.""" @@ -2656,6 +3209,7 @@ async def _iter_conversation_turn_events( instructions=instructions, request_metadata=request_metadata, resume_input=resume_input, + invocation_id=invocation_id, session_service_provider=provider, ) ambient_contexts = _build_runner_ambient_contexts( @@ -2752,6 +3306,7 @@ def _finish_span() -> None: emitted_response_artifacts = False responses_output: list[Any] = [] responses_response_id: str | None = response_id + runner_agentengine_metadata: dict[str, Any] = {} for attempt in range(2): try: runtime_context.history = list(prepared.history) @@ -2770,6 +3325,31 @@ def _finish_span() -> None: except StopAsyncIteration: break chunk_type = chunk.get("type") + if chunk_type == "checkpoint": + chunk_agentengine_metadata = _extract_agentengine_metadata(chunk) + if chunk_agentengine_metadata: + runner_agentengine_metadata.update(chunk_agentengine_metadata) + resume_run_id = "" + if prepared.resume_input and _is_checkpoint_resume_input(prepared.resume_input): + resume_run_id = str(prepared.resume_input.get("run_id") or "").strip() + checkpoint_args = _checkpoint_event_args_from_agentengine_metadata( + runner_agentengine_metadata.get("agentengine"), + fallback_run_id=resume_run_id or prepared.invocation_id, + ) + if checkpoint_args: + await append_run_checkpoint_event( + session_id=prepared.session_id, + author=runner_name, + run_id=checkpoint_args["run_id"], + checkpoint_id=checkpoint_args["checkpoint_id"], + framework=checkpoint_args["framework"], + framework_ref=checkpoint_args["framework_ref"], + phase=checkpoint_args.get("phase") or "stream", + invocation_id=prepared.invocation_id, + metadata=checkpoint_args.get("metadata"), + session_service_provider=provider, + ) + continue if chunk_type == "responses_output": raw_output = chunk.get("output") responses_output = raw_output if isinstance(raw_output, list) else [] @@ -2839,11 +3419,20 @@ def _finish_span() -> None: continue if chunk_type == "tool_result": emitted_response_artifacts = True + tool_name = str(chunk.get("tool_name") or "tool") + tool_args = chunk.get("tool_args", {}) + if not isinstance(tool_args, Mapping): + tool_args = {} + tool_run_id = str(chunk.get("run_id") or prepared.invocation_id) + checkpoint_metadata = _latest_checkpoint_metadata_for_run( + await provider().get_events(prepared.session_id), + tool_run_id, + ) approval_interrupt_info = approval_interrupt_info_from_result( chunk.get("tool_output", ""), - fallback_tool_name=str(chunk.get("tool_name") or "tool"), - tool_args=chunk.get("tool_args", {}), - run_id=chunk.get("run_id"), + fallback_tool_name=tool_name, + tool_args=tool_args, + run_id=tool_run_id, ) if approval_interrupt_info: await append_conversation_event( @@ -2880,9 +3469,25 @@ def _finish_span() -> None: invocation_id=prepared.invocation_id, event_type="tool_result", metadata={ - "tool_name": chunk.get("tool_name"), + "tool_name": tool_name, "tool_output": chunk.get("tool_output", ""), - "run_id": chunk.get("run_id"), + "run_id": tool_run_id, + "tool_receipt": _tool_receipt_metadata( + session_id=prepared.session_id, + run_id=tool_run_id, + tool_call_id=tool_run_id, + tool_name=tool_name, + tool_args=tool_args, + checkpoint_id=checkpoint_metadata.get("checkpoint_id"), + framework=checkpoint_metadata.get("framework"), + framework_ref=checkpoint_metadata.get("framework_ref"), + status=( + "failed" + if isinstance(chunk.get("tool_output"), Mapping) + and chunk.get("tool_output", {}).get("ok") is False + else "completed" + ), + ), }, session_service_provider=provider, ) @@ -2927,6 +3532,21 @@ def _finish_span() -> None: if final_text: accumulated_text = final_text break + except asyncio.CancelledError: + await append_run_status_event( + session_id=prepared.session_id, + author=runner_name, + status="cancelled", + invocation_id=prepared.invocation_id, + detail="cancel_requested", + session_service_provider=provider, + ) + yield { + "type": "cancelled", + "session_id": prepared.session_id, + "metadata": {**trace_metadata, **prepared.request_metadata}, + } + return except Exception as exc: if attempt == 0 and not emitted_anything and _is_prompt_too_long_error(exc): yield {"type": "compaction", "phase": "start", "trigger": "prompt_too_long"} @@ -2954,6 +3574,27 @@ def _finish_span() -> None: prepared = await _refresh_history(prepared, session_service_provider=provider) runtime_context.history = list(prepared.history) continue + if attempt == 0 and not emitted_anything: + fallback_model = fallback_model_for_exception(exc, current_model=model) + if fallback_model: + model = fallback_model + prepare_runner(runner, fallback_model) + prepared.model_options = { + **prepared.model_options, + **model_policy_options_for_model(fallback_model), + } + runtime_context.model = fallback_model + runtime_context.model_options = prepared.model_options + _set_span_attribute(span, "ksadk.model.fallback", fallback_model) + await append_run_status_event( + session_id=prepared.session_id, + author=runner_name, + status="in_progress", + invocation_id=prepared.invocation_id, + detail=f"fallback_model:{fallback_model}", + session_service_provider=provider, + ) + continue await append_run_status_event( session_id=prepared.session_id, author=runner_name, @@ -2965,7 +3606,14 @@ def _finish_span() -> None: yield {"type": "error", "message": str(exc) or "Agent 运行失败"} return - assistant_metadata = {**trace_metadata, **dict(request_metadata or {})} + request_metadata_without_agentengine = { + key: value for key, value in dict(request_metadata or {}).items() if key != "agentengine" + } + assistant_metadata = { + **trace_metadata, + **request_metadata_without_agentengine, + **_merge_agentengine_metadata(request_metadata, runner_agentengine_metadata), + } if responses_output: assistant_metadata["responses_output"] = responses_output if responses_response_id: @@ -3033,6 +3681,7 @@ async def stream_conversation_turn( request_metadata: Mapping[str, Any] | None = None, resume_input: Mapping[str, Any] | None = None, account_id: str | None = None, + invocation_id: Optional[str] = None, session_service_provider: Callable[[], Any] | None = None, ) -> AsyncIterator[str]: """Legacy ksadk response SSE stream used by hosted chat and chat-completions.""" @@ -3051,6 +3700,7 @@ async def stream_conversation_turn( request_metadata=request_metadata, resume_input=resume_input, account_id=account_id, + invocation_id=invocation_id, session_service_provider=session_service_provider, ): event_type = event.get("type") @@ -3094,6 +3744,8 @@ async def stream_conversation_turn( yield _response_sse( "response.error", {"message": event.get("message") or "Agent 运行失败"} ) + elif event_type == "cancelled": + yield _response_sse("response.cancelled", {"status": "cancelled"}) elif event_type == "completed": final_payload = build_responses_payload( output_text=str(event.get("output_text") or ""), @@ -3122,6 +3774,7 @@ async def stream_responses_conversation_turn( request_metadata: Mapping[str, Any] | None = None, resume_input: Mapping[str, Any] | None = None, account_id: str | None = None, + invocation_id: Optional[str] = None, session_service_provider: Callable[[], Any] | None = None, ) -> AsyncIterator[str]: """OpenAI Responses-style SSE stream.""" @@ -3199,6 +3852,7 @@ def _next_output_index() -> int: resume_input=resume_input, response_id=response_id, account_id=account_id, + invocation_id=invocation_id, session_service_provider=session_service_provider, ): event_metadata = event.get("metadata") @@ -3383,6 +4037,23 @@ def _next_output_index() -> int: yield _response_sse("response.failed", failed_payload) return + if event_type == "cancelled": + cancelled_payload = build_responses_payload( + output_text=completed_text, + model=model, + session_id=str(event.get("session_id") or session_id or ""), + response_id=response_id, + created_at=created_at, + status="cancelled", + metadata=( + event.get("metadata") + if isinstance(event.get("metadata"), Mapping) + else response_metadata + ), + ) + yield _response_sse("response.cancelled", cancelled_payload) + return + if event_type == "completed": completed_text = str(event.get("output_text") or completed_text) if completed_text and not message_started: diff --git a/ksadk/model_policy.py b/ksadk/model_policy.py new file mode 100644 index 00000000..fee62e48 --- /dev/null +++ b/ksadk/model_policy.py @@ -0,0 +1,219 @@ +from __future__ import annotations + +import copy +import json +import os +from typing import Any, Mapping + +from ksadk.configs.settings import DEFAULT_MODEL_NAME + +DEFAULT_MULTIMODAL_MODEL = "kimi-k2.7-code" +DEFAULT_FALLBACK_MODEL = "deepseek-v4-pro" + +DEFAULT_MODEL_POLICY: dict[str, Any] = { + "version": "v1", + "primary": {"model": DEFAULT_MODEL_NAME}, + "multimodal": {"model": DEFAULT_MULTIMODAL_MODEL}, + "fallback": { + "model": DEFAULT_FALLBACK_MODEL, + "fallback_errors": [ + "timeout", + "temporarily unavailable", + "temporary unavailable", + "model unavailable", + "rate limit", + "too many requests", + "503", + "504", + ], + "on_errors": [ + "timeout", + "temporarily unavailable", + "temporary unavailable", + "model unavailable", + "rate limit", + "too many requests", + "503", + "504", + ], + }, + "models": { + DEFAULT_MODEL_NAME: { + "options": {}, + }, + DEFAULT_MULTIMODAL_MODEL: { + "input": ["text", "image"], + "options": {"temperature": 1}, + }, + DEFAULT_FALLBACK_MODEL: { + "options": {}, + }, + }, +} + + +def _deep_merge(base: dict[str, Any], override: Mapping[str, Any]) -> dict[str, Any]: + merged = copy.deepcopy(base) + for key, value in override.items(): + if ( + isinstance(value, Mapping) + and isinstance(merged.get(key), dict) + ): + merged[key] = _deep_merge(merged[key], value) + else: + merged[key] = copy.deepcopy(value) + return merged + + +def normalize_model_policy(raw: str | Mapping[str, Any] | None = None) -> dict[str, Any]: + if raw is None or raw == "": + return copy.deepcopy(DEFAULT_MODEL_POLICY) + if isinstance(raw, str): + try: + parsed = json.loads(raw) + except json.JSONDecodeError: + return copy.deepcopy(DEFAULT_MODEL_POLICY) + else: + parsed = raw + if not isinstance(parsed, Mapping): + return copy.deepcopy(DEFAULT_MODEL_POLICY) + normalized = _deep_merge(DEFAULT_MODEL_POLICY, parsed) + fallback = normalized.get("fallback") + if isinstance(fallback, dict): + raw_errors = fallback.get("fallback_errors") or fallback.get("on_errors") + if isinstance(raw_errors, list): + fallback["fallback_errors"] = list(raw_errors) + fallback["on_errors"] = list(raw_errors) + return normalized + + +def _unqualified_model_name(model: str) -> str: + return str(model or "").strip().rsplit("/", 1)[-1] + + +def model_policy_options_for_model( + model: str, + policy: str | Mapping[str, Any] | None = None, +) -> dict[str, Any]: + normalized = normalize_model_policy(policy) + models = normalized.get("models") + if not isinstance(models, Mapping): + return {} + + candidates = [str(model or "").strip(), _unqualified_model_name(model)] + for candidate in candidates: + config = models.get(candidate) + if isinstance(config, Mapping) and isinstance(config.get("options"), Mapping): + return dict(config["options"]) + return {} + + +def fallback_model_for_exception( + exc: BaseException, + *, + current_model: str, + policy: str | Mapping[str, Any] | None = None, +) -> str | None: + normalized = normalize_model_policy(policy) + fallback = normalized.get("fallback") + if not isinstance(fallback, Mapping): + return None + + fallback_model = str(fallback.get("model") or "").strip() + if not fallback_model or fallback_model == str(current_model or "").strip(): + return None + + message = str(exc or "").strip().lower() + if not message: + return None + if "400" in message or "invalid request" in message or "bad request" in message: + return None + + transient_markers = fallback.get("fallback_errors") or fallback.get("on_errors") + if not isinstance(transient_markers, list): + transient_markers = DEFAULT_MODEL_POLICY["fallback"]["on_errors"] + + if any(str(marker).lower() in message for marker in transient_markers): + return fallback_model + return None + + +def _provider_ref(model: str, provider: str = "ksyun") -> str: + value = str(model or "").strip() + if not value or "/" in value: + return value + return f"{provider}/{value}" + + +def _role_model(policy: Mapping[str, Any], role: str) -> str: + value = policy.get(role) + if isinstance(value, Mapping): + return str(value.get("model") or "").strip() + return str(value or "").strip() + + +def _catalog_from_policy(policy: Mapping[str, Any]) -> list[dict[str, Any]]: + models = policy.get("models") if isinstance(policy.get("models"), Mapping) else {} + catalog: list[dict[str, Any]] = [] + seen: set[str] = set() + for role in ("primary", "multimodal", "fallback"): + model = _role_model(policy, role) + if not model or model in seen: + continue + seen.add(model) + metadata = dict(models.get(model) or {}) if isinstance(models, Mapping) else {} + item = { + "id": model, + "name": model, + "api": metadata.get("api") or "openai-completions", + "input": metadata.get("input") or ["text"], + } + if isinstance(metadata.get("options"), Mapping) and metadata["options"]: + item["options"] = dict(metadata["options"]) + catalog.append(item) + return catalog + + +def build_runtime_model_policy_env( + env_vars: Mapping[str, str] | None, + *, + runtime: str, + policy: str | Mapping[str, Any] | None = None, +) -> dict[str, str]: + env = dict(env_vars or {}) + normalized = normalize_model_policy(policy or os.getenv("AGENTENGINE_MODEL_POLICY_JSON")) + env.setdefault( + "AGENTENGINE_MODEL_POLICY_JSON", + json.dumps(normalized, ensure_ascii=False, sort_keys=True, separators=(",", ":")), + ) + primary = _role_model(normalized, "primary") + fallback = _role_model(normalized, "fallback") + multimodal = _role_model(normalized, "multimodal") + has_primary = any( + str(env.get(key) or "").strip() + for key in ("OPENCLAW_DEFAULT_MODEL", "HERMES_DEFAULT_MODEL", "OPENAI_MODEL_NAME", "MODEL_NAME") + ) + runtime_name = str(runtime or "").strip().lower() + if runtime_name == "openclaw": + has_catalog = bool(str(env.get("OPENCLAW_MODEL_CATALOG_JSON") or "").strip()) + if primary and not has_primary and not has_catalog: + env["OPENAI_MODEL_NAME"] = _provider_ref(primary) + if fallback: + env.setdefault("OPENCLAW_FALLBACK_MODEL", _provider_ref(fallback)) + if multimodal: + env.setdefault("OPENCLAW_IMAGE_MODEL", _provider_ref(multimodal)) + env.setdefault("OPENCLAW_MODEL_CATALOG_JSON", json.dumps(_catalog_from_policy(normalized), ensure_ascii=False)) + return env + if runtime_name == "hermes": + if primary and not has_primary: + env["OPENAI_MODEL_NAME"] = primary + env["HERMES_DEFAULT_MODEL"] = primary + if fallback: + env.setdefault("HERMES_FALLBACK_MODEL", fallback) + env.setdefault("HERMES_MODEL_CATALOG_JSON", json.dumps(_catalog_from_policy(normalized), ensure_ascii=False)) + return env + if primary and not has_primary: + env["OPENAI_MODEL_NAME"] = primary + if fallback: + env.setdefault("OPENAI_FALLBACK_MODEL_NAME", fallback) + return env diff --git a/ksadk/server/app.py b/ksadk/server/app.py index 0ad33416..0915c33e 100644 --- a/ksadk/server/app.py +++ b/ksadk/server/app.py @@ -26,6 +26,7 @@ from pydantic import BaseModel, Field import ksadk.conversations as conversation +from ksadk.conversations.attachment_storage import AttachmentStorageService from ksadk.conversations.attachments import compact_attachment_result_for_session from ksadk.conversations.session_title import ( HEURISTIC_SESSION_TITLE_SOURCE, @@ -62,6 +63,7 @@ runner: BaseRunner = None _runner_loaded = False _DETACHED_STREAMS: set[asyncio.Task[Any]] = set() +_DETACHED_STREAMS_BY_INVOCATION: dict[str, "_DetachedSSEStream"] = {} _RUN_TERMINAL_STATUSES = { "completed", "failed", @@ -76,14 +78,20 @@ class _DetachedSSEStream: _MAX_BACKLOG_CHUNKS = 256 - def __init__(self, source: AsyncIterator[str]): + def __init__(self, source: AsyncIterator[str], *, invocation_id: str | None = None): self._source = source + self.invocation_id = invocation_id self._subscribers: set[asyncio.Queue[str | None]] = set() self._backlog: list[str] = [] self._done = False self._task = asyncio.create_task(self._consume()) _DETACHED_STREAMS.add(self._task) self._task.add_done_callback(_DETACHED_STREAMS.discard) + if self.invocation_id: + _DETACHED_STREAMS_BY_INVOCATION[self.invocation_id] = self + self._task.add_done_callback( + lambda _task: _DETACHED_STREAMS_BY_INVOCATION.pop(self.invocation_id or "", None) + ) async def _consume(self) -> None: try: @@ -98,6 +106,8 @@ async def _consume(self) -> None: *(subscriber.put(chunk) for subscriber in subscribers), return_exceptions=True, ) + except asyncio.CancelledError: + raise except Exception: logger.exception("Detached SSE stream failed") raise @@ -123,6 +133,11 @@ def subscribe(self) -> asyncio.Queue[str | None]: def unsubscribe(self, queue: asyncio.Queue[str | None]) -> None: self._subscribers.discard(queue) + def cancel(self) -> bool: + if self._task.done(): + return False + return self._task.cancel() + async def iter_for_client(self) -> AsyncIterator[str]: queue = self.subscribe() try: @@ -135,8 +150,10 @@ async def iter_for_client(self) -> AsyncIterator[str]: self.unsubscribe(queue) -def _detached_streaming_response(source: AsyncIterator[str]) -> StreamingResponse: - detached = _DetachedSSEStream(source) +def _detached_streaming_response( + source: AsyncIterator[str], *, invocation_id: str | None = None +) -> StreamingResponse: + detached = _DetachedSSEStream(source, invocation_id=invocation_id) return StreamingResponse(detached.iter_for_client(), media_type="text/event-stream") @@ -624,18 +641,60 @@ class CreateSessionActionRequest(BaseModel): class ListSessionsActionRequest(BaseModel): AgentId: str UserId: Optional[str] = "user" + Page: int = Field(1, ge=1) + PageSize: int = Field(20, ge=1, le=200) class SessionIdRequest(BaseModel): SessionId: str +class ListSessionEventsActionRequest(BaseModel): + SessionId: str + Offset: Optional[int] = Field(None, ge=0) + Limit: Optional[int] = Field(None, ge=1) + + +class ListSessionCheckpointsActionRequest(BaseModel): + AgentId: str + SessionId: str + RunId: Optional[str] = None + + +class ListToolReceiptsActionRequest(BaseModel): + AgentId: str + SessionId: str + RunId: Optional[str] = None + CheckpointId: Optional[str] = None + + +class ResumeRunActionRequest(BaseModel): + AgentId: str + SessionId: str + RunId: str + CheckpointId: str + ResumeAttemptId: Optional[str] = None + InvocationId: Optional[str] = None + Stream: bool = False + Model: Optional[str] = None + ModelMetadata: Optional[Dict[str, Any]] = None + ModelOptions: Optional[Dict[str, Any]] = None + + +class PreviewCheckpointResumeActionRequest(BaseModel): + AgentId: str + SessionId: str + RunId: str + CheckpointId: str + + class RunAgentActionRequest(BaseModel): AgentId: str Messages: List[Dict[str, Any]] = Field(default_factory=list) UserId: Optional[str] = "user" AccountId: Optional[str] = None SessionId: Optional[str] = None + InvocationId: Optional[str] = None ApiFormat: str = "responses" Stream: bool = False Model: Optional[str] = None @@ -733,6 +792,22 @@ def _resolve_responses_session_and_user(request: ResponsesRequest) -> tuple[str return resolved_session_id, resolved_user_id +def _runtime_agent_id(active_runner: BaseRunner) -> str: + runtime_id = _clean_optional_string(os.getenv("AGENT_RUNTIME_ID")) + if runtime_id: + return runtime_id + return str(getattr(active_runner.detection_result, "name", "") or "agent") + + +def _metadata_invocation_id(metadata: Mapping[str, Any] | None) -> str | None: + if not isinstance(metadata, Mapping): + return None + agentengine_metadata = metadata.get("agentengine") + if not isinstance(agentengine_metadata, Mapping): + return None + return _clean_optional_string(agentengine_metadata.get("invocation_id")) + + class WorkspaceDeleteActionRequest(BaseModel): AgentId: Optional[str] = None Path: str @@ -797,6 +872,196 @@ def _event_to_action_payload(event: SessionEvent) -> dict[str, Any]: return payload +def _checkpoint_event_to_action_payload(event: SessionEvent) -> dict[str, Any] | None: + if event.event_type != "run_checkpoint": + return None + metadata = event.metadata or {} + run_id = str(metadata.get("run_id") or "").strip() + checkpoint_id = str(metadata.get("checkpoint_id") or "").strip() + framework = str(metadata.get("framework") or "").strip() + framework_ref = metadata.get("framework_ref") + if not run_id or not checkpoint_id or not framework or not isinstance(framework_ref, Mapping): + return None + payload = { + "EventId": event.id, + "SessionId": event.session_id, + "InvocationId": event.invocation_id, + "SeqId": event.seq_id, + "Timestamp": event.timestamp, + "RunId": run_id, + "CheckpointId": checkpoint_id, + "Framework": framework, + "FrameworkRef": dict(framework_ref), + "Phase": str(metadata.get("phase") or ""), + "Metadata": metadata, + } + stage = str(metadata.get("stage") or metadata.get("title") or "").strip() + summary = str(metadata.get("summary") or metadata.get("description") or "").strip() + next_action = str(metadata.get("next_action") or metadata.get("nextAction") or "").strip() + status = str(metadata.get("status") or "").strip() + if stage: + payload["Stage"] = stage + if summary: + payload["Summary"] = summary + if next_action: + payload["NextAction"] = next_action + if status: + payload["Status"] = status + return payload + + +_SIDE_EFFECT_TOOL_NAMES = { + "write_workspace_file", + "write_workspace_files", + "delete_workspace_file", + "execute_skills", + "run_command", + "run_code", +} + + +def _tool_receipt_event_to_action_payload(event: SessionEvent) -> dict[str, Any] | None: + if event.event_type != "tool_result": + return None + metadata = event.metadata or {} + receipt = metadata.get("tool_receipt") + if not isinstance(receipt, Mapping): + return None + tool_name = str(receipt.get("tool_name") or metadata.get("tool_name") or "").strip() + if not tool_name: + return None + return { + "EventId": event.id, + "SessionId": event.session_id, + "InvocationId": event.invocation_id, + "SeqId": event.seq_id, + "Timestamp": event.timestamp, + "ReceiptId": str(receipt.get("receipt_id") or ""), + "IdempotencyKey": str(receipt.get("idempotency_key") or ""), + "ToolName": tool_name, + "ToolCallId": str(receipt.get("tool_call_id") or ""), + "RunId": str(receipt.get("run_id") or metadata.get("run_id") or ""), + "CheckpointId": str(receipt.get("checkpoint_id") or ""), + "Status": str(receipt.get("status") or ""), + "Replayed": bool(receipt.get("replayed") or metadata.get("replayed")), + "Metadata": dict(metadata), + } + + +def _build_checkpoint_resume_preview( + *, + checkpoint: Mapping[str, Any], + events: list[SessionEvent], +) -> dict[str, Any]: + checkpoint_seq_id = int(checkpoint.get("SeqId") or 0) + run_id = str(checkpoint.get("RunId") or "") + receipts: list[dict[str, Any]] = [] + for event in events: + if checkpoint_seq_id and int(event.seq_id or 0) > checkpoint_seq_id: + continue + receipt = _tool_receipt_event_to_action_payload(event) + if receipt is None: + continue + if run_id and receipt["RunId"] and receipt["RunId"] != run_id: + continue + receipts.append(receipt) + + side_effect_receipts = [ + receipt for receipt in receipts if receipt["ToolName"] in _SIDE_EFFECT_TOOL_NAMES + ] + risk_level = "low" + if side_effect_receipts: + risk_level = "medium" + if any(receipt["Status"] == "failed" for receipt in receipts): + risk_level = "high" + + return { + "Checkpoint": dict(checkpoint), + "Capabilities": { + "Checkpoints": True, + "CheckpointResume": True, + "ToolReceipts": True, + "IdempotentToolReplay": True, + }, + "ToolReceipts": receipts, + "Risk": { + "Level": risk_level, + "DuplicateSideEffectRisk": bool(side_effect_receipts), + "SideEffectReceiptCount": len(side_effect_receipts), + "FailedReceiptCount": len([receipt for receipt in receipts if receipt["Status"] == "failed"]), + }, + "Summary": { + "RunId": run_id, + "CheckpointId": str(checkpoint.get("CheckpointId") or ""), + "Phase": str(checkpoint.get("Phase") or ""), + "ToolReceiptCount": len(receipts), + }, + } + + +async def _find_session_checkpoint( + *, + service: Any, + session_id: str, + run_id: str, + checkpoint_id: str, +) -> dict[str, Any] | None: + for event in reversed(await service.get_events(session_id)): + checkpoint = _checkpoint_event_to_action_payload(event) + if checkpoint is None: + continue + if checkpoint["RunId"] != run_id: + continue + if checkpoint["CheckpointId"] != checkpoint_id: + continue + return checkpoint + return None + + +async def _resolve_checkpoint_resume_input_from_session( + *, + service: Any, + agent_id: str, + session_id: str | None, + resume_input: Mapping[str, Any] | None, +) -> dict[str, Any] | None: + if not isinstance(resume_input, Mapping): + return None + if str(resume_input.get("type") or "").strip() != "agentengine.resume_checkpoint": + return dict(resume_input) + normalized_session_id = str(session_id or "").strip() + if not normalized_session_id: + raise HTTPException(status_code=400, detail="Checkpoint resume requires session_id") + + session = await service.get_session(normalized_session_id) + if not session or session.agent_id != agent_id: + raise HTTPException(status_code=404, detail="Session not found") + + run_id = str(resume_input.get("run_id") or "").strip() + checkpoint_id = str(resume_input.get("checkpoint_id") or "").strip() + if not run_id or not checkpoint_id: + raise HTTPException(status_code=400, detail="Checkpoint resume requires run_id and checkpoint_id") + + checkpoint = await _find_session_checkpoint( + service=service, + session_id=normalized_session_id, + run_id=run_id, + checkpoint_id=checkpoint_id, + ) + if checkpoint is None: + raise HTTPException(status_code=404, detail="Checkpoint not found") + + resume_attempt_id = str(resume_input.get("resume_attempt_id") or "").strip() + return { + "type": "agentengine.resume_checkpoint", + "run_id": run_id, + "checkpoint_id": checkpoint_id, + "resume_attempt_id": resume_attempt_id or f"resume_{uuid.uuid4().hex}", + "framework": checkpoint["Framework"], + "framework_ref": checkpoint["FrameworkRef"], + } + + def _feedback_state_key(response_id: str) -> str: return str(response_id or "").strip() @@ -955,6 +1220,9 @@ async def get_agent_ui_bootstrap(request: UiBootstrapRequest): "Enabled": True, "Resume": True, "Abort": True, + "Checkpoints": True, + "CheckpointResume": True, + "CheckpointResumePreview": True, }, "MCP": False, "HostedRuntime": False, @@ -987,11 +1255,23 @@ async def create_session_action(request: CreateSessionActionRequest): @app.post("/agentengine/api/v1/ListSessions") async def list_sessions_action(request: ListSessionsActionRequest): service = resolve_session_service() - sessions = await service.list_sessions(request.AgentId, request.UserId or "user") + offset = (request.Page - 1) * request.PageSize + sessions = await service.list_sessions( + request.AgentId, + request.UserId or "user", + offset=offset, + limit=request.PageSize, + ) + total = await service.count_sessions(request.AgentId, request.UserId or "user") session_payloads = [await _session_to_action_payload(session) for session in sessions] return _action_response( "ListSessions", - {"Sessions": session_payloads}, + { + "Sessions": session_payloads, + "Total": total, + "Page": request.Page, + "PageSize": request.PageSize, + }, ) @@ -1014,15 +1294,177 @@ async def delete_session_action(request: SessionIdRequest): @app.post("/agentengine/api/v1/ListSessionEvents") -async def list_session_events_action(request: SessionIdRequest): +async def list_session_events_action(request: ListSessionEventsActionRequest): service = resolve_session_service() - events = await service.get_events(request.SessionId) + events = await service.get_events( + request.SessionId, + offset=request.Offset, + limit=request.Limit, + ) + total = await service.count_events(request.SessionId) return _action_response( "ListSessionEvents", - {"Events": [_event_to_action_payload(event) for event in events]}, + { + "Events": [_event_to_action_payload(event) for event in events], + "Total": total, + "Offset": request.Offset or 0, + "Limit": request.Limit if request.Limit is not None else len(events), + }, + ) + + +@app.post("/agentengine/api/v1/ListSessionCheckpoints") +async def list_session_checkpoints_action(request: ListSessionCheckpointsActionRequest): + service = resolve_session_service() + session = await service.get_session(request.SessionId) + if not session or session.agent_id != request.AgentId: + raise HTTPException(status_code=404, detail="Session not found") + + run_id_filter = str(request.RunId or "").strip() + checkpoints: list[dict[str, Any]] = [] + for event in await service.get_events(request.SessionId): + checkpoint = _checkpoint_event_to_action_payload(event) + if checkpoint is None: + continue + if run_id_filter and checkpoint["RunId"] != run_id_filter: + continue + checkpoints.append(checkpoint) + + return _action_response( + "ListSessionCheckpoints", + {"Checkpoints": checkpoints}, + ) + + +@app.post("/agentengine/api/v1/ListToolReceipts") +async def list_tool_receipts_action(request: ListToolReceiptsActionRequest): + service = resolve_session_service() + session = await service.get_session(request.SessionId) + if not session or session.agent_id != request.AgentId: + raise HTTPException(status_code=404, detail="Session not found") + + run_id_filter = str(request.RunId or "").strip() + checkpoint_id_filter = str(request.CheckpointId or "").strip() + receipts: list[dict[str, Any]] = [] + for event in await service.get_events(request.SessionId): + receipt = _tool_receipt_event_to_action_payload(event) + if receipt is None: + continue + if run_id_filter and receipt["RunId"] != run_id_filter: + continue + if checkpoint_id_filter and receipt["CheckpointId"] != checkpoint_id_filter: + continue + receipts.append(receipt) + + return _action_response( + "ListToolReceipts", + {"ToolReceipts": receipts}, + ) + + +@app.post("/agentengine/api/v1/PreviewCheckpointResume") +async def preview_checkpoint_resume_action(request: PreviewCheckpointResumeActionRequest): + service = resolve_session_service() + session = await service.get_session(request.SessionId) + if not session or session.agent_id != request.AgentId: + raise HTTPException(status_code=404, detail="Session not found") + + events = await service.get_events(request.SessionId) + checkpoint = None + for event in reversed(events): + candidate = _checkpoint_event_to_action_payload(event) + if candidate is None: + continue + if candidate["RunId"] != str(request.RunId): + continue + if candidate["CheckpointId"] != str(request.CheckpointId): + continue + checkpoint = candidate + break + if checkpoint is None: + raise HTTPException(status_code=404, detail="Checkpoint not found") + + return _action_response( + "PreviewCheckpointResume", + {"Preview": _build_checkpoint_resume_preview(checkpoint=checkpoint, events=events)}, ) +@app.post("/agentengine/api/v1/ResumeRun") +async def resume_run_action(request: ResumeRunActionRequest): + service = resolve_session_service() + session = await service.get_session(request.SessionId) + if not session or session.agent_id != request.AgentId: + raise HTTPException(status_code=404, detail="Session not found") + + checkpoint = await _find_session_checkpoint( + service=service, + session_id=request.SessionId, + run_id=str(request.RunId), + checkpoint_id=str(request.CheckpointId), + ) + if checkpoint is None: + raise HTTPException(status_code=404, detail="Checkpoint not found") + + resume_input = { + "type": "agentengine.resume_checkpoint", + "run_id": str(request.RunId), + "checkpoint_id": str(request.CheckpointId), + "resume_attempt_id": str(request.ResumeAttemptId or f"resume_{uuid.uuid4().hex}"), + "framework": checkpoint["Framework"], + "framework_ref": checkpoint["FrameworkRef"], + } + active_runner = _resolve_active_runner() + user_id = session.user_id or "user" + + if request.Stream: + resume_invocation_id = str(request.InvocationId or resume_input["resume_attempt_id"]) + return _detached_streaming_response( + conversation.stream_responses_conversation_turn( + runner=active_runner, + agent_id=request.AgentId, + user_id=user_id, + messages=[], + session_id=request.SessionId, + model=request.Model, + model_metadata=request.ModelMetadata, + model_options=request.ModelOptions, + request_metadata={"responses_conversation": True}, + resume_input=resume_input, + invocation_id=resume_invocation_id, + prepare_runner=_prepare_runner_for_model, + session_service_provider=resolve_session_service, + ), + invocation_id=resume_invocation_id, + ) + + response_id = f"resp_{uuid.uuid4().hex}" + resolved_session_id, result = await conversation.invoke_conversation_once( + runner=active_runner, + agent_id=request.AgentId, + user_id=user_id, + messages=[], + session_id=request.SessionId, + model=request.Model, + model_metadata=request.ModelMetadata, + model_options=request.ModelOptions, + request_metadata={"responses_conversation": True}, + resume_input=resume_input, + response_id=response_id, + invocation_id=str(resume_input["resume_attempt_id"]), + prepare_runner=_prepare_runner_for_model, + session_service_provider=resolve_session_service, + ) + payload = conversation.build_responses_payload( + output_text=result["output_text"], + model=request.Model, + session_id=resolved_session_id, + response_id=response_id, + metadata=result.get("metadata") if isinstance(result.get("metadata"), dict) else None, + ) + return _action_response("ResumeRun", payload) + + @app.get("/agentengine/api/v1/SubscribeRunEvents", include_in_schema=False) async def subscribe_run_events_action( SessionId: str = Query(...), @@ -1072,25 +1514,23 @@ async def event_generator() -> AsyncIterator[str]: return StreamingResponse(event_generator(), media_type="text/event-stream") @app.post("/agentengine/api/v1/UploadFile") async def upload_file_action(file: UploadFile = File(...)): - uploads_dir = _resolve_uploads_dir() - ext = Path(file.filename or "").suffix file_id = uuid.uuid4().hex - target_path = uploads_dir / f"{file_id}{ext}" - size_bytes = 0 - - with open(target_path, "wb") as f: - while chunk := await file.read(1024 * 1024): - size_bytes += len(chunk) - f.write(chunk) + data = await file.read() + file_uri, _local_path = await AttachmentStorageService().store( + data=data, + file_id=file_id, + display_name=file.filename, + mime_type=file.content_type, + ) return _action_response( "UploadFile", { "FileData": { - "fileUri": f"{_UPLOAD_URI_SCHEME}{file_id}", + "fileUri": file_uri, "displayName": file.filename or "uploaded_file", "mimeType": file.content_type or "application/octet-stream", - "sizeBytes": size_bytes, + "sizeBytes": len(data), } } ) @@ -1098,16 +1538,14 @@ async def upload_file_action(file: UploadFile = File(...)): @app.get("/agentengine/api/v1/AttachmentContent", include_in_schema=False) async def attachment_content_action(FileUri: str = Query(...)): - storage_path = _resolve_attachment_storage_path(FileUri) - if storage_path is None or not storage_path.is_file(): + loaded = AttachmentStorageService().read(FileUri) + if loaded is None: raise HTTPException(status_code=404, detail="Attachment not found") - media_type, _ = mimetypes.guess_type(storage_path.name) - return FileResponse( - path=storage_path, - media_type=media_type or "application/octet-stream", - filename=storage_path.name, - content_disposition_type="inline", + return Response( + content=loaded.data, + media_type=loaded.mime_type or "application/octet-stream", + headers={"Content-Disposition": f'inline; filename="{loaded.display_name}"'}, ) @@ -1162,13 +1600,36 @@ async def delete_workspace_file_action(request: WorkspaceDeleteActionRequest): @app.post("/agentengine/api/v1/CancelRun") async def cancel_run_action(request: CancelRunActionRequest): + detached = _DETACHED_STREAMS_BY_INVOCATION.get(request.InvocationId) + found = detached is not None + cancel_requested = False + if detached is not None: + cancel_requested = detached.cancel() + runner_cancel_status = "not_found" if found else "unsupported" active_runner = _resolve_active_runner() if active_runner is not None: try: - active_runner.request_cancel(request.InvocationId) + runner_result = active_runner.request_cancel(request.InvocationId) + if isinstance(runner_result, str) and runner_result: + runner_cancel_status = runner_result + elif runner_result is True: + runner_cancel_status = "accepted" + elif runner_result is False and not found: + runner_cancel_status = "not_found" except Exception as exc: + runner_cancel_status = "error" logger.warning("CancelRun failed: %s", exc) - return _action_response("CancelRun", {"Cancelled": True}) + runner_accepted = runner_cancel_status in {"accepted", "cancelling", "cancelled"} + status = "cancelling" if found or runner_accepted else runner_cancel_status + return _action_response( + "CancelRun", + { + "Cancelled": bool(cancel_requested or runner_accepted), + "Found": found, + "Status": status, + "RunnerCancelStatus": runner_cancel_status, + }, + ) @app.get("/agentengine/api/v1/GetWorkspaceFileContent", include_in_schema=False) @@ -1377,11 +1838,18 @@ async def run_agent_action(request: RunAgentActionRequest): api_format = (request.ApiFormat or "responses").strip().lower() run_user_id = _clean_optional_string(request.UserId) or "user" account_id = _clean_optional_string(request.AccountId) + service = resolve_session_service() resume_input = ( conversation.extract_responses_resume_input(request.ResponsesInput) if request.ResponsesInput is not None else None ) + resume_input = await _resolve_checkpoint_resume_input_from_session( + service=service, + agent_id=request.AgentId, + session_id=request.SessionId, + resume_input=resume_input, + ) if resume_input is not None: messages = [] elif request.ResponsesInput is not None and api_format == "responses": @@ -1422,9 +1890,11 @@ async def run_agent_action(request: RunAgentActionRequest): request_metadata=request_metadata or None, resume_input=resume_input, account_id=account_id, + invocation_id=request.InvocationId, prepare_runner=_prepare_runner_for_model, session_service_provider=resolve_session_service, - ) + ), + invocation_id=request.InvocationId, ) responses_response_id = ( @@ -1443,6 +1913,7 @@ async def run_agent_action(request: RunAgentActionRequest): resume_input=resume_input, response_id=responses_response_id, account_id=account_id, + invocation_id=request.InvocationId, prepare_runner=_prepare_runner_for_model, session_service_provider=resolve_session_service, ) @@ -1460,6 +1931,7 @@ async def run_agent_action(request: RunAgentActionRequest): model=request.Model, session_id=resolved_session_id, response_id=responses_response_id, + metadata=result.get("metadata") if isinstance(result.get("metadata"), Mapping) else None, ) return _action_response("RunAgent", payload) @@ -2101,10 +2573,16 @@ async def responses(request: ResponsesRequest): """OpenAI Responses 兼容接口。""" active_runner = _resolve_active_runner() resolved_session_id, resolved_user_id = _resolve_responses_session_and_user(request) + agent_id = _runtime_agent_id(active_runner) resume_input = conversation.extract_responses_resume_input(request.input) + resume_input = await _resolve_checkpoint_resume_input_from_session( + service=resolve_session_service(), + agent_id=agent_id, + session_id=resolved_session_id, + resume_input=resume_input, + ) messages = [] if resume_input is not None else conversation.normalize_responses_input(request.input) - agent_id = active_runner.detection_result.name request_metadata = dict(request.metadata or {}) if request.previous_response_id: request_metadata.setdefault("previous_response_id", request.previous_response_id) @@ -2119,9 +2597,10 @@ async def responses(request: ResponsesRequest): if request.store is not None: request_metadata.setdefault("store", request.store) account_id = _clean_optional_string(request.account_id) + invocation_id = _metadata_invocation_id(request_metadata) if request.stream: - return StreamingResponse( + return _detached_streaming_response( conversation.stream_responses_conversation_turn( runner=active_runner, agent_id=agent_id, @@ -2135,10 +2614,11 @@ async def responses(request: ResponsesRequest): request_metadata=request_metadata, resume_input=resume_input, account_id=account_id, + invocation_id=invocation_id, prepare_runner=_prepare_runner_for_model, session_service_provider=resolve_session_service, ), - media_type="text/event-stream", + invocation_id=invocation_id, ) response_id = f"resp_{uuid.uuid4().hex}" @@ -2156,6 +2636,7 @@ async def responses(request: ResponsesRequest): resume_input=resume_input, response_id=response_id, account_id=account_id, + invocation_id=invocation_id, prepare_runner=_prepare_runner_for_model, session_service_provider=resolve_session_service, ) @@ -2173,7 +2654,7 @@ async def chat_completions(request: ChatCompletionRequest): """OpenAI 兼容的聊天补全接口 (支持流式和非流式)""" active_runner = _resolve_active_runner() messages = conversation.normalize_kop_messages(request.messages) - agent_id = active_runner.detection_result.name + agent_id = _runtime_agent_id(active_runner) resolved_user_id = _clean_optional_string(request.user) or "user" account_id = _clean_optional_string(request.account_id) diff --git a/ksadk/sessions/base.py b/ksadk/sessions/base.py index 4becb911..6895e134 100644 --- a/ksadk/sessions/base.py +++ b/ksadk/sessions/base.py @@ -277,9 +277,19 @@ async def list_sessions( self, agent_id: str, user_id: Optional[str] = None, + offset: Optional[int] = None, + limit: Optional[int] = None, ) -> list[Session]: raise NotImplementedError + @abc.abstractmethod + async def count_sessions( + self, + agent_id: str, + user_id: Optional[str] = None, + ) -> int: + raise NotImplementedError + @abc.abstractmethod async def delete_session(self, session_id: str) -> bool: raise NotImplementedError @@ -310,6 +320,10 @@ async def get_events( ) -> list[SessionEvent]: raise NotImplementedError + @abc.abstractmethod + async def count_events(self, session_id: str) -> int: + raise NotImplementedError + @abc.abstractmethod async def get_state( self, diff --git a/ksadk/sessions/in_memory.py b/ksadk/sessions/in_memory.py index 28d1f668..4c057c53 100644 --- a/ksadk/sessions/in_memory.py +++ b/ksadk/sessions/in_memory.py @@ -47,6 +47,8 @@ async def list_sessions( self, agent_id: str, user_id: Optional[str] = None, + offset: Optional[int] = None, + limit: Optional[int] = None, ) -> list[Session]: async with self._lock: sessions = [ @@ -55,7 +57,21 @@ async def list_sessions( if session.agent_id == agent_id and (user_id is None or session.user_id == user_id) ] sessions.sort(key=lambda item: (item.updated_at, item.created_at), reverse=True) - return sessions + start = offset or 0 + end = None if limit is None else start + limit + return sessions[start:end] + + async def count_sessions( + self, + agent_id: str, + user_id: Optional[str] = None, + ) -> int: + async with self._lock: + return sum( + 1 + for session in self._sessions.values() + if session.agent_id == agent_id and (user_id is None or session.user_id == user_id) + ) async def delete_session(self, session_id: str) -> bool: async with self._lock: @@ -151,6 +167,11 @@ async def get_events( events = session.events[start:end] return copy.deepcopy(events) + async def count_events(self, session_id: str) -> int: + async with self._lock: + session = self._sessions.get(session_id) + return len(session.events) if session else 0 + async def get_state( self, agent_id: str, diff --git a/ksadk/sessions/local_service.py b/ksadk/sessions/local_service.py index 252b841e..d1f32253 100644 --- a/ksadk/sessions/local_service.py +++ b/ksadk/sessions/local_service.py @@ -7,7 +7,9 @@ import os import sqlite3 import time +from contextlib import closing, contextmanager from pathlib import Path +from collections.abc import Iterator from typing import Optional from ksadk.sessions.base import ( @@ -78,9 +80,25 @@ async def list_sessions( self, agent_id: str, user_id: Optional[str] = None, + offset: Optional[int] = None, + limit: Optional[int] = None, ) -> list[Session]: async with self._lock: - return await asyncio.to_thread(self._list_sessions_sync, agent_id, user_id) + return await asyncio.to_thread( + self._list_sessions_sync, + agent_id, + user_id, + offset, + limit, + ) + + async def count_sessions( + self, + agent_id: str, + user_id: Optional[str] = None, + ) -> int: + async with self._lock: + return await asyncio.to_thread(self._count_sessions_sync, agent_id, user_id) async def delete_session(self, session_id: str) -> bool: async with self._lock: @@ -120,6 +138,10 @@ async def get_events( async with self._lock: return await asyncio.to_thread(self._get_events_sync, session_id, offset, limit) + async def count_events(self, session_id: str) -> int: + async with self._lock: + return await asyncio.to_thread(self._count_events_sync, session_id) + async def get_state( self, agent_id: str, @@ -164,6 +186,12 @@ def _connect(self) -> sqlite3.Connection: connection.execute("PRAGMA foreign_keys = ON") return connection + @contextmanager + def _connection(self) -> Iterator[sqlite3.Connection]: + with closing(self._connect()) as connection: + with connection: + yield connection + @staticmethod def _table_exists(connection: sqlite3.Connection, table_name: str) -> bool: row = connection.execute( @@ -226,7 +254,7 @@ def _migrate_legacy_schema(self, connection: sqlite3.Connection) -> None: ) def _ensure_schema(self) -> None: - with self._connect() as connection: + with self._connection() as connection: self._migrate_legacy_schema(connection) connection.executescript( f""" @@ -312,7 +340,7 @@ def _create_session_sync( user_id: str, session_id: Optional[str], ) -> Session: - with self._connect() as connection: + with self._connection() as connection: if session_id: existing = self._get_session_sync(session_id, connection=connection) if existing is not None: @@ -405,8 +433,10 @@ def _list_sessions_sync( self, agent_id: str, user_id: Optional[str], + offset: Optional[int] = None, + limit: Optional[int] = None, ) -> list[Session]: - with self._connect() as connection: + with self._connection() as connection: query = f""" SELECT id, agent_id, user_id, title, title_source, summary, first_prompt, last_prompt, @@ -419,6 +449,15 @@ def _list_sessions_sync( query += " AND user_id = ?" params.append(user_id) query += " ORDER BY updated_at DESC, created_at DESC" + if limit is not None: + query += " LIMIT ?" + params.append(limit) + if offset is not None: + query += " OFFSET ?" + params.append(offset) + elif offset is not None: + query += " LIMIT -1 OFFSET ?" + params.append(offset) rows = connection.execute(query, params).fetchall() return [ Session( @@ -439,8 +478,22 @@ def _list_sessions_sync( for row in rows ] + def _count_sessions_sync(self, agent_id: str, user_id: Optional[str]) -> int: + with self._connection() as connection: + query = f""" + SELECT COUNT(*) AS total + FROM {KSADK_SESSIONS_TABLE} + WHERE agent_id = ? + """ + params: list[object] = [agent_id] + if user_id is not None: + query += " AND user_id = ?" + params.append(user_id) + row = connection.execute(query, params).fetchone() + return int(row["total"] if row else 0) + def _delete_session_sync(self, session_id: str) -> bool: - with self._connect() as connection: + with self._connection() as connection: row = connection.execute( f"SELECT 1 FROM {KSADK_SESSIONS_TABLE} WHERE id = ?", (session_id,), @@ -455,7 +508,7 @@ def _delete_session_sync(self, session_id: str) -> bool: return True def _append_event_sync(self, session_id: str, event: SessionEvent) -> SessionEvent: - with self._connect() as connection: + with self._connection() as connection: session_row = connection.execute( f""" SELECT agent_id, user_id, state_json, version @@ -554,7 +607,7 @@ def _update_session_metadata_sync( first_prompt: Optional[str], last_prompt: Optional[str], ) -> Session: - with self._connect() as connection: + with self._connection() as connection: row = connection.execute( f""" SELECT @@ -658,6 +711,14 @@ def _get_events_sync( if owns_connection: connection.close() + def _count_events_sync(self, session_id: str) -> int: + with self._connection() as connection: + row = connection.execute( + f"SELECT COUNT(*) AS total FROM {KSADK_EVENTS_TABLE} WHERE session_id = ?", + (session_id,), + ).fetchone() + return int(row["total"] if row else 0) + def _get_state_sync( self, agent_id: str, @@ -665,7 +726,7 @@ def _get_state_sync( session_id: Optional[str], scope: str, ) -> Optional[SessionState]: - with self._connect() as connection: + with self._connection() as connection: if scope == "session" and session_id: session = self._get_session_sync(session_id, connection=connection) if session is None: @@ -709,7 +770,7 @@ def _update_state_sync( scope: str, state_delta: dict, ) -> SessionState: - with self._connect() as connection: + with self._connection() as connection: updated_at = time.time() if scope == "session": diff --git a/ksadk/sessions/postgres_service.py b/ksadk/sessions/postgres_service.py index e4e0358c..f8a54630 100644 --- a/ksadk/sessions/postgres_service.py +++ b/ksadk/sessions/postgres_service.py @@ -112,6 +112,8 @@ async def list_sessions( self, agent_id: str, user_id: Optional[str] = None, + offset: Optional[int] = None, + limit: Optional[int] = None, ) -> list[Session]: await self._ensure_schema() async with self._pool.acquire() as connection: @@ -123,12 +125,39 @@ async def list_sessions( """ params: list[Any] = [self.namespace, agent_id] if user_id is not None: - query += " AND user_id = $3" params.append(user_id) + query += f" AND user_id = ${len(params)}" query += " ORDER BY updated_at DESC, created_at DESC" + if limit is not None: + params.append(limit) + query += f" LIMIT ${len(params)}" + if offset is not None: + params.append(offset) + query += f" OFFSET ${len(params)}" + elif offset is not None: + params.append(offset) + query += f" OFFSET ${len(params)}" rows = await connection.fetch(query, *params) return [self._session_from_row(row, events=[]) for row in rows] + async def count_sessions( + self, + agent_id: str, + user_id: Optional[str] = None, + ) -> int: + await self._ensure_schema() + async with self._pool.acquire() as connection: + query = f""" + SELECT COUNT(*) AS total + FROM {KSADK_PG_SESSIONS_TABLE} + WHERE namespace = $1 AND agent_id = $2 + """ + params: list[Any] = [self.namespace, agent_id] + if user_id is not None: + params.append(user_id) + query += f" AND user_id = ${len(params)}" + return int(await connection.fetchval(query, *params) or 0) + async def delete_session(self, session_id: str) -> bool: await self._ensure_schema() async with self._pool.acquire() as connection: @@ -320,6 +349,16 @@ async def get_events( rows = await connection.fetch(query, *params) return [self._event_from_row(row) for row in rows] + async def count_events(self, session_id: str) -> int: + await self._ensure_schema() + async with self._pool.acquire() as connection: + query = f""" + SELECT COUNT(*) AS total + FROM {KSADK_PG_EVENTS_TABLE} + WHERE namespace = $1 AND session_id = $2 + """ + return int(await connection.fetchval(query, self.namespace, session_id) or 0) + async def get_state( self, agent_id: str, diff --git a/ksadk/version.py b/ksadk/version.py index 4df5e633..3e63be25 100644 --- a/ksadk/version.py +++ b/ksadk/version.py @@ -1,4 +1,4 @@ """KsADK 版本信息""" -VERSION = "0.6.5" +VERSION = "0.6.6" __version__ = VERSION diff --git a/pyproject.toml b/pyproject.toml index dd2cd90e..fb836d43 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "ksadk" -version = "0.6.5" +version = "0.6.6" description = "KsADK Agent Runtime Platform - unified runtime, debugging, deployment and observability for AI agents" readme = "README.md" requires-python = ">=3.10" @@ -49,6 +49,8 @@ dependencies = [ # HTTP 请求 + AWS 签名 "requests>=2.28.0", "requests-aws4auth>=1.2.0", + # AICP SDK for default knowledge-base and long-term-memory tools + "kingsoftcloud-sdk-python>=1.5.8.94", "cryptography>=44.0.0", "websockets>=12.0,<16.0", "qrcode>=7.4.0", @@ -111,9 +113,7 @@ tracing = [ "openinference-instrumentation-langchain>=0.1.0", ] # 知识库 & 记忆库支持 -kb = [ - "kingsoftcloud-sdk-python>=1.5.8.94", -] +kb = [] # Skill Runtime E2B backend skills = [ "e2b>=2.0.0", @@ -123,15 +123,11 @@ dev = [ "pytest>=7.0.0", "pytest-asyncio>=0.21.0", "fastmcp>=2.0.0", + "build>=1.0.0", + "twine>=5.0.0", "black>=22.0.0", "ruff>=0.1.0", "mypy>=1.0.0", - "mkdocs>=1.6.0,<2.0.0", - "mkdocs-material>=9.5.0,<10.0.0", - "mkdocs-click>=0.8.0,<1.0.0", - "mkdocs-static-i18n>=1.3.0,<2.0.0", - "mkdocstrings[python]>=0.27.0,<1.0.0", - "twine>=6.0.0,<7.0.0", ] # 全部依赖 all = [ @@ -146,9 +142,6 @@ ksadk = "ksadk.cli:main" Homepage = "https://github.com/kingsoftcloud/ksadk-python" Repository = "https://github.com/kingsoftcloud/ksadk-python" Documentation = "https://kingsoftcloud.github.io/ksadk-python/" -PyPI = "https://pypi.org/project/ksadk/" -Source = "https://github.com/kingsoftcloud/ksadk-python" -Issues = "https://github.com/kingsoftcloud/ksadk-python/issues" [tool.setuptools.packages.find] where = ["."] diff --git a/tests/snapshots/help_snapshots.txt b/tests/snapshots/help_snapshots.txt new file mode 100644 index 00000000..20092891 --- /dev/null +++ b/tests/snapshots/help_snapshots.txt @@ -0,0 +1,350 @@ +=== root_help === +Usage: cli [OPTIONS] COMMAND [ARGS]... + +AgentEngine CLI +支持 Hermes / OpenClaw / DeepAgents / LangGraph / LangChain / Google ADK +的本地运行与云端部署。 + +工作流命令: + agentengine a2a 暴露 A2A 服务与 Agent Card + agentengine agent Agent 资源管理 + agentengine build 构建部署制品 + agentengine completion Shell 补全管理 + agentengine dashboard 打开云端 Agent Dashboard + agentengine deploy 部署到云端 + agentengine files 管理 workspace 文件 + agentengine hermes Hermes Agent 资源管理 + agentengine init 创建新项目 + agentengine launch 一键构建+部署 + agentengine mcp MCP 资源管理 + agentengine openclaw OpenClaw 资源管理 + agentengine run 运行 Agent + agentengine version Agent 版本管理 + agentengine web 本地调试 Agent Invoke UI + +配置: + agentengine config 项目配置与模型设置 + +全局选项: + --output 输出格式(pretty/json) + --no-color 禁用颜色输出 + --dry-run 全局 Dry Run(仅打印请求,不执行) + --version 显示版本号 + -h, --help 显示帮助信息 + +使用 `agentengine --help` 查看子命令帮助。 + +=== a2a_help === +Usage: cli a2a [OPTIONS] COMMAND [ARGS]... + + A2A 协议服务与 Agent Card + +Options: + -h, --help Show this message and exit. + +Commands: + card 输出 Agent Card JSON。 + serve 启动 A2A 协议服务。 + +=== a2a_serve_help === +Usage: cli a2a serve [OPTIONS] [AGENT_DIR] + + 启动 A2A 协议服务。 + +Options: + --host TEXT 服务监听地址 [default: 0.0.0.0] + --port INTEGER 服务端口 [default: 8081] + --url TEXT Agent Card 对外宣告地址 + --name TEXT 覆盖 Agent 名称 + --description TEXT 覆盖 Agent 描述 + --skill TEXT 可重复传入,追加 Agent Card 技能 + --no-trace 禁用 Tracing + -h, --help Show this message and exit. + +=== a2a_card_help === +Usage: cli a2a card [OPTIONS] [AGENT_DIR] + + 输出 Agent Card JSON。 + +Options: + --url TEXT Agent Card 对外宣告地址 [default: http://127.0.0.1:8081] + --name TEXT 覆盖 Agent 名称 + --description TEXT 覆盖 Agent 描述 + --skill TEXT 可重复传入,追加 Agent Card 技能 + -h, --help Show this message and exit. + +=== agent_help === +Usage: cli agent [OPTIONS] COMMAND [ARGS]... + + Agent 资源管理。 + +Options: + -h, --help Show this message and exit. + +Commands: + delete 删除一个或多个 Agent。 + invoke 与 Agent 交互。 + list 列出已部署的 Agent。 + status 查看单个 Agent 状态。 + +=== dashboard_help === +Usage: cli dashboard [OPTIONS] COMMAND [ARGS]... + + Dashboard 资源管理。 + + 标准动作: + open 打开 Agent Dashboard + share 管理 Dashboard 分享链接 + + 示例: + agentengine dashboard open + agentengine dashboard open ar-xxxx + agentengine dashboard share list ar-xxxx + +Options: + -h, --help Show this message and exit. + +Commands: + open 打开 Agent Dashboard。 + share Dashboard 分享链接管理。 + +=== dashboard_open_help === +Usage: cli dashboard open [OPTIONS] [AGENT_REF] + + 打开 Agent Dashboard。 + +Options: + -a, --agent, --agent-id TEXT Agent 名称或 ID + -r, --region TEXT 区域 + --path TEXT 目标 UI 路径(默认根据配置自动推导) + --share 创建可分享链接(默认创建私有临时链接) + --expires-seconds TEXT 链接有效期(秒);支持 never(=0) + --force-new 强制新建链接(跳过复用) + --no-open 仅打印 URL,不自动打开浏览器 + --direct 直接打开 endpoint/path(跳过短链接创建) + --output [pretty|json] 输出格式 + -h, --help Show this message and exit. + +=== hermes_help === +Usage: cli hermes [OPTIONS] COMMAND [ARGS]... + + Hermes Agent 资源管理。 + +Options: + -h, --help Show this message and exit. + +Commands: + connect 进入远端 Hermes gateway setup 向导,执行扫码连接。 + delete 删除 Hermes Agent。 + deploy 部署 Hermes runtime 到云端。 + exec 透传受限 Hermes 只读运维子命令。 + list 列出 Hermes Agent。 + open 打开 Hermes 管理 UI,或使用 --chat 打开统一聊天页。 + pairing 透传 Hermes pairing 审批子命令。 + status 查看 Hermes Agent 状态。 + +=== mcp_help === +Usage: cli mcp [OPTIONS] COMMAND [ARGS]... + + MCP 资源管理。 + + 标准动作: + list 列出已部署的 MCP + status 查看单个 MCP 状态 + delete 删除一个或多个 MCP + deploy 部署 MCP 到云端 + build 构建 MCP 制品 + + 示例: + agentengine mcp deploy . + agentengine mcp list + KSYUN_REGION=cn-beijing-6 agentengine mcp status + +Options: + -h, --help Show this message and exit. + +Commands: + build 构建 MCP Server 制品。 + delete 删除 MCP。 + deploy 部署 MCP Server 到云端 + list 列出已部署的 MCP + status 查看 MCP 状态 + +=== mcp_build_help === +Usage: cli mcp build [OPTIONS] [MCP_DIR] + + 构建 MCP Server 制品。 + +Options: + --artifact-type [Code|Container] + 构建模式: Code-代码包 (默认) 或 Container-镜像模式 + --push 构建后上传/推送制品 + --tag TEXT 镜像标签 (Container 模式) + --registry TEXT 镜像仓库地址 (Container 模式) + -r, --region TEXT 构建使用的区域 (Code 模式用于 KS3,Container 模式用于默认镜像仓库推断) + --ks3-bucket TEXT KS3 存储桶名称 (Code 模式,默认: + agentengine-{account_id}-{region}) + --no-cache 强制重新构建,不使用缓存 (Code/Container 模式均适用) + --output [pretty|json] 输出格式 + -h, --help Show this message and exit. + +=== openclaw_help === +Usage: cli openclaw [OPTIONS] COMMAND [ARGS]... + + OpenClaw 资源管理。 + + 标准动作: + deploy 部署 OpenClaw 到云端 + list 列出已部署的 OpenClaw + status 查看单个 OpenClaw 状态 + gateway Gateway 入口、日志与诊断 + tui 连接远端 OpenClaw 原生 TUI + repair 通过控制面执行 OpenClaw 修复动作 + channel Channel 统一入口 + delete 删除一个或多个 OpenClaw + + 示例: + agentengine openclaw deploy + agentengine openclaw list + agentengine openclaw status + agentengine openclaw tui + agentengine openclaw gateway open + agentengine openclaw repair + agentengine openclaw channel status --probe + agentengine openclaw channel connect --channel weixin + agentengine openclaw delete + +Options: + -h, --help Show this message and exit. + +Commands: + channel OpenClaw Channel 统一入口。 + delete 删除 OpenClaw 实例。 + deploy 部署 OpenClaw 到云端 + gateway OpenClaw gateway 入口、日志与诊断。 + list 列出已部署的 OpenClaw 实例 + repair 通过控制面执行 OpenClaw 修复动作。 + status 查看 OpenClaw 状态 + tui 连接远端 OpenClaw 原生 TUI(不需要本机安装 OpenClaw CLI)。 + +=== version_help === +Usage: cli version [OPTIONS] COMMAND [ARGS]... + + Agent 版本资源管理。 + + 标准动作: + list 列出版本历史 + release 发布新版本 + rollback 回滚到指定版本 + + 示例: + agentengine version list + agentengine version list --agent ar-xxxx + agentengine version release --agent ar-xxxx --tag vX.Y.Z + agentengine version rollback --agent ar-xxxx --to vX.Y.Z -y + + 说明: + 在项目目录下可不传 --agent,会自动从本地状态/配置解析目标 Agent + 也支持显式指定: --agent / --agent-id / 位置参数 + 跨环境执行时请显式设置 KSYUN_REGION + +Options: + -h, --help Show this message and exit. + +Commands: + list 列出版本历史 + release 发布新版本 + rollback 回滚到指定版本 + +=== config_help === +Usage: cli config [OPTIONS] COMMAND [ARGS]... + + 配置命令组。 + + 直接运行 `agentengine config` 会进入向导。 标准子命令为 `wizard` / `show` / `set` / `model`。 + +Options: + -h, --help Show this message and exit. + +Commands: + model 切换默认模型。 + set 非交互式设置配置项。 + show 查看项目配置、全局配置与当前生效环境变量。 + wizard 通过交互式向导配置项目。 + +=== config_wizard_help === +Usage: cli config wizard [OPTIONS] + + 通过交互式向导配置项目。 + +Options: + --file TEXT 配置文件路径(默认自动复用 agentengine.yaml/ksadk.yaml) + -s, --set TEXT 设置配置项 key=value + --global 强制更新全局配置 + -h, --help Show this message and exit. + +=== config_show_help === +Usage: cli config show [OPTIONS] + + 查看项目配置、全局配置与当前生效环境变量。 + +Options: + --output [pretty|json] 输出格式 + -h, --help Show this message and exit. + +=== config_set_help === +Usage: cli config set [OPTIONS] [SET_ITEMS]... + + 非交互式设置配置项。 + + 示例: + agentengine config set region=cn-beijing-6 + agentengine config set OPENAI_MODEL_NAME=glm-5.2 OPENAI_BASE_URL=https://example.com/v1 + agentengine config set KSYUN_REGION=cn-beijing-6 --global + +Options: + --global 同时更新全局配置 + --output [pretty|json] 输出格式 + -h, --help Show this message and exit. + +=== config_model_help === +Usage: cli config model [OPTIONS] + + 切换默认模型。 + +Options: + --multi 交互式多选模型,并按当前框架写入模型 allowlist + --env TEXT 按模型列表生成环境变量,逗号分隔;首个模型作为默认模型,不写入 .env + --framework [auto|openclaw|hermes|generic] + allowlist 变量选择策略;auto 会读取当前目录框架 [default: + auto] + -h, --help Show this message and exit. + +=== completion_help === +Usage: cli completion [OPTIONS] COMMAND [ARGS]... + + Shell 补全管理。 + +Options: + -h, --help Show this message and exit. + +Commands: + bash 输出 Bash 补全脚本 + install 自动安装补全脚本到 Shell 配置文件 + zsh 输出 Zsh 补全脚本 + +=== model_alias_help === +Usage: cli model [OPTIONS] + +这是兼容入口,建议迁移到新的 canonical 命令。 + +推荐命令: agentengine config model +查看帮助: agentengine config model --help + +=== status_alias_help === +Usage: cli status [OPTIONS] [AGENT_REF] + +这是兼容入口,建议迁移到新的 canonical 命令。 + +推荐命令: agentengine agent status +查看帮助: agentengine agent status --help diff --git a/tests/test_attachment_storage.py b/tests/test_attachment_storage.py new file mode 100644 index 00000000..9102c52e --- /dev/null +++ b/tests/test_attachment_storage.py @@ -0,0 +1,169 @@ +from __future__ import annotations + +import importlib + +import httpx +import pytest + +from ksadk.conversations.attachment_storage import AttachmentStorageService +from ksadk.conversations.attachments import resolve_attachment_storage_path +from ksadk.conversations.normalize import normalize_parts_content +from ksadk.server.api_models import FileData, Part + + +@pytest.mark.asyncio +async def test_runtime_upload_file_uses_ks3_metadata_and_attachment_content_reads_ks3( + monkeypatch, + tmp_path, +): + server_app_module = importlib.import_module("ksadk.server.app") + ui_dir = tmp_path / ".agentengine" / "ui" + monkeypatch.setenv("AGENTENGINE_UI_DIR", str(ui_dir)) + monkeypatch.setenv("KSYUN_ACCOUNT_ID", "acct-1") + monkeypatch.setenv("KS3_REGION", "cn-beijing-6") + stored: dict[tuple[str, str], bytes] = {} + + async def fake_put(self, *, bucket, object_key, data, mime_type): + assert bucket == "agentengine-acct-1-cn-beijing-6" + assert object_key.startswith("agents/_runtime/attachments/") + assert object_key.endswith(".png") + assert mime_type == "image/png" + stored[(bucket, object_key)] = data + + async def fake_read(self, *, bucket, object_key): + return stored[(bucket, object_key)] + + monkeypatch.setattr(AttachmentStorageService, "_put_ks3_object", fake_put) + monkeypatch.setattr(AttachmentStorageService, "_read_ks3_object", fake_read) + + transport = httpx.ASGITransport(app=server_app_module.app) + async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client: + upload_response = await client.post( + "/agentengine/api/v1/UploadFile", + files={"file": ("arch.png", b"\x89PNG\r\n\x1a\nruntime-ks3", "image/png")}, + ) + + assert upload_response.status_code == 200 + file_uri = upload_response.json()["Data"]["FileData"]["fileUri"] + file_id = file_uri.removeprefix("ksadk-upload://") + local_file = ui_dir / "files" / f"{file_id}.png" + local_file.unlink() + + content_response = await client.get( + "/agentengine/api/v1/AttachmentContent", + params={"FileUri": file_uri}, + ) + + assert content_response.status_code == 200 + assert content_response.headers["content-type"].startswith("image/png") + assert content_response.content == b"\x89PNG\r\n\x1a\nruntime-ks3" + + +def test_resolve_attachment_storage_path_restores_missing_local_cache_from_ks3( + monkeypatch, + tmp_path, +): + monkeypatch.setenv("AGENTENGINE_UI_DIR", str(tmp_path / ".agentengine" / "ui")) + monkeypatch.setenv("KSYUN_ACCOUNT_ID", "acct-1") + monkeypatch.setenv("KS3_REGION", "cn-beijing-6") + service = AttachmentStorageService() + + async def fake_put(self, **_kwargs): + return None + + async def fake_read(self, *, bucket, object_key): + assert bucket == "agentengine-acct-1-cn-beijing-6" + assert object_key.startswith("agents/_runtime/attachments/") + return b"restored" + + monkeypatch.setattr(AttachmentStorageService, "_put_ks3_object", fake_put) + monkeypatch.setattr(AttachmentStorageService, "_read_ks3_object", fake_read) + + file_uri, local_path = service.store_sync( + data=b"initial", + file_id="abc123.png", + display_name="abc.png", + mime_type="image/png", + ) + local_path.unlink() + + restored_path = resolve_attachment_storage_path(file_uri) + + assert restored_path == local_path + assert restored_path.read_bytes() == b"restored" + + +def test_resolve_attachment_storage_path_downloads_hosted_ae_upload_via_kop( + monkeypatch, + tmp_path, +): + monkeypatch.setenv("AGENTENGINE_UI_DIR", str(tmp_path / ".agentengine" / "ui")) + calls = [] + + class FakeResponse: + status_code = 200 + headers = { + "content-type": "text/markdown; charset=utf-8", + "content-disposition": 'inline; filename="brief.md"', + } + content = b"# Brief\n\nHosted attachment body" + + def fake_action_raw_request(self, method, action, *, params=None, **_kwargs): + calls.append({"method": method, "action": action, "params": params}) + return FakeResponse() + + monkeypatch.setattr( + "ksadk.api.client.AgentEngineClient._action_raw_request", + fake_action_raw_request, + ) + + restored_path = resolve_attachment_storage_path("ae-upload://hosted123.md") + + assert calls == [ + { + "method": "GET", + "action": "AttachmentContent", + "params": {"FileUri": "ae-upload://hosted123.md"}, + } + ] + assert restored_path is not None + assert restored_path.name == "hosted123.md" + assert restored_path.read_bytes() == b"# Brief\n\nHosted attachment body" + + +def test_normalize_parts_content_reads_hosted_markdown_attachment_via_kop( + monkeypatch, + tmp_path, +): + monkeypatch.setenv("AGENTENGINE_UI_DIR", str(tmp_path / ".agentengine" / "ui")) + + class FakeResponse: + status_code = 200 + headers = { + "content-type": "text/markdown", + "content-disposition": 'inline; filename="brief.md"', + } + content = b"# Brief\n\nHosted attachment body" + + monkeypatch.setattr( + "ksadk.api.client.AgentEngineClient._action_raw_request", + lambda self, method, action, *, params=None, **_kwargs: FakeResponse(), + ) + + payload = normalize_parts_content( + [ + Part( + fileData=FileData( + fileUri="ae-upload://hosted123.md", + mimeType="text/markdown", + displayName="brief.md", + ) + ) + ] + ) + + result = payload["attachment_results"][0] + assert result["status"] == "ok" + assert result["kind"] == "text" + assert result["text"] == "# Brief\n\nHosted attachment body" + assert "Hosted attachment body" in payload["content"] diff --git a/tests/test_cli_dry_run.py b/tests/test_cli_dry_run.py index af24a319..4262d10e 100644 --- a/tests/test_cli_dry_run.py +++ b/tests/test_cli_dry_run.py @@ -1998,9 +1998,8 @@ async def _fake_fetch_provider_model_catalog(**_kwargs): for item in _FakeOpenClawCreateClient.create_payload["env_vars"] } catalog = json.loads(env_vars["OPENCLAW_MODEL_CATALOG_JSON"]) - assert [item["id"] for item in catalog] == ["deepseek-v4-pro"] - assert catalog[0]["contextWindow"] == 1_000_000 - assert catalog[0]["maxTokens"] == 384_000 + assert [item["id"] for item in catalog] == ["glm-5.2", "kimi-k2.7-code", "deepseek-v4-pro"] + assert catalog[1]["options"] == {"temperature": 1} def test_openclaw_deploy_writes_allowlisted_models_from_provider_catalog(monkeypatch, tmp_path): @@ -2053,7 +2052,7 @@ async def _fake_fetch_provider_model_catalog(**_kwargs): for item in _FakeOpenClawCreateClient.create_payload["env_vars"] } catalog = json.loads(env_vars["OPENCLAW_MODEL_CATALOG_JSON"]) - assert [item["id"] for item in catalog] == ["deepseek-v4-pro", "glm-5.1"] + assert [item["id"] for item in catalog] == ["glm-5.2", "kimi-k2.7-code", "deepseek-v4-pro"] assert "kimi-k2.6" not in {item["id"] for item in catalog} diff --git a/tests/test_client_workspace_files.py b/tests/test_client_workspace_files.py new file mode 100644 index 00000000..a801933e --- /dev/null +++ b/tests/test_client_workspace_files.py @@ -0,0 +1,473 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from ksadk.api import AttachmentContent +from ksadk.api.client import AgentEngineAPIError, AgentEngineClient + + +class _FakeRuntimeResponse: + def __init__( + self, + *, + status_code: int = 200, + json_payload=None, + content: bytes = b"", + headers: dict[str, str] | None = None, + ): + self.status_code = status_code + self._json_payload = json_payload + self.content = content + self.headers = headers or {"content-type": "application/json"} + self.text = content.decode("utf-8", errors="ignore") + + def json(self): + if self._json_payload is None: + raise json.JSONDecodeError("Expecting value", self.text or "", 0) + return self._json_payload + + +class _FakeRuntimeSession: + def __init__(self, responses: list[_FakeRuntimeResponse]): + self._responses = list(responses) + self.calls: list[dict] = [] + + def request(self, method, url, **kwargs): + self.calls.append( + { + "method": method, + "url": url, + "headers": kwargs.get("headers"), + "params": kwargs.get("params"), + "files": kwargs.get("files"), + "stream": kwargs.get("stream"), + } + ) + if not self._responses: + raise AssertionError("unexpected runtime request") + return self._responses.pop(0) + + +def test_attachment_content_is_exported_from_api_package(): + assert AttachmentContent.__name__ == "AttachmentContent" + + +def test_download_attachment_content_uses_signed_attachment_action(monkeypatch): + client = AgentEngineClient(base_url="http://example.com", access_key="", secret_key="") + calls: list[dict] = [] + + def _fake_action_raw_request( + method, + action, + *, + params=None, + accept="application/json", + **kwargs, + ): + calls.append( + { + "method": method, + "action": action, + "params": params, + "accept": accept, + "extra": kwargs, + } + ) + return _FakeRuntimeResponse( + content=b"# hosted", + headers={ + "content-type": "text/markdown; charset=utf-8", + "content-disposition": "inline; filename*=UTF-8''%E6%B5%8B%E8%AF%95.md", + }, + ) + + monkeypatch.setattr(client, "_action_raw_request", _fake_action_raw_request) + + content = client.download_attachment_content("ae-upload://hosted123.md") + + assert calls == [ + { + "method": "GET", + "action": "AttachmentContent", + "params": {"FileUri": "ae-upload://hosted123.md"}, + "accept": "application/octet-stream", + "extra": {}, + } + ] + assert content.data == b"# hosted" + assert content.content_type == "text/markdown; charset=utf-8" + assert content.display_name == "测试.md" + + +@pytest.mark.asyncio +async def test_list_workspace_files_uses_direct_runtime_endpoint(monkeypatch): + client = AgentEngineClient(base_url="http://example.com", access_key="", secret_key="") + + async def _fake_get_agent(**kwargs): + assert kwargs == {"agent_id": "ar-demo", "name": None, "include_api_key": True} + return { + "basic": {"agent_id": "ar-demo", "name": "demo"}, + "quick_access": { + "public_endpoint": "https://agent.example.com", + "api_key": "ak-demo", + }, + } + + session = _FakeRuntimeSession( + [ + _FakeRuntimeResponse( + json_payload={ + "Root": "workspace", + "Path": "docs", + "Entries": [{"Name": "guide.md", "Path": "docs/guide.md", "Type": "file"}], + } + ) + ] + ) + monkeypatch.setattr(client, "get_agent", _fake_get_agent) + monkeypatch.setattr(client, "_get_session", lambda: session) + + payload = await client.list_workspace_files(agent_id="ar-demo", path="docs", recursive=True) + + assert payload["path"] == "docs" + assert payload["entries"][0]["path"] == "docs/guide.md" + assert session.calls == [ + { + "method": "GET", + "url": "https://agent.example.com/_ksadk/workspace/v1/entries", + "headers": {"Authorization": "Bearer ak-demo"}, + "params": {"path": "docs", "recursive": "true"}, + "files": None, + "stream": False, + } + ] + + +@pytest.mark.asyncio +async def test_upload_download_and_delete_workspace_file_use_runtime_data_plane( + monkeypatch, + tmp_path: Path, +): + client = AgentEngineClient(base_url="http://example.com", access_key="", secret_key="") + + async def _fake_get_agent(**kwargs): + assert kwargs["include_api_key"] is True + return { + "basic": {"agent_id": "ar-demo", "name": "demo"}, + "quick_access": { + "public_endpoint": "https://agent.example.com", + "api_key": "ak-demo", + }, + } + + local_file = tmp_path / "report.txt" + local_file.write_text("workspace hello", encoding="utf-8") + session = _FakeRuntimeSession( + [ + _FakeRuntimeResponse( + json_payload={ + "Entry": { + "Name": "report.txt", + "Path": "reports/report.txt", + "Type": "file", + "SizeBytes": 15, + } + } + ), + _FakeRuntimeResponse( + content=b"workspace hello", + headers={"content-type": "text/plain"}, + ), + _FakeRuntimeResponse(json_payload={"Deleted": True}), + ] + ) + monkeypatch.setattr(client, "get_agent", _fake_get_agent) + monkeypatch.setattr(client, "_get_session", lambda: session) + + upload_payload = await client.upload_workspace_file( + agent_id="ar-demo", + remote_path="reports/report.txt", + local_path=local_file, + ) + download_payload = await client.download_workspace_file( + agent_id="ar-demo", + remote_path="reports/report.txt", + ) + delete_payload = await client.delete_workspace_file( + agent_id="ar-demo", + remote_path="reports/report.txt", + ) + + assert upload_payload["entry"]["path"] == "reports/report.txt" + assert download_payload == b"workspace hello" + assert delete_payload["deleted"] is True + assert delete_payload["transport_mode"] == "runtime_direct" + assert session.calls[0]["method"] == "POST" + assert session.calls[0]["url"] == "https://agent.example.com/_ksadk/workspace/v1/files/reports/report.txt" + assert session.calls[0]["headers"] == {"Authorization": "Bearer ak-demo"} + assert session.calls[0]["files"] is not None + assert session.calls[1] == { + "method": "GET", + "url": "https://agent.example.com/_ksadk/workspace/v1/files/reports/report.txt", + "headers": {"Authorization": "Bearer ak-demo"}, + "params": None, + "files": None, + "stream": False, + } + assert session.calls[2] == { + "method": "DELETE", + "url": "https://agent.example.com/_ksadk/workspace/v1/files/reports/report.txt", + "headers": {"Authorization": "Bearer ak-demo"}, + "params": None, + "files": None, + "stream": False, + } + + +@pytest.mark.asyncio +async def test_list_workspace_files_surfaces_invalid_runtime_json_with_actionable_error( + monkeypatch, +): + client = AgentEngineClient(base_url="http://example.com", access_key="", secret_key="") + + async def _fake_get_agent(**kwargs): + assert kwargs["include_api_key"] is True + return { + "basic": {"agent_id": "ar-demo", "name": "demo"}, + "quick_access": { + "public_endpoint": "https://agent.example.com", + "api_key": "ak-demo", + }, + } + + session = _FakeRuntimeSession( + [ + _FakeRuntimeResponse( + content=b"", + headers={"content-type": "application/json"}, + ) + ] + ) + monkeypatch.setattr(client, "get_agent", _fake_get_agent) + monkeypatch.setattr(client, "_get_session", lambda: session) + + with pytest.raises(AgentEngineAPIError) as excinfo: + await client.list_workspace_files(agent_id="ar-demo", path="docs") + + assert excinfo.value.code == 502 + assert "workspace runtime returned invalid JSON" in excinfo.value.message + assert "https://agent.example.com/_ksadk/workspace/v1/entries" in excinfo.value.message + + +@pytest.mark.asyncio +async def test_list_workspace_files_uses_action_proxy_for_openclaw_without_runtime_api_key( + monkeypatch, +): + client = AgentEngineClient(base_url="http://example.com", access_key="", secret_key="") + recorded: dict[str, object] = {} + + async def _fake_get_agent(**kwargs): + assert kwargs["include_api_key"] is True + return { + "basic": {"agent_id": "ar-openclaw", "name": "demo-openclaw"}, + "deployment": {"framework": "openclaw"}, + "quick_access": { + "public_endpoint": "https://openclaw.example.com", + }, + } + + def _fake_action(action, params=None): + recorded["action"] = action + recorded["params"] = params + return { + "root": "workspace", + "path": "docs", + "entries": [{"name": "guide.md", "path": "docs/guide.md", "type": "file"}], + } + + monkeypatch.setattr(client, "get_agent", _fake_get_agent) + monkeypatch.setattr(client, "_action", _fake_action) + monkeypatch.setattr( + client, + "_workspace_runtime_request", + lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("runtime direct path must not be used")), + ) + + payload = await client.list_workspace_files(agent_id="ar-openclaw", path="docs", recursive=True) + + assert payload["path"] == "docs" + assert payload["entries"][0]["path"] == "docs/guide.md" + assert recorded == { + "action": "ListWorkspaceFiles", + "params": { + "AgentId": "ar-openclaw", + "Name": "demo-openclaw", + "Path": "docs", + "Recursive": True, + }, + } + + +@pytest.mark.asyncio +async def test_list_workspace_files_uses_action_proxy_for_openclaw_even_with_api_key( + monkeypatch, +): + client = AgentEngineClient(base_url="http://example.com", access_key="", secret_key="") + recorded: dict[str, object] = {} + + async def _fake_get_agent(**kwargs): + assert kwargs["include_api_key"] is True + return { + "basic": {"agent_id": "ar-openclaw", "name": "demo-openclaw"}, + "deployment": {"framework": "openclaw"}, + "quick_access": { + "public_endpoint": "https://openclaw.example.com", + "api_key": "ak-openclaw", + }, + } + + def _fake_action(action, params=None): + recorded["action"] = action + recorded["params"] = params + return { + "root": "workspace", + "path": ".", + "entries": [{"name": "guide.md", "path": "guide.md", "type": "file"}], + } + + monkeypatch.setattr(client, "get_agent", _fake_get_agent) + monkeypatch.setattr(client, "_action", _fake_action) + monkeypatch.setattr( + client, + "_workspace_runtime_request", + lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("runtime direct path must not be used")), + ) + + payload = await client.list_workspace_files(agent_id="ar-openclaw") + + assert payload["entries"][0]["path"] == "guide.md" + assert recorded == { + "action": "ListWorkspaceFiles", + "params": { + "AgentId": "ar-openclaw", + "Name": "demo-openclaw", + "Path": ".", + "Recursive": False, + }, + } + + +@pytest.mark.asyncio +async def test_workspace_file_data_plane_uses_action_proxy_for_openclaw_without_runtime_api_key( + monkeypatch, + tmp_path: Path, +): + client = AgentEngineClient(base_url="http://example.com", access_key="", secret_key="") + recorded: list[dict[str, object]] = [] + + async def _fake_get_agent(**kwargs): + assert kwargs["include_api_key"] is True + return { + "basic": {"agent_id": "ar-openclaw", "name": "demo-openclaw"}, + "deployment": {"framework": "openclaw"}, + "quick_access": { + "public_endpoint": "https://openclaw.example.com", + }, + } + + def _fake_action(action, params=None): + recorded.append({"action": action, "params": params}) + if action == "DeleteWorkspaceFile": + return {"deleted": True} + raise AssertionError(f"unexpected json action {action}") + + def _fake_action_raw_request(method, action, *, params=None, data=None, files=None, accept="application/json"): + recorded.append( + { + "method": method, + "action": action, + "params": params, + "data": data, + "files": files, + "accept": accept, + } + ) + if action == "AddWorkspaceFile": + return _FakeRuntimeResponse( + json_payload={ + "Entry": { + "Name": "report.txt", + "Path": "reports/report.txt", + "Type": "file", + "SizeBytes": 15, + } + } + ) + if action == "GetWorkspaceFileContent": + return _FakeRuntimeResponse( + content=b"workspace hello", + headers={"content-type": "text/plain"}, + ) + raise AssertionError(f"unexpected raw action {action}") + + local_file = tmp_path / "report.txt" + local_file.write_text("workspace hello", encoding="utf-8") + + monkeypatch.setattr(client, "get_agent", _fake_get_agent) + monkeypatch.setattr(client, "_action", _fake_action) + monkeypatch.setattr(client, "_action_raw_request", _fake_action_raw_request) + monkeypatch.setattr( + client, + "_workspace_runtime_request", + lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("runtime direct path must not be used")), + ) + + upload_payload = await client.upload_workspace_file( + agent_id="ar-openclaw", + remote_path="reports/report.txt", + local_path=local_file, + ) + download_payload = await client.download_workspace_file( + agent_id="ar-openclaw", + remote_path="reports/report.txt", + ) + delete_payload = await client.delete_workspace_file( + agent_id="ar-openclaw", + remote_path="reports/report.txt", + ) + + assert upload_payload["entry"]["path"] == "reports/report.txt" + assert download_payload == b"workspace hello" + assert delete_payload["deleted"] is True + assert delete_payload["transport_mode"] == "action_proxy" + assert recorded[0]["action"] == "AddWorkspaceFile" + assert recorded[0]["method"] == "POST" + assert recorded[0]["data"] == { + "AgentId": "ar-openclaw", + "Name": "demo-openclaw", + "Path": "reports/report.txt", + } + assert recorded[0]["files"] is not None + assert recorded[1] == { + "method": "GET", + "action": "GetWorkspaceFileContent", + "params": { + "AgentId": "ar-openclaw", + "Name": "demo-openclaw", + "FilePath": "reports/report.txt", + }, + "data": None, + "files": None, + "accept": "application/octet-stream", + } + assert recorded[2] == { + "action": "DeleteWorkspaceFile", + "params": { + "AgentId": "ar-openclaw", + "Name": "demo-openclaw", + "Path": "reports/report.txt", + }, + } diff --git a/tests/test_cmd_config_wizard.py b/tests/test_cmd_config_wizard.py new file mode 100644 index 00000000..e48b63cf --- /dev/null +++ b/tests/test_cmd_config_wizard.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +from pathlib import Path + +import yaml + +from ksadk.cli import cmd_config + + +class _Prompt: + def __init__(self, value): + self.value = value + + def ask(self): + return self.value + + +def test_config_wizard_accepts_existing_hermes_framework(monkeypatch, tmp_path: Path): + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(cmd_config, "is_stdout_tty", lambda: True) + monkeypatch.setattr("ksadk.configs.global_config.global_config_exists", lambda: True) + + (tmp_path / "agentengine.yaml").write_text( + yaml.safe_dump( + { + "name": "hermes", + "description": "existing description", + "framework": "hermes", + "entry_point": "hermes/agent.py", + "agent_variable": "root_agent", + "region": "pre-online", + }, + allow_unicode=True, + ), + encoding="utf-8", + ) + + def _text(_message, *, default="", **_kwargs): + return _Prompt(default) + + def _password(_message, *, default="", **_kwargs): + return _Prompt(default) + + def _confirm(message, *, default=False, **_kwargs): + assert message in {"是否配置金山云凭证?", "是否使用 container 模式部署?"} + return _Prompt(default) + + def _select(_message, *, choices, default=None, **_kwargs): + if default not in choices: + raise ValueError(f"default {default!r} is not a valid choice") + return _Prompt(default) + + monkeypatch.setattr(cmd_config.questionary, "text", _text) + monkeypatch.setattr(cmd_config.questionary, "password", _password) + monkeypatch.setattr(cmd_config.questionary, "confirm", _confirm) + monkeypatch.setattr(cmd_config.questionary, "select", _select) + + cmd_config.run_config_wizard(config_file=None, set_items=(), is_global=False) + + updated = yaml.safe_load((tmp_path / "agentengine.yaml").read_text(encoding="utf-8-sig")) + assert updated["framework"] == "hermes" + + +def test_config_wizard_prompts_for_kcr_username(monkeypatch, tmp_path: Path): + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(cmd_config, "is_stdout_tty", lambda: True) + monkeypatch.setattr("ksadk.configs.global_config.global_config_exists", lambda: True) + + text_answers = { + "Agent 名称:": "demo-agent", + "Agent 描述:": "demo", + "Base URL (OPENAI_BASE_URL) [选填,默认使用金山云星流平台URL]:": "", + "模型名称 (OPENAI_MODEL_NAME) [选填,默认使用金山云星流平台glm-5.2]:": "", + "KCR 用户名 (企业版请填写访问凭证用户名):": "enterprise-user", + "镜像仓库地址 [选填,如: agenthzzqy-vpc.ksyunkcr.com/testagent-pub]:": "agenthzzqy-vpc.ksyunkcr.com/testagent-pub", + } + password_answers = { + "API Key (OPENAI_API_KEY):": "", + "KCR 密码或 Token:": "enterprise-pass", + } + + def _text(message, *, default="", **_kwargs): + return _Prompt(text_answers.get(message, default)) + + def _password(message, *, default="", **_kwargs): + return _Prompt(password_answers.get(message, default)) + + def _confirm(message, *, default=False, **_kwargs): + if message == "是否配置金山云凭证?": + return _Prompt(False) + if message == "是否使用 container 模式部署?": + return _Prompt(True) + raise AssertionError(f"unexpected confirm prompt: {message}") + + def _select(_message, *, default=None, **_kwargs): + return _Prompt(default) + + monkeypatch.setattr(cmd_config.questionary, "text", _text) + monkeypatch.setattr(cmd_config.questionary, "password", _password) + monkeypatch.setattr(cmd_config.questionary, "confirm", _confirm) + monkeypatch.setattr(cmd_config.questionary, "select", _select) + + cmd_config.run_config_wizard(config_file=None, set_items=(), is_global=False) + + env_text = (tmp_path / ".env").read_text(encoding="utf-8-sig") + assert "KCR_USERNAME=enterprise-user" in env_text + assert "KCR_PASSWORD=enterprise-pass" in env_text + assert "KCR_REGISTRY=agenthzzqy-vpc.ksyunkcr.com/testagent-pub" in env_text diff --git a/tests/test_cmd_hermes.py b/tests/test_cmd_hermes.py index bf88e798..2cfaa80d 100644 --- a/tests/test_cmd_hermes.py +++ b/tests/test_cmd_hermes.py @@ -12,7 +12,6 @@ REPO_ROOT = Path(__file__).resolve().parents[1] -HERMES_DOCKERFILE = REPO_ROOT / "deploy" / "hermes" / "Dockerfile" MAKEFILE = REPO_ROOT / "Makefile" @@ -286,15 +285,14 @@ async def get_client_bootstrap_config(self, **kwargs): } -def test_hermes_build_defaults_track_v2026_5_29_2_release(): - dockerfile = HERMES_DOCKERFILE.read_text(encoding="utf-8") +def test_hermes_build_defaults_are_externalized_to_agentengine_images_repo(): makefile = MAKEFILE.read_text(encoding="utf-8") - assert 'ARG HERMES_AGENT_REF=v2026.5.29.2' in dockerfile - assert 'HERMES_TAG ?= 2026.5.29.2-ksadk-v3' in makefile - assert 'HERMES_AGENT_REF ?= v2026.5.29.2' in makefile + assert not (REPO_ROOT / "deploy" / "hermes" / "Dockerfile").exists() + assert "AGENTENGINE_IMAGES_DIR ?= ../agentengine-images" in makefile + assert '$(MAKE) -C "$(AGENTENGINE_IMAGES_DIR)" $@' in makefile + assert "-f deploy/hermes/Dockerfile" not in makefile assert cmd_hermes.DEFAULT_HERMES_IMAGE.endswith(':2026.5.29.2-ksadk-v1') - assert '"langfuse>=3.9.0,<4"' in dockerfile def test_hermes_deploy_refreshes_quick_access_when_agent_id_is_immediate(monkeypatch, tmp_path: Path): @@ -899,15 +897,18 @@ def test_hermes_deploy_defaults_model_base_url_and_omits_api_key(tmp_path: Path, assert result.exit_code == 0, result.output assert "https://kspmas.ksyun.com/v1/" in result.output - assert "glm-5.1" in result.output + assert "glm-5.2" in result.output assert any( item["Key"] == "OPENAI_BASE_URL" and item["Value"] == "https://kspmas.ksyun.com/v1/" for item in _FakeHermesClient.create_payload["env_vars"] ) assert any( - item["Key"] == "OPENAI_MODEL_NAME" and item["Value"] == "glm-5.1" + item["Key"] == "OPENAI_MODEL_NAME" and item["Value"] == "glm-5.2" for item in _FakeHermesClient.create_payload["env_vars"] ) + env_vars = {item["Key"]: item["Value"] for item in _FakeHermesClient.create_payload["env_vars"]} + assert json.loads(env_vars["AGENTENGINE_MODEL_POLICY_JSON"])["fallback"]["model"] == "deepseek-v4-pro" + assert env_vars["HERMES_FALLBACK_MODEL"] == "deepseek-v4-pro" assert not any(item["Key"] == "OPENAI_API_KEY" for item in _FakeHermesClient.create_payload["env_vars"]) @@ -1000,26 +1001,21 @@ def test_hermes_deploy_preserves_configured_public_kspmas_url(tmp_path: Path, mo ) -def test_hermes_deploy_sets_glm_51_context_length_and_default_fallback(tmp_path: Path, monkeypatch): +def test_hermes_deploy_uses_model_policy_fallback_for_kspmas(tmp_path: Path, monkeypatch): runner = CliRunner() _FakeHermesClient.create_payload = None monkeypatch.chdir(tmp_path) monkeypatch.setenv("OPENAI_API_KEY", "sk-test") monkeypatch.setenv("OPENAI_BASE_URL", "http://kspmas.ksyun.com/v1") - monkeypatch.setenv("OPENAI_MODEL_NAME", "glm-5.1") + monkeypatch.setenv("OPENAI_MODEL_NAME", "glm-5.2") monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesClient) result = runner.invoke(cmd_hermes.hermes, ["deploy", "--name", "demo-hermes"]) assert result.exit_code == 0, result.output - assert any( - item["Key"] == "HERMES_CONTEXT_LENGTH" and item["Value"] == "200000" - for item in _FakeHermesClient.create_payload["env_vars"] - ) - assert any( - item["Key"] == "HERMES_FALLBACK_MODEL" and item["Value"] == "kimi-k2.6" - for item in _FakeHermesClient.create_payload["env_vars"] - ) + env_vars = {item["Key"]: item["Value"] for item in _FakeHermesClient.create_payload["env_vars"]} + assert env_vars["HERMES_FALLBACK_MODEL"] == "deepseek-v4-pro" + assert "kimi-k2.6" not in env_vars.values() def test_hermes_deploy_forwards_explicit_fallback_model(tmp_path: Path, monkeypatch): @@ -1365,6 +1361,54 @@ def test_hermes_status_shows_langfuse_trace_url(monkeypatch): assert "https://trace.example.com/project/arhermes1/traces" in result.output +class _FakeHermesUpdatingClient(_FakeHermesClient): + async def get_agent(self, agent_id=None, name=None, include_api_key=False): + return { + "basic": { + "agent_id": agent_id or "ar-hermes-1", + "name": name or "demo-hermes", + "status": "UPDATING", + "phase": "Updating", + "message": "Container 'agent-runtime' failed: ImagePullBackOff", + "framework": "hermes", + "region": "cn-beijing-6", + "replicas": 1, + "ready_replicas": 0, + }, + "quick_access": { + "public_endpoint": "https://hermes.example.com", + "api_key": "ak-hermes" if include_api_key else None, + }, + "advanced": { + "observability_url": "https://trace.example.com/project/arhermes1/traces", + }, + } + + +def test_hermes_status_shows_message_and_replicas_when_not_running(monkeypatch): + runner = CliRunner() + monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesUpdatingClient) + + result = runner.invoke(cmd_hermes.hermes, ["status", "ar-hermes-1"]) + + assert result.exit_code == 0, result.output + assert "UPDATING" in result.output + assert "0/1" in result.output + assert "ImagePullBackOff" in result.output + + +def test_hermes_status_hides_message_when_running(monkeypatch): + runner = CliRunner() + monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesClient) + + result = runner.invoke(cmd_hermes.hermes, ["status", "ar-hermes-1"]) + + assert result.exit_code == 0, result.output + assert "RUNNING" in result.output + assert "消息" not in result.output + assert "副本" not in result.output + + def test_hermes_delete_uses_delete_specific_next_steps(monkeypatch): runner = CliRunner() _FakeHermesClient.deleted = [] diff --git a/tests/test_conversation_runtime.py b/tests/test_conversation_runtime.py index 85321a97..14d71aba 100644 --- a/tests/test_conversation_runtime.py +++ b/tests/test_conversation_runtime.py @@ -14,11 +14,16 @@ from opentelemetry.sdk.trace.export import SimpleSpanProcessor from ksadk.conversations.context import build_history_from_events +from ksadk.conversations.context import canonical_event_type from ksadk.conversations.model_options import normalize_model_options from ksadk.conversations.model_context import estimate_text_tokens from ksadk.conversations.runtime import ( + PreparedConversationTurn, + _build_runner_request_payload, _build_runner_ambient_contexts, append_context_checkpoint_event, + append_run_checkpoint_event, + append_run_resume_event, build_compaction_sse_event, build_run_input, compact_conversation_history, @@ -55,6 +60,67 @@ async def invoke(self, input_data: dict) -> dict: return {"output": "assistant says hi"} +class _TransientFallbackRunner(_StubRunner): + def __init__(self): + super().__init__() + self.fail_once = True + + async def invoke(self, input_data: dict) -> dict: + self.calls.append(input_data) + if self.fail_once: + self.fail_once = False + raise RuntimeError("model unavailable") + return {"output": "assistant says hi"} + + async def stream(self, input_data: dict): + self.calls.append(input_data) + if self.fail_once: + self.fail_once = False + raise RuntimeError("model unavailable") + yield {"type": "text", "delta": "fallback answer"} + yield {"type": "final", "output": "fallback answer"} + + +class _CheckpointMetadataRunner(_StubRunner): + async def invoke(self, input_data: dict) -> dict: + self.calls.append(input_data) + return { + "output": "checkpointed", + "metadata": { + "agentengine": { + "run_id": "run-1", + "framework": "langgraph", + "framework_ref": { + "langgraph": { + "thread_id": "tenant:agent:sess-1", + "checkpoint_id": "ckpt-1", + } + }, + } + }, + } + + +class _CheckpointResumeAdvancedRunner(_StubRunner): + async def invoke(self, input_data: dict) -> dict: + self.calls.append(input_data) + return { + "output": "resumed", + "metadata": { + "agentengine": { + "run_id": "run-1", + "framework": "langgraph", + "framework_ref": { + "langgraph": { + "thread_id": "tenant:agent:sess-1", + "checkpoint_id": "ckpt-after-resume", + } + }, + } + }, + } + + class _PromptTooLongRunner(_StubRunner): def __init__(self): super().__init__() @@ -68,6 +134,12 @@ async def invoke(self, input_data: dict) -> dict: return {"output": "compacted answer"} +class _FailingRunner(_StubRunner): + async def invoke(self, input_data: dict) -> dict: + self.calls.append(input_data) + raise RuntimeError("boom") + + class _StreamingRunner(_StubRunner): def __init__(self): super().__init__() @@ -79,6 +151,77 @@ async def stream(self, input_data: dict): yield {"type": "final", "output": "hello"} +class _CheckpointMetadataStreamingRunner(_StreamingRunner): + async def stream(self, input_data: dict): + self.stream_calls.append(input_data) + yield {"type": "text", "delta": "hello"} + yield { + "type": "checkpoint", + "metadata": { + "agentengine": { + "run_id": "run-1", + "framework": "langgraph", + "framework_ref": { + "langgraph": { + "thread_id": "tenant:agent:sess-1", + "checkpoint_id": "ckpt-stream", + } + }, + } + }, + } + + +class _CheckpointMetadataPhaseStreamingRunner(_StreamingRunner): + async def stream(self, input_data: dict): + self.stream_calls.append(input_data) + yield { + "type": "checkpoint", + "metadata": { + "agentengine": { + "run_id": "run-1", + "phase": "数据清洗完成,等待生成报告", + "stage": "清洗聚合指标", + "summary": "GMV、转化率和退款率已经聚合完成", + "next_action": "恢复后继续生成复盘报告", + "framework": "langgraph", + "framework_ref": { + "langgraph": { + "thread_id": "tenant:agent:sess-1", + "checkpoint_id": "ckpt-business-stage", + } + }, + } + }, + } + + +class _CheckpointMetadataWithoutRunIdStreamingRunner(_StreamingRunner): + async def stream(self, input_data: dict): + self.stream_calls.append(input_data) + yield { + "type": "checkpoint", + "metadata": { + "agentengine": { + "framework": "langgraph", + "framework_ref": { + "langgraph": { + "thread_id": "tenant:agent:sess-1", + "checkpoint_id": "ckpt-stream-after-resume", + } + }, + } + }, + } + + +class _BlockingStreamingRunner(_StreamingRunner): + async def stream(self, input_data: dict): + self.stream_calls.append(input_data) + yield {"type": "text", "delta": "hello"} + await asyncio.Event().wait() + + class _ApprovalToolResultStreamingRunner(_StreamingRunner): async def stream(self, input_data: dict): self.stream_calls.append(input_data) @@ -107,6 +250,25 @@ async def stream(self, input_data: dict): yield {"type": "final", "output": "should not complete"} +class _SuccessfulToolResultStreamingRunner(_StreamingRunner): + async def stream(self, input_data: dict): + self.stream_calls.append(input_data) + yield { + "type": "tool_call", + "tool_name": "list_skills", + "tool_args": {"include": ["focused"]}, + "run_id": "run-list-skills", + } + yield { + "type": "tool_result", + "tool_name": "list_skills", + "tool_args": {"include": ["focused"]}, + "tool_output": {"ok": True, "skills": [{"name": "ppt-translator"}]}, + "run_id": "run-list-skills", + } + yield {"type": "final", "output": "done"} + + class _ResumeStreamingRunner(_StreamingRunner): async def stream(self, input_data: dict): self.stream_calls.append(input_data) @@ -1241,6 +1403,51 @@ async def test_invoke_conversation_once_passes_model_options_to_runner(monkeypat } +@pytest.mark.asyncio +async def test_invoke_conversation_once_falls_back_on_transient_model_error(monkeypatch): + service = InMemorySessionService() + monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service) + runner = _TransientFallbackRunner() + + await invoke_conversation_once( + runner=runner, + agent_id="demo-agent", + user_id="user-1", + session_id=None, + messages=[{"role": "user", "content": "hello"}], + model="glm-5.2", + prepare_runner=lambda current_runner, model: current_runner.prepare_for_request(model), + ) + + assert runner.prepared_models == ["glm-5.2", "deepseek-v4-pro"] + assert runner.calls[-1]["model"] == "deepseek-v4-pro" + + +@pytest.mark.asyncio +async def test_stream_conversation_turn_falls_back_before_first_delta(monkeypatch): + service = InMemorySessionService() + monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service) + runner = _TransientFallbackRunner() + + events = [ + event + async for event in stream_conversation_turn( + runner=runner, + agent_id="demo-agent", + user_id="user-1", + session_id=None, + messages=[{"role": "user", "content": "hello"}], + model="glm-5.2", + prepare_runner=lambda current_runner, model: current_runner.prepare_for_request(model), + ) + ] + + assert runner.prepared_models == ["glm-5.2", "deepseek-v4-pro"] + assert runner.calls[-1]["model"] == "deepseek-v4-pro" + assert any("fallback answer" in event for event in events) + assert any("response.completed" in event for event in events) + + @pytest.mark.asyncio async def test_invoke_conversation_once_auto_saves_turn_to_sdk_memory_by_default(monkeypatch): service = InMemorySessionService() @@ -1463,6 +1670,21 @@ async def test_invoke_conversation_once_executes_approved_builtin_tool_resume( await service.create_session( agent_id="demo-agent", user_id="user-1", session_id="sess-tool-approval" ) + await append_run_checkpoint_event( + session_id="sess-tool-approval", + author="demo-agent", + run_id="call_write", + checkpoint_id="ckpt-before-tool", + framework="langgraph", + framework_ref={ + "langgraph": { + "thread_id": "tenant:agent:sess-tool-approval", + "checkpoint_id": "ckpt-before-tool", + } + }, + invocation_id="inv-checkpoint", + session_service_provider=lambda: service, + ) await service.append_event( "sess-tool-approval", SessionEvent( @@ -1508,6 +1730,159 @@ async def test_invoke_conversation_once_executes_approved_builtin_tool_resume( tool_result = next(event for event in events if event.event_type == "tool_result") assert tool_result.metadata["tool_name"] == "write_workspace_file" assert tool_result.metadata["tool_output"]["ok"] is True + receipt = tool_result.metadata["tool_receipt"] + assert receipt["tool_name"] == "write_workspace_file" + assert receipt["tool_call_id"] == "call_write" + assert receipt["run_id"] == "call_write" + assert receipt["checkpoint_id"] == "ckpt-before-tool" + assert receipt["framework"] == "langgraph" + assert receipt["framework_ref"]["langgraph"]["thread_id"] == "tenant:agent:sess-tool-approval" + assert receipt["status"] == "completed" + assert receipt["idempotency_key"].startswith("tool_receipt:") + + +@pytest.mark.asyncio +async def test_invoke_conversation_once_treats_accepted_memory_save_as_completed_receipt( + monkeypatch, +): + service = InMemorySessionService() + monkeypatch.setenv("KSADK_TOOL_APPROVAL_MODE", "strict") + monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service) + monkeypatch.setattr( + "ksadk.conversations.runtime._builtin_tool_callable", + lambda name: ( + lambda **kwargs: { + "ok": False, + "status": "accepted_not_extracted", + "message": "记忆保存请求已被后端受理,但尚未抽取成可检索记忆。", + "session_state": 0, + "session_id": "sess-memory-accepted", + } + ) + if name == "save_memory" + else None, + ) + await service.create_session( + agent_id="demo-agent", user_id="user-1", session_id="sess-memory-accepted" + ) + await service.append_event( + "sess-memory-accepted", + SessionEvent( + id="evt-approval", + author="demo-agent", + event_type="approval_request", + content={"role": "model", "parts": [{"text": "approval required"}]}, + metadata={ + "interrupt_info": { + "approval_request_id": "appr_save_memory", + "tool_name": "save_memory", + "arguments": {"content": "favorite_breakfast: 武汉热干面"}, + "run_id": "call_save_memory", + "server_label": "ksadk", + } + }, + invocation_id="inv-approval", + ), + ) + runner = _StubRunner() + + await invoke_conversation_once( + runner=runner, + agent_id="demo-agent", + user_id="user-1", + session_id="sess-memory-accepted", + messages=[], + model="gpt-4o", + resume_input={ + "type": "mcp_approval_response", + "approval_request_id": "appr_save_memory", + "approve": True, + }, + prepare_runner=lambda current_runner, model: current_runner.prepare_for_request(model), + ) + + assert runner.calls[-1]["input"]["output"]["status"] == "accepted_not_extracted" + events = await service.get_events("sess-memory-accepted") + tool_result = next(event for event in events if event.event_type == "tool_result") + receipt = tool_result.metadata["tool_receipt"] + assert receipt["tool_name"] == "save_memory" + assert receipt["status"] == "completed" + + +@pytest.mark.asyncio +async def test_invoke_conversation_once_replays_existing_tool_receipt_without_side_effect( + monkeypatch, + tmp_path: Path, +): + service = InMemorySessionService() + workspace_ui = tmp_path / "ui" + monkeypatch.setenv("AGENTENGINE_UI_DIR", str(workspace_ui)) + monkeypatch.setenv("KSADK_TOOL_APPROVAL_MODE", "strict") + monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service) + await service.create_session( + agent_id="demo-agent", user_id="user-1", session_id="sess-tool-replay" + ) + await service.append_event( + "sess-tool-replay", + SessionEvent( + id="evt-approval", + author="demo-agent", + event_type="approval_request", + content={"role": "model", "parts": [{"text": "approval required"}]}, + metadata={ + "interrupt_info": { + "approval_request_id": "appr_write", + "tool_name": "write_workspace_file", + "arguments": {"path": "notes.txt", "content": "hello"}, + "run_id": "call_write", + "server_label": "ksadk", + } + }, + invocation_id="inv-approval", + ), + ) + runner = _StubRunner() + resume_input = { + "type": "mcp_approval_response", + "approval_request_id": "appr_write", + "approve": True, + } + + await invoke_conversation_once( + runner=runner, + agent_id="demo-agent", + user_id="user-1", + session_id="sess-tool-replay", + messages=[], + model="gpt-4o", + resume_input=resume_input, + prepare_runner=lambda current_runner, model: current_runner.prepare_for_request(model), + ) + (workspace_ui / "workspace" / "notes.txt").write_text("changed-by-user", encoding="utf-8") + + await invoke_conversation_once( + runner=runner, + agent_id="demo-agent", + user_id="user-1", + session_id="sess-tool-replay", + messages=[], + model="gpt-4o", + resume_input=resume_input, + prepare_runner=lambda current_runner, model: current_runner.prepare_for_request(model), + ) + + assert (workspace_ui / "workspace" / "notes.txt").read_text(encoding="utf-8") == "changed-by-user" + assert runner.calls[-1]["input"]["type"] == "function_call_output" + assert runner.calls[-1]["input"]["output"]["ok"] is True + assert runner.calls[-1]["input"]["output"]["replayed"] is True + events = await service.get_events("sess-tool-replay") + tool_results = [event for event in events if event.event_type == "tool_result"] + assert len(tool_results) == 2 + assert tool_results[-1].metadata["tool_receipt"]["replayed"] is True + assert ( + tool_results[-1].metadata["tool_receipt"]["idempotency_key"] + == tool_results[0].metadata["tool_receipt"]["idempotency_key"] + ) @pytest.mark.asyncio @@ -2015,6 +2390,34 @@ async def test_stream_conversation_turn_passes_session_id_to_runner(monkeypatch) assert runner.stream_calls[-1]["session_id"] == session.id +@pytest.mark.asyncio +async def test_stream_conversation_turn_emits_final_text_after_tool_events(monkeypatch): + service = InMemorySessionService() + monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service) + runner = _SuccessfulToolResultStreamingRunner() + + chunks = [ + chunk + async for chunk in stream_conversation_turn( + runner=runner, + agent_id="demo-agent", + user_id="user-1", + session_id=None, + messages=[{"role": "user", "content": "记住这个"}], + model="gpt-4o", + prepare_runner=lambda current_runner, model: current_runner.prepare_for_request(model), + session_service_provider=lambda: service, + ) + ] + + assert any("response.completed" in chunk and '"output_text": "done"' in chunk for chunk in chunks) + completed_payload = _extract_sse_payload(chunks, "response.completed") + session_id = completed_payload["session_id"] + events = await service.get_events(session_id) + assistant_messages = [event for event in events if event.event_type == "assistant_message"] + assert assistant_messages[-1].content["parts"][0]["text"] == "done" + + @pytest.mark.asyncio async def test_stream_responses_conversation_turn_maps_ksadk_resume_to_runner_resume(monkeypatch): service = InMemorySessionService() @@ -2065,6 +2468,45 @@ async def test_stream_responses_conversation_turn_maps_ksadk_resume_to_runner_re assert "approval_response" in [event.event_type for event in events] +@pytest.mark.asyncio +async def test_stream_responses_conversation_turn_emits_cancelled_terminal(monkeypatch): + service = InMemorySessionService() + monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service) + runner = _BlockingStreamingRunner() + chunks: list[str] = [] + + async def consume(): + async for chunk in stream_responses_conversation_turn( + runner=runner, + agent_id="demo-agent", + user_id="user-1", + session_id="sess-cancel-stream", + messages=[{"role": "user", "content": "cancel me"}], + model="gpt-4o", + invocation_id="inv-cancel-stream", + prepare_runner=lambda current_runner, model: current_runner.prepare_for_request(model), + session_service_provider=lambda: service, + ): + chunks.append(chunk) + + task = asyncio.create_task(consume()) + for _ in range(20): + if any("response.output_text.delta" in chunk for chunk in chunks): + break + await asyncio.sleep(0.01) + task.cancel() + await task + + events = await service.get_events("sess-cancel-stream") + statuses = [ + event.content.get("status") + for event in events + if event.event_type == "run_status" + ] + assert statuses == ["in_progress", "cancelled"] + assert any(chunk.startswith("event: response.cancelled\n") for chunk in chunks) + + @pytest.mark.asyncio async def test_stream_responses_conversation_turn_promotes_gateway_approval_result_to_interrupt(monkeypatch): service = InMemorySessionService() @@ -2112,10 +2554,61 @@ async def test_stream_responses_conversation_turn_promotes_gateway_approval_resu @pytest.mark.asyncio -async def test_stream_responses_conversation_turn_replays_completed_output_items(monkeypatch): +async def test_stream_responses_conversation_turn_adds_tool_receipt_to_tool_result(monkeypatch): service = InMemorySessionService() monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service) - runner = _CompletedOutputStreamingRunner() + runner = _SuccessfulToolResultStreamingRunner() + await service.create_session( + agent_id="demo-agent", user_id="user-1", session_id="sess-tool-receipt" + ) + await append_run_checkpoint_event( + session_id="sess-tool-receipt", + author="demo-agent", + run_id="run-list-skills", + checkpoint_id="ckpt-list-skills", + framework="langgraph", + framework_ref={ + "langgraph": { + "thread_id": "tenant:agent:sess-tool-receipt", + "checkpoint_id": "ckpt-list-skills", + } + }, + invocation_id="inv-checkpoint", + session_service_provider=lambda: service, + ) + + chunks = [ + chunk + async for chunk in stream_responses_conversation_turn( + runner=runner, + agent_id="demo-agent", + user_id="user-1", + session_id="sess-tool-receipt", + messages=[{"role": "user", "content": "列出 skills"}], + model="gpt-4o", + prepare_runner=lambda current_runner, model: current_runner.prepare_for_request(model), + session_service_provider=lambda: service, + ) + ] + + assert any(chunk.startswith("event: response.completed\n") for chunk in chunks) + events = await service.get_events("sess-tool-receipt") + tool_result = next(event for event in events if event.event_type == "tool_result") + receipt = tool_result.metadata["tool_receipt"] + assert receipt["tool_name"] == "list_skills" + assert receipt["tool_call_id"] == "run-list-skills" + assert receipt["run_id"] == "run-list-skills" + assert receipt["checkpoint_id"] == "ckpt-list-skills" + assert receipt["framework"] == "langgraph" + assert receipt["status"] == "completed" + assert receipt["idempotency_key"].startswith("tool_receipt:") + + +@pytest.mark.asyncio +async def test_stream_responses_conversation_turn_replays_completed_output_items(monkeypatch): + service = InMemorySessionService() + monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service) + runner = _CompletedOutputStreamingRunner() chunks = [ chunk @@ -2472,6 +2965,38 @@ async def invoke(self, input_data: dict) -> dict: assert session.title_source == "heuristic" +@pytest.mark.asyncio +async def test_invoke_conversation_once_strips_inline_think_markup_from_output(monkeypatch): + service = InMemorySessionService() + monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service) + + class _ThinkingTagRunner(_StubRunner): + async def invoke(self, input_data: dict) -> dict: + self.calls.append(input_data) + return {"output": "先判断问题。我是招聘助手。"} + + runner = _ThinkingTagRunner() + session_id, result = await invoke_conversation_once( + runner=runner, + agent_id="demo-agent", + user_id="user-1", + session_id=None, + messages=[{"role": "user", "content": "你好,请介绍一下你自己"}], + model="glm-5.1", + prepare_runner=lambda current_runner, model: current_runner.prepare_for_request(model), + ) + + events = await service.get_events(session_id) + assistant_event = next(event for event in events if event.event_type == "assistant_message") + session = await service.get_session(session_id) + + assert result["output_text"] == "我是招聘助手。" + assert assistant_event.content["parts"][0]["text"] == "我是招聘助手。" + assert session is not None + assert session.summary == "我是招聘助手。" + assert session.title == "招聘助手能力" + + @pytest.mark.asyncio async def test_invoke_conversation_once_uses_heuristic_title_for_architecture_attachment( monkeypatch, @@ -2560,6 +3085,399 @@ def test_session_event_infers_canonical_message_types(): assert assistant_event.event_type == "assistant_message" +def test_runtime_checkpoint_events_are_canonical_but_not_projected_to_history(): + events = [ + SessionEvent( + id="evt-1", + author="demo-agent", + event_type="run_checkpoint", + content={"text": "checkpoint saved"}, + metadata={"run_id": "run-1", "checkpoint_id": "ckpt-1"}, + seq_id=1, + ), + SessionEvent( + id="evt-2", + author="demo-agent", + event_type="run_resume", + content={"text": "resume requested"}, + metadata={"run_id": "run-1", "resume_attempt_id": "resume-1"}, + seq_id=2, + ), + SessionEvent( + id="evt-3", + author="user", + event_type="user_message", + content={"role": "user", "parts": [{"text": "继续"}]}, + seq_id=3, + ), + ] + + assert canonical_event_type("run_checkpoint") == "run_checkpoint" + assert canonical_event_type("run_resume") == "run_resume" + assert build_history_from_events(events) == [{"role": "user", "content": "继续"}] + + +@pytest.mark.asyncio +async def test_append_run_checkpoint_and_resume_events(monkeypatch): + service = InMemorySessionService() + await service.create_session(agent_id="demo-agent", user_id="user-1", session_id="sess-1") + monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service) + + checkpoint = await append_run_checkpoint_event( + session_id="sess-1", + author="demo-agent", + run_id="run-1", + checkpoint_id="ckpt-1", + framework="langgraph", + framework_ref={ + "langgraph": { + "thread_id": "tenant:agent:sess-1", + "checkpoint_id": "ckpt-1", + } + }, + phase="tool_result", + invocation_id="inv-1", + ) + resume = await append_run_resume_event( + session_id="sess-1", + author="demo-agent", + run_id="run-1", + checkpoint_id="ckpt-1", + resume_attempt_id="resume-1", + framework="langgraph", + framework_ref={ + "langgraph": { + "thread_id": "tenant:agent:sess-1", + "checkpoint_id": "ckpt-1", + } + }, + invocation_id="inv-2", + ) + + assert checkpoint.event_type == "run_checkpoint" + assert checkpoint.metadata["run_id"] == "run-1" + assert checkpoint.metadata["checkpoint_id"] == "ckpt-1" + assert checkpoint.metadata["framework_ref"]["langgraph"]["thread_id"] == "tenant:agent:sess-1" + assert resume.event_type == "run_resume" + assert resume.metadata["resume_attempt_id"] == "resume-1" + + +def test_extract_responses_resume_input_accepts_checkpoint_resume_action(): + resume_input = extract_responses_resume_input( + [ + { + "type": "agentengine.resume_checkpoint", + "run_id": "run-1", + "checkpoint_id": "ckpt-1", + "resume_attempt_id": "resume-1", + "framework": "langgraph", + "framework_ref": { + "langgraph": { + "thread_id": "tenant:agent:sess-1", + "checkpoint_id": "ckpt-1", + } + }, + } + ] + ) + + assert resume_input == { + "type": "agentengine.resume_checkpoint", + "run_id": "run-1", + "checkpoint_id": "ckpt-1", + "resume_attempt_id": "resume-1", + "framework": "langgraph", + "framework_ref": { + "langgraph": { + "thread_id": "tenant:agent:sess-1", + "checkpoint_id": "ckpt-1", + } + }, + } + + +def test_build_runner_request_payload_exposes_invocation_id(): + prepared = PreparedConversationTurn( + session_id="sess-1", + invocation_id="inv-runtime-cancel", + user_input="hello", + user_display_input="hello", + history=[], + input_content=[], + input_messages=[], + user_parts=[], + attachments=[], + attachment_results=[], + current_attachments=[], + current_attachment_results=[], + has_current_files=False, + ) + runtime_context = PlatformInvocationContext( + agent_id="demo-agent", + user_id="user-1", + session_id="sess-1", + history=[], + input_content=[], + input_messages=[], + input_parts=[], + attachments=[], + attachment_results=[], + current_attachments=[], + current_attachment_results=[], + has_current_files=False, + runner_type="langgraph", + ) + + payload = _build_runner_request_payload( + prepared=prepared, + model="demo-model", + runtime_context=runtime_context, + ) + + assert payload["invocation_id"] == "inv-runtime-cancel" + + +@pytest.mark.asyncio +async def test_invoke_conversation_once_checkpoint_resume_writes_runtime_event(monkeypatch): + service = InMemorySessionService() + await service.create_session(agent_id="demo-agent", user_id="user-1", session_id="sess-1") + monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service) + + runner = _StubRunner() + session_id, result = await invoke_conversation_once( + runner=runner, + agent_id="demo-agent", + user_id="user-1", + session_id="sess-1", + messages=[], + model="demo-model", + prepare_runner=lambda active_runner, model: active_runner.prepare_for_request(model), + resume_input={ + "type": "agentengine.resume_checkpoint", + "run_id": "run-1", + "checkpoint_id": "ckpt-1", + "resume_attempt_id": "resume-1", + "framework": "langgraph", + "framework_ref": { + "langgraph": { + "thread_id": "tenant:agent:sess-1", + "checkpoint_id": "ckpt-1", + } + }, + }, + ) + + events = await service.get_events(session_id) + resume_events = [event for event in events if event.event_type == "run_resume"] + assert len(resume_events) == 1 + assert resume_events[0].metadata["run_id"] == "run-1" + assert resume_events[0].metadata["checkpoint_id"] == "ckpt-1" + assert resume_events[0].metadata["resume_attempt_id"] == "resume-1" + assert build_history_from_events(events) == [{"role": "model", "content": "assistant says hi"}] + assert runner.calls[0]["checkpoint_resume"] is True + assert runner.calls[0]["run_id"] == "run-1" + assert runner.calls[0]["framework_ref"]["langgraph"]["checkpoint_id"] == "ckpt-1" + assert result["metadata"]["agentengine"]["run_id"] == "run-1" + assert result["metadata"]["agentengine"]["resume_attempt_id"] == "resume-1" + + +@pytest.mark.asyncio +async def test_invoke_conversation_once_failure_does_not_write_completed_or_assistant(monkeypatch): + service = InMemorySessionService() + await service.create_session(agent_id="demo-agent", user_id="user-1", session_id="sess-fail") + monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service) + + runner = _FailingRunner() + with pytest.raises(RuntimeError, match="boom"): + await invoke_conversation_once( + runner=runner, + agent_id="demo-agent", + user_id="user-1", + session_id="sess-fail", + messages=[{"role": "user", "content": "hello"}], + model="demo-model", + prepare_runner=lambda active_runner, model: active_runner.prepare_for_request(model), + ) + + events = await service.get_events("sess-fail") + assert [event.event_type for event in events] == ["user_message", "run_status", "run_status"] + assert [event.content.get("status") for event in events if event.event_type == "run_status"] == [ + "in_progress", + "failed", + ] + + +@pytest.mark.asyncio +async def test_checkpoint_resume_response_metadata_prefers_new_checkpoint(monkeypatch): + service = InMemorySessionService() + await service.create_session(agent_id="demo-agent", user_id="user-1", session_id="sess-1") + monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service) + + runner = _CheckpointResumeAdvancedRunner() + _, result = await invoke_conversation_once( + runner=runner, + agent_id="demo-agent", + user_id="user-1", + session_id="sess-1", + messages=[], + model="demo-model", + prepare_runner=lambda active_runner, model: active_runner.prepare_for_request(model), + resume_input={ + "type": "agentengine.resume_checkpoint", + "run_id": "run-1", + "checkpoint_id": "ckpt-before-resume", + "resume_attempt_id": "resume-1", + "framework": "langgraph", + "framework_ref": { + "langgraph": { + "thread_id": "tenant:agent:sess-1", + "checkpoint_id": "ckpt-before-resume", + } + }, + }, + ) + + assert ( + result["metadata"]["agentengine"]["framework_ref"]["langgraph"]["checkpoint_id"] + == "ckpt-after-resume" + ) + events = await service.get_events("sess-1") + assert [event.event_type for event in events if event.event_type.startswith("run_")] == [ + "run_resume", + "run_status", + "run_checkpoint", + "run_status", + ] + + +@pytest.mark.asyncio +async def test_invoke_conversation_once_records_runner_checkpoint_metadata(monkeypatch): + service = InMemorySessionService() + await service.create_session(agent_id="demo-agent", user_id="user-1", session_id="sess-1") + monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service) + + runner = _CheckpointMetadataRunner() + session_id, result = await invoke_conversation_once( + runner=runner, + agent_id="demo-agent", + user_id="user-1", + session_id="sess-1", + messages=[{"role": "user", "content": "hello"}], + model="demo-model", + prepare_runner=lambda active_runner, model: active_runner.prepare_for_request(model), + ) + + events = await service.get_events(session_id) + checkpoint_events = [event for event in events if event.event_type == "run_checkpoint"] + assert len(checkpoint_events) == 1 + assert checkpoint_events[0].metadata["run_id"] == "run-1" + assert checkpoint_events[0].metadata["checkpoint_id"] == "ckpt-1" + assert checkpoint_events[0].metadata["framework_ref"]["langgraph"]["thread_id"] == "tenant:agent:sess-1" + assert result["metadata"]["agentengine"]["framework_ref"]["langgraph"]["checkpoint_id"] == "ckpt-1" + + +@pytest.mark.asyncio +async def test_stream_conversation_turn_records_checkpoint_chunk(monkeypatch): + service = InMemorySessionService() + await service.create_session(agent_id="demo-agent", user_id="user-1", session_id="sess-1") + monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service) + + runner = _CheckpointMetadataStreamingRunner() + chunks = [ + chunk + async for chunk in stream_conversation_turn( + runner=runner, + agent_id="demo-agent", + user_id="user-1", + session_id="sess-1", + messages=[{"role": "user", "content": "hello"}], + model="demo-model", + prepare_runner=lambda active_runner, model: active_runner.prepare_for_request(model), + ) + ] + + events = await service.get_events("sess-1") + checkpoint_events = [event for event in events if event.event_type == "run_checkpoint"] + assert len(checkpoint_events) == 1 + assert checkpoint_events[0].metadata["run_id"] == "run-1" + assert checkpoint_events[0].metadata["checkpoint_id"] == "ckpt-stream" + completed = [chunk for chunk in chunks if "response.completed" in chunk][0] + assert "ckpt-stream" in completed + + +@pytest.mark.asyncio +async def test_stream_conversation_turn_preserves_checkpoint_phase(monkeypatch): + service = InMemorySessionService() + await service.create_session(agent_id="demo-agent", user_id="user-1", session_id="sess-1") + monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service) + + runner = _CheckpointMetadataPhaseStreamingRunner() + chunks = [ + chunk + async for chunk in stream_conversation_turn( + runner=runner, + agent_id="demo-agent", + user_id="user-1", + session_id="sess-1", + messages=[{"role": "user", "content": "hello"}], + model="demo-model", + prepare_runner=lambda active_runner, model: active_runner.prepare_for_request(model), + ) + ] + + events = await service.get_events("sess-1") + checkpoint_events = [event for event in events if event.event_type == "run_checkpoint"] + assert len(checkpoint_events) == 1 + assert checkpoint_events[0].metadata["checkpoint_id"] == "ckpt-business-stage" + assert checkpoint_events[0].metadata["phase"] == "数据清洗完成,等待生成报告" + assert checkpoint_events[0].metadata["stage"] == "清洗聚合指标" + assert checkpoint_events[0].metadata["summary"] == "GMV、转化率和退款率已经聚合完成" + assert checkpoint_events[0].metadata["next_action"] == "恢复后继续生成复盘报告" + assert any("response.completed" in chunk for chunk in chunks) + + +@pytest.mark.asyncio +async def test_stream_checkpoint_resume_falls_back_to_original_run_id(monkeypatch): + service = InMemorySessionService() + await service.create_session(agent_id="demo-agent", user_id="user-1", session_id="sess-1") + monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service) + + runner = _CheckpointMetadataWithoutRunIdStreamingRunner() + chunks = [ + chunk + async for chunk in stream_conversation_turn( + runner=runner, + agent_id="demo-agent", + user_id="user-1", + session_id="sess-1", + messages=[], + model="demo-model", + prepare_runner=lambda active_runner, model: active_runner.prepare_for_request(model), + invocation_id="resume-attempt-1", + resume_input={ + "type": "agentengine.resume_checkpoint", + "run_id": "run-original", + "checkpoint_id": "ckpt-before", + "resume_attempt_id": "resume-attempt-1", + "framework": "langgraph", + "framework_ref": { + "langgraph": { + "thread_id": "tenant:agent:sess-1", + "checkpoint_id": "ckpt-before", + } + }, + }, + ) + ] + + events = await service.get_events("sess-1") + checkpoint_events = [event for event in events if event.event_type == "run_checkpoint"] + assert len(checkpoint_events) == 1 + assert checkpoint_events[0].metadata["run_id"] == "run-original" + assert checkpoint_events[0].metadata["checkpoint_id"] == "ckpt-stream-after-resume" + assert any("response.completed" in chunk for chunk in chunks) + + def test_build_history_from_events_prefers_latest_checkpoint_and_tail(): events = [ SessionEvent( diff --git a/tests/test_model_policy.py b/tests/test_model_policy.py new file mode 100644 index 00000000..91d3165e --- /dev/null +++ b/tests/test_model_policy.py @@ -0,0 +1,56 @@ +import os + +from ksadk.configs.settings import DEFAULT_MODEL_NAME +from ksadk.model_policy import ( + DEFAULT_MODEL_POLICY, + fallback_model_for_exception, + model_policy_options_for_model, + normalize_model_policy, +) + + +def test_default_model_policy_matches_release_defaults(): + policy = normalize_model_policy(DEFAULT_MODEL_POLICY) + + assert DEFAULT_MODEL_NAME == "glm-5.2" + assert policy["primary"]["model"] == "glm-5.2" + assert policy["multimodal"]["model"] == "kimi-k2.7-code" + assert policy["fallback"]["model"] == "deepseek-v4-pro" + assert policy["models"]["kimi-k2.7-code"]["options"]["temperature"] == 1 + + +def test_model_policy_options_apply_kimi_temperature_constraint(): + assert model_policy_options_for_model("kimi-k2.7-code") == {"temperature": 1} + assert model_policy_options_for_model("ksyun/kimi-k2.7-code") == {"temperature": 1} + assert model_policy_options_for_model("glm-5.2") == {} + + +def test_model_policy_env_override_keeps_default_shape(monkeypatch): + monkeypatch.setenv( + "AGENTENGINE_MODEL_POLICY_JSON", + '{"primary":{"model":"custom-primary"},"fallback":{"model":"custom-fallback"}}', + ) + + policy = normalize_model_policy(os.environ["AGENTENGINE_MODEL_POLICY_JSON"]) + + assert policy["primary"]["model"] == "custom-primary" + assert policy["fallback"]["model"] == "custom-fallback" + assert policy["multimodal"]["model"] == "kimi-k2.7-code" + + +def test_fallback_model_for_exception_only_accepts_transient_errors(): + assert ( + fallback_model_for_exception(RuntimeError("model unavailable"), current_model="glm-5.2") + == "deepseek-v4-pro" + ) + assert ( + fallback_model_for_exception(RuntimeError("invalid request 400"), current_model="glm-5.2") + is None + ) + assert ( + fallback_model_for_exception( + RuntimeError("model unavailable"), + current_model="deepseek-v4-pro", + ) + is None + ) diff --git a/tests/test_openclaw_env_vars.py b/tests/test_openclaw_env_vars.py new file mode 100644 index 00000000..ee497055 --- /dev/null +++ b/tests/test_openclaw_env_vars.py @@ -0,0 +1,447 @@ +import re +import json +import pytest + +from ksadk.cli import cmd_openclaw + + +def test_build_openclaw_env_vars_defaults_to_trusted_proxy(monkeypatch): + monkeypatch.setattr(cmd_openclaw, "_GLOBAL_ENV_CACHE", {}) + monkeypatch.delenv("OPENCLAW_GATEWAY_AUTH_MODE", raising=False) + monkeypatch.delenv("OPENCLAW_TRUSTED_PROXY_USER_HEADER", raising=False) + monkeypatch.delenv("OPENCLAW_TRUSTED_PROXIES", raising=False) + + env = cmd_openclaw._build_openclaw_env_vars() + + assert env["OPENCLAW_GATEWAY_AUTH_MODE"] == "trusted-proxy" + assert env["OPENCLAW_TRUSTED_PROXY_USER_HEADER"] == "x-forwarded-user" + assert env["OPENCLAW_INTERNAL_TRUSTED_PROXY_USER"] == "openclaw-backend" + assert env["OPENCLAW_INTERNAL_TRUSTED_PROXY_USER_HEADER"] == "x-forwarded-user" + assert env["OPENCLAW_TRUSTED_PROXIES"] == "127.0.0.1,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,35.0.0.0/8" + + +def test_build_openclaw_env_vars_switches_to_token_mode_when_token_configured(monkeypatch): + monkeypatch.setattr(cmd_openclaw, "_GLOBAL_ENV_CACHE", {}) + monkeypatch.setenv("OPENCLAW_GATEWAY_TOKEN", "gateway-token-demo") + + env = cmd_openclaw._build_openclaw_env_vars() + + assert env["OPENCLAW_GATEWAY_AUTH_MODE"] == "token" + assert env["OPENCLAW_GATEWAY_TOKEN"] == "gateway-token-demo" + assert env["OPENCLAW_GATEWAY_PASSWORD"] == "gateway-token-demo" + + +def test_build_openclaw_env_vars_accepts_password_alias_for_token_mode(monkeypatch): + monkeypatch.setattr(cmd_openclaw, "_GLOBAL_ENV_CACHE", {}) + monkeypatch.setenv("OPENCLAW_GATEWAY_AUTH_MODE", "token") + monkeypatch.setenv("OPENCLAW_GATEWAY_PASSWORD", "gateway-password-demo") + + env = cmd_openclaw._build_openclaw_env_vars() + + assert env["OPENCLAW_GATEWAY_AUTH_MODE"] == "token" + assert env["OPENCLAW_GATEWAY_TOKEN"] == "gateway-password-demo" + assert env["OPENCLAW_GATEWAY_PASSWORD"] == "gateway-password-demo" + + +def test_build_openclaw_env_vars_rejects_mismatched_token_and_password(monkeypatch): + monkeypatch.setattr(cmd_openclaw, "_GLOBAL_ENV_CACHE", {}) + monkeypatch.setenv("OPENCLAW_GATEWAY_AUTH_MODE", "token") + monkeypatch.setenv("OPENCLAW_GATEWAY_TOKEN", "gateway-token-demo") + monkeypatch.setenv("OPENCLAW_GATEWAY_PASSWORD", "gateway-password-other") + + with pytest.raises(ValueError, match="OPENCLAW_GATEWAY_TOKEN.*OPENCLAW_GATEWAY_PASSWORD"): + cmd_openclaw._build_openclaw_env_vars() + + +def test_build_openclaw_env_vars_rejects_token_secret_outside_token_mode(monkeypatch): + monkeypatch.setattr(cmd_openclaw, "_GLOBAL_ENV_CACHE", {}) + monkeypatch.setenv("OPENCLAW_GATEWAY_AUTH_MODE", "trusted-proxy") + monkeypatch.setenv("OPENCLAW_GATEWAY_TOKEN", "gateway-token-demo") + + with pytest.raises(ValueError, match="仅在 OPENCLAW_GATEWAY_AUTH_MODE=token 时支持"): + cmd_openclaw._build_openclaw_env_vars() + + +def test_build_openclaw_env_vars_requires_secret_for_token_mode(monkeypatch): + monkeypatch.setattr(cmd_openclaw, "_GLOBAL_ENV_CACHE", {}) + monkeypatch.setenv("OPENCLAW_GATEWAY_AUTH_MODE", "token") + monkeypatch.delenv("OPENCLAW_GATEWAY_TOKEN", raising=False) + monkeypatch.delenv("OPENCLAW_GATEWAY_PASSWORD", raising=False) + + with pytest.raises(ValueError, match="OPENCLAW_GATEWAY_TOKEN"): + cmd_openclaw._build_openclaw_env_vars() + + +def test_build_openclaw_env_vars_uses_custom_trusted_proxy_env(monkeypatch): + monkeypatch.setattr(cmd_openclaw, "_GLOBAL_ENV_CACHE", {}) + monkeypatch.setenv("OPENCLAW_GATEWAY_AUTH_MODE", "trusted-proxy") + monkeypatch.setenv("OPENCLAW_TRUSTED_PROXY_USER_HEADER", "x-auth-request-user") + monkeypatch.setenv("OPENCLAW_INTERNAL_TRUSTED_PROXY_USER", "internal-agent") + monkeypatch.setenv("OPENCLAW_TRUSTED_PROXIES", '["10.244.0.0/16","10.96.0.0/12"]') + + env = cmd_openclaw._build_openclaw_env_vars() + + assert env["OPENCLAW_GATEWAY_AUTH_MODE"] == "trusted-proxy" + assert env["OPENCLAW_TRUSTED_PROXY_USER_HEADER"] == "x-auth-request-user" + assert env["OPENCLAW_INTERNAL_TRUSTED_PROXY_USER"] == "internal-agent" + assert env["OPENCLAW_INTERNAL_TRUSTED_PROXY_USER_HEADER"] == "x-auth-request-user" + assert env["OPENCLAW_TRUSTED_PROXIES"] == "10.244.0.0/16,10.96.0.0/12" + + +def test_build_openclaw_env_vars_defaults_to_auto_approval_first(monkeypatch): + monkeypatch.setattr(cmd_openclaw, "_GLOBAL_ENV_CACHE", {}) + monkeypatch.delenv("OPENCLAW_EXEC_HOST", raising=False) + monkeypatch.delenv("OPENCLAW_EXEC_ASK", raising=False) + monkeypatch.delenv("OPENCLAW_EXEC_ASK_FALLBACK", raising=False) + monkeypatch.delenv("OPENCLAW_EXEC_AUTO_ALLOW_SKILLS", raising=False) + monkeypatch.delenv("OPENCLAW_EXEC_DEFAULT_ALLOWLIST_ENABLED", raising=False) + monkeypatch.delenv("OPENCLAW_EXEC_ALLOWLIST", raising=False) + monkeypatch.delenv("OPENCLAW_FS_WORKSPACE_ONLY", raising=False) + monkeypatch.delenv("OPENCLAW_MODEL_API_KEY_SECRET_SOURCE", raising=False) + monkeypatch.delenv("OPENCLAW_MODEL_API_KEY_SECRET_FILE_PATH", raising=False) + monkeypatch.delenv("OPENCLAW_EXEC_STRICT_MODE", raising=False) + monkeypatch.delenv("OPENCLAW_EXEC_SAFE_MODE", raising=False) + + env = cmd_openclaw._build_openclaw_env_vars() + + assert env["OPENCLAW_EXEC_HOST"] == "gateway" + assert env["OPENCLAW_EXEC_STRICT_MODE"] == "false" + assert env["OPENCLAW_EXEC_UNSAFE_MODE"] == "true" + assert env["OPENCLAW_EXEC_SECURITY"] == "full" + assert env["OPENCLAW_EXEC_ASK"] == "off" + assert env["OPENCLAW_EXEC_ASK_FALLBACK"] == "full" + assert env["OPENCLAW_EXEC_AUTO_ALLOW_SKILLS"] == "false" + assert env["OPENCLAW_EXEC_DEFAULT_ALLOWLIST_ENABLED"] == "false" + assert env["OPENCLAW_FS_WORKSPACE_ONLY"] == "false" + assert env["OPENCLAW_MODEL_API_KEY_SECRET_SOURCE"] == "file" + assert "OPENCLAW_EXEC_ALLOWLIST" not in env + assert "OPENCLAW_MODEL_API_KEY_SECRET_FILE_PATH" not in env + + +def test_build_openclaw_env_vars_exposes_exec_confirmation_controls(monkeypatch): + monkeypatch.setattr(cmd_openclaw, "_GLOBAL_ENV_CACHE", {}) + monkeypatch.setenv("OPENCLAW_EXEC_HOST", "node") + monkeypatch.setenv("OPENCLAW_EXEC_SECURITY", "deny") + monkeypatch.setenv("OPENCLAW_EXEC_ASK", "on-miss") + monkeypatch.setenv("OPENCLAW_EXEC_ASK_FALLBACK", "allowlist") + monkeypatch.setenv("OPENCLAW_EXEC_AUTO_ALLOW_SKILLS", "true") + monkeypatch.setenv("OPENCLAW_ELEVATED_ENABLED", "true") + monkeypatch.setenv("OPENCLAW_EXEC_DEFAULT_ALLOWLIST_ENABLED", "false") + monkeypatch.setenv("OPENCLAW_EXEC_ALLOWLIST", "/opt/tools/read-only") + monkeypatch.setenv("OPENCLAW_FS_WORKSPACE_ONLY", "false") + monkeypatch.setenv("OPENCLAW_MODEL_API_KEY_SECRET_SOURCE", "env") + monkeypatch.setenv("OPENCLAW_MODEL_API_KEY_SECRET_FILE_PATH", "/tmp/runtime-secrets.json") + + env = cmd_openclaw._build_openclaw_env_vars() + + assert env["OPENCLAW_EXEC_HOST"] == "node" + assert env["OPENCLAW_EXEC_SECURITY"] == "deny" + assert env["OPENCLAW_EXEC_ASK"] == "on-miss" + assert env["OPENCLAW_EXEC_ASK_FALLBACK"] == "allowlist" + assert env["OPENCLAW_EXEC_AUTO_ALLOW_SKILLS"] == "true" + assert env["OPENCLAW_ELEVATED_ENABLED"] == "true" + assert env["OPENCLAW_EXEC_DEFAULT_ALLOWLIST_ENABLED"] == "false" + assert env["OPENCLAW_EXEC_ALLOWLIST"] == "/opt/tools/read-only" + assert env["OPENCLAW_FS_WORKSPACE_ONLY"] == "false" + assert env["OPENCLAW_MODEL_API_KEY_SECRET_SOURCE"] == "env" + assert env["OPENCLAW_MODEL_API_KEY_SECRET_FILE_PATH"] == "/tmp/runtime-secrets.json" + + +def test_build_openclaw_env_vars_enables_strict_mode_when_requested(monkeypatch): + monkeypatch.setattr(cmd_openclaw, "_GLOBAL_ENV_CACHE", {}) + monkeypatch.setenv("OPENCLAW_EXEC_STRICT_MODE", "true") + monkeypatch.delenv("OPENCLAW_EXEC_SECURITY", raising=False) + monkeypatch.delenv("OPENCLAW_EXEC_ASK_FALLBACK", raising=False) + monkeypatch.delenv("OPENCLAW_EXEC_DEFAULT_ALLOWLIST_ENABLED", raising=False) + + env = cmd_openclaw._build_openclaw_env_vars() + + assert env["OPENCLAW_EXEC_STRICT_MODE"] == "true" + assert env["OPENCLAW_EXEC_UNSAFE_MODE"] == "false" + assert env["OPENCLAW_EXEC_SECURITY"] == "allowlist" + assert env["OPENCLAW_EXEC_ASK"] == "off" + assert env["OPENCLAW_EXEC_ASK_FALLBACK"] == "allowlist" + assert env["OPENCLAW_EXEC_DEFAULT_ALLOWLIST_ENABLED"] == "true" + + +def test_build_openclaw_env_vars_applies_strict_security_profile(monkeypatch): + monkeypatch.setattr(cmd_openclaw, "_GLOBAL_ENV_CACHE", {}) + monkeypatch.setenv("OPENCLAW_EXEC_STRICT_MODE", "false") + monkeypatch.setenv("OPENCLAW_EXEC_SECURITY", "full") + monkeypatch.setenv("OPENCLAW_EXEC_ASK_FALLBACK", "full") + monkeypatch.setenv("OPENCLAW_EXEC_DEFAULT_ALLOWLIST_ENABLED", "false") + monkeypatch.setenv("OPENCLAW_FS_WORKSPACE_ONLY", "true") + + env = cmd_openclaw._build_openclaw_env_vars(security_profile="strict") + + assert env["OPENCLAW_EXEC_STRICT_MODE"] == "true" + assert env["OPENCLAW_EXEC_UNSAFE_MODE"] == "false" + assert env["OPENCLAW_EXEC_SECURITY"] == "allowlist" + assert env["OPENCLAW_EXEC_ASK"] == "off" + assert env["OPENCLAW_EXEC_ASK_FALLBACK"] == "allowlist" + assert env["OPENCLAW_EXEC_DEFAULT_ALLOWLIST_ENABLED"] == "true" + assert env["OPENCLAW_FS_WORKSPACE_ONLY"] == "false" + + +def test_build_openclaw_env_vars_applies_strictest_security_profile(monkeypatch): + monkeypatch.setattr(cmd_openclaw, "_GLOBAL_ENV_CACHE", {}) + monkeypatch.setenv("OPENCLAW_EXEC_SECURITY", "allowlist") + monkeypatch.setenv("OPENCLAW_EXEC_ASK_FALLBACK", "allowlist") + monkeypatch.setenv("OPENCLAW_EXEC_DEFAULT_ALLOWLIST_ENABLED", "true") + monkeypatch.setenv("OPENCLAW_FS_WORKSPACE_ONLY", "false") + + env = cmd_openclaw._build_openclaw_env_vars(security_profile="strictest") + + assert env["OPENCLAW_EXEC_STRICT_MODE"] == "true" + assert env["OPENCLAW_EXEC_UNSAFE_MODE"] == "false" + assert env["OPENCLAW_EXEC_SECURITY"] == "deny" + assert env["OPENCLAW_EXEC_ASK"] == "off" + assert env["OPENCLAW_EXEC_ASK_FALLBACK"] == "deny" + assert env["OPENCLAW_EXEC_DEFAULT_ALLOWLIST_ENABLED"] == "false" + assert env["OPENCLAW_FS_WORKSPACE_ONLY"] == "true" + + +def test_build_openclaw_env_vars_defaults_exec_to_relaxed_without_explicit_profile(monkeypatch): + monkeypatch.setattr(cmd_openclaw, "_GLOBAL_ENV_CACHE", {}) + monkeypatch.delenv("OPENCLAW_EXEC_STRICT_MODE", raising=False) + monkeypatch.delenv("OPENCLAW_EXEC_SECURITY", raising=False) + monkeypatch.delenv("OPENCLAW_EXEC_ASK_FALLBACK", raising=False) + monkeypatch.delenv("OPENCLAW_EXEC_DEFAULT_ALLOWLIST_ENABLED", raising=False) + + env = cmd_openclaw._build_openclaw_env_vars() + + assert env["OPENCLAW_EXEC_STRICT_MODE"] == "false" + assert env["OPENCLAW_EXEC_UNSAFE_MODE"] == "true" + assert env["OPENCLAW_EXEC_SECURITY"] == "full" + assert env["OPENCLAW_EXEC_ASK_FALLBACK"] == "full" + assert env["OPENCLAW_EXEC_DEFAULT_ALLOWLIST_ENABLED"] == "false" + + +def test_build_openclaw_env_vars_injects_default_model_policy(monkeypatch): + monkeypatch.setattr(cmd_openclaw, "_GLOBAL_ENV_CACHE", {}) + monkeypatch.delenv("OPENCLAW_DEFAULT_MODEL", raising=False) + monkeypatch.delenv("OPENAI_MODEL_NAME", raising=False) + monkeypatch.delenv("MODEL_NAME", raising=False) + monkeypatch.delenv("LLM_MODEL", raising=False) + monkeypatch.delenv("OPENCLAW_MODEL_BASE_URL", raising=False) + monkeypatch.delenv("OPENAI_BASE_URL", raising=False) + monkeypatch.delenv("OPENAI_API_BASE", raising=False) + monkeypatch.delenv("OPENCLAW_MODEL_CATALOG_JSON", raising=False) + monkeypatch.delenv("OPENCLAW_MODEL_PROVIDER_ID", raising=False) + monkeypatch.delenv("OPENCLAW_MODEL_API", raising=False) + + env = cmd_openclaw._build_openclaw_env_vars() + + assert "OPENCLAW_DEFAULT_MODEL" not in env + assert env["OPENAI_MODEL_NAME"] == "ksyun/glm-5.2" + assert env["OPENCLAW_FALLBACK_MODEL"] == "ksyun/deepseek-v4-pro" + assert env["OPENCLAW_IMAGE_MODEL"] == "ksyun/kimi-k2.7-code" + assert "AGENTENGINE_MODEL_POLICY_JSON" in env + catalog = json.loads(env["OPENCLAW_MODEL_CATALOG_JSON"]) + assert [item["id"] for item in catalog] == ["glm-5.2", "kimi-k2.7-code", "deepseek-v4-pro"] + assert catalog[1]["options"] == {"temperature": 1} + assert "OPENCLAW_MODEL_BASE_URL" not in env + assert "OPENCLAW_MODEL_PROVIDER_ID" not in env + assert "OPENCLAW_MODEL_API" not in env + + +def test_build_openclaw_env_vars_global_model_preference_keeps_dual_catalog(monkeypatch): + monkeypatch.setattr(cmd_openclaw, "_GLOBAL_ENV_CACHE", {}) + monkeypatch.delenv("OPENCLAW_DEFAULT_MODEL", raising=False) + monkeypatch.delenv("OPENCLAW_MODEL_CATALOG_JSON", raising=False) + monkeypatch.setenv("OPENAI_MODEL_NAME", "glm-5.1") + + env = cmd_openclaw._build_openclaw_env_vars() + + assert env["OPENAI_MODEL_NAME"] == "ksyun/glm-5.1" + assert "OPENCLAW_DEFAULT_MODEL" not in env + catalog = json.loads(env["OPENCLAW_MODEL_CATALOG_JSON"]) + assert [item["id"] for item in catalog] == ["glm-5.2", "kimi-k2.7-code", "deepseek-v4-pro"] + + +def test_build_openclaw_env_vars_explicit_glm5_is_forwarded_without_catalog(monkeypatch): + monkeypatch.setattr(cmd_openclaw, "_GLOBAL_ENV_CACHE", {}) + monkeypatch.delenv("OPENCLAW_MODEL_CATALOG_JSON", raising=False) + monkeypatch.setenv("OPENCLAW_DEFAULT_MODEL", "ksyun/glm-5.1") + + env = cmd_openclaw._build_openclaw_env_vars() + + assert env["OPENCLAW_DEFAULT_MODEL"] == "ksyun/glm-5.1" + catalog = json.loads(env["OPENCLAW_MODEL_CATALOG_JSON"]) + assert [item["id"] for item in catalog] == ["glm-5.2", "kimi-k2.7-code", "deepseek-v4-pro"] + + +def test_build_openclaw_env_vars_preserves_explicit_model_catalog(monkeypatch): + monkeypatch.setattr(cmd_openclaw, "_GLOBAL_ENV_CACHE", {}) + monkeypatch.setenv("OPENCLAW_MODEL_CATALOG_JSON", '[{"id":"glm-5.1"}]') + + env = cmd_openclaw._build_openclaw_env_vars() + + assert env["OPENCLAW_MODEL_CATALOG_JSON"] == '[{"id":"glm-5.1"}]' + + +def test_openclaw_provider_model_metadata_builds_catalog_for_creation(monkeypatch): + monkeypatch.setattr(cmd_openclaw, "_GLOBAL_ENV_CACHE", {}) + monkeypatch.delenv("OPENCLAW_MODEL_CATALOG_JSON", raising=False) + monkeypatch.setenv("OPENAI_MODEL_NAME", "deepseek-v4-pro") + + env = cmd_openclaw._build_openclaw_env_vars() + changed = cmd_openclaw._apply_openclaw_provider_model_metadata( + env, + { + "id": "deepseek-v4-pro", + "context_window_tokens": 1_000_000, + "max_output_tokens": 384_000, + }, + ) + + assert changed is True + catalog = json.loads(env["OPENCLAW_MODEL_CATALOG_JSON"]) + assert [item["id"] for item in catalog] == [ + "glm-5.2", + "kimi-k2.7-code", + "deepseek-v4-pro", + ] + assert catalog[1]["options"] == {"temperature": 1} + assert catalog[-1] == { + "id": "deepseek-v4-pro", + "name": "deepseek-v4-pro", + "api": "openai-completions", + "reasoning": True, + "input": ["text", "image"], + "cost": {"input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0}, + "contextWindow": 1_000_000, + "maxTokens": 384_000, + } + + +def test_openclaw_provider_model_metadata_preserves_explicit_catalog_items(monkeypatch): + monkeypatch.setattr(cmd_openclaw, "_GLOBAL_ENV_CACHE", {}) + monkeypatch.setenv( + "OPENCLAW_MODEL_CATALOG_JSON", + json.dumps( + [ + {"id": "custom-model", "name": "custom-model"}, + {"id": "deepseek-v4-pro", "name": "old"}, + ] + ), + ) + + env = cmd_openclaw._build_openclaw_env_vars() + changed = cmd_openclaw._apply_openclaw_provider_model_metadata( + env, + { + "id": "deepseek-v4-pro", + "context_window_tokens": 1_000_000, + } + ) + + assert changed is True + catalog = json.loads(env["OPENCLAW_MODEL_CATALOG_JSON"]) + assert catalog[0]["id"] == "custom-model" + assert catalog[1]["id"] == "deepseek-v4-pro" + assert catalog[1]["contextWindow"] == 1_000_000 + + +def test_build_openclaw_env_vars_forwards_explicit_web_tool_overrides(monkeypatch): + monkeypatch.setattr(cmd_openclaw, "_GLOBAL_ENV_CACHE", {}) + monkeypatch.setenv("OPENCLAW_WEB_FETCH_ENABLED", "true") + monkeypatch.setenv("OPENCLAW_WEB_SEARCH_PROVIDER", "perplexity") + monkeypatch.setenv("OPENCLAW_WEB_SEARCH_BASE_URL", "https://search.example.com/v1") + monkeypatch.setenv("OPENCLAW_WEB_SEARCH_MODEL", "sonar-pro") + monkeypatch.setenv("OPENCLAW_WEB_SEARCH_API_KEY_SECRET_SOURCE", "env") + monkeypatch.setenv("OPENCLAW_WEB_SEARCH_API_KEY_SECRET_PROVIDER", "default") + monkeypatch.setenv("OPENCLAW_WEB_SEARCH_API_KEY_SECRET_ID", "OPENCLAW_WEB_SEARCH_API_KEY") + + env = cmd_openclaw._build_openclaw_env_vars() + + assert env["OPENCLAW_WEB_FETCH_ENABLED"] == "true" + assert env["OPENCLAW_WEB_SEARCH_PROVIDER"] == "perplexity" + assert env["OPENCLAW_WEB_SEARCH_BASE_URL"] == "https://search.example.com/v1" + assert env["OPENCLAW_WEB_SEARCH_MODEL"] == "sonar-pro" + assert env["OPENCLAW_WEB_SEARCH_API_KEY_SECRET_SOURCE"] == "env" + assert env["OPENCLAW_WEB_SEARCH_API_KEY_SECRET_PROVIDER"] == "default" + assert env["OPENCLAW_WEB_SEARCH_API_KEY_SECRET_ID"] == "OPENCLAW_WEB_SEARCH_API_KEY" + + +def test_build_openclaw_env_vars_forwards_explicit_builtin_browser_toggle(monkeypatch): + monkeypatch.setattr(cmd_openclaw, "_GLOBAL_ENV_CACHE", {}) + monkeypatch.setenv("OPENCLAW_BROWSER_ENABLED", "true") + + env = cmd_openclaw._build_openclaw_env_vars() + + assert env["OPENCLAW_BROWSER_ENABLED"] == "true" + assert env["OPENCLAW_BROWSER_NO_SANDBOX"] == "true" + assert env["OPENCLAW_BROWSER_HEADLESS"] == "true" + + +def test_build_openclaw_env_vars_forwards_explicit_browser_ssrf_policy_json(monkeypatch): + monkeypatch.setattr(cmd_openclaw, "_GLOBAL_ENV_CACHE", {}) + monkeypatch.setenv( + "OPENCLAW_BROWSER_SSRF_POLICY_JSON", + '{"dangerouslyAllowPrivateNetwork":false,"hostnameAllowlist":["docs.example.com"]}', + ) + + env = cmd_openclaw._build_openclaw_env_vars() + + assert env["OPENCLAW_BROWSER_SSRF_POLICY_JSON"] == ( + '{"dangerouslyAllowPrivateNetwork":false,"hostnameAllowlist":["docs.example.com"]}' + ) + + +def test_build_openclaw_env_vars_forwards_channel_bootstrap_json(monkeypatch): + monkeypatch.setattr(cmd_openclaw, "_GLOBAL_ENV_CACHE", {}) + monkeypatch.setenv( + "OPENCLAW_CHANNEL_BOOTSTRAP_JSON", + '{"wps-xiezuo":{"appId":"app-demo","appSecret":"secret-demo"},"feishu":{"appId":"app-demo"}}', + ) + + env = cmd_openclaw._build_openclaw_env_vars() + + assert env["OPENCLAW_CHANNEL_BOOTSTRAP_JSON"] == ( + '{"wps-xiezuo":{"appId":"app-demo","appSecret":"secret-demo"},"feishu":{"appId":"app-demo"}}' + ) + + +def test_parse_extra_openclaw_env_pairs_supports_custom_keys_and_explicit_override(): + parsed = cmd_openclaw._parse_extra_openclaw_env_pairs( + ( + "FOO=bar", + "OPENCLAW_GATEWAY_PORT=9090", + "FOO=baz", + "EMPTY_VALUE=", + ) + ) + + assert parsed == { + "FOO": "baz", + "OPENCLAW_GATEWAY_PORT": "9090", + "EMPTY_VALUE": "", + } + + +def test_parse_extra_openclaw_env_pairs_rejects_invalid_items(): + with pytest.raises(ValueError, match="KEY=VALUE"): + cmd_openclaw._parse_extra_openclaw_env_pairs(("MISSING_EQUALS",)) + + with pytest.raises(ValueError, match="合法的环境变量名"): + cmd_openclaw._parse_extra_openclaw_env_pairs(("1BAD=value",)) + + with pytest.raises(ValueError, match="trusted-proxy、token 或 none"): + cmd_openclaw._parse_extra_openclaw_env_pairs(("OPENCLAW_GATEWAY_AUTH_MODE=password",)) + + +def test_parse_extra_openclaw_env_pairs_accepts_token_auth_mode(): + parsed = cmd_openclaw._parse_extra_openclaw_env_pairs(("OPENCLAW_GATEWAY_AUTH_MODE=token",)) + + assert parsed == {"OPENCLAW_GATEWAY_AUTH_MODE": "token"} + + +def test_generate_default_openclaw_name_is_high_entropy(): + name1 = cmd_openclaw._generate_default_openclaw_name() + name2 = cmd_openclaw._generate_default_openclaw_name() + + assert name1 != name2 + assert len(name1) <= 64 + assert name1.startswith("openclaw-gateway-") + assert re.fullmatch(r"openclaw-gateway-\d{10}-[0-9a-f]{6}", name1) is not None diff --git a/tests/test_patch_langchain.py b/tests/test_patch_langchain.py new file mode 100644 index 00000000..6a8b9fbe --- /dev/null +++ b/tests/test_patch_langchain.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +from langchain_core.messages import HumanMessage +from langchain_openai import ChatOpenAI + +from ksadk.runtime_context import PlatformInvocationContext, platform_invocation_scope +from ksadk.runners.patch_langchain import apply_patch + + +def _context() -> PlatformInvocationContext: + return PlatformInvocationContext( + agent_id="demo-agent", + user_id="user", + session_id="sess-1", + history=[], + input_content=[], + input_messages=[], + input_parts=[], + attachments=[], + attachment_results=[], + current_attachments=[], + current_attachment_results=[], + has_current_files=False, + runner_type="langgraph", + model="gpt-4o", + model_options={"thinking": {"type": "disabled"}}, + ) + + +def test_chat_openai_patch_maps_request_model_options_for_chat_completions(): + apply_patch() + llm = ChatOpenAI(model="gpt-4o", api_key="sk-test", use_responses_api=False) + + with platform_invocation_scope(_context()): + payload = llm._get_request_payload([HumanMessage(content="hello")]) + + assert "reasoning_effort" not in payload + assert payload["extra_body"]["max_reasoning_tokens"] == 0 + assert "thinking" not in payload["extra_body"] + + +def test_chat_openai_patch_keeps_supported_reasoning_effort_for_chat_completions(): + apply_patch() + llm = ChatOpenAI(model="gpt-4o", api_key="sk-test", use_responses_api=False) + context = _context() + context.model_options = {"reasoning": {"effort": "low"}} + + with platform_invocation_scope(context): + payload = llm._get_request_payload([HumanMessage(content="hello")]) + + assert payload["reasoning_effort"] == "low" + + +def test_chat_openai_patch_maps_enabled_thinking_to_reasoning_effort_for_chat_completions(): + apply_patch() + llm = ChatOpenAI(model="gpt-4o", api_key="sk-test", use_responses_api=False) + context = _context() + context.model_options = {"thinking": {"type": "enabled"}} + + with platform_invocation_scope(context): + payload = llm._get_request_payload([HumanMessage(content="hello")]) + + assert payload["reasoning_effort"] == "medium" + assert "extra_body" not in payload or "thinking" not in payload.get("extra_body", {}) + + +def test_chat_openai_patch_maps_request_model_options_for_responses_api(): + apply_patch() + llm = ChatOpenAI(model="gpt-4o", api_key="sk-test", use_responses_api=True) + + with platform_invocation_scope(_context()): + payload = llm._get_request_payload([HumanMessage(content="hello")]) + + assert payload["reasoning"] == {"effort": "none"} + assert payload["extra_body"]["thinking"] == {"type": "disabled"} + assert payload["extra_body"]["max_reasoning_tokens"] == 0 + + +def test_chat_openai_patch_preserves_temperature_override(): + apply_patch() + llm = ChatOpenAI(model="kimi-k2.7-code", api_key="sk-test", use_responses_api=False) + context = _context() + context.model = "kimi-k2.7-code" + context.model_options = {"temperature": 1} + + with platform_invocation_scope(context): + payload = llm._get_request_payload([HumanMessage(content="hello")]) + + assert payload["temperature"] == 1 diff --git a/tests/test_public_release_positioning.py b/tests/test_public_release_positioning.py index 79d10162..9241c3c0 100644 --- a/tests/test_public_release_positioning.py +++ b/tests/test_public_release_positioning.py @@ -17,12 +17,15 @@ def test_public_readme_positions_ksadk_as_runtime_platform(): for expected in ( "Build agents once. Run them anywhere.", "Agent Runtime Platform", - "为什么需要 KsADK", + "Why KsADK", "30 秒快速体验", - "架构", - "生态定位对比", - "文档与样例", - "相关项目", + "Architecture", + "Comparison", + "Examples", + "Deployment", + "Observability", + "Documentation", + "Community", "KSYUN_REGION=cn-beijing-6", ): assert expected in readme @@ -30,9 +33,9 @@ def test_public_readme_positions_ksadk_as_runtime_platform(): assert "Agent Development Kit" not in readme assert "KSADK_SKILL_SERVICE_REGION=pre-online" not in readme assert "```mermaid" not in readme - assert "```bash" in readme + assert "```text" in readme assert "当前版本:" not in readme - assert "候选版本:" not in readme + assert "发布版本:`0.6.6`" in readme def test_public_metadata_uses_runtime_platform_positioning(): @@ -40,21 +43,21 @@ def test_public_metadata_uses_runtime_platform_positioning(): init_text = _read("ksadk/__init__.py") version_text = _read("ksadk/version.py") - assert pyproject["project"]["version"] == "0.6.5" - assert 'VERSION = "0.6.5"' in version_text + assert pyproject["project"]["version"] == "0.6.6" + assert 'VERSION = "0.6.6"' in version_text assert "Agent Runtime Platform" in pyproject["project"]["description"] assert "Agent Runtime Platform" in init_text assert "Agent Development Kit" not in pyproject["project"]["description"] assert "Agent Development Kit" not in init_text -def test_changelog_marks_0_6_5_ready_for_authorized_release(): +def test_changelog_marks_0_6_6_ready_for_authorized_release(): changelog = _read("CHANGELOG.md") - assert "## [0.6.5] - 2026-06-15" in changelog - assert "GitHub Release" in changelog + assert "## [0.6.6] - 2026-06-18" in changelog + assert "统一模型策略 v1" in changelog assert "PyPI Trusted Publishing" in changelog - assert "@kingsoftcloud/ksadk-web@latest" in changelog + assert "KSADK_PACKAGE_SPEC=ksadk==0.6.6" in changelog def test_pypi_publish_workflow_uses_trusted_publishing_and_bundles_ksadk_web(): diff --git a/tests/test_server_session_app.py b/tests/test_server_session_app.py index 7fe09db5..c05faa4a 100644 --- a/tests/test_server_session_app.py +++ b/tests/test_server_session_app.py @@ -8,7 +8,9 @@ import httpx import pytest +from fastapi.responses import Response +import ksadk.conversations as conversation from ksadk.runners.base_runner import BaseRunner from ksadk.server.api_models import AgentRunRequest, InlineData, Part from ksadk.sessions.base import SessionEvent @@ -34,9 +36,46 @@ async def invoke(self, input_data: dict) -> dict: return {"output": "assistant says hi"} async def stream(self, input_data: dict): + self.calls.append(input_data) yield {"type": "final", "output": "assistant says hi"} +class _CheckpointResumeRunner(_DummyRunner): + async def invoke(self, input_data: dict) -> dict: + self.calls.append(input_data) + return { + "output": "resumed from checkpoint", + "metadata": { + "agentengine": { + "run_id": str(input_data.get("run_id") or ""), + "framework": "langgraph", + "framework_ref": input_data.get("framework_ref") or {}, + } + }, + } + + +class _CheckpointMetadataRunner(_DummyRunner): + async def invoke(self, input_data: dict) -> dict: + self.calls.append(input_data) + session_id = str(input_data.get("session_id") or "") + return { + "output": "checkpoint ready", + "metadata": { + "agentengine": { + "run_id": "run-hosted", + "framework": "langgraph", + "framework_ref": { + "langgraph": { + "thread_id": session_id, + "checkpoint_id": "ckpt-hosted", + } + }, + } + }, + } + + class _OverrideStreamingRunner(BaseRunner): def __init__(self): super().__init__( @@ -73,6 +112,20 @@ async def stream(self, input_data: dict): yield {"type": "final", "output": "hello"} +class _CancellableStreamingRunner(_OverrideStreamingRunner): + def __init__(self): + super().__init__() + self.cancel_requests: list[str] = [] + + async def stream(self, input_data: dict): + yield {"type": "text", "delta": "hel"} + await asyncio.Event().wait() + + def request_cancel(self, invocation_id: str) -> str: + self.cancel_requests.append(invocation_id) + return "accepted" + + class _ModelAwareRunner(_DummyRunner): def __init__(self): super().__init__() @@ -128,6 +181,32 @@ def _sse_events(response_text: str) -> list[tuple[str, dict]]: return events +@pytest.mark.asyncio +async def test_ui_bootstrap_advertises_checkpoint_resume_capabilities(monkeypatch): + server_app_module = importlib.import_module("ksadk.server.app") + service = InMemorySessionService() + runner = _DummyRunner() + + monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service) + server_app_module.set_runner(runner) + + transport = httpx.ASGITransport(app=server_app_module.app) + async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client: + response = await client.post( + "/agentengine/api/v1/GetAgentUiBootstrap", + json={"AgentId": "demo-agent"}, + ) + + assert response.status_code == 200 + run_lifecycle = response.json()["Data"]["Capabilities"]["RunLifecycle"] + assert run_lifecycle["Enabled"] is True + assert run_lifecycle["Resume"] is True + assert run_lifecycle["Abort"] is True + assert run_lifecycle["Checkpoints"] is True + assert run_lifecycle["CheckpointResume"] is True + assert run_lifecycle["CheckpointResumePreview"] is True + + @pytest.mark.asyncio async def test_run_sse_uses_new_session_service(monkeypatch): server_app_module = importlib.import_module("ksadk.server.app") @@ -627,6 +706,39 @@ async def test_list_sessions_projects_heuristic_title_for_existing_fallback_sess assert session["TitleSource"] == "heuristic" +@pytest.mark.asyncio +async def test_runtime_local_list_sessions_returns_page_metadata(monkeypatch): + server_app_module = importlib.import_module("ksadk.server.app") + service = InMemorySessionService() + for index in range(5): + await service.create_session( + agent_id="demo-agent", + user_id="user-1", + session_id=f"sess-page-{index}", + ) + + monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service) + + transport = httpx.ASGITransport(app=server_app_module.app) + async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client: + response = await client.post( + "/agentengine/api/v1/ListSessions", + json={ + "AgentId": "demo-agent", + "UserId": "user-1", + "Page": 2, + "PageSize": 2, + }, + ) + + assert response.status_code == 200 + data = response.json()["Data"] + assert data["Page"] == 2 + assert data["PageSize"] == 2 + assert data["Total"] == 5 + assert len(data["Sessions"]) == 2 + + @pytest.mark.asyncio async def test_session_actions_do_not_return_inline_attachment_data_in_state(monkeypatch): server_app_module = importlib.import_module("ksadk.server.app") @@ -1497,6 +1609,216 @@ async def test_responses_uses_official_conversation_as_runtime_session(monkeypat assert runner.calls[-1]["platform_context"]["account_id"] == "acct-a" +@pytest.mark.asyncio +async def test_responses_uses_runtime_agent_id_for_hosted_session_lifecycle(monkeypatch): + server_app_module = importlib.import_module("ksadk.server.app") + service = InMemorySessionService() + runner = _CheckpointMetadataRunner() + + monkeypatch.setenv("AGENT_RUNTIME_ID", "ar-hosted-runtime") + monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service) + server_app_module.set_runner(runner) + + transport = httpx.ASGITransport(app=server_app_module.app) + async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client: + response = await client.post( + "/v1/responses", + json={ + "input": "hello", + "conversation": "conv-hosted", + "stream": False, + }, + ) + checkpoints_response = await client.post( + "/agentengine/api/v1/ListSessionCheckpoints", + json={"AgentId": "ar-hosted-runtime", "SessionId": "conv-hosted"}, + ) + + assert response.status_code == 200 + session = await service.get_session("conv-hosted") + assert session is not None + assert session.agent_id == "ar-hosted-runtime" + assert checkpoints_response.status_code == 200 + checkpoints = checkpoints_response.json()["Data"]["Checkpoints"] + assert checkpoints[0]["RunId"] == "run-hosted" + assert checkpoints[0]["CheckpointId"] == "ckpt-hosted" + + +@pytest.mark.asyncio +async def test_responses_accepts_agentengine_checkpoint_resume_input(monkeypatch): + server_app_module = importlib.import_module("ksadk.server.app") + conversation_runtime = importlib.import_module("ksadk.conversations.runtime") + service = InMemorySessionService() + runner = _CheckpointResumeRunner() + + await service.create_session(agent_id="demo-agent", user_id="user-a", session_id="conv-resume") + monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service) + server_app_module.set_runner(runner) + await conversation_runtime.append_run_checkpoint_event( + session_id="conv-resume", + author="demo-agent", + run_id="run-1", + checkpoint_id="ckpt-1", + framework="langgraph", + framework_ref={ + "langgraph": { + "thread_id": "tenant:agent:conv-resume", + "checkpoint_id": "ckpt-1", + } + }, + invocation_id="inv-checkpoint", + session_service_provider=lambda: service, + ) + + transport = httpx.ASGITransport(app=server_app_module.app) + async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client: + response = await client.post( + "/v1/responses", + json={ + "input": [ + { + "type": "agentengine.resume_checkpoint", + "run_id": "run-1", + "checkpoint_id": "ckpt-1", + "resume_attempt_id": "resume-1", + "framework": "langgraph", + "framework_ref": { + "langgraph": { + "thread_id": "forged-client-thread", + "checkpoint_id": "forged-client-checkpoint", + } + }, + } + ], + "conversation": "conv-resume", + "safety_identifier": "user-a", + "stream": False, + }, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["metadata"]["agentengine"]["run_id"] == "run-1" + assert payload["metadata"]["agentengine"]["framework_ref"]["langgraph"]["checkpoint_id"] == "ckpt-1" + assert runner.calls[-1]["checkpoint_resume"] is True + assert runner.calls[-1]["run_id"] == "run-1" + assert runner.calls[-1]["framework_ref"]["langgraph"]["thread_id"] == "tenant:agent:conv-resume" + assert runner.calls[-1]["framework_ref"]["langgraph"]["checkpoint_id"] == "ckpt-1" + events = await service.get_events("conv-resume") + assert [event.event_type for event in events] == [ + "run_checkpoint", + "run_resume", + "run_status", + "run_checkpoint", + "assistant_message", + "run_status", + ] + + +@pytest.mark.asyncio +async def test_responses_rejects_agentengine_checkpoint_resume_without_server_checkpoint(monkeypatch): + server_app_module = importlib.import_module("ksadk.server.app") + service = InMemorySessionService() + runner = _CheckpointResumeRunner() + + await service.create_session(agent_id="demo-agent", user_id="user-a", session_id="conv-resume-missing") + monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service) + server_app_module.set_runner(runner) + + transport = httpx.ASGITransport(app=server_app_module.app) + async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client: + response = await client.post( + "/v1/responses", + json={ + "input": [ + { + "type": "agentengine.resume_checkpoint", + "run_id": "run-1", + "checkpoint_id": "ckpt-1", + "framework": "langgraph", + "framework_ref": { + "langgraph": { + "thread_id": "client-only-thread", + "checkpoint_id": "ckpt-1", + } + }, + } + ], + "conversation": "conv-resume-missing", + "safety_identifier": "user-a", + "stream": False, + }, + ) + + assert response.status_code == 404 + assert runner.calls == [] + assert await service.get_events("conv-resume-missing") == [] + + +@pytest.mark.asyncio +async def test_run_agent_responses_checkpoint_resume_resolves_framework_ref_from_server(monkeypatch): + server_app_module = importlib.import_module("ksadk.server.app") + conversation_runtime = importlib.import_module("ksadk.conversations.runtime") + service = InMemorySessionService() + runner = _CheckpointResumeRunner() + + await service.create_session(agent_id="demo-agent", user_id="user-1", session_id="sess-runagent-resume") + monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service) + server_app_module.set_runner(runner) + await conversation_runtime.append_run_checkpoint_event( + session_id="sess-runagent-resume", + author="demo-agent", + run_id="run-1", + checkpoint_id="ckpt-1", + framework="langgraph", + framework_ref={ + "langgraph": { + "thread_id": "tenant:agent:sess-runagent-resume", + "checkpoint_id": "ckpt-1", + } + }, + invocation_id="inv-checkpoint", + session_service_provider=lambda: service, + ) + + transport = httpx.ASGITransport(app=server_app_module.app) + async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client: + response = await client.post( + "/agentengine/api/v1/RunAgent", + json={ + "AgentId": "demo-agent", + "SessionId": "sess-runagent-resume", + "UserId": "user-1", + "ApiFormat": "responses", + "Stream": False, + "ResponsesInput": [ + { + "type": "agentengine.resume_checkpoint", + "run_id": "run-1", + "checkpoint_id": "ckpt-1", + "resume_attempt_id": "resume-1", + "framework": "langgraph", + "framework_ref": { + "langgraph": { + "thread_id": "forged-client-thread", + "checkpoint_id": "forged-client-checkpoint", + } + }, + } + ], + }, + ) + + assert response.status_code == 200 + payload = response.json()["Data"] + assert payload["metadata"]["agentengine"]["run_id"] == "run-1" + assert ( + payload["metadata"]["agentengine"]["framework_ref"]["langgraph"]["thread_id"] + == "tenant:agent:sess-runagent-resume" + ) + assert runner.calls[-1]["framework_ref"]["langgraph"]["thread_id"] == "tenant:agent:sess-runagent-resume" + + @pytest.mark.asyncio async def test_responses_accepts_official_conversation_object(monkeypatch): server_app_module = importlib.import_module("ksadk.server.app") @@ -1525,6 +1847,92 @@ async def test_responses_accepts_official_conversation_object(monkeypatch): assert session.user_id == "user-object" +@pytest.mark.asyncio +async def test_responses_uses_agentengine_metadata_invocation_id(monkeypatch): + server_app_module = importlib.import_module("ksadk.server.app") + service = InMemorySessionService() + runner = _DummyRunner() + + monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service) + server_app_module.set_runner(runner) + + transport = httpx.ASGITransport(app=server_app_module.app) + async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client: + response = await client.post( + "/v1/responses", + json={ + "input": "hello", + "conversation": "conv-invocation", + "metadata": {"agentengine": {"invocation_id": "run-known-invocation"}}, + "stream": False, + }, + ) + + assert response.status_code == 200 + events = await service.get_events("conv-invocation") + assert events[0].invocation_id == "run-known-invocation" + + +@pytest.mark.asyncio +async def test_stream_responses_uses_agentengine_metadata_invocation_id(monkeypatch): + server_app_module = importlib.import_module("ksadk.server.app") + service = InMemorySessionService() + runner = _DummyRunner() + + monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service) + server_app_module.set_runner(runner) + + transport = httpx.ASGITransport(app=server_app_module.app) + async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client: + async with client.stream( + "POST", + "/v1/responses", + json={ + "input": "hello", + "conversation": "conv-stream-invocation", + "metadata": {"agentengine": {"invocation_id": "run-known-stream"}}, + "stream": True, + }, + ) as response: + chunks = [] + assert response.status_code == 200 + async for _line in response.aiter_lines(): + chunks.append(_line) + + events = await service.get_events("conv-stream-invocation") + assert events, chunks + assert events[0].invocation_id == "run-known-stream" + + +@pytest.mark.asyncio +async def test_stream_responses_registers_invocation_for_cancel_run(monkeypatch): + server_app_module = importlib.import_module("ksadk.server.app") + service = InMemorySessionService() + runner = _CancellableStreamingRunner() + invocation_id = "run-responses-cancel" + captured_invocations: list[str | None] = [] + + monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service) + server_app_module.set_runner(runner) + + def fake_detached_streaming_response(source, *, invocation_id=None): + captured_invocations.append(invocation_id) + return Response(status_code=202) + + monkeypatch.setattr(server_app_module, "_detached_streaming_response", fake_detached_streaming_response) + response = await server_app_module.responses( + server_app_module.ResponsesRequest( + input="hello", + conversation="conv-stream-cancel", + metadata={"agentengine": {"invocation_id": invocation_id}}, + stream=True, + ) + ) + + assert response.status_code == 202 + assert captured_invocations == [invocation_id] + + @pytest.mark.asyncio async def test_responses_uses_deprecated_user_when_safety_identifier_missing(monkeypatch): server_app_module = importlib.import_module("ksadk.server.app") @@ -1552,6 +1960,36 @@ async def test_responses_uses_deprecated_user_when_safety_identifier_missing(mon assert session.user_id == "deprecated-user" +@pytest.mark.asyncio +async def test_stream_responses_user_and_account_reach_platform_context(monkeypatch): + service = InMemorySessionService() + runner = _DummyRunner() + + chunks = [ + chunk + async for chunk in conversation.stream_responses_conversation_turn( + runner=runner, + agent_id="demo-agent", + user_id="ui-user-1", + messages=[{"role": "user", "content": "hello"}], + session_id="sess-hosted-stream", + model=None, + account_id="acct-1", + prepare_runner=lambda _runner, _model: None, + session_service_provider=lambda: service, + ) + ] + + session = await service.get_session("sess-hosted-stream") + assert session is not None + assert session.user_id == "ui-user-1" + assert runner.calls, chunks + platform_context = runner.calls[-1]["platform_context"] + assert platform_context["user_id"] == "ui-user-1" + assert platform_context["account_id"] == "acct-1" + assert platform_context["session_id"] == "sess-hosted-stream" + + @pytest.mark.asyncio async def test_responses_rejects_conflicting_conversation_and_legacy_session_id(monkeypatch): server_app_module = importlib.import_module("ksadk.server.app") @@ -1665,6 +2103,530 @@ async def test_responses_events_are_visible_through_runtime_local_list_session_e assert message_events[1]["Content"]["parts"][0]["text"] == "assistant says hi" +@pytest.mark.asyncio +async def test_runtime_local_list_session_events_returns_total_and_page(monkeypatch): + server_app_module = importlib.import_module("ksadk.server.app") + service = InMemorySessionService() + await service.create_session( + agent_id="demo-agent", + user_id="user-1", + session_id="sess-events-page", + ) + for index in range(4): + await service.append_event( + "sess-events-page", + SessionEvent( + author="user", + event_type="user_message", + content={"index": index}, + ), + ) + + monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service) + + transport = httpx.ASGITransport(app=server_app_module.app) + async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client: + response = await client.post( + "/agentengine/api/v1/ListSessionEvents", + json={ + "SessionId": "sess-events-page", + "Offset": 1, + "Limit": 2, + }, + ) + + assert response.status_code == 200 + data = response.json()["Data"] + assert data["Offset"] == 1 + assert data["Limit"] == 2 + assert data["Total"] == 4 + assert [event["SeqId"] for event in data["Events"]] == [2, 3] + + +@pytest.mark.asyncio +async def test_list_session_checkpoints_filters_by_agent_session_and_run(monkeypatch): + server_app_module = importlib.import_module("ksadk.server.app") + conversation_runtime = importlib.import_module("ksadk.conversations.runtime") + service = InMemorySessionService() + runner = _DummyRunner() + + await service.create_session(agent_id="demo-agent", user_id="user-1", session_id="sess-checkpoints") + await service.create_session(agent_id="other-agent", user_id="user-1", session_id="sess-other") + monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service) + server_app_module.set_runner(runner) + + await conversation_runtime.append_run_checkpoint_event( + session_id="sess-checkpoints", + author="demo-agent", + run_id="run-1", + checkpoint_id="ckpt-1", + framework="langgraph", + framework_ref={"langgraph": {"thread_id": "tenant:agent:sess-checkpoints", "checkpoint_id": "ckpt-1"}}, + phase="tool_result", + invocation_id="inv-1", + session_service_provider=lambda: service, + ) + await conversation_runtime.append_run_checkpoint_event( + session_id="sess-checkpoints", + author="demo-agent", + run_id="run-2", + checkpoint_id="ckpt-2", + framework="langgraph", + framework_ref={"langgraph": {"thread_id": "tenant:agent:sess-checkpoints", "checkpoint_id": "ckpt-2"}}, + phase="completed", + invocation_id="inv-2", + session_service_provider=lambda: service, + ) + await conversation_runtime.append_run_checkpoint_event( + session_id="sess-other", + author="other-agent", + run_id="run-1", + checkpoint_id="ckpt-other", + framework="langgraph", + framework_ref={"langgraph": {"thread_id": "tenant:other:sess-other", "checkpoint_id": "ckpt-other"}}, + invocation_id="inv-other", + session_service_provider=lambda: service, + ) + + transport = httpx.ASGITransport(app=server_app_module.app) + async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client: + response = await client.post( + "/agentengine/api/v1/ListSessionCheckpoints", + json={"AgentId": "demo-agent", "SessionId": "sess-checkpoints", "RunId": "run-1"}, + ) + wrong_agent = await client.post( + "/agentengine/api/v1/ListSessionCheckpoints", + json={"AgentId": "other-agent", "SessionId": "sess-checkpoints"}, + ) + + assert response.status_code == 200 + checkpoints = response.json()["Data"]["Checkpoints"] + assert [item["CheckpointId"] for item in checkpoints] == ["ckpt-1"] + assert checkpoints[0]["RunId"] == "run-1" + assert checkpoints[0]["Framework"] == "langgraph" + assert checkpoints[0]["FrameworkRef"]["langgraph"]["thread_id"] == "tenant:agent:sess-checkpoints" + assert wrong_agent.status_code == 404 + + +@pytest.mark.asyncio +async def test_list_session_checkpoints_returns_business_resume_fields(monkeypatch): + server_app_module = importlib.import_module("ksadk.server.app") + conversation_runtime = importlib.import_module("ksadk.conversations.runtime") + service = InMemorySessionService() + + await service.create_session(agent_id="demo-agent", user_id="user-1", session_id="sess-business-checkpoints") + monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service) + await conversation_runtime.append_run_checkpoint_event( + session_id="sess-business-checkpoints", + author="demo-agent", + run_id="run-business", + checkpoint_id="ckpt-metrics", + framework="langgraph", + framework_ref={"langgraph": {"thread_id": "tenant:agent:sess-business-checkpoints", "checkpoint_id": "ckpt-metrics"}}, + phase="指标聚合已完成,等待生成报告", + invocation_id="inv-business", + metadata={ + "stage": "清洗聚合指标", + "summary": "GMV、转化率和退款率已经聚合完成", + "next_action": "继续生成复盘报告", + "status": "completed", + }, + session_service_provider=lambda: service, + ) + + transport = httpx.ASGITransport(app=server_app_module.app) + async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client: + response = await client.post( + "/agentengine/api/v1/ListSessionCheckpoints", + json={"AgentId": "demo-agent", "SessionId": "sess-business-checkpoints"}, + ) + + assert response.status_code == 200 + checkpoint = response.json()["Data"]["Checkpoints"][0] + assert checkpoint["RunId"] == "run-business" + assert checkpoint["CheckpointId"] == "ckpt-metrics" + assert checkpoint["Phase"] == "指标聚合已完成,等待生成报告" + assert checkpoint["Stage"] == "清洗聚合指标" + assert checkpoint["Summary"] == "GMV、转化率和退款率已经聚合完成" + assert checkpoint["NextAction"] == "继续生成复盘报告" + assert checkpoint["Status"] == "completed" + + +@pytest.mark.asyncio +async def test_resume_run_action_reuses_checkpoint_and_records_resume(monkeypatch): + server_app_module = importlib.import_module("ksadk.server.app") + conversation_runtime = importlib.import_module("ksadk.conversations.runtime") + service = InMemorySessionService() + runner = _CheckpointResumeRunner() + + await service.create_session(agent_id="demo-agent", user_id="user-1", session_id="sess-resume-action") + monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service) + server_app_module.set_runner(runner) + await conversation_runtime.append_run_checkpoint_event( + session_id="sess-resume-action", + author="demo-agent", + run_id="run-1", + checkpoint_id="ckpt-1", + framework="langgraph", + framework_ref={"langgraph": {"thread_id": "tenant:agent:sess-resume-action", "checkpoint_id": "ckpt-1"}}, + invocation_id="inv-1", + session_service_provider=lambda: service, + ) + + transport = httpx.ASGITransport(app=server_app_module.app) + async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client: + response = await client.post( + "/agentengine/api/v1/ResumeRun", + json={ + "AgentId": "demo-agent", + "SessionId": "sess-resume-action", + "RunId": "run-1", + "CheckpointId": "ckpt-1", + "ResumeAttemptId": "resume-1", + "Stream": False, + }, + ) + + assert response.status_code == 200 + payload = response.json()["Data"] + assert payload["session_id"] == "sess-resume-action" + assert payload["metadata"]["agentengine"]["run_id"] == "run-1" + assert runner.calls[-1]["checkpoint_resume"] is True + assert runner.calls[-1]["framework_ref"]["langgraph"]["checkpoint_id"] == "ckpt-1" + events = await service.get_events("sess-resume-action") + resume_events = [event for event in events if event.event_type == "run_resume"] + assert len(resume_events) == 1 + assert resume_events[0].metadata["resume_attempt_id"] == "resume-1" + + +@pytest.mark.asyncio +async def test_resume_run_action_stream_uses_invocation_id_for_detached_cancel(monkeypatch): + server_app_module = importlib.import_module("ksadk.server.app") + conversation_runtime = importlib.import_module("ksadk.conversations.runtime") + service = InMemorySessionService() + runner = _CheckpointResumeRunner() + + await service.create_session(agent_id="demo-agent", user_id="user-1", session_id="sess-resume-stream") + monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service) + server_app_module.set_runner(runner) + await conversation_runtime.append_run_checkpoint_event( + session_id="sess-resume-stream", + author="demo-agent", + run_id="run-1", + checkpoint_id="ckpt-1", + framework="langgraph", + framework_ref={"langgraph": {"thread_id": "tenant:agent:sess-resume-stream", "checkpoint_id": "ckpt-1"}}, + invocation_id="inv-checkpoint", + session_service_provider=lambda: service, + ) + + captured_invocations: list[str | None] = [] + + def fake_detached_streaming_response(source, *, invocation_id=None): + captured_invocations.append(invocation_id) + return Response(status_code=202) + + monkeypatch.setattr(server_app_module, "_detached_streaming_response", fake_detached_streaming_response) + transport = httpx.ASGITransport(app=server_app_module.app) + async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client: + response = await client.post( + "/agentengine/api/v1/ResumeRun", + json={ + "AgentId": "demo-agent", + "SessionId": "sess-resume-stream", + "RunId": "run-1", + "CheckpointId": "ckpt-1", + "ResumeAttemptId": "resume-1", + "InvocationId": "run-ui-resume-1", + "Stream": True, + }, + ) + + assert response.status_code == 202 + assert captured_invocations == ["run-ui-resume-1"] + + +@pytest.mark.asyncio +async def test_resume_run_action_stream_registers_detached_cancel(monkeypatch): + server_app_module = importlib.import_module("ksadk.server.app") + conversation_runtime = importlib.import_module("ksadk.conversations.runtime") + service = InMemorySessionService() + runner = _CancellableStreamingRunner() + + await service.create_session(agent_id="demo-agent", user_id="user-1", session_id="sess-resume-cancel") + monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service) + server_app_module.set_runner(runner) + await conversation_runtime.append_run_checkpoint_event( + session_id="sess-resume-cancel", + author="demo-agent", + run_id="run-1", + checkpoint_id="ckpt-1", + framework="langgraph", + framework_ref={ + "langgraph": { + "thread_id": "tenant:agent:sess-resume-cancel", + "checkpoint_id": "ckpt-1", + } + }, + invocation_id="inv-checkpoint", + session_service_provider=lambda: service, + ) + + invocation_id = "run-ui-resume-cancel" + original_detached_streaming_response = server_app_module._detached_streaming_response + + def start_detached_stream_and_return_accepted(source, *, invocation_id=None): + original_detached_streaming_response(source, invocation_id=invocation_id) + return Response(status_code=202) + + monkeypatch.setattr( + server_app_module, + "_detached_streaming_response", + start_detached_stream_and_return_accepted, + ) + + transport = httpx.ASGITransport(app=server_app_module.app) + async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client: + resume_response = await client.post( + "/agentengine/api/v1/ResumeRun", + json={ + "AgentId": "demo-agent", + "SessionId": "sess-resume-cancel", + "RunId": "run-1", + "CheckpointId": "ckpt-1", + "ResumeAttemptId": "resume-1", + "InvocationId": invocation_id, + "Stream": True, + }, + ) + + for _ in range(20): + events = await service.get_events("sess-resume-cancel") + statuses = [ + event.content.get("status") + for event in events + if event.event_type == "run_status" + ] + if statuses == ["in_progress"]: + break + await asyncio.sleep(0.02) + + cancel_response = await client.post( + "/agentengine/api/v1/CancelRun", + json={"AgentId": "demo-agent", "InvocationId": invocation_id}, + ) + + assert resume_response.status_code == 202 + assert cancel_response.status_code == 200 + cancel_data = cancel_response.json()["Data"] + assert cancel_data["Found"] is True + assert cancel_data["Cancelled"] is True + assert cancel_data["RunnerCancelStatus"] == "accepted" + + for _ in range(20): + events = await service.get_events("sess-resume-cancel") + statuses = [ + event.content.get("status") + for event in events + if event.event_type == "run_status" + ] + if statuses == ["in_progress", "cancelled"]: + break + await asyncio.sleep(0.02) + + events = await service.get_events("sess-resume-cancel") + statuses = [ + event.content.get("status") + for event in events + if event.event_type == "run_status" + ] + assert statuses == ["in_progress", "cancelled"] + assert runner.cancel_requests == [invocation_id] + + +@pytest.mark.asyncio +async def test_resume_run_action_rejects_unknown_checkpoint(monkeypatch): + server_app_module = importlib.import_module("ksadk.server.app") + service = InMemorySessionService() + runner = _CheckpointResumeRunner() + + await service.create_session(agent_id="demo-agent", user_id="user-1", session_id="sess-resume-missing") + monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service) + server_app_module.set_runner(runner) + + transport = httpx.ASGITransport(app=server_app_module.app) + async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client: + response = await client.post( + "/agentengine/api/v1/ResumeRun", + json={ + "AgentId": "demo-agent", + "SessionId": "sess-resume-missing", + "RunId": "run-unknown", + "CheckpointId": "ckpt-unknown", + "Stream": False, + }, + ) + + assert response.status_code == 404 + assert runner.calls == [] + events = await service.get_events("sess-resume-missing") + assert events == [] + + +@pytest.mark.asyncio +async def test_preview_checkpoint_resume_summarizes_checkpoint_and_tool_receipts(monkeypatch): + server_app_module = importlib.import_module("ksadk.server.app") + conversation_runtime = importlib.import_module("ksadk.conversations.runtime") + service = InMemorySessionService() + runner = _CheckpointResumeRunner() + + await service.create_session(agent_id="demo-agent", user_id="user-1", session_id="sess-preview") + monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service) + server_app_module.set_runner(runner) + await service.append_event( + "sess-preview", + SessionEvent( + id="evt-tool", + author="tool", + event_type="tool_result", + content={"role": "user", "parts": [{"text": "{'ok': True}"}]}, + metadata={ + "tool_name": "write_workspace_file", + "tool_args": {"path": "notes.txt", "content": "hello"}, + "tool_output": {"ok": True, "path": "notes.txt"}, + "run_id": "run-1", + "tool_receipt": { + "receipt_id": "tr_1", + "idempotency_key": "tool_receipt:abc", + "tool_name": "write_workspace_file", + "tool_call_id": "call_write", + "run_id": "run-1", + "checkpoint_id": "", + "status": "completed", + "created_at": 10.0, + }, + }, + invocation_id="inv-tool", + ), + ) + await conversation_runtime.append_run_checkpoint_event( + session_id="sess-preview", + author="demo-agent", + run_id="run-1", + checkpoint_id="ckpt-1", + framework="langgraph", + framework_ref={"langgraph": {"thread_id": "tenant:agent:sess-preview", "checkpoint_id": "ckpt-1"}}, + phase="tool_result", + invocation_id="inv-1", + session_service_provider=lambda: service, + ) + + transport = httpx.ASGITransport(app=server_app_module.app) + async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client: + response = await client.post( + "/agentengine/api/v1/PreviewCheckpointResume", + json={ + "AgentId": "demo-agent", + "SessionId": "sess-preview", + "RunId": "run-1", + "CheckpointId": "ckpt-1", + }, + ) + + assert response.status_code == 200 + preview = response.json()["Data"]["Preview"] + assert preview["Checkpoint"]["CheckpointId"] == "ckpt-1" + assert preview["Capabilities"]["CheckpointResume"] is True + assert preview["Risk"]["Level"] == "medium" + assert preview["Risk"]["DuplicateSideEffectRisk"] is True + assert preview["ToolReceipts"][0]["ToolName"] == "write_workspace_file" + assert preview["ToolReceipts"][0]["Status"] == "completed" + + +@pytest.mark.asyncio +async def test_list_tool_receipts_filters_by_agent_session_run_and_checkpoint(monkeypatch): + server_app_module = importlib.import_module("ksadk.server.app") + service = InMemorySessionService() + runner = _DummyRunner() + + await service.create_session(agent_id="demo-agent", user_id="user-1", session_id="sess-receipts") + await service.create_session(agent_id="other-agent", user_id="user-1", session_id="sess-other-receipts") + monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service) + server_app_module.set_runner(runner) + + await service.append_event( + "sess-receipts", + SessionEvent( + id="evt-receipt-1", + author="tool", + event_type="tool_result", + content={"role": "user", "parts": [{"text": "{'ok': True}"}]}, + metadata={ + "tool_name": "write_workspace_file", + "run_id": "run-1", + "tool_receipt": { + "receipt_id": "tr_1", + "idempotency_key": "tool_receipt:1", + "tool_name": "write_workspace_file", + "tool_call_id": "call-1", + "run_id": "run-1", + "checkpoint_id": "ckpt-1", + "status": "completed", + "replayed": False, + }, + }, + invocation_id="inv-1", + ), + ) + await service.append_event( + "sess-receipts", + SessionEvent( + id="evt-receipt-2", + author="tool", + event_type="tool_result", + content={"role": "user", "parts": [{"text": "{'ok': True}"}]}, + metadata={ + "tool_name": "send_notification", + "run_id": "run-2", + "tool_receipt": { + "receipt_id": "tr_2", + "idempotency_key": "tool_receipt:2", + "tool_name": "send_notification", + "tool_call_id": "call-2", + "run_id": "run-2", + "checkpoint_id": "ckpt-2", + "status": "completed", + }, + }, + invocation_id="inv-2", + ), + ) + + transport = httpx.ASGITransport(app=server_app_module.app) + async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client: + response = await client.post( + "/agentengine/api/v1/ListToolReceipts", + json={ + "AgentId": "demo-agent", + "SessionId": "sess-receipts", + "RunId": "run-1", + "CheckpointId": "ckpt-1", + }, + ) + wrong_agent = await client.post( + "/agentengine/api/v1/ListToolReceipts", + json={"AgentId": "other-agent", "SessionId": "sess-receipts"}, + ) + + assert response.status_code == 200 + receipts = response.json()["Data"]["ToolReceipts"] + assert [receipt["ReceiptId"] for receipt in receipts] == ["tr_1"] + assert receipts[0]["ToolName"] == "write_workspace_file" + assert receipts[0]["RunId"] == "run-1" + assert receipts[0]["CheckpointId"] == "ckpt-1" + assert wrong_agent.status_code == 404 + + @pytest.mark.asyncio async def test_run_agent_action_passes_model_options_to_runner(monkeypatch): server_app_module = importlib.import_module("ksadk.server.app") @@ -1817,3 +2779,94 @@ async def test_run_agent_stream_continues_after_client_disconnect(monkeypatch): ] assert events[-2].content["parts"][0]["text"] == "hello" assert events[-1].content["status"] == "completed" + + +@pytest.mark.asyncio +async def test_cancel_run_cancels_detached_stream_and_writes_cancelled_status(monkeypatch): + server_app_module = importlib.import_module("ksadk.server.app") + service = InMemorySessionService() + runner = _CancellableStreamingRunner() + + monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service) + server_app_module.set_runner(runner) + + invocation_id = "inv-cancel-detached" + transport = httpx.ASGITransport(app=server_app_module.app) + async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client: + server_app_module._detached_streaming_response( + conversation.stream_responses_conversation_turn( + runner=runner, + agent_id="demo-agent", + user_id="user", + messages=[{"role": "user", "content": "hello"}], + session_id="sess-cancel-run", + model=None, + prepare_runner=lambda _runner, _model: None, + invocation_id=invocation_id, + session_service_provider=lambda: service, + ), + invocation_id=invocation_id, + ) + + for _ in range(20): + events = await service.get_events("sess-cancel-run") + statuses = [ + event.content.get("status") + for event in events + if event.event_type == "run_status" + ] + if statuses == ["in_progress"]: + break + await asyncio.sleep(0.02) + + cancel_response = await client.post( + "/agentengine/api/v1/CancelRun", + json={"AgentId": "demo-agent", "InvocationId": invocation_id}, + ) + + assert cancel_response.status_code == 200 + cancel_data = cancel_response.json()["Data"] + assert cancel_data["Found"] is True + assert cancel_data["Cancelled"] is True + assert cancel_data["Status"] == "cancelling" + + for _ in range(20): + events = await service.get_events("sess-cancel-run") + if events and events[-1].event_type == "run_status" and events[-1].content.get("status") == "cancelled": + break + await asyncio.sleep(0.02) + + events = await service.get_events("sess-cancel-run") + event_types = [event.event_type for event in events] + statuses = [ + event.content.get("status") + for event in events + if event.event_type == "run_status" + ] + assert statuses == ["in_progress", "cancelled"] + assert "assistant_message" not in event_types + assert runner.cancel_requests == [invocation_id] + + +@pytest.mark.asyncio +async def test_cancel_run_reports_unsupported_when_runner_has_no_cancel_hook(monkeypatch): + server_app_module = importlib.import_module("ksadk.server.app") + service = InMemorySessionService() + runner = _OverrideStreamingRunner() + + monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service) + server_app_module.set_runner(runner) + + transport = httpx.ASGITransport(app=server_app_module.app) + async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client: + response = await client.post( + "/agentengine/api/v1/CancelRun", + json={"AgentId": "demo-agent", "InvocationId": "inv-unsupported"}, + ) + + assert response.status_code == 200 + cancel_data = response.json()["Data"] + assert cancel_data["Found"] is False + assert cancel_data["Cancelled"] is False + assert cancel_data["Status"] == "unsupported" + assert cancel_data["RunnerCancelStatus"] == "unsupported" diff --git a/tests/test_unified_agent_ui_local.py b/tests/test_unified_agent_ui_local.py index 45333be2..8385ff80 100644 --- a/tests/test_unified_agent_ui_local.py +++ b/tests/test_unified_agent_ui_local.py @@ -180,232 +180,66 @@ async def test_get_agent_ui_bootstrap_matches_local_shape_parity(monkeypatch): assert payload["Data"]["Agent"]["AgentId"] == "demo-agent" assert payload["Data"]["Agent"]["Framework"] == "langgraph" assert payload["Data"]["Modules"] == ["Chat", "Build", "Deploy"] - assert payload["Data"]["Capabilities"] == { - "Attachments": True, - "WorkspaceFiles": True, - "Thinking": True, - "Approval": True, - "StopRun": True, - "ResumeRun": True, - "MCP": False, - "HostedRuntime": False, - "NativeTerminal": { - "Enabled": False, - "Mode": None, - "Protocol": "ks-terminal.v1", - "Path": None, - }, - "BuiltinTools": [ - { - "name": "list_skills", - "group": "skill", - "description": "List skills discoverable from configured Skill Spaces.", - "risk_level": "low", - "requires_approval": False, - "side_effects": [], - "enabled": True, - }, - { - "name": "search_skills", - "group": "skill", - "description": "Search skills by name, aliases, tags, description, and examples.", - "risk_level": "low", - "requires_approval": False, - "side_effects": [], - "enabled": True, - }, - { - "name": "load_skill", - "group": "skill", - "description": "Download and load a skill's SKILL.md instructions from configured Skill Spaces.", - "risk_level": "low", - "requires_approval": False, - "side_effects": ["skill_cache_write"], - "enabled": True, - }, - { - "name": "execute_skills", - "group": "skill", - "description": "Execute a workflow through the configured Skill Runtime.", - "risk_level": "high", - "requires_approval": False, - "side_effects": ["isolated_runtime_execution"], - "enabled": False, - "backend": "disabled", - "boundary": "isolated_skill_runtime", - }, - { - "name": "workspace_status", - "group": "workspace", - "description": "Return current AgentEngine workspace status.", - "risk_level": "low", - "requires_approval": False, - "side_effects": [], - "enabled": True, - "boundary": "workspace_root", - }, - { - "name": "list_workspace_files", - "group": "workspace", - "description": "List files under the AgentEngine workspace.", - "risk_level": "low", - "requires_approval": False, - "side_effects": [], - "enabled": True, - "boundary": "workspace_root", - }, - { - "name": "read_workspace_file", - "group": "workspace", - "description": "Read a UTF-8 text file from the AgentEngine workspace.", - "risk_level": "low", - "requires_approval": False, - "side_effects": [], - "enabled": True, - "boundary": "workspace_root", - }, - { - "name": "write_workspace_file", - "group": "workspace", - "description": "Write a UTF-8 text file inside the AgentEngine workspace.", - "risk_level": "medium", - "requires_approval": False, - "side_effects": ["workspace_write"], - "enabled": True, - "boundary": "workspace_root", - }, - { - "name": "write_workspace_files", - "group": "workspace", - "description": "Write multiple UTF-8 text files inside the AgentEngine workspace.", - "risk_level": "medium", - "requires_approval": False, - "side_effects": ["workspace_write"], - "enabled": True, - "boundary": "workspace_root", - }, - { - "name": "edit_workspace_file", - "group": "workspace", - "description": "Replace an exact text snippet inside a UTF-8 workspace file.", - "risk_level": "medium", - "requires_approval": False, - "side_effects": ["workspace_edit"], - "enabled": True, - "boundary": "workspace_root", - }, - { - "name": "lint_workspace_file", - "group": "workspace", - "description": "Run lightweight built-in lint checks for a UTF-8 workspace text file.", - "risk_level": "low", - "requires_approval": False, - "side_effects": [], - "enabled": True, - "boundary": "workspace_root", - }, - { - "name": "search_workspace_files", - "group": "workspace", - "description": "Search UTF-8 text files in the AgentEngine workspace.", - "risk_level": "low", - "requires_approval": False, - "side_effects": [], - "enabled": True, - "boundary": "workspace_root", - }, - { - "name": "delete_workspace_file", - "group": "workspace", - "description": "Delete a file or empty directory inside the AgentEngine workspace.", - "risk_level": "high", - "requires_approval": False, - "side_effects": ["workspace_delete"], - "enabled": True, - "boundary": "workspace_root", - }, - { - "name": "component_status", - "group": "platform", - "description": "Report AgentEngine built-in toolset and runtime binding status.", - "risk_level": "low", - "requires_approval": False, - "side_effects": [], - "enabled": True, - }, - { - "name": "search_knowledge_base", - "group": "platform", - "description": ( - "搜索知识库获取相关信息。\n\n" - "当需要查找专业知识、文档内容或特定领域信息时使用此工具。\n" - "会自动从已配置的金山云知识库中检索最相关的内容。\n\n" - "Args:\n" - " query: 检索关键词或问题" - ), - "risk_level": "low", - "requires_approval": False, - "side_effects": [], - "enabled": True, - }, - { - "name": "load_memory", - "group": "platform", - "description": "检索当前用户的长期记忆。", - "risk_level": "low", - "requires_approval": False, - "side_effects": [], - "enabled": True, - }, - { - "name": "save_memory", - "group": "platform", - "description": "保存一条长期记忆。", - "risk_level": "low", - "requires_approval": False, - "side_effects": [], - "enabled": True, - }, - { - "name": "sandbox_status", - "group": "sandbox", - "description": "Report configured AgentEngine sandbox status and boundaries.", - "risk_level": "low", - "requires_approval": False, - "side_effects": [], - "enabled": False, - "backend": "none", - "boundary": "isolated_sandbox", - }, - { - "name": "run_command", - "group": "sandbox", - "description": "Run a shell command inside the configured isolated sandbox.", - "risk_level": "high", - "requires_approval": False, - "side_effects": ["sandbox_command_execution"], - "enabled": False, - "backend": "none", - "boundary": "isolated_sandbox", - }, - { - "name": "run_code", - "group": "sandbox", - "description": "Write code to the sandbox and execute it through the configured sandbox backend.", - "risk_level": "high", - "requires_approval": False, - "side_effects": ["sandbox_code_execution"], - "enabled": False, - "backend": "none", - "boundary": "isolated_sandbox", - }, - ], - "RunLifecycle": { - "Enabled": True, - "Resume": True, - "Abort": True, - }, + + capabilities = payload["Data"]["Capabilities"] + assert capabilities["Attachments"] is True + assert capabilities["WorkspaceFiles"] is True + assert capabilities["Thinking"] is True + assert capabilities["Approval"] is True + assert capabilities["StopRun"] is True + assert capabilities["ResumeRun"] is True + assert capabilities["MCP"] is False + assert capabilities["HostedRuntime"] is False + assert capabilities["NativeTerminal"] == { + "Enabled": False, + "Mode": None, + "Protocol": "ks-terminal.v1", + "Path": None, + } + assert capabilities["RunLifecycle"] == { + "Enabled": True, + "Resume": True, + "Abort": True, + "Checkpoints": True, + "CheckpointResume": True, + "CheckpointResumePreview": True, } + + builtin_tools = {tool["name"]: tool for tool in capabilities["BuiltinTools"]} + assert set(builtin_tools) >= { + "list_skills", + "search_skills", + "load_skill", + "execute_skills", + "workspace_status", + "list_workspace_files", + "read_workspace_file", + "write_workspace_file", + "write_workspace_files", + "edit_workspace_file", + "lint_workspace_file", + "search_workspace_files", + "delete_workspace_file", + "component_status", + "search_knowledge_base", + "load_memory", + "save_memory", + "sandbox_status", + "run_command", + "run_code", + } + assert builtin_tools["execute_skills"] | { + "name": "execute_skills", + "group": "skill", + "risk_level": "high", + "requires_approval": False, + "enabled": False, + "backend": "disabled", + "boundary": "isolated_skill_runtime", + } == builtin_tools["execute_skills"] + assert builtin_tools["search_knowledge_base"]["args"]["query"]["type"] == "string" + assert builtin_tools["load_memory"]["args"]["query"]["type"] == "string" + assert builtin_tools["save_memory"]["args"]["content"]["type"] == "string" assert payload["Data"]["WorkspaceFiles"] == { "Enabled": True, "MaxUploadBytes": 104857600, @@ -800,7 +634,11 @@ async def test_upload_file_action_returns_server_handle_and_stores_file(monkeypa assert file_data["sizeBytes"] == 5 file_id = file_data["fileUri"].removeprefix("ksadk-upload://") - stored_files = list((tmp_path / ".agentengine" / "ui" / "files").glob(f"{file_id}*")) + stored_files = [ + path + for path in (tmp_path / ".agentengine" / "ui" / "files").glob(f"{file_id}*") + if not path.name.endswith(".meta.json") + ] assert len(stored_files) == 1 assert stored_files[0].read_bytes() == b"hello" @@ -1967,4 +1805,3 @@ def test_static_workbench_uses_openai_responses_content_for_inline_attachments() assert "filename:" in source assert "file_url:" in source assert "inlineData: {" not in source - diff --git a/uv.lock b/uv.lock index cc1a15cf..934bf065 100644 --- a/uv.lock +++ b/uv.lock @@ -346,15 +346,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/53/23/b65f568ed0c22f1efacb744d2db1a33c8068f384b8c9b482b52ebdbc3ef6/authlib-1.6.9-py2.py3-none-any.whl", hash = "sha256:f08b4c14e08f0861dc18a32357b33fbcfd2ea86cfe3fe149484b4d764c4a0ac3", size = 244197, upload-time = "2026-03-02T07:44:00.307Z" }, ] -[[package]] -name = "babel" -version = "2.18.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, -] - [[package]] name = "backports-asyncio-runner" version = "1.2.0" @@ -373,19 +364,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b9/fa/123043af240e49752f1c4bd24da5053b6bd00cad78c2be53c0d1e8b975bc/backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34", size = 30181, upload-time = "2024-05-28T17:01:53.112Z" }, ] -[[package]] -name = "backrefs" -version = "7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/a7dd63622beef68cc0d3c3c36d472e143dd95443d5ebf14cd1a5b4dfbf11/backrefs-7.0.tar.gz", hash = "sha256:4989bb9e1e99eb23647c7160ed51fb21d0b41b5d200f2d3017da41e023097e82", size = 7012453, upload-time = "2026-04-28T16:28:04.215Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/39/39a31d7eae729ea14ed10c3ccef79371197177b9355a86cb3525709e8502/backrefs-7.0-py310-none-any.whl", hash = "sha256:b57cd227ea556b0aed3dc9b8da4628db4eabc0402c6d7fcfc69283a93955f7e9", size = 380824, upload-time = "2026-04-28T16:27:55.647Z" }, - { url = "https://files.pythonhosted.org/packages/c9/b5/9302644225ba7dfa934a2ff2b9c7bb85701313a90dddb3dfaf693fa5bae2/backrefs-7.0-py311-none-any.whl", hash = "sha256:a0fa7360c63509e9e077e174ef4e6d3c21c8db94189b9d957289ae6d794b9475", size = 392626, upload-time = "2026-04-28T16:27:57.42Z" }, - { url = "https://files.pythonhosted.org/packages/36/da/87912ddec6e06feffbaa3d7aa18fc6352bee2e8f1fee185d7d1690f8f4e8/backrefs-7.0-py312-none-any.whl", hash = "sha256:ca42ce6a49ace3d75684dfa9937f3373902a63284ecb385ce36d15e5dcb41c12", size = 398537, upload-time = "2026-04-28T16:27:58.913Z" }, - { url = "https://files.pythonhosted.org/packages/00/bb/90ba423612b6aa0adccc6b1874bcd4a9b44b660c0c16f346611e00f64ac3/backrefs-7.0-py313-none-any.whl", hash = "sha256:f2c52955d631b9e1ac4cd56209f0a3a946d592b98e7790e77699339ae01c102a", size = 400491, upload-time = "2026-04-28T16:28:00.928Z" }, - { url = "https://files.pythonhosted.org/packages/3e/5c/fb93d3092640a24dfb7bd7727a24016d7c01774ca013e60efd3f683c8002/backrefs-7.0-py314-none-any.whl", hash = "sha256:a6448b28180e3ca01134c9cf09dcebafad8531072e09903c5451748a05f24bc9", size = 412349, upload-time = "2026-04-28T16:28:02.412Z" }, -] - [[package]] name = "beartype" version = "0.22.9" @@ -461,6 +439,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9d/2a/9186535ce58db529927f6cf5990a849aa9e052eea3e2cfefe20b9e1802da/bracex-2.6-py3-none-any.whl", hash = "sha256:0b0049264e7340b3ec782b5cb99beb325f36c3782a32e36e876452fd49a09952", size = 11508, upload-time = "2025-06-22T19:12:29.781Z" }, ] +[[package]] +name = "build" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "os_name == 'nt'" }, + { name = "importlib-metadata", marker = "python_full_version < '3.10.2'" }, + { name = "packaging" }, + { name = "pyproject-hooks" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/e0/df5e171f685f82f37b12e1f208064e24244911079d7b767447d1af7e0d70/build-1.5.0.tar.gz", hash = "sha256:302c22c3ba2a0fd5f3911918651341ebb3896176cbdec15bd421f80b1afc7647", size = 89796, upload-time = "2026-04-30T03:18:25.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/fe/6bea5c9162869c5beba5d9c8abbed835ec85bf1ec1fba05a3822325c45f3/build-1.5.0-py3-none-any.whl", hash = "sha256:13f3eecb844759ab66efec90ca17639bbf14dc06cb2fdf37a9010322d9c50a6f", size = 26018, upload-time = "2026-04-30T03:18:23.644Z" }, +] + [[package]] name = "burner-redis" version = "0.1.7" @@ -867,11 +861,11 @@ wheels = [ [[package]] name = "docutils" -version = "0.22.4" +version = "0.23" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", size = 2291750, upload-time = "2025-12-18T19:00:26.443Z" } +sdist = { url = "https://files.pythonhosted.org/packages/39/a4/5180d9afc57e8fca05601dd652bdff19604c218814037fe90ffc7625a50a/docutils-0.23.tar.gz", hash = "sha256:746f5060322511280a1e50eb76846ed6bf2342984b2ac04dc42caa1a8d78799e", size = 2303823, upload-time = "2026-05-27T17:41:06.934Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" }, + { url = "https://files.pythonhosted.org/packages/32/91/30151a39f7570f448ed84529390628a651d7f27c87d73c9b887f8189695e/docutils-0.23-py3-none-any.whl", hash = "sha256:25d013af9bf23bc1c7b2b093dff4208166c53a94786c9e447808335ef1185fea", size = 634701, upload-time = "2026-05-27T17:40:58.442Z" }, ] [[package]] @@ -1205,18 +1199,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e6/ab/fb21f4c939bb440104cc2b396d3be1d9b7a9fd3c6c2a53d98c45b3d7c954/fsspec-2026.2.0-py3-none-any.whl", hash = "sha256:98de475b5cb3bd66bedd5c4679e87b4fdfe1a3bf4d707b151b3c07e58c9a2437", size = 202505, upload-time = "2026-02-05T21:50:51.819Z" }, ] -[[package]] -name = "ghp-import" -version = "2.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "python-dateutil" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943, upload-time = "2022-05-02T15:47:16.11Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" }, -] - [[package]] name = "google-adk" version = "1.34.0" @@ -1872,15 +1854,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/29/4b/45d90626aef8e65336bed690106d1382f7a43665e2249017e9527df8823b/greenlet-3.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c04c5e06ec3e022cbfe2cd4a846e1d4e50087444f875ff6d2c2ad8445495cf1a", size = 237086, upload-time = "2026-02-20T20:20:45.786Z" }, ] -[[package]] -name = "griffelib" -version = "2.0.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9d/82/74f4a3310cdabfbb10da554c3a672847f1ed33c6f61dd472681ce7f1fe67/griffelib-2.0.2.tar.gz", hash = "sha256:3cf20b3bc470e83763ffbf236e0076b1211bac1bc67de13daf494640f2de707e", size = 166461, upload-time = "2026-03-27T11:34:51.091Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/11/8c/c9138d881c79aa0ea9ed83cbd58d5ca75624378b38cee225dcf5c42cc91f/griffelib-2.0.2-py3-none-any.whl", hash = "sha256:925c857658fb1ba40c0772c37acbc2ab650bd794d9c1b9726922e36ea4117ea1", size = 142357, upload-time = "2026-03-27T11:34:46.275Z" }, -] - [[package]] name = "grpc-google-iam-v1" version = "0.14.3" @@ -2451,7 +2424,7 @@ wheels = [ [[package]] name = "ksadk" -version = "0.6.5" +version = "0.6.6" source = { editable = "." } dependencies = [ { name = "a2a-sdk" }, @@ -2464,6 +2437,7 @@ dependencies = [ { name = "httpx" }, { name = "httpx-sse" }, { name = "jsonschema" }, + { name = "kingsoftcloud-sdk-python" }, { name = "ks3sdk" }, { name = "langchain" }, { name = "langchain-core" }, @@ -2502,22 +2476,17 @@ adk = [ all = [ { name = "a2a-sdk", extra = ["http-server"] }, { name = "black" }, + { name = "build" }, { name = "deepagents", marker = "python_full_version >= '3.11'" }, { name = "e2b" }, { name = "fastmcp" }, { name = "google-adk" }, { name = "json-repair" }, - { name = "kingsoftcloud-sdk-python" }, { name = "langchain" }, { name = "langchain-core" }, { name = "langchain-openai" }, { name = "langgraph" }, { name = "litellm" }, - { name = "mkdocs" }, - { name = "mkdocs-click" }, - { name = "mkdocs-material" }, - { name = "mkdocs-static-i18n" }, - { name = "mkdocstrings", extra = ["python"] }, { name = "mypy" }, { name = "openinference-instrumentation-langchain" }, { name = "protobuf" }, @@ -2534,21 +2503,14 @@ deepagents = [ ] dev = [ { name = "black" }, + { name = "build" }, { name = "fastmcp" }, - { name = "mkdocs" }, - { name = "mkdocs-click" }, - { name = "mkdocs-material" }, - { name = "mkdocs-static-i18n" }, - { name = "mkdocstrings", extra = ["python"] }, { name = "mypy" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "ruff" }, { name = "twine" }, ] -kb = [ - { name = "kingsoftcloud-sdk-python" }, -] langchain = [ { name = "langchain" }, { name = "langchain-core" }, @@ -2574,6 +2536,7 @@ requires-dist = [ { name = "asyncpg", specifier = ">=0.30.0,<1.0.0" }, { name = "beautifulsoup4", specifier = ">=4.12.0" }, { name = "black", marker = "extra == 'dev'", specifier = ">=22.0.0" }, + { name = "build", marker = "extra == 'dev'", specifier = ">=1.0.0" }, { name = "click", specifier = ">=8.0.0" }, { name = "cryptography", specifier = ">=44.0.0" }, { name = "deepagents", marker = "python_full_version >= '3.11' and extra == 'deepagents'", specifier = ">=0.6.2,<1.0.0" }, @@ -2586,7 +2549,7 @@ requires-dist = [ { name = "httpx-sse", specifier = ">=0.4.0" }, { name = "json-repair", marker = "extra == 'adk'", specifier = ">=0.25.0" }, { name = "jsonschema", specifier = ">=4.0.0,<5.0.0" }, - { name = "kingsoftcloud-sdk-python", marker = "extra == 'kb'", specifier = ">=1.5.8.94" }, + { name = "kingsoftcloud-sdk-python", specifier = ">=1.5.8.94" }, { name = "ks3sdk", specifier = ">=1.15.0" }, { name = "ksadk", extras = ["a2a", "adk", "langchain", "langgraph", "deepagents", "kb", "skills", "tracing", "dev"], marker = "extra == 'all'" }, { name = "langchain", specifier = ">=1.3.0,<2.0.0" }, @@ -2603,11 +2566,6 @@ requires-dist = [ { name = "langgraph", marker = "extra == 'deepagents'", specifier = ">=1.2.0,<1.3.0" }, { name = "langgraph", marker = "extra == 'langgraph'", specifier = ">=1.2.0,<1.3.0" }, { name = "litellm", marker = "extra == 'adk'", specifier = ">=1.0.0" }, - { name = "mkdocs", marker = "extra == 'dev'", specifier = ">=1.6.0,<2.0.0" }, - { name = "mkdocs-click", marker = "extra == 'dev'", specifier = ">=0.8.0,<1.0.0" }, - { name = "mkdocs-material", marker = "extra == 'dev'", specifier = ">=9.5.0,<10.0.0" }, - { name = "mkdocs-static-i18n", marker = "extra == 'dev'", specifier = ">=1.3.0,<2.0.0" }, - { name = "mkdocstrings", extras = ["python"], marker = "extra == 'dev'", specifier = ">=0.27.0,<1.0.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.0.0" }, { name = "openinference-instrumentation-langchain", marker = "extra == 'tracing'", specifier = ">=0.1.0" }, { name = "opentelemetry-api", specifier = "==1.37.0" }, @@ -2631,7 +2589,7 @@ requires-dist = [ { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.0" }, { name = "sse-starlette", specifier = ">=2.1.0" }, { name = "textual", specifier = ">=0.50.0" }, - { name = "twine", marker = "extra == 'dev'", specifier = ">=6.0.0,<7.0.0" }, + { name = "twine", marker = "extra == 'dev'", specifier = ">=5.0.0" }, { name = "uvicorn", specifier = ">=0.23.0" }, { name = "websockets", specifier = ">=12.0,<16.0" }, ] @@ -3026,25 +2984,16 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509, upload-time = "2025-04-10T12:50:53.297Z" }, ] -[[package]] -name = "markdown" -version = "3.10.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805, upload-time = "2026-02-09T14:57:26.942Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" }, -] - [[package]] name = "markdown-it-py" -version = "3.0.0" +version = "4.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mdurl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" }, + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, ] [package.optional-dependencies] @@ -3183,160 +3132,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] -[[package]] -name = "mergedeep" -version = "1.3.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661, upload-time = "2021-02-05T18:55:30.623Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" }, -] - -[[package]] -name = "mkdocs" -version = "1.6.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "ghp-import" }, - { name = "jinja2" }, - { name = "markdown" }, - { name = "markupsafe" }, - { name = "mergedeep" }, - { name = "mkdocs-get-deps" }, - { name = "packaging" }, - { name = "pathspec" }, - { name = "pyyaml" }, - { name = "pyyaml-env-tag" }, - { name = "watchdog" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/bc/c6/bbd4f061bd16b378247f12953ffcb04786a618ce5e904b8c5a01a0309061/mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2", size = 3889159, upload-time = "2024-08-30T12:24:06.899Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451, upload-time = "2024-08-30T12:24:05.054Z" }, -] - -[[package]] -name = "mkdocs-autorefs" -version = "1.4.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markdown" }, - { name = "markupsafe" }, - { name = "mkdocs" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/52/c0/f641843de3f612a6b48253f39244165acff36657a91cc903633d456ae1ac/mkdocs_autorefs-1.4.4.tar.gz", hash = "sha256:d54a284f27a7346b9c38f1f852177940c222da508e66edc816a0fa55fc6da197", size = 56588, upload-time = "2026-02-10T15:23:55.105Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/28/de/a3e710469772c6a89595fc52816da05c1e164b4c866a89e3cb82fb1b67c5/mkdocs_autorefs-1.4.4-py3-none-any.whl", hash = "sha256:834ef5408d827071ad1bc69e0f39704fa34c7fc05bc8e1c72b227dfdc5c76089", size = 25530, upload-time = "2026-02-10T15:23:53.817Z" }, -] - -[[package]] -name = "mkdocs-click" -version = "0.9.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "markdown" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a1/c7/8c25f3a3b379def41e6d0bb5c4beeab7aa8a394b17e749f498504102cfa5/mkdocs_click-0.9.0.tar.gz", hash = "sha256:6050917628d4740517541422b607404d044117bc31b770c4f9e9e1939a50c908", size = 18720, upload-time = "2025-04-07T16:59:36.387Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/fc/9124ab36e2341e78d8d9c669511bd70f52ea0de8105760c31fabec1f9396/mkdocs_click-0.9.0-py3-none-any.whl", hash = "sha256:5208e828f4f68f63c847c1ef7be48edee9964090390afc8f5b3d4cbe5ea9bbed", size = 15104, upload-time = "2025-04-07T16:59:34.807Z" }, -] - -[[package]] -name = "mkdocs-get-deps" -version = "0.2.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mergedeep" }, - { name = "platformdirs" }, - { name = "pyyaml" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ce/25/b3cccb187655b9393572bde9b09261d267c3bf2f2cdabe347673be5976a6/mkdocs_get_deps-0.2.2.tar.gz", hash = "sha256:8ee8d5f316cdbbb2834bc1df6e69c08fe769a83e040060de26d3c19fad3599a1", size = 11047, upload-time = "2026-03-10T02:46:33.632Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/88/29/744136411e785c4b0b744d5413e56555265939ab3a104c6a4b719dad33fd/mkdocs_get_deps-0.2.2-py3-none-any.whl", hash = "sha256:e7878cbeac04860b8b5e0ca31d3abad3df9411a75a32cde82f8e44b6c16ff650", size = 9555, upload-time = "2026-03-10T02:46:32.256Z" }, -] - -[[package]] -name = "mkdocs-material" -version = "9.7.6" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "babel" }, - { name = "backrefs" }, - { name = "colorama" }, - { name = "jinja2" }, - { name = "markdown" }, - { name = "mkdocs" }, - { name = "mkdocs-material-extensions" }, - { name = "paginate" }, - { name = "pygments" }, - { name = "pymdown-extensions" }, - { name = "requests" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/45/29/6d2bcf41ae40802c4beda2432396fff97b8456fb496371d1bc7aad6512ec/mkdocs_material-9.7.6.tar.gz", hash = "sha256:00bdde50574f776d328b1862fe65daeaf581ec309bd150f7bff345a098c64a69", size = 4097959, upload-time = "2026-03-19T15:41:58.161Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/01/bc663630c510822c95c47a66af9fa7a443c295b47d5f041e5e6ae62ef659/mkdocs_material-9.7.6-py3-none-any.whl", hash = "sha256:71b84353921b8ea1ba84fe11c50912cc512da8fe0881038fcc9a0761c0e635ba", size = 9305470, upload-time = "2026-03-19T15:41:55.217Z" }, -] - -[[package]] -name = "mkdocs-material-extensions" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/79/9b/9b4c96d6593b2a541e1cb8b34899a6d021d208bb357042823d4d2cabdbe7/mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443", size = 11847, upload-time = "2023-11-22T19:09:45.208Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728, upload-time = "2023-11-22T19:09:43.465Z" }, -] - -[[package]] -name = "mkdocs-static-i18n" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mkdocs" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ce/f9/51e2ffda9c7210bc35a24f3717b08c052cd4b728dfa87f901c00d8005259/mkdocs_static_i18n-1.3.1.tar.gz", hash = "sha256:a6125ea7db6cc1a900d76a967f262535af09831160a93c56d7f0d522a79b5faf", size = 1371325, upload-time = "2026-02-20T10:42:41.835Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/0b/43ff4afb6b438d47718b1959a22075ed95d8460d8c47381878b37a40de63/mkdocs_static_i18n-1.3.1-py3-none-any.whl", hash = "sha256:4036e24795a150c9c4d4b001ed24a43aec01335f76188dbe5a5d8fb4a27eba65", size = 21853, upload-time = "2026-02-20T10:42:40.551Z" }, -] - -[[package]] -name = "mkdocstrings" -version = "0.30.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "jinja2" }, - { name = "markdown" }, - { name = "markupsafe" }, - { name = "mkdocs" }, - { name = "mkdocs-autorefs" }, - { name = "pymdown-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c5/33/2fa3243439f794e685d3e694590d28469a9b8ea733af4b48c250a3ffc9a0/mkdocstrings-0.30.1.tar.gz", hash = "sha256:84a007aae9b707fb0aebfc9da23db4b26fc9ab562eb56e335e9ec480cb19744f", size = 106350, upload-time = "2025-09-19T10:49:26.446Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7b/2c/f0dc4e1ee7f618f5bff7e05898d20bf8b6e7fa612038f768bfa295f136a4/mkdocstrings-0.30.1-py3-none-any.whl", hash = "sha256:41bd71f284ca4d44a668816193e4025c950b002252081e387433656ae9a70a82", size = 36704, upload-time = "2025-09-19T10:49:24.805Z" }, -] - -[package.optional-dependencies] -python = [ - { name = "mkdocstrings-python" }, -] - -[[package]] -name = "mkdocstrings-python" -version = "2.0.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "griffelib" }, - { name = "mkdocs-autorefs" }, - { name = "mkdocstrings" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/29/33/c225eaf898634bdda489a6766fc35d1683c640bffe0e0acd10646b13536d/mkdocstrings_python-2.0.3.tar.gz", hash = "sha256:c518632751cc869439b31c9d3177678ad2bfa5c21b79b863956ad68fc92c13b8", size = 199083, upload-time = "2026-02-20T10:38:36.368Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/32/28/79f0f8de97cce916d5ae88a7bee1ad724855e83e6019c0b4d5b3fabc80f3/mkdocstrings_python-2.0.3-py3-none-any.whl", hash = "sha256:0b83513478bdfd803ff05aa43e9b1fca9dd22bcd9471f09ca6257f009bc5ee12", size = 104779, upload-time = "2026-02-20T10:38:34.517Z" }, -] - [[package]] name = "mmh3" version = "5.2.1" @@ -4354,15 +4149,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, ] -[[package]] -name = "paginate" -version = "0.5.7" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ec/46/68dde5b6bc00c1296ec6466ab27dddede6aec9af1b99090e1107091b3b84/paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945", size = 19252, upload-time = "2024-08-25T14:17:24.139Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591", size = 13746, upload-time = "2024-08-25T14:17:22.55Z" }, -] - [[package]] name = "pathable" version = "0.6.0" @@ -5065,19 +4851,6 @@ crypto = [ { name = "cryptography" }, ] -[[package]] -name = "pymdown-extensions" -version = "10.21.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markdown" }, - { name = "pyyaml" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/9e/26/d1015444da4d952a1ca487a236b522eb979766f0295a0bd0c5fc089989a9/pymdown_extensions-10.21.3.tar.gz", hash = "sha256:72cfcf55f07aea0d4af2c4f11dd4e52466ddfb1bb819673146398e0bd3a77354", size = 854140, upload-time = "2026-05-13T12:57:32.267Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/85/545a951eecc270fcd688288c600017e2050a1aacb56c711d208586d3e470/pymdown_extensions-10.21.3-py3-none-any.whl", hash = "sha256:d7a5d08014fc571e80ca21dd6f854e31f94c489800350564d55d15b3c41e76b6", size = 269002, upload-time = "2026-05-13T12:57:30.296Z" }, -] - [[package]] name = "pyopenssl" version = "26.2.0" @@ -5121,6 +4894,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/80/fc9d01d5ed37ba4c42ca2b55b4339ae6e200b456be3a1aaddf4a9fa99b8c/pyperclip-1.11.0-py3-none-any.whl", hash = "sha256:299403e9ff44581cb9ba2ffeed69c7aa96a008622ad0c46cb575ca75b5b84273", size = 11063, upload-time = "2025-09-26T14:40:36.069Z" }, ] +[[package]] +name = "pyproject-hooks" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/82/28175b2414effca1cdac8dc99f76d660e7a4fb0ceefa4b4ab8f5f6742925/pyproject_hooks-1.2.0.tar.gz", hash = "sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8", size = 19228, upload-time = "2024-09-29T09:24:13.293Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913", size = 10216, upload-time = "2024-09-29T09:24:11.978Z" }, +] + [[package]] name = "pytest" version = "9.0.2" @@ -5335,18 +5117,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] -[[package]] -name = "pyyaml-env-tag" -version = "1.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyyaml" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/79c822141bfd05a853236b504869ebc6b70159afc570e1d5a20641782eaa/pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff", size = 5737, upload-time = "2025-05-13T15:24:01.64Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" }, -] - [[package]] name = "qrcode" version = "8.2" @@ -5394,16 +5164,16 @@ wheels = [ [[package]] name = "readme-renderer" -version = "44.0" +version = "45.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "docutils" }, { name = "nh3" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5a/a9/104ec9234c8448c4379768221ea6df01260cd6c2ce13182d4eac531c8342/readme_renderer-44.0.tar.gz", hash = "sha256:8712034eabbfa6805cacf1402b4eeb2a73028f72d1166d6f5cb7f9c047c5d1e1", size = 32056, upload-time = "2024-07-08T15:00:57.805Z" } +sdist = { url = "https://files.pythonhosted.org/packages/02/51/d3a6ea424652c60f05600d8c2e01a55c913755e7cdad64afabbd1aa16f44/readme_renderer-45.0.tar.gz", hash = "sha256:030a8fac74904f8fba11ad1bb6964e3f76e896dc7e5e71f16af190c9056696d1", size = 36172, upload-time = "2026-06-09T21:05:17.37Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/67/921ec3024056483db83953ae8e48079ad62b92db7880013ca77632921dd0/readme_renderer-44.0-py3-none-any.whl", hash = "sha256:2fbca89b81a08526aadf1357a8c2ae889ec05fb03f5da67f9769c9a592166151", size = 13310, upload-time = "2024-07-08T15:00:56.577Z" }, + { url = "https://files.pythonhosted.org/packages/97/1b/295bf2fa3e740131778065e5ffa2c481f0e7210182d408e9a2c244ff5b0c/readme_renderer-45.0-py3-none-any.whl", hash = "sha256:3385ed220117104a2bceb4a9dac8c5fdf6d1f96890d7ea2a9c7174fd5c84091f", size = 14134, upload-time = "2026-06-09T21:05:15.85Z" }, ] [[package]] From b7c8ca598d8cfd16960128b0acd28d1e16c56f81 Mon Sep 17 00:00:00 2001 From: xiayu Date: Thu, 18 Jun 2026 17:05:57 +0800 Subject: [PATCH 02/52] fix: allow kspmas-internal and agentengine-public registry paths in open-source audit - kspmas-internal.sdns.ksyun.com is an explicitly supported public SDK endpoint - hub.kce.ksyun.com/agentengine-public/ paths are public container registry references - Both patterns are now whitelisted in the content audit rules - Add test coverage for the new whitelist behavior --- scripts/open_source_audit.py | 3 ++- tests/test_open_source_audit.py | 25 +++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/scripts/open_source_audit.py b/scripts/open_source_audit.py index ecb5008d..ab449df1 100644 --- a/scripts/open_source_audit.py +++ b/scripts/open_source_audit.py @@ -212,13 +212,14 @@ def to_dict(self) -> dict[str, object]: pattern=re.compile( r"(? Date: Thu, 18 Jun 2026 17:17:57 +0800 Subject: [PATCH 03/52] fix: split blocked registry string in test to avoid self-audit violation --- tests/test_open_source_audit.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_open_source_audit.py b/tests/test_open_source_audit.py index 70828871..2e5ff566 100644 --- a/tests/test_open_source_audit.py +++ b/tests/test_open_source_audit.py @@ -341,7 +341,7 @@ def test_content_audit_allows_kspmas_internal_and_public_registry_paths(tmp_path '# HERMES_IMAGE=hub.kce.ksyun.com/agentengine-public/hermes-agent:tag\n', encoding="utf-8", ) - blocked_registry = "hub.kce.ksyun.com/private-registry/image" + blocked_registry = "hub.kce." + "ksyun.com/private-registry/image" (tmp_path / "other.py").write_text( f'IMAGE = "{blocked_registry}"\n', encoding="utf-8", From 6f7bfa66c59fbe6209b0f6e2671010e82bd65108 Mon Sep 17 00:00:00 2001 From: xiayu Date: Thu, 18 Jun 2026 17:21:25 +0800 Subject: [PATCH 04/52] fix: add missing dependency modules for 0.6.6 release - reasoning_markup.py: required by conversations/runtime.py - env_options.py: CLI environment option parsing - terminal_exec_policy.py: shared terminal exec allowlist --- ksadk/cli/env_options.py | 82 +++++++++ ksadk/conversations/reasoning_markup.py | 91 ++++++++++ ksadk/terminal_exec_policy.py | 229 ++++++++++++++++++++++++ 3 files changed, 402 insertions(+) create mode 100644 ksadk/cli/env_options.py create mode 100644 ksadk/conversations/reasoning_markup.py create mode 100644 ksadk/terminal_exec_policy.py diff --git a/ksadk/cli/env_options.py b/ksadk/cli/env_options.py new file mode 100644 index 00000000..70d3b965 --- /dev/null +++ b/ksadk/cli/env_options.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +import json +import re +from pathlib import Path + +import click + +_ENV_KEY_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") + + +def parse_env_pairs(items: tuple[str, ...] | list[str] | None) -> dict[str, str]: + """Parse repeated KEY=VALUE CLI env options.""" + parsed: dict[str, str] = {} + for raw_item in items or (): + item = str(raw_item or "").strip() + if not item or "=" not in item: + raise ValueError(f"自定义环境变量格式错误: {raw_item!r},应为 KEY=VALUE") + key, value = item.split("=", 1) + key = key.strip() + if not _ENV_KEY_PATTERN.fullmatch(key): + raise ValueError(f"自定义环境变量名不合法: {key!r},请使用合法的环境变量名") + parsed[key] = value + return parsed + + +def load_env_file(env_file: str | None, *, base_dir: Path | None = None) -> dict[str, str]: + """Load explicit runtime env variables from a dotenv or JSON object file.""" + if not env_file: + return {} + + path = Path(env_file) + if not path.is_absolute() and base_dir is not None: + path = base_dir / path + if not path.exists(): + raise ValueError(f"环境变量文件不存在: {path}") + + if path.suffix.lower() == ".json": + raw = json.loads(path.read_text(encoding="utf-8-sig")) + if not isinstance(raw, dict): + raise ValueError(f"环境变量 JSON 文件必须是对象: {path}") + items = raw.items() + else: + from dotenv import dotenv_values + + items = dotenv_values(path, encoding="utf-8-sig").items() + + parsed: dict[str, str] = {} + for raw_key, raw_value in items: + if not raw_key or raw_value is None: + continue + key = str(raw_key).lstrip("\ufeff").strip() + if not _ENV_KEY_PATTERN.fullmatch(key): + raise ValueError(f"环境变量名不合法: {key!r},文件: {path}") + parsed[key] = str(raw_value) + return parsed + + +def resolve_explicit_env_vars( + *, + env_file: str | None, + env_pairs: tuple[str, ...] | list[str] | None, + base_dir: Path, +) -> dict[str, str]: + env_vars = load_env_file(env_file, base_dir=base_dir) + env_vars.update(parse_env_pairs(env_pairs)) + return env_vars + + +def env_options(func): + func = click.option( + "--env-file", + type=click.Path(exists=False, dir_okay=False), + help="额外运行时环境变量文件,支持 .env 或 JSON 对象", + )(func) + func = click.option( + "--env", + "extra_env", + multiple=True, + help="额外透传运行时环境变量,格式 KEY=VALUE,可重复传入", + )(func) + return func diff --git a/ksadk/conversations/reasoning_markup.py b/ksadk/conversations/reasoning_markup.py new file mode 100644 index 00000000..39c2b148 --- /dev/null +++ b/ksadk/conversations/reasoning_markup.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from typing import Literal + +ReasoningPartKind = Literal["thinking", "text"] + + +@dataclass(frozen=True) +class ReasoningMarkupPart: + kind: ReasoningPartKind + text: str + + +@dataclass +class ReasoningMarkupParser: + """Incrementally split inline markup out of streamed text.""" + + _buffer: str = "" + _in_think: bool = False + _pending: list[ReasoningMarkupPart] = field(default_factory=list) + + def feed(self, chunk: str) -> list[ReasoningMarkupPart]: + self._buffer += str(chunk or "") + parts: list[ReasoningMarkupPart] = [] + + while self._buffer: + tag = "" if self._in_think else "" + index = self._buffer.find(tag) + if index >= 0: + self._append(parts, self._buffer[:index]) + self._buffer = self._buffer[index + len(tag):] + self._in_think = not self._in_think + continue + + prefix_len = self._partial_tag_prefix_len(self._buffer, tag) + if self._in_think: + break + emit_len = len(self._buffer) - prefix_len + if emit_len <= 0: + break + self._append(parts, self._buffer[:emit_len]) + self._buffer = self._buffer[emit_len:] + break + + return self._merge(parts) + + def flush(self) -> list[ReasoningMarkupPart]: + if not self._buffer: + return [] + parts: list[ReasoningMarkupPart] = [] + self._append(parts, self._buffer) + self._buffer = "" + return self._merge(parts) + + def _append(self, parts: list[ReasoningMarkupPart], text: str) -> None: + if not text: + return + parts.append(ReasoningMarkupPart("thinking" if self._in_think else "text", text)) + + @staticmethod + def _merge(parts: list[ReasoningMarkupPart]) -> list[ReasoningMarkupPart]: + merged: list[ReasoningMarkupPart] = [] + for part in parts: + if not part.text: + continue + if merged and merged[-1].kind == part.kind: + merged[-1] = ReasoningMarkupPart(part.kind, merged[-1].text + part.text) + else: + merged.append(part) + return merged + + @staticmethod + def _partial_tag_prefix_len(value: str, tag: str) -> int: + max_len = min(len(value), len(tag) - 1) + for size in range(max_len, 0, -1): + if tag.startswith(value[-size:]): + return size + return 0 + + +def strip_reasoning_markup(text: str) -> str: + """Remove inline blocks from text used as final answer or title input.""" + value = str(text or "") + if not value: + return "" + value = re.sub(r"]*>.*?", "", value, flags=re.IGNORECASE | re.DOTALL) + value = re.sub(r"]*>.*$", "", value, flags=re.IGNORECASE | re.DOTALL) + value = re.sub(r"]*>", "", value, flags=re.IGNORECASE) + return value diff --git a/ksadk/terminal_exec_policy.py b/ksadk/terminal_exec_policy.py new file mode 100644 index 00000000..22ead99b --- /dev/null +++ b/ksadk/terminal_exec_policy.py @@ -0,0 +1,229 @@ +"""Shared remote terminal exec command policy.""" + +from __future__ import annotations + +import os +import re +from collections.abc import Iterable, Sequence +from dataclasses import dataclass + +SHELL_METACHARS = set("|&;<>()$`\\\n\r") +FORBIDDEN_LAUNCHERS = { + "bash", + "sh", + "zsh", + "fish", + "python", + "python3", + "node", + "npx", + "pnpm", + "npm", + "yarn", + "uv", + "uvx", + "hermes", +} + +TERMINAL_EXEC_ALLOWLIST_ENV = "KSADK_TERMINAL_EXEC_SUBCOMMAND_ALLOWLIST" + +COMMON_REMOTE_EXEC_PREFIXES: tuple[tuple[str, ...], ...] = ( + ("cat",), + ("date",), + ("df",), + ("du",), + ("find",), + ("git", "diff"), + ("git", "log"), + ("git", "rev-parse"), + ("git", "show"), + ("git", "status"), + ("head",), + ("id",), + ("ls",), + ("ps",), + ("pwd",), + ("stat",), + ("tail",), + ("uname",), + ("wc",), + ("whoami",), +) + + +@dataclass(frozen=True) +class TerminalExecPolicy: + name: str + default_exact: tuple[tuple[str, ...], ...] = () + default_prefixes: tuple[tuple[str, ...], ...] = () + default_bounded_prefixes: tuple[tuple[tuple[str, ...], int, int], ...] = () + env_names: tuple[str, ...] = (TERMINAL_EXEC_ALLOWLIST_ENV,) + + +GENERIC_TERMINAL_EXEC_POLICY = TerminalExecPolicy( + name="terminal", + default_prefixes=COMMON_REMOTE_EXEC_PREFIXES, +) + +HERMES_TERMINAL_EXEC_POLICY = TerminalExecPolicy( + name="Hermes", + default_exact=( + ("cron", "list"), + ("cron", "status"), + ("config", "check"), + ("config", "env-path"), + ("config", "path"), + ("config", "show"), + ("doctor",), + ("gateway", "status"), + ("insights",), + ("sessions", "list"), + ("skills", "audit"), + ("skills", "check"), + ("skills", "list"), + ("status",), + ("tools", "list"), + ("version",), + ), + default_bounded_prefixes=( + (("sessions", "export"), 3, 3), + (("sessions", "show"), 3, 3), + ), +) + +OPENCLAW_TERMINAL_EXEC_POLICY = TerminalExecPolicy( + name="OpenClaw", + default_prefixes=( + *COMMON_REMOTE_EXEC_PREFIXES, + ("openclaw", "channels", "login"), + ), +) + +_ALLOWLIST_ENTRY_SPLIT_RE = re.compile(r"[\n;,]+") +_ALLOWLIST_TOKEN_SPLIT_RE = re.compile(r"\s+") + + +def normalize_exec_argv( + argv: Iterable[str], + *, + policy_name: str = "terminal", + allow_shell_metachars: bool = False, + allow_forbidden_launchers: bool = False, +) -> list[str]: + normalized = [str(item).strip() for item in argv] + if not normalized: + raise ValueError(f"{policy_name} exec requires argv") + for index, item in enumerate(normalized): + if not item: + raise ValueError(f"{policy_name} exec argv contains an empty argument") + if not allow_shell_metachars and any(char in SHELL_METACHARS for char in item): + raise ValueError(f"{policy_name} exec does not allow shell metacharacters: {item}") + if index == 0 and item.startswith("-"): + raise ValueError(f"{policy_name} exec command is invalid: {item}") + if not allow_forbidden_launchers and index == 0 and item in FORBIDDEN_LAUNCHERS: + raise ValueError(f"{policy_name} exec launcher is not allowed: {item}") + return normalized + + +def validate_terminal_exec_argv( + argv: Iterable[str], + *, + policy: TerminalExecPolicy = GENERIC_TERMINAL_EXEC_POLICY, +) -> list[str]: + if _env_allowlist_all(policy.env_names): + return normalize_exec_argv( + argv, + policy_name=policy.name, + allow_shell_metachars=True, + allow_forbidden_launchers=True, + ) + + normalized = normalize_exec_argv(argv, policy_name=policy.name) + allowed_exact = tuple(tuple(item) for item in policy.default_exact) + allowed_prefixes = ( + tuple(tuple(item) for item in policy.default_prefixes) + + _env_allowlist_prefixes(policy.env_names, policy_name=policy.name) + ) + + if _matches_exact_or_prefix( + normalized, + allowed_exact=allowed_exact, + allowed_prefixes=allowed_prefixes, + allowed_bounded_prefixes=policy.default_bounded_prefixes, + ): + return normalized + suggested_prefix = normalized[0] + raise ValueError( + f"{policy.name} exec subcommand is not allowed: {' '.join(normalized)}. " + f"Set {TERMINAL_EXEC_ALLOWLIST_ENV}='{suggested_prefix}' to allow this prefix, " + f"or {TERMINAL_EXEC_ALLOWLIST_ENV}='*' to allow all remote exec commands." + ) + + +def _env_allowlist_prefixes( + env_names: Sequence[str], + *, + policy_name: str, +) -> tuple[tuple[str, ...], ...]: + prefixes: list[tuple[str, ...]] = [] + seen: set[tuple[str, ...]] = set() + for env_name in env_names: + for prefix in _parse_allowlist_prefixes(os.getenv(env_name), policy_name=policy_name): + if prefix not in seen: + prefixes.append(prefix) + seen.add(prefix) + return tuple(prefixes) + + +def _env_allowlist_all(env_names: Sequence[str]) -> bool: + for env_name in env_names: + raw = os.getenv(env_name) + if not raw: + continue + for entry in _ALLOWLIST_ENTRY_SPLIT_RE.split(raw): + if entry.strip() == "*": + return True + return False + + +def _parse_allowlist_prefixes(raw: str | None, *, policy_name: str) -> tuple[tuple[str, ...], ...]: + if not raw: + return () + prefixes: list[tuple[str, ...]] = [] + for entry in _ALLOWLIST_ENTRY_SPLIT_RE.split(raw): + entry = entry.strip() + if not entry: + continue + if entry == "*": + continue + tokens = tuple(token for token in _ALLOWLIST_TOKEN_SPLIT_RE.split(entry) if token) + normalize_exec_argv(tokens, policy_name=policy_name) + prefixes.append(tokens) + return tuple(prefixes) + + +def _matches_exact_or_prefix( + argv: Sequence[str], + *, + allowed_exact: Sequence[Sequence[str]], + allowed_prefixes: Sequence[Sequence[str]], + allowed_bounded_prefixes: Sequence[tuple[Sequence[str], int, int]], +) -> bool: + argv_tuple = tuple(argv) + for item in allowed_exact: + if argv_tuple == tuple(item): + return True + for prefix in allowed_prefixes: + prefix_tuple = tuple(prefix) + if prefix_tuple and argv_tuple[: len(prefix_tuple)] == prefix_tuple: + return True + for prefix, min_len, max_len in allowed_bounded_prefixes: + prefix_tuple = tuple(prefix) + if ( + prefix_tuple + and argv_tuple[: len(prefix_tuple)] == prefix_tuple + and len(argv_tuple) >= min_len + and len(argv_tuple) <= max_len + ): + return True + return False From 348cb16ed20fd1a1cc08ad01417751b8ebd7e5e7 Mon Sep 17 00:00:00 2001 From: xiayu Date: Thu, 18 Jun 2026 17:24:03 +0800 Subject: [PATCH 05/52] sync: align all ksadk source, tests, scripts, and build config with 0.6.6 feature branch Brings the public release branch in sync with the full internal feature branch source tree for 0.6.6, including: - All ksadk/ source modules (CLI, conversations, sessions, runners, etc.) - All test files and test snapshots - Build scripts, Makefile, pyproject.toml, uv.lock - CHANGELOG.md, README.md, and the remote agent runtime interface doc - Audit rules updated to allow kspmas-internal and agentengine-public paths --- Makefile | 655 +++---- ksadk/__init__.py | 3 +- ksadk/builders/code_builder.py | 16 +- ksadk/builders/container_builder.py | 212 ++- ksadk/builders/mcp_builder.py | 7 +- ksadk/cli/__init__.py | 4 + ksadk/cli/cmd_build.py | 2 +- ksadk/cli/cmd_dashboard.py | 104 +- ksadk/cli/cmd_deploy.py | 20 +- ksadk/cli/cmd_launch.py | 19 + ksadk/cli/cmd_mcp.py | 21 +- ksadk/cli/network_options.py | 216 +++ ksadk/common/constants.py | 6 +- ksadk/configs/env_registry.py | 15 +- ksadk/conversations/__init__.py | 4 + ksadk/conversations/context.py | 6 + ksadk/conversations/session_title.py | 9 +- ksadk/deployment/providers/serverless.py | 183 +- ksadk/detection/detector.py | 3 + ksadk/hermes_terminal.py | 110 +- ksadk/memory/adk/__init__.py | 8 +- ksadk/memory/adk/backends/sdk_ltm_backend.py | 117 +- ksadk/memory/adk/short_term_memory.py | 31 +- ksadk/memory/adk_tool.py | 6 +- ksadk/memory/langchain_tool.py | 11 +- ksadk/memory/tool.py | 47 +- ksadk/openclaw_gateway.py | 13 +- ksadk/runners/adk_runner.py | 5 +- ksadk/runners/base_runner.py | 10 +- ksadk/runners/factory.py | 25 + ksadk/runners/langgraph_runner.py | 160 +- ksadk/runners/remote_runner.py | 114 +- ksadk/sandbox/backends/e2b.py | 55 +- ksadk/sessions/continuity.py | 2 + ksadk/terminal_client.py | 9 +- ksadk/tools/gateway.py | 32 + ksadk/toolsets/__init__.py | 73 +- ksadk/tracing/setup.py | 7 +- scripts/ci-frontend-check.sh | 24 + scripts/debug_aicp_memory.py | 353 ++++ scripts/prepare_zread_source_snapshot.py | 64 + scripts/test_ks3_upload.py | 81 + scripts/validate_checkpoint_resume_e2e.py | 747 ++++++++ scripts/validate_hosted_long_task_e2e.py | 699 ++++++++ scripts/validate_long_task_pilot.py | 493 +++++ scripts/zread_subpath_proxy.py | 230 +++ tests/long_task/__init__.py | 1 + tests/long_task/test_checkpoint_resume.py | 158 ++ tests/long_task/test_runtime_cancel.py | 135 ++ tests/long_task/test_tool_idempotency.py | 118 ++ tests/skills/__init__.py | 0 tests/skills/test_adk_runner_skill_runtime.py | 371 ++++ tests/skills/test_package_store.py | 73 + tests/skills/test_runtime.py | 37 +- tests/skills/test_service_client_http.py | 4 +- tests/skills/test_web_artifacts_fixture.py | 24 + tests/snapshots/error_hint_snapshots.txt | 44 + tests/snapshots/resource_output_snapshots.txt | 73 + tests/snapshots/workflow_help_snapshots.txt | 144 ++ tests/test_a2a_cli.py | 125 ++ tests/test_a2a_integration.py | 242 +++ tests/test_agent.py | 68 + tests/test_agent_access.py | 104 ++ tests/test_agentengine_toolsets.py | 131 ++ tests/test_aicp_env.py | 83 + tests/test_attachment_pipeline.py | 106 ++ tests/test_builder_requirements_merge.py | 443 +++++ tests/test_cli_global_options.py | 156 ++ tests/test_cli_platform_refactor.py | 277 +++ tests/test_cli_root_entrypoint.py | 18 + tests/test_client_framework_passthrough.py | 445 +++++ tests/test_client_get_agent_name.py | 84 + tests/test_client_http_error_logging.py | 28 + tests/test_client_mcp_payloads.py | 221 +++ tests/test_client_permission_precheck.py | 423 +++++ tests/test_cmd_build_upload_urls.py | 103 ++ tests/test_cmd_completion.py | 117 ++ tests/test_cmd_create_from_agent.py | 507 ++++++ tests/test_cmd_dashboard_fallback.py | 667 +++++++ tests/test_cmd_deploy_no_cache.py | 521 ++++++ tests/test_cmd_files.py | 942 ++++++++++ tests/test_cmd_invoke.py | 1595 +++++++++++++++++ tests/test_cmd_launch_no_cache.py | 286 +++ tests/test_cmd_mcp_no_cache.py | 74 + tests/test_cmd_model.py | 170 ++ tests/test_code_builder_binary_compat.py | 31 + tests/test_code_builder_pip_indexes.py | 528 ++++++ .../test_code_builder_rebuild_fingerprint.py | 313 ++++ tests/test_code_builder_static_assets.py | 95 + tests/test_config_env_registry.py | 97 + tests/test_config_root_visibility.py | 51 + tests/test_container_registry_credentials.py | 136 ++ tests/test_deepagents_integration.py | 249 +++ tests/test_deploy_integration.py | 931 ++++++++++ tests/test_error_utils_hints.py | 143 ++ tests/test_help_snapshots.py | 144 ++ tests/test_hermes_container_builder.py | 101 ++ tests/test_hermes_terminal.py | 510 ++++++ tests/test_hermes_terminal_e2e.py | 157 ++ tests/test_json_contracts.py | 729 ++++++++ tests/test_ks3_uploader_urls.py | 261 +++ ...est_langchain_runner_session_continuity.py | 253 +++ tests/test_langfuse_exporter.py | 77 + tests/test_langfuse_runner_utils.py | 49 + tests/test_langgraph_runner_resume.py | 277 +++ tests/test_local_runtime_reexec.py | 199 ++ tests/test_long_task_pilot_validation.py | 240 +++ tests/test_mcp_runtime.py | 379 ++++ tests/test_openclaw_gateway.py | 127 ++ tests/test_orchestration_agents.py | 327 ++++ tests/test_platform_memory_tools.py | 142 ++ tests/test_postgres_session_service.py | 106 ++ tests/test_remote_runner.py | 405 +++++ tests/test_resource_output_snapshots.py | 231 +++ tests/test_runner.py | 670 +++++++ tests/test_runtime_common_memory_backend.py | 126 ++ tests/test_runtime_common_packaging.py | 55 +- tests/test_sandbox_backend.py | 217 +++ tests/test_server_app_fastapi_compat.py | 27 + tests/test_server_file_upload_parsing.py | 129 ++ .../test_server_workspace_preview_security.py | 50 + tests/test_session_continuity.py | 178 ++ tests/test_session_title.py | 69 + tests/test_sessions_service.py | 343 ++++ tests/test_setup_environment.py | 78 + tests/test_stm_config.py | 223 +++ tests/test_storage_defaults.py | 53 + tests/test_tool_gateway.py | 23 + tests/test_tracing_setup_otlp.py | 26 + tests/test_tui_app.py | 11 + tests/test_tui_clipboard.py | 17 + tests/test_ui_config_resolution.py | 103 ++ tests/test_validate_hosted_long_task_e2e.py | 39 + tests/test_workflow_common.py | 221 +++ tests/test_workflow_help_snapshots.py | 51 + tests/unit/knowledge_base/test_client_env.py | 57 + .../memory/test_adk_memory_comprehensive.py | 1166 ++++++++++++ 137 files changed, 24747 insertions(+), 622 deletions(-) create mode 100755 scripts/ci-frontend-check.sh create mode 100644 scripts/debug_aicp_memory.py create mode 100644 scripts/prepare_zread_source_snapshot.py create mode 100644 scripts/test_ks3_upload.py create mode 100644 scripts/validate_checkpoint_resume_e2e.py create mode 100644 scripts/validate_hosted_long_task_e2e.py create mode 100644 scripts/validate_long_task_pilot.py create mode 100644 scripts/zread_subpath_proxy.py create mode 100644 tests/long_task/__init__.py create mode 100644 tests/long_task/test_checkpoint_resume.py create mode 100644 tests/long_task/test_runtime_cancel.py create mode 100644 tests/long_task/test_tool_idempotency.py create mode 100644 tests/skills/__init__.py create mode 100644 tests/skills/test_adk_runner_skill_runtime.py create mode 100644 tests/skills/test_package_store.py create mode 100644 tests/skills/test_web_artifacts_fixture.py create mode 100644 tests/snapshots/error_hint_snapshots.txt create mode 100644 tests/snapshots/resource_output_snapshots.txt create mode 100644 tests/snapshots/workflow_help_snapshots.txt create mode 100644 tests/test_a2a_cli.py create mode 100644 tests/test_a2a_integration.py create mode 100644 tests/test_agent.py create mode 100644 tests/test_agent_access.py create mode 100644 tests/test_aicp_env.py create mode 100644 tests/test_attachment_pipeline.py create mode 100644 tests/test_builder_requirements_merge.py create mode 100644 tests/test_cli_global_options.py create mode 100644 tests/test_cli_platform_refactor.py create mode 100644 tests/test_cli_root_entrypoint.py create mode 100644 tests/test_client_framework_passthrough.py create mode 100644 tests/test_client_get_agent_name.py create mode 100644 tests/test_client_http_error_logging.py create mode 100644 tests/test_client_mcp_payloads.py create mode 100644 tests/test_client_permission_precheck.py create mode 100644 tests/test_cmd_build_upload_urls.py create mode 100644 tests/test_cmd_completion.py create mode 100644 tests/test_cmd_create_from_agent.py create mode 100644 tests/test_cmd_dashboard_fallback.py create mode 100644 tests/test_cmd_deploy_no_cache.py create mode 100644 tests/test_cmd_files.py create mode 100644 tests/test_cmd_invoke.py create mode 100644 tests/test_cmd_launch_no_cache.py create mode 100644 tests/test_cmd_mcp_no_cache.py create mode 100644 tests/test_cmd_model.py create mode 100644 tests/test_code_builder_binary_compat.py create mode 100644 tests/test_code_builder_pip_indexes.py create mode 100644 tests/test_code_builder_rebuild_fingerprint.py create mode 100644 tests/test_code_builder_static_assets.py create mode 100644 tests/test_config_env_registry.py create mode 100644 tests/test_config_root_visibility.py create mode 100644 tests/test_container_registry_credentials.py create mode 100644 tests/test_deepagents_integration.py create mode 100644 tests/test_deploy_integration.py create mode 100644 tests/test_error_utils_hints.py create mode 100644 tests/test_help_snapshots.py create mode 100644 tests/test_hermes_container_builder.py create mode 100644 tests/test_hermes_terminal.py create mode 100644 tests/test_hermes_terminal_e2e.py create mode 100644 tests/test_json_contracts.py create mode 100644 tests/test_ks3_uploader_urls.py create mode 100644 tests/test_langchain_runner_session_continuity.py create mode 100644 tests/test_langfuse_exporter.py create mode 100644 tests/test_langfuse_runner_utils.py create mode 100644 tests/test_local_runtime_reexec.py create mode 100644 tests/test_long_task_pilot_validation.py create mode 100644 tests/test_mcp_runtime.py create mode 100644 tests/test_openclaw_gateway.py create mode 100644 tests/test_orchestration_agents.py create mode 100644 tests/test_platform_memory_tools.py create mode 100644 tests/test_postgres_session_service.py create mode 100644 tests/test_remote_runner.py create mode 100644 tests/test_resource_output_snapshots.py create mode 100644 tests/test_runner.py create mode 100644 tests/test_runtime_common_memory_backend.py create mode 100644 tests/test_sandbox_backend.py create mode 100644 tests/test_server_app_fastapi_compat.py create mode 100644 tests/test_server_file_upload_parsing.py create mode 100644 tests/test_server_workspace_preview_security.py create mode 100644 tests/test_session_continuity.py create mode 100644 tests/test_session_title.py create mode 100644 tests/test_sessions_service.py create mode 100644 tests/test_setup_environment.py create mode 100644 tests/test_stm_config.py create mode 100644 tests/test_storage_defaults.py create mode 100644 tests/test_tui_app.py create mode 100644 tests/test_tui_clipboard.py create mode 100644 tests/test_ui_config_resolution.py create mode 100644 tests/test_validate_hosted_long_task_e2e.py create mode 100644 tests/test_workflow_common.py create mode 100644 tests/test_workflow_help_snapshots.py create mode 100644 tests/unit/knowledge_base/test_client_env.py create mode 100644 tests/unit/memory/test_adk_memory_comprehensive.py diff --git a/Makefile b/Makefile index b88ea1db..0181b0ae 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ # AgentEngine Makefile # 用于同步 KsADK Web static 和管理项目 -.PHONY: help install build-webui sync-static sync-ksadk-web-static webui clean clean-cache clean-dist clean-static clean-offline dev dev-webui dev-backend test test-webui publish publish-test open-source-audit open-source-audit-public-repo open-source-audit-dist open-source-audit-ksadk-python-export open-source-audit-ksadk-web open-source-smoke-install open-source-smoke-ksadk-web open-source-review open-source-review-bundle open-source-review-bundle-verify open-source-approval-check open-source-publication-plan open-source-publication-state public-status public-sync-check public-secret-audit public-audit public-test public-build-check public-preflight public-publish-check public-release-tag public-review public-docs-build public-docs-serve public-docs-audit +.PHONY: help install clean clean-cache clean-dist clean-static clean-offline dev test publish publish-test public-status public-init-worktree public-worktree-status public-sync-check public-secret-audit public-audit public-docs-build public-test public-build-check public-preflight public-publish-check public-release-tag public-review openclaw-build openclaw-push openclaw-size hermes-build hermes-push hermes-size docs-check-wiki docs-prepare-source docs-docker-build docs-docker-push docs-helm-lint docs-helm-template docs-deploy docs-deploy-all docs-status docs-logs sync-ksadk-web-static sync-hosted-ui build-frontend build-webui sync-static webui build-wheel build-all clean-frontend # 默认目标 help: @@ -9,17 +9,14 @@ help: @echo " \033[1;36m金山云 AgentEngine\033[0m 开发工具" @echo "" @echo " \033[1;32m开发命令:\033[0m" - @echo " make install 安装 Python 开发依赖" - @echo " make dev 启动本地 SDK Web 服务" + @echo " make install 安装 Python 依赖" + @echo " make dev 启动本地后端和已打包 Web UI" @echo " make test 运行测试" @echo "" - @echo " \033[1;32mWeb UI 静态产物:\033[0m" + @echo " \033[1;32mWeb UI 构建:\033[0m" @echo " make sync-ksadk-web-static KSADK_WEB_VERSION=latest" @echo " 从 @kingsoftcloud/ksadk-web npm 包同步 static" - @echo " make build-webui 从 npm 包同步 static(兼容入口)" - @echo " make sync-static 从 npm 包同步 static(兼容入口)" - @echo " make webui 从 npm 包同步已打包的 Web UI 静态产物" - @echo " 可编辑 Web UI 源码位于 https://github.com/kingsoftcloud/ksadk-web" + @echo " make build-frontend 同步 ksadk-web static" @echo "" @echo " \033[1;32m版本管理:\033[0m" @echo " make version 显示当前版本" @@ -32,23 +29,12 @@ help: @echo " make build 构建 Python 包" @echo " make release V=x.x.x 指定版本构建" @echo " make publish 发布到 PyPI" - @echo " make open-source-audit 运行开源公开产物审计" - @echo " make open-source-audit-dist 审计 dist/ 中的 sdist/wheel 文件清单" - @echo " make open-source-audit-ksadk-python-export 生成并审计 ksadk-python 清洁导出候选仓" - @echo " make open-source-audit-ksadk-web 生成并审计 KSADK Web 候选仓" - @echo " make open-source-smoke-install 在干净 venv 安装 wheel 并检查 CLI" - @echo " make open-source-smoke-ksadk-web 在独立候选仓中测试并构建 KSADK Web" - @echo " make open-source-review 运行开源候选本地审核验证" - @echo " make open-source-review-bundle 生成本地维护者审核包" - @echo " make open-source-approval-check 校验公开发布审批记录是否完整" - @echo " make open-source-publication-plan 生成审批后的 GitHub 导入命令计划" - @echo " make open-source-publication-state 只读检查 GitHub/Pages/PyPI 外部发布状态" - @echo " make open-source-review-bundle-verify 校验本地开源审核包完整性" @echo "" @echo " \033[1;32m公开发布门禁:\033[0m" @echo " make public-status 查看公开发布相关状态" + @echo " make public-init-worktree 初始化/校验 .worktrees/public-main" @echo " make public-preflight GitHub/PyPI/Release 前必须通过的本地门禁" - @echo " make public-release-tag V=x.y.z 在 GitHub main 对齐后创建公开 release 留痕 tag" + @echo " make public-release-tag V=x.y.z 创建公开 release 留痕 tag" @echo " make public-review 公开候选审核入口" @echo " make public-publish-check 发布状态核对" @echo "" @@ -60,9 +46,14 @@ help: @echo " make offline-windows Windows x64 离线包" @echo " make offline-all 打包所有平台" @echo "" - @echo " \033[1;32m公开文档站:\033[0m" - @echo " make public-docs-build 构建 GitHub Pages 候选文档站" - @echo " make public-docs-audit 审计 GitHub Pages 候选文件清单" + @echo " \033[1;32mAgentEngine 镜像:\033[0m" + @echo " Hermes / OpenClaw / Skill Runtime 镜像已迁移到内部 agentengine-images 仓库" + @echo " 可设置 AGENTENGINE_IMAGES_DIR=../agentengine-images 后继续使用兼容入口" + @echo "" + @echo " \033[1;32mzread 文档站:\033[0m" + @echo " make docs-deploy-all 构建原生 zread 文档镜像 + 推送 + 部署到预发" + @echo " make docs-status 查看预发文档站状态" + @echo " make docs-deploy-all ENV=online DOCS_VERSION=x # 部署线上" @echo "" @echo " \033[1;32m清理:\033[0m" @echo " make clean 清理构建产物和本地测试缓存" @@ -80,40 +71,11 @@ install-python: @echo "📦 安装 Python 依赖..." pip install -e ".[dev]" -install-webui: - @echo "ℹ️ Web UI 源码位于独立仓库: https://github.com/kingsoftcloud/ksadk-web" - @echo " ksadk-python 公开仓只包含已打包静态产物。" - # ============================================================ # Web UI static 同步 # ============================================================ STATIC_DIR = ksadk/server/static -KSADK_WEB_VERSION ?= latest -KSADK_WEB_PACKAGE ?= @kingsoftcloud/ksadk-web -KSADK_WEB_TARBALL_NAME := kingsoftcloud-ksadk-web-$(patsubst v%,%,$(KSADK_WEB_VERSION)).tgz -KSADK_WEB_RELEASE_URL ?= -KSADK_WEB_CACHE_DIR ?= .cache/ksadk-web -OPEN_SOURCE_SMOKE_VENV ?= /tmp/ksadk-open-source-smoke -OPEN_SOURCE_SMOKE_WHEEL ?= dist/ksadk-$(VERSION)-py3-none-any.whl - -sync-ksadk-web-static: - @echo "Sync KsADK Web static assets from $(KSADK_WEB_PACKAGE)@$(KSADK_WEB_VERSION)" - @rm -rf "$(KSADK_WEB_CACHE_DIR)/package" - @mkdir -p "$(KSADK_WEB_CACHE_DIR)" "$(STATIC_DIR)" - @if [ -n "$(KSADK_WEB_RELEASE_URL)" ]; then \ - echo "Using explicit KSADK_WEB_RELEASE_URL=$(KSADK_WEB_RELEASE_URL)"; \ - curl -fL --retry 3 --retry-delay 2 --retry-all-errors "$(KSADK_WEB_RELEASE_URL)" -o "$(KSADK_WEB_CACHE_DIR)/$(KSADK_WEB_TARBALL_NAME)"; \ - echo "$(KSADK_WEB_TARBALL_NAME)" > "$(KSADK_WEB_CACHE_DIR)/.tarball-name"; \ - else \ - npm pack "$(KSADK_WEB_PACKAGE)@$(patsubst v%,%,$(KSADK_WEB_VERSION))" --pack-destination "$(KSADK_WEB_CACHE_DIR)" > "$(KSADK_WEB_CACHE_DIR)/.tarball-name"; \ - fi - tar -xzf "$(KSADK_WEB_CACHE_DIR)/$$(cat "$(KSADK_WEB_CACHE_DIR)/.tarball-name")" -C "$(KSADK_WEB_CACHE_DIR)" - @test -d "$(KSADK_WEB_CACHE_DIR)/package/dist-ksadk" || (echo "ERROR: dist-ksadk missing in $$(cat "$(KSADK_WEB_CACHE_DIR)/.tarball-name")" && exit 1) - @rm -rf "$(STATIC_DIR)" - @mkdir -p "$(STATIC_DIR)" - cp -R "$(KSADK_WEB_CACHE_DIR)/package/dist-ksadk/." "$(STATIC_DIR)/" - @echo "Synced KsADK Web $(KSADK_WEB_VERSION) static assets into $(STATIC_DIR)" build-webui sync-static webui: sync-ksadk-web-static @echo "Deprecated target: Web UI is sourced from $(KSADK_WEB_PACKAGE), not local source." @@ -123,17 +85,12 @@ build-webui sync-static webui: sync-ksadk-web-static # ============================================================ dev: - @echo "🚀 启动本地 SDK Web 服务..." - @echo " URL: http://localhost:8000" + @echo "🚀 启动开发服务器..." + @echo " 后端: http://localhost:8000" @echo "" @echo "使用 Ctrl+C 停止服务" python -m ksadk.cli web . -dev-webui: - @echo "ℹ️ Web UI 开发服务器请在独立仓库运行:" - @echo " git clone https://github.com/kingsoftcloud/ksadk-web" - @echo " cd ksadk-web && npm ci && npm run dev" - dev-backend: @echo "🔧 启动后端开发服务器..." python -m ksadk.cli web . @@ -146,231 +103,6 @@ test: @echo "🧪 运行 Python 测试..." pytest tests/ -v -open-source-audit: open-source-audit-public-repo - -open-source-audit-public-repo: - @echo "🔎 生成并审计 ksadk-python 清洁导出候选仓..." - @rm -rf /tmp/ksadk-python-export-candidate - @python3 scripts/prepare_ksadk_python_export.py --output-dir /tmp/ksadk-python-export-candidate --json > /tmp/ksadk-python-export-candidate.json - @python3 scripts/open_source_audit.py --target public-repo --root /tmp/ksadk-python-export-candidate - -open-source-audit-dist: - @echo "🔎 审计 sdist/wheel 发布产物..." - @python3 scripts/audit_release_artifacts.py dist - -open-source-audit-ksadk-python-export: - @echo "🔎 生成并审计 ksadk-python 清洁导出候选仓..." - @rm -rf /tmp/ksadk-python-export-candidate - @python3 scripts/prepare_ksadk_python_export.py --output-dir /tmp/ksadk-python-export-candidate --json > /tmp/ksadk-python-export-candidate.json - @python3 scripts/open_source_audit.py --target public-repo --root /tmp/ksadk-python-export-candidate - -open-source-audit-ksadk-web: - @echo "🔎 生成并审计 ksadk-web 候选仓..." - @rm -rf /tmp/ksadk-web-export-candidate - @python3 scripts/prepare_ksadk_web_export.py --output-dir /tmp/ksadk-web-export-candidate --json > /tmp/ksadk-web-export-candidate.json - @python3 scripts/open_source_audit.py --target ksadk-web-candidate --root /tmp/ksadk-web-export-candidate - -open-source-smoke-install: - @echo "🧪 在干净 venv 中安装 wheel 并检查 CLI..." - @if [ ! -f "$(OPEN_SOURCE_SMOKE_WHEEL)" ]; then \ - echo "❌ 找不到 $(OPEN_SOURCE_SMOKE_WHEEL),请先运行 uv build"; \ - exit 1; \ - fi - @rm -rf "$(OPEN_SOURCE_SMOKE_VENV)" - @python3 -m venv "$(OPEN_SOURCE_SMOKE_VENV)" - @"$(OPEN_SOURCE_SMOKE_VENV)/bin/python" -m pip install --upgrade pip >/tmp/ksadk-open-source-smoke-pip.log - @"$(OPEN_SOURCE_SMOKE_VENV)/bin/python" -m pip install "$(OPEN_SOURCE_SMOKE_WHEEL)" >/tmp/ksadk-open-source-smoke-install.log - @"$(OPEN_SOURCE_SMOKE_VENV)/bin/agentengine" --help >/tmp/ksadk-open-source-smoke-agentengine-help.txt - @"$(OPEN_SOURCE_SMOKE_VENV)/bin/agentengine" web --help >/tmp/ksadk-open-source-smoke-agentengine-web-help.txt - @echo "✅ wheel 安装 smoke 通过:agentengine --help 与 agentengine web --help 均可用" - -open-source-smoke-ksadk-web: - @echo "🧪 在独立候选仓中测试并构建 KSADK Web..." - @rm -rf /tmp/ksadk-web-export-candidate - @python3 scripts/prepare_ksadk_web_export.py --output-dir /tmp/ksadk-web-export-candidate --json > /tmp/ksadk-web-export-candidate.json - @cd /tmp/ksadk-web-export-candidate && npm ci - @cd /tmp/ksadk-web-export-candidate && npm test - @cd /tmp/ksadk-web-export-candidate && npm run build:ksadk - @cd /tmp/ksadk-web-export-candidate && npm run build:hosted - @cd /tmp/ksadk-web-export-candidate && npm audit --audit-level=moderate - @echo "✅ KSADK Web 独立候选仓测试、双构建与 npm audit 通过" - -open-source-review: public-preflight open-source-audit-ksadk-python-export open-source-audit-ksadk-web public-docs-audit open-source-audit-dist open-source-smoke-install - @echo "✅ 开源候选本地审核验证完成" - -open-source-review-bundle: - @echo "📦 生成本地开源审核包..." - @python3 scripts/prepare_open_source_review_bundle.py - @$(MAKE) open-source-review-bundle-verify - -open-source-review-bundle-verify: - @echo "🔎 校验本地开源审核包..." - @python3 scripts/verify_open_source_review_bundle.py - -open-source-approval-check: - @echo "🔐 校验公开发布审批记录..." - @python3 scripts/check_approval_record.py $(if $(APPROVAL_RECORD),--approval-record "$(APPROVAL_RECORD)",) - -open-source-publication-plan: - @echo "🧭 生成审批后的 GitHub 导入命令计划..." - @python3 scripts/plan_github_publication.py --output /tmp/ksadk-open-source-review-bundle/github-publication-plan.md - -open-source-publication-state: - @echo "🔎 只读检查公开发布外部状态..." - @python3 scripts/check_publication_state.py --phase "$(PUBLIC_PUBLISH_PHASE)" --version "$(VERSION)" - -public-docs-build: - @echo "📚 构建 GitHub Pages 候选文档站..." - @uv run --extra dev python -m mkdocs build --strict - -public-docs-serve: - @echo "🌐 启动公开文档站预览..." - @uv run --extra dev python -m mkdocs serve - -public-docs-audit: public-docs-build - @echo "🔎 审计 GitHub Pages 候选文档站..." - @find site -type f | sed 's|^site/||' | python3 scripts/open_source_audit.py --target github-pages --file-list - - -PUBLIC_BRANCH ?= main -PUBLIC_REPO ?= https://github.com/kingsoftcloud/ksadk-python -PUBLIC_DOCS_URL ?= https://kingsoftcloud.github.io/ksadk-python/ -PUBLIC_PYPI_PROJECT ?= ksadk -PUBLIC_ALIAS_PYPI_PROJECT ?= agentengine-sdk-python -PUBLIC_RELEASE_TAG ?= v$(V) -PUBLIC_PUBLISH_PHASE ?= pre-publish - -public-status: - @echo "==> public candidate" - @git status --short --branch - @echo "" - @echo "==> remotes" - @git remote -v - @echo "" - @echo "==> configured public targets" - @echo "PUBLIC_BRANCH=$(PUBLIC_BRANCH)" - @echo "PUBLIC_REPO=$(PUBLIC_REPO)" - @echo "PUBLIC_DOCS_URL=$(PUBLIC_DOCS_URL)" - @echo "PUBLIC_PYPI_PROJECT=$(PUBLIC_PYPI_PROJECT)" - @echo "PUBLIC_ALIAS_PYPI_PROJECT=$(PUBLIC_ALIAS_PYPI_PROJECT)" - -public-sync-check: - @echo "==> public candidate branch policy" - @branch=$$(git branch --show-current); \ - if [ -z "$$branch" ] && [ "$$GITHUB_ACTIONS" = "true" ]; then \ - case "$$GITHUB_REF" in \ - refs/tags/v*|refs/heads/$(PUBLIC_BRANCH)) ;; \ - *) \ - echo "❌ GitHub Actions public gate must run from refs/tags/v* or refs/heads/$(PUBLIC_BRANCH): $$GITHUB_REF"; \ - exit 1; \ - ;; \ - esac; \ - else \ - case "$$branch" in \ - $(PUBLIC_BRANCH)|release/public-*|review/public-*|open-source/*) ;; \ - *) \ - echo "❌ 当前分支不是公开 main 或公开候选分支: $$branch"; \ - echo " 公开候选应在 release/public-x.y.z、open-source/* 或等价审核分支运行门禁。"; \ - exit 1; \ - ;; \ - esac; \ - fi - @if [ -f ".pypirc" ]; then \ - echo "❌ 仓库根目录存在 .pypirc,必须删除后再进入公开流程"; \ - exit 1; \ - fi - @echo "✅ public branch policy passed" - -public-secret-audit: public-sync-check - @echo "==> secret and sensitive-file audit" - @if git ls-files | grep -E '(^|/)(\.pypirc|kubeconfig|.*\.kubeconfig|id_rsa|id_ed25519)$$'; then \ - echo "❌ 发现禁止跟踪的敏感文件"; \ - exit 1; \ - fi - @pattern='pypi-[A-Za-z0-9_-]{20,}|AKIA[0-9A-Z]{16}|BEGIN (RSA|OPENSSH|EC|DSA) PRIVATE KEY|SecretAccessKey[[:space:]]*[:=][[:space:]]*[^<[:space:]]+'; \ - if command -v rg >/dev/null 2>&1; then \ - rg -n --hidden -S --glob '!.git/**' --glob '!node_modules/**' --glob '!dist/**' --glob '!build/**' --glob '!*.egg-info/**' "$$pattern" .; \ - else \ - grep -RInE --exclude-dir=.git --exclude-dir=node_modules --exclude-dir=dist --exclude-dir=build --exclude-dir='*.egg-info' "$$pattern" .; \ - fi; \ - status=$$?; \ - if [ $$status -eq 0 ]; then \ - echo "❌ secret pattern audit failed"; \ - exit 1; \ - elif [ $$status -gt 1 ]; then \ - echo "❌ secret pattern audit command failed"; \ - exit $$status; \ - fi - @echo "✅ secret audit passed" - -public-audit: public-secret-audit - @echo "==> public source audit" - @blocked=$$(git ls-files | grep -E '^(\.pypirc|docs/internal/|\.zread/(wiki|site)/)' || true); \ - if [ -n "$$blocked" ]; then \ - echo "❌ blocked tracked paths:"; \ - echo "$$blocked"; \ - exit 1; \ - fi - @$(MAKE) open-source-audit-public-repo - @echo "✅ public source audit passed" - -public-test: - @echo "==> public tests" - @uv run --extra dev pytest tests/test_open_source_audit.py tests/test_public_positioning_docs.py tests/test_prepare_ksadk_python_export.py tests/test_prepare_ksadk_web_export.py tests/test_runtime_common_packaging.py tests/test_tracing_setup_otlp.py tests/test_check_publication_state.py tests/test_check_approval_record.py tests/test_public_release_gates.py tests/test_markdown_repair.py tests/test_conversation_runtime.py tests/test_server_session_app.py -q - -public-build-check: clean-dist sync-ksadk-web-static - @echo "==> build and twine check" - @uv build - @uv run --extra dev pytest tests/test_runtime_common_packaging.py::test_built_wheel_excludes_web_ui_node_modules -q - @uv run --extra dev python -m twine check dist/* - -public-preflight: public-audit public-build-check public-test public-docs-build - @git diff --check - @$(MAKE) open-source-audit-dist - @echo "✅ public preflight passed" - -public-publish-check: - @echo "==> publication state check" - @if [ -f "scripts/check_publication_state.py" ]; then \ - uv run python scripts/check_publication_state.py --phase "$(PUBLIC_PUBLISH_PHASE)" --version "$(VERSION)"; \ - else \ - echo "⚠️ scripts/check_publication_state.py 不存在,执行基础 HTTP 检查"; \ - python3 -c 'import json, urllib.request; targets={"repo":"$(PUBLIC_REPO)","docs":"$(PUBLIC_DOCS_URL)","pypi":"https://pypi.org/pypi/$(PUBLIC_PYPI_PROJECT)/json","alias_pypi":"https://pypi.org/pypi/$(PUBLIC_ALIAS_PYPI_PROJECT)/json"}; [print("%s: HTTP %s%s" % (name, resp.status, ("\n version=%s" % json.load(resp)["info"].get("version")) if name.endswith("pypi") else "")) for name, url in targets.items() for resp in [urllib.request.urlopen(url, timeout=20)]]'; \ - fi - -public-release-tag: open-source-approval-check public-preflight public-publish-check -ifndef V - $(error ❌ 请指定版本号,例如: make public-release-tag V=$(VERSION)) -endif - @branch=$$(git branch --show-current); \ - if [ "$$branch" != "$(PUBLIC_BRANCH)" ]; then \ - echo "❌ public release tag 必须在公开 $(PUBLIC_BRANCH) 分支创建,当前分支是 $$branch"; \ - echo " 请先完成维护者 review,并将已审核候选推送到 GitHub $(PUBLIC_BRANCH)。"; \ - exit 1; \ - fi - @if ! git rev-parse --verify "github/$(PUBLIC_BRANCH)" >/dev/null 2>&1; then \ - echo "❌ 找不到 github/$(PUBLIC_BRANCH),请先 git fetch github $(PUBLIC_BRANCH)"; \ - exit 1; \ - fi - @if [ "$$(git rev-parse HEAD)" != "$$(git rev-parse github/$(PUBLIC_BRANCH))" ]; then \ - echo "❌ 当前 HEAD 未与 github/$(PUBLIC_BRANCH) 对齐,不能创建公开 release tag"; \ - exit 1; \ - fi - @echo "==> creating public release tag: $(PUBLIC_RELEASE_TAG)" - @if git rev-parse "$(PUBLIC_RELEASE_TAG)" >/dev/null 2>&1; then \ - echo "❌ tag already exists: $(PUBLIC_RELEASE_TAG)"; \ - exit 1; \ - fi - @git tag -a "$(PUBLIC_RELEASE_TAG)" -m "Public release $(PUBLIC_RELEASE_TAG)" - @echo "✅ tag created: $(PUBLIC_RELEASE_TAG)" - @echo " push after approval: git push github $(PUBLIC_RELEASE_TAG)" - -public-review: public-status public-preflight - @echo "✅ public review gate passed" - -test-webui: - @echo "🧪 运行 Web UI 测试..." - cd $(WEBUI_DIR) && npm test - # ============================================================ # 构建和发布 # ============================================================ @@ -472,27 +204,24 @@ endif @$(MAKE) build @echo "🎉 v$(V) 发布包已准备就绪" -# 发布配置文件仅允许放在用户主目录,避免 PyPI/TestPyPI token 进入仓库。 -PYPIRC := ~/.pypirc +# 发布配置文件只允许使用用户目录凭证。仓库根目录不得存放 .pypirc。 +PYPIRC := $(HOME)/.pypirc DIST_DIR := dist clean-dist: @echo "🧹 清理 dist/build 临时产物..." @rm -rf $(DIST_DIR)/* build/ *.egg-info/ -publish: open-source-approval-check public-preflight public-publish-check clean-dist build +publish: clean-dist build @echo "🚀 发布 v$(VERSION) 到 PyPI..." @if [ -f ".pypirc" ]; then \ - echo "❌ 错误: 项目根目录不允许存在 .pypirc,避免 PyPI token 进入仓库"; \ - echo " 请移到 ~/.pypirc 或使用环境变量 TWINE_USERNAME/TWINE_PASSWORD"; \ + echo "❌ 错误: 仓库根目录存在 .pypirc,拒绝发布"; \ + echo " 请删除仓库内 .pypirc,只使用 $(PYPIRC) 或 CI Secret。"; \ exit 1; \ fi - @if [ ! -f ~/.pypirc ] && [ -z "$$TWINE_PASSWORD" ]; then \ - echo "❌ 错误: 找不到 ~/.pypirc,也未设置 TWINE_PASSWORD"; \ - echo " 请在用户主目录创建 ~/.pypirc 文件:"; \ - echo " [pypi]"; \ - echo " username = __token__"; \ - echo " password = "; \ + @if [ ! -f "$(PYPIRC)" ]; then \ + echo "❌ 错误: 找不到 $(PYPIRC)"; \ + echo " PyPI 凭证只能放在用户目录或 CI Secret,不能放进仓库。"; \ exit 1; \ fi @FILES=$$(ls $(DIST_DIR)/ksadk-$(VERSION)-*.whl 2>/dev/null || true); \ @@ -504,21 +233,17 @@ publish: open-source-approval-check public-preflight public-publish-check clean- fi; \ echo "📦 将上传文件:"; \ echo "$$FILES"; \ - if [ -n "$$TWINE_PASSWORD" ]; then \ - python -m twine upload $$FILES; \ - else \ - python -m twine upload --config-file $(PYPIRC) $$FILES; \ - fi + python -m twine upload --config-file $(PYPIRC) $$FILES -publish-test: open-source-approval-check public-preflight public-publish-check clean-dist build +publish-test: clean-dist build @echo "🧪 发布 v$(VERSION) 到 TestPyPI..." @if [ -f ".pypirc" ]; then \ - echo "❌ 错误: 项目根目录不允许存在 .pypirc,避免 TestPyPI token 进入仓库"; \ - echo " 请移到 ~/.pypirc 或使用环境变量 TWINE_USERNAME/TWINE_PASSWORD"; \ + echo "❌ 错误: 仓库根目录存在 .pypirc,拒绝发布"; \ + echo " 请删除仓库内 .pypirc,只使用 $(PYPIRC) 或 CI Secret。"; \ exit 1; \ fi - @if [ ! -f ~/.pypirc ] && [ -z "$$TWINE_PASSWORD" ]; then \ - echo "❌ 错误: 找不到 ~/.pypirc,也未设置 TWINE_PASSWORD"; \ + @if [ ! -f "$(PYPIRC)" ]; then \ + echo "❌ 错误: 找不到 $(PYPIRC)"; \ exit 1; \ fi @FILES=$$(ls $(DIST_DIR)/ksadk-$(VERSION)-*.whl 2>/dev/null || true); \ @@ -530,12 +255,146 @@ publish-test: open-source-approval-check public-preflight public-publish-check c fi; \ echo "📦 将上传文件:"; \ echo "$$FILES"; \ - if [ -n "$$TWINE_PASSWORD" ]; then \ - python -m twine upload --repository testpypi $$FILES; \ + python -m twine upload --config-file $(PYPIRC) --repository testpypi $$FILES + +# ============================================================ +# 公开发布门禁 +# ============================================================ + +PUBLIC_WORKTREE ?= .worktrees/public-main +PUBLIC_BRANCH ?= main +PUBLIC_REPO ?= https://github.com/kingsoftcloud/ksadk-python +PUBLIC_DOCS_URL ?= https://kingsoftcloud.github.io/ksadk-python/ +PUBLIC_PYPI_PROJECT ?= ksadk +PUBLIC_ALIAS_PYPI_PROJECT ?= agentengine-sdk-python +PUBLIC_RELEASE_TAG ?= v$(V) + +public-status: + @echo "==> internal worktree" + @git status --short --branch + @echo "" + @echo "==> remotes" + @git remote -v + @echo "" + @echo "==> configured public targets" + @echo "PUBLIC_WORKTREE=$(PUBLIC_WORKTREE)" + @echo "PUBLIC_BRANCH=$(PUBLIC_BRANCH)" + @echo "PUBLIC_REPO=$(PUBLIC_REPO)" + @echo "PUBLIC_DOCS_URL=$(PUBLIC_DOCS_URL)" + @echo "PUBLIC_PYPI_PROJECT=$(PUBLIC_PYPI_PROJECT)" + @echo "PUBLIC_ALIAS_PYPI_PROJECT=$(PUBLIC_ALIAS_PYPI_PROJECT)" + @echo "" + @echo "==> worktrees" + @git worktree list + +public-init-worktree: + @if ! git remote get-url github >/dev/null 2>&1; then \ + echo "==> adding github remote: $(PUBLIC_REPO)"; \ + git remote add github $(PUBLIC_REPO); \ + fi + @git fetch github $(PUBLIC_BRANCH) + @if [ -d "$(PUBLIC_WORKTREE)" ]; then \ + echo "==> public worktree exists: $(PUBLIC_WORKTREE)"; \ + git -C "$(PUBLIC_WORKTREE)" status --short --branch; \ else \ - python -m twine upload --config-file $(PYPIRC) --repository testpypi $$FILES; \ + echo "==> creating public worktree: $(PUBLIC_WORKTREE)"; \ + if git show-ref --verify --quiet "refs/heads/$(PUBLIC_BRANCH)"; then \ + git worktree add "$(PUBLIC_WORKTREE)" "$(PUBLIC_BRANCH)"; \ + else \ + git worktree add -b "$(PUBLIC_BRANCH)" "$(PUBLIC_WORKTREE)" github/$(PUBLIC_BRANCH); \ + fi; \ fi +public-worktree-status: + @if [ ! -d "$(PUBLIC_WORKTREE)/.git" ] && [ ! -f "$(PUBLIC_WORKTREE)/.git" ]; then \ + echo "❌ 公开工作树不存在: $(PUBLIC_WORKTREE)"; \ + echo " 建议创建: git worktree add $(PUBLIC_WORKTREE) $(PUBLIC_BRANCH)"; \ + exit 1; \ + fi + @git -C "$(PUBLIC_WORKTREE)" status --short --branch + +public-sync-check: + @echo "==> public sync policy" + @branch=$$(git branch --show-current); \ + if [ "$$branch" != "master" ]; then \ + echo "❌ 当前分支不是 master: $$branch"; \ + echo " 公开候选应从内部 master 的已审核变更生成。"; \ + exit 1; \ + fi + @if [ -f ".pypirc" ]; then \ + echo "❌ 仓库根目录存在 .pypirc,必须删除后再进入公开流程"; \ + exit 1; \ + fi + @echo "✅ sync policy passed" + +public-secret-audit: + @echo "==> secret and sensitive-file audit" + @if git ls-files | grep -E '(^|/)(\.pypirc|kubeconfig|.*\.kubeconfig|id_rsa|id_ed25519)$$'; then \ + echo "❌ 发现禁止跟踪的敏感文件"; \ + exit 1; \ + fi + @if rg -n --hidden -S --glob '!.git/**' --glob '!node_modules/**' --glob '!dist/**' --glob '!build/**' --glob '!*.egg-info/**' 'pypi-[A-Za-z0-9_-]{20,}|AKIA[0-9A-Z]{16}|BEGIN (RSA|OPENSSH|EC|DSA) PRIVATE KEY|SecretAccessKey\s*[:=]\s*[^<\s]+' .; then \ + echo "❌ secret pattern audit failed"; \ + exit 1; \ + fi + @echo "✅ secret audit passed" + +public-audit: public-secret-audit + @echo "==> public source audit" + @blocked=$$(git ls-files | grep -E '^(\.pypirc$$|\.zread/(wiki|site)/)' || true); \ + if [ -n "$$blocked" ]; then \ + echo "❌ blocked tracked paths:"; \ + echo "$$blocked"; \ + exit 1; \ + fi + @echo "✅ public path audit passed" + +public-docs-build: + @echo "==> docs build" + @if [ -f "mkdocs.yml" ]; then \ + uv run mkdocs build --strict; \ + else \ + echo "⚠️ mkdocs.yml 不存在,跳过 docs build"; \ + fi + +public-test: + @echo "==> test" + @uv run pytest + +public-build-check: clean-dist sync-ksadk-web-static + @echo "==> build and twine check" + @uv build + @uv run pytest tests/test_runtime_common_packaging.py::test_built_wheel_excludes_web_ui_node_modules -q + @uv run --extra dev python -m twine check dist/* + +public-preflight: public-audit sync-ksadk-web-static public-test public-docs-build public-build-check + @echo "✅ public preflight passed" + +public-publish-check: + @echo "==> publication state check" + @if [ -f "scripts/check_publication_state.py" ]; then \ + uv run python scripts/check_publication_state.py --phase pre-publish; \ + else \ + echo "⚠️ scripts/check_publication_state.py 不存在,执行基础 HTTP 检查"; \ + python3 -c 'import json, urllib.request; targets={"repo":"$(PUBLIC_REPO)","docs":"$(PUBLIC_DOCS_URL)","pypi":"https://pypi.org/pypi/$(PUBLIC_PYPI_PROJECT)/json","alias_pypi":"https://pypi.org/pypi/$(PUBLIC_ALIAS_PYPI_PROJECT)/json"}; [print((lambda resp, name: f"{name}: HTTP {resp.status}" + (f"\n version={json.load(resp)[\"info\"].get(\"version\")}" if name.endswith("pypi") else ""))(urllib.request.urlopen(url, timeout=20), name)) for name, url in targets.items()]'; \ + fi + +public-release-tag: +ifndef V + $(error ❌ 请指定版本号,例如: make public-release-tag V=0.6.2) +endif + @echo "==> creating public release tag: $(PUBLIC_RELEASE_TAG)" + @if git rev-parse "$(PUBLIC_RELEASE_TAG)" >/dev/null 2>&1; then \ + echo "❌ tag already exists: $(PUBLIC_RELEASE_TAG)"; \ + exit 1; \ + fi + @git tag -a "$(PUBLIC_RELEASE_TAG)" -m "Public release $(PUBLIC_RELEASE_TAG)" + @echo "✅ tag created: $(PUBLIC_RELEASE_TAG)" + @echo " push after approval: git push github $(PUBLIC_RELEASE_TAG)" + +public-review: public-status public-preflight + @echo "✅ public review gate passed" + # ============================================================ # 离线打包 (多平台支持) # ============================================================ @@ -627,6 +486,183 @@ offline-current: build @echo "💡 离线安装方法:" @echo " pip install --no-index --find-links=$(OFFLINE_DIR)/current ksadk" +# ============================================================ +# AgentEngine 镜像构建兼容入口 +# ============================================================ + +AGENTENGINE_IMAGES_DIR ?= ../agentengine-images + +openclaw-build openclaw-push openclaw-size hermes-build hermes-push hermes-size: + @if [ ! -d "$(AGENTENGINE_IMAGES_DIR)" ]; then \ + echo "❌ AgentEngine 镜像资产已迁移到内部仓库 agentengine-images。"; \ + echo " 请先克隆仓库,或设置 AGENTENGINE_IMAGES_DIR=/path/to/agentengine-images"; \ + exit 1; \ + fi + @$(MAKE) -C "$(AGENTENGINE_IMAGES_DIR)" $@ + + +# ============================================================ +# zread 文档站发布 +# ============================================================ +# +# 依赖本地 .zread/wiki/current 指向的完整 wiki 版本。发布镜像会运行 +# zread browse 原生 UI,保留 zread 样式、前端交互和 Mermaid 渲染。 +# + +DOCS_PROJECT_NAME ?= ksadk-docs +DOCS_DOCKER_REGISTRY ?= hub.kce.ksyun.com +DOCS_DOCKER_NAMESPACE ?= bigdata-ai +DOCS_WIKI_VERSION ?= $(shell test -f .zread/wiki/current && sed 's|^versions/||' .zread/wiki/current || echo missing-wiki) +DOCS_VERSION ?= zread-$(DOCS_WIKI_VERSION) +ENV ?= pre +DOCS_FORCE_UPDATE ?= 0 +DOCS_FORCE_UPDATE_NONCE ?= $(shell date '+%Y%m%d%H%M%S') + +ifeq ($(ENV),online) + DOCS_KUBECONFIG_PATH := $(HOME)/.kube/agentengine-online + DOCS_VALUES_FILE := deploy/helm/ksadk-docs/values-online.yaml +else + DOCS_KUBECONFIG_PATH := $(HOME)/.kube/agentengine-pre + DOCS_VALUES_FILE := deploy/helm/ksadk-docs/values-pre.yaml +endif + +DOCS_IMAGE := $(DOCS_DOCKER_REGISTRY)/$(DOCS_DOCKER_NAMESPACE)/$(DOCS_PROJECT_NAME):$(DOCS_VERSION) +DOCS_NAMESPACE ?= agentengine +DOCS_HELM_RELEASE ?= ksadk-docs +DOCS_HELM_CHART := deploy/helm/ksadk-docs +DOCS_HELM_TIMEOUT ?= 600s +DOCS_BASE_PATH ?= /ksadk-docs +DOCS_BASE_IMAGE ?= hub.kce.ksyun.com/bigdata-ai/agentengine-server-base:v0.4.1 +DOCS_ZREAD_VERSION ?= 0.2.12 +DOCS_ZREAD_SHA256 ?= faf5ef7f2f8edc24d41b84fd838322882846f4bab10f1a9210de29cba2a53a10 +DOCS_HELM_SET_FLAGS := --set image.tag=$(DOCS_VERSION) --set docs.basePath=$(DOCS_BASE_PATH) + +ifeq ($(DOCS_FORCE_UPDATE),1) + DOCS_HELM_SET_FLAGS += --set-string podAnnotations.force-redeploy=$(DOCS_FORCE_UPDATE_NONCE) +endif + +docs-check-wiki: + @if [ ! -f ".zread/wiki/current" ]; then \ + echo "❌ 缺少 .zread/wiki/current,请先运行 zread generate -y --stdio"; \ + exit 1; \ + fi + @if [ ! -f ".zread/wiki/versions/$(DOCS_WIKI_VERSION)/wiki.json" ]; then \ + echo "❌ 缺少 .zread/wiki/versions/$(DOCS_WIKI_VERSION)/wiki.json"; \ + exit 1; \ + fi + @python3 -c 'import json; from pathlib import Path; version = Path(".zread/wiki/current").read_text().strip().removeprefix("versions/"); root = Path(".zread/wiki/versions", version); wiki = json.loads((root / "wiki.json").read_text()); pages = wiki.get("pages") or []; assert pages, "wiki.json 中没有页面,拒绝发布"; missing = [p.get("file") for p in pages if not (root / p.get("file", "")).exists()]; print(f"✅ zread wiki: {version}, pages={len(pages)}, missing={len(missing)}"); [print(f"❌ 缺失页面文件: {name}") for name in missing]; raise SystemExit(1 if missing else 0)' + @if [ -f ".zread/wiki/drafts/wiki.json" ]; then \ + echo "⚠️ 检测到 .zread/wiki/drafts/wiki.json,本次仍发布 current 完整版本: $(DOCS_WIKI_VERSION)"; \ + fi + +docs-prepare-source: docs-check-wiki + @python3 scripts/prepare_zread_source_snapshot.py + +docs-docker-build: docs-check-wiki docs-prepare-source + @echo "🐳 构建 KsADK 原生 zread 文档镜像: $(DOCS_IMAGE)" + @DOCKER_BUILDKIT=1 docker build --pull=false --platform linux/amd64 \ + -f Dockerfile.docs \ + --build-arg DOCS_BASE_IMAGE=$(DOCS_BASE_IMAGE) \ + --build-arg ZREAD_VERSION=$(DOCS_ZREAD_VERSION) \ + --build-arg ZREAD_SHA256=$(DOCS_ZREAD_SHA256) \ + -t $(DOCS_IMAGE) \ + . + +docs-docker-push: docs-docker-build + @echo "📤 推送 KsADK 文档镜像: $(DOCS_IMAGE)" + @docker push $(DOCS_IMAGE) + +docs-helm-lint: + @echo "==> helm lint $(DOCS_HELM_CHART)" + @helm lint $(DOCS_HELM_CHART) + +docs-helm-template: + @echo "==> helm template $(DOCS_HELM_RELEASE) ($(ENV))" + @helm template $(DOCS_HELM_RELEASE) $(DOCS_HELM_CHART) \ + --namespace $(DOCS_NAMESPACE) \ + --values $(DOCS_VALUES_FILE) \ + $(DOCS_HELM_SET_FLAGS) + +docs-deploy: docs-helm-lint + @echo "==> helm upgrade --install $(DOCS_HELM_RELEASE) ($(ENV))" + @echo " namespace=$(DOCS_NAMESPACE) image=$(DOCS_IMAGE) timeout=$(DOCS_HELM_TIMEOUT) force_update=$(DOCS_FORCE_UPDATE)" + @set -e; \ + if helm upgrade --install $(DOCS_HELM_RELEASE) $(DOCS_HELM_CHART) \ + --kubeconfig $(DOCS_KUBECONFIG_PATH) \ + --namespace $(DOCS_NAMESPACE) \ + --create-namespace \ + --values $(DOCS_VALUES_FILE) \ + $(DOCS_HELM_SET_FLAGS) \ + --wait \ + --timeout $(DOCS_HELM_TIMEOUT); then \ + echo "==> deployment ready"; \ + echo "==> url: http://$$(helm get values $(DOCS_HELM_RELEASE) --kubeconfig $(DOCS_KUBECONFIG_PATH) -n $(DOCS_NAMESPACE) -a -o json | python3 -c 'import json,sys; print(json.load(sys.stdin)["ingress"]["host"])')$(DOCS_BASE_PATH)/"; \ + else \ + status=$$?; \ + echo "==> deployment failed, collecting diagnostics..."; \ + kubectl --kubeconfig $(DOCS_KUBECONFIG_PATH) get deploy,pods,svc,ingress -n $(DOCS_NAMESPACE) -l app.kubernetes.io/name=$(DOCS_PROJECT_NAME) -o wide || true; \ + latest_pod=$$(kubectl --kubeconfig $(DOCS_KUBECONFIG_PATH) get pods -n $(DOCS_NAMESPACE) -l app.kubernetes.io/name=$(DOCS_PROJECT_NAME) --sort-by=.metadata.creationTimestamp -o name 2>/dev/null | tail -n 1 | cut -d/ -f2); \ + if [ -n "$$latest_pod" ]; then \ + echo "==> latest pod: $$latest_pod"; \ + kubectl --kubeconfig $(DOCS_KUBECONFIG_PATH) describe pod -n $(DOCS_NAMESPACE) "$$latest_pod" | sed -n '/Events:/,$$p' || true; \ + fi; \ + exit $$status; \ + fi + +docs-deploy-all: docs-docker-push docs-deploy + +docs-status: + @kubectl --kubeconfig $(DOCS_KUBECONFIG_PATH) get pods,svc,ingress -n $(DOCS_NAMESPACE) -l app.kubernetes.io/name=$(DOCS_PROJECT_NAME) + +docs-logs: + @kubectl --kubeconfig $(DOCS_KUBECONFIG_PATH) logs -f -n $(DOCS_NAMESPACE) deployment/$(DOCS_HELM_RELEASE) + + + +# ============================================================ +# KsADK Web static 同步 +# ============================================================ + +STATIC_DIR := ksadk/server/static +KSADK_WEB_VERSION ?= latest +KSADK_WEB_PACKAGE ?= @kingsoftcloud/ksadk-web +KSADK_WEB_TARBALL_NAME := kingsoftcloud-ksadk-web-$(patsubst v%,%,$(KSADK_WEB_VERSION)).tgz +KSADK_WEB_RELEASE_URL ?= +KSADK_WEB_CACHE_DIR ?= .cache/ksadk-web + +sync-ksadk-web-static: + @echo "Sync KsADK Web static assets from $(KSADK_WEB_PACKAGE)@$(KSADK_WEB_VERSION)" + @rm -rf "$(KSADK_WEB_CACHE_DIR)/package" + @mkdir -p "$(KSADK_WEB_CACHE_DIR)" "$(STATIC_DIR)" + @if [ -n "$(KSADK_WEB_RELEASE_URL)" ]; then \ + echo "Using explicit KSADK_WEB_RELEASE_URL=$(KSADK_WEB_RELEASE_URL)"; \ + curl -fL --retry 3 --retry-delay 2 --retry-all-errors "$(KSADK_WEB_RELEASE_URL)" -o "$(KSADK_WEB_CACHE_DIR)/$(KSADK_WEB_TARBALL_NAME)"; \ + echo "$(KSADK_WEB_TARBALL_NAME)" > "$(KSADK_WEB_CACHE_DIR)/.tarball-name"; \ + else \ + npm pack "$(KSADK_WEB_PACKAGE)@$(patsubst v%,%,$(KSADK_WEB_VERSION))" --pack-destination "$(KSADK_WEB_CACHE_DIR)" > "$(KSADK_WEB_CACHE_DIR)/.tarball-name"; \ + fi + tar -xzf "$(KSADK_WEB_CACHE_DIR)/$$(cat "$(KSADK_WEB_CACHE_DIR)/.tarball-name")" -C "$(KSADK_WEB_CACHE_DIR)" + @test -d "$(KSADK_WEB_CACHE_DIR)/package/dist-ksadk" || (echo "ERROR: dist-ksadk missing in $$(cat "$(KSADK_WEB_CACHE_DIR)/.tarball-name")" && exit 1) + @rm -rf "$(STATIC_DIR)" + @mkdir -p "$(STATIC_DIR)" + cp -R "$(KSADK_WEB_CACHE_DIR)/package/dist-ksadk/." "$(STATIC_DIR)/" + @echo "Synced KsADK Web $(KSADK_WEB_VERSION) static assets into $(STATIC_DIR)" + +sync-hosted-ui: sync-ksadk-web-static + @echo "sync-hosted-ui is deprecated; static assets now come from $(KSADK_WEB_PACKAGE)." + +build-frontend: sync-ksadk-web-static + @echo "Frontend static assets synced from $(KSADK_WEB_VERSION)" + +build-wheel: build-frontend + uv build + +build-all: build-wheel + @echo "Build complete. Wheel is in dist/" + +clean-frontend: + rm -rf $(STATIC_DIR) + # ============================================================ # 清理 # ============================================================ @@ -634,7 +670,6 @@ offline-current: build clean: @echo "🧹 清理构建产物和本地缓存..." rm -rf dist/ build/ *.egg-info/ .eggs/ - rm -rf $(WEBUI_DIST) rm -rf .pytest_cache/ .mypy_cache/ .ruff_cache/ .coverage coverage.xml htmlcov/ .tox/ .nox/ rm -rf $(OFFLINE_DIR)/ find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true diff --git a/ksadk/__init__.py b/ksadk/__init__.py index 86127bf4..2b2d6663 100644 --- a/ksadk/__init__.py +++ b/ksadk/__init__.py @@ -1,7 +1,8 @@ """ KsADK: Agent Runtime Platform for AI agents. -统一运行、调试、部署和可观测体验,支持 ADK、LangGraph、LangChain 和 DeepAgents。 +Build with ADK, LangGraph, LangChain or DeepAgents; run, debug, deploy and +observe through one runtime experience. """ from ksadk.version import VERSION diff --git a/ksadk/builders/code_builder.py b/ksadk/builders/code_builder.py index 103408c4..8b04fd98 100644 --- a/ksadk/builders/code_builder.py +++ b/ksadk/builders/code_builder.py @@ -677,7 +677,9 @@ def _walk_project_dir(self, root_dir: Path): yield file_path def _should_skip_root_path(self, path: Path) -> bool: - if path.name.startswith(".") and path.name != ".env": + if self._is_real_dotenv_file(path.name): + return True + if path.name.startswith(".") and path.name != ".env.example": return True if path.name in self.IGNORED_ROOT_NAMES: return True @@ -691,6 +693,8 @@ def _should_skip_dir_name(self, dir_name: str) -> bool: return dir_name in self.IGNORED_DIR_NAMES def _should_skip_project_file(self, file_path: Path) -> bool: + if self._is_real_dotenv_file(file_path.name): + return True if file_path.name in self.IGNORED_FILE_NAMES: return True if file_path.suffix == ".pyc": @@ -698,6 +702,13 @@ def _should_skip_project_file(self, file_path: Path) -> bool: if "__pycache__" in file_path.parts: return True return False + + @staticmethod + def _is_real_dotenv_file(file_name: str) -> bool: + return file_name == ".env" or ( + file_name.startswith(".env.") + and file_name not in {".env.example", ".env.sample", ".env.template"} + ) def _prepare_requirements(self, detection_result) -> Path: """准备 requirements.txt""" @@ -1757,7 +1768,8 @@ def _generate_entrypoint(self, detection_result) -> str: name="{detection_result.name}", entry_point="{detection_result.entry_point}", package_path=os.path.join(CODE_ROOT, "{package_name}"), - agent_variable="{detection_result.agent_variable}" + agent_variable="{detection_result.agent_variable}", + runner_class="{getattr(detection_result, 'runner_class', '')}" ) logger.info(f"框架: {{detection_result.name}}") diff --git a/ksadk/builders/container_builder.py b/ksadk/builders/container_builder.py index 59cfd934..e7f761f7 100644 --- a/ksadk/builders/container_builder.py +++ b/ksadk/builders/container_builder.py @@ -26,6 +26,78 @@ ) +def _registry_host(registry: str | None) -> str: + value = str(registry or "").strip() + for prefix in ("http://", "https://"): + if value.startswith(prefix): + value = value[len(prefix):] + break + return value.split("/", 1)[0].strip() + + +def _registry_kind(registry: str | None) -> str: + host = _registry_host(registry) + if host.endswith(".ksyunkcr.com"): + return "enterprise_kcr" + if host.endswith(".kce.ksyun.com"): + return "personal_kcr" + return "third_party" + + +def registry_kind_label(kind: str) -> str: + if kind == "personal_kcr": + return "个人版 KCR" + if kind == "enterprise_kcr": + return "企业版 KCR" + return "第三方镜像仓库" + + +def resolve_registry_credentials( + registry: str | None, + *, + environ: Optional[dict[str, str]] = None, +) -> tuple[str, str, str]: + """Resolve registry credentials. + + Returns (username, password, kind). Personal KCR keeps the historical + KSYUN_ACCOUNT_ID fallback; enterprise KCR and third-party registries require + explicit registry credentials. + """ + env = environ if environ is not None else os.environ + kind = _registry_kind(registry) + password = str(env.get("KCR_PASSWORD") or "") + username = str(env.get("KCR_USERNAME") or "") + if kind == "personal_kcr" and not username: + username = str(env.get("KSYUN_ACCOUNT_ID") or "") + return username, password, kind + + +def print_registry_credentials_help(registry: str | None, *, kind: str | None = None) -> None: + kind = kind or _registry_kind(registry) + click.echo("") + if kind == "personal_kcr": + click.secho("❌ 缺少个人版 KCR 镜像仓库凭证。", fg="red") + click.echo("个人版 KCR 可使用 KSYUN_ACCOUNT_ID 作为用户名兜底,并配置 KCR_PASSWORD。") + click.echo("") + click.echo("请设置:") + click.echo(" KSYUN_ACCOUNT_ID=<金山云账号ID>") + click.echo(" KCR_PASSWORD=<个人版 KCR 访问凭证密码或 Token>") + else: + click.secho("❌ 缺少企业版或第三方镜像仓库凭证。", fg="red") + click.echo("企业版或第三方镜像仓库必须配置 KCR_USERNAME 和 KCR_PASSWORD。") + click.echo("KSYUN_ACCOUNT_ID 只会作为个人版 KCR 的用户名兜底。") + click.echo("") + click.echo("请设置:") + click.echo(" KCR_USERNAME=<镜像仓库访问凭证用户名>") + click.echo(" KCR_PASSWORD=<镜像仓库访问凭证密码或 Token>") + click.echo("") + click.echo("可选设置默认构建仓库:") + click.echo(" KCR_REGISTRY=/") + if registry: + click.echo("") + click.echo(f"当前目标 registry: {_registry_host(registry)}") + + def ensure_docker_running() -> bool: """确保 Docker 正在运行""" if not shutil.which('docker'): @@ -91,12 +163,48 @@ def __init__(self, project_dir: Path, config: dict = None, self.no_cache = no_cache def _get_smart_kcr_endpoint(self, region: str) -> str: - """Return a public default registry namespace for container builds.""" - _ = region - return "ghcr.io/kingsoftcloud/agentengine" + """智能选择 KCR endpoint + + 使用企业版 KCR (hub.kce.ksyun.com/agentengine/)。 + 优先使用内网 VPC endpoint,如果内网不可达则使用公网。 + """ + from ksadk.configs.settings import check_endpoint_reachable + + # 企业版 KCR 地址 (带 agentengine 命名空间) + vpc_endpoint = "hub-vpc.kce.ksyun.com/agentengine" + public_endpoint = "hub.kce.ksyun.com/agentengine" + + click.echo(f"🔍 检测 KCR 内网连通性...") + + # 检测 VPC 内网是否可达 (端口 443 for HTTPS registry) + if check_endpoint_reachable("hub-vpc.kce.ksyun.com", port=443, timeout=2.0): + click.secho(f" ✅ 使用内网: {vpc_endpoint}", fg='green') + return vpc_endpoint + else: + click.echo(f" ℹ️ 使用公网: {public_endpoint}") + return public_endpoint def _optimize_kcr_endpoint(self, registry: str) -> str: - """Return the configured registry unchanged.""" + """优化 KCR endpoint + + 如果是金山云 KCR 公网地址,且内网可达,则替换为内网地址。 + """ + from ksadk.configs.settings import check_endpoint_reachable + + # 已经是内网地址 + if 'hub-vpc' in registry: + return registry + + # 匹配企业版 KCR: hub.kce.ksyun.com + if 'hub.kce.ksyun.com' in registry: + click.echo(f"🔍 检测 KCR 内网连通性...") + if check_endpoint_reachable("hub-vpc.kce.ksyun.com", port=443, timeout=2.0): + optimized = registry.replace("hub.kce.ksyun.com", "hub-vpc.kce.ksyun.com") + click.secho(f" ✅ 优化为内网: {optimized}", fg='green') + return optimized + else: + click.echo(f" ℹ️ 使用公网: {registry}") + return registry def _package(self, detection_result) -> 'PackageInfo': @@ -114,10 +222,14 @@ def _package(self, detection_result) -> 'PackageInfo': and (project_path / "entrypoint.sh").exists() ) - # 复制项目文件 + # 复制项目文件。真实 .env 只通过 deploy payload 注入,不进入镜像上下文。 for item in project_path.iterdir(): - # 排除隐藏文件(但保留 .env*) 和特定忽略目录 - if (item.name.startswith('.') and not item.name.startswith('.env')) or item.name in ('__pycache__', '.git', 'node_modules'): + if CodeBuilder._is_real_dotenv_file(item.name): + continue + if ( + (item.name.startswith('.') and item.name != '.env.example') + or item.name in ('__pycache__', '.git', 'node_modules') + ): continue dest = output_dir / item.name if item.is_dir(): @@ -155,6 +267,37 @@ def _ignore_ksadk_source(current_dir: str, names: list[str]): ksadk_dest, ignore=_ignore_ksadk_source, ) + + # 复制 ksadk_runtime_common (如果存在) + try: + import ksadk_runtime_common + runtime_common_src = Path(ksadk_runtime_common.__file__).parent + runtime_common_dest = output_dir / "ksadk_runtime_common" + if runtime_common_dest.exists(): + shutil.rmtree(runtime_common_dest) + shutil.copytree( + runtime_common_src, + runtime_common_dest, + ignore=shutil.ignore_patterns("__pycache__", "*.pyc"), + ) + except ImportError: + # 旧版本 ksadk 没有 ksadk_runtime_common,从源码目录复制 + # 这个分支处理从 master 迁移到 main 后,环境中安装的是旧版本的情况 + source_runtime_common = Path(ksadk_src).parent / "ksadk_runtime_common" + if source_runtime_common.exists(): + runtime_common_dest = output_dir / "ksadk_runtime_common" + if runtime_common_dest.exists(): + shutil.rmtree(runtime_common_dest) + shutil.copytree( + source_runtime_common, + runtime_common_dest, + ignore=shutil.ignore_patterns("__pycache__", "*.pyc"), + ) + else: + click.echo(click.style( + "⚠️ 警告: 未找到 ksadk_runtime_common,镜像可能无法正常运行", + fg="yellow" + )) dockerfile_path = output_dir / "Dockerfile" if not is_container_first_template: @@ -329,7 +472,8 @@ def _generate_entrypoint(self, detection_result, package_name: str) -> str: name="{detection_result.name}", entry_point="{detection_result.entry_point}", package_path="/app/{package_name}", - agent_variable="{detection_result.agent_variable}" + agent_variable="{detection_result.agent_variable}", + runner_class="{getattr(detection_result, 'runner_class', '')}" ) logger.info(f"框架: {{detection_result.name}}") @@ -388,7 +532,7 @@ def build(self) -> BuildResult: if not image_tag: image_tag = config.get('image', {}).get('tag', 'latest') - # Registry 优先级: 命令行 > .env KCR_REGISTRY > agentengine.yaml > 公开默认 registry + # Registry 优先级: 命令行 > .env KCR_REGISTRY > agentengine.yaml > 默认企业版 KCR import os image_registry = self.registry if not image_registry: @@ -396,8 +540,10 @@ def build(self) -> BuildResult: if not image_registry: image_registry = config.get('image', {}).get('registry', '') if not image_registry: + # 默认使用企业版 KCR,智能选择内网/公网 image_registry = self._get_smart_kcr_endpoint(os.getenv('KSYUN_REGION', 'cn-beijing-6')) else: + # 即使设置了 registry,如果是金山云公网地址,也尝试优化为内网 image_registry = self._optimize_kcr_endpoint(image_registry) full_image = f"{image_registry}/{image_name}:{image_tag}" @@ -501,20 +647,19 @@ def _auto_login_from_env(self, registry: str) -> bool: # 加载 .env load_dotenv() - # KCR_REGISTRY 默认使用公开占位 registry + # KCR_REGISTRY is a generic registry/namespace target used for KCR and third-party registries. kcr_registry = os.getenv('KCR_REGISTRY', '') if not kcr_registry: - kcr_registry = "ghcr.io/kingsoftcloud/agentengine" + kcr_registry = "hub.kce.ksyun.com/agentengine" - # KCR_USERNAME 默认使用 KSYUN_ACCOUNT_ID - kcr_username = os.getenv('KCR_USERNAME', '') or os.getenv('KSYUN_ACCOUNT_ID', '') - kcr_password = os.getenv('KCR_PASSWORD', '') + kcr_username, kcr_password, registry_kind = resolve_registry_credentials(registry) # 检查是否匹配当前 registry (支持部分匹配) if registry not in kcr_registry and kcr_registry not in registry: return False if not kcr_username or not kcr_password: + print_registry_credentials_help(registry, kind=registry_kind) return False click.echo(f"🔐 使用 .env 中的凭证登录 {registry}...") @@ -536,7 +681,7 @@ def _auto_login_from_env(self, registry: str) -> bool: click.secho(f"⚠️ 自动登录异常: {e}", fg='yellow') return False - def get_registry_credentials(self) -> dict: + def get_registry_credentials(self, registry: str | None = None) -> dict: """获取镜像仓库凭证 (用于传给 Serverless) 返回扁平化结构: {"username": "...", "password": "..."} @@ -546,9 +691,8 @@ def get_registry_credentials(self) -> dict: load_dotenv() - # KCR_USERNAME 默认使用 KSYUN_ACCOUNT_ID - username = os.getenv('KCR_USERNAME', '') or os.getenv('KSYUN_ACCOUNT_ID', '') - password = os.getenv('KCR_PASSWORD', '') + registry = registry or os.getenv("KCR_REGISTRY", "") + username, password, _ = resolve_registry_credentials(registry) if username and password: return { @@ -591,32 +735,6 @@ def _check_registry_auth(self, registry: str) -> bool: def _print_auth_help(self, registry: str): """打印认证帮助信息""" - click.echo("") - click.echo("🔐 请先登录镜像仓库:") - click.echo("") - - if 'kce.ksyun.com' in registry or 'hub-' in registry: - # 金山云 KCR - click.echo(f" # 金山云容器镜像服务 (KCR)") - click.echo(f" docker login {registry}") - click.echo("") - click.echo(" 用户名: 您的金山云账号 ID") - click.echo(" 密码: 在 KCR 控制台获取临时密码") - click.echo("") - click.echo(" 获取密码: https://kcr.console.ksyun.com/ → 访问凭证") - elif 'docker.io' in registry or registry == '': - # Docker Hub - click.echo(" # Docker Hub") - click.echo(" docker login") - click.echo("") - click.echo(" 提示: 需要先在 https://hub.docker.com 注册账号") - else: - # 其他仓库 - click.echo(f" docker login {registry}") - - click.echo("") - click.echo("💡 配置 CLI 默认仓库:") - click.echo(f" agentengine config set defaults.registry {registry}") - click.echo("") - click.echo(" 配置后构建将自动使用该仓库:") - click.echo(f" agentengine build --mode container --push") + print_registry_credentials_help(registry) + click.echo("也可以先手动登录:") + click.echo(f" docker login {registry or 'docker.io'}") diff --git a/ksadk/builders/mcp_builder.py b/ksadk/builders/mcp_builder.py index adacc400..2700f34a 100644 --- a/ksadk/builders/mcp_builder.py +++ b/ksadk/builders/mcp_builder.py @@ -370,7 +370,12 @@ def _package_mcp_project(self, detection_result: MCPDetectionResult): output_dir.mkdir(parents=True, exist_ok=True) for item in project_path.iterdir(): - if (item.name.startswith('.') and not item.name.startswith('.env')) or item.name in ('__pycache__', '.git', 'node_modules'): + if CodeBuilder._is_real_dotenv_file(item.name): + continue + if ( + (item.name.startswith('.') and item.name != '.env.example') + or item.name in ('__pycache__', '.git', 'node_modules') + ): continue dest = output_dir / item.name if item.is_dir(): diff --git a/ksadk/cli/__init__.py b/ksadk/cli/__init__.py index 8208304b..6cafb5c6 100644 --- a/ksadk/cli/__init__.py +++ b/ksadk/cli/__init__.py @@ -499,9 +499,13 @@ def main(): try: from ksadk.configs.global_config import get_env_from_global_config global_env = get_env_from_global_config() + injected_keys = [] for key, value in global_env.items(): if not os.environ.get(key): os.environ[key] = value + injected_keys.append(key) + if injected_keys: + os.environ["KSADK_GLOBAL_CONFIG_ENV_KEYS"] = ",".join(sorted(injected_keys)) except Exception: pass diff --git a/ksadk/cli/cmd_build.py b/ksadk/cli/cmd_build.py index 06c83fc7..b3d4fecf 100644 --- a/ksadk/cli/cmd_build.py +++ b/ksadk/cli/cmd_build.py @@ -71,7 +71,7 @@ def build( # 1) 默认构建 (code 模式) agentengine build . # 2) 显式指定构建参数 - agentengine build . --mode container --push --registry ghcr.io/your-org + agentengine build . --mode container --push --registry hub-cn-beijing-6.kce.ksyun.com # 3) 显式指定区域 KSYUN_REGION=cn-beijing-6 agentengine build . --mode code --push --no-cache """ diff --git a/ksadk/cli/cmd_dashboard.py b/ksadk/cli/cmd_dashboard.py index cf9e4d7b..19850a17 100644 --- a/ksadk/cli/cmd_dashboard.py +++ b/ksadk/cli/cmd_dashboard.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import os import webbrowser from datetime import datetime, timedelta, timezone from pathlib import Path @@ -52,6 +53,7 @@ ) DEFAULT_PRIVATE_LINK_EXPIRES_SECONDS = 24 * 60 * 60 +DEFAULT_REGION = "cn-beijing-6" DASHBOARD_RESOURCE = ResourceDescriptor( name="Dashboard", @@ -167,7 +169,7 @@ def _abort_dashboard_error( help=build_resource_group_help(DASHBOARD_RESOURCE), ) @click.option("--agent", "--agent-id", "agent_option", "-a", hidden=True, help="(兼容) Agent 名称或 ID") -@click.option("--region", "-r", default="cn-beijing-6", envvar="KSYUN_REGION", hidden=True, help="(兼容) 区域") +@click.option("--region", "-r", default=None, envvar="KSYUN_REGION", hidden=True, help="(兼容) 区域") @click.option("--path", "ui_path", default=None, hidden=True, help="(兼容) 目标 UI 路径") @click.option("--share", is_flag=True, hidden=True, help="(兼容) 创建可分享链接") @click.option( @@ -186,7 +188,7 @@ def _abort_dashboard_error( def dashboard( ctx: click.Context, agent_option: Optional[str], - region: str, + region: Optional[str], ui_path: Optional[str], share: bool, expires_seconds: Optional[int], @@ -196,6 +198,7 @@ def dashboard( output_mode: str | None, ): _ = output_mode + ctx.obj["dashboard_region_source"] = _region_parameter_source(ctx, "region") if ctx.invoked_subcommand is not None: return @@ -211,6 +214,7 @@ def dashboard( positional_agent=positional_ref, agent_option=agent_option, region=region, + region_source=str(ctx.obj.get("dashboard_region_source") or ""), ui_path=ui_path, share=share, expires_seconds=expires_seconds, @@ -223,7 +227,7 @@ def dashboard( @dashboard.command("open", context_settings=CONTEXT_SETTINGS) @click.argument("agent_ref", required=False) @click.option("--agent", "--agent-id", "agent_option", "-a", help="Agent 名称或 ID") -@click.option("--region", "-r", default="cn-beijing-6", envvar="KSYUN_REGION", help="区域") +@click.option("--region", "-r", default=None, envvar="KSYUN_REGION", help="区域") @click.option("--path", "ui_path", default=None, help="目标 UI 路径(默认根据配置自动推导)") @click.option("--share", is_flag=True, help="创建可分享链接(默认创建私有临时链接)") @click.option( @@ -237,10 +241,12 @@ def dashboard( @click.option("--no-open", is_flag=True, help="仅打印 URL,不自动打开浏览器") @click.option("--direct", is_flag=True, help="直接打开 endpoint/path(跳过短链接创建)") @cli_output_option() +@click.pass_context def dashboard_open( + ctx: click.Context, agent_ref: Optional[str], agent_option: Optional[str], - region: str, + region: Optional[str], ui_path: Optional[str], share: bool, expires_seconds: Optional[int], @@ -255,6 +261,7 @@ def dashboard_open( positional_agent=agent_ref, agent_option=agent_option, region=region, + region_source=_region_parameter_source(ctx, "region"), ui_path=ui_path, share=share, expires_seconds=expires_seconds, @@ -272,15 +279,17 @@ def dashboard_share(): @dashboard_share.command("list", context_settings=CONTEXT_SETTINGS) @click.argument("agent_ref", required=False) @click.option("--agent", "--agent-id", "agent_option", "-a", help="Agent 名称或 ID") -@click.option("--region", "-r", default="cn-beijing-6", envvar="KSYUN_REGION", help="区域") +@click.option("--region", "-r", default=None, envvar="KSYUN_REGION", help="区域") @click.option("--type", "link_type", type=click.Choice(["private", "share"]), default=None, help="链接类型过滤") @click.option("--status", type=click.Choice(["active", "revoked"]), default=None, help="状态过滤") @pagination_options(default_page=1, default_size=20) @cli_output_option() +@click.pass_context def dashboard_share_list( + ctx: click.Context, agent_ref: Optional[str], agent_option: Optional[str], - region: str, + region: Optional[str], link_type: Optional[str], status: Optional[str], page: int, @@ -299,6 +308,7 @@ def dashboard_share_list( cwd = Path(".").resolve() state = load_state(cwd) + effective_region = _resolve_effective_region(region, state, region_source=_region_parameter_source(ctx, "region")) primary_ref, fallback_ref = _resolve_references(explicit_ref, cwd) if not primary_ref: _abort_dashboard_error( @@ -310,7 +320,7 @@ def dashboard_share_list( ) try: - detail, _, _ = asyncio.run(_resolve_agent_detail(region, primary_ref, fallback_ref)) + detail, _, _ = asyncio.run(_resolve_agent_detail(effective_region, primary_ref, fallback_ref)) except Exception as e: _abort_dashboard_error(e, context="获取 Agent 信息失败", argv=["dashboard", "share", "list"]) agent_id = (detail.get("agent_id") or "").strip() @@ -324,7 +334,7 @@ def dashboard_share_list( try: result = asyncio.run( _list_dashboard_access_links( - region=region, + region=effective_region, agent_id=agent_id or None, agent_name=agent_name or None, link_type=link_type, @@ -416,7 +426,8 @@ def _open_dashboard( *, positional_agent: Optional[str], agent_option: Optional[str], - region: str, + region: Optional[str], + region_source: str, ui_path: Optional[str], share: bool, expires_seconds: Optional[int], @@ -438,6 +449,7 @@ def _open_dashboard( cwd = Path(".").resolve() state = load_state(cwd) + effective_region = _resolve_effective_region(region, state, region_source=region_source) primary_ref, fallback_ref = _resolve_references(explicit_ref, cwd) if not primary_ref: _abort_dashboard_error( @@ -452,7 +464,7 @@ def _open_dashboard( print_info(f"未显式指定 Agent,使用 {primary_ref.source_text}: {primary_ref.value}") try: - detail, used_ref, state_stale = asyncio.run(_resolve_agent_detail(region, primary_ref, fallback_ref)) + detail, used_ref, state_stale = asyncio.run(_resolve_agent_detail(effective_region, primary_ref, fallback_ref)) except Exception as e: _abort_dashboard_error(e, context="获取 Agent 信息失败", argv=["dashboard", "open"]) return @@ -476,6 +488,7 @@ def _open_dashboard( cli_url=None, ) normalized_path = _normalize_ui_path(resolved_ui.path or "/") + link_path = normalized_path if ui_path is not None else None base_url = _build_base_ui_url(endpoint, normalized_path) if direct: @@ -483,12 +496,17 @@ def _open_dashboard( return if _is_openclaw_target(state=state, detail=detail): + openclaw_link_path = _resolve_openclaw_link_path( + state=state, + cli_path=ui_path, + normalized_path=normalized_path, + ) try: link_data = asyncio.run( _create_openclaw_gateway_access_link( - region=region, + region=effective_region, detail=detail, - path=normalized_path, + path=openclaw_link_path, link_type="share" if share else "private", expires_seconds=_normalize_expires_seconds( link_type="share" if share else "private", @@ -508,11 +526,11 @@ def _open_dashboard( try: link_data = asyncio.run( _create_dashboard_access_link( - region=region, + region=effective_region, agent_id=(detail.get("agent_id") or "").strip() or None, agent_name=(detail.get("name") or "").strip() or None, link_type=link_type, - path=normalized_path, + path=link_path, expires_seconds=validated_expires, force_new=force_new, ) @@ -576,12 +594,66 @@ def _validate_ui_path_option(ui_path: Optional[str]) -> None: ) +def _resolve_effective_region(region: Optional[str], state: Optional[dict], *, region_source: str) -> str: + explicit_region = str(region or "").strip() + if explicit_region and region_source == "commandline": + return explicit_region + if explicit_region and region_source == "environment" and not _is_global_config_injected_region(): + return explicit_region + state_region = str((state or {}).get("region") or "").strip() + return state_region or explicit_region or DEFAULT_REGION + + +def _region_parameter_source(ctx: click.Context, name: str) -> str: + try: + source = ctx.get_parameter_source(name) + except Exception: + return "" + if source == click.core.ParameterSource.COMMANDLINE: + return "commandline" + if source == click.core.ParameterSource.ENVIRONMENT: + return "environment" + return "" + + +def _is_global_config_injected_region() -> bool: + keys = { + item.strip() + for item in os.environ.get("KSADK_GLOBAL_CONFIG_ENV_KEYS", "").split(",") + if item.strip() + } + return "KSYUN_REGION" in keys + + def _is_openclaw_target(*, state: Optional[dict], detail: dict) -> bool: state_type = str((state or {}).get("type") or "").strip().lower() framework = str(detail.get("framework") or "").strip().lower() return state_type == "openclaw" or framework == "openclaw" +def _resolve_openclaw_link_path( + *, + state: Optional[dict], + cli_path: Optional[str], + normalized_path: str, +) -> Optional[str]: + if cli_path is not None: + return normalized_path + + state_data = state if isinstance(state, dict) else {} + nested = state_data.get("ui") if isinstance(state_data.get("ui"), dict) else {} + state_path = state_data.get("ui_path") or nested.get("path") + if state_path is None: + return None + + # OpenClaw 的历史默认路径曾经落在 / 或 /chat。这里不把 legacy 默认 + # 当作显式 Path 传给 server,让 server 端按当前运行时规则推导。 + state_normalized = _normalize_ui_path(str(state_path)) + if state_normalized in {"/", "/chat"}: + return None + return normalized_path + + def _emit_url(title: str, url: str, *, no_open: bool): print_success(title) print_kv("URL", url, value_style="#58a6ff") @@ -692,7 +764,7 @@ async def _create_dashboard_access_link( agent_id: Optional[str], agent_name: Optional[str], link_type: str, - path: str, + path: Optional[str], expires_seconds: Optional[int], force_new: bool, ) -> dict: @@ -724,7 +796,7 @@ async def _create_openclaw_gateway_access_link( *, region: str, detail: dict, - path: str = "/", + path: Optional[str] = None, link_type: str, expires_seconds: Optional[int], force_new: bool = False, diff --git a/ksadk/cli/cmd_deploy.py b/ksadk/cli/cmd_deploy.py index 631b3ceb..2880b032 100644 --- a/ksadk/cli/cmd_deploy.py +++ b/ksadk/cli/cmd_deploy.py @@ -8,11 +8,11 @@ """ import os -import json import click import asyncio from pathlib import Path from ksadk.api.client import DryRunExit +from ksadk.cli.env_options import env_options, resolve_explicit_env_vars from ksadk.cli.storage import build_storage_config from ksadk.common.constants import ( get_ks3_endpoints, @@ -25,6 +25,7 @@ apply_network_config as _apply_network_config_shared, network_cli_kwargs, network_options, + resolve_deploy_target_network, validate_deploy_target_network, ) from ksadk.cli.workflow_common import ( @@ -89,6 +90,7 @@ @click.option("--storage-mount-path", default=None, help="PVC 挂载目录(默认按框架推导)") @click.option("--no-storage", is_flag=True, help="禁用默认 PVC 挂载") @network_options +@env_options @click.option( "--observability/--no-observability", default=True, help="是否启用可观测性 (默认开启)" ) @@ -125,6 +127,8 @@ def deploy( subnet_id: str | None, security_group_id: str | None, availability_zone: str | None, + extra_env: tuple[str, ...], + env_file: str | None, observability: bool, push: bool, no_cache: bool, @@ -193,6 +197,8 @@ def deploy( security_group_id=security_group_id, availability_zone=availability_zone, ), + extra_env=extra_env, + env_file=env_file, repackage=repackage, dry_run_context=dry_run_context, ), @@ -274,6 +280,8 @@ async def _deploy_async( subnet_id: str | None = None, security_group_id: str | None = None, availability_zone: str | None = None, + extra_env: tuple[str, ...] = (), + env_file: str | None = None, dry_run_context: dict[str, object] | None = None, *, repackage: bool = False, @@ -284,6 +292,14 @@ async def _deploy_async( agent_path = Path(agent_dir).resolve() config = _load_config(agent_path) + try: + explicit_env_vars = resolve_explicit_env_vars( + env_file=env_file, + env_pairs=extra_env, + base_dir=agent_path, + ) + except ValueError as e: + raise validation_error(str(e)) effective_artifact_type = _resolve_artifact_type_input(config, artifact_type) print_workflow_header( title="Agent 部署", @@ -363,6 +379,7 @@ async def _deploy_async( "dry_run": dry_run, "no_cache": no_cache, "repackage": repackage, + "env_vars": explicit_env_vars, }, ) @@ -403,6 +420,7 @@ async def _deploy_async( availability_zone=availability_zone, ) validate_deploy_target_network(deploy_target) + resolve_deploy_target_network(deploy_target, region=region, dry_run=dry_run) storage_config = build_storage_config( detection_result.type.value, target=target, diff --git a/ksadk/cli/cmd_launch.py b/ksadk/cli/cmd_launch.py index 49c2a68c..25bbbb1e 100644 --- a/ksadk/cli/cmd_launch.py +++ b/ksadk/cli/cmd_launch.py @@ -8,11 +8,13 @@ from ksadk.api.client import DryRunExit from ksadk.cli.cmd_deploy import _apply_network_config, _resolve_artifact_type_input, _resolve_ui_config_inputs from ksadk.cli.dry_run import effective_dry_run, run_async_with_dry_run +from ksadk.cli.env_options import env_options, resolve_explicit_env_vars from ksadk.cli.error_utils import cli_error_from_exception, is_debug_mode_enabled, remote_error, usage_error, validation_error from ksadk.cli.network_options import ( apply_network_cli_overrides, network_cli_kwargs, network_options, + resolve_deploy_target_network, validate_deploy_target_network, ) from ksadk.cli.storage import build_storage_config @@ -69,6 +71,7 @@ @click.option("--storage-mount-path", default=None, help="PVC 挂载目录(默认按框架推导)") @click.option("--no-storage", is_flag=True, help="禁用默认 PVC 挂载") @network_options +@env_options @click.option("--dry-run", is_flag=True, help="仅打印请求,不执行实际操作") @click.option( "--artifact-type", @@ -104,6 +107,8 @@ def launch( subnet_id: str | None, security_group_id: str | None, availability_zone: str | None, + extra_env: tuple[str, ...], + env_file: str | None, dry_run: bool, artifact_type: str, no_version: bool, @@ -163,6 +168,8 @@ def launch( security_group_id=security_group_id, availability_zone=availability_zone, ), + extra_env=extra_env, + env_file=env_file, dry_run_context=dry_run_context, ), dry_run=dry_run, @@ -208,6 +215,8 @@ async def _launch_async( subnet_id: str | None = None, security_group_id: str | None = None, availability_zone: str | None = None, + extra_env: tuple[str, ...] = (), + env_file: str | None = None, dry_run_context: dict[str, object] | None = None, ): from ksadk.detection import FrameworkDetector @@ -215,6 +224,14 @@ async def _launch_async( agent_path = Path(agent_dir).resolve() config = _load_config(agent_path) + try: + explicit_env_vars = resolve_explicit_env_vars( + env_file=env_file, + env_pairs=extra_env, + base_dir=agent_path, + ) + except ValueError as e: + raise validation_error(str(e)) effective_artifact_type = _resolve_artifact_type_input(config, artifact_type) if target == "serverless" else artifact_type print_workflow_header( title="Agent Launch", @@ -272,6 +289,7 @@ async def _launch_async( "ui_path": resolved_ui_path, "ui_url": resolved_ui_url, "dry_run": dry_run, + "env_vars": explicit_env_vars, }, ) @@ -299,6 +317,7 @@ async def _launch_async( availability_zone=availability_zone, ) validate_deploy_target_network(deploy_target) + resolve_deploy_target_network(deploy_target, region=region, dry_run=dry_run) storage_config = build_storage_config( detection_result.type.value, target=target, diff --git a/ksadk/cli/cmd_mcp.py b/ksadk/cli/cmd_mcp.py index cff63ee7..13c50e46 100644 --- a/ksadk/cli/cmd_mcp.py +++ b/ksadk/cli/cmd_mcp.py @@ -51,6 +51,10 @@ render_workflow_result, resolve_artifact_build_plan, ) +from ksadk.builders.container_builder import ( + registry_kind_label, + resolve_registry_credentials, +) console = get_console() @@ -330,7 +334,7 @@ def _default_container_registry(config: dict, registry: str | None) -> str: str(registry or "").strip().rstrip("/") or str(os.getenv("KCR_REGISTRY") or "").strip().rstrip("/") or str((config.get("image") or {}).get("registry") or "").strip().rstrip("/") - or "ghcr.io/kingsoftcloud/agentengine" + or "hub.kce.ksyun.com/agentengine" ) @@ -558,17 +562,22 @@ def _build_mcp_request_data( "bucket": bucket, } else: - kcr_username = os.getenv("KCR_USERNAME", "") or os.getenv("KSYUN_ACCOUNT_ID", "") - kcr_password = os.getenv("KCR_PASSWORD") + kcr_username, kcr_password, registry_kind = resolve_registry_credentials(artifact_reference) if kcr_username and kcr_password: request_data["image_credential"] = { - "endpoint": os.getenv("KCR_ENDPOINT", "ghcr.io"), + "endpoint": artifact_reference.split("/", 1)[0], "username": kcr_username, "password": kcr_password, } print_kv("镜像凭证", f"{kcr_username}@{request_data['image_credential']['endpoint']}") else: - print_warn("未配置镜像凭证 (KCR_USERNAME/KCR_PASSWORD),私有镜像可能无法拉取") + if registry_kind == "personal_kcr": + print_warn("未配置个人版 KCR 镜像凭证 (KSYUN_ACCOUNT_ID/KCR_PASSWORD),私有镜像可能无法拉取") + else: + print_warn( + f"未配置{registry_kind_label(registry_kind)} 镜像凭证 " + "(KCR_USERNAME/KCR_PASSWORD),私有镜像可能无法拉取" + ) return request_data @@ -847,6 +856,8 @@ async def _deploy_mcp_async( subnet_id=subnet_id, security_group_id=security_group_id, availability_zone=availability_zone, + region=region, + dry_run=dry_run, ) if network_payload: request_data["network"] = network_payload diff --git a/ksadk/cli/network_options.py b/ksadk/cli/network_options.py index 795dd00b..ced8f180 100644 --- a/ksadk/cli/network_options.py +++ b/ksadk/cli/network_options.py @@ -2,11 +2,14 @@ from __future__ import annotations +import json +import os from typing import TYPE_CHECKING, Any, Mapping, Optional import click from ksadk.cli.error_utils import validation_error +from ksadk.cli.ui import print_info, print_warn if TYPE_CHECKING: from ksadk.deployment.base import DeployTarget @@ -157,6 +160,40 @@ def validate_deploy_target_network(deploy_target: "DeployTarget") -> None: ) +def resolve_deploy_target_network( + deploy_target: "DeployTarget", + *, + region: str | None = None, + dry_run: bool = False, +) -> None: + """Fill optional network fields on a DeployTarget when they can be inferred.""" + network = getattr(deploy_target, "network", None) + if network is None: + return + if str(getattr(network, "availability_zone", "") or "").strip(): + return + if not bool(getattr(network, "enable_vpc_access", False)): + return + subnet_id = str(getattr(network, "subnet_id", "") or "").strip() + if not subnet_id or dry_run: + return + resolved_region = str(region or getattr(deploy_target, "region", "") or "").strip() + if not resolved_region: + return + availability_zone = _resolve_subnet_availability_zone( + subnet_id=subnet_id, + region=resolved_region, + ) + if availability_zone: + network.availability_zone = availability_zone + print_info(f"已根据子网 {subnet_id} 自动推断可用区: {availability_zone}") + else: + print_warn( + "未能根据子网自动推断可用区;如私网 ENI 调度失败,请显式传入 " + "`--availability-zone`。" + ) + + def build_network_payload(**network_kwargs: Any) -> dict[str, Any] | None: """Build lower-case network payload for AgentEngineClient create/update calls.""" payload: dict[str, Any] = {} @@ -174,6 +211,7 @@ def build_network_payload(**network_kwargs: Any) -> dict[str, Any] | None: payload["enable_vpc_access"] = True _validate_network_payload(payload) + _fill_network_availability_zone(payload, network_kwargs) return payload @@ -192,3 +230,181 @@ def _validate_network_payload(payload: Mapping[str, Any]) -> None: "`--availability-zone` 是可选字段,不替代子网或安全组。", ], ) + + +def _fill_network_availability_zone(payload: dict[str, Any], network_kwargs: Mapping[str, Any]) -> None: + if str(payload.get("availability_zone") or "").strip(): + return + if not bool(payload.get("enable_vpc_access")): + return + subnet_id = str(payload.get("subnet_id") or "").strip() + if not subnet_id: + return + if bool(network_kwargs.get("dry_run")): + return + region = str(network_kwargs.get("region") or "").strip() + if not region: + return + + availability_zone = _resolve_subnet_availability_zone(subnet_id=subnet_id, region=region) + if availability_zone: + payload["availability_zone"] = availability_zone + print_info(f"已根据子网 {subnet_id} 自动推断可用区: {availability_zone}") + else: + print_warn( + "未能根据子网自动推断可用区;如私网 ENI 调度失败,请显式传入 " + "`--availability-zone`。" + ) + + +def _resolve_subnet_availability_zone(*, subnet_id: str, region: str) -> str | None: + subnet_id = str(subnet_id or "").strip() + region = str(region or "").strip() + if not subnet_id or not region: + return None + + access_key, secret_key = _resolve_ksyun_credentials() + if not access_key or not secret_key: + return None + + try: + VpcClient, DescribeSubnetsRequest, Credential, ClientProfile, HttpProfile = _import_vpc_sdk() + except Exception as exc: + print_warn(f"缺少 VPC 子网查询 SDK,跳过可用区自动推断: {exc}") + return None + + response = None + last_error: Exception | None = None + for endpoint, protocol in ((None, None), ("vpc.inner.api.ksyun.com", "http")): + if endpoint and not _should_retry_inner_vpc_endpoint(last_error): + break + try: + profile = ClientProfile( + httpProfile=HttpProfile(reqTimeout=10, endpoint=endpoint, protocol=protocol) + ) + client = VpcClient(Credential(access_key, secret_key), region, profile) + request = DescribeSubnetsRequest() + request.SubnetId = {"1": subnet_id} + response = client.DescribeSubnets(request) + break + except Exception as exc: + last_error = exc + + if response is None: + print_warn(f"查询子网 {subnet_id} 可用区失败,跳过自动推断: {last_error}") + return None + + return _extract_subnet_availability_zone(response, subnet_id) + + +def _import_vpc_sdk(): + from ksyun.client.vpc.v20160304.client import VpcClient + from ksyun.client.vpc.v20160304.models import DescribeSubnetsRequest + from ksyun.common.credential import Credential + from ksyun.common.profile.client_profile import ClientProfile + from ksyun.common.profile.http_profile import HttpProfile + + return VpcClient, DescribeSubnetsRequest, Credential, ClientProfile, HttpProfile + + +def _should_retry_inner_vpc_endpoint(error: Exception | None) -> bool: + if error is None: + return False + return "InnerAccountCanOnlyAccessThroughIntranet" in str(error) + + +def _resolve_ksyun_credentials() -> tuple[str, str]: + access_key = os.getenv("KSYUN_ACCESS_KEY") or os.getenv("KS3_ACCESS_KEY") or "" + secret_key = os.getenv("KSYUN_SECRET_KEY") or os.getenv("KS3_SECRET_KEY") or "" + if access_key and secret_key: + return access_key.strip(), secret_key.strip() + + try: + from ksadk.configs.global_config import get_env_from_global_config + + global_env = get_env_from_global_config() + except Exception: + global_env = {} + + access_key = access_key or global_env.get("KSYUN_ACCESS_KEY") or global_env.get("KS3_ACCESS_KEY") or "" + secret_key = secret_key or global_env.get("KSYUN_SECRET_KEY") or global_env.get("KS3_SECRET_KEY") or "" + return str(access_key or "").strip(), str(secret_key or "").strip() + + +def _extract_subnet_availability_zone(response: Any, subnet_id: str) -> str | None: + if isinstance(response, str): + try: + response = json.loads(response) + except json.JSONDecodeError: + return None + if not isinstance(response, Mapping): + return None + + wanted_subnet_id = str(subnet_id or "").strip() + subnets = list(_iter_subnet_dicts(response)) + for subnet in subnets: + current_subnet_id = _pick_text( + subnet, + "SubnetId", + "subnetId", + "subnet_id", + "id", + "Id", + ) + if current_subnet_id and current_subnet_id != wanted_subnet_id: + continue + availability_zone = _pick_text( + subnet, + "AvailabilityZone", + "AvailabilityZoneName", + "availabilityZone", + "availabilityZoneName", + "availability_zone", + "availability_zone_name", + "Zone", + "zone", + ) + if availability_zone: + return availability_zone + + if len(subnets) == 1: + return _pick_text( + subnets[0], + "AvailabilityZone", + "AvailabilityZoneName", + "availabilityZone", + "availabilityZoneName", + "availability_zone", + "availability_zone_name", + "Zone", + "zone", + ) + return None + + +def _iter_subnet_dicts(value: Any): + if isinstance(value, Mapping): + subnet_keys = ("SubnetId", "subnetId", "subnet_id") + zone_keys = ( + "AvailabilityZone", + "AvailabilityZoneName", + "availabilityZone", + "availabilityZoneName", + "availability_zone", + "availability_zone_name", + ) + if any(key in value for key in subnet_keys) or any(key in value for key in zone_keys): + yield value + for child in value.values(): + yield from _iter_subnet_dicts(child) + elif isinstance(value, list): + for item in value: + yield from _iter_subnet_dicts(item) + + +def _pick_text(data: Mapping[str, Any], *keys: str) -> str: + for key in keys: + value = data.get(key) + if value is not None and str(value).strip(): + return str(value).strip() + return "" diff --git a/ksadk/common/constants.py b/ksadk/common/constants.py index 573a5f14..f372efae 100644 --- a/ksadk/common/constants.py +++ b/ksadk/common/constants.py @@ -2,8 +2,10 @@ 通用常量定义 """ -# Serverless Endpoint (public control plane) -DEFAULT_SERVERLESS_ENDPOINT = "https://aicp.api.ksyun.com" +# Serverless Endpoint (默认内网预发环境) +DEFAULT_SERVERLESS_ENDPOINT = ( + "http://kmr.pre-online.inner.api.ksyun.com" +) # KS3 Region 映射表 # 用户输入的 region (如 cn-beijing-6) -> (外网endpoint, 内网endpoint, region_code) diff --git a/ksadk/configs/env_registry.py b/ksadk/configs/env_registry.py index 8cbd5e19..314ab9b8 100644 --- a/ksadk/configs/env_registry.py +++ b/ksadk/configs/env_registry.py @@ -61,6 +61,7 @@ class EnvVarSpec: EnvVarSpec("KSADK_EVENTS_TABLE", "sessions", "Internal SQLite events table constant."), EnvVarSpec("KSADK_FEISHU_APP_ID", "cli", "Feishu helper app id used by OpenClaw diagnostics."), EnvVarSpec("KSADK_FEISHU_RESULT_PATH", "cli", "Feishu helper result file path."), + EnvVarSpec("KSADK_GLOBAL_CONFIG_ENV_KEYS", "cli", "Internal marker for env vars injected from global config."), EnvVarSpec("KSADK_KB", "knowledge_base", "AICP knowledge-base connection prefix."), EnvVarSpec("KSADK_KB_ACCESS_KEY", "knowledge_base", "Knowledge-base API access key.", sensitive=True), EnvVarSpec("KSADK_KB_DATASET_ID", "knowledge_base", "Knowledge-base dataset id."), @@ -119,6 +120,8 @@ class EnvVarSpec: EnvVarSpec("KSADK_RUNTIME_REQUIREMENTS", "builders", "Internal bundled runtime requirements constant."), EnvVarSpec("KSADK_SANDBOX_ALLOW_INTERNET_ACCESS", "sandbox", "Allow remote sandbox internet access.", "true"), EnvVarSpec("KSADK_SANDBOX_BACKEND", "sandbox", "Generic sandbox backend selector.", "e2b"), + EnvVarSpec("KSADK_SANDBOX_STARTUP_RETRY_ATTEMPTS", "sandbox", "Sandbox startup readiness retry attempts.", "6"), + EnvVarSpec("KSADK_SANDBOX_STARTUP_RETRY_DELAY", "sandbox", "Sandbox startup readiness initial retry delay seconds.", "0.2"), EnvVarSpec("KSADK_SANDBOX_TEMPLATE_ID", "sandbox", "Sandbox console template id."), EnvVarSpec("KSADK_SANDBOX_TIMEOUT", "sandbox", "Sandbox session timeout seconds.", "900"), EnvVarSpec("KSADK_SANDBOX_TYPE", "sandbox", "Sandbox type: aio, code, browser, or private.", "aio"), @@ -166,13 +169,19 @@ class EnvVarSpec: EnvVarSpec("KSADK_STM_PATH", "sessions", "Short-term-memory SQLite path."), EnvVarSpec("KSADK_STM_URL", "sessions", "Short-term-memory database URL.", sensitive=True), EnvVarSpec("KSADK_TENANT_ID", "sessions", "Tenant id used for session namespace scoping."), + EnvVarSpec( + "KSADK_TERMINAL_EXEC_SUBCOMMAND_ALLOWLIST", + "terminal", + "Comma-separated remote terminal exec prefixes appended to the default allowlist; use * to allow all.", + ), EnvVarSpec("KSADK_TOOL_APPROVAL_MODE", "tools", "Built-in tool approval mode: off or strict.", "off"), EnvVarSpec("KSADK_UPDATED_AT", "configs", "Internal config update timestamp field."), EnvVarSpec("KSADK_VERSION", "configs", "Internal config version field."), EnvVarSpec("KSADK_WEB_CACHE_DIR", "web", "Directory used by hosted Web UI static asset sync cache."), - EnvVarSpec("KSADK_WEB_RELEASE_URL", "web", "Hosted Web UI release tarball URL."), - EnvVarSpec("KSADK_WEB_TARBALL_NAME", "web", "Hosted Web UI release tarball file name."), - EnvVarSpec("KSADK_WEB_VERSION", "web", "Hosted Web UI release version tag.", "v0.2.0"), + EnvVarSpec("KSADK_WEB_PACKAGE", "web", "KsADK Web npm package name.", "@kingsoftcloud/ksadk-web"), + EnvVarSpec("KSADK_WEB_RELEASE_URL", "web", "Optional KsADK Web tarball URL fallback."), + EnvVarSpec("KSADK_WEB_TARBALL_NAME", "web", "KsADK Web fallback tarball file name."), + EnvVarSpec("KSADK_WEB_VERSION", "web", "KsADK Web npm dist-tag or version.", "latest"), EnvVarSpec("KSADK_WORKFLOW_PROMPT", "skills", "Prompt text exposed to local Skill workflow scripts."), EnvVarSpec("KSADK_WORKSPACE_ID", "sessions", "Workspace id used for session namespace scoping."), EnvVarSpec("OTEL_EXPORTER_OTLP_ENDPOINT", "tracing", "Generic OTLP HTTP endpoint used to derive the traces endpoint."), diff --git a/ksadk/conversations/__init__.py b/ksadk/conversations/__init__.py index c302c5c7..ceb106cb 100644 --- a/ksadk/conversations/__init__.py +++ b/ksadk/conversations/__init__.py @@ -32,6 +32,8 @@ append_conversation_event, append_context_checkpoint_event, append_reasoning_event, + append_run_checkpoint_event, + append_run_resume_event, append_run_status_event, build_chat_completions_payload, build_compaction_sse_event, @@ -53,6 +55,8 @@ "append_conversation_event", "append_context_checkpoint_event", "append_reasoning_event", + "append_run_checkpoint_event", + "append_run_resume_event", "append_run_status_event", "attachment_from_part", "attachment_prompt_text", diff --git a/ksadk/conversations/context.py b/ksadk/conversations/context.py index 1a4fab8b..3649d6ec 100644 --- a/ksadk/conversations/context.py +++ b/ksadk/conversations/context.py @@ -15,6 +15,8 @@ "attachment_ref", "reasoning", "run_status", + "run_checkpoint", + "run_resume", "context_checkpoint", "compaction_boundary", } @@ -102,6 +104,10 @@ def canonical_event_type( return "compaction_boundary" if raw in {"status", "run_status"}: return "run_status" + if raw in {"run_checkpoint", "runtime_checkpoint"}: + return "run_checkpoint" + if raw in {"run_resume", "runtime_resume"}: + return "run_resume" if raw in {"assistant", "model"} or role in {"assistant", "model"} or author in {"assistant", "model"}: return "assistant_message" return "user_message" diff --git a/ksadk/conversations/session_title.py b/ksadk/conversations/session_title.py index ccd4b65a..a5db0b88 100644 --- a/ksadk/conversations/session_title.py +++ b/ksadk/conversations/session_title.py @@ -8,6 +8,7 @@ from ksadk.configs.settings import settings from ksadk.conversations.model_options import model_options_for_chat_completions +from ksadk.conversations.reasoning_markup import strip_reasoning_markup DEFAULT_SESSION_TITLE_TIMEOUT_MS = 8_000 SESSION_TITLE_MAX_CHARS = 24 @@ -43,7 +44,7 @@ def _normalize_source_text(text: str) -> str: - value = str(text or "").strip() + value = strip_reasoning_markup(str(text or "")).strip() if not value: return "" value = _FILE_MARKUP_RE.sub(" 附件 ", value) @@ -52,7 +53,7 @@ def _normalize_source_text(text: str) -> str: def _sanitize_title(text: str) -> str: - value = str(text or "").strip() + value = strip_reasoning_markup(str(text or "")).strip() if not value: return "" value = value.splitlines()[0].strip() @@ -73,7 +74,7 @@ def _normalize_compare_text(text: str) -> str: def _truncate_title(text: str) -> str: - value = str(text or "").strip() + value = strip_reasoning_markup(str(text or "")).strip() if len(value) <= SESSION_TITLE_MAX_CHARS: return value return value[:SESSION_TITLE_MAX_CHARS].rstrip() @@ -149,7 +150,7 @@ def resolve_session_title_model(current_model: str | None) -> str: def build_session_title_messages(*, first_prompt: str, assistant_text: str) -> list[dict[str, str]]: - assistant_excerpt = str(assistant_text or "").strip() + assistant_excerpt = strip_reasoning_markup(str(assistant_text or "")).strip() if len(assistant_excerpt) > 240: assistant_excerpt = assistant_excerpt[:240].rstrip() + "…" return [ diff --git a/ksadk/deployment/providers/serverless.py b/ksadk/deployment/providers/serverless.py index ebc955b1..5e1aee1d 100644 --- a/ksadk/deployment/providers/serverless.py +++ b/ksadk/deployment/providers/serverless.py @@ -27,13 +27,68 @@ ) from ksadk.deployment.registry import DeployProviderRegistry from ksadk.deployment.ui_config import resolve_ui_config, ui_config_to_state_fields -from ksadk.builders.container_builder import ContainerBuilder +from ksadk.builders.container_builder import ( + ContainerBuilder, + registry_kind_label, + resolve_registry_credentials, +) +from ksadk.configs.env_registry import ENV_VAR_REGISTRY +from ksadk.configs.global_config import get_env_from_global_config from ksadk.api import AgentEngineClient, DryRunExit logger = logging.getLogger(__name__) +_DEPLOY_PROCESS_ENV_ALLOWLIST = frozenset( + { + spec.name + for spec in ENV_VAR_REGISTRY + if spec.module + not in { + "builders", + "cli", + "configs", + "web", + } + } +) | frozenset( + { + "E2B_API_KEY", + "E2B_API_URL", + "OPENAI_API_BASE", + "OPENAI_API_KEY", + "OPENAI_BASE_URL", + "OPENAI_MODEL_NAME", + "SKILL_SPACE_ID", + "KSYUN_ACCESS_KEY", + "KSYUN_ACCOUNT_ID", + "KSYUN_REGION", + "KSYUN_SECRET_KEY", + } +) +_DEPLOY_PROCESS_ENV_PREFIXES = ("KSADK_", "OPENAI_", "KSYUN_", "E2B_") +_DEPLOY_PROCESS_ENV_DENYLIST = frozenset( + { + spec.name + for spec in ENV_VAR_REGISTRY + if spec.module in {"builders", "cli", "configs", "web"} + } +) | frozenset( + { + "KSADK_GLOBAL_CONFIG_ENV_KEYS", + "KSADK_UPDATED_AT", + "KSADK_VERSION", + } +) + + +def _should_forward_process_env(name: str) -> bool: + if name in _DEPLOY_PROCESS_ENV_DENYLIST: + return False + return name in _DEPLOY_PROCESS_ENV_ALLOWLIST or name.startswith(_DEPLOY_PROCESS_ENV_PREFIXES) + + @DeployProviderRegistry.register("serverless") @DeployProviderRegistry.register("kcf") @DeployProviderRegistry.register("kce") @@ -55,6 +110,35 @@ class ServerlessProvider(BaseDeployProvider): def __init__(self, config: Dict[str, Any] = None): self.config = config or {} + @staticmethod + def _image_credential_from_env(image_ref: str) -> Optional[Dict[str, str]]: + username, password, registry_kind = resolve_registry_credentials(image_ref) + if username and password: + return { + "endpoint": image_ref.split("/", 1)[0], + "username": username, + "password": password, + } + + if password and not username and registry_kind != "personal_kcr": + click.secho( + f" ⚠️ 检测到 KCR_PASSWORD 但缺少 KCR_USERNAME,已忽略{registry_kind_label(registry_kind)}镜像凭证;" + "企业版 KCR 和第三方镜像仓库必须配置 KCR_USERNAME + KCR_PASSWORD", + fg="yellow", + ) + elif registry_kind == "personal_kcr": + click.secho( + " ⚠️ 未配置个人版 KCR 镜像凭证 (KSYUN_ACCOUNT_ID/KCR_PASSWORD),私有镜像可能无法拉取", + fg="yellow", + ) + else: + click.secho( + f" ⚠️ 未配置{registry_kind_label(registry_kind)}镜像凭证 " + "(KCR_USERNAME/KCR_PASSWORD),私有镜像可能无法拉取", + fg="yellow", + ) + return None + @staticmethod def _parse_code_artifact_path(artifact_path: str) -> tuple[Optional[str], Optional[str]]: """解析代码包路径,返回 (bucket, object_key)。""" @@ -102,6 +186,30 @@ def _load_project_env_vars(env_file: Path) -> Dict[str, str]: env_vars[clean_key] = str(value) return env_vars + @classmethod + def _load_deploy_env_vars( + cls, + project_dir: str | Path, + explicit_env_vars: Optional[Dict[str, str]] = None, + ) -> tuple[Dict[str, str], bool, int]: + """读取部署时注入到托管运行时的环境变量。 + + 全局配置作为兜底,项目 .env 作为项目级覆盖;真实 .env 文件不会随 + Code/Container 制品打包,只通过 deploy payload 注入到 Pod 环境变量。 + """ + env_vars: Dict[str, str] = dict(get_env_from_global_config()) + env_file = Path(project_dir) / ".env" + project_env_count = 0 + for key, value in sorted(os.environ.items()): + if value and _should_forward_process_env(key): + env_vars.setdefault(key, value) + if env_file.exists(): + project_env = cls._load_project_env_vars(env_file) + project_env_count = len(project_env) + env_vars.update(project_env) + env_vars.update(explicit_env_vars or {}) + return env_vars, env_file.exists(), project_env_count + @staticmethod def _persist_build_metadata(package_info: PackageInfo) -> None: """持久化最近一次成功构建的制品信息,供后续命中缓存。""" @@ -515,6 +623,7 @@ async def deploy(self, package_info: PackageInfo, target: DeployTarget) -> Deplo click.echo(f" 执行热更新 (endpoint 保持不变)...") update_data = { + "artifact_type": artifact_type, "artifact_path": artifact_path, "resources": { "cpu": target.resources.cpu, @@ -537,15 +646,28 @@ async def deploy(self, package_info: PackageInfo, target: DeployTarget) -> Deplo if ks3_config: update_data["ks3"] = ks3_config - - # 加载本地 .env 并注入到环境变量 (更新时也同步) - env_file = Path(project_dir) / ".env" - env_vars = {} - if env_file.exists(): - env_vars = self._load_project_env_vars(env_file) - if env_vars: - update_data["env_vars"] = env_vars - click.echo(f" 📦 更新环境变量: {len(env_vars)} 项 from .env") + elif artifact_type == "Container": + image_credential = self._image_credential_from_env(artifact_path) + if image_credential: + update_data["image_credential"] = image_credential + click.echo( + f" 🔑 镜像凭证: {image_credential['username']}@{image_credential['endpoint']}" + ) + + # 加载全局配置 + 本地 .env,并通过部署参数注入到运行时环境变量。 + env_vars, env_file_exists, project_env_count = self._load_deploy_env_vars( + project_dir, + target.extra.get("env_vars") or {}, + ) + if env_vars: + update_data["env_vars"] = env_vars + if env_file_exists: + click.echo( + f" 📦 更新环境变量: {len(env_vars)} 项 " + f"(全局配置 + {project_env_count} 项 from .env)" + ) + else: + click.echo(f" 📦 更新环境变量: {len(env_vars)} 项 from 全局配置") network_config = self._serialize_network_config(target) if network_config: @@ -641,28 +763,27 @@ async def deploy(self, package_info: PackageInfo, target: DeployTarget) -> Deplo # Container 模式: 传递镜像凭证 if artifact_type == "Container": - # KCR_USERNAME 默认使用 KSYUN_ACCOUNT_ID - kcr_username = os.getenv("KCR_USERNAME", "") or os.getenv("KSYUN_ACCOUNT_ID", "") - kcr_password = os.getenv("KCR_PASSWORD") - kcr_endpoint = os.getenv("KCR_ENDPOINT", "ghcr.io") - - if kcr_username and kcr_password: - request_data["image_credential"] = { - "endpoint": kcr_endpoint, - "username": kcr_username, - "password": kcr_password, - } - click.echo(f" 🔑 镜像凭证: {kcr_username}@{kcr_endpoint}") + image_credential = self._image_credential_from_env(artifact_path) + if image_credential: + request_data["image_credential"] = image_credential + click.echo( + f" 🔑 镜像凭证: {image_credential['username']}@{image_credential['endpoint']}" + ) + + # 加载全局配置 + 本地 .env,并通过部署参数注入到运行时环境变量。 + env_vars, env_file_exists, project_env_count = self._load_deploy_env_vars( + project_dir, + target.extra.get("env_vars") or {}, + ) + if env_vars: + if env_file_exists: + click.echo( + f" 📦 加载环境变量: {len(env_vars)} 项 " + f"(全局配置 + {project_env_count} 项 from .env)" + ) else: - click.secho(" ⚠️ 未配置镜像凭证 (KCR_USERNAME/KCR_PASSWORD),私有镜像可能无法拉取", fg="yellow") - - # 加载本地 .env 并注入到环境变量 - env_file = Path(project_dir) / ".env" - env_vars = {} - if env_file.exists(): - env_vars = self._load_project_env_vars(env_file) - click.echo(f" 📦 加载环境变量: {len(env_vars)} 项 from .env") - + click.echo(f" 📦 加载环境变量: {len(env_vars)} 项 from 全局配置") + if env_vars: request_data["env_vars"] = env_vars diff --git a/ksadk/detection/detector.py b/ksadk/detection/detector.py index 4e1ea430..c2eff8fe 100644 --- a/ksadk/detection/detector.py +++ b/ksadk/detection/detector.py @@ -30,6 +30,7 @@ class DetectionResult: entry_point: str package_path: str agent_variable: str = "root_agent" + runner_class: str = "" confidence: float = 0.0 @property @@ -117,6 +118,7 @@ def _check_config(self) -> Optional[DetectionResult]: ) entry_point = config.get("entry_point", default_entry_point) agent_variable = config.get("agent_variable", "root_agent") + runner_class = str(config.get("runner_class") or "").strip() entry_path = self.project_dir / str(entry_point).replace("\\", "/") if not entry_path.exists() or not entry_path.is_file(): return None @@ -136,6 +138,7 @@ def _check_config(self) -> Optional[DetectionResult]: entry_point=entry_point, package_path=str(package_path), agent_variable=agent_variable, + runner_class=runner_class, confidence=1.0 ) except Exception: diff --git a/ksadk/hermes_terminal.py b/ksadk/hermes_terminal.py index d8fad483..57e580f3 100644 --- a/ksadk/hermes_terminal.py +++ b/ksadk/hermes_terminal.py @@ -12,32 +12,24 @@ import signal import ssl import sys +from ctypes import wintypes from dataclasses import dataclass from typing import Any, Callable, Iterable, Mapping, Sequence from urllib.parse import urlsplit, urlunsplit -from ctypes import wintypes +from ksadk.terminal_exec_policy import ( + GENERIC_TERMINAL_EXEC_POLICY, + HERMES_TERMINAL_EXEC_POLICY, + SHELL_METACHARS, + TerminalExecPolicy, +) +from ksadk.terminal_exec_policy import ( + validate_terminal_exec_argv as validate_exec_argv_with_policy, +) TERMINAL_SUBPROTOCOL = "ks-terminal.v1" TERMINAL_WS_PATH = "/_ksadk/terminal/ws" -_SHELL_METACHARS = set("|&;<>()$`\\\n\r") -_FORBIDDEN_LAUNCHERS = { - "bash", - "sh", - "zsh", - "fish", - "python", - "python3", - "node", - "npx", - "pnpm", - "npm", - "yarn", - "uv", - "uvx", - "hermes", -} _PAIRING_PLATFORMS = { "discord", "dingtalk", @@ -54,35 +46,7 @@ "webhook", "weixin", "whatsapp", -} -_SINGLE_READONLY_COMMANDS = {"status", "doctor", "version", "insights"} -_NESTED_READONLY_COMMANDS: dict[str, dict[str, tuple[int, int]]] = { - "sessions": { - "list": (2, 2), - "show": (3, 3), - "export": (3, 3), - }, - "config": { - "show": (2, 2), - "check": (2, 2), - "path": (2, 2), - "env-path": (2, 2), - }, - "skills": { - "list": (2, 2), - "audit": (2, 2), - "check": (2, 2), - }, - "tools": { - "list": (2, 2), - }, - "cron": { - "list": (2, 2), - "status": (2, 2), - }, - "gateway": { - "status": (2, 2), - }, + "wpsxiezuo", } _WINDOWS_INPUT_FLAGS = { @@ -152,54 +116,18 @@ def detect_terminal_size() -> TerminalSize: def validate_hermes_exec_argv(argv: Iterable[str]) -> list[str]: - normalized = [str(item).strip() for item in argv] - if not normalized: - raise ValueError("Hermes exec requires a subcommand") - for item in normalized: - if not item: - raise ValueError("Hermes exec argv contains an empty argument") - if item.startswith("-"): - raise ValueError(f"Hermes exec does not allow shell/options: {item}") - if any(char in _SHELL_METACHARS for char in item): - raise ValueError(f"Hermes exec does not allow shell metacharacters: {item}") + return validate_exec_argv_with_policy(argv, policy=HERMES_TERMINAL_EXEC_POLICY) - top = normalized[0] - if top in _FORBIDDEN_LAUNCHERS: - raise ValueError(f"Hermes exec launcher is not allowed: {top}") - if top in _SINGLE_READONLY_COMMANDS: - if len(normalized) != 1: - raise ValueError(f"Hermes exec subcommand is not allowed: {' '.join(normalized)}") - return normalized - - nested = _NESTED_READONLY_COMMANDS.get(top) - if not nested or len(normalized) < 2: - raise ValueError(f"Hermes exec subcommand is not allowed: {' '.join(normalized)}") +def validate_terminal_exec_argv(argv: Iterable[str]) -> list[str]: + return validate_exec_argv_with_policy(argv, policy=GENERIC_TERMINAL_EXEC_POLICY) - sub = normalized[1] - bounds = nested.get(sub) - if not bounds: - raise ValueError(f"Hermes exec subcommand is not allowed: {' '.join(normalized)}") - min_len, max_len = bounds - if len(normalized) < min_len or len(normalized) > max_len: - raise ValueError(f"Hermes exec subcommand is not allowed: {' '.join(normalized)}") - return normalized +def build_terminal_exec_validator(policy: TerminalExecPolicy) -> Callable[[Iterable[str]], list[str]]: + def _validator(argv: Iterable[str]) -> list[str]: + return validate_exec_argv_with_policy(argv, policy=policy) -def validate_terminal_exec_argv(argv: Iterable[str]) -> list[str]: - normalized = [str(item).strip() for item in argv] - if not normalized: - raise ValueError("terminal exec requires argv") - for index, item in enumerate(normalized): - if not item: - raise ValueError("terminal exec argv contains an empty argument") - if any(char in _SHELL_METACHARS for char in item): - raise ValueError(f"terminal exec does not allow shell metacharacters: {item}") - if index == 0 and item.startswith("-"): - raise ValueError(f"terminal exec command is invalid: {item}") - if index == 0 and item in _FORBIDDEN_LAUNCHERS: - raise ValueError(f"terminal exec launcher is not allowed: {item}") - return normalized + return _validator def validate_hermes_pairing_argv(argv: Iterable[str]) -> list[str]: @@ -211,7 +139,7 @@ def validate_hermes_pairing_argv(argv: Iterable[str]) -> list[str]: raise ValueError("Hermes pairing argv contains an empty argument") if item.startswith("-"): raise ValueError(f"Hermes pairing does not allow shell/options: {item}") - if any(char in _SHELL_METACHARS for char in item): + if any(char in SHELL_METACHARS for char in item): raise ValueError(f"Hermes pairing does not allow shell metacharacters: {item}") action = normalized[0] diff --git a/ksadk/memory/adk/__init__.py b/ksadk/memory/adk/__init__.py index bc89b64f..4e3bc852 100644 --- a/ksadk/memory/adk/__init__.py +++ b/ksadk/memory/adk/__init__.py @@ -14,8 +14,12 @@ 环境变量: # 短期记忆 - KSADK_STM_BACKEND=local # local | sqlite | database - KSADK_STM_DB_URL= # 数据库 URL + KSADK_ADK_SESSION_BACKEND=database + KSADK_ADK_SESSION_URL= # ADK 数据库 URL + KSADK_SESSION_BACKEND=postgres # unified fallback + KSADK_SESSION_DSN= # unified fallback + KSADK_STM_BACKEND=local # legacy fallback + KSADK_STM_DB_URL= # legacy fallback # 长期记忆 KSADK_LTM_BACKEND=http # local | http diff --git a/ksadk/memory/adk/backends/sdk_ltm_backend.py b/ksadk/memory/adk/backends/sdk_ltm_backend.py index 25e65d8e..e6a7716c 100644 --- a/ksadk/memory/adk/backends/sdk_ltm_backend.py +++ b/ksadk/memory/adk/backends/sdk_ltm_backend.py @@ -22,9 +22,9 @@ import logging import time import uuid -from typing import Any, List +from typing import Any -from pydantic import ConfigDict +from pydantic import ConfigDict, Field from ksadk.memory.adk.backends.base_ltm_backend import BaseLongTermMemoryBackend @@ -72,6 +72,8 @@ class SdkLTMBackend(BaseLongTermMemoryBackend): agent_id: str = "" scene_id: str = DEFAULT_SCENE_ID last_error: str = "" + last_create_response: dict[str, Any] = Field(default_factory=dict) + last_session_status: dict[str, Any] = Field(default_factory=dict) _aicp_client: Any = None @@ -155,7 +157,7 @@ def _get_client(self): ) return self._aicp_client - def _build_conversation(self, event_strings: List[str]) -> list: + def _build_conversation(self, event_strings: list[str]) -> list: """将事件字符串列表转换为 Conversation 格式 每个 event_string 是 JSON: {"role":"user","parts":[{"text":"..."}]} @@ -194,7 +196,7 @@ def _effective_scene_id(self) -> str: return self.scene_id or DEFAULT_SCENE_ID def save_memory( - self, user_id: str, event_strings: List[str], **kwargs + self, user_id: str, event_strings: list[str], **kwargs ) -> bool: """调用 CreateMemorySdk 写入记忆 @@ -223,8 +225,8 @@ def save_memory( agent_id = metadata.get("agent_id") or self.agent_id session_id = metadata.get("session_id") or kwargs.get("session_id") params = { - "Namespace": memory_collection_id, - "UserId": user_id, + "MemoryCollectionId": memory_collection_id, + "AgentUserId": user_id, "SceneId": self._effective_scene_id(), "DataType": "conversation", "Data": {"Conversation": conversation}, @@ -239,7 +241,15 @@ def save_memory( f"user_id={user_id}, messages={len(conversation)}" ) - client.call("CreateMemorySdk", params, options={"IsPostJson": True}) + response = client.call( + "CreateMemorySdk", params, options={"IsPostJson": True} + ) + self.last_create_response = self._parse_json_response(response) or {} + self.last_session_status = { + "SessionId": session_id, + "AgentUserId": user_id, + "MemoryCollectionId": memory_collection_id, + } logger.info( f"Saved {len(conversation)} messages to AICP memory service " @@ -254,7 +264,7 @@ def save_memory( def search_memory( self, user_id: str, query: str, top_k: int = 5, **kwargs - ) -> List[str]: + ) -> list[str]: """调用 QueryMemorySdk 检索记忆 Args: @@ -272,8 +282,8 @@ def search_memory( try: self.last_error = "" params = { - "Namespace": memory_collection_id, - "UserId": user_id, + "MemoryCollectionId": memory_collection_id, + "AgentUserId": user_id, "Query": query, "Limit": top_k, "SceneId": self._effective_scene_id(), @@ -314,17 +324,59 @@ def search_memory( logger.error(f"QueryMemorySdk failed: {e}") return [] - def _parse_query_response(self, response: str) -> List[str]: + def get_session_status( + self, + *, + user_id: str, + session_id: str, + page_size: int = 20, + ) -> dict[str, Any] | None: + """Return raw AICP session status for a recently submitted memory session.""" + if not session_id: + return None + + client = self._get_client() + memory_collection_id = self._effective_memory_collection_id() + params = { + "MemoryCollectionId": memory_collection_id, + "AgentUserId": user_id, + "Page": 1, + "PageSize": page_size, + } + + try: + response = client.call("ListSessions", params, options={"IsPostJson": True}) + data = self._parse_json_response(response) + except Exception as e: + self.last_error = str(e) + logger.warning(f"ListSessions failed while checking memory status: {e}") + return None + + payload = data.get("Data") if isinstance(data, dict) else None + items = payload.get("Items") if isinstance(payload, dict) else None + if not isinstance(items, list): + return None + + for item in items: + if isinstance(item, dict) and item.get("SessionId") == session_id: + self.last_session_status = item + return item + return None + + def _parse_query_response(self, response: str) -> list[str]: """解析 QueryMemorySdk 响应 响应格式待 API 文档确认后完善。 当前按通用格式解析,兼容多种可能的返回结构。 """ try: - data = json.loads(response) if isinstance(response, str) else response + data = self._parse_json_response(response) except (json.JSONDecodeError, TypeError): logger.error(f"Failed to parse QueryMemorySdk response: {str(response)[:200]}") return [] + if not isinstance(data, dict): + logger.error(f"Failed to parse QueryMemorySdk response: {str(response)[:200]}") + return [] # 尝试多种可能的响应字段名 memories = [] @@ -340,6 +392,7 @@ def _parse_query_response(self, response: str) -> List[str]: text = ( item.get("Content") or item.get("Text") + or item.get("Memory") or item.get("Data") or json.dumps(item, ensure_ascii=False) ) @@ -348,15 +401,7 @@ def _parse_query_response(self, response: str) -> List[str]: # 格式 2: {"Data": [...]} elif "Data" in data and isinstance(data["Data"], list): for item in data["Data"]: - if isinstance(item, str): - memories.append(item) - elif isinstance(item, dict): - text = ( - item.get("Content") - or item.get("Text") - or json.dumps(item, ensure_ascii=False) - ) - memories.append(text) + memories.extend(self._parse_memory_item(item)) # 格式 3: {"Results": [...]} elif "Results" in data: @@ -367,6 +412,7 @@ def _parse_query_response(self, response: str) -> List[str]: text = ( item.get("Content") or item.get("Text") + or item.get("Memory") or json.dumps(item, ensure_ascii=False) ) memories.append(text) @@ -379,3 +425,32 @@ def _parse_query_response(self, response: str) -> List[str]: ) return memories + + def _parse_json_response(self, response: Any) -> Any: + if isinstance(response, str): + return json.loads(response) + return response + + def _parse_memory_item(self, item: Any) -> list[str]: + if isinstance(item, str): + return [item] + if not isinstance(item, dict): + return [] + + if isinstance(item.get("Memories"), list): + parsed: list[str] = [] + for memory in item["Memories"]: + parsed.extend(self._parse_memory_item(memory)) + return parsed + + text = ( + item.get("Content") + or item.get("Text") + or item.get("Memory") + or item.get("Data") + ) + if text is None: + return [] + if isinstance(text, (dict, list)): + return [json.dumps(text, ensure_ascii=False)] + return [str(text)] diff --git a/ksadk/memory/adk/short_term_memory.py b/ksadk/memory/adk/short_term_memory.py index 76f4c64e..1bb1ea9b 100644 --- a/ksadk/memory/adk/short_term_memory.py +++ b/ksadk/memory/adk/short_term_memory.py @@ -6,8 +6,10 @@ 参考 VeADK: veadk/memory/short_term_memory.py 环境变量: - KSADK_STM_BACKEND: 后端类型 (local / sqlite / database) - KSADK_STM_DB_URL: 数据库连接 URL (sqlite/database 时需要) + KSADK_SESSION_BACKEND: 统一 session 后端类型 (memory / local / sqlite / postgres) + KSADK_SESSION_DSN: 统一 session 数据库 DSN + KSADK_STM_BACKEND: 旧短期记忆后端类型 (local / sqlite / database) + KSADK_STM_DB_URL: 旧数据库连接 URL (sqlite/database 时需要) 使用示例: # InMemory (默认,开发测试) @@ -46,6 +48,8 @@ def _normalize_backend_name(backend: str) -> str: normalized = str(backend or "").strip().lower() if normalized == "memory": return "local" + if normalized == "postgres": + return "database" return normalized @@ -65,6 +69,10 @@ def _normalize_database_url(db_url: str) -> str: return normalized +def _session_backend_requires_database_url(backend: str) -> bool: + return _normalize_backend_name(backend) == "database" + + class ShortTermMemory(BaseModel): """短期记忆 - 会话管理 @@ -124,11 +132,9 @@ def model_post_init(self, __context: Any) -> None: case "database": if not self.db_url: - logger.warning( - "ShortTermMemory: backend='database' but no db_url. " - "Falling back to InMemorySessionService." + raise ValueError( + "KSADK_SESSION_DSN is required when ADK session backend resolves to database/postgres" ) - self._session_service = InMemorySessionService() else: self._init_database_service(self.db_url) @@ -238,15 +244,22 @@ def from_env(cls) -> "ShortTermMemory": KSADK_STM_BACKEND: 平台级 STM backend KSADK_STM_URL / KSADK_STM_DB_URL: 平台级数据库 URL KSADK_STM_PATH / KSADK_STM_DB_PATH: 平台级 SQLite 路径 + KSADK_SESSION_BACKEND: 统一 session backend fallback + KSADK_SESSION_DSN: 统一 session DSN fallback """ explicit_backend = _normalize_backend_name( - _env_first("KSADK_ADK_SESSION_BACKEND", "KSADK_STM_BACKEND") + _env_first( + "KSADK_ADK_SESSION_BACKEND", + "KSADK_STM_BACKEND", + "KSADK_SESSION_BACKEND", + ) ) db_url = _normalize_database_url( _env_first( "KSADK_ADK_SESSION_URL", "KSADK_STM_URL", "KSADK_STM_DB_URL", + "KSADK_SESSION_DSN", ) ) configured_db_path = _env_first( @@ -257,6 +270,10 @@ def from_env(cls) -> "ShortTermMemory": db_path = configured_db_path or "/tmp/ksadk_local_database.db" backend = explicit_backend + if _session_backend_requires_database_url(backend) and not db_url: + raise ValueError( + "KSADK_SESSION_DSN is required when ADK session backend resolves to database/postgres" + ) if not backend: if db_url: backend = "database" diff --git a/ksadk/memory/adk_tool.py b/ksadk/memory/adk_tool.py index 9ad851f8..5a20bd56 100644 --- a/ksadk/memory/adk_tool.py +++ b/ksadk/memory/adk_tool.py @@ -12,7 +12,11 @@ def save_memory(content: str) -> dict: """保存一条长期记忆。""" - return {"result": _save_memory(content)} + result = _save_memory(content) + return { + "result": result.get("message", ""), + **result, + } def create_adk_tool(): diff --git a/ksadk/memory/langchain_tool.py b/ksadk/memory/langchain_tool.py index e6108524..ece3de04 100644 --- a/ksadk/memory/langchain_tool.py +++ b/ksadk/memory/langchain_tool.py @@ -4,7 +4,8 @@ import logging -from ksadk.memory.tool import load_memory, save_memory +from ksadk.memory.tool import load_memory as _load_memory +from ksadk.memory.tool import save_memory as _save_memory logger = logging.getLogger(__name__) @@ -17,13 +18,13 @@ def create_langchain_tools(): def load_memory_tool(query: str) -> str: """检索当前用户的长期记忆。""" - return load_memory(query) + return _load_memory(query) @tool - def save_memory_tool(content: str) -> str: + def save_memory_tool(content: str) -> dict: """保存一条长期记忆。""" - return save_memory(content) + return _save_memory(content) load_memory_tool.name = "load_memory" save_memory_tool.name = "save_memory" @@ -32,7 +33,7 @@ def save_memory_tool(content: str) -> str: logger.warning( "langchain-core not installed, returning raw functions. Install with: pip install langchain-core" ) - return load_memory, save_memory + return _load_memory, _save_memory load_memory, save_memory = create_langchain_tools() diff --git a/ksadk/memory/tool.py b/ksadk/memory/tool.py index 8eb00a5d..c95ff8fe 100644 --- a/ksadk/memory/tool.py +++ b/ksadk/memory/tool.py @@ -32,10 +32,42 @@ def load_memory(query: str) -> str: return f"长期记忆检索失败: {exc}" -def save_memory(content: str) -> str: +def _is_sdk_backend(service: LongTermMemoryService) -> bool: + backend = getattr(service, "_backend", None) + return backend.__class__.__name__ == "SdkLTMBackend" + + +def _sdk_pending_result(service: LongTermMemoryService, context) -> dict: + backend = getattr(service, "_backend", None) + session_status = None + get_session_status = getattr(backend, "get_session_status", None) + if callable(get_session_status): + session_status = get_session_status( + user_id=context.user_id, + session_id=context.session_id, + ) + + result = { + "ok": False, + "status": "accepted_not_extracted", + "message": "记忆保存请求已被后端受理,但尚未抽取成可检索记忆。", + } + if isinstance(session_status, dict): + result["session_state"] = session_status.get("State") + result["session_id"] = session_status.get("SessionId") or context.session_id + elif context.session_id: + result["session_id"] = context.session_id + return result + + +def save_memory(content: str) -> dict: context = get_current_invocation_context() if context is None: - return "记忆保存失败: 缺少运行时上下文。" + return { + "ok": False, + "status": "failed", + "message": "记忆保存失败: 缺少运行时上下文。", + } try: service = _get_or_create_service() @@ -49,11 +81,16 @@ def save_memory(content: str) -> str: }, ) if ok: - return "记忆已保存。" + if _is_sdk_backend(service): + entries = service.search_entries(user_id=context.user_id, query=content, top_k=1) + if not entries: + return _sdk_pending_result(service, context) + return {"ok": True, "status": "persisted", "message": "记忆已保存。"} backend = getattr(service, "_backend", None) last_error = str(getattr(backend, "last_error", "") or "").strip() - return f"记忆保存失败: {last_error}" if last_error else "记忆保存失败。" + message = f"记忆保存失败: {last_error}" if last_error else "记忆保存失败。" + return {"ok": False, "status": "failed", "message": message} except Exception as exc: logger.error("save_memory failed: %s", exc) - return f"记忆保存失败: {exc}" + return {"ok": False, "status": "failed", "message": f"记忆保存失败: {exc}"} diff --git a/ksadk/openclaw_gateway.py b/ksadk/openclaw_gateway.py index 933269f1..68b468bd 100644 --- a/ksadk/openclaw_gateway.py +++ b/ksadk/openclaw_gateway.py @@ -19,6 +19,7 @@ DEFAULT_CLIENT_NAME = "openclaw-control-ui" DEFAULT_CLIENT_MODE = "webchat" DEFAULT_SCOPES = ("operator.admin",) +GATEWAY_PROTOCOL_VERSION = 4 class OpenClawGatewayError(RuntimeError): @@ -119,7 +120,7 @@ def has_method(self, method: str) -> bool: async def build_access_info( self, *, - path: str = "/", + path: Optional[str] = None, expires_seconds: Optional[int] = None, link_type: str = "private", force_new: bool = False, @@ -141,10 +142,10 @@ async def build_access_info( response = await asyncio.to_thread( self.session.get, access_url, - allow_redirects=True, + allow_redirects=False, timeout=30, ) - if response.status_code >= 400: + if response.status_code >= 400 or response.status_code < 300: raise OpenClawGatewayError( f"Dashboard short-link bootstrap failed: HTTP {response.status_code}" ) @@ -169,7 +170,7 @@ async def build_access_info( async def connect( self, *, - path: str = "/", + path: Optional[str] = None, expires_seconds: Optional[int] = None, link_type: str = "private", ) -> dict[str, Any]: @@ -189,8 +190,8 @@ async def connect( self.hello = await self.request( "connect", { - "minProtocol": 3, - "maxProtocol": 3, + "minProtocol": GATEWAY_PROTOCOL_VERSION, + "maxProtocol": GATEWAY_PROTOCOL_VERSION, "client": { "id": self.client_name, "displayName": "ksadk openclaw channel", diff --git a/ksadk/runners/adk_runner.py b/ksadk/runners/adk_runner.py index e524ac20..b498b84e 100644 --- a/ksadk/runners/adk_runner.py +++ b/ksadk/runners/adk_runner.py @@ -107,7 +107,8 @@ def _init_short_term_memory(self): 环境变量: KSADK_ADK_SESSION_BACKEND / PATH / URL: ADK 专用 session 配置 - KSADK_STM_BACKEND / PATH / URL: 平台级 STM 配置 + KSADK_STM_BACKEND / PATH / URL: 旧平台级 STM 配置 + KSADK_SESSION_BACKEND / DSN: 统一 session 配置 fallback """ configured_names = ( "KSADK_ADK_SESSION_BACKEND", @@ -118,6 +119,8 @@ def _init_short_term_memory(self): "KSADK_STM_URL", "KSADK_STM_DB_PATH", "KSADK_STM_DB_URL", + "KSADK_SESSION_BACKEND", + "KSADK_SESSION_DSN", ) if not any(str(os.environ.get(name, "")).strip() for name in configured_names): return None diff --git a/ksadk/runners/base_runner.py b/ksadk/runners/base_runner.py index ea8876f6..f6d82072 100644 --- a/ksadk/runners/base_runner.py +++ b/ksadk/runners/base_runner.py @@ -69,9 +69,13 @@ def prepare_for_request(self, model: Optional[str]) -> None: """在请求进入实际 runner 前同步模型或做必要刷新。""" self.sync_process_model_env(model) - def request_cancel(self, invocation_id: str) -> None: - """请求取消指定调用。默认 no-op,子类可 override 实现真正的取消。""" - pass + def request_cancel(self, invocation_id: str) -> str: + """请求取消指定调用。 + + 返回值用于 API 层区分真实取消、未命中和不支持取消的边界。 + 子类可返回 ``accepted``、``not_found`` 或 ``unsupported``。 + """ + return "unsupported" async def close(self) -> None: """释放 runner 持有的运行期资源。""" diff --git a/ksadk/runners/factory.py b/ksadk/runners/factory.py index 178d2f83..963c524d 100644 --- a/ksadk/runners/factory.py +++ b/ksadk/runners/factory.py @@ -2,6 +2,9 @@ Runner Factory - 根据检测结果创建对应的 Runner """ +import importlib +import sys +from pathlib import Path from typing import TYPE_CHECKING from ksadk.detection import DetectionResult, FrameworkType from ksadk.runners.base_runner import BaseRunner @@ -28,6 +31,28 @@ def create_runner(detection_result: DetectionResult, project_dir: str) -> BaseRu except ImportError: pass + custom_runner_class = str(getattr(detection_result, "runner_class", "") or "").strip() + if custom_runner_class: + project_path = Path(project_dir).resolve() + for candidate in (project_path, project_path / "src"): + if candidate.is_dir() and str(candidate) not in sys.path: + sys.path.insert(0, str(candidate)) + module_name, separator, class_name = custom_runner_class.rpartition(".") + if not separator or not module_name or not class_name: + raise ValueError( + "runner_class must use a fully-qualified class path, " + "for example 'agent.CustomRunner'" + ) + try: + module = importlib.import_module(module_name) + runner_class = getattr(module, class_name) + except (ImportError, AttributeError) as exc: + raise ValueError(f"无法加载自定义 Runner: {custom_runner_class}") from exc + if not isinstance(runner_class, type) or not issubclass(runner_class, BaseRunner): + raise TypeError(f"自定义 Runner 必须继承 BaseRunner: {custom_runner_class}") + runner = runner_class(detection_result, project_dir) + return runner + if detection_result.type == FrameworkType.ADK: from ksadk.runners.adk_runner import ADKRunner return ADKRunner(detection_result, project_dir) diff --git a/ksadk/runners/langgraph_runner.py b/ksadk/runners/langgraph_runner.py index 8573c5df..bfaa0f0d 100644 --- a/ksadk/runners/langgraph_runner.py +++ b/ksadk/runners/langgraph_runner.py @@ -16,6 +16,7 @@ from ksadk.runners.utils import get_langfuse_callback, get_langfuse_metadata, load_agent_module from langgraph.types import Command from ksadk.conversations.attachments import classify_attachment_kind, read_attachment_bytes +from ksadk.conversations.reasoning_markup import ReasoningMarkupParser, strip_reasoning_markup class LangGraphRunner(BaseRunner): @@ -64,6 +65,89 @@ def _get_config(self, session_id: str) -> dict: return config + @staticmethod + def _extract_langgraph_checkpoint_ref(payload: Dict[str, Any]) -> dict[str, Any]: + framework_ref = payload.get("framework_ref") or {} + if not isinstance(framework_ref, dict): + return {} + langgraph_ref = framework_ref.get("langgraph") or {} + if not isinstance(langgraph_ref, dict): + return {} + return dict(langgraph_ref) + + @classmethod + def _apply_checkpoint_resume_config( + cls, + config: dict[str, Any], + *, + session_id: str, + checkpoint_ref: dict[str, Any], + ) -> dict[str, Any]: + checkpoint_id = str(checkpoint_ref.get("checkpoint_id") or "").strip() + if not checkpoint_id: + raise ValueError("checkpoint_resume requires framework_ref.langgraph.checkpoint_id") + + thread_id = str(checkpoint_ref.get("thread_id") or session_id or "").strip() + if not thread_id: + raise ValueError("checkpoint_resume requires session_id or framework_ref.langgraph.thread_id") + + next_config = dict(config) + configurable = dict(next_config.get("configurable") or {}) + configurable["thread_id"] = thread_id + checkpoint_ns = str(checkpoint_ref.get("checkpoint_ns") or "").strip() + if checkpoint_ns: + configurable["checkpoint_ns"] = checkpoint_ns + configurable["checkpoint_id"] = checkpoint_id + next_config["configurable"] = configurable + return next_config + + @staticmethod + def _checkpoint_ref_from_state(state: Any) -> dict[str, Any]: + state_config = None + if isinstance(state, dict): + state_config = state.get("config") + else: + state_config = getattr(state, "config", None) + if not isinstance(state_config, dict): + return {} + configurable = state_config.get("configurable") or {} + if not isinstance(configurable, dict): + return {} + thread_id = str(configurable.get("thread_id") or "").strip() + checkpoint_id = str(configurable.get("checkpoint_id") or "").strip() + if not thread_id or not checkpoint_id: + return {} + return { + "langgraph": { + "thread_id": thread_id, + **( + {"checkpoint_ns": str(configurable.get("checkpoint_ns")).strip()} + if str(configurable.get("checkpoint_ns") or "").strip() + else {} + ), + "checkpoint_id": checkpoint_id, + } + } + + async def _latest_checkpoint_metadata(self, config: dict[str, Any]) -> dict[str, Any]: + state = None + try: + if callable(getattr(self._agent, "aget_state", None)): + state = await self._agent.aget_state(config) + elif callable(getattr(self._agent, "get_state", None)): + state = self._agent.get_state(config) + except Exception: + return {} + framework_ref = self._checkpoint_ref_from_state(state) + if not framework_ref: + return {} + return { + "agentengine": { + "framework": "langgraph", + "framework_ref": framework_ref, + } + } + @staticmethod def _ambient_context_text(payload: Dict[str, Any]) -> str: sections: list[str] = [] @@ -294,14 +378,24 @@ async def invoke(self, input_data: Dict[str, Any]) -> Dict[str, Any]: payload = dict(input_data) session_id = payload.pop("session_id", None) or str(uuid.uuid4())[:8] is_resume = payload.pop("resume", False) + is_checkpoint_resume = bool(payload.pop("checkpoint_resume", False)) + checkpoint_ref = self._extract_langgraph_checkpoint_ref(payload) history = payload.pop("history", []) native_context = self.build_native_context(payload.get("platform_context")) normalized_payload = self._strip_platform_context_fields(payload) config = self._get_config(session_id) + if is_checkpoint_resume: + config = self._apply_checkpoint_resume_config( + config, + session_id=session_id, + checkpoint_ref=checkpoint_ref, + ) # 判断输入格式 / resume - if self._has_prepare_state_hook(): + if is_checkpoint_resume: + state = None + elif self._has_prepare_state_hook(): state = self._prepare_state_with_hook(payload, session_id, history, is_resume=is_resume) elif is_resume: if "input" in normalized_payload and len(normalized_payload) == 1: @@ -312,7 +406,13 @@ async def invoke(self, input_data: Dict[str, Any]) -> Dict[str, Any]: state = self._to_state(payload, history) try: - if is_resume: + if is_checkpoint_resume: + result = await self._invoke_graph( + None, + config=config, + context=native_context, + ) + elif is_resume: result = await self._invoke_graph( Command(resume=state), config=config, @@ -325,7 +425,11 @@ async def invoke(self, input_data: Dict[str, Any]) -> Dict[str, Any]: context=native_context, ) - return {"output": self._extract_output(result), "raw": result} + output = {"output": self._extract_output(result), "raw": result} + metadata = await self._latest_checkpoint_metadata(config) + if metadata: + output["metadata"] = metadata + return output except Exception as e: if "Interrupt" in type(e).__name__: @@ -371,6 +475,8 @@ async def stream(self, input_data: Dict[str, Any]) -> AsyncIterator[Dict[str, An session_id = payload.pop("session_id", None) or str(uuid.uuid4())[:8] history = payload.pop("history", []) is_resume = payload.pop("resume", False) + is_checkpoint_resume = bool(payload.pop("checkpoint_resume", False)) + checkpoint_ref = self._extract_langgraph_checkpoint_ref(payload) native_context = self.build_native_context(payload.get("platform_context")) normalized_payload = self._strip_platform_context_fields(payload) @@ -380,10 +486,20 @@ async def stream(self, input_data: Dict[str, Any]) -> AsyncIterator[Dict[str, An invoke_payload["history"] = history if is_resume: invoke_payload["resume"] = True + if is_checkpoint_resume: + invoke_payload["checkpoint_resume"] = True config = self._get_config(session_id) + if is_checkpoint_resume: + config = self._apply_checkpoint_resume_config( + config, + session_id=session_id, + checkpoint_ref=checkpoint_ref, + ) - if self._has_prepare_state_hook(): + if is_checkpoint_resume: + state = None + elif self._has_prepare_state_hook(): state = self._prepare_state_with_hook(payload, session_id, history, is_resume=is_resume) elif is_resume: if "input" in normalized_payload and len(normalized_payload) == 1: @@ -395,6 +511,7 @@ async def stream(self, input_data: Dict[str, Any]) -> AsyncIterator[Dict[str, An accumulated_text = "" accumulated_reasoning = "" + inline_reasoning_parser = ReasoningMarkupParser() emitted_non_text_event = False final_output_text = "" @@ -404,7 +521,7 @@ async def stream(self, input_data: Dict[str, Any]) -> AsyncIterator[Dict[str, An return try: - stream_input = Command(resume=state) if is_resume else state + stream_input = None if is_checkpoint_resume else (Command(resume=state) if is_resume else state) stream_kwargs = {"version": "v2", "config": config} if native_context and self._callable_accepts_keyword(self._agent.astream_events, "context"): stream_kwargs["context"] = native_context @@ -433,9 +550,16 @@ async def stream(self, input_data: Dict[str, Any]) -> AsyncIterator[Dict[str, An content = content[len(accumulated_reasoning):] elif reasoning and content.startswith(reasoning): content = content[len(reasoning):] - if content and content.strip(): - accumulated_text += content - yield {"delta": content, "type": "text"} + if content: + for part in inline_reasoning_parser.feed(content): + if not part.text or not part.text.strip(): + continue + if part.kind == "thinking": + accumulated_reasoning += part.text + yield {"delta": part.text, "type": "thinking"} + else: + accumulated_text += part.text + yield {"delta": part.text, "type": "text"} elif event_kind == "on_tool_start": emitted_non_text_event = True @@ -465,7 +589,7 @@ async def stream(self, input_data: Dict[str, Any]) -> AsyncIterator[Dict[str, An return extracted_output = self._extract_output(output) if extracted_output: - final_output_text = str(extracted_output) + final_output_text = strip_reasoning_markup(str(extracted_output)) except Exception as e: if "Interrupt" in type(e).__name__: @@ -473,12 +597,30 @@ async def stream(self, input_data: Dict[str, Any]) -> AsyncIterator[Dict[str, An return raise + for part in inline_reasoning_parser.flush(): + if not part.text or not part.text.strip(): + continue + if part.kind == "thinking": + accumulated_reasoning += part.text + yield {"delta": part.text, "type": "thinking"} + else: + accumulated_text += part.text + yield {"delta": part.text, "type": "text"} + if not accumulated_text: if final_output_text: yield {"output": final_output_text, "type": "final"} elif not emitted_non_text_event: result = await self.invoke(invoke_payload) yield {"output": result.get("output", ""), "type": "final"} + metadata = result.get("metadata") if isinstance(result, dict) else None + if isinstance(metadata, dict) and metadata.get("agentengine"): + yield {"type": "checkpoint", "metadata": metadata} + return + + metadata = await self._latest_checkpoint_metadata(config) + if metadata: + yield {"type": "checkpoint", "metadata": metadata} def _filter_tool_tags(self, content: str) -> str: """过滤 标签""" diff --git a/ksadk/runners/remote_runner.py b/ksadk/runners/remote_runner.py index cd3e6c99..29726e7f 100644 --- a/ksadk/runners/remote_runner.py +++ b/ksadk/runners/remote_runner.py @@ -106,6 +106,100 @@ def _build_responses_input(user_input: Any) -> Any: return items return str(user_input or "") + @staticmethod + def _build_responses_conversation_history(history: Any, current_input: Any) -> list[dict[str, Any]]: + if not isinstance(history, Sequence) or isinstance( + history, (str, bytes, bytearray) + ): + return [] + + messages: list[dict[str, Any]] = [] + current_text = RemoteRunner._responses_message_text( + {"role": "user", "content": current_input} + ).strip() + for item in history: + if not isinstance(item, Mapping): + continue + role = str(item.get("role") or "").strip().lower() + if role == "model": + role = "assistant" + if role not in {"user", "assistant"}: + continue + text = RemoteRunner._responses_message_text(item).strip() + if not text: + continue + if role == "user" and current_text and text == current_text: + continue + messages.append( + { + "role": role, + "content": [{"type": "input_text", "text": text}], + } + ) + return messages + + @staticmethod + def _responses_conversation_name(input_data: Mapping[str, Any], session_id: Optional[str]) -> str: + if not session_id: + return "" + platform_context = input_data.get("platform_context") + agent_id = "" + if isinstance(platform_context, Mapping): + agent_id = str(platform_context.get("agent_id") or "").strip() + if agent_id: + return f"agentengine:{agent_id}:{session_id}" + return f"agentengine:{session_id}" + + @staticmethod + def _responses_conversation_value(value: Any) -> str: + if isinstance(value, Mapping): + return str(value.get("id") or "").strip() + return str(value or "").strip() + + def _build_responses_payload( + self, + input_data: Mapping[str, Any], + *, + stream: bool, + ) -> dict[str, Any]: + user_input = input_data.get("input", "") + session_id = input_data.get("session_id") or self.session_id + previous_response_id = input_data.get("previous_response_id") + + if self.responses_session_header: + payload = { + "input": self._build_responses_input(user_input), + "stream": stream, + } + else: + payload = { + "input": self._build_responses_input(user_input), + "stream": stream, + } + history_enabled = bool(input_data.get("responses_conversation")) and not previous_response_id + if history_enabled: + history = self._build_responses_conversation_history( + input_data.get("history"), + user_input, + ) + if history: + payload["conversation_history"] = history + conversation = self._responses_conversation_value(input_data.get("conversation")) + if conversation and not previous_response_id: + payload["conversation"] = conversation + elif ( + input_data.get("responses_conversation") + and session_id + and not previous_response_id + ): + conversation = self._responses_conversation_name(input_data, str(session_id)) + if conversation: + payload["conversation"] = conversation + + if previous_response_id: + payload["previous_response_id"] = str(previous_response_id) + return payload + @staticmethod def _is_chat_style_message(value: Mapping[str, Any]) -> bool: return not value.get("type") and ("role" in value or "content" in value) @@ -145,21 +239,15 @@ async def invoke(self, input_data: Dict[str, Any]) -> Dict[str, Any]: if self.api_format == "responses": url = f"{self.endpoint}/v1/responses" - payload = { - "input": self._build_responses_input(user_input), - "stream": False, - } + payload = self._build_responses_payload(input_data, stream=False) else: url = f"{self.endpoint}/v1/chat/completions" payload = { "messages": [{"role": "user", "content": user_input}], "stream": False, } - if session_id and not (self.api_format == "responses" and self.responses_session_header): + if session_id and self.api_format != "responses": payload["session_id"] = session_id - previous_response_id = input_data.get("previous_response_id") - if self.api_format == "responses" and previous_response_id: - payload["previous_response_id"] = str(previous_response_id) if self.model: payload["model"] = self.model @@ -188,21 +276,15 @@ async def stream(self, input_data: Dict[str, Any]) -> AsyncIterator[Dict[str, An if self.api_format == "responses": url = f"{self.endpoint}/v1/responses" - payload = { - "input": self._build_responses_input(user_input), - "stream": True, - } + payload = self._build_responses_payload(input_data, stream=True) else: url = f"{self.endpoint}/v1/chat/completions" payload = { "messages": [{"role": "user", "content": user_input}], "stream": True, } - if session_id and not (self.api_format == "responses" and self.responses_session_header): + if session_id and self.api_format != "responses": payload["session_id"] = session_id - previous_response_id = input_data.get("previous_response_id") - if self.api_format == "responses" and previous_response_id: - payload["previous_response_id"] = str(previous_response_id) if self.model: payload["model"] = self.model diff --git a/ksadk/sandbox/backends/e2b.py b/ksadk/sandbox/backends/e2b.py index 470b7196..55ac6b2b 100644 --- a/ksadk/sandbox/backends/e2b.py +++ b/ksadk/sandbox/backends/e2b.py @@ -1,5 +1,7 @@ from __future__ import annotations +import os +import time from typing import Any from ksadk.sandbox.base import ( @@ -10,6 +12,49 @@ SandboxSpec, ) +_TRANSIENT_STARTUP_ERROR_NAMES = { + "NotFoundException", + "FileNotFoundException", + "SandboxNotFoundException", +} + + +def _startup_retry_attempts() -> int: + raw = os.environ.get("KSADK_SANDBOX_STARTUP_RETRY_ATTEMPTS", "6") + try: + return max(1, int(raw)) + except ValueError: + return 6 + + +def _startup_retry_delay() -> float: + raw = os.environ.get("KSADK_SANDBOX_STARTUP_RETRY_DELAY", "0.2") + try: + return max(0.0, float(raw)) + except ValueError: + return 0.2 + + +def _is_transient_startup_error(exc: Exception) -> bool: + return type(exc).__name__ in _TRANSIENT_STARTUP_ERROR_NAMES + + +def _with_startup_retry(operation): + attempts = _startup_retry_attempts() + delay = _startup_retry_delay() + last_exc: Exception | None = None + for attempt in range(attempts): + try: + return operation() + except Exception as exc: + if not _is_transient_startup_error(exc) or attempt >= attempts - 1: + raise + last_exc = exc + time.sleep(min(delay * (2**attempt), 1.0)) + if last_exc is not None: + raise last_exc + raise SandboxError("E2B sandbox startup retry failed unexpectedly") + class E2BSandboxSession: def __init__(self, sandbox: Any): @@ -92,7 +137,15 @@ def create_session( envs=runtime_env, allow_internet_access=self.spec.allow_internet_access, ) - session = E2BSandboxSession(sandbox) + session = self._wrap_sandbox(sandbox) + self._wait_until_ready(session) for item in input_files or []: session.write_file(item.target_path, item.source.read_bytes()) return session + + def _wrap_sandbox(self, sandbox: Any) -> E2BSandboxSession: + return E2BSandboxSession(sandbox) + + def _wait_until_ready(self, session: E2BSandboxSession) -> None: + _with_startup_retry(lambda: session.run_command("true")) + _with_startup_retry(lambda: session.write_file("/tmp/.ksadk-sandbox-ready", "")) diff --git a/ksadk/sessions/continuity.py b/ksadk/sessions/continuity.py index 6f361aa7..7f33e35d 100644 --- a/ksadk/sessions/continuity.py +++ b/ksadk/sessions/continuity.py @@ -302,6 +302,8 @@ def continuity_status( "KSADK_STM_URL", "KSADK_STM_DB_PATH", "KSADK_STM_DB_URL", + "KSADK_SESSION_BACKEND", + "KSADK_SESSION_DSN", ) ) return SessionContinuityStatus( diff --git a/ksadk/terminal_client.py b/ksadk/terminal_client.py index 2e278869..9e3e4539 100644 --- a/ksadk/terminal_client.py +++ b/ksadk/terminal_client.py @@ -14,11 +14,13 @@ TERMINAL_WS_PATH, TerminalSize, build_start_frame, + build_terminal_exec_validator, build_terminal_ws_url, detect_terminal_size, run_hermes_terminal_session, validate_terminal_exec_argv, ) +from ksadk.terminal_exec_policy import GENERIC_TERMINAL_EXEC_POLICY, TerminalExecPolicy async def run_terminal_session( @@ -31,6 +33,7 @@ async def run_terminal_session( argv: Sequence[str] | None = None, cwd: str | None = None, options: Mapping[str, Any] | None = None, + exec_policy: TerminalExecPolicy | None = None, stdin: Any | None = None, stdout: Any | None = None, ) -> int: @@ -47,7 +50,11 @@ async def run_terminal_session( options=options, stdin=stdin, stdout=stdout, - exec_argv_validator=validate_terminal_exec_argv, + exec_argv_validator=( + build_terminal_exec_validator(exec_policy) + if exec_policy is not None and exec_policy != GENERIC_TERMINAL_EXEC_POLICY + else validate_terminal_exec_argv + ), ) diff --git a/ksadk/tools/gateway.py b/ksadk/tools/gateway.py index 68352f8e..44fd7329 100644 --- a/ksadk/tools/gateway.py +++ b/ksadk/tools/gateway.py @@ -1,6 +1,8 @@ from __future__ import annotations import os +import hashlib +import json from collections.abc import Callable, Mapping, Sequence from dataclasses import dataclass, field from typing import Any @@ -104,6 +106,36 @@ def tool_policy_requires_approval( return policy.risk_level.lower() in {"medium", "high", "critical"} +def _canonical_tool_args(tool_args: Any) -> str: + try: + return json.dumps(tool_args or {}, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + except TypeError: + return json.dumps(str(tool_args), ensure_ascii=False, separators=(",", ":")) + + +def build_tool_receipt_idempotency_key( + *, + session_id: str, + run_id: str, + checkpoint_id: str | None = None, + tool_call_id: str | None = None, + tool_name: str, + tool_args: Any = None, +) -> str: + payload = { + "session_id": str(session_id or ""), + "run_id": str(run_id or ""), + "checkpoint_id": str(checkpoint_id or ""), + "tool_call_id": str(tool_call_id or ""), + "tool_name": str(tool_name or ""), + "tool_args": _canonical_tool_args(tool_args), + } + digest = hashlib.sha256( + json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8") + ).hexdigest() + return f"tool_receipt:{digest}" + + def approval_interrupt_info_from_result( result: Any, *, diff --git a/ksadk/toolsets/__init__.py b/ksadk/toolsets/__init__.py index 73331f11..300d2bd1 100644 --- a/ksadk/toolsets/__init__.py +++ b/ksadk/toolsets/__init__.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json from collections.abc import Iterable from collections.abc import Mapping from typing import Any @@ -135,8 +136,8 @@ def describe_agentengine_tools(include: Iterable[str] | None = None) -> list[dic def agentengine_tool_dispatcher( action: str, tool_name: str | None = None, - arguments: dict[str, Any] | None = None, - include: Iterable[str] | str | None = None, + arguments: dict[str, Any] | str | None = None, + include: str | Iterable[str] | None = None, ) -> dict[str, Any]: """List, describe, or call less frequently bound AgentEngine built-in tools.""" @@ -172,9 +173,15 @@ def agentengine_tool_dispatcher( tools, _ = _select_agentengine_tools(include=[target_name], include_dispatcher=False) except ValueError: return _unknown_tool_error(target_name) - result = _invoke_tool(tools[0], dict(arguments or {})) + tool_arguments, arguments_error = _normalize_tool_arguments(arguments) + if arguments_error: + return arguments_error + tool_arguments = _normalize_dispatched_tool_arguments(target_name, tool_arguments) + result = _invoke_tool(tools[0], tool_arguments) if isinstance(result, dict) and result.get("type") == "approval_required": return {**result, "dispatched_tool_name": target_name} + if isinstance(result, dict) and result.get("ok") is False: + return {"ok": False, "tool_name": target_name, "result": result} return {"ok": True, "tool_name": target_name, "result": result} return { @@ -256,7 +263,7 @@ def _build_descriptor_registry(*, include_dispatcher: bool) -> dict[str, dict[st for platform_tool in get_platform_tools(): name = _tool_name(platform_tool) if name and name not in registry: - registry[name] = { + spec = { "name": name, "group": "platform", "description": str(getattr(platform_tool, "description", "") or ""), @@ -265,6 +272,10 @@ def _build_descriptor_registry(*, include_dispatcher: bool) -> dict[str, dict[st "side_effects": [], "enabled": True, } + args = _tool_args(platform_tool) + if args: + spec["args"] = args + registry[name] = spec if include_dispatcher: registry[_DISPATCHER_TOOL_NAME] = _tool_spec( group="dispatcher", @@ -298,6 +309,60 @@ def _normalize_tool_name(tool_name: str | None) -> str: return str(tool_name or "").strip() +def _normalize_tool_arguments(arguments: dict[str, Any] | str | None) -> tuple[dict[str, Any], dict[str, Any] | None]: + if arguments is None: + return {}, None + if isinstance(arguments, dict): + return dict(arguments), None + if isinstance(arguments, str): + text = arguments.strip() + if not text: + return {}, None + try: + parsed = json.loads(text) + except json.JSONDecodeError as exc: + return {}, { + "ok": False, + "error_type": "invalid_arguments_json", + "error_message": f"arguments must be a JSON object string: {exc.msg}", + "arguments": arguments, + } + if isinstance(parsed, dict): + return parsed, None + return {}, { + "ok": False, + "error_type": "invalid_arguments_type", + "error_message": "arguments JSON string must decode to an object", + "arguments_type": type(parsed).__name__, + } + return {}, { + "ok": False, + "error_type": "invalid_arguments_type", + "error_message": "arguments must be a dictionary or JSON object string", + "arguments_type": type(arguments).__name__, + } + + +def _normalize_dispatched_tool_arguments(tool_name: str, arguments: dict[str, Any]) -> dict[str, Any]: + if tool_name == "save_memory" and "content" not in arguments: + if "key" in arguments and "value" in arguments: + return {"content": f"{arguments['key']}: {_stringify_memory_value(arguments['value'])}"} + return arguments + + +def _stringify_memory_value(value: Any) -> str: + if isinstance(value, str): + return value + return json.dumps(value, ensure_ascii=False) + + +def _tool_args(tool: Any) -> dict[str, Any]: + args = getattr(tool, "args", None) + if isinstance(args, dict): + return dict(args) + return {} + + def _tool_name(tool: Any) -> str: return str(getattr(tool, "name", None) or getattr(tool, "__name__", "") or "") diff --git a/ksadk/tracing/setup.py b/ksadk/tracing/setup.py index 8b5d00ea..557bfd10 100644 --- a/ksadk/tracing/setup.py +++ b/ksadk/tracing/setup.py @@ -57,7 +57,12 @@ def _parse_otlp_headers(raw: str) -> dict[str, str]: key = key.strip() if not key: continue - headers[key] = unquote(value.strip()) + decoded_value = unquote(value.strip()) + if key.lower() == "authorization": + scheme, sep, credential = decoded_value.partition("+") + if sep and scheme.lower() in {"basic", "bearer"}: + decoded_value = f"{scheme} {credential}" + headers[key] = decoded_value return headers diff --git a/scripts/ci-frontend-check.sh b/scripts/ci-frontend-check.sh new file mode 100755 index 00000000..37dec95c --- /dev/null +++ b/scripts/ci-frontend-check.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "$0")/.." && pwd)" +cd "$repo_root" + +make sync-ksadk-web-static + +if [ ! -f ksadk/server/static/index.html ]; then + echo "FAIL: ksadk/server/static/index.html missing" + exit 1 +fi + +if ! ls ksadk/server/static/assets/*.js >/dev/null 2>&1; then + echo "FAIL: synced static bundle is missing JS assets" + exit 1 +fi + +if ! ls ksadk/server/static/assets/*.css >/dev/null 2>&1; then + echo "FAIL: synced static bundle is missing CSS assets" + exit 1 +fi + +echo "PASS: KsADK Web static sync check OK" diff --git a/scripts/debug_aicp_memory.py b/scripts/debug_aicp_memory.py new file mode 100644 index 00000000..f3a36e8d --- /dev/null +++ b/scripts/debug_aicp_memory.py @@ -0,0 +1,353 @@ +#!/usr/bin/env python3 +"""Debug AICP memory collection resources and SDK read/write behavior. + +Examples: + python ksadk-python/scripts/debug_aicp_memory.py service-status + python ksadk-python/scripts/debug_aicp_memory.py list + python ksadk-python/scripts/debug_aicp_memory.py get --memory-id mem-xxx + python ksadk-python/scripts/debug_aicp_memory.py create --name demo --description "debug" + python ksadk-python/scripts/debug_aicp_memory.py write \ + --memory-id mem-xxx --user-id debug-user --text ping + python ksadk-python/scripts/debug_aicp_memory.py query \ + --memory-id mem-xxx --user-id debug-user --query ping + python ksadk-python/scripts/debug_aicp_memory.py list-sessions \ + --memory-id mem-xxx --user-id debug-user + python ksadk-python/scripts/debug_aicp_memory.py session-memories \ + --memory-id mem-xxx --session-id sess-xxx + python ksadk-python/scripts/debug_aicp_memory.py metrics --memory-id mem-xxx +""" + +from __future__ import annotations + +import argparse +import copy +import json +import os +import sys +import time +import uuid +from pathlib import Path +from typing import Any + + +def load_simple_dotenv(env_file: str | None = None) -> None: + """Load .env files without requiring python-dotenv.""" + candidates = [] + if env_file: + candidates.append(Path(env_file).expanduser()) + candidates.extend( + [ + Path.cwd() / ".env", + Path.cwd().parent / ".env", + Path(__file__).resolve().parents[1] / ".env", + Path(__file__).resolve().parents[2] / ".env", + Path(__file__).resolve().parents[3] / ".env", + ] + ) + + for env_path in candidates: + if not env_path.exists(): + continue + for raw_line in env_path.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + key = key.strip() + value = value.strip().strip("'").strip('"') + os.environ.setdefault(key, value) + + +def build_client(): + from ksyun.client.aicp.v20251114 import client + from ksyun.common import credential + from ksyun.common.profile.client_profile import ClientProfile + from ksyun.common.profile.http_profile import HttpProfile + + access_key = os.getenv("KSADK_LTM_ACCESS_KEY") or os.getenv("KSYUN_ACCESS_KEY") + secret_key = os.getenv("KSADK_LTM_SECRET_KEY") or os.getenv("KSYUN_SECRET_KEY") + region = os.getenv("KSADK_LTM_REGION", "cn-beijing-6") + endpoint = os.getenv("KSADK_LTM_ENDPOINT", "aicp.api.ksyun.com") + scheme = os.getenv("KSADK_LTM_SCHEME", "https") + + if not access_key or not secret_key: + raise SystemExit( + "Missing AK/SK. Set KSADK_LTM_ACCESS_KEY/SECRET_KEY " + "or KSYUN_ACCESS_KEY/SECRET_KEY." + ) + + cred = credential.Credential(access_key, secret_key) + http = HttpProfile() + http.endpoint = endpoint + http.reqMethod = "POST" + http.reqTimeout = 60 + http.scheme = scheme + + profile = ClientProfile() + profile.httpProfile = http + + cli = client.AicpClient(cred, region, profile=profile) + return cli, { + "region": region, + "endpoint": endpoint, + "scheme": scheme, + "access_key_tail": access_key[-6:], + } + + +def print_json(data: Any) -> None: + print(json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True)) + + +def safe_call(label: str, func, request=None): + try: + body = func(request) if request is not None else func() + parsed = json.loads(body) if isinstance(body, str) else body + return {"ok": True, "action": label, "response": parsed} + except Exception as exc: # noqa: BLE001 + return { + "ok": False, + "action": label, + "error_type": type(exc).__name__, + "error": str(exc), + } + + +def cmd_service_status(cli): + from ksyun.client.aicp.v20251114 import models + + req = models.GetMemoryBaseServiceRequest() + return safe_call("GetMemoryBaseService", cli.GetMemoryBaseService, req) + + +def cmd_list(cli, args): + from ksyun.client.aicp.v20251114 import models + + req = models.ListMemoryCollectionsRequest() + if args.name_keyword: + req.NameKeyword = args.name_keyword + if args.name: + req.Name = args.name + if args.memory_id: + req.MemoryCollectionId = args.memory_id + if args.status: + req.Status = args.status + req.Marker = args.marker + req.MaxResults = args.max_results + return safe_call("ListMemoryCollections", cli.ListMemoryCollections, req) + + +def cmd_get(cli, args): + from ksyun.client.aicp.v20251114 import models + + req = models.GetMemoryCollectionRequest() + req.MemoryCollectionId = args.memory_id + return safe_call("GetMemoryCollection", cli.GetMemoryCollection, req) + + +def cmd_create(cli, args): + from ksyun.client.aicp.v20251114 import models + + req = models.CreateMemoryCollectionRequest() + req.Name = args.name + if args.description: + req.Description = args.description + return safe_call("CreateMemoryCollection", cli.CreateMemoryCollection, req) + + +def raw_sdk_call(cli, action: str, params: dict[str, Any]) -> dict[str, Any]: + safe_params = copy.deepcopy(params) + if "Accesskey" in safe_params and isinstance(safe_params["Accesskey"], str): + ak = safe_params["Accesskey"] + safe_params["Accesskey"] = f"{ak[:4]}...{ak[-4:]}" + try: + body = cli.call(action, params, options={"IsPostJson": True}) + parsed = json.loads(body) if isinstance(body, str) else body + return {"ok": True, "action": action, "params": safe_params, "response": parsed} + except Exception as exc: # noqa: BLE001 + return { + "ok": False, + "action": action, + "params": safe_params, + "error_type": type(exc).__name__, + "error": str(exc), + } + + +def cmd_write(cli, args): + params = { + "MemoryCollectionId": args.memory_id, + "AgentUserId": args.user_id, + "SceneId": args.scene_id, + "Data": { + "Conversation": [ + { + "Role": args.role, + "CreatedAt": int(time.time() * 1000), + "MessageId": str(uuid.uuid4()), + "Content": [{"Type": "input_text", "Text": args.text}], + } + ] + }, + } + if args.agent_id: + params["AgentId"] = args.agent_id + if args.session_id: + params["SessionId"] = args.session_id + return raw_sdk_call(cli, "CreateMemorySdk", params) + + +def cmd_query(cli, args): + params = { + "MemoryCollectionId": args.memory_id, + "AgentUserId": args.user_id, + "SceneId": args.scene_id, + "Query": args.query, + "Limit": args.limit, + } + if args.mode: + params["Mode"] = args.mode + return raw_sdk_call(cli, "QueryMemorySdk", params) + + +def cmd_list_sessions(cli, args): + params = { + "MemoryCollectionId": args.memory_id, + "AgentUserId": args.user_id, + "Page": args.page, + "PageSize": args.page_size, + } + if args.query: + params["Query"] = args.query + if args.created_after: + params["CreatedAfter"] = args.created_after + if args.created_before: + params["CreatedBefore"] = args.created_before + return raw_sdk_call(cli, "ListSessions", params) + + +def cmd_session_memories(cli, args): + params = { + "MemoryCollectionId": args.memory_id, + "SessionId": args.session_id, + } + return raw_sdk_call(cli, "QuerySessionMemories", params) + + +def cmd_metrics(cli, args): + end_time = args.end_time or int(time.time()) + start_time = args.start_time or end_time - args.last_seconds + params = { + "MemoryCollectionId": args.memory_id, + "StartTime": start_time, + "EndTime": end_time, + } + return raw_sdk_call(cli, "QueryMemoryCollectionMetrics", params) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--env-file", + help="Optional path to a .env file. Values are loaded before reading process env.", + ) + subparsers = parser.add_subparsers(dest="command", required=True) + + subparsers.add_parser("service-status", help="Get memory base service status") + + list_parser = subparsers.add_parser("list", help="List memory collections") + list_parser.add_argument("--name-keyword") + list_parser.add_argument("--name") + list_parser.add_argument("--memory-id") + list_parser.add_argument("--status") + list_parser.add_argument("--marker", type=int, default=1) + list_parser.add_argument("--max-results", type=int, default=20) + + get_parser = subparsers.add_parser("get", help="Get a memory collection by id") + get_parser.add_argument("--memory-id", required=True) + + create_parser = subparsers.add_parser("create", help="Create a memory collection") + create_parser.add_argument("--name", required=True) + create_parser.add_argument("--description", default="") + + write_parser = subparsers.add_parser("write", help="Write memory into a memory collection") + write_parser.add_argument("--memory-id", "--namespace", dest="memory_id", required=True) + write_parser.add_argument("--user-id", default="debug-user") + write_parser.add_argument("--text", required=True) + write_parser.add_argument("--role", default="user") + write_parser.add_argument("--agent-id", default="") + write_parser.add_argument("--session-id", default="") + write_parser.add_argument("--scene-id", default="_sys_general") + + query_parser = subparsers.add_parser("query", help="Query memory from a memory collection") + query_parser.add_argument("--memory-id", "--namespace", dest="memory_id", required=True) + query_parser.add_argument("--user-id", default="debug-user") + query_parser.add_argument("--query", required=True) + query_parser.add_argument("--limit", type=int, default=5) + query_parser.add_argument("--scene-id", default="_sys_general") + query_parser.add_argument("--mode", default="") + + sessions_parser = subparsers.add_parser( + "list-sessions", help="List raw memory sessions for a user" + ) + sessions_parser.add_argument("--memory-id", "--namespace", dest="memory_id", required=True) + sessions_parser.add_argument("--user-id", default="debug-user") + sessions_parser.add_argument("--query", default="") + sessions_parser.add_argument("--page", type=int, default=1) + sessions_parser.add_argument("--page-size", type=int, default=20) + sessions_parser.add_argument("--created-after", type=int, default=0) + sessions_parser.add_argument("--created-before", type=int, default=0) + + session_memories_parser = subparsers.add_parser( + "session-memories", help="Query extracted memories for one raw session" + ) + session_memories_parser.add_argument("--memory-id", "--namespace", dest="memory_id", required=True) + session_memories_parser.add_argument("--session-id", required=True) + + metrics_parser = subparsers.add_parser( + "metrics", help="Query memory collection action metrics" + ) + metrics_parser.add_argument("--memory-id", "--namespace", dest="memory_id", required=True) + metrics_parser.add_argument("--start-time", type=int, default=0) + metrics_parser.add_argument("--end-time", type=int, default=0) + metrics_parser.add_argument("--last-seconds", type=int, default=3600) + + return parser + + +def main() -> int: + parser = build_parser() + args = parser.parse_args() + load_simple_dotenv(args.env_file) + cli, config = build_client() + + print_json({"config": config, "command": args.command}) + + if args.command == "service-status": + result = cmd_service_status(cli) + elif args.command == "list": + result = cmd_list(cli, args) + elif args.command == "get": + result = cmd_get(cli, args) + elif args.command == "create": + result = cmd_create(cli, args) + elif args.command == "write": + result = cmd_write(cli, args) + elif args.command == "query": + result = cmd_query(cli, args) + elif args.command == "list-sessions": + result = cmd_list_sessions(cli, args) + elif args.command == "session-memories": + result = cmd_session_memories(cli, args) + elif args.command == "metrics": + result = cmd_metrics(cli, args) + else: + parser.error(f"Unsupported command: {args.command}") + return 2 + + print_json(result) + return 0 if result.get("ok") else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/prepare_zread_source_snapshot.py b/scripts/prepare_zread_source_snapshot.py new file mode 100644 index 00000000..e7111202 --- /dev/null +++ b/scripts/prepare_zread_source_snapshot.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +"""Copy source files referenced by zread pages into a small Docker snapshot.""" + +from __future__ import annotations + +import re +import shutil +from pathlib import Path +from urllib.parse import unquote + + +ROOT = Path.cwd() +WIKI_CURRENT = ROOT / ".zread" / "wiki" / "current" +SOURCE_DIR = ROOT / ".zread" / "source" +LINK_RE = re.compile(r"\[[^\]]+\]\(([^)]+)\)") + + +def current_wiki_root() -> Path: + version = WIKI_CURRENT.read_text(encoding="utf-8").strip().removeprefix("versions/") + return ROOT / ".zread" / "wiki" / "versions" / version + + +def is_local_source_href(href: str) -> bool: + return not href.startswith(("http://", "https://", "#", "mailto:", "javascript:")) + + +def referenced_files(wiki_root: Path) -> list[Path]: + files: set[Path] = set() + for markdown in wiki_root.glob("*.md"): + text = markdown.read_text(encoding="utf-8") + for match in LINK_RE.finditer(text): + href = unquote(match.group(1).strip()).split("#", 1)[0] + if not href or not is_local_source_href(href): + continue + candidate = Path(href) + if candidate.is_absolute() or ".." in candidate.parts: + continue + source = ROOT / candidate + if source.is_file(): + files.add(candidate) + return sorted(files, key=lambda path: path.as_posix()) + + +def main() -> int: + wiki_root = current_wiki_root() + files = referenced_files(wiki_root) + if SOURCE_DIR.exists(): + shutil.rmtree(SOURCE_DIR) + SOURCE_DIR.mkdir(parents=True, exist_ok=True) + + total_bytes = 0 + for relative in files: + source = ROOT / relative + target = SOURCE_DIR / relative + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, target) + total_bytes += source.stat().st_size + + print(f"✅ zread source snapshot: files={len(files)}, bytes={total_bytes}, dir={SOURCE_DIR}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/test_ks3_upload.py b/scripts/test_ks3_upload.py new file mode 100644 index 00000000..5c42e34e --- /dev/null +++ b/scripts/test_ks3_upload.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +""" +KS3 上传调试脚本 +测试 bucket 创建和文件上传 +""" + +import os +from pathlib import Path +from dotenv import load_dotenv + +# 加载 .env (支持多种位置) +for env_path in [Path(".env"), Path("/tmp/my-agent/.env"), Path.home() / ".env"]: + if env_path.exists(): + load_dotenv(env_path) + print(f"✓ 加载 .env: {env_path}") + break + +ak = os.environ.get("KSYUN_ACCESS_KEY") +sk = os.environ.get("KSYUN_SECRET_KEY") + +print(f"AK: {ak[:10]}..." if ak else "AK: 未设置") +print(f"SK: {sk[:10]}..." if sk else "SK: 未设置") + +BUCKET_NAME = "agentengine" +REGION = "cn-beijing" +HOST = f"ks3-{REGION}.ksyuncs.com" + +print(f"\nHost: {HOST}") +print(f"Bucket: {BUCKET_NAME}") + +try: + from ks3.connection import Connection + + conn = Connection(ak, sk, host=HOST) + print(f"\n✓ 连接成功") + + # 列出所有 bucket + print("\n现有 Buckets:") + buckets = conn.get_all_buckets() + for b in buckets: + print(f" - {b.name}") + + # 检查目标 bucket 是否存在 + bucket_exists = any(b.name == BUCKET_NAME for b in buckets) + + if not bucket_exists: + print(f"\n⚠️ Bucket '{BUCKET_NAME}' 不存在,尝试创建...") + try: + new_bucket = conn.create_bucket(BUCKET_NAME) + print(f"✓ Bucket 创建成功: {new_bucket.name}") + except Exception as e: + print(f"✗ 创建失败: {e}") + # 可能需要指定 location + print("\n尝试使用 location 参数创建...") + try: + new_bucket = conn.create_bucket(BUCKET_NAME, location=REGION.upper()) + print(f"✓ Bucket 创建成功: {new_bucket.name}") + except Exception as e2: + print(f"✗ 仍然失败: {e2}") + else: + print(f"\n✓ Bucket '{BUCKET_NAME}' 已存在") + + # 测试上传 + print("\n测试上传...") + bucket = conn.get_bucket(BUCKET_NAME) + test_key = bucket.new_key("test/hello.txt") + result = test_key.set_contents_from_string("Hello from KsADK!") + print(f"上传结果: {result}") + + if result and result.status == 200: + print("✓ 测试上传成功!") + # 读取验证 + content = test_key.get_contents_as_string() + print(f"读取内容: {content}") + else: + print(f"✗ 上传返回非 200: {result}") + +except ImportError as e: + print(f"✗ ks3sdk 未安装: {e}") +except Exception as e: + print(f"✗ 错误: {type(e).__name__}: {e}") diff --git a/scripts/validate_checkpoint_resume_e2e.py b/scripts/validate_checkpoint_resume_e2e.py new file mode 100644 index 00000000..f3fac4f3 --- /dev/null +++ b/scripts/validate_checkpoint_resume_e2e.py @@ -0,0 +1,747 @@ +#!/usr/bin/env python3 +"""Validate KSADK checkpoint resume against a real PostgreSQL backend. + +This script is intentionally self-contained so it can run inside a preprod Pod +with the current source tree copied in. It validates the W1 path: + +1. LangGraph persists a checkpoint in PostgreSQL. +2. KSADK writes run_checkpoint events into the shared session backend. +3. ListSessionCheckpoints exposes the checkpoint. +4. ResumeRun resumes through LangGraphRunner without rerunning prior nodes. +""" + +from __future__ import annotations + +import argparse +import asyncio +import importlib +import json +import os +import uuid +from types import SimpleNamespace +from typing import Any + +import httpx + +from ksadk.runners.langgraph_runner import LangGraphRunner +from ksadk.runners.base_runner import BaseRunner + + +AGENT_ID = "lt-w1-e2e-agent" +USER_ID = "lt-w1-e2e-user" +CANCEL_AGENT_ID = "lt-w25-cancel-agent" +CANCEL_USER_ID = "lt-w25-cancel-user" +CANCEL_RESUME_AGENT_ID = "lt-w25-cancel-resume-agent" +CANCEL_RESUME_USER_ID = "lt-w25-cancel-resume-user" +E2E_NODE_COUNTS: dict[str, int] = {} + + +class E2ELangGraphRunner(LangGraphRunner): + def load_agent(self) -> None: + return None + + +class CancellableStreamingRunner(BaseRunner): + def __init__(self) -> None: + super().__init__( + detection_result=SimpleNamespace( + name=CANCEL_AGENT_ID, + type=SimpleNamespace(value="mock"), + ), + project_dir=".", + ) + self.cancel_requests: list[str] = [] + + def load_agent(self) -> None: + return None + + async def invoke(self, input_data: dict[str, Any]) -> dict[str, Any]: + return {"output": "should not be used"} + + async def stream(self, input_data: dict[str, Any]): + yield {"type": "text", "delta": "started"} + await asyncio.Event().wait() + + def request_cancel(self, invocation_id: str) -> str: + self.cancel_requests.append(str(invocation_id)) + return "accepted" + + +class CancelThenResumeLangGraphRunner(E2ELangGraphRunner): + def __init__(self, *args: Any, hold_after_checkpoint: bool = True, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self.hold_after_checkpoint = hold_after_checkpoint + self.cancel_requests: list[str] = [] + + async def stream(self, input_data: dict[str, Any]): + payload = dict(input_data) + is_checkpoint_resume = bool(payload.get("checkpoint_resume")) + if is_checkpoint_resume: + async for chunk in super().stream(payload): + yield chunk + return + + result = await self.invoke(payload) + metadata = result.get("metadata") if isinstance(result, dict) else None + if isinstance(metadata, dict) and metadata.get("agentengine"): + yield {"type": "checkpoint", "metadata": metadata} + yield {"type": "text", "delta": "checkpoint persisted"} + if self.hold_after_checkpoint: + await asyncio.Event().wait() + + def request_cancel(self, invocation_id: str) -> str: + self.cancel_requests.append(str(invocation_id)) + return "accepted" + + +async def _build_graph(*, dsn: str) -> Any: + from typing import TypedDict + + from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver + from langgraph.graph import END, StateGraph + + class VerifyState(TypedDict, total=False): + input: str + log: list[str] + answer: str + + def _append(state: VerifyState, node: str) -> VerifyState: + E2E_NODE_COUNTS[node] = E2E_NODE_COUNTS.get(node, 0) + 1 + log = list(state.get("log") or []) + log.append(node) + return {"log": log, "answer": ",".join(log)} + + def node_a(state: VerifyState) -> VerifyState: + return _append(state, "a") + + def node_b(state: VerifyState) -> VerifyState: + return _append(state, "b") + + def node_c(state: VerifyState) -> VerifyState: + return _append(state, "c") + + saver_cm = AsyncPostgresSaver.from_conn_string(dsn) + saver = await saver_cm.__aenter__() + await saver.setup() + graph = StateGraph(VerifyState) + graph.add_node("a", node_a) + graph.add_node("b", node_b) + graph.add_node("c", node_c) + graph.set_entry_point("a") + graph.add_edge("a", "b") + graph.add_edge("b", "c") + graph.add_edge("c", END) + app = graph.compile(checkpointer=saver, interrupt_before=["c"]) + app._ksadk_e2e_saver_cm = saver_cm + return app + + +async def _build_runner(*, dsn: str) -> LangGraphRunner: + runner = E2ELangGraphRunner( + detection_result=SimpleNamespace( + name=AGENT_ID, + type=SimpleNamespace(value="langgraph"), + entry_point="agent.py", + agent_variable="app", + ), + project_dir=".", + ) + runner._agent = await _build_graph(dsn=dsn) + runner._module = SimpleNamespace() + return runner + + +async def _build_cancel_then_resume_runner(*, dsn: str) -> CancelThenResumeLangGraphRunner: + runner = CancelThenResumeLangGraphRunner( + detection_result=SimpleNamespace( + name=CANCEL_RESUME_AGENT_ID, + type=SimpleNamespace(value="langgraph"), + entry_point="agent.py", + agent_variable="app", + ), + project_dir=".", + ) + runner._agent = await _build_graph(dsn=dsn) + runner._module = SimpleNamespace() + return runner + + +async def _close_runner(runner: LangGraphRunner) -> None: + agent = getattr(runner, "_agent", None) + saver_cm = getattr(agent, "_ksadk_e2e_saver_cm", None) + if saver_cm is not None: + await saver_cm.__aexit__(None, None, None) + + +def _action_data(payload: dict[str, Any]) -> dict[str, Any]: + data = payload.get("Data") + if not isinstance(data, dict): + raise AssertionError(f"Action payload missing Data: {payload}") + return data + + +async def _list_events(client: httpx.AsyncClient, session_id: str) -> list[dict[str, Any]]: + events_response = await client.post( + "/agentengine/api/v1/ListSessionEvents", + json={"SessionId": session_id}, + ) + events_response.raise_for_status() + return _action_data(events_response.json())["Events"] + + +async def _checkpoint_state_values(runner: LangGraphRunner, checkpoint: dict[str, Any]) -> dict[str, Any]: + framework_ref = checkpoint.get("FrameworkRef") if isinstance(checkpoint.get("FrameworkRef"), dict) else {} + langgraph_ref = framework_ref.get("langgraph") if isinstance(framework_ref.get("langgraph"), dict) else {} + configurable = { + key: value + for key, value in { + "thread_id": langgraph_ref.get("thread_id"), + "checkpoint_id": langgraph_ref.get("checkpoint_id"), + "checkpoint_ns": langgraph_ref.get("checkpoint_ns"), + }.items() + if value + } + if not configurable: + return {} + state = await runner._agent.aget_state({"configurable": configurable}) + values = getattr(state, "values", None) + return dict(values or {}) if isinstance(values, dict) else {} + + +def _summarize_events(events: list[dict[str, Any]]) -> list[dict[str, Any]]: + summary: list[dict[str, Any]] = [] + for event in events: + metadata = event.get("Metadata") if isinstance(event.get("Metadata"), dict) else {} + content = event.get("Content") if isinstance(event.get("Content"), dict) else {} + summary.append( + { + "SeqId": event.get("SeqId"), + "EventType": event.get("EventType"), + "Author": event.get("Author"), + "MetadataKeys": sorted(metadata.keys()), + "AgentEngine": metadata.get("agentengine"), + "Content": content, + } + ) + return summary + + +async def run_validation(*, dsn: str, keep_session: bool) -> dict[str, Any]: + namespace = f"lt_w1_e2e_{uuid.uuid4().hex[:10]}" + session_id = f"sess_{uuid.uuid4().hex}" + thread_prefix = f"{namespace}:{AGENT_ID}:{session_id}" + os.environ["KSADK_SESSION_BACKEND"] = "postgres" + os.environ["KSADK_SESSION_DSN"] = dsn + os.environ["KSADK_SESSION_NAMESPACE"] = namespace + os.environ["KSADK_SESSION_TENANT_ID"] = "lt_w1_e2e_tenant" + os.environ["KSADK_SESSION_WORKSPACE_ID"] = "lt_w1_e2e_workspace" + os.environ["KSADK_E2E_LANGGRAPH_DSN"] = dsn + E2E_NODE_COUNTS.clear() + + runner = await _build_runner(dsn=dsn) + try: + from ksadk.sessions import reset_session_service + + server_app_module = importlib.import_module("ksadk.server.app") + await reset_session_service() + server_app_module.set_runner(runner) + transport = httpx.ASGITransport(app=server_app_module.app) + async with httpx.AsyncClient( + transport=transport, + base_url="http://ksadk.local", + timeout=60, + ) as client: + run_response = await client.post( + "/agentengine/api/v1/RunAgent", + json={ + "AgentId": AGENT_ID, + "UserId": USER_ID, + "SessionId": session_id, + "ApiFormat": "responses", + "Stream": False, + "ResponsesInput": [ + { + "role": "user", + "content": [ + { + "type": "input_text", + "text": "run until checkpoint", + } + ], + } + ], + }, + ) + run_response.raise_for_status() + run_payload = run_response.json() + checkpoints_response = await client.post( + "/agentengine/api/v1/ListSessionCheckpoints", + json={"AgentId": AGENT_ID, "SessionId": session_id}, + ) + checkpoints_response.raise_for_status() + checkpoints = _action_data(checkpoints_response.json())["Checkpoints"] + if not checkpoints: + events = await _list_events(client, session_id) + raise AssertionError( + "ListSessionCheckpoints returned no checkpoints\n" + f"RunAgent payload: {json.dumps(run_payload, ensure_ascii=False)}\n" + f"Events: {json.dumps(_summarize_events(events), ensure_ascii=False)}" + ) + checkpoint = checkpoints[0] + run_id = checkpoint["RunId"] + checkpoint_id = checkpoint["CheckpointId"] + checkpoint_state = await _checkpoint_state_values(runner, checkpoint) + checkpoint_log = list(checkpoint_state.get("log") or []) + if checkpoint_log != ["a", "b"]: + raise AssertionError( + "Checkpoint state before resume should contain exactly a,b; " + f"got {checkpoint_log!r}" + ) + node_counts_before_resume = dict(E2E_NODE_COUNTS) + + resume_response = await client.post( + "/agentengine/api/v1/ResumeRun", + json={ + "AgentId": AGENT_ID, + "SessionId": session_id, + "RunId": run_id, + "CheckpointId": checkpoint_id, + "Stream": False, + }, + ) + resume_response.raise_for_status() + resume_payload = resume_response.json() + resume_data = _action_data(resume_payload) + output_text = str(resume_data.get("output_text") or "") + if output_text != "a,b,c": + raise AssertionError( + f"ResumeRun output should be 'a,b,c', got {output_text!r}" + ) + node_counts_after_resume = dict(E2E_NODE_COUNTS) + if node_counts_after_resume != {"a": 1, "b": 1, "c": 1}: + raise AssertionError( + "ResumeRun should not rerun completed nodes; " + f"before={node_counts_before_resume}, after={node_counts_after_resume}" + ) + + events = await _list_events(client, session_id) + run_checkpoint_count = sum( + 1 for event in events if event.get("EventType") == "run_checkpoint" + ) + run_resume_count = sum( + 1 for event in events if event.get("EventType") == "run_resume" + ) + if run_checkpoint_count < 2: + raise AssertionError( + f"Expected at least two run_checkpoint events, got {run_checkpoint_count}" + ) + if run_resume_count < 1: + raise AssertionError("Expected a run_resume event") + + if not keep_session: + delete_response = await client.post( + "/agentengine/api/v1/DeleteSession", + json={"SessionId": session_id}, + ) + delete_response.raise_for_status() + + return { + "namespace": namespace, + "session_id": session_id, + "run_id": run_id, + "checkpoint_id": checkpoint_id, + "output_text": output_text, + "checkpoint_count": len(checkpoints), + "run_checkpoint_event_count": run_checkpoint_count, + "run_resume_event_count": run_resume_count, + "checkpoint_log_before_resume": checkpoint_log, + "node_counts_before_resume": node_counts_before_resume, + "node_counts_after_resume": node_counts_after_resume, + "resume_did_not_rerun_prior_nodes": True, + "kept_session": keep_session, + "run_action": run_payload.get("Code"), + "resume_action": resume_payload.get("Code"), + } + finally: + await _close_runner(runner) + + +async def run_cancel_validation(*, dsn: str, keep_session: bool) -> dict[str, Any]: + namespace = f"lt_w25_cancel_{uuid.uuid4().hex[:10]}" + session_id = f"sess_{uuid.uuid4().hex}" + invocation_id = f"run_{uuid.uuid4().hex}" + os.environ["KSADK_SESSION_BACKEND"] = "postgres" + os.environ["KSADK_SESSION_DSN"] = dsn + os.environ["KSADK_SESSION_NAMESPACE"] = namespace + os.environ["KSADK_SESSION_TENANT_ID"] = "lt_w25_cancel_tenant" + os.environ["KSADK_SESSION_WORKSPACE_ID"] = "lt_w25_cancel_workspace" + + from ksadk.sessions import reset_session_service + import ksadk.conversations as conversation + + server_app_module = importlib.import_module("ksadk.server.app") + await reset_session_service() + runner = CancellableStreamingRunner() + server_app_module.set_runner(runner) + transport = httpx.ASGITransport(app=server_app_module.app) + async with httpx.AsyncClient( + transport=transport, + base_url="http://ksadk.local", + timeout=60, + ) as client: + server_app_module._detached_streaming_response( + conversation.stream_responses_conversation_turn( + runner=runner, + agent_id=CANCEL_AGENT_ID, + user_id=CANCEL_USER_ID, + messages=[{"role": "user", "content": "start long streaming run"}], + session_id=session_id, + model=None, + prepare_runner=lambda _runner, _model: None, + invocation_id=invocation_id, + session_service_provider=server_app_module.resolve_session_service, + ), + invocation_id=invocation_id, + ) + + for _ in range(50): + events = await _list_events(client, session_id) + statuses = [ + event.get("Content", {}).get("status") + for event in events + if event.get("EventType") == "run_status" + ] + if statuses == ["in_progress"]: + break + await asyncio.sleep(0.1) + else: + raise AssertionError("Cancel validation did not observe in_progress status") + + cancel_response = await client.post( + "/agentengine/api/v1/CancelRun", + json={"AgentId": CANCEL_AGENT_ID, "InvocationId": invocation_id}, + ) + cancel_response.raise_for_status() + cancel_data = _action_data(cancel_response.json()) + if cancel_data.get("Found") is not True or cancel_data.get("Cancelled") is not True: + raise AssertionError(f"CancelRun did not hit active run: {cancel_data}") + + for _ in range(50): + events = await _list_events(client, session_id) + statuses = [ + event.get("Content", {}).get("status") + for event in events + if event.get("EventType") == "run_status" + ] + if statuses and statuses[-1] == "cancelled": + break + await asyncio.sleep(0.1) + else: + raise AssertionError("Cancel validation did not observe cancelled status") + + event_count_at_cancel = len(events) + await asyncio.sleep(3) + final_events = await _list_events(client, session_id) + unexpected_terminal = [ + event + for event in final_events[event_count_at_cancel:] + if event.get("EventType") in {"assistant_message", "run_checkpoint"} + or ( + event.get("EventType") == "run_status" + and event.get("Content", {}).get("status") == "completed" + ) + ] + if unexpected_terminal: + raise AssertionError( + "Cancel validation observed unexpected events after cancelled: " + f"{json.dumps(_summarize_events(unexpected_terminal), ensure_ascii=False)}" + ) + + if not keep_session: + delete_response = await client.post( + "/agentengine/api/v1/DeleteSession", + json={"SessionId": session_id}, + ) + delete_response.raise_for_status() + + return { + "namespace": namespace, + "session_id": session_id, + "invocation_id": invocation_id, + "cancel_action": cancel_response.json().get("Code"), + "cancel_found": cancel_data.get("Found"), + "cancel_status": cancel_data.get("Status"), + "cancelled_event_count": sum( + 1 + for event in final_events + if event.get("EventType") == "run_status" + and event.get("Content", {}).get("status") == "cancelled" + ), + "post_cancel_extra_event_count": len(final_events) - event_count_at_cancel, + "runner_cancel_requests": list(runner.cancel_requests), + "kept_session": keep_session, + } + + +async def _wait_for_status( + client: httpx.AsyncClient, + *, + session_id: str, + status: str, + attempts: int = 50, +) -> list[dict[str, Any]]: + for _ in range(attempts): + events = await _list_events(client, session_id) + statuses = [ + event.get("Content", {}).get("status") + for event in events + if event.get("EventType") == "run_status" + ] + if statuses and statuses[-1] == status: + return events + await asyncio.sleep(0.1) + raise AssertionError(f"Did not observe run_status={status!r}") + + +async def _wait_for_checkpoint( + client: httpx.AsyncClient, + *, + agent_id: str, + session_id: str, + attempts: int = 50, +) -> dict[str, Any]: + for _ in range(attempts): + response = await client.post( + "/agentengine/api/v1/ListSessionCheckpoints", + json={"AgentId": agent_id, "SessionId": session_id}, + ) + response.raise_for_status() + checkpoints = _action_data(response.json())["Checkpoints"] + if checkpoints: + return checkpoints[0] + await asyncio.sleep(0.1) + events = await _list_events(client, session_id) + raise AssertionError( + "Did not observe checkpoint before cancel\n" + f"Events: {json.dumps(_summarize_events(events), ensure_ascii=False)}" + ) + + +async def run_cancel_then_resume_validation(*, dsn: str, keep_session: bool) -> dict[str, Any]: + namespace = f"lt_w25_cancel_resume_{uuid.uuid4().hex[:10]}" + session_id = f"sess_{uuid.uuid4().hex}" + invocation_id = f"run_{uuid.uuid4().hex}" + os.environ["KSADK_SESSION_BACKEND"] = "postgres" + os.environ["KSADK_SESSION_DSN"] = dsn + os.environ["KSADK_SESSION_NAMESPACE"] = namespace + os.environ["KSADK_SESSION_TENANT_ID"] = "lt_w25_cancel_resume_tenant" + os.environ["KSADK_SESSION_WORKSPACE_ID"] = "lt_w25_cancel_resume_workspace" + os.environ["KSADK_E2E_LANGGRAPH_DSN"] = dsn + E2E_NODE_COUNTS.clear() + + runner = await _build_cancel_then_resume_runner(dsn=dsn) + try: + from ksadk.sessions import reset_session_service + import ksadk.conversations as conversation + + server_app_module = importlib.import_module("ksadk.server.app") + await reset_session_service() + server_app_module.set_runner(runner) + transport = httpx.ASGITransport(app=server_app_module.app) + async with httpx.AsyncClient( + transport=transport, + base_url="http://ksadk.local", + timeout=60, + ) as client: + server_app_module._detached_streaming_response( + conversation.stream_responses_conversation_turn( + runner=runner, + agent_id=CANCEL_RESUME_AGENT_ID, + user_id=CANCEL_RESUME_USER_ID, + messages=[{"role": "user", "content": "start, checkpoint, then wait"}], + session_id=session_id, + model=None, + prepare_runner=lambda _runner, _model: None, + invocation_id=invocation_id, + session_service_provider=server_app_module.resolve_session_service, + ), + invocation_id=invocation_id, + ) + + await _wait_for_status(client, session_id=session_id, status="in_progress") + checkpoint = await _wait_for_checkpoint( + client, + agent_id=CANCEL_RESUME_AGENT_ID, + session_id=session_id, + ) + run_id = checkpoint["RunId"] + checkpoint_id = checkpoint["CheckpointId"] + if run_id != invocation_id: + raise AssertionError( + f"Checkpoint run_id should match cancelled invocation_id; {run_id!r} != {invocation_id!r}" + ) + checkpoint_state = await _checkpoint_state_values(runner, checkpoint) + checkpoint_log = list(checkpoint_state.get("log") or []) + if checkpoint_log != ["a", "b"]: + raise AssertionError( + "Checkpoint state before cancel should contain exactly a,b; " + f"got {checkpoint_log!r}" + ) + + cancel_response = await client.post( + "/agentengine/api/v1/CancelRun", + json={"AgentId": CANCEL_RESUME_AGENT_ID, "InvocationId": invocation_id}, + ) + cancel_response.raise_for_status() + cancel_data = _action_data(cancel_response.json()) + if cancel_data.get("Found") is not True or cancel_data.get("Cancelled") is not True: + raise AssertionError(f"CancelRun did not hit checkpointed active run: {cancel_data}") + + cancelled_events = await _wait_for_status( + client, + session_id=session_id, + status="cancelled", + ) + event_count_at_cancel = len(cancelled_events) + await asyncio.sleep(1) + post_cancel_events = await _list_events(client, session_id) + unexpected_post_cancel = [ + event + for event in post_cancel_events[event_count_at_cancel:] + if event.get("EventType") in {"assistant_message", "run_checkpoint"} + or ( + event.get("EventType") == "run_status" + and event.get("Content", {}).get("status") == "completed" + ) + ] + if unexpected_post_cancel: + raise AssertionError( + "Cancel then resume validation observed unexpected events after cancelled: " + f"{json.dumps(_summarize_events(unexpected_post_cancel), ensure_ascii=False)}" + ) + + node_counts_before_resume = dict(E2E_NODE_COUNTS) + resume_response = await client.post( + "/agentengine/api/v1/ResumeRun", + json={ + "AgentId": CANCEL_RESUME_AGENT_ID, + "SessionId": session_id, + "RunId": run_id, + "CheckpointId": checkpoint_id, + "Stream": False, + }, + ) + resume_response.raise_for_status() + resume_payload = resume_response.json() + resume_data = _action_data(resume_payload) + output_text = str(resume_data.get("output_text") or "") + if output_text != "a,b,c": + raise AssertionError( + f"ResumeRun after cancel should output 'a,b,c', got {output_text!r}" + ) + node_counts_after_resume = dict(E2E_NODE_COUNTS) + if node_counts_after_resume != {"a": 1, "b": 1, "c": 1}: + raise AssertionError( + "ResumeRun after cancel should not rerun completed nodes; " + f"before={node_counts_before_resume}, after={node_counts_after_resume}" + ) + + final_events = await _list_events(client, session_id) + run_checkpoint_count = sum( + 1 for event in final_events if event.get("EventType") == "run_checkpoint" + ) + run_resume_count = sum( + 1 for event in final_events if event.get("EventType") == "run_resume" + ) + cancelled_event_count = sum( + 1 + for event in final_events + if event.get("EventType") == "run_status" + and event.get("Content", {}).get("status") == "cancelled" + ) + if run_checkpoint_count < 2: + raise AssertionError( + f"Expected at least two run_checkpoint events after resume, got {run_checkpoint_count}" + ) + if run_resume_count < 1: + raise AssertionError("Expected a run_resume event after cancel") + + if not keep_session: + delete_response = await client.post( + "/agentengine/api/v1/DeleteSession", + json={"SessionId": session_id}, + ) + delete_response.raise_for_status() + + return { + "namespace": namespace, + "session_id": session_id, + "run_id": run_id, + "invocation_id": invocation_id, + "checkpoint_id": checkpoint_id, + "cancel_action": cancel_response.json().get("Code"), + "cancel_found": cancel_data.get("Found"), + "cancel_status": cancel_data.get("Status"), + "cancelled_event_count": cancelled_event_count, + "post_cancel_extra_event_count": len(post_cancel_events) - event_count_at_cancel, + "runner_cancel_requests": list(runner.cancel_requests), + "output_text_after_resume": output_text, + "run_checkpoint_event_count": run_checkpoint_count, + "run_resume_event_count": run_resume_count, + "checkpoint_log_before_cancel": checkpoint_log, + "node_counts_before_resume": node_counts_before_resume, + "node_counts_after_resume": node_counts_after_resume, + "resume_after_cancel_did_not_rerun_prior_nodes": True, + "kept_session": keep_session, + "resume_action": resume_payload.get("Code"), + } + finally: + await _close_runner(runner) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument( + "--dsn", + default=os.environ.get("KSADK_SESSION_DSN", ""), + help="PostgreSQL DSN. Defaults to KSADK_SESSION_DSN.", + ) + parser.add_argument( + "--keep-session", + action="store_true", + help="Keep the generated KSADK session rows for debugging.", + ) + parser.add_argument( + "--include-cancel", + action="store_true", + help="Also validate W2.5 detached streaming CancelRun behavior.", + ) + args = parser.parse_args() + dsn = args.dsn.strip() + if not dsn: + raise SystemExit("--dsn or KSADK_SESSION_DSN is required") + async def _run_all() -> dict[str, Any]: + result: dict[str, Any] = { + "checkpoint_resume": await run_validation( + dsn=dsn, + keep_session=args.keep_session, + ) + } + if args.include_cancel: + result["runtime_cancel"] = await run_cancel_validation( + dsn=dsn, + keep_session=args.keep_session, + ) + result["cancel_then_resume"] = await run_cancel_then_resume_validation( + dsn=dsn, + keep_session=args.keep_session, + ) + return result + + result = asyncio.run(_run_all()) + print(json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/validate_hosted_long_task_e2e.py b/scripts/validate_hosted_long_task_e2e.py new file mode 100644 index 00000000..1fcd33cd --- /dev/null +++ b/scripts/validate_hosted_long_task_e2e.py @@ -0,0 +1,699 @@ +#!/usr/bin/env python3 +"""Validate long-task resume through the public Hosted path. + +This script targets the real Hosted route: + + PublicEndpoint -> agentengine-gateway -> agentengine-server -> runtime + +It does not talk to the runtime pod directly and it does not need the PG DSN. +Use it after a long-task-capable LangGraph/ADK agent is already deployed. +""" + +from __future__ import annotations + +import argparse +import json +import sys +import threading +import time +import uuid +from dataclasses import dataclass +from typing import Any +from urllib.parse import urljoin + +import httpx + + +DEFAULT_PROMPT = "run until checkpoint" +TERMINAL_STATUSES = {"completed", "failed", "cancelled", "resume_failed"} + + +class HostedE2EError(AssertionError): + pass + + +@dataclass +class HostedClient: + base_url: str + agent_id: str + user_id: str + timeout: float + api_key: str = "" + cookie: str = "" + account_id: str = "" + principal_id: str = "" + + def __post_init__(self) -> None: + self.base_url = self.base_url.rstrip("/") + "/" + headers = { + "Accept": "application/json", + "X-Ksc-Request-Id": f"hosted-e2e-{uuid.uuid4().hex}", + } + if self.api_key: + headers["Authorization"] = f"Bearer {self.api_key}" + if self.cookie: + headers["Cookie"] = self.cookie + if self.account_id: + headers["X-Auth-Account-Id"] = self.account_id + if self.principal_id: + headers["X-Ksc-User-uuid"] = self.principal_id + self.client = httpx.Client( + base_url=self.base_url, + headers=headers, + timeout=httpx.Timeout(self.timeout, connect=min(self.timeout, 20.0)), + follow_redirects=True, + verify=False, + ) + + def close(self) -> None: + self.client.close() + + def action(self, name: str, payload: dict[str, Any]) -> dict[str, Any]: + response = self.client.post(f"agentengine/api/v1/{name}", json=payload) + if response.status_code >= 400: + raise HostedE2EError( + f"{name} HTTP {response.status_code}: {response.text[:1000]}" + ) + try: + body = response.json() + except json.JSONDecodeError as exc: + raise HostedE2EError(f"{name} returned non-JSON body: {response.text[:1000]}") from exc + if isinstance(body, dict) and body.get("Code") not in (None, 0): + raise HostedE2EError(f"{name} returned Code={body.get('Code')}: {body}") + return body + + def stream_action(self, name: str, payload: dict[str, Any], *, max_seconds: float) -> str: + chunks: list[str] = [] + deadline = time.monotonic() + max_seconds + with self.client.stream( + "POST", + f"agentengine/api/v1/{name}", + json=payload, + headers={"Accept": "text/event-stream"}, + ) as response: + if response.status_code >= 400: + text = response.read().decode("utf-8", "replace") + raise HostedE2EError(f"{name} stream HTTP {response.status_code}: {text[:1000]}") + content_type = response.headers.get("content-type", "") + if "text/event-stream" not in content_type.lower(): + raise HostedE2EError(f"{name} did not return SSE content-type: {content_type}") + for line in response.iter_lines(): + if time.monotonic() > deadline: + raise HostedE2EError(f"{name} stream timed out after {max_seconds}s") + if line: + chunks.append(line) + return "\n".join(chunks) + + +def _data(payload: dict[str, Any], action: str) -> dict[str, Any]: + data = payload.get("Data") + if not isinstance(data, dict): + raise HostedE2EError(f"{action} missing Data object: {payload}") + return data + + +def _extract_capabilities(bootstrap: dict[str, Any]) -> dict[str, Any]: + capabilities = _data(bootstrap, "GetAgentUiBootstrap").get("Capabilities") + if not isinstance(capabilities, dict): + raise HostedE2EError(f"GetAgentUiBootstrap missing Capabilities: {bootstrap}") + return capabilities + + +def _assert_checkpoint_capability(capabilities: dict[str, Any]) -> None: + run_lifecycle = capabilities.get("RunLifecycle") + if not isinstance(run_lifecycle, dict): + raise HostedE2EError(f"Capabilities.RunLifecycle is missing: {capabilities}") + missing = [ + key + for key in ("Checkpoints", "CheckpointResume") + if run_lifecycle.get(key) is not True + ] + if missing: + raise HostedE2EError( + "Hosted bootstrap does not advertise checkpoint lifecycle: " + f"missing_true={missing}, RunLifecycle={run_lifecycle}" + ) + + +def _make_responses_input(prompt: str) -> list[dict[str, Any]]: + return [ + { + "role": "user", + "content": [ + { + "type": "input_text", + "text": prompt, + } + ], + } + ] + + +def _run_agent(client: HostedClient, *, session_id: str, prompt: str) -> dict[str, Any]: + return client.action( + "RunAgent", + { + "AgentId": client.agent_id, + "UserId": client.user_id, + "SessionId": session_id, + "ApiFormat": "responses", + "Stream": False, + "ResponsesInput": _make_responses_input(prompt), + }, + ) + + +def _stream_run_agent_background( + client: HostedClient, + *, + session_id: str, + prompt: str, + invocation_id: str, + max_seconds: float, +) -> tuple[threading.Thread, dict[str, Any]]: + result: dict[str, Any] = {"sse": "", "error": None} + + def _run() -> None: + try: + result["sse"] = client.stream_action( + "RunAgent", + { + "AgentId": client.agent_id, + "UserId": client.user_id, + "SessionId": session_id, + "InvocationId": invocation_id, + "ApiFormat": "responses", + "Stream": True, + "ResponsesInput": _make_responses_input(prompt), + }, + max_seconds=max_seconds, + ) + except Exception as exc: # pragma: no cover - surfaced by caller in integration mode. + result["error"] = exc + + thread = threading.Thread(target=_run, name=f"hosted-e2e-stream-{invocation_id}", daemon=True) + thread.start() + return thread, result + + +def _list_checkpoints(client: HostedClient, *, session_id: str, run_id: str = "") -> list[dict[str, Any]]: + payload: dict[str, Any] = { + "AgentId": client.agent_id, + "SessionId": session_id, + } + if run_id: + payload["RunId"] = run_id + response = client.action("ListSessionCheckpoints", payload) + checkpoints = _data(response, "ListSessionCheckpoints").get("Checkpoints") + if not isinstance(checkpoints, list): + raise HostedE2EError(f"ListSessionCheckpoints missing Checkpoints list: {response}") + return checkpoints + + +def _is_retryable_checkpoint_not_found(exc: HostedE2EError) -> bool: + text = str(exc) + return "ListSessionCheckpoints" in text and ( + "Code=404" in text + or "HTTP 404" in text + or "not found" in text.lower() + or "资源不存在" in text + ) + + +def _list_events(client: HostedClient, *, session_id: str) -> list[dict[str, Any]]: + response = client.action( + "ListSessionEvents", + {"AgentId": client.agent_id, "SessionId": session_id}, + ) + events = _data(response, "ListSessionEvents").get("Events") + if not isinstance(events, list): + raise HostedE2EError(f"ListSessionEvents missing Events list: {response}") + return events + + +def _event_type_counts(events: list[dict[str, Any]]) -> dict[str, int]: + counts: dict[str, int] = {} + for event in events: + event_type = str(event.get("EventType") or "") + counts[event_type] = counts.get(event_type, 0) + 1 + return counts + + +def _wait_for_checkpoint( + client: HostedClient, + *, + session_id: str, + run_id: str = "", + attempts: int, + interval: float, +) -> dict[str, Any]: + last_checkpoints: list[dict[str, Any]] = [] + for _ in range(attempts): + try: + last_checkpoints = _list_checkpoints(client, session_id=session_id, run_id=run_id) + except HostedE2EError as exc: + if not _is_retryable_checkpoint_not_found(exc): + raise + last_checkpoints = [] + if last_checkpoints: + return last_checkpoints[0] + time.sleep(interval) + events = _list_events(client, session_id=session_id) + raise HostedE2EError( + "No checkpoint appeared through Hosted facade; " + f"last_checkpoints={last_checkpoints}, event_counts={_event_type_counts(events)}" + ) + + +def _maybe_preview(client: HostedClient, *, session_id: str, run_id: str, checkpoint_id: str) -> dict[str, Any]: + try: + return client.action( + "PreviewCheckpointResume", + { + "AgentId": client.agent_id, + "SessionId": session_id, + "RunId": run_id, + "CheckpointId": checkpoint_id, + }, + ) + except HostedE2EError as exc: + return {"skipped": True, "error": str(exc)} + + +def _maybe_list_tool_receipts( + client: HostedClient, + *, + session_id: str, + run_id: str, + checkpoint_id: str, +) -> dict[str, Any]: + try: + return client.action( + "ListToolReceipts", + { + "AgentId": client.agent_id, + "SessionId": session_id, + "RunId": run_id, + "CheckpointId": checkpoint_id, + }, + ) + except HostedE2EError as exc: + return {"skipped": True, "error": str(exc)} + + +def _resume_stream( + client: HostedClient, + *, + session_id: str, + run_id: str, + checkpoint_id: str, + invocation_id: str, + max_seconds: float, +) -> str: + return client.stream_action( + "ResumeRun", + { + "AgentId": client.agent_id, + "SessionId": session_id, + "RunId": run_id, + "CheckpointId": checkpoint_id, + "InvocationId": invocation_id, + "Stream": True, + }, + max_seconds=max_seconds, + ) + + +def _cancel_run(client: HostedClient, *, invocation_id: str) -> dict[str, Any]: + return client.action( + "CancelRun", + { + "AgentId": client.agent_id, + "InvocationId": invocation_id, + }, + ) + + +def _event_statuses(events: list[dict[str, Any]], *, invocation_id: str = "") -> list[str]: + statuses: list[str] = [] + for event in events: + if invocation_id and str(event.get("InvocationId") or "") != invocation_id: + continue + if event.get("EventType") != "run_status": + continue + content = event.get("Content") + if isinstance(content, dict) and content.get("status"): + statuses.append(str(content["status"])) + return statuses + + +def validate_checkpoint_resume( + client: HostedClient, + *, + session_id: str, + prompt: str, + wait_attempts: int, + wait_interval: float, + stream_timeout: float, +) -> dict[str, Any]: + bootstrap = client.action( + "GetAgentUiBootstrap", + {"AgentId": client.agent_id, "SessionId": session_id}, + ) + capabilities = _extract_capabilities(bootstrap) + _assert_checkpoint_capability(capabilities) + + run_payload = _run_agent(client, session_id=session_id, prompt=prompt) + checkpoint = _wait_for_checkpoint( + client, + session_id=session_id, + attempts=wait_attempts, + interval=wait_interval, + ) + run_id = str(checkpoint.get("RunId") or "").strip() + checkpoint_id = str(checkpoint.get("CheckpointId") or "").strip() + if not run_id or not checkpoint_id: + raise HostedE2EError(f"Checkpoint missing RunId/CheckpointId: {checkpoint}") + + preview = _maybe_preview( + client, + session_id=session_id, + run_id=run_id, + checkpoint_id=checkpoint_id, + ) + tool_receipts = _maybe_list_tool_receipts( + client, + session_id=session_id, + run_id=run_id, + checkpoint_id=checkpoint_id, + ) + + resume_invocation_id = f"run_{uuid.uuid4().hex}" + resume_sse = _resume_stream( + client, + session_id=session_id, + run_id=run_id, + checkpoint_id=checkpoint_id, + invocation_id=resume_invocation_id, + max_seconds=stream_timeout, + ) + events = _list_events(client, session_id=session_id) + event_counts = _event_type_counts(events) + statuses = _event_statuses(events, invocation_id=resume_invocation_id) + + if event_counts.get("run_resume", 0) < 1: + raise HostedE2EError(f"ResumeRun did not create run_resume event: {event_counts}") + if event_counts.get("run_checkpoint", 0) < 1: + raise HostedE2EError(f"Session has no run_checkpoint events after resume: {event_counts}") + if statuses and statuses[-1] not in TERMINAL_STATUSES: + raise HostedE2EError(f"ResumeRun terminal status not reached: {statuses}") + + return { + "status": "pass", + "session_id": session_id, + "run_id": run_id, + "checkpoint_id": checkpoint_id, + "resume_invocation_id": resume_invocation_id, + "bootstrap_run_lifecycle": capabilities.get("RunLifecycle"), + "run_agent_code": run_payload.get("Code"), + "checkpoint_count": len(_list_checkpoints(client, session_id=session_id)), + "event_counts": event_counts, + "resume_statuses": statuses, + "resume_sse_line_count": len([line for line in resume_sse.splitlines() if line.strip()]), + "preview": _summarize_optional_action(preview, "Preview"), + "tool_receipts": _summarize_optional_action(tool_receipts, "ToolReceipts"), + } + + +def _summarize_optional_action(payload: dict[str, Any], data_key: str) -> dict[str, Any]: + if payload.get("skipped"): + return {"status": "skipped", "error": payload.get("error")} + data = payload.get("Data") if isinstance(payload.get("Data"), dict) else {} + value = data.get(data_key) + if isinstance(value, list): + return {"status": "pass", "count": len(value)} + if isinstance(value, dict): + return {"status": "pass", "keys": sorted(value.keys())} + return {"status": "pass", "present": value is not None} + + +def validate_cancel_active( + client: HostedClient, + *, + session_id: str, + invocation_id: str, + wait_attempts: int, + wait_interval: float, +) -> dict[str, Any]: + cancel_payload = _cancel_run(client, invocation_id=invocation_id) + cancel_data = _data(cancel_payload, "CancelRun") + for _ in range(wait_attempts): + events = _list_events(client, session_id=session_id) + statuses = _event_statuses(events, invocation_id=invocation_id) + if statuses and statuses[-1] in TERMINAL_STATUSES: + return { + "status": "pass", + "session_id": session_id, + "invocation_id": invocation_id, + "cancel_data": cancel_data, + "statuses": statuses, + "event_counts": _event_type_counts(events), + } + time.sleep(wait_interval) + raise HostedE2EError( + f"CancelRun did not reach terminal status for {invocation_id}: {cancel_data}" + ) + + +def validate_cancel_then_resume( + client: HostedClient, + *, + session_id: str, + prompt: str, + wait_attempts: int, + wait_interval: float, + stream_timeout: float, +) -> dict[str, Any]: + bootstrap = client.action( + "GetAgentUiBootstrap", + {"AgentId": client.agent_id, "SessionId": session_id}, + ) + capabilities = _extract_capabilities(bootstrap) + _assert_checkpoint_capability(capabilities) + + invocation_id = f"run_{uuid.uuid4().hex}" + stream_thread, stream_result = _stream_run_agent_background( + client, + session_id=session_id, + prompt=prompt, + invocation_id=invocation_id, + max_seconds=stream_timeout, + ) + + try: + checkpoint = _wait_for_checkpoint( + client, + session_id=session_id, + run_id=invocation_id, + attempts=wait_attempts, + interval=wait_interval, + ) + run_id = str(checkpoint.get("RunId") or "").strip() + checkpoint_id = str(checkpoint.get("CheckpointId") or "").strip() + if run_id != invocation_id: + raise HostedE2EError( + f"Checkpoint RunId should match active invocation_id: {run_id!r} != {invocation_id!r}" + ) + if not checkpoint_id: + raise HostedE2EError(f"Checkpoint missing CheckpointId: {checkpoint}") + + cancel_payload = _cancel_run(client, invocation_id=invocation_id) + cancel_data = _data(cancel_payload, "CancelRun") + if cancel_data.get("Cancelled") is not True: + raise HostedE2EError(f"CancelRun did not accept active stream: {cancel_data}") + + cancelled_statuses: list[str] = [] + event_count_at_cancel = 0 + for _ in range(wait_attempts): + events = _list_events(client, session_id=session_id) + cancelled_statuses = _event_statuses(events, invocation_id=invocation_id) + if cancelled_statuses and cancelled_statuses[-1] == "cancelled": + event_count_at_cancel = len(events) + break + time.sleep(wait_interval) + else: + raise HostedE2EError( + f"CancelRun did not create cancelled status for {invocation_id}: {cancel_data}" + ) + + stream_thread.join(timeout=min(stream_timeout, 10.0)) + if stream_thread.is_alive(): + raise HostedE2EError("RunAgent stream did not close after CancelRun") + if stream_result.get("error") is not None: + raise HostedE2EError(f"RunAgent stream failed during cancel validation: {stream_result['error']}") + + post_cancel_events = _list_events(client, session_id=session_id) + unexpected_post_cancel = [ + event + for event in post_cancel_events[event_count_at_cancel:] + if event.get("EventType") in {"assistant_message", "run_checkpoint"} + or ( + event.get("EventType") == "run_status" + and isinstance(event.get("Content"), dict) + and event["Content"].get("status") == "completed" + ) + ] + if unexpected_post_cancel: + raise HostedE2EError( + "Unexpected assistant/checkpoint/completed events appeared after cancel: " + f"{_event_type_counts(unexpected_post_cancel)}" + ) + + resume_invocation_id = f"run_{uuid.uuid4().hex}" + resume_sse = _resume_stream( + client, + session_id=session_id, + run_id=run_id, + checkpoint_id=checkpoint_id, + invocation_id=resume_invocation_id, + max_seconds=stream_timeout, + ) + + final_events = _list_events(client, session_id=session_id) + final_counts = _event_type_counts(final_events) + resume_statuses = _event_statuses(final_events, invocation_id=resume_invocation_id) + if final_counts.get("run_resume", 0) < 1: + raise HostedE2EError(f"ResumeRun after cancel did not create run_resume event: {final_counts}") + if final_counts.get("run_checkpoint", 0) < 2: + raise HostedE2EError( + f"ResumeRun after cancel should leave at least two checkpoint events: {final_counts}" + ) + if resume_statuses and resume_statuses[-1] not in TERMINAL_STATUSES: + raise HostedE2EError(f"ResumeRun after cancel did not reach terminal status: {resume_statuses}") + + return { + "status": "pass", + "session_id": session_id, + "run_id": run_id, + "checkpoint_id": checkpoint_id, + "cancel_invocation_id": invocation_id, + "resume_invocation_id": resume_invocation_id, + "bootstrap_run_lifecycle": capabilities.get("RunLifecycle"), + "cancel_data": cancel_data, + "cancel_statuses": cancelled_statuses, + "event_counts": final_counts, + "resume_statuses": resume_statuses, + "stream_closed_after_cancel": not stream_thread.is_alive(), + "run_sse_line_count": len( + [line for line in str(stream_result.get("sse") or "").splitlines() if line.strip()] + ), + "resume_sse_line_count": len([line for line in resume_sse.splitlines() if line.strip()]), + } + finally: + if stream_thread.is_alive(): + stream_thread.join(timeout=1.0) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser() + parser.add_argument( + "--endpoint", + required=True, + help="PublicEndpoint, e.g. https://ar-xxx.agent-pre.kspmas.ksyun.com", + ) + parser.add_argument("--agent-id", required=True, help="Agent runtime ID, e.g. ar-...") + parser.add_argument("--api-key", default="", help="Optional runtime API key for public endpoint auth.") + parser.add_argument( + "--cookie", + default="", + help="Optional Cookie header value, e.g. ae_ui_session=... from a private/share link.", + ) + parser.add_argument("--account-id", default="", help="Optional account id header.") + parser.add_argument("--principal-id", default="", help="Optional principal/user id header.") + parser.add_argument("--user-id", default="hosted-long-task-e2e-user") + parser.add_argument("--session-id", default="", help="Defaults to a generated sess_... id.") + parser.add_argument("--prompt", default=DEFAULT_PROMPT) + parser.add_argument("--timeout", type=float, default=90.0) + parser.add_argument("--stream-timeout", type=float, default=120.0) + parser.add_argument("--wait-attempts", type=int, default=60) + parser.add_argument("--wait-interval", type=float, default=1.0) + parser.add_argument( + "--mode", + choices=["checkpoint-resume", "cancel-active", "cancel-then-resume"], + default="checkpoint-resume", + ) + parser.add_argument( + "--invocation-id", + default="", + help="Required for --mode cancel-active; must be an active run invocation id.", + ) + return parser + + +def main() -> int: + args = build_parser().parse_args() + session_id = args.session_id or f"sess_{uuid.uuid4().hex}" + client = HostedClient( + base_url=args.endpoint, + agent_id=args.agent_id, + user_id=args.user_id, + timeout=args.timeout, + api_key=args.api_key, + cookie=args.cookie, + account_id=args.account_id, + principal_id=args.principal_id, + ) + try: + if args.mode == "checkpoint-resume": + result = validate_checkpoint_resume( + client, + session_id=session_id, + prompt=args.prompt, + wait_attempts=args.wait_attempts, + wait_interval=args.wait_interval, + stream_timeout=args.stream_timeout, + ) + else: + if args.mode == "cancel-then-resume": + result = validate_cancel_then_resume( + client, + session_id=session_id, + prompt=args.prompt, + wait_attempts=args.wait_attempts, + wait_interval=args.wait_interval, + stream_timeout=args.stream_timeout, + ) + elif not args.invocation_id: + raise HostedE2EError("--invocation-id is required for --mode cancel-active") + else: + result = validate_cancel_active( + client, + session_id=session_id, + invocation_id=args.invocation_id, + wait_attempts=args.wait_attempts, + wait_interval=args.wait_interval, + ) + print(json.dumps({"hosted_long_task_e2e": result}, ensure_ascii=False, indent=2)) + return 0 + except Exception as exc: + print( + json.dumps( + { + "hosted_long_task_e2e": { + "status": "fail", + "error_type": type(exc).__name__, + "error": str(exc), + } + }, + ensure_ascii=False, + indent=2, + ), + file=sys.stderr, + ) + return 1 + finally: + client.close() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/validate_long_task_pilot.py b/scripts/validate_long_task_pilot.py new file mode 100644 index 00000000..7e443175 --- /dev/null +++ b/scripts/validate_long_task_pilot.py @@ -0,0 +1,493 @@ +#!/usr/bin/env python3 +"""Run the long-task pilot acceptance validation and emit a JSON report. + +This wraps the lower-level checkpoint resume e2e script with the W3 acceptance +shape expected by the delivery plan. It intentionally avoids printing the DSN. +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import os +import sys +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from scripts.validate_checkpoint_resume_e2e import ( + run_cancel_validation, + run_cancel_then_resume_validation, + run_validation, +) + + +def _pass_fail(condition: bool) -> str: + return "pass" if condition else "fail" + + +def _rate(condition: bool) -> float: + return 1.0 if condition else 0.0 + + +def _checkpoint_resume_passed(result: dict[str, Any]) -> bool: + return ( + result.get("output_text") == "a,b,c" + and int(result.get("checkpoint_count") or 0) >= 1 + and int(result.get("run_checkpoint_event_count") or 0) >= 2 + and int(result.get("run_resume_event_count") or 0) >= 1 + and result.get("resume_did_not_rerun_prior_nodes") is True + ) + + +def _runtime_cancel_passed(result: dict[str, Any] | None) -> bool: + if not result: + return False + return ( + result.get("cancel_found") is True + and str(result.get("cancel_status") or "") in {"cancelling", "cancelled"} + and int(result.get("cancelled_event_count") or 0) >= 1 + and int(result.get("post_cancel_extra_event_count") or 0) == 0 + ) + + +def _cancel_then_resume_passed(result: dict[str, Any] | None) -> bool: + if not result: + return False + return ( + result.get("cancel_found") is True + and str(result.get("cancel_status") or "") in {"cancelling", "cancelled"} + and int(result.get("cancelled_event_count") or 0) >= 1 + and int(result.get("post_cancel_extra_event_count") or 0) == 0 + and result.get("output_text_after_resume") == "a,b,c" + and result.get("resume_after_cancel_did_not_rerun_prior_nodes") is True + ) + + +async def _build_single_pilot_report( + *, + dsn: str, + keep_session: bool, + include_cancel: bool, +) -> dict[str, Any]: + generated_at = datetime.now(UTC).isoformat() + try: + checkpoint_result = await run_validation(dsn=dsn, keep_session=keep_session) + except Exception as exc: + return { + "report_type": "long_task_pilot_validation", + "generated_at": generated_at, + "overall_status": "fail", + "cases": { + "checkpoint_resume": { + "status": "fail", + "error_type": type(exc).__name__, + "error": str(exc), + }, + "runtime_cancel": { + "status": "skipped", + "reason": "checkpoint_resume failed before cancel validation", + }, + "cancel_then_resume": { + "status": "skipped", + "reason": "checkpoint_resume failed before cancel-then-resume validation", + }, + }, + "metrics": { + "checkpoint_resume_success_rate": 0.0, + "runtime_cancel_success_rate": None, + "cancel_then_resume_success_rate": None, + "checkpoint_event_count": None, + "resume_event_count": None, + "post_cancel_extra_event_count": None, + }, + "acceptance": { + "same_run_id_resume": "fail", + "checkpoint_list_visible": "fail", + "resume_does_not_restart": "fail", + "runtime_cancel_terminal": "skipped", + "no_events_after_cancel": "skipped", + "cancel_then_resume_after_cancelled": "skipped", + "resume_after_cancel_does_not_restart": "skipped", + }, + "notes": [ + "DSN is intentionally omitted from this report.", + "Provider/tool deep cancellation support must be reported as accepted or unsupported per runner/tool.", + ], + } + + cancel_result = None + cancel_then_resume_result = None + if include_cancel: + try: + cancel_result = await run_cancel_validation(dsn=dsn, keep_session=keep_session) + except Exception as exc: + cancel_result = { + "error_type": type(exc).__name__, + "error": str(exc), + } + try: + cancel_then_resume_result = await run_cancel_then_resume_validation( + dsn=dsn, + keep_session=keep_session, + ) + except Exception as exc: + cancel_then_resume_result = { + "error_type": type(exc).__name__, + "error": str(exc), + } + + checkpoint_ok = _checkpoint_resume_passed(checkpoint_result) + cancel_ok = _runtime_cancel_passed(cancel_result) if include_cancel else None + cancel_then_resume_ok = ( + _cancel_then_resume_passed(cancel_then_resume_result) if include_cancel else None + ) + overall_ok = checkpoint_ok and (cancel_ok is not False) and (cancel_then_resume_ok is not False) + + cases: dict[str, Any] = { + "checkpoint_resume": { + "status": _pass_fail(checkpoint_ok), + "session_id": checkpoint_result.get("session_id"), + "run_id": checkpoint_result.get("run_id"), + "checkpoint_id": checkpoint_result.get("checkpoint_id"), + "output_text": checkpoint_result.get("output_text"), + "checkpoint_count": checkpoint_result.get("checkpoint_count"), + "run_checkpoint_event_count": checkpoint_result.get("run_checkpoint_event_count"), + "run_resume_event_count": checkpoint_result.get("run_resume_event_count"), + "checkpoint_log_before_resume": checkpoint_result.get("checkpoint_log_before_resume"), + "node_counts_after_resume": checkpoint_result.get("node_counts_after_resume"), + "resume_did_not_rerun_prior_nodes": checkpoint_result.get("resume_did_not_rerun_prior_nodes"), + } + } + if include_cancel: + cases["runtime_cancel"] = { + "status": _pass_fail(bool(cancel_ok)), + "error_type": cancel_result.get("error_type") if cancel_result else None, + "error": cancel_result.get("error") if cancel_result else None, + "session_id": cancel_result.get("session_id") if cancel_result else None, + "invocation_id": cancel_result.get("invocation_id") if cancel_result else None, + "cancel_found": cancel_result.get("cancel_found") if cancel_result else None, + "cancel_status": cancel_result.get("cancel_status") if cancel_result else None, + "cancelled_event_count": cancel_result.get("cancelled_event_count") if cancel_result else None, + "post_cancel_extra_event_count": ( + cancel_result.get("post_cancel_extra_event_count") if cancel_result else None + ), + } + cases["cancel_then_resume"] = { + "status": _pass_fail(bool(cancel_then_resume_ok)), + "error_type": ( + cancel_then_resume_result.get("error_type") if cancel_then_resume_result else None + ), + "error": cancel_then_resume_result.get("error") if cancel_then_resume_result else None, + "session_id": ( + cancel_then_resume_result.get("session_id") if cancel_then_resume_result else None + ), + "run_id": cancel_then_resume_result.get("run_id") if cancel_then_resume_result else None, + "invocation_id": ( + cancel_then_resume_result.get("invocation_id") if cancel_then_resume_result else None + ), + "checkpoint_id": ( + cancel_then_resume_result.get("checkpoint_id") if cancel_then_resume_result else None + ), + "cancel_found": ( + cancel_then_resume_result.get("cancel_found") if cancel_then_resume_result else None + ), + "cancel_status": ( + cancel_then_resume_result.get("cancel_status") if cancel_then_resume_result else None + ), + "cancelled_event_count": ( + cancel_then_resume_result.get("cancelled_event_count") + if cancel_then_resume_result + else None + ), + "post_cancel_extra_event_count": ( + cancel_then_resume_result.get("post_cancel_extra_event_count") + if cancel_then_resume_result + else None + ), + "output_text_after_resume": ( + cancel_then_resume_result.get("output_text_after_resume") + if cancel_then_resume_result + else None + ), + "checkpoint_log_before_cancel": ( + cancel_then_resume_result.get("checkpoint_log_before_cancel") + if cancel_then_resume_result + else None + ), + "node_counts_after_resume": ( + cancel_then_resume_result.get("node_counts_after_resume") + if cancel_then_resume_result + else None + ), + "resume_after_cancel_did_not_rerun_prior_nodes": ( + cancel_then_resume_result.get("resume_after_cancel_did_not_rerun_prior_nodes") + if cancel_then_resume_result + else None + ), + } + else: + cases["runtime_cancel"] = { + "status": "skipped", + "reason": "cancel validation disabled by --skip-cancel", + } + cases["cancel_then_resume"] = { + "status": "skipped", + "reason": "cancel validation disabled by --skip-cancel", + } + + no_events_after_cancel = ( + cancel_result is not None and int(cancel_result.get("post_cancel_extra_event_count") or 0) == 0 + ) + cancel_then_resume_after_cancel = bool(cancel_then_resume_ok) + return { + "report_type": "long_task_pilot_validation", + "generated_at": generated_at, + "overall_status": _pass_fail(overall_ok), + "cases": cases, + "metrics": { + "checkpoint_resume_success_rate": _rate(checkpoint_ok), + "runtime_cancel_success_rate": _rate(bool(cancel_ok)) if include_cancel else None, + "cancel_then_resume_success_rate": ( + _rate(bool(cancel_then_resume_ok)) if include_cancel else None + ), + "checkpoint_event_count": checkpoint_result.get("run_checkpoint_event_count"), + "resume_event_count": checkpoint_result.get("run_resume_event_count"), + "node_counts_after_resume": checkpoint_result.get("node_counts_after_resume"), + "post_cancel_extra_event_count": ( + cancel_result.get("post_cancel_extra_event_count") if cancel_result else None + ), + }, + "acceptance": { + "same_run_id_resume": _pass_fail(bool(checkpoint_result.get("run_id")) and checkpoint_ok), + "checkpoint_list_visible": _pass_fail(int(checkpoint_result.get("checkpoint_count") or 0) >= 1), + "resume_does_not_restart": _pass_fail( + checkpoint_result.get("resume_did_not_rerun_prior_nodes") is True + ), + "runtime_cancel_terminal": ( + _pass_fail(bool(cancel_ok)) if include_cancel else "skipped" + ), + "no_events_after_cancel": ( + _pass_fail(no_events_after_cancel) if include_cancel else "skipped" + ), + "cancel_then_resume_after_cancelled": ( + _pass_fail(cancel_then_resume_after_cancel) if include_cancel else "skipped" + ), + "resume_after_cancel_does_not_restart": ( + _pass_fail( + cancel_then_resume_result is not None + and cancel_then_resume_result.get("resume_after_cancel_did_not_rerun_prior_nodes") + is True + ) + if include_cancel + else "skipped" + ), + }, + "notes": [ + "DSN is intentionally omitted from this report.", + "Provider/tool deep cancellation support must be reported as accepted or unsupported per runner/tool.", + ], + } + + +def _case_passed(report: dict[str, Any], case_name: str) -> bool: + cases = report.get("cases") if isinstance(report.get("cases"), dict) else {} + case = cases.get(case_name) if isinstance(cases, dict) else None + return isinstance(case, dict) and case.get("status") == "pass" + + +def _first_nonpassing_case(reports: list[dict[str, Any]], case_name: str) -> dict[str, Any]: + for report in reports: + cases = report.get("cases") if isinstance(report.get("cases"), dict) else {} + case = cases.get(case_name) if isinstance(cases, dict) else None + if isinstance(case, dict) and case.get("status") != "pass": + return dict(case) + cases = reports[-1].get("cases") if reports else {} + case = cases.get(case_name) if isinstance(cases, dict) else {} + return dict(case) if isinstance(case, dict) else {} + + +def _max_metric(reports: list[dict[str, Any]], metric_name: str) -> int | None: + values: list[int] = [] + for report in reports: + metrics = report.get("metrics") if isinstance(report.get("metrics"), dict) else {} + value = metrics.get(metric_name) if isinstance(metrics, dict) else None + if value is not None: + values.append(int(value or 0)) + return max(values) if values else None + + +async def build_pilot_report( + *, + dsn: str, + keep_session: bool, + include_cancel: bool, + iterations: int = 1, +) -> dict[str, Any]: + total_iterations = max(1, int(iterations or 1)) + if total_iterations == 1: + return await _build_single_pilot_report( + dsn=dsn, + keep_session=keep_session, + include_cancel=include_cancel, + ) + + generated_at = datetime.now(UTC).isoformat() + iteration_reports: list[dict[str, Any]] = [] + for index in range(1, total_iterations + 1): + report = await _build_single_pilot_report( + dsn=dsn, + keep_session=keep_session, + include_cancel=include_cancel, + ) + report["iteration"] = index + iteration_reports.append(report) + + checkpoint_passed = sum( + 1 for report in iteration_reports if _case_passed(report, "checkpoint_resume") + ) + cancel_passed = ( + sum(1 for report in iteration_reports if _case_passed(report, "runtime_cancel")) + if include_cancel + else None + ) + cancel_then_resume_passed = ( + sum(1 for report in iteration_reports if _case_passed(report, "cancel_then_resume")) + if include_cancel + else None + ) + checkpoint_ok = checkpoint_passed == total_iterations + cancel_ok = (cancel_passed == total_iterations) if include_cancel else True + cancel_then_resume_ok = ( + cancel_then_resume_passed == total_iterations + if include_cancel + else True + ) + + checkpoint_case = _first_nonpassing_case(iteration_reports, "checkpoint_resume") + checkpoint_case["status"] = _pass_fail(checkpoint_ok) + cases: dict[str, Any] = {"checkpoint_resume": checkpoint_case} + if include_cancel: + cancel_case = _first_nonpassing_case(iteration_reports, "runtime_cancel") + cancel_case["status"] = _pass_fail(cancel_ok) + cases["runtime_cancel"] = cancel_case + else: + cases["runtime_cancel"] = { + "status": "skipped", + "reason": "cancel validation disabled by --skip-cancel", + } + cases["cancel_then_resume"] = { + "status": "skipped", + "reason": "cancel validation disabled by --skip-cancel", + } + if include_cancel: + cancel_then_resume_case = _first_nonpassing_case( + iteration_reports, + "cancel_then_resume", + ) + cancel_then_resume_case["status"] = _pass_fail(cancel_then_resume_ok) + cases["cancel_then_resume"] = cancel_then_resume_case + + return { + "report_type": "long_task_pilot_validation", + "generated_at": generated_at, + "overall_status": _pass_fail(checkpoint_ok and cancel_ok and cancel_then_resume_ok), + "cases": cases, + "iterations": iteration_reports, + "metrics": { + "total_iterations": total_iterations, + "checkpoint_resume_passed": checkpoint_passed, + "checkpoint_resume_failed": total_iterations - checkpoint_passed, + "runtime_cancel_passed": cancel_passed if include_cancel else None, + "runtime_cancel_failed": ( + total_iterations - int(cancel_passed or 0) if include_cancel else None + ), + "cancel_then_resume_passed": ( + cancel_then_resume_passed if include_cancel else None + ), + "cancel_then_resume_failed": ( + total_iterations - int(cancel_then_resume_passed or 0) + if include_cancel + else None + ), + "checkpoint_resume_success_rate": checkpoint_passed / total_iterations, + "runtime_cancel_success_rate": ( + int(cancel_passed or 0) / total_iterations if include_cancel else None + ), + "cancel_then_resume_success_rate": ( + int(cancel_then_resume_passed or 0) / total_iterations + if include_cancel + else None + ), + "max_post_cancel_extra_event_count": _max_metric( + iteration_reports, + "post_cancel_extra_event_count", + ), + }, + "acceptance": { + "same_run_id_resume": _pass_fail(checkpoint_ok), + "checkpoint_list_visible": _pass_fail(checkpoint_ok), + "resume_does_not_restart": _pass_fail(checkpoint_ok), + "runtime_cancel_terminal": _pass_fail(cancel_ok) if include_cancel else "skipped", + "no_events_after_cancel": _pass_fail(cancel_ok) if include_cancel else "skipped", + "cancel_then_resume_after_cancelled": ( + _pass_fail(cancel_then_resume_ok) if include_cancel else "skipped" + ), + "resume_after_cancel_does_not_restart": ( + _pass_fail(cancel_then_resume_ok) if include_cancel else "skipped" + ), + }, + "notes": [ + "DSN is intentionally omitted from this report.", + "Provider/tool deep cancellation support must be reported as accepted or unsupported per runner/tool.", + ], + } + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument( + "--dsn", + default=os.environ.get("KSADK_SESSION_DSN", ""), + help="PostgreSQL DSN. Defaults to KSADK_SESSION_DSN. The value is not printed.", + ) + parser.add_argument( + "--keep-session", + action="store_true", + help="Keep generated session rows for debugging.", + ) + parser.add_argument( + "--skip-cancel", + action="store_true", + help="Skip W2.5 runtime cancel validation.", + ) + parser.add_argument( + "--iterations", + type=int, + default=1, + help="Number of independent validation iterations to run. Use 100 for W3 95%% acceptance.", + ) + args = parser.parse_args() + dsn = args.dsn.strip() + if not dsn: + raise SystemExit("--dsn or KSADK_SESSION_DSN is required") + + report = asyncio.run( + build_pilot_report( + dsn=dsn, + keep_session=args.keep_session, + include_cancel=not args.skip_cancel, + iterations=args.iterations, + ) + ) + print(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True)) + return 0 if report["overall_status"] == "pass" else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/zread_subpath_proxy.py b/scripts/zread_subpath_proxy.py new file mode 100644 index 00000000..311dd253 --- /dev/null +++ b/scripts/zread_subpath_proxy.py @@ -0,0 +1,230 @@ +#!/usr/bin/env python3 +"""Serve native zread under a non-root path. + +zread's embedded UI currently assumes root-relative assets and APIs. This proxy +keeps zread itself unchanged, while making the app safe to publish below +`/ksadk-docs` on a shared ingress host. +""" + +from __future__ import annotations + +import os +import signal +import subprocess +import sys +import threading +import time +import gzip +import urllib.error +import urllib.parse +import urllib.request +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + + +LISTEN_HOST = os.environ.get("HOST", "0.0.0.0") +LISTEN_PORT = int(os.environ.get("PORT", "8080")) +UPSTREAM_HOST = "127.0.0.1" +UPSTREAM_PORT = int(os.environ.get("ZREAD_UPSTREAM_PORT", "9681")) +BASE_PATH = "/" + os.environ.get("DOCS_BASE_PATH", "/ksadk-docs").strip("/") +if BASE_PATH == "/": + BASE_PATH = "" +try: + CACHE_BUSTER = os.environ.get("DOCS_CACHE_BUSTER") or open(".zread/wiki/current", encoding="utf-8").read().strip() +except OSError: + CACHE_BUSTER = str(int(time.time())) +CACHE_BUSTER = CACHE_BUSTER.replace("/", "-") + + +def start_zread() -> subprocess.Popen[bytes]: + return subprocess.Popen( + [ + "zread", + "browse", + "--host", + UPSTREAM_HOST, + "--port", + str(UPSTREAM_PORT), + "--stdio", + ], + stdin=subprocess.PIPE, + stdout=subprocess.DEVNULL, + stderr=subprocess.STDOUT, + ) + + +def wait_for_upstream(process: subprocess.Popen[bytes]) -> None: + deadline = time.time() + 60 + url = f"http://{UPSTREAM_HOST}:{UPSTREAM_PORT}/" + last_error: Exception | None = None + while time.time() < deadline: + if process.poll() is not None: + raise RuntimeError(f"zread exited early with code {process.returncode}") + try: + with urllib.request.urlopen(url, timeout=2) as response: + if response.status < 500: + return + except Exception as exc: # noqa: BLE001 - surfaced after retry window. + last_error = exc + time.sleep(0.5) + raise RuntimeError(f"zread did not become ready: {last_error}") + + +def rewrite_text(body: bytes, content_type: str, content_encoding: str) -> bytes: + if content_encoding.lower() == "gzip": + body = gzip.decompress(body) + + if not any(token in content_type for token in ("text/html", "javascript", "text/css")): + return body + + text = body.decode("utf-8", errors="replace") + if "text/html" in content_type: + text = text.replace('src="/', f'src="{BASE_PATH}/') + text = text.replace('href="/', f'href="{BASE_PATH}/') + text = text.replace('.js"', f'.js?v={CACHE_BUSTER}"') + text = text.replace('.css"', f'.css?v={CACHE_BUSTER}"') + if 'rel="icon"' not in text: + text = text.replace("", '') + + if "javascript" in content_type: + text = text.replace("`/api/", f"`{BASE_PATH}/api/") + text = text.replace('"/api/', f'"{BASE_PATH}/api/') + text = text.replace("'/api/", f"'{BASE_PATH}/api/") + text = text.replace("fetch(`/api/", f"fetch(`{BASE_PATH}/api/") + text = text.replace('fetch("/api/', f'fetch("{BASE_PATH}/api/') + text = text.replace("href:`/api/", f"href:`{BASE_PATH}/api/") + text = text.replace( + "resolveInternalWikiHref:e=>`/${e}`", + f"resolveInternalWikiHref:e=>`{BASE_PATH}/${{e}}`", + ) + text = text.replace( + "buildTopicHref:e=>`/${e}`", + f"buildTopicHref:e=>`{BASE_PATH}/${{e}}`", + ) + text = text.replace( + "window.history.pushState(null,``,`/${e.slug}`)", + f"window.history.pushState(null,``,`{BASE_PATH}/${{e.slug}}`)", + ) + text = text.replace( + "function pJt(){return window.location.pathname.replace(/^\\//,``)}", + "function pJt(){return window.location.pathname" + f".replace(/^\\/{BASE_PATH.strip('/')}\\/?/,``)" + ".replace(/^\\//,``)}", + ) + + return text.encode("utf-8") + + +class ProxyHandler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def log_message(self, fmt: str, *args: object) -> None: + sys.stderr.write("%s - - [%s] %s\n" % (self.address_string(), self.log_date_time_string(), fmt % args)) + + def do_GET(self) -> None: # noqa: N802 - stdlib hook name. + self._proxy() + + def do_HEAD(self) -> None: # noqa: N802 - stdlib hook name. + self._proxy(head_only=True) + + def do_PUT(self) -> None: # noqa: N802 - zread editor save endpoint. + self._proxy(with_body=True) + + def _proxy(self, *, head_only: bool = False, with_body: bool = False) -> None: + parsed = urllib.parse.urlsplit(self.path) + if parsed.path == "/healthz": + self._send_plain(200, "ok\n") + return + + if BASE_PATH and parsed.path == BASE_PATH: + self.send_response(308) + self.send_header("Location", f"{BASE_PATH}/") + self.send_header("Content-Length", "0") + self.end_headers() + return + + upstream_path = parsed.path + if BASE_PATH: + if not (parsed.path == BASE_PATH or parsed.path.startswith(f"{BASE_PATH}/")): + self._send_plain(404, "not found\n") + return + upstream_path = parsed.path[len(BASE_PATH) :] or "/" + + upstream_url = urllib.parse.urlunsplit( + ("http", f"{UPSTREAM_HOST}:{UPSTREAM_PORT}", upstream_path, parsed.query, "") + ) + body = None + if with_body: + length = int(self.headers.get("Content-Length", "0")) + body = self.rfile.read(length) if length else b"" + + headers = { + "Accept": self.headers.get("Accept", "*/*"), + "Accept-Encoding": "identity", + } + if "Content-Type" in self.headers: + headers["Content-Type"] = self.headers["Content-Type"] + + request = urllib.request.Request(upstream_url, data=body, headers=headers, method=self.command) + try: + with urllib.request.urlopen(request, timeout=60) as response: + response_body = b"" if head_only else response.read() + content_type = response.headers.get("Content-Type", "") + content_encoding = response.headers.get("Content-Encoding", "") + is_rewritten = any(token in content_type for token in ("text/html", "javascript", "text/css")) + response_body = rewrite_text(response_body, content_type, content_encoding) + self.send_response(response.status) + for key, value in response.headers.items(): + lower = key.lower() + if lower in {"content-length", "content-encoding", "transfer-encoding", "connection"}: + continue + if is_rewritten and lower == "cache-control": + continue + self.send_header(key, value) + if is_rewritten: + self.send_header("Cache-Control", "no-cache") + self.send_header("Content-Length", str(len(response_body))) + self.end_headers() + if not head_only: + self.wfile.write(response_body) + except urllib.error.HTTPError as exc: + error_body = b"" if head_only else exc.read() + self.send_response(exc.code) + self.send_header("Content-Length", str(len(error_body))) + self.end_headers() + if not head_only: + self.wfile.write(error_body) + + def _send_plain(self, status: int, body: str) -> None: + payload = body.encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "text/plain; charset=utf-8") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + +def main() -> int: + process = start_zread() + + def stop_process(*_: object) -> None: + if process.poll() is None: + process.terminate() + + signal.signal(signal.SIGTERM, stop_process) + signal.signal(signal.SIGINT, stop_process) + + wait_for_upstream(process) + server = ThreadingHTTPServer((LISTEN_HOST, LISTEN_PORT), ProxyHandler) + + watcher = threading.Thread(target=lambda: (process.wait(), server.shutdown()), daemon=True) + watcher.start() + + try: + server.serve_forever() + finally: + stop_process() + return process.returncode or 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/long_task/__init__.py b/tests/long_task/__init__.py new file mode 100644 index 00000000..acd33e3f --- /dev/null +++ b/tests/long_task/__init__.py @@ -0,0 +1 @@ +"""Long-task recovery acceptance tests.""" diff --git a/tests/long_task/test_checkpoint_resume.py b/tests/long_task/test_checkpoint_resume.py new file mode 100644 index 00000000..8dd51310 --- /dev/null +++ b/tests/long_task/test_checkpoint_resume.py @@ -0,0 +1,158 @@ +from __future__ import annotations + +from ksadk.conversations.context import build_history_from_events +from ksadk.conversations.runtime import ( + append_run_checkpoint_event, + append_run_resume_event, + extract_responses_resume_input, + invoke_conversation_once, +) +from ksadk.sessions.base import SessionEvent +from ksadk.sessions.in_memory import InMemorySessionService + +import pytest + + +class _CheckpointResumeRunner: + def __init__(self): + self.detection_result = type("Detection", (), {"name": "demo-agent"})() + self.calls: list[dict] = [] + + def prepare_for_request(self, model): + del model + + async def invoke(self, input_data: dict) -> dict: + self.calls.append(input_data) + return { + "output": "resumed", + "metadata": { + "agentengine": { + "run_id": input_data["run_id"], + "framework": "langgraph", + "framework_ref": { + "langgraph": { + "thread_id": "tenant:agent:sess-long", + "checkpoint_id": "ckpt-after", + } + }, + } + }, + } + + +def test_checkpoint_runtime_events_are_not_projected_to_model_history(): + events = [ + SessionEvent( + id="evt-checkpoint", + author="demo-agent", + event_type="run_checkpoint", + content={"status": "saved"}, + metadata={"run_id": "run-1", "checkpoint_id": "ckpt-1"}, + seq_id=1, + ), + SessionEvent( + id="evt-resume", + author="demo-agent", + event_type="run_resume", + content={"status": "requested"}, + metadata={"run_id": "run-1", "resume_attempt_id": "resume-1"}, + seq_id=2, + ), + SessionEvent( + id="evt-user", + author="user", + event_type="user_message", + content={"role": "user", "parts": [{"text": "继续"}]}, + seq_id=3, + ), + ] + + assert build_history_from_events(events) == [{"role": "user", "content": "继续"}] + + +def test_responses_input_accepts_checkpoint_resume_action(): + resume_input = extract_responses_resume_input( + [ + { + "type": "agentengine.resume_checkpoint", + "run_id": "run-1", + "checkpoint_id": "ckpt-before", + "resume_attempt_id": "resume-1", + "framework": "langgraph", + "framework_ref": { + "langgraph": { + "thread_id": "tenant:agent:sess-long", + "checkpoint_id": "ckpt-before", + } + }, + } + ] + ) + + assert resume_input["type"] == "agentengine.resume_checkpoint" + assert resume_input["run_id"] == "run-1" + assert resume_input["checkpoint_id"] == "ckpt-before" + assert resume_input["resume_attempt_id"] == "resume-1" + + +@pytest.mark.asyncio +async def test_checkpoint_resume_keeps_same_run_id_and_records_attempt(monkeypatch): + service = InMemorySessionService() + await service.create_session(agent_id="demo-agent", user_id="user-1", session_id="sess-long") + monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service) + + checkpoint = await append_run_checkpoint_event( + session_id="sess-long", + author="demo-agent", + run_id="run-1", + checkpoint_id="ckpt-before", + framework="langgraph", + framework_ref={ + "langgraph": { + "thread_id": "tenant:agent:sess-long", + "checkpoint_id": "ckpt-before", + } + }, + phase="tool_result", + invocation_id="inv-checkpoint", + ) + resume = await append_run_resume_event( + session_id="sess-long", + author="demo-agent", + run_id="run-1", + checkpoint_id="ckpt-before", + resume_attempt_id="resume-1", + framework="langgraph", + framework_ref=checkpoint.metadata["framework_ref"], + invocation_id="inv-resume", + ) + + assert checkpoint.metadata["run_id"] == resume.metadata["run_id"] == "run-1" + assert resume.metadata["resume_attempt_id"] == "resume-1" + + runner = _CheckpointResumeRunner() + _, result = await invoke_conversation_once( + runner=runner, + agent_id="demo-agent", + user_id="user-1", + session_id="sess-long", + messages=[], + model="demo-model", + prepare_runner=lambda active_runner, model: active_runner.prepare_for_request(model), + resume_input={ + "type": "agentengine.resume_checkpoint", + "run_id": "run-1", + "checkpoint_id": "ckpt-before", + "resume_attempt_id": "resume-1", + "framework": "langgraph", + "framework_ref": checkpoint.metadata["framework_ref"], + }, + ) + + assert runner.calls[0]["checkpoint_resume"] is True + assert runner.calls[0]["run_id"] == "run-1" + assert result["metadata"]["agentengine"]["run_id"] == "run-1" + assert ( + result["metadata"]["agentengine"]["framework_ref"]["langgraph"]["checkpoint_id"] + == "ckpt-after" + ) diff --git a/tests/long_task/test_runtime_cancel.py b/tests/long_task/test_runtime_cancel.py new file mode 100644 index 00000000..55b57a42 --- /dev/null +++ b/tests/long_task/test_runtime_cancel.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +import asyncio +import importlib +from types import SimpleNamespace + +import httpx +import pytest + +from ksadk.conversations import runtime as conversation_runtime +from ksadk.runners.base_runner import BaseRunner +from ksadk.sessions.in_memory import InMemorySessionService + + +class _UnsupportedCancelRunner(BaseRunner): + def __init__(self): + super().__init__( + detection_result=SimpleNamespace(name="demo-agent", type=SimpleNamespace(value="mock")), + project_dir=".", + ) + + def load_agent(self) -> None: + return None + + async def invoke(self, input_data: dict): + return {"output": "unused"} + + async def stream(self, input_data: dict): + yield {"type": "text", "delta": "unused"} + + +class _CancellableStreamingRunner(_UnsupportedCancelRunner): + def __init__(self): + super().__init__() + self.cancel_requests: list[str] = [] + + async def stream(self, input_data: dict): + yield {"type": "text", "delta": "started"} + await asyncio.Event().wait() + + def request_cancel(self, invocation_id: str) -> str: + self.cancel_requests.append(invocation_id) + return "accepted" + + +@pytest.mark.asyncio +async def test_cancel_run_reports_unsupported_for_runner_without_cancel_hook(monkeypatch): + server_app_module = importlib.import_module("ksadk.server.app") + service = InMemorySessionService() + runner = _UnsupportedCancelRunner() + + monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service) + server_app_module.set_runner(runner) + + transport = httpx.ASGITransport(app=server_app_module.app) + async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client: + response = await client.post( + "/agentengine/api/v1/CancelRun", + json={"AgentId": "demo-agent", "InvocationId": "inv-unsupported"}, + ) + + assert response.status_code == 200 + data = response.json()["Data"] + assert data["Cancelled"] is False + assert data["Found"] is False + assert data["Status"] == "unsupported" + assert data["RunnerCancelStatus"] == "unsupported" + + +@pytest.mark.asyncio +async def test_cancel_run_stops_detached_stream_and_writes_cancelled_terminal(monkeypatch): + server_app_module = importlib.import_module("ksadk.server.app") + service = InMemorySessionService() + runner = _CancellableStreamingRunner() + invocation_id = "inv-cancel-long-task" + + monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service) + server_app_module.set_runner(runner) + server_app_module._detached_streaming_response( + conversation_runtime.stream_responses_conversation_turn( + runner=runner, + agent_id="demo-agent", + user_id="user-1", + messages=[{"role": "user", "content": "start"}], + session_id="sess-cancel-long-task", + model=None, + prepare_runner=lambda _runner, _model: None, + invocation_id=invocation_id, + session_service_provider=lambda: service, + ), + invocation_id=invocation_id, + ) + + for _ in range(20): + events = await service.get_events("sess-cancel-long-task") + statuses = [ + event.content.get("status") + for event in events + if event.event_type == "run_status" + ] + if statuses == ["in_progress"]: + break + await asyncio.sleep(0.02) + + transport = httpx.ASGITransport(app=server_app_module.app) + async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client: + response = await client.post( + "/agentengine/api/v1/CancelRun", + json={"AgentId": "demo-agent", "InvocationId": invocation_id}, + ) + + assert response.status_code == 200 + data = response.json()["Data"] + assert data["Cancelled"] is True + assert data["Found"] is True + assert data["Status"] == "cancelling" + assert data["RunnerCancelStatus"] == "accepted" + assert runner.cancel_requests == [invocation_id] + + for _ in range(30): + events = await service.get_events("sess-cancel-long-task") + if events and events[-1].event_type == "run_status" and events[-1].content.get("status") == "cancelled": + break + await asyncio.sleep(0.02) + + events = await service.get_events("sess-cancel-long-task") + statuses = [ + event.content.get("status") + for event in events + if event.event_type == "run_status" + ] + event_types = [event.event_type for event in events] + assert statuses == ["in_progress", "cancelled"] + assert "assistant_message" not in event_types + assert "run_checkpoint" not in event_types diff --git a/tests/long_task/test_tool_idempotency.py b/tests/long_task/test_tool_idempotency.py new file mode 100644 index 00000000..f57ba9ac --- /dev/null +++ b/tests/long_task/test_tool_idempotency.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from ksadk.conversations.runtime import invoke_conversation_once +from ksadk.sessions.base import SessionEvent +from ksadk.sessions.in_memory import InMemorySessionService +from ksadk.tools.gateway import build_tool_receipt_idempotency_key + + +class _ApprovalRunner: + def __init__(self): + self.detection_result = type("Detection", (), {"name": "demo-agent"})() + self.calls: list[dict] = [] + + def prepare_for_request(self, model): + del model + + async def invoke(self, input_data: dict) -> dict: + self.calls.append(input_data) + return {"output": "ok"} + + +def test_tool_receipt_key_is_stable_for_argument_order(): + left = build_tool_receipt_idempotency_key( + session_id="sess-1", + run_id="run-1", + checkpoint_id="ckpt-1", + tool_call_id="call-1", + tool_name="write_workspace_file", + tool_args={"path": "notes.txt", "content": "hello"}, + ) + right = build_tool_receipt_idempotency_key( + session_id="sess-1", + run_id="run-1", + checkpoint_id="ckpt-1", + tool_call_id="call-1", + tool_name="write_workspace_file", + tool_args={"content": "hello", "path": "notes.txt"}, + ) + + assert left == right + assert left.startswith("tool_receipt:") + + +@pytest.mark.asyncio +async def test_approved_side_effect_tool_replays_receipt_without_second_write( + monkeypatch, + tmp_path: Path, +): + service = InMemorySessionService() + workspace_ui = tmp_path / "ui" + monkeypatch.setenv("AGENTENGINE_UI_DIR", str(workspace_ui)) + monkeypatch.setenv("KSADK_TOOL_APPROVAL_MODE", "strict") + monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service) + await service.create_session(agent_id="demo-agent", user_id="user-1", session_id="sess-tool") + await service.append_event( + "sess-tool", + SessionEvent( + id="evt-approval", + author="demo-agent", + event_type="approval_request", + content={"role": "model", "parts": [{"text": "approval required"}]}, + metadata={ + "interrupt_info": { + "approval_request_id": "appr_write", + "tool_name": "write_workspace_file", + "arguments": {"path": "notes.txt", "content": "hello"}, + "run_id": "call_write", + "server_label": "ksadk", + } + }, + invocation_id="inv-approval", + ), + ) + runner = _ApprovalRunner() + resume_input = { + "type": "mcp_approval_response", + "approval_request_id": "appr_write", + "approve": True, + } + + await invoke_conversation_once( + runner=runner, + agent_id="demo-agent", + user_id="user-1", + session_id="sess-tool", + messages=[], + model="demo-model", + resume_input=resume_input, + prepare_runner=lambda active_runner, model: active_runner.prepare_for_request(model), + ) + target = workspace_ui / "workspace" / "notes.txt" + assert target.read_text(encoding="utf-8") == "hello" + target.write_text("changed-by-user", encoding="utf-8") + + await invoke_conversation_once( + runner=runner, + agent_id="demo-agent", + user_id="user-1", + session_id="sess-tool", + messages=[], + model="demo-model", + resume_input=resume_input, + prepare_runner=lambda active_runner, model: active_runner.prepare_for_request(model), + ) + + assert target.read_text(encoding="utf-8") == "changed-by-user" + events = await service.get_events("sess-tool") + tool_results = [event for event in events if event.event_type == "tool_result"] + assert len(tool_results) == 2 + assert tool_results[-1].metadata["tool_receipt"]["replayed"] is True + assert ( + tool_results[-1].metadata["tool_receipt"]["idempotency_key"] + == tool_results[0].metadata["tool_receipt"]["idempotency_key"] + ) diff --git a/tests/skills/__init__.py b/tests/skills/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/skills/test_adk_runner_skill_runtime.py b/tests/skills/test_adk_runner_skill_runtime.py new file mode 100644 index 00000000..38dddf1e --- /dev/null +++ b/tests/skills/test_adk_runner_skill_runtime.py @@ -0,0 +1,371 @@ +from __future__ import annotations + +import textwrap +from types import SimpleNamespace +from uuid import uuid4 + +from ksadk.detection import DetectionResult, FrameworkType + + +def _write_adk_project(tmp_path, source: str) -> DetectionResult: + package_name = f"skill_agent_{uuid4().hex[:8]}" + package_dir = tmp_path / package_name + package_dir.mkdir() + (package_dir / "__init__.py").write_text("", encoding="utf-8") + (package_dir / "agent.py").write_text(textwrap.dedent(source), encoding="utf-8") + return DetectionResult( + type=FrameworkType.ADK, + name="demo-agent", + entry_point=f"{package_name}/agent.py", + package_path=str(package_dir), + agent_variable="root_agent", + confidence=1.0, + ) + + +def _tool_names(tools): + return [getattr(tool, "name", None) or getattr(tool, "__name__", "") for tool in tools] + + +class FakeRunner: + instances = [] + + def __init__(self, **kwargs): + self.kwargs = kwargs + FakeRunner.instances.append(self) + + +def _patch_runner(monkeypatch): + import google.adk.runners as adk_runners + + from ksadk.runners.adk_runner import ADKRunner + + FakeRunner.instances.clear() + monkeypatch.setattr(ADKRunner, "_apply_json_patch", lambda self: None) + monkeypatch.setattr(ADKRunner, "_init_short_term_memory", lambda self: None) + monkeypatch.setattr(ADKRunner, "_init_long_term_memory", lambda self: None) + monkeypatch.setattr(ADKRunner, "_init_knowledge_base", lambda self: None) + monkeypatch.setattr(adk_runners, "Runner", FakeRunner) + return ADKRunner + + +def test_adk_runner_injects_execute_skills_for_sandbox_mode(monkeypatch, tmp_path): + ADKRunner = _patch_runner(monkeypatch) + detection = _write_adk_project( + tmp_path, + """ + class DemoAgent: + def __init__(self): + self.name = "demo-agent" + self.tools = [] + self.instruction = "Be helpful." + + root_agent = DemoAgent() + """, + ) + monkeypatch.setenv("KSADK_SKILLS_MODE", "sandbox") + monkeypatch.setenv("KSADK_SKILL_RUNTIME_BACKEND", "disabled") + monkeypatch.setenv("KSADK_SKILL_SPACE_IDS", "ss-1") + + runner = ADKRunner(detection, str(tmp_path)) + runner.load_agent() + + assert _tool_names(runner._agent.tools) == ["execute_skills"] + assert len(FakeRunner.instances) == 1 + + +def test_adk_runner_injects_remote_skill_manifest_into_instruction(monkeypatch, tmp_path): + ADKRunner = _patch_runner(monkeypatch) + detection = _write_adk_project( + tmp_path, + """ + class DemoAgent: + def __init__(self): + self.name = "demo-agent" + self.tools = [] + self.instruction = "Be helpful." + + root_agent = DemoAgent() + """, + ) + + def fake_load_remote_skill_manifests(skill_space_ids=None): + assert skill_space_ids == ["ss-user", "ss-public"] + return [ + { + "name": "demo-skill", + "description": "Create spreadsheet reports", + "version": "v1", + } + ] + + monkeypatch.setenv("KSADK_SKILLS_MODE", "sandbox") + monkeypatch.setenv("KSADK_SKILL_RUNTIME_BACKEND", "disabled") + monkeypatch.setenv("KSADK_SKILL_SPACE_IDS", "ss-user") + monkeypatch.setenv("KSADK_PUBLIC_SKILL_SPACE_IDS", "ss-public") + monkeypatch.setattr( + "ksadk.skills.tool_defs.load_remote_skill_manifests", + fake_load_remote_skill_manifests, + raising=False, + ) + + runner = ADKRunner(detection, str(tmp_path)) + runner.load_agent() + + assert "demo-skill" in runner._agent.instruction + assert "Create spreadsheet reports" in runner._agent.instruction + assert "skill_names" in runner._agent.instruction + + +def test_remote_skill_manifest_filters_public_skills_with_allowlist(monkeypatch): + from ksadk.skills.tool_defs import load_remote_skill_manifests + + class FakeClient: + def __init__(self, **kwargs): + pass + + def list_skills_by_space_id(self, space_id): + from ksadk.skills.models import SkillListResponse + + assert space_id == "ss-user" + payload = { + "Data": { + "Skills": [ + { + "SkillId": "sk-demo", + "VersionId": "sv-demo-v1", + "Version": "v1", + "Name": "demo-skill", + "Status": "Active", + } + ] + } + } + return SkillListResponse.from_payload(payload, space_id=space_id) + + def list_available_premade_skills(self): + from ksadk.skills.models import SkillListResponse + + payload = { + "Data": { + "Skills": [ + { + "SkillId": "premade-pdf", + "VersionId": "", + "Version": "", + "Name": "pdf", + "Status": "AVAILABLE", + }, + { + "SkillId": "premade-weather", + "VersionId": "", + "Version": "", + "Name": "weather", + "Status": "AVAILABLE", + }, + ] + } + } + return SkillListResponse.from_payload(payload, space_id="public") + + monkeypatch.setenv("KSADK_SKILL_SERVICE_URL", "https://skill.example/api/v1") + monkeypatch.setenv("KSADK_SKILL_SPACE_IDS", "ss-user") + monkeypatch.setenv("KSADK_PUBLIC_SKILL_SPACE_IDS", "ss-public") + monkeypatch.setenv("KSADK_PUBLIC_SKILL_ALLOWLIST", "weather") + monkeypatch.setattr( + "ksadk.skills.tool_defs.SkillServiceClient", + FakeClient, + ) + + manifests = load_remote_skill_manifests() + + assert [item["name"] for item in manifests] == ["demo-skill", "weather"] + + +def test_execute_skills_passes_public_skill_spaces_through_env(monkeypatch): + from ksadk.skills.tool_defs import build_execute_skills_tool + + calls = [] + + class FakeBackend: + def run_workflow(self, workflow_prompt, **kwargs): + calls.append((workflow_prompt, kwargs)) + return SimpleNamespace(to_dict=lambda: {"status": "ok"}) + + monkeypatch.setenv("KSADK_SKILL_SPACE_IDS", "ss-user") + monkeypatch.setenv("KSADK_PUBLIC_SKILL_SPACE_IDS", "ss-public-a, ss-public-b") + + tool = build_execute_skills_tool(backend=FakeBackend(), session_id="sess-1") + result = tool("use demo-skill") + + assert result == {"status": "ok"} + assert calls[0][1]["skill_space_ids"] == ["ss-user"] + assert calls[0][1]["env"]["KSADK_PUBLIC_SKILL_SPACE_IDS"] == "ss-public-a, ss-public-b" + + +def test_adk_runner_auto_mode_prefers_configured_runtime_backend_over_cache_dir(monkeypatch, tmp_path): + ADKRunner = _patch_runner(monkeypatch) + cache_dir = tmp_path / "cache" + cache_dir.mkdir() + detection = _write_adk_project( + tmp_path, + """ + class DemoAgent: + def __init__(self): + self.name = "demo-agent" + self.tools = [] + self.instruction = "Be helpful." + + root_agent = DemoAgent() + """, + ) + monkeypatch.delenv("KSADK_SKILLS_MODE", raising=False) + monkeypatch.setenv("KSADK_SKILL_RUNTIME_BACKEND", "disabled") + monkeypatch.setenv("KSADK_SKILL_CACHE_DIR", str(cache_dir)) + + runner = ADKRunner(detection, str(tmp_path)) + runner.load_agent() + assert _tool_names(runner._agent.tools) == [] + + monkeypatch.setenv("KSADK_SKILL_RUNTIME_BACKEND", "e2b") + monkeypatch.setenv("KSADK_SKILL_RUNTIME_TEMPLATE_ID", "tpl-1") + monkeypatch.setattr( + "ksadk.skills.runtime.backends.e2b.E2BSkillRuntimeBackend.from_env", + lambda: type("Backend", (), {"run_workflow": lambda self, *args, **kwargs: None})(), + ) + + runner = ADKRunner(detection, str(tmp_path)) + runner.load_agent() + assert _tool_names(runner._agent.tools) == ["execute_skills"] + + +def test_adk_runner_auto_mode_uses_generic_sandbox_template(monkeypatch, tmp_path): + ADKRunner = _patch_runner(monkeypatch) + detection = _write_adk_project( + tmp_path, + """ + class DemoAgent: + def __init__(self): + self.name = "demo-agent" + self.tools = [] + self.instruction = "Be helpful." + + root_agent = DemoAgent() + """, + ) + monkeypatch.delenv("KSADK_SKILLS_MODE", raising=False) + monkeypatch.delenv("KSADK_SKILL_RUNTIME_BACKEND", raising=False) + monkeypatch.delenv("KSADK_SKILL_RUNTIME_TEMPLATE_ID", raising=False) + monkeypatch.setenv("KSADK_SANDBOX_TEMPLATE_ID", "tpl-aio") + monkeypatch.setattr( + "ksadk.skills.runtime.backends.e2b.E2BSkillRuntimeBackend.from_env", + lambda: type("Backend", (), {"run_workflow": lambda self, *args, **kwargs: None})(), + ) + + runner = ADKRunner(detection, str(tmp_path)) + runner.load_agent() + + assert _tool_names(runner._agent.tools) == ["execute_skills"] + + +def test_adk_runner_auto_mode_respects_explicit_disabled_runtime_backend(monkeypatch, tmp_path): + ADKRunner = _patch_runner(monkeypatch) + detection = _write_adk_project( + tmp_path, + """ + class DemoAgent: + def __init__(self): + self.name = "demo-agent" + self.tools = [] + self.instruction = "Be helpful." + + root_agent = DemoAgent() + """, + ) + monkeypatch.delenv("KSADK_SKILLS_MODE", raising=False) + monkeypatch.setenv("KSADK_SKILL_RUNTIME_BACKEND", "disabled") + monkeypatch.setenv("KSADK_SANDBOX_TEMPLATE_ID", "tpl-aio") + + runner = ADKRunner(detection, str(tmp_path)) + runner.load_agent() + + assert _tool_names(runner._agent.tools) == [] + + +def test_adk_runner_no_longer_injects_legacy_sandbox_tools_by_default(monkeypatch, tmp_path): + ADKRunner = _patch_runner(monkeypatch) + detection = _write_adk_project( + tmp_path, + """ + class DemoAgent: + def __init__(self): + self.name = "demo-agent" + self.tools = [] + self.instruction = "Be helpful." + + root_agent = DemoAgent() + """, + ) + monkeypatch.delenv("KSADK_SKILLS_MODE", raising=False) + runner = ADKRunner(detection, str(tmp_path)) + runner.load_agent() + + assert "execute_python" not in _tool_names(runner._agent.tools) + assert "execute_bash" not in _tool_names(runner._agent.tools) + assert "execute_javascript" not in _tool_names(runner._agent.tools) + + +def test_adk_runner_deduplicates_existing_execute_skills(monkeypatch, tmp_path): + ADKRunner = _patch_runner(monkeypatch) + detection = _write_adk_project( + tmp_path, + """ + def execute_skills(workflow_prompt: str) -> dict: + return {"stdout": workflow_prompt} + + class DemoAgent: + def __init__(self): + self.name = "demo-agent" + self.tools = [execute_skills] + self.instruction = "Be helpful." + + root_agent = DemoAgent() + """, + ) + monkeypatch.setenv("KSADK_SKILLS_MODE", "sandbox") + monkeypatch.setenv("KSADK_SKILL_RUNTIME_BACKEND", "disabled") + monkeypatch.setenv("KSADK_SKILL_SPACE_IDS", "ss-1") + + runner = ADKRunner(detection, str(tmp_path)) + runner.load_agent() + + assert _tool_names(runner._agent.tools).count("execute_skills") == 1 + + +def test_adk_runner_injects_local_skills_tool_for_local_mode(monkeypatch, tmp_path): + ADKRunner = _patch_runner(monkeypatch) + skill_root = tmp_path / "skills" / "demo-skill" + skill_root.mkdir(parents=True) + (skill_root / "SKILL.md").write_text( + "---\nname: demo-skill\ndescription: Demo\n---\n# Demo\n", + encoding="utf-8", + ) + detection = _write_adk_project( + tmp_path, + """ + class DemoAgent: + def __init__(self): + self.name = "demo-agent" + self.tools = [] + self.instruction = "Be helpful." + + root_agent = DemoAgent() + """, + ) + monkeypatch.setenv("KSADK_SKILLS_MODE", "local") + monkeypatch.setenv("KSADK_LOCAL_SKILLS_DIR", str(tmp_path / "skills")) + + runner = ADKRunner(detection, str(tmp_path)) + runner.load_agent() + + assert _tool_names(runner._agent.tools) == ["skills_tool"] diff --git a/tests/skills/test_package_store.py b/tests/skills/test_package_store.py new file mode 100644 index 00000000..6d27ad2d --- /dev/null +++ b/tests/skills/test_package_store.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +import hashlib +import io +import zipfile +from pathlib import Path + +import pytest + +from ksadk.skills.models import ContentHash, SkillRef +from ksadk.skills.package_store import PackageStore, SkillPackageError + + +def _make_zip(entries: dict[str, str | bytes]) -> bytes: + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as archive: + for name, content in entries.items(): + archive.writestr(name, content) + return buffer.getvalue() + + +def _ref(digest: str) -> SkillRef: + return SkillRef( + skill_id="sk-web", + version_id="sv-web-v1", + version="v1", + name="web-artifacts-builder", + content_hash=ContentHash.parse(f"sha256:{digest}"), + ) + + +def test_package_store_verifies_hash_and_extracts_skill_root(tmp_path: Path): + payload = _make_zip({"web-artifacts-builder/SKILL.md": "---\nname: web-artifacts-builder\n---\n# Skill\n"}) + digest = hashlib.sha256(payload).hexdigest() + store = PackageStore(cache_dir=tmp_path) + + package = store.store_archive(_ref(digest), payload) + + assert package.root_dir.name == "web-artifacts-builder" + assert (package.root_dir / "SKILL.md").exists() + assert package.archive_path.exists() + assert package.cache_hit is False + + second = store.store_archive(_ref(digest), payload) + assert second.cache_hit is True + assert second.root_dir == package.root_dir + + +def test_package_store_rejects_hash_mismatch(tmp_path: Path): + payload = _make_zip({"skill/SKILL.md": "# Skill\n"}) + store = PackageStore(cache_dir=tmp_path) + + with pytest.raises(SkillPackageError, match="ContentHash mismatch"): + store.store_archive(_ref("0" * 64), payload) + + +def test_package_store_rejects_zip_slip(tmp_path: Path): + payload = _make_zip({"../escape.txt": "nope", "skill/SKILL.md": "# Skill\n"}) + digest = hashlib.sha256(payload).hexdigest() + store = PackageStore(cache_dir=tmp_path) + + with pytest.raises(SkillPackageError, match="unsafe zip member"): + store.store_archive(_ref(digest), payload) + + +def test_package_store_returns_none_for_corrupted_cache(tmp_path: Path): + payload = _make_zip({"skill/SKILL.md": "# Skill\n"}) + digest = hashlib.sha256(payload).hexdigest() + store = PackageStore(cache_dir=tmp_path) + package = store.store_archive(_ref(digest), payload) + package.archive_path.write_bytes(b"corrupted") + + assert store.get_cached(_ref(digest)) is None diff --git a/tests/skills/test_runtime.py b/tests/skills/test_runtime.py index 22423db2..7921a32d 100644 --- a/tests/skills/test_runtime.py +++ b/tests/skills/test_runtime.py @@ -1,16 +1,18 @@ from __future__ import annotations import json -import os import subprocess import sys -import types from pathlib import Path import pytest -from ksadk.skills.runtime import SkillRuntimeError, SkillRuntimeResult, create_skill_runtime_backend -from ksadk.skills.runtime import SkillWorkflowRequest +from ksadk.skills.runtime import ( + SkillRuntimeError, + SkillRuntimeResult, + SkillWorkflowRequest, + create_skill_runtime_backend, +) from ksadk.skills.runtime.backends.e2b import E2BSkillRuntimeBackend from ksadk.skills.runtime.backends.local import LocalProcessSkillRuntimeBackend @@ -62,7 +64,7 @@ def test_e2b_skill_runtime_backend_from_env_prefers_generic_sandbox_vars(monkeyp monkeypatch.setenv("KSADK_SANDBOX_TIMEOUT", "321") monkeypatch.setenv("KSADK_SKILL_RUNTIME_TIMEOUT", "123") monkeypatch.setenv("KSADK_SANDBOX_ALLOW_INTERNET_ACCESS", "false") - monkeypatch.setitem(sys.modules, "e2b", types.SimpleNamespace(Sandbox=object)) + monkeypatch.setattr("e2b.Sandbox", object) backend = E2BSkillRuntimeBackend.from_env() @@ -156,7 +158,11 @@ def kill(self): }, }, ) in calls - request_write = next(value for name, value in calls if name == "file_write") + request_write = next( + value + for name, value in calls + if name == "file_write" and value[0] == "/tmp/ksadk-workflow-request.json" + ) assert request_write[0] == "/tmp/ksadk-workflow-request.json" assert json.loads(request_write[1].decode("utf-8")) == { "workflow_prompt": "build artifact", @@ -215,7 +221,9 @@ def test_e2b_backend_redacts_secret_from_errors(monkeypatch): class FakeSandbox: @classmethod def create(cls, **kwargs): - raise RuntimeError("failed with super-secret-token and skill-service-token and skill-service-secret") + raise RuntimeError( + "failed with super-secret-token and skill-service-token and skill-service-secret" + ) backend = E2BSkillRuntimeBackend(sandbox_cls=FakeSandbox, template_id="tpl-1") @@ -269,15 +277,24 @@ def kill(self): session_id="sess-1", ) - request_write = next(value for name, value in calls if name == "file_write") + request_write = next( + value + for name, value in calls + if name == "file_write" and value[0] == "/tmp/ksadk-workflow-request.json" + ) request_path, request_bytes = request_write assert request_path == "/tmp/ksadk-workflow-request.json" assert json.loads(request_bytes.decode("utf-8")) == { "workflow_prompt": "hello 'quoted'", "skill_names": ["demo-skill"], } - run_command = next(value for name, value in calls if name == "run") - assert run_command == "python -u /home/ksadk/agent.py --request-file /tmp/ksadk-workflow-request.json" + run_command = next( + value for name, value in calls if name == "run" and "/home/ksadk/agent.py" in value + ) + assert ( + run_command + == "python -u /home/ksadk/agent.py --request-file /tmp/ksadk-workflow-request.json" + ) def test_local_process_backend_writes_request_file_envelope(monkeypatch, tmp_path: Path): diff --git a/tests/skills/test_service_client_http.py b/tests/skills/test_service_client_http.py index dadaae30..e4c0f21b 100644 --- a/tests/skills/test_service_client_http.py +++ b/tests/skills/test_service_client_http.py @@ -318,7 +318,7 @@ def handler(request: httpx.Request) -> httpx.Response: client = SkillServiceClient( base_url="http://aicp.inner.api.ksyun.com", - account_id="2000003485", + account_id="73398439", transport=httpx.MockTransport(handler), ) @@ -334,7 +334,7 @@ def handler(request: httpx.Request) -> httpx.Response: ) assert headers["x-ksc-region"] == "cn-beijing-6" assert headers["x-ksc-custom-source"] == "pre" - assert headers["x-ksc-account-id"] == "2000003485" + assert headers["x-ksc-account-id"] == "73398439" def test_service_client_uses_registered_kop_action_for_available_premade_skills(): diff --git a/tests/skills/test_web_artifacts_fixture.py b/tests/skills/test_web_artifacts_fixture.py new file mode 100644 index 00000000..9206d925 --- /dev/null +++ b/tests/skills/test_web_artifacts_fixture.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +import hashlib +import zipfile +from pathlib import Path + + +FIXTURE = Path("/Users/xiayu/Downloads/web-artifacts-builder.zip") +EXPECTED_SHA256 = "b95f0735357fcf879bd53ed85cb242679ec74438e3bc8e85b1f27193169b6ecf" + + +def test_web_artifacts_builder_zip_matches_skill_service_fixture_contract(): + assert FIXTURE.exists(), "fixture zip should be present for local/preprod verification" + data = FIXTURE.read_bytes() + assert hashlib.sha256(data).hexdigest() == EXPECTED_SHA256 + + with zipfile.ZipFile(FIXTURE) as archive: + names = set(archive.namelist()) + skill_md = archive.read("web-artifacts-builder/SKILL.md").decode("utf-8") + + assert "web-artifacts-builder/SKILL.md" in names + assert "web-artifacts-builder/scripts/init-artifact.sh" in names + assert "web-artifacts-builder/scripts/bundle-artifact.sh" in names + assert "name: web-artifacts-builder" in skill_md diff --git a/tests/snapshots/error_hint_snapshots.txt b/tests/snapshots/error_hint_snapshots.txt new file mode 100644 index 00000000..68bb2e49 --- /dev/null +++ b/tests/snapshots/error_hint_snapshots.txt @@ -0,0 +1,44 @@ +=== dashboard_not_found === +未找到 Agent。 +- 请确认 Agent 名称/ID 是否正确,可先执行 `agentengine agent list` 查看已部署 Agent。 +- 可显式指定 Agent:`agentengine dashboard open --agent `。 +=== dashboard_list_not_found === +未找到 Agent。 +- 请确认 Agent 名称/ID 是否正确,可先执行 `agentengine agent list` 查看已部署 Agent。 +- `agentengine dashboard list` 不是有效命令。 +- 如果要查看分享链接,请使用 `agentengine dashboard share list --agent `。 +=== dashboard_share_not_found === +未找到 Dashboard 分享链接或目标 Agent。 +- 请先执行 `agentengine dashboard share list --agent ` 查看分享链接。 +- 如需先确认 Agent,请执行 `agentengine agent list`。 +=== mcp_not_found === +未找到 MCP。 +- 请确认 MCP 名称/ID 是否正确,可先执行 `agentengine mcp list` 查看已部署 MCP。 +=== openclaw_not_found === +未找到 OpenClaw。 +- 请确认 OpenClaw 名称/ID 是否正确,可先执行 `agentengine openclaw list` 查看已部署实例。 +=== version_not_found === +未找到目标 Agent 或版本。 +- 请先执行 `agentengine agent list` 确认目标 Agent。 +- 然后执行 `agentengine version list --agent ` 查看版本。 +=== auth_failed === +鉴权失败。 +- 请检查 KSYUN_ACCESS_KEY / KSYUN_SECRET_KEY 是否正确。 +- 如使用子账号,请确认已授予对应接口权限。 +=== missing_aksk === +未检测到金山云 AK/SK。 +- 请检查当前 shell 或项目 `.env` 中是否设置了 `KSYUN_ACCESS_KEY` / `KSYUN_SECRET_KEY`(兼容 `KS3_ACCESS_KEY` / `KS3_SECRET_KEY`)。 +- 先到 AgentEngine Runtime 控制台确认账号是否具备运行时权限: https://ksp.console.ksyun.com/#/agentEngineRuntime +- 如当前子账号没有权限,请到 IAM 授权页授权: https://uc.console.ksyun.com/pro/iam/#/permission/authorize +- 如果还没有金山云 AK/SK,请让主账号先到 IAM 控制台创建子账号并生成访问密钥: https://uc.console.ksyun.com/pro/iam/ +=== invalid_aksk === +金山云 AK/SK 不正确或已失效。 +- 请检查当前 shell 或项目 `.env` 中的 `KSYUN_ACCESS_KEY` / `KSYUN_SECRET_KEY` 是否填写正确,且没有多余空格。 +- 确认该 AK/SK 未被禁用、删除或重置,并且属于当前要操作的金山云账号。 +- 如需确认账号是否具备 AgentEngine Runtime 权限,可先查看: https://ksp.console.ksyun.com/#/agentEngineRuntime +- 如果凭证属于子账号但仍然被拒绝,请到 IAM 授权页检查授权: https://uc.console.ksyun.com/pro/iam/#/permission/authorize +=== missing_runtime_permission === +当前金山云账号没有 AgentEngine Runtime 所需权限。 +- 请先到 AgentEngine Runtime 控制台确认当前账号是否具备运行时权限: https://ksp.console.ksyun.com/#/agentEngineRuntime +- 如当前子账号没有权限,请到 IAM 授权页授权: https://uc.console.ksyun.com/pro/iam/#/permission/authorize +- 如果还没有可用的金山云 AK/SK,请让主账号先到 IAM 控制台创建子账号并生成访问密钥: https://uc.console.ksyun.com/pro/iam/ diff --git a/tests/snapshots/resource_output_snapshots.txt b/tests/snapshots/resource_output_snapshots.txt new file mode 100644 index 00000000..e94eeb52 --- /dev/null +++ b/tests/snapshots/resource_output_snapshots.txt @@ -0,0 +1,73 @@ +=== mcp_list === +MCP 列表 +┏━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +┃ ID ┃ 名称 ┃ 状态 ┃ MCP URL ┃ +┡━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩ +│ mcp-1 │ demo-mcp │ RUNNING │ https://demo.example.com/mcp │ +└───────┴──────────┴─────────┴──────────────────────────────┘ +MCP总数: 1 页码: 1 每页: 20 +使用 `agentengine mcp status ` 查看详情。 +=== mcp_status === +MCP 状态 demo-mcp +──────────────────────────────────────────────────────────────────────────────── + ID: mcp-1 + 状态: RUNNING + 区域: cn-beijing-6 + Endpoint: https://demo.example.com + MCP URL: https://demo.example.com/mcp + 认证: 已开启 + 工具: search + 创建时间: 2026-03-20T12:00:00Z + 更新时间: 2026-03-20T12:05:00Z +下一步建议 + • `agentengine mcp list` +=== openclaw_list === +OpenClaw 列表 +┏━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┓ +┃ ID ┃ 名称 ┃ 状态 ┃ Endpoint ┃ 区域 ┃ +┡━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━┩ +│ ar-openclaw-1 │ demo-openclaw │ RUNNING │ https://openclaw.e… │ cn-beijing-6 │ +└───────────────┴───────────────┴─────────┴─────────────────────┴──────────────┘ +OpenClaw总数: 1 页码: 1 每页: 20 +账号: 2000003485 region: cn-beijing-6 总计: 1 +=== openclaw_status === +OpenClaw 状态 demo-openclaw +──────────────────────────────────────────────────────────────────────────────── + ID: ar-openclaw-1 + 状态: RUNNING + 框架: openclaw + 区域: cn-beijing-6 + Endpoint: https://openclaw.example.com + Langfuse: - + 镜像: hub.kce.ksyun.com/openclaw:latest + 创建时间: 2026-03-20 20:00:00 CST (2026-03-20 12:00:00 UTC) + 更新时间: 2026-03-20 20:05:00 CST (2026-03-20 12:05:00 UTC) +下一步建议 + • `agentengine invoke ar-openclaw-1` + • `agentengine openclaw tui ar-openclaw-1` + • `agentengine dashboard open ar-openclaw-1 --path /chat` + • `agentengine openclaw list` +=== version_list === +版本列表 +┏━━━━━━━━┳━━━━━━┳━━━━━━┳━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┓ +┃ Tag ┃ 状态 ┃ 流量 ┃ 创建时间 ┃ 描述 ┃ +┡━━━━━━━━╇━━━━━━╇━━━━━━╇━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━┩ +│ v1.0.0 │ 当前 │ 100% │ 2026-03-20 20:00:00 │ 部署自动发布 │ +└────────┴──────┴──────┴─────────────────────┴──────────────┘ +版本总数: 1 页码: 1 每页: 20 +使用 `agentengine version release` 创建新版本。 +=== dashboard_share_list === +Dashboard 链接列表 +┏━━━━━━━┳━━━━━━━┳━━━━━━━━┳━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +┃ ID ┃ 类型 ┃ 状态 ┃ 路径 ┃ 过期时间 ┃ 创建时间 ┃ +┡━━━━━━━╇━━━━━━━╇━━━━━━━━╇━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩ +│ lnk-1 │ share │ active │ / │ 永久 │ 2026-03-20 20:00:00 CST │ +│ │ │ │ │ │ (2026-03-20 12:00:00 UTC) │ +└───────┴───────┴────────┴──────┴──────────┴───────────────────────────────────┘ +Dashboard 链接总数: 1 页码: 1 每页: 20 +使用 `agentengine dashboard share revoke ` 撤销链接。 +=== dashboard_share_revoke === +Dashboard 链接撤销结果 lnk-1 +──────────────────────────────────────────────────────────────────────────────── + ID: lnk-1 + 状态: 已撤销 diff --git a/tests/snapshots/workflow_help_snapshots.txt b/tests/snapshots/workflow_help_snapshots.txt new file mode 100644 index 00000000..e6fbf1d5 --- /dev/null +++ b/tests/snapshots/workflow_help_snapshots.txt @@ -0,0 +1,144 @@ +=== build_help === +Usage: build [OPTIONS] [AGENT_DIR] + + 将 Agent 应用构建为可部署的格式 + + AGENT_DIR: Agent 项目目录 (默认: 当前目录) + + 模式: + code: 打包 zip + 依赖,上传 KS3 (默认) + container: 构建 Docker 镜像 + + 示例: + # 1) 默认构建 (code 模式) + agentengine build . + # 2) 显式指定构建参数 + agentengine build . --mode container --push --registry hub-cn-beijing-6.kce.ksyun.com + # 3) 显式指定区域 + KSYUN_REGION=cn-beijing-6 agentengine build . --mode code --push --no-cache + +Options: + -m, --mode [container|code] 构建模式: code (默认, zip+KS3) 或 container (Docker) + -t, --tag TEXT 镜像标签 (container 模式) + --registry TEXT 镜像仓库地址 (container 模式) + --push 构建后推送 (镜像到仓库 / zip到KS3) + --no-cache 强制重新构建,不使用缓存 (code: 忽略已有 zip;container: docker + --no-cache) + --repackage Code 模式复用依赖缓存,但强制重新打包当前代码/runtime + -r, --region TEXT KS3 区域 (code 模式) + --ks3-bucket TEXT KS3 bucket 名称 (code 模式, 默认: agentengine-{region}) + --output [pretty|json] 输出格式 + -h, --help Show this message and exit. +=== deploy_help === +Usage: deploy [OPTIONS] [AGENT_DIR] + + 部署 Agent 到云端 + + AGENT_DIR: Agent 项目目录 (默认: 当前目录) + + 示例: + # 1) 默认部署 (serverless) + agentengine deploy . + # 2) 显式指定部署参数 + agentengine deploy . --target kcf --account-id X-Ksc-Account-Id + # 3) 显式指定区域 + KSYUN_REGION=cn-beijing-6 agentengine deploy . --target serverless --dry-run + +Options: + -t, --target [serverless|kcf|kce] + 部署目标 (default: serverless) + -n, --name TEXT 部署名称 + -r, --region TEXT 区域 (default: cn-beijing-6) + --account-id TEXT 金山云账号 ID + --artifact-type [Code|Container] + Serverless 部署模式 (default: Code) + --namespace TEXT K8s 命名空间 + -p, --port INTEGER 服务端口 (default: 8000) + --registry TEXT 镜像仓库地址 (k8s/serverless Container 模式) + --ks3-path TEXT KS3 代码包路径 (Serverless Code 模式) + --ks3-bucket TEXT KS3 bucket 名称 (Serverless Code 模式, 默认: + agentengine-{region}) + --image TEXT Docker 镜像地址 (Container 模式) + --ui-profile [auto|adk|langchain|openclaw|hermes|custom] + Dashboard UI 类型 + --ui-path TEXT Dashboard UI 路径 (例如 /) + --ui-url TEXT 完整 Dashboard URL(自研前端) + --storage-size-gi INTEGER PVC 容量(Gi) [default: 20] + --storage-mount-path TEXT PVC 挂载目录(默认按框架推导) + --no-storage 禁用默认 PVC 挂载 + --enable-public-access / --disable-public-access + 是否开启公网访问;未指定时使用配置文件或平台默认值 + --enable-vpc-access 开启 VPC 私网访问 + --vpc-id TEXT VPC ID(开启 VPC 访问时必填) + --subnet-id TEXT 子网 ID(开启 VPC 访问时必填) + --security-group-id TEXT 安全组 ID(开启 VPC 访问时必填) + --availability-zone TEXT 可用区(可选) + --env TEXT 额外透传运行时环境变量,格式 KEY=VALUE,可重复传入 + --env-file FILE 额外运行时环境变量文件,支持 .env 或 JSON 对象 + --observability / --no-observability + 是否启用可观测性 (默认开启) + --push 构建后推送镜像 + --no-cache 强制重新构建,不使用缓存 + --repackage Code 模式复用依赖缓存,但强制重新打包当前代码/runtime + --no-version 部署成功后不自动创建版本快照 + --auto-rollback 部署失败时自动回滚到上一版本 + --dry-run 只生成配置,打印 curl 请求,不执行部署 + --list-providers 列出可用的部署目标 + --output [pretty|json] 输出格式 + -h, --help Show this message and exit. +=== launch_help === +Usage: launch [OPTIONS] [AGENT_DIR] + + 一键完成构建和部署 (Build + Deploy) + + 此命令会自动执行: + 1. 代码打包 / 镜像构建 + 2. 上传代码到 KS3 / 推送镜像到 KCR + 3. 调用 API 创建或更新 Agent + + 示例: + # 1) 默认一键部署 (serverless) + agentengine launch . + # 2) 显式指定部署参数 + agentengine launch . --target kce --artifact-type Container + # 3) 显式指定区域 + KSYUN_REGION=cn-beijing-6 agentengine launch . --target serverless --no-cache + +Options: + -t, --target [serverless|kcf|kce] + 部署目标 (default: serverless) + -n, --name TEXT 部署名称 + -r, --region TEXT 区域 (serverless) + --account-id TEXT 金山云账号 ID + --observability / --no-observability + 是否启用可观测性 + --no-cache 强制重新构建,不使用缓存 + -p, --port INTEGER 服务端口 (default: 8000) + --namespace TEXT K8s 命名空间 + --registry TEXT 镜像仓库地址 + --ks3-bucket TEXT KS3 bucket 名称 + --ks3-path TEXT KS3 代码包路径 + --image TEXT Docker 镜像地址 + --ui-profile [auto|adk|langchain|openclaw|hermes|custom] + Dashboard UI 类型 + --ui-path TEXT Dashboard UI 路径 (例如 /) + --ui-url TEXT 完整 Dashboard URL(自研前端) + --storage-size-gi INTEGER PVC 容量(Gi) [default: 20] + --storage-mount-path TEXT PVC 挂载目录(默认按框架推导) + --no-storage 禁用默认 PVC 挂载 + --enable-public-access / --disable-public-access + 是否开启公网访问;未指定时使用配置文件或平台默认值 + --enable-vpc-access 开启 VPC 私网访问 + --vpc-id TEXT VPC ID(开启 VPC 访问时必填) + --subnet-id TEXT 子网 ID(开启 VPC 访问时必填) + --security-group-id TEXT 安全组 ID(开启 VPC 访问时必填) + --availability-zone TEXT 可用区(可选) + --env TEXT 额外透传运行时环境变量,格式 KEY=VALUE,可重复传入 + --env-file FILE 额外运行时环境变量文件,支持 .env 或 JSON 对象 + --dry-run 仅打印请求,不执行实际操作 + --artifact-type [Code|Container] + 部署模式 (serverless default: Code) + --no-version 部署成功后不自动创建版本快照 + --auto-rollback 部署失败时自动回滚到上一版本 + --output [pretty|json] 输出格式 + -h, --help Show this message and exit. diff --git a/tests/test_a2a_cli.py b/tests/test_a2a_cli.py new file mode 100644 index 00000000..cc8306d1 --- /dev/null +++ b/tests/test_a2a_cli.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from click.testing import CliRunner +from starlette.testclient import TestClient + +from ksadk.cli import _register_commands, cli + + +def _write_project_config(tmp_path: Path) -> Path: + (tmp_path / "agentengine.yaml").write_text( + "\n".join( + [ + "framework: adk", + "name: demo-agent", + "package: demo_agent", + "entry_point: demo_agent/agent.py", + "agent_variable: root_agent", + "", + ] + ), + encoding="utf-8", + ) + package_dir = tmp_path / "demo_agent" + package_dir.mkdir() + (package_dir / "__init__.py").write_text("", encoding="utf-8") + (package_dir / "agent.py").write_text("root_agent = object()\n", encoding="utf-8") + return tmp_path + + +def test_root_help_lists_a2a_workflow_command(): + _register_commands() + + result = CliRunner().invoke(cli, ["--help"]) + + assert result.exit_code == 0, result.output + assert "a2a" in result.output + + +def test_a2a_card_command_outputs_agent_card_json(monkeypatch, tmp_path): + project_dir = _write_project_config(tmp_path) + monkeypatch.setattr("ksadk.configs.setup_environment", lambda _path: None) + _register_commands() + + result = CliRunner().invoke( + cli, + [ + "a2a", + "card", + str(project_dir), + "--description", + "CLI generated card", + "--skill", + "echo", + ], + ) + + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert payload["name"] == "demo-agent" + assert payload["url"] == "http://127.0.0.1:8081" + assert payload["description"] == "CLI generated card" + assert [skill["id"] for skill in payload["skills"]] == ["echo"] + + +def test_a2a_serve_builds_server_and_exposes_agent_card(monkeypatch, tmp_path): + project_dir = _write_project_config(tmp_path) + captured: dict[str, object] = {} + + class FakeRunner: + def __init__(self) -> None: + self.loaded = False + + def load_agent(self) -> None: + self.loaded = True + + async def invoke(self, input_data): + return {"output": input_data["input"]} + + async def stream(self, input_data): + yield {"type": "final", "output": input_data["input"]} + + fake_runner = FakeRunner() + + monkeypatch.setattr("ksadk.configs.setup_environment", lambda _path: None) + monkeypatch.setattr( + "ksadk.cli.cmd_a2a.create_runner", + lambda result, project_dir: fake_runner, + ) + + def fake_uvicorn_run(app, host, port, **kwargs): + captured.update({"app": app, "host": host, "port": port, "kwargs": kwargs}) + + monkeypatch.setattr("uvicorn.run", fake_uvicorn_run) + _register_commands() + + result = CliRunner().invoke( + cli, + [ + "a2a", + "serve", + str(project_dir), + "--port", + "9091", + "--skill", + "echo", + ], + ) + + assert result.exit_code == 0, result.output + assert fake_runner.loaded is True + assert captured["host"] == "0.0.0.0" + assert captured["port"] == 9091 + + client = TestClient(captured["app"]) + current_card = client.get("/.well-known/agent-card.json") + legacy_card = client.get("/.well-known/agent.json") + + assert current_card.status_code == 200 + assert legacy_card.status_code == 200 + assert current_card.json()["name"] == "demo-agent" + assert current_card.json()["url"] == "http://127.0.0.1:9091" + assert [skill["id"] for skill in current_card.json()["skills"]] == ["echo"] diff --git a/tests/test_a2a_integration.py b/tests/test_a2a_integration.py new file mode 100644 index 00000000..1aa5a54d --- /dev/null +++ b/tests/test_a2a_integration.py @@ -0,0 +1,242 @@ +from __future__ import annotations + +import httpx +import pytest +from sse_starlette.sse import AppStatus + +from ksadk.a2a import AgentCardBuilder, KsA2AServer, RemoteA2AAgent, RemoteA2AClient, to_a2a + + +@pytest.fixture(autouse=True) +def _reset_sse_app_status(): + AppStatus.should_exit = False + AppStatus.should_exit_event = None + yield + AppStatus.should_exit = False + AppStatus.should_exit_event = None + + +class _InvokeOnlyRunner: + def __init__(self) -> None: + self.calls: list[dict] = [] + + async def invoke(self, input_data): + self.calls.append(input_data) + return {"output": f"invoke:{input_data['input']}"} + + +class _StreamingRunner: + def __init__(self) -> None: + self.calls: list[dict] = [] + + async def invoke(self, input_data): + self.calls.append({"mode": "invoke", **input_data}) + return {"output": f"invoke:{input_data['input']}"} + + async def stream(self, input_data): + self.calls.append(input_data) + yield {"delta": "hello", "type": "text"} + yield {"delta": " world", "type": "text"} + + +class _StreamingRunnerWithFinalChunk(_StreamingRunner): + async def stream(self, input_data): + self.calls.append(input_data) + yield {"delta": "hello", "type": "text"} + yield {"delta": " world", "type": "text"} + yield {"output": "hello world", "type": "final"} + + +class _StreamingRunnerWithOverrideFinalChunk(_StreamingRunner): + async def stream(self, input_data): + self.calls.append(input_data) + yield {"delta": "hello", "type": "text"} + yield {"delta": " world", "type": "text"} + yield {"output": "goodbye", "type": "final"} + + +class _FailingRunner: + async def invoke(self, input_data): + raise RuntimeError(f"boom:{input_data['input']}") + + +def test_agent_card_builder_defaults(): + card = AgentCardBuilder( + name="demo", + url="http://localhost:8000", + skills=["search"], + ).build() + + assert card.name == "demo" + assert card.url == "http://localhost:8000" + assert card.capabilities.streaming is True + assert card.capabilities.push_notifications is False + assert card.default_input_modes == ["text/plain"] + assert card.default_output_modes == ["text/plain"] + assert card.skills[0].id == "search" + assert card.skills[0].tags == ["search"] + + +@pytest.mark.asyncio +async def test_a2a_server_exposes_cards_and_supports_invoke_roundtrip(): + runner = _InvokeOnlyRunner() + server = to_a2a( + runner=runner, + app_name="echo_agent", + url="http://testserver", + description="Echo test agent", + skills=["echo"], + ) + app = server.build() + transport = httpx.ASGITransport(app=app) + + async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as http_client: + current_card = await http_client.get("/.well-known/agent-card.json") + legacy_card = await http_client.get("/.well-known/agent.json") + + assert current_card.status_code == 200 + assert legacy_card.status_code == 200 + assert current_card.json()["name"] == "echo_agent" + assert legacy_card.json()["name"] == "echo_agent" + + client = RemoteA2AClient(endpoint="http://testserver", http_client=http_client) + card = await client.get_card() + result = await client.invoke("ping", context_id="session-1") + + assert card.name == "echo_agent" + assert result["output"] == "invoke:ping" + assert result["context_id"] == "session-1" + assert runner.calls == [ + { + "input": "ping", + "task_id": result["task_id"], + "context_id": "session-1", + "session_id": "session-1", + "state": {}, + "branch": "", + "metadata": {}, + } + ] + + +@pytest.mark.asyncio +async def test_remote_a2a_agent_streams_chunks_and_adapts_runner_contract(): + runner = _StreamingRunner() + server = KsA2AServer( + runner=runner, + app_name="stream_agent", + url="http://testserver", + skills=["stream"], + ) + app = server.build() + transport = httpx.ASGITransport(app=app) + + async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as http_client: + agent = RemoteA2AAgent( + endpoint="http://testserver", + name="remote_stream_agent", + http_client=http_client, + ) + + invoke_result = await agent.invoke( + { + "input": "hi", + "session_id": "thread-1", + "state": {"topic": "streaming"}, + "branch": "fanout-a", + } + ) + chunks = [ + chunk + async for chunk in agent.stream( + { + "input": "hi", + "session_id": "thread-1", + "state": {"topic": "streaming"}, + "branch": "fanout-a", + } + ) + ] + + assert invoke_result["output"] == "hello world" + assert invoke_result["context_id"] == "thread-1" + assert [chunk["delta"] for chunk in chunks] == ["hello", " world"] + assert all(chunk["context_id"] == "thread-1" for chunk in chunks) + assert runner.calls == [ + { + "input": "hi", + "task_id": invoke_result["task_id"], + "context_id": "thread-1", + "session_id": "thread-1", + "state": {"topic": "streaming"}, + "branch": "fanout-a", + "metadata": { + "state": {"topic": "streaming"}, + "branch": "fanout-a", + }, + }, + { + "input": "hi", + "task_id": chunks[0]["task_id"], + "context_id": "thread-1", + "session_id": "thread-1", + "state": {"topic": "streaming"}, + "branch": "fanout-a", + "metadata": { + "state": {"topic": "streaming"}, + "branch": "fanout-a", + }, + }, + ] + + +@pytest.mark.asyncio +async def test_remote_a2a_client_ignores_duplicate_final_stream_output(): + server = KsA2AServer( + runner=_StreamingRunnerWithFinalChunk(), + app_name="stream_agent", + url="http://testserver", + skills=["stream"], + ) + app = server.build() + transport = httpx.ASGITransport(app=app) + + async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as http_client: + client = RemoteA2AClient(endpoint="http://testserver", http_client=http_client) + result = await client.invoke("hi", context_id="thread-1") + + assert result["output"] == "hello world" + + +@pytest.mark.asyncio +async def test_remote_a2a_client_uses_non_prefix_final_stream_output_as_authoritative(): + server = KsA2AServer( + runner=_StreamingRunnerWithOverrideFinalChunk(), + app_name="stream_agent", + url="http://testserver", + skills=["stream"], + ) + app = server.build() + transport = httpx.ASGITransport(app=app) + + async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as http_client: + client = RemoteA2AClient(endpoint="http://testserver", http_client=http_client) + result = await client.invoke("hi", context_id="thread-1") + + assert result["output"] == "goodbye" + + +@pytest.mark.asyncio +async def test_remote_a2a_client_raises_for_failed_tasks(): + server = KsA2AServer( + runner=_FailingRunner(), + app_name="broken_agent", + url="http://testserver", + ) + app = server.build() + transport = httpx.ASGITransport(app=app) + + async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as http_client: + client = RemoteA2AClient(endpoint="http://testserver", http_client=http_client) + with pytest.raises(RuntimeError, match="boom:oops"): + await client.invoke("oops") diff --git a/tests/test_agent.py b/tests/test_agent.py new file mode 100644 index 00000000..1097cebe --- /dev/null +++ b/tests/test_agent.py @@ -0,0 +1,68 @@ +"""Tests for the current agent loading contract.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +from ksadk.runners.utils.loader import load_agent_module + + +def _write_package(project_dir: Path, package_name: str, module_name: str, content: str) -> str: + package_dir = project_dir / package_name + package_dir.mkdir(parents=True, exist_ok=True) + (package_dir / "__init__.py").write_text("", encoding="utf-8") + (package_dir / f"{module_name}.py").write_text(content, encoding="utf-8") + return f"{package_name}/{module_name}.py" + + +def _cleanup_module(module_name: str) -> None: + sys.modules.pop(module_name, None) + + +def test_load_agent_module_returns_root_agent_and_module(tmp_path: Path): + entry_point = _write_package( + tmp_path, + "agent_loader_basic_pkg", + "agent_impl", + 'root_agent = {"name": "demo-agent", "framework": "langgraph"}\n', + ) + module_name = "agent_loader_basic_pkg.agent_impl" + _cleanup_module(module_name) + + agent, module = load_agent_module(str(tmp_path), entry_point, "root_agent") + + assert agent == {"name": "demo-agent", "framework": "langgraph"} + assert module.__name__ == module_name + + +def test_load_agent_module_supports_nested_entry_point(tmp_path: Path): + project_pkg = tmp_path / "agent_loader_nested_pkg" + nested_pkg = project_pkg / "agents" + nested_pkg.mkdir(parents=True, exist_ok=True) + (project_pkg / "__init__.py").write_text("", encoding="utf-8") + (nested_pkg / "__init__.py").write_text("", encoding="utf-8") + (nested_pkg / "entry.py").write_text('root_agent = "nested-root-agent"\n', encoding="utf-8") + module_name = "agent_loader_nested_pkg.agents.entry" + _cleanup_module(module_name) + + agent, module = load_agent_module(str(tmp_path), "agent_loader_nested_pkg/agents/entry.py", "root_agent") + + assert agent == "nested-root-agent" + assert module.__name__ == module_name + + +def test_load_agent_module_raises_when_agent_variable_is_missing(tmp_path: Path): + entry_point = _write_package( + tmp_path, + "agent_loader_missing_attr_pkg", + "agent_impl", + 'some_other_name = "not-root-agent"\n', + ) + module_name = "agent_loader_missing_attr_pkg.agent_impl" + _cleanup_module(module_name) + + with pytest.raises(AttributeError, match="未找到 root_agent"): + load_agent_module(str(tmp_path), entry_point, "root_agent") diff --git a/tests/test_agent_access.py b/tests/test_agent_access.py new file mode 100644 index 00000000..10f6c54c --- /dev/null +++ b/tests/test_agent_access.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +from contextlib import contextmanager + +from ksadk.api.client import AgentEngineAPIError +from ksadk.deployment.agent_access import ( + get_latest_agent_access, + normalize_deployment_status, +) + + +class _FakeClient: + def __init__(self) -> None: + self.calls = 0 + self.suppression_used = False + + @contextmanager + def suppress_http_error_logging(self, predicate=None): + self.suppression_used = predicate is not None + yield + + async def get_agent(self, *, agent_id=None, name=None, include_api_key=False): + self.calls += 1 + if self.calls < 3: + raise AgentEngineAPIError( + 404, + "未找到对应的 Agent", + details={ + "http_status": 404, + "remote_error_message": "未找到对应的 Agent", + }, + ) + return { + "basic": { + "agent_id": agent_id or "ar-demo", + "name": name or "demo-agent", + "status": "RUNNING", + "framework": "hermes", + "region": "pre-online", + }, + "quick_access": { + "public_endpoint": "https://agent.example.com", + "api_key": "ak-demo" if include_api_key else None, + }, + } + + +async def _fake_detail_fetcher(agent_ref: str, include_api_key: bool): + return { + "agent_id": agent_ref, + "name": "demo-openclaw", + "status": "RUNNING", + "framework": "openclaw", + "region": "pre-online", + "endpoint": "https://openclaw.example.com", + "api_key": "ak-openclaw" if include_api_key else None, + } + + +def test_get_latest_agent_access_retries_transient_get_agent_not_found_and_suppresses_logs(): + import asyncio + + client = _FakeClient() + + result = asyncio.run( + get_latest_agent_access( + client, + agent_id="ar-demo", + attempts=3, + interval_seconds=0, + include_api_key=True, + ) + ) + + assert client.suppression_used is True + assert client.calls == 3 + assert result["agent_id"] == "ar-demo" + assert result["endpoint"] == "https://agent.example.com" + assert result["api_key"] == "ak-demo" + assert result["status"] == "RUNNING" + + +def test_get_latest_agent_access_supports_custom_detail_fetcher(): + import asyncio + + result = asyncio.run( + get_latest_agent_access( + object(), + agent_id="ar-openclaw-demo", + attempts=1, + interval_seconds=0, + detail_fetcher=_fake_detail_fetcher, + ) + ) + + assert result["agent_id"] == "ar-openclaw-demo" + assert result["framework"] == "openclaw" + assert result["region"] == "pre-online" + assert result["endpoint"] == "https://openclaw.example.com" + + +def test_normalize_deployment_status_maps_numeric_to_submitted(): + assert normalize_deployment_status(200) == "SUBMITTED" + assert normalize_deployment_status("running") == "RUNNING" diff --git a/tests/test_agentengine_toolsets.py b/tests/test_agentengine_toolsets.py index 54eae80e..2dbc1501 100644 --- a/tests/test_agentengine_toolsets.py +++ b/tests/test_agentengine_toolsets.py @@ -1,4 +1,44 @@ from ksadk.toolsets import agentengine_tool_dispatcher +from ksadk.toolsets import get_agentengine_tools +from ksadk.runtime_context import PlatformInvocationContext, platform_invocation_scope + + +class _FakeMemoryService: + def __init__(self): + self.save_calls = [] + self._backend = None + + def save_text(self, *, user_id: str, content: str, metadata: dict) -> bool: + self.save_calls.append((user_id, content, metadata)) + return True + + +class _FailingMemoryService(_FakeMemoryService): + def __init__(self): + super().__init__() + self._backend = type("Backend", (), {"last_error": "write not persisted"})() + + def save_text(self, *, user_id: str, content: str, metadata: dict) -> bool: + self.save_calls.append((user_id, content, metadata)) + return False + + +def _context() -> PlatformInvocationContext: + return PlatformInvocationContext( + agent_id="demo-agent", + user_id="user-1", + session_id="sess-1", + history=[], + input_content=[], + input_messages=[], + input_parts=[], + attachments=[], + attachment_results=[], + current_attachments=[], + current_attachment_results=[], + has_current_files=False, + runner_type="langgraph", + ) def test_dispatcher_list_returns_error_for_unknown_include_without_raising(): @@ -8,3 +48,94 @@ def test_dispatcher_list_returns_error_for_unknown_include_without_raising(): assert result["error_type"] == "unknown_tool" assert result["tool_name"] == "file" + +def test_dispatcher_langchain_tool_accepts_json_string_arguments(): + dispatcher = next( + tool + for tool in get_agentengine_tools(include=["agentengine_tool_dispatcher"]) + if tool.name == "agentengine_tool_dispatcher" + ) + + result = dispatcher.invoke( + { + "action": "list", + "include": "workspace", + "arguments": '{"unused": true}', + } + ) + + assert result["ok"] is True + assert result["tool_count"] > 0 + + +def test_dispatcher_call_accepts_json_string_arguments(): + dispatcher = next( + tool + for tool in get_agentengine_tools(include=["agentengine_tool_dispatcher"]) + if tool.name == "agentengine_tool_dispatcher" + ) + + result = dispatcher.invoke( + { + "action": "call", + "tool_name": "component_status", + "arguments": "{}", + } + ) + + assert result["ok"] is True + assert result["tool_name"] == "component_status" + assert result["result"]["ok"] is True + + +def test_dispatcher_describe_exposes_langchain_tool_args(): + result = agentengine_tool_dispatcher(action="describe", tool_name="save_memory") + + assert result["ok"] is True + assert result["tool"]["args"]["content"]["type"] == "string" + + +def test_dispatcher_save_memory_accepts_key_value_arguments(monkeypatch): + service = _FakeMemoryService() + monkeypatch.setattr("ksadk.memory.tool._get_or_create_service", lambda: service) + + with platform_invocation_scope(_context()): + result = agentengine_tool_dispatcher( + action="call", + tool_name="save_memory", + arguments={"key": "user_name", "value": "张三"}, + ) + + assert result == { + "ok": True, + "tool_name": "save_memory", + "result": {"ok": True, "status": "persisted", "message": "记忆已保存。"}, + } + assert service.save_calls == [ + ( + "user-1", + "user_name: 张三", + { + "agent_id": "demo-agent", + "session_id": "sess-1", + "runner_type": "langgraph", + }, + ) + ] + + +def test_dispatcher_propagates_save_memory_failure(monkeypatch): + service = _FailingMemoryService() + monkeypatch.setattr("ksadk.memory.tool._get_or_create_service", lambda: service) + + with platform_invocation_scope(_context()): + result = agentengine_tool_dispatcher( + action="call", + tool_name="save_memory", + arguments={"content": "用户喜欢云主机"}, + ) + + assert result["ok"] is False + assert result["tool_name"] == "save_memory" + assert result["result"]["ok"] is False + assert "记忆保存失败" in result["result"]["message"] diff --git a/tests/test_aicp_env.py b/tests/test_aicp_env.py new file mode 100644 index 00000000..49d965f5 --- /dev/null +++ b/tests/test_aicp_env.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +import socket + +from ksadk.common.aicp_env import resolve_aicp_connection + + +def test_resolve_aicp_connection_uses_explicit_endpoint(monkeypatch): + monkeypatch.setenv("KSADK_KB_ENDPOINT", "aicp.example.com") + monkeypatch.setenv("KSADK_KB_REGION", "pre-online") + + connection = resolve_aicp_connection("KSADK_KB") + + assert connection == { + "endpoint": "aicp.example.com", + "scheme": "https", + "region": "pre-online", + } + + +def test_resolve_aicp_connection_prefers_reachable_inner_endpoint(monkeypatch): + def fake_create_connection(address, timeout=1.0): + host, port = address + if host == "aicp.internal.api.ksyun.com" and port == 80: + return _FakeSocket() + raise OSError("unreachable") + + monkeypatch.delenv("KSADK_KB_ENDPOINT", raising=False) + monkeypatch.delenv("KSADK_KB_SCHEME", raising=False) + monkeypatch.setattr(socket, "create_connection", fake_create_connection) + + connection = resolve_aicp_connection("KSADK_KB") + + assert connection["endpoint"] == "aicp.internal.api.ksyun.com" + assert connection["scheme"] == "http" + + +def test_resolve_aicp_connection_falls_back_to_inner_when_internal_unreachable(monkeypatch): + def fake_create_connection(address, timeout=1.0): + host, port = address + if host == "aicp.inner.api.ksyun.com" and port == 80: + return _FakeSocket() + raise OSError("unreachable") + + monkeypatch.delenv("KSADK_KB_ENDPOINT", raising=False) + monkeypatch.delenv("KSADK_KB_SCHEME", raising=False) + monkeypatch.setattr(socket, "create_connection", fake_create_connection) + + connection = resolve_aicp_connection("KSADK_KB") + + assert connection["endpoint"] == "aicp.inner.api.ksyun.com" + assert connection["scheme"] == "http" + + +def test_resolve_aicp_connection_falls_back_to_public_when_private_endpoints_unreachable(monkeypatch): + monkeypatch.delenv("KSADK_KB_ENDPOINT", raising=False) + monkeypatch.delenv("KSADK_KB_SCHEME", raising=False) + monkeypatch.setattr(socket, "create_connection", lambda *args, **kwargs: (_ for _ in ()).throw(OSError())) + + connection = resolve_aicp_connection("KSADK_KB") + + assert connection["endpoint"] == "aicp.api.ksyun.com" + assert connection["scheme"] == "https" + + +def test_resolve_aicp_connection_honors_global_endpoint_mode(monkeypatch): + monkeypatch.delenv("KSADK_KB_ENDPOINT", raising=False) + monkeypatch.delenv("KSADK_KB_SCHEME", raising=False) + monkeypatch.setenv("KSADK_AICP_ENDPOINT_MODE", "inner") + monkeypatch.setattr(socket, "create_connection", lambda *args, **kwargs: (_ for _ in ()).throw(OSError())) + + connection = resolve_aicp_connection("KSADK_KB") + + assert connection["endpoint"] == "aicp.inner.api.ksyun.com" + assert connection["scheme"] == "http" + + +class _FakeSocket: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False diff --git a/tests/test_attachment_pipeline.py b/tests/test_attachment_pipeline.py new file mode 100644 index 00000000..d83c2579 --- /dev/null +++ b/tests/test_attachment_pipeline.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +import base64 +import io +import zipfile + +import pytest + +from ksadk.conversations.normalize import normalize_parts_content +from ksadk.server.api_models import InlineData, Part + + +def _inline_part(*, name: str, mime_type: str, raw: bytes) -> Part: + return Part( + inlineData=InlineData( + data=base64.b64encode(raw).decode("ascii"), + mimeType=mime_type, + displayName=name, + ) + ) + + +def test_normalize_parts_content_returns_attachment_results_for_inline_text_file(): + payload = normalize_parts_content( + [_inline_part(name="resume.txt", mime_type="text/plain", raw="张三\n8年经验".encode("utf-8"))] + ) + + assert payload["attachments"][0]["display_name"] == "resume.txt" + result = payload["attachment_results"][0] + assert result["display_name"] == "resume.txt" + assert result["kind"] == "text" + assert result["status"] == "ok" + assert result["extraction_method"] == "text_decode" + assert result["text_excerpt"] == "张三\n8年经验" + assert result["text"] == "张三\n8年经验" + assert payload["content"].startswith("[上传文件: resume.txt]") + assert "8年经验" in payload["content"] + + +def test_normalize_parts_content_falls_back_to_pdf_ocr_when_native_extract_is_empty(monkeypatch): + monkeypatch.setattr( + "ksadk.conversations.attachments.extract_pdf_text", + lambda raw: "", + ) + monkeypatch.setattr( + "ksadk.conversations.attachments.perform_ocr", + lambda raw, mime_type, display_name: { + "text": "李四 10年产品经验", + "engine": "mock-ocr", + }, + ) + + payload = normalize_parts_content( + [_inline_part(name="resume.pdf", mime_type="application/pdf", raw=b"%PDF-1.4 fake")] + ) + + result = payload["attachment_results"][0] + assert result["kind"] == "document" + assert result["status"] == "ok" + assert result["extraction_method"] == "pdf_ocr" + assert result["text"] == "李四 10年产品经验" + assert any("OCR" in warning for warning in result["warnings"]) + assert result["document"]["ocr_engine"] == "mock-ocr" + + +def test_normalize_parts_content_uses_ocr_for_image_attachments(monkeypatch): + monkeypatch.setattr( + "ksadk.conversations.attachments.perform_ocr", + lambda raw, mime_type, display_name: { + "text": "王五\n算法工程师", + "engine": "mock-ocr", + }, + ) + + payload = normalize_parts_content( + [_inline_part(name="avatar.png", mime_type="image/png", raw=b"\x89PNG\r\n")] + ) + + result = payload["attachment_results"][0] + assert result["kind"] == "image" + assert result["status"] == "ok" + assert result["extraction_method"] == "image_ocr" + assert result["text"] == "王五\n算法工程师" + assert result["image"]["ocr_engine"] == "mock-ocr" + + +def test_normalize_parts_content_safely_enumerates_zip_and_blocks_nested_archives(): + archive_stream = io.BytesIO() + with zipfile.ZipFile(archive_stream, "w") as archive: + archive.writestr("resume.txt", "候选人A\n负责增长业务") + archive.writestr("nested.zip", b"PK\x03\x04not-allowed") + archive.writestr("../escape.txt", "blocked") + + payload = normalize_parts_content( + [_inline_part(name="bundle.zip", mime_type="application/zip", raw=archive_stream.getvalue())] + ) + + result = payload["attachment_results"][0] + assert result["kind"] == "archive" + assert result["status"] == "partial" + assert result["extraction_method"] == "zip_enumeration" + assert any("nested.zip" in warning for warning in result["warnings"]) + assert any("escape.txt" in warning for warning in result["warnings"]) + assert result["archive"]["entries"][0]["path"] == "resume.txt" + assert result["archive"]["extracted_entries"][0]["display_name"] == "resume.txt" + assert "候选人A" in result["text"] diff --git a/tests/test_builder_requirements_merge.py b/tests/test_builder_requirements_merge.py new file mode 100644 index 00000000..4b0802fe --- /dev/null +++ b/tests/test_builder_requirements_merge.py @@ -0,0 +1,443 @@ +from types import SimpleNamespace + +from ksadk.builders import container_builder +from ksadk.builders.code_builder import CodeBuilder +from ksadk.builders.container_builder import ContainerBuilder +from ksadk.builders.mcp_builder import MCPCodeBuilder +from ksadk.deployment.manager import K8sDeployer +from ksadk.detection import DetectionResult, FrameworkType + + +def _detection_result(framework: str): + return SimpleNamespace(type=SimpleNamespace(value=framework)) + + +def _full_detection_result(framework_type: FrameworkType): + return DetectionResult( + type=framework_type, + name="demo_agent", + entry_point="demo_agent/agent.py", + package_path="/tmp/demo_agent", + agent_variable="root_agent", + ) + + +def test_ensure_docker_running_prints_windows_docker_desktop_hint(monkeypatch, capsys): + monkeypatch.setattr(container_builder.shutil, "which", lambda _name: "/usr/bin/docker") + monkeypatch.setattr(container_builder.platform, "system", lambda: "Windows") + monkeypatch.setattr( + container_builder.subprocess, + "run", + lambda *_args, **_kwargs: SimpleNamespace(returncode=1), + ) + + assert container_builder.ensure_docker_running() is False + output = capsys.readouterr().out + assert "Docker Desktop" in output + assert "systemctl" not in output + + +def test_code_builder_prefers_user_pins_over_base_requirements(tmp_path): + (tmp_path / "requirements.txt").write_text( + "fastapi==0.121.2\nuvicorn==0.38.0\npython-dotenv==1.2.1\n", + encoding="utf-8", + ) + builder = CodeBuilder(tmp_path) + + deps = builder._build_requirements_list(_detection_result("langgraph")) + + assert "fastapi==0.121.2" in deps + assert "uvicorn==0.38.0" in deps + assert "python-dotenv==1.2.1" in deps + assert "fastapi>=0.100.0" not in deps + assert "uvicorn>=0.23.0" not in deps + assert "python-dotenv>=1.0.0" not in deps + + +def test_container_builder_prefers_user_pins_over_base_requirements(tmp_path): + (tmp_path / "requirements.txt").write_text( + "fastapi==0.121.2\nuvicorn==0.38.0\npython-dotenv==1.2.1\n", + encoding="utf-8", + ) + builder = ContainerBuilder(tmp_path) + + deps = builder._generate_requirements( + _detection_result("langgraph"), + tmp_path, + ).splitlines() + + assert "fastapi==0.121.2" in deps + assert "uvicorn==0.38.0" in deps + assert "python-dotenv==1.2.1" in deps + assert "fastapi>=0.100.0" not in deps + assert "uvicorn>=0.23.0" not in deps + assert "python-dotenv>=1.0.0" not in deps + + +def test_mcp_builder_prefers_user_pins_over_base_requirements(tmp_path): + (tmp_path / "requirements.txt").write_text( + "uvicorn==0.38.0\npython-dotenv==1.2.1\n", + encoding="utf-8", + ) + builder = MCPCodeBuilder(tmp_path) + builder.build_dir.mkdir(parents=True, exist_ok=True) + + requirements_path = builder._prepare_mcp_requirements(SimpleNamespace()) + deps = requirements_path.read_text(encoding="utf-8").splitlines() + + assert "uvicorn==0.38.0" in deps + assert "python-dotenv==1.2.1" in deps + assert "uvicorn>=0.23.0" not in deps + assert "python-dotenv>=1.0.0" not in deps + + +def test_code_builder_omits_bundled_ksadk_package_from_runtime_requirements(tmp_path): + (tmp_path / "requirements.txt").write_text( + "fastapi==0.121.2\nksadk==0.4.0\n", + encoding="utf-8", + ) + builder = CodeBuilder(tmp_path) + + deps = builder._build_requirements_list(_detection_result("langgraph")) + + assert "fastapi==0.121.2" in deps + assert "ksadk==0.4.0" not in deps + assert all(not dep.startswith("ksadk") for dep in deps) + assert "a2a-sdk>=0.3.22" in deps + assert "requests-aws4auth>=1.2.0" in deps + + +def test_container_builder_omits_bundled_ksadk_package_from_runtime_requirements(tmp_path): + (tmp_path / "requirements.txt").write_text( + "fastapi==0.121.2\nksadk==0.4.0\n", + encoding="utf-8", + ) + builder = ContainerBuilder(tmp_path) + + deps = builder._generate_requirements( + _detection_result("langgraph"), + tmp_path, + ).splitlines() + + assert "fastapi==0.121.2" in deps + assert "ksadk==0.4.0" not in deps + assert all(not dep.startswith("ksadk") for dep in deps) + assert "a2a-sdk>=0.3.22" in deps + assert "requests-aws4auth>=1.2.0" in deps + + +def test_code_builder_bundles_attachment_runtime_requirements_without_optional_backends(tmp_path): + builder = CodeBuilder(tmp_path) + + deps = builder._build_requirements_list(_detection_result("langgraph")) + + assert "pypdf>=6.0.0" in deps + assert "beautifulsoup4>=4.12.0" in deps + assert "rapidocr-onnxruntime>=1.2.0" not in deps + assert "mcp>=1.1.0" not in deps + assert "langchain-mcp-adapters>=0.0.1" not in deps + assert "asyncpg>=0.30.0,<1.0.0" not in deps + assert "boto3==1.40.61" not in deps + assert "SQLAlchemy==2.0.44" not in deps + assert "psycopg[binary]==3.3.0" not in deps + assert "psycopg-pool==3.3.0" not in deps + assert "pandas==2.2.2" not in deps + assert "openpyxl==3.1.5" not in deps + assert "xlrd==2.0.2" not in deps + assert "python-pptx==1.0.2" not in deps + assert "docx2python==3.5.0" not in deps + + +def test_code_builder_includes_mcp_runtime_when_project_uses_langchain_mcp_adapter(tmp_path): + (tmp_path / "agent.py").write_text( + "from langchain_mcp_adapters.client import MultiServerMCPClient\n", + encoding="utf-8", + ) + builder = CodeBuilder(tmp_path) + + deps = builder._build_requirements_list(_detection_result("langgraph")) + + assert "mcp>=1.1.0" in deps + assert "langchain-mcp-adapters>=0.0.1" in deps + + +def test_code_builder_includes_mcp_runtime_when_env_declares_mcp_servers(tmp_path): + (tmp_path / ".env").write_text('KSADK_MCP_SERVERS=[{"name":"demo","url":"http://mcp"}]\n', encoding="utf-8") + builder = CodeBuilder(tmp_path) + + deps = builder._build_requirements_list(_detection_result("langgraph")) + + assert "mcp>=1.1.0" in deps + assert "langchain-mcp-adapters>=0.0.1" in deps + + +def test_code_builder_does_not_include_mcp_runtime_for_empty_mcp_servers(tmp_path): + (tmp_path / ".env").write_text("KSADK_MCP_SERVERS=[]\n", encoding="utf-8") + builder = CodeBuilder(tmp_path) + + deps = builder._build_requirements_list(_detection_result("langgraph")) + + assert "mcp>=1.1.0" not in deps + assert "langchain-mcp-adapters>=0.0.1" not in deps + + +def test_code_builder_ignores_cached_build_files_when_detecting_optional_imports(tmp_path): + cached_dir = tmp_path / ".agentengine" / "code_build" / "old" + cached_dir.mkdir(parents=True) + (cached_dir / "agent.py").write_text( + "from langchain_mcp_adapters.client import MultiServerMCPClient\n", + encoding="utf-8", + ) + (tmp_path / "agent.py").write_text("root_agent = object()\n", encoding="utf-8") + builder = CodeBuilder(tmp_path) + + deps = builder._build_requirements_list(_detection_result("langgraph")) + + assert "mcp>=1.1.0" not in deps + assert "langchain-mcp-adapters>=0.0.1" not in deps + + +def test_code_builder_includes_mcp_runtime_when_build_flag_enabled(tmp_path, monkeypatch): + monkeypatch.setenv("KSADK_BUILD_ENABLE_MCP", "true") + builder = CodeBuilder(tmp_path) + + deps = builder._build_requirements_list(_detection_result("langgraph")) + + assert "mcp>=1.1.0" in deps + assert "langchain-mcp-adapters>=0.0.1" in deps + + +def test_code_builder_includes_asyncpg_when_postgres_session_declared(tmp_path): + (tmp_path / ".env").write_text("KSADK_SESSION_BACKEND=postgres\n", encoding="utf-8") + builder = CodeBuilder(tmp_path) + + deps = builder._build_requirements_list(_detection_result("langgraph")) + + assert "asyncpg>=0.30.0,<1.0.0" in deps + + +def test_code_builder_includes_asyncpg_when_postgres_dsn_declared(tmp_path): + (tmp_path / ".env").write_text( + "KSADK_SESSION_DSN=postgresql://user:pass@example.com/db\n", + encoding="utf-8", + ) + builder = CodeBuilder(tmp_path) + + deps = builder._build_requirements_list(_detection_result("langgraph")) + + assert "asyncpg>=0.30.0,<1.0.0" in deps + + +def test_code_builder_includes_asyncpg_when_build_flag_enabled(tmp_path, monkeypatch): + monkeypatch.setenv("KSADK_BUILD_ENABLE_POSTGRES_SESSION", "true") + builder = CodeBuilder(tmp_path) + + deps = builder._build_requirements_list(_detection_result("langgraph")) + + assert "asyncpg>=0.30.0,<1.0.0" in deps + + +def test_code_builder_includes_attachment_ocr_runtime_when_enabled(tmp_path, monkeypatch): + monkeypatch.setenv("KSADK_BUILD_ENABLE_ATTACHMENT_OCR", "true") + builder = CodeBuilder(tmp_path) + + deps = builder._build_requirements_list(_detection_result("langgraph")) + + assert "pypdf>=6.0.0" in deps + assert "beautifulsoup4>=4.12.0" in deps + assert "rapidocr-onnxruntime>=1.2.0" in deps + + +def test_container_builder_uses_same_optional_runtime_detection(tmp_path): + (tmp_path / ".env").write_text( + 'KSADK_MCP_SERVERS=[{"name":"demo","url":"http://mcp"}]\nKSADK_SESSION_BACKEND=postgres\n', + encoding="utf-8", + ) + builder = ContainerBuilder(tmp_path) + + deps = builder._generate_requirements( + _detection_result("langgraph"), + tmp_path, + ).splitlines() + + assert "mcp>=1.1.0" in deps + assert "langchain-mcp-adapters>=0.0.1" in deps + assert "asyncpg>=0.30.0,<1.0.0" in deps + + +def test_code_builder_uses_validated_langgraph_ecosystem_dependency_window(tmp_path): + builder = CodeBuilder(tmp_path) + + deps = builder._build_requirements_list(_detection_result("deepagents")) + + assert "fastapi>=0.100.0,<1.0.0" in deps + assert "langchain>=1.3.0,<2.0.0" in deps + assert "langchain-core>=1.4.0,<2.0.0" in deps + assert "langchain-openai>=1.2.0,<2.0.0" in deps + assert "langgraph>=1.2.0,<1.3.0" in deps + assert "deepagents>=0.6.2,<1.0.0" in deps + assert "langgraph>=0.1.0" not in deps + + +def test_code_builder_uses_validated_adk_dependency_window(tmp_path): + builder = CodeBuilder(tmp_path) + + deps = builder._build_requirements_list(_detection_result("adk")) + + assert "fastapi>=0.100.0,<1.0.0" in deps + assert "google-adk>=1.34.0,<2.0.0" in deps + assert "google-adk>=1.0.0" not in deps + + +def test_container_builder_bundles_attachment_runtime_requirements_without_optional_backends(tmp_path): + builder = ContainerBuilder(tmp_path) + + deps = builder._generate_requirements( + _detection_result("langgraph"), + tmp_path, + ).splitlines() + + assert "pypdf>=6.0.0" in deps + assert "beautifulsoup4>=4.12.0" in deps + assert "rapidocr-onnxruntime>=1.2.0" not in deps + + +def test_container_builder_includes_attachment_ocr_runtime_when_enabled(tmp_path, monkeypatch): + monkeypatch.setenv("KSADK_BUILD_ENABLE_ATTACHMENT_OCR", "true") + builder = ContainerBuilder(tmp_path) + + deps = builder._generate_requirements( + _detection_result("langgraph"), + tmp_path, + ).splitlines() + + assert "pypdf>=6.0.0" in deps + assert "beautifulsoup4>=4.12.0" in deps + assert "rapidocr-onnxruntime>=1.2.0" in deps + + + +def test_container_builder_uses_same_framework_dependency_windows(tmp_path): + builder = ContainerBuilder(tmp_path) + + deps = builder._generate_requirements( + _detection_result("deepagents"), + tmp_path, + ).splitlines() + + assert "fastapi>=0.100.0,<1.0.0" in deps + assert "langchain>=1.3.0,<2.0.0" in deps + assert "langchain-core>=1.4.0,<2.0.0" in deps + assert "langchain-openai>=1.2.0,<2.0.0" in deps + assert "langgraph>=1.2.0,<1.3.0" in deps + assert "deepagents>=0.6.2,<1.0.0" in deps + + +def test_k8s_deployer_uses_same_framework_dependency_windows(): + deployer = K8sDeployer() + + deps = deployer._generate_requirements(_detection_result("deepagents")).splitlines() + + assert "fastapi>=0.100.0,<1.0.0" in deps + assert "langchain>=1.3.0,<2.0.0" in deps + assert "langchain-core>=1.4.0,<2.0.0" in deps + assert "langchain-openai>=1.2.0,<2.0.0" in deps + assert "langgraph>=1.2.0,<1.3.0" in deps + assert "deepagents>=0.6.2,<1.0.0" in deps + assert "boto3==1.40.61" not in deps + assert "SQLAlchemy==2.0.44" not in deps + assert "psycopg[binary]==3.3.0" not in deps + assert "psycopg-pool==3.3.0" not in deps + assert "pandas==2.2.2" not in deps + assert "openpyxl==3.1.5" not in deps + assert "xlrd==2.0.2" not in deps + assert "python-pptx==1.0.2" not in deps + assert "docx2python==3.5.0" not in deps + + +def test_code_builder_includes_bundled_attachment_runtime_requirements(tmp_path): + builder = CodeBuilder(tmp_path) + + deps = builder._build_requirements_list(_detection_result("langgraph")) + + assert "pypdf>=6.0.0" in deps + assert "beautifulsoup4>=4.12.0" in deps + + +def test_container_builder_includes_bundled_attachment_runtime_requirements(tmp_path): + builder = ContainerBuilder(tmp_path) + + deps = builder._generate_requirements( + _detection_result("langgraph"), + tmp_path, + ).splitlines() + + assert "pypdf>=6.0.0" in deps + assert "beautifulsoup4>=4.12.0" in deps + + +def test_code_builder_entrypoint_uses_otlp_direct_by_default_for_code_frameworks(tmp_path): + builder = CodeBuilder(tmp_path) + + for framework_type in ( + FrameworkType.ADK, + FrameworkType.LANGCHAIN, + FrameworkType.LANGGRAPH, + FrameworkType.DEEPAGENTS, + ): + entrypoint = builder._generate_entrypoint(_full_detection_result(framework_type)) + + assert "LANGFUSE_USE_CALLBACK" in entrypoint + assert "use_callback_only=is_langchain" not in entrypoint + assert 'in ("LANGCHAIN", "LANGGRAPH", "DEEPAGENTS")' not in entrypoint + + +def test_code_builder_entrypoint_patches_langchain_before_loading_user_agent(tmp_path): + builder = CodeBuilder(tmp_path) + + entrypoint = builder._generate_entrypoint(_full_detection_result(FrameworkType.LANGGRAPH)) + + patch_index = entrypoint.index("apply_langchain_patch()") + load_index = entrypoint.index("runner.load_agent()") + assert patch_index < load_index + + +def test_code_builder_entrypoint_adds_src_layout_to_pythonpath(tmp_path): + builder = CodeBuilder(tmp_path) + + entrypoint = builder._generate_entrypoint(_full_detection_result(FrameworkType.DEEPAGENTS)) + + assert 'CODE_SRC = os.path.join(CODE_ROOT, "src")' in entrypoint + assert "sys.path.insert(0, CODE_SRC)" in entrypoint + + +def test_container_builder_entrypoint_uses_otlp_direct_by_default_for_code_frameworks(tmp_path): + builder = ContainerBuilder(tmp_path) + + for framework_type in ( + FrameworkType.ADK, + FrameworkType.LANGCHAIN, + FrameworkType.LANGGRAPH, + FrameworkType.DEEPAGENTS, + ): + entrypoint = builder._generate_entrypoint( + _full_detection_result(framework_type), + "demo_agent", + ) + + assert "LANGFUSE_USE_CALLBACK" in entrypoint + assert "use_callback_only=is_langchain" not in entrypoint + assert 'in ("LANGCHAIN", "LANGGRAPH", "DEEPAGENTS")' not in entrypoint + + +def test_container_builder_entrypoint_patches_langchain_before_loading_user_agent(tmp_path): + builder = ContainerBuilder(tmp_path) + + entrypoint = builder._generate_entrypoint( + _full_detection_result(FrameworkType.LANGGRAPH), + "demo_agent", + ) + + patch_index = entrypoint.index("apply_langchain_patch()") + load_index = entrypoint.index("runner.load_agent()") + assert patch_index < load_index diff --git a/tests/test_cli_global_options.py b/tests/test_cli_global_options.py new file mode 100644 index 00000000..7687a958 --- /dev/null +++ b/tests/test_cli_global_options.py @@ -0,0 +1,156 @@ +from __future__ import annotations + +import json + +from click.testing import CliRunner + +from ksadk.cli import _register_commands, cli +from ksadk.cli.dry_run import effective_dry_run +from ksadk.cli.ui import emit_json, is_color_disabled, is_json_output + + +def _parse_json(output: str) -> dict: + return json.loads(output.strip()) + + +def test_global_options_are_accepted_at_root_group_and_command_positions(monkeypatch): + _register_commands() + runner = CliRunner() + + def fake_run_status_command(*, dry_run: bool, **kwargs): # noqa: ARG001 + emit_json( + { + "json": is_json_output(), + "dry_run": effective_dry_run(dry_run), + "no_color": is_color_disabled(), + } + ) + + monkeypatch.setattr("ksadk.cli.cmd_agent.run_status_command", fake_run_status_command) + + cases = [ + ["--output", "json", "--dry-run", "--no-color", "agent", "list", "--account-id", "2000003485"], + ["agent", "--output", "json", "--dry-run", "--no-color", "list", "--account-id", "2000003485"], + ["agent", "list", "--output", "json", "--dry-run", "--no-color", "--account-id", "2000003485"], + ] + + for argv in cases: + result = runner.invoke(cli, argv) + assert result.exit_code == 0, result.output + assert _parse_json(result.output) == { + "json": True, + "dry_run": True, + "no_color": True, + } + + +def test_group_level_output_option_uses_canonical_cli_error_for_unsupported_command(): + _register_commands() + runner = CliRunner() + + result = runner.invoke(cli, ["agent", "--output", "json", "invoke"]) + + assert result.exit_code == 2, result.output + payload = _parse_json(result.output) + assert payload["ok"] is False + assert payload["error"]["code"] == "usage_error" + assert "--output json" in payload["error"]["message"] + + +def test_group_level_dry_run_option_uses_canonical_cli_error_for_unsupported_command(): + _register_commands() + runner = CliRunner() + + result = runner.invoke(cli, ["agent", "--dry-run", "invoke"]) + + assert result.exit_code == 2, result.output + assert "--dry-run" in result.output + + +def test_agent_list_passes_framework_filter_to_status_command(monkeypatch): + _register_commands() + runner = CliRunner() + + def fake_run_status_command(*, framework: str | None, **kwargs): # noqa: ARG001 + emit_json({"framework": framework}) + + monkeypatch.setattr("ksadk.cli.cmd_agent.run_status_command", fake_run_status_command) + + result = runner.invoke( + cli, + [ + "agent", + "list", + "--account-id", + "2000003485", + "--framework", + " langgraph, adk ", + "--output", + "json", + ], + ) + + assert result.exit_code == 0, result.output + assert _parse_json(result.output) == {"framework": " langgraph, adk "} + + +def test_agent_list_hides_openclaw_and_hermes_by_default(monkeypatch): + _register_commands() + runner = CliRunner() + + class FakeAgentClient: + def __init__(self, *args, **kwargs): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def list_agents(self, **kwargs): + assert kwargs.get("framework") is None + return { + "agents": [ + { + "agent_id": "ar-langgraph", + "name": "regular-agent", + "status": "RUNNING", + "framework": "langgraph", + }, + { + "agent_id": "ar-openclaw", + "name": "openclaw-agent", + "status": "RUNNING", + "framework": "openclaw", + }, + { + "agent_id": "ar-hermes", + "name": "hermes-agent", + "status": "RUNNING", + "framework": "hermes", + }, + ], + "total": 3, + } + + async def close(self): + return None + + monkeypatch.setattr("ksadk.api.AgentEngineClient", FakeAgentClient) + + result = runner.invoke( + cli, + [ + "agent", + "list", + "--account-id", + "2000003485", + "--output", + "json", + ], + ) + + assert result.exit_code == 0, result.output + payload = _parse_json(result.output) + assert [item["framework"] for item in payload["items"]] == ["langgraph"] diff --git a/tests/test_cli_platform_refactor.py b/tests/test_cli_platform_refactor.py new file mode 100644 index 00000000..827e7d06 --- /dev/null +++ b/tests/test_cli_platform_refactor.py @@ -0,0 +1,277 @@ +from pathlib import Path + +import yaml +from click.testing import CliRunner + +from ksadk.cli.cmd_agent import agent +from ksadk.cli.cmd_mcp import mcp +from ksadk.cli.cmd_openclaw import openclaw +from ksadk.cli.cmd_version import version + + +class _FakeMCPClient: + last_name_lookup = None + + def __init__(self, *args, **kwargs): + self.kwargs = kwargs + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def get_mcp(self, mcp_id): + return { + "mcp_id": mcp_id, + "name": "demo-mcp", + "status": "running", + "region": "cn-beijing-6", + "endpoint": "https://demo.example.com", + "mcp_endpoint": "https://demo.example.com/mcp", + "enable_auth": True, + "tools": ["search"], + "created_at": "2026-03-20T12:00:00Z", + "updated_at": "2026-03-20T12:05:00Z", + } + + async def get_mcp_by_name(self, name, region=None): + type(self).last_name_lookup = {"name": name, "region": region} + return { + "mcp_id": "mcp-by-name", + "name": name, + "status": "running", + "region": region or "cn-beijing-6", + "endpoint": "https://demo.example.com", + "mcp_endpoint": "https://demo.example.com/mcp", + "enable_auth": False, + "created_at": "2026-03-20T12:00:00Z", + "updated_at": "2026-03-20T12:05:00Z", + } + + async def list_mcps(self, **kwargs): + page = int(kwargs.get("page", 1)) + page_size = int(kwargs.get("page_size", 20)) + all_items = [ + {"mcp_id": "mcp-1", "name": "first", "status": "running", "mcp_endpoint": "https://demo1.example.com/mcp"}, + {"mcp_id": "mcp-2", "name": "second", "status": "failed", "mcp_endpoint": "https://demo2.example.com/mcp"}, + {"mcp_id": "mcp-3", "name": "third", "status": "creating", "mcp_endpoint": "https://demo3.example.com/mcp"}, + ] + start = (page - 1) * page_size + end = start + page_size + return { + "mcps": all_items[start:end], + "total": 3, + } + + async def close(self): + return None + + +class _FakeAgentStatusClient: + def __init__(self, *args, **kwargs): + self.kwargs = kwargs + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def get_agent(self, agent_id=None, name=None): + return { + "basic": { + "agent_id": agent_id or "ar-openclaw-local", + "name": name or "openclaw-local", + "status": "RUNNING", + "framework": "openclaw", + "replicas": 1, + "ready_replicas": 1, + }, + "quick_access": { + "public_endpoint": "https://openclaw.example.com", + }, + "advanced": { + "observability_url": "https://trace.example.com/project/aropenclawlocal/traces", + }, + } + + +def test_mcp_status_falls_back_to_local_state(monkeypatch, tmp_path: Path): + runner = CliRunner() + monkeypatch.chdir(tmp_path) + monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeMCPClient) + state_path = tmp_path / ".agentengine.state" + state_path.write_text( + yaml.safe_dump({"type": "mcp", "mcp_id": "mcp-local", "region": "cn-beijing-6"}), + encoding="utf-8", + ) + + result = runner.invoke(mcp, ["status"]) + + assert result.exit_code == 0, result.output + assert "mcp-local" in result.output + assert "MCP 状态" in result.output + + +def test_agent_status_falls_back_to_openclaw_local_state(monkeypatch, tmp_path: Path): + runner = CliRunner() + monkeypatch.chdir(tmp_path) + monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeAgentStatusClient) + state_path = tmp_path / ".agentengine.state" + state_path.write_text( + yaml.safe_dump({"type": "openclaw", "agent_id": "ar-openclaw-local", "region": "pre-online"}), + encoding="utf-8", + ) + + result = runner.invoke(agent, ["status", "--account-id", "2000003485"]) + + assert result.exit_code == 0, result.output + assert "ar-openclaw-local" in result.output + assert "Langfuse" in result.output + assert "https://trace.example.com/project/aropenclawlocal/traces" in result.output + + +def test_mcp_list_supports_pagination(monkeypatch): + runner = CliRunner() + monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeMCPClient) + + result = runner.invoke(mcp, ["list", "--page", "2", "--size", "1"]) + + assert result.exit_code == 0, result.output + assert "mcp-2" in result.output + assert "mcp-1" not in result.output + assert "MCP总数: 3 页码: 2 每页: 1" in result.output + + +def test_mcp_status_passes_region_to_name_lookup(monkeypatch): + runner = CliRunner() + + class _FallbackToNameClient(_FakeMCPClient): + last_name_lookup = None + + async def get_mcp(self, mcp_id): + raise RuntimeError("not found") + + monkeypatch.setattr("ksadk.api.AgentEngineClient", _FallbackToNameClient) + + result = runner.invoke(mcp, ["status", "demo-mcp", "--region", "cn-shanghai-2"]) + + assert result.exit_code == 0, result.output + assert _FallbackToNameClient.last_name_lookup == { + "name": "demo-mcp", + "region": "cn-shanghai-2", + } + + +def test_agent_list_fills_visible_page_after_filtering_openclaw(monkeypatch): + runner = CliRunner() + + async def _fake_list_agent_runtimes( + region, + account_id, + dry_run=False, + *, + page=1, + page_size=20, + framework=None, + ): + assert framework is None + if page == 1: + return { + "agents": [ + { + "agentRuntimeId": f"ar-openclaw-{idx}", + "agentRuntimeName": f"openclaw-{idx}", + "status": "RUNNING", + "replicas": 1, + "readyReplicas": 1, + "endpoint": f"https://openclaw-{idx}.example.com", + "framework": "openclaw", + } + for idx in range(100) + ], + "total": 101, + } + if page == 2: + return { + "agents": [ + { + "agentRuntimeId": "ar-agent-1", + "agentRuntimeName": "visible-agent", + "status": "RUNNING", + "replicas": 1, + "readyReplicas": 1, + "endpoint": "https://agent.example.com", + "framework": "langgraph", + } + ], + "total": 101, + } + return {"agents": [], "total": 101} + + monkeypatch.setattr("ksadk.cli.cmd_status._list_agent_runtimes", _fake_list_agent_runtimes) + + result = runner.invoke(agent, ["list", "--page", "1", "--size", "1", "--account-id", "2000003485"]) + + assert result.exit_code == 0, result.output + assert "visible-agent" in result.output + assert "Agent总数: 1 页码: 1 每页: 1" in result.output + + +def test_agent_list_with_explicit_openclaw_framework_does_not_hide_results(monkeypatch): + runner = CliRunner() + + async def _fake_list_agent_runtimes( + region, + account_id, + dry_run=False, + *, + page=1, + page_size=20, + framework=None, + ): + assert framework == "openclaw" + return { + "agents": [ + { + "agentRuntimeId": "ar-openclaw-1", + "agentRuntimeName": "openclaw-visible", + "status": "RUNNING", + "replicas": 1, + "readyReplicas": 1, + "endpoint": "https://openclaw.example.com", + "framework": "openclaw", + } + ], + "total": 1, + } + + monkeypatch.setattr("ksadk.cli.cmd_status._list_agent_runtimes", _fake_list_agent_runtimes) + + result = runner.invoke( + agent, + [ + "list", + "--page", + "1", + "--size", + "20", + "--account-id", + "2000003485", + "--framework", + "openclaw", + ], + ) + + assert result.exit_code == 0, result.output + assert "openclaw-visible" in result.output + assert "已隐藏" not in result.output + + +def test_resource_groups_support_short_help(): + runner = CliRunner() + + for command in (mcp, openclaw, version): + result = runner.invoke(command, ["-h"]) + assert result.exit_code == 0, result.output diff --git a/tests/test_cli_root_entrypoint.py b/tests/test_cli_root_entrypoint.py new file mode 100644 index 00000000..473c3f39 --- /dev/null +++ b/tests/test_cli_root_entrypoint.py @@ -0,0 +1,18 @@ +import sys + +import pytest + +from ksadk.cli import main + + +def test_main_without_args_shows_help_without_error_prefix(monkeypatch, capsys): + monkeypatch.setattr(sys, "argv", ["agentengine"]) + + with pytest.raises(SystemExit) as exc_info: + main() + + captured = capsys.readouterr() + + assert exc_info.value.code == 0 + assert "AgentEngine CLI" in captured.out + assert "错误:" not in captured.out diff --git a/tests/test_client_framework_passthrough.py b/tests/test_client_framework_passthrough.py new file mode 100644 index 00000000..79f65663 --- /dev/null +++ b/tests/test_client_framework_passthrough.py @@ -0,0 +1,445 @@ +"""Client framework tests.""" + +import pytest + +from ksadk.api.client import AgentEngineClient + + +def _build_create_payload() -> dict: + return { + "name": "deepagents-demo", + "framework": "deepagents", + "artifact_type": "Code", + "artifact_path": "ks3://bucket/path/code.zip", + "region": "cn-beijing-6", + } + + +@pytest.mark.asyncio +async def test_create_agent_preserves_deepagents_when_server_supports_it(monkeypatch): + client = AgentEngineClient(base_url="http://example.com", access_key="", secret_key="") + calls = [] + + def fake_action(action: str, params: dict): + calls.append((action, params.copy())) + return {"agent_id": "ar-new"} + + monkeypatch.setattr(client, "_action", fake_action) + + result = await client.create_agent(_build_create_payload()) + + assert result["agent_id"] == "ar-new" + assert len(calls) == 1 + assert calls[0][0] == "CreateAgentProduct" + assert calls[0][1]["Framework"] == "deepagents" + + +@pytest.mark.asyncio +async def test_create_agent_forwards_network_configuration(monkeypatch): + client = AgentEngineClient(base_url="http://example.com", access_key="", secret_key="") + calls = [] + + def fake_action(action: str, params: dict): + calls.append((action, params.copy())) + return {"agent_id": "ar-network"} + + monkeypatch.setattr(client, "_action", fake_action) + + payload = _build_create_payload() + payload["network"] = { + "enable_public_access": False, + "enable_vpc_access": True, + "vpc_id": "vpc-demo", + "subnet_id": "subnet-demo", + "security_group_id": "sg-demo", + "availability_zone": "cn-beijing-6a", + } + + await client.create_agent(payload) + + assert calls[0][1]["Network"] == { + "EnablePublicAccess": False, + "EnableVpcAccess": True, + "VpcId": "vpc-demo", + "SubnetId": "subnet-demo", + "SecurityGroupId": "sg-demo", + "AvailabilityZone": "cn-beijing-6a", + } + + +@pytest.mark.asyncio +async def test_create_agent_forwards_ui_config(monkeypatch): + client = AgentEngineClient(base_url="http://example.com", access_key="", secret_key="") + calls = [] + + def fake_action(action: str, params: dict): + calls.append((action, params.copy())) + return {"agent_id": "ar-ui"} + + monkeypatch.setattr(client, "_action", fake_action) + + payload = _build_create_payload() + payload["ui_config"] = { + "profile": "custom", + "path": "/chat", + "url": "https://ui.example.com/custom-ui/", + } + + await client.create_agent(payload) + + assert calls[0][1]["UiConfig"] == { + "Profile": "custom", + "Path": "/chat", + "Url": "https://ui.example.com/custom-ui/", + } + + +@pytest.mark.asyncio +async def test_create_dashboard_access_link_can_omit_path(monkeypatch): + client = AgentEngineClient(base_url="http://example.com", access_key="", secret_key="") + calls = [] + + def fake_action(action: str, params: dict): + calls.append((action, params.copy())) + return {"link_id": "dash-link"} + + monkeypatch.setattr(client, "_action", fake_action) + + await client.create_dashboard_access_link( + agent_id="ar-openclaw", + link_type="private", + path=None, + expires_seconds=3600, + ) + + assert calls == [ + ( + "CreateDashboardAccessLink", + { + "AgentId": "ar-openclaw", + "LinkType": "private", + "ForceNew": False, + "ExpiresSeconds": 3600, + }, + ) + ] + + +@pytest.mark.asyncio +async def test_create_agent_forwards_storage_configuration(monkeypatch): + client = AgentEngineClient(base_url="http://example.com", access_key="", secret_key="") + calls = [] + + def fake_action(action: str, params: dict): + calls.append((action, params.copy())) + return {"agent_id": "ar-storage"} + + monkeypatch.setattr(client, "_action", fake_action) + + payload = _build_create_payload() + payload["storage"] = { + "mount_path": "/home/node/.agentengine", + "size_gi": 20, + } + + await client.create_agent(payload) + + assert calls[0][1]["Storage"] == { + "MountPath": "/home/node/.agentengine", + "SizeGi": 20, + } + + +@pytest.mark.asyncio +async def test_create_agent_forwards_memory_configuration(monkeypatch): + client = AgentEngineClient(base_url="http://example.com", access_key="", secret_key="") + calls = [] + + def fake_action(action: str, params: dict): + calls.append((action, params.copy())) + return {"agent_id": "ar-memory"} + + monkeypatch.setattr(client, "_action", fake_action) + + payload = _build_create_payload() + payload["memory_config"] = { + "memory_system": "mem0", + "mem0_instance_id": "c17b20b1-faf7-4c98-91a7-38d1ee581ba1", + "mem0_instance_name": "mem-demo", + "mem0_region": "pre-online", + } + + await client.create_agent(payload) + + assert calls[0][1]["MemoryConfig"] == { + "MemorySystem": "mem0", + "Mem0InstanceId": "c17b20b1-faf7-4c98-91a7-38d1ee581ba1", + "Mem0InstanceName": "mem-demo", + "Mem0Region": "pre-online", + } + + +@pytest.mark.asyncio +async def test_update_agent_forwards_network_configuration(monkeypatch): + client = AgentEngineClient(base_url="http://example.com", access_key="", secret_key="") + calls = [] + + def fake_action(action: str, params: dict): + calls.append((action, params.copy())) + return {"agent_id": "ar-network"} + + monkeypatch.setattr(client, "_action", fake_action) + + await client.update_agent( + "ar-network", + { + "network": { + "enable_public_access": True, + "enable_vpc_access": True, + "vpc_id": "vpc-demo", + "subnet_id": "subnet-demo", + "security_group_id": "sg-demo", + } + }, + ) + + assert calls[0][0] == "UpdateAgent" + assert calls[0][1]["Network"] == { + "EnablePublicAccess": True, + "EnableVpcAccess": True, + "VpcId": "vpc-demo", + "SubnetId": "subnet-demo", + "SecurityGroupId": "sg-demo", + } + + +@pytest.mark.asyncio +async def test_update_agent_forwards_ui_config(monkeypatch): + client = AgentEngineClient(base_url="http://example.com", access_key="", secret_key="") + calls = [] + + def fake_action(action: str, params: dict): + calls.append((action, params.copy())) + return {"agent_id": "ar-ui"} + + monkeypatch.setattr(client, "_action", fake_action) + + await client.update_agent( + "ar-ui", + { + "ui_config": { + "profile": "custom", + "path": "/chat", + "url": "https://ui.example.com/custom-ui/", + } + }, + ) + + assert calls[0][0] == "UpdateAgent" + assert calls[0][1]["UiConfig"] == { + "Profile": "custom", + "Path": "/chat", + "Url": "https://ui.example.com/custom-ui/", + } + + +@pytest.mark.asyncio +async def test_update_agent_forwards_storage_disable_configuration(monkeypatch): + client = AgentEngineClient(base_url="http://example.com", access_key="", secret_key="") + calls = [] + + def fake_action(action: str, params: dict): + calls.append((action, params.copy())) + return {"agent_id": "ar-storage"} + + monkeypatch.setattr(client, "_action", fake_action) + + await client.update_agent( + "ar-storage", + { + "storage": { + "mount_path": "/home/node/.agentengine", + "size_gi": 64, + } + }, + ) + + assert calls[0][0] == "UpdateAgent" + assert calls[0][1]["Storage"] == { + "MountPath": "/home/node/.agentengine", + "SizeGi": 64, + } + + +@pytest.mark.asyncio +async def test_update_agent_forwards_memory_configuration(monkeypatch): + client = AgentEngineClient(base_url="http://example.com", access_key="", secret_key="") + calls = [] + + def fake_action(action: str, params: dict): + calls.append((action, params.copy())) + return {"agent_id": "ar-memory"} + + monkeypatch.setattr(client, "_action", fake_action) + + await client.update_agent( + "ar-memory", + { + "memory_config": { + "memory_system": "openclaw_default", + } + }, + ) + + assert calls[0][0] == "UpdateAgent" + assert calls[0][1]["MemoryConfig"] == { + "MemorySystem": "openclaw_default", + } + + +@pytest.mark.asyncio +async def test_update_agent_forwards_observability_configuration(monkeypatch): + client = AgentEngineClient(base_url="http://example.com", access_key="", secret_key="") + calls = [] + + def fake_action(action: str, params: dict): + calls.append((action, params.copy())) + return {"agent_id": "ar-observable"} + + monkeypatch.setattr(client, "_action", fake_action) + + await client.update_agent( + "ar-observable", + { + "observability": { + "langfuse_enabled": True, + } + }, + ) + + assert calls[0][0] == "UpdateAgent" + assert calls[0][1]["Advanced"]["EnableObservability"] is True + + +@pytest.mark.asyncio +async def test_list_agents_normalizes_multi_framework_string(monkeypatch): + client = AgentEngineClient(base_url="http://example.com", access_key="", secret_key="") + calls = [] + + def fake_action(action: str, params: dict): + calls.append((action, params.copy())) + return {"Agents": [], "Total": 0} + + monkeypatch.setattr(client, "_action", fake_action) + + await client.list_agents(framework=" langgraph, adk ") + + assert calls[0][0] == "ListAgents" + assert calls[0][1]["Framework"] == "langgraph,adk" + + +@pytest.mark.asyncio +async def test_list_agents_accepts_framework_sequences(monkeypatch): + client = AgentEngineClient(base_url="http://example.com", access_key="", secret_key="") + calls = [] + + def fake_action(action: str, params: dict): + calls.append((action, params.copy())) + return {"Agents": [], "Total": 0} + + monkeypatch.setattr(client, "_action", fake_action) + + await client.list_agents(framework=["langgraph", "adk"]) + + assert calls[0][0] == "ListAgents" + assert calls[0][1]["Framework"] == "langgraph,adk" + + +@pytest.mark.asyncio +async def test_run_openclaw_repair_forwards_control_plane_action(monkeypatch): + client = AgentEngineClient(base_url="http://example.com", access_key="", secret_key="") + calls = [] + + def fake_action(action: str, params: dict): + calls.append((action, params.copy())) + return {"ok": True, "repair_action": "doctor-fix"} + + monkeypatch.setattr(client, "_action", fake_action) + + result = await client.run_openclaw_repair("ar-openclaw-1") + + assert result == {"ok": True, "repair_action": "doctor-fix"} + assert calls == [ + ( + "RunOpenClawRepair", + { + "AgentId": "ar-openclaw-1", + "RepairAction": "doctor-fix", + }, + ) + ] + + +@pytest.mark.asyncio +async def test_create_agent_detects_enterprise_registry_from_image_addr(monkeypatch): + client = AgentEngineClient(base_url="http://example.com", access_key="", secret_key="") + calls = [] + + def fake_action(action: str, params: dict): + calls.append((action, params.copy())) + return {"agent_id": "ar-enterprise"} + + monkeypatch.setattr(client, "_action", fake_action) + + await client.create_agent( + { + "name": "enterprise-demo", + "framework": "langgraph", + "artifact_type": "Container", + "artifact_path": "agenthzzqy-vpc.ksyunkcr.com/testagent-pub/0606agent:v6", + "image_credential": {"username": "kcr-user", "password": "kcr-pass"}, + } + ) + + assert calls[0][1]["ContainerConfig"] == { + "ImageType": "Enterprise", + "EnterpriseInstance": "agenthzzqy", + "NameSpace": "testagent-pub", + "ImageRepo": "0606agent", + "ImageVersion": "v6", + "ImageAddr": "agenthzzqy-vpc.ksyunkcr.com/testagent-pub/0606agent:v6", + "UserName": "kcr-user", + "Password": "kcr-pass", + } + + +@pytest.mark.asyncio +async def test_create_agent_keeps_third_party_registry_as_personal_with_credentials(monkeypatch): + client = AgentEngineClient(base_url="http://example.com", access_key="", secret_key="") + calls = [] + + def fake_action(action: str, params: dict): + calls.append((action, params.copy())) + return {"agent_id": "ar-third-party"} + + monkeypatch.setattr(client, "_action", fake_action) + + await client.create_agent( + { + "name": "dockerhub-demo", + "framework": "langgraph", + "artifact_type": "Container", + "artifact_path": "registry-1.docker.io/acme/agent-runtime:v1", + "image_credential": {"username": "docker-user", "password": "docker-pass"}, + } + ) + + assert calls[0][1]["ContainerConfig"] == { + "ImageType": "Personal", + "NameSpace": "acme", + "ImageRepo": "agent-runtime", + "ImageVersion": "v1", + "ImageAddr": "registry-1.docker.io/acme/agent-runtime:v1", + "UserName": "docker-user", + "Password": "docker-pass", + } diff --git a/tests/test_client_get_agent_name.py b/tests/test_client_get_agent_name.py new file mode 100644 index 00000000..7a972078 --- /dev/null +++ b/tests/test_client_get_agent_name.py @@ -0,0 +1,84 @@ +import pytest + +from ksadk.api.client import AgentEngineClient + + +@pytest.mark.asyncio +async def test_get_agent_name_uses_get_agent_only(monkeypatch): + client = AgentEngineClient(base_url="http://example.com", access_key="", secret_key="") + calls = [] + + def fake_action(action: str, params: dict): + calls.append((action, params.copy())) + return {"AgentId": "ar-demo"} + + monkeypatch.setattr(client, "_action", fake_action) + + result = await client.get_agent(name="demo", include_api_key=True) + + assert result["AgentId"] == "ar-demo" + assert calls == [("GetAgent", {"Name": "demo", "IncludeApiKey": True})] + + +@pytest.mark.asyncio +async def test_get_agent_name_does_not_fallback_to_legacy_action(monkeypatch): + client = AgentEngineClient(base_url="http://example.com", access_key="", secret_key="") + calls = [] + + def fake_action(action: str, params: dict): + calls.append((action, params.copy())) + if action == "GetAgent": + raise Exception("HTTP 404 Not Found") + raise Exception("unexpected legacy action") + + monkeypatch.setattr(client, "_action", fake_action) + + with pytest.raises(Exception, match="HTTP 404"): + await client.get_agent(name="missing-agent") + + assert calls == [("GetAgent", {"Name": "missing-agent"})] + + +@pytest.mark.asyncio +async def test_get_agent_by_id_does_not_fallback_on_not_found_with_request_id(monkeypatch): + client = AgentEngineClient(base_url="http://example.com", access_key="", secret_key="") + calls = [] + + def fake_action(action: str, params: dict): + calls.append((action, params.copy())) + raise Exception( + 'HTTP 404 POST http://example.com/?Action=GetAgent&Version=2024-06-12: ' + '{"Code":404,"Message":"未找到对应的 Agent","RequestId":"abc-id-123"}' + ) + + monkeypatch.setattr(client, "_action", fake_action) + + with pytest.raises(Exception, match="HTTP 404"): + await client.get_agent(agent_id="ar-missing") + + assert calls == [("GetAgent", {"AgentId": "ar-missing"})] + + +@pytest.mark.asyncio +async def test_get_agent_by_id_falls_back_only_for_legacy_field_compat(monkeypatch): + client = AgentEngineClient(base_url="http://example.com", access_key="", secret_key="") + calls = [] + + def fake_action(action: str, params: dict): + calls.append((action, params.copy())) + if len(calls) == 1: + raise Exception( + 'HTTP 422 POST http://example.com/?Action=GetAgent&Version=2024-06-12: ' + '{"detail":[{"loc":["body","AgentId"],"msg":"extra inputs are not permitted"}]}' + ) + return {"AgentId": "ar-demo"} + + monkeypatch.setattr(client, "_action", fake_action) + + result = await client.get_agent(agent_id="ar-demo") + + assert result["AgentId"] == "ar-demo" + assert calls == [ + ("GetAgent", {"AgentId": "ar-demo"}), + ("GetAgent", {"Id": "ar-demo"}), + ] diff --git a/tests/test_client_http_error_logging.py b/tests/test_client_http_error_logging.py new file mode 100644 index 00000000..d9b3b665 --- /dev/null +++ b/tests/test_client_http_error_logging.py @@ -0,0 +1,28 @@ +import logging + +from ksadk.api.client import AgentEngineClient + + +def test_client_can_suppress_selected_http_error_logs(caplog): + client = AgentEngineClient(base_url="http://example.com", access_key="", secret_key="") + + with caplog.at_level(logging.ERROR, logger="ksadk.api.client"): + with client.suppress_http_error_logging( + lambda *, method, full_url, status_code, resp_text, details: ( + method == "POST" + and "Action=GetAgent" in full_url + and status_code == 404 + and "未找到对应的 Agent" in ( + str(details.get("remote_error_message") or "") + resp_text + ) + ) + ): + client._log_http_error( + method="POST", + full_url="http://example.com/?Action=GetAgent&Version=2024-06-12", + status_code=404, + resp_text='{"Message":"未找到对应的 Agent"}', + details={"remote_error_message": "未找到对应的 Agent", "http_status": 404}, + ) + + assert "Request failed" not in caplog.text diff --git a/tests/test_client_mcp_payloads.py b/tests/test_client_mcp_payloads.py new file mode 100644 index 00000000..04e05084 --- /dev/null +++ b/tests/test_client_mcp_payloads.py @@ -0,0 +1,221 @@ +"""Client MCP payload contract tests.""" + +import pytest + +from ksadk.api.client import AgentEngineClient + + +@pytest.mark.asyncio +async def test_create_mcp_code_uses_nested_server_schema(monkeypatch): + client = AgentEngineClient(base_url="http://example.com", access_key="", secret_key="") + calls: list[tuple[str, dict]] = [] + + def fake_action(action: str, params: dict): + calls.append((action, params.copy())) + return {"mcp_id": "mcp-created"} + + monkeypatch.setattr(client, "_action", fake_action) + + result = await client.create_mcp( + { + "name": "demo-mcp", + "description": "demo", + "artifact_type": "Code", + "artifact_path": "ks3://demo-bucket/mcps/demo-mcp/code_20260324120000.zip", + "region": "pre-online", + "enable_auth": True, + "resources": {"cpu": "2", "memory": "4Gi"}, + "scaling": {"min_replicas": 2, "max_replicas": 8, "concurrency": 35}, + "metadata": {"mcp_variable": "server", "tools": ["ping", "add"]}, + "ks3": { + "access_key": "ak", + "secret_key": "sk", + "region": "pre-online", + "bucket": "demo-bucket", + }, + } + ) + + assert result["mcp_id"] == "mcp-created" + assert calls == [ + ( + "CreateMCP", + { + "Name": "demo-mcp", + "Description": "demo", + "Region": "cn-beijing-6", + "DeploymentType": "Code", + "Resource": {"Cpu": 2, "Memory": 4}, + "Scaling": {"MinReplicas": 2, "MaxReplicas": 8, "QpsPerInstance": 35}, + "Access": {"AuthType": "ApiKey"}, + "Advanced": {"McpVariable": "server", "Tools": ["ping", "add"]}, + "CodeConfig": { + "Path": "ks3://demo-bucket/mcps/demo-mcp/code_20260324120000.zip", + "AccessKey": "ak", + "SecretKey": "sk", + "Region": "cn-beijing-6", + "Bucket": "demo-bucket", + }, + }, + ) + ] + + +@pytest.mark.asyncio +async def test_create_mcp_container_uses_nested_container_config(monkeypatch): + client = AgentEngineClient(base_url="http://example.com", access_key="", secret_key="") + calls: list[tuple[str, dict]] = [] + + def fake_action(action: str, params: dict): + calls.append((action, params.copy())) + return {"mcp_id": "mcp-created"} + + monkeypatch.setattr(client, "_action", fake_action) + + await client.create_mcp( + { + "name": "demo-mcp", + "artifact_type": "Container", + "artifact_path": "hub.kce.ksyun.com/agentengine/demo-mcp:v0.3.6", + "region": "cn-beijing-6", + "enable_auth": False, + "metadata": {"mcp_variable": "mcp", "tools": ["ping"]}, + "image_credential": {"username": "demo-user", "password": "demo-pass"}, + } + ) + + assert calls[0][0] == "CreateMCP" + payload = calls[0][1] + assert payload["DeploymentType"] == "Container" + assert payload["Access"] == {"AuthType": "None"} + assert payload["ContainerConfig"] == { + "ImageType": "Personal", + "NameSpace": "agentengine", + "ImageRepo": "demo-mcp", + "ImageVersion": "v0.3.6", + "ImageAddr": "hub.kce.ksyun.com/agentengine/demo-mcp:v0.3.6", + "UserName": "demo-user", + "Password": "demo-pass", + } + + +@pytest.mark.asyncio +async def test_create_mcp_includes_network_only_when_explicit(monkeypatch): + client = AgentEngineClient(base_url="http://example.com", access_key="", secret_key="") + calls: list[tuple[str, dict]] = [] + + def fake_action(action: str, params: dict): + calls.append((action, params.copy())) + return {"mcp_id": "mcp-created"} + + monkeypatch.setattr(client, "_action", fake_action) + + await client.create_mcp( + { + "name": "demo-mcp", + "artifact_type": "Code", + "artifact_path": "ks3://demo-bucket/mcps/demo-mcp/code.zip", + } + ) + await client.create_mcp( + { + "name": "demo-mcp", + "artifact_type": "Code", + "artifact_path": "ks3://demo-bucket/mcps/demo-mcp/code.zip", + "network": { + "enable_public_access": False, + "enable_vpc_access": True, + "vpc_id": "vpc-cli", + "subnet_id": "subnet-cli", + "security_group_id": "sg-cli", + "availability_zone": "cn-beijing-6b", + }, + } + ) + + assert "Network" not in calls[0][1] + assert calls[1][1]["Network"] == { + "EnablePublicAccess": False, + "EnableVpcAccess": True, + "VpcId": "vpc-cli", + "SubnetId": "subnet-cli", + "SecurityGroupId": "sg-cli", + "AvailabilityZone": "cn-beijing-6b", + } + + +@pytest.mark.asyncio +async def test_update_mcp_uses_nested_partial_sections(monkeypatch): + client = AgentEngineClient(base_url="http://example.com", access_key="", secret_key="") + calls: list[tuple[str, dict]] = [] + + def fake_action(action: str, params: dict): + calls.append((action, params.copy())) + return {"mcp_id": "mcp-updated"} + + monkeypatch.setattr(client, "_action", fake_action) + + await client.update_mcp( + "mcp-123", + { + "artifact_type": "Container", + "artifact_path": "hub.kce.ksyun.com/agentengine/demo-mcp:v0.3.7", + "enable_auth": True, + "scaling": {"min_replicas": 1, "max_replicas": 3, "concurrency": 12}, + "metadata": {"mcp_variable": "svc", "tools": ["ping", "health"]}, + }, + ) + + assert calls[0][0] == "UpdateMCP" + assert calls[0][1] == { + "Id": "mcp-123", + "DeploymentType": "Container", + "ContainerConfig": { + "ImageType": "Personal", + "NameSpace": "agentengine", + "ImageRepo": "demo-mcp", + "ImageVersion": "v0.3.7", + "ImageAddr": "hub.kce.ksyun.com/agentengine/demo-mcp:v0.3.7", + }, + "Scaling": {"MinReplicas": 1, "MaxReplicas": 3, "QpsPerInstance": 12}, + "Access": {"AuthType": "ApiKey"}, + "Advanced": {"McpVariable": "svc", "Tools": ["ping", "health"]}, + } + + +@pytest.mark.asyncio +async def test_update_mcp_can_send_network_without_artifact(monkeypatch): + client = AgentEngineClient(base_url="http://example.com", access_key="", secret_key="") + calls: list[tuple[str, dict]] = [] + + def fake_action(action: str, params: dict): + calls.append((action, params.copy())) + return {"mcp_id": "mcp-updated"} + + monkeypatch.setattr(client, "_action", fake_action) + + await client.update_mcp( + "mcp-123", + { + "network": { + "enable_public_access": False, + "vpc_id": "vpc-cli", + "subnet_id": "subnet-cli", + "security_group_id": "sg-cli", + }, + }, + ) + + assert calls[0] == ( + "UpdateMCP", + { + "Id": "mcp-123", + "Network": { + "EnablePublicAccess": False, + "EnableVpcAccess": False, + "VpcId": "vpc-cli", + "SubnetId": "subnet-cli", + "SecurityGroupId": "sg-cli", + }, + }, + ) diff --git a/tests/test_client_permission_precheck.py b/tests/test_client_permission_precheck.py new file mode 100644 index 00000000..58ca5127 --- /dev/null +++ b/tests/test_client_permission_precheck.py @@ -0,0 +1,423 @@ +from __future__ import annotations + +import logging + +import pytest + +from ksadk.api.client import AgentEngineAPIError, AgentEngineClient + + +@pytest.fixture(autouse=True) +def clear_permission_probe_cache(): + cache = getattr(AgentEngineClient, "_permission_probe_cache", None) + if isinstance(cache, dict): + cache.clear() + yield + cache = getattr(AgentEngineClient, "_permission_probe_cache", None) + if isinstance(cache, dict): + cache.clear() + + +def _build_client() -> AgentEngineClient: + return AgentEngineClient( + base_url="https://aicp.api.ksyun.com", + access_key="ak", + secret_key="sk", + region="cn-beijing-6", + ) + + +@pytest.mark.asyncio +async def test_list_agents_prechecks_default_role(monkeypatch): + client = _build_client() + monkeypatch.setenv("KSYUN_ACCOUNT_ID", "2000003485") + calls: list[tuple[str, str, dict]] = [] + + def fake_request(method: str, path: str, body: dict | None = None): + calls.append((method, path, dict(body or {}))) + if path.endswith("/CheckIamRole"): + return { + "Code": 0, + "Message": "Success", + "Data": {"HasPermission": True, "RoleName": "KsyunAgentEngineDefaultRole"}, + } + if path.endswith("/ListAgents"): + return { + "Code": 0, + "Message": "Success", + "Data": {"Agents": [], "Total": 0, "Page": 1, "PageSize": 20}, + } + raise AssertionError(f"unexpected path: {path}") + + monkeypatch.setattr(client, "_request", fake_request) + + result = await client.list_agents() + + assert result["agents"] == [] + assert calls[0][1].endswith("/CheckIamRole") + assert calls[0][2] == {"RoleName": "KsyunAgentEngineDefaultRole"} + assert calls[1][1].endswith("/ListAgents") + + +@pytest.mark.asyncio +async def test_permission_denied_stops_main_request(monkeypatch): + client = _build_client() + monkeypatch.setenv("KSYUN_ACCOUNT_ID", "2000003485") + calls: list[tuple[str, str, dict]] = [] + + def fake_request(method: str, path: str, body: dict | None = None): + calls.append((method, path, dict(body or {}))) + if path.endswith("/CheckIamRole"): + return { + "Code": 403, + "Message": "当前账号没有 KsyunAgentEngineDefaultRole 权限", + "Data": {"HasPermission": False, "RoleName": "KsyunAgentEngineDefaultRole"}, + } + raise AssertionError("main request should not be sent") + + monkeypatch.setattr(client, "_request", fake_request) + + with pytest.raises(AgentEngineAPIError, match="当前账号没有 KsyunAgentEngineDefaultRole 权限"): + await client.list_agents() + + assert calls == [ + ( + "POST", + "/agentengine/api/v1/CheckIamRole", + {"RoleName": "KsyunAgentEngineDefaultRole"}, + ) + ] + + +@pytest.mark.asyncio +async def test_probe_failure_is_fail_open(monkeypatch): + client = _build_client() + monkeypatch.setenv("KSYUN_ACCOUNT_ID", "2000003485") + calls: list[tuple[str, str, dict]] = [] + + def fake_request(method: str, path: str, body: dict | None = None): + calls.append((method, path, dict(body or {}))) + if path.endswith("/CheckIamRole"): + raise RuntimeError("HTTP 503 POST https://aicp.api.ksyun.com: probe unavailable") + if path.endswith("/GetAgent"): + return { + "Code": 0, + "Message": "Success", + "Data": {"Basic": {"AgentId": "ar-demo"}}, + } + raise AssertionError(f"unexpected path: {path}") + + monkeypatch.setattr(client, "_request", fake_request) + + result = await client.get_agent(agent_id="ar-demo") + + assert result["basic"]["agent_id"] == "ar-demo" + assert [path for _, path, _ in calls] == [ + "/agentengine/api/v1/CheckIamRole", + "/agentengine/api/v1/GetAgent", + ] + + +@pytest.mark.asyncio +async def test_permission_probe_uses_cache(monkeypatch): + client = _build_client() + monkeypatch.setenv("KSYUN_ACCOUNT_ID", "2000003485") + calls: list[tuple[str, str, dict]] = [] + + def fake_request(method: str, path: str, body: dict | None = None): + calls.append((method, path, dict(body or {}))) + if path.endswith("/CheckIamRole"): + return { + "Code": 0, + "Message": "Success", + "Data": {"HasPermission": True, "RoleName": "KsyunAgentEngineDefaultRole"}, + } + if path.endswith("/ListAgents"): + return { + "Code": 0, + "Message": "Success", + "Data": {"Agents": [], "Total": 0, "Page": 1, "PageSize": 20}, + } + if path.endswith("/GetAgent"): + return { + "Code": 0, + "Message": "Success", + "Data": {"Basic": {"AgentId": "ar-demo"}}, + } + raise AssertionError(f"unexpected path: {path}") + + monkeypatch.setattr(client, "_request", fake_request) + + await client.list_agents() + await client.get_agent(agent_id="ar-demo") + + assert [path for _, path, _ in calls].count("/agentengine/api/v1/CheckIamRole") == 1 + + +@pytest.mark.asyncio +async def test_create_agent_precheck_uses_explicit_iam_role(monkeypatch): + client = _build_client() + monkeypatch.setenv("KSYUN_ACCOUNT_ID", "2000003485") + calls: list[tuple[str, str, dict]] = [] + + def fake_request(method: str, path: str, body: dict | None = None): + calls.append((method, path, dict(body or {}))) + if path.endswith("/CheckIamRole"): + return { + "Code": 0, + "Message": "Success", + "Data": {"HasPermission": True, "RoleName": "CustomRuntimeRole"}, + } + if path.endswith("/CreateAgentProduct"): + return { + "Code": 0, + "Message": "Success", + "Data": {"AgentId": "ar-new"}, + } + raise AssertionError(f"unexpected path: {path}") + + monkeypatch.setattr(client, "_request", fake_request) + + await client.create_agent( + { + "name": "demo-agent", + "framework": "langgraph", + "artifact_type": "Code", + "artifact_path": "ks3://demo-bucket/code.zip", + "region": "cn-beijing-6", + "auth_type": "Iam", + "iam_role": "CustomRuntimeRole", + } + ) + + assert calls[0] == ( + "POST", + "/agentengine/api/v1/CheckIamRole", + {"RoleName": "CustomRuntimeRole"}, + ) + + +def test_request_parses_kop_auth_error_payload(monkeypatch, caplog): + client = AgentEngineClient( + base_url="https://aicp.api.ksyun.com", + access_key="ak", + secret_key="sk", + region="cn-beijing-6", + ) + + class _FakeResponse: + status_code = 400 + text = ( + '{"RequestId":"req-missing-ak","Error":{"Code":"MissingAccesskey",' + '"Message":"Access Key is Missing","Type":"Sender"}}' + ) + + def json(self): + return { + "RequestId": "req-missing-ak", + "Error": { + "Code": "MissingAccesskey", + "Message": "Access Key is Missing", + "Type": "Sender", + }, + } + + class _FakeSession: + def request(self, **_kwargs): + return _FakeResponse() + + monkeypatch.setattr(client, "_get_session", lambda: _FakeSession()) + + with caplog.at_level(logging.WARNING, logger="ksadk.api.client"): + with pytest.raises(AgentEngineAPIError) as exc: + client._request("POST", "/agentengine/api/v1/GetAgent", {"AgentId": "ar-demo"}) + + assert exc.value.code == 400 + assert exc.value.details["remote_error_code"] == "MissingAccesskey" + assert exc.value.details["request_id"] == "req-missing-ak" + assert not [record for record in caplog.records if record.levelno >= logging.WARNING] + + +def test_request_honors_curl_ssl_insecure_for_control_plane(monkeypatch): + client = AgentEngineClient( + base_url="https://aicp.api.ksyun.com", + access_key="ak", + secret_key="sk", + region="cn-beijing-6", + ) + captured = {} + + class _FakeResponse: + status_code = 200 + text = '{"Code":0,"Data":{"Ok":true}}' + + def json(self): + return {"Code": 0, "Data": {"Ok": True}} + + class _FakeSession: + def request(self, **kwargs): + captured.update(kwargs) + return _FakeResponse() + + monkeypatch.setenv("CURL_SSL_INSECURE", "1") + monkeypatch.setattr(client, "_get_session", lambda: _FakeSession()) + + result = client._request("POST", "/agentengine/api/v1/GetAgent", {"AgentId": "ar-demo"}) + + assert result["Data"]["Ok"] is True + assert captured["verify"] is False + + +def test_request_retries_inner_endpoint_for_inner_account(monkeypatch): + client = AgentEngineClient( + base_url="https://aicp.api.ksyun.com", + access_key="ak", + secret_key="sk", + region="cn-beijing-6", + ) + urls: list[str] = [] + + class _FakeResponse: + def __init__(self, status_code: int, text: str): + self.status_code = status_code + self.text = text + + def json(self): + return {"Code": 0, "Data": {"AgentId": "ar-inner"}} + + class _FakeSession: + def request(self, **kwargs): + urls.append(kwargs["url"]) + if len(urls) == 1: + return _FakeResponse( + 403, + ( + '{"RequestId":"req-inner","Error":{' + '"Code":"InnerAccountCanOnlyAccessThroughIntranet",' + '"Message":"The inner account can only access through intranet",' + '"Type":"Sender"}}' + ), + ) + return _FakeResponse(200, '{"Code":0,"Data":{"AgentId":"ar-inner"}}') + + monkeypatch.setattr(client, "_get_session", lambda: _FakeSession()) + + result = client._request("POST", "/agentengine/api/v1/CreateAgentProduct", {"Name": "demo"}) + + assert result["Data"]["AgentId"] == "ar-inner" + assert urls == [ + "https://aicp.api.ksyun.com/?Action=CreateAgentProduct&Version=2024-06-12", + "http://aicp.inner.api.ksyun.com/?Action=CreateAgentProduct&Version=2024-06-12", + ] + assert client.base_url == "http://aicp.inner.api.ksyun.com" + + +def test_auto_detected_public_endpoint_retries_inner_for_inner_account(monkeypatch): + monkeypatch.delenv("AGENTENGINE_SERVER_URL", raising=False) + monkeypatch.setattr(AgentEngineClient, "_is_connectable", staticmethod(lambda *_args, **_kwargs: False)) + client = AgentEngineClient( + access_key="ak", + secret_key="sk", + region="cn-beijing-6", + ) + urls: list[str] = [] + + class _FakeResponse: + def __init__(self, status_code: int, text: str): + self.status_code = status_code + self.text = text + + def json(self): + return {"Code": 0, "Data": {"AgentId": "ar-inner"}} + + class _FakeSession: + def request(self, **kwargs): + urls.append(kwargs["url"]) + if len(urls) == 1: + return _FakeResponse( + 403, + ( + '{"RequestId":"req-inner","Error":{' + '"Code":"InnerAccountCanOnlyAccessThroughIntranet",' + '"Message":"The inner account can only access through intranet",' + '"Type":"Sender"}}' + ), + ) + return _FakeResponse(200, '{"Code":0,"Data":{"AgentId":"ar-inner"}}') + + assert client.base_url == "https://aicp.api.ksyun.com" + monkeypatch.setattr(client, "_get_session", lambda: _FakeSession()) + + result = client._request("POST", "/agentengine/api/v1/CreateAgentProduct", {"Name": "demo"}) + + assert result["Data"]["AgentId"] == "ar-inner" + assert urls == [ + "https://aicp.api.ksyun.com/?Action=CreateAgentProduct&Version=2024-06-12", + "http://aicp.inner.api.ksyun.com/?Action=CreateAgentProduct&Version=2024-06-12", + ] + assert client.base_url == "http://aicp.inner.api.ksyun.com" + + +def test_action_raw_request_retries_inner_endpoint_for_inner_account(monkeypatch): + client = AgentEngineClient( + base_url="https://aicp.api.ksyun.com", + access_key="ak", + secret_key="sk", + region="cn-beijing-6", + ) + urls: list[str] = [] + + class _FakeResponse: + def __init__(self, status_code: int, text: str): + self.status_code = status_code + self.text = text + + class _FakeSession: + def request(self, **kwargs): + urls.append(kwargs["url"]) + if len(urls) == 1: + return _FakeResponse( + 403, + ( + '{"RequestId":"req-inner","Error":{' + '"Code":"InnerAccountCanOnlyAccessThroughIntranet",' + '"Message":"The inner account can only access through intranet",' + '"Type":"Sender"}}' + ), + ) + return _FakeResponse(200, '{"Code":0}') + + monkeypatch.setattr(client, "_get_session", lambda: _FakeSession()) + + response = client._action_raw_request("GET", "ExportWorkspaceZip") + + assert response.status_code == 200 + assert urls == [ + "https://aicp.api.ksyun.com/?Action=ExportWorkspaceZip&Version=2024-06-12", + "http://aicp.inner.api.ksyun.com/?Action=ExportWorkspaceZip&Version=2024-06-12", + ] + assert client.base_url == "http://aicp.inner.api.ksyun.com" + + +def test_permission_probe_auth_failure_is_quiet(monkeypatch, caplog): + client = _build_client() + monkeypatch.setenv("KSYUN_ACCOUNT_ID", "2000003485") + + def fake_request(_method: str, _path: str, _body: dict | None = None): + raise AgentEngineAPIError( + 400, + "Access Key is Missing", + details={ + "http_status": 400, + "remote_error_code": "MissingAccesskey", + "remote_error_message": "Access Key is Missing", + "request_id": "req-missing-ak", + }, + ) + + monkeypatch.setattr(client, "_request", fake_request) + + with caplog.at_level(logging.WARNING, logger="ksadk.api.client"): + client._maybe_precheck_permission("GetAgent", {"AgentId": "ar-demo"}) + + assert not [record for record in caplog.records if "Permission probe failed" in record.message] diff --git a/tests/test_cmd_build_upload_urls.py b/tests/test_cmd_build_upload_urls.py new file mode 100644 index 00000000..096b52d4 --- /dev/null +++ b/tests/test_cmd_build_upload_urls.py @@ -0,0 +1,103 @@ +import asyncio +import json +from pathlib import Path + +from ksadk.builders import BuildResult +from ksadk.cli import cmd_build + + +class _FakeNow: + def strftime(self, _fmt: str) -> str: + return "20260308154645" + + +class _FakeDatetime: + @staticmethod + def now(): + return _FakeNow() + + +class _FakeCodeBuilder: + last_config: dict | None = None + + def __init__(self, project_dir: Path, config: dict = None): + self.project_dir = Path(project_dir) + self.config = config or {} + self.__class__.last_config = self.config + + def build(self) -> BuildResult: + return BuildResult( + success=True, + artifact_path=self.project_dir / ".agentengine" / "code_build" / "demo.zip", + artifact_size=1234, + metadata={"agent_name": "demo_agent", "framework": "langgraph"}, + ) + + +class _FakeKS3Uploader: + last_object_key: str | None = None + + def __init__(self, region: str, bucket: str = None): + self.region = region + self.bucket = bucket + + async def upload(self, _file_path: Path, object_key: str): + self.__class__.last_object_key = object_key + return f"ks3://agentengine-test/{object_key}" + + def get_public_url_by_key(self, object_key: str) -> str: + return f"https://public.example.com/{object_key.lstrip('/')}" + + def get_internal_url_by_key(self, object_key: str) -> str: + return f"https://internal.example.com/{object_key.lstrip('/')}" + + +def test_build_push_prints_object_key_urls_and_never_prints_code_zip(tmp_path: Path, monkeypatch, capsys): + import ksadk.builders as builders_module + + monkeypatch.setattr(builders_module, "CodeBuilder", _FakeCodeBuilder) + monkeypatch.setattr(builders_module, "KS3Uploader", _FakeKS3Uploader) + monkeypatch.setattr(cmd_build, "datetime", _FakeDatetime) + + asyncio.run( + cmd_build._build_code( + agent_path=tmp_path, + push=True, + region="cn-beijing-6", + ks3_bucket="agentengine-test", + no_cache=True, + repackage=False, + ) + ) + + out = capsys.readouterr().out + expected_name = "code_20260308154645.zip" + expected_key = f"agents/demo_agent/{expected_name}" + + assert _FakeKS3Uploader.last_object_key == expected_key + assert expected_name in out + assert "/code.zip" not in out + assert "回滚请使用历史不可变包路径 (ks3_path)" in out + + metadata_path = tmp_path / ".agentengine" / "build-metadata.json" + metadata = json.loads(metadata_path.read_text(encoding="utf-8")) + assert metadata["metadata"]["ks3_path"].endswith(expected_key) + + +def test_build_code_passes_repackage_to_code_builder(tmp_path: Path, monkeypatch): + import ksadk.builders as builders_module + + monkeypatch.setattr(builders_module, "CodeBuilder", _FakeCodeBuilder) + + asyncio.run( + cmd_build._build_code( + agent_path=tmp_path, + push=False, + region="cn-beijing-6", + ks3_bucket=None, + no_cache=False, + repackage=True, + ) + ) + + assert _FakeCodeBuilder.last_config == {"no_cache": False, "repackage": True} diff --git a/tests/test_cmd_completion.py b/tests/test_cmd_completion.py new file mode 100644 index 00000000..6ce64957 --- /dev/null +++ b/tests/test_cmd_completion.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace +import sys + +from click.testing import CliRunner + +from ksadk.cli.cmd_completion import completion + + +def test_completion_bash_script_strips_click_typed_prefix(): + runner = CliRunner() + result = runner.invoke(completion, ["bash"]) + + assert result.exit_code == 0, result.output + assert 'line="${line#*,}"' in result.output + assert "_AGENTENGINE_COMPLETE=bash_complete" in result.output + + +def test_completion_install_rewrites_zshrc_to_source_after_compinit(tmp_path: Path, monkeypatch): + home = tmp_path + monkeypatch.setenv("HOME", str(home)) + + zshrc = home / ".zshrc" + zshrc.write_text( + """ +if command -v agentengine >/dev/null 2>&1; then + eval "$(_AGENTENGINE_COMPLETE=zsh_source agentengine)" +fi + +source /tmp/placeholder +source /Users/test/.agentengine-complete.zsh + +autoload -Uz compinit && compinit +""".lstrip(), + encoding="utf-8", + ) + + monkeypatch.setattr( + "subprocess.run", + lambda *args, **kwargs: SimpleNamespace(stdout="#compdef agentengine\n", returncode=0), + ) + + runner = CliRunner() + result = runner.invoke(completion, ["install", "--shell", "zsh"]) + + assert result.exit_code == 0, result.output + + expected_source = f'source "{home / ".agentengine-complete.zsh"}"' + updated = zshrc.read_text(encoding="utf-8") + + assert 'eval "$(_AGENTENGINE_COMPLETE=zsh_source agentengine)"' not in updated + assert updated.count(expected_source) == 1 + assert updated.rfind("compinit") < updated.rfind(expected_source) + + +def test_completion_install_prefers_bash_profile_on_macos(tmp_path: Path, monkeypatch): + home = tmp_path + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("SHELL", "/bin/bash") + monkeypatch.setattr(sys, "platform", "darwin", raising=False) + + bash_profile = home / ".bash_profile" + bash_profile.write_text("# existing profile\n", encoding="utf-8") + + monkeypatch.setattr( + "subprocess.run", + lambda *args, **kwargs: SimpleNamespace(stdout="_agentengine_completion() { :; }\n", returncode=0), + ) + + runner = CliRunner() + result = runner.invoke(completion, ["install", "--shell", "auto"]) + + assert result.exit_code == 0, result.output + updated = bash_profile.read_text(encoding="utf-8") + assert f'source "{home / ".agentengine-complete.bash"}"' in updated + + +def test_completion_install_auto_detects_git_bash_without_shell_env(tmp_path: Path, monkeypatch): + home = tmp_path + monkeypatch.setenv("HOME", str(home)) + monkeypatch.delenv("SHELL", raising=False) + monkeypatch.setenv("MSYSTEM", "MINGW64") + + monkeypatch.setattr( + "subprocess.run", + lambda *args, **kwargs: SimpleNamespace(stdout="_agentengine_completion() { :; }\n", returncode=0), + ) + + runner = CliRunner() + result = runner.invoke(completion, ["install", "--shell", "auto"]) + + assert result.exit_code == 0, result.output + bashrc = home / ".bashrc" + assert bashrc.exists() + assert f'source "{home / ".agentengine-complete.bash"}"' in bashrc.read_text(encoding="utf-8") + + +def test_completion_install_auto_detects_wsl_without_shell_env(tmp_path: Path, monkeypatch): + home = tmp_path + monkeypatch.setenv("HOME", str(home)) + monkeypatch.delenv("SHELL", raising=False) + monkeypatch.setenv("WSL_DISTRO_NAME", "Ubuntu") + + monkeypatch.setattr( + "subprocess.run", + lambda *args, **kwargs: SimpleNamespace(stdout="_agentengine_completion() { :; }\n", returncode=0), + ) + + runner = CliRunner() + result = runner.invoke(completion, ["install", "--shell", "auto"]) + + assert result.exit_code == 0, result.output + bashrc = home / ".bashrc" + assert bashrc.exists() + assert f'source "{home / ".agentengine-complete.bash"}"' in bashrc.read_text(encoding="utf-8") diff --git a/tests/test_cmd_create_from_agent.py b/tests/test_cmd_create_from_agent.py new file mode 100644 index 00000000..e760254c --- /dev/null +++ b/tests/test_cmd_create_from_agent.py @@ -0,0 +1,507 @@ +from pathlib import Path +import asyncio +import importlib +import py_compile +import sys + +from click.testing import CliRunner + +from ksadk.cli import cmd_create +from ksadk.cli.cmd_deploy import _resolve_artifact_type_input + + +def test_quick_start_command_lines_quote_posix_project_paths(): + lines = cmd_create._quick_start_command_lines( + "Demo Agent", + ["agentengine config"], + system="Linux", + ) + + assert lines == ["cd 'Demo Agent' && agentengine config"] + + +def test_quick_start_command_lines_support_windows_powershell_and_cmd(): + lines = cmd_create._quick_start_command_lines( + "Demo Agent", + ["agentengine config"], + system="Windows", + ) + + assert lines == [ + "PowerShell:", + "Set-Location -LiteralPath 'Demo Agent'", + "agentengine config", + "cmd.exe:", + 'cd /d "Demo Agent" && agentengine config', + ] + + +def test_find_entry_file_from_agentengine_yaml(tmp_path: Path): + src = tmp_path / "src" + src.mkdir(parents=True) + entry = src / "agentengine_adapter.py" + entry.write_text("root_agent = object()\n", encoding="utf-8") + (tmp_path / "agentengine.yaml").write_text( + "framework: langgraph\nentry_point: src/agentengine_adapter.py\nagent_variable: root_agent\n", + encoding="utf-8", + ) + + found = cmd_create._find_entry_file(tmp_path) + assert found is not None + found_file, found_var = found + assert found_file == entry + assert found_var == "root_agent" + + +def test_find_entry_file_ignores_config_when_agent_variable_missing(tmp_path: Path): + src = tmp_path / "src" / "demo" + src.mkdir(parents=True) + (src / "main.py").write_text( + "from fastapi import FastAPI\n" + "app = FastAPI()\n", + encoding="utf-8", + ) + entry = src / "agent.py" + entry.write_text( + "from google.adk.agents import Agent\n" + "root_agent = Agent(name='demo')\n", + encoding="utf-8", + ) + (tmp_path / "agentengine.yaml").write_text( + "framework: adk\nentry_point: src/demo/main.py\nagent_variable: root_agent\n", + encoding="utf-8", + ) + + found = cmd_create._find_entry_file(tmp_path) + + assert found is not None + found_file, found_var = found + assert found_file == entry + assert found_var == "root_agent" + + +def test_find_entry_file_prefers_valid_langgraph_json(tmp_path: Path): + src = tmp_path / "src" / "demo" + src.mkdir(parents=True) + entry = src / "graph.py" + entry.write_text( + "from deepagents import create_deep_agent\n" + "graph = create_deep_agent(model=None)\n", + encoding="utf-8", + ) + (tmp_path / "agentengine.yaml").write_text( + "framework: deepagents\nentry_point: src/demo/main.py\nagent_variable: root_agent\n", + encoding="utf-8", + ) + (tmp_path / "langgraph.json").write_text( + '{"graphs": {"agent": "./src/demo/graph.py:graph"}}\n', + encoding="utf-8", + ) + + found = cmd_create._find_entry_file(tmp_path) + + assert found is not None + found_file, found_var = found + assert found_file == entry + assert found_var == "graph" + + +def test_find_entry_file_ignores_langgraph_json_local_variable(tmp_path: Path): + src = tmp_path / "src" / "demo" + src.mkdir(parents=True) + graph_file = src / "graph.py" + graph_file.write_text( + "from deepagents import create_deep_agent\n" + "async def init_agent_resources():\n" + " graph = create_deep_agent(model=None)\n" + " return graph\n", + encoding="utf-8", + ) + adapter = src / "agentengine_adapter.py" + adapter.write_text("root_agent = object()\n", encoding="utf-8") + (tmp_path / "langgraph.json").write_text( + '{"graphs": {"agent": "./src/demo/graph.py:graph"}}\n', + encoding="utf-8", + ) + + found = cmd_create._find_entry_file(tmp_path) + + assert found is not None + found_file, found_var = found + assert found_file == adapter + assert found_var == "root_agent" + + +def test_find_entry_file_recursive_scan(tmp_path: Path): + entry = tmp_path / "src" / "nested" / "custom_entry.py" + entry.parent.mkdir(parents=True) + entry.write_text("root_agent = object()\n", encoding="utf-8") + + found = cmd_create._find_entry_file(tmp_path) + assert found is not None + found_file, found_var = found + assert found_file == entry + assert found_var == "root_agent" + + +def test_wrap_agent_directory_ignores_venv_and_exports_nested_entry(tmp_path: Path, monkeypatch): + source = tmp_path / "source" + entry = source / "src" / "agentengine_adapter.py" + entry.parent.mkdir(parents=True) + entry.write_text( + "def build_agent():\n" + " return {\"ok\": True}\n" + "root_agent = build_agent()\n", + encoding="utf-8", + ) + + # Should be excluded by copytree ignore rules + venv_file = source / ".venv-ae" / "lib" / "dummy.py" + venv_file.parent.mkdir(parents=True) + venv_file.write_text("x = 1\n", encoding="utf-8") + + monkeypatch.setattr("ksadk.configs.global_config.global_config_exists", lambda: False) + monkeypatch.setattr("ksadk.configs.global_config.get_env_from_global_config", lambda: {}) + + project_path = tmp_path / "wrapped-project" + cmd_create._wrap_agent_directory(source, str(project_path), "langgraph", entry, "root_agent") + + package_dir = project_path / "wrapped_project" + assert package_dir.exists() + assert not (package_dir / ".venv-ae").exists() + + init_content = (package_dir / "__init__.py").read_text(encoding="utf-8") + assert "from .src.agentengine_adapter import root_agent as root_agent" in init_content + + +def test_wrap_langgraph_messages_directory_does_not_generate_adapter(tmp_path: Path, monkeypatch): + source = tmp_path / "source" + entry = source / "agent.py" + source.mkdir() + entry.write_text( + "from langgraph.graph import MessagesState\n" + "def node(state):\n" + " return {\"messages\": []}\n" + "root_agent = object()\n", + encoding="utf-8", + ) + monkeypatch.setattr("ksadk.configs.global_config.global_config_exists", lambda: False) + monkeypatch.setattr("ksadk.configs.global_config.get_env_from_global_config", lambda: {}) + + project_path = tmp_path / "wrapped-messages" + cmd_create._wrap_agent_directory(source, str(project_path), "langgraph", entry, "root_agent") + + package_dir = project_path / "wrapped_messages" + assert not (package_dir / "agentengine_adapter.py").exists() + config_text = (project_path / "agentengine.yaml").read_text(encoding="utf-8-sig") + assert "entry_point: wrapped_messages/agent.py" in config_text + + +def test_wrap_langgraph_custom_state_directory_generates_adapter(tmp_path: Path, monkeypatch): + source = tmp_path / "source" + entry = source / "agent.py" + source.mkdir() + entry.write_text( + "from typing import TypedDict\n" + "class State(TypedDict):\n" + " query: str\n" + "def node(state: State):\n" + " return {\"answer\": state[\"query\"]}\n" + "workflow = 'StateGraph(State)'\n" + "root_agent = object()\n", + encoding="utf-8", + ) + monkeypatch.setattr("ksadk.configs.global_config.global_config_exists", lambda: False) + monkeypatch.setattr("ksadk.configs.global_config.get_env_from_global_config", lambda: {}) + + project_path = tmp_path / "wrapped-custom" + cmd_create._wrap_agent_directory(source, str(project_path), "langgraph", entry, "root_agent") + + package_dir = project_path / "wrapped_custom" + adapter_text = (package_dir / "agentengine_adapter.py").read_text(encoding="utf-8") + assert "from .agent import root_agent as root_agent" in adapter_text + assert '"query": payload.get("input", "")' in adapter_text + config_text = (project_path / "agentengine.yaml").read_text(encoding="utf-8-sig") + assert "entry_point: wrapped_custom/agentengine_adapter.py" in config_text + assert "agent_variable: root_agent" in config_text + + +def test_wrap_langgraph_custom_state_directory_detects_state_outside_entry(tmp_path: Path, monkeypatch): + source = tmp_path / "source" + source.mkdir() + (source / "agent.py").write_text("from .graph import root_agent\n", encoding="utf-8") + (source / "graph.py").write_text( + "from typing import TypedDict\n" + "from langgraph.graph import StateGraph\n" + "class State(TypedDict):\n" + " question: str\n" + "def node(state: State):\n" + " return {\"answer\": state[\"question\"]}\n" + "root_agent = object()\n", + encoding="utf-8", + ) + monkeypatch.setattr("ksadk.configs.global_config.global_config_exists", lambda: False) + monkeypatch.setattr("ksadk.configs.global_config.get_env_from_global_config", lambda: {}) + + project_path = tmp_path / "wrapped-split-custom" + cmd_create._wrap_agent_directory(source, str(project_path), "langgraph", source / "agent.py", "root_agent") + + package_dir = project_path / "wrapped_split_custom" + adapter_text = (package_dir / "agentengine_adapter.py").read_text(encoding="utf-8") + assert '"question": payload.get("input", "")' in adapter_text + config_text = (project_path / "agentengine.yaml").read_text(encoding="utf-8-sig") + assert "entry_point: wrapped_split_custom/agentengine_adapter.py" in config_text + + +def test_wrap_langgraph_ambiguous_file_generates_review_adapter(tmp_path: Path, monkeypatch): + source = tmp_path / "agent.py" + source.write_text( + "from langgraph.graph import StateGraph\n" + "root_agent = object()\n", + encoding="utf-8", + ) + monkeypatch.setattr("ksadk.configs.global_config.global_config_exists", lambda: False) + monkeypatch.setattr("ksadk.configs.global_config.get_env_from_global_config", lambda: {}) + + project_path = tmp_path / "wrapped-ambiguous" + cmd_create._wrap_agent_file(source, str(project_path), "langgraph", "root_agent") + + package_dir = project_path / "wrapped_ambiguous" + adapter_text = (package_dir / "agentengine_adapter.py").read_text(encoding="utf-8") + assert "TODO: Map AgentEngine's chat payload" in adapter_text + assert "return dict(payload)" in adapter_text + config_text = (project_path / "agentengine.yaml").read_text(encoding="utf-8-sig") + assert "entry_point: wrapped_ambiguous/agentengine_adapter.py" in config_text + + +def test_wrap_deepagents_service_directory_generates_runtime_adapter(tmp_path: Path, monkeypatch): + source = tmp_path / "source" + pkg = source / "src" / "bill_diagnosis" + pkg.mkdir(parents=True) + (pkg / "main.py").write_text( + "from fastapi import FastAPI\n" + "from .lifespan import lifespan\n" + "app = FastAPI(lifespan=lifespan)\n", + encoding="utf-8", + ) + (pkg / "graph.py").write_text( + "from deepagents import create_deep_agent\n" + "async def init_agent_resources():\n" + " return create_deep_agent(model=None), None, None, None\n", + encoding="utf-8", + ) + (pkg / "lifespan.py").write_text( + "class DeepAgentRunnable:\n" + " def __init__(self, agent, langfuse_mgr=None):\n" + " self.agent = agent\n" + " async def _ainvoke(self, input, config=None, **kwargs):\n" + " return {\"response\": input.get(\"message\", \"\")}\n", + encoding="utf-8", + ) + (source / "agentengine.yaml").write_text( + "framework: deepagents\nentry_point: src/bill_diagnosis/main.py\nagent_variable: root_agent\n", + encoding="utf-8", + ) + monkeypatch.setattr("ksadk.configs.global_config.global_config_exists", lambda: False) + monkeypatch.setattr("ksadk.configs.global_config.get_env_from_global_config", lambda: {}) + + project_path = tmp_path / "wrapped-service" + cmd_create._wrap_agent_directory(source, str(project_path), "deepagents", source / "src" / "bill_diagnosis" / "main.py", "root_agent") + + package_dir = project_path / "wrapped_service" + adapter_text = (package_dir / "agentengine_adapter.py").read_text(encoding="utf-8") + assert "class AgentEngineDeepAgentsServiceAdapter" in adapter_text + assert "async def ainvoke" in adapter_text + assert '"message": message' in adapter_text + assert 'INIT_MODULE = ".src.bill_diagnosis.graph"' in adapter_text + assert "importlib.import_module(INIT_MODULE, __package__)" in adapter_text + config_text = (project_path / "agentengine.yaml").read_text(encoding="utf-8-sig") + assert "entry_point: wrapped_service/agentengine_adapter.py" in config_text + assert "agent_variable: root_agent" in config_text + + +def test_wrap_deepagents_service_directory_ignores_langgraph_json_local_graph(tmp_path: Path, monkeypatch): + source = tmp_path / "source" + pkg = source / "src" / "bill_diagnosis" + pkg.mkdir(parents=True) + graph_file = pkg / "graph.py" + graph_file.write_text( + "from deepagents import create_deep_agent\n" + "async def init_agent_resources():\n" + " graph = create_deep_agent(model=None)\n" + " return graph, None, None, None\n", + encoding="utf-8", + ) + (pkg / "main.py").write_text( + "from fastapi import FastAPI\n" + "from .lifespan import lifespan\n" + "app = FastAPI(lifespan=lifespan)\n", + encoding="utf-8", + ) + (pkg / "lifespan.py").write_text( + "class DeepAgentRunnable:\n" + " async def _ainvoke(self, input, config=None, **kwargs):\n" + " return {\"response\": input.get(\"message\", \"\")}\n", + encoding="utf-8", + ) + (source / "langgraph.json").write_text( + '{"graphs": {"agent": "./src/bill_diagnosis/graph.py:graph"}}\n', + encoding="utf-8", + ) + (source / "agentengine.yaml").write_text( + "framework: deepagents\n" + "entry_point: src/bill_diagnosis/graph.py\n" + "agent_variable: graph\n", + encoding="utf-8", + ) + monkeypatch.setattr("ksadk.configs.global_config.global_config_exists", lambda: False) + monkeypatch.setattr("ksadk.configs.global_config.get_env_from_global_config", lambda: {}) + + found = cmd_create._find_entry_file(source) + assert found is not None + found_file, found_var = found + assert found_file == graph_file + assert found_var == "root_agent" + + project_path = tmp_path / "wrapped-service-local-graph" + cmd_create._wrap_agent_directory(source, str(project_path), "deepagents", found_file, found_var) + + package_dir = project_path / "wrapped_service_local_graph" + config_text = (project_path / "agentengine.yaml").read_text(encoding="utf-8-sig") + assert "entry_point: wrapped_service_local_graph/agentengine_adapter.py" in config_text + assert "agent_variable: root_agent" in config_text + adapter_text = (package_dir / "agentengine_adapter.py").read_text(encoding="utf-8") + assert 'INIT_MODULE = ".src.bill_diagnosis.graph"' in adapter_text + assert not (package_dir / "ksadk_agentengine_adapter.py").exists() + + +def test_generated_deepagents_service_adapter_invokes_fake_service(tmp_path: Path, monkeypatch): + source = tmp_path / "source" + pkg = source / "src" / "bill_diagnosis" + pkg.mkdir(parents=True) + (pkg / "main.py").write_text( + "from fastapi import FastAPI\n" + "from .lifespan import lifespan\n" + "app = FastAPI(lifespan=lifespan)\n", + encoding="utf-8", + ) + (pkg / "graph.py").write_text( + "# deepagents create_deep_agent(\n" + "class FakeGraph:\n" + " async def ainvoke(self, payload, **kwargs):\n" + " return {\"messages\": [{\"content\": payload.get(\"message\", \"\")}]}\n" + "async def init_agent_resources():\n" + " return FakeGraph(), None, None, None\n", + encoding="utf-8", + ) + (pkg / "lifespan.py").write_text( + "class DeepAgentRunnable:\n" + " def __init__(self, agent, langfuse_mgr=None):\n" + " self.agent = agent\n" + " async def _ainvoke(self, input, config=None, **kwargs):\n" + " return {\"response\": \"service:\" + input.get(\"message\", \"\")}\n", + encoding="utf-8", + ) + monkeypatch.setattr("ksadk.configs.global_config.global_config_exists", lambda: False) + monkeypatch.setattr("ksadk.configs.global_config.get_env_from_global_config", lambda: {}) + + project_path = tmp_path / "wrapped-service" + cmd_create._wrap_agent_directory(source, str(project_path), "deepagents", source / "src" / "bill_diagnosis" / "main.py", "root_agent") + + sys.path.insert(0, str(project_path)) + try: + module = importlib.import_module("wrapped_service.agentengine_adapter") + result = asyncio.run(module.root_agent.ainvoke({"input": "hello", "session_id": "s1"})) + finally: + sys.path.remove(str(project_path)) + + assert result["output"] == "service:hello" + + +def test_create_openclaw_only_generates_env_file(tmp_path: Path, monkeypatch): + runner = CliRunner() + monkeypatch.chdir(tmp_path) + monkeypatch.setattr("ksadk.configs.global_config.global_config_exists", lambda: True) + monkeypatch.setattr( + "ksadk.configs.global_config.get_env_from_global_config", + lambda: { + "OPENAI_API_KEY": "sk-openclaw", + "OPENAI_BASE_URL": "https://model.example.com/v1", + "OPENAI_MODEL_NAME": "glm-5.1", + "LANGFUSE_PUBLIC_KEY": "pk-should-not-exist", + "LANGFUSE_SECRET_KEY": "sk-should-not-exist", + "LANGFUSE_BASE_URL": "https://langfuse.example.com", + "KSYUN_ACCESS_KEY": "ak-demo", + "KSYUN_SECRET_KEY": "sk-demo", + "KSYUN_REGION": "cn-beijing-6", + "KSYUN_ACCOUNT_ID": "1234567890", + }, + ) + + result = runner.invoke(cmd_create.create, ["demo-openclaw", "-f", "openclaw"]) + + assert result.exit_code == 0, result.output + + project_dir = tmp_path / "demo-openclaw" + assert project_dir.exists() + assert sorted(path.name for path in project_dir.iterdir()) == [".env"] + + env_text = (project_dir / ".env").read_text(encoding="utf-8-sig") + assert "KSYUN_ACCESS_KEY=ak-demo" in env_text + assert "KSYUN_SECRET_KEY=sk-demo" in env_text + assert "KSYUN_REGION=cn-beijing-6" in env_text + assert "KSYUN_ACCOUNT_ID=1234567890" in env_text + assert "OPENAI_API_KEY=sk-openclaw" in env_text + assert "OPENAI_BASE_URL=https://model.example.com/v1" in env_text + assert "OPENAI_MODEL_NAME=glm-5.1" in env_text + assert "LANGFUSE_" not in env_text + + +def test_create_hermes_generates_container_first_template(tmp_path: Path, monkeypatch): + runner = CliRunner() + monkeypatch.chdir(tmp_path) + monkeypatch.setattr("ksadk.configs.global_config.global_config_exists", lambda: True) + monkeypatch.setattr( + "ksadk.configs.global_config.get_env_from_global_config", + lambda: { + "OPENAI_API_KEY": "sk-hermes", + "OPENAI_BASE_URL": "https://model.example.com/v1", + "OPENAI_MODEL_NAME": "glm-hermes", + "KSYUN_ACCESS_KEY": "ak-demo", + "KSYUN_SECRET_KEY": "sk-demo", + "KSYUN_REGION": "cn-beijing-6", + }, + ) + + result = runner.invoke(cmd_create.create, ["demo-hermes", "-f", "hermes"]) + + assert result.exit_code == 0, result.output + project_dir = tmp_path / "demo-hermes" + assert (project_dir / ".env").exists() + assert (project_dir / ".env.example").exists() + assert (project_dir / "agentengine.yaml").exists() + assert (project_dir / "Dockerfile").exists() + assert (project_dir / "entrypoint.sh").exists() + assert (project_dir / "runtime" / "app.py").exists() + assert (project_dir / "README.md").exists() + assert not (project_dir / "demo_hermes" / "agent.py").exists() + + config_text = (project_dir / "agentengine.yaml").read_text(encoding="utf-8-sig") + assert "framework: hermes" in config_text + assert "artifact_type: Container" in config_text + assert "ui_profile: hermes" in config_text + + readme_text = (project_dir / "README.md").read_text(encoding="utf-8-sig") + assert "agentengine hermes deploy" in readme_text + assert "agentengine launch . --artifact-type Container" not in readme_text + + env_text = (project_dir / ".env").read_text(encoding="utf-8-sig") + assert "OPENAI_API_KEY=sk-hermes" in env_text + assert "OPENAI_BASE_URL=https://model.example.com/v1" in env_text + assert "OPENAI_MODEL_NAME=glm-hermes" in env_text + py_compile.compile(str(project_dir / "runtime" / "app.py"), doraise=True) + + +def test_deploy_artifact_type_defaults_to_config_for_hermes_template(): + assert _resolve_artifact_type_input({"artifact_type": "Container"}, None) == "Container" + assert _resolve_artifact_type_input({"artifact_type": "Container"}, "Code") == "Code" diff --git a/tests/test_cmd_dashboard_fallback.py b/tests/test_cmd_dashboard_fallback.py new file mode 100644 index 00000000..3c6f5dc6 --- /dev/null +++ b/tests/test_cmd_dashboard_fallback.py @@ -0,0 +1,667 @@ +import json +from pathlib import Path + +from click.testing import CliRunner + +from ksadk.cli import cmd_dashboard + + +async def _fake_resolve_agent_detail(*_args, **_kwargs): + return ( + { + "agent_id": "ar-test", + "name": "demo-agent", + "framework": "langgraph", + "endpoint": "http://demo.example.com", + }, + type("Ref", (), {"source": "cli", "source_text": "CLI", "value": "ar-test"})(), + False, + ) + + +async def _fake_create_access_link(*_args, **_kwargs): + return { + "link_id": "lnk-1", + "access_url": "http://demo.example.com/s/lnk-1", + "expires_at": "2026-03-09T00:00:00Z", + } + + +def test_dashboard_uses_access_link_by_default(monkeypatch): + opened = {} + captured = {} + runner = CliRunner() + + monkeypatch.setattr(cmd_dashboard, "load_state", lambda _cwd: {}) + monkeypatch.setattr(cmd_dashboard, "_resolve_agent_detail", _fake_resolve_agent_detail) + async def _fake_create(*_args, **kwargs): + captured.update(kwargs) + return await _fake_create_access_link() + + monkeypatch.setattr(cmd_dashboard, "_create_dashboard_access_link", _fake_create) + monkeypatch.setattr(cmd_dashboard.webbrowser, "open", lambda url: opened.setdefault("url", url)) + + result = runner.invoke(cmd_dashboard.dashboard, ["ar-test"]) + assert result.exit_code == 0, result.output + assert opened == {} + assert captured["path"] is None + assert "http://demo.example.com/s/lnk-1" in result.output + + +def test_dashboard_open_is_canonical_command(monkeypatch): + opened = {} + runner = CliRunner() + + monkeypatch.setattr(cmd_dashboard, "load_state", lambda _cwd: {}) + monkeypatch.setattr(cmd_dashboard, "_resolve_agent_detail", _fake_resolve_agent_detail) + monkeypatch.setattr(cmd_dashboard, "_create_dashboard_access_link", _fake_create_access_link) + monkeypatch.setattr(cmd_dashboard.webbrowser, "open", lambda url: opened.setdefault("url", url)) + + result = runner.invoke(cmd_dashboard.dashboard, ["open", "ar-test"]) + assert result.exit_code == 0, result.output + assert opened == {} + assert "http://demo.example.com/s/lnk-1" in result.output + + +def test_dashboard_open_uses_state_region_when_region_is_not_explicit(tmp_path: Path, monkeypatch): + runner = CliRunner() + captured = {} + + (tmp_path / ".agentengine.state").write_text( + "agent_id: ar-test\n" + "region: pre-online\n", + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("KSYUN_REGION", raising=False) + monkeypatch.setattr( + cmd_dashboard, + "load_state", + lambda _cwd: {"agent_id": "ar-test", "region": "pre-online"}, + ) + + async def _fake_resolve(region, primary_ref, fallback_ref): + captured["region"] = region + return await _fake_resolve_agent_detail(region, primary_ref, fallback_ref) + + monkeypatch.setattr(cmd_dashboard, "_resolve_agent_detail", _fake_resolve) + monkeypatch.setattr(cmd_dashboard, "_create_dashboard_access_link", _fake_create_access_link) + monkeypatch.setattr(cmd_dashboard.webbrowser, "open", lambda _url: None) + + result = runner.invoke(cmd_dashboard.dashboard, ["open"]) + + assert result.exit_code == 0, result.output + assert captured["region"] == "pre-online" + + +def test_dashboard_open_explicit_region_overrides_state_region(tmp_path: Path, monkeypatch): + runner = CliRunner() + captured = {} + + (tmp_path / ".agentengine.state").write_text( + "agent_id: ar-test\n" + "region: pre-online\n", + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("KSYUN_REGION", raising=False) + monkeypatch.setattr( + cmd_dashboard, + "load_state", + lambda _cwd: {"agent_id": "ar-test", "region": "pre-online"}, + ) + + async def _fake_resolve(region, primary_ref, fallback_ref): + captured["region"] = region + return await _fake_resolve_agent_detail(region, primary_ref, fallback_ref) + + monkeypatch.setattr(cmd_dashboard, "_resolve_agent_detail", _fake_resolve) + monkeypatch.setattr(cmd_dashboard, "_create_dashboard_access_link", _fake_create_access_link) + monkeypatch.setattr(cmd_dashboard.webbrowser, "open", lambda _url: None) + + result = runner.invoke(cmd_dashboard.dashboard, ["open", "--region", "cn-beijing-6"]) + + assert result.exit_code == 0, result.output + assert captured["region"] == "cn-beijing-6" + + +def test_dashboard_open_prefers_state_region_over_global_config_injected_region(tmp_path: Path, monkeypatch): + runner = CliRunner() + captured = {} + + (tmp_path / ".agentengine.state").write_text( + "agent_id: ar-test\n" + "region: pre-online\n", + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("KSYUN_REGION", "cn-beijing-6") + monkeypatch.setenv("KSADK_GLOBAL_CONFIG_ENV_KEYS", "KSYUN_REGION") + monkeypatch.setattr( + cmd_dashboard, + "load_state", + lambda _cwd: {"agent_id": "ar-test", "region": "pre-online"}, + ) + + async def _fake_resolve(region, primary_ref, fallback_ref): + captured["region"] = region + return await _fake_resolve_agent_detail(region, primary_ref, fallback_ref) + + monkeypatch.setattr(cmd_dashboard, "_resolve_agent_detail", _fake_resolve) + monkeypatch.setattr(cmd_dashboard, "_create_dashboard_access_link", _fake_create_access_link) + monkeypatch.setattr(cmd_dashboard.webbrowser, "open", lambda _url: None) + + result = runner.invoke(cmd_dashboard.dashboard, ["open"]) + + assert result.exit_code == 0, result.output + assert captured["region"] == "pre-online" + + +def test_dashboard_open_env_region_overrides_state_region(tmp_path: Path, monkeypatch): + runner = CliRunner() + captured = {} + + (tmp_path / ".agentengine.state").write_text( + "agent_id: ar-test\n" + "region: pre-online\n", + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("KSYUN_REGION", "cn-shanghai-3") + monkeypatch.delenv("KSADK_GLOBAL_CONFIG_ENV_KEYS", raising=False) + monkeypatch.setattr( + cmd_dashboard, + "load_state", + lambda _cwd: {"agent_id": "ar-test", "region": "pre-online"}, + ) + + async def _fake_resolve(region, primary_ref, fallback_ref): + captured["region"] = region + return await _fake_resolve_agent_detail(region, primary_ref, fallback_ref) + + monkeypatch.setattr(cmd_dashboard, "_resolve_agent_detail", _fake_resolve) + monkeypatch.setattr(cmd_dashboard, "_create_dashboard_access_link", _fake_create_access_link) + monkeypatch.setattr(cmd_dashboard.webbrowser, "open", lambda _url: None) + + result = runner.invoke(cmd_dashboard.dashboard, ["open"]) + + assert result.exit_code == 0, result.output + assert captured["region"] == "cn-shanghai-3" + + +def test_dashboard_open_rejects_path_with_embedded_option(monkeypatch): + runner = CliRunner() + + async def _unexpected_resolve(*_args, **_kwargs): + raise AssertionError("dashboard open should reject malformed --path before remote lookup") + + monkeypatch.setattr(cmd_dashboard, "_resolve_agent_detail", _unexpected_resolve) + + result = runner.invoke( + cmd_dashboard.dashboard, + ["open", "ar-test", "--path", "/chat--share", "--expires-seconds", "3600", "--no-open"], + ) + + assert result.exit_code != 0 + assert "--path 的值疑似拼入了 `--share`" in result.output + assert "agentengine dashboard open --path /chat --share" in result.output + + +def test_dashboard_remote_open_uses_hosted_chat_path_even_with_custom_ui_state(monkeypatch): + runner = CliRunner() + captured = {} + + monkeypatch.setattr( + cmd_dashboard, + "load_state", + lambda _cwd: { + "ui_profile": "custom", + "ui_path": "/custom-chat", + "ui_url": "https://ui.example.com/custom-chat/", + }, + ) + monkeypatch.setattr(cmd_dashboard, "_resolve_agent_detail", _fake_resolve_agent_detail) + + async def _fake_create(*_args, **kwargs): + captured.update(kwargs) + return await _fake_create_access_link() + + monkeypatch.setattr(cmd_dashboard, "_create_dashboard_access_link", _fake_create) + monkeypatch.setattr(cmd_dashboard.webbrowser, "open", lambda _url: None) + + result = runner.invoke(cmd_dashboard.dashboard, ["open", "ar-test"]) + + assert result.exit_code == 0, result.output + assert captured["path"] is None + + +def test_dashboard_open_resolves_openclaw_state_from_cwd(tmp_path: Path, monkeypatch): + runner = CliRunner() + opened = {} + captured = {} + + state_path = tmp_path / ".agentengine.state" + state_path.write_text( + "agent_id: ar-openclaw-1\n" + "name: demo-openclaw\n" + "type: openclaw\n", + encoding="utf-8", + ) + + monkeypatch.chdir(tmp_path) + monkeypatch.setattr( + cmd_dashboard, + "load_state", + lambda _cwd: {"agent_id": "ar-openclaw-1", "name": "demo-openclaw", "type": "openclaw"}, + ) + + async def _fake_resolve(_region, primary_ref, fallback_ref): + assert primary_ref.value == "ar-openclaw-1" + assert primary_ref.source == "state.agent_id" + assert fallback_ref is None + return ( + { + "agent_id": "ar-openclaw-1", + "name": "demo-openclaw", + "framework": "openclaw", + "endpoint": "http://demo.example.com", + }, + primary_ref, + False, + ) + + class _FakeGateway: + async def build_access_info(self, *, path="/", expires_seconds=None, link_type="private", force_new=False): + captured.update( + { + "path": path, + "expires_seconds": expires_seconds, + "link_type": link_type, + "force_new": force_new, + } + ) + return type( + "Info", + (), + { + "access_url": "http://demo.example.com/s/gateway-1", + "ws_url": "ws://demo.example.com/", + "link_id": "gateway-1", + "expires_at": None, + }, + )() + + async def close(self): + return None + + monkeypatch.setattr(cmd_dashboard, "_resolve_agent_detail", _fake_resolve) + monkeypatch.setattr(cmd_dashboard, "_build_openclaw_gateway_client", lambda _region, _detail: _FakeGateway()) + monkeypatch.setattr(cmd_dashboard.webbrowser, "open", lambda url: opened.setdefault("url", url)) + + result = runner.invoke(cmd_dashboard.dashboard, ["open"]) + + assert result.exit_code == 0, result.output + assert opened == {} + assert captured == {"path": None, "expires_seconds": None, "link_type": "private", "force_new": False} + assert "未显式指定 Agent,使用 .agentengine.state 的 agent_id: ar-openclaw-1" in result.output + assert "http://demo.example.com/s/gateway-1" in result.output + + +def test_dashboard_open_omits_path_for_hermes_generic_access_link(monkeypatch): + runner = CliRunner() + captured = {} + + async def _fake_resolve(_region, primary_ref, fallback_ref): + return ( + { + "agent_id": "ar-hermes-1", + "name": "demo-hermes", + "framework": "hermes", + "endpoint": "http://hermes.example.com", + }, + primary_ref, + False, + ) + + async def _fake_create(*_args, **kwargs): + captured.update(kwargs) + return await _fake_create_access_link() + + monkeypatch.setattr(cmd_dashboard, "load_state", lambda _cwd: {}) + monkeypatch.setattr(cmd_dashboard, "_resolve_agent_detail", _fake_resolve) + monkeypatch.setattr(cmd_dashboard, "_create_dashboard_access_link", _fake_create) + monkeypatch.setattr( + cmd_dashboard, + "_create_openclaw_gateway_access_link", + lambda **_kwargs: (_ for _ in ()).throw(AssertionError("Hermes must not use OpenClaw gateway link")), + ) + monkeypatch.setattr(cmd_dashboard.webbrowser, "open", lambda _url: None) + + result = runner.invoke(cmd_dashboard.dashboard, ["open", "ar-hermes-1"]) + + assert result.exit_code == 0, result.output + assert captured["path"] is None + assert captured["expires_seconds"] is None + + +def test_dashboard_open_force_new_passes_through(monkeypatch): + runner = CliRunner() + captured = {} + + async def _fake_resolve(_region, primary_ref, fallback_ref): + return ( + { + "agent_id": "ar-hermes-1", + "name": "demo-hermes", + "framework": "hermes", + "endpoint": "http://hermes.example.com", + }, + primary_ref, + False, + ) + + async def _fake_create(*_args, **kwargs): + captured.update(kwargs) + return await _fake_create_access_link() + + monkeypatch.setattr(cmd_dashboard, "load_state", lambda _cwd: {}) + monkeypatch.setattr(cmd_dashboard, "_resolve_agent_detail", _fake_resolve) + monkeypatch.setattr(cmd_dashboard, "_create_dashboard_access_link", _fake_create) + monkeypatch.setattr(cmd_dashboard.webbrowser, "open", lambda _url: None) + + result = runner.invoke( + cmd_dashboard.dashboard, + ["open", "ar-hermes-1", "--path", "/", "--share", "--expires-seconds", "86400", "--force-new", "--no-open"], + ) + + assert result.exit_code == 0, result.output + assert captured["path"] == "/" + assert captured["link_type"] == "share" + assert captured["expires_seconds"] == 86400 + assert captured["force_new"] is True + + +def test_dashboard_open_routes_openclaw_to_gateway_short_link(tmp_path: Path, monkeypatch): + runner = CliRunner() + opened = {} + captured = {} + + (tmp_path / ".agentengine.state").write_text( + "agent_id: ar-openclaw-1\n" + "name: demo-openclaw\n" + "type: openclaw\n", + encoding="utf-8", + ) + + monkeypatch.chdir(tmp_path) + monkeypatch.setattr( + cmd_dashboard, + "load_state", + lambda _cwd: {"agent_id": "ar-openclaw-1", "name": "demo-openclaw", "type": "openclaw"}, + ) + + async def _fake_resolve(_region, primary_ref, fallback_ref): + return ( + { + "agent_id": "ar-openclaw-1", + "name": "demo-openclaw", + "framework": "-", + "endpoint": "http://demo.example.com", + }, + primary_ref, + False, + ) + + class _FakeGateway: + async def build_access_info(self, *, path="/", expires_seconds=None, link_type="private", force_new=False): + captured.update( + { + "path": path, + "expires_seconds": expires_seconds, + "link_type": link_type, + "force_new": force_new, + } + ) + return type( + "Info", + (), + { + "access_url": "http://demo.example.com/s/gateway-1", + "ws_url": "ws://demo.example.com/", + "link_id": "gateway-1", + "expires_at": None, + }, + )() + + async def close(self): + return None + + monkeypatch.setattr(cmd_dashboard, "_resolve_agent_detail", _fake_resolve) + monkeypatch.setattr(cmd_dashboard, "_build_openclaw_gateway_client", lambda _region, _detail: _FakeGateway()) + monkeypatch.setattr( + cmd_dashboard, + "_create_dashboard_access_link", + lambda **_kwargs: (_ for _ in ()).throw(AssertionError("should not create generic dashboard link")), + ) + monkeypatch.setattr(cmd_dashboard.webbrowser, "open", lambda url: opened.setdefault("url", url)) + + result = runner.invoke( + cmd_dashboard.dashboard, + ["--share", "--expires-seconds", "0", "--no-open"], + ) + + assert result.exit_code == 0, result.output + assert opened == {} + assert captured == {"path": None, "expires_seconds": 0, "link_type": "share", "force_new": False} + assert "http://demo.example.com/s/gateway-1" in result.output + + +def test_dashboard_open_passes_custom_path_to_openclaw_gateway_link(tmp_path: Path, monkeypatch): + runner = CliRunner() + captured = {} + + (tmp_path / ".agentengine.state").write_text( + "agent_id: ar-openclaw-1\n" + "name: demo-openclaw\n" + "type: openclaw\n", + encoding="utf-8", + ) + + monkeypatch.chdir(tmp_path) + monkeypatch.setattr( + cmd_dashboard, + "load_state", + lambda _cwd: {"agent_id": "ar-openclaw-1", "name": "demo-openclaw", "type": "openclaw"}, + ) + + async def _fake_resolve(_region, primary_ref, fallback_ref): + return ( + { + "agent_id": "ar-openclaw-1", + "name": "demo-openclaw", + "framework": "openclaw", + "endpoint": "http://demo.example.com", + }, + primary_ref, + False, + ) + + class _FakeGateway: + async def build_access_info(self, *, path="/", expires_seconds=None, link_type="private", force_new=False): + captured.update( + { + "path": path, + "expires_seconds": expires_seconds, + "link_type": link_type, + "force_new": force_new, + } + ) + return type( + "Info", + (), + { + "access_url": "http://demo.example.com/s/gateway-chat", + "ws_url": "ws://demo.example.com/", + "link_id": "gateway-chat", + "expires_at": None, + }, + )() + + async def close(self): + return None + + monkeypatch.setattr(cmd_dashboard, "_resolve_agent_detail", _fake_resolve) + monkeypatch.setattr(cmd_dashboard, "_build_openclaw_gateway_client", lambda _region, _detail: _FakeGateway()) + + result = runner.invoke( + cmd_dashboard.dashboard, + ["open", "--share", "--path", "/chat", "--expires-seconds", "0", "--no-open"], + ) + + assert result.exit_code == 0, result.output + assert captured == {"path": "/chat", "expires_seconds": 0, "link_type": "share", "force_new": False} + + +def test_dashboard_open_passes_force_new_to_openclaw_gateway_link(tmp_path: Path, monkeypatch): + runner = CliRunner() + captured = {} + + (tmp_path / ".agentengine.state").write_text( + "agent_id: ar-openclaw-1\n" + "name: demo-openclaw\n" + "type: openclaw\n", + encoding="utf-8", + ) + + monkeypatch.chdir(tmp_path) + monkeypatch.setattr( + cmd_dashboard, + "load_state", + lambda _cwd: {"agent_id": "ar-openclaw-1", "name": "demo-openclaw", "type": "openclaw"}, + ) + + async def _fake_resolve(_region, primary_ref, fallback_ref): + return ( + { + "agent_id": "ar-openclaw-1", + "name": "demo-openclaw", + "framework": "openclaw", + "endpoint": "http://demo.example.com", + }, + primary_ref, + False, + ) + + class _FakeGateway: + async def build_access_info(self, *, path="/", expires_seconds=None, link_type="private", force_new=False): + captured.update( + { + "path": path, + "expires_seconds": expires_seconds, + "link_type": link_type, + "force_new": force_new, + } + ) + return type( + "Info", + (), + { + "access_url": "http://demo.example.com/s/gateway-2", + "ws_url": "ws://demo.example.com/", + "link_id": "gateway-2", + "expires_at": None, + }, + )() + + async def close(self): + return None + + monkeypatch.setattr(cmd_dashboard, "_resolve_agent_detail", _fake_resolve) + monkeypatch.setattr(cmd_dashboard, "_build_openclaw_gateway_client", lambda _region, _detail: _FakeGateway()) + + result = runner.invoke( + cmd_dashboard.dashboard, + ["open", "--share", "--expires-seconds", "0", "--force-new", "--no-open"], + ) + + assert result.exit_code == 0, result.output + assert captured == {"path": None, "expires_seconds": 0, "link_type": "share", "force_new": True} + assert "http://demo.example.com/s/gateway-2" in result.output + + +def test_dashboard_supports_share_subcommand(monkeypatch): + runner = CliRunner() + + async def _fake_list(*_args, **_kwargs): + return {"total": 1, "links": [{"link_id": "abc123", "link_type": "share", "status": "active", "path": "/", "expires_at": None, "created_at": "2026-03-09T00:00:00Z"}]} + + monkeypatch.setattr(cmd_dashboard, "load_state", lambda _cwd: {}) + monkeypatch.setattr(cmd_dashboard, "_resolve_agent_detail", _fake_resolve_agent_detail) + monkeypatch.setattr(cmd_dashboard, "_list_dashboard_access_links", _fake_list) + + result = runner.invoke(cmd_dashboard.dashboard, ["share", "list", "ar-test"]) + assert result.exit_code == 0, result.output + assert "abc123" in result.output + + +def test_dashboard_list_is_no_longer_ambiguous(): + runner = CliRunner() + + result = runner.invoke(cmd_dashboard.dashboard, ["list"]) + + assert result.exit_code != 0 + assert "dashboard open" in result.output + assert "dashboard share list" in result.output + + +def test_dashboard_help_shows_canonical_subcommands_only(): + runner = CliRunner() + + result = runner.invoke(cmd_dashboard.dashboard, ["--help"]) + + assert result.exit_code == 0, result.output + assert "open" in result.output + assert "share" in result.output + assert "--agent" not in result.output + + +def test_dashboard_direct_invocation_resets_output_mode_after_json(monkeypatch): + runner = CliRunner() + + monkeypatch.setattr(cmd_dashboard, "load_state", lambda _cwd: {}) + monkeypatch.setattr(cmd_dashboard, "_resolve_agent_detail", _fake_resolve_agent_detail) + monkeypatch.setattr(cmd_dashboard, "_create_dashboard_access_link", _fake_create_access_link) + monkeypatch.setattr(cmd_dashboard.webbrowser, "open", lambda _url: None) + + json_result = runner.invoke(cmd_dashboard.dashboard, ["open", "ar-test", "--output", "json"]) + assert json_result.exit_code == 0, json_result.output + assert json.loads(json_result.output)["ok"] is True + + pretty_result = runner.invoke(cmd_dashboard.dashboard, ["ar-test"]) + assert pretty_result.exit_code == 0, pretty_result.output + assert not pretty_result.output.lstrip().startswith("{") + assert "Dashboard 打开结果" in pretty_result.output + + +def test_dashboard_open_json_uses_server_returned_link_type(monkeypatch): + runner = CliRunner() + + monkeypatch.setattr(cmd_dashboard, "load_state", lambda _cwd: {}) + monkeypatch.setattr(cmd_dashboard, "_resolve_agent_detail", _fake_resolve_agent_detail) + + async def _fake_create_access_link_with_private_type(*_args, **_kwargs): + return { + "link_id": "lnk-1", + "link_type": "private", + "access_url": "http://demo.example.com/s/lnk-1", + "expires_at": "2026-03-09T00:00:00Z", + } + + monkeypatch.setattr(cmd_dashboard, "_create_dashboard_access_link", _fake_create_access_link_with_private_type) + monkeypatch.setattr(cmd_dashboard.webbrowser, "open", lambda _url: None) + + result = runner.invoke(cmd_dashboard.dashboard, ["open", "ar-test", "--share", "--output", "json"]) + + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert payload["result"]["type"] == "private" diff --git a/tests/test_cmd_deploy_no_cache.py b/tests/test_cmd_deploy_no_cache.py new file mode 100644 index 00000000..09ec31be --- /dev/null +++ b/tests/test_cmd_deploy_no_cache.py @@ -0,0 +1,521 @@ +import asyncio +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from ksadk.cli import cmd_deploy +from ksadk.cli.error_utils import CLIError +from ksadk.deployment.base import DeployResult, DeployStatus, PackageInfo + + +class _FakeDetectionType: + value = "langgraph" + + +class _FakeDetectionResult: + type = _FakeDetectionType() + name = "langgraph" + entry_point = "agent.py" + + +class _FakeProvider: + def __init__(self): + self.calls = [] + self.package_metadata_file_exists = None + self.last_target = None + + async def validate_config(self, _target): + self.last_target = _target + self.calls.append("validate") + return True, "" + + async def package(self, project_dir, _detection_result, _config): + self.calls.append("package") + metadata_file = Path(project_dir) / ".agentengine" / "build-metadata.json" + self.package_metadata_file_exists = metadata_file.exists() + return PackageInfo( + name="demo-agent", + framework="langgraph", + build_dir=str(Path(project_dir) / ".agentengine" / "build"), + project_dir=str(project_dir), + metadata={}, + ) + + async def build(self, package_info, _target): + self.calls.append("build") + package_info.metadata["ks3_path"] = "ks3://bucket/agents/demo-agent/code_20260320170000.zip" + return package_info + + async def deploy(self, package_info, _target): + self.calls.append("deploy") + assert package_info.metadata.get("ks3_path") + return DeployResult( + status=DeployStatus.DEPLOYING, + agent_id="ar-demo", + agent_name="demo-agent", + endpoint="http://demo-endpoint", + message="ok", + ) + + +def test_deploy_no_cache_triggers_build_and_clears_metadata(tmp_path: Path, monkeypatch): + provider = _FakeProvider() + metadata_dir = tmp_path / ".agentengine" + metadata_dir.mkdir(parents=True, exist_ok=True) + (metadata_dir / "build-metadata.json").write_text('{"metadata":{"ks3_path":"ks3://old/path.zip"}}', encoding="utf-8") + + monkeypatch.setattr("ksadk.detection.FrameworkDetector", lambda *_args, **_kwargs: type("D", (), {"detect": lambda self: _FakeDetectionResult()})()) + monkeypatch.setattr("ksadk.cli.cmd_deploy._load_config", lambda *_args, **_kwargs: {"name": "demo-agent"}) + monkeypatch.setattr("ksadk.deployment.DeploymentManager.get_provider", lambda *_args, **_kwargs: provider) + + asyncio.run( + cmd_deploy._deploy_async( + agent_dir=str(tmp_path), + target="serverless", + name=None, + region="cn-beijing-6", + account_id="2000003485", + artifact_type="Code", + namespace="default", + port=8000, + registry=None, + ks3_path=None, + ks3_bucket=None, + image=None, + ui_profile=None, + ui_path=None, + ui_url=None, + observability=True, + push=False, + no_cache=True, + no_version=True, + auto_rollback=False, + dry_run=False, + ) + ) + + assert provider.package_metadata_file_exists is False + assert provider.calls == ["validate", "package", "build", "deploy"] + + +def test_deploy_repackage_triggers_build_and_clears_cached_artifact_metadata(tmp_path: Path, monkeypatch): + provider = _FakeProvider() + metadata_dir = tmp_path / ".agentengine" + metadata_dir.mkdir(parents=True, exist_ok=True) + (metadata_dir / "build-metadata.json").write_text('{"metadata":{"ks3_path":"ks3://old/path.zip"}}', encoding="utf-8") + + monkeypatch.setattr("ksadk.detection.FrameworkDetector", lambda *_args, **_kwargs: type("D", (), {"detect": lambda self: _FakeDetectionResult()})()) + monkeypatch.setattr("ksadk.cli.cmd_deploy._load_config", lambda *_args, **_kwargs: {"name": "demo-agent"}) + monkeypatch.setattr("ksadk.deployment.DeploymentManager.get_provider", lambda *_args, **_kwargs: provider) + + asyncio.run( + cmd_deploy._deploy_async( + agent_dir=str(tmp_path), + target="serverless", + name=None, + region="cn-beijing-6", + account_id="2000003485", + artifact_type="Code", + namespace="default", + port=8000, + registry=None, + ks3_path=None, + ks3_bucket=None, + image=None, + ui_profile=None, + ui_path=None, + ui_url=None, + observability=True, + push=False, + no_cache=False, + repackage=True, + no_version=True, + auto_rollback=False, + dry_run=False, + ) + ) + + assert provider.package_metadata_file_exists is False + assert provider.last_target.extra["repackage"] is True + assert provider.last_target.extra["no_cache"] is False + assert provider.calls == ["validate", "package", "build", "deploy"] + + +def test_deploy_no_cache_warns_when_explicit_ks3_path_is_supplied(tmp_path: Path, monkeypatch, capsys): + provider = _FakeProvider() + + monkeypatch.setattr("ksadk.detection.FrameworkDetector", lambda *_args, **_kwargs: type("D", (), {"detect": lambda self: _FakeDetectionResult()})()) + monkeypatch.setattr("ksadk.cli.cmd_deploy._load_config", lambda *_args, **_kwargs: {"name": "demo-agent"}) + monkeypatch.setattr("ksadk.deployment.DeploymentManager.get_provider", lambda *_args, **_kwargs: provider) + + asyncio.run( + cmd_deploy._deploy_async( + agent_dir=str(tmp_path), + target="serverless", + name=None, + region="cn-beijing-6", + account_id="2000003485", + artifact_type="Code", + namespace="default", + port=8000, + registry=None, + ks3_path="ks3://bucket/agents/demo-agent/code_manual.zip", + ks3_bucket=None, + image=None, + ui_profile=None, + ui_path=None, + ui_url=None, + observability=True, + push=False, + no_cache=True, + no_version=True, + auto_rollback=False, + dry_run=False, + ) + ) + + out = capsys.readouterr().out + assert "已显式指定 --ks3-path" in out + assert provider.calls == ["validate", "package", "deploy"] + + +def test_deploy_reads_network_config_from_agentengine_yaml(tmp_path: Path, monkeypatch): + provider = _FakeProvider() + + monkeypatch.setattr("ksadk.detection.FrameworkDetector", lambda *_args, **_kwargs: type("D", (), {"detect": lambda self: _FakeDetectionResult()})()) + monkeypatch.setattr( + "ksadk.cli.cmd_deploy._load_config", + lambda *_args, **_kwargs: { + "name": "demo-agent", + "network": { + "enable_public_access": False, + "enable_vpc_access": True, + "vpc_id": "vpc-demo", + "subnet_id": "subnet-demo", + "security_group_id": "sg-demo", + "availability_zone": "cn-beijing-6a", + }, + }, + ) + monkeypatch.setattr("ksadk.deployment.DeploymentManager.get_provider", lambda *_args, **_kwargs: provider) + + asyncio.run( + cmd_deploy._deploy_async( + agent_dir=str(tmp_path), + target="serverless", + name=None, + region="cn-beijing-6", + account_id="2000003485", + artifact_type="Code", + namespace="default", + port=8000, + registry=None, + ks3_path="ks3://bucket/agents/demo-agent/code_manual.zip", + ks3_bucket=None, + image=None, + ui_profile=None, + ui_path=None, + ui_url=None, + observability=True, + push=False, + no_cache=False, + no_version=True, + auto_rollback=False, + dry_run=False, + ) + ) + + assert provider.last_target is not None + assert provider.last_target.network.enable_vpc_access is True + assert provider.last_target.network.vpc_id == "vpc-demo" + assert provider.last_target.network.subnet_id == "subnet-demo" + assert provider.last_target.network.security_group_id == "sg-demo" + assert provider.last_target.network.availability_zone == "cn-beijing-6a" + + +def test_deploy_cli_network_options_override_config(tmp_path: Path, monkeypatch): + provider = _FakeProvider() + runner = CliRunner() + + monkeypatch.setattr("ksadk.detection.FrameworkDetector", lambda *_args, **_kwargs: type("D", (), {"detect": lambda self: _FakeDetectionResult()})()) + monkeypatch.setattr( + "ksadk.cli.cmd_deploy._load_config", + lambda *_args, **_kwargs: { + "name": "demo-agent", + "network": { + "enable_public_access": True, + "enable_vpc_access": True, + "vpc_id": "vpc-config", + "subnet_id": "subnet-config", + "security_group_id": "sg-config", + "availability_zone": "cn-beijing-6a", + }, + }, + ) + monkeypatch.setattr("ksadk.deployment.DeploymentManager.get_provider", lambda *_args, **_kwargs: provider) + + result = runner.invoke( + cmd_deploy.deploy, + [ + str(tmp_path), + "--ks3-path", + "ks3://bucket/agents/demo-agent/code_manual.zip", + "--disable-public-access", + "--enable-vpc-access", + "--vpc-id", + "vpc-cli", + "--subnet-id", + "subnet-cli", + "--security-group-id", + "sg-cli", + "--availability-zone", + "cn-beijing-6b", + "--no-version", + ], + ) + + assert result.exit_code == 0, result.output + assert provider.last_target is not None + assert provider.last_target.network.enable_public_access is False + assert provider.last_target.network.enable_vpc_access is True + assert provider.last_target.network.vpc_id == "vpc-cli" + assert provider.last_target.network.subnet_id == "subnet-cli" + assert provider.last_target.network.security_group_id == "sg-cli" + assert provider.last_target.network.availability_zone == "cn-beijing-6b" + + +def test_deploy_cli_forwards_explicit_env_and_env_file(tmp_path: Path, monkeypatch): + provider = _FakeProvider() + runner = CliRunner() + env_file = tmp_path / "runtime-env.json" + env_file.write_text( + '{"APP_MODE":"file","FILE_ONLY":"1","OVERRIDE_ME":"from-file"}', + encoding="utf-8", + ) + + monkeypatch.setattr("ksadk.detection.FrameworkDetector", lambda *_args, **_kwargs: type("D", (), {"detect": lambda self: _FakeDetectionResult()})()) + monkeypatch.setattr("ksadk.cli.cmd_deploy._load_config", lambda *_args, **_kwargs: {"name": "demo-agent"}) + monkeypatch.setattr("ksadk.deployment.DeploymentManager.get_provider", lambda *_args, **_kwargs: provider) + + result = runner.invoke( + cmd_deploy.deploy, + [ + str(tmp_path), + "--ks3-path", + "ks3://bucket/agents/demo-agent/code_manual.zip", + "--env-file", + str(env_file), + "--env", + "OVERRIDE_ME=from-cli", + "--env", + "CLI_ONLY=yes", + "--no-version", + ], + ) + + assert result.exit_code == 0, result.output + assert provider.last_target is not None + assert provider.last_target.extra["env_vars"] == { + "APP_MODE": "file", + "FILE_ONLY": "1", + "OVERRIDE_ME": "from-cli", + "CLI_ONLY": "yes", + } + + +def test_deploy_rejects_hermes_and_openclaw_frameworks(tmp_path: Path, monkeypatch): + runner = CliRunner() + + monkeypatch.setattr( + "ksadk.detection.FrameworkDetector", + lambda *_args, **_kwargs: type( + "D", + (), + {"detect": lambda self: type("R", (), {"type": type("T", (), {"value": "hermes"})(), "name": "hermes"})()}, + )(), + ) + monkeypatch.setattr("ksadk.cli.cmd_deploy._load_config", lambda *_args, **_kwargs: {"name": "demo-hermes"}) + + result = runner.invoke( + cmd_deploy.deploy, + [ + str(tmp_path), + "--target", + "serverless", + "--ks3-path", + "ks3://bucket/agents/demo-hermes/code.zip", + ], + ) + + assert result.exit_code != 0 + assert "Hermes 项目请使用" in str(result.exception) + + monkeypatch.setattr( + "ksadk.detection.FrameworkDetector", + lambda *_args, **_kwargs: type( + "D", + (), + {"detect": lambda self: type("R", (), {"type": type("T", (), {"value": "openclaw"})(), "name": "openclaw"})()}, + )(), + ) + monkeypatch.setattr("ksadk.cli.cmd_deploy._load_config", lambda *_args, **_kwargs: {"name": "demo-openclaw"}) + + result = runner.invoke( + cmd_deploy.deploy, + [ + str(tmp_path), + "--target", + "serverless", + "--ks3-path", + "ks3://bucket/agents/demo-openclaw/code.zip", + ], + ) + + assert result.exit_code != 0 + assert "OpenClaw 项目请使用" in str(result.exception) + + +def test_deploy_rejects_incomplete_vpc_network_config(tmp_path: Path, monkeypatch): + provider = _FakeProvider() + + monkeypatch.setattr("ksadk.detection.FrameworkDetector", lambda *_args, **_kwargs: type("D", (), {"detect": lambda self: _FakeDetectionResult()})()) + monkeypatch.setattr( + "ksadk.cli.cmd_deploy._load_config", + lambda *_args, **_kwargs: { + "name": "demo-agent", + "network": { + "enable_vpc_access": True, + "vpc_id": "vpc-demo", + }, + }, + ) + monkeypatch.setattr("ksadk.deployment.DeploymentManager.get_provider", lambda *_args, **_kwargs: provider) + + with pytest.raises(CLIError) as exc_info: + asyncio.run( + cmd_deploy._deploy_async( + agent_dir=str(tmp_path), + target="serverless", + name=None, + region="cn-beijing-6", + account_id="2000003485", + artifact_type="Code", + namespace="default", + port=8000, + registry=None, + ks3_path="ks3://bucket/agents/demo-agent/code_manual.zip", + ks3_bucket=None, + image=None, + ui_profile=None, + ui_path=None, + ui_url=None, + observability=True, + push=False, + no_cache=False, + no_version=True, + auto_rollback=False, + dry_run=False, + ) + ) + + assert exc_info.value.code == "validation_error" + assert "VpcId、SubnetId、SecurityGroupId" in exc_info.value.message + + +def test_deploy_network_ids_imply_vpc_access(tmp_path: Path, monkeypatch): + provider = _FakeProvider() + + monkeypatch.setattr("ksadk.detection.FrameworkDetector", lambda *_args, **_kwargs: type("D", (), {"detect": lambda self: _FakeDetectionResult()})()) + monkeypatch.setattr( + "ksadk.cli.cmd_deploy._load_config", + lambda *_args, **_kwargs: { + "name": "demo-agent", + "deploy": { + "network": { + "vpc_id": "vpc-demo", + "subnet_id": "subnet-demo", + "security_group_id": "sg-demo", + }, + }, + }, + ) + monkeypatch.setattr("ksadk.deployment.DeploymentManager.get_provider", lambda *_args, **_kwargs: provider) + + asyncio.run( + cmd_deploy._deploy_async( + agent_dir=str(tmp_path), + target="serverless", + name=None, + region="cn-beijing-6", + account_id="2000003485", + artifact_type="Code", + namespace="default", + port=8000, + registry=None, + ks3_path="ks3://bucket/agents/demo-agent/code_manual.zip", + ks3_bucket=None, + image=None, + ui_profile=None, + ui_path=None, + ui_url=None, + observability=True, + push=False, + no_cache=False, + no_version=True, + auto_rollback=False, + dry_run=False, + ) + ) + + assert provider.last_target is not None + assert provider.last_target.network.enable_vpc_access is True + + +def test_deploy_reads_ui_config_from_agentengine_yaml_when_cli_not_set(tmp_path: Path, monkeypatch): + provider = _FakeProvider() + + monkeypatch.setattr("ksadk.detection.FrameworkDetector", lambda *_args, **_kwargs: type("D", (), {"detect": lambda self: _FakeDetectionResult()})()) + monkeypatch.setattr( + "ksadk.cli.cmd_deploy._load_config", + lambda *_args, **_kwargs: { + "name": "demo-agent", + "ui": { + "profile": "custom", + "path": "/custom-chat", + "url": "https://ui.example.com/custom-chat", + }, + }, + ) + monkeypatch.setattr("ksadk.deployment.DeploymentManager.get_provider", lambda *_args, **_kwargs: provider) + + asyncio.run( + cmd_deploy._deploy_async( + agent_dir=str(tmp_path), + target="serverless", + name=None, + region="cn-beijing-6", + account_id="2000003485", + artifact_type="Code", + namespace="default", + port=8000, + registry=None, + ks3_path="ks3://bucket/agents/demo-agent/code_manual.zip", + ks3_bucket=None, + image=None, + ui_profile=None, + ui_path=None, + ui_url=None, + observability=True, + push=False, + no_cache=False, + no_version=True, + auto_rollback=False, + dry_run=False, + ) + ) + + assert provider.last_target is not None + assert provider.last_target.extra["ui_profile"] == "custom" + assert provider.last_target.extra["ui_path"] == "/custom-chat" + assert provider.last_target.extra["ui_url"] == "https://ui.example.com/custom-chat" diff --git a/tests/test_cmd_files.py b/tests/test_cmd_files.py new file mode 100644 index 00000000..52146ab4 --- /dev/null +++ b/tests/test_cmd_files.py @@ -0,0 +1,942 @@ +from __future__ import annotations + +import asyncio +import json +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from ksadk.api import AgentEngineAPIError +from ksadk.cli import _register_commands, cli + + +@pytest.fixture(autouse=True) +def _isolate_region_env(monkeypatch): + monkeypatch.delenv("KSYUN_REGION", raising=False) + + +class _FakeFilesClient: + init_calls: list[dict] = [] + list_calls: list[dict] = [] + upload_calls: list[dict] = [] + download_calls: list[dict] = [] + delete_calls: list[dict] = [] + list_results: dict[str, object] = {} + download_payloads: dict[str, bytes] = {} + workspace_health: dict[str, object] = { + "root": "workspace", + "workspace_path": "/home/node/.hermes/workspace", + } + + def __init__(self, *args, **kwargs): + self.kwargs = kwargs + self.__class__.init_calls.append(kwargs) + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def list_workspace_files(self, **kwargs): + self.__class__.list_calls.append(kwargs) + path = kwargs["path"] + if path in self.__class__.list_results: + result = self.__class__.list_results[path] + if isinstance(result, Exception): + raise result + return result + return { + "root": "workspace", + "path": kwargs["path"], + "entries": [ + {"name": "inputs", "path": "inputs", "type": "directory"}, + {"name": "report.txt", "path": "report.txt", "type": "file", "size_bytes": 7}, + ], + } + + async def upload_workspace_file(self, **kwargs): + self.__class__.upload_calls.append(kwargs) + return {"entry": {"path": kwargs["remote_path"], "type": "file", "size_bytes": 7}} + + async def download_workspace_file(self, **kwargs): + self.__class__.download_calls.append(kwargs) + payload = self.__class__.download_payloads.get(kwargs["remote_path"]) + if payload is not None: + return payload + return b"payload" + + async def delete_workspace_file(self, **kwargs): + self.__class__.delete_calls.append(kwargs) + return {"deleted": True} + + async def get_workspace_health(self, **kwargs): + return dict(self.__class__.workspace_health) + + +def _reset_fake_files_client() -> None: + _FakeFilesClient.init_calls = [] + _FakeFilesClient.list_calls = [] + _FakeFilesClient.upload_calls = [] + _FakeFilesClient.download_calls = [] + _FakeFilesClient.delete_calls = [] + _FakeFilesClient.list_results = {} + _FakeFilesClient.download_payloads = {} + _FakeFilesClient.workspace_health = { + "root": "workspace", + "workspace_path": "/home/node/.hermes/workspace", + } + + +def test_files_list_command_supports_json_output(monkeypatch): + from ksadk.cli import cmd_files + + _reset_fake_files_client() + _register_commands() + monkeypatch.setattr(cmd_files, "AgentEngineClient", _FakeFilesClient) + + runner = CliRunner() + result = runner.invoke( + cli, + [ + "--output", + "json", + "files", + "list", + "--agent", + "demo-agent", + "--path", + "docs", + "--region", + "cn-beijing-6", + ], + ) + + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert payload["ok"] is True + assert payload["action"] == "list" + assert payload["workspace_root"] == "workspace" + assert payload["path"] == "docs" + assert payload["workspace_display_path"] == "workspace:/docs" + assert payload["workspace_real_root"] == "/home/node/.hermes/workspace" + assert payload["workspace_real_path"] == "/home/node/.hermes/workspace/docs" + assert payload["summary"] == { + "entry_count": 2, + "directory_count": 1, + "file_count": 1, + } + assert payload["entries"][0]["path"] == "inputs" + assert payload["entries"][0]["display_path"] == "workspace:/inputs" + assert payload["entries"][0]["real_path"] == "/home/node/.hermes/workspace/inputs" + assert payload["entries"][1]["size_human"] == "7 B" + assert _FakeFilesClient.list_calls == [ + {"agent_id": "demo-agent", "path": "docs", "recursive": False} + ] + assert _FakeFilesClient.init_calls == [{"region": "cn-beijing-6"}] + + +def test_files_list_command_supports_direct_runtime_access(monkeypatch): + from ksadk.cli import cmd_files + + _reset_fake_files_client() + _register_commands() + monkeypatch.setattr(cmd_files, "AgentEngineClient", _FakeFilesClient) + + runner = CliRunner() + result = runner.invoke( + cli, + [ + "--output", + "json", + "files", + "list", + "--endpoint", + "http://127.0.0.1:18080", + "--api-key", + "ak-direct-demo", + "--path", + "docs", + ], + ) + + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert payload["path"] == "docs" + assert _FakeFilesClient.list_calls == [ + { + "agent_id": None, + "path": "docs", + "recursive": False, + "endpoint": "http://127.0.0.1:18080", + "api_key": "ak-direct-demo", + } + ] + assert _FakeFilesClient.init_calls == [{"region": "cn-beijing-6"}] + + +def test_files_list_command_accepts_workspace_style_absolute_path(monkeypatch): + from ksadk.cli import cmd_files + + _reset_fake_files_client() + _register_commands() + monkeypatch.setattr(cmd_files, "AgentEngineClient", _FakeFilesClient) + + runner = CliRunner() + result = runner.invoke( + cli, + [ + "--output", + "json", + "files", + "list", + "--agent", + "demo-agent", + "--path", + "/tmp", + ], + ) + + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert payload["path"] == "tmp" + assert payload["workspace_display_path"] == "workspace:/tmp" + assert _FakeFilesClient.list_calls == [ + {"agent_id": "demo-agent", "path": "tmp", "recursive": False} + ] + + +def test_files_list_command_prefers_openclaw_state_runtime_access_when_api_key_is_ready( + monkeypatch, + tmp_path: Path, +): + from ksadk.cli import cmd_files + + _reset_fake_files_client() + _FakeFilesClient.workspace_health = { + "root": "workspace", + "workspace_path": "/home/node/.openclaw/workspace", + } + _register_commands() + monkeypatch.setattr(cmd_files, "AgentEngineClient", _FakeFilesClient) + monkeypatch.chdir(tmp_path) + (tmp_path / ".agentengine.state").write_text( + "\n".join( + [ + "type: openclaw", + "framework: openclaw", + "agent_id: ar-openclaw-1", + "name: demo-openclaw", + "endpoint: https://openclaw.example.com", + "api_key: ak-openclaw", + "region: pre-online", + "", + ] + ), + encoding="utf-8", + ) + + runner = CliRunner() + result = runner.invoke( + cli, + [ + "--output", + "json", + "files", + "list", + "--path", + "docs", + ], + ) + + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert payload["path"] == "docs" + assert payload["workspace_real_root"] == "/home/node/.openclaw/workspace" + assert payload["workspace_real_path"] == "/home/node/.openclaw/workspace/docs" + assert _FakeFilesClient.init_calls == [{"region": "pre-online"}] + assert _FakeFilesClient.list_calls == [ + { + "agent_id": "ar-openclaw-1", + "path": "docs", + "recursive": False, + "endpoint": "https://openclaw.example.com", + "api_key": "ak-openclaw", + } + ] + + +def test_files_list_command_falls_back_to_project_config(monkeypatch, tmp_path: Path): + from ksadk.cli import cmd_files + + _reset_fake_files_client() + _register_commands() + monkeypatch.setattr(cmd_files, "AgentEngineClient", _FakeFilesClient) + monkeypatch.chdir(tmp_path) + (tmp_path / "agentengine.yaml").write_text("name: demo-agent\nframework: langgraph\n", encoding="utf-8") + + runner = CliRunner() + result = runner.invoke( + cli, + [ + "--output", + "json", + "files", + "list", + "--path", + "docs", + ], + ) + + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert payload["path"] == "docs" + assert _FakeFilesClient.init_calls == [{"region": "cn-beijing-6"}] + assert _FakeFilesClient.list_calls == [ + { + "agent_id": "demo-agent", + "path": "docs", + "recursive": False, + } + ] + + +def test_files_upload_download_and_delete_commands(monkeypatch, tmp_path: Path): + from ksadk.cli import cmd_files + + _reset_fake_files_client() + _register_commands() + monkeypatch.setattr(cmd_files, "AgentEngineClient", _FakeFilesClient) + + local_file = tmp_path / "report.txt" + local_file.write_text("payload", encoding="utf-8") + download_path = tmp_path / "downloaded.txt" + + runner = CliRunner() + upload_result = runner.invoke( + cli, + [ + "files", + "upload", + "--agent", + "demo-agent", + "--local-path", + str(local_file), + "--remote-path", + "reports/report.txt", + ], + ) + download_result = runner.invoke( + cli, + [ + "files", + "download", + "--agent", + "demo-agent", + "--remote-path", + "reports/report.txt", + "--output-path", + str(download_path), + ], + ) + delete_result = runner.invoke( + cli, + [ + "files", + "delete", + "--agent", + "demo-agent", + "--remote-path", + "reports/report.txt", + "--yes", + ], + ) + + assert upload_result.exit_code == 0, upload_result.output + assert download_result.exit_code == 0, download_result.output + assert delete_result.exit_code == 0, delete_result.output + assert download_path.read_text(encoding="utf-8") == "payload" + assert _FakeFilesClient.upload_calls == [ + { + "agent_id": "demo-agent", + "remote_path": "reports/report.txt", + "local_path": local_file, + } + ] + assert _FakeFilesClient.download_calls == [ + { + "agent_id": "demo-agent", + "remote_path": "reports/report.txt", + } + ] + assert _FakeFilesClient.delete_calls == [ + { + "agent_id": "demo-agent", + "remote_path": "reports/report.txt", + } + ] + assert _FakeFilesClient.init_calls == [ + {"region": "cn-beijing-6"}, + {"region": "cn-beijing-6"}, + {"region": "cn-beijing-6"}, + ] + + +def test_files_upload_pretty_output_shows_local_and_remote_paths(monkeypatch, tmp_path: Path): + from ksadk.cli import cmd_files + + _reset_fake_files_client() + _register_commands() + monkeypatch.setattr(cmd_files, "AgentEngineClient", _FakeFilesClient) + + local_file = tmp_path / "resume.pdf" + local_file.write_bytes(b"pdf-data") + + runner = CliRunner() + result = runner.invoke( + cli, + [ + "files", + "upload", + "--agent", + "demo-agent", + "--local-path", + str(local_file), + "--remote-path", + "pdf", + ], + ) + + assert result.exit_code == 0, result.output + assert "上传完成" in result.output + assert f"本地文件:{local_file}" in result.output + assert "远端文件:workspace:/pdf" in result.output + assert "文件大小:7 B" in result.output + + +def test_files_upload_json_output_includes_agent_friendly_fields(monkeypatch, tmp_path: Path): + from ksadk.cli import cmd_files + + _reset_fake_files_client() + _register_commands() + monkeypatch.setattr(cmd_files, "AgentEngineClient", _FakeFilesClient) + + local_file = tmp_path / "resume.pdf" + local_file.write_bytes(b"pdf-data") + + runner = CliRunner() + result = runner.invoke( + cli, + [ + "--output", + "json", + "files", + "upload", + "--agent", + "demo-agent", + "--local-path", + str(local_file), + "--remote-path", + "pdf", + ], + ) + + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert payload["ok"] is True + assert payload["action"] == "upload" + assert payload["workspace_root"] == "workspace" + assert payload["local_path"] == str(local_file) + assert payload["remote_path"] == "pdf" + assert payload["remote_display_path"] == "workspace:/pdf" + assert payload["summary"] == { + "uploaded": 1, + "size_bytes": 7, + "size_human": "7 B", + } + assert payload["entry"]["display_path"] == "workspace:/pdf" + + +def test_files_list_pretty_output_uses_readable_entry_lines(monkeypatch): + from ksadk.cli import cmd_files + + _reset_fake_files_client() + _register_commands() + monkeypatch.setattr(cmd_files, "AgentEngineClient", _FakeFilesClient) + + runner = CliRunner() + result = runner.invoke( + cli, + [ + "files", + "list", + "--agent", + "demo-agent", + "--path", + ".", + ], + ) + + assert result.exit_code == 0, result.output + assert "工作空间:workspace" in result.output + assert "当前目录:workspace:/" in result.output + assert "实际目录:/home/node/.hermes/workspace" in result.output + assert "条目数量:2" in result.output + assert "目录(1)" in result.output + assert " workspace:/inputs" in result.output + assert "文件(1)" in result.output + assert " workspace:/report.txt 7 B" in result.output + + +def test_files_commands_support_direct_runtime_access(monkeypatch, tmp_path: Path): + from ksadk.cli import cmd_files + + _reset_fake_files_client() + _register_commands() + monkeypatch.setattr(cmd_files, "AgentEngineClient", _FakeFilesClient) + + local_file = tmp_path / "report.txt" + local_file.write_text("payload", encoding="utf-8") + download_path = tmp_path / "downloaded.txt" + + runner = CliRunner() + upload_result = runner.invoke( + cli, + [ + "files", + "upload", + "--endpoint", + "http://127.0.0.1:18080", + "--api-key", + "ak-direct-demo", + "--local-path", + str(local_file), + "--remote-path", + "reports/report.txt", + ], + ) + download_result = runner.invoke( + cli, + [ + "files", + "download", + "--endpoint", + "http://127.0.0.1:18080", + "--api-key", + "ak-direct-demo", + "--remote-path", + "reports/report.txt", + "--output-path", + str(download_path), + ], + ) + delete_result = runner.invoke( + cli, + [ + "files", + "delete", + "--endpoint", + "http://127.0.0.1:18080", + "--api-key", + "ak-direct-demo", + "--remote-path", + "reports/report.txt", + "--yes", + ], + ) + + assert upload_result.exit_code == 0, upload_result.output + assert download_result.exit_code == 0, download_result.output + assert delete_result.exit_code == 0, delete_result.output + assert download_path.read_text(encoding="utf-8") == "payload" + assert _FakeFilesClient.upload_calls == [ + { + "agent_id": None, + "remote_path": "reports/report.txt", + "local_path": local_file, + "endpoint": "http://127.0.0.1:18080", + "api_key": "ak-direct-demo", + } + ] + assert _FakeFilesClient.download_calls == [ + { + "agent_id": None, + "remote_path": "reports/report.txt", + "endpoint": "http://127.0.0.1:18080", + "api_key": "ak-direct-demo", + } + ] + assert _FakeFilesClient.delete_calls == [ + { + "agent_id": None, + "remote_path": "reports/report.txt", + "endpoint": "http://127.0.0.1:18080", + "api_key": "ak-direct-demo", + } + ] + assert _FakeFilesClient.init_calls == [ + {"region": "cn-beijing-6"}, + {"region": "cn-beijing-6"}, + {"region": "cn-beijing-6"}, + ] + + +def test_files_upload_accepts_positional_agent(monkeypatch, tmp_path: Path): + from ksadk.cli import cmd_files + + _reset_fake_files_client() + _register_commands() + monkeypatch.setattr(cmd_files, "AgentEngineClient", _FakeFilesClient) + + local_file = tmp_path / "report.txt" + local_file.write_text("payload", encoding="utf-8") + + runner = CliRunner() + result = runner.invoke( + cli, + [ + "files", + "upload", + "demo-agent", + "--local-path", + str(local_file), + "--remote-path", + "reports/report.txt", + ], + ) + + assert result.exit_code == 0, result.output + assert _FakeFilesClient.upload_calls == [ + { + "agent_id": "demo-agent", + "remote_path": "reports/report.txt", + "local_path": local_file, + } + ] + + +def test_files_push_uploads_new_files_and_skips_existing_targets_by_default(monkeypatch, tmp_path: Path): + from ksadk.cli import cmd_files + + _reset_fake_files_client() + _register_commands() + monkeypatch.setattr(cmd_files, "AgentEngineClient", _FakeFilesClient) + + local_dir = tmp_path / "skills" + local_dir.mkdir() + (local_dir / "README.md").write_text("local readme", encoding="utf-8") + nested_dir = local_dir / "nested" + nested_dir.mkdir() + (nested_dir / "tool.py").write_text("print('ok')\n", encoding="utf-8") + + _FakeFilesClient.list_results["bundle"] = { + "root": "workspace", + "path": "bundle", + "entries": [ + {"name": "README.md", "path": "bundle/README.md", "type": "file", "size_bytes": 12}, + ], + } + + runner = CliRunner() + result = runner.invoke( + cli, + [ + "--output", + "json", + "files", + "push", + "--agent", + "demo-agent", + "--local-dir", + str(local_dir), + "--remote-path", + "bundle", + ], + ) + + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert payload["ok"] is True + assert payload["action"] == "push" + assert payload["direction"] == "push" + assert payload["remote_display_path"] == "workspace:/bundle" + assert payload["summary"] == { + "created_count": 1, + "overwritten_count": 0, + "skipped_count": 1, + "total_files": 2, + } + assert payload["created"] == ["bundle/nested/tool.py"] + assert payload["skipped"] == ["bundle/README.md"] + assert payload["overwritten"] == [] + assert payload["results"]["created"][0]["display_path"] == "workspace:/bundle/nested/tool.py" + assert payload["results"]["skipped"][0]["display_path"] == "workspace:/bundle/README.md" + assert _FakeFilesClient.upload_calls == [ + { + "agent_id": "demo-agent", + "remote_path": "bundle/nested/tool.py", + "local_path": nested_dir / "tool.py", + } + ] + + +def test_push_workspace_files_can_ignore_local_dev_artifacts(monkeypatch, tmp_path: Path): + from ksadk.cli import cmd_files + + _reset_fake_files_client() + monkeypatch.setattr(cmd_files, "AgentEngineClient", _FakeFilesClient) + + local_dir = tmp_path / "bundle" + local_dir.mkdir() + (local_dir / "app.py").write_text("print('ok')\n", encoding="utf-8") + + git_object = local_dir / ".git" / "objects" / "ab" + git_object.mkdir(parents=True) + (git_object / "blob").write_bytes(b"x" * 2048) + + agentengine_ui = local_dir / ".agentengine" / "ui" + agentengine_ui.mkdir(parents=True) + (agentengine_ui / "sessions.sqlite").write_bytes(b"sqlite-data") + + payload = asyncio.run( + cmd_files._push_workspace_files( + agent_ref="demo-agent", + local_dir=local_dir, + remote_path="bundle", + force=True, + region="cn-beijing-6", + endpoint=None, + api_key=None, + ignore_dev_artifacts=True, + ) + ) + + assert payload["created"] == ["bundle/app.py"] + assert payload["total_files"] == 1 + assert _FakeFilesClient.upload_calls == [ + { + "agent_id": "demo-agent", + "remote_path": "bundle/app.py", + "local_path": local_dir / "app.py", + } + ] + + +def test_files_push_pretty_output_is_readable_in_chinese(monkeypatch, tmp_path: Path): + from ksadk.cli import cmd_files + + _reset_fake_files_client() + _register_commands() + monkeypatch.setattr(cmd_files, "AgentEngineClient", _FakeFilesClient) + + local_dir = tmp_path / "bundle" + local_dir.mkdir() + (local_dir / "hello.txt").write_text("hello", encoding="utf-8") + + runner = CliRunner() + result = runner.invoke( + cli, + [ + "files", + "push", + "--agent", + "demo-agent", + "--local-dir", + str(local_dir), + "--remote-path", + "bundle", + ], + ) + + assert result.exit_code == 0, result.output + assert "推送完成" in result.output + assert f"本地目录:{local_dir}" in result.output + assert "远端目录:workspace:/bundle" in result.output + assert "统计:新增 1,覆盖 0,跳过 0,共 1" in result.output + assert "已新增:workspace:/bundle/hello.txt" in result.output + + +def test_files_push_pretty_output_shows_action_proxy_transport_hint(monkeypatch, tmp_path: Path): + from ksadk.cli import cmd_files + + _reset_fake_files_client() + _register_commands() + monkeypatch.setattr(cmd_files, "AgentEngineClient", _FakeFilesClient) + + local_dir = tmp_path / "bundle" + local_dir.mkdir() + (local_dir / "hello.txt").write_text("hello", encoding="utf-8") + _FakeFilesClient.list_results["bundle"] = { + "root": "workspace", + "path": "bundle", + "entries": [], + "transport_mode": "action_proxy", + } + + runner = CliRunner() + result = runner.invoke( + cli, + [ + "files", + "push", + "--agent", + "demo-agent", + "--local-dir", + str(local_dir), + "--remote-path", + "bundle", + ], + ) + + assert result.exit_code == 0, result.output + assert "访问链路:通过平台 action 代理访问远端 workspace" in result.output + + +def test_files_push_treats_missing_remote_directory_as_empty(monkeypatch, tmp_path: Path): + from ksadk.cli import cmd_files + + _reset_fake_files_client() + _register_commands() + monkeypatch.setattr(cmd_files, "AgentEngineClient", _FakeFilesClient) + + local_dir = tmp_path / "bundle" + local_dir.mkdir() + (local_dir / "hello.txt").write_text("hello", encoding="utf-8") + _FakeFilesClient.list_results["new-bundle"] = AgentEngineAPIError(404, "workspace path not found") + + runner = CliRunner() + result = runner.invoke( + cli, + [ + "--output", + "json", + "files", + "push", + "--agent", + "demo-agent", + "--local-dir", + str(local_dir), + "--remote-path", + "new-bundle", + ], + ) + + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert payload["created"] == ["new-bundle/hello.txt"] + assert payload["skipped"] == [] + assert _FakeFilesClient.upload_calls == [ + { + "agent_id": "demo-agent", + "remote_path": "new-bundle/hello.txt", + "local_path": local_dir / "hello.txt", + } + ] + + +def test_files_pull_downloads_new_files_and_overwrites_with_force(monkeypatch, tmp_path: Path): + from ksadk.cli import cmd_files + + _reset_fake_files_client() + _register_commands() + monkeypatch.setattr(cmd_files, "AgentEngineClient", _FakeFilesClient) + + local_dir = tmp_path / "mirror" + local_dir.mkdir() + existing_file = local_dir / "README.md" + existing_file.write_text("old local", encoding="utf-8") + + _FakeFilesClient.list_results["bundle"] = { + "root": "workspace", + "path": "bundle", + "entries": [ + {"name": "README.md", "path": "bundle/README.md", "type": "file", "size_bytes": 12}, + {"name": "tool.py", "path": "bundle/nested/tool.py", "type": "file", "size_bytes": 11}, + ], + } + _FakeFilesClient.download_payloads = { + "bundle/README.md": b"new remote", + "bundle/nested/tool.py": b"print('ok')\n", + } + + runner = CliRunner() + result = runner.invoke( + cli, + [ + "--output", + "json", + "files", + "pull", + "--agent", + "demo-agent", + "--remote-path", + "bundle", + "--local-dir", + str(local_dir), + "--force", + ], + ) + + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert payload["direction"] == "pull" + assert payload["created"] == ["nested/tool.py"] + assert payload["overwritten"] == ["README.md"] + assert payload["skipped"] == [] + assert existing_file.read_text(encoding="utf-8") == "new remote" + assert (local_dir / "nested" / "tool.py").read_text(encoding="utf-8") == "print('ok')\n" + assert _FakeFilesClient.download_calls == [ + { + "agent_id": "demo-agent", + "remote_path": "bundle/README.md", + }, + { + "agent_id": "demo-agent", + "remote_path": "bundle/nested/tool.py", + }, + ] + + +def test_files_pull_json_output_includes_transport_metadata(monkeypatch, tmp_path: Path): + from ksadk.cli import cmd_files + + _reset_fake_files_client() + _register_commands() + monkeypatch.setattr(cmd_files, "AgentEngineClient", _FakeFilesClient) + + local_dir = tmp_path / "mirror" + local_dir.mkdir() + _FakeFilesClient.list_results["bundle"] = { + "root": "workspace", + "path": "bundle", + "transport_mode": "action_proxy", + "entries": [ + {"name": "tool.py", "path": "bundle/nested/tool.py", "type": "file", "size_bytes": 11}, + ], + } + _FakeFilesClient.download_payloads = { + "bundle/nested/tool.py": b"print('ok')\n", + } + + runner = CliRunner() + result = runner.invoke( + cli, + [ + "--output", + "json", + "files", + "pull", + "--agent", + "demo-agent", + "--remote-path", + "bundle", + "--local-dir", + str(local_dir), + ], + ) + + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert payload["transport_mode"] == "action_proxy" + assert payload["transport_hint"] == "通过平台 action 代理访问远端 workspace" diff --git a/tests/test_cmd_invoke.py b/tests/test_cmd_invoke.py new file mode 100644 index 00000000..98d988f8 --- /dev/null +++ b/tests/test_cmd_invoke.py @@ -0,0 +1,1595 @@ +import asyncio +import sys +from pathlib import Path + +import click +import pytest +import yaml + +from ksadk.api import AgentEngineAPIError +from ksadk.cli import cmd_invoke +from ksadk.cli.cmd_invoke import ( + _extract_content, + _extract_response_content, + _invoke_hermes_terminal_tui, + _invoke_openclaw_terminal_tui, + _resolve_remote_api_format, + _select_remote_api_format, + run_invoke_command, +) + + +class _FakeInvokeClient: + calls = [] + + def __init__(self, *args, **kwargs): + self.kwargs = kwargs + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def get_agent(self, agent_id=None, name=None, include_api_key=False): + self.__class__.calls.append( + { + "agent_id": agent_id, + "name": name, + "include_api_key": include_api_key, + } + ) + return { + "basic": { + "agent_id": "ar-demo", + "name": "demo-agent", + }, + "quick_access": { + "public_endpoint": "https://fresh.example.com", + "api_key": "ak-fresh", + }, + } + + +class _FakeOpenClawInvokeClient: + calls = [] + + def __init__(self, *args, **kwargs): + self.kwargs = kwargs + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def get_agent(self, agent_id=None, name=None, include_api_key=False): + self.__class__.calls.append( + { + "agent_id": agent_id, + "name": name, + "include_api_key": include_api_key, + } + ) + return { + "basic": { + "agent_id": "ar-openclaw-demo", + "name": "demo-openclaw", + }, + "deployment": { + "framework": "openclaw", + }, + "quick_access": { + "public_endpoint": "https://openclaw.example.com", + "api_key": "ak-openclaw", + }, + } + + +class _FakeStreamResponse: + def __init__(self, lines): + self._lines = lines + + def raise_for_status(self): + return None + + async def aiter_lines(self): + for line in self._lines: + yield line + + +class _FakeStreamContext: + def __init__(self, response): + self._response = response + + async def __aenter__(self): + return self._response + + async def __aexit__(self, exc_type, exc, tb): + return False + + +class _FakeStreamClient: + def __init__(self, *args, **kwargs): + self.kwargs = kwargs + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + def stream(self, *_args, **_kwargs): + return _FakeStreamContext( + _FakeStreamResponse( + [ + 'data: {"choices":[{"delta":{"content":"ok"}}],"error":null}', + "data: [DONE]", + ] + ) + ) + + +def test_run_invoke_command_refreshes_stale_state_from_remote(monkeypatch, tmp_path: Path): + state_file = tmp_path / ".agentengine.state" + state_file.write_text( + yaml.safe_dump( + { + "agent_id": "ar-demo", + "name": "demo-agent", + "endpoint": "http://stale.example.com", + "api_key": None, + } + ), + encoding="utf-8", + ) + + captured = {} + + async def _fake_invoke_once(endpoint, message, api_key, session_id, stream, insecure, model, api_format="chat_completions"): + captured["endpoint"] = endpoint + captured["api_key"] = api_key + captured["message"] = message + + monkeypatch.chdir(tmp_path) + monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeInvokeClient) + monkeypatch.setattr("ksadk.cli.cmd_invoke._invoke_once", _fake_invoke_once) + + run_invoke_command( + agent_ref=None, + agent_option=None, + endpoint=None, + api_key=None, + message="hello", + session=None, + region="pre-online", + local=False, + insecure=False, + transport="auto", + model=None, + show_thinking=False, + ) + + state = yaml.safe_load(state_file.read_text(encoding="utf-8")) + assert captured["endpoint"] == "https://fresh.example.com" + assert captured["api_key"] == "ak-fresh" + assert state["endpoint"] == "https://fresh.example.com" + assert state["api_key"] == "ak-fresh" + assert _FakeInvokeClient.calls[-1] == { + "agent_id": "ar-demo", + "name": None, + "include_api_key": True, + } + + +def test_run_invoke_command_persists_generated_session_id(monkeypatch, tmp_path: Path): + captured_sessions = [] + + async def _fake_invoke_once(endpoint, message, api_key, session_id, stream, insecure, model, api_format="chat_completions"): + captured_sessions.append(session_id) + + monkeypatch.chdir(tmp_path) + monkeypatch.setattr("ksadk.cli.cmd_invoke._invoke_once", _fake_invoke_once) + + run_invoke_command( + agent_ref=None, + agent_option=None, + endpoint=None, + api_key=None, + message="hello", + session=None, + region="pre-online", + local=True, + insecure=False, + transport="auto", + model=None, + show_thinking=False, + ) + + state_file = tmp_path / ".agentengine.state" + state = yaml.safe_load(state_file.read_text(encoding="utf-8")) + assert captured_sessions[0] + assert state["session_id"] == captured_sessions[0] + + run_invoke_command( + agent_ref=None, + agent_option=None, + endpoint=None, + api_key=None, + message="continue", + session=None, + region="pre-online", + local=True, + insecure=False, + transport="auto", + model=None, + show_thinking=False, + ) + + assert captured_sessions[1] == captured_sessions[0] + + +def test_extract_content_supports_response_output_text_delta(): + content, reasoning = _extract_content( + { + "_event": "response.output_text.delta", + "delta": "你好", + } + ) + + assert content == "你好" + assert reasoning == "" + + +def test_extract_content_supports_response_reasoning_delta(): + content, reasoning = _extract_content( + { + "_event": "response.reasoning.delta", + "delta": "先分析一下", + } + ) + + assert content == "" + assert reasoning == "先分析一下" + + +def test_extract_content_ignores_response_completed_payload(): + content, reasoning = _extract_content( + { + "_event": "response.completed", + "output_text": "最终答案", + } + ) + + assert content == "" + assert reasoning == "" + + +async def test_stream_chat_ignores_null_error_field(monkeypatch, capsys): + monkeypatch.setitem( + sys.modules, + "httpx", + type("HttpxModule", (), {"AsyncClient": _FakeStreamClient}), + ) + + chunks = [ + chunk + async for chunk in cmd_invoke._stream_chat( + "https://agent.example.com", + "hello", + api_key="ak-demo", + ) + ] + + assert chunks == [{"choices": [{"delta": {"content": "ok"}}], "error": None}] + captured = capsys.readouterr() + assert "Error: None" not in captured.out + assert "Error: None" not in captured.err + + +def test_extract_response_content_supports_responses_payload(): + assert ( + _extract_response_content( + { + "output": [ + { + "content": [ + { + "type": "output_text", + "text": "最终答案", + } + ] + } + ] + } + ) + == "最终答案" + ) + + +def test_select_remote_api_format_prefers_responses_for_openclaw(): + state = {"framework": "openclaw"} + + assert _select_remote_api_format(state, {}) == "responses" + + +def test_select_remote_api_format_prefers_responses_for_hermes(): + state = {"framework": "hermes"} + + assert _select_remote_api_format(state, {}) == "responses" + + +def test_select_remote_api_format_keeps_chat_completions_for_default_agents(): + assert _select_remote_api_format({}, {}) == "chat_completions" + + +def test_resolve_remote_api_format_rejects_openclaw_when_responses_route_missing(monkeypatch): + async def _fake_probe(**_kwargs): + return False + + monkeypatch.setattr(cmd_invoke, "_probe_openclaw_responses_route", _fake_probe) + + with pytest.raises(click.ClickException) as exc_info: + asyncio.run( + _resolve_remote_api_format( + endpoint="https://openclaw.example.com", + api_key="ak-openclaw", + insecure=False, + state={"framework": "openclaw"}, + latest_access={}, + ) + ) + + assert "/v1/responses" in str(exc_info.value) + assert "agentengine dashboard open" in str(exc_info.value) + + +def test_resolve_remote_api_format_probes_openclaw_with_runtime_gateway_token(monkeypatch): + captured = {} + + async def _fake_probe(**kwargs): + captured.update(kwargs) + return True + + monkeypatch.setattr(cmd_invoke, "_probe_openclaw_responses_route", _fake_probe) + + api_format = asyncio.run( + _resolve_remote_api_format( + endpoint="https://openclaw.example.com", + api_key="ak-openclaw", + runtime_api_key="gateway-token", + insecure=False, + state={"framework": "openclaw"}, + latest_access={}, + ) + ) + + assert api_format == "responses" + assert captured["api_key"] == "gateway-token" + + +def test_run_invoke_command_defaults_to_hermes_native_tui_for_hermes_state(monkeypatch, tmp_path: Path): + (tmp_path / ".agentengine.state").write_text( + yaml.safe_dump( + { + "type": "hermes", + "framework": "hermes", + "endpoint": "https://hermes.example.com", + "api_key": "ak-hermes", + } + ), + encoding="utf-8", + ) + + captured = {"native": 0, "chat": 0} + + def _fake_native(endpoint, api_key=None, session_id=None, insecure=False): + captured["native"] += 1 + captured["endpoint"] = endpoint + captured["api_key"] = api_key + + def _fake_chat(*_args, **_kwargs): + captured["chat"] += 1 + + monkeypatch.chdir(tmp_path) + monkeypatch.setattr("ksadk.cli.cmd_invoke._invoke_hermes_terminal_tui", _fake_native) + monkeypatch.setattr("ksadk.cli.cmd_invoke._invoke_tui", _fake_chat) + + run_invoke_command( + agent_ref=None, + agent_option=None, + endpoint="https://hermes.example.com", + api_key=None, + message=None, + session=None, + region="cn-beijing-6", + local=False, + insecure=False, + model=None, + show_thinking=False, + transport="auto", + ) + + assert captured["native"] == 1 + assert captured["chat"] == 0 + assert captured["endpoint"] == "https://hermes.example.com" + assert captured["api_key"] == "ak-hermes" + + +def test_run_invoke_command_defaults_to_openclaw_native_tui_for_openclaw_state(monkeypatch, tmp_path: Path): + (tmp_path / ".agentengine.state").write_text( + yaml.safe_dump( + { + "type": "openclaw", + "framework": "openclaw", + "endpoint": "https://openclaw.example.com", + "api_key": "ak-openclaw", + } + ), + encoding="utf-8", + ) + + captured = {"native": 0, "chat": 0} + + def _fake_native(endpoint, api_key=None, session_id=None, insecure=False): + captured["native"] += 1 + captured["endpoint"] = endpoint + captured["api_key"] = api_key + + def _fake_chat( + endpoint, + api_key=None, + session_id=None, + insecure=False, + model=None, + show_thinking=False, + api_format=None, + responses_session_header=None, + ): + captured["chat"] += 1 + captured["endpoint"] = endpoint + captured["api_key"] = api_key + captured["api_format"] = api_format + captured["responses_session_header"] = responses_session_header + + monkeypatch.chdir(tmp_path) + monkeypatch.setattr("ksadk.cli.cmd_invoke._invoke_openclaw_terminal_tui", _fake_native) + monkeypatch.setattr("ksadk.cli.cmd_invoke._invoke_tui", _fake_chat) + monkeypatch.setattr( + "ksadk.cli.cmd_invoke._resolve_remote_api_format", + lambda **_kwargs: pytest.fail("native OpenClaw TUI must not probe /v1/responses"), + ) + + run_invoke_command( + agent_ref=None, + agent_option=None, + endpoint="https://openclaw.example.com", + api_key=None, + message=None, + session=None, + region="cn-beijing-6", + local=False, + insecure=False, + model=None, + show_thinking=False, + transport="auto", + ) + + assert captured["native"] == 1 + assert captured["chat"] == 0 + assert captured["endpoint"] == "https://openclaw.example.com" + assert captured["api_key"] == "ak-openclaw" + + +def test_run_invoke_command_transport_chat_uses_responses_tui_for_openclaw_state(monkeypatch, tmp_path: Path): + (tmp_path / ".agentengine.state").write_text( + yaml.safe_dump( + { + "type": "openclaw", + "framework": "openclaw", + "endpoint": "https://openclaw.example.com", + "api_key": "ak-openclaw", + } + ), + encoding="utf-8", + ) + + captured = {"native": 0, "chat": 0} + + def _fake_native(*_args, **_kwargs): + captured["native"] += 1 + + def _fake_chat( + endpoint, + api_key=None, + session_id=None, + insecure=False, + model=None, + show_thinking=False, + api_format=None, + responses_session_header=None, + ): + captured["chat"] += 1 + captured["endpoint"] = endpoint + captured["api_key"] = api_key + captured["api_format"] = api_format + captured["responses_session_header"] = responses_session_header + + monkeypatch.chdir(tmp_path) + monkeypatch.setattr("ksadk.cli.cmd_invoke._invoke_openclaw_terminal_tui", _fake_native) + monkeypatch.setattr("ksadk.cli.cmd_invoke._invoke_tui", _fake_chat) + monkeypatch.setattr( + "ksadk.cli.cmd_invoke._resolve_remote_api_format", + lambda **_kwargs: asyncio.sleep(0, result="responses"), + ) + + run_invoke_command( + agent_ref=None, + agent_option=None, + endpoint="https://openclaw.example.com", + api_key=None, + message=None, + session=None, + region="cn-beijing-6", + local=False, + insecure=False, + model=None, + show_thinking=False, + transport="chat", + ) + + assert captured["native"] == 0 + assert captured["chat"] == 1 + assert captured["endpoint"] == "https://openclaw.example.com" + assert captured["api_key"] == "ak-openclaw" + assert captured["api_format"] == "responses" + assert captured["responses_session_header"] == "x-openclaw-session-key" + + +def test_run_invoke_command_uses_openclaw_gateway_token_env_for_runtime_calls(monkeypatch, tmp_path: Path): + (tmp_path / ".agentengine.state").write_text( + yaml.safe_dump( + { + "type": "openclaw", + "framework": "openclaw", + "endpoint": "https://openclaw.example.com", + "api_key": "ak-openclaw", + "openclaw_auth_mode": "token", + } + ), + encoding="utf-8", + ) + + captured = {} + + def _fake_chat( + endpoint, + api_key=None, + session_id=None, + insecure=False, + model=None, + show_thinking=False, + api_format=None, + responses_session_header=None, + ): + captured["endpoint"] = endpoint + captured["runtime_api_key"] = api_key + captured["api_format"] = api_format + captured["responses_session_header"] = responses_session_header + + async def _fake_resolve_remote_api_format(**kwargs): + captured["probe_api_key"] = kwargs["runtime_api_key"] + return "responses" + + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("OPENCLAW_GATEWAY_TOKEN", "gateway-token") + monkeypatch.setattr("ksadk.cli.cmd_invoke._invoke_tui", _fake_chat) + monkeypatch.setattr("ksadk.cli.cmd_invoke._resolve_remote_api_format", _fake_resolve_remote_api_format) + + run_invoke_command( + agent_ref=None, + agent_option=None, + endpoint="https://openclaw.example.com", + api_key=None, + message=None, + session=None, + region="cn-beijing-6", + local=False, + insecure=False, + model=None, + show_thinking=False, + transport="chat", + ) + + assert captured["endpoint"] == "https://openclaw.example.com" + assert captured["probe_api_key"] == "gateway-token" + assert captured["runtime_api_key"] == "gateway-token" + assert captured["api_format"] == "responses" + assert captured["responses_session_header"] == "x-openclaw-session-key" + + +def test_run_invoke_command_uses_openclaw_gateway_token_state_for_runtime_calls(monkeypatch, tmp_path: Path): + (tmp_path / ".agentengine.state").write_text( + yaml.safe_dump( + { + "type": "openclaw", + "framework": "openclaw", + "endpoint": "https://openclaw.example.com", + "api_key": "ak-openclaw", + "openclaw_auth_mode": "token", + "openclaw_gateway_token": "gateway-token-from-state", + } + ), + encoding="utf-8", + ) + + captured = {} + + def _fake_chat( + endpoint, + api_key=None, + session_id=None, + insecure=False, + model=None, + show_thinking=False, + api_format=None, + responses_session_header=None, + ): + captured["endpoint"] = endpoint + captured["runtime_api_key"] = api_key + captured["api_format"] = api_format + captured["responses_session_header"] = responses_session_header + + async def _fake_resolve_remote_api_format(**kwargs): + captured["probe_api_key"] = kwargs["runtime_api_key"] + return "responses" + + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("OPENCLAW_GATEWAY_TOKEN", raising=False) + monkeypatch.delenv("OPENCLAW_GATEWAY_PASSWORD", raising=False) + monkeypatch.setattr("ksadk.cli.cmd_invoke._invoke_tui", _fake_chat) + monkeypatch.setattr("ksadk.cli.cmd_invoke._resolve_remote_api_format", _fake_resolve_remote_api_format) + + run_invoke_command( + agent_ref=None, + agent_option=None, + endpoint="https://openclaw.example.com", + api_key=None, + message=None, + session=None, + region="cn-beijing-6", + local=False, + insecure=False, + model=None, + show_thinking=False, + transport="chat", + ) + + assert captured["endpoint"] == "https://openclaw.example.com" + assert captured["probe_api_key"] == "gateway-token-from-state" + assert captured["runtime_api_key"] == "gateway-token-from-state" + assert captured["api_format"] == "responses" + assert captured["responses_session_header"] == "x-openclaw-session-key" + + +def test_run_invoke_command_rejects_openclaw_token_mode_without_gateway_token(monkeypatch, tmp_path: Path): + (tmp_path / ".agentengine.state").write_text( + yaml.safe_dump( + { + "type": "openclaw", + "framework": "openclaw", + "endpoint": "https://openclaw.example.com", + "api_key": "ak-openclaw", + "openclaw_auth_mode": "token", + } + ), + encoding="utf-8", + ) + + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("OPENCLAW_GATEWAY_TOKEN", raising=False) + monkeypatch.delenv("OPENCLAW_GATEWAY_PASSWORD", raising=False) + + with pytest.raises(click.ClickException) as exc_info: + run_invoke_command( + agent_ref=None, + agent_option=None, + endpoint="https://openclaw.example.com", + api_key=None, + message=None, + session=None, + region="cn-beijing-6", + local=False, + insecure=False, + model=None, + show_thinking=False, + transport="auto", + ) + + assert "OPENCLAW_GATEWAY_TOKEN" in str(exc_info.value) + assert "--gateway-token" in str(exc_info.value) + + +def test_run_invoke_command_resolves_openclaw_state_without_explicit_agent(monkeypatch, tmp_path: Path): + state_file = tmp_path / ".agentengine.state" + state_file.write_text( + yaml.safe_dump( + { + "agent_id": "ar-openclaw-demo", + "type": "openclaw", + "framework": "openclaw", + "endpoint": "https://stale-openclaw.example.com", + "api_key": "ak-stale", + } + ), + encoding="utf-8", + ) + + captured = {} + + async def _fake_invoke_once(endpoint, message, api_key, session_id, stream, insecure, model, api_format="chat_completions"): + captured["endpoint"] = endpoint + captured["api_key"] = api_key + captured["api_format"] = api_format + + async def _fake_resolve_remote_api_format(**_kwargs): + return "responses" + + monkeypatch.chdir(tmp_path) + monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawInvokeClient) + monkeypatch.setattr("ksadk.cli.cmd_invoke._invoke_once", _fake_invoke_once) + monkeypatch.setattr("ksadk.cli.cmd_invoke._resolve_remote_api_format", _fake_resolve_remote_api_format) + + run_invoke_command( + agent_ref=None, + agent_option=None, + endpoint=None, + api_key=None, + message="hello", + session=None, + region="pre-online", + local=False, + insecure=False, + transport="auto", + model=None, + show_thinking=False, + ) + + state = yaml.safe_load(state_file.read_text(encoding="utf-8")) + assert captured["endpoint"] == "https://openclaw.example.com" + assert captured["api_key"] == "ak-openclaw" + assert captured["api_format"] == "responses" + assert state["type"] == "openclaw" + assert state["framework"] == "openclaw" + assert _FakeOpenClawInvokeClient.calls[-1] == { + "agent_id": "ar-openclaw-demo", + "name": None, + "include_api_key": True, + } + + +def test_run_invoke_command_transport_chat_rejects_generic_chat_tui_for_hermes(monkeypatch, tmp_path: Path): + (tmp_path / ".agentengine.state").write_text( + yaml.safe_dump( + { + "type": "hermes", + "framework": "hermes", + "endpoint": "https://hermes.example.com", + } + ), + encoding="utf-8", + ) + + captured = {"native": 0, "chat": 0} + + def _fake_native(*_args, **_kwargs): + captured["native"] += 1 + + def _fake_chat(*_args, **_kwargs): + captured["chat"] += 1 + + monkeypatch.chdir(tmp_path) + monkeypatch.setattr("ksadk.cli.cmd_invoke._invoke_hermes_terminal_tui", _fake_native) + monkeypatch.setattr("ksadk.cli.cmd_invoke._invoke_tui", _fake_chat) + + with pytest.raises(SystemExit) as exc_info: + run_invoke_command( + agent_ref=None, + agent_option=None, + endpoint="https://hermes.example.com", + api_key=None, + message=None, + session=None, + region="cn-beijing-6", + local=False, + insecure=False, + model=None, + show_thinking=False, + transport="chat", + ) + + assert exc_info.value.code == 1 + assert captured["native"] == 0 + assert captured["chat"] == 0 + + +def test_run_invoke_command_message_mode_keeps_http_chat_path(monkeypatch, tmp_path: Path): + (tmp_path / ".agentengine.state").write_text( + yaml.safe_dump( + { + "type": "hermes", + "framework": "hermes", + "endpoint": "https://hermes.example.com", + } + ), + encoding="utf-8", + ) + + captured = {"once": 0, "native": 0, "chat": 0} + + async def _fake_invoke_once(endpoint, message, api_key, session_id, stream, insecure, model, api_format="chat_completions"): + captured["once"] += 1 + captured["endpoint"] = endpoint + captured["message"] = message + + def _fake_native(*_args, **_kwargs): + captured["native"] += 1 + + def _fake_chat(*_args, **_kwargs): + captured["chat"] += 1 + + monkeypatch.chdir(tmp_path) + monkeypatch.setattr("ksadk.cli.cmd_invoke._invoke_once", _fake_invoke_once) + monkeypatch.setattr("ksadk.cli.cmd_invoke._invoke_hermes_terminal_tui", _fake_native) + monkeypatch.setattr("ksadk.cli.cmd_invoke._invoke_tui", _fake_chat) + + run_invoke_command( + agent_ref=None, + agent_option=None, + endpoint="https://hermes.example.com", + api_key=None, + message="hello", + session=None, + region="cn-beijing-6", + local=False, + insecure=False, + model="glm-5", + show_thinking=False, + transport="auto", + ) + + assert captured["once"] == 1 + assert captured["native"] == 0 + assert captured["chat"] == 0 + assert captured["endpoint"] == "https://hermes.example.com" + assert captured["message"] == "hello" + + +def test_invoke_hermes_terminal_tui_exits_cleanly_on_keyboard_interrupt(monkeypatch): + class _ImmediateAwaitable: + def __await__(self): + if False: + yield None + return 0 + + def _fake_terminal_session(**_kwargs): + return _ImmediateAwaitable() + + def _raise_keyboard_interrupt(_awaitable): + raise KeyboardInterrupt + + monkeypatch.setattr("ksadk.cli.cmd_invoke._warmup_hermes_terminal", lambda **_kwargs: None) + monkeypatch.setattr("ksadk.cli.cmd_invoke.run_hermes_terminal_session", _fake_terminal_session) + monkeypatch.setattr("ksadk.cli.cmd_invoke.asyncio.run", _raise_keyboard_interrupt) + + with pytest.raises(SystemExit) as exc_info: + _invoke_hermes_terminal_tui( + endpoint="https://hermes.example.com", + api_key="ak-hermes", + session_id="sess-1", + insecure=False, + ) + + assert exc_info.value.code == 130 + + +def test_invoke_hermes_terminal_tui_warms_up_with_status_before_tui(monkeypatch): + calls = [] + + async def _fake_terminal_session(**kwargs): + calls.append(kwargs) + return 0 + + monkeypatch.setattr("ksadk.cli.cmd_invoke.run_hermes_terminal_session", _fake_terminal_session) + + _invoke_hermes_terminal_tui( + endpoint="https://hermes.example.com", + api_key="ak-hermes", + session_id="sess-1", + insecure=False, + ) + + assert [call["mode"] for call in calls] == ["exec", "tui"] + assert calls[0]["argv"] == ["status"] + assert calls[0]["endpoint"] == "https://hermes.example.com" + assert calls[0]["api_key"] == "ak-hermes" + assert calls[0]["session_id"] == "sess-1" + assert calls[1]["argv"] == [] + + +def test_invoke_openclaw_terminal_tui_uses_common_terminal_client(monkeypatch): + captured = {} + + def _fake_terminal_session(**kwargs): + captured.update(kwargs) + return object() + + def _run_success(_awaitable): + return 0 + + monkeypatch.setattr("ksadk.cli.cmd_invoke.run_terminal_session", _fake_terminal_session) + monkeypatch.setattr("ksadk.cli.cmd_invoke.asyncio.run", _run_success) + + _invoke_openclaw_terminal_tui( + endpoint="https://openclaw.example.com", + api_key="gateway-token", + session_id="sess-1", + insecure=True, + ) + + assert captured["endpoint"] == "https://openclaw.example.com" + assert captured["api_key"] == "gateway-token" + assert captured["session_id"] == "sess-1" + assert captured["mode"] == "tui" + + +def test_run_invoke_command_syncs_local_workspace_before_hermes_native_tui(monkeypatch, tmp_path: Path): + workspace_dir = tmp_path / "demo-workspace" + workspace_dir.mkdir() + (workspace_dir / "notes.txt").write_text("hello workspace", encoding="utf-8") + (tmp_path / ".agentengine.state").write_text( + yaml.safe_dump( + { + "agent_id": "ar-hermes-1", + "type": "hermes", + "framework": "hermes", + "endpoint": "https://hermes.example.com", + "api_key": "ak-hermes", + } + ), + encoding="utf-8", + ) + + captured = {} + + async def _fake_sync_local_workspace_for_hermes_invoke(**kwargs): + captured["sync_kwargs"] = kwargs + return { + "remote_path": "demo-workspace", + "local_dir": str(workspace_dir), + "created": ["demo-workspace/notes.txt"], + "overwritten": [], + "skipped": [], + "total_files": 1, + "direction": "push", + } + + def _fake_emit_sync_payload(payload, _output_mode): + captured["sync_payload"] = payload + + def _fake_native(endpoint, api_key=None, session_id=None, insecure=False, cwd=None): + captured["native"] = { + "endpoint": endpoint, + "api_key": api_key, + "session_id": session_id, + "cwd": cwd, + } + + monkeypatch.chdir(tmp_path) + monkeypatch.setattr( + "ksadk.cli.cmd_invoke._sync_local_workspace_for_hermes_invoke", + _fake_sync_local_workspace_for_hermes_invoke, + ) + monkeypatch.setattr("ksadk.cli.cmd_invoke._emit_sync_payload", _fake_emit_sync_payload) + monkeypatch.setattr("ksadk.cli.cmd_invoke._build_sync_payload", lambda payload: payload) + monkeypatch.setattr("ksadk.cli.cmd_invoke._invoke_hermes_terminal_tui", _fake_native) + monkeypatch.setattr("ksadk.cli.cmd_invoke._invoke_tui", lambda *_args, **_kwargs: None) + + run_invoke_command( + agent_ref=None, + agent_option=None, + endpoint="https://hermes.example.com", + api_key=None, + message=None, + session=None, + region="pre-online", + local=False, + insecure=False, + transport="auto", + model=None, + show_thinking=False, + local_workspace=str(workspace_dir), + remote_workspace_path=None, + ) + + assert captured["sync_kwargs"]["remote_path"] == "demo-workspace" + assert captured["native"]["cwd"] == "demo-workspace" + + +def test_run_invoke_command_rejects_local_workspace_outside_hermes_native(monkeypatch, tmp_path: Path): + workspace_dir = tmp_path / "demo-workspace" + workspace_dir.mkdir() + (tmp_path / ".agentengine.state").write_text( + yaml.safe_dump( + { + "type": "hermes", + "framework": "hermes", + "endpoint": "https://hermes.example.com", + } + ), + encoding="utf-8", + ) + + monkeypatch.chdir(tmp_path) + + with pytest.raises(SystemExit) as exc_info: + run_invoke_command( + agent_ref=None, + agent_option=None, + endpoint="https://hermes.example.com", + api_key=None, + message="hello", + session=None, + region="pre-online", + local=False, + insecure=False, + transport="auto", + model=None, + show_thinking=False, + local_workspace=str(workspace_dir), + remote_workspace_path=None, + ) + + assert exc_info.value.code == 1 + + +def test_run_invoke_command_rejects_remote_workspace_path_without_local_workspace(monkeypatch, tmp_path: Path): + monkeypatch.chdir(tmp_path) + + with pytest.raises(SystemExit) as exc_info: + run_invoke_command( + agent_ref=None, + agent_option=None, + endpoint="https://hermes.example.com", + api_key="ak-hermes", + message=None, + session=None, + region="pre-online", + local=False, + insecure=False, + transport="native", + model=None, + show_thinking=False, + local_workspace=None, + remote_workspace_path="demo-workspace", + ) + + assert exc_info.value.code == 1 + + +def test_sync_local_workspace_for_hermes_invoke_rejects_single_file_over_limit(monkeypatch, tmp_path: Path): + from ksadk.cli.cmd_invoke import _sync_local_workspace_for_hermes_invoke + + workspace_dir = tmp_path / "demo-workspace" + workspace_dir.mkdir() + oversized = workspace_dir / "large.bin" + oversized.write_bytes(b"0123456789") + + async def _fake_lookup_workspace_upload_limit(**_kwargs): + return 5 + + async def _fake_push_workspace_files(**_kwargs): + raise AssertionError("should reject before uploading") + + monkeypatch.setattr( + "ksadk.cli.cmd_invoke._lookup_workspace_upload_limit", + _fake_lookup_workspace_upload_limit, + ) + monkeypatch.setattr( + "ksadk.cli.cmd_invoke._push_workspace_files", + _fake_push_workspace_files, + ) + + with pytest.raises(click.ClickException) as exc_info: + asyncio.run( + _sync_local_workspace_for_hermes_invoke( + agent_ref="ar-hermes-1", + local_workspace=workspace_dir, + remote_path="demo-workspace", + region="pre-online", + endpoint="https://hermes.example.com", + api_key="ak-hermes", + ) + ) + + assert "超过" in str(exc_info.value) + + +def test_sync_local_workspace_for_hermes_invoke_rejects_total_directory_size_over_limit(monkeypatch, tmp_path: Path): + from ksadk.cli.cmd_invoke import _sync_local_workspace_for_hermes_invoke + + workspace_dir = tmp_path / "demo-workspace" + workspace_dir.mkdir() + (workspace_dir / "a.txt").write_bytes(b"1234") + (workspace_dir / "b.txt").write_bytes(b"5678") + + async def _fake_lookup_workspace_upload_limit(**_kwargs): + return 7 + + async def _fake_push_workspace_files(**_kwargs): + raise AssertionError("should reject before uploading") + + monkeypatch.setattr( + "ksadk.cli.cmd_invoke._lookup_workspace_upload_limit", + _fake_lookup_workspace_upload_limit, + ) + monkeypatch.setattr( + "ksadk.cli.cmd_invoke._push_workspace_files", + _fake_push_workspace_files, + ) + + with pytest.raises(click.ClickException) as exc_info: + asyncio.run( + _sync_local_workspace_for_hermes_invoke( + agent_ref="ar-hermes-1", + local_workspace=workspace_dir, + remote_path="demo-workspace", + region="pre-online", + endpoint="https://hermes.example.com", + api_key="ak-hermes", + ) + ) + + assert "目录总大小" in str(exc_info.value) + + +def test_sync_local_workspace_for_hermes_invoke_reports_progress(monkeypatch, tmp_path: Path): + from ksadk.cli.cmd_invoke import _sync_local_workspace_for_hermes_invoke + + workspace_dir = tmp_path / "demo-workspace" + workspace_dir.mkdir() + (workspace_dir / "a.txt").write_text("hello", encoding="utf-8") + events: list[dict] = [] + + async def _fake_lookup_workspace_upload_limit(**_kwargs): + return 100 + + async def _fake_push_workspace_files(**kwargs): + kwargs["progress_callback"]( + { + "phase": "upload_start", + "current": 1, + "total": 1, + "remote_path": "demo-workspace/a.txt", + "local_path": str(workspace_dir / "a.txt"), + "size_bytes": 5, + } + ) + kwargs["progress_callback"]( + { + "phase": "upload_done", + "current": 1, + "total": 1, + "remote_path": "demo-workspace/a.txt", + } + ) + return { + "remote_path": "demo-workspace", + "local_dir": str(workspace_dir), + "created": ["demo-workspace/a.txt"], + "overwritten": [], + "skipped": [], + "total_files": 1, + "direction": "push", + } + + monkeypatch.setattr( + "ksadk.cli.cmd_invoke._lookup_workspace_upload_limit", + _fake_lookup_workspace_upload_limit, + ) + monkeypatch.setattr( + "ksadk.cli.cmd_invoke._push_workspace_files", + _fake_push_workspace_files, + ) + + payload = asyncio.run( + _sync_local_workspace_for_hermes_invoke( + agent_ref="ar-hermes-1", + local_workspace=workspace_dir, + remote_path="demo-workspace", + region="pre-online", + endpoint="https://hermes.example.com", + api_key="ak-hermes", + progress_callback=events.append, + ) + ) + + assert payload["total_files"] == 1 + assert [event["phase"] for event in events] == [ + "limit_done", + "scan_done", + "upload_start", + "upload_done", + ] + assert events[1]["total_files"] == 1 + assert events[1]["total_bytes"] == 5 + + +def test_sync_local_workspace_for_hermes_invoke_ignores_local_dev_artifacts(monkeypatch, tmp_path: Path): + from ksadk.cli.cmd_invoke import _sync_local_workspace_for_hermes_invoke + + workspace_dir = tmp_path / "demo-workspace" + workspace_dir.mkdir() + (workspace_dir / "app.py").write_text("print('ok')\n", encoding="utf-8") + + git_object = workspace_dir / ".git" / "objects" / "aa" + git_object.mkdir(parents=True) + (git_object / "blob").write_bytes(b"x" * 4096) + + events: list[dict] = [] + captured: dict[str, object] = {} + + async def _fake_lookup_workspace_upload_limit(**_kwargs): + return 1024 + + async def _fake_push_workspace_files(**kwargs): + captured["ignore_dev_artifacts"] = kwargs["ignore_dev_artifacts"] + return { + "remote_path": "demo-workspace", + "local_dir": str(workspace_dir), + "created": ["demo-workspace/app.py"], + "overwritten": [], + "skipped": [], + "total_files": 1, + "direction": "push", + } + + monkeypatch.setattr( + "ksadk.cli.cmd_invoke._lookup_workspace_upload_limit", + _fake_lookup_workspace_upload_limit, + ) + monkeypatch.setattr( + "ksadk.cli.cmd_invoke._push_workspace_files", + _fake_push_workspace_files, + ) + + payload = asyncio.run( + _sync_local_workspace_for_hermes_invoke( + agent_ref="ar-hermes-1", + local_workspace=workspace_dir, + remote_path="demo-workspace", + region="pre-online", + endpoint="https://hermes.example.com", + api_key="ak-hermes", + progress_callback=events.append, + ) + ) + + assert payload["total_files"] == 1 + assert events[1]["total_files"] == 1 + assert events[1]["total_bytes"] == 12 + assert captured["ignore_dev_artifacts"] is True + + +def test_emit_workspace_sync_progress_shows_percentage_bar(capsys): + from ksadk.cli.cmd_invoke import _build_workspace_sync_progress_emitter + + emitter = _build_workspace_sync_progress_emitter(verbose=True) + emitter( + { + "phase": "upload_start", + "current": 2, + "total": 4, + "remote_path": "demo-workspace/app.py", + "size_bytes": 12, + } + ) + + output = capsys.readouterr().out + assert "50%" in output + assert "2/4" in output + assert "上传 demo-workspace/app.py" in output + assert "[" in output and "]" in output + + +def test_workspace_sync_progress_emitter_uses_inline_updates_by_default(monkeypatch): + from ksadk.cli import cmd_invoke + + echo_calls: list[dict] = [] + + def _fake_echo(message="", **kwargs): + echo_calls.append({"message": message, "kwargs": kwargs}) + + monkeypatch.setattr(cmd_invoke.click, "echo", _fake_echo) + monkeypatch.setattr(cmd_invoke.click, "secho", lambda *args, **kwargs: None) + + emitter = cmd_invoke._build_workspace_sync_progress_emitter(verbose=False) + emitter( + { + "phase": "upload_start", + "current": 1, + "total": 3, + "remote_path": "demo-workspace/a-very-long-file-name.txt", + "size_bytes": 5, + } + ) + emitter( + { + "phase": "upload_start", + "current": 2, + "total": 3, + "remote_path": "b.txt", + "size_bytes": 5, + } + ) + emitter( + { + "phase": "upload_done", + "current": 3, + "total": 3, + "remote_path": "demo-workspace/c.txt", + } + ) + + upload_calls = [call for call in echo_calls if "上传 " in str(call["message"])] + assert len(upload_calls) == 2 + assert all(call["kwargs"].get("nl") is False for call in upload_calls) + assert any(call["message"] == "" for call in echo_calls) + assert str(upload_calls[1]["message"]).endswith(" ") + + +def test_workspace_sync_progress_emitter_supports_verbose_file_logs(monkeypatch): + from ksadk.cli import cmd_invoke + + echo_calls: list[dict] = [] + + def _fake_echo(message="", **kwargs): + echo_calls.append({"message": message, "kwargs": kwargs}) + + monkeypatch.setattr(cmd_invoke.click, "echo", _fake_echo) + monkeypatch.setattr(cmd_invoke.click, "secho", lambda *args, **kwargs: None) + + emitter = cmd_invoke._build_workspace_sync_progress_emitter(verbose=True) + emitter( + { + "phase": "upload_start", + "current": 2, + "total": 4, + "remote_path": "demo-workspace/app.py", + "size_bytes": 12, + } + ) + + assert echo_calls + assert "上传 demo-workspace/app.py" in str(echo_calls[0]["message"]) + assert echo_calls[0]["kwargs"].get("nl", True) is True + + +def test_run_invoke_command_builds_verbose_workspace_sync_emitter(monkeypatch, tmp_path: Path): + workspace_dir = tmp_path / "demo-workspace" + workspace_dir.mkdir() + (workspace_dir / "notes.txt").write_text("hello workspace", encoding="utf-8") + (tmp_path / ".agentengine.state").write_text( + yaml.safe_dump( + { + "agent_id": "ar-hermes-1", + "type": "hermes", + "framework": "hermes", + "endpoint": "https://hermes.example.com", + "api_key": "ak-hermes", + } + ), + encoding="utf-8", + ) + + captured: dict[str, object] = {} + + async def _fake_sync_local_workspace_for_hermes_invoke(**kwargs): + captured["progress_callback"] = kwargs["progress_callback"] + return { + "remote_path": "demo-workspace", + "local_dir": str(workspace_dir), + "created": ["demo-workspace/notes.txt"], + "overwritten": [], + "skipped": [], + "total_files": 1, + "direction": "push", + } + + monkeypatch.chdir(tmp_path) + monkeypatch.setattr( + "ksadk.cli.cmd_invoke._sync_local_workspace_for_hermes_invoke", + _fake_sync_local_workspace_for_hermes_invoke, + ) + monkeypatch.setattr("ksadk.cli.cmd_invoke._emit_sync_payload", lambda *_args, **_kwargs: None) + monkeypatch.setattr("ksadk.cli.cmd_invoke._build_sync_payload", lambda payload: payload) + monkeypatch.setattr("ksadk.cli.cmd_invoke._invoke_hermes_terminal_tui", lambda **_kwargs: None) + monkeypatch.setattr( + "ksadk.cli.cmd_invoke._build_workspace_sync_progress_emitter", + lambda verbose: captured.setdefault("verbose_workspace_sync", verbose) or (lambda _event: None), + ) + + run_invoke_command( + agent_ref=None, + agent_option=None, + endpoint="https://hermes.example.com", + api_key=None, + message=None, + session=None, + region="pre-online", + local=False, + insecure=False, + transport="auto", + model=None, + show_thinking=False, + local_workspace=str(workspace_dir), + remote_workspace_path=None, + verbose_workspace_sync=True, + ) + + assert captured["verbose_workspace_sync"] is True + + +def test_sync_local_workspace_for_hermes_invoke_keeps_git_when_under_limit(monkeypatch, tmp_path: Path): + from ksadk.cli.cmd_invoke import _sync_local_workspace_for_hermes_invoke + + workspace_dir = tmp_path / "demo-workspace" + workspace_dir.mkdir() + (workspace_dir / "app.py").write_text("print('ok')\n", encoding="utf-8") + git_dir = workspace_dir / ".git" + git_dir.mkdir() + (git_dir / "HEAD").write_text("ref: refs/heads/main\n", encoding="utf-8") + + captured: dict[str, object] = {} + + async def _fake_lookup_workspace_upload_limit(**_kwargs): + return 1024 * 1024 + + async def _fake_push_workspace_files(**kwargs): + captured["ignore_git_artifacts"] = kwargs["ignore_git_artifacts"] + return { + "remote_path": "demo-workspace", + "local_dir": str(workspace_dir), + "created": ["demo-workspace/app.py", "demo-workspace/.git/HEAD"], + "overwritten": [], + "skipped": [], + "total_files": 2, + "direction": "push", + } + + monkeypatch.setattr( + "ksadk.cli.cmd_invoke._lookup_workspace_upload_limit", + _fake_lookup_workspace_upload_limit, + ) + monkeypatch.setattr( + "ksadk.cli.cmd_invoke._push_workspace_files", + _fake_push_workspace_files, + ) + + payload = asyncio.run( + _sync_local_workspace_for_hermes_invoke( + agent_ref="ar-hermes-1", + local_workspace=workspace_dir, + remote_path="demo-workspace", + region="pre-online", + endpoint="https://hermes.example.com", + api_key="ak-hermes", + ) + ) + + assert payload["total_files"] == 2 + assert captured["ignore_git_artifacts"] is False + + +def test_sync_local_workspace_for_hermes_invoke_drops_git_when_needed_for_limit(monkeypatch, tmp_path: Path): + from ksadk.cli.cmd_invoke import _sync_local_workspace_for_hermes_invoke + + workspace_dir = tmp_path / "demo-workspace" + workspace_dir.mkdir() + (workspace_dir / "app.py").write_text("print('ok')\n", encoding="utf-8") + git_objects = workspace_dir / ".git" / "objects" + git_objects.mkdir(parents=True) + (git_objects / "blob").write_bytes(b"x" * 600) + + events: list[dict] = [] + captured: dict[str, object] = {} + + async def _fake_lookup_workspace_upload_limit(**_kwargs): + return 512 + + async def _fake_push_workspace_files(**kwargs): + captured["ignore_git_artifacts"] = kwargs["ignore_git_artifacts"] + return { + "remote_path": "demo-workspace", + "local_dir": str(workspace_dir), + "created": ["demo-workspace/app.py"], + "overwritten": [], + "skipped": [], + "total_files": 1, + "direction": "push", + } + + monkeypatch.setattr( + "ksadk.cli.cmd_invoke._lookup_workspace_upload_limit", + _fake_lookup_workspace_upload_limit, + ) + monkeypatch.setattr( + "ksadk.cli.cmd_invoke._push_workspace_files", + _fake_push_workspace_files, + ) + + payload = asyncio.run( + _sync_local_workspace_for_hermes_invoke( + agent_ref="ar-hermes-1", + local_workspace=workspace_dir, + remote_path="demo-workspace", + region="pre-online", + endpoint="https://hermes.example.com", + api_key="ak-hermes", + progress_callback=events.append, + ) + ) + + assert payload["total_files"] == 1 + assert captured["ignore_git_artifacts"] is True + assert ".git" in events[1]["ignored_artifacts"] + + +def test_sync_local_workspace_for_hermes_invoke_wraps_remote_errors(monkeypatch, tmp_path: Path): + from ksadk.cli.cmd_invoke import _sync_local_workspace_for_hermes_invoke + + workspace_dir = tmp_path / "demo-workspace" + workspace_dir.mkdir() + (workspace_dir / "a.txt").write_text("hello", encoding="utf-8") + events: list[dict] = [] + + async def _fake_lookup_workspace_upload_limit(**_kwargs): + return 100 + + async def _fake_push_workspace_files(**kwargs): + kwargs["progress_callback"]( + { + "phase": "upload_start", + "current": 1, + "total": 1, + "remote_path": "demo-workspace/a.txt", + } + ) + raise AgentEngineAPIError(500, "runtime exploded") + + monkeypatch.setattr( + "ksadk.cli.cmd_invoke._lookup_workspace_upload_limit", + _fake_lookup_workspace_upload_limit, + ) + monkeypatch.setattr( + "ksadk.cli.cmd_invoke._push_workspace_files", + _fake_push_workspace_files, + ) + + with pytest.raises(click.ClickException) as exc_info: + asyncio.run( + _sync_local_workspace_for_hermes_invoke( + agent_ref="ar-hermes-1", + local_workspace=workspace_dir, + remote_path="demo-workspace", + region="pre-online", + endpoint="https://hermes.example.com", + api_key="ak-hermes", + progress_callback=events.append, + ) + ) + + message = str(exc_info.value) + assert "同步远端 workspace 失败" in message + assert "上传 demo-workspace/a.txt" in message + assert "runtime exploded" in message diff --git a/tests/test_cmd_launch_no_cache.py b/tests/test_cmd_launch_no_cache.py new file mode 100644 index 00000000..1338cf08 --- /dev/null +++ b/tests/test_cmd_launch_no_cache.py @@ -0,0 +1,286 @@ +import asyncio +from pathlib import Path + +from click.testing import CliRunner + +from ksadk.cli import cmd_launch +from ksadk.deployment.base import DeployResult, DeployStatus, PackageInfo + + +class _FakeDetectionType: + value = "langgraph" + + +class _FakeDetectionResult: + type = _FakeDetectionType() + name = "langgraph" + entry_point = "agent.py" + + +class _FakeProvider: + def __init__(self): + self.calls = [] + self.package_metadata_file_exists = None + self.last_target = None + + async def validate_config(self, _target): + self.last_target = _target + self.calls.append("validate") + return True, "" + + async def package(self, project_dir, _detection_result, _config): + self.calls.append("package") + metadata_file = Path(project_dir) / ".agentengine" / "build-metadata.json" + self.package_metadata_file_exists = metadata_file.exists() + return PackageInfo( + name="demo-agent", + framework="langgraph", + build_dir=str(Path(project_dir) / ".agentengine" / "build"), + project_dir=str(project_dir), + metadata={}, + ) + + async def build(self, package_info, _target): + self.calls.append("build") + package_info.metadata["ks3_path"] = "ks3://bucket/agents/demo-agent/code_20260320170000.zip" + return package_info + + async def deploy(self, _package_info, _target): + self.calls.append("deploy") + return DeployResult( + status=DeployStatus.DEPLOYING, + agent_id="ar-demo", + agent_name="demo-agent", + endpoint="http://demo-endpoint", + message="ok", + ) + + +def test_launch_no_cache_triggers_build_and_clears_metadata(tmp_path: Path, monkeypatch): + provider = _FakeProvider() + metadata_dir = tmp_path / ".agentengine" + metadata_dir.mkdir(parents=True, exist_ok=True) + (metadata_dir / "build-metadata.json").write_text('{"metadata":{"ks3_path":"ks3://old/path.zip"}}', encoding="utf-8") + + monkeypatch.setattr("ksadk.detection.FrameworkDetector", lambda *_args, **_kwargs: type("D", (), {"detect": lambda self: _FakeDetectionResult()})()) + monkeypatch.setattr("ksadk.cli.cmd_launch._load_config", lambda *_args, **_kwargs: {"name": "demo-agent"}) + monkeypatch.setattr("ksadk.deployment.DeploymentManager.get_provider", lambda *_args, **_kwargs: provider) + + asyncio.run( + cmd_launch._launch_async( + agent_dir=str(tmp_path), + target="serverless", + name=None, + region="cn-beijing-6", + account_id="2000003485", + observability=True, + no_cache=True, + port=8000, + namespace="default", + registry=None, + ks3_bucket=None, + ks3_path=None, + image=None, + ui_profile=None, + ui_path=None, + ui_url=None, + dry_run=False, + artifact_type="Code", + no_version=True, + auto_rollback=False, + ) + ) + + assert provider.package_metadata_file_exists is False + assert provider.calls == ["validate", "package", "build", "deploy"] + + +def test_launch_no_cache_warns_when_explicit_ks3_path_is_supplied(tmp_path: Path, monkeypatch, capsys): + provider = _FakeProvider() + + monkeypatch.setattr("ksadk.detection.FrameworkDetector", lambda *_args, **_kwargs: type("D", (), {"detect": lambda self: _FakeDetectionResult()})()) + monkeypatch.setattr("ksadk.cli.cmd_launch._load_config", lambda *_args, **_kwargs: {"name": "demo-agent"}) + monkeypatch.setattr("ksadk.deployment.DeploymentManager.get_provider", lambda *_args, **_kwargs: provider) + + asyncio.run( + cmd_launch._launch_async( + agent_dir=str(tmp_path), + target="serverless", + name=None, + region="cn-beijing-6", + account_id="2000003485", + observability=True, + no_cache=True, + port=8000, + namespace="default", + registry=None, + ks3_bucket=None, + ks3_path="ks3://bucket/agents/demo-agent/code_manual.zip", + image=None, + ui_profile=None, + ui_path=None, + ui_url=None, + dry_run=False, + artifact_type="Code", + no_version=True, + auto_rollback=False, + ) + ) + + out = capsys.readouterr().out + assert "已显式指定 --ks3-path" in out + assert provider.calls == ["validate", "package", "deploy"] + + +def test_launch_cli_network_options_apply_to_deploy_target(tmp_path: Path, monkeypatch): + provider = _FakeProvider() + runner = CliRunner() + + monkeypatch.setattr("ksadk.detection.FrameworkDetector", lambda *_args, **_kwargs: type("D", (), {"detect": lambda self: _FakeDetectionResult()})()) + monkeypatch.setattr("ksadk.cli.cmd_launch._load_config", lambda *_args, **_kwargs: {"name": "demo-agent"}) + monkeypatch.setattr("ksadk.deployment.DeploymentManager.get_provider", lambda *_args, **_kwargs: provider) + + result = runner.invoke( + cmd_launch.launch, + [ + str(tmp_path), + "--ks3-path", + "ks3://bucket/agents/demo-agent/code_manual.zip", + "--disable-public-access", + "--enable-vpc-access", + "--vpc-id", + "vpc-cli", + "--subnet-id", + "subnet-cli", + "--security-group-id", + "sg-cli", + "--availability-zone", + "cn-beijing-6b", + "--no-version", + ], + ) + + assert result.exit_code == 0, result.output + assert provider.last_target is not None + assert provider.last_target.network.enable_public_access is False + assert provider.last_target.network.enable_vpc_access is True + assert provider.last_target.network.vpc_id == "vpc-cli" + assert provider.last_target.network.subnet_id == "subnet-cli" + assert provider.last_target.network.security_group_id == "sg-cli" + assert provider.last_target.network.availability_zone == "cn-beijing-6b" + + +def test_launch_network_ids_imply_vpc_access(tmp_path: Path, monkeypatch): + provider = _FakeProvider() + runner = CliRunner() + + monkeypatch.setattr("ksadk.detection.FrameworkDetector", lambda *_args, **_kwargs: type("D", (), {"detect": lambda self: _FakeDetectionResult()})()) + monkeypatch.setattr("ksadk.cli.cmd_launch._load_config", lambda *_args, **_kwargs: {"name": "demo-agent"}) + monkeypatch.setattr("ksadk.deployment.DeploymentManager.get_provider", lambda *_args, **_kwargs: provider) + + result = runner.invoke( + cmd_launch.launch, + [ + str(tmp_path), + "--ks3-path", + "ks3://bucket/agents/demo-agent/code_manual.zip", + "--vpc-id", + "vpc-cli", + "--subnet-id", + "subnet-cli", + "--security-group-id", + "sg-cli", + "--no-version", + ], + ) + + assert result.exit_code == 0, result.output + assert provider.last_target is not None + assert provider.last_target.network.enable_vpc_access is True + + +def test_launch_cli_forwards_explicit_env_and_env_file(tmp_path: Path, monkeypatch): + provider = _FakeProvider() + runner = CliRunner() + env_file = tmp_path / "runtime.env" + env_file.write_text( + "APP_MODE=file\nFILE_ONLY=1\nOVERRIDE_ME=from-file\n", + encoding="utf-8", + ) + + monkeypatch.setattr("ksadk.detection.FrameworkDetector", lambda *_args, **_kwargs: type("D", (), {"detect": lambda self: _FakeDetectionResult()})()) + monkeypatch.setattr("ksadk.cli.cmd_launch._load_config", lambda *_args, **_kwargs: {"name": "demo-agent"}) + monkeypatch.setattr("ksadk.deployment.DeploymentManager.get_provider", lambda *_args, **_kwargs: provider) + + result = runner.invoke( + cmd_launch.launch, + [ + str(tmp_path), + "--ks3-path", + "ks3://bucket/agents/demo-agent/code_manual.zip", + "--env-file", + str(env_file), + "--env", + "OVERRIDE_ME=from-cli", + "--env", + "CLI_ONLY=yes", + "--no-version", + ], + ) + + assert result.exit_code == 0, result.output + assert provider.last_target is not None + assert provider.last_target.extra["env_vars"] == { + "APP_MODE": "file", + "FILE_ONLY": "1", + "OVERRIDE_ME": "from-cli", + "CLI_ONLY": "yes", + } + + +def test_launch_reads_ui_config_from_agentengine_yaml_when_cli_not_set(tmp_path: Path, monkeypatch): + provider = _FakeProvider() + + monkeypatch.setattr("ksadk.detection.FrameworkDetector", lambda *_args, **_kwargs: type("D", (), {"detect": lambda self: _FakeDetectionResult()})()) + monkeypatch.setattr( + "ksadk.cli.cmd_launch._load_config", + lambda *_args, **_kwargs: { + "name": "demo-agent", + "ui": { + "profile": "custom", + "path": "/custom-chat", + "url": "https://ui.example.com/custom-chat", + }, + }, + ) + monkeypatch.setattr("ksadk.deployment.DeploymentManager.get_provider", lambda *_args, **_kwargs: provider) + + asyncio.run( + cmd_launch._launch_async( + agent_dir=str(tmp_path), + target="serverless", + name=None, + region="cn-beijing-6", + account_id="2000003485", + observability=True, + no_cache=False, + port=8000, + namespace="default", + registry=None, + ks3_bucket=None, + ks3_path="ks3://bucket/agents/demo-agent/code_manual.zip", + image=None, + ui_profile=None, + ui_path=None, + ui_url=None, + dry_run=False, + artifact_type="Code", + no_version=True, + auto_rollback=False, + ) + ) + + assert provider.last_target is not None + assert provider.last_target.extra["ui_profile"] == "custom" + assert provider.last_target.extra["ui_path"] == "/custom-chat" + assert provider.last_target.extra["ui_url"] == "https://ui.example.com/custom-chat" diff --git a/tests/test_cmd_mcp_no_cache.py b/tests/test_cmd_mcp_no_cache.py new file mode 100644 index 00000000..ab4e229b --- /dev/null +++ b/tests/test_cmd_mcp_no_cache.py @@ -0,0 +1,74 @@ +import asyncio +from pathlib import Path + +import pytest + +from ksadk.api.client import DryRunExit +from ksadk.cli import cmd_mcp + + +class _FakeMCPDetectionResult: + is_valid = True + entry_point = "server.py" + mcp_variable = "mcp" + tools = ["tool_a", "tool_b"] + + +class _FakeMCPDetector: + def __init__(self, *_args, **_kwargs): + pass + + def detect(self): + return _FakeMCPDetectionResult() + + +class _FakeDryRunClient: + def __init__(self, *args, **kwargs): + self.kwargs = kwargs + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def create_mcp(self, request): + raise DryRunExit("dry-run", payload={"body": request}) + + async def update_mcp(self, *_args, **_kwargs): + raise DryRunExit("dry-run", payload={"body": {}}) + + async def close(self): + return None + + +def test_mcp_deploy_dry_run_skips_local_build_and_relies_on_plan(tmp_path: Path, monkeypatch): + monkeypatch.setattr("ksadk.detection.mcp_detector.MCPDetector", _FakeMCPDetector) + monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeDryRunClient) + + def _should_not_build(*_args, **_kwargs): + raise AssertionError("Dry run should not trigger artifact build") + + monkeypatch.setattr(cmd_mcp, "_build_code_artifact", _should_not_build) + + with pytest.raises(DryRunExit) as exc_info: + asyncio.run( + cmd_mcp._deploy_mcp_async( + mcp_dir=str(tmp_path), + name=None, + region="cn-beijing-6", + ks3_bucket="agentengine-test", + enable_auth=False, + dry_run=True, + artifact_type="Code", + no_cache=True, + ) + ) + + payload = exc_info.value.payload or {} + body = payload.get("body") or {} + + assert body["artifact_type"] == "Code" + assert body["region"] == "cn-beijing-6" + assert body["artifact_path"].startswith("ks3://agentengine-test/") + assert "dry-run" in body["artifact_path"] diff --git a/tests/test_cmd_model.py b/tests/test_cmd_model.py new file mode 100644 index 00000000..f16c05eb --- /dev/null +++ b/tests/test_cmd_model.py @@ -0,0 +1,170 @@ +from pathlib import Path + +import pytest +import yaml +from click.testing import CliRunner + +from ksadk.cli import cmd_model +from ksadk.cli.cmd_config import config + + +@pytest.fixture(autouse=True) +def _isolate_model_env(monkeypatch): + for key in ( + "OPENAI_MODEL_NAME", + "MODEL_NAME", + "OPENAI_API_BASE", + "COZE_WORKLOAD_IDENTITY_API_KEY", + "COZE_INTEGRATION_BASE_URL", + "COZE_INTEGRATION_MODEL_BASE_URL", + ): + monkeypatch.delenv(key, raising=False) + + +def test_config_model_env_prints_openclaw_allowlist_from_state(monkeypatch, tmp_path: Path): + (tmp_path / ".agentengine.state").write_text( + yaml.safe_dump({"type": "openclaw", "framework": "openclaw"}), + encoding="utf-8", + ) + runner = CliRunner() + monkeypatch.chdir(tmp_path) + + result = runner.invoke( + config, + [ + "model", + "--env", + "deepseek-v4-pro,glm-5.1", + ], + ) + + assert result.exit_code == 0, result.output + assert result.output.splitlines() == [ + "OPENAI_MODEL_NAME=deepseek-v4-pro", + "OPENCLAW_MODEL_ALLOWLIST=deepseek-v4-pro,glm-5.1", + ] + assert not (tmp_path / ".env").exists() + + +def test_config_model_env_prints_generic_allowlist_for_hermes(monkeypatch, tmp_path: Path): + (tmp_path / "agentengine.yaml").write_text( + "framework: hermes\n", + encoding="utf-8", + ) + runner = CliRunner() + monkeypatch.chdir(tmp_path) + + result = runner.invoke( + config, + [ + "model", + "--env", + "deepseek-v4-pro,glm-5.1", + ], + ) + + assert result.exit_code == 0, result.output + assert result.output.splitlines() == [ + "OPENAI_MODEL_NAME=deepseek-v4-pro", + "AGENTENGINE_MODEL_ALLOWLIST=deepseek-v4-pro,glm-5.1", + ] + assert not (tmp_path / ".env").exists() + + +def test_config_model_env_single_model_prints_only_default(monkeypatch, tmp_path: Path): + (tmp_path / ".agentengine.state").write_text( + yaml.safe_dump({"type": "openclaw", "framework": "openclaw"}), + encoding="utf-8", + ) + runner = CliRunner() + monkeypatch.chdir(tmp_path) + + result = runner.invoke( + config, + [ + "model", + "--env", + "deepseek-v4-pro", + ], + ) + + assert result.exit_code == 0, result.output + assert result.output.splitlines() == ["OPENAI_MODEL_NAME=deepseek-v4-pro"] + assert not (tmp_path / ".env").exists() + + +def test_config_model_multi_select_writes_openclaw_allowlist(monkeypatch, tmp_path: Path): + (tmp_path / ".agentengine.state").write_text( + yaml.safe_dump({"type": "openclaw", "framework": "openclaw"}), + encoding="utf-8", + ) + runner = CliRunner() + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("OPENAI_BASE_URL", "https://kspmas.ksyun.com/v1") + monkeypatch.setenv("OPENAI_API_KEY", "sk-test") + monkeypatch.setattr(cmd_model, "is_stdout_tty", lambda: True) + + class _Response: + def raise_for_status(self): + return None + + def json(self): + return { + "data": [ + {"id": "glm-5.1"}, + {"id": "deepseek-v4-pro"}, + {"id": "kimi-k2.6"}, + ] + } + + class _Prompt: + def ask(self): + return ["deepseek-v4-pro", "glm-5.1"] + + monkeypatch.setattr(cmd_model.httpx, "get", lambda *_args, **_kwargs: _Response()) + monkeypatch.setattr(cmd_model.questionary, "checkbox", lambda *_args, **_kwargs: _Prompt()) + + result = runner.invoke(config, ["model", "--multi"]) + + assert result.exit_code == 0, result.output + env_text = (tmp_path / ".env").read_text(encoding="utf-8") + assert "OPENAI_MODEL_NAME=deepseek-v4-pro" in env_text + assert "OPENCLAW_MODEL_ALLOWLIST=deepseek-v4-pro,glm-5.1" in env_text + + +def test_config_model_writes_current_project_env_not_parent_env(monkeypatch, tmp_path: Path): + parent_env = tmp_path / ".env" + parent_env.write_text( + "OPENAI_BASE_URL=https://parent.example/v1\nOPENAI_MODEL_NAME=parent-model\n", + encoding="utf-8", + ) + project_dir = tmp_path / "demo-agent" + project_dir.mkdir() + + runner = CliRunner() + monkeypatch.chdir(project_dir) + monkeypatch.setenv("OPENAI_BASE_URL", "https://kspmas.ksyun.com/v1") + monkeypatch.setenv("OPENAI_API_KEY", "sk-test") + monkeypatch.setattr(cmd_model, "is_stdout_tty", lambda: True) + + class _Response: + def raise_for_status(self): + return None + + def json(self): + return {"data": [{"id": "deepseek-v4-pro"}, {"id": "glm-5.1"}]} + + class _Prompt: + def ask(self): + return "deepseek-v4-pro" + + monkeypatch.setattr(cmd_model.httpx, "get", lambda *_args, **_kwargs: _Response()) + monkeypatch.setattr(cmd_model.questionary, "select", lambda *_args, **_kwargs: _Prompt()) + + result = runner.invoke(config, ["model"]) + + assert result.exit_code == 0, result.output + assert "OPENAI_MODEL_NAME=deepseek-v4-pro" in (project_dir / ".env").read_text(encoding="utf-8") + assert parent_env.read_text(encoding="utf-8") == ( + "OPENAI_BASE_URL=https://parent.example/v1\nOPENAI_MODEL_NAME=parent-model\n" + ) diff --git a/tests/test_code_builder_binary_compat.py b/tests/test_code_builder_binary_compat.py new file mode 100644 index 00000000..6e07852e --- /dev/null +++ b/tests/test_code_builder_binary_compat.py @@ -0,0 +1,31 @@ +from ksadk.builders.code_builder import CodeBuilder + + +def test_detect_critical_binary_issues_accepts_target_python_abi(tmp_path): + builder = CodeBuilder(tmp_path) + names = ["pydantic_core/_pydantic_core.cpython-312-x86_64-linux-gnu.so"] + + issues = builder._detect_critical_binary_issues(names) + + assert not issues + + +def test_detect_critical_binary_issues_rejects_python_abi_mismatch(tmp_path): + builder = CodeBuilder(tmp_path) + names = ["pydantic_core/_pydantic_core.cpython-313-x86_64-linux-gnu.so"] + + issues = builder._detect_critical_binary_issues(names) + + assert ( + "python-abi-mismatch:pydantic_core/_pydantic_core:" + "expected-cpython-312-or-abi3" + ) in issues + + +def test_detect_critical_binary_issues_rejects_non_linux_binary(tmp_path): + builder = CodeBuilder(tmp_path) + names = ["pydantic_core/_pydantic_core.cpython-312-darwin.so"] + + issues = builder._detect_critical_binary_issues(names) + + assert "missing-linux:pydantic_core/_pydantic_core" in issues diff --git a/tests/test_code_builder_pip_indexes.py b/tests/test_code_builder_pip_indexes.py new file mode 100644 index 00000000..7c5f31ba --- /dev/null +++ b/tests/test_code_builder_pip_indexes.py @@ -0,0 +1,528 @@ +import io +import json +import subprocess +import sys + +from ksadk.builders.code_builder import CodeBuilder + + +def _completed_process(cmd): + return subprocess.CompletedProcess(cmd, 0, "", "") + + +class _FakePopen: + def __init__(self, cmd, *, calls, output_lines=None, returncode=0, **_kwargs): + calls.append(cmd) + self.args = cmd + self.returncode = returncode + self.stdout = io.StringIO("".join(output_lines or [])) + + def wait(self, timeout=None): + return self.returncode + + def kill(self): + return None + + +def test_install_dependencies_respects_explicit_pip_index(tmp_path, monkeypatch): + builder = CodeBuilder(tmp_path) + builder.deps_dir.mkdir(parents=True, exist_ok=True) + requirements_path = tmp_path / "requirements.txt" + requirements_path.write_text("demo==1.0\n", encoding="utf-8") + + calls = [] + + def fake_popen(cmd, **kwargs): + return _FakePopen( + cmd, + calls=calls, + output_lines=[ + "Collecting demo==1.0\n", + "Installing collected packages: demo\n", + "Successfully installed demo-1.0\n", + ], + **kwargs, + ) + + monkeypatch.setenv("PIP_INDEX_URL", "https://pypi.org/simple") + monkeypatch.setattr("ksadk.builders.code_builder.subprocess.Popen", fake_popen) + monkeypatch.setattr(CodeBuilder, "_scan_incompatible_binaries_in_deps", lambda self: []) + + assert builder._install_dependencies(requirements_path) is True + assert calls + assert "-i" not in calls[0] + + +def test_install_dependencies_prefers_target_runtime_wheels(tmp_path, monkeypatch): + builder = CodeBuilder(tmp_path) + builder.deps_dir.mkdir(parents=True, exist_ok=True) + requirements_path = tmp_path / "requirements.txt" + requirements_path.write_text("demo==1.0\n", encoding="utf-8") + + calls = [] + + def fake_popen(cmd, **kwargs): + return _FakePopen( + cmd, + calls=calls, + output_lines=[ + "Collecting demo==1.0\n", + "Downloading demo-1.0-py3-none-any.whl\n", + "Installing collected packages: demo\n", + "Successfully installed demo-1.0\n", + ], + **kwargs, + ) + + monkeypatch.setattr("ksadk.builders.code_builder.subprocess.Popen", fake_popen) + monkeypatch.setattr(CodeBuilder, "_scan_incompatible_binaries_in_deps", lambda self: []) + + assert builder._install_dependencies(requirements_path) is True + assert calls + assert "--platform" in calls[0] + assert "manylinux2014_x86_64" in calls[0] + assert "--python-version" in calls[0] + assert builder.TARGET_PYTHON_VERSION in calls[0] + assert "--only-binary=:all:" in calls[0] + + +def test_install_dependencies_uses_persistent_project_pip_cache(tmp_path, monkeypatch): + builder = CodeBuilder(tmp_path) + builder.deps_dir.mkdir(parents=True, exist_ok=True) + requirements_path = tmp_path / "requirements.txt" + requirements_path.write_text("demo==1.0\n", encoding="utf-8") + + calls = [] + + def fake_popen(cmd, **kwargs): + return _FakePopen( + cmd, + calls=calls, + output_lines=[ + "Collecting demo==1.0\n", + "Downloading demo-1.0-py3-none-any.whl\n", + "Installing collected packages: demo\n", + "Successfully installed demo-1.0\n", + ], + **kwargs, + ) + + monkeypatch.setattr("ksadk.builders.code_builder.subprocess.Popen", fake_popen) + monkeypatch.setattr(CodeBuilder, "_scan_incompatible_binaries_in_deps", lambda self: []) + + assert builder._install_dependencies(requirements_path) is True + + assert "--cache-dir" in calls[0] + cache_pos = calls[0].index("--cache-dir") + assert calls[0][cache_pos + 1] == str(builder.build_dir / "pip_cache") + + +def test_install_dependencies_timeout_is_configurable(tmp_path, monkeypatch): + builder = CodeBuilder(tmp_path) + builder.deps_dir.mkdir(parents=True, exist_ok=True) + requirements_path = tmp_path / "requirements.txt" + requirements_path.write_text("demo==1.0\n", encoding="utf-8") + + calls = [] + + def fake_popen(cmd, **kwargs): + return _FakePopen( + cmd, + calls=calls, + output_lines=[ + "Collecting demo==1.0\n", + "Installing collected packages: demo\n", + "Successfully installed demo-1.0\n", + ], + **kwargs, + ) + + observed_timeouts = [] + + def fake_run_streamed(self, install_cmd, *, timeout): + observed_timeouts.append(timeout) + return subprocess.CompletedProcess(install_cmd, 0, "", "") + + monkeypatch.setenv("KSADK_BUILD_PIP_INSTALL_TIMEOUT_SECONDS", "2700") + monkeypatch.setattr("ksadk.builders.code_builder.subprocess.Popen", fake_popen) + monkeypatch.setattr(CodeBuilder, "_run_streamed_pip_install", fake_run_streamed) + monkeypatch.setattr(CodeBuilder, "_scan_incompatible_binaries_in_deps", lambda self: []) + + assert builder._install_dependencies(requirements_path) is True + + assert observed_timeouts == [2700] + + +def test_replace_platform_binaries_respects_explicit_pip_index(tmp_path, monkeypatch): + builder = CodeBuilder(tmp_path) + builder.build_dir.mkdir(parents=True, exist_ok=True) + builder.deps_dir.mkdir(parents=True, exist_ok=True) + (builder.deps_dir / "tiktoken").mkdir(parents=True, exist_ok=True) + (builder.deps_dir / "tiktoken" / "_tiktoken.cpython-314-darwin.so").write_text("", encoding="utf-8") + (builder.deps_dir / "tiktoken-0.9.0.dist-info").mkdir(parents=True, exist_ok=True) + + calls = [] + + def fake_run(cmd, **kwargs): + calls.append(cmd) + return _completed_process(cmd) + + monkeypatch.setenv("PIP_INDEX_URL", "https://pypi.org/simple") + monkeypatch.setattr("ksadk.builders.code_builder.subprocess.run", fake_run) + + builder._replace_platform_binaries() + + assert calls + assert "-i" not in calls[0] + + +def test_install_dependencies_reports_percent_bar_and_recent_event( + tmp_path, + monkeypatch, + capsys, +): + builder = CodeBuilder(tmp_path) + builder.deps_dir.mkdir(parents=True, exist_ok=True) + requirements_path = tmp_path / "requirements.txt" + requirements_path.write_text("demo==1.0\n", encoding="utf-8") + + calls = [] + + def fake_popen(cmd, **kwargs): + return _FakePopen( + cmd, + calls=calls, + output_lines=[ + "Collecting demo==1.0\n", + "Downloading demo-1.0-py3-none-any.whl\n", + "Installing collected packages: demo\n", + "Successfully installed demo-1.0\n", + ], + **kwargs, + ) + + monkeypatch.setattr("ksadk.builders.code_builder.subprocess.Popen", fake_popen) + monkeypatch.setattr(CodeBuilder, "_scan_incompatible_binaries_in_deps", lambda self: []) + + assert builder._install_dependencies(requirements_path) is True + + output = capsys.readouterr().out + assert "100%" in output + assert "安装包: demo" in output + + +def test_install_progress_is_monotonic_and_uses_arrow_style_bar(tmp_path): + builder = CodeBuilder(tmp_path) + + builder._emit_install_progress(40, "下载依赖", "Downloading demo-1.0.whl") + builder._emit_install_progress(18, "解析依赖", "Collecting demo==1.0") + + assert builder._install_progress_percent == 40 + assert builder._install_progress_stage_name == "下载依赖" + assert builder._install_progress_summary_text == "Downloading demo-1.0.whl" + + rendered = builder._render_install_progress(40, "下载依赖", "Downloading demo-1.0.whl") + assert "#" not in rendered + assert ">" in rendered + assert "=" in rendered + + +def test_install_dependencies_aggregates_repeated_download_updates( + tmp_path, + monkeypatch, + capsys, +): + builder = CodeBuilder(tmp_path) + builder.deps_dir.mkdir(parents=True, exist_ok=True) + requirements_path = tmp_path / "requirements.txt" + requirements_path.write_text("demo==1.0\n", encoding="utf-8") + + calls = [] + download_lines = [ + f"Using cached https://mirror.example/simple/demo-{index}.whl\n" + for index in range(1, 13) + ] + + def fake_popen(cmd, **kwargs): + return _FakePopen( + cmd, + calls=calls, + output_lines=[ + "Collecting demo==1.0\n", + *download_lines, + "Installing collected packages: demo\n", + "Successfully installed demo-1.0\n", + ], + **kwargs, + ) + + monkeypatch.setattr("ksadk.builders.code_builder.subprocess.Popen", fake_popen) + monkeypatch.setattr(CodeBuilder, "_scan_incompatible_binaries_in_deps", lambda self: []) + + assert builder._install_dependencies(requirements_path) is True + + output = capsys.readouterr().out + assert "已处理 10 个 wheel" in output + assert output.count("下载依赖") < len(download_lines) + + +def test_install_dependencies_advances_download_progress_with_wheel_activity( + tmp_path, + monkeypatch, + capsys, +): + builder = CodeBuilder(tmp_path) + builder.deps_dir.mkdir(parents=True, exist_ok=True) + requirements_path = tmp_path / "requirements.txt" + requirements_path.write_text("demo==1.0\n", encoding="utf-8") + + calls = [] + download_lines = [ + f"Downloading demo-{index}.0-py3-none-any.whl\n" + for index in range(1, 26) + ] + + def fake_popen(cmd, **kwargs): + return _FakePopen( + cmd, + calls=calls, + output_lines=[ + "Collecting demo==1.0\n", + *download_lines, + "Installing collected packages: demo\n", + "Successfully installed demo-1.0\n", + ], + **kwargs, + ) + + monkeypatch.setattr("ksadk.builders.code_builder.subprocess.Popen", fake_popen) + monkeypatch.setattr(CodeBuilder, "_scan_incompatible_binaries_in_deps", lambda self: []) + + assert builder._install_dependencies(requirements_path) is True + + output = capsys.readouterr().out + assert "下载依赖" in output + assert "耗时" in output + assert "已处理 25 个 wheel" in output + download_percents = [ + int(line.split("%", 1)[0].rsplit(" ", 1)[-1]) + for line in output.splitlines() + if "下载依赖" in line and "%" in line + ] + assert max(download_percents) >= 60 + + +def test_install_dependencies_does_not_pin_long_downloads_at_68_percent( + tmp_path, + monkeypatch, + capsys, +): + builder = CodeBuilder(tmp_path) + builder.deps_dir.mkdir(parents=True, exist_ok=True) + requirements_path = tmp_path / "requirements.txt" + requirements_path.write_text("demo==1.0\n", encoding="utf-8") + + calls = [] + download_lines = [ + f"Using cached https://mirror.example/simple/demo-{index}.whl\n" + for index in range(1, 71) + ] + + def fake_popen(cmd, **kwargs): + return _FakePopen( + cmd, + calls=calls, + output_lines=[ + "Collecting demo==1.0\n", + *download_lines, + "Installing collected packages: demo\n", + "Successfully installed demo-1.0\n", + ], + **kwargs, + ) + + monkeypatch.setattr("ksadk.builders.code_builder.subprocess.Popen", fake_popen) + monkeypatch.setattr(CodeBuilder, "_scan_incompatible_binaries_in_deps", lambda self: []) + + assert builder._install_dependencies(requirements_path) is True + + output = capsys.readouterr().out + download_percents = [ + int(line.split("%", 1)[0].rsplit(" ", 1)[-1]) + for line in output.splitlines() + if "下载依赖" in line and "%" in line + ] + assert max(download_percents) > 68 + + +def test_install_dependencies_prefers_fastest_cached_pip_index(tmp_path, monkeypatch): + home = tmp_path / "home" + cache_dir = home / ".agentengine" + cache_dir.mkdir(parents=True, exist_ok=True) + cache_path = cache_dir / "pip-index-cache.json" + cache_path.write_text( + json.dumps( + { + "version": 1, + "updated_at": 999.0, + "order": [ + "https://mirrors.aliyun.com/pypi/simple", + "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple", + "https://mirrors.cloud.tencent.com/pypi/simple", + "https://pypi.org/simple", + ], + } + ), + encoding="utf-8", + ) + + builder = CodeBuilder(tmp_path) + builder.deps_dir.mkdir(parents=True, exist_ok=True) + requirements_path = tmp_path / "requirements.txt" + requirements_path.write_text("demo==1.0\n", encoding="utf-8") + + calls = [] + + def fake_popen(cmd, **kwargs): + return _FakePopen( + cmd, + calls=calls, + output_lines=[ + "Collecting demo==1.0\n", + "Installing collected packages: demo\n", + "Successfully installed demo-1.0\n", + ], + **kwargs, + ) + + monkeypatch.setenv("HOME", str(home)) + monkeypatch.delenv("PIP_INDEX_URL", raising=False) + monkeypatch.delenv("UV_INDEX_URL", raising=False) + monkeypatch.setattr("ksadk.builders.code_builder.time.time", lambda: 1000.0) + monkeypatch.setattr("ksadk.builders.code_builder.subprocess.Popen", fake_popen) + monkeypatch.setattr(CodeBuilder, "_scan_incompatible_binaries_in_deps", lambda self: []) + + assert builder._install_dependencies(requirements_path) is True + assert calls + + index_pos = calls[0].index("-i") + assert calls[0][index_pos + 1] == "https://mirrors.aliyun.com/pypi/simple" + + +def test_install_dependencies_download_summary_uses_artifact_name( + tmp_path, + monkeypatch, + capsys, +): + builder = CodeBuilder(tmp_path) + builder.deps_dir.mkdir(parents=True, exist_ok=True) + requirements_path = tmp_path / "requirements.txt" + requirements_path.write_text("demo==1.0\n", encoding="utf-8") + + calls = [] + + def fake_popen(cmd, **kwargs): + return _FakePopen( + cmd, + calls=calls, + output_lines=[ + "Collecting demo==1.0\n", + "Downloading demo-1.0-py3-none-any.whl.metadata (117 kB)\n", + "Installing collected packages: demo\n", + "Successfully installed demo-1.0\n", + ], + **kwargs, + ) + + monkeypatch.setattr("ksadk.builders.code_builder.subprocess.Popen", fake_popen) + monkeypatch.setattr(CodeBuilder, "_scan_incompatible_binaries_in_deps", lambda self: []) + + assert builder._install_dependencies(requirements_path) is True + + output = capsys.readouterr().out + assert "demo-1.0-py3-none-any.whl.metadata" in output + assert "最近: (117" not in output + + +def test_install_dependencies_bootstraps_pip_when_missing( + tmp_path, + monkeypatch, + capsys, +): + builder = CodeBuilder(tmp_path) + builder.deps_dir.mkdir(parents=True, exist_ok=True) + requirements_path = tmp_path / "requirements.txt" + requirements_path.write_text("demo==1.0\n", encoding="utf-8") + + calls = [] + popen_attempts = {"count": 0} + + def fake_run(cmd, **kwargs): + calls.append(cmd) + return subprocess.CompletedProcess(cmd, 0, "", "") + + def fake_popen(cmd, **kwargs): + popen_attempts["count"] += 1 + if popen_attempts["count"] == 1: + return _FakePopen( + cmd, + calls=[], + output_lines=[f"{sys.executable}: No module named pip\n"], + returncode=1, + **kwargs, + ) + return _FakePopen( + cmd, + calls=[], + output_lines=[ + "Collecting demo==1.0\n", + "Installing collected packages: demo\n", + "Successfully installed demo-1.0\n", + ], + **kwargs, + ) + + monkeypatch.setattr("ksadk.builders.code_builder.subprocess.run", fake_run) + monkeypatch.setattr("ksadk.builders.code_builder.subprocess.Popen", fake_popen) + monkeypatch.setattr(CodeBuilder, "_scan_incompatible_binaries_in_deps", lambda self: []) + + assert builder._install_dependencies(requirements_path) is True + + output = capsys.readouterr().out + assert "pip 工具链缺失" in output + assert calls + assert calls[0][:3] == [sys.executable, "-m", "ensurepip"] + + +def test_package_zip_reports_milestone_progress_for_large_dependency_tree( + tmp_path, + monkeypatch, + capsys, +): + builder = CodeBuilder(tmp_path) + builder.deps_dir.mkdir(parents=True, exist_ok=True) + (tmp_path / "agent.py").write_text("print('ok')\n", encoding="utf-8") + (tmp_path / "agentengine.yaml").write_text("name: demo-agent\nframework: langgraph\n", encoding="utf-8") + for index in range(1, 1002): + (builder.deps_dir / f"dep_{index}.py").write_text("# dep\n", encoding="utf-8") + + monkeypatch.setattr(CodeBuilder, "_iter_bundled_source_files", lambda self: iter(())) + + detection_result = type( + "Detection", + (), + { + "package_path": str(tmp_path / "agent.py"), + "type": type("T", (), {"name": "LANGGRAPH"})(), + "name": "demo-agent", + "entry_point": "agent.py", + "agent_variable": "agent", + }, + )() + + builder._package_zip(builder.build_dir / "demo.zip", detection_result) + + output = capsys.readouterr().out + assert "打包依赖" in output + assert "100%" in output + assert "1000/1001 files" not in output diff --git a/tests/test_code_builder_rebuild_fingerprint.py b/tests/test_code_builder_rebuild_fingerprint.py new file mode 100644 index 00000000..27a7ec6e --- /dev/null +++ b/tests/test_code_builder_rebuild_fingerprint.py @@ -0,0 +1,313 @@ +import time +import zipfile +from pathlib import Path +from types import SimpleNamespace + +import ksadk + +from ksadk.builders.code_builder import CodeBuilder + + +class _FakeType: + value = "langgraph" + name = "LANGGRAPH" + + +class _FakeFrameworkDetector: + def __init__(self, *_args, **_kwargs): + pass + + def detect(self): + return SimpleNamespace( + type=_FakeType(), + name="demo-agent", + entry_point="agent.py", + package_path="agent.py", + agent_variable="agent", + ) + + +def _fake_package_zip(zip_path: Path, _detection_result): + with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf: + zf.writestr("agent.py", "print('ok')\n") + + +def test_code_builder_skips_rebuild_when_only_mtime_changes(tmp_path: Path, monkeypatch): + (tmp_path / "agent.py").write_text("print('ok')\n", encoding="utf-8") + (tmp_path / "agentengine.yaml").write_text("name: demo-agent\nframework: langgraph\n", encoding="utf-8") + + package_calls = [] + + monkeypatch.setattr("ksadk.detection.FrameworkDetector", _FakeFrameworkDetector) + monkeypatch.setattr(CodeBuilder, "_install_dependencies", lambda self, _req: True) + monkeypatch.setattr( + CodeBuilder, + "_package_zip", + lambda self, zip_path, detection_result: (package_calls.append(zip_path), _fake_package_zip(zip_path, detection_result)), + ) + + builder = CodeBuilder(tmp_path) + first = builder.build() + assert first.success is True + assert len(package_calls) == 1 + + time.sleep(0.01) + agent_file = tmp_path / "agent.py" + original_content = agent_file.read_text(encoding="utf-8") + agent_file.write_text(original_content, encoding="utf-8") + + second = builder.build() + assert second.success is True + assert len(package_calls) == 1 + + +def test_code_builder_rebuilds_when_file_content_changes(tmp_path: Path, monkeypatch): + (tmp_path / "agent.py").write_text("print('ok')\n", encoding="utf-8") + (tmp_path / "agentengine.yaml").write_text("name: demo-agent\nframework: langgraph\n", encoding="utf-8") + + package_calls = [] + + monkeypatch.setattr("ksadk.detection.FrameworkDetector", _FakeFrameworkDetector) + monkeypatch.setattr(CodeBuilder, "_install_dependencies", lambda self, _req: True) + monkeypatch.setattr( + CodeBuilder, + "_package_zip", + lambda self, zip_path, detection_result: (package_calls.append(zip_path), _fake_package_zip(zip_path, detection_result)), + ) + + builder = CodeBuilder(tmp_path) + first = builder.build() + assert first.success is True + assert len(package_calls) == 1 + + agent_file = tmp_path / "agent.py" + agent_file.write_text("print('changed')\n", encoding="utf-8") + + second = builder.build() + assert second.success is True + assert len(package_calls) == 2 + + +def test_code_builder_rebuilds_when_ksadk_source_changes(tmp_path: Path, monkeypatch): + (tmp_path / "agent.py").write_text("print('ok')\n", encoding="utf-8") + (tmp_path / "agentengine.yaml").write_text("name: demo-agent\nframework: langgraph\n", encoding="utf-8") + + fake_ksadk_root = tmp_path.parent / f"{tmp_path.name}_fake_ksadk" / "ksadk" + (fake_ksadk_root / "configs").mkdir(parents=True, exist_ok=True) + (fake_ksadk_root / "__init__.py").write_text("__version__ = 'test'\n", encoding="utf-8") + settings_file = fake_ksadk_root / "configs" / "settings.py" + settings_file.write_text("VALUE = 'v1'\n", encoding="utf-8") + + package_calls = [] + + monkeypatch.setattr("ksadk.detection.FrameworkDetector", _FakeFrameworkDetector) + monkeypatch.setattr(CodeBuilder, "_install_dependencies", lambda self, _req: True) + monkeypatch.setattr( + CodeBuilder, + "_package_zip", + lambda self, zip_path, detection_result: (package_calls.append(zip_path), _fake_package_zip(zip_path, detection_result)), + ) + monkeypatch.setattr(ksadk, "__file__", str(fake_ksadk_root / "__init__.py")) + + builder = CodeBuilder(tmp_path) + first = builder.build() + assert first.success is True + assert len(package_calls) == 1 + + settings_file.write_text("VALUE = 'v2'\n", encoding="utf-8") + + second = builder.build() + assert second.success is True + assert len(package_calls) == 2 + + +def test_code_builder_no_cache_reinstalls_dependencies_when_requirements_unchanged( + tmp_path: Path, + monkeypatch, +): + (tmp_path / "agent.py").write_text("print('ok')\n", encoding="utf-8") + (tmp_path / "agentengine.yaml").write_text("name: demo-agent\nframework: langgraph\n", encoding="utf-8") + (tmp_path / "requirements.txt").write_text("httpx==0.28.1\n", encoding="utf-8") + + install_calls = [] + package_calls = [] + + def fake_install(self, _req): + install_calls.append("install") + self.deps_dir.mkdir(parents=True, exist_ok=True) + (self.deps_dir / "httpx.py").write_text("# dep\n", encoding="utf-8") + return True + + monkeypatch.setattr("ksadk.detection.FrameworkDetector", _FakeFrameworkDetector) + monkeypatch.setattr(CodeBuilder, "_install_dependencies", fake_install) + monkeypatch.setattr(CodeBuilder, "_scan_incompatible_binaries_in_deps", lambda self: []) + monkeypatch.setattr( + CodeBuilder, + "_package_zip", + lambda self, zip_path, detection_result: (package_calls.append(zip_path), _fake_package_zip(zip_path, detection_result)), + ) + + builder = CodeBuilder(tmp_path, config={"no_cache": True}) + first = builder.build() + second = builder.build() + + assert first.success is True + assert second.success is True + assert len(package_calls) == 2 + assert len(install_calls) == 2 + + +def test_code_builder_no_cache_reinstalls_dependencies_when_requirements_change( + tmp_path: Path, + monkeypatch, +): + (tmp_path / "agent.py").write_text("print('ok')\n", encoding="utf-8") + (tmp_path / "agentengine.yaml").write_text("name: demo-agent\nframework: langgraph\n", encoding="utf-8") + requirements = tmp_path / "requirements.txt" + requirements.write_text("httpx==0.28.1\n", encoding="utf-8") + + install_calls = [] + + def fake_install(self, _req): + install_calls.append("install") + self.deps_dir.mkdir(parents=True, exist_ok=True) + marker = self.deps_dir / f"dep-{len(install_calls)}.txt" + marker.write_text("ok\n", encoding="utf-8") + return True + + monkeypatch.setattr("ksadk.detection.FrameworkDetector", _FakeFrameworkDetector) + monkeypatch.setattr(CodeBuilder, "_install_dependencies", fake_install) + monkeypatch.setattr(CodeBuilder, "_scan_incompatible_binaries_in_deps", lambda self: []) + monkeypatch.setattr( + CodeBuilder, + "_package_zip", + lambda self, zip_path, detection_result: _fake_package_zip(zip_path, detection_result), + ) + + builder = CodeBuilder(tmp_path, config={"no_cache": True}) + first = builder.build() + requirements.write_text("httpx==0.28.1\nrequests==2.32.3\n", encoding="utf-8") + second = builder.build() + + assert first.success is True + assert second.success is True + assert len(install_calls) == 2 + + +def test_code_builder_repackage_reuses_dependencies_but_rebuilds_zip( + tmp_path: Path, + monkeypatch, +): + (tmp_path / "agent.py").write_text("print('ok')\n", encoding="utf-8") + (tmp_path / "agentengine.yaml").write_text("name: demo-agent\nframework: langgraph\n", encoding="utf-8") + (tmp_path / "requirements.txt").write_text("httpx==0.28.1\n", encoding="utf-8") + + install_calls = [] + package_calls = [] + + def fake_install(self, _req): + install_calls.append("install") + self.deps_dir.mkdir(parents=True, exist_ok=True) + (self.deps_dir / "httpx.py").write_text("# dep\n", encoding="utf-8") + return True + + monkeypatch.setattr("ksadk.detection.FrameworkDetector", _FakeFrameworkDetector) + monkeypatch.setattr(CodeBuilder, "_install_dependencies", fake_install) + monkeypatch.setattr(CodeBuilder, "_scan_incompatible_binaries_in_deps", lambda self: []) + monkeypatch.setattr( + CodeBuilder, + "_package_zip", + lambda self, zip_path, detection_result: (package_calls.append(zip_path), _fake_package_zip(zip_path, detection_result)), + ) + + initial = CodeBuilder(tmp_path).build() + assert initial.success is True + + repackaged = CodeBuilder(tmp_path, config={"repackage": True}).build() + + assert repackaged.success is True + assert len(install_calls) == 1 + assert len(package_calls) == 2 + + +def test_code_builder_package_zip_reports_top_size_contributors( + tmp_path: Path, + monkeypatch, + capsys, +): + (tmp_path / "agent.py").write_text("print('ok')\n", encoding="utf-8") + + builder = CodeBuilder(tmp_path) + builder.deps_dir.mkdir(parents=True, exist_ok=True) + (builder.deps_dir / "large_dep").mkdir() + (builder.deps_dir / "large_dep" / "payload.bin").write_bytes(b"x" * 2048) + (builder.deps_dir / "small_dep.py").write_text("# dep\n", encoding="utf-8") + + monkeypatch.setattr(CodeBuilder, "_iter_bundled_source_files", lambda self: []) + + zip_path = builder.build_dir / "demo.zip" + builder._package_zip(zip_path, _FakeFrameworkDetector().detect()) + + output = capsys.readouterr().out + assert "包体积:" in output + assert "体积 Top" in output + assert "large_dep" in output + + +def test_code_builder_package_zip_suggests_container_only_for_large_artifacts( + tmp_path: Path, + capsys, +): + builder = CodeBuilder(tmp_path) + + builder._emit_package_size_report_from_entries( + raw_total=499 * 1024 * 1024, + compressed_total=299 * 1024 * 1024, + by_top_level={"deps": 499 * 1024 * 1024}, + ) + assert "建议使用 container 模式" not in capsys.readouterr().out + + builder._emit_package_size_report_from_entries( + raw_total=501 * 1024 * 1024, + compressed_total=299 * 1024 * 1024, + by_top_level={"deps": 501 * 1024 * 1024}, + ) + assert "建议使用 container 模式" in capsys.readouterr().out + + builder._emit_package_size_report_from_entries( + raw_total=100 * 1024 * 1024, + compressed_total=301 * 1024 * 1024, + by_top_level={"deps": 100 * 1024 * 1024}, + ) + assert "建议使用 container 模式" in capsys.readouterr().out + + +def test_code_builder_reports_rebuild_reason_for_runtime_source_changes( + tmp_path: Path, + monkeypatch, + capsys, +): + (tmp_path / "agent.py").write_text("print('ok')\n", encoding="utf-8") + (tmp_path / "agentengine.yaml").write_text("name: demo-agent\nframework: langgraph\n", encoding="utf-8") + + fake_ksadk_root = tmp_path.parent / f"{tmp_path.name}_fake_ksadk_reason" / "ksadk" + (fake_ksadk_root / "configs").mkdir(parents=True, exist_ok=True) + (fake_ksadk_root / "__init__.py").write_text("__version__ = 'test'\n", encoding="utf-8") + settings_file = fake_ksadk_root / "configs" / "settings.py" + settings_file.write_text("VALUE = 'v1'\n", encoding="utf-8") + + monkeypatch.setattr("ksadk.detection.FrameworkDetector", _FakeFrameworkDetector) + monkeypatch.setattr(CodeBuilder, "_install_dependencies", lambda self, _req: True) + monkeypatch.setattr(CodeBuilder, "_package_zip", lambda self, zip_path, detection_result: _fake_package_zip(zip_path, detection_result)) + monkeypatch.setattr(ksadk, "__file__", str(fake_ksadk_root / "__init__.py")) + + first = CodeBuilder(tmp_path).build() + assert first.success is True + capsys.readouterr() + + settings_file.write_text("VALUE = 'v2'\n", encoding="utf-8") + second = CodeBuilder(tmp_path).build() + assert second.success is True + + output = capsys.readouterr().out + assert "ksadk runtime 变更" in output diff --git a/tests/test_code_builder_static_assets.py b/tests/test_code_builder_static_assets.py new file mode 100644 index 00000000..cceeb3a0 --- /dev/null +++ b/tests/test_code_builder_static_assets.py @@ -0,0 +1,95 @@ +import zipfile +from types import SimpleNamespace + +from ksadk.builders.code_builder import CodeBuilder + + +class _FakeType: + name = "LANGGRAPH" + + +def test_code_builder_packages_web_static_assets(tmp_path): + # 最小项目结构 + (tmp_path / "agent.py").write_text("print('ok')\n", encoding="utf-8") + + builder = CodeBuilder(tmp_path) + builder.build_dir.mkdir(parents=True, exist_ok=True) + builder.deps_dir.mkdir(parents=True, exist_ok=True) + + detection_result = SimpleNamespace( + package_path=str(tmp_path), + type=_FakeType(), + name="demo_agent", + entry_point="agent.py", + agent_variable="root_agent", + ) + + zip_path = tmp_path / "demo.zip" + builder._package_zip(zip_path, detection_result) + + with zipfile.ZipFile(zip_path) as zf: + names = zf.namelist() + + static_files = [n for n in names if n.startswith("ksadk/server/static/")] + assert static_files, "应包含 ksadk/server/static 目录下资源" + assert any(n.endswith(".html") for n in static_files), "应包含 html 入口" + assert any(n.endswith(".js") for n in static_files), "应包含 js 资源" + assert any(n.endswith(".css") for n in static_files), "应包含 css 资源" + assert not any(n.startswith("ksadk/server/web-ui/") for n in names), ( + "runtime 产物不应包含前端源码/node_modules" + ) + + +def test_code_builder_packages_runtime_common_sources(tmp_path): + (tmp_path / "agent.py").write_text("print('ok')\n", encoding="utf-8") + + builder = CodeBuilder(tmp_path) + builder.build_dir.mkdir(parents=True, exist_ok=True) + builder.deps_dir.mkdir(parents=True, exist_ok=True) + + detection_result = SimpleNamespace( + package_path=str(tmp_path), + type=_FakeType(), + name="demo_agent", + entry_point="agent.py", + agent_variable="root_agent", + ) + + zip_path = tmp_path / "demo.zip" + builder._package_zip(zip_path, detection_result) + + with zipfile.ZipFile(zip_path) as zf: + names = zf.namelist() + + assert any(n.startswith("ksadk_runtime_common/") for n in names), ( + "应包含 ksadk_runtime_common 共享运行时代码" + ) + + +def test_code_builder_excludes_real_dotenv_files_but_keeps_example(tmp_path): + (tmp_path / "agent.py").write_text("print('ok')\n", encoding="utf-8") + (tmp_path / ".env").write_text("OPENAI_API_KEY=secret\n", encoding="utf-8") + (tmp_path / ".env.local").write_text("LOCAL_SECRET=secret\n", encoding="utf-8") + (tmp_path / ".env.example").write_text("OPENAI_API_KEY=\n", encoding="utf-8") + + builder = CodeBuilder(tmp_path) + builder.build_dir.mkdir(parents=True, exist_ok=True) + builder.deps_dir.mkdir(parents=True, exist_ok=True) + + detection_result = SimpleNamespace( + package_path=str(tmp_path), + type=_FakeType(), + name="demo_agent", + entry_point="agent.py", + agent_variable="root_agent", + ) + + zip_path = tmp_path / "demo.zip" + builder._package_zip(zip_path, detection_result) + + with zipfile.ZipFile(zip_path) as zf: + names = set(zf.namelist()) + + assert ".env" not in names + assert ".env.local" not in names + assert ".env.example" in names diff --git a/tests/test_config_env_registry.py b/tests/test_config_env_registry.py new file mode 100644 index 00000000..bf23eb7d --- /dev/null +++ b/tests/test_config_env_registry.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import subprocess +from pathlib import Path + +from ksadk.configs.env_registry import ENV_VAR_REGISTRY + + +def _source_ksadk_env_names() -> set[str]: + result = subprocess.run( + ["rg", "-o", "KSADK_[A-Z0-9_]+", "ksadk"], + check=True, + capture_output=True, + text=True, + ) + return { + line.rsplit(":", 1)[-1].strip() + for line in result.stdout.splitlines() + if line.strip() + } + + +def test_env_registry_has_unique_sorted_names(): + names = [item.name for item in ENV_VAR_REGISTRY] + + assert len(names) == len(set(names)) + assert names == sorted(names) + + +def test_env_registry_covers_ksadk_env_vars_in_source(): + registry_names = {item.name for item in ENV_VAR_REGISTRY} + + assert _source_ksadk_env_names() <= registry_names + + +def test_env_registry_docs_cover_registered_names(): + doc_text = Path("docs/ksadk环境变量参考.md").read_text(encoding="utf-8") + + for item in ENV_VAR_REGISTRY: + assert item.name in doc_text + + +def test_env_registry_defaults_ksadk_web_static_sync_to_latest_npm_release(): + specs = {item.name: item for item in ENV_VAR_REGISTRY} + + assert specs["KSADK_WEB_VERSION"].default == "latest" + assert specs["KSADK_WEB_PACKAGE"].default == "@kingsoftcloud/ksadk-web" + assert specs["KSADK_WEB_RELEASE_URL"].default == "" + + +def test_env_reference_documents_operational_metadata_and_common_runtime_vars(): + doc_text = Path("docs/ksadk环境变量参考.md").read_text(encoding="utf-8") + + for heading in ("是否必传", "别名/兼容", "配置方/来源", "是否业务自定义"): + assert heading in doc_text + + for name in ( + "E2B_API_URL", + "E2B_API_KEY", + "SKILL_SPACE_ID", + "AGENTENGINE_MODEL_ALLOWLIST", + "AGENTENGINE_UI_DIR", + "AGENT_BROWSER_EXECUTABLE_PATH", + "AGENT_BROWSER_HOME", + "FIRECRAWL_API_KEY", + "HERMES_DASHBOARD_HOST", + "HERMES_HOSTED_RUNTIME", + "KSADK_KB_AMBIENT_POLICY", + "KSADK_KB_SCHEME", + "KSADK_LTM_AMBIENT_POLICY", + "KSADK_MEMORY_BACKEND", + "KDOCS_OPEN_BROWSER", + "KS_ACCESS_KEY_ID", + "KSYUN_ACCESS_KEY", + "KSYUN_SECRET_KEY", + "KSYUN_ACCOUNT_ID", + "KSYUN_REGION", + "MEM0_API_KEY", + "OPENCLAW_ALLOWED_ORIGINS", + "OPENCLAW_BROWSER_ENABLED", + "OPENCLAW_DEFAULT_EXTENSIONS_DIR", + "OPENCLAW_GATEWAY_INTERNAL_PORT", + "OPENCLAW_GATEWAY_LOCAL_RESTART_MAX", + "OPENCLAW_MODEL_CATALOG_JSON", + "OPENCLAW_MODEL_API_KEY_SECRET_ID", + "OPENCLAW_PRESET_SKILLS_DIR", + "OPENCLAW_RUNTIME_PLAYWRIGHT_DOWNLOAD_HOST", + "OPENCLAW_WEB_SAFE_SEARCH_MODE", + "OPENCLAW_WEB_SEARCH_API_KEY_SECRET_ID", + "OPENCLAW_WEB_FETCH_ENABLED", + "COZE_WORKLOAD_IDENTITY_API_KEY", + "OPENAI_API_KEY", + "OPENAI_BASE_URL", + "OPENAI_MODEL_NAME", + "PLAYWRIGHT_DOWNLOAD_HOST", + ): + assert name in doc_text diff --git a/tests/test_config_root_visibility.py b/tests/test_config_root_visibility.py new file mode 100644 index 00000000..a56344ea --- /dev/null +++ b/tests/test_config_root_visibility.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from click.testing import CliRunner + +from ksadk.cli import _register_commands, cli + + +def test_root_help_shows_config_and_completion_but_not_model(): + _register_commands() + runner = CliRunner() + + result = runner.invoke(cli, ["--help"]) + + assert result.exit_code == 0, result.output + assert "agentengine config" in result.output + assert "agentengine completion" in result.output + assert " model " not in result.output + + +def test_config_without_subcommand_still_runs_wizard(monkeypatch): + _register_commands() + runner = CliRunner() + captured: dict[str, object] = {} + + def _fake_run_config_wizard(*, config_file: str | None, set_items: tuple, is_global: bool): + captured["config_file"] = config_file + captured["set_items"] = set_items + captured["is_global"] = is_global + + monkeypatch.setattr("ksadk.cli.cmd_config.run_config_wizard", _fake_run_config_wizard) + + result = runner.invoke(cli, ["config"]) + + assert result.exit_code == 0, result.output + assert captured == { + "config_file": None, + "set_items": (), + "is_global": False, + } + + +def test_config_requires_interactive_tty_for_wizard_path(): + _register_commands() + runner = CliRunner() + + result = runner.invoke(cli, ["config"]) + + assert result.exit_code == 2, result.output + assert "需要交互式终端" in result.output + assert "config show" in result.output + assert "config set" in result.output diff --git a/tests/test_container_registry_credentials.py b/tests/test_container_registry_credentials.py new file mode 100644 index 00000000..ae76e6ef --- /dev/null +++ b/tests/test_container_registry_credentials.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +from ksadk.builders.container_builder import ContainerBuilder +from ksadk.builders.mcp_builder import MCPContainerBuilder +from ksadk.cli import cmd_mcp, cmd_openclaw +from ksadk.detection.mcp_detector import MCPDetectionResult +from ksadk.deployment.providers.serverless import ServerlessProvider + + +def test_enterprise_registry_requires_explicit_kcr_username(monkeypatch, tmp_path, capsys): + monkeypatch.delenv("KCR_USERNAME", raising=False) + monkeypatch.setenv("KSYUN_ACCOUNT_ID", "2000003485") + monkeypatch.setenv("KCR_PASSWORD", "secret") + monkeypatch.setenv("KCR_REGISTRY", "agenthzzqy-vpc.ksyunkcr.com/testagent-pub") + + builder = ContainerBuilder(tmp_path) + + assert builder._auto_login_from_env("agenthzzqy-vpc.ksyunkcr.com") is False + + output = capsys.readouterr().out + assert "企业版或第三方镜像仓库必须配置 KCR_USERNAME 和 KCR_PASSWORD" in output + assert "KCR_USERNAME=<镜像仓库访问凭证用户名>" in output + assert "KSYUN_ACCOUNT_ID 只会作为个人版 KCR 的用户名兜底" in output + + +def test_personal_registry_can_fallback_to_ksyun_account_id(monkeypatch, tmp_path): + calls = [] + monkeypatch.delenv("KCR_USERNAME", raising=False) + monkeypatch.setenv("KSYUN_ACCOUNT_ID", "2000003485") + monkeypatch.setenv("KCR_PASSWORD", "secret") + monkeypatch.setenv("KCR_REGISTRY", "hub.kce.ksyun.com/agentengine") + + def fake_run(cmd, **kwargs): + calls.append((cmd, kwargs)) + + class Result: + returncode = 0 + stderr = "" + + return Result() + + monkeypatch.setattr("ksadk.builders.container_builder.subprocess.run", fake_run) + + builder = ContainerBuilder(tmp_path) + + assert builder._auto_login_from_env("hub.kce.ksyun.com") is True + assert calls[0][0] == [ + "docker", + "login", + "hub.kce.ksyun.com", + "-u", + "2000003485", + "--password-stdin", + ] + assert calls[0][1]["input"] == "secret" + + +def test_mcp_container_request_does_not_fallback_for_enterprise_registry(monkeypatch, capsys): + monkeypatch.delenv("KCR_USERNAME", raising=False) + monkeypatch.setenv("KSYUN_ACCOUNT_ID", "2000003485") + monkeypatch.setenv("KCR_PASSWORD", "secret") + + class Detection: + mcp_variable = "mcp" + tools = [] + + request = cmd_mcp._build_mcp_request_data( + config={}, + mcp_name="demo-mcp", + artifact_type="Container", + artifact_reference="agenthzzqy-vpc.ksyunkcr.com/testagent-pub/demo:v1", + region="cn-beijing-6", + enable_auth=False, + detection_result=Detection(), + ) + + assert "image_credential" not in request + output = capsys.readouterr().out + assert "未配置企业版 KCR 镜像凭证 (KCR_USERNAME/KCR_PASSWORD)" in output + + +def test_openclaw_container_request_does_not_fallback_for_third_party_registry(monkeypatch, capsys): + monkeypatch.delenv("KCR_USERNAME", raising=False) + monkeypatch.setenv("KSYUN_ACCOUNT_ID", "2000003485") + monkeypatch.setenv("KCR_PASSWORD", "secret") + monkeypatch.setattr(cmd_openclaw, "_GLOBAL_ENV_CACHE", {}) + + username, password, kind = cmd_openclaw.resolve_registry_credentials( + "registry-1.docker.io/acme/openclaw:v1", + environ=cmd_openclaw._openclaw_registry_env(), + ) + + assert (username, password, kind) == ("", "secret", "third_party") + + +def test_serverless_container_request_does_not_fallback_for_enterprise_registry(monkeypatch, capsys): + monkeypatch.delenv("KCR_USERNAME", raising=False) + monkeypatch.setenv("KSYUN_ACCOUNT_ID", "2000003485") + monkeypatch.setenv("KCR_PASSWORD", "secret") + + credential = ServerlessProvider._image_credential_from_env( + "agenthzzqy-vpc.ksyunkcr.com/testagent-pub/demo:v1" + ) + + assert credential is None + output = capsys.readouterr().out + assert "缺少 KCR_USERNAME" in output + assert "企业版 KCR" in output + + +def test_mcp_container_builder_excludes_real_dotenv_files_but_keeps_example(tmp_path): + (tmp_path / "server.py").write_text( + "from fastmcp import FastMCP\nmcp = FastMCP('demo')\n", + encoding="utf-8", + ) + (tmp_path / ".env").write_text("OPENAI_API_KEY=secret\n", encoding="utf-8") + (tmp_path / ".env.local").write_text("LOCAL_SECRET=secret\n", encoding="utf-8") + (tmp_path / ".env.example").write_text("OPENAI_API_KEY=\n", encoding="utf-8") + + package = MCPContainerBuilder(tmp_path)._package_mcp_project( + MCPDetectionResult( + is_mcp=True, + name="demo-mcp", + entry_point="server.py", + package_path=str(tmp_path), + mcp_variable="mcp", + tools=[], + confidence=1.0, + ) + ) + + build_dir = tmp_path / ".agentengine" / "container_build" + assert package.build_dir == str(build_dir) + assert not (build_dir / ".env").exists() + assert not (build_dir / ".env.local").exists() + assert (build_dir / ".env.example").exists() diff --git a/tests/test_deepagents_integration.py b/tests/test_deepagents_integration.py new file mode 100644 index 00000000..a36b6947 --- /dev/null +++ b/tests/test_deepagents_integration.py @@ -0,0 +1,249 @@ +"""DeepAgents framework integration tests.""" + +from pathlib import Path + +import pytest +import yaml + +from ksadk.detection import FrameworkDetector, FrameworkType, DetectionResult +from ksadk.runners.factory import create_runner +from ksadk.runners.utils.loader import load_agent_module + + +def _write_deepagents_project(project_dir: Path) -> None: + package_name = "deepagents_demo" + package_dir = project_dir / package_name + package_dir.mkdir(parents=True) + + (package_dir / "__init__.py").write_text( + 'from .agent import root_agent\n__all__ = ["root_agent"]\n', + encoding="utf-8", + ) + + (package_dir / "agent.py").write_text( + '''from collections.abc import Callable, Sequence +from typing import Any + +from deepagents import create_deep_agent +from langchain_core.language_models import LanguageModelInput +from langchain_core.language_models.fake_chat_models import GenericFakeChatModel +from langchain_core.messages import AIMessage +from langchain_core.runnables import Runnable +from langchain_core.tools import BaseTool + + +class FixedGenericFakeChatModel(GenericFakeChatModel): + def bind_tools( + self, + tools: Sequence[dict[str, Any] | type | Callable | BaseTool], + *, + tool_choice: str | None = None, + **kwargs: Any, + ) -> Runnable[LanguageModelInput, AIMessage]: + return self + + +fake_model = FixedGenericFakeChatModel( + messages=iter( + [ + AIMessage( + content="", + tool_calls=[ + { + "name": "write_todos", + "args": {"todos": []}, + "id": "call_1", + "type": "tool_call", + } + ], + ), + AIMessage(content="DeepAgents invoke ok"), + ] + ) +) + +root_agent = create_deep_agent(model=fake_model) +''', + encoding="utf-8", + ) + + (project_dir / "agentengine.yaml").write_text( + yaml.dump( + { + "name": "deepagents-demo", + "framework": "deepagents", + "entry_point": f"{package_name}/agent.py", + "package": package_name, + "agent_variable": "root_agent", + } + ), + encoding="utf-8", + ) + + +def _write_deepagents_script_entry(project_dir: Path, entry_file: str) -> None: + project_dir.mkdir(parents=True, exist_ok=True) + (project_dir / entry_file).write_text( + """from deepagents import create_deep_agent + +root_agent = create_deep_agent(model=None) +""", + encoding="utf-8", + ) + + +def test_detector_supports_deepagents_from_config(tmp_path: Path): + _write_deepagents_project(tmp_path) + detector = FrameworkDetector(str(tmp_path)) + result = detector.detect() + assert result.type == FrameworkType.DEEPAGENTS + assert result.entry_point.endswith("deepagents_demo/agent.py") + + +def test_detector_reads_custom_runner_class_from_config(tmp_path: Path): + package_dir = tmp_path / "demo_agent" + package_dir.mkdir() + (package_dir / "__init__.py").write_text("", encoding="utf-8") + (package_dir / "agent.py").write_text("root_agent = object()\n", encoding="utf-8") + (tmp_path / "agentengine.yaml").write_text( + yaml.dump( + { + "name": "demo-agent", + "framework": "langgraph", + "entry_point": "demo_agent/agent.py", + "package": "demo_agent", + "agent_variable": "root_agent", + "runner_class": "demo_agent.agent.CustomRunner", + } + ), + encoding="utf-8", + ) + + result = FrameworkDetector(str(tmp_path)).detect() + + assert result.type == FrameworkType.LANGGRAPH + assert result.runner_class == "demo_agent.agent.CustomRunner" + + +def test_detector_ignores_config_when_agent_variable_missing_and_finds_src_agent(tmp_path: Path): + package_dir = tmp_path / "src" / "demo_agent" + package_dir.mkdir(parents=True) + (package_dir / "main.py").write_text( + "from fastapi import FastAPI\n" + "app = FastAPI()\n", + encoding="utf-8", + ) + (package_dir / "agent.py").write_text( + "from langchain_openai import ChatOpenAI\n" + "from langchain_core.output_parsers import StrOutputParser\n" + "root_agent = ChatOpenAI() | StrOutputParser()\n", + encoding="utf-8", + ) + (tmp_path / "agentengine.yaml").write_text( + "name: demo-agent\nframework: langchain\nentry_point: src/demo_agent/main.py\nagent_variable: root_agent\n", + encoding="utf-8", + ) + + result = FrameworkDetector(str(tmp_path)).detect() + + assert result.type == FrameworkType.LANGCHAIN + assert result.entry_point == "src/demo_agent/agent.py" + assert Path(result.package_path) == package_dir + + +def test_detector_reads_valid_langgraph_json_when_config_is_stale(tmp_path: Path): + package_dir = tmp_path / "src" / "demo_agent" + package_dir.mkdir(parents=True) + (package_dir / "graph.py").write_text( + "from langgraph.graph import StateGraph\n" + "graph = StateGraph(dict).compile()\n", + encoding="utf-8", + ) + (tmp_path / "agentengine.yaml").write_text( + "name: demo-agent\nframework: langgraph\nentry_point: src/demo_agent/main.py\nagent_variable: root_agent\n", + encoding="utf-8", + ) + (tmp_path / "langgraph.json").write_text( + '{"graphs": {"agent": "./src/demo_agent/graph.py:graph"}}\n', + encoding="utf-8", + ) + + result = FrameworkDetector(str(tmp_path)).detect() + + assert result.type == FrameworkType.LANGGRAPH + assert result.entry_point == "src/demo_agent/graph.py" + assert result.agent_variable == "graph" + + +@pytest.mark.parametrize("entry_file", ["agent.py", "main.py", "app.py"]) +def test_detector_supports_script_project_without_package_init(tmp_path: Path, entry_file: str): + nested_project = tmp_path / "deep" / "deep" + _write_deepagents_script_entry(nested_project, entry_file) + + detector = FrameworkDetector(str(nested_project)) + result = detector.detect() + + assert result.type == FrameworkType.DEEPAGENTS + assert result.entry_point == entry_file + assert Path(result.package_path) == nested_project + + +def test_detector_supports_bom_encoded_agent_file(tmp_path: Path): + nested_project = tmp_path / "deep" / "deep" + nested_project.mkdir(parents=True, exist_ok=True) + (nested_project / "agent.py").write_text( + "\ufefffrom deepagents import create_deep_agent\nroot_agent = create_deep_agent(model=None)\n", + encoding="utf-8", + ) + + detector = FrameworkDetector(str(nested_project)) + result = detector.detect() + + assert result.type == FrameworkType.DEEPAGENTS + assert result.entry_point == "agent.py" + + +def test_loader_supports_src_layout_imports(tmp_path: Path): + package_dir = tmp_path / "src" / "src_demo" + package_dir.mkdir(parents=True) + (package_dir / "__init__.py").write_text("", encoding="utf-8") + (package_dir / "helper.py").write_text("VALUE = 'src import ok'\n", encoding="utf-8") + (package_dir / "agent.py").write_text( + "from src_demo.helper import VALUE\n" + "root_agent = VALUE\n", + encoding="utf-8", + ) + + agent, module = load_agent_module(str(tmp_path), "src/src_demo/agent.py", "root_agent") + + assert agent == "src import ok" + assert module.__name__ == "src.src_demo.agent" + + +def test_factory_creates_deepagents_runner(tmp_path: Path): + detection = DetectionResult( + type=FrameworkType.DEEPAGENTS, + name="deepagents-demo", + entry_point="deepagents_demo/agent.py", + package_path=str(tmp_path / "deepagents_demo"), + agent_variable="root_agent", + ) + runner = create_runner(detection, str(tmp_path)) + assert runner.__class__.__name__ == "DeepAgentsRunner" + + +@pytest.mark.asyncio +async def test_create_runner_invoke_deepagents_e2e(tmp_path: Path): + pytest.importorskip("deepagents") + + _write_deepagents_project(tmp_path) + detector = FrameworkDetector(str(tmp_path)) + result = detector.detect() + assert result.type == FrameworkType.DEEPAGENTS + + runner = create_runner(result, str(tmp_path)) + runner.load_agent() + + response = await runner.invoke({"input": "hello deepagents"}) + assert "output" in response + assert "DeepAgents invoke ok" in response["output"] diff --git a/tests/test_deploy_integration.py b/tests/test_deploy_integration.py new file mode 100644 index 00000000..28e681c4 --- /dev/null +++ b/tests/test_deploy_integration.py @@ -0,0 +1,931 @@ +""" +CLI 部署集成测试 + +测试 Agent 部署的本地状态文件机制 +""" + +import os +import json +import pytest +import tempfile +import yaml +from pathlib import Path +from unittest.mock import AsyncMock, patch, MagicMock + +from ksadk.deployment.providers.serverless import ServerlessProvider +from ksadk.deployment.base import PackageInfo, DeployTarget, DeployStatus +from ksadk.builders.base import BuildResult + + +# ============================================================================ +# Fixtures +# ============================================================================ + +@pytest.fixture +def temp_project_dir(): + """创建临时项目目录""" + with tempfile.TemporaryDirectory() as tmpdir: + # 创建基本项目结构 + project_dir = Path(tmpdir) + (project_dir / "agent.py").write_text("# Agent code") + (project_dir / "agentengine.yaml").write_text(yaml.dump({ + "name": "test-agent", + "framework": "langgraph" + })) + yield project_dir + + +@pytest.fixture +def sample_package_info(temp_project_dir): + """示例打包信息""" + return PackageInfo( + name="test-agent", + framework="langgraph", + build_dir=str(temp_project_dir / ".agentengine" / "build"), + project_dir=str(temp_project_dir), + metadata={ + "ks3_path": "ks3://test-bucket/agents/test-agent/code.zip" + } + ) + + +@pytest.fixture +def sample_deploy_target(): + """示例部署目标""" + return DeployTarget( + provider="serverless", + region="cn-beijing-6", + extra={ + "artifact_type": "Code", + "enable_observability": True + } + ) + + +# ============================================================================ +# Local State File Tests +# ============================================================================ + +class TestLocalStateFile: + """本地状态文件测试""" + + def test_load_state_empty(self, temp_project_dir): + """测试加载空状态文件""" + provider = ServerlessProvider() + state_file = temp_project_dir / ".agentengine.state" + + state = provider._load_state(state_file) + + assert state == {} + + def test_load_state_existing(self, temp_project_dir): + """测试加载已存在的状态文件""" + provider = ServerlessProvider() + state_file = temp_project_dir / ".agentengine.state" + + # 创建状态文件 + state_file.write_text(yaml.dump({ + "agent_id": "ar-20260119-abcdef", + "name": "test-agent", + "endpoint": "https://test.kspmas.ksyun.com" + })) + + state = provider._load_state(state_file) + + assert state["agent_id"] == "ar-20260119-abcdef" + assert state["name"] == "test-agent" + + def test_save_state(self, temp_project_dir): + """测试保存状态文件""" + provider = ServerlessProvider() + state_file = temp_project_dir / ".agentengine.state" + + provider._save_state(state_file, { + "agent_id": "ar-20260119-newid", + "name": "new-agent", + "endpoint": "https://new.kspmas.ksyun.com" + }) + + assert state_file.exists() + + loaded = yaml.safe_load(state_file.read_text()) + assert loaded["agent_id"] == "ar-20260119-newid" + + +# ============================================================================ +# Deploy Logic Tests +# ============================================================================ + +class TestDeployLogic: + """部署逻辑测试""" + + @pytest.mark.asyncio + async def test_deploy_create_new_agent( + self, + temp_project_dir, + sample_package_info, + sample_deploy_target + ): + """测试首次部署 - 创建新 Agent""" + provider = ServerlessProvider() + + # 模拟 AgentEngineClient + mock_client = AsyncMock() + mock_client.create_agent = AsyncMock(return_value={ + "agent_id": "ar-20260119-newagent", + "name": "test-agent", + "endpoint": "https://test.kspmas.ksyun.com", + "api_key": "ak-test-key" + }) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock() + + with patch.dict(os.environ, {"AGENTENGINE_SERVER_URL": "http://localhost:8080"}), \ + patch('ksadk.deployment.providers.serverless.AgentEngineClient', return_value=mock_client), \ + patch('ksadk.common.auth.AWSV4Auth') as MockAuth: + + MockAuth.return_value.access_key = "test-ak" + MockAuth.return_value.secret_key = "test-sk" + + result = await provider.deploy(sample_package_info, sample_deploy_target) + + assert result.status == DeployStatus.DEPLOYING + assert result.agent_name == "test-agent" + assert "首次部署" in result.message + + # 验证状态文件已创建 + state_file = temp_project_dir / ".agentengine.state" + assert state_file.exists() + + state = yaml.safe_load(state_file.read_text()) + assert state["agent_id"] == "ar-20260119-newagent" + + @pytest.mark.asyncio + async def test_deploy_create_new_agent_refreshes_quick_access_when_agent_id_is_immediate( + self, + temp_project_dir, + sample_package_info, + sample_deploy_target, + ): + """测试首次部署即使立即拿到 agent_id,也会回查并持久化 quick access。""" + provider = ServerlessProvider() + + mock_client = AsyncMock() + mock_client.create_agent = AsyncMock( + return_value={ + "agent_id": "ar-20260119-newagent", + "name": "test-agent", + "endpoint": "http://stale.example.com", + "api_key": None, + "order_id": "ord-123", + } + ) + mock_client.get_agent = AsyncMock( + return_value={ + "basic": { + "agent_id": "ar-20260119-newagent", + "name": "test-agent", + }, + "quick_access": { + "public_endpoint": "https://fresh.example.com", + "api_key": "ak-fresh-key", + }, + } + ) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock() + + with patch.dict(os.environ, {"AGENTENGINE_SERVER_URL": "http://localhost:8080"}), \ + patch("ksadk.deployment.providers.serverless.AgentEngineClient", return_value=mock_client), \ + patch("ksadk.common.auth.AWSV4Auth") as MockAuth: + + MockAuth.return_value.access_key = "test-ak" + MockAuth.return_value.secret_key = "test-sk" + + await provider.deploy(sample_package_info, sample_deploy_target) + + state_file = temp_project_dir / ".agentengine.state" + state = yaml.safe_load(state_file.read_text()) + assert state["endpoint"] == "https://fresh.example.com" + assert state["api_key"] == "ak-fresh-key" + + @pytest.mark.asyncio + async def test_deploy_create_new_agent_retries_quick_access_when_agent_not_yet_visible( + self, + temp_project_dir, + sample_package_info, + sample_deploy_target, + ): + """测试首次部署后 GetAgent 短暂 404 时,会短退避重试而不是立即打印警告。""" + provider = ServerlessProvider() + + mock_client = AsyncMock() + mock_client.create_agent = AsyncMock( + return_value={ + "agent_id": "ar-20260119-newagent", + "name": "test-agent", + "endpoint": "http://stale.example.com", + "api_key": None, + "order_id": "ord-123", + } + ) + mock_client.get_agent = AsyncMock( + side_effect=[ + Exception( + 'HTTP 404 POST http://aicp.inner.api.ksyun.com/?Action=GetAgent&Version=2024-06-12: ' + '{"Code":404,"Message":"未找到对应的 Agent","RequestId":"req-1","Data":null}' + ), + { + "basic": { + "agent_id": "ar-20260119-newagent", + "name": "test-agent", + }, + "quick_access": { + "public_endpoint": "https://fresh.example.com", + "api_key": "ak-fresh-key", + }, + }, + ] + ) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock() + + with patch.dict(os.environ, {"AGENTENGINE_SERVER_URL": "http://localhost:8080"}), \ + patch("ksadk.deployment.providers.serverless.AgentEngineClient", return_value=mock_client), \ + patch("ksadk.deployment.agent_access.asyncio.sleep", new=AsyncMock()) as mock_sleep, \ + patch("ksadk.deployment.providers.serverless.logger.warning") as mock_warning, \ + patch("ksadk.common.auth.AWSV4Auth") as MockAuth: + + MockAuth.return_value.access_key = "test-ak" + MockAuth.return_value.secret_key = "test-sk" + + await provider.deploy(sample_package_info, sample_deploy_target) + + state_file = temp_project_dir / ".agentengine.state" + state = yaml.safe_load(state_file.read_text()) + assert state["endpoint"] == "https://fresh.example.com" + assert state["api_key"] == "ak-fresh-key" + assert mock_client.get_agent.await_count == 2 + mock_sleep.assert_awaited_once_with(0.3) + mock_warning.assert_not_called() + + @pytest.mark.asyncio + async def test_deploy_update_existing_agent( + self, + temp_project_dir, + sample_package_info, + sample_deploy_target + ): + """测试二次部署 - 更新已有 Agent""" + provider = ServerlessProvider() + + # 预先创建状态文件 + state_file = temp_project_dir / ".agentengine.state" + state_file.write_text(yaml.dump({ + "agent_id": "ar-20260119-existing", + "name": "test-agent", + "endpoint": "https://existing.kspmas.ksyun.com" + })) + + # 模拟 AgentEngineClient + mock_client = AsyncMock() + mock_client.update_agent = AsyncMock(return_value={ + "agent_id": "ar-20260119-existing", + "name": "test-agent", + "endpoint": "https://existing.kspmas.ksyun.com" + }) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock() + + with patch.dict(os.environ, {"AGENTENGINE_SERVER_URL": "http://localhost:8080"}), \ + patch('ksadk.deployment.providers.serverless.AgentEngineClient', return_value=mock_client), \ + patch('ksadk.common.auth.AWSV4Auth') as MockAuth: + + MockAuth.return_value.access_key = "test-ak" + MockAuth.return_value.secret_key = "test-sk" + + result = await provider.deploy(sample_package_info, sample_deploy_target) + + assert result.status == DeployStatus.DEPLOYING + assert "已更新" in result.message + + # 验证调用了 update_agent 而不是 create_agent + mock_client.update_agent.assert_called_once() + mock_client.create_agent.assert_not_called() + + @pytest.mark.asyncio + async def test_deploy_update_existing_agent_refreshes_quick_access_in_state( + self, + temp_project_dir, + sample_package_info, + sample_deploy_target, + ): + """测试热更新后会把最新 quick access endpoint/api_key 回填到本地状态。""" + provider = ServerlessProvider() + + state_file = temp_project_dir / ".agentengine.state" + state_file.write_text( + yaml.dump( + { + "agent_id": "ar-20260119-existing", + "name": "test-agent", + "endpoint": "http://stale.example.com", + "api_key": None, + } + ) + ) + + mock_client = AsyncMock() + mock_client.get_agent = AsyncMock( + side_effect=[ + { + "basic": { + "agent_id": "ar-20260119-existing", + "name": "test-agent", + } + }, + { + "basic": { + "agent_id": "ar-20260119-existing", + "name": "test-agent", + }, + "quick_access": { + "public_endpoint": "https://fresh.example.com", + "api_key": "ak-fresh-key", + }, + }, + ] + ) + mock_client.update_agent = AsyncMock( + return_value={ + "agent_id": "ar-20260119-existing", + "name": "test-agent", + "endpoint": "http://stale.example.com", + } + ) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock() + + with patch.dict(os.environ, {"AGENTENGINE_SERVER_URL": "http://localhost:8080"}), \ + patch("ksadk.deployment.providers.serverless.AgentEngineClient", return_value=mock_client), \ + patch("ksadk.common.auth.AWSV4Auth") as MockAuth: + + MockAuth.return_value.access_key = "test-ak" + MockAuth.return_value.secret_key = "test-sk" + + await provider.deploy(sample_package_info, sample_deploy_target) + + state = yaml.safe_load(state_file.read_text()) + assert state["endpoint"] == "https://fresh.example.com" + assert state["api_key"] == "ak-fresh-key" + + @pytest.mark.asyncio + async def test_deploy_rejects_ks3_path_without_object_key( + self, + temp_project_dir, + sample_deploy_target, + ): + """测试当 ks3_path 只有 bucket 没有 object key 时,本地直接报错。""" + provider = ServerlessProvider() + bad_package_info = PackageInfo( + name="test-agent", + framework="langgraph", + build_dir=str(temp_project_dir / ".agentengine" / "build"), + project_dir=str(temp_project_dir), + metadata={ + "ks3_path": "ks3://test-bucket" + }, + ) + + with pytest.raises(ValueError, match="ks3_path 格式无效"): + await provider.deploy(bad_package_info, sample_deploy_target) + + @pytest.mark.asyncio + async def test_deploy_persists_ui_config_to_state( + self, + temp_project_dir, + sample_package_info, + sample_deploy_target, + ): + """测试部署后会持久化 UI 配置,供 dashboard 无参打开使用。""" + provider = ServerlessProvider() + sample_deploy_target.extra.update( + { + "ui_profile": "langchain", + "ui_path": "/", + "ui_url": None, + } + ) + + captured = {} + mock_client = AsyncMock() + async def _fake_create_agent(payload): + captured["payload"] = payload + return { + "agent_id": "ar-20260119-newagent-ui", + "name": "test-agent", + "endpoint": "https://test.kspmas.ksyun.com", + "api_key": "ak-test-key", + } + + mock_client.create_agent = AsyncMock(side_effect=_fake_create_agent) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock() + + with patch.dict(os.environ, {"AGENTENGINE_SERVER_URL": "http://localhost:8080"}), patch( + "ksadk.deployment.providers.serverless.AgentEngineClient", return_value=mock_client + ), patch("ksadk.common.auth.AWSV4Auth") as MockAuth: + MockAuth.return_value.access_key = "test-ak" + MockAuth.return_value.secret_key = "test-sk" + + await provider.deploy(sample_package_info, sample_deploy_target) + + state_file = temp_project_dir / ".agentengine.state" + state = yaml.safe_load(state_file.read_text()) + assert state["ui_profile"] == "langchain" + assert state["ui_path"] == "/" + assert captured["payload"]["ui_config"] == { + "profile": "langchain", + "path": "/", + "url": None, + } + + @pytest.mark.asyncio + async def test_deploy_update_forwards_ui_config_to_control_plane( + self, + temp_project_dir, + sample_package_info, + sample_deploy_target, + ): + provider = ServerlessProvider() + sample_deploy_target.extra.update( + { + "ui_profile": "custom", + "ui_path": "/chat", + "ui_url": "https://ui.example.com/custom-ui/", + } + ) + + state_file = temp_project_dir / ".agentengine.state" + state_file.write_text( + yaml.dump( + { + "agent_id": "ar-20260119-existing", + "name": "test-agent", + "endpoint": "https://existing.kspmas.ksyun.com", + } + ) + ) + + captured = {} + mock_client = AsyncMock() + mock_client.get_agent = AsyncMock( + side_effect=[ + {"basic": {"agent_id": "ar-20260119-existing", "name": "test-agent"}}, + {"basic": {"agent_id": "ar-20260119-existing", "name": "test-agent"}}, + ] + ) + + async def _fake_update_agent(agent_id, payload): + captured["agent_id"] = agent_id + captured["payload"] = payload + return { + "agent_id": agent_id, + "name": "test-agent", + "endpoint": "https://existing.kspmas.ksyun.com", + } + + mock_client.update_agent = AsyncMock(side_effect=_fake_update_agent) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock() + + with patch.dict(os.environ, {"AGENTENGINE_SERVER_URL": "http://localhost:8080"}), \ + patch("ksadk.deployment.providers.serverless.AgentEngineClient", return_value=mock_client), \ + patch("ksadk.common.auth.AWSV4Auth") as MockAuth: + + MockAuth.return_value.access_key = "test-ak" + MockAuth.return_value.secret_key = "test-sk" + + await provider.deploy(sample_package_info, sample_deploy_target) + + assert captured["agent_id"] == "ar-20260119-existing" + assert captured["payload"]["ui_config"] == { + "profile": "custom", + "path": "/chat", + "url": "https://ui.example.com/custom-ui/", + } + + @pytest.mark.asyncio + async def test_deploy_strips_bom_from_env_keys( + self, + temp_project_dir, + sample_package_info, + sample_deploy_target, + ): + """测试 .env 带 BOM 时,环境变量 key 会被规范化。""" + provider = ServerlessProvider() + env_file = temp_project_dir / ".env" + env_file.write_text( + "OPENAI_API_KEY=test-key\nOPENAI_MODEL_NAME=test-model\n", + encoding="utf-8-sig", + ) + + captured = {} + mock_client = AsyncMock() + + async def _fake_create_agent(payload): + captured["payload"] = payload + return { + "agent_id": "ar-20260119-bom", + "name": "test-agent", + "endpoint": "https://test.kspmas.ksyun.com", + "api_key": "ak-test-key", + } + + mock_client.create_agent = AsyncMock(side_effect=_fake_create_agent) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock() + + with patch.dict(os.environ, {"AGENTENGINE_SERVER_URL": "http://localhost:8080"}), \ + patch("ksadk.deployment.providers.serverless.AgentEngineClient", return_value=mock_client), \ + patch("ksadk.common.auth.AWSV4Auth") as MockAuth: + + MockAuth.return_value.access_key_id = "test-ak" + MockAuth.return_value.secret_access_key = "test-sk" + + await provider.deploy(sample_package_info, sample_deploy_target) + + env_vars = captured["payload"]["env_vars"] + assert "OPENAI_API_KEY" in env_vars + assert "\ufeffOPENAI_API_KEY" not in env_vars + assert env_vars["OPENAI_MODEL_NAME"] == "test-model" + + @pytest.mark.asyncio + async def test_deploy_merges_global_env_with_project_env( + self, + temp_project_dir, + sample_package_info, + sample_deploy_target, + ): + """部署环境变量使用全局配置 + 项目 .env,且项目 .env 优先。""" + provider = ServerlessProvider() + (temp_project_dir / ".env").write_text( + "OPENAI_API_KEY=project-key\nPROJECT_ONLY=project-value\n", + encoding="utf-8", + ) + + captured = {} + mock_client = AsyncMock() + + async def _fake_create_agent(payload): + captured["payload"] = payload + return { + "agent_id": "ar-20260119-env", + "name": "test-agent", + "endpoint": "https://test.kspmas.ksyun.com", + "api_key": "ak-test-key", + } + + mock_client.create_agent = AsyncMock(side_effect=_fake_create_agent) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock() + + with patch.dict(os.environ, {"AGENTENGINE_SERVER_URL": "http://localhost:8080"}), \ + patch( + "ksadk.deployment.providers.serverless.get_env_from_global_config", + return_value={ + "OPENAI_API_KEY": "global-key", + "OPENAI_BASE_URL": "https://model.example.com/v1", + }, + ), \ + patch("ksadk.deployment.providers.serverless.AgentEngineClient", return_value=mock_client), \ + patch("ksadk.common.auth.AWSV4Auth") as MockAuth: + + MockAuth.return_value.access_key_id = "test-ak" + MockAuth.return_value.secret_access_key = "test-sk" + + await provider.deploy(sample_package_info, sample_deploy_target) + + env_vars = captured["payload"]["env_vars"] + assert env_vars["OPENAI_API_KEY"] == "project-key" + assert env_vars["OPENAI_BASE_URL"] == "https://model.example.com/v1" + assert env_vars["PROJECT_ONLY"] == "project-value" + + def test_deploy_env_vars_precedence_and_process_env_allowlist( + self, + temp_project_dir, + ): + """环境变量优先级: 全局配置 < allowlist shell env < 项目 .env < 显式 env。""" + provider = ServerlessProvider() + (temp_project_dir / ".env").write_text( + "OPENAI_API_KEY=project-key\nPROJECT_ONLY=project-value\n", + encoding="utf-8", + ) + + with patch.dict( + os.environ, + { + "A": "B", + "OPENAI_API_KEY": "shell-key", + "KSADK_BUILD_ENABLE_MCP": "true", + "KSADK_CUSTOM_RUNTIME_FLAG": "from-shell", + "KSADK_SANDBOX_TEMPLATE_ID": "tmpl-shell", + }, + clear=True, + ), patch( + "ksadk.deployment.providers.serverless.get_env_from_global_config", + return_value={ + "OPENAI_API_KEY": "global-key", + "OPENAI_BASE_URL": "https://model.example.com/v1", + }, + ): + env_vars, _, _ = provider._load_deploy_env_vars( + temp_project_dir, + { + "OPENAI_API_KEY": "explicit-key", + "CUSTOM_RUNTIME_FLAG": "enabled", + }, + ) + + assert env_vars["OPENAI_API_KEY"] == "explicit-key" + assert env_vars["OPENAI_BASE_URL"] == "https://model.example.com/v1" + assert env_vars["PROJECT_ONLY"] == "project-value" + assert env_vars["KSADK_CUSTOM_RUNTIME_FLAG"] == "from-shell" + assert env_vars["KSADK_SANDBOX_TEMPLATE_ID"] == "tmpl-shell" + assert env_vars["CUSTOM_RUNTIME_FLAG"] == "enabled" + assert "A" not in env_vars + assert "KSADK_BUILD_ENABLE_MCP" not in env_vars + + def test_deploy_project_env_overrides_process_env_allowlist( + self, + temp_project_dir, + ): + provider = ServerlessProvider() + (temp_project_dir / ".env").write_text( + "OPENAI_API_KEY=project-key\nOPENAI_MODEL_NAME=project-model\n", + encoding="utf-8", + ) + + with patch.dict( + os.environ, + { + "OPENAI_API_KEY": "shell-key", + "OPENAI_MODEL_NAME": "shell-model", + }, + clear=True, + ), patch( + "ksadk.deployment.providers.serverless.get_env_from_global_config", + return_value={"OPENAI_API_KEY": "global-key"}, + ): + env_vars, _, _ = provider._load_deploy_env_vars(temp_project_dir) + + assert env_vars["OPENAI_API_KEY"] == "project-key" + assert env_vars["OPENAI_MODEL_NAME"] == "project-model" + + @pytest.mark.asyncio + async def test_deploy_forwards_network_configuration_to_create_agent( + self, + temp_project_dir, + sample_package_info, + sample_deploy_target, + ): + """测试 serverless deploy 会把网络配置透传给 CreateAgent。""" + provider = ServerlessProvider() + sample_deploy_target.network.enable_public_access = False + sample_deploy_target.network.enable_vpc_access = True + sample_deploy_target.network.vpc_id = "vpc-demo" + sample_deploy_target.network.subnet_id = "subnet-demo" + sample_deploy_target.network.security_group_id = "sg-demo" + sample_deploy_target.network.availability_zone = "cn-beijing-6a" + + captured = {} + mock_client = AsyncMock() + + async def _fake_create_agent(payload): + captured["payload"] = payload + return { + "agent_id": "ar-20260119-network", + "name": "test-agent", + "endpoint": "https://test.kspmas.ksyun.com", + "api_key": "ak-test-key", + } + + mock_client.create_agent = AsyncMock(side_effect=_fake_create_agent) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock() + + with patch.dict(os.environ, {"AGENTENGINE_SERVER_URL": "http://localhost:8080"}), \ + patch("ksadk.deployment.providers.serverless.AgentEngineClient", return_value=mock_client), \ + patch("ksadk.common.auth.AWSV4Auth") as MockAuth: + + MockAuth.return_value.access_key_id = "test-ak" + MockAuth.return_value.secret_access_key = "test-sk" + + await provider.deploy(sample_package_info, sample_deploy_target) + + assert captured["payload"]["network"] == { + "enable_public_access": False, + "enable_vpc_access": True, + "vpc_id": "vpc-demo", + "subnet_id": "subnet-demo", + "security_group_id": "sg-demo", + "availability_zone": "cn-beijing-6a", + } + + @pytest.mark.asyncio + async def test_deploy_forwards_storage_configuration_to_create_agent( + self, + temp_project_dir, + sample_package_info, + sample_deploy_target, + ): + """测试 serverless deploy 会把存储配置透传给 CreateAgent。""" + provider = ServerlessProvider() + sample_deploy_target.storage.mount_path = "/home/node/.agentengine" + sample_deploy_target.storage.size_gi = 64 + + captured = {} + mock_client = AsyncMock() + + async def _fake_create_agent(payload): + captured["payload"] = payload + return { + "agent_id": "ar-20260119-storage", + "name": "test-agent", + "endpoint": "https://test.kspmas.ksyun.com", + "api_key": "ak-test-key", + } + + mock_client.create_agent = AsyncMock(side_effect=_fake_create_agent) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock() + + with patch.dict(os.environ, {"AGENTENGINE_SERVER_URL": "http://localhost:8080"}), \ + patch("ksadk.deployment.providers.serverless.AgentEngineClient", return_value=mock_client), \ + patch("ksadk.common.auth.AWSV4Auth") as MockAuth: + + MockAuth.return_value.access_key_id = "test-ak" + MockAuth.return_value.secret_access_key = "test-sk" + + await provider.deploy(sample_package_info, sample_deploy_target) + + assert captured["payload"]["storage"] == { + "mount_path": "/home/node/.agentengine", + "size_gi": 64, + } + + @pytest.mark.asyncio + async def test_build_persists_ks3_path_metadata_for_followup_cache( + self, + temp_project_dir, + ): + """测试 provider.build 后会持久化 ks3_path,供后续 deploy/launch 命中缓存。""" + provider = ServerlessProvider() + package_info = PackageInfo( + name="test-agent", + framework="langgraph", + build_dir=str(temp_project_dir / ".agentengine" / "build"), + project_dir=str(temp_project_dir), + metadata={}, + ) + target = DeployTarget( + provider="serverless", + region="cn-beijing-6", + extra={"artifact_type": "Code", "no_cache": False}, + ) + + fake_build_result = BuildResult( + success=True, + artifact_path=temp_project_dir / ".agentengine" / "code_build" / "test-agent.zip", + artifact_size=1234, + metadata={"agent_name": "test-agent", "framework": "langgraph"}, + ) + mock_builder = MagicMock() + mock_builder.build.return_value = fake_build_result + + mock_uploader = AsyncMock() + mock_uploader.upload = AsyncMock(return_value="ks3://test-bucket/agents/test-agent/code_20260320180000.zip") + + with patch("ksadk.deployment.providers.serverless.CodeBuilder", return_value=mock_builder), \ + patch("ksadk.deployment.providers.serverless.KS3Uploader", return_value=mock_uploader): + result = await provider.build(package_info, target) + + metadata_file = temp_project_dir / ".agentengine" / "build-metadata.json" + assert metadata_file.exists() + metadata = json.loads(metadata_file.read_text(encoding="utf-8")) + assert metadata["metadata"]["ks3_path"] == result.metadata["ks3_path"] + + class _PackageDetectionType: + value = "langgraph" + + class _PackageDetectionResult: + name = "test-agent" + type = _PackageDetectionType() + entry_point = "agent.py" + + packaged_again = await provider.package( + str(temp_project_dir), + _PackageDetectionResult(), + {}, + ) + assert packaged_again.metadata["ks3_path"] == result.metadata["ks3_path"] + + @pytest.mark.asyncio + async def test_deploy_converts_ks3_path_to_internal_url_for_serverless_runtime_pull( + self, + temp_project_dir, + monkeypatch, + ): + provider = ServerlessProvider() + package_info = PackageInfo( + name="test-agent", + framework="langgraph", + build_dir=str(temp_project_dir / ".agentengine" / "build"), + project_dir=str(temp_project_dir), + metadata={"ks3_path": "ks3://test-bucket/agents/test-agent/code.zip"}, + ) + target = DeployTarget( + provider="serverless", + region="cn-beijing-6", + extra={"artifact_type": "Code"}, + ) + captured = {} + + mock_client = AsyncMock() + + async def _fake_create_agent(data): + captured.update(data) + return { + "agent_id": "ar-test", + "name": "test-agent", + "endpoint": "https://test.kspmas.ksyun.com", + "api_key": "ak-test-key", + } + + mock_client.create_agent = AsyncMock(side_effect=_fake_create_agent) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock() + monkeypatch.setenv("KS3_ENDPOINT_MODE", "public") + + with patch.dict(os.environ, {"AGENTENGINE_SERVER_URL": "http://localhost:8080"}), \ + patch("ksadk.deployment.providers.serverless.AgentEngineClient", return_value=mock_client), \ + patch("ksadk.common.auth.AWSV4Auth") as MockAuth: + + MockAuth.return_value.access_key_id = "test-ak" + MockAuth.return_value.secret_access_key = "test-sk" + + await provider.deploy(package_info, target) + + assert captured["artifact_path"] == ( + "http://test-bucket.ks3-cn-beijing-internal.ksyuncs.com/agents/test-agent/code.zip" + ) + + @pytest.mark.asyncio + async def test_container_build_uses_cached_image_without_rebuild( + self, + temp_project_dir, + ): + """测试 container 模式存在 cached image 时,不会重复 build。""" + provider = ServerlessProvider() + package_info = PackageInfo( + name="test-agent", + framework="langgraph", + build_dir=str(temp_project_dir / ".agentengine" / "build"), + project_dir=str(temp_project_dir), + metadata={"image": "hub.kce.ksyun.com/agentengine/test-agent:cached"}, + ) + target = DeployTarget( + provider="serverless", + region="cn-beijing-6", + extra={"artifact_type": "Container", "no_cache": False}, + ) + + with patch("ksadk.deployment.providers.serverless.ContainerBuilder") as MockBuilder: + result = await provider.build(package_info, target) + + assert result.image == "hub.kce.ksyun.com/agentengine/test-agent:cached" + MockBuilder.assert_not_called() + + +# ============================================================================ +# State File Not Uploaded Tests +# ============================================================================ + +class TestStateFileNotUploaded: + """验证状态文件不会被上传""" + + def test_state_file_excluded_from_package(self, temp_project_dir): + """测试状态文件在打包时被排除""" + # 创建状态文件 + state_file = temp_project_dir / ".agentengine.state" + state_file.write_text("agent_id: test") + + # 模拟打包逻辑 (检查 code_builder.py 中的排除规则) + excluded_items = [] + + for item in temp_project_dir.iterdir(): + if item.name.startswith('.'): + if item.name != '.env': + excluded_items.append(item.name) + + assert ".agentengine.state" in excluded_items diff --git a/tests/test_error_utils_hints.py b/tests/test_error_utils_hints.py new file mode 100644 index 00000000..c0d1ce33 --- /dev/null +++ b/tests/test_error_utils_hints.py @@ -0,0 +1,143 @@ +from pathlib import Path + +from ksadk.api.client import AgentEngineAPIError +from ksadk.cli.error_utils import explain_exception + + +SNAPSHOT_FILE = Path(__file__).parent / "snapshots" / "error_hint_snapshots.txt" + + +def load_section_snapshots(path: Path) -> dict[str, str]: + sections: dict[str, str] = {} + current_name: str | None = None + current_lines: list[str] = [] + + for line in path.read_text(encoding="utf-8").splitlines(): + if line.startswith("=== ") and line.endswith(" ==="): + if current_name is not None: + sections[current_name] = "\n".join(current_lines).rstrip() + "\n" + current_name = line[4:-4] + current_lines = [] + continue + current_lines.append(line) + + if current_name is not None: + sections[current_name] = "\n".join(current_lines).rstrip() + "\n" + + return sections + + +def test_dashboard_not_found_hint_points_to_canonical_open(): + err = Exception("Server API Error (Code: 404): Agent not found") + + _, hints = explain_exception(err, argv=["dashboard"]) + + assert any("agentengine agent list" in hint for hint in hints) + assert any("agentengine dashboard open --agent" in hint for hint in hints) + + +def test_dashboard_list_hint_points_to_share_list(): + err = Exception("Server API Error (Code: 404): Agent not found") + + _, hints = explain_exception(err, argv=["dashboard", "list"]) + + assert any("dashboard list" in hint for hint in hints) + assert any("dashboard share list" in hint for hint in hints) + + +def test_error_hint_snapshots_match_canonical_hints(): + snapshots = load_section_snapshots(SNAPSHOT_FILE) + cases = { + "dashboard_not_found": ( + Exception("Server API Error (Code: 404): Agent not found"), + ["dashboard"], + ), + "dashboard_list_not_found": ( + Exception("Server API Error (Code: 404): Agent not found"), + ["dashboard", "list"], + ), + "dashboard_share_not_found": ( + Exception("Server API Error (Code: 404): Agent not found"), + ["dashboard", "share", "list"], + ), + "mcp_not_found": ( + Exception("Server API Error (Code: 404): MCP not found"), + ["mcp", "status"], + ), + "openclaw_not_found": ( + Exception("Server API Error (Code: 404): OpenClaw not found"), + ["openclaw", "status"], + ), + "version_not_found": ( + Exception("Server API Error (Code: 404): Version not found"), + ["version", "list"], + ), + "auth_failed": ( + Exception("Server API Error (Code: 401): unauthorized"), + ["mcp", "status"], + ), + "missing_aksk": ( + AgentEngineAPIError( + 400, + "Access Key is Missing", + details={ + "http_status": 400, + "remote_error_code": "MissingAccesskey", + "remote_error_message": "Access Key is Missing", + "request_id": "req-missing-ak", + }, + ), + ["hermes", "status"], + ), + "invalid_aksk": ( + AgentEngineAPIError( + 403, + "The Access Key Id you provided does not exist", + details={ + "http_status": 403, + "remote_error_code": "InvalidAccessKey", + "remote_error_message": "The Access Key Id you provided does not exist", + "request_id": "req-invalid-ak", + }, + ), + ["agent", "status"], + ), + "missing_runtime_permission": ( + AgentEngineAPIError( + 403, + "当前账号没有 KsyunAgentEngineDefaultRole 权限", + details={ + "http_status": 403, + "remote_error_code": "AccessDenied", + "remote_error_message": "当前账号没有 KsyunAgentEngineDefaultRole 权限", + "request_id": "req-no-role", + }, + ), + ["openclaw", "status"], + ), + } + + for name, (err, argv) in cases.items(): + summary, hints = explain_exception(err, argv=argv) + actual = "\n".join([summary, *[f"- {hint}" for hint in hints]]).rstrip() + "\n" + assert actual == snapshots[name] + + +def test_missing_aksk_hint_points_to_credential_and_permission_docs(): + err = AgentEngineAPIError( + 400, + "Access Key is Missing", + details={ + "http_status": 400, + "remote_error_code": "MissingAccesskey", + "remote_error_message": "Access Key is Missing", + }, + ) + + summary, hints = explain_exception(err, argv=["hermes", "status"]) + + assert "AK/SK" in summary + assert any("KSYUN_ACCESS_KEY" in hint for hint in hints) + assert any("agentEngineRuntime" in hint for hint in hints) + assert any("/permission/authorize" in hint for hint in hints) + assert any("/pro/iam/" in hint for hint in hints) diff --git a/tests/test_help_snapshots.py b/tests/test_help_snapshots.py new file mode 100644 index 00000000..e1601bc6 --- /dev/null +++ b/tests/test_help_snapshots.py @@ -0,0 +1,144 @@ +from __future__ import annotations + +import re +from pathlib import Path + +from click.testing import CliRunner +from click.utils import strip_ansi +from rich.cells import cell_len + +import ksadk.cli as cli_module +from ksadk.cli import ROOT_HELP_COMMANDS, SHORT_HELP_MAP, _register_commands, cli + +SNAPSHOT_FILE = Path(__file__).parent / "snapshots" / "help_snapshots.txt" +COLORED_ROOT_HELP_ROWS = { + "agentengine init": "初始化项目", + "agentengine run": "运行 API Server", + "agentengine web": "本地调试 Agent Invoke UI", + "agentengine build": "构建部署制品", + "agentengine deploy": "部署到云端", + "agentengine launch": "一键构建+部署", + "agentengine agent": "Agent 资源管理", + "agentengine dashboard": "打开云端 Agent Dashboard", + "agentengine hermes": "Hermes Agent 资源管理", + "agentengine openclaw": "OpenClaw 资源管理", + "agentengine config": "项目配置向导与模型配置", + "--output": "输出格式(pretty/json)", + "--no-color": "禁用颜色输出", + "--version": "显示版本号", + "-h, --help": "显示帮助信息", +} + + +def load_section_snapshots(path: Path) -> dict[str, str]: + sections: dict[str, str] = {} + current_name: str | None = None + current_lines: list[str] = [] + + for line in path.read_text(encoding="utf-8").splitlines(): + if line.startswith("=== ") and line.endswith(" ==="): + if current_name is not None: + sections[current_name] = "\n".join(current_lines).rstrip() + "\n" + current_name = line[4:-4] + current_lines = [] + continue + current_lines.append(line) + + if current_name is not None: + sections[current_name] = "\n".join(current_lines).rstrip() + "\n" + + return sections + + +def _normalize_help(text: str) -> str: + text = re.sub(r"v\d+\.\d+\.\d+(?:[-+][^\s]+)?", "vX.Y.Z", text) + return text.rstrip() + "\n" + + +def test_help_snapshots_match_canonical_cli_surface(): + _register_commands() + runner = CliRunner() + snapshots = load_section_snapshots(SNAPSHOT_FILE) + + commands = { + "root_help": ["--help"], + "a2a_help": ["a2a", "--help"], + "a2a_serve_help": ["a2a", "serve", "--help"], + "a2a_card_help": ["a2a", "card", "--help"], + "agent_help": ["agent", "--help"], + "dashboard_help": ["dashboard", "--help"], + "dashboard_open_help": ["dashboard", "open", "--help"], + "hermes_help": ["hermes", "--help"], + "mcp_help": ["mcp", "--help"], + "mcp_build_help": ["mcp", "build", "--help"], + "openclaw_help": ["openclaw", "--help"], + "version_help": ["version", "--help"], + "config_help": ["config", "--help"], + "config_wizard_help": ["config", "wizard", "--help"], + "config_show_help": ["config", "show", "--help"], + "config_set_help": ["config", "set", "--help"], + "config_model_help": ["config", "model", "--help"], + "completion_help": ["completion", "--help"], + "model_alias_help": ["model", "--help"], + "status_alias_help": ["status", "--help"], + } + + for name, argv in commands.items(): + result = runner.invoke(cli, argv) + assert result.exit_code == 0, result.output + assert _normalize_help(result.output) == snapshots[name] + + +def test_colored_root_help_command_columns_align_with_unicode_icons(monkeypatch): + _register_commands() + monkeypatch.setattr(cli_module, "should_render_banner", lambda: True) + + result = CliRunner().invoke(cli, ["--help"], color=True) + + assert result.exit_code == 0, result.output + + command_lines: list[str] = [] + in_commands = False + for line in strip_ansi(result.output).splitlines(): + if "可用命令:" in line: + in_commands = True + continue + if in_commands and line.startswith(" ") and line.strip(): + command_lines.append(line) + + assert len(command_lines) == len(ROOT_HELP_COMMANDS) + + command_offsets: set[int] = set() + description_offsets: set[int] = set() + for line in command_lines: + command_name = next( + name for name in ROOT_HELP_COMMANDS if re.search(rf"\b{re.escape(name)}\b", line) + ) + description = SHORT_HELP_MAP[command_name] + + command_offsets.add(cell_len(line[: line.index(command_name)])) + description_offsets.add(cell_len(line[: line.index(description)])) + + assert command_offsets == {10} + assert description_offsets == {34} + + +def test_colored_root_help_overview_rows_share_description_column(monkeypatch): + _register_commands() + monkeypatch.setattr(cli_module, "should_render_banner", lambda: True) + + result = CliRunner().invoke(cli, ["--help"], color=True) + + assert result.exit_code == 0, result.output + + lines = strip_ansi(result.output).splitlines() + description_offsets: dict[str, int] = {} + for label, description in COLORED_ROOT_HELP_ROWS.items(): + line = next( + line + for line in lines + if line.startswith(" ") and label in line and description in line + ) + description_offsets[label] = cell_len(line[: line.index(description)]) + + assert set(description_offsets.values()) == {34} diff --git a/tests/test_hermes_container_builder.py b/tests/test_hermes_container_builder.py new file mode 100644 index 00000000..a5b5bd78 --- /dev/null +++ b/tests/test_hermes_container_builder.py @@ -0,0 +1,101 @@ +import os +import subprocess +import sys +from pathlib import Path + +from ksadk.builders.container_builder import ContainerBuilder +from ksadk.detection import DetectionResult, FrameworkDetector, FrameworkType + + +def test_container_builder_preserves_hermes_template_dockerfile(tmp_path: Path): + project = tmp_path / "demo-hermes" + project.mkdir() + (project / "runtime").mkdir() + (project / "runtime" / "app.py").write_text("app = object()\n", encoding="utf-8") + (project / "entrypoint.sh").write_text("#!/usr/bin/env bash\nexec true\n", encoding="utf-8") + (project / "Dockerfile").write_text("FROM python:3.12-slim\nCMD [\"/app/entrypoint.sh\"]\n", encoding="utf-8") + (project / "agentengine.yaml").write_text( + "name: demo_hermes\nframework: hermes\nartifact_type: Container\n", + encoding="utf-8", + ) + + detection = FrameworkDetector(str(project)).detect() + assert detection.type.value == "hermes" + assert detection.entry_point == "runtime/app.py" + + package = ContainerBuilder(project)._package(detection) + + build_dir = Path(package.build_dir) + assert (build_dir / "Dockerfile").read_text(encoding="utf-8") == "FROM python:3.12-slim\nCMD [\"/app/entrypoint.sh\"]\n" + assert (build_dir / "entrypoint.sh").exists() + assert not (build_dir / "entrypoint.py").exists() + + +def test_container_builder_bundles_runtime_common_for_image_mode(tmp_path: Path): + project = tmp_path / "demo-langgraph" + project.mkdir() + package_dir = project / "demo_langgraph" + package_dir.mkdir() + (package_dir / "__init__.py").write_text("", encoding="utf-8") + (package_dir / "agent.py").write_text("root_agent = object()\n", encoding="utf-8") + + detection = DetectionResult( + type=FrameworkType.LANGGRAPH, + name="demo-langgraph", + entry_point="demo_langgraph/agent.py", + package_path=str(package_dir), + agent_variable="root_agent", + confidence=1.0, + ) + + package = ContainerBuilder(project)._package(detection) + + build_dir = Path(package.build_dir) + assert (build_dir / "ksadk" / "server" / "app.py").exists() + assert (build_dir / "ksadk_runtime_common" / "workspace_files" / "__init__.py").exists() + + env = os.environ.copy() + env["PYTHONPATH"] = str(build_dir) + result = subprocess.run( + [ + sys.executable, + "-c", + "import ksadk.server.app; import ksadk_runtime_common; print('ok')", + ], + cwd=tmp_path, + env=env, + text=True, + capture_output=True, + timeout=30, + ) + + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == "ok" + + +def test_container_builder_excludes_real_dotenv_files_but_keeps_example(tmp_path: Path): + project = tmp_path / "demo-langgraph" + project.mkdir() + package_dir = project / "demo_langgraph" + package_dir.mkdir() + (package_dir / "__init__.py").write_text("", encoding="utf-8") + (package_dir / "agent.py").write_text("root_agent = object()\n", encoding="utf-8") + (project / ".env").write_text("OPENAI_API_KEY=secret\n", encoding="utf-8") + (project / ".env.local").write_text("LOCAL_SECRET=secret\n", encoding="utf-8") + (project / ".env.example").write_text("OPENAI_API_KEY=\n", encoding="utf-8") + + detection = DetectionResult( + type=FrameworkType.LANGGRAPH, + name="demo-langgraph", + entry_point="demo_langgraph/agent.py", + package_path=str(package_dir), + agent_variable="root_agent", + confidence=1.0, + ) + + package = ContainerBuilder(project)._package(detection) + + build_dir = Path(package.build_dir) + assert not (build_dir / ".env").exists() + assert not (build_dir / ".env.local").exists() + assert (build_dir / ".env.example").exists() diff --git a/tests/test_hermes_terminal.py b/tests/test_hermes_terminal.py new file mode 100644 index 00000000..fe848dd8 --- /dev/null +++ b/tests/test_hermes_terminal.py @@ -0,0 +1,510 @@ +import asyncio +import contextlib +import io +import json +import os +import sys +from types import SimpleNamespace + +import pytest + +import ksadk.hermes_terminal as hermes_terminal +from ksadk.hermes_terminal import ( + TERMINAL_SUBPROTOCOL, + _recv_loop, + _send_control, + _stdin_loop, + build_start_frame, + build_terminal_ws_url, + run_hermes_terminal_session, + validate_hermes_exec_argv, + validate_hermes_pairing_argv, +) +from ksadk.terminal_client import run_terminal_session +from ksadk.terminal_exec_policy import ( + OPENCLAW_TERMINAL_EXEC_POLICY, +) +from ksadk.terminal_exec_policy import ( + validate_terminal_exec_argv as validate_exec_argv_with_policy, +) + + +def test_build_terminal_ws_url_uses_terminal_path_and_ws_scheme(): + assert ( + build_terminal_ws_url("https://agent.example.com/runtime/") + == "wss://agent.example.com/runtime/_ksadk/terminal/ws" + ) + assert build_terminal_ws_url("http://agent.example.com") == "ws://agent.example.com/_ksadk/terminal/ws" + + +def test_build_start_frame_encodes_protocol_contract(): + payload = json.loads(build_start_frame(mode="exec", argv=["status"], cols=120, rows=40)) + + assert payload == { + "type": "start", + "mode": "exec", + "argv": ["status"], + "cols": 120, + "rows": 40, + } + + +def test_build_start_frame_supports_pairing_mode(): + payload = json.loads(build_start_frame(mode="pairing", argv=["list"], cols=120, rows=40)) + + assert payload["mode"] == "pairing" + assert payload["argv"] == ["list"] + + +def test_build_start_frame_supports_connect_mode(): + payload = json.loads(build_start_frame(mode="connect", argv=[], cols=120, rows=40)) + + assert payload["mode"] == "connect" + assert payload["argv"] == [] + + +def test_build_start_frame_supports_workspace_cwd(): + payload = json.loads(build_start_frame(mode="tui", argv=[], cols=120, rows=40, cwd="demo-workspace")) + + assert payload["mode"] == "tui" + assert payload["cwd"] == "demo-workspace" + + +def test_build_start_frame_supports_whitelisted_terminal_options(): + payload = json.loads( + build_start_frame( + mode="tui", + argv=[], + cols=120, + rows=40, + options={ + "message": "你好", + "thinking": "medium", + "history_limit": 50, + "timeout_ms": 30000, + "deliver": True, + }, + ) + ) + + assert payload["mode"] == "tui" + assert payload["options"] == { + "message": "你好", + "thinking": "medium", + "history_limit": 50, + "timeout_ms": 30000, + "deliver": True, + } + + +@pytest.mark.parametrize( + "argv", + [ + ["status"], + ["doctor"], + ["version"], + ["sessions", "list"], + ["sessions", "show", "session-1"], + ["sessions", "export", "session-1"], + ["config", "show"], + ["config", "check"], + ["skills", "list"], + ["skills", "audit"], + ["tools", "list"], + ["insights"], + ["cron", "list"], + ["cron", "status"], + ["gateway", "status"], + ], +) +def test_validate_hermes_exec_argv_accepts_read_only_subcommands(argv): + assert validate_hermes_exec_argv(argv) == argv + + +@pytest.mark.parametrize( + "argv", + [ + [], + ["setup"], + ["auth"], + ["update"], + ["install"], + ["uninstall"], + ["gateway", "start"], + ["gateway", "restart"], + ["cron", "add"], + ["cron", "remove"], + ["pairing"], + ["skills", "install"], + ["doctor", "--fix"], + ["config", "query"], + ["config", "query", "model.context_length"], + ["status;rm", "-rf"], + ["sessions", "list", "|", "cat"], + ], +) +def test_validate_hermes_exec_argv_rejects_mutating_or_shell_like_commands(argv): + with pytest.raises(ValueError): + validate_hermes_exec_argv(argv) + + +def test_validate_hermes_exec_argv_accepts_env_allowlisted_prefix(monkeypatch): + monkeypatch.setenv("KSADK_TERMINAL_EXEC_SUBCOMMAND_ALLOWLIST", "config") + + assert validate_hermes_exec_argv(["config", "set", "memory.provider", "hindsight"]) == [ + "config", + "set", + "memory.provider", + "hindsight", + ] + + +def test_validate_hermes_exec_argv_env_allowlist_still_rejects_shell_metacharacters(monkeypatch): + monkeypatch.setenv("KSADK_TERMINAL_EXEC_SUBCOMMAND_ALLOWLIST", "config set") + + with pytest.raises(ValueError): + validate_hermes_exec_argv(["config", "set", "memory.provider", "hindsight;rm"]) + + +def test_validate_terminal_exec_argv_defaults_to_common_commands(monkeypatch): + monkeypatch.delenv("KSADK_TERMINAL_EXEC_SUBCOMMAND_ALLOWLIST", raising=False) + + assert hermes_terminal.validate_terminal_exec_argv(["ls", "-la"]) == ["ls", "-la"] + assert hermes_terminal.validate_terminal_exec_argv(["git", "status", "--short"]) == [ + "git", + "status", + "--short", + ] + with pytest.raises(ValueError): + hermes_terminal.validate_terminal_exec_argv(["openclaw", "config", "set", "memory.provider", "hindsight"]) + + +def test_validate_terminal_exec_argv_rejection_mentions_allowlist_env(monkeypatch): + monkeypatch.delenv("KSADK_TERMINAL_EXEC_SUBCOMMAND_ALLOWLIST", raising=False) + + with pytest.raises(ValueError) as exc_info: + hermes_terminal.validate_terminal_exec_argv(["openclaw", "config", "set"]) + + message = str(exc_info.value) + assert "KSADK_TERMINAL_EXEC_SUBCOMMAND_ALLOWLIST='openclaw'" in message + assert "KSADK_TERMINAL_EXEC_SUBCOMMAND_ALLOWLIST='*'" in message + + +def test_validate_terminal_exec_argv_accepts_common_env_allowlisted_prefix(monkeypatch): + monkeypatch.setenv("KSADK_TERMINAL_EXEC_SUBCOMMAND_ALLOWLIST", "openclaw config") + + assert hermes_terminal.validate_terminal_exec_argv( + ["openclaw", "config", "set", "memory.provider", "hindsight"] + ) == ["openclaw", "config", "set", "memory.provider", "hindsight"] + + +def test_validate_terminal_exec_argv_accepts_wildcard_env_allowlist(monkeypatch): + monkeypatch.setenv("KSADK_TERMINAL_EXEC_SUBCOMMAND_ALLOWLIST", "*") + + assert hermes_terminal.validate_terminal_exec_argv(["python", "-c", "print('ok')"]) == [ + "python", + "-c", + "print('ok')", + ] + + +def test_openclaw_exec_policy_allows_remote_cli_fallback_by_default(monkeypatch): + monkeypatch.delenv("KSADK_TERMINAL_EXEC_SUBCOMMAND_ALLOWLIST", raising=False) + + assert validate_exec_argv_with_policy( + ["openclaw", "channels", "login", "--channel", "openclaw-weixin"], + policy=OPENCLAW_TERMINAL_EXEC_POLICY, + ) == ["openclaw", "channels", "login", "--channel", "openclaw-weixin"] + + +@pytest.mark.parametrize( + "argv", + [ + ["list"], + ["approve", "feishu", "ABC123"], + ["approve", "weixin", "XYZ789"], + ["approve", "wpsxiezuo", "WPS123"], + ["revoke", "feishu", "user-1"], + ["revoke", "wpsxiezuo", "user-1"], + ["clear-pending"], + ], +) +def test_validate_hermes_pairing_argv_accepts_safe_pairing_commands(argv): + assert validate_hermes_pairing_argv(argv) == argv + + +@pytest.mark.parametrize( + "argv", + [ + [], + ["approve"], + ["approve", "unknown-platform", "ABC123"], + ["approve", "feishu", "ABC123", "extra"], + ["revoke", "unknown", "user-1"], + ["clear-pending", "now"], + ["list", "--json"], + ["approve", "feishu", "A;B"], + ["pairing", "list"], + ], +) +def test_validate_hermes_pairing_argv_rejects_unsafe_or_unsupported_commands(argv): + with pytest.raises(ValueError): + validate_hermes_pairing_argv(argv) + + +def test_terminal_session_helpers_are_importable_without_real_tty(): + assert SimpleNamespace is not None + + +class _FakeReceiveWebSocket: + def __init__(self, messages): + self._messages = list(messages) + + def __aiter__(self): + return self + + async def __anext__(self): + if not self._messages: + raise StopAsyncIteration + return self._messages.pop(0) + + +class _FakeSendWebSocket: + def __init__(self): + self.sent = [] + + async def send(self, payload): + self.sent.append(payload) + + +class _FakeTerminalConnection: + def __init__(self, ws): + self.ws = ws + + async def __aenter__(self): + return self.ws + + async def __aexit__(self, exc_type, exc, tb): + return False + + +class _FakeTerminalWebSocket(_FakeSendWebSocket): + subprotocol = TERMINAL_SUBPROTOCOL + + def __init__(self): + super().__init__() + self._messages = [ + json.dumps({"type": "ready"}), + json.dumps({"type": "exit", "code": 0}), + ] + + def __aiter__(self): + return self + + async def __anext__(self): + if not self._messages: + raise StopAsyncIteration + return self._messages.pop(0) + + +class _NonTtyDefaultStdin: + def isatty(self): + return False + + def fileno(self): # pragma: no cover - should not be reached + raise AssertionError("default non-tty stdin should not be read for exec/pairing") + + +class _FakeKernel32: + def __init__(self, mode: int): + self.mode = mode + self.handles = [] + self.set_modes = [] + + def GetConsoleMode(self, handle, mode_ptr): + self.handles.append(handle) + mode_ptr._obj.value = self.mode + return 1 + + def SetConsoleMode(self, handle, mode): + self.set_modes.append((handle, mode)) + return 1 + + +class _FakeWindowsStdin: + def __init__(self, fd: int = 11): + self._fd = fd + + def isatty(self): + return True + + def fileno(self): + return self._fd + + +@pytest.mark.asyncio +async def test_recv_loop_writes_binary_output_and_returns_exit_code(): + ws = _FakeReceiveWebSocket([b"hello", json.dumps({"type": "ready"}), json.dumps({"type": "exit", "code": 7})]) + stdout = io.BytesIO() + + exit_code = await _recv_loop(ws, stdout) + + assert exit_code == 7 + assert stdout.getvalue() == b"hello" + + +@pytest.mark.asyncio +async def test_send_control_encodes_text_control_frame(): + ws = _FakeSendWebSocket() + + await _send_control(ws, {"type": "resize", "cols": 100, "rows": 30}) + await _send_control(ws, {"type": "signal", "signal": "SIGINT"}) + + assert [json.loads(item) for item in ws.sent] == [ + {"type": "resize", "cols": 100, "rows": 30}, + {"type": "signal", "signal": "SIGINT"}, + ] + + +@pytest.mark.asyncio +async def test_stdin_loop_sends_binary_stdin_and_eof_control_frame(): + read_fd, write_fd = os.pipe() + os.write(write_fd, b"abc") + os.close(write_fd) + reader = os.fdopen(read_fd, "rb", closefd=True) + ws = _FakeSendWebSocket() + + try: + await _stdin_loop(ws, reader) + finally: + reader.close() + + assert ws.sent[0] == b"abc" + assert json.loads(ws.sent[1]) == {"type": "stdin_eof"} + + +@pytest.mark.asyncio +async def test_terminal_session_cancels_blocked_stdin_after_remote_exit(monkeypatch): + read_fd, write_fd = os.pipe() + reader = os.fdopen(read_fd, "rb", closefd=True) + fake_ws = _FakeTerminalWebSocket() + + async def _fake_connect(*_args, **_kwargs): + return _FakeTerminalConnection(fake_ws) + + monkeypatch.setattr("ksadk.hermes_terminal._connect_websocket", _fake_connect) + + try: + exit_code = await asyncio.wait_for( + run_hermes_terminal_session( + endpoint="https://agent.example.com", + mode="tui", + stdin=reader, + stdout=io.BytesIO(), + ), + timeout=1, + ) + finally: + os.close(write_fd) + reader.close() + + assert exit_code == 0 + assert json.loads(fake_ws.sent[0])["mode"] == "tui" + + +@pytest.mark.asyncio +async def test_exec_session_does_not_read_default_non_tty_stdin(monkeypatch): + fake_ws = _FakeTerminalWebSocket() + + async def _fake_connect(*_args, **_kwargs): + return _FakeTerminalConnection(fake_ws) + + monkeypatch.setattr("ksadk.hermes_terminal._connect_websocket", _fake_connect) + monkeypatch.setattr(sys, "stdin", _NonTtyDefaultStdin()) + + exit_code = await run_hermes_terminal_session( + endpoint="https://agent.example.com", + mode="exec", + argv=["status"], + stdout=io.BytesIO(), + ) + + assert exit_code == 0 + assert json.loads(fake_ws.sent[0])["mode"] == "exec" + assert json.loads(fake_ws.sent[1]) == {"type": "stdin_eof"} + + +def test_windows_raw_terminal_enables_console_raw_mode_and_restores(monkeypatch): + stdin = _FakeWindowsStdin() + fake_kernel32 = _FakeKernel32(mode=0x00FF) + fake_msvcrt = SimpleNamespace(get_osfhandle=lambda fd: fd + 1000) + + with hermes_terminal._windows_raw_terminal( + stdin, + kernel32=fake_kernel32, + msvcrt_module=fake_msvcrt, + ): + pass + + assert fake_kernel32.handles == [1011] + assert fake_kernel32.set_modes[0] == (1011, 0x02B8) + assert fake_kernel32.set_modes[1] == (1011, 0x00FF) + + +@pytest.mark.asyncio +async def test_hermes_terminal_session_uses_windows_raw_terminal_on_windows(monkeypatch): + fake_ws = _FakeTerminalWebSocket() + fake_stdin = _FakeWindowsStdin() + entered = [] + + @contextlib.contextmanager + def _fake_windows_raw_terminal(stdin, **_kwargs): + entered.append(stdin) + yield + + async def _fake_connect(*_args, **_kwargs): + return _FakeTerminalConnection(fake_ws) + + monkeypatch.setattr("ksadk.hermes_terminal._connect_websocket", _fake_connect) + monkeypatch.setattr(hermes_terminal.sys, "platform", "win32") + monkeypatch.setattr(hermes_terminal, "_windows_raw_terminal", _fake_windows_raw_terminal) + monkeypatch.setattr(hermes_terminal, "_read_stdin_chunk", lambda _fd: asyncio.sleep(0, result=b"")) + + exit_code = await run_hermes_terminal_session( + endpoint="https://agent.example.com", + mode="exec", + argv=["status"], + stdin=fake_stdin, + stdout=io.BytesIO(), + ) + + assert exit_code == 0 + assert entered == [fake_stdin] + + +@pytest.mark.asyncio +async def test_terminal_exec_with_openclaw_policy_allows_openclaw_cli_argv(monkeypatch): + fake_ws = _FakeTerminalWebSocket() + + async def _fake_connect(*_args, **_kwargs): + return _FakeTerminalConnection(fake_ws) + + monkeypatch.setattr("ksadk.hermes_terminal._connect_websocket", _fake_connect) + monkeypatch.setattr(sys, "stdin", _NonTtyDefaultStdin()) + + exit_code = await run_terminal_session( + endpoint="https://agent.example.com", + mode="exec", + argv=["openclaw", "channels", "login", "--channel", "openclaw-weixin"], + exec_policy=OPENCLAW_TERMINAL_EXEC_POLICY, + stdout=io.BytesIO(), + ) + + assert exit_code == 0 + assert json.loads(fake_ws.sent[0]) == { + "type": "start", + "mode": "exec", + "argv": ["openclaw", "channels", "login", "--channel", "openclaw-weixin"], + "cols": 80, + "rows": 24, + } diff --git a/tests/test_hermes_terminal_e2e.py b/tests/test_hermes_terminal_e2e.py new file mode 100644 index 00000000..833263c1 --- /dev/null +++ b/tests/test_hermes_terminal_e2e.py @@ -0,0 +1,157 @@ +import io +import json +import os + +import pytest +import websockets + +from ksadk.hermes_terminal import TERMINAL_SUBPROTOCOL, run_hermes_terminal_session + + +@pytest.mark.asyncio +async def test_terminal_session_real_websocket_exec_round_trip(): + observed = {} + + async def _handler(ws): + observed["subprotocol"] = ws.subprotocol + observed["start"] = json.loads(await ws.recv()) + await ws.send(json.dumps({"type": "ready"})) + observed["stdin"] = await ws.recv() + observed["eof"] = json.loads(await ws.recv()) + await ws.send(b"status ok\n") + await ws.send(json.dumps({"type": "exit", "code": 0})) + + server = await websockets.serve( + _handler, + "127.0.0.1", + 0, + subprotocols=[TERMINAL_SUBPROTOCOL], + ) + port = server.sockets[0].getsockname()[1] + read_fd, write_fd = os.pipe() + os.write(write_fd, b"abc") + os.close(write_fd) + stdin = os.fdopen(read_fd, "rb", closefd=True) + stdout = io.BytesIO() + + try: + exit_code = await run_hermes_terminal_session( + endpoint=f"http://127.0.0.1:{port}", + mode="exec", + argv=["status"], + stdin=stdin, + stdout=stdout, + ) + finally: + stdin.close() + server.close() + await server.wait_closed() + + assert exit_code == 0 + assert observed["subprotocol"] == TERMINAL_SUBPROTOCOL + assert observed["start"]["mode"] == "exec" + assert observed["start"]["argv"] == ["status"] + assert observed["stdin"] == b"abc" + assert observed["eof"] == {"type": "stdin_eof"} + assert stdout.getvalue() == b"status ok\n" + + +@pytest.mark.asyncio +async def test_terminal_session_real_websocket_pairing_start_frame(): + observed = {} + + async def _handler(ws): + observed["start"] = json.loads(await ws.recv()) + await ws.send(json.dumps({"type": "ready"})) + await ws.send(json.dumps({"type": "exit", "code": 0})) + + server = await websockets.serve( + _handler, + "127.0.0.1", + 0, + subprotocols=[TERMINAL_SUBPROTOCOL], + ) + port = server.sockets[0].getsockname()[1] + + try: + exit_code = await run_hermes_terminal_session( + endpoint=f"http://127.0.0.1:{port}", + mode="pairing", + argv=["list"], + stdin=io.BytesIO(), + stdout=io.BytesIO(), + ) + finally: + server.close() + await server.wait_closed() + + assert exit_code == 0 + assert observed["start"]["mode"] == "pairing" + assert observed["start"]["argv"] == ["list"] + + +@pytest.mark.asyncio +async def test_terminal_session_real_websocket_connect_start_frame(): + observed = {} + + async def _handler(ws): + observed["start"] = json.loads(await ws.recv()) + await ws.send(json.dumps({"type": "ready"})) + await ws.send(json.dumps({"type": "exit", "code": 0})) + + server = await websockets.serve( + _handler, + "127.0.0.1", + 0, + subprotocols=[TERMINAL_SUBPROTOCOL], + ) + port = server.sockets[0].getsockname()[1] + + try: + exit_code = await run_hermes_terminal_session( + endpoint=f"http://127.0.0.1:{port}", + mode="connect", + stdin=io.BytesIO(), + stdout=io.BytesIO(), + ) + finally: + server.close() + await server.wait_closed() + + assert exit_code == 0 + assert observed["start"]["mode"] == "connect" + assert observed["start"]["argv"] == [] + + +@pytest.mark.asyncio +async def test_terminal_session_real_websocket_tui_start_frame_carries_cwd(): + observed = {} + + async def _handler(ws): + observed["start"] = json.loads(await ws.recv()) + await ws.send(json.dumps({"type": "ready"})) + await ws.send(json.dumps({"type": "exit", "code": 0})) + + server = await websockets.serve( + _handler, + "127.0.0.1", + 0, + subprotocols=[TERMINAL_SUBPROTOCOL], + ) + port = server.sockets[0].getsockname()[1] + + try: + exit_code = await run_hermes_terminal_session( + endpoint=f"http://127.0.0.1:{port}", + mode="tui", + cwd="demo-workspace", + stdin=io.BytesIO(), + stdout=io.BytesIO(), + ) + finally: + server.close() + await server.wait_closed() + + assert exit_code == 0 + assert observed["start"]["mode"] == "tui" + assert observed["start"]["cwd"] == "demo-workspace" diff --git a/tests/test_json_contracts.py b/tests/test_json_contracts.py new file mode 100644 index 00000000..b276816b --- /dev/null +++ b/tests/test_json_contracts.py @@ -0,0 +1,729 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import yaml +from click.testing import CliRunner + +from ksadk.api.client import DryRunExit +from ksadk.cli import _register_commands, cli +from ksadk.cli import cmd_dashboard, cmd_deploy, cmd_launch, cmd_mcp +from ksadk.cli.cmd_build import build +from ksadk.cli.cmd_mcp import mcp +from ksadk.deployment.base import DeployResult, DeployStatus, PackageInfo +from ksadk.builders.base import BuildResult + + +def _parse_json(output: str) -> dict: + return json.loads(output.strip()) + + +def test_config_show_json_envelope(tmp_path: Path, monkeypatch): + _register_commands() + runner = CliRunner() + monkeypatch.chdir(tmp_path) + + (tmp_path / "agentengine.yaml").write_text( + yaml.safe_dump({"name": "demo-agent", "framework": "langgraph", "region": "cn-beijing-6"}), + encoding="utf-8", + ) + (tmp_path / ".env").write_text("OPENAI_MODEL_NAME=demo-model\n", encoding="utf-8") + monkeypatch.setattr( + "ksadk.configs.global_config.load_global_config", + lambda: {"cloud": {"KSYUN_REGION": "cn-guangzhou-1"}}, + ) + monkeypatch.setattr( + "ksadk.configs.global_config.get_env_from_global_config", + lambda: {"KSYUN_REGION": "cn-guangzhou-1"}, + ) + monkeypatch.setattr( + "ksadk.configs.global_config.get_global_config_path", + lambda: tmp_path / ".agentengine" / "settings.json", + ) + + result = runner.invoke(cli, ["--output", "json", "config", "show"]) + + assert result.exit_code == 0, result.output + payload = _parse_json(result.output) + assert payload["ok"] is True + assert payload["kind"] == "status" + assert payload["resource"] == "config" + assert payload["item"]["project_config"]["name"] == "demo-agent" + assert payload["item"]["effective_env"]["OPENAI_MODEL_NAME"] == "demo-model" + assert payload["item"]["effective_env"]["KSYUN_REGION"] == "cn-guangzhou-1" + + +def test_config_set_json_envelope_and_file_updates(tmp_path: Path, monkeypatch): + _register_commands() + runner = CliRunner() + monkeypatch.chdir(tmp_path) + + result = runner.invoke( + cli, + [ + "--output", + "json", + "config", + "set", + "region=cn-beijing-6", + "OPENAI_MODEL_NAME=glm-5.1", + ], + ) + + assert result.exit_code == 0, result.output + payload = _parse_json(result.output) + assert payload["ok"] is True + assert payload["kind"] == "result" + assert payload["resource"] == "config" + assert payload["action"] == "set" + assert sorted(payload["result"]["updated_project_keys"]) == ["region"] + assert sorted(payload["result"]["updated_env_keys"]) == ["KSYUN_REGION", "OPENAI_MODEL_NAME"] + + project_config = yaml.safe_load((tmp_path / "agentengine.yaml").read_text(encoding="utf-8-sig")) + env_text = (tmp_path / ".env").read_text(encoding="utf-8-sig") + assert project_config["region"] == "cn-beijing-6" + assert "OPENAI_MODEL_NAME=glm-5.1" in env_text + assert "KSYUN_REGION=cn-beijing-6" in env_text + + +def test_config_set_uppercase_env_var_updates_project_env(tmp_path: Path, monkeypatch): + _register_commands() + runner = CliRunner() + monkeypatch.chdir(tmp_path) + + result = runner.invoke( + cli, + [ + "--output", + "json", + "config", + "set", + "AGENTENGINE_SERVER_URL=http://aicp.inner.api.ksyun.com", + ], + ) + + assert result.exit_code == 0, result.output + payload = _parse_json(result.output) + assert payload["result"]["updated_project_keys"] == [] + assert payload["result"]["updated_env_keys"] == ["AGENTENGINE_SERVER_URL"] + + env_text = (tmp_path / ".env").read_text(encoding="utf-8-sig") + assert "AGENTENGINE_SERVER_URL=http://aicp.inner.api.ksyun.com" in env_text + assert not (tmp_path / "agentengine.yaml").exists() + + +def test_dashboard_open_json_does_not_open_browser(monkeypatch): + runner = CliRunner() + opened_urls: list[str] = [] + + async def _fake_resolve_agent_detail(*_args, **_kwargs): + return ( + { + "agent_id": "ar-demo", + "name": "demo-agent", + "framework": "langgraph", + "endpoint": "https://agent.example.com", + }, + type("Ref", (), {"source": "cli", "source_text": "CLI", "value": "ar-demo"})(), + False, + ) + + async def _fake_create_dashboard_access_link(**_kwargs): + return { + "link_id": "lnk-demo", + "expires_at": None, + "access_url": "https://dashboard.example.com/share/lnk-demo", + } + + monkeypatch.setattr(cmd_dashboard, "_resolve_agent_detail", _fake_resolve_agent_detail) + monkeypatch.setattr(cmd_dashboard, "_create_dashboard_access_link", _fake_create_dashboard_access_link) + monkeypatch.setattr(cmd_dashboard, "load_state", lambda _cwd: {}) + monkeypatch.setattr(cmd_dashboard.webbrowser, "open", lambda url: opened_urls.append(url)) + + result = runner.invoke(cmd_dashboard.dashboard, ["open", "ar-demo", "--output", "json"]) + + assert result.exit_code == 0, result.output + payload = _parse_json(result.output) + assert payload["ok"] is True + assert payload["kind"] == "result" + assert payload["resource"] == "dashboard_share" + assert payload["action"] == "open" + assert payload["result"]["url"] == "https://dashboard.example.com/share/lnk-demo" + assert opened_urls == [] + + +def test_dashboard_share_revoke_json_requires_yes(monkeypatch): + runner = CliRunner() + + async def _should_not_run(**_kwargs): + raise AssertionError("delete should not be called without --yes") + + monkeypatch.setattr(cmd_dashboard, "_delete_dashboard_access_link", _should_not_run) + + result = runner.invoke(cmd_dashboard.dashboard, ["share", "revoke", "lnk-demo", "--output", "json"]) + + assert result.exit_code == 2, result.output + payload = _parse_json(result.output) + assert payload["ok"] is False + assert payload["error"]["code"] == "usage_error" + assert "--yes" in payload["error"]["message"] or "--yes" in "".join(payload["hints"]) + + +class _FakeMCPDetectionResult: + is_valid = True + entry_point = "mcp_server.py" + mcp_variable = "mcp" + tools = ["search", "fetch"] + + +class _FakeMCPDetector: + def __init__(self, *_args, **_kwargs): + pass + + def detect(self): + return _FakeMCPDetectionResult() + + +class _FakeMCPBuildResult: + success = True + artifact_path = Path("/tmp/demo-mcp.zip") + error_message = "" + metadata = {} + + +async def _fake_build_code_artifact(*_args, **_kwargs): + build_result = BuildResult( + success=True, + artifact_path=Path("/tmp/demo-mcp.zip"), + artifact_size=1234, + metadata={"framework": "mcp"}, + ) + return build_result, "ks3://demo-bucket/mcps/demo-mcp/code_20260322120000.zip" + + +async def _fake_build_mcp_async(*_args, **_kwargs): + return { + "framework": "mcp", + "artifact_type": "code", + "artifact_reference": "ks3://demo-bucket/mcps/demo-mcp/code_fake.zip", + "artifact_built": True, + "artifact_source": "built", + "artifact_reused": False, + "push": True, + } + + +class _FakeMCPClient: + def __init__(self, *args, **kwargs): + self.kwargs = kwargs + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def create_mcp(self, _request): + return { + "mcp_id": "mcp-demo", + "endpoint": "https://mcp.example.com", + "api_key": "secret", + } + + async def update_mcp(self, _mcp_id, _request): + raise AssertionError("update path should not be used in this test") + + +class _FakeMCPDryRunClient: + def __init__(self, *args, **kwargs): + self.kwargs = kwargs + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def create_mcp(self, request): + raise DryRunExit("dry-run", payload={"body": request}) + + async def close(self): + return None + + +def test_mcp_deploy_json_envelope(tmp_path: Path, monkeypatch): + runner = CliRunner() + monkeypatch.chdir(tmp_path) + monkeypatch.setattr("ksadk.detection.mcp_detector.MCPDetector", _FakeMCPDetector) + monkeypatch.setattr(cmd_mcp, "_build_code_artifact", _fake_build_code_artifact) + monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeMCPClient) + + result = runner.invoke(mcp, ["deploy", str(tmp_path), "--output", "json"]) + + assert result.exit_code == 0, result.output + payload = _parse_json(result.output) + assert payload["ok"] is True + assert payload["kind"] == "result" + assert payload["resource"] == "workflow" + assert payload["action"] == "deploy" + assert payload["result"]["artifact_type"] == "code" + assert payload["result"]["artifact_reference"] == "ks3://demo-bucket/mcps/demo-mcp/code_20260322120000.zip" + assert payload["result"]["mcp_id"] == "mcp-demo" + assert payload["result"]["mcp_url"] == "https://mcp.example.com/mcp" + + +def test_mcp_deploy_dry_run_json_envelope(tmp_path: Path, monkeypatch): + runner = CliRunner() + monkeypatch.chdir(tmp_path) + monkeypatch.setattr("ksadk.detection.mcp_detector.MCPDetector", _FakeMCPDetector) + monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeMCPDryRunClient) + + def _should_not_build(*_args, **_kwargs): + raise AssertionError("Dry run should not build artifacts") + + monkeypatch.setattr(cmd_mcp, "_build_code_artifact", _should_not_build) + + result = runner.invoke( + mcp, + ["deploy", str(tmp_path), "--dry-run", "--output", "json", "--ks3-bucket", "demo-bucket"], + ) + + assert result.exit_code == 0, result.output + payload = _parse_json(result.output) + assert payload["ok"] is True + assert payload["kind"] == "dry_run" + assert payload["resource"] == "workflow" + assert payload["action"] == "deploy" + assert payload["request"]["body"]["artifact_type"] == "Code" + assert payload["plan"]["artifact"]["reference"].startswith("ks3://demo-bucket/") + + +async def _fake_build_mcp_async(**_kwargs): + return { + "framework": "mcp", + "artifact_type": "code", + "artifact_source": "built", + "artifact_reused": False, + "artifact_built": True, + "artifact_reference": "ks3://demo-bucket/mcps/demo-mcp/code_fake.zip", + "push": True, + "region": "cn-beijing-6", + "mcp_name": "demo-mcp", + "tools": ["ping", "add"], + } + + +def test_mcp_build_json_envelope(tmp_path: Path, monkeypatch): + runner = CliRunner() + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(cmd_mcp, "_build_mcp_async", _fake_build_mcp_async) + + result = runner.invoke(mcp, ["build", str(tmp_path), "--artifact-type", "Code", "--output", "json"]) + + assert result.exit_code == 0, result.output + payload = _parse_json(result.output) + assert payload["ok"] is True + assert payload["kind"] == "result" + assert payload["resource"] == "workflow" + assert payload["action"] == "build" + assert payload["result"]["artifact_type"] == "code" + assert payload["result"]["artifact_reference"] == "ks3://demo-bucket/mcps/demo-mcp/code_fake.zip" + + +class _FakeBuildResult: + def __init__(self): + self.success = True + self.error_message = "" + self.metadata = {"framework": "langgraph", "agent_name": "demo-agent", "reused": False} + self.artifact_path = Path("/tmp/demo-agent.zip") + self.artifact_size_mb = 12.5 + + +class _FakeCodeBuilder: + def __init__(self, *args, **kwargs): + self.kwargs = kwargs + + def build(self): + return _FakeBuildResult() + + +class _FakeContainerBuildResult: + def __init__(self): + self.success = True + self.error_message = "" + self.metadata = {"framework": "langgraph", "image": "hub.kce.ksyun.com/demo/demo-agent:latest", "reused": False} + self.artifact_path = Path("/tmp/demo-image.tar") + self.artifact_size_mb = 25.0 + + +class _FakeContainerBuilderPushFailure: + def __init__(self, *args, **kwargs): + self.kwargs = kwargs + + def build(self): + return _FakeContainerBuildResult() + + def push(self, _image): + return False + + +def test_build_json_envelope(monkeypatch, tmp_path: Path): + runner = CliRunner() + monkeypatch.setattr("ksadk.builders.CodeBuilder", _FakeCodeBuilder) + + result = runner.invoke(build, [str(tmp_path), "--output", "json"]) + + assert result.exit_code == 0, result.output + payload = _parse_json(result.output) + assert payload["ok"] is True + assert payload["kind"] == "result" + assert payload["resource"] == "workflow" + assert payload["action"] == "build" + assert payload["result"]["artifact_type"] == "code" + assert payload["result"]["artifact_built"] is True + + +def test_build_push_failure_returns_structured_json_error(monkeypatch, tmp_path: Path): + _register_commands() + runner = CliRunner() + monkeypatch.setattr("ksadk.builders.ContainerBuilder", _FakeContainerBuilderPushFailure) + + result = runner.invoke( + cli, + ["--output", "json", "build", str(tmp_path), "--mode", "container", "--push"], + ) + + assert result.exit_code == 6, result.output + payload = _parse_json(result.output) + assert payload["ok"] is False + assert payload["error"]["code"] == "remote_error" + assert payload["error"]["details"]["image"] == "hub.kce.ksyun.com/demo/demo-agent:latest" + + +class _FakeDetectionType: + value = "langgraph" + + +class _FakeDetectionResult: + type = _FakeDetectionType() + name = "langgraph" + entry_point = "agent.py" + + +class _FakeProvider: + def __init__(self): + self.calls = [] + + async def validate_config(self, _target): + self.calls.append("validate") + return True, "" + + async def package(self, project_dir, _detection_result, _config): + self.calls.append("package") + return PackageInfo( + name="demo-agent", + framework="langgraph", + build_dir=str(Path(project_dir) / ".agentengine" / "build"), + project_dir=str(project_dir), + metadata={}, + ) + + async def build(self, package_info, _target): + self.calls.append("build") + package_info.metadata["ks3_path"] = "ks3://bucket/agents/demo-agent/code_20260320170000.zip" + return package_info + + async def deploy(self, _package_info, _target): + self.calls.append("deploy") + return DeployResult( + status=DeployStatus.DEPLOYING, + agent_id="ar-demo", + agent_name="demo-agent", + endpoint="http://demo-endpoint", + message="ok", + ) + + +class _FakeWorkflowDryRunProvider(_FakeProvider): + async def deploy(self, package_info, target): + self.calls.append("deploy") + artifact_reference = ( + package_info.metadata.get("ks3_path") + or package_info.image + or target.extra.get("ks3_path") + or target.extra.get("image") + or "" + ) + return DeployResult( + status=DeployStatus.SKIPPED, + message="dry run", + metadata={ + "dry_run_request": { + "method": "POST", + "url": "https://agentengine.example.com/agentengine/api/v1/CreateAgent", + "headers": {"Content-Type": "application/json"}, + "body": {"ArtifactPath": artifact_reference, "Name": package_info.name}, + "curl": "curl -X POST https://agentengine.example.com/agentengine/api/v1/CreateAgent", + } + }, + ) + + +class _NoBuildDuringDryRunProvider(_FakeWorkflowDryRunProvider): + async def build(self, package_info, _target): + raise AssertionError("build should not run during dry-run") + + +def test_deploy_json_envelope(tmp_path: Path, monkeypatch): + provider = _FakeProvider() + runner = CliRunner() + + monkeypatch.setattr("ksadk.detection.FrameworkDetector", lambda *_args, **_kwargs: type("D", (), {"detect": lambda self: _FakeDetectionResult()})()) + monkeypatch.setattr("ksadk.cli.cmd_deploy._load_config", lambda *_args, **_kwargs: {"name": "demo-agent"}) + monkeypatch.setattr("ksadk.deployment.DeploymentManager.get_provider", lambda *_args, **_kwargs: provider) + + result = runner.invoke( + cmd_deploy.deploy, + [str(tmp_path), "--account-id", "2000003485", "--output", "json"], + ) + + assert result.exit_code == 0, result.output + payload = _parse_json(result.output) + assert payload["ok"] is True + assert payload["kind"] == "result" + assert payload["resource"] == "workflow" + assert payload["action"] == "deploy" + assert payload["result"]["agent_id"] == "ar-demo" + assert payload["result"]["endpoint"] == "http://demo-endpoint" + + +def test_launch_json_envelope(tmp_path: Path, monkeypatch): + provider = _FakeProvider() + runner = CliRunner() + + monkeypatch.setattr("ksadk.detection.FrameworkDetector", lambda *_args, **_kwargs: type("D", (), {"detect": lambda self: _FakeDetectionResult()})()) + monkeypatch.setattr("ksadk.cli.cmd_launch._load_config", lambda *_args, **_kwargs: {"name": "demo-agent"}) + monkeypatch.setattr("ksadk.deployment.DeploymentManager.get_provider", lambda *_args, **_kwargs: provider) + + result = runner.invoke( + cmd_launch.launch, + [str(tmp_path), "--account-id", "2000003485", "--output", "json"], + ) + + assert result.exit_code == 0, result.output + payload = _parse_json(result.output) + assert payload["ok"] is True + assert payload["kind"] == "result" + assert payload["resource"] == "workflow" + assert payload["action"] == "launch" + assert payload["result"]["agent_id"] == "ar-demo" + assert payload["result"]["endpoint"] == "http://demo-endpoint" + + +def test_deploy_dry_run_json_envelope_includes_local_plan_and_remote_curl(tmp_path: Path, monkeypatch): + provider = _NoBuildDuringDryRunProvider() + runner = CliRunner() + + monkeypatch.setattr("ksadk.detection.FrameworkDetector", lambda *_args, **_kwargs: type("D", (), {"detect": lambda self: _FakeDetectionResult()})()) + monkeypatch.setattr("ksadk.cli.cmd_deploy._load_config", lambda *_args, **_kwargs: {"name": "demo-agent"}) + monkeypatch.setattr("ksadk.deployment.DeploymentManager.get_provider", lambda *_args, **_kwargs: provider) + + result = runner.invoke( + cmd_deploy.deploy, + [str(tmp_path), "--account-id", "2000003485", "--dry-run", "--output", "json"], + ) + + assert result.exit_code == 0, result.output + payload = _parse_json(result.output) + assert payload["ok"] is True + assert payload["kind"] == "dry_run" + assert payload["resource"] == "workflow" + assert payload["action"] == "deploy" + assert payload["plan"]["artifact"]["should_build"] is True + assert payload["plan"]["artifact"]["will_build"] is False + assert payload["plan"]["artifact"]["should_local_build"] is True + assert payload["plan"]["artifact"]["will_local_build"] is False + assert payload["plan"]["artifact"]["should_publish"] is True + assert payload["plan"]["artifact"]["will_publish"] is False + assert payload["plan"]["artifact"]["source"] == "planned_build" + assert payload["plan"]["artifact"]["reference_is_predicted"] is True + assert [step["name"] for step in payload["plan"]["steps"]] == [ + "validate_config", + "package", + "local_build", + "artifact_publish", + "deploy_request", + ] + assert payload["plan"]["steps"][-1]["name"] == "deploy_request" + assert payload["plan"]["steps"][2]["will_run"] is False + assert payload["plan"]["steps"][2]["planned"] is True + assert payload["plan"]["steps"][2]["reason"] == "dry_run_prediction" + assert payload["plan"]["steps"][3]["kind"] == "remote" + assert payload["plan"]["steps"][3]["will_run"] is False + assert payload["plan"]["steps"][3]["planned"] is True + assert payload["plan"]["steps"][3]["reason"] == "dry_run_prediction" + assert "CreateAgent" in payload["request"]["curl"] + assert payload["request"]["body"]["ArtifactPath"].startswith("ks3://agentengine-2000003485-cn-beijing-6/") + + +def test_deploy_dry_run_pretty_output_groups_summary_plan_and_request(tmp_path: Path, monkeypatch): + provider = _NoBuildDuringDryRunProvider() + runner = CliRunner() + + monkeypatch.setattr("ksadk.detection.FrameworkDetector", lambda *_args, **_kwargs: type("D", (), {"detect": lambda self: _FakeDetectionResult()})()) + monkeypatch.setattr("ksadk.cli.cmd_deploy._load_config", lambda *_args, **_kwargs: {"name": "demo-agent"}) + monkeypatch.setattr("ksadk.deployment.DeploymentManager.get_provider", lambda *_args, **_kwargs: provider) + + result = runner.invoke( + cmd_deploy.deploy, + [str(tmp_path), "--account-id", "2000003485", "--dry-run"], + ) + + assert result.exit_code == 0, result.output + assert "执行摘要" in result.output + assert "本次执行" in result.output + assert "仅计划" in result.output + assert "local_build" in result.output + assert "artifact_publish" in result.output + assert "远端请求" in result.output + assert "请求方法" in result.output + assert "请求地址" in result.output + assert "请求字段" in result.output + assert "Curl:" in result.output + + +def test_launch_dry_run_json_envelope_tracks_external_artifact_plan(tmp_path: Path, monkeypatch): + provider = _FakeWorkflowDryRunProvider() + runner = CliRunner() + + monkeypatch.setattr("ksadk.detection.FrameworkDetector", lambda *_args, **_kwargs: type("D", (), {"detect": lambda self: _FakeDetectionResult()})()) + monkeypatch.setattr("ksadk.cli.cmd_launch._load_config", lambda *_args, **_kwargs: {"name": "demo-agent"}) + monkeypatch.setattr("ksadk.deployment.DeploymentManager.get_provider", lambda *_args, **_kwargs: provider) + + result = runner.invoke( + cmd_launch.launch, + [ + str(tmp_path), + "--account-id", + "2000003485", + "--artifact-type", + "Container", + "--image", + "hub.kce.ksyun.com/demo/demo-agent:latest", + "--dry-run", + "--output", + "json", + ], + ) + + assert result.exit_code == 0, result.output + payload = _parse_json(result.output) + assert payload["ok"] is True + assert payload["kind"] == "dry_run" + assert payload["action"] == "launch" + assert payload["plan"]["artifact"]["should_build"] is False + assert payload["plan"]["artifact"]["should_local_build"] is False + assert payload["plan"]["artifact"]["should_publish"] is False + assert payload["plan"]["artifact"]["explicit_ref_option"] == "--image" + assert payload["plan"]["artifact"]["source"] == "external" + assert payload["plan"]["steps"][2]["name"] == "local_build" + assert payload["plan"]["steps"][2]["will_run"] is False + assert payload["plan"]["steps"][2]["reason"] == "explicit_reference" + assert payload["plan"]["steps"][3]["name"] == "artifact_publish" + assert payload["plan"]["steps"][3]["will_run"] is False + assert payload["plan"]["steps"][3]["reason"] == "explicit_reference" + assert payload["request"]["body"]["ArtifactPath"] == "hub.kce.ksyun.com/demo/demo-agent:latest" + + +def test_launch_dry_run_json_skips_real_build_and_predicts_container_reference(tmp_path: Path, monkeypatch): + provider = _NoBuildDuringDryRunProvider() + runner = CliRunner() + + monkeypatch.setattr("ksadk.detection.FrameworkDetector", lambda *_args, **_kwargs: type("D", (), {"detect": lambda self: _FakeDetectionResult()})()) + monkeypatch.setattr("ksadk.cli.cmd_launch._load_config", lambda *_args, **_kwargs: {"name": "demo-agent"}) + monkeypatch.setattr("ksadk.deployment.DeploymentManager.get_provider", lambda *_args, **_kwargs: provider) + + result = runner.invoke( + cmd_launch.launch, + [ + str(tmp_path), + "--account-id", + "2000003485", + "--artifact-type", + "Container", + "--registry", + "hub.kce.ksyun.com/demo", + "--dry-run", + "--output", + "json", + ], + ) + + assert result.exit_code == 0, result.output + payload = _parse_json(result.output) + assert payload["kind"] == "dry_run" + assert payload["plan"]["artifact"]["should_build"] is True + assert payload["plan"]["artifact"]["will_build"] is False + assert payload["plan"]["artifact"]["should_publish"] is True + assert payload["plan"]["artifact"]["will_publish"] is False + assert payload["plan"]["artifact"]["source"] == "planned_build" + assert payload["plan"]["steps"][2]["name"] == "local_build" + assert payload["plan"]["steps"][2]["reason"] == "dry_run_prediction" + assert payload["plan"]["steps"][3]["name"] == "artifact_publish" + assert payload["plan"]["steps"][3]["reason"] == "dry_run_prediction" + assert payload["request"]["body"]["ArtifactPath"] == "hub.kce.ksyun.com/demo/demo-agent:dry-run" + + +def test_deploy_reuses_cached_artifact_without_rebuild(tmp_path: Path, monkeypatch): + provider = _NoBuildDuringDryRunProvider() + runner = CliRunner() + metadata_dir = tmp_path / ".agentengine" + metadata_dir.mkdir(parents=True, exist_ok=True) + (metadata_dir / "build-metadata.json").write_text( + json.dumps({"metadata": {"ks3_path": "ks3://bucket/agents/demo-agent/cached.zip"}}), + encoding="utf-8", + ) + + monkeypatch.setattr("ksadk.detection.FrameworkDetector", lambda *_args, **_kwargs: type("D", (), {"detect": lambda self: _FakeDetectionResult()})()) + monkeypatch.setattr("ksadk.cli.cmd_deploy._load_config", lambda *_args, **_kwargs: {"name": "demo-agent"}) + monkeypatch.setattr("ksadk.deployment.DeploymentManager.get_provider", lambda *_args, **_kwargs: provider) + + result = runner.invoke( + cmd_deploy.deploy, + [str(tmp_path), "--account-id", "2000003485", "--output", "json"], + ) + + assert result.exit_code == 0, result.output + payload = _parse_json(result.output) + assert payload["result"]["artifact_source"] == "cached" + assert payload["result"]["artifact_reused"] is True + assert payload["result"]["artifact_built"] is False + assert payload["result"]["artifact_reference"] == "ks3://bucket/agents/demo-agent/cached.zip" + + +def test_launch_reuses_cached_container_artifact_without_rebuild(tmp_path: Path, monkeypatch): + provider = _NoBuildDuringDryRunProvider() + runner = CliRunner() + metadata_dir = tmp_path / ".agentengine" + metadata_dir.mkdir(parents=True, exist_ok=True) + (metadata_dir / "build-metadata.json").write_text( + json.dumps({"image": "hub.kce.ksyun.com/demo/demo-agent:cached", "metadata": {"image": "hub.kce.ksyun.com/demo/demo-agent:cached"}}), + encoding="utf-8", + ) + + monkeypatch.setattr("ksadk.detection.FrameworkDetector", lambda *_args, **_kwargs: type("D", (), {"detect": lambda self: _FakeDetectionResult()})()) + monkeypatch.setattr("ksadk.cli.cmd_launch._load_config", lambda *_args, **_kwargs: {"name": "demo-agent"}) + monkeypatch.setattr("ksadk.deployment.DeploymentManager.get_provider", lambda *_args, **_kwargs: provider) + + result = runner.invoke( + cmd_launch.launch, + [str(tmp_path), "--account-id", "2000003485", "--artifact-type", "Container", "--output", "json"], + ) + + assert result.exit_code == 0, result.output + payload = _parse_json(result.output) + assert payload["result"]["artifact_source"] == "cached" + assert payload["result"]["artifact_reused"] is True + assert payload["result"]["artifact_built"] is False + assert payload["result"]["artifact_reference"] == "hub.kce.ksyun.com/demo/demo-agent:cached" diff --git a/tests/test_ks3_uploader_urls.py b/tests/test_ks3_uploader_urls.py new file mode 100644 index 00000000..31c7ce9f --- /dev/null +++ b/tests/test_ks3_uploader_urls.py @@ -0,0 +1,261 @@ +import asyncio +import pickle +from concurrent.futures import ThreadPoolExecutor +from types import SimpleNamespace + +from ksadk.builders.ks3_uploader import KS3Uploader + + +def test_public_and_internal_url_by_key_include_full_object_key(): + uploader = KS3Uploader(region="cn-beijing-6", bucket="agentengine-test-cn-beijing-6") + object_key = "agents/hr_projects_wrap_test/code_20260308154645.zip" + + public_url = uploader.get_public_url_by_key(object_key) + internal_url = uploader.get_internal_url_by_key(object_key) + + assert public_url.endswith(f"/{object_key}") + assert internal_url.endswith(f"/{object_key}") + assert "code_20260308154645.zip" in public_url + assert "code_20260308154645.zip" in internal_url + + +def test_url_by_key_normalizes_leading_slash(): + uploader = KS3Uploader(region="cn-beijing-6", bucket="agentengine-test-cn-beijing-6") + object_key = "/agents/demo/code_20260308154645.zip" + + public_url = uploader.get_public_url_by_key(object_key) + internal_url = uploader.get_internal_url_by_key(object_key) + + assert "//agents/" not in public_url + assert "//agents/" not in internal_url + assert public_url.endswith("/agents/demo/code_20260308154645.zip") + assert internal_url.endswith("/agents/demo/code_20260308154645.zip") + + +def test_rank_upload_endpoints_prefers_fastest_reachable_host(monkeypatch): + uploader = KS3Uploader(region="cn-beijing-6", bucket="agentengine-test-cn-beijing-6") + + monkeypatch.setattr( + "ksadk.builders.ks3_uploader.get_ks3_endpoints", + lambda _region: ("ks3-public.example.com", "ks3-internal.example.com"), + ) + monkeypatch.setattr( + uploader, + "_probe_endpoint_latency", + lambda host: { + "ks3-public.example.com": 0.32, + "ks3-internal.example.com": 0.08, + }[host], + raising=False, + ) + + targets, summary = uploader._rank_upload_endpoints() + + assert [item["host"] for item in targets] == [ + "ks3-internal.example.com", + "ks3-public.example.com", + ] + assert "测速优先" in summary + + +def test_auto_rank_upload_endpoints_skips_unreachable_fallback(monkeypatch): + uploader = KS3Uploader(region="cn-beijing-6", bucket="agentengine-test-cn-beijing-6") + + monkeypatch.setattr( + "ksadk.builders.ks3_uploader.get_ks3_endpoints", + lambda _region: ("ks3-public.example.com", "ks3-internal.example.com"), + ) + monkeypatch.setattr( + uploader, + "_probe_endpoint_latency", + lambda host: { + "ks3-public.example.com": 0.04, + "ks3-internal.example.com": None, + }[host], + raising=False, + ) + + targets, summary = uploader._rank_upload_endpoints() + + assert [item["host"] for item in targets] == ["ks3-public.example.com"] + assert "跳过不可达端点" in summary + + +def test_large_upload_timeout_default_allows_slow_customer_networks(tmp_path): + uploader = KS3Uploader(region="cn-beijing-6", bucket="agentengine-test-cn-beijing-6") + artifact = tmp_path / "large.zip" + with artifact.open("wb") as fp: + fp.truncate(380 * 1024 * 1024) + + assert uploader._upload_timeout_seconds(artifact) >= 1800 + + +def test_upload_timeout_env_override_still_wins(tmp_path, monkeypatch): + uploader = KS3Uploader(region="cn-beijing-6", bucket="agentengine-test-cn-beijing-6") + artifact = tmp_path / "large.zip" + with artifact.open("wb") as fp: + fp.truncate(380 * 1024 * 1024) + monkeypatch.setenv("KS3_UPLOAD_TIMEOUT_SECONDS", "2400") + + assert uploader._upload_timeout_seconds(artifact) == 2400 + + +def test_large_upload_uses_resumable_multipart_task(tmp_path, monkeypatch): + uploader = KS3Uploader(region="cn-beijing-6", bucket="agentengine-test-cn-beijing-6") + artifact = tmp_path / "large.zip" + with artifact.open("wb") as fp: + fp.truncate(120 * 1024 * 1024) + + captured = {} + + class _FakeConnection: + def __init__(self, *args, **kwargs): + captured["connection_kwargs"] = kwargs + + class _FakeKey: + def __init__(self): + self.name = "agents/demo/code.zip" + + class _FakeBucket: + def new_key(self, object_key): + captured["object_key"] = object_key + return _FakeKey() + + class _FakeExecutor: + def __init__(self, max_workers): + captured["max_workers"] = max_workers + + class _FakeUploadTask: + def __init__(self, key, bucket, src_file, executor, **kwargs): + captured["task"] = { + "key": key, + "bucket": bucket, + "src_file": src_file, + "executor": executor, + **kwargs, + } + + def upload(self, headers=None): + captured["headers"] = headers + return SimpleNamespace(response_metadata=SimpleNamespace(status=200)) + + monkeypatch.setenv("KSYUN_ACCESS_KEY", "ak") + monkeypatch.setenv("KSYUN_SECRET_KEY", "sk") + monkeypatch.setattr("ks3.connection.Connection", _FakeConnection) + monkeypatch.setattr("ksadk.builders.ks3_uploader.ThreadPoolExecutor", _FakeExecutor) + monkeypatch.setattr(uploader, "_ensure_bucket", lambda _conn: _FakeBucket(), raising=False) + monkeypatch.setattr("ksadk.builders.ks3_uploader.UploadTask", _FakeUploadTask) + + assert uploader._upload_via_host(artifact, "agents/demo/code.zip", "ks3.example.com") is True + assert captured["task"]["src_file"] == str(artifact) + assert captured["task"]["resumable"] is True + assert captured["task"]["resumable_filename"].endswith(".agentengine/ks3_resume/agents_demo_code.zip.ks3resume") + + +def test_ks3_resumable_upload_skips_already_uploaded_parts(tmp_path, monkeypatch): + import ks3.upload as ks3_upload + from ks3.multipart import MultiPartUpload, Part + from ks3.upload import UploadRecord, UploadTask + + artifact = tmp_path / "large.zip" + artifact.write_bytes(b"x" * (2 * 1024 * 1024)) + object_key = "agents/demo/code.zip" + resumable_file = tmp_path / ".agentengine" / "ks3_resume" / "agents_demo_code.zip.ks3resume" + resumable_file.parent.mkdir(parents=True, exist_ok=True) + part_info_cls = getattr(ks3_upload, "PartInfo", None) + if part_info_cls is not None: + uploaded_part = part_info_cls(size=1024 * 1024, part_crc="crc1") + else: + uploaded_part = Part() + uploaded_part.size = 1024 * 1024 + uploaded_part.part_crc = "crc1" + + record = UploadRecord( + "upload-id", + artifact.stat().st_size, + artifact.stat().st_mtime, + "bucket", + object_key, + 1024 * 1024, + {1: uploaded_part}, + ) + with resumable_file.open("wb") as fp: + pickle.dump(record, fp) + + uploaded_parts = [] + + class _FakeBucket: + name = "bucket" + connection = SimpleNamespace( + enable_crc=True, + provider=SimpleNamespace(checksum_crc64ecma_header="x-kss-checksum-crc64ecma"), + ) + + def _fake_upload_part_from_file(self, fp, part_num, headers=None): + uploaded_parts.append(part_num) + return SimpleNamespace(getheader=lambda _name: f"crc-{part_num}") + + def _fake_complete_upload(self, headers=None): + return SimpleNamespace( + response_metadata=SimpleNamespace( + status=200, + headers={"ETag": "etag", "x-kss-checksum-crc64ecma": "crc"}, + request_id="req", + ), + etag="etag", + ) + + monkeypatch.setattr(MultiPartUpload, "upload_part_from_file", _fake_upload_part_from_file, raising=False) + monkeypatch.setattr(MultiPartUpload, "complete_upload", _fake_complete_upload, raising=False) + + task = UploadTask( + key=SimpleNamespace(name=object_key), + bucket=_FakeBucket(), + src_file=str(artifact), + executor=ThreadPoolExecutor(max_workers=2), + part_size=1024 * 1024, + resumable=True, + resumable_filename=str(resumable_file), + ) + + result = task.upload(headers={}) + + assert uploaded_parts == [2] + assert result.response_metadata.status == 200 + assert not resumable_file.exists() + + +def test_upload_retries_next_endpoint_after_transport_failure(tmp_path, monkeypatch): + uploader = KS3Uploader(region="cn-beijing-6", bucket="agentengine-test-cn-beijing-6") + artifact = tmp_path / "demo.zip" + artifact.write_bytes(b"zip") + + monkeypatch.setenv("KSYUN_ACCESS_KEY", "ak") + monkeypatch.setenv("KSYUN_SECRET_KEY", "sk") + monkeypatch.setattr( + uploader, + "_rank_upload_endpoints", + lambda: ( + [ + {"host": "ks3-internal.example.com", "label": "内网"}, + {"host": "ks3-public.example.com", "label": "公网"}, + ], + "测速优先 内网", + ), + raising=False, + ) + + calls = [] + + def fake_upload_via_host(_file_path, _object_key, host): + calls.append(host) + if host == "ks3-internal.example.com": + raise TimeoutError("internal timeout") + return True + + monkeypatch.setattr(uploader, "_upload_via_host", fake_upload_via_host, raising=False) + + result = asyncio.run(uploader.upload(artifact, "agents/demo/code.zip")) + + assert result == "ks3://agentengine-test-cn-beijing-6/agents/demo/code.zip" + assert calls == ["ks3-internal.example.com", "ks3-public.example.com"] diff --git a/tests/test_langchain_runner_session_continuity.py b/tests/test_langchain_runner_session_continuity.py new file mode 100644 index 00000000..2f850d9c --- /dev/null +++ b/tests/test_langchain_runner_session_continuity.py @@ -0,0 +1,253 @@ +from __future__ import annotations + +from types import SimpleNamespace + +import pytest +from langchain_core.chat_history import InMemoryChatMessageHistory +from langchain_core.runnables import RunnableLambda +from langchain_core.runnables.history import RunnableWithMessageHistory + +from ksadk.runners.langchain_runner import LangChainRunner + + +class _RecordingAgent: + def __init__(self): + self.calls: list[tuple[dict, dict | None]] = [] + + async def ainvoke(self, payload, config=None): + self.calls.append((payload, config)) + return {"output": "ok"} + + +def _make_runner(agent, module=None) -> LangChainRunner: + detection = SimpleNamespace(entry_point="src/agent.py", agent_variable="root_agent") + runner = LangChainRunner(detection, ".") + runner._agent = agent + runner._module = module or SimpleNamespace() + return runner + + +@pytest.mark.asyncio +async def test_langchain_runner_uses_standard_prepare_input_hook(): + agent = _RecordingAgent() + captured: list[tuple[dict, dict]] = [] + + def ksadk_prepare_input(payload: dict, session_context: dict) -> dict: + captured.append((payload, session_context)) + return { + "question": payload["input"], + "history_len": len(session_context["history"]), + "session_id": session_context["session_id"], + } + + runner = _make_runner(agent, module=SimpleNamespace(ksadk_prepare_input=ksadk_prepare_input)) + + result = await runner.invoke( + { + "session_id": "sess-1", + "input": "现在进展到哪了", + "history": [ + {"role": "user", "content": "我叫张三"}, + {"role": "model", "content": "记住了"}, + ], + } + ) + + assert result["output"] == "ok" + assert captured == [ + ( + {"input": "现在进展到哪了"}, + { + "session_id": "sess-1", + "history": [ + {"role": "user", "content": "我叫张三"}, + {"role": "model", "content": "记住了"}, + ], + "input_parts": [], + "attachments": [], + "attachment_results": [], + "instructions": None, + "platform_context": None, + "kb_context": None, + "memory_context": None, + }, + ) + ] + assert agent.calls[0][0] == { + "question": "现在进展到哪了", + "history_len": 2, + "session_id": "sess-1", + } + + +@pytest.mark.asyncio +async def test_langchain_runner_uses_runnable_with_message_history_session_config(): + store: dict[str, InMemoryChatMessageHistory] = {} + + def get_history(session_id: str) -> InMemoryChatMessageHistory: + return store.setdefault(session_id, InMemoryChatMessageHistory()) + + def sync_chain(payload: dict) -> dict: + messages = payload["input"] + return {"output": f"history={len(messages)}"} + + runnable = RunnableWithMessageHistory(RunnableLambda(sync_chain), get_history) + runner = _make_runner(runnable) + + result = await runner.invoke({"session_id": "sess-history", "input": "hello"}) + + assert result["output"] == "history=1" + assert [message.content for message in store["sess-history"].messages] == ["hello", "history=1"] + + +@pytest.mark.asyncio +async def test_langchain_runner_falls_back_to_transcript_replay_prompt(): + agent = _RecordingAgent() + runner = _make_runner(agent) + + await runner.invoke( + { + "session_id": "sess-replay", + "input": "那我叫什么", + "history": [ + {"role": "user", "content": "我叫张三"}, + {"role": "model", "content": "我记住了"}, + {"role": "user", "content": "那我叫什么"}, + ], + } + ) + + payload, _config = agent.calls[0] + assert payload["input"].startswith("Conversation history:") + assert "user: 我叫张三" in payload["input"] + assert "assistant: 我记住了" in payload["input"] + assert payload["input"].rstrip().endswith("user: 那我叫什么") + + +@pytest.mark.asyncio +async def test_langchain_runner_standard_hook_receives_platform_kb_and_memory_context(): + agent = _RecordingAgent() + captured: list[dict] = [] + + def ksadk_prepare_input(payload: dict, session_context: dict) -> dict: + captured.append(session_context) + return payload + + runner = _make_runner(agent, module=SimpleNamespace(ksadk_prepare_input=ksadk_prepare_input)) + + await runner.invoke( + { + "session_id": "sess-2", + "input": "查一下最新支持库", + "platform_context": {"agent_id": "demo-agent", "user_id": "user-1"}, + "kb_context": {"formatted_text": "KB facts"}, + "memory_context": {"formatted_text": "Memory facts"}, + } + ) + + assert captured == [ + { + "session_id": "sess-2", + "history": [], + "input_parts": [], + "attachments": [], + "attachment_results": [], + "instructions": None, + "platform_context": {"agent_id": "demo-agent", "user_id": "user-1"}, + "kb_context": {"formatted_text": "KB facts"}, + "memory_context": {"formatted_text": "Memory facts"}, + } + ] + + +@pytest.mark.asyncio +async def test_langchain_runner_replay_prompt_includes_ambient_kb_and_memory_context(): + agent = _RecordingAgent() + runner = _make_runner(agent) + + await runner.invoke( + { + "session_id": "sess-3", + "input": "继续", + "kb_context": {"formatted_text": "知识库: 当前支持标准型和计算型"}, + "memory_context": {"formatted_text": "记忆: 用户上次查过主机机型"}, + } + ) + + payload, _config = agent.calls[0] + assert "Knowledge base context:" in payload["input"] + assert "知识库: 当前支持标准型和计算型" in payload["input"] + assert "Long-term memory context:" in payload["input"] + assert "记忆: 用户上次查过主机机型" in payload["input"] + + +@pytest.mark.asyncio +async def test_langchain_runner_replay_prompt_includes_instructions(): + agent = _RecordingAgent() + runner = _make_runner(agent) + + await runner.invoke( + { + "session_id": "sess-instructions", + "input": "hello", + "instructions": "只用中文回答", + } + ) + + payload, _config = agent.calls[0] + assert payload["input"].startswith("只用中文回答") + assert payload["input"].rstrip().endswith("user: hello") + + +@pytest.mark.asyncio +async def test_langchain_runner_message_history_includes_instructions_without_ambient_context(): + store: dict[str, InMemoryChatMessageHistory] = {} + seen_messages = [] + + def get_history(session_id: str) -> InMemoryChatMessageHistory: + return store.setdefault(session_id, InMemoryChatMessageHistory()) + + def sync_chain(payload: dict) -> dict: + seen_messages.append(payload["input"]) + return {"output": "ok"} + + runnable = RunnableWithMessageHistory(RunnableLambda(sync_chain), get_history) + runner = _make_runner(runnable) + + result = await runner.invoke( + { + "session_id": "sess-history-instructions", + "input": "hello", + "instructions": "只用中文回答", + } + ) + + assert result["output"] == "ok" + assert seen_messages + assert seen_messages[0][0].__class__.__name__ == "SystemMessage" + assert "只用中文回答" in seen_messages[0][0].content + assert seen_messages[0][1].content == "hello" + + +def test_langchain_runner_extracts_wrapped_history_runnable(): + store: dict[str, InMemoryChatMessageHistory] = {} + + def get_history(session_id: str) -> InMemoryChatMessageHistory: + return store.setdefault(session_id, InMemoryChatMessageHistory()) + + runnable = RunnableLambda(lambda payload: {"output": payload["input"]}) + wrapped = RunnableWithMessageHistory(runnable, get_history) + runner = _make_runner(wrapped) + + extracted = runner._extract_wrapped_history_runnable() + + assert extracted is not None + assert hasattr(extracted, "invoke") + + +def test_langchain_runner_logs_unknown_wrapped_history_shape(caplog): + caplog.set_level("DEBUG", logger="ksadk.runners.langchain_runner") + runner = _make_runner(SimpleNamespace(bound=object())) + + assert runner._extract_wrapped_history_runnable() is None + assert "Unable to inspect RunnableWithMessageHistory wrapper" in caplog.text diff --git a/tests/test_langfuse_exporter.py b/tests/test_langfuse_exporter.py new file mode 100644 index 00000000..ee7dc7b1 --- /dev/null +++ b/tests/test_langfuse_exporter.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from types import SimpleNamespace + +from ksadk.tracing.exporters.langfuse_exporter import LangfuseExporterConfig, _LangfuseSpanExporter + + +class _FakeLangfuse: + def __init__(self): + self.traces: list[dict] = [] + + def trace(self, **kwargs): + self.traces.append(kwargs) + return SimpleNamespace(generation=lambda **_kwargs: None, span=lambda **_kwargs: None) + + def flush(self): + return None + + +class _FakeSpan: + def __init__(self, attributes: dict[str, object]): + self.name = "demo-agent" + self.attributes = attributes + self.parent = None + self.context = SimpleNamespace(trace_id=1, span_id=2) + + +def _exporter_with_fake_client() -> tuple[_LangfuseSpanExporter, _FakeLangfuse]: + fake = _FakeLangfuse() + exporter = _LangfuseSpanExporter( + LangfuseExporterConfig(public_key="pk-test", secret_key="sk-test") + ) + exporter._langfuse = fake + exporter._agent_config = None + return exporter, fake + + +def test_langfuse_exporter_reads_openinference_style_user_and_session_keys(): + exporter, fake = _exporter_with_fake_client() + + exporter._export_trace( + "trace-1", + [ + _FakeSpan( + { + "langfuse.session.id": "conv-a", + "session.id": "conv-a", + "langfuse.user.id": "user-a", + "user.id": "user-a", + "user.input": "hello", + "agent.output": "hi", + } + ) + ], + ) + + assert fake.traces[-1]["session_id"] == "conv-a" + assert fake.traces[-1]["user_id"] == "user-a" + + +def test_langfuse_exporter_still_reads_legacy_user_and_session_keys(): + exporter, fake = _exporter_with_fake_client() + + exporter._export_trace( + "trace-1", + [ + _FakeSpan( + { + "langfuse.session_id": "legacy-session", + "langfuse.user_id": "legacy-user", + } + ) + ], + ) + + assert fake.traces[-1]["session_id"] == "legacy-session" + assert fake.traces[-1]["user_id"] == "legacy-user" diff --git a/tests/test_langfuse_runner_utils.py b/tests/test_langfuse_runner_utils.py new file mode 100644 index 00000000..36b2a86f --- /dev/null +++ b/tests/test_langfuse_runner_utils.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import importlib +import sys +import types + + +class _FakeCallbackHandler: + instances = 0 + + def __init__(self): + self.__class__.instances += 1 + + +def _reload_langfuse_utils(monkeypatch): + module = importlib.import_module("ksadk.runners.utils.langfuse") + module = importlib.reload(module) + monkeypatch.setattr(module, "_langfuse_callback", None) + _FakeCallbackHandler.instances = 0 + monkeypatch.setitem( + sys.modules, + "langfuse.langchain", + types.SimpleNamespace(CallbackHandler=_FakeCallbackHandler), + ) + return module + + +def test_langfuse_callback_disabled_by_default_when_otlp_direct_is_available(monkeypatch): + monkeypatch.setenv("LANGFUSE_PUBLIC_KEY", "pk-test") + monkeypatch.setenv("LANGFUSE_SECRET_KEY", "sk-test") + monkeypatch.setenv("LANGFUSE_BASE_URL", "https://trace-pre.example.com") + monkeypatch.delenv("LANGFUSE_USE_CALLBACK", raising=False) + + module = _reload_langfuse_utils(monkeypatch) + + assert module.get_langfuse_callback() is None + assert _FakeCallbackHandler.instances == 0 + + +def test_langfuse_callback_can_be_enabled_explicitly(monkeypatch): + monkeypatch.setenv("LANGFUSE_PUBLIC_KEY", "pk-test") + monkeypatch.setenv("LANGFUSE_SECRET_KEY", "sk-test") + monkeypatch.setenv("LANGFUSE_BASE_URL", "https://trace-pre.example.com") + monkeypatch.setenv("LANGFUSE_USE_CALLBACK", "true") + + module = _reload_langfuse_utils(monkeypatch) + + assert isinstance(module.get_langfuse_callback(), _FakeCallbackHandler) + assert _FakeCallbackHandler.instances == 1 diff --git a/tests/test_langgraph_runner_resume.py b/tests/test_langgraph_runner_resume.py index 5321c666..d0fd2681 100644 --- a/tests/test_langgraph_runner_resume.py +++ b/tests/test_langgraph_runner_resume.py @@ -12,18 +12,35 @@ def __init__(self): self.last_ainvoke_state = None self.last_astream_state = None self.last_ainvoke_context = None + self.last_ainvoke_config = None + self.last_astream_config = None + self.state_config = None async def ainvoke(self, state, config=None, context=None): self.last_ainvoke_state = state self.last_ainvoke_context = context + self.last_ainvoke_config = config return {"messages": [{"content": "ok"}]} + def get_state(self, config): + del config + return SimpleNamespace(config=self.state_config) + async def astream_events(self, state, version="v2", config=None): self.last_astream_state = state + self.last_astream_config = config if False: yield {} +class _AsyncStateAgent(_DummyAgent): + async def aget_state(self, config): + del config + return SimpleNamespace(config=self.state_config) + + get_state = None + + class _Chunk: def __init__(self, content="", reasoning_content=None): self.content = content @@ -110,6 +127,32 @@ async def astream_events(self, state, version="v2", config=None): } +class _InlineThinkTagStreamingAgent(_DummyAgent): + async def astream_events(self, state, version="v2", config=None): + self.last_astream_state = state + yield { + "event": "on_chat_model_stream", + "data": {"chunk": _Chunk(content="先分析需求。这是最终回复。")}, + } + + +class _SplitInlineThinkTagStreamingAgent(_DummyAgent): + async def astream_events(self, state, version="v2", config=None): + self.last_astream_state = state + yield { + "event": "on_chat_model_stream", + "data": {"chunk": _Chunk(content="先")}, + } + yield { + "event": "on_chat_model_stream", + "data": {"chunk": _Chunk(content="分析需求。这是")}, + } + yield { + "event": "on_chat_model_stream", + "data": {"chunk": _Chunk(content="最终回复。")}, + } + + def _make_runner(module=None) -> LangGraphRunner: detection = SimpleNamespace(entry_point="src/agent.py", agent_variable="root_agent") runner = LangGraphRunner(detection, ".") @@ -143,6 +186,18 @@ def _make_tool_then_answer_streaming_runner() -> LangGraphRunner: return runner +def _make_inline_think_tag_streaming_runner() -> LangGraphRunner: + runner = _make_runner() + runner._agent = _InlineThinkTagStreamingAgent() + return runner + + +def _make_split_inline_think_tag_streaming_runner() -> LangGraphRunner: + runner = _make_runner() + runner._agent = _SplitInlineThinkTagStreamingAgent() + return runner + + @pytest.mark.asyncio async def test_invoke_simplified_input_preserves_extra_state(): runner = _make_runner() @@ -228,6 +283,186 @@ async def test_stream_resume_uses_command(): assert chunks and chunks[-1]["type"] == "final" +@pytest.mark.asyncio +async def test_invoke_checkpoint_resume_uses_checkpoint_id_and_none_input(): + runner = _make_runner() + + result = await runner.invoke( + { + "session_id": "sess-1", + "checkpoint_resume": True, + "framework_ref": { + "langgraph": { + "thread_id": "tenant-a:agent-b:sess-1", + "checkpoint_id": "ckpt-123", + } + }, + } + ) + + assert result["output"] == "ok" + assert runner._agent.last_ainvoke_state is None + assert runner._agent.last_ainvoke_config["configurable"] == { + "thread_id": "tenant-a:agent-b:sess-1", + "checkpoint_id": "ckpt-123", + } + + +@pytest.mark.asyncio +async def test_invoke_checkpoint_resume_preserves_checkpoint_namespace_when_present(): + runner = _make_runner() + + await runner.invoke( + { + "session_id": "sess-1", + "checkpoint_resume": True, + "framework_ref": { + "langgraph": { + "thread_id": "tenant-a:agent-b:sess-1", + "checkpoint_ns": "subgraph-ns", + "checkpoint_id": "ckpt-123", + } + }, + } + ) + + assert runner._agent.last_ainvoke_config["configurable"] == { + "thread_id": "tenant-a:agent-b:sess-1", + "checkpoint_ns": "subgraph-ns", + "checkpoint_id": "ckpt-123", + } + + +@pytest.mark.asyncio +async def test_invoke_reports_latest_langgraph_checkpoint_ref_from_state_config(): + runner = _make_runner() + runner._agent.state_config = { + "configurable": { + "thread_id": "tenant-a:agent-b:sess-1", + "checkpoint_id": "ckpt-after", + } + } + + result = await runner.invoke({"session_id": "tenant-a:agent-b:sess-1", "input": "hello"}) + + assert result["metadata"]["agentengine"] == { + "framework": "langgraph", + "framework_ref": { + "langgraph": { + "thread_id": "tenant-a:agent-b:sess-1", + "checkpoint_id": "ckpt-after", + } + }, + } + + +@pytest.mark.asyncio +async def test_invoke_reports_checkpoint_namespace_from_state_config_when_present(): + runner = _make_runner() + runner._agent.state_config = { + "configurable": { + "thread_id": "tenant-a:agent-b:sess-1", + "checkpoint_ns": "subgraph-ns", + "checkpoint_id": "ckpt-after", + } + } + + result = await runner.invoke({"session_id": "tenant-a:agent-b:sess-1", "input": "hello"}) + + assert result["metadata"]["agentengine"]["framework_ref"]["langgraph"] == { + "thread_id": "tenant-a:agent-b:sess-1", + "checkpoint_ns": "subgraph-ns", + "checkpoint_id": "ckpt-after", + } + + +@pytest.mark.asyncio +async def test_invoke_reports_latest_langgraph_checkpoint_ref_from_async_state_config(): + runner = _make_runner() + runner._agent = _AsyncStateAgent() + runner._agent.state_config = { + "configurable": { + "thread_id": "tenant-a:agent-b:sess-async", + "checkpoint_id": "ckpt-async", + } + } + + result = await runner.invoke({"session_id": "tenant-a:agent-b:sess-async", "input": "hello"}) + + assert result["metadata"]["agentengine"] == { + "framework": "langgraph", + "framework_ref": { + "langgraph": { + "thread_id": "tenant-a:agent-b:sess-async", + "checkpoint_id": "ckpt-async", + } + }, + } + + +@pytest.mark.asyncio +async def test_stream_checkpoint_resume_uses_checkpoint_id_and_none_input(): + runner = _make_runner() + + chunks = [ + chunk + async for chunk in runner.stream( + { + "session_id": "sess-1", + "checkpoint_resume": True, + "framework_ref": { + "langgraph": { + "checkpoint_id": "ckpt-456", + } + }, + } + ) + ] + + assert chunks and chunks[-1]["type"] == "final" + assert runner._agent.last_astream_state is None + assert runner._agent.last_astream_config["configurable"] == { + "thread_id": "sess-1", + "checkpoint_id": "ckpt-456", + } + + +@pytest.mark.asyncio +async def test_stream_reports_latest_langgraph_checkpoint_ref_from_state_config(): + runner = _make_streaming_runner() + runner._agent.state_config = { + "configurable": { + "thread_id": "tenant-a:agent-b:sess-1", + "checkpoint_id": "ckpt-stream", + } + } + + chunks = [ + chunk + async for chunk in runner.stream( + { + "session_id": "tenant-a:agent-b:sess-1", + "input": "hello", + } + ) + ] + + assert chunks[-1] == { + "type": "checkpoint", + "metadata": { + "agentengine": { + "framework": "langgraph", + "framework_ref": { + "langgraph": { + "thread_id": "tenant-a:agent-b:sess-1", + "checkpoint_id": "ckpt-stream", + } + }, + } + }, + } + + @pytest.mark.asyncio async def test_stream_does_not_mix_reasoning_into_final_text(): runner = _make_streaming_runner() @@ -269,6 +504,48 @@ async def test_stream_ignores_content_when_chunk_duplicates_reasoning(): ] +@pytest.mark.asyncio +async def test_stream_extracts_inline_think_tags_from_content(): + runner = _make_inline_think_tag_streaming_runner() + + chunks = [ + chunk + async for chunk in runner.stream( + { + "session_id": "s1", + "input": "写一个python快排的示例", + } + ) + ] + + assert chunks == [ + {"delta": "先分析需求。", "type": "thinking"}, + {"delta": "这是最终回复。", "type": "text"}, + ] + + +@pytest.mark.asyncio +async def test_stream_extracts_split_inline_think_tags_from_content(): + runner = _make_split_inline_think_tag_streaming_runner() + + chunks = [ + chunk + async for chunk in runner.stream( + { + "session_id": "s1", + "input": "写一个python快排的示例", + } + ) + ] + + thinking_deltas = [chunk["delta"] for chunk in chunks if chunk["type"] == "thinking"] + text_deltas = [chunk["delta"] for chunk in chunks if chunk["type"] == "text"] + + assert thinking_deltas == ["先分析需求。"] + assert "".join(text_deltas) == "这是最终回复。" + assert all(" str: + assert captured["file"] == str(venv_python) + args = captured["args"] + assert isinstance(args, list) + assert args[:2] == [str(venv_python), "-c"] + assert "from ksadk.cli import main; main()" in args[2] + assert args[3:] == command_args + return args[2] + + +def _write_project_venv(project_dir: Path) -> Path: + venv_bin = project_dir / ".venv" / "bin" + venv_bin.mkdir(parents=True) + venv_python = venv_bin / "python" + venv_python.write_text("#!/bin/sh\n", encoding="utf-8") + return venv_python + + +def _capture_reexec(monkeypatch): + import ksadk.cli.local_runtime as local_runtime + + captured: dict[str, object] = {} + + def _fake_execvpe(file: str, args: list[str], env: dict[str, str]) -> None: + captured["file"] = file + captured["args"] = args + captured["env"] = env + raise SystemExit(23) + + monkeypatch.delenv("AGENTENGINE_LOCAL_RUNTIME_VENV_REEXEC", raising=False) + monkeypatch.setattr(local_runtime.sys, "executable", sys.executable, raising=False) + monkeypatch.setattr(local_runtime.os, "execvpe", _fake_execvpe, raising=False) + return local_runtime, captured + + +def test_run_reexecs_with_project_venv_python(monkeypatch, tmp_path: Path): + project_dir = tmp_path / "demo-agent" + project_dir.mkdir() + venv_python = _write_project_venv(project_dir) + local_runtime, captured = _capture_reexec(monkeypatch) + _register_commands() + + result = CliRunner().invoke( + cli, + [ + "run", + str(project_dir), + "--port", + "8899", + "--interactive", + "--no-trace", + "--model", + "demo-model", + "--show-thinking", + "--no-stream", + ], + ) + + assert result.exit_code == 23 + bootstrap_code = _assert_bootstrap_args( + captured, + venv_python, + [ + "run", + str(project_dir.resolve()), + "--port", + "8899", + "--interactive", + "--no-trace", + "--model", + "demo-model", + "--show-thinking", + "--no-stream", + ], + ) + assert str(Path(local_runtime.__file__).resolve().parents[2]) in bootstrap_code + env = captured["env"] + assert isinstance(env, dict) + assert env["AGENTENGINE_LOCAL_RUNTIME_VENV_REEXEC"] == "1" + + +def test_run_reexec_bootstrap_includes_current_site_packages(monkeypatch, tmp_path: Path): + project_dir = tmp_path / "demo-agent" + project_dir.mkdir() + venv_python = _write_project_venv(project_dir) + local_runtime, captured = _capture_reexec(monkeypatch) + fake_site_packages = str(tmp_path / "current-site-packages") + Path(fake_site_packages).mkdir() + monkeypatch.setattr(local_runtime, "_current_site_package_paths", lambda: [fake_site_packages]) + _register_commands() + + result = CliRunner().invoke(cli, ["run", str(project_dir), "--no-trace"]) + + assert result.exit_code == 23 + bootstrap_code = _assert_bootstrap_args( + captured, + venv_python, + [ + "run", + str(project_dir.resolve()), + "--port", + "8080", + "--no-trace", + ], + ) + assert fake_site_packages in bootstrap_code + + +def test_run_reexecs_when_venv_python_symlinks_to_current_python(monkeypatch, tmp_path: Path): + project_dir = tmp_path / "demo-agent" + project_dir.mkdir() + venv_bin = project_dir / ".venv" / "bin" + venv_bin.mkdir(parents=True) + venv_python = venv_bin / "python" + try: + venv_python.symlink_to(sys.executable) + except OSError: + venv_python.write_text("#!/bin/sh\n", encoding="utf-8") + _local_runtime, captured = _capture_reexec(monkeypatch) + _register_commands() + + result = CliRunner().invoke(cli, ["run", str(project_dir), "--no-trace"]) + + assert result.exit_code == 23 + _assert_bootstrap_args( + captured, + venv_python, + [ + "run", + str(project_dir.resolve()), + "--port", + "8080", + "--no-trace", + ], + ) + + +def test_a2a_serve_reexecs_with_project_venv_python(monkeypatch, tmp_path: Path): + project_dir = tmp_path / "demo-agent" + project_dir.mkdir() + venv_python = _write_project_venv(project_dir) + _local_runtime, captured = _capture_reexec(monkeypatch) + _register_commands() + + result = CliRunner().invoke( + cli, + [ + "a2a", + "serve", + str(project_dir), + "--host", + "127.0.0.1", + "--port", + "9091", + "--url", + "http://example.test/a2a", + "--name", + "demo", + "--description", + "local a2a", + "--skill", + "echo", + "--no-trace", + ], + ) + + assert result.exit_code == 23 + _assert_bootstrap_args( + captured, + venv_python, + [ + "a2a", + "serve", + str(project_dir.resolve()), + "--host", + "127.0.0.1", + "--port", + "9091", + "--url", + "http://example.test/a2a", + "--name", + "demo", + "--description", + "local a2a", + "--skill", + "echo", + "--no-trace", + ], + ) diff --git a/tests/test_long_task_pilot_validation.py b/tests/test_long_task_pilot_validation.py new file mode 100644 index 00000000..31373eda --- /dev/null +++ b/tests/test_long_task_pilot_validation.py @@ -0,0 +1,240 @@ +import json + +import pytest + +from scripts import validate_long_task_pilot + + +@pytest.mark.asyncio +async def test_build_pilot_report_includes_resume_cancel_and_acceptance_metrics(monkeypatch): + async def fake_run_validation(*, dsn: str, keep_session: bool): + assert dsn == "postgresql://example" + assert keep_session is False + return { + "session_id": "sess_resume", + "run_id": "run_resume", + "checkpoint_id": "ckpt_resume", + "output_text": "a,b,c", + "checkpoint_count": 1, + "run_checkpoint_event_count": 2, + "run_resume_event_count": 1, + "checkpoint_log_before_resume": ["a", "b"], + "node_counts_after_resume": {"a": 1, "b": 1, "c": 1}, + "resume_did_not_rerun_prior_nodes": True, + } + + async def fake_run_cancel_validation(*, dsn: str, keep_session: bool): + assert dsn == "postgresql://example" + assert keep_session is False + return { + "session_id": "sess_cancel", + "invocation_id": "run_cancel", + "cancel_found": True, + "cancel_status": "cancelling", + "cancelled_event_count": 1, + "post_cancel_extra_event_count": 0, + } + + async def fake_run_cancel_then_resume_validation(*, dsn: str, keep_session: bool): + assert dsn == "postgresql://example" + assert keep_session is False + return { + "session_id": "sess_closed_loop", + "run_id": "run_closed_loop", + "invocation_id": "run_closed_loop", + "checkpoint_id": "ckpt_closed_loop", + "cancel_found": True, + "cancel_status": "cancelling", + "cancelled_event_count": 1, + "post_cancel_extra_event_count": 0, + "output_text_after_resume": "a,b,c", + "checkpoint_log_before_cancel": ["a", "b"], + "node_counts_after_resume": {"a": 1, "b": 1, "c": 1}, + "resume_after_cancel_did_not_rerun_prior_nodes": True, + } + + monkeypatch.setattr(validate_long_task_pilot, "run_validation", fake_run_validation) + monkeypatch.setattr(validate_long_task_pilot, "run_cancel_validation", fake_run_cancel_validation) + monkeypatch.setattr( + validate_long_task_pilot, + "run_cancel_then_resume_validation", + fake_run_cancel_then_resume_validation, + ) + + report = await validate_long_task_pilot.build_pilot_report( + dsn="postgresql://example", + keep_session=False, + include_cancel=True, + ) + + assert report["overall_status"] == "pass" + assert report["metrics"]["checkpoint_resume_success_rate"] == 1.0 + assert report["metrics"]["runtime_cancel_success_rate"] == 1.0 + assert report["metrics"]["cancel_then_resume_success_rate"] == 1.0 + assert report["cases"]["checkpoint_resume"]["status"] == "pass" + assert report["cases"]["runtime_cancel"]["status"] == "pass" + assert report["cases"]["cancel_then_resume"]["status"] == "pass" + assert report["acceptance"]["same_run_id_resume"] == "pass" + assert report["acceptance"]["resume_does_not_restart"] == "pass" + assert report["cases"]["checkpoint_resume"]["resume_did_not_rerun_prior_nodes"] is True + assert report["metrics"]["node_counts_after_resume"] == {"a": 1, "b": 1, "c": 1} + assert report["acceptance"]["no_events_after_cancel"] == "pass" + assert report["acceptance"]["cancel_then_resume_after_cancelled"] == "pass" + assert report["acceptance"]["resume_after_cancel_does_not_restart"] == "pass" + assert report["cases"]["cancel_then_resume"]["output_text_after_resume"] == "a,b,c" + json.dumps(report, ensure_ascii=False) + + +@pytest.mark.asyncio +async def test_build_pilot_report_marks_failed_cancel_boundary(monkeypatch): + async def fake_run_validation(*, dsn: str, keep_session: bool): + return { + "output_text": "a,b,c", + "checkpoint_count": 1, + "run_checkpoint_event_count": 2, + "run_resume_event_count": 1, + "checkpoint_log_before_resume": ["a", "b"], + "node_counts_after_resume": {"a": 1, "b": 1, "c": 1}, + "resume_did_not_rerun_prior_nodes": True, + } + + async def fake_run_cancel_validation(*, dsn: str, keep_session: bool): + return { + "cancel_found": True, + "cancel_status": "cancelling", + "cancelled_event_count": 1, + "post_cancel_extra_event_count": 2, + } + + async def fake_run_cancel_then_resume_validation(*, dsn: str, keep_session: bool): + return { + "cancel_found": True, + "cancel_status": "cancelling", + "cancelled_event_count": 1, + "post_cancel_extra_event_count": 0, + "output_text_after_resume": "a,b,c", + "node_counts_after_resume": {"a": 1, "b": 1, "c": 1}, + "resume_after_cancel_did_not_rerun_prior_nodes": True, + } + + monkeypatch.setattr(validate_long_task_pilot, "run_validation", fake_run_validation) + monkeypatch.setattr(validate_long_task_pilot, "run_cancel_validation", fake_run_cancel_validation) + monkeypatch.setattr( + validate_long_task_pilot, + "run_cancel_then_resume_validation", + fake_run_cancel_then_resume_validation, + ) + + report = await validate_long_task_pilot.build_pilot_report( + dsn="postgresql://example", + keep_session=False, + include_cancel=True, + ) + + assert report["overall_status"] == "fail" + assert report["metrics"]["runtime_cancel_success_rate"] == 0.0 + assert report["acceptance"]["no_events_after_cancel"] == "fail" + + +@pytest.mark.asyncio +async def test_build_pilot_report_aggregates_multiple_iterations(monkeypatch): + checkpoint_calls = 0 + cancel_calls = 0 + + async def fake_run_validation(*, dsn: str, keep_session: bool): + nonlocal checkpoint_calls + checkpoint_calls += 1 + result = { + "session_id": f"sess_resume_{checkpoint_calls}", + "run_id": f"run_resume_{checkpoint_calls}", + "checkpoint_id": f"ckpt_resume_{checkpoint_calls}", + "output_text": "a,b,c", + "checkpoint_count": 1, + "run_checkpoint_event_count": 2, + "run_resume_event_count": 1, + "checkpoint_log_before_resume": ["a", "b"], + "node_counts_after_resume": {"a": 1, "b": 1, "c": 1}, + "resume_did_not_rerun_prior_nodes": True, + } + if checkpoint_calls == 2: + result["resume_did_not_rerun_prior_nodes"] = False + result["node_counts_after_resume"] = {"a": 2, "b": 2, "c": 1} + return result + + async def fake_run_cancel_validation(*, dsn: str, keep_session: bool): + nonlocal cancel_calls + cancel_calls += 1 + return { + "session_id": f"sess_cancel_{cancel_calls}", + "invocation_id": f"run_cancel_{cancel_calls}", + "cancel_found": True, + "cancel_status": "cancelling", + "cancelled_event_count": 1, + "post_cancel_extra_event_count": 0, + } + + async def fake_run_cancel_then_resume_validation(*, dsn: str, keep_session: bool): + return { + "session_id": "sess_closed_loop", + "run_id": "run_closed_loop", + "invocation_id": "run_closed_loop", + "checkpoint_id": "ckpt_closed_loop", + "cancel_found": True, + "cancel_status": "cancelling", + "cancelled_event_count": 1, + "post_cancel_extra_event_count": 0, + "output_text_after_resume": "a,b,c", + "checkpoint_log_before_cancel": ["a", "b"], + "node_counts_after_resume": {"a": 1, "b": 1, "c": 1}, + "resume_after_cancel_did_not_rerun_prior_nodes": True, + } + + monkeypatch.setattr(validate_long_task_pilot, "run_validation", fake_run_validation) + monkeypatch.setattr(validate_long_task_pilot, "run_cancel_validation", fake_run_cancel_validation) + monkeypatch.setattr( + validate_long_task_pilot, + "run_cancel_then_resume_validation", + fake_run_cancel_then_resume_validation, + ) + + report = await validate_long_task_pilot.build_pilot_report( + dsn="postgresql://example", + keep_session=False, + include_cancel=True, + iterations=3, + ) + + assert report["overall_status"] == "fail" + assert report["metrics"]["total_iterations"] == 3 + assert report["metrics"]["checkpoint_resume_passed"] == 2 + assert report["metrics"]["runtime_cancel_passed"] == 3 + assert report["metrics"]["cancel_then_resume_passed"] == 3 + assert report["metrics"]["checkpoint_resume_success_rate"] == pytest.approx(2 / 3) + assert report["metrics"]["runtime_cancel_success_rate"] == 1.0 + assert report["metrics"]["cancel_then_resume_success_rate"] == 1.0 + assert len(report["iterations"]) == 3 + assert report["iterations"][1]["cases"]["checkpoint_resume"]["status"] == "fail" + assert report["cases"]["checkpoint_resume"]["status"] == "fail" + assert report["acceptance"]["resume_does_not_restart"] == "fail" + + +@pytest.mark.asyncio +async def test_build_pilot_report_returns_json_failure_when_validation_raises(monkeypatch): + async def fake_run_validation(*, dsn: str, keep_session: bool): + raise RuntimeError("database unavailable") + + monkeypatch.setattr(validate_long_task_pilot, "run_validation", fake_run_validation) + + report = await validate_long_task_pilot.build_pilot_report( + dsn="postgresql://example", + keep_session=False, + include_cancel=True, + ) + + assert report["overall_status"] == "fail" + assert report["cases"]["checkpoint_resume"]["status"] == "fail" + assert report["cases"]["checkpoint_resume"]["error_type"] == "RuntimeError" + assert "database unavailable" in report["cases"]["checkpoint_resume"]["error"] + assert report["cases"]["runtime_cancel"]["status"] == "skipped" + assert report["cases"]["cancel_then_resume"]["status"] == "skipped" + json.dumps(report, ensure_ascii=False) diff --git a/tests/test_mcp_runtime.py b/tests/test_mcp_runtime.py new file mode 100644 index 00000000..d0544d4d --- /dev/null +++ b/tests/test_mcp_runtime.py @@ -0,0 +1,379 @@ +from __future__ import annotations + +import json +import socket +import textwrap +import threading +import time +from contextlib import contextmanager +from types import SimpleNamespace +from uuid import uuid4 + +import httpx +import pytest +import uvicorn +from fastmcp import FastMCP +from sse_starlette.sse import AppStatus + +from ksadk.detection import DetectionResult, FrameworkType + + +def _write_adk_project(tmp_path, source: str) -> DetectionResult: + package_name = f"demo_agent_{uuid4().hex[:8]}" + package_dir = tmp_path / package_name + package_dir.mkdir() + (package_dir / "__init__.py").write_text("", encoding="utf-8") + (package_dir / "agent.py").write_text(textwrap.dedent(source), encoding="utf-8") + return DetectionResult( + type=FrameworkType.ADK, + name="demo-agent", + entry_point=f"{package_name}/agent.py", + package_path=str(package_dir), + agent_variable="root_agent", + confidence=1.0, + ) + + +@contextmanager +def _run_fastmcp_http_server(app): + AppStatus.should_exit = False + AppStatus.should_exit_event = None + + sock = socket.socket() + sock.bind(("127.0.0.1", 0)) + host, port = sock.getsockname() + sock.close() + + config = uvicorn.Config(app, host=host, port=port, log_level="warning") + server = uvicorn.Server(config) + thread = threading.Thread(target=server.run, daemon=True) + thread.start() + + deadline = time.time() + 5 + while not server.started and time.time() < deadline: + time.sleep(0.05) + + if not server.started: + raise RuntimeError("FastMCP test server failed to start") + + try: + yield f"http://{host}:{port}" + finally: + server.should_exit = True + thread.join(timeout=5) + AppStatus.should_exit = False + AppStatus.should_exit_event = None + + +@pytest.fixture +def weather_mcp_server(): + server = FastMCP("weather") + + @server.tool + def forecast(city: str) -> str: + return f"forecast:{city}" + + app = server.http_app(path="/mcp", transport="streamable-http") + with _run_fastmcp_http_server(app) as base_url: + yield base_url + + +def test_load_mcp_server_configs_validates_shape(): + from ksadk.mcp_runtime import load_mcp_server_configs + + configs = load_mcp_server_configs( + json.dumps( + [ + { + "name": "weather", + "url": "https://example.com/mcp", + "api_key": "ak-123", + "tool_filter": ["forecast", "alerts"], + "tool_name_prefix": "weather", + } + ] + ) + ) + + assert len(configs) == 1 + assert configs[0].name == "weather" + assert configs[0].url == "https://example.com/mcp" + assert configs[0].api_key == "ak-123" + assert configs[0].tool_filter == ("forecast", "alerts") + assert configs[0].tool_name_prefix == "weather" + + +def test_load_mcp_server_configs_rejects_invalid_payloads(): + from ksadk.mcp_runtime import load_mcp_server_configs + + with pytest.raises(ValueError, match="JSON array"): + load_mcp_server_configs("{}") + + with pytest.raises(ValueError, match="/mcp"): + load_mcp_server_configs( + json.dumps([{"name": "weather", "url": "https://example.com/api"}]) + ) + + +def test_build_connection_params_includes_bearer_auth_header(): + from ksadk.mcp_runtime import MCPServerConfig, build_connection_params + + descriptor = MCPServerConfig( + name="weather", + url="https://example.com/mcp", + api_key="ak-123", + tool_filter=("forecast",), + tool_name_prefix="weather", + ) + + params = build_connection_params(descriptor) + + assert params.url == "https://example.com/mcp" + assert params.headers == {"Authorization": "Bearer ak-123"} + + +def test_build_connection_params_disables_proxy_for_loopback_urls(monkeypatch): + from ksadk.mcp_runtime import MCPServerConfig, build_connection_params + + monkeypatch.setenv("HTTP_PROXY", "http://127.0.0.1:7890") + descriptor = MCPServerConfig(name="local", url="http://127.0.0.1:8899/mcp") + + params = build_connection_params(descriptor) + client = params.httpx_client_factory() + + try: + assert client._trust_env is False + finally: + import anyio + + anyio.run(client.aclose) + + +@pytest.mark.asyncio +async def test_build_mcp_toolset_roundtrip_lists_and_calls_remote_tools(weather_mcp_server): + from ksadk.mcp_runtime import MCPServerConfig, build_mcp_toolset + + headers_seen: list[dict[str, str]] = [] + + def httpx_client_factory(headers=None, timeout=None, auth=None): + headers_seen.append(dict(headers or {})) + return httpx.AsyncClient( + headers=headers, + timeout=timeout, + auth=auth, + follow_redirects=True, + trust_env=False, + ) + + descriptor = MCPServerConfig( + name="weather", + url=f"{weather_mcp_server}/mcp", + api_key="secret-token", + tool_filter=("forecast",), + tool_name_prefix="weather", + ) + toolset = build_mcp_toolset( + descriptor, + httpx_client_factory=httpx_client_factory, + ) + + tools = await toolset.get_tools_with_prefix() + result = await tools[0]._run_async_impl( + args={"city": "beijing"}, + tool_context=SimpleNamespace(_invocation_context=None), + credential=None, + ) + + assert [tool.name for tool in tools] == ["weather_forecast"] + assert result["content"][0]["text"] == "forecast:beijing" + assert headers_seen[0]["Authorization"] == "Bearer secret-token" + + await toolset.close() + + +def test_adk_runner_load_agent_injects_mcp_toolsets_and_deduplicates(monkeypatch, tmp_path): + import google.adk.runners as adk_runners + + from ksadk.runners.adk_runner import ADKRunner + + detection = _write_adk_project( + tmp_path, + """ + class DemoAgent: + def __init__(self): + self.name = "demo-agent" + self.tools = [] + self.instruction = "Be helpful." + + root_agent = DemoAgent() + """, + ) + + class FakeRunner: + def __init__(self, **kwargs): + self.kwargs = kwargs + + class FakeToolset: + def __init__(self, key: str): + self._ksadk_mcp_toolset_key = key + + monkeypatch.delenv("KSADK_ENABLE_MCP_TOOLS", raising=False) + monkeypatch.setattr(ADKRunner, "_apply_json_patch", lambda self: None) + monkeypatch.setattr(ADKRunner, "_init_short_term_memory", lambda self: None) + monkeypatch.setattr(ADKRunner, "_init_long_term_memory", lambda self: None) + monkeypatch.setattr(ADKRunner, "_init_knowledge_base", lambda self: None) + monkeypatch.setattr(adk_runners, "Runner", FakeRunner) + monkeypatch.setattr( + "ksadk.mcp_runtime.load_mcp_toolsets_from_env", + lambda: [ + FakeToolset("https://example.com/mcp|weather"), + FakeToolset("https://example.com/mcp|weather"), + ], + ) + + runner = ADKRunner(detection, str(tmp_path)) + runner.load_agent() + + keys = [ + getattr(tool, "_ksadk_mcp_toolset_key", None) + for tool in runner._agent.tools + if getattr(tool, "_ksadk_mcp_toolset_key", None) + ] + assert keys == ["https://example.com/mcp|weather"] + + +def test_adk_runner_load_agent_skips_mcp_toolsets_when_disabled(monkeypatch, tmp_path): + import google.adk.runners as adk_runners + + from ksadk.runners.adk_runner import ADKRunner + + detection = _write_adk_project( + tmp_path, + """ + class DemoAgent: + def __init__(self): + self.name = "demo-agent" + self.tools = [] + self.instruction = "Be helpful." + + root_agent = DemoAgent() + """, + ) + + class FakeRunner: + def __init__(self, **kwargs): + self.kwargs = kwargs + + class FakeToolset: + def __init__(self, key: str): + self._ksadk_mcp_toolset_key = key + + monkeypatch.setenv("KSADK_ENABLE_MCP_TOOLS", "0") + monkeypatch.setattr(ADKRunner, "_apply_json_patch", lambda self: None) + monkeypatch.setattr(ADKRunner, "_init_short_term_memory", lambda self: None) + monkeypatch.setattr(ADKRunner, "_init_long_term_memory", lambda self: None) + monkeypatch.setattr(ADKRunner, "_init_knowledge_base", lambda self: None) + monkeypatch.setattr(adk_runners, "Runner", FakeRunner) + monkeypatch.setattr( + "ksadk.mcp_runtime.load_mcp_toolsets_from_env", + lambda: [FakeToolset("https://example.com/mcp|weather")], + ) + + runner = ADKRunner(detection, str(tmp_path)) + runner.load_agent() + + assert all( + getattr(tool, "_ksadk_mcp_toolset_key", None) is None + for tool in runner._agent.tools + ) + + +@pytest.mark.asyncio +async def test_adk_runner_invoke_roundtrip_with_remote_mcp_tools( + monkeypatch, + tmp_path, + weather_mcp_server, +): + import google.adk.runners as adk_runners + + from ksadk.runners.adk_runner import ADKRunner + + detection = _write_adk_project( + tmp_path, + """ + class DemoAgent: + def __init__(self): + self.name = "demo-agent" + self.tools = [] + self.instruction = "Use the weather_forecast tool." + + root_agent = DemoAgent() + """, + ) + + class FakeRunner: + def __init__(self, **kwargs): + self.agent = kwargs["agent"] + + async def run_async( + self, + *, + session_id, + user_id, + new_message, + state_delta=None, + run_config=None, + ): + toolsets = [ + tool for tool in self.agent.tools if hasattr(tool, "get_tools_with_prefix") + ] + assert toolsets + tools = await toolsets[0].get_tools_with_prefix() + payload = await tools[0]._run_async_impl( + args={"city": new_message.parts[0].text}, + tool_context=SimpleNamespace(_invocation_context=None), + credential=None, + ) + text = payload["content"][0]["text"] + yield SimpleNamespace( + content=SimpleNamespace( + parts=[SimpleNamespace(text=text, thought=False)] + ) + ) + + monkeypatch.delenv("KSADK_ENABLE_MCP_TOOLS", raising=False) + monkeypatch.setenv( + "KSADK_MCP_SERVERS", + json.dumps( + [ + { + "name": "weather", + "url": f"{weather_mcp_server}/mcp", + "api_key": "secret-token", + "tool_filter": ["forecast"], + "tool_name_prefix": "weather", + } + ] + ), + ) + monkeypatch.setattr(ADKRunner, "_apply_json_patch", lambda self: None) + monkeypatch.setattr(ADKRunner, "_init_short_term_memory", lambda self: None) + monkeypatch.setattr(ADKRunner, "_init_long_term_memory", lambda self: None) + monkeypatch.setattr(ADKRunner, "_init_knowledge_base", lambda self: None) + monkeypatch.setattr(adk_runners, "Runner", FakeRunner) + + runner = ADKRunner(detection, str(tmp_path)) + runner.load_agent() + + result = await runner.invoke({"input": "beijing"}) + + assert result["output"] == "forecast:beijing" + assert any( + getattr(tool, "_ksadk_mcp_toolset_key", None) + for tool in runner._agent.tools + ) + + for toolset in runner._runtime_toolsets: + close = getattr(toolset, "close", None) + if close is not None: + await close() diff --git a/tests/test_openclaw_gateway.py b/tests/test_openclaw_gateway.py new file mode 100644 index 00000000..4589e689 --- /dev/null +++ b/tests/test_openclaw_gateway.py @@ -0,0 +1,127 @@ +import asyncio + +import pytest + +from ksadk.openclaw_gateway import DashboardAccessInfo, OpenClawGatewayClient +from ksadk.openclaw_gateway import OpenClawGatewayError + + +class _CapturingGatewayClient(OpenClawGatewayClient): + def __init__(self): + super().__init__(region="pre-online", agent_id="ar-demo-1") + self.connect_request = None + + async def build_access_info(self, **_kwargs): + return DashboardAccessInfo( + agent_id="ar-demo-1", + agent_name="demo", + access_url="http://dashboard.example.com/s/link", + ws_url="ws://dashboard.example.com/", + cookie_header="sid=demo", + origin="http://dashboard.example.com", + ) + + async def _connect_ws(self, _ws_url, _headers): + return object() + + async def _wait_for_connect_challenge(self, *, timeout_ms=10_000): + return "nonce-demo" + + async def request(self, method, params=None, *, timeout_ms=30_000): + if method == "connect": + self.connect_request = dict(params or {}) + return {"features": {"methods": []}} + + +def test_openclaw_gateway_client_uses_current_protocol_v4_for_managed_runtime(): + client = _CapturingGatewayClient() + + asyncio.run(client.connect()) + + assert client.connect_request["minProtocol"] == 4 + assert client.connect_request["maxProtocol"] == 4 + + +class _FakeCookieJar: + def get_dict(self): + return {"ae_ui_session": "sid-demo"} + + +class _FakeSession: + def __init__(self, response): + self.response = response + self.cookies = _FakeCookieJar() + self.calls = [] + + def get(self, url, *, allow_redirects, timeout): + self.calls.append( + { + "url": url, + "allow_redirects": allow_redirects, + "timeout": timeout, + } + ) + return self.response + + +class _FakeResponse: + def __init__(self, status_code): + self.status_code = status_code + + +def test_build_access_info_accepts_short_link_redirect_without_following(monkeypatch): + client = OpenClawGatewayClient(region="pre-online", agent_id="ar-demo-1") + client.session = _FakeSession(_FakeResponse(302)) + + async def fake_create_dashboard_access_link(**_kwargs): + return {"access_url": "http://dashboard.example.com/s/link", "link_id": "link"} + + class FakeAgentEngineClient: + def __init__(self, **_kwargs): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, *_args): + return None + + create_dashboard_access_link = staticmethod(fake_create_dashboard_access_link) + + monkeypatch.setattr("ksadk.openclaw_gateway.AgentEngineClient", FakeAgentEngineClient) + + info = asyncio.run(client.build_access_info()) + + assert info.cookie_header == "ae_ui_session=sid-demo" + assert client.session.calls == [ + { + "url": "http://dashboard.example.com/s/link", + "allow_redirects": False, + "timeout": 30, + } + ] + + +def test_build_access_info_rejects_non_redirect_short_link(monkeypatch): + client = OpenClawGatewayClient(region="pre-online", agent_id="ar-demo-1") + client.session = _FakeSession(_FakeResponse(200)) + + async def fake_create_dashboard_access_link(**_kwargs): + return {"access_url": "http://dashboard.example.com/s/link", "link_id": "link"} + + class FakeAgentEngineClient: + def __init__(self, **_kwargs): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, *_args): + return None + + create_dashboard_access_link = staticmethod(fake_create_dashboard_access_link) + + monkeypatch.setattr("ksadk.openclaw_gateway.AgentEngineClient", FakeAgentEngineClient) + + with pytest.raises(OpenClawGatewayError, match="HTTP 200"): + asyncio.run(client.build_access_info()) diff --git a/tests/test_orchestration_agents.py b/tests/test_orchestration_agents.py new file mode 100644 index 00000000..e58fe1f1 --- /dev/null +++ b/tests/test_orchestration_agents.py @@ -0,0 +1,327 @@ +from __future__ import annotations + +import asyncio + +import pytest + +from ksadk.agents import ( + AgentEvent, + EventType, + LoopAgent, + OrchestrationContext, + ParallelAgent, + RunnerAgent, + SequentialAgent, +) + + +async def _collect_events(agent, context: OrchestrationContext) -> list[AgentEvent]: + return [event async for event in agent.run_async(context)] + + +def _event_types(events: list[AgentEvent]) -> list[EventType]: + return [event.event_type for event in events] + + +@pytest.mark.asyncio +async def test_sequential_agent_runs_sub_agents_in_order(): + async def research(context: OrchestrationContext) -> dict: + return { + "data": "research-notes", + "state_delta": {"research": "research-notes"}, + } + + async def write(context: OrchestrationContext) -> dict: + return { + "data": f"draft:{context.get('research')}", + "state_delta": {"draft": f"draft:{context.get('research')}"}, + } + + pipeline = SequentialAgent(name="pipeline", sub_agents=[research, write]) + context = OrchestrationContext(session_id="s1", state={"input": "topic"}) + + events = await _collect_events(pipeline, context) + + assert context.state["research"] == "research-notes" + assert context.state["draft"] == "draft:research-notes" + assert [event.agent_name for event in events if event.event_type == EventType.TEXT_OUTPUT] == [ + "research", + "write", + ] + assert _event_types(events).count(EventType.AGENT_START) >= 3 + assert _event_types(events).count(EventType.AGENT_END) >= 3 + + +@pytest.mark.asyncio +async def test_parallel_agent_isolates_branch_context_and_merges_results(): + async def alpha(context: OrchestrationContext) -> dict: + await asyncio.sleep(0.01) + return { + "data": "alpha-output", + "state_delta": {"shared": "alpha", "alpha_only": 1}, + } + + async def beta(context: OrchestrationContext) -> dict: + return { + "data": "beta-output", + "state_delta": {"shared": "beta", "beta_only": 2}, + } + + agent = ParallelAgent(name="fanout", sub_agents=[alpha, beta]) + context = OrchestrationContext(session_id="s1", state={"input": "topic", "seed": "base"}) + + events = await _collect_events(agent, context) + + text_events = [event for event in events if event.event_type == EventType.TEXT_OUTPUT] + assert {event.branch for event in text_events} == {"alpha", "beta"} + assert context.state["seed"] == "base" + assert context.state["alpha_only"] == 1 + assert context.state["beta_only"] == 2 + assert context.state["fanout_results"]["alpha"]["shared"] == "alpha" + assert context.state["fanout_results"]["beta"]["shared"] == "beta" + assert context.state["fanout_conflicts"]["shared"] == {"alpha": "alpha", "beta": "beta"} + + +@pytest.mark.asyncio +async def test_parallel_agent_does_not_leak_nested_state_between_branches(): + async def alpha(context: OrchestrationContext) -> dict: + context.state["shared"]["items"].append("alpha") + return {"state_delta": {"alpha_only": 1}} + + async def beta(context: OrchestrationContext) -> dict: + context.state["shared"]["items"].append("beta") + return {"state_delta": {"beta_only": 2}} + + agent = ParallelAgent(name="fanout", sub_agents=[alpha, beta]) + context = OrchestrationContext(state={"shared": {"items": []}}) + + await _collect_events(agent, context) + + assert context.state["shared"] == {"items": []} + assert context.state["fanout_results"]["alpha"]["shared"] == {"items": ["alpha"]} + assert context.state["fanout_results"]["beta"]["shared"] == {"items": ["beta"]} + assert context.state["fanout_conflicts"]["shared"] == { + "alpha": {"items": ["alpha"]}, + "beta": {"items": ["beta"]}, + } + + +@pytest.mark.asyncio +async def test_parallel_agent_reraises_branch_failures(): + async def ok(context: OrchestrationContext) -> dict: + return {"data": "ok", "state_delta": {"ok": True}} + + async def boom(context: OrchestrationContext) -> dict: + raise RuntimeError("boom") + + agent = ParallelAgent(name="fanout", sub_agents=[ok, boom]) + context = OrchestrationContext() + events: list[AgentEvent] = [] + + with pytest.raises(RuntimeError, match="boom"): + async for event in agent.run_async(context): + events.append(event) + + assert any( + event.agent_name == "boom" + and event.event_type == EventType.ERROR + and event.data == "boom" + for event in events + ) + assert any( + event.agent_name == "fanout" + and event.event_type == EventType.ERROR + and event.data == "boom" + for event in events + ) + + +@pytest.mark.asyncio +async def test_loop_agent_exits_on_max_iterations(): + async def tick(context: OrchestrationContext) -> dict: + count = context.get("count", 0) + 1 + return {"data": count, "state_delta": {"count": count}} + + agent = LoopAgent(name="ticker", sub_agents=[tick], max_iterations=3) + context = OrchestrationContext() + + events = await _collect_events(agent, context) + + assert context.state["count"] == 3 + assert [ + event.metadata["iteration"] + for event in events + if "iteration" in event.metadata + ] == [0, 1, 2] + + +@pytest.mark.asyncio +async def test_loop_agent_exits_on_exit_condition(): + async def increment(context: OrchestrationContext) -> dict: + count = context.get("count", 0) + 1 + return {"data": count, "state_delta": {"count": count}} + + agent = LoopAgent( + name="until-two", + sub_agents=[increment], + exit_condition=lambda context: context.get("count", 0) >= 2, + max_iterations=10, + ) + context = OrchestrationContext() + + await _collect_events(agent, context) + + assert context.state["count"] == 2 + + +@pytest.mark.asyncio +async def test_loop_agent_exits_on_escalate_event(): + async def review(context: OrchestrationContext) -> dict: + count = context.get("count", 0) + 1 + return { + "event_type": EventType.ESCALATE, + "data": "needs-human", + "state_delta": {"count": count}, + "escalate": True, + } + + agent = LoopAgent(name="review-loop", sub_agents=[review], max_iterations=10) + context = OrchestrationContext() + + events = await _collect_events(agent, context) + + assert context.state["count"] == 1 + assert any(event.event_type == EventType.ESCALATE for event in events) + + +@pytest.mark.asyncio +async def test_nested_orchestration_agents_share_state_through_parent_context(): + async def draft(context: OrchestrationContext) -> dict: + return {"data": "draft", "state_delta": {"draft": "v1"}} + + async def review_a(context: OrchestrationContext) -> dict: + return {"data": "review-a", "state_delta": {"score_a": 0.7}} + + async def review_b(context: OrchestrationContext) -> dict: + return {"data": "review-b", "state_delta": {"score_b": 0.9}} + + parallel_reviews = ParallelAgent(name="reviews", sub_agents=[review_a, review_b]) + workflow = SequentialAgent(name="workflow", sub_agents=[draft, parallel_reviews]) + context = OrchestrationContext(state={"input": "topic"}) + + await _collect_events(workflow, context) + + assert context.state["draft"] == "v1" + assert context.state["score_a"] == 0.7 + assert context.state["score_b"] == 0.9 + assert context.state["reviews_results"]["review_a"]["score_a"] == 0.7 + assert context.state["reviews_results"]["review_b"]["score_b"] == 0.9 + + +class _InvokeOnlyRunner: + def __init__(self): + self.calls = [] + + async def invoke(self, input_data): + self.calls.append(input_data) + return {"output": f"invoke:{input_data['input']}", "state_delta": {"runner_mode": "invoke"}} + + +class _StreamingRunner: + def __init__(self): + self.calls = [] + + async def invoke(self, input_data): + self.calls.append(("invoke", input_data)) + return {"output": "unused"} + + async def stream(self, input_data): + self.calls.append(("stream", input_data)) + yield {"delta": "hello", "type": "text"} + yield {"delta": " world", "type": "text"} + + +@pytest.mark.asyncio +async def test_runner_adapter_supports_invoke_and_stream_modes(): + invoke_runner = _InvokeOnlyRunner() + stream_runner = _StreamingRunner() + agent = SequentialAgent( + name="pipeline", + sub_agents=[ + RunnerAgent(name="invoke_runner", runner=invoke_runner), + stream_runner, + ], + ) + context = OrchestrationContext(session_id="session-1", state={"input": "hi"}) + + events = await _collect_events(agent, context) + + assert invoke_runner.calls == [ + {"input": "hi", "state": {"input": "hi"}, "session_id": "session-1", "branch": ""} + ] + assert stream_runner.calls == [ + ( + "stream", + { + "input": "hi", + "state": { + "input": "hi", + "runner_mode": "invoke", + "invoke_runner_output": "invoke:hi", + }, + "session_id": "session-1", + "branch": "", + }, + ) + ] + assert context.state["runner_mode"] == "invoke" + assert context.state["invoke_runner_output"] == "invoke:hi" + assert context.state["streaming_runner_output"] == "hello world" + assert [ + event.data for event in events if event.agent_name == "streaming_runner" + ] == ["hello", " world"] + + +@pytest.mark.asyncio +async def test_orchestration_agent_yields_error_event_before_reraising(): + async def ok(context: OrchestrationContext) -> dict: + return {"data": "ok"} + + async def boom(context: OrchestrationContext) -> dict: + raise RuntimeError("boom") + + agent = SequentialAgent(name="pipeline", sub_agents=[ok, boom]) + context = OrchestrationContext() + events: list[AgentEvent] = [] + + with pytest.raises(RuntimeError, match="boom"): + async for event in agent.run_async(context): + events.append(event) + + assert any( + event.agent_name == "pipeline" + and event.event_type == EventType.ERROR + and event.data == "boom" + for event in events + ) + + +def test_name_validation_rejects_invalid_and_duplicate_names(): + async def duplicate(context: OrchestrationContext) -> dict: + return {"data": "one"} + + duplicate.__name__ = "same" + + async def also_duplicate(context: OrchestrationContext) -> dict: + return {"data": "two"} + + also_duplicate.__name__ = "same" + + with pytest.raises(ValueError, match="valid identifier"): + SequentialAgent(name="not valid", sub_agents=[]) + + with pytest.raises(ValueError, match="unique"): + SequentialAgent(name="pipeline", sub_agents=[duplicate, also_duplicate]) + + with pytest.raises(ValueError, match="valid identifier"): + SequentialAgent(name="pipeline", sub_agents=[lambda context: context]) diff --git a/tests/test_platform_memory_tools.py b/tests/test_platform_memory_tools.py new file mode 100644 index 00000000..beee3df0 --- /dev/null +++ b/tests/test_platform_memory_tools.py @@ -0,0 +1,142 @@ +from __future__ import annotations + +from ksadk.runtime_context import PlatformInvocationContext, platform_invocation_scope + + +class _FakeMemoryService: + def __init__(self): + self.search_calls: list[tuple[str, str, int | None]] = [] + self.save_calls: list[tuple[str, str, dict]] = [] + self._backend = None + + def search_text(self, *, user_id: str, query: str, top_k: int | None = None) -> str: + self.search_calls.append((user_id, query, top_k)) + return f"memories for {user_id}: {query}" + + def save_text(self, *, user_id: str, content: str, metadata: dict) -> bool: + self.save_calls.append((user_id, content, metadata)) + return True + + +class _AcceptedButUnverifiedMemoryService(_FakeMemoryService): + def __init__(self): + super().__init__() + class SdkLTMBackend: + last_error = "" + + def get_session_status(self, *, user_id: str, session_id: str) -> dict: + return {"SessionId": session_id, "State": 0} + + self._backend = SdkLTMBackend() + + def search_entries(self, *, user_id: str, query: str, top_k: int | None = None) -> list[str]: + self.search_calls.append((user_id, query, top_k)) + return [] + + +class _FailingMemoryService(_FakeMemoryService): + def __init__(self): + super().__init__() + self._backend = type("Backend", (), {"last_error": "NotFound: missing memory"})() + + def save_text(self, *, user_id: str, content: str, metadata: dict) -> bool: + self.save_calls.append((user_id, content, metadata)) + return False + + +def _context() -> PlatformInvocationContext: + return PlatformInvocationContext( + agent_id="demo-agent", + user_id="user-1", + session_id="sess-1", + history=[{"role": "user", "content": "hello"}], + input_content=[], + input_messages=[], + input_parts=[], + attachments=[], + attachment_results=[], + current_attachments=[], + current_attachment_results=[], + has_current_files=False, + runner_type="langgraph", + ) + + +def test_load_memory_uses_platform_invocation_context(monkeypatch): + from ksadk.memory.tool import load_memory + + service = _FakeMemoryService() + monkeypatch.setattr("ksadk.memory.tool._get_or_create_service", lambda: service) + + with platform_invocation_scope(_context()): + result = load_memory("project status") + + assert result == "memories for user-1: project status" + assert service.search_calls == [("user-1", "project status", None)] + + +def test_save_memory_persists_agent_and_session_metadata(monkeypatch): + from ksadk.memory.tool import save_memory + + service = _FakeMemoryService() + monkeypatch.setattr("ksadk.memory.tool._get_or_create_service", lambda: service) + + with platform_invocation_scope(_context()): + result = save_memory("用户喜欢云主机") + + assert result == {"ok": True, "status": "persisted", "message": "记忆已保存。"} + assert service.save_calls == [ + ( + "user-1", + "用户喜欢云主机", + { + "agent_id": "demo-agent", + "session_id": "sess-1", + "runner_type": "langgraph", + }, + ) + ] + + +def test_save_memory_without_runtime_context_returns_diagnostic(monkeypatch): + from ksadk.memory.tool import save_memory + + service = _FakeMemoryService() + monkeypatch.setattr("ksadk.memory.tool._get_or_create_service", lambda: service) + + result = save_memory("no context") + + assert result["ok"] is False + assert "缺少运行时上下文" in result["message"] + assert service.save_calls == [] + + +def test_save_memory_failure_includes_backend_error(monkeypatch): + from ksadk.memory.tool import save_memory + + service = _FailingMemoryService() + monkeypatch.setattr("ksadk.memory.tool._get_or_create_service", lambda: service) + + with platform_invocation_scope(_context()): + result = save_memory("用户喜欢云主机") + + assert result["ok"] is False + assert "记忆保存失败" in result["message"] + assert "NotFound: missing memory" in result["message"] + + +def test_save_memory_reports_unverified_sdk_acceptance(monkeypatch): + from ksadk.memory.tool import save_memory + + service = _AcceptedButUnverifiedMemoryService() + monkeypatch.setattr("ksadk.memory.tool._get_or_create_service", lambda: service) + + with platform_invocation_scope(_context()): + result = save_memory("用户喜欢云主机") + + assert result["ok"] is False + assert result["status"] == "accepted_not_extracted" + assert "尚未抽取" in result["message"] + assert result["session_id"] == "sess-1" + assert result["session_state"] == 0 + assert service.search_calls == [("user-1", "用户喜欢云主机", 1)] diff --git a/tests/test_postgres_session_service.py b/tests/test_postgres_session_service.py new file mode 100644 index 00000000..e2a66344 --- /dev/null +++ b/tests/test_postgres_session_service.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +import os + +import pytest + +from ksadk.sessions.base import SessionEvent + +pytestmark = pytest.mark.asyncio + + +async def test_postgres_session_service_two_instances_share_sessions_events_and_state(): + dsn = os.getenv("KSADK_TEST_POSTGRES_DSN") + if not dsn: + pytest.skip("Set KSADK_TEST_POSTGRES_DSN to run Postgres session integration tests") + + from ksadk.sessions.postgres_service import PostgresSessionService + + namespace = "pytest_cross_pod" + service_a = PostgresSessionService(dsn=dsn, namespace=namespace) + service_b = PostgresSessionService(dsn=dsn, namespace=namespace) + session_id = "pytest-sess-cross-pod" + + try: + await service_a.delete_session(session_id) + created = await service_a.create_session( + agent_id="demo-agent", + user_id="user-1", + session_id=session_id, + ) + await service_a.append_event( + session_id, + SessionEvent( + id="pytest-evt-1", + author="user", + event_type="user_message", + content={"role": "user", "parts": [{"text": "hello"}]}, + state_delta={"turns": 1}, + metadata={"tenant_id": "tenant-a"}, + ), + ) + await service_a.update_state( + agent_id="demo-agent", + user_id="user-1", + session_id=session_id, + scope="runner_runtime:langgraph", + state_delta={"path": "replay", "level": "semantic"}, + ) + + listed = await service_b.list_sessions("demo-agent", "user-1") + fetched = await service_b.get_session(session_id) + events = await service_b.get_events(session_id) + session_state = await service_b.get_state("demo-agent", "user-1", session_id, "session") + runtime_state = await service_b.get_state( + "demo-agent", + "user-1", + session_id, + "runner_runtime:langgraph", + ) + + assert created.id == session_id + assert session_id in [session.id for session in listed] + assert fetched is not None + assert fetched.state == {"turns": 1} + assert [event.id for event in events] == ["pytest-evt-1"] + assert events[0].seq_id == 1 + assert session_state is not None + assert session_state.state == {"turns": 1} + assert runtime_state is not None + assert runtime_state.state == {"path": "replay", "level": "semantic"} + finally: + await service_a.delete_session(session_id) + await service_a.aclose() + await service_b.aclose() + + +async def test_postgres_session_service_namespaces_isolate_same_session_id(): + dsn = os.getenv("KSADK_TEST_POSTGRES_DSN") + if not dsn: + pytest.skip("Set KSADK_TEST_POSTGRES_DSN to run Postgres session integration tests") + + from ksadk.sessions.postgres_service import PostgresSessionService + + session_id = "pytest-sess-same-id" + service_a = PostgresSessionService(dsn=dsn, namespace="pytest_tenant_a") + service_b = PostgresSessionService(dsn=dsn, namespace="pytest_tenant_b") + + try: + await service_a.delete_session(session_id) + await service_b.delete_session(session_id) + await service_a.create_session("agent-a", "user-1", session_id=session_id) + await service_b.create_session("agent-b", "user-1", session_id=session_id) + + assert [session.agent_id for session in await service_a.list_sessions("agent-a", "user-1")] == [ + "agent-a" + ] + assert [session.agent_id for session in await service_b.list_sessions("agent-b", "user-1")] == [ + "agent-b" + ] + assert await service_a.list_sessions("agent-b", "user-1") == [] + assert await service_b.list_sessions("agent-a", "user-1") == [] + finally: + await service_a.delete_session(session_id) + await service_b.delete_session(session_id) + await service_a.aclose() + await service_b.aclose() diff --git a/tests/test_remote_runner.py b/tests/test_remote_runner.py new file mode 100644 index 00000000..b1707b18 --- /dev/null +++ b/tests/test_remote_runner.py @@ -0,0 +1,405 @@ +import pytest + +from ksadk.runners.remote_runner import RemoteRunner + + +class _FakeResponse: + def __init__(self, *, json_payload=None, lines=None): + self._json_payload = json_payload or {} + self._lines = lines or [] + + def raise_for_status(self): + return None + + def json(self): + return self._json_payload + + async def aiter_lines(self): + for line in self._lines: + yield line + + +class _FakeStream: + def __init__(self, response): + self.response = response + + async def __aenter__(self): + return self.response + + async def __aexit__(self, exc_type, exc, tb): + return False + + +class _FakeAsyncClient: + calls = [] + post_payload = { + "output": [ + { + "content": [ + { + "type": "output_text", + "text": "hello responses", + } + ] + } + ] + } + stream_lines = [ + "event: response.output_text.delta", + 'data: {"type":"response.output_text.delta","delta":"hello"}', + "event: response.reasoning.delta", + 'data: {"type":"response.reasoning.delta","delta":"thinking"}', + "data: [DONE]", + ] + + def __init__(self, **kwargs): + self.kwargs = kwargs + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def post(self, url, json=None, headers=None): + self.__class__.calls.append( + {"method": "POST", "url": url, "json": json, "headers": headers} + ) + return _FakeResponse(json_payload=self.post_payload) + + def stream(self, method, url, json=None, headers=None): + self.__class__.calls.append( + {"method": method, "url": url, "json": json, "headers": headers} + ) + return _FakeStream(_FakeResponse(lines=self.stream_lines)) + + +@pytest.mark.asyncio +async def test_remote_runner_responses_invoke_keeps_external_responses_stateless_by_default( + monkeypatch, +): + import httpx + + _FakeAsyncClient.calls = [] + monkeypatch.setattr(httpx, "AsyncClient", _FakeAsyncClient) + runner = RemoteRunner( + endpoint="https://agent.example.com", api_key="ak-demo", api_format="responses" + ) + + payload = await runner.invoke( + { + "input": "hi", + "session_id": "sess-1", + "platform_context": {"agent_id": "demo-agent"}, + } + ) + + assert payload == {"output": "hello responses"} + assert _FakeAsyncClient.calls[0]["url"] == "https://agent.example.com/v1/responses" + assert _FakeAsyncClient.calls[0]["json"] == { + "input": "hi", + "stream": False, + } + assert _FakeAsyncClient.calls[0]["headers"]["Authorization"] == "Bearer ak-demo" + + +@pytest.mark.asyncio +async def test_remote_runner_responses_invoke_forwards_explicit_conversation(monkeypatch): + import httpx + + _FakeAsyncClient.calls = [] + monkeypatch.setattr(httpx, "AsyncClient", _FakeAsyncClient) + runner = RemoteRunner(endpoint="https://agent.example.com", api_format="responses") + + await runner.invoke( + { + "input": "hi", + "conversation": "customer-thread-1", + } + ) + + assert _FakeAsyncClient.calls[0]["json"]["conversation"] == "customer-thread-1" + + +@pytest.mark.asyncio +async def test_remote_runner_responses_explicit_conversation_does_not_send_ksadk_history( + monkeypatch, +): + import httpx + + _FakeAsyncClient.calls = [] + monkeypatch.setattr(httpx, "AsyncClient", _FakeAsyncClient) + runner = RemoteRunner(endpoint="https://agent.example.com", api_format="responses") + + await runner.invoke( + { + "input": "hi", + "conversation": {"id": "customer-thread-1"}, + "history": [{"role": "user", "content": "old"}], + } + ) + + assert _FakeAsyncClient.calls[0]["json"]["conversation"] == "customer-thread-1" + assert "conversation_history" not in _FakeAsyncClient.calls[0]["json"] + + +@pytest.mark.asyncio +async def test_remote_runner_responses_normalizes_chat_style_input_for_openclaw(monkeypatch): + import httpx + + _FakeAsyncClient.calls = [] + monkeypatch.setattr(httpx, "AsyncClient", _FakeAsyncClient) + runner = RemoteRunner(endpoint="https://agent.example.com", api_format="responses") + + await runner.invoke( + { + "input": [ + {"role": "system", "content": "You are concise."}, + {"role": "user", "content": [{"type": "input_text", "text": "你好"}]}, + ] + } + ) + + assert _FakeAsyncClient.calls[0]["json"]["input"] == "你好" + + +@pytest.mark.asyncio +async def test_remote_runner_responses_keeps_standard_item_array_and_previous_response_id( + monkeypatch, +): + import httpx + + _FakeAsyncClient.calls = [] + monkeypatch.setattr(httpx, "AsyncClient", _FakeAsyncClient) + runner = RemoteRunner(endpoint="https://agent.example.com", api_format="responses") + + await runner.invoke( + { + "input": { + "type": "function_call_output", + "call_id": "call_123", + "output": "ok", + }, + "previous_response_id": "resp_123", + } + ) + + assert _FakeAsyncClient.calls[0]["json"]["input"] == [ + { + "type": "function_call_output", + "call_id": "call_123", + "output": "ok", + } + ] + assert _FakeAsyncClient.calls[0]["json"]["previous_response_id"] == "resp_123" + assert "conversation" not in _FakeAsyncClient.calls[0]["json"] + + +@pytest.mark.asyncio +async def test_remote_runner_responses_stream_parses_text_and_reasoning(monkeypatch): + import httpx + + _FakeAsyncClient.calls = [] + monkeypatch.setattr(httpx, "AsyncClient", _FakeAsyncClient) + runner = RemoteRunner(endpoint="https://agent.example.com", api_format="responses") + + chunks = [chunk async for chunk in runner.stream({"input": "hi"})] + + assert _FakeAsyncClient.calls[0]["url"] == "https://agent.example.com/v1/responses" + assert chunks == [ + {"delta": "hello", "type": "text"}, + {"delta": "thinking", "type": "thinking"}, + ] + + +@pytest.mark.asyncio +async def test_remote_runner_responses_stream_sends_hermes_conversation_and_history( + monkeypatch, +): + import httpx + + _FakeAsyncClient.calls = [] + monkeypatch.setattr(httpx, "AsyncClient", _FakeAsyncClient) + runner = RemoteRunner(endpoint="https://agent.example.com", api_format="responses") + + chunks = [ + chunk + async for chunk in runner.stream( + { + "input": "s6-overlay是什么", + "session_id": "sess-1", + "responses_conversation": True, + "platform_context": {"agent_id": "demo-agent"}, + "history": [ + {"role": "user", "content": "tini 是什么"}, + {"role": "model", "content": "tini 是容器 init 进程。"}, + {"role": "user", "content": "s6-overlay是什么"}, + ], + } + ) + ] + + assert chunks + assert _FakeAsyncClient.calls[0]["json"]["input"] == "s6-overlay是什么" + assert _FakeAsyncClient.calls[0]["json"]["conversation"] == "agentengine:demo-agent:sess-1" + assert "session_id" not in _FakeAsyncClient.calls[0]["json"] + assert _FakeAsyncClient.calls[0]["json"]["conversation_history"] == [ + {"role": "user", "content": [{"type": "input_text", "text": "tini 是什么"}]}, + { + "role": "assistant", + "content": [{"type": "input_text", "text": "tini 是容器 init 进程。"}], + }, + ] + + +@pytest.mark.asyncio +async def test_remote_runner_responses_stream_does_not_mix_conversation_with_previous_response_id( + monkeypatch, +): + import httpx + + _FakeAsyncClient.calls = [] + monkeypatch.setattr(httpx, "AsyncClient", _FakeAsyncClient) + runner = RemoteRunner(endpoint="https://agent.example.com", api_format="responses") + + chunks = [ + chunk + async for chunk in runner.stream( + { + "input": "继续", + "session_id": "sess-1", + "responses_conversation": True, + "previous_response_id": "resp_123", + "history": [{"role": "user", "content": "旧消息"}], + "platform_context": {"agent_id": "demo-agent"}, + } + ) + ] + + assert chunks + assert _FakeAsyncClient.calls[0]["json"]["previous_response_id"] == "resp_123" + assert "conversation" not in _FakeAsyncClient.calls[0]["json"] + assert "conversation_history" not in _FakeAsyncClient.calls[0]["json"] + + +@pytest.mark.asyncio +async def test_remote_runner_responses_stream_parses_native_tool_items(monkeypatch): + import httpx + + class ToolStreamClient(_FakeAsyncClient): + stream_lines = [ + "event: response.output_item.added", + ( + 'data: {"output_index":0,"item":{"id":"fc_1","type":"function_call",' + '"name":"search","arguments":""}}' + ), + "", + "event: response.function_call_arguments.delta", + 'data: {"item_id":"fc_1","delta":"{\\"q\\":"}', + "", + "event: response.function_call_arguments.delta", + 'data: {"item_id":"fc_1","delta":"\\"openclaw\\"}"}', + "", + "event: response.function_call_arguments.done", + 'data: {"item_id":"fc_1","arguments":"{\\"q\\":\\"openclaw\\"}"}', + "", + "event: response.output_item.done", + ( + 'data: {"output_index":0,"item":{"id":"out_1",' + '"type":"function_call_output","call_id":"fc_1","output":{"ok":true}}}' + ), + "", + "event: response.completed", + ( + 'data: {"response":{"id":"resp_1","output":[{"id":"fc_1",' + '"type":"function_call","name":"search",' + '"arguments":"{\\"q\\":\\"openclaw\\"}"},{"id":"out_1",' + '"type":"function_call_output","call_id":"fc_1","output":{"ok":true}}]}}' + ), + "", + "data: [DONE]", + ] + + ToolStreamClient.calls = [] + monkeypatch.setattr(httpx, "AsyncClient", ToolStreamClient) + runner = RemoteRunner(endpoint="https://agent.example.com", api_format="responses") + + chunks = [chunk async for chunk in runner.stream({"input": "hi"})] + + assert chunks == [ + {"type": "tool_call", "tool_name": "search", "tool_args": "", "status": "running"}, + {"type": "tool_call", "tool_name": "search", "tool_args": '{"q":', "status": "running"}, + { + "type": "tool_call", + "tool_name": "search", + "tool_args": '{"q":"openclaw"}', + "status": "running", + }, + { + "type": "tool_call", + "tool_name": "search", + "tool_args": '{"q":"openclaw"}', + "status": "running", + }, + {"type": "tool_result", "tool_name": "search", "tool_output": '{\n "ok": true\n}'}, + { + "type": "responses_output", + "output": [ + { + "id": "fc_1", + "type": "function_call", + "name": "search", + "arguments": '{"q":"openclaw"}', + }, + { + "id": "out_1", + "type": "function_call_output", + "call_id": "fc_1", + "output": {"ok": True}, + }, + ], + "response_id": "resp_1", + }, + ] + + +@pytest.mark.asyncio +async def test_remote_runner_responses_can_send_openclaw_session_header(monkeypatch): + import httpx + + _FakeAsyncClient.calls = [] + monkeypatch.setattr(httpx, "AsyncClient", _FakeAsyncClient) + runner = RemoteRunner( + endpoint="https://agent.example.com", + api_key="gateway-token", + api_format="responses", + responses_session_header="x-openclaw-session-key", + ) + + await runner.invoke({"input": "hi", "session_id": "sess-1"}) + + assert _FakeAsyncClient.calls[0]["headers"]["Authorization"] == "Bearer gateway-token" + assert _FakeAsyncClient.calls[0]["headers"]["x-openclaw-session-key"] == "sess-1" + assert "session_id" not in _FakeAsyncClient.calls[0]["json"] + assert "conversation" not in _FakeAsyncClient.calls[0]["json"] + + +@pytest.mark.asyncio +async def test_remote_runner_responses_stream_surfaces_failed_event(monkeypatch): + import httpx + + class FailedStreamClient(_FakeAsyncClient): + stream_lines = [ + "event: response.failed", + 'data: {"response":{"error":{"code":"api_error","message":"internal error"}}}', + "", + "data: [DONE]", + ] + + FailedStreamClient.calls = [] + monkeypatch.setattr(httpx, "AsyncClient", FailedStreamClient) + runner = RemoteRunner(endpoint="https://agent.example.com", api_format="responses") + + chunks = [chunk async for chunk in runner.stream({"input": "hi"})] + + assert chunks == [{"type": "error", "message": "internal error"}] diff --git a/tests/test_resource_output_snapshots.py b/tests/test_resource_output_snapshots.py new file mode 100644 index 00000000..bc9c4da0 --- /dev/null +++ b/tests/test_resource_output_snapshots.py @@ -0,0 +1,231 @@ +from __future__ import annotations + +from pathlib import Path + +from click.testing import CliRunner + +from ksadk.cli import cmd_dashboard +from ksadk.cli.cmd_mcp import mcp +from ksadk.cli.cmd_openclaw import openclaw +from ksadk.cli.cmd_version import version + + +SNAPSHOT_FILE = Path(__file__).parent / "snapshots" / "resource_output_snapshots.txt" + + +def load_section_snapshots(path: Path) -> dict[str, str]: + sections: dict[str, str] = {} + current_name: str | None = None + current_lines: list[str] = [] + + for line in path.read_text(encoding="utf-8").splitlines(): + if line.startswith("=== ") and line.endswith(" ==="): + if current_name is not None: + sections[current_name] = "\n".join(current_lines).rstrip() + "\n" + current_name = line[4:-4] + current_lines = [] + continue + current_lines.append(line) + + if current_name is not None: + sections[current_name] = "\n".join(current_lines).rstrip() + "\n" + + return sections + + +def _normalize_output(text: str) -> str: + return text.rstrip() + "\n" + + +class _FakeMCPClient: + def __init__(self, *args, **kwargs): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def list_mcps(self, **kwargs): + return { + "mcps": [ + { + "mcp_id": "mcp-1", + "name": "demo-mcp", + "status": "running", + "mcp_endpoint": "https://demo.example.com/mcp", + } + ], + "total": 1, + } + + async def get_mcp(self, mcp_id): + return { + "mcp_id": mcp_id, + "name": "demo-mcp", + "status": "running", + "region": "cn-beijing-6", + "endpoint": "https://demo.example.com", + "mcp_endpoint": "https://demo.example.com/mcp", + "enable_auth": True, + "tools": ["search"], + "created_at": "2026-03-20T12:00:00Z", + "updated_at": "2026-03-20T12:05:00Z", + } + + async def get_mcp_by_name(self, name, region=None): + return await self.get_mcp(name) + + async def close(self): + return None + + +class _FakeOpenClawClient: + def __init__(self, *args, **kwargs): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def list_agents(self, **kwargs): + return { + "agents": [ + { + "agent_id": "ar-openclaw-1", + "name": "demo-openclaw", + "status": "running", + "endpoint": "https://openclaw.example.com", + "region": "cn-beijing-6", + "account_id": "2000003485", + } + ], + "total": 1, + } + + async def get_agent(self, **kwargs): + return { + "basic": { + "agent_id": kwargs.get("agent_id") or "ar-openclaw-1", + "name": "demo-openclaw", + "status": "RUNNING", + "framework": "openclaw", + "region": "cn-beijing-6", + "created_at": "2026-03-20T12:00:00Z", + "updated_at": "2026-03-20T12:05:00Z", + }, + "quick_access": { + "public_endpoint": "https://openclaw.example.com", + }, + "deployment": { + "artifact_path": "hub.kce.ksyun.com/openclaw:latest", + }, + } + + async def close(self): + return None + + +class _FakeVersionClient: + async def list_versions(self, agent_id, page, size): + return { + "versions": [ + { + "tag": "v1.0.0", + "status": "current", + "traffic_percentage": 100, + "created_at": "2026-03-20T12:00:00Z", + "description": "Auto-released by deploy at 2026-03-20", + } + ], + "total": 1, + } + + async def close(self): + return None + + +async def _fake_resolve_target_agent_id(**kwargs): + return "ar-version-1" + + +async def _fake_resolve_agent_detail(*_args, **_kwargs): + return ( + { + "agent_id": "ar-demo", + "name": "demo-agent", + "framework": "langgraph", + "endpoint": "https://agent.example.com", + }, + type("Ref", (), {"source": "cli", "source_text": "CLI", "value": "ar-demo"})(), + False, + ) + + +async def _fake_list_dashboard_access_links(**_kwargs): + return { + "total": 1, + "links": [ + { + "link_id": "lnk-1", + "link_type": "share", + "status": "active", + "path": "/", + "expires_at": None, + "created_at": "2026-03-20T12:00:00Z", + } + ], + } + + +async def _fake_delete_dashboard_access_link(**_kwargs): + return {"deleted": True} + + +def test_resource_output_snapshots(monkeypatch): + runner = CliRunner() + snapshots = load_section_snapshots(SNAPSHOT_FILE) + + monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeMCPClient) + result = runner.invoke(mcp, ["list"]) + assert result.exit_code == 0, result.output + assert _normalize_output(result.output) == snapshots["mcp_list"] + + result = runner.invoke(mcp, ["status", "mcp-1"]) + assert result.exit_code == 0, result.output + assert _normalize_output(result.output) == snapshots["mcp_status"] + + monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawClient) + monkeypatch.setattr("ksadk.cli.cmd_openclaw._GLOBAL_ENV_CACHE", {}) + result = runner.invoke( + openclaw, + ["list"], + env={"KSYUN_ACCOUNT_ID": "2000003485"}, + ) + assert result.exit_code == 0, result.output + assert _normalize_output(result.output) == snapshots["openclaw_list"] + + result = runner.invoke(openclaw, ["status", "ar-openclaw-1"]) + assert result.exit_code == 0, result.output + assert _normalize_output(result.output) == snapshots["openclaw_status"] + + monkeypatch.setattr("ksadk.cli.cmd_version._get_client", lambda *, region, dry_run=False: _FakeVersionClient()) + monkeypatch.setattr("ksadk.cli.cmd_version._resolve_target_agent_id", _fake_resolve_target_agent_id) + result = runner.invoke(version, ["list", "--agent", "demo-agent"]) + assert result.exit_code == 0, result.output + assert _normalize_output(result.output) == snapshots["version_list"] + + monkeypatch.setattr(cmd_dashboard, "_resolve_agent_detail", _fake_resolve_agent_detail) + monkeypatch.setattr(cmd_dashboard, "_list_dashboard_access_links", _fake_list_dashboard_access_links) + monkeypatch.setattr(cmd_dashboard, "_delete_dashboard_access_link", _fake_delete_dashboard_access_link) + monkeypatch.setattr(cmd_dashboard, "load_state", lambda _cwd: {}) + result = runner.invoke(cmd_dashboard.dashboard, ["share", "list", "ar-demo"]) + assert result.exit_code == 0, result.output + assert _normalize_output(result.output) == snapshots["dashboard_share_list"] + + result = runner.invoke(cmd_dashboard.dashboard, ["share", "revoke", "lnk-1", "--yes"]) + assert result.exit_code == 0, result.output + assert _normalize_output(result.output) == snapshots["dashboard_share_revoke"] diff --git a/tests/test_runner.py b/tests/test_runner.py new file mode 100644 index 00000000..c8a848a8 --- /dev/null +++ b/tests/test_runner.py @@ -0,0 +1,670 @@ +"""Tests for the current runner contract.""" + +from __future__ import annotations + +import base64 +import os +import textwrap +from types import SimpleNamespace +from types import ModuleType +from typing import Any +from uuid import uuid4 + +import pytest + +from ksadk.detection import DetectionResult, FrameworkType +from ksadk.runners.base_runner import BaseRunner +from ksadk.runners.factory import create_runner + + +class _StubRunner(BaseRunner): + def __init__(self, detection_result: Any, project_dir: str): + super().__init__(detection_result, project_dir) + self.agent = "stub-agent" + + def load_agent(self) -> None: + self._agent = self.agent + + async def invoke(self, input_data): + return {"output": input_data} + + async def stream(self, input_data): + yield {"output": input_data} + + +class _AsyncClosableToolset: + def __init__(self): + self.closed = 0 + + async def close(self): + self.closed += 1 + + +class _SyncClosableToolset: + def __init__(self): + self.closed = 0 + + def close(self): + self.closed += 1 + + +class _AsyncAClosableToolset: + def __init__(self): + self.closed = 0 + + async def aclose(self): + self.closed += 1 + + +class _FailingClosableToolset: + def __init__(self): + self.closed = 0 + + async def close(self): + self.closed += 1 + raise RuntimeError("close failed") + + +def _install_runner_module(monkeypatch, module_path: str, class_name: str): + fake_module = ModuleType(module_path) + + class _FrameworkRunner(_StubRunner): + pass + + _FrameworkRunner.__name__ = class_name + setattr(fake_module, class_name, _FrameworkRunner) + monkeypatch.setitem(__import__("sys").modules, module_path, fake_module) + return _FrameworkRunner + + +def _write_adk_project(tmp_path, source: str) -> DetectionResult: + package_name = f"demo_agent_{uuid4().hex[:8]}" + package_dir = tmp_path / package_name + package_dir.mkdir() + (package_dir / "__init__.py").write_text("", encoding="utf-8") + (package_dir / "agent.py").write_text(textwrap.dedent(source), encoding="utf-8") + return DetectionResult( + type=FrameworkType.ADK, + name="demo-agent", + entry_point=f"{package_name}/agent.py", + package_path=str(package_dir), + agent_variable="root_agent", + confidence=1.0, + ) + + +def _tool_names(tools: list[Any]) -> list[str]: + return [getattr(tool, "name", None) or getattr(tool, "__name__", "") for tool in tools] + + +def _write_detection( + framework_type: FrameworkType, + *, + entry_point: str = "demo/agent.py", + package_path: str = "/tmp/demo", +) -> DetectionResult: + return DetectionResult( + type=framework_type, + name="demo-agent", + entry_point=entry_point, + package_path=package_path, + agent_variable="root_agent", + confidence=1.0, + ) + + +@pytest.mark.parametrize( + ("framework_type", "module_path", "class_name"), + [ + (FrameworkType.ADK, "ksadk.runners.adk_runner", "ADKRunner"), + (FrameworkType.LANGGRAPH, "ksadk.runners.langgraph_runner", "LangGraphRunner"), + (FrameworkType.LANGCHAIN, "ksadk.runners.langchain_runner", "LangChainRunner"), + (FrameworkType.DEEPAGENTS, "ksadk.runners.deepagents_runner", "DeepAgentsRunner"), + ], +) +def test_create_runner_dispatches_by_framework( + monkeypatch, + framework_type, + module_path: str, + class_name: str, +): + expected_class = _install_runner_module(monkeypatch, module_path, class_name) + detection = DetectionResult( + type=framework_type, + name="demo-agent", + entry_point="demo/agent.py", + package_path="/tmp/demo", + agent_variable="root_agent", + confidence=1.0, + ) + + runner = create_runner(detection, "/workspace/demo") + + assert isinstance(runner, expected_class) + assert runner.detection_result == detection + assert runner.project_dir == "/workspace/demo" + + +def test_create_runner_rejects_unknown_framework(): + detection = DetectionResult( + type=FrameworkType.UNKNOWN, + name="unknown-agent", + entry_point="", + package_path="", + ) + + with pytest.raises(ValueError, match="不支持的框架类型"): + create_runner(detection, "/workspace/demo") + + +def test_create_runner_uses_custom_runner_class(monkeypatch, tmp_path): + runner_class = _install_runner_module(monkeypatch, "demo_agent.runner", "CustomRunner") + detection = DetectionResult( + type=FrameworkType.LANGGRAPH, + name="demo-agent", + entry_point="agent.py", + package_path=str(tmp_path), + agent_variable="root_agent", + runner_class="demo_agent.runner.CustomRunner", + confidence=1.0, + ) + + runner = create_runner(detection, str(tmp_path)) + + assert isinstance(runner, runner_class) + assert runner.detection_result is detection + assert runner.project_dir == str(tmp_path) + + +def test_create_runner_rejects_custom_runner_that_is_not_base_runner(monkeypatch, tmp_path): + fake_module = ModuleType("demo_agent.bad_runner") + + class BadRunner: + pass + + fake_module.BadRunner = BadRunner + monkeypatch.setitem(__import__("sys").modules, "demo_agent.bad_runner", fake_module) + detection = DetectionResult( + type=FrameworkType.LANGGRAPH, + name="demo-agent", + entry_point="agent.py", + package_path=str(tmp_path), + agent_variable="root_agent", + runner_class="demo_agent.bad_runner.BadRunner", + confidence=1.0, + ) + + with pytest.raises(TypeError, match="自定义 Runner 必须继承 BaseRunner"): + create_runner(detection, str(tmp_path)) + + +def test_runners_package_exports_only_create_runner(): + import ksadk.runners as runners + + assert hasattr(runners, "create_runner") + assert set(runners.__all__) == {"BaseRunner", "create_runner"} + + +@pytest.mark.asyncio +async def test_base_runner_close_and_async_context_are_noops(): + detection = _write_detection(FrameworkType.LANGCHAIN) + runner = _StubRunner(detection, "/workspace/demo") + + async with runner as active_runner: + assert active_runner is runner + + assert await runner.close() is None + + +@pytest.mark.asyncio +async def test_adk_runner_close_releases_runtime_toolsets_once(tmp_path): + from ksadk.runners.adk_runner import ADKRunner + + runner = ADKRunner(_write_detection(FrameworkType.ADK), str(tmp_path)) + async_close = _AsyncClosableToolset() + sync_close = _SyncClosableToolset() + async_aclose = _AsyncAClosableToolset() + runner._runtime_toolsets = [async_close, sync_close, async_aclose] + + await runner.close() + await runner.close() + + assert async_close.closed == 1 + assert sync_close.closed == 1 + assert async_aclose.closed == 1 + assert runner._runtime_toolsets == [] + + +@pytest.mark.asyncio +async def test_adk_runner_close_continues_after_toolset_failure(tmp_path, caplog): + from ksadk.runners.adk_runner import ADKRunner + + runner = ADKRunner(_write_detection(FrameworkType.ADK), str(tmp_path)) + failing = _FailingClosableToolset() + ok = _AsyncClosableToolset() + runner._runtime_toolsets = [failing, ok] + + await runner.close() + + assert failing.closed == 1 + assert ok.closed == 1 + assert runner._runtime_toolsets == [] + assert "Failed to close runtime toolset" in caplog.text + + +def test_langchain_runner_prepare_for_request_reloads_agent_when_model_changes( + monkeypatch, + tmp_path, +): + import ksadk.runners.langchain_runner as langchain_runner_module + + loaded_models: list[tuple[str | None, bool]] = [] + + def fake_load_agent_module(project_dir: str, entry_point: str, agent_variable: str, *, force_reload: bool = False): + loaded_models.append((os.getenv("OPENAI_MODEL_NAME"), force_reload)) + return SimpleNamespace(invoke=lambda *args, **kwargs: None), ModuleType("demo.agent") + + monkeypatch.setattr(langchain_runner_module, "load_agent_module", fake_load_agent_module) + monkeypatch.setenv("OPENAI_MODEL_NAME", "glm-5.1") + monkeypatch.setenv("MODEL_NAME", "glm-5.1") + + runner = langchain_runner_module.LangChainRunner( + _write_detection(FrameworkType.LANGCHAIN), + str(tmp_path), + ) + runner.load_agent() + runner.prepare_for_request("gpt-4o") + + assert loaded_models == [("glm-5.1", False), ("gpt-4o", True)] + + +def test_langgraph_runner_prepare_for_request_reloads_agent_when_model_changes( + monkeypatch, + tmp_path, +): + import ksadk.runners.langgraph_runner as langgraph_runner_module + + loaded_models: list[tuple[str | None, bool]] = [] + + def fake_load_agent_module(project_dir: str, entry_point: str, agent_variable: str, *, force_reload: bool = False): + loaded_models.append((os.getenv("OPENAI_MODEL_NAME"), force_reload)) + return SimpleNamespace(invoke=lambda *args, **kwargs: None), ModuleType("demo.agent") + + monkeypatch.setattr(langgraph_runner_module, "load_agent_module", fake_load_agent_module) + monkeypatch.setenv("OPENAI_MODEL_NAME", "glm-5.1") + monkeypatch.setenv("MODEL_NAME", "glm-5.1") + + runner = langgraph_runner_module.LangGraphRunner( + _write_detection(FrameworkType.LANGGRAPH), + str(tmp_path), + ) + runner.load_agent() + runner.prepare_for_request("gpt-4o") + + assert loaded_models == [("glm-5.1", False), ("gpt-4o", True)] + + +def test_adk_runner_prepare_for_request_updates_explicit_model_tree(monkeypatch, tmp_path): + from ksadk.runners.adk_runner import ADKRunner + + class FakeLiteLlm: + def __init__(self, model: str): + self.model = model + + child_agent = SimpleNamespace(model=FakeLiteLlm("openai/glm-5.1"), sub_agents=[]) + root_agent = SimpleNamespace(model=FakeLiteLlm("openai/glm-5.1"), sub_agents=[child_agent]) + + runner = ADKRunner(_write_detection(FrameworkType.ADK), str(tmp_path)) + runner._agent = root_agent + + monkeypatch.delenv("OPENAI_MODEL_NAME", raising=False) + monkeypatch.delenv("MODEL_NAME", raising=False) + + runner.prepare_for_request("gpt-4o") + + assert root_agent.model.model == "openai/gpt-4o" + assert child_agent.model.model == "openai/gpt-4o" + assert os.environ["OPENAI_MODEL_NAME"] == "gpt-4o" + assert os.environ["MODEL_NAME"] == "gpt-4o" + + +def test_adk_runner_prepare_for_request_restores_default_model_when_request_omits_model( + monkeypatch, + tmp_path, +): + from ksadk.runners.adk_runner import ADKRunner + + class FakeLiteLlm: + def __init__(self, model: str): + self.model = model + + child_agent = SimpleNamespace(model=FakeLiteLlm("openai/deepseek-v3.2"), sub_agents=[]) + root_agent = SimpleNamespace(model=FakeLiteLlm("openai/deepseek-v3.2"), sub_agents=[child_agent]) + + runner = ADKRunner(_write_detection(FrameworkType.ADK), str(tmp_path)) + runner._agent = root_agent + runner._default_model_name = "deepseek-v3.2" + runner._default_model_reference = "openai/deepseek-v3.2" + runner._active_model_name = "openai/deepseek-v3.2" + + monkeypatch.setenv("OPENAI_MODEL_NAME", "deepseek-v3.2") + monkeypatch.setenv("MODEL_NAME", "deepseek-v3.2") + + runner.prepare_for_request("dummy") + assert root_agent.model.model == "openai/dummy" + assert child_agent.model.model == "openai/dummy" + + runner.prepare_for_request(None) + + assert root_agent.model.model == "openai/deepseek-v3.2" + assert child_agent.model.model == "openai/deepseek-v3.2" + assert os.environ["OPENAI_MODEL_NAME"] == "deepseek-v3.2" + assert os.environ["MODEL_NAME"] == "deepseek-v3.2" + + +def test_base_runner_run_server_registers_runner(monkeypatch): + recorded: dict[str, Any] = {} + + class _DemoRunner(_StubRunner): + pass + + fake_server_module = ModuleType("ksadk.server") + fake_server_module.app = object() + fake_server_module.set_runner = lambda runner: recorded.setdefault("runner", runner) + + fake_uvicorn_module = ModuleType("uvicorn") + fake_uvicorn_module.run = lambda app, host, port: recorded.update( + {"app": app, "host": host, "port": port} + ) + + monkeypatch.setitem(__import__("sys").modules, "ksadk.server", fake_server_module) + monkeypatch.setitem(__import__("sys").modules, "uvicorn", fake_uvicorn_module) + + detection = DetectionResult( + type=FrameworkType.LANGGRAPH, + name="demo-agent", + entry_point="demo/agent.py", + package_path="/tmp/demo", + ) + runner = _DemoRunner(detection, "/workspace/demo") + + runner.run_server(port=9000) + + assert recorded["runner"] is runner + assert recorded["app"] is fake_server_module.app + assert recorded["host"] == "0.0.0.0" + assert recorded["port"] == 9000 + + +def test_adk_runner_load_agent_does_not_inject_legacy_sandbox_tools_by_default( + monkeypatch, tmp_path +): + import google.adk.runners as adk_runners + + from ksadk.runners.adk_runner import ADKRunner + + detection = _write_adk_project( + tmp_path, + """ + class DemoAgent: + def __init__(self): + self.name = "demo-agent" + self.tools = [] + self.instruction = "Be helpful." + + root_agent = DemoAgent() + """, + ) + + class FakeRunner: + instances: list["FakeRunner"] = [] + + def __init__(self, **kwargs): + self.kwargs = kwargs + FakeRunner.instances.append(self) + + monkeypatch.delenv("KSADK_SKILLS_MODE", raising=False) + monkeypatch.setattr(ADKRunner, "_apply_json_patch", lambda self: None) + monkeypatch.setattr(ADKRunner, "_init_short_term_memory", lambda self: None) + monkeypatch.setattr(ADKRunner, "_init_long_term_memory", lambda self: None) + monkeypatch.setattr(ADKRunner, "_init_knowledge_base", lambda self: None) + monkeypatch.setattr(adk_runners, "Runner", FakeRunner) + runner = ADKRunner(detection, str(tmp_path)) + runner.load_agent() + + assert _tool_names(runner._agent.tools) == [] + assert len(FakeRunner.instances) == 1 + + +def test_adk_runner_load_agent_deduplicates_existing_execute_skills(monkeypatch, tmp_path): + import google.adk.runners as adk_runners + + from ksadk.runners.adk_runner import ADKRunner + + detection = _write_adk_project( + tmp_path, + """ + def execute_skills(workflow_prompt: str) -> dict: + return {"stdout": workflow_prompt} + + def keep_tool(value: str) -> str: + return value + + class DemoAgent: + def __init__(self): + self.name = "demo-agent" + self.tools = [keep_tool, execute_skills] + self.instruction = "Be helpful." + + root_agent = DemoAgent() + """, + ) + + class FakeRunner: + def __init__(self, **kwargs): + self.kwargs = kwargs + + monkeypatch.setenv("KSADK_SKILLS_MODE", "sandbox") + monkeypatch.setenv("KSADK_SKILL_RUNTIME_BACKEND", "disabled") + monkeypatch.setenv("KSADK_SKILL_SPACE_IDS", "ss-1") + monkeypatch.setattr(ADKRunner, "_apply_json_patch", lambda self: None) + monkeypatch.setattr(ADKRunner, "_init_short_term_memory", lambda self: None) + monkeypatch.setattr(ADKRunner, "_init_long_term_memory", lambda self: None) + monkeypatch.setattr(ADKRunner, "_init_knowledge_base", lambda self: None) + monkeypatch.setattr(adk_runners, "Runner", FakeRunner) + + runner = ADKRunner(detection, str(tmp_path)) + runner.load_agent() + + tool_names = _tool_names(runner._agent.tools) + assert tool_names.count("execute_skills") == 1 + assert "keep_tool" in tool_names + + +def test_adk_runner_load_agent_skips_skill_runtime_when_not_in_sandbox_mode( + monkeypatch, tmp_path +): + import google.adk.runners as adk_runners + + from ksadk.runners.adk_runner import ADKRunner + + detection = _write_adk_project( + tmp_path, + """ + class DemoAgent: + def __init__(self): + self.name = "demo-agent" + self.tools = [] + self.instruction = "Be helpful." + + root_agent = DemoAgent() + """, + ) + + class FakeRunner: + def __init__(self, **kwargs): + self.kwargs = kwargs + + monkeypatch.setenv("KSADK_SKILLS_MODE", "local") + monkeypatch.setattr(ADKRunner, "_apply_json_patch", lambda self: None) + monkeypatch.setattr(ADKRunner, "_init_short_term_memory", lambda self: None) + monkeypatch.setattr(ADKRunner, "_init_long_term_memory", lambda self: None) + monkeypatch.setattr(ADKRunner, "_init_knowledge_base", lambda self: None) + monkeypatch.setattr(adk_runners, "Runner", FakeRunner) + + runner = ADKRunner(detection, str(tmp_path)) + runner.load_agent() + + assert _tool_names(runner._agent.tools) == [] + + +def test_adk_runner_build_adk_content_supports_inline_and_reference_attachments(tmp_path): + from ksadk.runners.adk_runner import ADKRunner + + detection = SimpleNamespace( + entry_point="agent.py", + agent_variable="root_agent", + name="demo-agent", + ) + runner = ADKRunner(detection, str(tmp_path)) + archive_path = tmp_path / "bundle.zip" + archive_path.write_bytes(b"PK\x03\x04demo-zip") + + content = runner._build_adk_content( + "请总结附件", + [ + { + "display_name": "notes.txt", + "mime_type": "text/plain", + "transport": "inline", + "data": base64.b64encode("候选人简历内容".encode("utf-8")).decode("ascii"), + }, + { + "display_name": "bundle.zip", + "mime_type": "application/zip", + "transport": "reference", + "file_uri": "ksadk-upload://abc123", + "storage_path": str(archive_path), + }, + ], + ) + + assert content.parts[0].text == "请总结附件" + assert content.parts[1].inline_data.data == "候选人简历内容".encode("utf-8") + assert content.parts[2].inline_data.data == b"PK\x03\x04demo-zip" + + +def test_adk_runner_build_adk_content_does_not_read_arbitrary_local_file_uri(tmp_path): + from ksadk.runners.adk_runner import ADKRunner + + detection = SimpleNamespace( + entry_point="agent.py", + agent_variable="root_agent", + name="demo-agent", + ) + runner = ADKRunner(detection, str(tmp_path)) + + secret_path = tmp_path / "secret.txt" + secret_path.write_text("should-not-leak", encoding="utf-8") + + content = runner._build_adk_content( + "请分析附件", + [ + { + "display_name": "secret.txt", + "mime_type": "text/plain", + "transport": "reference", + "file_uri": f"local:{secret_path}", + } + ], + ) + + assert len(content.parts) == 1 + assert content.parts[0].text == "请分析附件" + + +def test_adk_runner_build_adk_content_skips_images_for_text_only_models(tmp_path): + from ksadk.runners.adk_runner import ADKRunner + + detection = SimpleNamespace( + entry_point="agent.py", + agent_variable="root_agent", + name="demo-agent", + ) + runner = ADKRunner(detection, str(tmp_path)) + + content = runner._build_adk_content( + "请分析这张图", + [ + { + "display_name": "diagram.png", + "mime_type": "image/png", + "transport": "inline", + "data": base64.b64encode(b"fake-png-bytes").decode("ascii"), + } + ], + model_metadata={ + "capabilities": { + "multimodal_input_image": False, + } + }, + ) + + assert len(content.parts) == 2 + assert content.parts[0].text == "请分析这张图" + assert "当前模型不支持图片输入" in content.parts[1].text + + +@pytest.mark.asyncio +async def test_adk_runner_invoke_forwards_attachment_results_via_state_delta(tmp_path, monkeypatch): + from google.genai import types + from ksadk.runners.adk_runner import ADKRunner + + detection = SimpleNamespace( + entry_point="agent.py", + agent_variable="root_agent", + name="demo-agent", + ) + runner = ADKRunner(detection, str(tmp_path)) + runner._agent = SimpleNamespace(name="demo-agent") + + captured: dict[str, Any] = {} + + class _FakeRunner: + async def run_async(self, *, session_id, user_id, new_message, state_delta=None, run_config=None): + captured["session_id"] = session_id + captured["user_id"] = user_id + captured["new_message"] = new_message + captured["state_delta"] = state_delta + yield SimpleNamespace(content=SimpleNamespace(parts=[types.Part(text="ok")])) + + async def _fake_ensure_session(external_session_id=None): + return "adk-session-1" + + monkeypatch.setattr(runner, "_ensure_session", _fake_ensure_session) + monkeypatch.setattr(runner, "_prepare_trace_metadata", lambda session_id: ("", [], "", "demo-agent")) + runner._runner = _FakeRunner() + + result = await runner.invoke( + { + "session_id": "external-session", + "input": "请分析附件", + "attachments": [], + "input_parts": [{"text": "请分析附件"}], + "attachment_results": [{"display_name": "resume.pdf", "kind": "document"}], + "current_attachments": [], + "current_attachment_results": [{"display_name": "resume.pdf", "kind": "document"}], + "has_current_files": True, + } + ) + + assert result["output"] == "ok" + assert captured["session_id"] == "adk-session-1" + assert captured["state_delta"] == { + "input_parts": [{"text": "请分析附件"}], + "attachments": [], + "attachment_results": [{"display_name": "resume.pdf", "kind": "document"}], + "current_attachments": [], + "current_attachment_results": [{"display_name": "resume.pdf", "kind": "document"}], + "has_current_files": True, + } diff --git a/tests/test_runtime_common_memory_backend.py b/tests/test_runtime_common_memory_backend.py new file mode 100644 index 00000000..e54b0767 --- /dev/null +++ b/tests/test_runtime_common_memory_backend.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +import importlib + +import pytest + + +VALID_MEM0_UUID = "e52b7fac-e641-4b34-b9f7-6b0b9f190cd4" + + +def _memory_backend_module(): + return importlib.import_module("ksadk_runtime_common.memory_backend") + + +def _manifest_module(): + return importlib.import_module("ksadk_runtime_common.memory_backend.manifest") + + +def test_runtime_common_package_is_importable(): + module = importlib.import_module("ksadk_runtime_common") + + assert hasattr(module, "create_workspace_files_router") + assert hasattr(module, "workspace_files_enabled") + + +def test_render_openclaw_default_manifest_returns_empty_patch(): + memory_backend = _memory_backend_module() + + result = memory_backend.render_memory_backend_config( + { + "schema_version": "v1", + "backend_type": "openclaw_default", + } + ) + + assert result.model_dump() == { + "backend_type": "openclaw_default", + "config_patch": {}, + "required_env": [], + "plugin_ids": [], + "disabled_plugin_ids": ["openclaw-mem0"], + "clear_plugin_slots": ["memory"], + } + + +def test_render_mem0_manifest_requires_runtime_env(monkeypatch): + memory_backend = _memory_backend_module() + monkeypatch.delenv("MEM0_API_KEY", raising=False) + monkeypatch.delenv("MEM0_USER_ID", raising=False) + monkeypatch.delenv("MEM0_BASE_URL", raising=False) + + with pytest.raises(ValueError, match="MEM0_API_KEY"): + memory_backend.render_memory_backend_config( + { + "schema_version": "v1", + "backend_type": "mem0", + "config": { + "mem0_instance_id": VALID_MEM0_UUID, + }, + } + ) + + +def test_render_mem0_manifest_to_openclaw_patch(monkeypatch): + memory_backend = _memory_backend_module() + monkeypatch.setenv( + "MEM0_API_KEY", + f"2000104981.{VALID_MEM0_UUID}:mem0-secret", + ) + monkeypatch.setenv("MEM0_USER_ID", "2000104981") + monkeypatch.setenv("MEM0_BASE_URL", "http://mem-service.sdns.ksyun.com") + + result = memory_backend.render_memory_backend_config( + { + "schema_version": "v1", + "backend_type": "mem0", + "config": { + "mem0_instance_id": VALID_MEM0_UUID, + "mem0_region": "cn-qingyangtest-1", + }, + "secrets_env": { + "api_key": "MEM0_API_KEY", + "user_id": "MEM0_USER_ID", + "base_url": "MEM0_BASE_URL", + }, + } + ) + + assert result.model_dump() == { + "backend_type": "mem0", + "config_patch": { + "plugins": { + "slots": { + "memory": "openclaw-mem0", + }, + "entries": { + "openclaw-mem0": { + "enabled": True, + "config": { + "mode": "platform", + "apiKey": f"2000104981.{VALID_MEM0_UUID}:mem0-secret", + "baseUrl": "http://mem-service.sdns.ksyun.com", + "userId": "2000104981", + }, + }, + }, + } + }, + "required_env": ["MEM0_API_KEY", "MEM0_USER_ID", "MEM0_BASE_URL"], + "plugin_ids": ["openclaw-mem0"], + "disabled_plugin_ids": [], + "clear_plugin_slots": [], + } + + +def test_manifest_model_instances_are_revalidated_against_schema(): + memory_backend = _memory_backend_module() + manifest_module = _manifest_module() + manifest = manifest_module.MemoryBackendManifest( + schema_version="v1", + backend_type="mem0", + config={"mem0_instance_id": "not-a-uuid"}, + ) + + with pytest.raises(ValueError, match="mem0_instance_id"): + memory_backend.render_memory_backend_config(manifest) diff --git a/tests/test_runtime_common_packaging.py b/tests/test_runtime_common_packaging.py index 2719031a..4a31a285 100644 --- a/tests/test_runtime_common_packaging.py +++ b/tests/test_runtime_common_packaging.py @@ -1,6 +1,6 @@ from pathlib import Path -import tomllib import zipfile +import tomllib REPO_ROOT = Path(__file__).resolve().parents[1] @@ -13,16 +13,35 @@ def test_pyproject_uses_in_repo_runtime_common_source_package(): assert "ksadk_runtime_common*" in pyproject -def test_pyproject_declares_python_multipart_for_local_web_ui_uploads(): - pyproject = (REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8") +def test_runtime_common_workspace_router_is_python310_compatible(tmp_path: Path): + router_source = (REPO_ROOT / "ksadk_runtime_common" / "workspace_files" / "router.py").read_text( + encoding="utf-8" + ) + assert "from datetime import UTC" not in router_source + assert "datetime.UTC" not in router_source - assert "python-multipart>=0.0.9,<1.0.0" in pyproject + from ksadk_runtime_common.workspace_files.router import _isoformat_timestamp + target = tmp_path / "demo.txt" + target.write_text("ok", encoding="utf-8") -def test_pyproject_declares_python_socks_for_openclaw_gateway_proxy_support(): - pyproject = (REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8") + assert _isoformat_timestamp(target).endswith("Z") - assert "python-socks>=2.7.1,<3.0.0" in pyproject + +def test_distributed_python_sources_do_not_use_python311_datetime_utc(): + package_roots = [ + REPO_ROOT / "ksadk", + REPO_ROOT / "ksadk_runtime_common", + ] + offenders: list[str] = [] + + for package_root in package_roots: + for source_path in package_root.rglob("*.py"): + source = source_path.read_text(encoding="utf-8") + if "from datetime import UTC" in source or "datetime.UTC" in source: + offenders.append(str(source_path.relative_to(REPO_ROOT))) + + assert offenders == [] def test_built_wheel_excludes_web_ui_node_modules(): @@ -72,10 +91,22 @@ def test_pyproject_keeps_only_synced_static_as_ksadk_web_package_data(): assert all("server/web-ui" not in entry for entry in package_data) -def test_pyproject_declares_kingsoftcloud_sdk_in_kb_extra(): +def test_pyproject_declares_python_multipart_for_local_web_ui_uploads(): + pyproject = (REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8") + + assert "python-multipart>=0.0.9,<1.0.0" in pyproject + + +def test_pyproject_declares_python_socks_for_openclaw_gateway_proxy_support(): + pyproject = (REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8") + + assert "python-socks>=2.7.1,<3.0.0" in pyproject + + +def test_pyproject_declares_kingsoftcloud_sdk_as_default_dependency(): pyproject = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8")) - assert "kingsoftcloud-sdk-python>=1.5.8.94" in pyproject["project"]["optional-dependencies"]["kb"] + assert "kingsoftcloud-sdk-python>=1.5.8.94" in pyproject["project"]["dependencies"] def test_pyproject_declares_asyncpg_for_postgres_session_backend(): @@ -110,11 +141,11 @@ def test_repo_root_dockerignore_excludes_local_build_artifacts(): assert entry in dockerignore -def test_public_makefile_keeps_runtime_image_building_out_of_repo_root(): +def test_makefile_delegates_runtime_image_builds_to_agentengine_images_repo(): makefile = (REPO_ROOT / "Makefile").read_text(encoding="utf-8") - assert "OPENCLAW_CONTEXT := ." not in makefile - assert "HERMES_CONTEXT := ." not in makefile + assert "AGENTENGINE_IMAGES_DIR ?= ../agentengine-images" in makefile + assert "$(MAKE) -C \"$(AGENTENGINE_IMAGES_DIR)\" $@" in makefile assert "-f deploy/openclaw/Dockerfile" not in makefile assert "-f deploy/hermes/Dockerfile" not in makefile diff --git a/tests/test_sandbox_backend.py b/tests/test_sandbox_backend.py new file mode 100644 index 00000000..5a77316e --- /dev/null +++ b/tests/test_sandbox_backend.py @@ -0,0 +1,217 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from ksadk.sandbox import ( + E2BSandboxBackend, + SandboxCommandResult, + SandboxError, + SandboxInputFile, + SandboxSpec, + SandboxType, + create_sandbox_backend, +) + + +def test_sandbox_factory_creates_e2b_backend(monkeypatch): + monkeypatch.setenv("KSADK_SANDBOX_BACKEND", "e2b") + monkeypatch.setenv("KSADK_SANDBOX_TEMPLATE_ID", "tpl-aio") + + backend = create_sandbox_backend(sandbox_cls=object) + + assert isinstance(backend, E2BSandboxBackend) + assert backend.spec.template_id == "tpl-aio" + + +def test_sandbox_factory_supports_runtime_template_alias(monkeypatch): + monkeypatch.setenv("KSADK_SANDBOX_BACKEND", "e2b") + monkeypatch.delenv("KSADK_SANDBOX_TEMPLATE_ID", raising=False) + monkeypatch.setenv("KSADK_SKILL_RUNTIME_TEMPLATE_ID", "tpl-skill") + + backend = create_sandbox_backend(sandbox_cls=object) + + assert isinstance(backend, E2BSandboxBackend) + assert backend.spec.template_id == "tpl-skill" + + +def test_e2b_sandbox_backend_create_write_run_and_kill(tmp_path: Path): + calls: list[tuple[str, object]] = [] + source = tmp_path / "input.txt" + source.write_text("hello", encoding="utf-8") + + class FakeResult: + stdout = "ok\n" + stderr = "" + exit_code = 0 + + class FakeFiles: + def write(self, path, data): + calls.append(("file_write", (path, data))) + + class FakeCommands: + def run(self, command: str, **kwargs): + calls.append(("run", command)) + calls.append(("run_kwargs", kwargs)) + return FakeResult() + + class FakeSandbox: + sandbox_id = "sbx-123" + + def __init__(self): + self.files = FakeFiles() + self.commands = FakeCommands() + + @classmethod + def create(cls, **kwargs): + calls.append(("create", kwargs)) + return cls() + + def kill(self): + calls.append(("kill", self.sandbox_id)) + + backend = E2BSandboxBackend( + spec=SandboxSpec( + template_id="tpl-aio", + sandbox_type=SandboxType.AIO, + timeout=123, + allow_internet_access=True, + metadata={"purpose": "test"}, + env={"BASE_ENV": "1"}, + ), + sandbox_cls=FakeSandbox, + ) + + session = backend.create_session( + session_id="sess-1", + env={"REQUEST_ENV": "2"}, + input_files=[SandboxInputFile(source=source, target_path="/tmp/input.txt")], + ) + result = session.run_command("python -V", timeout=30, env={"REQUEST_ENV": "command"}) + session.kill() + + assert result == SandboxCommandResult(stdout="ok\n", stderr="", exit_code=0) + assert calls[0] == ( + "create", + { + "template": "tpl-aio", + "timeout": 123, + "metadata": { + "runtime": "ksadk", + "sandbox_type": "aio", + "purpose": "test", + "session_id": "sess-1", + }, + "envs": {"BASE_ENV": "1", "REQUEST_ENV": "2"}, + "allow_internet_access": True, + }, + ) + assert ("file_write", ("/tmp/input.txt", b"hello")) in calls + assert ("run", "python -V") in calls + assert ("run_kwargs", {"timeout": 30, "envs": {"REQUEST_ENV": "command"}}) in calls + assert calls[-1] == ("kill", "sbx-123") + + +def test_e2b_sandbox_backend_waits_for_startup_command_readiness(monkeypatch): + monkeypatch.setattr("ksadk.sandbox.backends.e2b.time.sleep", lambda _seconds: None) + calls: list[str] = [] + + class NotFoundException(Exception): + pass + + class FakeResult: + stdout = "ready\n" + stderr = "" + exit_code = 0 + + class FakeCommands: + def run(self, command: str, **kwargs): + calls.append(command) + if len(calls) == 1: + raise NotFoundException() + return FakeResult() + + class FakeFiles: + def write(self, path: str, data: str | bytes): + pass + + class FakeSandbox: + sandbox_id = "sbx-123" + def __init__(self): + self.commands = FakeCommands() + self.files = FakeFiles() + + @classmethod + def create(cls, **kwargs): + return cls() + + backend = E2BSandboxBackend( + spec=SandboxSpec(template_id="tpl-aio"), + sandbox_cls=FakeSandbox, + ) + e2b_session = backend.create_session(session_id="sess-1") + + result = e2b_session.run_command("python -V") + + assert result == SandboxCommandResult(stdout="ready\n", stderr="", exit_code=0) + assert calls == ["true", "true", "python -V"] + + +def test_e2b_sandbox_backend_waits_for_startup_filesystem_readiness(monkeypatch): + monkeypatch.setattr("ksadk.sandbox.backends.e2b.time.sleep", lambda _seconds: None) + calls: list[tuple[str, str | bytes]] = [] + + class FileNotFoundException(Exception): + pass + + class FakeResult: + stdout = "" + stderr = "" + exit_code = 0 + + class FakeCommands: + def run(self, command: str, **kwargs): + return FakeResult() + + class FakeFiles: + def write(self, path: str, data: str | bytes): + calls.append((path, data)) + if len(calls) == 1: + raise FileNotFoundException() + + class FakeSandbox: + sandbox_id = "sbx-123" + def __init__(self): + self.commands = FakeCommands() + self.files = FakeFiles() + + @classmethod + def create(cls, **kwargs): + return cls() + + source = Path(__file__) + backend = E2BSandboxBackend( + spec=SandboxSpec(template_id="tpl-aio"), + sandbox_cls=FakeSandbox, + ) + backend.create_session( + session_id="sess-1", + input_files=[SandboxInputFile(source=source, target_path="/tmp/input.txt")], + ) + + assert calls[0] == ("/tmp/.ksadk-sandbox-ready", "") + assert calls[1] == ("/tmp/.ksadk-sandbox-ready", "") + assert calls[2][0] == "/tmp/input.txt" + + +def test_e2b_sandbox_backend_requires_template_id(): + with pytest.raises(SandboxError, match="template id"): + E2BSandboxBackend(spec=SandboxSpec(template_id="")) + + +def test_sandbox_type_parses_console_types(): + assert SandboxType.from_value("All-in-one") is SandboxType.AIO + assert SandboxType.from_value("CodeInterpreter") is SandboxType.CODE + assert SandboxType.from_value("Browser") is SandboxType.BROWSER + assert SandboxType.from_value("Private") is SandboxType.PRIVATE diff --git a/tests/test_server_app_fastapi_compat.py b/tests/test_server_app_fastapi_compat.py new file mode 100644 index 00000000..8c760338 --- /dev/null +++ b/tests/test_server_app_fastapi_compat.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +import importlib +import sys + +import fastapi + + +def test_server_app_imports_when_fastapi_removes_add_event_handler(monkeypatch): + original_fastapi = fastapi.FastAPI + + class FastAPIWithoutAddEventHandler(original_fastapi): + def __getattribute__(self, name): + if name == "add_event_handler": + raise AttributeError( + "'FastAPI' object has no attribute 'add_event_handler'" + ) + return super().__getattribute__(name) + + monkeypatch.setattr(fastapi, "FastAPI", FastAPIWithoutAddEventHandler) + sys.modules.pop("ksadk.server", None) + sys.modules.pop("ksadk.server.app", None) + + module = importlib.import_module("ksadk.server.app") + + assert module.app is not None + diff --git a/tests/test_server_file_upload_parsing.py b/tests/test_server_file_upload_parsing.py new file mode 100644 index 00000000..a391c912 --- /dev/null +++ b/tests/test_server_file_upload_parsing.py @@ -0,0 +1,129 @@ +import base64 + +from ksadk.server.api_models import FileData, InlineData, Part +from ksadk.server.app import _attachment_from_part, _extract_user_input_from_parts + + +def test_extract_user_input_from_text_part(): + parts = [Part(text="看下这个候选人简历")] + text = _extract_user_input_from_parts(parts) + assert text == "看下这个候选人简历" + + +def test_extract_user_input_from_inline_text_file(): + content = "张三\n8年经验\n熟悉LangGraph" + encoded = base64.b64encode(content.encode("utf-8")).decode("ascii") + parts = [ + Part( + inlineData=InlineData( + data=encoded, + mimeType="text/plain", + displayName="张三.txt", + ) + ) + ] + + text = _extract_user_input_from_parts(parts) + assert "[上传文件: 张三.txt]" in text + assert "8年经验" in text + + +def test_extract_user_input_from_binary_file_keeps_metadata(): + encoded = base64.b64encode(b"\x89PNG\r\n").decode("ascii") + parts = [ + Part( + inlineData=InlineData( + data=encoded, + mimeType="image/png", + displayName="avatar.png", + ) + ) + ] + + text = _extract_user_input_from_parts(parts) + assert "avatar.png" in text + assert "image/png" in text + + +def test_extract_user_input_from_file_reference(): + parts = [ + Part( + fileData=FileData( + fileUri="ks3://bucket/path/a.txt", + mimeType="text/plain", + displayName="a.txt", + ) + ) + ] + + text = _extract_user_input_from_parts(parts) + assert "上传文件引用" in text + assert "a.txt" in text + + +def test_extract_user_input_from_local_file_reference_outside_uploads_dir_keeps_reference_only(tmp_path): + attachment_path = tmp_path / "resume.txt" + attachment_path.write_text("张三\n8年经验\n熟悉LangGraph", encoding="utf-8") + parts = [ + Part( + fileData=FileData( + fileUri=f"local:{attachment_path}", + mimeType="text/plain", + displayName="resume.txt", + ) + ) + ] + + text = _extract_user_input_from_parts(parts) + assert "上传文件引用" in text + assert "resume.txt" in text + assert "8年经验" not in text + + +def test_extract_user_input_from_opaque_upload_handle_reads_text(monkeypatch, tmp_path): + ui_dir = tmp_path / ".agentengine" / "ui" + uploads_dir = ui_dir / "files" + uploads_dir.mkdir(parents=True) + stored_file = uploads_dir / "abc123.txt" + stored_file.write_text("候选人简历内容\n熟悉DeepAgents", encoding="utf-8") + monkeypatch.setenv("AGENTENGINE_UI_DIR", str(ui_dir)) + + parts = [ + Part( + fileData=FileData( + fileUri="ksadk-upload://abc123", + mimeType="text/plain", + displayName="resume.txt", + ) + ) + ] + + text = _extract_user_input_from_parts(parts) + assert "[上传文件: resume.txt]" in text + assert "候选人简历内容" in text + + +def test_attachment_from_part_resolves_storage_path_for_upload_handle(monkeypatch, tmp_path): + ui_dir = tmp_path / ".agentengine" / "ui" + uploads_dir = ui_dir / "files" + uploads_dir.mkdir(parents=True) + stored_file = uploads_dir / "abc123.txt" + stored_file.write_text("hello", encoding="utf-8") + monkeypatch.setenv("AGENTENGINE_UI_DIR", str(ui_dir)) + + attachment = _attachment_from_part( + Part( + fileData=FileData( + fileUri="ksadk-upload://abc123", + mimeType="text/plain", + displayName="resume.txt", + ) + ) + ) + + assert attachment is not None + assert attachment["transport"] == "reference" + assert attachment["file_uri"] == "ksadk-upload://abc123" + assert attachment["storage_path"] == str(stored_file) + assert attachment["size_bytes"] == 5 + assert attachment["is_text"] is True diff --git a/tests/test_server_workspace_preview_security.py b/tests/test_server_workspace_preview_security.py new file mode 100644 index 00000000..d74d86bd --- /dev/null +++ b/tests/test_server_workspace_preview_security.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +import io +import importlib +import zipfile +from pathlib import Path + +from fastapi.testclient import TestClient + +appmod = importlib.import_module("ksadk.server.app") + + +def _client_with_workspace(monkeypatch, tmp_path: Path) -> tuple[TestClient, Path]: + session_dir = tmp_path / "session" + workspace = session_dir / "workspace" + workspace.mkdir(parents=True) + monkeypatch.setattr(appmod, "resolve_local_session_dir", lambda: session_dir) + return TestClient(appmod.app), workspace + + +def test_workspace_html_route_applies_sandbox_csp(monkeypatch, tmp_path: Path): + client, workspace = _client_with_workspace(monkeypatch, tmp_path) + (workspace / "index.html").write_text("ok", encoding="utf-8") + + response = client.get("/agentengine/api/v1/ws/agent-1/index.html") + + assert response.status_code == 200 + csp = response.headers.get("content-security-policy", "") + assert "sandbox allow-scripts allow-downloads" in csp + assert "connect-src 'none'" in csp + assert "script-src 'unsafe-inline' 'unsafe-eval' 'self' https:" in csp + assert "img-src data: blob: 'self' https:" in csp + assert '' in response.text + assert "data-ksadk-preview-anchor-handler" in response.text + + +def test_export_workspace_zip_does_not_follow_symlink_escape(monkeypatch, tmp_path: Path): + client, workspace = _client_with_workspace(monkeypatch, tmp_path) + (workspace / "safe.txt").write_text("safe", encoding="utf-8") + outside_secret = tmp_path / "secret.txt" + outside_secret.write_text("secret", encoding="utf-8") + (workspace / "leak.txt").symlink_to(outside_secret) + + response = client.get("/agentengine/api/v1/ExportWorkspaceZip") + + assert response.status_code == 200 + with zipfile.ZipFile(io.BytesIO(response.content)) as archive: + names = set(archive.namelist()) + assert "safe.txt" in names + assert "leak.txt" not in names diff --git a/tests/test_session_continuity.py b/tests/test_session_continuity.py new file mode 100644 index 00000000..5f9855a3 --- /dev/null +++ b/tests/test_session_continuity.py @@ -0,0 +1,178 @@ +from __future__ import annotations + +import sqlite3 +from pathlib import Path +from types import SimpleNamespace + +import httpx +import pytest + +from ksadk.runners.base_runner import BaseRunner +from ksadk.sessions.local_service import LocalSessionService + + +class _ContinuityRunner(BaseRunner): + def __init__(self): + super().__init__( + detection_result=SimpleNamespace( + name="demo-agent", + type=SimpleNamespace(value="langchain"), + ), + project_dir=".", + ) + + def load_agent(self) -> None: + return None + + async def invoke(self, input_data: dict) -> dict: + return {"output": "ok"} + + async def stream(self, input_data: dict): + yield {"type": "final", "output": "ok"} + + +@pytest.mark.asyncio +async def test_local_session_service_migrates_legacy_tables_to_namespaced_schema(tmp_path): + db_path = tmp_path / "sessions.sqlite" + connection = sqlite3.connect(db_path) + connection.executescript( + """ + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + agent_id TEXT NOT NULL, + user_id TEXT NOT NULL, + title TEXT NOT NULL DEFAULT '', + title_source TEXT NOT NULL DEFAULT '', + summary TEXT NOT NULL DEFAULT '', + first_prompt TEXT NOT NULL DEFAULT '', + last_prompt TEXT NOT NULL DEFAULT '', + state_json TEXT NOT NULL DEFAULT '{}', + created_at REAL NOT NULL, + updated_at REAL NOT NULL, + version INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE events ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + author TEXT NOT NULL, + event_type TEXT NOT NULL, + content_json TEXT NOT NULL DEFAULT '{}', + timestamp REAL NOT NULL, + state_delta_json TEXT NOT NULL DEFAULT '{}', + seq_id INTEGER NOT NULL, + invocation_id TEXT, + metadata_json TEXT NOT NULL DEFAULT '{}' + ); + CREATE TABLE states ( + scope TEXT NOT NULL, + agent_id TEXT NOT NULL, + user_id TEXT NOT NULL DEFAULT '', + session_id TEXT NOT NULL DEFAULT '', + state_json TEXT NOT NULL DEFAULT '{}', + version INTEGER NOT NULL DEFAULT 0, + updated_at REAL NOT NULL, + PRIMARY KEY (scope, agent_id, user_id, session_id) + ); + INSERT INTO sessions ( + id, agent_id, user_id, title, title_source, summary, first_prompt, last_prompt, + state_json, created_at, updated_at, version + ) VALUES ( + 'sess-1', 'demo-agent', 'user', 'old title', 'heuristic', 'summary', + 'first', 'last', '{\"topic\": \"billing\"}', 1, 2, 3 + ); + INSERT INTO events ( + id, session_id, author, event_type, content_json, timestamp, state_delta_json, seq_id, invocation_id, metadata_json + ) VALUES ( + 'evt-1', 'sess-1', 'user', 'user_message', '{\"role\": \"user\", \"parts\": [{\"text\": \"hello\"}]}', + 1, '{}', 1, NULL, '{}' + ); + INSERT INTO states ( + scope, agent_id, user_id, session_id, state_json, version, updated_at + ) VALUES ( + 'session', 'demo-agent', 'user', 'sess-1', '{\"topic\": \"billing\"}', 1, 2 + ); + """ + ) + connection.commit() + connection.close() + + service = LocalSessionService(db_path=db_path) + session = await service.get_session("sess-1") + + assert session is not None + assert session.state == {"topic": "billing"} + assert [event.id for event in session.events] == ["evt-1"] + + tables = { + row[0] + for row in sqlite3.connect(db_path).execute( + "SELECT name FROM sqlite_master WHERE type='table'" + ).fetchall() + } + assert "ksadk_sessions" in tables + assert "ksadk_events" in tables + assert "ksadk_states" in tables + + +@pytest.mark.asyncio +async def test_get_session_action_exposes_continuity_metadata(monkeypatch, tmp_path): + server_app_module = __import__("ksadk.server.app", fromlist=["app"]) + service = LocalSessionService(db_path=tmp_path / "sessions.sqlite") + await service.create_session("demo-agent", "user", session_id="sess-1") + await service.update_session_metadata( + "sess-1", + title="hello", + title_source="heuristic", + summary="assistant says hi", + first_prompt="hello", + last_prompt="hello", + ) + runner = _ContinuityRunner() + + monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service) + server_app_module.set_runner(runner) + + transport = httpx.ASGITransport(app=server_app_module.app) + async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client: + response = await client.post( + "/agentengine/api/v1/GetSession", + json={"SessionId": "sess-1"}, + ) + + assert response.status_code == 200 + continuity = response.json()["Data"]["Session"]["Continuity"] + assert continuity["Level"] == "semantic" + assert continuity["Path"] == "replay" + assert continuity["Runner"] == "langchain" + + +@pytest.mark.asyncio +async def test_bootstrap_exposes_session_backend_diagnostics(monkeypatch): + server_app_module = __import__("ksadk.server.app", fromlist=["app"]) + runner = _ContinuityRunner() + server_app_module.set_runner(runner) + monkeypatch.setattr( + server_app_module, + "describe_session_backend", + lambda: { + "Backend": "postgres", + "Shared": True, + "ProductionSafe": True, + "ContinuityDefault": "semantic/replay", + }, + ) + + transport = httpx.ASGITransport(app=server_app_module.app) + async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client: + response = await client.post( + "/agentengine/api/v1/GetAgentUiBootstrap", + json={"AgentId": "demo-agent"}, + ) + + assert response.status_code == 200 + session_backend = response.json()["Data"]["SessionBackend"] + assert session_backend["Backend"] == "postgres" + assert session_backend["Shared"] is True + assert session_backend["ProductionSafe"] is True + assert session_backend["ContinuityDefault"] == "semantic/replay" + assert "Dsn" not in session_backend diff --git a/tests/test_session_title.py b/tests/test_session_title.py new file mode 100644 index 00000000..21a130aa --- /dev/null +++ b/tests/test_session_title.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import pytest + +from ksadk.conversations.session_title import ( + SessionTitleClient, + build_heuristic_title, + build_session_title_messages, +) + + +@pytest.mark.asyncio +async def test_session_title_client_disables_thinking_for_fast_title_generation(monkeypatch): + captured_payload: dict = {} + + class _Response: + def raise_for_status(self) -> None: + return None + + def json(self) -> dict: + return { + "choices": [{"message": {"content": "能力介绍"}}], + "usage": {"total_tokens": 8}, + } + + class _AsyncClient: + def __init__(self, *, timeout): + self.timeout = timeout + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def post(self, url, *, headers, json): + captured_payload.update(json) + return _Response() + + monkeypatch.setattr("ksadk.conversations.session_title.httpx.AsyncClient", _AsyncClient) + + client = SessionTitleClient(api_base="https://models.example/v1", api_key="sk-test") + title, usage = await client.generate_title( + model="glm-5.1", + messages=[{"role": "user", "content": "你好"}], + timeout_ms=1000, + ) + + assert title == "能力介绍" + assert usage == {"total_tokens": 8} + assert captured_payload["stream"] is False + assert captured_payload["temperature"] == 0 + assert "reasoning_effort" not in captured_payload + assert captured_payload["extra_body"]["max_reasoning_tokens"] == 0 + assert "thinking" not in captured_payload["extra_body"] + + +def test_session_title_helpers_strip_inline_think_markup(): + title = build_heuristic_title( + first_prompt="你好,请介绍一下你自己", + assistant_text="先判断身份。我是招聘助手,可以筛选简历。", + ) + messages = build_session_title_messages( + first_prompt="你好,请介绍一下你自己", + assistant_text="先判断身份。我是招聘助手,可以筛选简历。", + ) + + assert title == "招聘助手能力" + assert " ADKRunner: + detection = SimpleNamespace(entry_point="agent.py", agent_variable="root_agent") + return ADKRunner(detection, "/tmp/test-project") + + +def test_platform_session_service_prefers_ksadk_stm_path(monkeypatch, tmp_path): + target = tmp_path / "shared-sessions.sqlite" + monkeypatch.delenv("AGENTENGINE_SESSION_BACKEND", raising=False) + monkeypatch.delenv("AGENTENGINE_UI_DIR", raising=False) + monkeypatch.setenv("KSADK_STM_BACKEND", "sqlite") + monkeypatch.setenv("KSADK_STM_PATH", str(target)) + + service = create_session_service() + + assert isinstance(service, LocalSessionService) + assert service.db_path == target.resolve() + + +def test_platform_session_service_supports_memory_backend(monkeypatch): + monkeypatch.setenv("KSADK_SESSION_BACKEND", "memory") + + service = create_session_service() + + assert isinstance(service, InMemorySessionService) + + +def test_platform_session_service_treats_local_as_sqlite(monkeypatch, tmp_path): + target = tmp_path / "sessions.sqlite" + monkeypatch.delenv("AGENTENGINE_SESSION_BACKEND", raising=False) + monkeypatch.setenv("KSADK_SESSION_BACKEND", "local") + monkeypatch.setenv("KSADK_SESSION_PATH", str(target)) + + service = create_session_service() + + assert isinstance(service, LocalSessionService) + assert service.db_path == target.resolve() + + +def test_platform_session_service_accepts_sqlite_alias(monkeypatch, tmp_path): + target = tmp_path / "sessions.sqlite" + monkeypatch.delenv("AGENTENGINE_SESSION_BACKEND", raising=False) + monkeypatch.setenv("KSADK_SESSION_BACKEND", "sqlite") + monkeypatch.setenv("KSADK_SESSION_PATH", str(target)) + + service = create_session_service() + + assert isinstance(service, LocalSessionService) + assert service.db_path == target.resolve() + + +def test_platform_session_service_requires_postgres_dsn(monkeypatch): + monkeypatch.setenv("KSADK_SESSION_BACKEND", "postgres") + monkeypatch.delenv("KSADK_SESSION_DSN", raising=False) + monkeypatch.delenv("KSADK_STM_URL", raising=False) + monkeypatch.delenv("KSADK_STM_DB_URL", raising=False) + + with pytest.raises(ValueError, match="KSADK_SESSION_DSN"): + create_session_service() + + +def test_describe_session_backend_marks_postgres_as_shared(monkeypatch): + dsn = "".join( + [ + "postgresql://", + "user", + ":", + "pass", + "@", + "example.invalid:5432/example_db", + ] + ) + monkeypatch.setenv("KSADK_SESSION_BACKEND", "postgres") + monkeypatch.setenv("KSADK_SESSION_DSN", dsn) + + payload = describe_session_backend() + + assert payload["Backend"] == "postgres" + assert payload["Shared"] is True + assert payload["ProductionSafe"] is True + assert payload["ContinuityDefault"] == "semantic/replay" + assert "Dsn" not in payload + assert "Namespace" not in payload + + +def test_describe_session_backend_marks_local_as_not_shared(monkeypatch, tmp_path): + monkeypatch.setenv("KSADK_SESSION_BACKEND", "local") + monkeypatch.setenv("KSADK_SESSION_PATH", str(tmp_path / "sessions.sqlite")) + + payload = describe_session_backend() + + assert payload["Backend"] == "local" + assert payload["Shared"] is False + assert payload["ProductionSafe"] is False + assert payload["ContinuityDefault"] == "local_only" + + +def test_platform_session_service_keeps_legacy_stm_db_path_alias(monkeypatch, tmp_path): + target = tmp_path / "legacy-sessions.sqlite" + monkeypatch.delenv("AGENTENGINE_SESSION_BACKEND", raising=False) + monkeypatch.delenv("AGENTENGINE_UI_DIR", raising=False) + monkeypatch.setenv("KSADK_STM_BACKEND", "sqlite") + monkeypatch.delenv("KSADK_STM_PATH", raising=False) + monkeypatch.setenv("KSADK_STM_DB_PATH", str(target)) + + service = create_session_service() + + assert isinstance(service, LocalSessionService) + assert service.db_path == target.resolve() + + +def test_short_term_memory_from_env_prefers_stm_path_alias(monkeypatch): + from ksadk.memory.adk.short_term_memory import ShortTermMemory + + monkeypatch.setenv("KSADK_STM_BACKEND", "sqlite") + monkeypatch.setenv("KSADK_STM_PATH", "/tmp/shared-sessions.sqlite") + monkeypatch.delenv("KSADK_STM_DB_PATH", raising=False) + monkeypatch.delenv("KSADK_ADK_SESSION_PATH", raising=False) + + stm = ShortTermMemory.from_env() + + assert stm.backend == "sqlite" + assert stm.local_database_path == "/tmp/shared-sessions.sqlite" + + +def test_short_term_memory_from_env_prefers_adk_session_override(monkeypatch): + from ksadk.memory.adk.short_term_memory import ShortTermMemory + + monkeypatch.setenv("KSADK_STM_BACKEND", "sqlite") + monkeypatch.setenv("KSADK_STM_PATH", "/tmp/shared-sessions.sqlite") + monkeypatch.setenv("KSADK_ADK_SESSION_PATH", "/tmp/adk-private.sqlite") + + stm = ShortTermMemory.from_env() + + assert stm.local_database_path == "/tmp/adk-private.sqlite" + + +def test_short_term_memory_from_env_falls_back_to_unified_session_dsn(monkeypatch): + from ksadk.memory.adk.short_term_memory import ShortTermMemory + + dsn = "postgresql+asyncpg://user:pass@example.invalid:5432/session_db" + monkeypatch.delenv("KSADK_ADK_SESSION_BACKEND", raising=False) + monkeypatch.delenv("KSADK_ADK_SESSION_URL", raising=False) + monkeypatch.delenv("KSADK_STM_BACKEND", raising=False) + monkeypatch.delenv("KSADK_STM_URL", raising=False) + monkeypatch.delenv("KSADK_STM_DB_URL", raising=False) + monkeypatch.setenv("KSADK_SESSION_BACKEND", "postgres") + monkeypatch.setenv("KSADK_SESSION_DSN", dsn) + + stm = ShortTermMemory.from_env() + + assert stm.backend == "database" + assert stm.db_url == dsn + + +def test_adk_runner_short_term_memory_initializes_from_unified_session_env(monkeypatch): + dsn = "postgresql+asyncpg://user:pass@example.invalid:5432/session_db" + monkeypatch.delenv("KSADK_ADK_SESSION_BACKEND", raising=False) + monkeypatch.delenv("KSADK_ADK_SESSION_URL", raising=False) + monkeypatch.delenv("KSADK_STM_BACKEND", raising=False) + monkeypatch.delenv("KSADK_STM_URL", raising=False) + monkeypatch.delenv("KSADK_STM_DB_URL", raising=False) + monkeypatch.setenv("KSADK_SESSION_BACKEND", "postgres") + monkeypatch.setenv("KSADK_SESSION_DSN", dsn) + runner = _make_adk_runner() + + stm = runner._init_short_term_memory() + + assert stm is not None + assert stm.backend == "database" + assert stm.db_url == dsn + + +def test_short_term_memory_from_env_requires_dsn_for_unified_postgres(monkeypatch): + from ksadk.memory.adk.short_term_memory import ShortTermMemory + + monkeypatch.delenv("KSADK_ADK_SESSION_BACKEND", raising=False) + monkeypatch.delenv("KSADK_ADK_SESSION_URL", raising=False) + monkeypatch.delenv("KSADK_STM_BACKEND", raising=False) + monkeypatch.delenv("KSADK_STM_URL", raising=False) + monkeypatch.delenv("KSADK_STM_DB_URL", raising=False) + monkeypatch.setenv("KSADK_SESSION_BACKEND", "postgres") + monkeypatch.delenv("KSADK_SESSION_DSN", raising=False) + + with pytest.raises(ValueError, match="KSADK_SESSION_DSN"): + ShortTermMemory.from_env() + + +def test_adk_runner_short_term_memory_uses_framework_specific_override(monkeypatch): + monkeypatch.setenv("KSADK_STM_BACKEND", "sqlite") + monkeypatch.setenv("KSADK_STM_PATH", "/tmp/shared-sessions.sqlite") + monkeypatch.setenv("KSADK_ADK_SESSION_PATH", "/tmp/adk-private.sqlite") + runner = _make_adk_runner() + + stm = runner._init_short_term_memory() + + assert stm is not None + assert stm.local_database_path == "/tmp/adk-private.sqlite" + + +def test_platform_session_service_accepts_registered_backend(monkeypatch): + def factory(config, project_dir): + assert config.backend == "custom" + assert project_dir == "/tmp/custom-project" + return InMemorySessionService() + + register_session_backend("custom", factory) + monkeypatch.setenv("KSADK_SESSION_BACKEND", "custom") + + service = create_session_service(project_dir="/tmp/custom-project") + + assert isinstance(service, InMemorySessionService) diff --git a/tests/test_storage_defaults.py b/tests/test_storage_defaults.py new file mode 100644 index 00000000..086d8f65 --- /dev/null +++ b/tests/test_storage_defaults.py @@ -0,0 +1,53 @@ +import pytest + +from ksadk.cli.storage import ( + DEFAULT_STORAGE_SIZE_GI, + build_storage_config, + resolve_default_storage_mount_path, + validate_storage_mount_path, + validate_storage_size_gi, +) + + +def test_resolve_default_storage_mount_path_for_frameworks(): + assert resolve_default_storage_mount_path("adk") == "/home/node/.agentengine" + assert resolve_default_storage_mount_path("langchain") == "/home/node/.agentengine" + assert resolve_default_storage_mount_path("langgraph") == "/home/node/.agentengine" + assert resolve_default_storage_mount_path("deepagents") == "/home/node/.agentengine" + assert resolve_default_storage_mount_path("hermes") == "/home/node/.hermes" + assert resolve_default_storage_mount_path("openclaw") == "/home/node/.openclaw" + + +def test_validate_storage_size_gi_enforces_range(): + assert validate_storage_size_gi(None) == DEFAULT_STORAGE_SIZE_GI + assert validate_storage_size_gi(20) == 20 + assert validate_storage_size_gi(500) == 500 + with pytest.raises(Exception): + validate_storage_size_gi(19) + with pytest.raises(Exception): + validate_storage_size_gi(501) + + +def test_validate_storage_mount_path_requires_absolute_path(): + assert validate_storage_mount_path("/home/node/.hermes/") == "/home/node/.hermes" + with pytest.raises(Exception): + validate_storage_mount_path("relative/path") + with pytest.raises(Exception): + validate_storage_mount_path("/") + + +def test_build_storage_config_defaults_for_serverless_targets(): + assert build_storage_config("hermes", target="serverless") == { + "mount_path": "/home/node/.hermes", + "size_gi": 20, + } + assert build_storage_config("openclaw", target="serverless") == { + "mount_path": "/home/node/.openclaw", + "size_gi": 20, + } + assert build_storage_config("langgraph", target="kce") == { + "mount_path": "/home/node/.agentengine", + "size_gi": 20, + } + assert build_storage_config("langgraph", target="serverless", no_storage=True) is None + assert build_storage_config("langgraph", target="docker") is None diff --git a/tests/test_tool_gateway.py b/tests/test_tool_gateway.py index a5883b3c..aa2e708d 100644 --- a/tests/test_tool_gateway.py +++ b/tests/test_tool_gateway.py @@ -4,6 +4,7 @@ ToolGateway, ToolPolicy, approval_interrupt_info_from_result, + build_tool_receipt_idempotency_key, default_tool_gateway, tool_policy_requires_approval, ) @@ -46,6 +47,28 @@ def test_tool_gateway_runs_approved_call_in_strict_mode(monkeypatch): } +def test_tool_receipt_idempotency_key_is_stable_for_argument_order(): + left = build_tool_receipt_idempotency_key( + session_id="sess-1", + run_id="run-1", + checkpoint_id="ckpt-1", + tool_call_id="call-1", + tool_name="write_workspace_file", + tool_args={"content": "hello", "path": "notes.txt"}, + ) + right = build_tool_receipt_idempotency_key( + session_id="sess-1", + run_id="run-1", + checkpoint_id="ckpt-1", + tool_call_id="call-1", + tool_name="write_workspace_file", + tool_args={"path": "notes.txt", "content": "hello"}, + ) + + assert left == right + assert left.startswith("tool_receipt:") + + def test_approval_interrupt_info_from_result_normalizes_payload(): result = { "type": "approval_required", diff --git a/tests/test_tracing_setup_otlp.py b/tests/test_tracing_setup_otlp.py index af3902ab..f9e05cd9 100644 --- a/tests/test_tracing_setup_otlp.py +++ b/tests/test_tracing_setup_otlp.py @@ -162,6 +162,32 @@ def test_generic_otlp_env_takes_precedence_over_langfuse_auto_env(monkeypatch): assert len(trace_api.provider.processors) == 1 +def test_generic_otlp_headers_decode_form_encoded_basic_auth(monkeypatch): + trace_api = _install_fake_otel(monkeypatch) + encoded_auth = base64.b64encode(b"pk-test:sk-test").decode("ascii") + monkeypatch.setenv("OTEL_EXPORTER_OTLP_PROTOCOL", "http/protobuf") + monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "https://collector.example.com/otel") + monkeypatch.setenv( + "OTEL_EXPORTER_OTLP_HEADERS", + f"Authorization=Basic+{encoded_auth},x-langfuse-ingestion-version=4", + ) + + setup = _reload_setup(monkeypatch) + + setup.setup_tracing( + enable_inmemory=False, + enable_langfuse=None, + enable_adk_instrumentation=False, + ) + + exporter = _FakeHttpOTLPSpanExporter.instances[0] + assert exporter.headers == { + "Authorization": f"Basic {encoded_auth}", + "x-langfuse-ingestion-version": "4", + } + assert len(trace_api.provider.processors) == 1 + + def test_generic_otlp_endpoint_derives_traces_path(monkeypatch): trace_api = _install_fake_otel(monkeypatch) monkeypatch.setenv("OTEL_EXPORTER_OTLP_PROTOCOL", "http/protobuf") diff --git a/tests/test_tui_app.py b/tests/test_tui_app.py new file mode 100644 index 00000000..f75761cd --- /dev/null +++ b/tests/test_tui_app.py @@ -0,0 +1,11 @@ +from ksadk.tui.app import AgentTUI + + +class _DummyRunner: + session_id = "sess-demo" + + +def test_agent_tui_prefers_runner_session_id(): + app = AgentTUI(runner=_DummyRunner(), project_dir=".") + + assert app.session_id == "sess-demo" diff --git a/tests/test_tui_clipboard.py b/tests/test_tui_clipboard.py new file mode 100644 index 00000000..329cbf11 --- /dev/null +++ b/tests/test_tui_clipboard.py @@ -0,0 +1,17 @@ +import sys +from types import SimpleNamespace + +from ksadk.tui import clipboard + + +def test_clipboard_copy_methods_skip_osc52_on_windows(monkeypatch): + fake_pyperclip = SimpleNamespace(copy=lambda _text: None) + app = SimpleNamespace(copy_to_clipboard=lambda _text: None) + + monkeypatch.setattr(clipboard.os, "name", "nt", raising=False) + monkeypatch.setitem(sys.modules, "pyperclip", fake_pyperclip) + + methods = clipboard._clipboard_copy_methods(app) + + assert clipboard._copy_osc52 not in methods + assert methods == [fake_pyperclip.copy, app.copy_to_clipboard] diff --git a/tests/test_ui_config_resolution.py b/tests/test_ui_config_resolution.py new file mode 100644 index 00000000..577de952 --- /dev/null +++ b/tests/test_ui_config_resolution.py @@ -0,0 +1,103 @@ +from ksadk.deployment.ui_config import is_same_origin, resolve_ui_config + + +def test_langgraph_defaults_to_chat_ui_path(): + cfg = resolve_ui_config( + framework="langgraph", + state={}, + cli_profile=None, + cli_path=None, + cli_url=None, + ) + + assert cfg.profile == "langchain" + assert cfg.path == "/chat" + assert cfg.url is None + + +def test_hermes_defaults_to_chat_ui_path(): + cfg = resolve_ui_config( + framework="hermes", + state={}, + cli_profile=None, + cli_path=None, + cli_url=None, + ) + + assert cfg.profile == "hermes" + assert cfg.path == "/chat" + assert cfg.url is None + + +def test_state_ui_config_applies_when_cli_not_set(): + cfg = resolve_ui_config( + framework="adk", + state={ + "ui_profile": "custom", + "ui_path": "/dashboard", + "ui_url": "https://ui.example.com/dashboard", + }, + cli_profile=None, + cli_path=None, + cli_url=None, + ) + + assert cfg.profile == "custom" + assert cfg.path == "/dashboard" + assert cfg.url == "https://ui.example.com/dashboard" + + +def test_cli_overrides_state_and_can_clear_ui_url(): + cfg = resolve_ui_config( + framework="langchain", + state={ + "ui_profile": "custom", + "ui_path": "/custom", + "ui_url": "https://ui.example.com/custom", + }, + cli_profile="langchain", + cli_path="/", + cli_url="", + ) + + assert cfg.profile == "langchain" + assert cfg.path == "/" + assert cfg.url is None + + +def test_legacy_langchain_state_path_is_migrated_to_chat(): + cfg = resolve_ui_config( + framework="langgraph", + state={ + "ui_profile": "langchain", + "ui_path": "/langchain", + }, + cli_profile=None, + cli_path=None, + cli_url=None, + ) + + assert cfg.profile == "langchain" + assert cfg.path == "/chat" + + +def test_legacy_root_state_path_is_migrated_to_chat_for_managed_profiles(): + cfg = resolve_ui_config( + framework="langgraph", + state={ + "ui_profile": "langchain", + "ui_path": "/", + }, + cli_profile=None, + cli_path=None, + cli_url=None, + ) + + assert cfg.profile == "langchain" + assert cfg.path == "/chat" + + +def test_same_origin_requires_scheme_and_netloc_match(): + assert is_same_origin("https://a.example.com/path", "https://a.example.com/") + assert not is_same_origin("https://a.example.com/path", "http://a.example.com/") + assert not is_same_origin("https://a.example.com/path", "https://b.example.com/") diff --git a/tests/test_validate_hosted_long_task_e2e.py b/tests/test_validate_hosted_long_task_e2e.py new file mode 100644 index 00000000..b9171963 --- /dev/null +++ b/tests/test_validate_hosted_long_task_e2e.py @@ -0,0 +1,39 @@ +from scripts.validate_hosted_long_task_e2e import HostedE2EError, _wait_for_checkpoint + + +class FlakyCheckpointClient: + agent_id = "ar-test" + + def __init__(self): + self.calls = 0 + + def action(self, name, payload): + self.calls += 1 + if name == "ListSessionCheckpoints" and self.calls == 1: + raise HostedE2EError( + "ListSessionCheckpoints returned Code=404: {'Code': 404}" + ) + if name == "ListSessionCheckpoints": + return { + "Data": { + "Checkpoints": [ + { + "RunId": payload.get("RunId") or "run-1", + "CheckpointId": "checkpoint-1", + } + ] + } + } + raise AssertionError(f"unexpected action: {name}") + + +def test_wait_for_checkpoint_retries_initial_not_found(): + checkpoint = _wait_for_checkpoint( + FlakyCheckpointClient(), + session_id="session-1", + run_id="run-1", + attempts=2, + interval=0, + ) + + assert checkpoint["CheckpointId"] == "checkpoint-1" diff --git a/tests/test_workflow_common.py b/tests/test_workflow_common.py new file mode 100644 index 00000000..c3471591 --- /dev/null +++ b/tests/test_workflow_common.py @@ -0,0 +1,221 @@ +from pathlib import Path +import json + +from ksadk.cli.workflow_common import ( + build_workflow_local_plan, + clear_build_metadata, + load_cached_artifact_reference, + plan_artifact_build, + resolve_artifact_build_plan, + should_build_artifact, +) + + +def test_should_build_artifact_serverless_code_and_container(): + assert should_build_artifact( + target="serverless", + artifact_type="Code", + ks3_path=None, + image=None, + ) is True + assert should_build_artifact( + target="serverless", + artifact_type="Code", + ks3_path="ks3://bucket/object.zip", + image=None, + ) is False + assert should_build_artifact( + target="serverless", + artifact_type="Container", + ks3_path=None, + image=None, + ) is True + assert should_build_artifact( + target="serverless", + artifact_type="Container", + ks3_path=None, + image="hub.kce.ksyun.com/demo:image", + ) is False + + +def test_should_build_artifact_non_serverless_never_builds(): + assert should_build_artifact( + target="kce", + artifact_type="Code", + ks3_path=None, + image=None, + ) is False + + +def test_plan_artifact_build_no_cache_behaviors(): + plan_rebuild = plan_artifact_build( + target="serverless", + artifact_type="Code", + ks3_path=None, + image=None, + no_cache=True, + ) + assert plan_rebuild.should_build is True + assert plan_rebuild.should_clear_metadata is True + assert plan_rebuild.explicit_ref_option is None + + plan_external = plan_artifact_build( + target="serverless", + artifact_type="Code", + ks3_path="ks3://bucket/object.zip", + image=None, + no_cache=True, + ) + assert plan_external.should_build is False + assert plan_external.should_clear_metadata is False + assert plan_external.explicit_ref_option == "--ks3-path" + + +def test_plan_artifact_build_repackage_rebuilds_without_clearing_dependency_cache(): + plan = plan_artifact_build( + target="serverless", + artifact_type="Code", + ks3_path=None, + image=None, + no_cache=False, + repackage=True, + ) + + assert plan.should_build is True + assert plan.should_clear_metadata is True + assert plan.explicit_ref_option is None + + +def test_clear_build_metadata(tmp_path: Path): + metadata_file = tmp_path / ".agentengine" / "build-metadata.json" + metadata_file.parent.mkdir(parents=True, exist_ok=True) + metadata_file.write_text("{}", encoding="utf-8") + + assert clear_build_metadata(tmp_path) is True + assert metadata_file.exists() is False + assert clear_build_metadata(tmp_path) is False + + +def test_load_cached_artifact_reference_reads_code_and_container_metadata(tmp_path: Path): + metadata_file = tmp_path / ".agentengine" / "build-metadata.json" + metadata_file.parent.mkdir(parents=True, exist_ok=True) + metadata_file.write_text( + json.dumps( + { + "image": "hub.kce.ksyun.com/demo/demo-agent:latest", + "metadata": { + "ks3_path": "ks3://bucket/agents/demo-agent/code.zip", + "image": "hub.kce.ksyun.com/demo/demo-agent:latest", + }, + } + ), + encoding="utf-8", + ) + + assert load_cached_artifact_reference(tmp_path, "Code") == "ks3://bucket/agents/demo-agent/code.zip" + assert load_cached_artifact_reference(tmp_path, "Container") == "hub.kce.ksyun.com/demo/demo-agent:latest" + + +def test_resolve_artifact_build_plan_prefers_cached_then_predicted_dry_run(): + base_plan = plan_artifact_build( + target="serverless", + artifact_type="Code", + ks3_path=None, + image=None, + no_cache=False, + ) + + cached = resolve_artifact_build_plan( + plan=base_plan, + target="serverless", + artifact_type="Code", + dry_run=False, + deploy_name="demo-agent", + region="cn-beijing-6", + account_id="2000003485", + ks3_bucket=None, + registry=None, + explicit_reference=None, + cached_reference="ks3://bucket/agents/demo-agent/cached.zip", + ) + assert cached.should_build is False + assert cached.will_build is False + assert cached.should_publish is False + assert cached.will_publish is False + assert cached.source == "cached" + assert cached.reference == "ks3://bucket/agents/demo-agent/cached.zip" + + predicted = resolve_artifact_build_plan( + plan=base_plan, + target="serverless", + artifact_type="Code", + dry_run=True, + deploy_name="demo-agent", + region="cn-beijing-6", + account_id="2000003485", + ks3_bucket=None, + registry=None, + explicit_reference=None, + cached_reference=None, + ) + assert predicted.should_build is True + assert predicted.will_build is False + assert predicted.should_publish is True + assert predicted.will_publish is False + assert predicted.source == "planned_build" + assert predicted.reference_is_predicted is True + assert predicted.reference == "ks3://agentengine-2000003485-cn-beijing-6/agents/demo-agent/code_.zip" + + +def test_build_workflow_local_plan_splits_local_build_and_artifact_publish_steps(): + base_plan = plan_artifact_build( + target="serverless", + artifact_type="Code", + ks3_path=None, + image=None, + no_cache=False, + ) + predicted = resolve_artifact_build_plan( + plan=base_plan, + target="serverless", + artifact_type="Code", + dry_run=True, + deploy_name="demo-agent", + region="cn-beijing-6", + account_id="2000003485", + ks3_bucket=None, + registry=None, + explicit_reference=None, + cached_reference=None, + ) + + plan = build_workflow_local_plan( + project_dir=Path("/tmp/demo-agent"), + framework="langgraph", + target="serverless", + region="cn-beijing-6", + deploy_name="demo-agent", + artifact_type="Code", + artifact_plan=predicted, + build_dir="/tmp/demo-agent/.agentengine/build", + artifact_reference=predicted.reference, + no_cache=False, + ) + + assert [step["name"] for step in plan["steps"]] == [ + "validate_config", + "package", + "local_build", + "artifact_publish", + "deploy_request", + ] + assert plan["steps"][2]["kind"] == "local" + assert plan["steps"][2]["planned"] is True + assert plan["steps"][2]["reason"] == "dry_run_prediction" + assert plan["steps"][3]["kind"] == "remote" + assert plan["steps"][3]["planned"] is True + assert plan["steps"][3]["reason"] == "dry_run_prediction" + assert plan["artifact"]["should_local_build"] is True + assert plan["artifact"]["will_local_build"] is False + assert plan["artifact"]["should_publish"] is True + assert plan["artifact"]["will_publish"] is False diff --git a/tests/test_workflow_help_snapshots.py b/tests/test_workflow_help_snapshots.py new file mode 100644 index 00000000..06b59d2a --- /dev/null +++ b/tests/test_workflow_help_snapshots.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from pathlib import Path + +from click.testing import CliRunner + +from ksadk.cli.cmd_build import build +from ksadk.cli.cmd_deploy import deploy +from ksadk.cli.cmd_launch import launch + + +SNAPSHOT_FILE = Path(__file__).parent / "snapshots" / "workflow_help_snapshots.txt" + + +def load_section_snapshots(path: Path) -> dict[str, str]: + sections: dict[str, str] = {} + current_name: str | None = None + current_lines: list[str] = [] + + for line in path.read_text(encoding="utf-8").splitlines(): + if line.startswith("=== ") and line.endswith(" ==="): + if current_name is not None: + sections[current_name] = "\n".join(current_lines).rstrip() + "\n" + current_name = line[4:-4] + current_lines = [] + continue + current_lines.append(line) + + if current_name is not None: + sections[current_name] = "\n".join(current_lines).rstrip() + "\n" + + return sections + + +def _normalize_help(text: str) -> str: + return text.rstrip() + "\n" + + +def test_workflow_help_snapshots_match(): + runner = CliRunner() + snapshots = load_section_snapshots(SNAPSHOT_FILE) + commands = { + "build_help": (build, ["--help"]), + "deploy_help": (deploy, ["--help"]), + "launch_help": (launch, ["--help"]), + } + + for section_name, (command, argv) in commands.items(): + result = runner.invoke(command, argv) + assert result.exit_code == 0, result.output + assert _normalize_help(result.output) == snapshots[section_name] diff --git a/tests/unit/knowledge_base/test_client_env.py b/tests/unit/knowledge_base/test_client_env.py new file mode 100644 index 00000000..f83540d9 --- /dev/null +++ b/tests/unit/knowledge_base/test_client_env.py @@ -0,0 +1,57 @@ +import os + + +def _clear_kb_env(monkeypatch): + for key in list(os.environ): + if key.startswith("KSADK_KB_") or key.startswith("KSYUN_"): + monkeypatch.delenv(key, raising=False) + + +def test_from_env_prefers_explicit_region_over_ksyun_region(monkeypatch): + from ksadk.knowledge_base.client import KnowledgeBaseClient + + _clear_kb_env(monkeypatch) + monkeypatch.setenv("KSADK_KB_DATASET_ID", "dataset-test") + monkeypatch.setenv("KSYUN_REGION", "pre-online") + monkeypatch.setenv("KSADK_KB_REGION", "cn-beijing-6") + + client = KnowledgeBaseClient.from_env() + + assert client.region == "cn-beijing-6" + + +def test_from_env_falls_back_to_ksyun_region(monkeypatch): + from ksadk.knowledge_base.client import KnowledgeBaseClient + + _clear_kb_env(monkeypatch) + monkeypatch.setenv("KSADK_KB_DATASET_ID", "dataset-test") + monkeypatch.setenv("KSYUN_REGION", "pre-online") + + client = KnowledgeBaseClient.from_env() + + assert client.region == "pre-online" + + +def test_from_env_uses_http_for_inner_endpoint_when_scheme_unset(monkeypatch): + from ksadk.knowledge_base.client import KnowledgeBaseClient + + _clear_kb_env(monkeypatch) + monkeypatch.setenv("KSADK_KB_DATASET_ID", "dataset-test") + monkeypatch.setenv("KSADK_KB_ENDPOINT", "aicp.inner.api.ksyun.com") + + client = KnowledgeBaseClient.from_env() + + assert client.scheme == "http" + + +def test_from_env_keeps_explicit_scheme_for_inner_endpoint(monkeypatch): + from ksadk.knowledge_base.client import KnowledgeBaseClient + + _clear_kb_env(monkeypatch) + monkeypatch.setenv("KSADK_KB_DATASET_ID", "dataset-test") + monkeypatch.setenv("KSADK_KB_ENDPOINT", "aicp.inner.api.ksyun.com") + monkeypatch.setenv("KSADK_KB_SCHEME", "https") + + client = KnowledgeBaseClient.from_env() + + assert client.scheme == "https" diff --git a/tests/unit/memory/test_adk_memory_comprehensive.py b/tests/unit/memory/test_adk_memory_comprehensive.py new file mode 100644 index 00000000..87e9d1d5 --- /dev/null +++ b/tests/unit/memory/test_adk_memory_comprehensive.py @@ -0,0 +1,1166 @@ +"""KsADK 记忆库 ADK 模块综合单元测试 + +覆盖 ADK 记忆模块的全部接口和使用流程,分 8 个测试类: + + A. TestInMemoryLTMBackendExtended - InMemoryLTMBackend 边界测试 + B. TestHttpLTMBackend - HttpLTMBackend Mock HTTP 测试 + C. TestSdkLTMBackend - SdkLTMBackend Mock SDK 测试 + D. TestLongTermMemoryInit - LongTermMemory 构造和工厂方法 + E. TestLongTermMemoryEventFiltering - 事件过滤逻辑 + F. TestLongTermMemorySearchMemory - 检索和响应解析 + G. TestShortTermMemory - ShortTermMemory 会话管理 + H. TestADKRunnerMemoryIntegration - ADKRunner 记忆集成 + +所有测试纯本地运行,不依赖 LLM / 远程 API。 + +运行方式: + .venv/bin/python -m pytest tests/unit/memory/test_adk_memory_comprehensive.py -v +""" + +import json +import os +import time +from unittest.mock import MagicMock, patch, AsyncMock + +import pytest + +# ============================================================ +# A. TestInMemoryLTMBackendExtended +# ============================================================ + + +class TestInMemoryLTMBackendExtended: + """InMemoryLTMBackend 边界场景和详细行为测试""" + + def _make_backend(self, index="test_app"): + from ksadk.memory.adk.backends.inmemory_ltm_backend import InMemoryLTMBackend + return InMemoryLTMBackend(index=index) + + def test_index_property(self): + backend = self._make_backend(index="my_custom_index") + assert backend.index == "my_custom_index" + + def test_save_empty_list_returns_true(self): + backend = self._make_backend() + assert backend.save_memory("user_1", []) is True + assert backend.search_memory("user_1", "anything") == [] + + def test_unicode_special_characters(self): + backend = self._make_backend() + events = [ + json.dumps({"text": "我喜欢🎉派对和日本語テスト"}, ensure_ascii=False), + json.dumps({"text": "特殊字符: <>&\"'\\n\\t"}, ensure_ascii=False), + ] + assert backend.save_memory("u1", events) is True + results = backend.search_memory("u1", "派对", top_k=5) + assert len(results) >= 1 + assert any("派对" in r for r in results) + + def test_large_volume_memory(self): + backend = self._make_backend() + events = [f"memory_item_{i}: topic_{i % 10}" for i in range(200)] + assert backend.save_memory("u1", events) is True + results = backend.search_memory("u1", "topic_5", top_k=10) + assert len(results) == 10 + + def test_top_k_limits_results(self): + backend = self._make_backend() + events = [f"event_{i}" for i in range(10)] + backend.save_memory("u1", events) + + results_3 = backend.search_memory("u1", "event", top_k=3) + assert len(results_3) == 3 + + results_1 = backend.search_memory("u1", "event", top_k=1) + assert len(results_1) == 1 + + # top_k > total: returns all + results_100 = backend.search_memory("u1", "event", top_k=100) + assert len(results_100) == 10 + + def test_no_match_returns_recent(self): + """查询无匹配时,返回最近 top_k 条记忆""" + backend = self._make_backend() + events = [f"event_{i}" for i in range(5)] + backend.save_memory("u1", events) + + results = backend.search_memory("u1", "completely_unrelated_xyz", top_k=3) + assert len(results) == 3 + # 应该是最后 3 条 + assert results == events[-3:] + + def test_multiple_users_isolation(self): + backend = self._make_backend() + for i in range(5): + backend.save_memory(f"user_{i}", [f"secret_data_for_user_{i}"]) + + for i in range(5): + results = backend.search_memory(f"user_{i}", f"secret_data_for_user_{i}") + assert len(results) == 1 + assert f"user_{i}" in results[0] + # 不应搜到其他用户的精确数据 + for j in range(5): + if j != i: + other_results = backend.search_memory( + f"user_{i}", f"secret_data_for_user_{j}" + ) + assert not any(f"user_{j}" in r for r in other_results) + + def test_full_match_scores_higher(self): + """完整匹配得分 (+10) 高于部分关键词匹配 (+1)""" + backend = self._make_backend() + backend.save_memory("u1", [ + "I love Python programming", # 完整匹配 "Python programming" + "Python is good", # 仅部分匹配 "Python" + ]) + results = backend.search_memory("u1", "Python programming", top_k=2) + assert len(results) == 2 + # 完整匹配的应该排在前面 + assert "Python programming" in results[0] + + +# ============================================================ +# B. TestHttpLTMBackend +# ============================================================ + + +class TestHttpLTMBackend: + """HttpLTMBackend Mock HTTP 测试""" + + def _make_backend(self, base_url="http://test.local", token="test-token"): + from ksadk.memory.adk.backends.http_ltm_backend import HttpLTMBackend + return HttpLTMBackend(index="test", base_url=base_url, token=token) + + def test_empty_base_url_save_returns_false(self): + backend = self._make_backend(base_url="") + assert backend.save_memory("u1", ["event"]) is False + + def test_empty_base_url_search_returns_empty(self): + backend = self._make_backend(base_url="") + assert backend.search_memory("u1", "query") == [] + + def test_save_memory_success(self): + backend = self._make_backend() + mock_response = MagicMock() + mock_response.raise_for_status = MagicMock() + mock_client = MagicMock() + mock_client.post.return_value = mock_response + backend._client = mock_client + + result = backend.save_memory("u1", ["event_1", "event_2"]) + assert result is True + mock_client.post.assert_called_once() + call_args = mock_client.post.call_args + payload = call_args[1]["json"] + assert payload["user_id"] == "u1" + assert payload["events"] == ["event_1", "event_2"] + + def test_save_memory_http_error(self): + import httpx + backend = self._make_backend() + mock_client = MagicMock() + mock_response = MagicMock() + mock_response.status_code = 500 + mock_response.text = "Internal Server Error" + mock_client.post.side_effect = httpx.HTTPStatusError( + "error", request=MagicMock(), response=mock_response + ) + backend._client = mock_client + + assert backend.save_memory("u1", ["event"]) is False + + def test_search_memory_success(self): + backend = self._make_backend() + mock_client = MagicMock() + mock_response = MagicMock() + mock_response.raise_for_status = MagicMock() + mock_response.json.return_value = {"memories": ["mem_1", "mem_2"]} + mock_client.post.return_value = mock_response + backend._client = mock_client + + results = backend.search_memory("u1", "query", top_k=5) + assert results == ["mem_1", "mem_2"] + + def test_search_memory_http_error(self): + import httpx + backend = self._make_backend() + mock_client = MagicMock() + mock_response = MagicMock() + mock_response.status_code = 404 + mock_response.text = "Not Found" + mock_client.post.side_effect = httpx.HTTPStatusError( + "error", request=MagicMock(), response=mock_response + ) + backend._client = mock_client + + assert backend.search_memory("u1", "query") == [] + + def test_client_lazy_init(self): + backend = self._make_backend() + assert backend._client is None + client1 = backend.client + assert backend._client is not None + client2 = backend.client + assert client1 is client2 + + def test_token_in_headers(self): + backend = self._make_backend(token="my-secret-token") + client = backend.client + assert "Authorization" in client.headers + assert client.headers["Authorization"] == "Bearer my-secret-token" + + def test_close_resets_client(self): + backend = self._make_backend() + _ = backend.client # trigger lazy init + assert backend._client is not None + backend.close() + assert backend._client is None + + +# ============================================================ +# C. TestSdkLTMBackend +# ============================================================ + + +class TestSdkLTMBackend: + """SdkLTMBackend Mock SDK 测试""" + + def _make_backend(self, **kwargs): + from ksadk.memory.adk.backends.sdk_ltm_backend import SdkLTMBackend + defaults = { + "index": "test_idx", + "access_key": "test_ak", + "secret_key": "test_sk", + "namespace": "test_ns", + } + defaults.update(kwargs) + return SdkLTMBackend(**defaults) + + def test_init_no_credentials_warning(self, caplog): + import logging + with caplog.at_level(logging.WARNING): + from ksadk.memory.adk.backends.sdk_ltm_backend import SdkLTMBackend + backend = SdkLTMBackend(index="test", access_key="", secret_key="") + assert "AK/SK not provided" in caplog.text + + def test_save_empty_events_returns_true(self): + backend = self._make_backend() + assert backend.save_memory("u1", []) is True + + def test_save_calls_create_memory_sdk(self): + backend = self._make_backend() + mock_client = MagicMock() + mock_client.call.return_value = '{"RequestId": "123"}' + + with patch.object(backend, '_get_client', return_value=mock_client): + event = json.dumps( + {"role": "user", "parts": [{"text": "hello"}]}, + ensure_ascii=False, + ) + result = backend.save_memory( + "u1", + [event], + metadata={"agent_id": "agent-1", "session_id": "sess-1"}, + ) + + assert result is True + mock_client.call.assert_called_once() + call_args = mock_client.call.call_args + assert call_args[0][0] == "CreateMemorySdk" + params = call_args[0][1] + assert params["MemoryCollectionId"] == "test_ns" + assert params["AgentUserId"] == "u1" + assert params["AgentId"] == "agent-1" + assert params["SessionId"] == "sess-1" + assert params["SceneId"] == "_sys_general" + assert params["DataType"] == "conversation" + assert "Namespace" not in params + assert "UserId" not in params + + def test_save_data_conversation_format(self): + """验证 Data 字段为 {"Conversation": [...]} 结构""" + backend = self._make_backend() + mock_client = MagicMock() + mock_client.call.return_value = "{}" + + with patch.object(backend, '_get_client', return_value=mock_client): + event = json.dumps( + {"role": "user", "parts": [{"text": "test msg"}]}, + ensure_ascii=False, + ) + backend.save_memory("u1", [event]) + + params = mock_client.call.call_args[0][1] + assert "Data" in params + assert "Conversation" in params["Data"] + assert isinstance(params["Data"]["Conversation"], list) + assert len(params["Data"]["Conversation"]) == 1 + + def test_save_conversation_item_fields(self): + """每个 Conversation 项必须有 Role/CreatedAt/MessageId/Content""" + backend = self._make_backend() + mock_client = MagicMock() + mock_client.call.return_value = "{}" + + with patch.object(backend, '_get_client', return_value=mock_client): + event = json.dumps( + {"role": "user", "parts": [{"text": "hello world"}]}, + ensure_ascii=False, + ) + backend.save_memory("u1", [event]) + + params = mock_client.call.call_args[0][1] + item = params["Data"]["Conversation"][0] + assert item["Role"] == "user" + assert isinstance(item["CreatedAt"], int) + assert item["CreatedAt"] > 0 + assert len(item["MessageId"]) > 0 + assert item["Content"] == [{"Type": "input_text", "Text": "hello world"}] + + def test_save_parses_event_json(self): + """从 ADK event JSON 正确提取 role 和 text""" + backend = self._make_backend() + mock_client = MagicMock() + mock_client.call.return_value = "{}" + + with patch.object(backend, '_get_client', return_value=mock_client): + events = [ + json.dumps({"role": "user", "parts": [{"text": "msg_1"}]}), + json.dumps({"role": "user", "parts": [{"text": "msg_2"}]}), + ] + backend.save_memory("u1", events) + + params = mock_client.call.call_args[0][1] + conv = params["Data"]["Conversation"] + assert len(conv) == 2 + assert conv[0]["Content"][0]["Text"] == "msg_1" + assert conv[1]["Content"][0]["Text"] == "msg_2" + + def test_save_plain_text_fallback(self): + """非 JSON 格式的事件字符串按纯文本处理""" + backend = self._make_backend() + mock_client = MagicMock() + mock_client.call.return_value = "{}" + + with patch.object(backend, '_get_client', return_value=mock_client): + backend.save_memory("u1", ["plain text message"]) + + params = mock_client.call.call_args[0][1] + item = params["Data"]["Conversation"][0] + assert item["Role"] == "user" + assert item["Content"][0]["Text"] == "plain text message" + + def test_save_exception_returns_false(self): + backend = self._make_backend() + mock_client = MagicMock() + mock_client.call.side_effect = Exception("SDK error") + + with patch.object(backend, '_get_client', return_value=mock_client): + assert backend.save_memory("u1", ["event"]) is False + + def test_search_calls_query_memory_sdk(self): + backend = self._make_backend() + mock_client = MagicMock() + mock_client.call.return_value = json.dumps({"Memories": ["result1"]}) + + with patch.object(backend, '_get_client', return_value=mock_client): + results = backend.search_memory("u1", "query", top_k=3) + + assert results == ["result1"] + call_args = mock_client.call.call_args + assert call_args[0][0] == "QueryMemorySdk" + params = call_args[0][1] + assert params["MemoryCollectionId"] == "test_ns" + assert params["AgentUserId"] == "u1" + assert params["SceneId"] == "_sys_general" + assert params["Query"] == "query" + assert params["Limit"] == 3 + assert "Namespace" not in params + assert "UserId" not in params + + def test_search_exception_returns_empty(self): + backend = self._make_backend() + mock_client = MagicMock() + mock_client.call.side_effect = Exception("SDK error") + + with patch.object(backend, '_get_client', return_value=mock_client): + assert backend.search_memory("u1", "query") == [] + + def test_get_session_status_calls_list_sessions(self): + backend = self._make_backend() + mock_client = MagicMock() + mock_client.call.return_value = json.dumps({ + "Code": 200, + "Message": "success", + "Data": { + "Total": 1, + "Items": [ + {"SessionId": "sess-1", "State": 0, "DataType": "conversation"}, + ], + }, + }) + + with patch.object(backend, '_get_client', return_value=mock_client): + status = backend.get_session_status(user_id="u1", session_id="sess-1") + + assert status == {"SessionId": "sess-1", "State": 0, "DataType": "conversation"} + call_args = mock_client.call.call_args + assert call_args[0][0] == "ListSessions" + assert call_args[0][1] == { + "MemoryCollectionId": "test_ns", + "AgentUserId": "u1", + "Page": 1, + "PageSize": 20, + } + + def test_namespace_fallback_to_index(self): + backend = self._make_backend(namespace="", index="fallback_idx") + mock_client = MagicMock() + mock_client.call.return_value = json.dumps({"Memories": []}) + + with patch.object(backend, '_get_client', return_value=mock_client): + backend.search_memory("u1", "query") + + params = mock_client.call.call_args[0][1] + assert params["MemoryCollectionId"] == "fallback_idx" + + def test_optional_search_params(self): + backend = self._make_backend(scene_id="scene_1") + mock_client = MagicMock() + mock_client.call.return_value = json.dumps({"Memories": []}) + + with patch.object(backend, '_get_client', return_value=mock_client): + backend.search_memory( + "u1", "query", + occurred_after=1000, + occurred_before=2000, + mode="semantic", + ) + + params = mock_client.call.call_args[0][1] + assert params["SceneId"] == "scene_1" + assert params["OccurredAfter"] == 1000 + assert params["OccurredBefore"] == 2000 + assert params["Mode"] == "semantic" + + # --- _parse_query_response tests --- + + def test_parse_response_memories_format(self): + backend = self._make_backend() + result = backend._parse_query_response({"Memories": ["text1", "text2"]}) + assert result == ["text1", "text2"] + + def test_parse_response_data_dict_format(self): + backend = self._make_backend() + result = backend._parse_query_response({ + "Data": [{"Content": "content_1"}, {"Text": "text_1"}] + }) + assert result == ["content_1", "text_1"] + + def test_parse_response_data_nested_empty_memories_is_empty(self): + backend = self._make_backend() + result = backend._parse_query_response({ + "Code": 200, + "Message": "success", + "Data": [{"Memories": []}], + }) + assert result == [] + + def test_parse_response_data_nested_aicp_memory_field(self): + backend = self._make_backend() + result = backend._parse_query_response({ + "Code": 200, + "Message": "success", + "Data": [{ + "Memories": [ + { + "MemoryId": "mem-1", + "Memory": "用户张三喜欢喝桃汁。", + "Score": 0.99, + }, + { + "MemoryId": "mem-2", + "Memory": "用户张三不喜欢喝咖啡。", + "Score": 0.98, + }, + ], + }], + }) + assert result == ["用户张三喜欢喝桃汁。", "用户张三不喜欢喝咖啡。"] + + def test_parse_response_results_format(self): + backend = self._make_backend() + result = backend._parse_query_response({ + "Results": [{"Text": "r1"}, {"Content": "r2"}] + }) + assert result == ["r1", "r2"] + + def test_parse_response_content_priority(self): + """Content 字段优先于 Text 和 Data""" + backend = self._make_backend() + result = backend._parse_query_response({ + "Memories": [{"Content": "preferred", "Text": "fallback", "Data": "last"}] + }) + assert result == ["preferred"] + + def test_parse_response_unknown_format(self, caplog): + import logging + backend = self._make_backend() + with caplog.at_level(logging.WARNING): + result = backend._parse_query_response({"UnknownKey": "value"}) + assert result == [] + assert "Unknown QueryMemorySdk response format" in caplog.text + + def test_parse_response_invalid_json(self): + backend = self._make_backend() + result = backend._parse_query_response("not valid json {{{") + assert result == [] + + +# ============================================================ +# D. TestLongTermMemoryInit +# ============================================================ + + +class TestLongTermMemoryInit: + """LongTermMemory 构造和 from_env() 工厂方法""" + + def test_init_local_string(self): + from ksadk.memory.adk.long_term_memory import LongTermMemory + from ksadk.memory.adk.backends.inmemory_ltm_backend import InMemoryLTMBackend + + ltm = LongTermMemory(backend="local", app_name="test_app") + assert isinstance(ltm._backend, InMemoryLTMBackend) + + def test_init_backend_instance(self): + from ksadk.memory.adk.long_term_memory import LongTermMemory + from ksadk.memory.adk.backends.inmemory_ltm_backend import InMemoryLTMBackend + + custom_backend = InMemoryLTMBackend(index="custom_index") + ltm = LongTermMemory(backend=custom_backend) + assert ltm._backend is custom_backend + assert ltm.index == "custom_index" + + def test_init_with_backend_config(self): + from ksadk.memory.adk.long_term_memory import LongTermMemory + + ltm = LongTermMemory( + backend="local", + backend_config={"index": "config_index"}, + app_name="test", + ) + assert ltm._backend.index == "config_index" + + def test_init_default_index(self): + from ksadk.memory.adk.long_term_memory import LongTermMemory + + ltm = LongTermMemory(backend="local") + assert ltm.index == "default_app" + + def test_init_app_name_as_index(self): + from ksadk.memory.adk.long_term_memory import LongTermMemory + + ltm = LongTermMemory(backend="local", app_name="my_app") + assert ltm.index == "my_app" + + def test_init_invalid_backend_raises(self): + from ksadk.memory.adk.long_term_memory import LongTermMemory + from pydantic import ValidationError + + with pytest.raises(ValidationError): + LongTermMemory(backend="unknown_backend", app_name="test") + + def test_from_env_default(self, monkeypatch): + from ksadk.memory.adk.long_term_memory import LongTermMemory + + # Clear all LTM env vars + for key in list(os.environ.keys()): + if key.startswith("KSADK_LTM_"): + monkeypatch.delenv(key, raising=False) + + ltm = LongTermMemory.from_env() + assert ltm.backend == "local" + + def test_from_env_http(self, monkeypatch): + from ksadk.memory.adk.long_term_memory import LongTermMemory + + monkeypatch.setenv("KSADK_LTM_BACKEND", "http") + monkeypatch.setenv("KSADK_LTM_HTTP_URL", "http://test.local") + monkeypatch.setenv("KSADK_LTM_HTTP_TOKEN", "tok123") + + ltm = LongTermMemory.from_env() + assert ltm.backend == "http" + assert ltm.backend_config["base_url"] == "http://test.local" + assert ltm.backend_config["token"] == "tok123" + + def test_from_env_sdk(self, monkeypatch): + from ksadk.memory.adk.long_term_memory import LongTermMemory + + monkeypatch.setenv("KSADK_LTM_BACKEND", "sdk") + monkeypatch.setenv("KSADK_LTM_ACCESS_KEY", "ak_test") + monkeypatch.setenv("KSADK_LTM_SECRET_KEY", "sk_test") + monkeypatch.setenv("KSADK_LTM_NAMESPACE", "ns_test") + + ltm = LongTermMemory.from_env() + assert ltm.backend == "sdk" + assert ltm.backend_config["access_key"] == "ak_test" + assert ltm.backend_config["secret_key"] == "sk_test" + assert ltm.backend_config["namespace"] == "ns_test" + assert ltm.backend_config["scene_id"] == "_sys_general" + + def test_from_env_sdk_ak_fallback(self, monkeypatch): + from ksadk.memory.adk.long_term_memory import LongTermMemory + + monkeypatch.setenv("KSADK_LTM_BACKEND", "sdk") + monkeypatch.delenv("KSADK_LTM_ACCESS_KEY", raising=False) + monkeypatch.delenv("KSADK_LTM_SECRET_KEY", raising=False) + monkeypatch.setenv("KSYUN_ACCESS_KEY", "fallback_ak") + monkeypatch.setenv("KSYUN_SECRET_KEY", "fallback_sk") + + ltm = LongTermMemory.from_env() + assert ltm.backend_config["access_key"] == "fallback_ak" + assert ltm.backend_config["secret_key"] == "fallback_sk" + + def test_from_env_sdk_prefers_explicit_region_over_ksyun_region( + self, monkeypatch + ): + from ksadk.memory.adk.long_term_memory import LongTermMemory + + monkeypatch.setenv("KSADK_LTM_BACKEND", "sdk") + monkeypatch.setenv("KSADK_LTM_REGION", "cn-beijing-6") + monkeypatch.setenv("KSYUN_REGION", "pre-online") + + ltm = LongTermMemory.from_env() + assert ltm.backend_config["region"] == "cn-beijing-6" + + def test_from_env_sdk_falls_back_to_ksyun_region(self, monkeypatch): + from ksadk.memory.adk.long_term_memory import LongTermMemory + + monkeypatch.setenv("KSADK_LTM_BACKEND", "sdk") + monkeypatch.delenv("KSADK_LTM_REGION", raising=False) + monkeypatch.setenv("KSYUN_REGION", "pre-online") + + ltm = LongTermMemory.from_env() + assert ltm.backend_config["region"] == "pre-online" + + def test_from_env_sdk_uses_http_for_inner_endpoint_when_scheme_unset( + self, monkeypatch + ): + from ksadk.memory.adk.long_term_memory import LongTermMemory + + monkeypatch.setenv("KSADK_LTM_BACKEND", "sdk") + monkeypatch.delenv("KSADK_LTM_SCHEME", raising=False) + monkeypatch.setenv("KSADK_LTM_ENDPOINT", "aicp.inner.api.ksyun.com") + + ltm = LongTermMemory.from_env() + assert ltm.backend_config["scheme"] == "http" + + def test_from_env_top_k(self, monkeypatch): + from ksadk.memory.adk.long_term_memory import LongTermMemory + + monkeypatch.setenv("KSADK_LTM_TOP_K", "10") + ltm = LongTermMemory.from_env() + assert ltm.top_k == 10 + + +# ============================================================ +# E. TestLongTermMemoryEventFiltering +# ============================================================ + + +class TestLongTermMemoryEventFiltering: + """LongTermMemory._filter_and_convert_events() 详细测试""" + + def _make_ltm(self): + from ksadk.memory.adk.long_term_memory import LongTermMemory + return LongTermMemory(backend="local", app_name="filter_test") + + def _make_event(self, author="user", text=None, function_call=None): + from google.adk.events.event import Event + from google.genai import types + + parts = [] + if text is not None: + parts.append(types.Part(text=text)) + if function_call is not None: + parts.append(types.Part(function_call=function_call)) + + content = types.Content(role=author, parts=parts) if parts else None + return Event(invocation_id="inv1", author=author, content=content) + + def test_only_user_events_pass(self): + ltm = self._make_ltm() + events = [ + self._make_event(author="user", text="user message"), + self._make_event(author="model", text="model reply"), + ] + result = ltm._filter_and_convert_events(events) + assert len(result) == 1 + assert "user message" in result[0] + + def test_function_call_filtered(self): + from google.genai import types + ltm = self._make_ltm() + events = [ + self._make_event( + author="user", + function_call=types.FunctionCall(name="search", args={"q": "test"}), + ), + ] + result = ltm._filter_and_convert_events(events) + assert len(result) == 0 + + def test_empty_content_filtered(self): + from google.adk.events.event import Event + ltm = self._make_ltm() + event = Event(invocation_id="inv1", author="user", content=None) + result = ltm._filter_and_convert_events([event]) + assert len(result) == 0 + + def test_empty_parts_filtered(self): + from google.adk.events.event import Event + from google.genai import types + ltm = self._make_ltm() + event = Event( + invocation_id="inv1", + author="user", + content=types.Content(role="user", parts=[]), + ) + result = ltm._filter_and_convert_events([event]) + assert len(result) == 0 + + def test_event_serialization_json(self): + ltm = self._make_ltm() + events = [self._make_event(author="user", text="hello world")] + result = ltm._filter_and_convert_events(events) + assert len(result) == 1 + + parsed = json.loads(result[0]) + assert "role" in parsed + assert "parts" in parsed + assert parsed["parts"][0]["text"] == "hello world" + + async def test_empty_session_no_save(self): + from google.adk.sessions import InMemorySessionService + ltm = self._make_ltm() + + svc = InMemorySessionService() + session = await svc.create_session(app_name="test", user_id="u1") + # Session has no events + await ltm.add_session_to_memory(session) + # No error, nothing saved + + async def test_all_filtered_no_save(self): + from google.adk.sessions import InMemorySessionService + ltm = self._make_ltm() + + svc = InMemorySessionService() + session = await svc.create_session(app_name="test", user_id="u1") + # Only model events + session.events = [self._make_event(author="model", text="model only")] + await ltm.add_session_to_memory(session) + # Nothing saved to backend + + def test_mixed_events_only_user_text_saved(self): + from google.genai import types + ltm = self._make_ltm() + events = [ + self._make_event(author="user", text="keep this"), + self._make_event(author="model", text="discard model"), + self._make_event( + author="user", + function_call=types.FunctionCall(name="fn", args={}), + ), + self._make_event(author="user", text="keep this too"), + ] + result = ltm._filter_and_convert_events(events) + assert len(result) == 2 + assert "keep this" in result[0] + assert "keep this too" in result[1] + + +# ============================================================ +# F. TestLongTermMemorySearchMemory +# ============================================================ + + +class TestLongTermMemorySearchMemory: + """LongTermMemory.search_memory() 返回格式和解析""" + + def _make_ltm(self): + from ksadk.memory.adk.long_term_memory import LongTermMemory + return LongTermMemory(backend="local", app_name="search_test") + + async def test_returns_search_memory_response(self): + from google.adk.memory.base_memory_service import SearchMemoryResponse + ltm = self._make_ltm() + result = await ltm.search_memory( + app_name="search_test", user_id="u1", query="anything" + ) + assert isinstance(result, SearchMemoryResponse) + + async def test_memory_entry_structure(self): + ltm = self._make_ltm() + # Pre-populate backend + ltm._backend.save_memory("u1", [ + json.dumps({"role": "user", "parts": [{"text": "test memory"}]}) + ]) + + result = await ltm.search_memory( + app_name="search_test", user_id="u1", query="test" + ) + assert len(result.memories) == 1 + entry = result.memories[0] + assert hasattr(entry, "author") + assert hasattr(entry, "content") + assert entry.content.parts[0].text == "test memory" + + async def test_json_format_parsing(self): + ltm = self._make_ltm() + ltm._backend.save_memory("u1", [ + json.dumps({"role": "user", "parts": [{"text": "parsed correctly"}]}) + ]) + + result = await ltm.search_memory( + app_name="test", user_id="u1", query="parsed" + ) + assert len(result.memories) == 1 + assert result.memories[0].content.parts[0].text == "parsed correctly" + assert result.memories[0].content.role == "user" + + async def test_plain_text_fallback(self): + ltm = self._make_ltm() + ltm._backend.save_memory("u1", ["just plain text, not json"]) + + result = await ltm.search_memory( + app_name="test", user_id="u1", query="plain text" + ) + assert len(result.memories) == 1 + assert result.memories[0].content.parts[0].text == "just plain text, not json" + assert result.memories[0].content.role == "user" + + async def test_non_standard_json_skipped(self): + ltm = self._make_ltm() + ltm._backend.save_memory("u1", [ + json.dumps({"invalid": "no parts key"}) + ]) + + result = await ltm.search_memory( + app_name="test", user_id="u1", query="invalid" + ) + # Non-standard format is skipped + assert len(result.memories) == 0 + + async def test_empty_results(self): + from google.adk.memory.base_memory_service import SearchMemoryResponse + ltm = self._make_ltm() + result = await ltm.search_memory( + app_name="test", user_id="nonexistent", query="anything" + ) + assert isinstance(result, SearchMemoryResponse) + assert len(result.memories) == 0 + + async def test_backend_error_returns_empty(self): + ltm = self._make_ltm() + # Replace the private _backend with a mock after construction + mock_backend = MagicMock() + mock_backend.search_memory.side_effect = Exception("boom") + object.__setattr__(ltm, '_backend', mock_backend) + + result = await ltm.search_memory( + app_name="test", user_id="u1", query="query" + ) + assert len(result.memories) == 0 + + async def test_top_k_passed_to_backend(self): + ltm = self._make_ltm() + ltm.top_k = 3 + mock_backend = MagicMock() + mock_backend.search_memory.return_value = [] + object.__setattr__(ltm, '_backend', mock_backend) + + await ltm.search_memory(app_name="test", user_id="u1", query="q") + mock_backend.search_memory.assert_called_once_with( + query="q", top_k=3, user_id="u1" + ) + + +# ============================================================ +# G. TestShortTermMemory +# ============================================================ + + +class TestShortTermMemory: + """ShortTermMemory 会话管理测试""" + + def test_init_local(self): + from ksadk.memory.adk.short_term_memory import ShortTermMemory + from google.adk.sessions import InMemorySessionService + + stm = ShortTermMemory(backend="local") + assert isinstance(stm.session_service, InMemorySessionService) + + def test_init_database_no_url_raises(self): + from ksadk.memory.adk.short_term_memory import ShortTermMemory + + with pytest.raises(ValueError, match="KSADK_SESSION_DSN"): + ShortTermMemory(backend="database", db_url="") + + def test_init_unknown_backend_raises(self): + """Pydantic Literal validation rejects unknown backends""" + from ksadk.memory.adk.short_term_memory import ShortTermMemory + from pydantic import ValidationError + + with pytest.raises(ValidationError): + ShortTermMemory(backend="xyz_unknown") + + def test_session_service_property(self): + from ksadk.memory.adk.short_term_memory import ShortTermMemory + from google.adk.sessions import BaseSessionService + + stm = ShortTermMemory(backend="local") + assert isinstance(stm.session_service, BaseSessionService) + + async def test_create_session_auto_id(self): + from ksadk.memory.adk.short_term_memory import ShortTermMemory + + stm = ShortTermMemory(backend="local") + session = await stm.create_session(app_name="app", user_id="u1") + assert session is not None + assert session.id # auto-generated, non-empty + + async def test_create_session_with_id(self): + from ksadk.memory.adk.short_term_memory import ShortTermMemory + + stm = ShortTermMemory(backend="local") + session = await stm.create_session( + app_name="app", user_id="u1", session_id="custom_session_123" + ) + assert session is not None + assert session.id == "custom_session_123" + + async def test_create_session_retrieves_existing(self): + from ksadk.memory.adk.short_term_memory import ShortTermMemory + + stm = ShortTermMemory(backend="local") + s1 = await stm.create_session( + app_name="app", user_id="u1", session_id="shared_id" + ) + s2 = await stm.create_session( + app_name="app", user_id="u1", session_id="shared_id" + ) + assert s1.id == s2.id + + def test_from_env_default(self, monkeypatch): + from ksadk.memory.adk.short_term_memory import ShortTermMemory + + monkeypatch.delenv("KSADK_STM_BACKEND", raising=False) + monkeypatch.delenv("KSADK_STM_PATH", raising=False) + monkeypatch.delenv("KSADK_STM_DB_URL", raising=False) + monkeypatch.delenv("KSADK_STM_DB_PATH", raising=False) + monkeypatch.delenv("KSADK_STM_URL", raising=False) + monkeypatch.delenv("KSADK_ADK_SESSION_BACKEND", raising=False) + monkeypatch.delenv("KSADK_ADK_SESSION_PATH", raising=False) + monkeypatch.delenv("KSADK_ADK_SESSION_URL", raising=False) + + stm = ShortTermMemory.from_env() + assert stm.backend == "local" + + def test_from_env_backend(self, monkeypatch): + from ksadk.memory.adk.short_term_memory import ShortTermMemory + + monkeypatch.setenv("KSADK_STM_BACKEND", "sqlite") + stm = ShortTermMemory.from_env() + assert stm.backend == "sqlite" + + def test_from_env_db_path(self, monkeypatch): + from ksadk.memory.adk.short_term_memory import ShortTermMemory + + monkeypatch.delenv("KSADK_STM_PATH", raising=False) + monkeypatch.delenv("KSADK_ADK_SESSION_PATH", raising=False) + monkeypatch.setenv("KSADK_STM_DB_PATH", "/custom/path.db") + stm = ShortTermMemory.from_env() + assert stm.local_database_path == "/custom/path.db" + + +# ============================================================ +# H. TestADKRunnerMemoryIntegration +# ============================================================ + + +class TestADKRunnerMemoryIntegration: + """ADKRunner 记忆初始化和工具注入测试""" + + def _make_runner(self): + from ksadk.runners.adk_runner import ADKRunner + + mock_detection = MagicMock() + mock_detection.entry_point = "agent.py" + mock_detection.agent_variable = "root_agent" + runner = ADKRunner(mock_detection, "/tmp/test_project") + return runner + + def test_init_stm_no_env(self, monkeypatch): + monkeypatch.delenv("KSADK_STM_BACKEND", raising=False) + monkeypatch.delenv("KSADK_STM_PATH", raising=False) + monkeypatch.delenv("KSADK_STM_URL", raising=False) + monkeypatch.delenv("KSADK_STM_DB_PATH", raising=False) + monkeypatch.delenv("KSADK_STM_DB_URL", raising=False) + monkeypatch.delenv("KSADK_ADK_SESSION_BACKEND", raising=False) + monkeypatch.delenv("KSADK_ADK_SESSION_PATH", raising=False) + monkeypatch.delenv("KSADK_ADK_SESSION_URL", raising=False) + runner = self._make_runner() + result = runner._init_short_term_memory() + assert result is None + + def test_init_stm_local(self, monkeypatch): + monkeypatch.setenv("KSADK_STM_BACKEND", "local") + runner = self._make_runner() + result = runner._init_short_term_memory() + assert result is not None + + def test_init_ltm_no_env(self, monkeypatch): + monkeypatch.delenv("KSADK_LTM_BACKEND", raising=False) + runner = self._make_runner() + result = runner._init_long_term_memory() + assert result is None + + def test_init_ltm_local(self, monkeypatch): + monkeypatch.setenv("KSADK_LTM_BACKEND", "local") + runner = self._make_runner() + mock_agent = MagicMock() + mock_agent.name = "test_agent" # set as attribute, not MagicMock constructor param + runner._agent = mock_agent + result = runner._init_long_term_memory() + assert result is not None + + def test_init_ltm_sdk_env(self, monkeypatch): + monkeypatch.setenv("KSADK_LTM_BACKEND", "sdk") + monkeypatch.setenv("KSADK_LTM_ACCESS_KEY", "ak") + monkeypatch.setenv("KSADK_LTM_SECRET_KEY", "sk") + monkeypatch.setenv("KSADK_LTM_NAMESPACE", "ns") + + runner = self._make_runner() + mock_agent = MagicMock() + mock_agent.name = "test_agent" + runner._agent = mock_agent + result = runner._init_long_term_memory() + assert result is not None + + def test_init_ltm_sdk_uses_ksyun_region_and_inner_http(self, monkeypatch): + monkeypatch.setenv("KSADK_LTM_BACKEND", "sdk") + monkeypatch.setenv("KSADK_LTM_ACCESS_KEY", "ak") + monkeypatch.setenv("KSADK_LTM_SECRET_KEY", "sk") + monkeypatch.setenv("KSADK_LTM_NAMESPACE", "ns") + monkeypatch.delenv("KSADK_LTM_REGION", raising=False) + monkeypatch.delenv("KSADK_LTM_SCHEME", raising=False) + monkeypatch.setenv("KSYUN_REGION", "pre-online") + monkeypatch.setenv("KSADK_LTM_ENDPOINT", "aicp.inner.api.ksyun.com") + + runner = self._make_runner() + mock_agent = MagicMock() + mock_agent.name = "test_agent" + runner._agent = mock_agent + result = runner._init_long_term_memory() + + assert result is not None + assert result.backend_config["region"] == "pre-online" + assert result.backend_config["scheme"] == "http" + + def test_inject_tool_into_empty(self): + runner = self._make_runner() + runner._agent = MagicMock() + runner._agent.tools = [] + + runner._inject_load_memory_tool() + tool_names = [ + getattr(t, "name", None) or getattr(t, "__name__", "") + for t in runner._agent.tools + ] + assert "load_memory" in tool_names + + def test_inject_tool_skips_duplicate(self): + from google.adk.tools import load_memory + + runner = self._make_runner() + runner._agent = MagicMock() + runner._agent.tools = [load_memory] + + runner._inject_load_memory_tool() + # Should still be just 1 + assert len(runner._agent.tools) == 1 + + def test_inject_tool_no_tools_attr(self): + runner = self._make_runner() + runner._agent = MagicMock(spec=[]) # no 'tools' attribute + del runner._agent.tools # ensure it's truly missing + + # Should not crash + runner._inject_load_memory_tool() + + def test_inject_save_memory_tool_into_empty(self): + runner = self._make_runner() + runner._agent = MagicMock() + runner._agent.tools = [] + + runner._inject_save_memory_tool() + tool_names = [ + getattr(t, "name", None) or getattr(t, "__name__", "") + for t in runner._agent.tools + ] + assert "save_memory" in tool_names + + async def test_ensure_session_new_external(self): + from google.adk.sessions import InMemorySessionService + + runner = self._make_runner() + runner._agent = MagicMock() + runner._agent.name = "test_agent" + runner._session_service = InMemorySessionService() + + session_id = await runner._ensure_session("external_123") + assert session_id is not None + assert "external_123" in runner._session_map + + async def test_ensure_session_cached(self): + from google.adk.sessions import InMemorySessionService + + runner = self._make_runner() + runner._agent = MagicMock() + runner._agent.name = "test_agent" + runner._session_service = InMemorySessionService() + + id1 = await runner._ensure_session("ext_1") + id2 = await runner._ensure_session("ext_1") + assert id1 == id2 + + async def test_ensure_session_default(self): + from google.adk.sessions import InMemorySessionService + + runner = self._make_runner() + runner._agent = MagicMock() + runner._agent.name = "test_agent" + runner._session_service = InMemorySessionService() + + id1 = await runner._ensure_session() + id2 = await runner._ensure_session() + assert id1 == id2 + assert runner._default_session_id == id1 + + async def test_save_to_ltm_no_ltm(self): + runner = self._make_runner() + runner._long_term_memory = None + result = await runner.save_session_to_long_term_memory("session_1") + assert result is False + + async def test_save_to_ltm_session_not_found(self): + from google.adk.sessions import InMemorySessionService + + runner = self._make_runner() + runner._agent = MagicMock() + runner._agent.name = "test_agent" + runner._session_service = InMemorySessionService() + runner._long_term_memory = MagicMock() + + result = await runner.save_session_to_long_term_memory("nonexistent_session") + assert result is False From f6d3614ed4d1f90076f1c09f5fc4557ea218d34e Mon Sep 17 00:00:00 2001 From: xiayu Date: Thu, 18 Jun 2026 17:43:40 +0800 Subject: [PATCH 06/52] fix: remove stale 0.6.5 tests and fix Python 3.10 datetime compatibility - Remove tests that don't exist in the 0.6.6 feature branch: test_public_positioning_docs, test_public_release_gates, test_openclaw_bootstrap_secretref, test_prepare_ksadk_python_export, test_prepare_ksadk_web_export - Fix ksadk_runtime_common/workspace_files/router.py: replace 'from datetime import UTC' with 'timezone.utc' for Python 3.10 compat --- .../workspace_files/router.py | 4 +- tests/test_openclaw_bootstrap_secretref.py | 4067 ----------------- tests/test_prepare_ksadk_python_export.py | 277 -- tests/test_prepare_ksadk_web_export.py | 282 -- tests/test_public_positioning_docs.py | 447 -- tests/test_public_release_gates.py | 83 - 6 files changed, 2 insertions(+), 5158 deletions(-) delete mode 100644 tests/test_openclaw_bootstrap_secretref.py delete mode 100644 tests/test_prepare_ksadk_python_export.py delete mode 100644 tests/test_prepare_ksadk_web_export.py delete mode 100644 tests/test_public_positioning_docs.py delete mode 100644 tests/test_public_release_gates.py diff --git a/ksadk_runtime_common/workspace_files/router.py b/ksadk_runtime_common/workspace_files/router.py index 034c5b63..d76b6033 100644 --- a/ksadk_runtime_common/workspace_files/router.py +++ b/ksadk_runtime_common/workspace_files/router.py @@ -4,7 +4,7 @@ import mimetypes from collections.abc import Callable -from datetime import UTC, datetime +from datetime import datetime, timezone from pathlib import Path from typing import Annotated @@ -34,7 +34,7 @@ def _isoformat_timestamp(path: Path) -> str: """Get an ISO 8601 timestamp for a file modification time.""" return ( - datetime.fromtimestamp(path.stat().st_mtime, tz=UTC) + datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc) .isoformat() .replace("+00:00", "Z") ) diff --git a/tests/test_openclaw_bootstrap_secretref.py b/tests/test_openclaw_bootstrap_secretref.py deleted file mode 100644 index 1d3b7a2d..00000000 --- a/tests/test_openclaw_bootstrap_secretref.py +++ /dev/null @@ -1,4067 +0,0 @@ -import json -import os -import subprocess -import time -import base64 -from pathlib import Path -from tempfile import TemporaryDirectory - - -REPO_ROOT = Path(__file__).resolve().parents[1] -BOOTSTRAP_SCRIPT = REPO_ROOT / "deploy" / "openclaw" / "bootstrap.sh" -OPENCLAW_DOCKERFILE = REPO_ROOT / "deploy" / "openclaw" / "Dockerfile" -LATEST_OPENCLAW_BASE_IMAGE = ( - "ghcr.io/openclaw/openclaw:2026.6.1-slim@" - "sha256:a83ee8716ab191534952299fe989374d75593aa9c7632c4e756e9d64b0ce8061" -) -VALID_MEM0_UUID = "e52b7fac-e641-4b34-b9f7-6b0b9f190cd4" - - -def _write_weixin_plugin_package_json( - plugin_root: Path, - *, - version: str = "2.1.7", - package_name: str = "@tencent-weixin/openclaw-weixin", -) -> None: - plugin_root.mkdir(parents=True, exist_ok=True) - (plugin_root / "package.json").write_text( - json.dumps( - { - "name": package_name, - "version": version, - } - ) - + "\n" - ) - - -def _compute_directory_signature(dir_path: Path) -> str: - result = subprocess.run( - [ - "bash", - "-lc", - r'''dir_path="$1" -find "$dir_path" \( -type f -o -type l \) | LC_ALL=C sort | while IFS= read -r file_path; do - rel_path="${file_path#"$dir_path/"}" - if [[ -L "$file_path" ]]; then - printf 'link\t%s\t%s\n' "$rel_path" "$(readlink "$file_path")" - continue - fi - printf 'file\t%s\t' "$rel_path" - cksum "$file_path" | awk '{print $1 "\t" $2}' -done | cksum | awk '{print $1 ":" $2}' -''', - "_", - str(dir_path), - ], - capture_output=True, - text=True, - check=True, - ) - return result.stdout.strip() - - -def _build_base_env(state_dir: str, config_path: str) -> dict: - env = os.environ.copy() - for key in ( - "OPENCLAW_DEFAULT_MODEL", - "OPENAI_MODEL_NAME", - "MODEL_NAME", - "LLM_MODEL", - "OPENCLAW_MODEL_CATALOG_JSON", - "OPENCLAW_MODEL_PROVIDER_ID", - "OPENCLAW_MODEL_BASE_URL", - "OPENCLAW_MODEL_API", - "OPENCLAW_MODEL_API_KEY", - "OPENAI_API_KEY", - "LLM_API_KEY", - "MODEL_API_KEY", - "LANGFUSE_PUBLIC_KEY", - "LANGFUSE_SECRET_KEY", - "LANGFUSE_BASE_URL", - "LANGFUSE_HOST", - "OTEL_SERVICE_NAME", - "OTEL_RESOURCE_ATTRIBUTES", - "OTEL_EXPORTER_OTLP_ENDPOINT", - "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", - ): - env.pop(key, None) - safe_bin_dir = Path(state_dir) / "safe-bin" - raw_bin_dir = Path(state_dir) / "bin" - workspace_template_dir = Path(state_dir) / "workspace-template" - safe_bin_dir.mkdir(parents=True, exist_ok=True) - raw_bin_dir.mkdir(parents=True, exist_ok=True) - workspace_template_dir.mkdir(parents=True, exist_ok=True) - for cmd in ["pwd", "ls", "whoami", "id", "uname", "date", "ps", "df", "du", "stat", "find", "cat", "head", "tail", "wc", "git", "mcporter", "sh-safe", "bash-safe"]: - wrapper_path = safe_bin_dir / cmd - wrapper_path.write_text("#!/bin/sh\nexit 0\n") - wrapper_path.chmod(0o755) - for cmd in ["curl", "jq", "yt-dlp", "openclaw", "agent-browser", "gh", "xreach"]: - raw_bin_path = raw_bin_dir / cmd - raw_bin_path.write_text("#!/bin/sh\nexit 0\n") - raw_bin_path.chmod(0o755) - (workspace_template_dir / "SOUL.md").write_text("security soul\n") - (workspace_template_dir / "AGENTS.md").write_text("security agents\n") - (workspace_template_dir / "MEMORY.md").write_text("persistent memory\n") - (workspace_template_dir / "USER.MD").write_text("user preferences\n") - (workspace_template_dir / "TOOLS.md").write_text("tool notes\n") - env.pop("OPENCLAW_MODEL_API_KEY", None) - env.pop("OPENAI_API_KEY", None) - env["HOME"] = state_dir - env["OPENCLAW_STATE_DIR"] = state_dir - env["OPENCLAW_CONFIG_PATH"] = config_path - env["OPENCLAW_BOOTSTRAP_ONLY"] = "1" - env["OPENCLAW_MODEL_PROVIDER_ID"] = "ksyun" - env["OPENCLAW_MODEL_BASE_URL"] = "http://example.test/v1" - env["OPENCLAW_DEFAULT_MODEL"] = "ksyun/glm-5.1" - env["OPENCLAW_SAFE_BIN_DIR"] = str(safe_bin_dir) - env["OPENCLAW_WORKSPACE_TEMPLATE_DIR"] = str(workspace_template_dir) - env["PATH"] = f"{raw_bin_dir}:{env['PATH']}" - return env - - -def _build_mem0_manifest_json() -> str: - return json.dumps( - { - "schema_version": "v1", - "backend_type": "mem0", - "config": { - "mem0_instance_id": VALID_MEM0_UUID, - "mem0_region": "cn-qingyangtest-1", - }, - "secrets_env": { - "api_key": "MEM0_API_KEY", - "user_id": "MEM0_USER_ID", - "base_url": "MEM0_BASE_URL", - }, - } - ) - - -def _build_openclaw_default_memory_manifest_json() -> str: - return json.dumps( - { - "schema_version": "v1", - "backend_type": "openclaw_default", - } - ) - - -def _assert_model_token_defaults(models: list[dict], *, minimum_max_tokens: int = 20000) -> None: - for model in models: - if "contextWindow" not in model and "maxTokens" not in model: - continue - assert model["contextWindow"] == 200000 - assert model["maxTokens"] >= minimum_max_tokens - - -def test_openclaw_dockerfile_tracks_latest_official_channel_plugins(): - dockerfile = OPENCLAW_DOCKERFILE.read_text(encoding="utf-8") - - assert ( - f"ARG OPENCLAW_BASE_IMAGE={LATEST_OPENCLAW_BASE_IMAGE}" - in dockerfile - ) - assert "ARG OPENCLAW_WEIXIN_PLUGIN_SPEC=@tencent-weixin/openclaw-weixin" in dockerfile - assert "ARG OPENCLAW_LARK_PLUGIN_SPEC=@larksuite/openclaw-lark" in dockerfile - assert "ARG OPENCLAW_MEM0_PLUGIN_ID=openclaw-mem0" in dockerfile - assert "ARG OPENCLAW_MEM0_PLUGIN_URL=https://memory-engine.ks3-cn-beijing.ksyuncs.com/ksc-openclaw-mem0-1.0.6.tgz" in dockerfile - assert "ksc-openclaw-mem0-1.1." not in dockerfile - assert "ARG OPENCLAW_INSTALL_WPS_XIEZUO_PLUGIN=true" in dockerfile - assert "ARG OPENCLAW_WPS_XIEZUO_PLUGIN_SPEC=@wps365/openclaw-wpsxiezuo" in dockerfile - assert "ARG OPENCLAW_WPS_XIEZUO_PLUGIN_ID=wps-xiezuo" in dockerfile - assert "ARG OPENCLAW_INSTALL_DIAGNOSTICS_OTEL_PLUGIN=true" in dockerfile - assert "ARG OPENCLAW_DIAGNOSTICS_OTEL_PLUGIN_SPEC=@openclaw/diagnostics-otel" in dockerfile - assert "ARG OPENCLAW_DIAGNOSTICS_OTEL_PLUGIN_ID=diagnostics-otel" in dockerfile - assert "openclaw-wps-xiezuo-1.6.0.tgz" not in dockerfile - assert "deploy/openclaw/wps-xiezuo-assets" not in dockerfile - assert 'install_default_plugin "${OPENCLAW_WPS_XIEZUO_PLUGIN_SPEC}" "${OPENCLAW_WPS_XIEZUO_PLUGIN_ID}"' in dockerfile - - -def test_openclaw_dockerfile_moves_apt_archives_out_of_var_cache(): - dockerfile = OPENCLAW_DOCKERFILE.read_text(encoding="utf-8") - - assert "mkdir -p /tmp/apt-cache" in dockerfile - assert "Dir::Cache::archives=/tmp/apt-cache" in dockerfile - assert "rm -rf /var/lib/apt/lists/* /tmp/apt-cache" in dockerfile - - -def test_openclaw_dockerfile_strips_workspace_dev_dependencies_before_plugin_install(): - dockerfile = OPENCLAW_DOCKERFILE.read_text(encoding="utf-8") - - assert 'spec.startsWith("workspace:")' in dockerfile - assert "delete deps[name]" in dockerfile - assert 'npm install --omit=dev --no-audit --no-fund --registry "${NPM_REGISTRY}"' in dockerfile - assert 'ln -s /app "${src_dir}/node_modules/openclaw"' in dockerfile - - -def test_openclaw_dockerfile_installs_local_plugin_archives_without_force_flag_for_2026_3_28_compatibility(): - dockerfile = OPENCLAW_DOCKERFILE.read_text(encoding="utf-8") - - assert "compatible with upstream OpenClaw 2026.3.28" in dockerfile - assert 'openclaw plugins install "${archive_path}"; \\' in dockerfile - assert 'openclaw plugins install "${archive_path}" --force; \\' not in dockerfile - - -def test_openclaw_runtime_bundles_runtime_common_and_manifest_renderer(): - dockerfile = OPENCLAW_DOCKERFILE.read_text(encoding="utf-8") - bootstrap = BOOTSTRAP_SCRIPT.read_text(encoding="utf-8") - - assert "COPY ksadk_runtime_common /opt/ksadk_runtime_common" in dockerfile - assert "COPY deploy/openclaw/workspace_files_app.py /opt/openclaw/workspace_files_app.py" in dockerfile - assert '"fastapi>=0.100.0,<0.124.0"' in dockerfile - assert '"httpx>=0.24.0,<1.0.0"' in dockerfile - assert '"uvicorn>=0.23.0,<1.0.0"' in dockerfile - assert '"websockets>=11.0.0,<16.0.0"' in dockerfile - assert '"python-multipart>=0.0.9,<1.0.0"' in dockerfile - assert "PYTHONPATH=/opt" in dockerfile - assert "from ksadk_runtime_common.memory_backend.render import render_to_json" in bootstrap - assert 'uvicorn workspace_files_app:app \\' in bootstrap - assert 'OPENCLAW_WORKSPACE_FILES_PROXY_URL' in bootstrap - - -def test_openclaw_dockerfile_clones_official_kdocs_skill_and_normalizes_runtime_layout(): - dockerfile = OPENCLAW_DOCKERFILE.read_text(encoding="utf-8") - - assert "ARG KDOCS_SKILL_REPO=https://github.com/kdocs-app/kdocs-skill.git" in dockerfile - assert 'git clone --depth 1 "${KDOCS_SKILL_REPO}" /tmp/kdocs-skill' in dockerfile - assert 'mkdir -p /opt/openclaw/preset-skills/kdocs/scripts' in dockerfile - assert 'cp -R /tmp/kdocs-skill/. /opt/openclaw/preset-skills/kdocs/' in dockerfile - assert 'printf \'%s\\n\' \\' in dockerfile - assert 'exec bash "${SCRIPT_DIR}/scripts/setup.sh" "$@"' in dockerfile - - -def test_bootstrap_writes_secretref_for_model_api_key(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - secrets_path = Path(tmpdir) / "secrets.json" - env = _build_base_env(tmpdir, str(config_path)) - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - - result = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode == 0, result.stderr or result.stdout - cfg = json.loads(config_path.read_text()) - assert cfg["models"]["providers"]["ksyun"]["apiKey"] == { - "source": "file", - "provider": "default", - "id": "/providers/ksyun/apiKey", - } - assert cfg["secrets"]["providers"]["default"] == { - "source": "file", - "path": str(secrets_path), - "mode": "json", - } - assert cfg["secrets"]["defaults"]["file"] == "default" - assert json.loads(secrets_path.read_text()) == { - "providers": { - "ksyun": { - "apiKey": "dummy-secret-value", - } - } - } - assert secrets_path.stat().st_mode & 0o777 == 0o600 - - -def test_bootstrap_applies_openclaw_config_patch_json(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - config_path.write_text( - json.dumps( - { - "plugins": { - "allow": ["existing-plugin"], - "entries": {"existing-plugin": {"enabled": True}}, - }, - "diagnostics": {"enabled": False}, - } - ) - + "\n" - ) - env = _build_base_env(tmpdir, str(config_path)) - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - env["OPENCLAW_CONFIG_PATCH_JSON"] = json.dumps( - { - "plugins": { - "allow": ["diagnostics-otel"], - "entries": {"diagnostics-otel": {"enabled": True}}, - }, - "diagnostics": { - "enabled": True, - "otel": { - "enabled": True, - "endpoint": "https://langfuse.pre.example.com/api/public/otel", - "protocol": "http/protobuf", - "serviceName": "agentengine-openclaw-demo", - "traces": True, - "metrics": False, - "logs": False, - "captureContent": False, - }, - }, - } - ) - - result = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode == 0, result.stderr or result.stdout - cfg = json.loads(config_path.read_text()) - assert cfg["plugins"]["entries"]["existing-plugin"]["enabled"] is True - assert cfg["plugins"]["entries"]["diagnostics-otel"]["enabled"] is True - assert "diagnostics-otel" in cfg["plugins"]["allow"] - assert cfg["diagnostics"]["enabled"] is True - assert cfg["diagnostics"]["otel"] == { - "enabled": True, - "endpoint": "https://langfuse.pre.example.com/api/public/otel", - "protocol": "http/protobuf", - "serviceName": "agentengine-openclaw-demo", - "traces": True, - "metrics": False, - "logs": False, - "captureContent": False, - } - - -def test_bootstrap_migrates_legacy_diagnostics_capture_content_to_otel(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - env = _build_base_env(tmpdir, str(config_path)) - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - env["OPENCLAW_CONFIG_PATCH_JSON"] = json.dumps( - { - "diagnostics": { - "enabled": True, - "captureContent": False, - "otel": { - "enabled": True, - "endpoint": "https://langfuse.pre.example.com/api/public/otel", - "protocol": "http/protobuf", - }, - } - } - ) - - result = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode == 0, result.stderr or result.stdout - cfg = json.loads(config_path.read_text()) - assert cfg["diagnostics"]["enabled"] is True - assert "captureContent" not in cfg["diagnostics"] - assert cfg["diagnostics"]["otel"]["captureContent"] is False - - -def test_bootstrap_enables_diagnostics_otel_for_langfuse_env(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - env = _build_base_env(tmpdir, str(config_path)) - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - env["LANGFUSE_PUBLIC_KEY"] = "pk-test" - env["LANGFUSE_SECRET_KEY"] = "sk-test" - env["LANGFUSE_BASE_URL"] = "https://langfuse.pre.example.com/" - env["OTEL_SERVICE_NAME"] = "openclaw-langfuse-e2e" - - result = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode == 0, result.stderr or result.stdout - cfg = json.loads(config_path.read_text()) - expected_auth = base64.b64encode(b"pk-test:sk-test").decode("ascii") - assert cfg["plugins"]["entries"]["diagnostics-otel"]["enabled"] is True - assert "diagnostics-otel" in cfg["plugins"]["allow"] - assert cfg["diagnostics"]["enabled"] is True - assert cfg["diagnostics"]["otel"] == { - "enabled": True, - "endpoint": "https://langfuse.pre.example.com/api/public/otel", - "protocol": "http/protobuf", - "serviceName": "openclaw-langfuse-e2e", - "traces": True, - "metrics": False, - "logs": False, - "headers": { - "Authorization": f"Basic {expected_auth}", - "x-langfuse-ingestion-version": "4", - }, - } - - -def test_bootstrap_maps_gateway_token_to_shared_secret_when_token_mode_enabled(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - env = _build_base_env(tmpdir, str(config_path)) - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - env["OPENCLAW_GATEWAY_AUTH_MODE"] = "token" - env["OPENCLAW_GATEWAY_TOKEN"] = "gateway-token-demo" - - result = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode == 0, result.stderr or result.stdout - cfg = json.loads(config_path.read_text()) - assert cfg["gateway"]["auth"]["mode"] == "token" - assert cfg["gateway"]["auth"]["password"] == "gateway-token-demo" - - -def test_bootstrap_enables_openresponses_http_endpoint_by_default(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - env = _build_base_env(tmpdir, str(config_path)) - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - - result = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode == 0, result.stderr or result.stdout - cfg = json.loads(config_path.read_text()) - assert cfg["gateway"]["http"]["endpoints"]["responses"]["enabled"] is True - - -def test_bootstrap_respects_explicit_openresponses_http_endpoint_disable(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - config_path.write_text( - json.dumps( - { - "gateway": { - "http": { - "endpoints": { - "responses": { - "enabled": False, - } - } - } - } - } - ), - encoding="utf-8", - ) - env = _build_base_env(tmpdir, str(config_path)) - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - - result = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode == 0, result.stderr or result.stdout - cfg = json.loads(config_path.read_text()) - assert cfg["gateway"]["http"]["endpoints"]["responses"]["enabled"] is False - - -def test_bootstrap_clears_stale_gateway_shared_secret_when_mode_returns_to_trusted_proxy(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - config_path.write_text( - json.dumps( - { - "gateway": { - "auth": { - "mode": "token", - "password": "stale-secret", - } - } - } - ) - ) - env = _build_base_env(tmpdir, str(config_path)) - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - env["OPENCLAW_GATEWAY_AUTH_MODE"] = "trusted-proxy" - env["OPENCLAW_GATEWAY_TOKEN"] = "stale-secret" - env["OPENCLAW_GATEWAY_PASSWORD"] = "stale-secret" - - result = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode == 0, result.stderr or result.stdout - cfg = json.loads(config_path.read_text()) - assert cfg["gateway"]["auth"]["mode"] == "trusted-proxy" - assert "password" not in (cfg.get("gateway", {}).get("auth", {})) - - -def test_bootstrap_keeps_env_secretref_when_explicitly_requested(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - env = _build_base_env(tmpdir, str(config_path)) - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - env["OPENCLAW_MODEL_API_KEY_SECRET_SOURCE"] = "env" - - result = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode == 0, result.stderr or result.stdout - cfg = json.loads(config_path.read_text()) - assert cfg["models"]["providers"]["ksyun"]["apiKey"] == { - "source": "env", - "provider": "default", - "id": "OPENCLAW_MODEL_API_KEY", - } - assert cfg["secrets"]["providers"]["default"]["source"] == "env" - assert cfg["secrets"]["defaults"]["env"] == "default" - - -def test_bootstrap_keeps_model_env_fallbacks_for_background_runs(): - source = BOOTSTRAP_SCRIPT.read_text(encoding="utf-8") - - assert "false missing-auth failures against auth-profiles.json" in source - assert "unset OPENCLAW_MODEL_API_KEY OPENAI_API_KEY LLM_API_KEY MODEL_API_KEY" not in source - - -def test_bootstrap_defaults_heartbeat_to_isolated_light_context(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - env = _build_base_env(tmpdir, str(config_path)) - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - - result = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode == 0, result.stderr or result.stdout - cfg = json.loads(config_path.read_text()) - assert cfg["agents"]["defaults"]["heartbeat"]["every"] == "30m" - assert cfg["agents"]["defaults"]["heartbeat"]["target"] == "none" - assert cfg["agents"]["defaults"]["heartbeat"]["isolatedSession"] is True - assert cfg["agents"]["defaults"]["heartbeat"]["lightContext"] is True - - -def test_bootstrap_disables_exec_notify_on_exit_by_default(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - env = _build_base_env(tmpdir, str(config_path)) - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - - result = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode == 0, result.stderr or result.stdout - cfg = json.loads(config_path.read_text()) - assert cfg["tools"]["exec"]["notifyOnExit"] is False - assert cfg["tools"]["exec"]["notifyOnExitEmptySuccess"] is False - - -def test_bootstrap_fails_without_secret_env_value(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - env = _build_base_env(tmpdir, str(config_path)) - - result = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode != 0 - combined = f"{result.stdout}\n{result.stderr}" - assert "missing bootstrap secret env for file-backed model api key" in combined - - -def test_bootstrap_does_not_keep_gateway_password_when_not_in_token_mode(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - env = _build_base_env(tmpdir, str(config_path)) - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - - result = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode == 0, result.stderr or result.stdout - cfg = json.loads(config_path.read_text()) - assert cfg["gateway"]["auth"]["mode"] == "trusted-proxy" - assert "password" not in cfg["gateway"]["auth"] - - -def test_bootstrap_defaults_dual_ksyun_catalog_when_unspecified(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - env = _build_base_env(tmpdir, str(config_path)) - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - env.pop("OPENCLAW_DEFAULT_MODEL", None) - env.pop("OPENCLAW_MODEL_CATALOG_JSON", None) - - result = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode == 0, result.stderr or result.stdout - cfg = json.loads(config_path.read_text()) - assert cfg["agents"]["defaults"]["model"]["primary"] == "ksyun/glm-5.1" - assert cfg["agents"]["defaults"]["model"]["fallbacks"] == ["ksyun/kimi-k2.6"] - assert cfg["agents"]["defaults"]["imageModel"]["primary"] == "ksyun/kimi-k2.6" - models = cfg["models"]["providers"]["ksyun"]["models"] - assert [item["id"] for item in models] == ["glm-5.1", "kimi-k2.6"] - assert models[0]["input"] == ["text"] - assert models[1]["input"] == ["text", "image"] - _assert_model_token_defaults(models) - selectable = cfg["agents"]["defaults"]["models"] - assert "ksyun/glm-5.1" in selectable - assert "ksyun/kimi-k2.6" in selectable - - -def test_bootstrap_global_model_preference_keeps_dual_ksyun_catalog(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - env = _build_base_env(tmpdir, str(config_path)) - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - env["OPENAI_MODEL_NAME"] = "glm-5.1" - env.pop("OPENCLAW_DEFAULT_MODEL", None) - env.pop("OPENCLAW_MODEL_CATALOG_JSON", None) - - result = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode == 0, result.stderr or result.stdout - cfg = json.loads(config_path.read_text()) - assert cfg["agents"]["defaults"]["model"]["primary"] == "ksyun/glm-5.1" - assert cfg["agents"]["defaults"]["model"]["fallbacks"] == ["ksyun/kimi-k2.6"] - assert cfg["agents"]["defaults"]["imageModel"]["primary"] == "ksyun/kimi-k2.6" - models = cfg["models"]["providers"]["ksyun"]["models"] - assert [item["id"] for item in models] == ["glm-5.1", "kimi-k2.6"] - assert models[0]["input"] == ["text"] - assert models[1]["input"] == ["text", "image"] - _assert_model_token_defaults(models) - - -def test_bootstrap_openclaw_default_model_alias_keeps_dual_catalog(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - env = _build_base_env(tmpdir, str(config_path)) - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - env["OPENCLAW_DEFAULT_MODEL"] = "ksyun/glm-5.1" - env.pop("OPENCLAW_MODEL_CATALOG_JSON", None) - - result = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode == 0, result.stderr or result.stdout - cfg = json.loads(config_path.read_text()) - assert cfg["agents"]["defaults"]["model"]["primary"] == "ksyun/glm-5.1" - assert cfg["agents"]["defaults"]["model"]["fallbacks"] == ["ksyun/kimi-k2.6"] - assert cfg["agents"]["defaults"]["imageModel"]["primary"] == "ksyun/kimi-k2.6" - models = cfg["models"]["providers"]["ksyun"]["models"] - assert [item["id"] for item in models] == ["glm-5.1", "kimi-k2.6"] - assert models[0]["input"] == ["text"] - assert models[1]["input"] == ["text", "image"] - _assert_model_token_defaults(models) - selectable = cfg["agents"]["defaults"]["models"] - assert "ksyun/glm-5.1" in selectable - assert "ksyun/kimi-k2.6" in selectable - - -def test_bootstrap_preserves_existing_defaults_model_fallbacks_and_image_model(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - config_path.write_text( - json.dumps( - { - "agents": { - "defaults": { - "model": { - "primary": "ksyun/deepseek-v3", - "fallbacks": ["ksyun/glm-5.1"], - }, - "imageModel": { - "primary": "ksyun/kimi-k2.6", - }, - } - } - } - ) - ) - env = _build_base_env(tmpdir, str(config_path)) - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - env["OPENCLAW_DEFAULT_MODEL"] = "ksyun/glm-5.1" - env.pop("OPENCLAW_MODEL_CATALOG_JSON", None) - - result = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode == 0, result.stderr or result.stdout - cfg = json.loads(config_path.read_text()) - assert cfg["agents"]["defaults"]["model"]["primary"] == "ksyun/deepseek-v3" - assert cfg["agents"]["defaults"]["model"]["fallbacks"] == ["ksyun/glm-5.1"] - assert cfg["agents"]["defaults"]["imageModel"]["primary"] == "ksyun/kimi-k2.6" - - -def test_bootstrap_prefers_glm51_as_default_primary_when_catalog_is_present(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - env = _build_base_env(tmpdir, str(config_path)) - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - env.pop("OPENCLAW_DEFAULT_MODEL", None) - env.pop("OPENAI_MODEL_NAME", None) - env["OPENCLAW_MODEL_CATALOG_JSON"] = ( - '[{"id":"kimi-k2.6"},{"id":"glm-5.1"}]' - ) - - result = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode == 0, result.stderr or result.stdout - cfg = json.loads(config_path.read_text()) - assert cfg["agents"]["defaults"]["model"]["primary"] == "ksyun/glm-5.1" - models = cfg["models"]["providers"]["ksyun"]["models"] - assert [item["id"] for item in models] == ["kimi-k2.6", "glm-5.1"] - - -def test_bootstrap_appends_primary_model_when_default_catalog_does_not_include_it(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - env = _build_base_env(tmpdir, str(config_path)) - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - env["OPENAI_MODEL_NAME"] = "ksyun/deepseek-v3" - env.pop("OPENCLAW_DEFAULT_MODEL", None) - env.pop("OPENCLAW_MODEL_CATALOG_JSON", None) - - result = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode == 0, result.stderr or result.stdout - cfg = json.loads(config_path.read_text()) - assert cfg["agents"]["defaults"]["model"]["primary"] == "ksyun/deepseek-v3" - models = cfg["models"]["providers"]["ksyun"]["models"] - assert [item["id"] for item in models] == ["glm-5.1", "kimi-k2.6", "deepseek-v3"] - _assert_model_token_defaults(models) - - -def test_bootstrap_qualifies_namespaced_model_selection_refs_when_provider_differs_from_prefix(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - env = _build_base_env(tmpdir, str(config_path)) - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - env["OPENCLAW_MODEL_PROVIDER_ID"] = "hanhai" - env["OPENAI_MODEL_NAME"] = "Qzhou/glm-5" - env.pop("OPENCLAW_DEFAULT_MODEL", None) - env["OPENCLAW_MODEL_CATALOG_JSON"] = json.dumps( - [ - { - "id": "Qzhou/glm-5", - "name": "glm-5", - "api": "openai-completions", - "reasoning": False, - "input": ["text"], - }, - { - "id": "Qzhou/kimi-k2.6", - "name": "kimi-k2.6", - "api": "openai-completions", - "reasoning": False, - "input": ["text", "image"], - }, - ] - ) - - result = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode == 0, result.stderr or result.stdout - cfg = json.loads(config_path.read_text()) - assert cfg["agents"]["defaults"]["model"]["primary"] == "hanhai/Qzhou/glm-5" - assert cfg["agents"]["defaults"]["model"]["fallbacks"] == ["hanhai/Qzhou/kimi-k2.6"] - assert cfg["agents"]["defaults"]["imageModel"]["primary"] == "hanhai/Qzhou/kimi-k2.6" - models = cfg["models"]["providers"]["hanhai"]["models"] - assert [item["id"] for item in models] == ["Qzhou/glm-5", "Qzhou/kimi-k2.6"] - selectable = cfg["agents"]["defaults"]["models"] - assert sorted(selectable) == ["hanhai/Qzhou/glm-5", "hanhai/Qzhou/kimi-k2.6"] - - -def test_bootstrap_qualifies_namespaced_model_selection_refs_without_catalog_when_provider_differs(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - env = _build_base_env(tmpdir, str(config_path)) - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - env["OPENCLAW_MODEL_PROVIDER_ID"] = "hanhai" - env["OPENAI_MODEL_NAME"] = "Qzhou/glm-5" - env.pop("OPENCLAW_DEFAULT_MODEL", None) - env.pop("OPENCLAW_MODEL_CATALOG_JSON", None) - - result = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode == 0, result.stderr or result.stdout - cfg = json.loads(config_path.read_text()) - assert cfg["agents"]["defaults"]["model"]["primary"] == "hanhai/Qzhou/glm-5" - assert "fallbacks" not in cfg["agents"]["defaults"]["model"] - models = cfg["models"]["providers"]["hanhai"]["models"] - assert [item["id"] for item in models] == ["Qzhou/glm-5"] - selectable = cfg["agents"]["defaults"]["models"] - assert sorted(selectable) == ["hanhai/Qzhou/glm-5"] - - -def test_bootstrap_migrates_legacy_namespaced_model_selection_refs_for_custom_provider(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - config_path.write_text( - json.dumps( - { - "agents": { - "defaults": { - "model": { - "primary": "Qzhou/glm-5", - "fallbacks": ["Qzhou/kimi-k2.6"], - }, - "imageModel": { - "primary": "Qzhou/kimi-k2.6", - }, - "models": { - "Qzhou/glm-5": {}, - "Qzhou/kimi-k2.6": {}, - }, - } - }, - "models": { - "providers": { - "hanhai": { - "models": [ - { - "id": "Qzhou/glm-5", - "name": "glm-5", - "api": "openai-completions", - "reasoning": False, - "input": ["text"], - }, - { - "id": "Qzhou/kimi-k2.6", - "name": "kimi-k2.6", - "api": "openai-completions", - "reasoning": False, - "input": ["text", "image"], - }, - ] - } - } - }, - } - ) - ) - env = _build_base_env(tmpdir, str(config_path)) - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - env["OPENCLAW_MODEL_PROVIDER_ID"] = "hanhai" - env["OPENAI_MODEL_NAME"] = "Qzhou/glm-5" - env.pop("OPENCLAW_DEFAULT_MODEL", None) - env["OPENCLAW_MODEL_CATALOG_JSON"] = json.dumps( - [ - { - "id": "Qzhou/glm-5", - "name": "glm-5", - "api": "openai-completions", - "reasoning": False, - "input": ["text"], - }, - { - "id": "Qzhou/kimi-k2.6", - "name": "kimi-k2.6", - "api": "openai-completions", - "reasoning": False, - "input": ["text", "image"], - }, - ] - ) - - result = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode == 0, result.stderr or result.stdout - cfg = json.loads(config_path.read_text()) - assert cfg["agents"]["defaults"]["model"]["primary"] == "hanhai/Qzhou/glm-5" - assert cfg["agents"]["defaults"]["model"]["fallbacks"] == ["hanhai/Qzhou/kimi-k2.6"] - assert cfg["agents"]["defaults"]["imageModel"]["primary"] == "hanhai/Qzhou/kimi-k2.6" - assert sorted(cfg["agents"]["defaults"]["models"]) == [ - "hanhai/Qzhou/glm-5", - "hanhai/Qzhou/kimi-k2.6", - ] - models = cfg["models"]["providers"]["hanhai"]["models"] - assert [item["id"] for item in models] == ["Qzhou/glm-5", "Qzhou/kimi-k2.6"] - - -def test_bootstrap_disables_builtin_web_search_by_default(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - env = _build_base_env(tmpdir, str(config_path)) - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - - result = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode == 0, result.stderr or result.stdout - cfg = json.loads(config_path.read_text()) - assert cfg["tools"]["web"]["search"]["enabled"] is False - assert cfg["tools"]["web"]["fetch"]["enabled"] is False - assert "provider" not in cfg["tools"]["web"]["search"] - assert "plugins" not in cfg or "perplexity" not in cfg.get("plugins", {}).get("entries", {}) - - -def test_bootstrap_cleans_up_legacy_auto_builtin_web_search(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - config_path.write_text( - json.dumps( - { - "tools": { - "web": { - "search": { - "enabled": True, - "provider": "perplexity", - } - } - }, - "plugins": { - "entries": { - "perplexity": { - "config": { - "webSearch": { - "baseUrl": "http://example.test/v1", - "model": "deepseek-v3.2", - "apiKey": { - "source": "file", - "provider": "default", - "id": "/providers/ksyun/apiKey", - }, - } - } - } - } - }, - } - ) - ) - env = _build_base_env(tmpdir, str(config_path)) - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - - result = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode == 0, result.stderr or result.stdout - cfg = json.loads(config_path.read_text()) - assert cfg["tools"]["web"]["search"]["enabled"] is False - assert "provider" not in cfg["tools"]["web"]["search"] - assert "plugins" not in cfg or "perplexity" not in cfg.get("plugins", {}).get("entries", {}) - - -def test_bootstrap_preserves_explicit_builtin_web_search_provider(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - config_path.write_text( - json.dumps( - { - "tools": { - "web": { - "search": { - "enabled": True, - "provider": "brave", - } - } - } - } - ) - ) - env = _build_base_env(tmpdir, str(config_path)) - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - - result = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode == 0, result.stderr or result.stdout - cfg = json.loads(config_path.read_text()) - assert cfg["tools"]["web"]["search"]["enabled"] is True - assert cfg["tools"]["web"]["search"]["provider"] == "brave" - assert "plugins" not in cfg or "perplexity" not in cfg.get("plugins", {}).get("entries", {}) - - -def test_bootstrap_disables_legacy_builtin_web_fetch_for_default_ksyun_runtime(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - config_path.write_text( - json.dumps( - { - "tools": { - "web": { - "fetch": { - "enabled": True, - } - } - } - } - ) - ) - env = _build_base_env(tmpdir, str(config_path)) - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - env["OPENCLAW_MODEL_BASE_URL"] = "https://kspmas.ksyun.com/v1/" - - result = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode == 0, result.stderr or result.stdout - cfg = json.loads(config_path.read_text()) - assert cfg["tools"]["web"]["fetch"]["enabled"] is False - - -def test_bootstrap_preserves_explicit_builtin_web_fetch_enablement_via_env(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - env = _build_base_env(tmpdir, str(config_path)) - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - env["OPENCLAW_WEB_FETCH_ENABLED"] = "true" - - result = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode == 0, result.stderr or result.stdout - cfg = json.loads(config_path.read_text()) - assert cfg["tools"]["web"]["fetch"]["enabled"] is True - - -def test_bootstrap_enables_builtin_browser_by_default(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - env = _build_base_env(tmpdir, str(config_path)) - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - - result = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode == 0, result.stderr or result.stdout - cfg = json.loads(config_path.read_text()) - assert cfg["browser"]["enabled"] is True - assert cfg["browser"]["headless"] is True - assert cfg["browser"]["noSandbox"] is True - assert cfg["browser"]["ssrfPolicy"] == {"dangerouslyAllowPrivateNetwork": True} - - -def test_bootstrap_preserves_explicit_builtin_browser_enablement_in_config(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - config_path.write_text( - json.dumps( - { - "browser": { - "enabled": True, - "headless": False, - "noSandbox": False, - "ssrfPolicy": { - "dangerouslyAllowPrivateNetwork": False, - "hostnameAllowlist": ["docs.example.com"], - }, - } - } - ) - ) - env = _build_base_env(tmpdir, str(config_path)) - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - - result = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode == 0, result.stderr or result.stdout - cfg = json.loads(config_path.read_text()) - assert cfg["browser"]["enabled"] is True - assert cfg["browser"]["headless"] is False - assert cfg["browser"]["noSandbox"] is False - assert cfg["browser"]["ssrfPolicy"] == { - "dangerouslyAllowPrivateNetwork": False, - "hostnameAllowlist": ["docs.example.com"], - } - - -def test_bootstrap_allows_reenabling_builtin_browser_via_env(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - env = _build_base_env(tmpdir, str(config_path)) - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - env["OPENCLAW_BROWSER_ENABLED"] = "true" - - result = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode == 0, result.stderr or result.stdout - cfg = json.loads(config_path.read_text()) - assert cfg["browser"]["enabled"] is True - - -def test_bootstrap_allows_disabling_builtin_browser_via_env(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - env = _build_base_env(tmpdir, str(config_path)) - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - env["OPENCLAW_BROWSER_ENABLED"] = "false" - - result = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode == 0, result.stderr or result.stdout - cfg = json.loads(config_path.read_text()) - assert cfg["browser"]["enabled"] is False - - -def test_bootstrap_keeps_browser_ssrf_policy_strict_in_strict_mode(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - env = _build_base_env(tmpdir, str(config_path)) - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - env["OPENCLAW_EXEC_STRICT_MODE"] = "true" - - result = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode == 0, result.stderr or result.stdout - cfg = json.loads(config_path.read_text()) - assert "ssrfPolicy" not in cfg["browser"] - - -def test_bootstrap_recovers_from_blank_secret_ref_env_overrides(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - env = _build_base_env(tmpdir, str(config_path)) - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - env["OPENCLAW_MODEL_API_KEY_SECRET_SOURCE"] = " file " - env["OPENCLAW_MODEL_API_KEY_SECRET_PROVIDER"] = " default " - env["OPENCLAW_MODEL_API_KEY_SECRET_FILE_PATH"] = " " - env["OPENCLAW_MODEL_API_KEY_SECRET_ID"] = " " - - result = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode == 0, result.stderr or result.stdout - cfg = json.loads(config_path.read_text()) - assert cfg["secrets"]["providers"]["default"] == { - "source": "file", - "path": str(Path(tmpdir) / "secrets.json"), - "mode": "json", - } - assert cfg["models"]["providers"]["ksyun"]["apiKey"] == { - "source": "file", - "provider": "default", - "id": "/providers/ksyun/apiKey", - } - - -def test_bootstrap_syncs_kdocs_by_default_without_token(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - preset_skills_dir = Path(tmpdir) / "preset-skills" - for skill_name in [ - "clawhub-store", - "agent-browser-clawdbot", - "kdocs", - "tavily-search", - ]: - skill_dir = preset_skills_dir / skill_name - skill_dir.mkdir(parents=True, exist_ok=True) - (skill_dir / "SKILL.md").write_text(f"{skill_name}\n") - - env = _build_base_env(tmpdir, str(config_path)) - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - env["OPENCLAW_PRESET_SKILLS_DIR"] = str(preset_skills_dir) - - result = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode == 0, result.stderr or result.stdout - synced_skills = sorted( - path.name for path in (Path(tmpdir) / "skills").iterdir() if path.is_dir() - ) - assert synced_skills == [ - "agent-browser-clawdbot", - "clawhub-store", - "kdocs", - ] - - -def test_bootstrap_removes_previously_synced_removed_preset_skill_when_unchanged(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - preset_skills_dir = Path(tmpdir) / "preset-skills" - skills_dir = Path(tmpdir) / "skills" - managed_find_skills_dir = skills_dir / "find-skills" - managed_find_skills_dir.mkdir(parents=True, exist_ok=True) - (managed_find_skills_dir / "SKILL.md").write_text("legacy managed find-skills\n") - - cache_dir = Path(tmpdir) / ".bootstrap-cache" / "preset-skills" - cache_dir.mkdir(parents=True, exist_ok=True) - (cache_dir / "find-skills.sig").write_text( - _compute_directory_signature(managed_find_skills_dir) + "\n" - ) - - for skill_name in [ - "clawhub-store", - "agent-browser-clawdbot", - "kdocs", - ]: - skill_dir = preset_skills_dir / skill_name - skill_dir.mkdir(parents=True, exist_ok=True) - (skill_dir / "SKILL.md").write_text(f"{skill_name}\n") - - env = _build_base_env(tmpdir, str(config_path)) - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - env["OPENCLAW_PRESET_SKILLS_DIR"] = str(preset_skills_dir) - - result = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode == 0, result.stderr or result.stdout - assert not managed_find_skills_dir.exists() - assert not (cache_dir / "find-skills.sig").exists() - - -def test_bootstrap_preserves_user_managed_removed_preset_skill(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - preset_skills_dir = Path(tmpdir) / "preset-skills" - user_find_skills_dir = Path(tmpdir) / "skills" / "find-skills" - user_find_skills_dir.mkdir(parents=True, exist_ok=True) - (user_find_skills_dir / "SKILL.md").write_text("custom user find-skills\n") - (user_find_skills_dir / "README.md").write_text("owned-by-user\n") - - for skill_name in [ - "clawhub-store", - "agent-browser-clawdbot", - "kdocs", - ]: - skill_dir = preset_skills_dir / skill_name - skill_dir.mkdir(parents=True, exist_ok=True) - (skill_dir / "SKILL.md").write_text(f"{skill_name}\n") - - env = _build_base_env(tmpdir, str(config_path)) - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - env["OPENCLAW_PRESET_SKILLS_DIR"] = str(preset_skills_dir) - - result = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode == 0, result.stderr or result.stdout - assert (user_find_skills_dir / "SKILL.md").read_text() == "custom user find-skills\n" - assert (user_find_skills_dir / "README.md").read_text() == "owned-by-user\n" - assert "preserved user-managed skill find-skills" in result.stderr - - -def test_bootstrap_removes_previously_synced_multi_search_skill_when_no_longer_default(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - preset_skills_dir = Path(tmpdir) / "preset-skills" - skills_dir = Path(tmpdir) / "skills" - managed_multi_search_dir = skills_dir / "multi-search-engine" - managed_multi_search_dir.mkdir(parents=True, exist_ok=True) - (managed_multi_search_dir / "SKILL.md").write_text("legacy managed multi-search-engine\n") - - cache_dir = Path(tmpdir) / ".bootstrap-cache" / "preset-skills" - cache_dir.mkdir(parents=True, exist_ok=True) - (cache_dir / "multi-search-engine.sig").write_text( - _compute_directory_signature(managed_multi_search_dir) + "\n" - ) - - for skill_name in [ - "clawhub-store", - "agent-browser-clawdbot", - "kdocs", - ]: - skill_dir = preset_skills_dir / skill_name - skill_dir.mkdir(parents=True, exist_ok=True) - (skill_dir / "SKILL.md").write_text(f"{skill_name}\n") - - env = _build_base_env(tmpdir, str(config_path)) - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - env["OPENCLAW_PRESET_SKILLS_DIR"] = str(preset_skills_dir) - - result = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode == 0, result.stderr or result.stdout - assert not managed_multi_search_dir.exists() - assert not (cache_dir / "multi-search-engine.sig").exists() - - -def test_bootstrap_removes_legacy_multi_search_skill_by_source_match_without_sig(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - preset_skills_dir = Path(tmpdir) / "preset-skills" - skills_dir = Path(tmpdir) / "skills" - bundled_multi_search_dir = preset_skills_dir / "multi-search-engine" - managed_multi_search_dir = skills_dir / "multi-search-engine" - - bundled_multi_search_dir.mkdir(parents=True, exist_ok=True) - (bundled_multi_search_dir / "SKILL.md").write_text("legacy bundled multi-search-engine\n") - managed_multi_search_dir.mkdir(parents=True, exist_ok=True) - (managed_multi_search_dir / "SKILL.md").write_text("legacy bundled multi-search-engine\n") - - for skill_name in [ - "clawhub-store", - "agent-browser-clawdbot", - "kdocs", - ]: - skill_dir = preset_skills_dir / skill_name - skill_dir.mkdir(parents=True, exist_ok=True) - (skill_dir / "SKILL.md").write_text(f"{skill_name}\n") - - env = _build_base_env(tmpdir, str(config_path)) - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - env["OPENCLAW_PRESET_SKILLS_DIR"] = str(preset_skills_dir) - - result = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode == 0, result.stderr or result.stdout - assert not managed_multi_search_dir.exists() - assert "removed deprecated bundled skill multi-search-engine (legacy source match)" in result.stderr - - -def test_bootstrap_syncs_multi_search_skill_when_explicitly_allowlisted(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - preset_skills_dir = Path(tmpdir) / "preset-skills" - for skill_name in [ - "clawhub-store", - "agent-browser-clawdbot", - "kdocs", - "multi-search-engine", - ]: - skill_dir = preset_skills_dir / skill_name - skill_dir.mkdir(parents=True, exist_ok=True) - (skill_dir / "SKILL.md").write_text(f"{skill_name}\n") - - env = _build_base_env(tmpdir, str(config_path)) - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - env["OPENCLAW_PRESET_SKILLS_DIR"] = str(preset_skills_dir) - env["OPENCLAW_PRESET_SKILLS_ALLOWLIST"] = ( - "clawhub-store,agent-browser-clawdbot,kdocs,multi-search-engine" - ) - - result = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode == 0, result.stderr or result.stdout - synced_skills = sorted( - path.name for path in (Path(tmpdir) / "skills").iterdir() if path.is_dir() - ) - assert synced_skills == [ - "agent-browser-clawdbot", - "clawhub-store", - "kdocs", - "multi-search-engine", - ] - - -def test_bootstrap_enforces_exec_approval_defaults(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - approvals_path = Path(tmpdir) / "exec-approvals.json" - workspace_path = Path(tmpdir) / "workspace" - env = _build_base_env(tmpdir, str(config_path)) - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - - result = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode == 0, result.stderr or result.stdout - - cfg = json.loads(config_path.read_text()) - assert cfg["tools"]["fs"]["workspaceOnly"] is False - assert cfg["tools"]["exec"]["host"] == "gateway" - assert cfg["tools"]["exec"]["security"] == "full" - assert cfg["tools"]["exec"]["ask"] == "off" - assert "pathPrepend" not in cfg["tools"]["exec"] - assert cfg["tools"]["elevated"]["enabled"] is False - assert cfg["agents"]["defaults"]["workspace"] == str(workspace_path) - - approvals = json.loads(approvals_path.read_text()) - assert approvals["defaults"] == { - "security": "full", - "ask": "off", - "askFallback": "full", - "autoAllowSkills": False, - } - assert "agents" not in approvals or "main" not in approvals.get("agents", {}) - assert not (workspace_path / "SOUL.md").exists() - assert not (workspace_path / "AGENTS.md").exists() - assert not (workspace_path / "MEMORY.md").exists() - assert not (workspace_path / "USER.MD").exists() - assert not (workspace_path / "TOOLS.md").exists() - - -def test_bootstrap_strict_mode_keeps_security_templates(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - workspace_path = Path(tmpdir) / "workspace" - env = _build_base_env(tmpdir, str(config_path)) - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - env["OPENCLAW_EXEC_STRICT_MODE"] = "true" - - result = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode == 0, result.stderr or result.stdout - assert (workspace_path / "SOUL.md").exists() - assert (workspace_path / "AGENTS.md").exists() - assert (workspace_path / "MEMORY.md").exists() - assert (workspace_path / "USER.MD").exists() - assert (workspace_path / "TOOLS.md").exists() - - -def test_bootstrap_relaxed_mode_cleans_legacy_builtin_security_templates(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - workspace_path = Path(tmpdir) / "workspace" - workspace_path.mkdir(parents=True, exist_ok=True) - (workspace_path / "SOUL.md").write_text("security soul\n") - (workspace_path / "AGENTS.md").write_text("security agents\n") - (workspace_path / "MEMORY.md").write_text("persistent memory\n") - (workspace_path / "USER.MD").write_text("user preferences\n") - (workspace_path / "TOOLS.md").write_text("tool notes\n") - env = _build_base_env(tmpdir, str(config_path)) - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - - result = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode == 0, result.stderr or result.stdout - assert not (workspace_path / "SOUL.md").exists() - assert not (workspace_path / "AGENTS.md").exists() - assert not (workspace_path / "MEMORY.md").exists() - assert not (workspace_path / "USER.MD").exists() - assert not (workspace_path / "TOOLS.md").exists() - - -def test_bootstrap_relaxed_mode_preserves_customized_security_templates(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - workspace_path = Path(tmpdir) / "workspace" - workspace_path.mkdir(parents=True, exist_ok=True) - soul_path = workspace_path / "SOUL.md" - agents_path = workspace_path / "AGENTS.md" - memory_path = workspace_path / "MEMORY.md" - user_path = workspace_path / "USER.MD" - tools_path = workspace_path / "TOOLS.md" - soul_path.write_text("my custom soul\n") - agents_path.write_text("my custom agents\n") - memory_path.write_text("my custom memory\n") - user_path.write_text("my custom user prefs\n") - tools_path.write_text("my custom tools\n") - env = _build_base_env(tmpdir, str(config_path)) - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - - result = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode == 0, result.stderr or result.stdout - assert soul_path.read_text() == "my custom soul\n" - assert agents_path.read_text() == "my custom agents\n" - assert memory_path.read_text() == "my custom memory\n" - assert user_path.read_text() == "my custom user prefs\n" - assert tools_path.read_text() == "my custom tools\n" - - -def test_bootstrap_preserves_existing_memory_file(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - workspace_path = Path(tmpdir) / "workspace" - workspace_path.mkdir(parents=True, exist_ok=True) - memory_path = workspace_path / "MEMORY.md" - memory_path.write_text("user customized memory\n") - env = _build_base_env(tmpdir, str(config_path)) - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - - result = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode == 0, result.stderr or result.stdout - assert memory_path.read_text() == "user customized memory\n" - - -def test_bootstrap_strict_mode_restores_allowlist_defaults(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - approvals_path = Path(tmpdir) / "exec-approvals.json" - env = _build_base_env(tmpdir, str(config_path)) - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - env["OPENCLAW_EXEC_STRICT_MODE"] = "true" - - result = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode == 0, result.stderr or result.stdout - cfg = json.loads(config_path.read_text()) - assert cfg["tools"]["exec"]["security"] == "allowlist" - approvals = json.loads(approvals_path.read_text()) - assert approvals["defaults"]["security"] == "allowlist" - allowlist = approvals["agents"]["main"]["allowlist"] - patterns = {entry["pattern"] for entry in allowlist} - command_names = {Path(pattern).name for pattern in patterns} - assert str(Path(tmpdir) / "safe-bin" / "bash-safe") in patterns - assert "curl" in command_names - assert "jq" in command_names - assert "openclaw" in command_names - assert "agent-browser" in command_names - assert "yt-dlp" not in command_names - assert "gh" not in command_names - assert "xreach" not in command_names - - -def test_multi_search_skill_avoids_curl_head_broken_pipe_pattern(): - skill_path = ( - REPO_ROOT - / "deploy" - / "openclaw" - / "preset-skills" - / "multi-search-engine" - / "SKILL.md" - ) - - content = skill_path.read_text() - - assert "curl -sS \"https://www.baidu.com/s?wd=QUERY\" | head -200" not in content - - -def test_multi_search_skill_prefers_cn_bing_and_builtin_browser_first(): - skill_path = ( - REPO_ROOT - / "deploy" - / "openclaw" - / "preset-skills" - / "multi-search-engine" - / "SKILL.md" - ) - - content = skill_path.read_text() - - assert "built-in fetch tool" not in content - assert "start with Bing CN / Sogou / 360 before trying Baidu" in content - assert "prefer the built-in `browser` tool first in this runtime" in content - assert "agent-browser open \"https://cn.bing.com/search?q=QUERY&ensearch=0\"" in content - assert "browser navigate https://www.baidu.com/s?wd=QUERY" not in content - assert "curl -sS \"https://cn.bing.com/search?q=QUERY\" | head -200" not in content - assert "Failure writing output to destination" in content - - -def test_bootstrap_merges_custom_exec_allowlist_patterns(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - approvals_path = Path(tmpdir) / "exec-approvals.json" - env = _build_base_env(tmpdir, str(config_path)) - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - env["OPENCLAW_EXEC_ALLOWLIST"] = "/opt/tools/read-only,/custom/bin/inspect" - - result = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode == 0, result.stderr or result.stdout - approvals = json.loads(approvals_path.read_text()) - patterns = {entry["pattern"] for entry in approvals["agents"]["main"]["allowlist"]} - assert "/opt/tools/read-only" in patterns - assert "/custom/bin/inspect" in patterns - - -def test_bootstrap_keeps_model_api_key_in_gateway_process_env_for_deferred_auth_paths(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - captured_env_path = Path(tmpdir) / "gateway.env" - fake_bin_dir = Path(tmpdir) / "bin" - fake_node_path = fake_bin_dir / "node" - real_node_path = subprocess.run( - ["bash", "-lc", "command -v node"], - capture_output=True, - text=True, - check=True, - ).stdout.strip() - fake_bin_dir.mkdir() - fake_node_path.write_text( - "#!/bin/sh\n" - 'if [ "$1" = "openclaw.mjs" ] && [ "$2" = "gateway" ] && [ "$3" = "run" ]; then\n' - ' printenv | sort > "${BOOTSTRAP_CAPTURE_ENV_PATH}"\n' - " exit 0\n" - "fi\n" - f'exec "{real_node_path}" "$@"\n' - ) - fake_node_path.chmod(0o755) - - env = _build_base_env(tmpdir, str(config_path)) - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - env["BOOTSTRAP_CAPTURE_ENV_PATH"] = str(captured_env_path) - env["OPENCLAW_WORKSPACE_FILES_ENABLED"] = "0" - env["OPENCLAW_RUNTIME_PROXY_ENABLED"] = "0" - env["PATH"] = f"{fake_bin_dir}:{env['PATH']}" - env.pop("OPENCLAW_BOOTSTRAP_ONLY", None) - - process = subprocess.Popen( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - - deadline = time.monotonic() + 5 - while not captured_env_path.exists() and time.monotonic() < deadline: - if process.poll() is not None: - break - time.sleep(0.05) - - assert captured_env_path.exists(), process.stderr.read() or process.stdout.read() - - process.terminate() - process.communicate(timeout=5) - - captured_env = captured_env_path.read_text() - assert "OPENCLAW_MODEL_API_KEY=dummy-secret-value" in captured_env - assert "OPENAI_API_KEY=" not in captured_env - assert "OPENCLAW_INTERNAL_TRUSTED_PROXY_USER=openclaw-backend" in captured_env - assert "OPENCLAW_INTERNAL_TRUSTED_PROXY_USER_HEADER=x-forwarded-user" in captured_env - assert "CLAWHUB_SITE=https://cn.clawhub-mirror.com" in captured_env - assert "CLAWHUB_REGISTRY=https://cn.clawhub-mirror.com" in captured_env - assert "NPM_CONFIG_REGISTRY=https://registry.npmmirror.com" in captured_env - assert "PIP_INDEX_URL=https://mirrors.aliyun.com/pypi/simple" in captured_env - assert "PIP_TRUSTED_HOST=mirrors.aliyun.com" in captured_env - assert "UV_INDEX_URL=https://mirrors.aliyun.com/pypi/simple" in captured_env - assert "PLAYWRIGHT_DOWNLOAD_HOST=https://npmmirror.com/mirrors/playwright" in captured_env - assert "PUPPETEER_DOWNLOAD_BASE_URL=https://npmmirror.com/mirrors/chrome-for-testing" in captured_env - - -def test_bootstrap_does_not_relaunch_gateway_after_upstream_handoff_restart(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - gateway_count_path = Path(tmpdir) / "gateway-count.txt" - successor_pid_path = Path(tmpdir) / "gateway-successor.pid" - fake_bin_dir = Path(tmpdir) / "bin" - fake_node_path = fake_bin_dir / "node" - real_node_path = subprocess.run( - ["bash", "-lc", "command -v node"], - capture_output=True, - text=True, - check=True, - ).stdout.strip() - fake_bin_dir.mkdir() - fake_node_path.write_text( - "#!/bin/sh\n" - 'if [ "$1" = "openclaw.mjs" ] && [ "$2" = "gateway" ] && [ "$3" = "run" ]; then\n' - ' count=0\n' - ' if [ -f "${BOOTSTRAP_GATEWAY_COUNT_PATH}" ]; then\n' - ' count="$(cat "${BOOTSTRAP_GATEWAY_COUNT_PATH}")"\n' - " fi\n" - ' count=$((count + 1))\n' - ' printf "%s\\n" "${count}" > "${BOOTSTRAP_GATEWAY_COUNT_PATH}"\n' - ' if [ "${count}" -eq 1 ]; then\n' - ' python3 -m http.server "${OPENCLAW_GATEWAY_PORT}" --bind 127.0.0.1 >/dev/null 2>&1 &\n' - ' printf "%s\\n" "$!" > "${BOOTSTRAP_SUCCESSOR_PID_PATH}"\n' - " exit 0\n" - " fi\n" - " exit 97\n" - "fi\n" - f'exec "{real_node_path}" "$@"\n' - ) - fake_node_path.chmod(0o755) - - env = _build_base_env(tmpdir, str(config_path)) - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - env["BOOTSTRAP_GATEWAY_COUNT_PATH"] = str(gateway_count_path) - env["BOOTSTRAP_SUCCESSOR_PID_PATH"] = str(successor_pid_path) - env["OPENCLAW_GATEWAY_PORT"] = "18080" - env["OPENCLAW_GATEWAY_LOCAL_RESTART_MAX"] = "0" - env["OPENCLAW_WORKSPACE_FILES_ENABLED"] = "0" - env["PATH"] = f"{fake_bin_dir}:{env['PATH']}" - env.pop("OPENCLAW_BOOTSTRAP_ONLY", None) - - process = subprocess.Popen( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - - try: - deadline = time.monotonic() + 5 - while ( - not gateway_count_path.exists() or not successor_pid_path.exists() - ) and time.monotonic() < deadline: - if process.poll() is not None: - break - time.sleep(0.05) - - assert gateway_count_path.exists(), process.stderr.read() or process.stdout.read() - assert successor_pid_path.exists(), process.stderr.read() or process.stdout.read() - - time.sleep(1.0) - assert process.poll() is None, process.stderr.read() or process.stdout.read() - assert gateway_count_path.read_text().strip() == "1" - finally: - if successor_pid_path.exists(): - successor_pid = successor_pid_path.read_text().strip() - if successor_pid: - subprocess.run( - ["kill", successor_pid], - check=False, - capture_output=True, - text=True, - ) - process.terminate() - try: - process.communicate(timeout=5) - except subprocess.TimeoutExpired: - process.kill() - process.communicate(timeout=5) - - -def test_bootstrap_runtime_proxy_moves_gateway_to_internal_port(): - bootstrap = BOOTSTRAP_SCRIPT.read_text(encoding="utf-8") - - assert 'RUNTIME_PROXY_ENABLED="${OPENCLAW_RUNTIME_PROXY_ENABLED:-true}"' in bootstrap - assert 'GATEWAY_INTERNAL_PORT="${OPENCLAW_GATEWAY_INTERNAL_PORT:-18080}"' in bootstrap - assert 'GATEWAY_LISTENER_PORT="${GATEWAY_INTERNAL_PORT:-18080}"' in bootstrap - assert 'OPENCLAW_GATEWAY_PROXY_BASE_URL="http://127.0.0.1:${GATEWAY_LISTENER_PORT}"' in bootstrap - assert "uvicorn openclaw_runtime_proxy_app:app" in bootstrap - assert '--port "${GATEWAY_PORT}"' in bootstrap - assert 'node openclaw.mjs gateway run --allow-unconfigured --bind "${BIND_MODE}" --port "${GATEWAY_LISTENER_PORT}"' in bootstrap - - -def test_bootstrap_writes_domestic_runtime_defaults_to_env_file(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - env = _build_base_env(tmpdir, str(config_path)) - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - - result = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode == 0, result.stderr or result.stdout - runtime_env = (Path(tmpdir) / ".env").read_text() - assert "CLAWHUB_SITE=https://cn.clawhub-mirror.com" in runtime_env - assert "CLAWHUB_REGISTRY=https://cn.clawhub-mirror.com" in runtime_env - assert "NPM_CONFIG_REGISTRY=https://registry.npmmirror.com" in runtime_env - assert "npm_config_registry=https://registry.npmmirror.com" in runtime_env - assert "YARN_NPM_REGISTRY_SERVER=https://registry.npmmirror.com" in runtime_env - assert "PIP_INDEX_URL=https://mirrors.aliyun.com/pypi/simple" in runtime_env - assert "PIP_TRUSTED_HOST=mirrors.aliyun.com" in runtime_env - assert "UV_INDEX_URL=https://mirrors.aliyun.com/pypi/simple" in runtime_env - assert "PLAYWRIGHT_DOWNLOAD_HOST=https://npmmirror.com/mirrors/playwright" in runtime_env - assert "PUPPETEER_DOWNLOAD_BASE_URL=https://npmmirror.com/mirrors/chrome-for-testing" in runtime_env - - -def test_agent_browser_skill_prefers_domestic_examples(): - skill_path = ( - REPO_ROOT - / "deploy" - / "openclaw" - / "preset-skills" - / "agent-browser-clawdbot" - / "SKILL.md" - ) - - content = skill_path.read_text() - - assert "agent-browser open https://www.google.com" not in content - assert "agent-browser open https://www.baidu.com" not in content - assert "agent-browser open https://cn.bing.com/search?q=AI+agents&ensearch=0" in content - assert "https://www.bing.com/news/search?q=AI&mkt=zh-CN" in content - assert "This image already bundles `agent-browser`" in content - assert "NPM_CONFIG_REGISTRY" in content - assert "PLAYWRIGHT_DOWNLOAD_HOST" in content - assert "Built-in `browser` is enabled by default in this image." in content - assert "Use `web-safe search` / `web-safe read` only when a cheap read-only fallback is enough" in content - assert "The task is small enough that a one-off interactive browser session is simpler" not in content - - -def test_bootstrap_does_not_auto_register_exa_defaults(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - raw_bin_dir = Path(tmpdir) / "bin" - capture_path = Path(tmpdir) / "mcporter.log" - raw_bin_dir.mkdir(parents=True, exist_ok=True) - mcporter_path = raw_bin_dir / "mcporter" - mcporter_path.write_text( - "#!/bin/sh\n" - "if [ \"$1\" = \"config\" ] && [ \"$2\" = \"get\" ]; then\n" - " exit 1\n" - "fi\n" - "printf '%s\\n' \"$*\" >> \"$MCPORTER_CAPTURE_PATH\"\n" - "exit 0\n" - ) - mcporter_path.chmod(0o755) - - env = _build_base_env(tmpdir, str(config_path)) - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - env["MCPORTER_CAPTURE_PATH"] = str(capture_path) - - result = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode == 0, result.stderr or result.stdout - assert not capture_path.exists() - - -def test_bootstrap_seeds_and_auto_enables_bundled_weixin_plugin(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - default_extensions_dir = Path(tmpdir) / "default-extensions" / "openclaw-weixin" - default_extensions_dir.mkdir(parents=True, exist_ok=True) - (default_extensions_dir / "manifest.json").write_text('{"name":"openclaw-weixin"}\n') - - env = _build_base_env(tmpdir, str(config_path)) - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - env["OPENCLAW_DEFAULT_EXTENSIONS_DIR"] = str(Path(tmpdir) / "default-extensions") - - result = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode == 0, result.stderr or result.stdout - cfg = json.loads(config_path.read_text()) - assert (Path(tmpdir) / "extensions" / "openclaw-weixin" / "manifest.json").exists() - assert cfg["plugins"]["entries"]["openclaw-weixin"]["enabled"] is True - - -def test_bootstrap_does_not_seed_deferred_mem0_plugin_by_default(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - default_extensions_dir = Path(tmpdir) / "default-extensions" / "openclaw-mem0" - default_extensions_dir.mkdir(parents=True, exist_ok=True) - (default_extensions_dir / "manifest.json").write_text('{"name":"openclaw-mem0"}\n') - - env = _build_base_env(tmpdir, str(config_path)) - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - env["OPENCLAW_DEFAULT_EXTENSIONS_DIR"] = str(Path(tmpdir) / "default-extensions") - - result = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode == 0, result.stderr or result.stdout - assert not (Path(tmpdir) / "extensions" / "openclaw-mem0").exists() - cfg = json.loads(config_path.read_text()) - assert "openclaw-mem0" not in (cfg.get("plugins", {}).get("entries", {}) or {}) - - -def test_bootstrap_preserves_user_managed_weixin_plugin_in_existing_extension_dir(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - default_extensions_dir = Path(tmpdir) / "default-extensions" / "openclaw-weixin" - default_extensions_dir.mkdir(parents=True, exist_ok=True) - (default_extensions_dir / "manifest.json").write_text( - '{"name":"openclaw-weixin","version":"2.0.0"}\n' - ) - (default_extensions_dir / "README.md").write_text("bundled-v2\n") - - existing_extension_dir = Path(tmpdir) / "extensions" / "openclaw-weixin" - existing_extension_dir.mkdir(parents=True, exist_ok=True) - (existing_extension_dir / "manifest.json").write_text( - '{"name":"openclaw-weixin","version":"1.0.0"}\n' - ) - (existing_extension_dir / "stale.txt").write_text("old-plugin-layout\n") - - env = _build_base_env(tmpdir, str(config_path)) - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - env["OPENCLAW_DEFAULT_EXTENSIONS_DIR"] = str(Path(tmpdir) / "default-extensions") - - result = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode == 0, result.stderr or result.stdout - assert (existing_extension_dir / "manifest.json").read_text() == ( - '{"name":"openclaw-weixin","version":"1.0.0"}\n' - ) - assert not (existing_extension_dir / "README.md").exists() - assert (existing_extension_dir / "stale.txt").read_text() == "old-plugin-layout\n" - - -def test_bootstrap_upgrades_previously_synced_weixin_plugin_when_bundle_changes(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - default_extensions_dir = Path(tmpdir) / "default-extensions" / "openclaw-weixin" - default_extensions_dir.mkdir(parents=True, exist_ok=True) - env = _build_base_env(tmpdir, str(config_path)) - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - env["OPENCLAW_DEFAULT_EXTENSIONS_DIR"] = str(Path(tmpdir) / "default-extensions") - - (default_extensions_dir / "manifest.json").write_text( - '{"name":"openclaw-weixin","version":"1.0.0"}\n' - ) - (default_extensions_dir / "README.md").write_text("bundled-v1\n") - - first = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - assert first.returncode == 0, first.stderr or first.stdout - - (default_extensions_dir / "manifest.json").write_text( - '{"name":"openclaw-weixin","version":"2.0.0"}\n' - ) - (default_extensions_dir / "README.md").write_text("bundled-v2\n") - - second = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - assert second.returncode == 0, second.stderr or second.stdout - - existing_extension_dir = Path(tmpdir) / "extensions" / "openclaw-weixin" - assert (existing_extension_dir / "manifest.json").read_text() == ( - '{"name":"openclaw-weixin","version":"2.0.0"}\n' - ) - assert (existing_extension_dir / "README.md").read_text() == "bundled-v2\n" - - -def test_bootstrap_preserves_user_modified_weixin_plugin_after_initial_seed(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - default_extensions_dir = Path(tmpdir) / "default-extensions" / "openclaw-weixin" - default_extensions_dir.mkdir(parents=True, exist_ok=True) - env = _build_base_env(tmpdir, str(config_path)) - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - env["OPENCLAW_DEFAULT_EXTENSIONS_DIR"] = str(Path(tmpdir) / "default-extensions") - - (default_extensions_dir / "manifest.json").write_text( - '{"name":"openclaw-weixin","version":"1.0.0"}\n' - ) - - first = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - assert first.returncode == 0, first.stderr or first.stdout - - existing_extension_dir = Path(tmpdir) / "extensions" / "openclaw-weixin" - (existing_extension_dir / "manifest.json").write_text( - '{"name":"openclaw-weixin","version":"9.9.9-user"}\n' - ) - (existing_extension_dir / "USER.md").write_text("custom-user-plugin\n") - (default_extensions_dir / "manifest.json").write_text( - '{"name":"openclaw-weixin","version":"2.0.0"}\n' - ) - (default_extensions_dir / "README.md").write_text("bundled-v2\n") - - second = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - assert second.returncode == 0, second.stderr or second.stdout - - assert (existing_extension_dir / "manifest.json").read_text() == ( - '{"name":"openclaw-weixin","version":"9.9.9-user"}\n' - ) - assert (existing_extension_dir / "USER.md").read_text() == "custom-user-plugin\n" - assert not (existing_extension_dir / "README.md").exists() - - -def test_bootstrap_preserves_existing_weixin_plugin_disablement(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - default_extensions_dir = Path(tmpdir) / "default-extensions" / "openclaw-weixin" - default_extensions_dir.mkdir(parents=True, exist_ok=True) - (default_extensions_dir / "manifest.json").write_text('{"name":"openclaw-weixin"}\n') - config_path.write_text( - json.dumps( - { - "plugins": { - "entries": { - "openclaw-weixin": { - "enabled": False, - } - } - } - } - ) - ) - - env = _build_base_env(tmpdir, str(config_path)) - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - env["OPENCLAW_DEFAULT_EXTENSIONS_DIR"] = str(Path(tmpdir) / "default-extensions") - - result = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode == 0, result.stderr or result.stdout - cfg = json.loads(config_path.read_text()) - assert (Path(tmpdir) / "extensions" / "openclaw-weixin" / "manifest.json").exists() - assert cfg["plugins"]["entries"]["openclaw-weixin"]["enabled"] is False - - -def test_bootstrap_auto_enables_bundled_lark_plugin(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - default_extensions_dir = Path(tmpdir) / "default-extensions" / "openclaw-lark" - default_extensions_dir.mkdir(parents=True, exist_ok=True) - (default_extensions_dir / "manifest.json").write_text('{"name":"openclaw-lark"}\n') - - env = _build_base_env(tmpdir, str(config_path)) - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - env["OPENCLAW_DEFAULT_EXTENSIONS_DIR"] = str(Path(tmpdir) / "default-extensions") - - result = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode == 0, result.stderr or result.stdout - cfg = json.loads(config_path.read_text()) - assert (Path(tmpdir) / "extensions" / "openclaw-lark" / "manifest.json").exists() - assert cfg["plugins"]["entries"]["openclaw-lark"]["enabled"] is True - - -def test_bootstrap_configures_wps_xiezuo_channel_from_channel_bootstrap_json(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - default_extensions_dir = Path(tmpdir) / "default-extensions" / "wps-xiezuo" - default_extensions_dir.mkdir(parents=True, exist_ok=True) - (default_extensions_dir / "manifest.json").write_text('{"name":"wps-xiezuo"}\n') - - env = _build_base_env(tmpdir, str(config_path)) - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - env["OPENCLAW_DEFAULT_EXTENSIONS_DIR"] = str(Path(tmpdir) / "default-extensions") - env["OPENCLAW_CHANNEL_BOOTSTRAP_JSON"] = json.dumps( - { - "wps-xiezuo": { - "appId": "app-demo", - "appSecret": "secret-demo", - } - } - ) - - result = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode == 0, result.stderr or result.stdout - cfg = json.loads(config_path.read_text()) - assert (Path(tmpdir) / "extensions" / "wps-xiezuo" / "manifest.json").exists() - assert cfg["plugins"]["entries"]["wps-xiezuo"]["enabled"] is True - assert "wps-xiezuo" in cfg["plugins"]["allow"] - channel = cfg["channels"]["wps-xiezuo"] - assert channel["enabled"] is True - assert channel["appId"] == "app-demo" - assert channel["appSecret"] == "secret-demo" - assert channel["baseUrl"] == "https://openapi.wps.cn" - assert channel["sdk"] == {"enabled": True, "logLevel": "info"} - assert channel["dmPolicy"] == "open" - assert channel["allowFrom"] == ["*"] - assert channel["groupPolicy"] == "open" - assert channel["instantAck"]["text"] == "内容处理中,请稍候..." - assert channel["mcp"]["enabled"] is True - assert channel["mcp"]["mode"] == "app" - assert "toolAllowlist" not in channel["mcp"] - assert "accounts" not in channel - assert "defaultAccountId" not in channel - assert {"type": "route", "agentId": "main", "match": {"channel": "wps-xiezuo"}} in cfg["bindings"] - - -def test_bootstrap_allows_wps_xiezuo_channel_without_complete_credentials(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - default_extensions_dir = Path(tmpdir) / "default-extensions" / "wps-xiezuo" - default_extensions_dir.mkdir(parents=True, exist_ok=True) - (default_extensions_dir / "manifest.json").write_text('{"name":"wps-xiezuo"}\n') - - env = _build_base_env(tmpdir, str(config_path)) - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - env["OPENCLAW_DEFAULT_EXTENSIONS_DIR"] = str(Path(tmpdir) / "default-extensions") - env["OPENCLAW_CHANNEL_BOOTSTRAP_JSON"] = json.dumps( - { - "wps-xiezuo": { - "appId": "app-demo", - } - } - ) - - result = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode == 0, result.stderr or result.stdout - cfg = json.loads(config_path.read_text()) - channel = cfg["channels"]["wps-xiezuo"] - assert channel["enabled"] is True - assert channel["appId"] == "app-demo" - assert channel["appSecret"] == "" - assert channel["baseUrl"] == "https://openapi.wps.cn" - assert channel["dmPolicy"] == "open" - assert channel["allowFrom"] == ["*"] - assert cfg["plugins"]["entries"]["wps-xiezuo"]["enabled"] is True - assert "wps-xiezuo" in cfg["plugins"]["allow"] - - -def test_bootstrap_preserves_explicit_wps_xiezuo_mcp_tool_allowlist(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - default_extensions_dir = Path(tmpdir) / "default-extensions" / "wps-xiezuo" - default_extensions_dir.mkdir(parents=True, exist_ok=True) - (default_extensions_dir / "manifest.json").write_text('{"name":"wps-xiezuo"}\n') - - env = _build_base_env(tmpdir, str(config_path)) - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - env["OPENCLAW_DEFAULT_EXTENSIONS_DIR"] = str(Path(tmpdir) / "default-extensions") - env["OPENCLAW_CHANNEL_BOOTSTRAP_JSON"] = json.dumps( - { - "wps-xiezuo": { - "appId": "app-demo", - "appSecret": "secret-demo", - "mcp": { - "enabled": True, - "mode": "app", - "toolAllowlist": ["wps_message_send"], - }, - } - } - ) - - result = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode == 0, result.stderr or result.stdout - cfg = json.loads(config_path.read_text()) - assert cfg["channels"]["wps-xiezuo"]["mcp"]["toolAllowlist"] == ["wps_message_send"] - - -def test_bootstrap_rewrites_stale_wps_xiezuo_accounts_to_flat_channel_config(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - config_path.write_text( - json.dumps( - { - "channels": { - "wps-xiezuo": { - "accounts": { - "default": { - "appId": "app-stale", - } - } - } - } - } - ) - ) - default_extensions_dir = Path(tmpdir) / "default-extensions" / "wps-xiezuo" - default_extensions_dir.mkdir(parents=True, exist_ok=True) - (default_extensions_dir / "manifest.json").write_text('{"name":"wps-xiezuo"}\n') - - env = _build_base_env(tmpdir, str(config_path)) - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - env["OPENCLAW_DEFAULT_EXTENSIONS_DIR"] = str(Path(tmpdir) / "default-extensions") - env["OPENCLAW_CHANNEL_BOOTSTRAP_JSON"] = json.dumps( - { - "wps-xiezuo": { - "appId": "app-demo", - "appSecret": "secret-demo", - } - } - ) - - result = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode == 0, result.stderr or result.stdout - cfg = json.loads(config_path.read_text()) - channel = cfg["channels"]["wps-xiezuo"] - assert channel["appId"] == "app-demo" - assert channel["appSecret"] == "secret-demo" - assert "accounts" not in channel - assert "defaultAccountId" not in channel - - -def test_bootstrap_configures_feishu_channel_from_channel_bootstrap_json(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - default_extensions_dir = Path(tmpdir) / "default-extensions" / "openclaw-lark" - default_extensions_dir.mkdir(parents=True, exist_ok=True) - (default_extensions_dir / "manifest.json").write_text('{"name":"openclaw-lark"}\n') - - env = _build_base_env(tmpdir, str(config_path)) - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - env["OPENCLAW_DEFAULT_EXTENSIONS_DIR"] = str(Path(tmpdir) / "default-extensions") - env["OPENCLAW_CHANNEL_BOOTSTRAP_JSON"] = json.dumps( - { - "feishu": { - "appId": "cli-app-id", - "appSecret": "cli-app-secret", - "domain": "lark", - } - } - ) - - result = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode == 0, result.stderr or result.stdout - cfg = json.loads(config_path.read_text()) - assert cfg["plugins"]["entries"]["openclaw-lark"]["enabled"] is True - assert cfg["channels"]["feishu"]["enabled"] is True - assert cfg["channels"]["feishu"]["appId"] == "cli-app-id" - assert cfg["channels"]["feishu"]["appSecret"] == "cli-app-secret" - assert cfg["channels"]["feishu"]["domain"] == "lark" - assert cfg["channels"]["feishu"]["connectionMode"] == "websocket" - assert cfg["channels"]["feishu"]["requireMention"] is True - assert cfg["channels"]["feishu"]["dmPolicy"] == "pairing" - assert cfg["channels"]["feishu"]["groupPolicy"] == "open" - - -def test_bootstrap_keeps_feishu_open_dm_policy_valid_when_existing_allow_from_is_specific(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - config_path.write_text( - json.dumps( - { - "channels": { - "feishu": { - "enabled": True, - "appId": "cli-app-id", - "appSecret": "cli-app-secret", - "dmPolicy": "open", - "allowFrom": ["ou_demo_1", "ou_demo_2"], - } - } - } - ) - ) - default_extensions_dir = Path(tmpdir) / "default-extensions" / "openclaw-lark" - default_extensions_dir.mkdir(parents=True, exist_ok=True) - (default_extensions_dir / "manifest.json").write_text('{"name":"openclaw-lark"}\n') - - env = _build_base_env(tmpdir, str(config_path)) - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - env["OPENCLAW_DEFAULT_EXTENSIONS_DIR"] = str(Path(tmpdir) / "default-extensions") - - result = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode == 0, result.stderr or result.stdout - cfg = json.loads(config_path.read_text()) - assert cfg["channels"]["feishu"]["dmPolicy"] == "open" - assert cfg["channels"]["feishu"]["allowFrom"] == ["ou_demo_1", "ou_demo_2", "*"] - - -def test_bootstrap_patches_bundled_weixin_gateway_login_methods_before_sync(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - plugin_root = Path(tmpdir) / "default-extensions" / "openclaw-weixin" - plugin_dir = plugin_root / "src" - plugin_dir.mkdir(parents=True, exist_ok=True) - _write_weixin_plugin_package_json(plugin_root) - (plugin_dir / "channel.ts").write_text( - "export const weixinPlugin = {\n" - " status: {\n" - " defaultRuntime: {},\n" - " },\n" - "};\n" - ) - - env = _build_base_env(tmpdir, str(config_path)) - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - env["OPENCLAW_DEFAULT_EXTENSIONS_DIR"] = str(Path(tmpdir) / "default-extensions") - - result = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode == 0, result.stderr or result.stdout - bundled_source = (Path(tmpdir) / "default-extensions" / "openclaw-weixin" / "src" / "channel.ts").read_text() - assert 'gatewayMethods: ["web.login.start", "web.login.wait"],' in bundled_source - patched_source = (Path(tmpdir) / "extensions" / "openclaw-weixin" / "src" / "channel.ts").read_text() - assert 'gatewayMethods: ["web.login.start", "web.login.wait"],' in patched_source - - -def test_bootstrap_patches_latest_weixin_gateway_login_methods_without_version_skip(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - plugin_root = Path(tmpdir) / "default-extensions" / "openclaw-weixin" - plugin_src_dir = plugin_root / "src" - - plugin_src_dir.mkdir(parents=True, exist_ok=True) - _write_weixin_plugin_package_json(plugin_root, version="2.1.7") - (plugin_src_dir / "channel.ts").write_text( - 'import type { ChannelPlugin, OpenClawConfig } from "openclaw/plugin-sdk/core";\n' - 'import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";\n' - 'import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/infra-runtime";\n' - 'export const weixinPlugin = {\n' - ' status: {},\n' - '};\n' - ) - - env = _build_base_env(tmpdir, str(config_path)) - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - env["OPENCLAW_DEFAULT_EXTENSIONS_DIR"] = str(Path(tmpdir) / "default-extensions") - - result = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode == 0, result.stderr or result.stdout - patched_source = (Path(tmpdir) / "extensions" / "openclaw-weixin" / "src" / "channel.ts").read_text() - assert 'gatewayMethods: ["web.login.start", "web.login.wait"],' in patched_source - assert "skipped bundled channel plugin compat patch" not in result.stderr - - -def test_bootstrap_only_adds_gateway_login_methods_for_target_weixin_version(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - plugin_root = Path(tmpdir) / "default-extensions" / "openclaw-weixin" - (plugin_root / "src").mkdir(parents=True, exist_ok=True) - - _write_weixin_plugin_package_json(plugin_root) - (plugin_root / "index.ts").write_text( - 'import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";\n' - 'import { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema";\n' - ) - (plugin_root / "src" / "channel.ts").write_text( - 'import type { ChannelPlugin, OpenClawConfig } from "openclaw/plugin-sdk/core";\n' - 'import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";\n' - 'import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/infra-runtime";\n' - 'export const weixinPlugin = {\n' - ' status: {},\n' - '};\n' - ) - - env = _build_base_env(tmpdir, str(config_path)) - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - env["OPENCLAW_DEFAULT_EXTENSIONS_DIR"] = str(Path(tmpdir) / "default-extensions") - - result = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode == 0, result.stderr or result.stdout - synced_root = Path(tmpdir) / "extensions" / "openclaw-weixin" - assert 'openclaw/plugin-sdk/plugin-entry' in (synced_root / "index.ts").read_text() - patched_channel = (synced_root / "src" / "channel.ts").read_text() - assert 'openclaw/plugin-sdk/infra-runtime' in patched_channel - assert 'gatewayMethods: ["web.login.start", "web.login.wait"],' in patched_channel - assert not (synced_root / "node_modules" / "openclaw").exists() - - -def test_bootstrap_patches_weixin_remote_login_patch_for_newer_official_version(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - plugin_root = Path(tmpdir) / "default-extensions" / "openclaw-weixin" - plugin_src_dir = plugin_root / "src" - - plugin_src_dir.mkdir(parents=True, exist_ok=True) - - _write_weixin_plugin_package_json(plugin_root, version="2.1.8") - (plugin_src_dir / "channel.ts").write_text( - 'import type { ChannelPlugin, OpenClawConfig } from "openclaw/plugin-sdk/core";\n' - 'import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";\n' - 'import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/infra-runtime";\n' - 'export const weixinPlugin = {\n' - ' status: {},\n' - '};\n' - ) - - env = _build_base_env(tmpdir, str(config_path)) - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - env["OPENCLAW_DEFAULT_EXTENSIONS_DIR"] = str(Path(tmpdir) / "default-extensions") - - result = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode == 0, result.stderr or result.stdout - bundled_source = (plugin_root / "src" / "channel.ts").read_text() - synced_source = (Path(tmpdir) / "extensions" / "openclaw-weixin" / "src" / "channel.ts").read_text() - assert 'gatewayMethods: ["web.login.start", "web.login.wait"],' in bundled_source - assert 'gatewayMethods: ["web.login.start", "web.login.wait"],' in synced_source - assert "skipped bundled channel plugin compat patch" not in result.stderr - - -def test_bootstrap_skips_weixin_remote_login_patch_for_older_official_version(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - plugin_root = Path(tmpdir) / "default-extensions" / "openclaw-weixin" - plugin_src_dir = plugin_root / "src" - - plugin_src_dir.mkdir(parents=True, exist_ok=True) - - _write_weixin_plugin_package_json(plugin_root, version="2.0.2") - (plugin_src_dir / "channel.ts").write_text( - 'import type { ChannelPlugin, OpenClawConfig } from "openclaw/plugin-sdk/core";\n' - 'import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";\n' - 'import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/infra-runtime";\n' - 'export const weixinPlugin = {\n' - ' status: {},\n' - '};\n' - ) - - env = _build_base_env(tmpdir, str(config_path)) - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - env["OPENCLAW_DEFAULT_EXTENSIONS_DIR"] = str(Path(tmpdir) / "default-extensions") - - result = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode == 0, result.stderr or result.stdout - bundled_source = (plugin_root / "src" / "channel.ts").read_text() - synced_source = (Path(tmpdir) / "extensions" / "openclaw-weixin" / "src" / "channel.ts").read_text() - assert 'openclaw/plugin-sdk/infra-runtime' in bundled_source - assert 'openclaw/plugin-sdk/infra-runtime' in synced_source - assert 'gatewayMethods: ["web.login.start", "web.login.wait"],' not in bundled_source - assert 'gatewayMethods: ["web.login.start", "web.login.wait"],' not in synced_source - assert "skipped bundled channel plugin compat patch" in result.stderr - - -def test_bootstrap_does_not_runtime_patch_user_managed_weixin_plugin(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - bundled_plugin_root = Path(tmpdir) / "default-extensions" / "openclaw-weixin" - existing_plugin_root = Path(tmpdir) / "extensions" / "openclaw-weixin" - (bundled_plugin_root / "src").mkdir(parents=True, exist_ok=True) - (existing_plugin_root / "src").mkdir(parents=True, exist_ok=True) - - _write_weixin_plugin_package_json(bundled_plugin_root, version="2.1.7") - _write_weixin_plugin_package_json(existing_plugin_root, version="9.9.9-user") - (bundled_plugin_root / "src" / "channel.ts").write_text( - 'export const weixinPlugin = {\n' - ' status: {},\n' - '};\n' - ) - original_user_source = ( - 'import type { ChannelPlugin, OpenClawConfig } from "openclaw/plugin-sdk/core";\n' - 'import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";\n' - 'import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/infra-runtime";\n' - 'export const weixinPlugin = {\n' - ' status: {},\n' - '};\n' - ) - (existing_plugin_root / "src" / "channel.ts").write_text(original_user_source) - - env = _build_base_env(tmpdir, str(config_path)) - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - env["OPENCLAW_DEFAULT_EXTENSIONS_DIR"] = str(Path(tmpdir) / "default-extensions") - - result = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode == 0, result.stderr or result.stdout - assert (existing_plugin_root / "src" / "channel.ts").read_text() == original_user_source - assert "preserved user-managed extension openclaw-weixin" in result.stderr - - -def test_bootstrap_runtime_patches_existing_official_weixin_216_plugin(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - existing_plugin_root = Path(tmpdir) / "extensions" / "openclaw-weixin" - - (existing_plugin_root / "src").mkdir(parents=True, exist_ok=True) - - _write_weixin_plugin_package_json(existing_plugin_root, version="2.1.7") - (existing_plugin_root / "index.ts").write_text( - 'import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";\n' - ) - (existing_plugin_root / "src" / "channel.ts").write_text( - 'import type { ChannelPlugin, OpenClawConfig } from "openclaw/plugin-sdk/core";\n' - 'import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";\n' - 'import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/infra-runtime";\n' - 'export const weixinPlugin = {\n' - ' status: {\n' - ' defaultRuntime: {},\n' - ' },\n' - '};\n' - ) - - env = _build_base_env(tmpdir, str(config_path)) - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - env["OPENCLAW_DEFAULT_EXTENSIONS_DIR"] = str(Path(tmpdir) / "default-extensions") - - result = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode == 0, result.stderr or result.stdout - assert 'openclaw/plugin-sdk/plugin-entry' in (existing_plugin_root / "index.ts").read_text() - patched_channel = (existing_plugin_root / "src" / "channel.ts").read_text() - assert 'openclaw/plugin-sdk/infra-runtime' in patched_channel - assert 'gatewayMethods: ["web.login.start", "web.login.wait"],' in patched_channel - assert not (existing_plugin_root / "node_modules" / "openclaw").exists() - - -def test_bootstrap_runs_bundled_kdocs_setup_when_token_present(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - marker_path = Path(tmpdir) / "kdocs.marker" - preset_skills_dir = Path(tmpdir) / "preset-skills" / "kdocs" - preset_skills_dir.mkdir(parents=True, exist_ok=True) - (preset_skills_dir / "setup.sh").write_text( - "#!/usr/bin/env bash\n" - "set -euo pipefail\n" - "printf '%s\\n' \"${KDOCS_TOKEN}\" > \"${OPENCLAW_KDOCS_MARKER_PATH}\"\n" - ) - (preset_skills_dir / "setup.sh").chmod(0o755) - (preset_skills_dir / "SKILL.md").write_text("kdocs skill\n") - - env = _build_base_env(tmpdir, str(config_path)) - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - env["OPENCLAW_PRESET_SKILLS_DIR"] = str(Path(tmpdir) / "preset-skills") - env["OPENCLAW_PRESET_SKILLS_ALLOWLIST"] = "kdocs" - env["OPENCLAW_KDOCS_MARKER_PATH"] = str(marker_path) - env["KDOCS_TOKEN"] = "kdocs-test-token" - - result = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode == 0, result.stderr or result.stdout - assert marker_path.read_text() == "kdocs-test-token\n" - assert (Path(tmpdir) / "skills" / "kdocs" / "setup.sh").exists() - - -def test_bootstrap_syncs_only_allowlisted_preset_skills(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - preset_skills_dir = Path(tmpdir) / "preset-skills" - for skill_name in [ - "clawhub-store", - "agent-browser-clawdbot", - "self-improving-agent", - "kdocs", - "agent-reach", - "tavily-search", - "tuanziguardianclaw", - ]: - skill_dir = preset_skills_dir / skill_name - skill_dir.mkdir(parents=True, exist_ok=True) - (skill_dir / "SKILL.md").write_text(f"{skill_name}\n") - - env = _build_base_env(tmpdir, str(config_path)) - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - env["OPENCLAW_PRESET_SKILLS_DIR"] = str(preset_skills_dir) - - result = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode == 0, result.stderr or result.stdout - synced_skills = sorted(path.name for path in (Path(tmpdir) / "skills").iterdir() if path.is_dir()) - assert synced_skills == [ - "agent-browser-clawdbot", - "clawhub-store", - "kdocs", - ] - cfg = json.loads(config_path.read_text()) - assert cfg["skills"]["allowBundled"] == [ - "clawhub-store", - "agent-browser-clawdbot", - "kdocs", - "wps365-skill", - ] - - -def test_bootstrap_strict_mode_keeps_tuanziguardianclaw_preset_skill(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - preset_skills_dir = Path(tmpdir) / "preset-skills" - for skill_name in [ - "clawhub-store", - "agent-browser-clawdbot", - "kdocs", - "self-improving-agent", - "tuanziguardianclaw", - ]: - skill_dir = preset_skills_dir / skill_name - skill_dir.mkdir(parents=True, exist_ok=True) - (skill_dir / "SKILL.md").write_text(f"{skill_name}\n") - - env = _build_base_env(tmpdir, str(config_path)) - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - env["OPENCLAW_PRESET_SKILLS_DIR"] = str(preset_skills_dir) - env["OPENCLAW_EXEC_STRICT_MODE"] = "true" - - result = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode == 0, result.stderr or result.stdout - synced_skills = sorted(path.name for path in (Path(tmpdir) / "skills").iterdir() if path.is_dir()) - assert synced_skills == [ - "agent-browser-clawdbot", - "clawhub-store", - "kdocs", - "self-improving-agent", - "tuanziguardianclaw", - ] - cfg = json.loads(config_path.read_text()) - assert cfg["skills"]["allowBundled"] == [ - "clawhub-store", - "agent-browser-clawdbot", - "kdocs", - "wps365-skill", - "self-improving-agent", - "tuanziguardianclaw", - ] - - -def test_bootstrap_overrides_stale_bundled_skill_allowlist_from_existing_config(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - config_path.write_text( - json.dumps( - { - "skills": { - "allowBundled": [ - "clawhub-store", - "tavily-search", - "agent-reach", - ] - } - } - ) - ) - env = _build_base_env(tmpdir, str(config_path)) - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - - result = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode == 0, result.stderr or result.stdout - cfg = json.loads(config_path.read_text()) - assert cfg["skills"]["allowBundled"] == [ - "clawhub-store", - "agent-browser-clawdbot", - "kdocs", - "wps365-skill", - ] - - -def test_bootstrap_enables_self_improvement_workspace_files(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - preset_skills_dir = Path(tmpdir) / "preset-skills" / "self-improving-agent" / ".learnings" - preset_skills_dir.mkdir(parents=True, exist_ok=True) - (preset_skills_dir / "LEARNINGS.md").write_text("learning template\n") - (preset_skills_dir / "ERRORS.md").write_text("error template\n") - (preset_skills_dir / "FEATURE_REQUESTS.md").write_text("feature template\n") - (preset_skills_dir.parent / "SKILL.md").write_text("self-improving-agent\n") - - env = _build_base_env(tmpdir, str(config_path)) - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - env["OPENCLAW_PRESET_SKILLS_DIR"] = str(Path(tmpdir) / "preset-skills") - env["OPENCLAW_PRESET_SKILLS_ALLOWLIST"] = "self-improving-agent" - - result = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode == 0, result.stderr or result.stdout - workspace_learnings = Path(tmpdir) / "workspace" / ".learnings" - assert (workspace_learnings / "LEARNINGS.md").read_text() == "learning template\n" - assert (workspace_learnings / "ERRORS.md").read_text() == "error template\n" - assert (workspace_learnings / "FEATURE_REQUESTS.md").read_text() == "feature template\n" - - -def test_bootstrap_patches_runtime_bundles_for_loopback_gateway_clients(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - dist_dir = Path(tmpdir) / "dist" - control_ui_assets_dir = dist_dir / "control-ui" / "assets" - control_ui_assets_dir.mkdir(parents=True, exist_ok=True) - client_bundle = dist_dir / "reply-test.js" - gateway_bundle = dist_dir / "gateway-cli-test.js" - server_bundle = dist_dir / "server.impl-test.js" - control_ui_bundle = control_ui_assets_dir / "main-test.js" - - client_bundle.write_text('const wsOptions = { maxPayload: 25 * 1024 * 1024 };') - gateway_bundle.write_text( - 'function shouldSkipBackendSelfPairing(params) {\n' - '\tif (!(params.connectParams.client.id === GATEWAY_CLIENT_IDS.GATEWAY_CLIENT && params.connectParams.client.mode === GATEWAY_CLIENT_MODES.BACKEND)) return false;\n' - '\tconst usesSharedSecretAuth = params.authMethod === "token" || params.authMethod === "password";\n' - '\tconst usesDeviceTokenAuth = params.authMethod === "device-token";\n' - '\treturn params.isLocalClient && !params.hasBrowserOriginHeader && (params.sharedAuthOk && usesSharedSecretAuth || usesDeviceTokenAuth);\n' - '}\n' - 'if (isLoopbackAddress(remoteAddr)) return { reason: "trusted_proxy_loopback_source" };\n' - 'function shouldAttachDeviceIdentityForGatewayCall(params) {\n' - '\treturn true;\n' - '}\n' - 'deviceIdentity: shouldAttachDeviceIdentityForGatewayCall({\n' - '\t\t\t\turl,\n' - '\t\t\t\ttoken,\n' - '\t\t\t\tpassword\n' - '\t\t\t}) ? loadOrCreateDeviceIdentity() : void 0,\n' - 'function ensureExplicitGatewayAuth(params) {\n' - '\tif (!params.urlOverride) return;\n' - '\tconst explicitToken = params.explicitAuth?.token;\n' - '}\n' - 'if (!device && (!isControlUi || decision.kind !== "allow")) clearUnboundScopes();\n' - ) - server_bundle.write_text( - 'function createGatewayHttpServer(opts) {\n' - '\tconst { canvasHost, clients, controlUiEnabled, controlUiBasePath, controlUiRoot, openAiChatCompletionsEnabled, openAiChatCompletionsConfig, openResponsesEnabled, openResponsesConfig, strictTransportSecurityHeader, handleHooksRequest, handlePluginRequest, shouldEnforcePluginGatewayAuth, resolvedAuth, rateLimiter, getReadiness } = opts;\n' - '\tconst getResolvedAuth = opts.getResolvedAuth ?? (() => resolvedAuth);\n' - '\tconst openAiCompatEnabled = openAiChatCompletionsEnabled || openResponsesEnabled;\n' - '\tasync function handleRequest(req, res) {\n' - '\t\tconst requestPath = new URL(req.url ?? "/", "http://localhost").pathname;\n' - '\t\tconst requestStages = [{\n' - '\t\t\tname: "hooks",\n' - '\t\t\trun: () => handleHooksRequest(req, res)\n' - '\t\t}];\n' - '\t\tif (controlUiEnabled) {\n' - '\t\t\trequestStages.push({\n' - '\t\t\t\tname: "control-ui-http",\n' - '\t\t\t\trun: async () => (await getControlUiModule()).handleControlUiHttpRequest(req, res, {\n' - '\t\t\t\t\tbasePath: controlUiBasePath,\n' - '\t\t\t\t\tconfig: configSnapshot,\n' - '\t\t\t\t\tagentId: resolveAssistantIdentity({ cfg: configSnapshot }).agentId,\n' - '\t\t\t\t\troot: controlUiRoot\n' - '\t\t\t\t})\n' - '\t\t\t});\n' - '\t\t}\n' - '\t}\n' - '}\n' - ) - control_ui_bundle.write_text('this.ws.addEventListener(`open`,()=>this.queueConnect())') - - env = _build_base_env(tmpdir, str(config_path)) - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - env["OPENCLAW_DIST_DIR"] = str(dist_dir) - env["OPENCLAW_WORKSPACE_FILES_ENABLED"] = "0" - - result = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode == 0, result.stderr or result.stdout - assert 'internalTrustedProxyUser' in client_bundle.read_text() - gateway_source = gateway_bundle.read_text() - assert 'usesLoopbackTrustedProxyAuth = params.authMethod === "trusted-proxy"' in gateway_source - assert 'const usesDeviceTokenAuth = params.authMethod === "device-token";' in gateway_source - assert 'usesLoopbackTrustedProxyAuth || params.sharedAuthOk && usesSharedSecretAuth || usesDeviceTokenAuth' in gateway_source - assert 'const internalLoopbackUserHeader = String(process.env.OPENCLAW_INTERNAL_TRUSTED_PROXY_USER_HEADER || process.env.OPENCLAW_TRUSTED_PROXY_USER_HEADER || "x-forwarded-user").trim().toLowerCase();' in gateway_source - assert 'const loopbackUser = headerValue(req.headers[internalLoopbackUserHeader || "x-forwarded-user"]);' in gateway_source - assert 'const forwardedLoopbackChain = String(headerValue(req.headers["x-forwarded-for"]) || "").split(",").map((value) => value.trim()).filter(Boolean);' in gateway_source - assert 'const trustedProxyAddressCheck = typeof isTrustedProxyAddress === "function" ? isTrustedProxyAddress : typeof isTrustedProxyAddress$1 === "function" ? isTrustedProxyAddress$1 : null;' in gateway_source - assert 'const forwardedLoopbackTrusted = !!trustedProxyAddressCheck && forwardedLoopbackChain.some((addr) => !isLoopbackAddress(addr) && trustedProxyAddressCheck(addr, trustedProxies));' in gateway_source - assert 'if (!forwardedLoopbackTrusted && (!internalLoopbackUser || !loopbackUser || loopbackUser.trim() !== internalLoopbackUser)) return { reason: "trusted_proxy_loopback_source" };' in gateway_source - assert 'function shouldAttachDeviceIdentityForGatewayCall(params) {' in gateway_source - assert '].includes(parsed.hostname)) return false;' in gateway_source - assert '}) ? loadOrCreateDeviceIdentity() : null,' in gateway_source - assert 'const parsed = new URL(params.urlOverride);' in gateway_source - assert 'if (["127.0.0.1", "::1", "localhost"].includes(parsed.hostname)) return;' in gateway_source - assert 'const keepUnboundScopes = !device && decision.kind === "allow" && authMethod === "trusted-proxy" && !hasBrowserOriginHeader;' in gateway_source - server_source = server_bundle.read_text() - assert 'async function handleWorkspaceFilesProxyRequest(req, res) {' not in server_source - assert 'name: "workspace-files-proxy"' not in server_source - assert 'requestUrl.pathname.startsWith("/_ksadk/workspace/v1/")' not in server_source - assert 'const targetUrl = new URL(`${requestUrl.pathname}${requestUrl.search}`' not in server_source - assert 'this.ws.addEventListener(`open`,()=>{this.lastSeq=null,this.queueConnect()})' in control_ui_bundle.read_text() - - -def test_bootstrap_patches_allow_loopback_runtime_for_forwarded_trusted_proxy_chain(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - dist_dir = Path(tmpdir) / "dist" - control_ui_assets_dir = dist_dir / "control-ui" / "assets" - control_ui_assets_dir.mkdir(parents=True, exist_ok=True) - client_bundle = dist_dir / "reply-test.js" - gateway_bundle = dist_dir / "gateway-cli-test.js" - server_bundle = dist_dir / "server.impl-test.js" - control_ui_bundle = control_ui_assets_dir / "main-test.js" - - client_bundle.write_text('const wsOptions = { maxPayload: 25 * 1024 * 1024 };') - gateway_bundle.write_text( - 'function shouldSkipBackendSelfPairing(params) {\n' - '\tif (!(params.connectParams.client.id === GATEWAY_CLIENT_IDS.GATEWAY_CLIENT && params.connectParams.client.mode === GATEWAY_CLIENT_MODES.BACKEND)) return false;\n' - '\tconst usesSharedSecretAuth = params.authMethod === "token" || params.authMethod === "password";\n' - '\tconst usesDeviceTokenAuth = params.authMethod === "device-token";\n' - '\treturn params.isLocalClient && !params.hasBrowserOriginHeader && (params.sharedAuthOk && usesSharedSecretAuth || usesDeviceTokenAuth);\n' - '}\n' - 'function authorizeTrustedProxy(params) {\n' - '\tconst { req, trustedProxies, trustedProxyConfig } = params;\n' - '\tconst remoteAddr = req.socket?.remoteAddress;\n' - '\tif (isLoopbackAddress(remoteAddr) && trustedProxyConfig.allowLoopback !== true) return { reason: "trusted_proxy_loopback_source" };\n' - '\treturn { user: headerValue(req.headers[trustedProxyConfig.userHeader.toLowerCase()]).trim() };\n' - '}\n' - 'function shouldAttachDeviceIdentityForGatewayCall(params) {\n' - '\treturn true;\n' - '}\n' - 'deviceIdentity: shouldAttachDeviceIdentityForGatewayCall({\n' - '\t\t\t\turl,\n' - '\t\t\t\ttoken,\n' - '\t\t\t\tpassword\n' - '\t\t\t}) ? loadOrCreateDeviceIdentity() : void 0,\n' - 'function ensureExplicitGatewayAuth(params) {\n' - '\tif (!params.urlOverride) return;\n' - '\tconst explicitToken = params.explicitAuth?.token;\n' - '}\n' - 'if (!device && (!isControlUi || decision.kind !== "allow")) clearUnboundScopes();\n' - ) - server_bundle.write_text( - 'function createGatewayHttpServer(opts) {\n' - '\tconst { canvasHost, clients, controlUiEnabled, controlUiBasePath, controlUiRoot, openAiChatCompletionsEnabled, openAiChatCompletionsConfig, openResponsesEnabled, openResponsesConfig, strictTransportSecurityHeader, handleHooksRequest, handlePluginRequest, shouldEnforcePluginGatewayAuth, resolvedAuth, rateLimiter, getReadiness } = opts;\n' - '\tconst getResolvedAuth = opts.getResolvedAuth ?? (() => resolvedAuth);\n' - '\tconst openAiCompatEnabled = openAiChatCompletionsEnabled || openResponsesEnabled;\n' - '\tasync function handleRequest(req, res) {\n' - '\t\tconst requestPath = new URL(req.url ?? "/", "http://localhost").pathname;\n' - '\t\tconst requestStages = [{\n' - '\t\t\tname: "hooks",\n' - '\t\t\trun: () => handleHooksRequest(req, res)\n' - '\t\t}];\n' - '\t\tif (controlUiEnabled) {\n' - '\t\t\trequestStages.push({\n' - '\t\t\t\tname: "control-ui-http",\n' - '\t\t\t\trun: async () => (await getControlUiModule()).handleControlUiHttpRequest(req, res, {\n' - '\t\t\t\t\tbasePath: controlUiBasePath,\n' - '\t\t\t\t\tconfig: configSnapshot,\n' - '\t\t\t\t\tagentId: resolveAssistantIdentity({ cfg: configSnapshot }).agentId,\n' - '\t\t\t\t\troot: controlUiRoot\n' - '\t\t\t\t})\n' - '\t\t\t});\n' - '\t\t}\n' - '\t}\n' - '}\n' - ) - control_ui_bundle.write_text('this.ws.addEventListener(`open`,()=>this.queueConnect())') - - env = _build_base_env(tmpdir, str(config_path)) - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - env["OPENCLAW_DIST_DIR"] = str(dist_dir) - env["OPENCLAW_WORKSPACE_FILES_ENABLED"] = "0" - - result = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode == 0, result.stderr or result.stdout - gateway_source = gateway_bundle.read_text() - assert 'if (isLoopbackAddress(remoteAddr) && trustedProxyConfig.allowLoopback !== true) {' in gateway_source - assert 'const forwardedLoopbackChain = String(headerValue(req.headers["x-forwarded-for"]) || "").split(",").map((value) => value.trim()).filter(Boolean);' in gateway_source - assert 'const trustedProxyAddressCheck = typeof isTrustedProxyAddress === "function" ? isTrustedProxyAddress : typeof isTrustedProxyAddress$1 === "function" ? isTrustedProxyAddress$1 : null;' in gateway_source - assert 'const forwardedLoopbackTrusted = !!trustedProxyAddressCheck && forwardedLoopbackChain.some((addr) => !isLoopbackAddress(addr) && trustedProxyAddressCheck(addr, trustedProxies));' in gateway_source - assert 'if (!forwardedLoopbackTrusted && (!internalLoopbackUser || !loopbackUser || loopbackUser.trim() !== internalLoopbackUser)) return { reason: "trusted_proxy_loopback_source" };' in gateway_source - - -def test_bootstrap_patches_openclaw_2026_5_18_split_auth_and_message_handler_runtime(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - dist_dir = Path(tmpdir) / "dist" - control_ui_assets_dir = dist_dir / "control-ui" / "assets" - control_ui_assets_dir.mkdir(parents=True, exist_ok=True) - client_bundle = dist_dir / "reply-test.js" - auth_bundle = dist_dir / "auth-test.js" - message_handler_bundle = dist_dir / "message-handler-test.js" - gateway_call_bundle = dist_dir / "gateway-call-test.js" - control_ui_bundle = control_ui_assets_dir / "main-test.js" - - client_bundle.write_text('const wsOptions = { maxPayload: 25 * 1024 * 1024 };') - auth_bundle.write_text( - 'function authorizeTrustedProxy(params) {\n' - '\tconst { req, trustedProxies, trustedProxyConfig } = params;\n' - '\tif (!req) return { reason: "trusted_proxy_no_request" };\n' - '\tconst remoteAddr = req.socket?.remoteAddress;\n' - '\tif (!remoteAddr || !isTrustedProxyAddress(remoteAddr, trustedProxies)) return { reason: "trusted_proxy_untrusted_source" };\n' - '\tconst remoteIsLoopback = isLoopbackAddress(remoteAddr);\n' - '\tif (remoteIsLoopback && trustedProxyConfig.allowLoopback !== true) return { reason: "trusted_proxy_loopback_source" };\n' - '\treturn { user: headerValue(req.headers[trustedProxyConfig.userHeader.toLowerCase()]).trim() };\n' - '}\n' - ) - message_handler_bundle.write_text( - 'function shouldSkipLocalBackendSelfPairing(params) {\n' - '\tif (!(params.connectParams.client.id === GATEWAY_CLIENT_IDS.GATEWAY_CLIENT && params.connectParams.client.mode === GATEWAY_CLIENT_MODES.BACKEND)) return false;\n' - '\tif (!(params.locality === "direct_local" || params.locality === "shared_secret_loopback_local") || params.hasBrowserOriginHeader) return false;\n' - '\tif (params.authMethod === "none") return true;\n' - '\tconst usesSharedSecretAuth = params.authMethod === "token" || params.authMethod === "password";\n' - '\tconst usesDeviceTokenAuth = params.authMethod === "device-token";\n' - '\treturn params.sharedAuthOk && usesSharedSecretAuth || usesDeviceTokenAuth;\n' - '}\n' - ) - gateway_call_bundle.write_text( - 'function ensureExplicitGatewayAuth(params) {\n' - '\tif (!params.urlOverride) return;\n' - '\tconst explicitToken = params.explicitAuth?.token;\n' - '}\n' - ) - control_ui_bundle.write_text('this.ws.addEventListener(`open`,()=>this.queueConnect())') - - env = _build_base_env(tmpdir, str(config_path)) - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - env["OPENCLAW_DIST_DIR"] = str(dist_dir) - env["OPENCLAW_WORKSPACE_FILES_ENABLED"] = "0" - - result = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode == 0, result.stderr or result.stdout - auth_source = auth_bundle.read_text() - assert 'if (remoteIsLoopback && trustedProxyConfig.allowLoopback !== true) {' in auth_source - assert 'const forwardedLoopbackChain = String(headerValue(req.headers["x-forwarded-for"]) || "").split(",").map((value) => value.trim()).filter(Boolean);' in auth_source - assert 'const trustedProxyAddressCheck = typeof isTrustedProxyAddress === "function" ? isTrustedProxyAddress : typeof isTrustedProxyAddress$1 === "function" ? isTrustedProxyAddress$1 : null;' in auth_source - assert 'const forwardedLoopbackTrusted = !!trustedProxyAddressCheck && forwardedLoopbackChain.some((addr) => !isLoopbackAddress(addr) && trustedProxyAddressCheck(addr, trustedProxies));' in auth_source - assert 'if (!forwardedLoopbackTrusted && (!internalLoopbackUser || !loopbackUser || loopbackUser.trim() !== internalLoopbackUser)) return { reason: "trusted_proxy_loopback_source" };' in auth_source - message_handler_source = message_handler_bundle.read_text() - assert 'const usesLoopbackTrustedProxyAuth = params.authMethod === "trusted-proxy";' in message_handler_source - assert 'return usesLoopbackTrustedProxyAuth || params.sharedAuthOk && usesSharedSecretAuth || usesDeviceTokenAuth;' in message_handler_source - - -def test_bootstrap_patches_openclaw_2026_5_26_control_ui_trusted_proxy_scopes(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - dist_dir = Path(tmpdir) / "dist" - control_ui_assets_dir = dist_dir / "control-ui" / "assets" - control_ui_assets_dir.mkdir(parents=True, exist_ok=True) - client_bundle = dist_dir / "reply-test.js" - auth_bundle = dist_dir / "auth-test.js" - message_handler_bundle = dist_dir / "message-handler-test.js" - gateway_call_bundle = dist_dir / "gateway-call-test.js" - control_ui_bundle = control_ui_assets_dir / "main-test.js" - - client_bundle.write_text('const wsOptions = { maxPayload: 25 * 1024 * 1024 };') - auth_bundle.write_text( - 'function authorizeTrustedProxy(params) {\n' - '\tconst { req, trustedProxies, trustedProxyConfig } = params;\n' - '\tif (!req) return { reason: "trusted_proxy_no_request" };\n' - '\tconst remoteAddr = req.socket?.remoteAddress;\n' - '\tif (!remoteAddr || !isTrustedProxyAddress(remoteAddr, trustedProxies)) return { reason: "trusted_proxy_untrusted_source" };\n' - '\tconst remoteIsLoopback = isLoopbackAddress(remoteAddr);\n' - '\tif (remoteIsLoopback && trustedProxyConfig.allowLoopback !== true) return { reason: "trusted_proxy_loopback_source" };\n' - '\treturn { user: headerValue(req.headers[trustedProxyConfig.userHeader.toLowerCase()]).trim() };\n' - '}\n' - ) - message_handler_bundle.write_text( - 'function shouldSkipLocalBackendSelfPairing(params) {\n' - '\tif (!(params.connectParams.client.id === GATEWAY_CLIENT_IDS.GATEWAY_CLIENT && params.connectParams.client.mode === GATEWAY_CLIENT_MODES.BACKEND)) return false;\n' - '\tif (!(params.locality === "direct_local" || params.locality === "shared_secret_loopback_local") || params.hasBrowserOriginHeader) return false;\n' - '\tif (params.authMethod === "none") return true;\n' - '\tconst usesSharedSecretAuth = params.authMethod === "token" || params.authMethod === "password";\n' - '\tconst usesDeviceTokenAuth = params.authMethod === "device-token";\n' - '\treturn params.sharedAuthOk && usesSharedSecretAuth || usesDeviceTokenAuth;\n' - '}\n' - 'function shouldClearUnboundScopesForMissingDeviceIdentity(params) {\n' - '\treturn params.decision.kind !== "allow" || !params.controlUiAuthPolicy.allowBypass && !params.preserveInsecureLocalControlUiScopes && (params.authMethod === "token" || params.authMethod === "password" || params.authMethod === "trusted-proxy");\n' - '}\n' - ) - gateway_call_bundle.write_text( - 'function ensureExplicitGatewayAuth(params) {\n' - '\tif (!params.urlOverride) return;\n' - '\tconst explicitToken = params.explicitAuth?.token;\n' - '}\n' - ) - control_ui_bundle.write_text('this.ws.addEventListener(`open`,()=>this.queueConnect())') - - env = _build_base_env(tmpdir, str(config_path)) - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - env["OPENCLAW_DIST_DIR"] = str(dist_dir) - env["OPENCLAW_WORKSPACE_FILES_ENABLED"] = "0" - - result = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode == 0, result.stderr or result.stdout - message_handler_source = message_handler_bundle.read_text() - assert 'const usesLoopbackTrustedProxyAuth = params.authMethod === "trusted-proxy";' in message_handler_source - assert 'return usesLoopbackTrustedProxyAuth || params.sharedAuthOk && usesSharedSecretAuth || usesDeviceTokenAuth;' in message_handler_source - assert '!params.trustedProxyAuthOk' in message_handler_source - - -def test_bootstrap_patches_openclaw_2026_5_26_config_schema_full_response_budget(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - dist_dir = Path(tmpdir) / "dist" - control_ui_assets_dir = dist_dir / "control-ui" / "assets" - control_ui_assets_dir.mkdir(parents=True, exist_ok=True) - client_bundle = dist_dir / "reply-test.js" - auth_bundle = dist_dir / "auth-test.js" - message_handler_bundle = dist_dir / "message-handler-test.js" - gateway_call_bundle = dist_dir / "gateway-call-test.js" - config_bundle = dist_dir / "config-test.js" - control_ui_bundle = control_ui_assets_dir / "main-test.js" - - client_bundle.write_text('const wsOptions = { maxPayload: 25 * 1024 * 1024 };') - auth_bundle.write_text( - 'function authorizeTrustedProxy(params) {\n' - '\tconst { req, trustedProxies, trustedProxyConfig } = params;\n' - '\tif (!req) return { reason: "trusted_proxy_no_request" };\n' - '\tconst remoteAddr = req.socket?.remoteAddress;\n' - '\tif (!remoteAddr || !isTrustedProxyAddress(remoteAddr, trustedProxies)) return { reason: "trusted_proxy_untrusted_source" };\n' - '\tconst remoteIsLoopback = isLoopbackAddress(remoteAddr);\n' - '\tif (remoteIsLoopback && trustedProxyConfig.allowLoopback !== true) return { reason: "trusted_proxy_loopback_source" };\n' - '\treturn { user: headerValue(req.headers[trustedProxyConfig.userHeader.toLowerCase()]).trim() };\n' - '}\n' - ) - message_handler_bundle.write_text( - 'function shouldSkipLocalBackendSelfPairing(params) {\n' - '\tif (!(params.connectParams.client.id === GATEWAY_CLIENT_IDS.GATEWAY_CLIENT && params.connectParams.client.mode === GATEWAY_CLIENT_MODES.BACKEND)) return false;\n' - '\tif (!(params.locality === "direct_local" || params.locality === "shared_secret_loopback_local") || params.hasBrowserOriginHeader) return false;\n' - '\tif (params.authMethod === "none") return true;\n' - '\tconst usesSharedSecretAuth = params.authMethod === "token" || params.authMethod === "password";\n' - '\tconst usesDeviceTokenAuth = params.authMethod === "device-token";\n' - '\treturn params.sharedAuthOk && usesSharedSecretAuth || usesDeviceTokenAuth;\n' - '}\n' - 'function shouldClearUnboundScopesForMissingDeviceIdentity(params) {\n' - '\treturn params.decision.kind !== "allow" || !params.controlUiAuthPolicy.allowBypass && !params.preserveInsecureLocalControlUiScopes && (params.authMethod === "token" || params.authMethod === "password" || params.authMethod === "trusted-proxy");\n' - '}\n' - ) - gateway_call_bundle.write_text( - 'function ensureExplicitGatewayAuth(params) {\n' - '\tif (!params.urlOverride) return;\n' - '\tconst explicitToken = params.explicitAuth?.token;\n' - '}\n' - ) - config_bundle.write_text( - 'function loadSchemaWithPlugins() {\n' - '\treturn loadGatewayRuntimeConfigSchema();\n' - '}\n' - 'const configHandlers = {\n' - '\t"config.get": async ({ params, respond }) => {\n' - '\t\trespond(true, redactConfigSnapshot(await readConfigFileSnapshot(), loadSchemaWithPlugins().uiHints), void 0);\n' - '\t},\n' - '\t"config.schema": ({ params, respond }) => {\n' - '\t\tif (!assertValidParams(params, validateConfigSchemaParams, "config.schema", respond)) return;\n' - '\t\trespond(true, loadSchemaWithPlugins(), void 0);\n' - '\t},\n' - '\t"config.schema.lookup": ({ params, respond, context }) => {\n' - '\t\tconst result = lookupConfigSchema(loadSchemaWithPlugins(), params.path, resolveConfigReloadMetadata);\n' - '\t\trespond(true, result, void 0);\n' - '\t}\n' - '};\n' - ) - control_ui_bundle.write_text('this.ws.addEventListener(`open`,()=>this.queueConnect())') - - env = _build_base_env(tmpdir, str(config_path)) - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - env["OPENCLAW_DIST_DIR"] = str(dist_dir) - env["OPENCLAW_WORKSPACE_FILES_ENABLED"] = "0" - - result = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode == 0, result.stderr or result.stdout - config_source = config_bundle.read_text() - assert "compactConfigSchemaResponseForAgentEngineGateway" in config_source - assert ( - "respond(true, compactConfigSchemaResponseForAgentEngineGateway(loadSchemaWithPlugins()), void 0);" - in config_source - ) - assert ( - "lookupConfigSchema(loadSchemaWithPlugins(), params.path, resolveConfigReloadMetadata)" - in config_source - ) - - -def test_bootstrap_patches_workspace_proxy_stage_for_upstream_2026_4_26_shape(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - dist_dir = Path(tmpdir) / "dist" - control_ui_assets_dir = dist_dir / "control-ui" / "assets" - control_ui_assets_dir.mkdir(parents=True, exist_ok=True) - client_bundle = dist_dir / "reply-test.js" - gateway_bundle = dist_dir / "gateway-cli-test.js" - server_bundle = dist_dir / "server.impl-test.js" - control_ui_bundle = control_ui_assets_dir / "main-test.js" - - client_bundle.write_text('const wsOptions = { maxPayload: 25 * 1024 * 1024 };') - gateway_bundle.write_text( - 'function shouldSkipBackendSelfPairing(params) {\n' - '\tif (!(params.connectParams.client.id === GATEWAY_CLIENT_IDS.GATEWAY_CLIENT && params.connectParams.client.mode === GATEWAY_CLIENT_MODES.BACKEND)) return false;\n' - '\tconst usesSharedSecretAuth = params.authMethod === "token" || params.authMethod === "password";\n' - '\tconst usesDeviceTokenAuth = params.authMethod === "device-token";\n' - '\treturn params.isLocalClient && !params.hasBrowserOriginHeader && (params.sharedAuthOk && usesSharedSecretAuth || usesDeviceTokenAuth);\n' - '}\n' - 'if (isLoopbackAddress(remoteAddr)) return { reason: "trusted_proxy_loopback_source" };\n' - 'function shouldAttachDeviceIdentityForGatewayCall(params) {\n' - '\treturn true;\n' - '}\n' - 'deviceIdentity: shouldAttachDeviceIdentityForGatewayCall({\n' - '\t\t\t\turl,\n' - '\t\t\t\ttoken,\n' - '\t\t\t\tpassword\n' - '\t\t\t}) ? loadOrCreateDeviceIdentity() : void 0,\n' - 'function ensureExplicitGatewayAuth(params) {\n' - '\tif (!params.urlOverride) return;\n' - '\tconst explicitToken = params.explicitAuth?.token;\n' - '}\n' - 'if (!device && (!isControlUi || decision.kind !== "allow")) clearUnboundScopes();\n' - ) - server_bundle.write_text( - 'function createGatewayHttpServer(opts) {\n' - '\tconst { canvasHost, clients, controlUiEnabled, controlUiBasePath, controlUiRoot, openAiChatCompletionsEnabled, openAiChatCompletionsConfig, openResponsesEnabled, openResponsesConfig, strictTransportSecurityHeader, handleHooksRequest, handlePluginRequest, shouldEnforcePluginGatewayAuth, resolvedAuth, trustedProxies, allowRealIpFallback, rateLimiter, getReadiness } = opts;\n' - '\tconst getResolvedAuth = opts.getResolvedAuth ?? (() => resolvedAuth);\n' - '\tconst openAiCompatEnabled = openAiChatCompletionsEnabled || openResponsesEnabled;\n' - '\tasync function handleRequest(req, res) {\n' - '\t\tconst scopedRequestPath = new URL(req.url ?? "/", "http://localhost").pathname;\n' - '\t\tconst requestStages = [{\n' - '\t\t\t\tname: "gateway-probes",\n' - '\t\t\t\trun: () => handleGatewayProbeRequest(req, res, scopedRequestPath, resolvedAuth, trustedProxies, allowRealIpFallback, getReadiness)\n' - '\t\t\t}, {\n' - '\t\t\t\tname: "hooks",\n' - '\t\t\t\trun: () => handleHooksRequest(req, res)\n' - '\t\t\t}];\n' - '\t\t\tif (openAiCompatEnabled && isOpenAiModelsPath(scopedRequestPath)) requestStages.push({\n' - '\t\t\tname: "models",\n' - '\t\t\trun: async () => (await getModelsHttpModule()).handleOpenAiModelsHttpRequest(req, res, {\n' - '\t\t\t\tauth: resolvedAuth,\n' - '\t\t\t\ttrustedProxies,\n' - '\t\t\t\tallowRealIpFallback,\n' - '\t\t\t\trateLimiter\n' - '\t\t\t})\n' - '\t\t});\n' - '\t}\n' - '}\n' - ) - control_ui_bundle.write_text('this.ws.addEventListener(`open`,()=>this.queueConnect())') - - env = _build_base_env(tmpdir, str(config_path)) - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - env["OPENCLAW_DIST_DIR"] = str(dist_dir) - - result = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode == 0, result.stderr or result.stdout - server_source = server_bundle.read_text() - assert 'async function handleWorkspaceFilesProxyRequest(req, res) {' in server_source - assert 'name: "workspace-files-proxy"' in server_source - assert 'run: () => handleWorkspaceFilesProxyRequest(req, res)' in server_source - assert 'if (openAiCompatEnabled && isOpenAiModelsPath(scopedRequestPath)) requestStages.push({' in server_source - - -def test_bootstrap_patches_workspace_proxy_stage_for_upstream_2026_6_1_shape(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - dist_dir = Path(tmpdir) / "dist" - control_ui_assets_dir = dist_dir / "control-ui" / "assets" - control_ui_assets_dir.mkdir(parents=True, exist_ok=True) - client_bundle = dist_dir / "reply-test.js" - gateway_bundle = dist_dir / "gateway-cli-test.js" - server_bundle = dist_dir / "server.impl-test.js" - control_ui_bundle = control_ui_assets_dir / "main-test.js" - - client_bundle.write_text('const wsOptions = { maxPayload: 25 * 1024 * 1024 };') - gateway_bundle.write_text( - 'function shouldSkipBackendSelfPairing(params) {\n' - '\tif (!(params.connectParams.client.id === GATEWAY_CLIENT_IDS.GATEWAY_CLIENT && params.connectParams.client.mode === GATEWAY_CLIENT_MODES.BACKEND)) return false;\n' - '\tconst usesSharedSecretAuth = params.authMethod === "token" || params.authMethod === "password";\n' - '\tconst usesDeviceTokenAuth = params.authMethod === "device-token";\n' - '\treturn params.isLocalClient && !params.hasBrowserOriginHeader && (params.sharedAuthOk && usesSharedSecretAuth || usesDeviceTokenAuth);\n' - '}\n' - 'if (isLoopbackAddress(remoteAddr)) return { reason: "trusted_proxy_loopback_source" };\n' - 'function shouldAttachDeviceIdentityForGatewayCall(params) {\n' - '\treturn true;\n' - '}\n' - 'deviceIdentity: shouldAttachDeviceIdentityForGatewayCall({\n' - '\t\t\t\turl,\n' - '\t\t\t\ttoken,\n' - '\t\t\t\tpassword\n' - '\t\t\t}) ? loadOrCreateDeviceIdentity() : void 0,\n' - 'function ensureExplicitGatewayAuth(params) {\n' - '\tif (!params.urlOverride) return;\n' - '\tconst explicitToken = params.explicitAuth?.token;\n' - '}\n' - 'if (!device && (!isControlUi || decision.kind !== "allow")) clearUnboundScopes();\n' - ) - server_bundle.write_text( - 'function createGatewayHttpServer(opts) {\n' - '\tconst { canvasHost, clients, controlUiEnabled, controlUiBasePath, controlUiRoot, openAiChatCompletionsEnabled, openAiChatCompletionsConfig, openResponsesEnabled, openResponsesConfig, strictTransportSecurityHeader, handleHooksRequest, handlePluginRequest, shouldEnforcePluginGatewayAuth, resolvedAuth, rateLimiter, getReadiness } = opts;\n' - '\tconst getResolvedAuth = opts.getResolvedAuth ?? (() => resolvedAuth);\n' - '\tconst openAiCompatEnabled = openAiChatCompletionsEnabled || openResponsesEnabled;\n' - '\tasync function handleRequest(req, res) {\n' - '\t\tconst scopedNodeCapability = normalizePluginNodeCapabilityScopedUrl(req.url ?? "/");\n' - '\t\tif (scopedNodeCapability.rewrittenUrl) req.url = scopedNodeCapability.rewrittenUrl;\n' - '\t\tconst scopedRequestPath = scopedNodeCapability.pathname;\n' - '\t\tconst resolvedAuthValue = getResolvedAuth();\n' - '\t\tconst requestStages = [{\n' - '\t\t\t\tname: "gateway-probes",\n' - '\t\t\t\trun: () => handleGatewayProbeRequest(req, res, scopedRequestPath, resolvedAuthValue, trustedProxies, allowRealIpFallback, getReadiness)\n' - '\t\t\t}, {\n' - '\t\t\t\tname: "hooks",\n' - '\t\t\t\trun: () => handleHooksRequest(req, res)\n' - '\t\t\t}];\n' - '\t\t\tif (openAiCompatEnabled && isOpenAiModelsPath(scopedRequestPath)) requestStages.push({\n' - '\t\t\tname: "models",\n' - '\t\t\trun: async () => (await getModelsHttpModule()).handleOpenAiModelsHttpRequest(req, res, {\n' - '\t\t\t\tauth: resolvedAuthValue,\n' - '\t\t\t\ttrustedProxies,\n' - '\t\t\t\tallowRealIpFallback,\n' - '\t\t\t\trateLimiter\n' - '\t\t\t})\n' - '\t\t});\n' - '\t}\n' - '}\n' - ) - control_ui_bundle.write_text('this.ws.addEventListener(`open`,()=>this.queueConnect())') - - env = _build_base_env(tmpdir, str(config_path)) - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - env["OPENCLAW_DIST_DIR"] = str(dist_dir) - - result = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode == 0, result.stderr or result.stdout - server_source = server_bundle.read_text() - assert 'async function handleWorkspaceFilesProxyRequest(req, res) {' in server_source - assert 'name: "workspace-files-proxy"' in server_source - assert 'run: () => handleWorkspaceFilesProxyRequest(req, res)' in server_source - assert 'auth: resolvedAuthValue' in server_source - - -def test_bootstrap_patches_workspace_proxy_stage_for_upstream_2026_3_28_shape(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - dist_dir = Path(tmpdir) / "dist" - control_ui_assets_dir = dist_dir / "control-ui" / "assets" - control_ui_assets_dir.mkdir(parents=True, exist_ok=True) - client_bundle = dist_dir / "reply-test.js" - gateway_bundle = dist_dir / "gateway-cli-test.js" - server_bundle = dist_dir / "gateway-cli-old-test.js" - control_ui_bundle = control_ui_assets_dir / "main-test.js" - - client_bundle.write_text('const wsOptions = { maxPayload: 25 * 1024 * 1024 };') - gateway_bundle.write_text( - 'function shouldSkipBackendSelfPairing(params) {\n' - '\tif (!(params.connectParams.client.id === GATEWAY_CLIENT_IDS.GATEWAY_CLIENT && params.connectParams.client.mode === GATEWAY_CLIENT_MODES.BACKEND)) return false;\n' - '\tconst usesSharedSecretAuth = params.authMethod === "token" || params.authMethod === "password";\n' - '\tconst usesDeviceTokenAuth = params.authMethod === "device-token";\n' - '\treturn params.isLocalClient && !params.hasBrowserOriginHeader && (params.sharedAuthOk && usesSharedSecretAuth || usesDeviceTokenAuth);\n' - '}\n' - 'if (isLoopbackAddress(remoteAddr)) return { reason: "trusted_proxy_loopback_source" };\n' - 'function shouldAttachDeviceIdentityForGatewayCall(params) {\n' - '\treturn true;\n' - '}\n' - 'deviceIdentity: shouldAttachDeviceIdentityForGatewayCall({\n' - '\t\t\t\turl,\n' - '\t\t\t\ttoken,\n' - '\t\t\t\tpassword\n' - '\t\t\t}) ? loadOrCreateDeviceIdentity() : void 0,\n' - 'function ensureExplicitGatewayAuth(params) {\n' - '\tif (!params.urlOverride) return;\n' - '\tconst explicitToken = params.explicitAuth?.token;\n' - '}\n' - ) - server_bundle.write_text( - 'function createGatewayHttpServer(opts) {\n' - '\tasync function handleRequest(req, res) {\n' - '\t\tconst requestPath = new URL(req.url ?? "/", "http://localhost").pathname;\n' - '\t\tconst requestStages = [\n' - '\t\t\t\t{\n' - '\t\t\t\t\tname: "hooks",\n' - '\t\t\t\t\trun: () => handleHooksRequest(req, res)\n' - '\t\t\t\t},\n' - '\t\t\t\t{\n' - '\t\t\t\t\tname: "models",\n' - '\t\t\t\t\trun: () => openAiCompatEnabled ? handleOpenAiModelsHttpRequest(req, res, {\n' - '\t\t\t\t\t\tauth: resolvedAuth,\n' - '\t\t\t\t\t\ttrustedProxies,\n' - '\t\t\t\t\t\tallowRealIpFallback,\n' - '\t\t\t\t\t\trateLimiter\n' - '\t\t\t\t\t}) : false\n' - '\t\t\t\t},\n' - '\t\t];\n' - '\t}\n' - '}\n' - ) - control_ui_bundle.write_text('this.ws.addEventListener(`open`,()=>this.queueConnect())') - - env = _build_base_env(tmpdir, str(config_path)) - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - env["OPENCLAW_DIST_DIR"] = str(dist_dir) - - result = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode == 0, result.stderr or result.stdout - server_source = server_bundle.read_text() - assert 'name: "workspace-files-proxy"' in server_source - assert 'run: () => handleWorkspaceFilesProxyRequest(req, res)' in server_source - assert 'name: "models"' in server_source - - -def test_bootstrap_accepts_upstream_2026_3_28_loopback_gateway_runtime_logic(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - dist_dir = Path(tmpdir) / "dist" - control_ui_assets_dir = dist_dir / "control-ui" / "assets" - control_ui_assets_dir.mkdir(parents=True, exist_ok=True) - client_bundle = dist_dir / "reply-test.js" - auth_bundle = dist_dir / "gateway-auth-test.js" - connect_policy_bundle = dist_dir / "connect-policy-test.js" - gateway_call_bundle = dist_dir / "call-test.js" - control_ui_bundle = control_ui_assets_dir / "main-test.js" - - client_bundle.write_text('const wsOptions = { maxPayload: 25 * 1024 * 1024 };') - auth_bundle.write_text( - 'function authorizeTrustedProxy(params) {\n' - '\tconst { req, trustedProxies, trustedProxyConfig } = params;\n' - '\tif (!req) return { reason: "trusted_proxy_no_request" };\n' - '\tconst remoteAddr = req.socket?.remoteAddress;\n' - '\tif (!remoteAddr || !isTrustedProxyAddress$1(remoteAddr, trustedProxies)) return { reason: "trusted_proxy_untrusted_source" };\n' - '\tconst userHeaderValue = headerValue(req.headers[trustedProxyConfig.userHeader.toLowerCase()]);\n' - '\treturn { user: userHeaderValue.trim() };\n' - '}\n' - ) - connect_policy_bundle.write_text( - 'function shouldSkipControlUiPairing(policy, role, trustedProxyAuthOk = false, authMode) {\n' - '\tif (trustedProxyAuthOk) {\n' - '\t\treturn true;\n' - '\t}\n' - '\treturn role === "operator" && policy.allowBypass;\n' - '}\n' - ) - gateway_call_bundle.write_text( - 'function ensureExplicitGatewayAuth(params) {\n' - '\tif (!params.urlOverride) return;\n' - '\tconst explicitToken = params.explicitAuth?.token;\n' - '}\n' - ) - control_ui_bundle.write_text('this.ws.addEventListener(`open`,()=>this.queueConnect())') - - env = _build_base_env(tmpdir, str(config_path)) - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - env["OPENCLAW_DIST_DIR"] = str(dist_dir) - env["OPENCLAW_WORKSPACE_FILES_ENABLED"] = "0" - - result = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode == 0, result.stderr or result.stdout - assert 'if (!remoteAddr || !isTrustedProxyAddress$1(remoteAddr, trustedProxies)) return { reason: "trusted_proxy_untrusted_source" };' in auth_bundle.read_text() - assert 'if (trustedProxyAuthOk) {' in connect_policy_bundle.read_text() - assert 'const parsed = new URL(params.urlOverride);' in gateway_call_bundle.read_text() - - -def test_bootstrap_disables_container_self_update_runtime_hooks(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - dist_dir = Path(tmpdir) / "dist" - control_ui_assets_dir = dist_dir / "control-ui" / "assets" - control_ui_assets_dir.mkdir(parents=True, exist_ok=True) - client_bundle = dist_dir / "reply-test.js" - gateway_bundle = dist_dir / "gateway-cli-test.js" - server_bundle = dist_dir / "server-test.js" - control_ui_bundle = control_ui_assets_dir / "main-test.js" - - client_bundle.write_text('const wsOptions = { maxPayload: 25 * 1024 * 1024 };') - gateway_bundle.write_text( - 'function shouldSkipBackendSelfPairing(params) {\n' - '\tif (!(params.connectParams.client.id === GATEWAY_CLIENT_IDS.GATEWAY_CLIENT && params.connectParams.client.mode === GATEWAY_CLIENT_MODES.BACKEND)) return false;\n' - '\tconst usesSharedSecretAuth = params.authMethod === "token" || params.authMethod === "password";\n' - '\tconst usesDeviceTokenAuth = params.authMethod === "device-token";\n' - '\treturn params.isLocalClient && !params.hasBrowserOriginHeader && (params.sharedAuthOk && usesSharedSecretAuth || usesDeviceTokenAuth);\n' - '}\n' - 'if (isLoopbackAddress(remoteAddr)) return { reason: "trusted_proxy_loopback_source" };\n' - 'function shouldAttachDeviceIdentityForGatewayCall(params) {\n' - '\treturn true;\n' - '}\n' - 'deviceIdentity: shouldAttachDeviceIdentityForGatewayCall({\n' - '\t\t\t\turl,\n' - '\t\t\t\ttoken,\n' - '\t\t\t\tpassword\n' - '\t\t\t}) ? loadOrCreateDeviceIdentity() : void 0,\n' - 'function ensureExplicitGatewayAuth(params) {\n' - '\tif (!params.urlOverride) return;\n' - '\tconst explicitToken = params.explicitAuth?.token;\n' - '}\n' - 'if (!device && (!isControlUi || decision.kind !== "allow")) clearUnboundScopes();\n' - ) - server_bundle.write_text( - 'let updateAvailableCache = null;\n' - 'function getUpdateAvailable() {\n' - '\treturn updateAvailableCache;\n' - '}\n' - 'function scheduleGatewayUpdateCheck(params) {\n' - '\tlet stopped = false;\n' - '\tlet timer = null;\n' - '\tlet running = false;\n' - '\tconst tick = async () => {\n' - '\t\tif (stopped || running) return;\n' - '\t\trunning = true;\n' - '\t\ttry {\n' - '\t\t\tawait runGatewayUpdateCheck(params);\n' - '\t\t} catch {} finally {\n' - '\t\t\trunning = false;\n' - '\t\t}\n' - '\t\tif (stopped) return;\n' - '\t\tconst intervalMs = resolveCheckIntervalMs(params.cfg);\n' - '\t\ttimer = setTimeout(() => {\n' - '\t\t\ttick();\n' - '\t\t}, intervalMs);\n' - '\t};\n' - '\ttick();\n' - '\treturn () => {\n' - '\t\tstopped = true;\n' - '\t\tif (timer) {\n' - '\t\t\tclearTimeout(timer);\n' - '\t\t\ttimer = null;\n' - '\t\t}\n' - '\t};\n' - '}\n' - ) - control_ui_bundle.write_text('this.ws.addEventListener(`open`,()=>this.queueConnect())') - - env = _build_base_env(tmpdir, str(config_path)) - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - env["OPENCLAW_DIST_DIR"] = str(dist_dir) - env["OPENCLAW_WORKSPACE_FILES_ENABLED"] = "0" - - result = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode == 0, result.stderr or result.stdout - patched_source = server_bundle.read_text() - assert 'function getUpdateAvailable() {\n\treturn null;\n}' in patched_source - assert 'function scheduleGatewayUpdateCheck(params) {\n\treturn () => {};\n}' in patched_source - - -def test_bootstrap_fails_when_required_runtime_patch_targets_are_missing(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - dist_dir = Path(tmpdir) / "dist" - dist_dir.mkdir(parents=True, exist_ok=True) - marker_file = dist_dir / ".agentengine-dist-marker" - (dist_dir / "control-ui-only.js").write_text('this.ws.addEventListener(`open`,()=>this.queueConnect())') - - env = _build_base_env(tmpdir, str(config_path)) - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - env["OPENCLAW_DIST_DIR"] = str(dist_dir) - env["OPENCLAW_DIST_PATCH_MARKER"] = str(marker_file) - - result = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode != 0 - combined = result.stderr or result.stdout - assert ( - "required dist patches missing:" in combined - or "必需的 dist 补丁缺失:" in combined - ) - assert not marker_file.exists() - - -def test_bootstrap_dist_patch_registry_uses_capability_group_and_variant_metadata(): - bootstrap = BOOTSTRAP_SCRIPT.read_text(encoding="utf-8") - - assert "const requiredCapabilities = new Set([" in bootstrap - assert "capability:" in bootstrap - assert "group:" in bootstrap - assert "variant:" in bootstrap - assert "why:" in bootstrap - assert "since:" in bootstrap - assert "按能力验证必需补丁" in bootstrap - assert "缺失的必需能力" in bootstrap - assert "requiredLabels" not in bootstrap - assert "patchedLabels" not in bootstrap - - -def test_bootstrap_defaults_state_dir_under_home_for_non_root_runtime(): - with TemporaryDirectory() as tmpdir: - home_dir = Path(tmpdir) / "home" / "node" - home_dir.mkdir(parents=True, exist_ok=True) - env = os.environ.copy() - env.pop("OPENCLAW_MODEL_API_KEY", None) - env.pop("OPENAI_API_KEY", None) - env.pop("OPENCLAW_STATE_DIR", None) - env.pop("OPENCLAW_CONFIG_PATH", None) - env["HOME"] = str(home_dir) - env["OPENCLAW_BOOTSTRAP_ONLY"] = "1" - env["OPENCLAW_MODEL_PROVIDER_ID"] = "ksyun" - env["OPENCLAW_MODEL_BASE_URL"] = "http://example.test/v1" - env["OPENCLAW_DEFAULT_MODEL"] = "ksyun/glm-5.1" - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - - result = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - - state_dir = home_dir / ".openclaw" - config_path = state_dir / "openclaw.json" - secrets_path = state_dir / "secrets.json" - - assert result.returncode == 0, result.stderr or result.stdout - assert config_path.exists() - assert secrets_path.exists() - cfg = json.loads(config_path.read_text()) - assert cfg["agents"]["defaults"]["workspace"] == str(state_dir / "workspace") - assert cfg["secrets"]["providers"]["default"]["path"] == str(secrets_path) - - -def test_bootstrap_applies_mem0_memory_backend_manifest(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - default_extensions_dir = Path(tmpdir) / "default-extensions" / "openclaw-mem0" - default_extensions_dir.mkdir(parents=True, exist_ok=True) - (default_extensions_dir / "manifest.json").write_text('{"name":"openclaw-mem0"}\n') - env = _build_base_env(tmpdir, str(config_path)) - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - env["OPENCLAW_DEFAULT_EXTENSIONS_DIR"] = str(Path(tmpdir) / "default-extensions") - env["MEMORY_BACKEND_MANIFEST"] = _build_mem0_manifest_json() - env["MEM0_API_KEY"] = f"2000104981.{VALID_MEM0_UUID}:mem0-secret" - env["MEM0_USER_ID"] = "2000104981" - env["MEM0_BASE_URL"] = "http://mem-service.example.test" - - result = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode == 0, result.stderr or result.stdout - cfg = json.loads(config_path.read_text()) - assert (Path(tmpdir) / "extensions" / "openclaw-mem0" / "manifest.json").exists() - assert cfg["plugins"]["slots"]["memory"] == "openclaw-mem0" - assert "openclaw-mem0" in cfg["plugins"]["allow"] - assert cfg["plugins"]["entries"]["openclaw-mem0"] == { - "enabled": True, - "config": { - "mode": "platform", - "apiKey": f"2000104981.{VALID_MEM0_UUID}:mem0-secret", - "baseUrl": "http://mem-service.example.test", - "userId": "2000104981", - }, - } - - -def test_bootstrap_openclaw_default_manifest_clears_existing_mem0_memory_backend(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - config_path.write_text( - json.dumps( - { - "plugins": { - "slots": {"memory": "openclaw-mem0", "search": "perplexity"}, - "allow": ["openclaw-mem0", "perplexity"], - "entries": { - "openclaw-mem0": { - "enabled": True, - "config": { - "mode": "platform", - "apiKey": "old-key", - "baseUrl": "http://mem-service.example.test", - "userId": "2000104981", - }, - }, - "perplexity": {"enabled": True}, - }, - } - } - ) - ) - env = _build_base_env(tmpdir, str(config_path)) - env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value" - env["MEMORY_BACKEND_MANIFEST"] = _build_openclaw_default_memory_manifest_json() - - result = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode == 0, result.stderr or result.stdout - cfg = json.loads(config_path.read_text()) - assert cfg["plugins"]["slots"] == {"search": "perplexity"} - assert "openclaw-mem0" not in cfg["plugins"]["allow"] - assert cfg["plugins"]["entries"]["openclaw-mem0"] == { - "enabled": False, - "config": {}, - } - assert cfg["plugins"]["entries"]["perplexity"] == {"enabled": True} - - -def test_bootstrap_fails_when_mem0_manifest_env_is_incomplete(): - with TemporaryDirectory() as tmpdir: - config_path = Path(tmpdir) / "openclaw.json" - env = _build_base_env(tmpdir, str(config_path)) - env["MEMORY_BACKEND_MANIFEST"] = _build_mem0_manifest_json() - - result = subprocess.run( - ["bash", str(BOOTSTRAP_SCRIPT)], - cwd=str(REPO_ROOT), - env=env, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode != 0 - combined = f"{result.stdout}\n{result.stderr}" - assert ( - "MEM0_API_KEY" in combined - or "MEM0_USER_ID" in combined - or "MEM0_BASE_URL" in combined - ) diff --git a/tests/test_prepare_ksadk_python_export.py b/tests/test_prepare_ksadk_python_export.py deleted file mode 100644 index 25a3f22f..00000000 --- a/tests/test_prepare_ksadk_python_export.py +++ /dev/null @@ -1,277 +0,0 @@ -from __future__ import annotations - -import importlib.util -import json -import subprocess -import sys -from pathlib import Path - - -REPO_ROOT = Path(__file__).resolve().parents[1] -SCRIPT_PATH = REPO_ROOT / "scripts" / "prepare_ksadk_python_export.py" -AUDIT_SCRIPT_PATH = REPO_ROOT / "scripts" / "open_source_audit.py" - - -def _load_export_module(): - spec = importlib.util.spec_from_file_location("prepare_ksadk_python_export", SCRIPT_PATH) - assert spec is not None - module = importlib.util.module_from_spec(spec) - assert spec.loader is not None - sys.modules[spec.name] = module - spec.loader.exec_module(module) - return module - - -def _write(path: Path, text: str = "ok\n") -> None: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(text, encoding="utf-8") - - -def _make_git_repo(root: Path) -> None: - files = { - "README.md": "# KSADK\n", - "README.en.md": "# KSADK\n", - "README.zh-CN.md": "# KSADK\n", - "LICENSE": "Apache License\n", - "mkdocs.yml": "site_name: KSADK\n", - "pyproject.toml": "[project]\nname = \"ksadk\"\n", - "ksadk/__init__.py": "\n", - "ksadk/server/static/index.html": "\n", - "ksadk/server/web-ui/src/App.tsx": "export default function App() { return null }\n", - "ksadk/server/web-ui/package.json": "{\"name\":\"web-ui\"}\n", - "ksadk/server/web-ui/dist-hosted/index.html": "\n", - "ksadk_runtime_common/__init__.py": "\n", - "ksadk_runtime_common/schemas/event.json": "{}\n", - "tests/test_open_source_audit.py": "def test_public():\n assert True\n", - "tests/test_public_positioning_docs.py": "def test_public_docs():\n assert True\n", - "tests/test_check_publication_state.py": "def test_publication_state():\n assert True\n", - "tests/test_tracing_setup_otlp.py": "def test_tracing_public():\n assert True\n", - "tests/test_deploy_integration.py": "def test_internal():\n assert True\n", - "tests/snapshots/help_snapshots.txt": "internal snapshot\n", - "public-docs/index.md": "# Public docs\n", - "public-docs/assets/ksadk-runtime-platform-hero.png": "hero\n", - "public-docs/assets/ksadk-web-ui-screenshot.png": "screenshot\n", - "public-docs/assets/ksadk-runtime-architecture.svg": "\n", - "public-docs/assets/ksadk-runtime-architecture.png": "png\n", - "public-docs/assets/ksadk-local-debugging-demo.gif": "gif\n", - "scripts/open_source_audit.py": "print('audit')\n", - "scripts/audit_release_artifacts.py": "print('dist audit')\n", - "scripts/check_publication_state.py": "print('publication')\n", - "scripts/generate_public_assets.py": "print('assets')\n", - "scripts/prepare_ksadk_python_export.py": "print('export')\n", - "scripts/prepare_ksadk_web_export.py": "print('web export')\n", - "docs/internal/release-secret.md": "internal\n", - "docs/archive/old.md": "old internal doc\n", - "examples/smart_assistant_adk/.env.example": "OPENAI_API_BASE=http://kspmas.ksyun.com/v1\n", - "deploy/hermes/Dockerfile": "FROM internal\n", - "deploy/openclaw/Dockerfile": "FROM internal\n", - "skills/agentengine-cluster-debug/SKILL.md": "kubeconfig\n", - "Makefile.openclaw": "DOCKER_REGISTRY ?= ghcr.io\n", - "Makefile.promo.Dockerfile": "FROM internal\n", - "CLAUDE.md": "public assistant notes\n", - "AGENTS.md": "public contributor notes\n", - ".env.example": "OPENAI_API_BASE=https://kspmas.ksyun.com/v1\n", - ".zread/wiki/current.md": "generated\n", - "site/index.html": "generated\n", - "dist/ksadk-0.1.0.whl": "wheel\n", - "build/lib/ksadk/__init__.py": "\n", - "ksadk.egg-info/PKG-INFO": "metadata\n", - ".pypirc": "token\n", - ".pypirc.example": "token placeholder\n", - "deploy/helm/ksadk-docs/values.yaml": "internal route\n", - "Dockerfile.docs": "internal docs deploy\n", - "__pycache__/module.pyc": "cache\n", - } - for rel_path, text in files.items(): - _write(root / rel_path, text) - - subprocess.run(["git", "init"], cwd=root, check=True, stdout=subprocess.PIPE) - subprocess.run(["git", "add", "."], cwd=root, check=True) - subprocess.run( - [ - "git", - "-c", - "user.email=test@example.com", - "-c", - "user.name=Test", - "commit", - "-m", - "init", - ], - cwd=root, - check=True, - stdout=subprocess.PIPE, - ) - - -def test_export_plan_selects_public_candidate_files_and_excludes_local_artifacts(tmp_path): - exporter = _load_export_module() - _make_git_repo(tmp_path) - - plan = exporter.build_export_plan(tmp_path) - - assert plan.ok is True - assert "README.md" in plan.export_paths - assert "README.en.md" in plan.export_paths - assert "README.zh-CN.md" in plan.export_paths - assert "CLAUDE.md" in plan.export_paths - assert "AGENTS.md" in plan.export_paths - assert "ksadk/__init__.py" in plan.export_paths - assert "ksadk/server/static/index.html" in plan.export_paths - assert "ksadk/server/web-ui/src/App.tsx" not in plan.export_paths - assert "ksadk/server/web-ui/package.json" not in plan.export_paths - assert "ksadk/server/web-ui/dist-hosted/index.html" not in plan.export_paths - assert "ksadk_runtime_common/__init__.py" in plan.export_paths - assert "ksadk_runtime_common/schemas/event.json" in plan.export_paths - assert "tests/test_open_source_audit.py" in plan.export_paths - assert "tests/test_public_positioning_docs.py" in plan.export_paths - assert "tests/test_check_publication_state.py" in plan.export_paths - assert "tests/test_tracing_setup_otlp.py" in plan.export_paths - assert "public-docs/index.md" in plan.export_paths - assert "public-docs/assets/ksadk-runtime-platform-hero.png" in plan.export_paths - assert "public-docs/assets/ksadk-web-ui-screenshot.png" in plan.export_paths - assert "public-docs/assets/ksadk-runtime-architecture.svg" in plan.export_paths - assert "public-docs/assets/ksadk-runtime-architecture.png" in plan.export_paths - assert "public-docs/assets/ksadk-local-debugging-demo.gif" in plan.export_paths - assert "docs/release-checklist.md" not in plan.export_paths - assert "docs/ksadk开源准备计划.md" not in plan.export_paths - assert "scripts/open_source_audit.py" in plan.export_paths - assert "scripts/audit_release_artifacts.py" in plan.export_paths - assert "scripts/check_publication_state.py" in plan.export_paths - assert "scripts/generate_public_assets.py" in plan.export_paths - assert "scripts/prepare_ksadk_python_export.py" in plan.export_paths - assert "scripts/prepare_ksadk_web_export.py" in plan.export_paths - assert "tests/test_deploy_integration.py" not in plan.export_paths - assert "tests/snapshots/help_snapshots.txt" not in plan.export_paths - assert "docs/internal/release-secret.md" not in plan.export_paths - assert "docs/archive/old.md" not in plan.export_paths - assert "examples/smart_assistant_adk/.env.example" not in plan.export_paths - assert "deploy/hermes/Dockerfile" not in plan.export_paths - assert "deploy/openclaw/Dockerfile" not in plan.export_paths - assert "skills/agentengine-cluster-debug/SKILL.md" not in plan.export_paths - assert "Makefile.openclaw" not in plan.export_paths - assert "Makefile.promo.Dockerfile" not in plan.export_paths - assert ".env.example" not in plan.export_paths - assert ".zread/wiki/current.md" not in plan.export_paths - assert "site/index.html" not in plan.export_paths - assert "dist/ksadk-0.1.0.whl" not in plan.export_paths - assert "build/lib/ksadk/__init__.py" not in plan.export_paths - assert "ksadk.egg-info/PKG-INFO" not in plan.export_paths - assert ".pypirc" not in plan.export_paths - assert ".pypirc.example" not in plan.export_paths - assert "deploy/helm/ksadk-docs/values.yaml" not in plan.export_paths - assert "Dockerfile.docs" not in plan.export_paths - assert "__pycache__/module.pyc" not in plan.export_paths - assert ".pypirc" in plan.excluded_paths - assert "deploy/hermes/Dockerfile" in plan.excluded_paths - assert "skills/agentengine-cluster-debug/SKILL.md" in plan.excluded_paths - - -def test_cli_writes_clean_export_candidate_and_manifest(tmp_path): - source_root = tmp_path / "source" - output_dir = tmp_path / "ksadk-python-export" - source_root.mkdir() - _make_git_repo(source_root) - - result = subprocess.run( - [ - sys.executable, - str(SCRIPT_PATH), - "--repo-root", - str(source_root), - "--output-dir", - str(output_dir), - "--json", - ], - cwd=REPO_ROOT, - check=False, - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - - assert result.returncode == 0, result.stderr - payload = json.loads(result.stdout) - assert payload["ok"] is True - assert payload["outputDir"] == str(output_dir.resolve()) - assert (output_dir / "README.md").is_file() - assert (output_dir / "README.en.md").is_file() - assert (output_dir / "README.zh-CN.md").is_file() - assert (output_dir / "CLAUDE.md").is_file() - assert (output_dir / "AGENTS.md").is_file() - assert (output_dir / "ksadk" / "__init__.py").is_file() - assert (output_dir / "ksadk" / "server" / "static" / "index.html").is_file() - assert not (output_dir / "ksadk" / "server" / "web-ui").exists() - assert (output_dir / "ksadk_runtime_common" / "__init__.py").is_file() - assert (output_dir / "public-docs" / "index.md").is_file() - assert (output_dir / "public-docs" / "assets" / "ksadk-runtime-platform-hero.png").is_file() - assert (output_dir / "public-docs" / "assets" / "ksadk-web-ui-screenshot.png").is_file() - assert (output_dir / "public-docs" / "assets" / "ksadk-runtime-architecture.svg").is_file() - assert (output_dir / "public-docs" / "assets" / "ksadk-runtime-architecture.png").is_file() - assert (output_dir / "public-docs" / "assets" / "ksadk-local-debugging-demo.gif").is_file() - assert (output_dir / "export-manifest.json").is_file() - assert not (output_dir / ".pypirc").exists() - assert not (output_dir / "docs" / "internal").exists() - assert not (output_dir / "docs" / "archive").exists() - assert not (output_dir / "examples").exists() - assert not (output_dir / ".zread").exists() - assert not (output_dir / "site").exists() - assert not (output_dir / "dist").exists() - assert not (output_dir / "deploy").exists() - assert not (output_dir / "skills").exists() - assert not (output_dir / "Makefile.openclaw").exists() - assert not (output_dir / "Makefile.promo.Dockerfile").exists() - assert not (output_dir / ".env.example").exists() - assert not (output_dir / "deploy" / "helm" / "ksadk-docs").exists() - - manifest = json.loads((output_dir / "export-manifest.json").read_text(encoding="utf-8")) - assert manifest["targetRepository"] == "https://github.com/kingsoftcloud/ksadk-python" - assert manifest["documentation"] == "https://kingsoftcloud.github.io/ksadk-python/" - assert manifest["exportPathCount"] == len(payload["export_paths"]) - assert ".pypirc" in manifest["excludedPaths"] - assert "deploy/hermes/Dockerfile" in manifest["excludedPaths"] - assert "tests/test_deploy_integration.py" in manifest["excludedPaths"] - assert "examples/smart_assistant_adk/.env.example" in manifest["excludedPaths"] - - audit = subprocess.run( - [ - sys.executable, - str(AUDIT_SCRIPT_PATH), - "--target", - "public-repo", - "--root", - str(output_dir), - ], - cwd=REPO_ROOT, - check=False, - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - assert audit.returncode == 0, audit.stdout + audit.stderr - - -def test_cli_summary_mode_prints_review_friendly_output(tmp_path): - _make_git_repo(tmp_path) - - result = subprocess.run( - [ - sys.executable, - str(SCRIPT_PATH), - "--repo-root", - str(tmp_path), - "--summary", - ], - cwd=REPO_ROOT, - check=False, - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - - assert result.returncode == 0, result.stderr - assert "ok: true" in result.stdout - assert "target repository: https://github.com/kingsoftcloud/ksadk-python" in result.stdout - assert "documentation: https://kingsoftcloud.github.io/ksadk-python/" in result.stdout - assert "export paths:" in result.stdout - assert "excluded paths:" in result.stdout diff --git a/tests/test_prepare_ksadk_web_export.py b/tests/test_prepare_ksadk_web_export.py deleted file mode 100644 index 0c0eea0c..00000000 --- a/tests/test_prepare_ksadk_web_export.py +++ /dev/null @@ -1,282 +0,0 @@ -from __future__ import annotations - -import importlib.util -import json -import subprocess -import sys -from pathlib import Path - - -REPO_ROOT = Path(__file__).resolve().parents[1] -SCRIPT_PATH = REPO_ROOT / "scripts" / "prepare_ksadk_web_export.py" - - -def _load_export_module(): - spec = importlib.util.spec_from_file_location("prepare_ksadk_web_export", SCRIPT_PATH) - assert spec is not None - module = importlib.util.module_from_spec(spec) - assert spec.loader is not None - sys.modules[spec.name] = module - spec.loader.exec_module(module) - return module - - -def _write(path: Path, text: str = "ok\n") -> None: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(text, encoding="utf-8") - - -def _make_ui_tree(root: Path, *, hosted: bool) -> None: - for file_name in [ - ".gitignore", - "README.md", - "components.json", - "eslint.config.js", - "index.html", - "package-lock.json", - "package.json", - "postcss.config.js", - "tailwind.config.ts", - "tsconfig.app.json", - "tsconfig.json", - "tsconfig.node.json", - "vite.config.ts", - "public/favicon.svg", - "public/icons.svg", - "src/App.tsx", - "src/main.tsx", - "src/App.css", - "src/index.css", - ]: - _write(root / file_name, f"{file_name}\n") - - package_json = { - "name": "web-ui", - "private": True, - "version": "0.0.0", - "type": "module", - "scripts": { - "dev": "vite", - "build": "vite build && node scripts/sync-static.mjs", - "build:hosted": "VITE_BASE_PATH=/chat/ vite build --outDir dist-hosted", - "build:all": "npm run build && npm run build:hosted", - "test": "vitest run src", - }, - } - (root / "package.json").write_text( - json.dumps(package_json, indent=2) + "\n", encoding="utf-8" - ) - package_lock = { - "name": "web-ui", - "version": "0.0.0", - "lockfileVersion": 3, - "packages": { - "": { - "name": "web-ui", - "version": "0.0.0", - } - }, - } - (root / "package-lock.json").write_text( - json.dumps(package_lock, indent=2) + "\n", encoding="utf-8" - ) - - common_tests = [ - "capabilities.test.mjs", - "feedback-utils.test.mjs", - "mobile-layout.test.mjs", - "native-platform.test.mjs", - "responses-stream.test.mjs", - "run-state.test.mjs", - "session-events.test.mjs", - "session-list.test.mjs", - "session-persistence.test.mjs", - "sidebar-contract.test.mjs", - "stream-control.test.mjs", - "terminal-session.test.mjs", - "tool-display.test.mjs", - "ui-utils.test.mjs", - "workspace-panel-contract.test.mjs", - "workspace-utils.test.mjs", - ] - for file_name in common_tests: - _write(root / "tests" / file_name, f"{file_name}\n") - - if hosted: - _write(root / "Dockerfile") - _write(root / "nginx.conf") - _write(root / "deploy/helm/agentengine-hosted-ui/values.yaml") - _write(root / "dist-hosted/index.html") - _write(root / "dist/index.html") - _write(root / "node_modules/.package-lock.json") - _write(root / "tests/helm-contract.test.mjs") - _write(root / "tests/makefile-contract.test.mjs") - else: - _write(root / "dist-hosted/index.html") - _write(root / "scripts/sync-static.mjs") - _write(root / "tests/hosted-ui-sync.test.mjs") - _write(root / "tests/sync-static.test.mjs") - - -def test_default_hosted_root_points_to_workspace_sibling_repo(): - exporter = _load_export_module() - - assert exporter.DEFAULT_HOSTED_ROOT.name == "agentengine-hosted-ui" - assert exporter.DEFAULT_HOSTED_ROOT.is_absolute() - - -def test_resolve_default_hosted_root_supports_nested_workspace(tmp_path): - exporter = _load_export_module() - hosted_root = tmp_path / "agentengine" / "agentengine-hosted-ui" - repo_root = tmp_path / "agentengine" / "ksadk-python" - hosted_root.mkdir(parents=True) - repo_root.mkdir(parents=True) - - assert exporter.resolve_default_hosted_root(repo_root) == hosted_root - - -def test_resolve_default_hosted_root_supports_flat_workspace(tmp_path): - exporter = _load_export_module() - hosted_root = tmp_path / "agentengine-hosted-ui" - repo_root = tmp_path / "ksadk-python" - hosted_root.mkdir(parents=True) - repo_root.mkdir(parents=True) - - assert exporter.resolve_default_hosted_root(repo_root) == hosted_root - - -def test_export_plan_selects_shared_files_and_excludes_consumer_shells(tmp_path): - exporter = _load_export_module() - hosted_root = tmp_path / "agentengine-hosted-ui" - ksadk_web_ui = tmp_path / "ksadk-python" / "ksadk" / "server" / "web-ui" - _make_ui_tree(hosted_root, hosted=True) - _make_ui_tree(ksadk_web_ui, hosted=False) - - plan = exporter.build_export_plan(hosted_root=hosted_root, ksadk_web_ui=ksadk_web_ui) - - assert plan.ok is True - assert "src/App.tsx" in plan.export_paths - assert "public/favicon.svg" in plan.export_paths - assert "tests/capabilities.test.mjs" in plan.export_paths - assert "tests/helm-contract.test.mjs" not in plan.export_paths - assert "tests/makefile-contract.test.mjs" not in plan.export_paths - assert "tests/hosted-ui-sync.test.mjs" not in plan.export_paths - assert "tests/sync-static.test.mjs" not in plan.export_paths - assert "Dockerfile" not in plan.export_paths - assert "nginx.conf" not in plan.export_paths - assert "deploy/helm/agentengine-hosted-ui/values.yaml" not in plan.export_paths - assert "dist-hosted/index.html" not in plan.export_paths - assert "node_modules/.package-lock.json" not in plan.export_paths - assert plan.hosted_only_tests == ["helm-contract.test.mjs", "makefile-contract.test.mjs"] - assert plan.ksadk_only_tests == ["hosted-ui-sync.test.mjs", "sync-static.test.mjs"] - - -def test_export_plan_reports_source_mismatch(tmp_path): - exporter = _load_export_module() - hosted_root = tmp_path / "agentengine-hosted-ui" - ksadk_web_ui = tmp_path / "ksadk-python" / "ksadk" / "server" / "web-ui" - _make_ui_tree(hosted_root, hosted=True) - _make_ui_tree(ksadk_web_ui, hosted=False) - (ksadk_web_ui / "src" / "App.tsx").write_text("changed\n", encoding="utf-8") - - plan = exporter.build_export_plan(hosted_root=hosted_root, ksadk_web_ui=ksadk_web_ui) - - assert plan.ok is False - assert any("src/App.tsx" in violation for violation in plan.violations) - - -def test_cli_writes_candidate_export_without_forbidden_files(tmp_path): - hosted_root = tmp_path / "agentengine-hosted-ui" - ksadk_web_ui = tmp_path / "ksadk-python" / "ksadk" / "server" / "web-ui" - output_dir = tmp_path / "ksadk-web-export" - _make_ui_tree(hosted_root, hosted=True) - _make_ui_tree(ksadk_web_ui, hosted=False) - - result = subprocess.run( - [ - sys.executable, - str(SCRIPT_PATH), - "--hosted-root", - str(hosted_root), - "--ksadk-web-ui", - str(ksadk_web_ui), - "--output-dir", - str(output_dir), - "--json", - ], - cwd=REPO_ROOT, - check=False, - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - - assert result.returncode == 0, result.stderr - payload = json.loads(result.stdout) - assert payload["ok"] is True - assert payload["generatedCandidateFiles"] - assert (output_dir / "src" / "App.tsx").is_file() - assert (output_dir / "tests" / "capabilities.test.mjs").is_file() - assert (output_dir / "README.md").is_file() - assert (output_dir / "LICENSE").is_file() - assert (output_dir / "SECURITY.md").is_file() - assert (output_dir / "CONTRIBUTING.md").is_file() - assert (output_dir / ".github" / "workflows" / "ci.yml").is_file() - assert (output_dir / ".github" / "workflows" / "pages.yml").is_file() - assert not (output_dir / "Dockerfile").exists() - assert not (output_dir / "nginx.conf").exists() - assert not (output_dir / "deploy").exists() - assert not (output_dir / "dist-hosted").exists() - assert not (output_dir / "node_modules").exists() - assert not (output_dir / "tests" / "helm-contract.test.mjs").exists() - assert not (output_dir / "tests" / "hosted-ui-sync.test.mjs").exists() - - package_json = json.loads((output_dir / "package.json").read_text(encoding="utf-8")) - assert package_json["name"] == "@kingsoftcloud/ksadk-web" - assert package_json["version"] == "0.1.0" - assert package_json["license"] == "Apache-2.0" - assert package_json["homepage"] == "https://kingsoftcloud.github.io/ksadk-web/" - assert "private" not in package_json - assert package_json["scripts"]["build"] == "npm run build:ksadk" - assert package_json["scripts"]["build:ksadk"] == "VITE_BASE_PATH=./ vite build --outDir dist-ksadk" - assert package_json["scripts"]["build:hosted"] == "VITE_BASE_PATH=/chat/ vite build --outDir dist-hosted" - assert "sync-static.mjs" not in json.dumps(package_json) - - package_lock = json.loads((output_dir / "package-lock.json").read_text(encoding="utf-8")) - assert package_lock["name"] == "@kingsoftcloud/ksadk-web" - assert package_lock["packages"][""]["name"] == "@kingsoftcloud/ksadk-web" - - manifest = json.loads((output_dir / "export-manifest.json").read_text(encoding="utf-8")) - assert manifest["publicDemo"] == "https://kingsoftcloud.github.io/ksadk-web/" - - -def test_cli_summary_mode_prints_review_friendly_output(tmp_path): - hosted_root = tmp_path / "agentengine-hosted-ui" - ksadk_web_ui = tmp_path / "ksadk-python" / "ksadk" / "server" / "web-ui" - _make_ui_tree(hosted_root, hosted=True) - _make_ui_tree(ksadk_web_ui, hosted=False) - - result = subprocess.run( - [ - sys.executable, - str(SCRIPT_PATH), - "--hosted-root", - str(hosted_root), - "--ksadk-web-ui", - str(ksadk_web_ui), - "--summary", - ], - cwd=REPO_ROOT, - check=False, - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - - assert result.returncode == 0, result.stderr - assert "ok: true" in result.stdout - assert "export paths:" in result.stdout - assert "hosted-only tests excluded: helm-contract.test.mjs, makefile-contract.test.mjs" in result.stdout - assert "ksadk-only tests excluded: hosted-ui-sync.test.mjs, sync-static.test.mjs" in result.stdout - assert "export_paths" not in result.stdout - assert "src/App.tsx" not in result.stdout diff --git a/tests/test_public_positioning_docs.py b/tests/test_public_positioning_docs.py deleted file mode 100644 index 5bfb7eac..00000000 --- a/tests/test_public_positioning_docs.py +++ /dev/null @@ -1,447 +0,0 @@ -from __future__ import annotations - -from pathlib import Path -import re -import tomllib -import yaml - - -ROOT = Path(__file__).resolve().parents[1] - - -class MkdocsTestLoader(yaml.SafeLoader): - pass - - -def _ignore_python_name(loader: MkdocsTestLoader, node: yaml.Node): - return loader.construct_scalar(node) - - -MkdocsTestLoader.add_constructor( - "tag:yaml.org,2002:python/name:pymdownx.superfences.fence_code_format", - _ignore_python_name, -) - - -def _read(relative_path: str) -> str: - return (ROOT / relative_path).read_text(encoding="utf-8") - - -def _public_markdown_and_config_files() -> list[Path]: - files = [ - ROOT / "README.md", - ROOT / "README.zh-CN.md", - ROOT / "README.en.md", - ROOT / "CHANGELOG.md", - ROOT / "mkdocs.yml", - ROOT / "pyproject.toml", - ] - files.extend(sorted((ROOT / "public-docs").rglob("*.md"))) - return files - - -PUBLIC_FORBIDDEN_PATTERNS = ( - ("pre_release_region", re.compile(r"\bpre[\W_]*online\b", re.IGNORECASE)), - ("pre_release_region_zh", re.compile(r"\u9884\u53d1")), - ( - "private_icp_endpoint", - re.compile( - r"\b(?!(?:aicp[.-](?:inner|internal)[.-]api[.-]ksyun[.-]com|aicp[.-]api[.-]ksyun[.-]com)\b)" - r"\w*icp[.-](?:inner|internal)[.-]api[.-][\w.-]+\b", - re.IGNORECASE, - ), - ), - ("private_agent_api_endpoint", re.compile(r"\bagent[.-]api[.-]pre\b", re.IGNORECASE)), - ("private_kspmas_endpoint", re.compile(r"\bkspmas[.-]internal\b", re.IGNORECASE)), - ("private_region_header", re.compile(r"\bX[-_]K(?:sc|SC)[-_]Region\b")), - ("private_custom_source_header", re.compile(r"\bX[-_]KSC[-_]CUSTOM[-_]SOURCE\b")), - ( - "private_review_process", - re.compile( - r"\b(?:internal\s+(?:ezone|review\s+gate|maintainer\s+review)|company\s+review)\b", - re.IGNORECASE, - ), - ), - ("private_review_process_zh", re.compile(r"\u5185\u90e8\s*(?:ezone|review|\u5ba1\u6838)")), -) - - -def _assert_no_public_sensitive_patterns(relative_path: str, text: str) -> None: - for label, pattern in PUBLIC_FORBIDDEN_PATTERNS: - assert not pattern.search(text), f"{relative_path} matches {label}: {pattern.pattern}" - - -def test_readmes_position_ksadk_as_runtime_platform(): - expected_sections = ( - "简体中文(默认)", - "一次构建 Agent,到处运行。", - "Agent Runtime Platform", - "public-docs/assets/ksadk-runtime-platform-hero-wide.png", - "真实 CLI 截图", - "为什么需要 KsADK", - "30 秒快速体验", - "真实本地 Web UI 演示", - "public-docs/assets/ksadk-web-ui-screenshot.png", - "public-docs/assets/ksadk-local-debugging-demo.gif", - "public-docs/assets/ksadk-runtime-architecture.png", - "架构", - "生态定位对比", - "可观测", - "文档与样例", - "相关项目", - "参与贡献", - ) - for relative_path in ("README.md", "README.zh-CN.md"): - text = _read(relative_path) - for expected in expected_sections: - assert expected in text, f"{relative_path} missing {expected}" - assert "```mermaid" not in text - assert "Agent Development Kit" not in text - _assert_no_public_sensitive_patterns(relative_path, text) - assert "当前版本:" not in text - assert "候选版本:" not in text - assert "## 0.6.4 重点" not in text - assert "## 0.6.3 重点" not in text - assert "repair_markdown" not in text - assert "[CHANGELOG.md](CHANGELOG.md)" not in text - assert "https://github.com/kingsoftcloud/ksadk-python/releases" not in text - - -def test_english_readme_positions_ksadk_as_runtime_platform(): - text = _read("README.en.md") - expected_sections = ( - "Build agents once. Run them anywhere.", - "Agent Runtime Platform", - "public-docs/assets/ksadk-runtime-platform-hero-wide.png", - "Real KsADK CLI screenshot", - "Why KsADK", - "30 Seconds Quick Start", - "local debugging Web UI", - "public-docs/assets/ksadk-web-ui-screenshot.png", - "public-docs/assets/ksadk-local-debugging-demo.gif", - "public-docs/assets/ksadk-runtime-architecture.png", - "Architecture", - "Ecosystem Positioning", - "Observability", - "Docs And Examples", - "Related Projects", - "Contributing", - ) - for expected in expected_sections: - assert expected in text - assert "```mermaid" not in text - assert "Agent Development Kit" not in text - _assert_no_public_sensitive_patterns("README.en.md", text) - assert "Current version:" not in text - assert "Candidate version:" not in text - assert "## 0.6.4 Highlights" not in text - assert "## 0.6.3 Highlights" not in text - assert "repair_markdown" not in text - assert "[CHANGELOG.md](CHANGELOG.md)" not in text - assert "https://github.com/kingsoftcloud/ksadk-python/releases" not in text - - -def test_docs_homepage_uses_runtime_platform_information_architecture(): - zh = _read("public-docs/index.md") - en = _read("public-docs/index.en.md") - - for expected in ( - "一次构建 Agent,到处运行。", - "Agent Runtime Platform", - "assets/ksadk-runtime-platform-hero-wide.png", - "真实 CLI 截图", - "为什么需要 KsADK", - "真实本地 Web UI 演示", - "assets/ksadk-web-ui-screenshot.png", - "assets/ksadk-local-debugging-demo.gif", - "assets/ksadk-runtime-architecture.png", - "生态定位", - "VEADK", - "AgentRun", - "OpenTelemetry", - "Hermes", - "OpenClaw", - "KSYUN_REGION=cn-beijing-6", - ): - assert expected in zh - - for expected in ( - "Build agents once. Run them anywhere.", - "Agent Runtime Platform", - "assets/ksadk-runtime-platform-hero-wide.png", - "Real KsADK CLI screenshot", - "Why KsADK", - "real local Web UI", - "assets/ksadk-web-ui-screenshot.png", - "assets/ksadk-local-debugging-demo.gif", - "assets/ksadk-runtime-architecture.png", - "Ecosystem Positioning", - "VEADK", - "AgentRun", - "OpenTelemetry", - "Hermes", - "OpenClaw", - "KSYUN_REGION=cn-beijing-6", - ): - assert expected in en - - for text in (zh, en): - assert "Agent Development Kit" not in text - assert "成熟 Agent SDK" not in text - - -def test_docs_include_phase_one_positioning_pages(): - zh_pages = { - "public-docs/getting-started/why-ksadk.md": ( - "为什么需要 KsADK", - "一次构建 Agent,到处运行。", - "ADK 解决 Agent 开发", - "LangGraph", - "OpenAI Agents SDK", - "Agent Runtime Platform", - ), - "public-docs/getting-started/architecture.md": ( - "架构", - "KsADK Agent Runtime Platform 架构", - "Skill Runtime", - "Workspace", - "Sandbox", - "Hermes / OpenClaw Runtime", - ), - "public-docs/getting-started/comparison.md": ( - "生态定位对比", - "这页不是能力打分榜", - "VEADK", - "AgentRun", - "repair_markdown(text, enabled=True)", - ), - } - en_pages = { - "public-docs/getting-started/why-ksadk.en.md": ( - "Why KsADK", - "Build agents once. Run them anywhere.", - "How do I run, debug, expose, deploy, and observe agents consistently?", - "OpenAI Agents SDK", - "Agent Runtime Platform", - ), - "public-docs/getting-started/architecture.en.md": ( - "Architecture", - "KsADK Agent Runtime Platform architecture", - "Skill Runtime", - "Workspace", - "Sandbox", - "Hermes / OpenClaw Runtime", - ), - "public-docs/getting-started/comparison.en.md": ( - "Ecosystem Positioning", - "not a feature scorecard", - "VEADK", - "AgentRun", - "repair_markdown(text, enabled=True)", - ), - } - - for relative_path, expected_terms in {**zh_pages, **en_pages}.items(): - text = _read(relative_path) - for expected in expected_terms: - assert expected in text, f"{relative_path} missing {expected}" - _assert_no_public_sensitive_patterns(relative_path, text) - - -def test_public_positioning_does_not_use_misleading_feature_scorecards(): - scorecard_headers = ( - "| 能力 | ADK | LangGraph | OpenAI Agents SDK | KsADK |", - "| Capability | ADK | LangGraph | OpenAI Agents SDK | KsADK |", - ) - misleading_cells = ( - "| OpenAI 兼容 API | 不内置 | 不内置 | 部分支持 | 支持 |", - "| OpenAI Compatible API | No | No | Partial | Yes |", - ) - - for relative_path in ( - "README.md", - "README.zh-CN.md", - "README.en.md", - "public-docs/index.md", - "public-docs/index.en.md", - "public-docs/getting-started/comparison.md", - "public-docs/getting-started/comparison.en.md", - ): - text = _read(relative_path) - for header in scorecard_headers: - assert header not in text, f"{relative_path} still uses old scorecard header" - for cell in misleading_cells: - assert cell not in text, f"{relative_path} still uses misleading OpenAI comparison" - - for relative_path in ( - "public-docs/index.md", - "public-docs/index.en.md", - "public-docs/getting-started/comparison.md", - "public-docs/getting-started/comparison.en.md", - ): - text = _read(relative_path) - assert "VEADK" in text - assert "AgentRun" in text - - -def test_readmes_stay_concise_and_do_not_duplicate_changelog(): - version_heading_pattern = re.compile(r"^##\s+0\.\d+\.\d+", re.MULTILINE) - - for relative_path in ("README.md", "README.zh-CN.md", "README.en.md"): - text = _read(relative_path) - assert not version_heading_pattern.search(text), ( - f"{relative_path} should link to CHANGELOG/Releases instead of listing version highlights" - ) - assert "GitHub Releases" not in text - assert "[CHANGELOG.md](CHANGELOG.md)" not in text - assert "repair_markdown" not in text - assert "最新" not in text - assert len(text.splitlines()) < 200, f"{relative_path} should stay concise" - assert '

KsADK

' in text - assert '

' in text - assert 'width="860"' in text - assert "ksadk-runtime-platform-hero-wide.png" in text - - -def test_public_positioning_uses_factual_ecosystem_focus_terms(): - expected_terms_by_path = { - "public-docs/getting-started/comparison.md": ("A2UI/Frontend", "VeFaaS", "AgentRuntime 生命周期", "Serverless Devs"), - "public-docs/getting-started/comparison.en.md": ("A2UI/Frontend", "VeFaaS", "AgentRuntime lifecycle", "Serverless Devs"), - } - - for relative_path, expected_terms in expected_terms_by_path.items(): - text = _read(relative_path) - for expected in expected_terms: - assert expected in text, f"{relative_path} missing ecosystem evidence term: {expected}" - - -def test_public_visual_assets_are_present_and_nonempty(): - expected_assets = ( - "public-docs/assets/ksadk-runtime-platform-hero.png", - "public-docs/assets/ksadk-runtime-platform-hero-wide.png", - "public-docs/assets/ksadk-web-ui-screenshot.png", - "public-docs/assets/ksadk-runtime-architecture.svg", - "public-docs/assets/ksadk-runtime-architecture.png", - "public-docs/assets/ksadk-local-debugging-demo.gif", - ) - for relative_path in expected_assets: - path = ROOT / relative_path - assert path.is_file(), f"{relative_path} missing" - assert path.stat().st_size > 4096, f"{relative_path} is unexpectedly small" - - -def test_readme_image_links_resolve_inside_repository(): - markdown_image = re.compile(r"!\[[^\]]*\]\(([^)]+)\)") - html_image = re.compile(r']*\bsrc="([^"]+)"', re.IGNORECASE) - - for relative_markdown_path in ("README.md", "README.zh-CN.md", "README.en.md"): - text = _read(relative_markdown_path) - image_targets = markdown_image.findall(text) + html_image.findall(text) - assert image_targets, f"{relative_markdown_path} should contain rendered images" - for image_target in image_targets: - if "://" in image_target: - continue - image_path = (ROOT / image_target).resolve() - assert image_path.is_relative_to(ROOT), ( - f"{relative_markdown_path} image escapes repository: {image_target}" - ) - assert image_path.is_file(), ( - f"{relative_markdown_path} image target missing: {image_target}" - ) - assert image_path.stat().st_size > 4096, ( - f"{relative_markdown_path} image target unexpectedly small: {image_target}" - ) - - -def test_public_navigation_is_task_oriented(): - mkdocs = _read("mkdocs.yml") - for expected in ("Getting Started", "Build", "Run", "Deploy", "Observe", "Extend", "Reference"): - assert expected in mkdocs - for expected in ( - "为什么需要 KsADK: getting-started/why-ksadk.md", - "架构: getting-started/architecture.md", - "生态定位对比: getting-started/comparison.md", - "为什么需要 KsADK: Why KsADK", - "架构: Architecture", - "生态定位对比: Ecosystem Positioning", - ): - assert expected in mkdocs - assert "快速开始: Quick Start" in mkdocs - assert "Kingsoft Cloud Agent Development Kit" not in mkdocs - assert "金山云智能体开发套件" not in mkdocs - - -def test_markdown_repair_is_documented_as_opt_in(): - expected_by_path = { - "public-docs/guides/agent-best-practices.md": ( - "Markdown 输出修复", - "显式开启", - "repair_markdown(text, enabled=True)", - "默认不会改写模型原文", - ), - "public-docs/guides/agent-best-practices.en.md": ( - "Markdown Output Repair", - "enable the lightweight repair helper", - "repair_markdown(text, enabled=True)", - "does not rewrite raw model output by default", - ), - } - - for relative_path, expected_terms in expected_by_path.items(): - text = _read(relative_path) - for expected in expected_terms: - assert expected in text, f"{relative_path} missing {expected}" - - -def test_english_navigation_translates_all_chinese_labels(): - config = yaml.load(_read("mkdocs.yml"), Loader=MkdocsTestLoader) - translations = ( - config["plugins"][1]["i18n"]["languages"][1]["nav_translations"] - ) - - def iter_labels(items): - for item in items: - if isinstance(item, str): - yield Path(item).stem - elif isinstance(item, dict): - for label, children in item.items(): - yield str(label) - if isinstance(children, list): - yield from iter_labels(children) - - missing = sorted( - label - for label in iter_labels(config["nav"]) - if any("\u4e00" <= character <= "\u9fff" for character in label) - and label not in translations - ) - - assert missing == [] - - -def test_public_materials_do_not_publish_environment_specific_release_words(): - for path in _public_markdown_and_config_files(): - text = path.read_text(encoding="utf-8") - relative_path = path.relative_to(ROOT) - _assert_no_public_sensitive_patterns(str(relative_path), text) - - -def test_package_metadata_is_runtime_platform_positioned_for_patch_candidate(): - pyproject = tomllib.loads(_read("pyproject.toml")) - init_text = _read("ksadk/__init__.py") - version_text = _read("ksadk/version.py") - - assert pyproject["project"]["version"] == "0.6.5" - assert 'VERSION = "0.6.5"' in version_text - assert "Agent Runtime Platform" in pyproject["project"]["description"] - assert "Agent Runtime Platform" in init_text - assert "Agent Development Kit" not in pyproject["project"]["description"] - assert "Agent Development Kit" not in init_text - - -def test_patch_version_changelog_is_ready_for_authorized_release(): - changelog = _read("CHANGELOG.md") - assert "## [0.6.5] - 2026-06-15" in changelog - assert "GitHub Release" in changelog - assert "PyPI Trusted Publishing" in changelog - assert "@kingsoftcloud/ksadk-web@latest" in changelog diff --git a/tests/test_public_release_gates.py b/tests/test_public_release_gates.py deleted file mode 100644 index ccf086bf..00000000 --- a/tests/test_public_release_gates.py +++ /dev/null @@ -1,83 +0,0 @@ -from __future__ import annotations - -import re -from pathlib import Path - - -ROOT = Path(__file__).resolve().parents[1] - - -def _makefile() -> str: - return (ROOT / "Makefile").read_text(encoding="utf-8") - - -def _target_dependencies(makefile: str, target: str) -> set[str]: - match = re.search(rf"^{re.escape(target)}:\s*(?P[^\n]*)$", makefile, re.MULTILINE) - assert match, f"missing Makefile target: {target}" - return set(match.group("deps").split()) - - -def test_external_publish_targets_require_review_gate_and_publish_state_check(): - makefile = _makefile() - - for target in ("publish", "publish-test"): - deps = _target_dependencies(makefile, target) - assert "open-source-approval-check" in deps - assert "public-preflight" in deps - assert "public-publish-check" in deps - - -def test_public_release_tag_requires_approval_check(): - makefile = _makefile() - - deps = _target_dependencies(makefile, "public-release-tag") - - assert "open-source-approval-check" in deps - assert "public-preflight" in deps - assert "public-publish-check" in deps - assert "内部审核" not in makefile - - -def test_approval_check_can_use_local_release_evidence_file(): - makefile = _makefile() - - match = re.search( - r"^open-source-approval-check:\n(?P(?:\t.*\n)+)", - makefile, - re.MULTILINE, - ) - assert match - - body = match.group("body") - assert "APPROVAL_RECORD" in body - assert "--approval-record" in body - - -def test_publication_state_make_target_uses_valid_phase(): - makefile = _makefile() - - match = re.search( - r"^open-source-publication-state:\n(?P(?:\t.*\n)+)", - makefile, - re.MULTILINE, - ) - assert match - body = match.group("body") - - assert "--phase placeholder" not in body - assert "--phase pre-publish" in body or 'PUBLIC_PUBLISH_PHASE' in body - - -def test_public_test_and_ci_cover_release_gate_and_runtime_markdown_tests(): - makefile = _makefile() - ci = (ROOT / ".github" / "workflows" / "ci.yml").read_text(encoding="utf-8") - - for required in ( - "tests/test_check_approval_record.py", - "tests/test_public_release_gates.py", - "tests/test_markdown_repair.py", - "tests/test_conversation_runtime.py", - "tests/test_server_session_app.py", - ): - assert required in makefile - assert required in ci From 9dbb04e506e9b110a8489ec6b5842ea7a22d9a91 Mon Sep 17 00:00:00 2001 From: xiayu Date: Thu, 18 Jun 2026 17:46:16 +0800 Subject: [PATCH 07/52] fix: allow curated user docs in public repo audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Allow docs/ksadk环境变量参考.md and docs/远程Agent运行时接口说明.md in the non-curated-docs deny rule - These are user-facing reference docs that belong in the public repo --- ...30\351\207\217\345\217\202\350\200\203.md" | 597 ++++++++++++++++++ scripts/open_source_audit.py | 2 +- 2 files changed, 598 insertions(+), 1 deletion(-) create mode 100644 "docs/ksadk\347\216\257\345\242\203\345\217\230\351\207\217\345\217\202\350\200\203.md" diff --git "a/docs/ksadk\347\216\257\345\242\203\345\217\230\351\207\217\345\217\202\350\200\203.md" "b/docs/ksadk\347\216\257\345\242\203\345\217\230\351\207\217\345\217\202\350\200\203.md" new file mode 100644 index 00000000..8e79ece2 --- /dev/null +++ "b/docs/ksadk\347\216\257\345\242\203\345\217\230\351\207\217\345\217\202\350\200\203.md" @@ -0,0 +1,597 @@ +# KSADK 环境变量参考 + +本文档面向部署、运行、运维和 SDK 集成排障。它不是业务代码 `.env` 模板;业务方自己的变量,例如 `APP_ENV`、`DB_URL`、`CUSTOM_API_KEY`,只要不是 KsADK / 平台运行时读取的变量,都属于业务自定义变量,不在本文逐项维护。 + +本文档基于当前 `feat/skill-runtime` 工作树和 `master` 分支源码扫描整理,覆盖 `ksadk/`、`deploy/`、`tests/` 中已经注册或常见可配置的运行时变量。测试专用变量、PID/marker/cache 等进程内部临时变量、镜像构建脚本内部常量不会逐项列入表格;如果要排查这些高级项,以对应脚本源码和模板 README 为准。 + +## 1. 阅读规则 + +| 字段 | 含义 | +| --- | --- | +| 变量 | 环境变量名。 | +| 作用层级 | 主要读取方:CLI、本地运行时、云端 Runtime、Runner、Sandbox、Skill Runtime、平台服务等。 | +| 是否必传 | `是` 表示该场景启用时必须设置;`条件必传` 表示只有选择某个 backend/能力时才必传;`否` 表示有默认值或可不启用。 | +| 默认值 | 未设置时的 SDK 行为。`未设置` 表示没有默认值。`代码常量` 表示源码内部表名/依赖列表,不建议用户改。 | +| 别名/兼容 | 可替代变量、旧变量或 fallback 链路。优先使用表中第一列变量。 | +| 敏感 | 是否包含 token、secret、DSN、API key。敏感变量只能通过本地 shell、CI Secret、K8S Secret 或平台 Secret 注入。 | +| 配置方/来源 | 一般由谁提供或注入。 | +| 是否业务自定义 | `否` 表示 KsADK/平台读取;`是` 表示业务方可自由定义,本文只说明边界。 | +| 说明 | 用途、取值、注意事项。 | + +## 2. 常见场景必传清单 + +### 2.1 本地运行普通 Agent + +| 变量 | 是否必传 | 别名/兼容 | 敏感 | 配置方/来源 | 说明 | +| --- | --- | --- | --- | --- | --- | +| `OPENAI_API_KEY` | 条件必传 | 部分 OpenAI 兼容实现也支持 `MODEL_API_KEY` | 是 | 开发者 / 模型网关 Secret | 使用 OpenAI 兼容模型时需要。 | +| `OPENAI_BASE_URL` | 条件必传 | `OPENAI_API_BASE` | 否 | 开发者 / 模型网关 | OpenAI 兼容接口 base url。 | +| `OPENAI_MODEL_NAME` | 条件必传 | `MODEL_NAME` | 否 | 开发者 | 默认模型名。 | +| `KSYUN_REGION` | 否 | 无 | 否 | 开发者 / 平台 | 本地 CLI 默认 `cn-beijing-6`。跨环境建议显式设置。 | + +### 2.2 CLI 构建、发布、部署到金山云 + +| 变量 | 是否必传 | 别名/兼容 | 敏感 | 配置方/来源 | 说明 | +| --- | --- | --- | --- | --- | --- | +| `KSYUN_ACCESS_KEY` | 是 | `KS3_ACCESS_KEY` | 是 | 开发者 / CI Secret | 金山云 API / KS3 / KOP 签名 AK。 | +| `KSYUN_SECRET_KEY` | 是 | `KS3_SECRET_KEY` | 是 | 开发者 / CI Secret | 金山云 API / KS3 / KOP 签名 SK。 | +| `KSYUN_ACCOUNT_ID` | 条件必传 | 无 | 否 | 开发者 / 平台账号 | 创建/查询/删除资源、权限预检查、个人版 KCR 用户名兜底等场景需要。 | +| `KSYUN_REGION` | 否 | 无 | 否 | 开发者 / 平台 | 默认 `cn-beijing-6`。 | +| `AGENTENGINE_SERVER_URL` | 否 | 无 | 否 | 平台 / 开发者 | 覆盖 AgentEngine Server 地址。内部账号/内网环境建议 `http://aicp.inner.api.ksyun.com`;公网账号通常不设置或使用 `https://aicp.api.ksyun.com`。 | +| `AGENTENGINE_API_VERSION` | 否 | 无 | 否 | 平台 / 开发者 | 覆盖 KOP API version。 | +| `AGENTENGINE_SIGN_SERVICE` | 否 | 无 | 否 | 平台 / 开发者 | 覆盖 KOP signing service。 | +| `KSADK_AICP_ENDPOINT_MODE` | 否 | 无 | 否 | 平台 / 开发者 | AICP endpoint 选择策略,支持 `auto/detect/internal/inner/public`。内网环境可显式设为 `inner`,跳过自动探测。 | + +### 2.3 ADK Runner 注入远端 MCP tools + +| 变量 | 是否必传 | 别名/兼容 | 敏感 | 配置方/来源 | 说明 | +| --- | --- | --- | --- | --- | --- | +| `KSADK_ENABLE_MCP_TOOLS` | 否 | 无 | 否 | 开发者 / 平台 | 默认 `1`,设为 `0/false/no/off` 禁用自动注入。 | +| `KSADK_MCP_SERVERS` | 条件必传 | 无 | 是 | 开发者 / 平台 Secret | JSON 数组,配置 MCP server url、api_key、tool_filter、tool_name_prefix。可能包含 token。 | + +### 2.4 Skill Runtime 本地模式 + +| 变量 | 是否必传 | 别名/兼容 | 敏感 | 配置方/来源 | 说明 | +| --- | --- | --- | --- | --- | --- | +| `KSADK_SKILLS_MODE` | 否 | 无 | 否 | 开发者 / Runner 环境 | `auto/local/sandbox`。本地调试可显式设为 `local`。 | +| `KSADK_LOCAL_SKILLS_DIR` | 条件必传 | `KSADK_SKILL_CACHE_DIR` 可作为 fallback | 否 | 开发者 | 本地已解压 Skill 包目录;目录下每个 skill 应包含 `SKILL.md`。 | +| `KSADK_SKILL_RUNTIME_BACKEND` | 否 | 无 | 否 | 开发者 | 本地进程模式设为 `local_process`。 | +| `KSADK_SKILL_RUNTIME_AGENT_PATH` | 条件必传 | 默认使用 SDK 内置 agent | 否 | 开发者 | `local_process` backend 的 agent 入口。 | + +### 2.5 Skill Runtime 远程 Sandbox / E2B 模式 + +| 变量 | 是否必传 | 别名/兼容 | 敏感 | 配置方/来源 | 说明 | +| --- | --- | --- | --- | --- | --- | +| `KSADK_SANDBOX_TEMPLATE_ID` | 是 | `KSADK_SKILL_RUNTIME_TEMPLATE_ID` | 否 | 沙箱控制台 / 沙箱团队 | 新部署优先使用。AIO template 是 Skill Runtime 默认推荐。 | +| `E2B_API_URL` | 是 | 无 | 否 | 沙箱团队 / Secret 配置 | E2B 兼容 manager endpoint。 | +| `E2B_API_KEY` | 是 | 无 | 是 | 沙箱团队 / Secret 配置 | E2B SDK 原生 API key,不能写入代码、文档示例明文、测试 fixture 或日志。 | +| `KSADK_SANDBOX_BACKEND` | 否 | 无 | 否 | 平台 / 开发者 | 默认 `e2b`。后续可扩展其他 backend。 | +| `KSADK_SANDBOX_TYPE` | 否 | 无 | 否 | 平台 / 开发者 | `aio/code/browser/private`,默认 `aio`。 | +| `KSADK_SANDBOX_TIMEOUT` | 否 | `KSADK_SKILL_RUNTIME_TIMEOUT` | 否 | 平台 / 开发者 | Sandbox 会话超时秒数,默认 `900`。 | +| `KSADK_SANDBOX_ALLOW_INTERNET_ACCESS` | 否 | `KSADK_SKILL_RUNTIME_ALLOW_INTERNET_ACCESS` | 否 | 平台 / 开发者 | 是否允许 sandbox 出网,默认 `true`。 | +| `KSADK_SKILLS_MODE` | 否 | 无 | 否 | Runner 环境 | `auto` 下检测到 sandbox backend/template 会注入 `execute_skills`;也可显式设为 `sandbox`。 | +| `KSADK_SKILL_RUNTIME_BACKEND` | 否 | 无 | 否 | Runner 环境 | 显式设为 `e2b` 会走远程 backend;显式 `disabled` 会禁止 Skill Runtime 注入。未设置且存在 `KSADK_SANDBOX_TEMPLATE_ID` 时自动使用 `e2b`。 | + +### 2.6 Skill Center / Skill Service + +| 变量 | 是否必传 | 别名/兼容 | 敏感 | 配置方/来源 | 说明 | +| --- | --- | --- | --- | --- | --- | +| `KSADK_SKILL_SERVICE_URL` | 条件必传 | 无 | 否 | 平台 / Skill Service | 配置后 Runtime agent 才会从 Skill Center 拉取 skill。直连 REST 可用 `/agentengine/skill/api/v1`,AICP KOP 可用 `http://aicp.inner.api.ksyun.com`。 | +| `KSADK_SKILL_SERVICE_ENDPOINT` | 否 | 无 | 否 | 平台 / Skill Service | 未设置 `KSADK_SKILL_SERVICE_URL` 时的 AICP endpoint 覆盖,只写 host/path,不含 scheme。 | +| `KSADK_SKILL_SERVICE_SCHEME` | 否 | 无 | 否 | 平台 / Skill Service | 未设置 `KSADK_SKILL_SERVICE_URL` 时的 AICP URL scheme 覆盖;内网 endpoint 默认会使用 `http`。 | +| `KSADK_SKILL_SPACE_IDS` | 条件必传 | `SKILL_SPACE_ID` | 否 | Agent 创建/更新时注入 / Runner 环境 | 逗号分隔 space id;单 space 兼容变量为 `SKILL_SPACE_ID`。 | +| `SKILL_SPACE_ID` | 条件必传 | `KSADK_SKILL_SPACE_IDS` | 否 | 兼容旧/单 space 注入 | 单个 Skill Space id。新部署优先 `KSADK_SKILL_SPACE_IDS`。 | +| `KSADK_SKILL_SERVICE_ACCOUNT_ID` | 条件必传 | `KSYUN_ACCOUNT_ID` | 否 | 平台 / 租户上下文 | Skill Service 租户隔离 header。KOP 或直连 REST 租户视图通常需要。 | +| `KSADK_SKILL_SERVICE_ACCESS_KEY` | 条件必传 | `KSYUN_ACCESS_KEY`、`KS3_ACCESS_KEY` | 是 | 平台 Secret | AICP KOP 签名 AK。直连 REST 或 bearer token 模式不需要。 | +| `KSADK_SKILL_SERVICE_SECRET_KEY` | 条件必传 | `KSYUN_SECRET_KEY`、`KS3_SECRET_KEY` | 是 | 平台 Secret | AICP KOP 签名 SK。直连 REST 或 bearer token 模式不需要。 | +| `KSADK_SKILL_SERVICE_TOKEN` | 条件必传 | 无 | 是 | 平台 Secret | Bearer token 模式使用;KOP 签名模式通常不使用。 | +| `KSADK_SKILL_SERVICE_REGION` | 否 | `KSYUN_REGION` | 否 | 平台 / 开发者 | 默认 `cn-beijing-6`。 | +| `KSADK_SKILL_SERVICE_API_VERSION` | 否 | 无 | 否 | 平台 / 开发者 | 默认 `2024-06-12`;不要复用 Sandbox KOP 的 `2026-04-01`。 | +| `KSADK_SKILL_SERVICE_SIGN_SERVICE` | 否 | 无 | 否 | 平台 / 开发者 | 默认 `aicp`。 | +| `KSADK_SKILL_MANIFEST_LIMIT` | 否 | 无 | 否 | 平台 / 开发者 | 外层 Agent instruction 最多注入的远端 skill manifest 数量,默认 `30`。 | +| `KSADK_SKILL_MANIFEST_TIMEOUT` | 否 | 无 | 否 | 平台 / 开发者 | 拉取远端 skill manifest 的超时秒数,默认 `5`。 | +| `KSADK_SELECTED_SKILL_NAMES` | 否 | 无 | 否 | Runner / Runtime agent | `execute_skills` 选中的 skill 名称列表,Runtime agent 优先按它下载;通常由 SDK 自动注入。 | +| `KSADK_SKILL_CACHE_DIR` | 否 | 无 | 否 | Runtime agent | Skill archive 下载和解压缓存目录。 | +| `KSADK_SKILL_WORKDIR` | 否 | 无 | 否 | Runtime agent | workflow 工作目录。 | +| `KSADK_SKILL_ARTIFACT_PROJECT` | 否 | 无 | 否 | Runtime agent | 最小 artifact workflow 默认项目名,默认 `ksadk-artifact`。 | + +### 2.7 知识库、记忆库、会话存储 + +| 变量 | 是否必传 | 别名/兼容 | 敏感 | 配置方/来源 | 说明 | +| --- | --- | --- | --- | --- | --- | +| `KSADK_KB_DATASET_ID` | 条件必传 | 无 | 否 | 平台 / 开发者 | 配置后启用知识库检索。 | +| `KSADK_KB_ACCESS_KEY` | 条件必传 | `KSYUN_ACCESS_KEY` | 是 | 平台 Secret | SDK 知识库 backend AK。 | +| `KSADK_KB_SECRET_KEY` | 条件必传 | `KSYUN_SECRET_KEY` | 是 | 平台 Secret | SDK 知识库 backend SK。 | +| `KSADK_KB_ENDPOINT` | 否 | 无 | 否 | 平台 / 开发者 | 默认 `aicp.api.ksyun.com`。 | +| `KSADK_KB_REGION` | 否 | 无 | 否 | 平台 / 开发者 | 默认 `cn-beijing-6`。 | +| `KSADK_KB_SCHEME` | 否 | 无 | 否 | 平台 / 开发者 | KB endpoint 协议。内网 endpoint 默认 `http`,其他默认 `https`。 | +| `KSADK_KB_AMBIENT_POLICY` | 否 | 无 | 否 | 平台 / 开发者 | runtime 自动注入知识库上下文策略:`on_demand/always/disabled`。 | +| `KSADK_LTM_BACKEND` | 否 | 无 | 否 | 开发者 | 长期记忆 backend,默认 `local`,可选 `http/sdk`。 | +| `KSADK_LTM_HTTP_URL` | 条件必传 | 无 | 是 | 平台 Secret | `KSADK_LTM_BACKEND=http` 时需要。 | +| `KSADK_LTM_HTTP_TOKEN` | 条件必传 | 无 | 是 | 平台 Secret | HTTP LTM 鉴权 token。 | +| `KSADK_LTM_ACCESS_KEY` | 条件必传 | `KSYUN_ACCESS_KEY` | 是 | 平台 Secret | SDK LTM AK。 | +| `KSADK_LTM_SECRET_KEY` | 条件必传 | `KSYUN_SECRET_KEY` | 是 | 平台 Secret | SDK LTM SK。 | +| `KSADK_LTM_AMBIENT_POLICY` | 否 | 无 | 否 | 平台 / 开发者 | runtime 自动注入长期记忆上下文策略:`on_demand/always/disabled`。 | +| `KSADK_MEMORY_BACKEND` | 否 | 无 | 否 | 开发者 | 轻量 KV/消息历史 MemoryManager backend,默认 `memory`。 | +| `KSADK_MEMORY_URL` | 条件必传 | 无 | 是 | 开发者 / Secret | `KSADK_MEMORY_BACKEND=redis` 等远端 backend 连接 URL。 | +| `KSADK_SESSION_BACKEND` | 否 | `AGENTENGINE_SESSION_BACKEND`、`KSADK_STM_BACKEND` | 否 | 平台 / 开发者 | 会话 backend,默认 `local`。ADK/STM 也会把它作为兜底。 | +| `KSADK_SESSION_DSN` | 条件必传 | `KSADK_STM_URL`、`KSADK_STM_DB_URL`、`KSADK_ADK_SESSION_URL` | 是 | 平台 Secret | `postgres` / `database` backend 时必传。ADK/STM 也会把它作为兜底。 | +| `KSADK_SESSION_PATH` | 否 | `KSADK_STM_PATH`、`KSADK_STM_DB_PATH` | 否 | 本地运行时 | 本地 SQLite 会话库路径。 | +| `KSADK_SESSION_NAMESPACE` | 否 | `KSADK_WORKSPACE_ID`、`AGENTENGINE_WORKSPACE_ID`、`KSADK_TENANT_ID`、`AGENTENGINE_TENANT_ID` | 否 | 平台 / 开发者 | 会话命名空间。 | + +### 2.8 可观测性和 Langfuse + +| 变量 | 是否必传 | 别名/兼容 | 敏感 | 配置方/来源 | 说明 | +| --- | --- | --- | --- | --- | --- | +| `LANGFUSE_PUBLIC_KEY` | 条件必传 | 无 | 是 | 平台 Secret / 开发者 | 启用 Langfuse 时需要。 | +| `LANGFUSE_SECRET_KEY` | 条件必传 | 无 | 是 | 平台 Secret / 开发者 | 启用 Langfuse 时需要。 | +| `LANGFUSE_BASE_URL` | 否 | `LANGFUSE_HOST` | 否 | 平台 / 开发者 | Langfuse endpoint。 | +| `LANGFUSE_USE_CALLBACK` | 否 | 无 | 否 | 开发者 | 控制是否启用 callback 集成。 | +| `OTEL_EXPORTER_OTLP_ENDPOINT` | 条件必传 | 无 | 否 | 平台 / 开发者 | OTel Collector endpoint;未设置 traces 专用 endpoint 时,KsADK 会派生 `/v1/traces`。 | +| `OTEL_EXPORTER_OTLP_PROTOCOL` | 否 | 无 | 否 | 平台 / 开发者 | 通用 OTLP 协议;KsADK 自动 HTTP exporter 当前支持 `http/protobuf`。 | +| `OTEL_EXPORTER_OTLP_HEADERS` | 否 | 无 | 是 | 平台 / 开发者 | 通用 OTLP headers,逗号分隔,值按 URL encoding;可能包含 `Authorization`。 | +| `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | 否 | 无 | 否 | 平台 / 开发者 | traces 专用 endpoint;设置后优先于通用 endpoint。 | +| `OTEL_EXPORTER_OTLP_TRACES_PROTOCOL` | 否 | 无 | 否 | 平台 / 开发者 | traces 专用 OTLP 协议;设置后优先于通用 protocol。 | +| `OTEL_EXPORTER_OTLP_TRACES_HEADERS` | 否 | 无 | 是 | 平台 / 开发者 | traces 专用 OTLP headers;设置后优先于通用 headers。 | +| `OTEL_SERVICE_NAME` | 否 | 无 | 否 | 平台 / 开发者 | OTel service name。 | +| `OTEL_RESOURCE_ATTRIBUTES` | 否 | 无 | 否 | 平台 / 开发者 | OTel resource attributes。 | + +## 3. 通用模型与 LLM 变量 + +| 变量 | 作用层级 | 是否必传 | 默认值 | 别名/兼容 | 敏感 | 配置方/来源 | 是否业务自定义 | 说明 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| `OPENAI_API_KEY` | 本地运行时 / Runtime 镜像 / OpenClaw / Hermes | 条件必传 | 未设置 | `LLM_API_KEY`、`MODEL_API_KEY`、部分 OpenClaw 场景使用 `OPENCLAW_MODEL_API_KEY` | 是 | 开发者 / Secret | 否 | OpenAI 兼容接口 API key。 | +| `OPENAI_BASE_URL` | 本地运行时 / Runtime 镜像 / OpenClaw / Hermes | 条件必传 | 未设置 | `OPENAI_API_BASE`、`LLM_API_BASE`、`MODEL_API_BASE`、部分 OpenClaw 场景使用 `OPENCLAW_MODEL_BASE_URL` | 否 | 开发者 / 平台 | 否 | OpenAI 兼容接口 base url。 | +| `OPENAI_MODEL_NAME` | 本地运行时 / Runtime 镜像 | 条件必传 | 未设置 | `LLM_MODEL`、`MODEL_NAME`、Hermes fallback 读取 `OPENAI_FALLBACK_MODEL_NAME` | 否 | 开发者 / 平台 | 否 | 默认模型名。 | +| `OPENAI_CONTEXT_LENGTH` | Hermes / 模型配置 | 否 | 未设置 | `MODEL_CONTEXT_LENGTH`、`HERMES_CONTEXT_LENGTH` | 否 | 开发者 / 平台 | 否 | 模型上下文长度提示。 | +| `OPENAI_FALLBACK_MODEL_NAME` | Hermes / 模型配置 | 否 | 未设置 | `HERMES_FALLBACK_MODEL` | 否 | 开发者 / 平台 | 否 | Hermes fallback 模型名 fallback。 | +| `LLM_API_KEY` | Serverless / 兼容模型配置 | 条件必传 | 未设置 | `OPENAI_API_KEY`、`MODEL_API_KEY` | 是 | 平台 Secret / 开发者 | 否 | Serverless 平台兼容模型 API key。 | +| `LLM_API_BASE` | Serverless / 兼容模型配置 | 条件必传 | 未设置 | `OPENAI_BASE_URL`、`MODEL_API_BASE` | 否 | 平台 / 开发者 | 否 | Serverless 平台兼容模型 endpoint。 | +| `LLM_MODEL` | Serverless / OpenClaw | 条件必传 | 未设置 | `OPENAI_MODEL_NAME`、`MODEL_NAME` | 否 | 平台 / 开发者 | 否 | Serverless/OpenClaw 兼容模型名。 | +| `MODEL_API_KEY` | OpenClaw / 兼容模型配置 | 条件必传 | 未设置 | `OPENAI_API_KEY` | 是 | 开发者 / Secret | 否 | 兼容 OpenClaw 模型配置。 | +| `MODEL_API_BASE` | OpenClaw / 兼容模型配置 | 条件必传 | 未设置 | `OPENAI_BASE_URL` | 否 | 开发者 / 平台 | 否 | 兼容 OpenClaw 模型 endpoint。 | +| `MODEL_BASE_URL` | CLI model / 兼容模型配置 | 否 | 未设置 | `OPENAI_BASE_URL`、`OPENAI_API_BASE`、`MODEL_API_BASE` | 否 | 开发者 / 平台 | 否 | 部分 CLI model 命令和历史配置读取的 base url。 | +| `MODEL_NAME` | 本地运行时 / OpenClaw | 条件必传 | 未设置 | `OPENAI_MODEL_NAME` | 否 | 开发者 / 平台 | 否 | 旧版模型名变量。 | +| `COZE_WORKLOAD_IDENTITY_API_KEY` | Coze 导出项目兼容 | 条件必传 | 未设置 | 未设置时可由 `OPENAI_API_KEY` 自动补齐 | 是 | 开发者 / Secret | 否 | 部分 Coze 导出项目依赖 `coze_coding_dev_sdk`,SDK 会尝试从 OpenAI 兼容配置补齐。 | +| `COZE_INTEGRATION_BASE_URL` | Coze 导出项目兼容 | 条件必传 | 未设置 | 未设置时可由 `OPENAI_BASE_URL` 自动补齐 | 否 | 开发者 / 平台 | 否 | Coze integration endpoint。 | +| `COZE_INTEGRATION_MODEL_BASE_URL` | Coze 导出项目兼容 | 条件必传 | 未设置 | 未设置时可由 `OPENAI_BASE_URL` 自动补齐 | 否 | 开发者 / 平台 | 否 | Coze model endpoint。 | +| `COZE_MODEL_NAME` | Coze 导出项目兼容 | 条件必传 | 未设置 | 通常跟随业务导出项目 | 否 | 开发者 / 平台 | 否 | Coze 导出项目模型名。 | + +## 4. 金山云账号、KOP、KS3 与镜像仓库 + +| 变量 | 作用层级 | 是否必传 | 默认值 | 别名/兼容 | 敏感 | 配置方/来源 | 是否业务自定义 | 说明 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| `KSYUN_ACCESS_KEY` | CLI / KOP / KS3 / Skill Service fallback | 条件必传 | 未设置 | `KS3_ACCESS_KEY` | 是 | 开发者 / CI Secret / K8S Secret | 否 | 金山云 AK。启用云端资源操作、KS3、KOP 签名时需要。 | +| `KSYUN_SECRET_KEY` | CLI / KOP / KS3 / Skill Service fallback | 条件必传 | 未设置 | `KS3_SECRET_KEY` | 是 | 开发者 / CI Secret / K8S Secret | 否 | 金山云 SK。 | +| `KSYUN_ACCOUNT_ID` | CLI / KOP / 权限预检查 / Skill Service fallback | 条件必传 | 未设置 | 无 | 否 | 平台账号 / 开发者 | 否 | 账号 ID。资源管理、租户隔离、个人版 KCR 用户名兜底等场景需要。 | +| `KSYUN_REGION` | CLI / KOP / KS3 / Skill Service fallback | 否 | `cn-beijing-6` | 无 | 否 | 开发者 / 平台 | 否 | 区域。跨环境、预发、生产联调建议显式设置。 | +| `KS_ACCESS_KEY_ID` | 旧 KingsoftCloudConfig | 条件必传 | 未设置 | 建议迁移到 `KSYUN_ACCESS_KEY` | 是 | 兼容旧配置 | 否 | 早期 SDK settings 读取的 AK;不与 `KSYUN_ACCESS_KEY` 自动互通。 | +| `KS_SECRET_ACCESS_KEY` | 旧 KingsoftCloudConfig | 条件必传 | 未设置 | 建议迁移到 `KSYUN_SECRET_KEY` | 是 | 兼容旧配置 | 否 | 早期 SDK settings 读取的 SK;不与 `KSYUN_SECRET_KEY` 自动互通。 | +| `KS_REGION` | 旧 KingsoftCloudConfig | 否 | `cn-beijing-6` | 建议迁移到 `KSYUN_REGION` | 否 | 兼容旧配置 | 否 | 早期 SDK settings 读取的 region;不与 `KSYUN_REGION` 自动互通。 | +| `KS3_ACCESS_KEY` | KS3 / 兼容 fallback | 条件必传 | 未设置 | `KSYUN_ACCESS_KEY` | 是 | 开发者 / Secret | 否 | KS3 专用 AK 兼容变量。 | +| `KS3_SECRET_KEY` | KS3 / 兼容 fallback | 条件必传 | 未设置 | `KSYUN_SECRET_KEY` | 是 | 开发者 / Secret | 否 | KS3 专用 SK 兼容变量。 | +| `KS3_BUCKET` | 构建上传 / 版本发布 | 条件必传 | 未设置 | 无 | 否 | 开发者 / 平台 | 否 | 自定义 KS3 bucket。 | +| `KS3_ENDPOINT_MODE` | KS3 上传 | 否 | 未设置 | 无 | 否 | 开发者 / 平台 | 否 | KS3 endpoint 选择策略。 | +| `KS3_ENDPOINT_PROBE_TIMEOUT_SECONDS` | KS3 上传 | 否 | 未设置 | 无 | 否 | 开发者 / 平台 | 否 | KS3 endpoint 探测超时。 | +| `KS3_UPLOAD_TIMEOUT_SECONDS` | KS3 上传 | 否 | 未设置 | 无 | 否 | 开发者 / 平台 | 否 | KS3 上传超时。 | +| `KCR_REGISTRY` | 镜像构建 / MCP / Serverless | 条件必传 | 未设置 | 无 | 否 | 开发者 / 平台 | 否 | 镜像仓库地址,通常为 `/`,例如 `agenthzzqy-vpc.ksyunkcr.com/testagent-pub` 或第三方 registry/namespace。 | +| `KCR_ENDPOINT` | 镜像构建 / MCP / Serverless | 否 | `hub.kce.ksyun.com` | 无 | 否 | 开发者 / 平台 | 否 | KCR endpoint。 | +| `KCR_USERNAME` | 镜像构建 / MCP / Serverless | 条件必传 | 未设置 | 个人版 KCR 可回退 `KSYUN_ACCOUNT_ID` | 否 | 开发者 / 平台 | 否 | 镜像仓库访问凭证用户名。企业版 KCR 和第三方镜像仓库必须显式设置;个人版 KCR 可留空并使用 `KSYUN_ACCOUNT_ID` 作为用户名兜底。 | +| `KCR_PASSWORD` | 镜像构建 / MCP / Serverless | 条件必传 | 未设置 | 无 | 是 | 开发者 / Secret | 否 | 镜像仓库访问凭证密码或 token。 | + +## 5. 通用 Sandbox Runtime + +| 变量 | 作用层级 | 是否必传 | 默认值 | 别名/兼容 | 敏感 | 配置方/来源 | 是否业务自定义 | 说明 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| `KSADK_SANDBOX_BACKEND` | Sandbox backend factory | 否 | `e2b` | 无 | 否 | 平台 / 开发者 | 否 | 通用 sandbox backend。首版支持 `e2b`。 | +| `KSADK_SANDBOX_TYPE` | Sandbox spec | 否 | `aio` | 无 | 否 | 沙箱控制台 / 平台 | 否 | `aio/code/browser/private`。Skill Runtime 默认推荐 `aio`。 | +| `KSADK_SANDBOX_TEMPLATE_ID` | Sandbox spec / Skill Runtime E2B backend | 条件必传 | 未设置 | `KSADK_SKILL_RUNTIME_TEMPLATE_ID` | 否 | 沙箱控制台 / 沙箱团队 | 否 | 远程 sandbox 执行时必传。新部署优先使用。 | +| `KSADK_SANDBOX_TIMEOUT` | Sandbox spec | 否 | `900` | `KSADK_SKILL_RUNTIME_TIMEOUT` | 否 | 平台 / 开发者 | 否 | Sandbox 会话超时秒数。 | +| `KSADK_SANDBOX_ALLOW_INTERNET_ACCESS` | Sandbox spec | 否 | `true` | `KSADK_SKILL_RUNTIME_ALLOW_INTERNET_ACCESS` | 否 | 平台 / 开发者 | 否 | 是否允许 sandbox 出网。 | +| `KSADK_SANDBOX_STARTUP_RETRY_ATTEMPTS` | E2B Sandbox backend | 否 | `6` | 无 | 否 | 平台 / 开发者 | 否 | 沙箱创建后 readiness 探测最大重试次数,用于兜底短暂 `NotFoundException` / `FileNotFoundException`。 | +| `KSADK_SANDBOX_STARTUP_RETRY_DELAY` | E2B Sandbox backend | 否 | `0.2` | 无 | 否 | 平台 / 开发者 | 否 | 沙箱 readiness 首次重试间隔秒数,后续指数退避,单次 sleep 上限 1 秒。 | +| `E2B_API_URL` | E2B SDK | 条件必传 | 未设置 | 无 | 否 | 沙箱团队 / Secret 配置 | 否 | E2B 兼容 manager endpoint。使用 E2B backend 时必传。 | +| `E2B_API_KEY` | E2B SDK | 条件必传 | 未设置 | 无 | 是 | 沙箱团队 / Secret 配置 | 否 | E2B API key。严禁写入代码、文档明文、测试 fixture、日志。 | + +## 6. Skill Runtime 与 Skill Center + +| 变量 | 作用层级 | 是否必传 | 默认值 | 别名/兼容 | 敏感 | 配置方/来源 | 是否业务自定义 | 说明 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| `KSADK_SKILLS_MODE` | ADK Runner | 否 | `auto` | 无 | 否 | 开发者 / 平台 | 否 | `auto/local/sandbox`。`auto` 会根据 sandbox template 或本地 skill 目录自动选择。 | +| `KSADK_LOCAL_SKILLS_DIR` | ADK Runner / Runtime agent | 条件必传 | 未设置 | `KSADK_SKILL_CACHE_DIR` 可作为 Runner 本地扫描 fallback | 否 | 开发者 | 否 | 本地 skill 目录。 | +| `KSADK_SKILL_RUNTIME_BACKEND` | Skill Runtime factory | 否 | `disabled`;未设置且存在 `KSADK_SANDBOX_TEMPLATE_ID` 时自动走 `e2b` | 无 | 否 | 开发者 / 平台 | 否 | `disabled/local_process/e2b`。显式 `disabled` 会阻止自动注入。 | +| `KSADK_SKILL_RUNTIME_TEMPLATE_ID` | Skill Runtime E2B backend | 否 | 未设置 | `KSADK_SANDBOX_TEMPLATE_ID` | 否 | 旧部署 / 兼容 | 否 | 兼容变量。新部署不要优先使用。 | +| `KSADK_SKILL_RUNTIME_TIMEOUT` | Skill Runtime command | 否 | `900` | `KSADK_SANDBOX_TIMEOUT` 在 E2B 会话层优先 | 否 | 开发者 / 平台 | 否 | workflow 命令超时秒数。 | +| `KSADK_SKILL_RUNTIME_ALLOW_INTERNET_ACCESS` | Skill Runtime E2B backend | 否 | `true` | `KSADK_SANDBOX_ALLOW_INTERNET_ACCESS` 优先 | 否 | 旧部署 / 兼容 | 否 | 兼容变量。 | +| `KSADK_SKILL_RUNTIME_AGENT_PATH` | local_process backend | 条件必传 | SDK 内置 `ksadk/skills/runtime/agent.py` | 无 | 否 | 开发者 | 否 | 本地进程 backend 的 agent 路径。 | +| `KSADK_SKILL_SERVICE_URL` | Runtime agent / Skill Service client | 条件必传 | 未设置 | 无 | 否 | Skill Service / 平台 | 否 | 配置后从 Skill Center 拉取技能。支持直连 REST 和 AICP KOP endpoint。 | +| `KSADK_SKILL_SERVICE_ENDPOINT` | Runtime agent / AICP resolver | 否 | 按 `KSADK_AICP_ENDPOINT_MODE` 自动选择 | 无 | 否 | Skill Service / 平台 | 否 | 未设置 `KSADK_SKILL_SERVICE_URL` 时覆盖 Skill Service AICP endpoint。 | +| `KSADK_SKILL_SERVICE_SCHEME` | Runtime agent / AICP resolver | 否 | 内网 endpoint 为 `http`,公网默认 `https` | 无 | 否 | Skill Service / 平台 | 否 | 未设置 `KSADK_SKILL_SERVICE_URL` 时覆盖 Skill Service AICP URL scheme。 | +| `KSADK_SKILL_SPACE_IDS` | Runner / Runtime agent | 条件必传 | 未设置 | `SKILL_SPACE_ID` | 否 | Agent 创建/更新 / 平台注入 | 否 | 逗号分隔 Skill Space id。 | +| `KSADK_PUBLIC_SKILL_ALLOWLIST` | Runtime agent | 否 | 未设置 | 无 | 否 | 平台 / Skill Service | 否 | 逗号分隔 public skill 名称白名单;未设置时加载 public space 下全部 active skills。 | +| `KSADK_PUBLIC_SKILL_SPACE_IDS` | Runner / Runtime agent | 否 | 未设置 | 无 | 否 | 平台 / Skill Service | 否 | 逗号分隔官方公共 Skill Space id,会追加在用户 space 之后。 | +| `SKILL_SPACE_ID` | Runtime agent / 兼容 | 条件必传 | 未设置 | `KSADK_SKILL_SPACE_IDS` | 否 | 旧部署 / 单 space 注入 | 否 | 单 space 兼容变量。 | +| `KSADK_SKILL_SERVICE_ACCOUNT_ID` | Skill Service client | 条件必传 | 未设置 | `KSYUN_ACCOUNT_ID` | 否 | 平台租户上下文 | 否 | 租户隔离 account id。 | +| `KSADK_SKILL_SERVICE_ACCESS_KEY` | Skill Service KOP signing | 条件必传 | 未设置 | `KSYUN_ACCESS_KEY`、`KS3_ACCESS_KEY` | 是 | Secret | 否 | AICP KOP endpoint 签名 AK。 | +| `KSADK_SKILL_SERVICE_SECRET_KEY` | Skill Service KOP signing | 条件必传 | 未设置 | `KSYUN_SECRET_KEY`、`KS3_SECRET_KEY` | 是 | Secret | 否 | AICP KOP endpoint 签名 SK。 | +| `KSADK_SKILL_SERVICE_TOKEN` | Skill Service bearer auth | 条件必传 | 未设置 | 无 | 是 | Secret | 否 | Bearer token 模式。 | +| `KSADK_SKILL_SERVICE_REGION` | Skill Service KOP signing | 否 | `cn-beijing-6` | `KSYUN_REGION` | 否 | 平台 / 开发者 | 否 | KOP 签名 region。 | +| `KSADK_SKILL_SERVICE_API_VERSION` | Skill Service KOP action | 否 | `2024-06-12` | 无 | 否 | Skill Service / 平台 | 否 | Skill Center KOP API 版本。 | +| `KSADK_SKILL_SERVICE_SIGN_SERVICE` | Skill Service KOP signing | 否 | `aicp` | 无 | 否 | Skill Service / 平台 | 否 | KOP signing service。 | +| `KSADK_SKILL_MANIFEST_LIMIT` | ADK Runner | 否 | `30` | 无 | 否 | 平台 / 开发者 | 否 | 外层 Agent instruction 最多注入的远端 skill manifest 数量。 | +| `KSADK_SKILL_MANIFEST_TIMEOUT` | Skill Service client | 否 | `5` | 无 | 否 | 平台 / 开发者 | 否 | 拉取远端 skill manifest 的超时秒数。 | +| `KSADK_SELECTED_SKILL_NAMES` | Runtime agent | 否 | 未设置 | 无 | 否 | Runner / Runtime agent | 否 | `execute_skills` 选中的 skill 名称列表,Runtime agent 优先按它下载。 | +| `KSADK_SKILL_ALLOW_HASH_MISMATCH` | Runtime agent / PackageStore | 否 | `false` | 无 | 否 | 调试 / 兼容旧包 | 否 | 允许 ContentHash 校验失败后以 unverified cache 加载旧 skill 包;生产不建议开启。 | +| `KSADK_SKILL_CACHE_DIR` | Runtime agent / PackageStore | 否 | 系统临时目录下 `ksadk-skill-cache` | 无 | 否 | Runtime agent | 否 | Skill archive 下载与解压缓存。 | +| `KSADK_SKILL_WORKDIR` | Runtime agent | 否 | 系统临时目录下 `ksadk-skill-workflow` | 无 | 否 | Runtime agent | 否 | workflow 工作目录。 | +| `KSADK_SKILL_OUTPUT_DIR` | Runtime agent workflow | 否 | `KSADK_SKILL_WORKDIR/artifacts` | 无 | 否 | Runtime agent | 否 | 传给本地 skill workflow 脚本的产物输出目录。 | +| `KSADK_SKILL_ROOT_DIR` | Runtime agent workflow | 否 | 当前执行 skill 根目录 | 无 | 否 | Runtime agent | 否 | 传给本地 skill workflow 脚本的 skill 根目录。 | +| `KSADK_SKILL_ARTIFACT_PROJECT` | Runtime agent | 否 | `ksadk-artifact` | 无 | 否 | Runtime agent | 否 | 最小 artifact workflow 项目目录名。 | +| `KSADK_WORKFLOW_PROMPT` | Runtime agent workflow | 否 | 当前 workflow prompt | 无 | 否 | Runtime agent | 否 | 传给本地 skill workflow 脚本的用户请求文本。 | + +## 7. MCP Runtime + +| 变量 | 作用层级 | 是否必传 | 默认值 | 别名/兼容 | 敏感 | 配置方/来源 | 是否业务自定义 | 说明 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| `KSADK_ENABLE_MCP_TOOLS` | ADK Runner | 否 | `1` | 无 | 否 | 开发者 / 平台 | 否 | 控制远端 MCP tools 自动注入。 | +| `KSADK_MCP_SERVERS` | MCP runtime | 条件必传 | 未设置 | 无 | 是 | 开发者 / 平台 Secret | 否 | JSON 数组。可能包含 MCP server api_key。 | + +## 8. 会话、短期记忆和长期记忆 + +| 变量 | 作用层级 | 是否必传 | 默认值 | 别名/兼容 | 敏感 | 配置方/来源 | 是否业务自定义 | 说明 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| `KSADK_SESSION_BACKEND` | Sessions | 否 | `local` | `AGENTENGINE_SESSION_BACKEND`、`KSADK_STM_BACKEND` | 否 | 开发者 / 平台 | 否 | 会话存储 backend。ADK/STM 也会把它作为兜底。 | +| `KSADK_SESSION_DSN` | Sessions | 条件必传 | 未设置 | `KSADK_STM_URL`、`KSADK_STM_DB_URL`、`KSADK_ADK_SESSION_URL` | 是 | Secret | 否 | PostgreSQL DSN。`postgres` / `database` backend 时必传。ADK/STM 也会把它作为兜底。 | +| `KSADK_SESSION_PATH` | Sessions | 否 | 项目目录下本地 sqlite 路径 | `KSADK_STM_PATH`、`KSADK_STM_DB_PATH` | 否 | 开发者 / 本地运行时 | 否 | 本地 SQLite 会话路径。 | +| `KSADK_SESSION_NAMESPACE` | Sessions | 否 | 未设置 | `KSADK_WORKSPACE_ID`、`AGENTENGINE_WORKSPACE_ID`、`KSADK_TENANT_ID`、`AGENTENGINE_TENANT_ID` | 否 | 平台 | 否 | 会话 namespace。 | +| `KSADK_TENANT_ID` | Sessions | 否 | 未设置 | `AGENTENGINE_TENANT_ID` | 否 | 平台 | 否 | 租户 id。 | +| `KSADK_WORKSPACE_ID` | Sessions | 否 | 未设置 | `AGENTENGINE_WORKSPACE_ID` | 否 | 平台 | 否 | workspace id。 | +| `KSADK_STM_BACKEND` | 旧 STM / Sessions fallback | 否 | 未设置 | `KSADK_SESSION_BACKEND` | 否 | 兼容旧部署 | 否 | 旧变量。新部署优先 `KSADK_SESSION_BACKEND`,但 ADK/STM 仍可读。 | +| `KSADK_STM_PATH` | 旧 STM / Sessions fallback | 否 | 未设置 | `KSADK_SESSION_PATH` | 否 | 兼容旧部署 | 否 | 旧变量。 | +| `KSADK_STM_DB_PATH` | 旧 STM / Sessions fallback | 否 | 未设置 | `KSADK_SESSION_PATH` | 否 | 兼容旧部署 | 否 | 旧变量。 | +| `KSADK_STM_URL` | 旧 STM / Sessions fallback | 条件必传 | 未设置 | `KSADK_SESSION_DSN` | 是 | 兼容旧部署 | 否 | 旧变量。ADK/STM 仍可读。 | +| `KSADK_STM_DB_URL` | 旧 STM / Sessions fallback | 条件必传 | 未设置 | `KSADK_SESSION_DSN` | 是 | 兼容旧部署 | 否 | 旧变量。ADK/STM 仍可读。 | +| `KSADK_ADK_SESSION_BACKEND` | ADK Memory | 否 | 未设置 | 无 | 否 | 开发者 / 平台 | 否 | ADK 原生 session backend。 | +| `KSADK_ADK_SESSION_PATH` | ADK Memory | 否 | 未设置 | 无 | 否 | 开发者 / 平台 | 否 | ADK 原生 session sqlite 路径。 | +| `KSADK_ADK_SESSION_URL` | ADK Memory | 条件必传 | 未设置 | `KSADK_SESSION_DSN` | 是 | Secret | 否 | ADK 原生 session 数据库 URL。统一 session DSN 也可兜底。 | +| `KSADK_MEMORY_BACKEND` | MemoryManager | 否 | `memory` | 无 | 否 | 开发者 / 平台 | 否 | 轻量 KV/消息历史 backend。当前内置 `memory`,注册 Redis backend 后可用 `redis`。 | +| `KSADK_MEMORY_URL` | MemoryManager | 条件必传 | 未设置 | 无 | 是 | Secret | 否 | 远端 MemoryManager backend 连接 URL,例如 Redis URL。 | +| `KSADK_MEMORY_PREFIX` | MemoryManager | 否 | `ksadk:memory:` | 无 | 否 | 开发者 / 平台 | 否 | MemoryManager key prefix。 | +| `KSADK_MEMORY_TTL` | MemoryManager | 否 | 未设置 | 无 | 否 | 开发者 / 平台 | 否 | MemoryManager 默认 TTL 秒数。 | +| `KSADK_LTM_BACKEND` | Long-term memory | 否 | `local` | 无 | 否 | 开发者 / 平台 | 否 | LTM backend。 | +| `KSADK_LTM_HTTP_URL` | HTTP LTM | 条件必传 | 未设置 | 无 | 是 | Secret | 否 | HTTP LTM URL。 | +| `KSADK_LTM_HTTP_TOKEN` | HTTP LTM | 条件必传 | 未设置 | 无 | 是 | Secret | 否 | HTTP LTM token。 | +| `KSADK_LTM_ACCESS_KEY` | SDK LTM | 条件必传 | 未设置 | `KSYUN_ACCESS_KEY` | 是 | Secret | 否 | SDK LTM AK。 | +| `KSADK_LTM_SECRET_KEY` | SDK LTM | 条件必传 | 未设置 | `KSYUN_SECRET_KEY` | 是 | Secret | 否 | SDK LTM SK。 | +| `KSADK_LTM_REGION` | SDK LTM | 否 | `cn-beijing-6` | 无 | 否 | 平台 / 开发者 | 否 | SDK LTM region。 | +| `KSADK_LTM_ENDPOINT` | SDK LTM | 否 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | SDK LTM endpoint。 | +| `KSADK_LTM_SCHEME` | SDK LTM | 否 | `https` | 无 | 否 | 平台 / 开发者 | 否 | SDK LTM scheme。 | +| `KSADK_LTM_INDEX` | LTM | 否 | 未设置 | 无 | 否 | 开发者 | 否 | LTM index。 | +| `KSADK_LTM_NAMESPACE` | LTM | 否 | 未设置 | 无 | 否 | 开发者 / 平台 | 否 | LTM 记忆库 ID;环境变量名保持不变,请填写新版 SDK 的 `MemoryCollectionId`。 | +| `KSADK_LTM_AGENT_ID` | LTM | 否 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | LTM agent id。 | +| `KSADK_LTM_SCENE_ID` | LTM | 否 | `_sys_general` | 无 | 否 | 平台 / 开发者 | 否 | LTM scene id;新版记忆库保存必传,未设置时使用通用场景 `_sys_general`。 | +| `KSADK_LTM_APP_NAME` | LTM | 否 | 未设置 | 无 | 否 | 开发者 | 否 | LTM application name 覆盖。 | +| `KSADK_LTM_TOP_K` | LTM | 否 | `5` | 无 | 否 | 开发者 | 否 | LTM 返回条数。 | +| `KSADK_LTM_AUTO_SAVE` | Conversations runtime | 否 | SDK LTM 已绑定时为 `true` | 无 | 否 | 平台 / 开发者 | 否 | 是否在每轮完成后 best-effort 镜像 user/assistant 文本到记忆库。只接受布尔语义:`true/false`、`1/0`、`on/off`。 | +| `KSADK_LTM_AMBIENT_ENABLED` | Conversations runtime | 否 | `true` | 无 | 否 | 平台 / 开发者 | 否 | 是否允许 runtime 自动加载长期记忆上下文。 | +| `KSADK_LTM_AMBIENT_POLICY` | Conversations runtime | 否 | `on_demand` | 无 | 否 | 平台 / 开发者 | 否 | 长期记忆 ambient context 策略:`on_demand/always/disabled`。 | +| `MEM0_API_KEY` | OpenClaw memory backend | 条件必传 | 未设置 | 无 | 是 | 平台 Secret | 否 | 选择 `mem0` memory backend manifest 时需要。 | +| `MEM0_USER_ID` | OpenClaw memory backend | 条件必传 | 未设置 | 无 | 否 | 平台 / 用户上下文 | 否 | 选择 `mem0` memory backend manifest 时需要。 | +| `MEM0_BASE_URL` | OpenClaw memory backend | 条件必传 | 未设置 | 无 | 否 | 平台 | 否 | 选择 `mem0` memory backend manifest 时需要。 | + +## 9. 知识库 + +| 变量 | 作用层级 | 是否必传 | 默认值 | 别名/兼容 | 敏感 | 配置方/来源 | 是否业务自定义 | 说明 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| `KSADK_KB` | Knowledge base | 否 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | AICP knowledge-base 连接配置前缀。 | +| `KSADK_KB_DATASET_ID` | Knowledge base | 条件必传 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | 存在时启用知识库。 | +| `KSADK_KB_ACCESS_KEY` | Knowledge base | 条件必传 | 未设置 | `KSYUN_ACCESS_KEY` | 是 | Secret | 否 | KB AK。 | +| `KSADK_KB_SECRET_KEY` | Knowledge base | 条件必传 | 未设置 | `KSYUN_SECRET_KEY` | 是 | Secret | 否 | KB SK。 | +| `KSADK_KB_ENDPOINT` | Knowledge base | 否 | `aicp.api.ksyun.com` | 无 | 否 | 平台 / 开发者 | 否 | KB endpoint。 | +| `KSADK_KB_REGION` | Knowledge base | 否 | `cn-beijing-6` | 无 | 否 | 平台 / 开发者 | 否 | KB region。 | +| `KSADK_KB_SCHEME` | Knowledge base | 否 | 内网 endpoint 默认 `http`,其他默认 `https` | 无 | 否 | 平台 / 开发者 | 否 | KB endpoint scheme。 | +| `KSADK_KB_SEARCH_METHOD` | Knowledge base | 否 | `intelligence_search` | 无 | 否 | 开发者 | 否 | 检索方法。 | +| `KSADK_KB_TOP_K` | Knowledge base | 否 | `5` | 无 | 否 | 开发者 | 否 | 返回条数。 | +| `KSADK_KB_SCORE_THRESHOLD` | Knowledge base | 否 | `0.0` | 无 | 否 | 开发者 | 否 | 分数阈值。 | +| `KSADK_KB_RERANKING_ENABLE` | Knowledge base | 否 | `false` | 无 | 否 | 开发者 | 否 | 是否启用 reranking。 | +| `KSADK_KB_AMBIENT_ENABLED` | Conversations runtime | 否 | `true` | 无 | 否 | 平台 / 开发者 | 否 | 是否允许 runtime 自动加载知识库上下文。 | +| `KSADK_KB_AMBIENT_POLICY` | Conversations runtime | 否 | `on_demand` | 无 | 否 | 平台 / 开发者 | 否 | 知识库 ambient context 策略:`on_demand/always/disabled`。 | +| `KSYUN_SECRET_ID` | Knowledge base fallback | 否 | 未设置 | `KSYUN_ACCESS_KEY` | 是 | 兼容旧配置 | 否 | 代码中作为 KB AK 的旧 fallback,建议使用 `KSADK_KB_ACCESS_KEY` 或 `KSYUN_ACCESS_KEY`。 | + +## 10. CLI、构建、部署和 UI 行为 + +| 变量 | 作用层级 | 是否必传 | 默认值 | 别名/兼容 | 敏感 | 配置方/来源 | 是否业务自定义 | 说明 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| `AGENTENGINE_SERVER_URL` | CLI / API client | 否 | 自动探测:优先 `http://aicp.inner.api.ksyun.com`,不可达时回落 `https://aicp.api.ksyun.com` | 无 | 否 | 平台 / 开发者 | 否 | 覆盖 AgentEngine Server 地址。内部账号/内网环境建议显式设为 `http://aicp.inner.api.ksyun.com`;公网账号通常不设置或使用 `https://aicp.api.ksyun.com`。如果公网 AICP 返回 `InnerAccountCanOnlyAccessThroughIntranet`,客户端会自动切内网重试一次。 | +| `AGENTENGINE_API_VERSION` | CLI / API client | 否 | 内置版本 | 无 | 否 | 平台 / 开发者 | 否 | 覆盖 AgentEngine API version。 | +| `AGENTENGINE_PRE_CONTROL_REGION` | CLI / API client | 否 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | 预发控制面 region 覆盖。 | +| `AGENTENGINE_PRE_CUSTOM_SOURCE` | CLI / API client | 否 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | 预发 custom source 覆盖。 | +| `KSADK_AICP_ENDPOINT_MODE` | AICP resolver | 否 | `auto` | 无 | 否 | 平台 / 开发者 | 否 | AICP endpoint 选择策略,支持 `auto/detect/internal/inner/public`。内网环境可显式设为 `inner`,跳过自动探测。 | +| `AGENTENGINE_MODEL_ALLOWLIST` | CLI model / OpenClaw | 否 | 未设置 | `OPENCLAW_MODEL_ALLOWLIST` | 否 | 平台 / 开发者 | 否 | 模型列表过滤。OpenClaw 场景优先使用 `OPENCLAW_MODEL_ALLOWLIST`。 | +| `AGENTENGINE_UI_DIR` | 本地 Web UI / Sessions | 否 | 未设置 | 无 | 否 | 本地开发者 | 否 | 本地 UI 静态目录覆盖,主要用于 Web/文件上传本地调试。 | +| `KSADK_WEB_VERSION` | Hosted Web UI static sync | 否 | `latest` | 可显式设置 `0.2.7` / `v0.2.7` | 否 | 构建环境 / 开发者 | 否 | `make sync-ksadk-web-static` 使用的 `@kingsoftcloud/ksadk-web` npm dist-tag 或版本,默认消费最新 release。 | +| `KSADK_WEB_PACKAGE` | Hosted Web UI static sync | 否 | `@kingsoftcloud/ksadk-web` | 无 | 否 | 构建环境 / 开发者 | 否 | 本地 UI static 同步使用的 npm 包名。 | +| `KSADK_WEB_TARBALL_NAME` | Hosted Web UI static sync | 否 | 根据 `KSADK_WEB_VERSION` 派生 | 无 | 否 | 构建环境 | 否 | 仅在设置 `KSADK_WEB_RELEASE_URL` 时作为下载保存文件名;npm pack 模式会使用 npm 返回的真实 tarball 文件名。 | +| `KSADK_WEB_RELEASE_URL` | Hosted Web UI static sync | 否 | 未设置 | 无 | 否 | 构建环境 / 开发者 | 否 | 可选兼容兜底。设置后跳过 npm pack,改从该 tarball URL 下载。 | +| `KSADK_WEB_CACHE_DIR` | Hosted Web UI static sync | 否 | `.cache/ksadk-web` | 无 | 否 | 构建环境 / 开发者 | 否 | KsADK Web 包解压缓存目录。 | +| `KSADK_GLOBAL_CONFIG_ENV_KEYS` | CLI | 否 | 未设置 | 无 | 否 | CLI 内部 | 否 | CLI 启动时记录哪些环境变量由 `~/.agentengine/settings.json` 补入,用于区分用户显式环境变量和全局配置默认值。 | +| `AGENTENGINE_LOCAL_RUNTIME_VENV_REEXEC` | 本地 runtime CLI | 否 | 自动判断 | 无 | 否 | 本地开发者 / 测试 | 否 | 控制本地 runtime 是否在虚拟环境中 re-exec。普通用户通常无需设置。 | +| `AGENTENGINE_WEB_VENV_REEXEC` | 本地 Web CLI | 否 | 自动判断 | 无 | 否 | 本地开发者 / 测试 | 否 | 控制本地 Web 命令是否在虚拟环境中 re-exec。普通用户通常无需设置。 | +| `AGENTENGINE_DEBUG` | CLI | 否 | 未设置 | 无 | 否 | 开发者 | 否 | 开启更详细错误输出。 | +| `AGENTENGINE_GLOBAL_DRY_RUN` | CLI / API client | 否 | 未设置 | 无 | 否 | 开发者 / 测试 | 否 | 全局 dry-run 开关。 | +| `AGENTENGINE_OUTPUT_MODE` | CLI | 否 | `pretty` | 无 | 否 | 开发者 / CI | 否 | 输出模式,影响 JSON/pretty 渲染。 | +| `AGENTENGINE_NO_COLOR` | CLI | 否 | 未设置 | `NO_COLOR` | 否 | 开发者 / CI | 否 | 禁用彩色输出。 | +| `SESSION_TITLE_MODEL` | Conversations runtime | 否 | 未设置 | 默认模型配置 | 否 | 开发者 / 平台 | 否 | 会话标题生成模型覆盖。 | +| `COMPACTION_DISABLE_SEMANTIC` | Conversations runtime | 否 | `false` | 无 | 否 | 开发者 / 平台 | 否 | 禁用语义压缩摘要。 | +| `COMPACTION_SUMMARY_TIMEOUT_MS` | Conversations runtime | 否 | `45000` | 无 | 否 | 开发者 / 平台 | 否 | 语义压缩摘要超时毫秒数。 | +| `COMPACTION_SUMMARY_MAX_GROUPS` | Conversations runtime | 否 | `12` | 无 | 否 | 开发者 / 平台 | 否 | 单次语义压缩最大分组数。 | +| `COMPACTION_SUMMARY_MODEL` | Conversations runtime | 否 | 默认模型配置 | 无 | 否 | 开发者 / 平台 | 否 | 语义压缩摘要模型覆盖。 | +| `PORT` | Runtime image / Web | 否 | `8080` | `KSADK_RUNTIME_PORT` 在部分模板中转写 | 否 | 平台 / Runtime 镜像 | 否 | 容器监听端口。业务服务也可能读取同名变量;此时属于业务自定义。 | +| `HOST` | MCP / Web runtime | 否 | `0.0.0.0` | 无 | 否 | Runtime 镜像 | 否 | MCP/Web 服务监听地址。 | +| `LOG_LEVEL` | Runtime image | 否 | `INFO` | 无 | 否 | 开发者 / 平台 | 否 | 模板运行时日志级别。 | +| `CODE_PATH` | Runtime image | 否 | `/app/code` | 无 | 否 | Runtime 镜像 | 否 | 代码包解压/挂载目录。 | +| `PIP_INDEX_URL` | 构建 / Runtime image | 否 | pip 默认 | `UV_INDEX_URL` | 否 | 开发者 / 平台 | 否 | Python 依赖安装源。 | +| `UV_INDEX_URL` | 构建 / Runtime image | 否 | uv 默认 | `PIP_INDEX_URL` | 否 | 开发者 / 平台 | 否 | uv 依赖安装源。 | +| `KSADK_BUILD_PIP_INSTALL_TIMEOUT_SECONDS` | Code Builder | 否 | `2700` | 无 | 否 | 构建环境 / 开发者 | 否 | 源码构建时 `pip install` 总超时秒数。 | +| `KSADK_BUILD_ENABLE_ATTACHMENT_OCR` | Code Builder / Container Builder | 否 | `false` | 无 | 否 | 构建环境 / 开发者 | 否 | 是否把平台本地 OCR 依赖打进代码包。不开启不影响多模态模型直接消费 `input_image`。 | +| `KSADK_BUILD_ENABLE_MCP` | Code Builder / Container Builder | 否 | `false` | 无 | 否 | 构建环境 / 开发者 | 否 | 强制把 `mcp` / `langchain-mcp-adapters` 打进包。通常会根据项目 import 或非空 `KSADK_MCP_SERVERS` 自动启用;`[]` 不会启用。 | +| `KSADK_BUILD_ENABLE_POSTGRES_SESSION` | Code Builder / Container Builder | 否 | `false` | 无 | 否 | 构建环境 / 开发者 | 否 | 强制把 `asyncpg` 打进包。通常会根据 `KSADK_SESSION_BACKEND=postgres`、`KSADK_SESSION_DSN` 或 PostgreSQL DSN 自动启用。 | +| `KSADK_RUNTIME_PORT` | Runtime image / CLI | 否 | `8080` | 无 | 否 | 平台 | 否 | 模板运行时 HTTP 端口。 | +| `KSADK_PROJECT_DIR` | Sessions / Web | 否 | 当前工作目录 | 无 | 否 | 本地运行时 | 否 | 本地 session/workspace 状态 project root。 | +| `KSADK_RESPONSES_SESSION_HEADER` | RemoteRunner | 否 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | 远端 Responses session 透传 header 名称。 | +| `KSADK_TERMINAL_EXEC_SUBCOMMAND_ALLOWLIST` | Terminal exec | 否 | 默认常见只读命令 | 无 | 否 | 平台 / 开发者 | 否 | 追加允许远程 terminal exec 透传的命令前缀,多个前缀用逗号、分号或换行分隔;例如 `config,openclaw config`。设置为 `*` 时允许全部远程 exec 命令。 | +| `KSADK_TOOL_APPROVAL_MODE` | Built-in tools / Conversations runtime | 否 | `off` | 无 | 否 | 平台 / 开发者 | 否 | 内置工具审批模式;`strict` 时中高风险工具需要审批。 | +| `KSADK_FEISHU_APP_ID` | OpenClaw diagnostics | 否 | 未设置 | 无 | 否 | 开发者 / 平台 | 否 | 飞书辅助 app id。 | +| `KSADK_FEISHU_RESULT_PATH` | OpenClaw diagnostics | 否 | 未设置 | 无 | 否 | 开发者 / 平台 | 否 | 飞书辅助结果路径。 | +| `KSADK_WORKSPACE_FILES_ENABLED` | Hermes/OpenClaw workspace files | 否 | 镜像内通常默认 `1` | `OPENCLAW_WORKSPACE_FILES_ENABLED` | 否 | Runtime 镜像 / 平台 | 否 | 工作区文件服务开关。 | +| `KSADK_WORKSPACE_ROOT` | Hermes/OpenClaw workspace files | 否 | 镜像工作目录 | `OPENCLAW_WORKSPACE_DIR`、`HERMES_WORKDIR` | 否 | Runtime 镜像 / 平台 | 否 | 工作区根目录。 | + +## 11. 可观测性 + +| 变量 | 作用层级 | 是否必传 | 默认值 | 别名/兼容 | 敏感 | 配置方/来源 | 是否业务自定义 | 说明 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| `LANGFUSE_PUBLIC_KEY` | Tracing / Runtime | 条件必传 | 未设置 | 无 | 是 | Secret | 否 | Langfuse public key。 | +| `LANGFUSE_SECRET_KEY` | Tracing / Runtime | 条件必传 | 未设置 | 无 | 是 | Secret | 否 | Langfuse secret key。 | +| `LANGFUSE_BASE_URL` | Tracing / Runtime | 否 | 未设置 | `LANGFUSE_HOST` | 否 | 平台 / 开发者 | 否 | Langfuse endpoint。 | +| `LANGFUSE_HOST` | Tracing / Runtime | 否 | 未设置 | `LANGFUSE_BASE_URL` | 否 | 兼容旧配置 | 否 | Langfuse endpoint 旧变量。 | +| `LANGFUSE_PROJECT_ID` | Tracing | 否 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | Langfuse project id。 | +| `LANGFUSE_USE_CALLBACK` | Tracing | 否 | 未设置 | 无 | 否 | 开发者 | 否 | 是否启用 Langfuse callback。 | +| `LANGCHAIN_TRACING_V2` | LangChain tracing | 否 | 未设置 | 无 | 否 | 开发者 / 平台 | 否 | LangChain v2 tracing 开关。 | +| `LANGCHAIN_VERBOSE` | Runtime image | 否 | `true` | 无 | 否 | 开发者 / 平台 | 否 | 模板运行时 LangChain verbose 开关。 | +| `OTEL_EXPORTER_OTLP_ENDPOINT` | OTel | 条件必传 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | OTel Collector endpoint;未设置 traces 专用 endpoint 时,KsADK 会派生 `/v1/traces`。 | +| `OTEL_EXPORTER_OTLP_PROTOCOL` | OTel | 否 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | 通用 OTLP 协议;KsADK 自动 HTTP exporter 当前支持 `http/protobuf`。 | +| `OTEL_EXPORTER_OTLP_HEADERS` | OTel | 否 | 未设置 | 无 | 是 | 平台 / 开发者 | 否 | 通用 OTLP headers,逗号分隔,值按 URL encoding;可能包含 `Authorization`。 | +| `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | OTel | 否 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | traces 专用 endpoint;设置后优先于通用 endpoint。 | +| `OTEL_EXPORTER_OTLP_TRACES_PROTOCOL` | OTel | 否 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | traces 专用 OTLP 协议;设置后优先于通用 protocol。 | +| `OTEL_EXPORTER_OTLP_TRACES_HEADERS` | OTel | 否 | 未设置 | 无 | 是 | 平台 / 开发者 | 否 | traces 专用 OTLP headers;设置后优先于通用 headers。 | +| `OTEL_SERVICE_NAME` | OTel | 否 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | service name。 | +| `OTEL_RESOURCE_ATTRIBUTES` | OTel | 否 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | resource attributes。 | + +## 12. Hermes 和 OpenClaw 常见运行时变量 + +Hermes / OpenClaw 有大量镜像启动和安全策略变量,本文只列常见运行时可配置项。`*_PID`、`*_MARKER`、`*_CACHE_DIR`、`*_SPEC`、`*_PLUGIN_ID`、`*_PATCH_ROOTS`、`*_READY_STATUSES` 等主要是脚本内部状态或模板常量,未逐项列出。完整模板变量以 `deploy/hermes/`、`deploy/openclaw/`、`deploy/openclaw-user-template/` 内 README 和 bootstrap 脚本为准。 + +| 变量 | 作用层级 | 是否必传 | 默认值 | 别名/兼容 | 敏感 | 配置方/来源 | 是否业务自定义 | 说明 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| `HERMES_MODEL_PROVIDER` | Hermes | 否 | `custom` | 无 | 否 | 开发者 / 平台 | 否 | Hermes 模型 provider。 | +| `HERMES_CONTEXT_LENGTH` | Hermes | 否 | `OPENAI_CONTEXT_LENGTH` / `MODEL_CONTEXT_LENGTH` | 无 | 否 | 开发者 / 平台 | 否 | 上下文长度。 | +| `HERMES_COMPRESSION_MODEL` | Hermes | 否 | `OPENAI_MODEL_NAME` | 无 | 否 | 开发者 / 平台 | 否 | 压缩模型。 | +| `HERMES_COMPRESSION_BASE_URL` | Hermes | 否 | `OPENAI_BASE_URL` | 无 | 否 | 开发者 / 平台 | 否 | 压缩模型 endpoint。 | +| `HERMES_COMPRESSION_PROVIDER` | Hermes | 否 | `HERMES_MODEL_PROVIDER` | 无 | 否 | 开发者 / 平台 | 否 | 压缩模型 provider。 | +| `HERMES_COMPRESSION_CONTEXT_LENGTH` | Hermes | 否 | `HERMES_CONTEXT_LENGTH` | 无 | 否 | 开发者 / 平台 | 否 | 压缩模型上下文长度。 | +| `HERMES_COMPRESSION_TIMEOUT` | Hermes | 否 | `120` | 无 | 否 | 开发者 / 平台 | 否 | 压缩请求超时秒数。 | +| `HERMES_FALLBACK_MODEL` | Hermes | 否 | `OPENAI_FALLBACK_MODEL_NAME` | 无 | 否 | 开发者 / 平台 | 否 | fallback 模型。 | +| `HERMES_FALLBACK_BASE_URL` | Hermes | 否 | `OPENAI_BASE_URL` | 无 | 否 | 开发者 / 平台 | 否 | fallback endpoint。 | +| `HERMES_FALLBACK_PROVIDER` | Hermes | 否 | `custom` | 无 | 否 | 开发者 / 平台 | 否 | fallback 模型 provider。 | +| `HERMES_HOSTED_RUNTIME` | Hermes | 否 | `1` | 无 | 否 | Runtime 镜像 / 平台 | 否 | 标识 Hermes 以 hosted runtime 模式运行。 | +| `HERMES_HOME` | Hermes | 否 | `${HERMES_STATE_DIR}` | 无 | 否 | Runtime 镜像 | 否 | Hermes 状态根目录。 | +| `HERMES_STATE_DIR` | Hermes | 否 | `${HOME}/.hermes` | 无 | 否 | Runtime 镜像 | 否 | Hermes 状态目录。 | +| `HERMES_WORKDIR` | Hermes | 否 | 镜像默认值 | 无 | 否 | Runtime 镜像 | 否 | Hermes 工作目录。 | +| `HERMES_RUN_DIR` | Hermes | 否 | `${HERMES_HOME}/run` | 无 | 否 | Runtime 镜像 | 否 | Hermes 运行时 PID/socket 目录。 | +| `HERMES_SESSION_DIR` | Hermes | 否 | `${HERMES_HOME}/sessions` | 无 | 否 | Runtime 镜像 | 否 | Hermes 会话目录。 | +| `MCPORTER_HOME` | Hermes | 否 | `${HERMES_HOME}/mcporter` | 无 | 否 | Runtime 镜像 | 否 | MCPorter 状态目录。 | +| `XDG_CONFIG_HOME` | Hermes | 否 | `${HERMES_HOME}/xdg/config` | 无 | 否 | Runtime 镜像 | 否 | XDG config 目录覆盖。 | +| `XDG_CACHE_HOME` | Hermes | 否 | `${HERMES_HOME}/xdg/cache` | 无 | 否 | Runtime 镜像 | 否 | XDG cache 目录覆盖。 | +| `XDG_STATE_HOME` | Hermes | 否 | `${HERMES_HOME}/xdg/state` | 无 | 否 | Runtime 镜像 | 否 | XDG state 目录覆盖。 | +| `AGENT_BROWSER_HOME` | Hermes browser | 否 | `/usr/local/lib/node_modules/agent-browser` | 无 | 否 | Runtime 镜像 | 否 | browser agent 安装目录。 | +| `AGENT_BROWSER_EXECUTABLE_PATH` | Hermes/OpenClaw browser | 否 | `/usr/bin/chromium` 或自动探测 | `OPENCLAW_BROWSER_EXECUTABLE_PATH` | 否 | Runtime 镜像 / 开发者 | 否 | 浏览器可执行文件路径覆盖。 | +| `AGENT_BROWSER_STATE_DIR` | Hermes browser | 否 | `${HERMES_HOME}/browser` | 无 | 否 | Runtime 镜像 | 否 | browser agent 状态目录。 | +| `AGENT_BROWSER_RUN_DIR` | Hermes browser | 否 | `${AGENT_BROWSER_STATE_DIR}/run` | 无 | 否 | Runtime 镜像 | 否 | browser agent 运行目录。 | +| `AGENT_BROWSER_SESSION_DIR` | Hermes browser | 否 | `${AGENT_BROWSER_STATE_DIR}/sessions` | 无 | 否 | Runtime 镜像 | 否 | browser agent 会话目录。 | +| `AGENT_BROWSER_SOCKET_DIR` | Hermes browser | 否 | `${AGENT_BROWSER_RUN_DIR}` | 无 | 否 | Runtime 镜像 | 否 | browser agent socket 目录。 | +| `AGENT_BROWSER_ARTIFACTS_DIR` | Hermes browser | 否 | `${AGENT_BROWSER_STATE_DIR}/artifacts` | 无 | 否 | Runtime 镜像 | 否 | browser agent 产物目录。 | +| `AGENT_BROWSER_LOG_DIR` | Hermes browser | 否 | `${AGENT_BROWSER_STATE_DIR}/logs` | 无 | 否 | Runtime 镜像 | 否 | browser agent 日志目录。 | +| `API_SERVER_ENABLED` | Hermes API server | 否 | `true` | 无 | 否 | Runtime 镜像 / 开发者 | 否 | Hermes 内置 API server 开关。 | +| `API_SERVER_HOST` | Hermes API server | 否 | `127.0.0.1` | 无 | 否 | Runtime 镜像 / 开发者 | 否 | Hermes 内置 API server host。 | +| `API_SERVER_PORT` | Hermes API server | 否 | `8642` | 无 | 否 | Runtime 镜像 / 开发者 | 否 | Hermes 内置 API server port。 | +| `API_SERVER_KEY` | Hermes API server | 条件必传 | 未设置 | `HERMES_API_SERVER_KEY` | 是 | Secret | 否 | Hermes API server 鉴权 key。 | +| `TAVILY_API_KEY` | Hermes / OpenClaw web search | 条件必传 | 未设置 | `OPENCLAW_TAVILY_API_KEY` | 是 | Secret | 否 | Tavily 搜索 key。 | +| `FIRECRAWL_API_KEY` | Hermes web/search skill | 条件必传 | 未设置 | 无 | 是 | Secret | 否 | bundled web/search skill 使用 Firecrawl 时需要。 | +| `EXA_API_KEY` | Hermes web/search skill | 条件必传 | 未设置 | 无 | 是 | Secret | 否 | bundled web/search skill 使用 Exa 时需要。 | +| `PARALLEL_API_KEY` | Hermes web/search skill | 条件必传 | 未设置 | 无 | 是 | Secret | 否 | bundled web/search skill 使用 Parallel 时需要。 | +| `BROWSERBASE_API_KEY` | Hermes browser skill | 条件必传 | 未设置 | 无 | 是 | Secret | 否 | browser skill 使用 Browserbase 时需要。 | +| `BROWSER_USE_API_KEY` | Hermes browser skill | 条件必传 | 未设置 | 无 | 是 | Secret | 否 | browser-use 云服务模式需要。 | +| `CAMOFOX_URL` | Hermes browser skill | 条件必传 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | browser skill 使用 Camofox 服务时的 endpoint。 | +| `KDOCS_OPEN_BROWSER` | Hermes kdocs skill | 否 | `0` | 无 | 否 | 开发者 | 否 | kdocs token 获取脚本是否自动打开浏览器。 | +| `HERMES_DASHBOARD_HOST` | Hermes | 否 | `127.0.0.1` | 无 | 否 | Runtime 镜像 / 开发者 | 否 | Dashboard 监听 host。 | +| `HERMES_DASHBOARD_PORT` | Hermes | 否 | `9119` | 无 | 否 | Runtime 镜像 / 开发者 | 否 | Dashboard 端口。 | +| `HERMES_UI_LOCALE` | Hermes | 否 | `zh` | `LANG`、`LC_ALL` | 否 | Runtime 镜像 / 开发者 | 否 | UI 语言。 | +| `HERMES_API_SERVER_KEY` | Hermes CLI | 条件必传 | 未设置 | `API_SERVER_KEY` | 是 | Secret | 否 | Hermes API server 鉴权 key。 | +| `HERMES_IMAGE` | Hermes CLI | 否 | CLI 内置镜像 | `HERMES_DOCKER_IMAGE` | 否 | 开发者 / CI | 否 | Hermes 镜像覆盖。 | +| `HERMES_RESOURCE` | Hermes CLI | 否 | 未设置 | 无 | 否 | 开发者 / 平台 | 否 | Hermes 资源规格覆盖。 | +| `OPENCLAW_GATEWAY_AUTH_MODE` | OpenClaw | 条件必传 | 模板默认值 | 无 | 否 | 平台 / 开发者 | 否 | Gateway 鉴权模式。 | +| `OPENCLAW_GATEWAY_TOKEN` | OpenClaw | 条件必传 | 未设置 | 无 | 是 | Secret | 否 | token 模式鉴权 token。 | +| `OPENCLAW_GATEWAY_PASSWORD` | OpenClaw | 条件必传 | 未设置 | 无 | 是 | Secret | 否 | password 模式鉴权密码。 | +| `OPENCLAW_GATEWAY_PORT` | OpenClaw | 否 | 模板默认值 | 无 | 否 | Runtime 镜像 / 平台 | 否 | Gateway 端口。 | +| `OPENCLAW_GATEWAY_BIND` | OpenClaw | 否 | `lan` | 无 | 否 | Runtime 镜像 / 平台 | 否 | gateway 绑定模式。 | +| `OPENCLAW_GATEWAY_TRUSTED_PROXY_USER_HEADER` | OpenClaw | 条件必传 | 模板默认值 | `OPENCLAW_TRUSTED_PROXY_USER_HEADER` | 否 | 平台 | 否 | trusted-proxy 用户 header。 | +| `OPENCLAW_TRUSTED_PROXIES` | OpenClaw | 否 | 模板默认值 | 无 | 否 | 平台 | 否 | trusted-proxy 允许代理列表。 | +| `OPENCLAW_INTERNAL_TRUSTED_PROXY_USER` | OpenClaw | 否 | `openclaw-backend` | 无 | 否 | Runtime 镜像 / 平台 | 否 | 内部 loopback 请求用户。 | +| `OPENCLAW_INTERNAL_TRUSTED_PROXY_USER_HEADER` | OpenClaw | 否 | `OPENCLAW_TRUSTED_PROXY_USER_HEADER` | 无 | 否 | Runtime 镜像 / 平台 | 否 | 内部 loopback 用户 header。 | +| `OPENCLAW_ALLOWED_ORIGINS` | OpenClaw | 否 | 模板默认值 | 无 | 否 | 平台 / 开发者 | 否 | CORS allowed origins,支持列表/JSON。 | +| `OPENCLAW_ALLOW_INSECURE_AUTH` | OpenClaw | 否 | `false` | 无 | 否 | 开发者 / 测试 | 否 | 允许不安全鉴权配置,生产不要开启。 | +| `OPENCLAW_DISABLE_DEVICE_AUTH` | OpenClaw | 否 | `false` | 无 | 否 | 开发者 / 测试 | 否 | 禁用设备鉴权。 | +| `OPENCLAW_MODEL_API_KEY` | OpenClaw | 条件必传 | 未设置 | `OPENAI_API_KEY` / `MODEL_API_KEY` | 是 | Secret | 否 | OpenClaw 模型 API key。 | +| `OPENCLAW_MODEL_BASE_URL` | OpenClaw | 条件必传 | 未设置 | `OPENAI_BASE_URL` / `MODEL_API_BASE` | 否 | 平台 / 开发者 | 否 | OpenClaw 模型 endpoint。 | +| `OPENCLAW_DEFAULT_MODEL` | OpenClaw | 条件必传 | 未设置 | `OPENAI_MODEL_NAME` / `MODEL_NAME` | 否 | 平台 / 开发者 | 否 | OpenClaw 默认模型。 | +| `OPENCLAW_MODEL_PROVIDER_ID` | OpenClaw | 否 | `ksyun` | 无 | 否 | 平台 / 开发者 | 否 | OpenClaw 模型 provider id。 | +| `OPENCLAW_MODEL_API` | OpenClaw | 否 | `openai-completions` | 无 | 否 | 平台 / 开发者 | 否 | OpenClaw 模型 API 类型。 | +| `OPENCLAW_MODEL_CATALOG_JSON` | OpenClaw | 否 | 自动生成 | 无 | 否 | 平台 / 开发者 | 否 | 覆盖模型 catalog。 | +| `OPENCLAW_MODEL_ALLOWLIST` | OpenClaw | 否 | 未设置 | `AGENTENGINE_MODEL_ALLOWLIST` | 否 | 平台 / 开发者 | 否 | OpenClaw 模型白名单。 | +| `OPENCLAW_MODEL_API_KEY_SECRET_SOURCE` | OpenClaw | 否 | 模板默认值 | 无 | 否 | 平台 | 否 | 模型 API key secret 来源,例如 env/file。 | +| `OPENCLAW_MODEL_API_KEY_SECRET_PROVIDER` | OpenClaw | 否 | 模板默认值 | 无 | 否 | 平台 | 否 | 模型 API key secret provider 标识。 | +| `OPENCLAW_MODEL_API_KEY_SECRET_ID` | OpenClaw / safe exec | 条件必传 | 未设置 | 无 | 是 | Secret 配置 | 否 | file/secret-provider 模式下的模型或 web-search key 引用 ID。 | +| `OPENCLAW_MODEL_API_KEY_SECRET_FILE_PATH` | OpenClaw | 条件必传 | 模板默认值 | 无 | 是 | Secret 挂载 | 否 | file secret 模式下的 key 文件路径。 | +| `OPENCLAW_BROWSER_ENABLED` | OpenClaw | 否 | 安全策略决定 | 无 | 否 | 平台 / 开发者 | 否 | 是否启用浏览器能力。 | +| `OPENCLAW_BROWSER_NO_SANDBOX` | OpenClaw | 否 | `true` | 无 | 否 | Runtime 镜像 / 平台 | 否 | Chromium no-sandbox 开关。 | +| `OPENCLAW_BROWSER_HEADLESS` | OpenClaw | 否 | `true` | 无 | 否 | Runtime 镜像 / 平台 | 否 | 浏览器 headless 开关。 | +| `OPENCLAW_BROWSER_EXECUTABLE_PATH` | OpenClaw | 否 | 自动探测 | `OPENCLAW_BROWSER_EXECUTABLE` | 否 | Runtime 镜像 / 平台 | 否 | 浏览器可执行文件路径。 | +| `OPENCLAW_BROWSER_SSRF_POLICY_JSON` | OpenClaw | 否 | 模板默认策略 | 无 | 否 | 平台 / 开发者 | 否 | 浏览器 SSRF 策略 JSON。 | +| `OPENCLAW_WEB_FETCH_ENABLED` | OpenClaw | 否 | 模板默认值 | 无 | 否 | 平台 / 开发者 | 否 | web fetch 能力开关。 | +| `OPENCLAW_WEB_SEARCH_PROVIDER` | OpenClaw | 否 | 模板默认值 | 无 | 否 | 平台 / 开发者 | 否 | web search provider。 | +| `OPENCLAW_WEB_SEARCH_BASE_URL` | OpenClaw | 条件必传 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | web search endpoint。 | +| `OPENCLAW_WEB_SEARCH_MODEL` | OpenClaw | 条件必传 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | web search 模型名。 | +| `OPENCLAW_WEB_SEARCH_API_KEY` | OpenClaw | 条件必传 | 未设置 | 无 | 是 | Secret | 否 | web search API key。 | +| `OPENCLAW_WEB_SEARCH_API_KEY_SECRET_SOURCE` | OpenClaw | 否 | 模板默认值 | 无 | 否 | 平台 | 否 | web search key secret 来源。 | +| `OPENCLAW_WEB_SEARCH_API_KEY_SECRET_PROVIDER` | OpenClaw | 否 | 模板默认值 | 无 | 否 | 平台 | 否 | web search key secret provider 标识。 | +| `OPENCLAW_WEB_SEARCH_API_KEY_SECRET_ID` | OpenClaw | 条件必传 | 未设置 | 无 | 是 | Secret 配置 | 否 | web search key 引用 ID。 | +| `OPENCLAW_TAVILY_API_KEY` | OpenClaw web search | 条件必传 | 未设置 | `TAVILY_API_KEY` | 是 | Secret | 否 | OpenClaw Tavily 搜索 key。 | +| `OPENCLAW_WEB_SAFE_SEARCH_MODE` | OpenClaw safe exec | 否 | `bing` | 无 | 否 | 平台 / 开发者 | 否 | safe web search 模式,支持默认 Bing RSS 或模型搜索。 | +| `OPENCLAW_WEB_SAFE_SEARCH_MODEL` | OpenClaw safe exec | 条件必传 | 未设置 | `OPENCLAW_WEB_SEARCH_MODEL` / 默认模型 | 否 | 平台 / 开发者 | 否 | safe web search 模型名覆盖。 | +| `OPENCLAW_WEB_SAFE_SEARCH_BASE_URL` | OpenClaw safe exec | 条件必传 | 未设置 | `OPENCLAW_WEB_SEARCH_BASE_URL` / 模型 base url | 否 | 平台 / 开发者 | 否 | safe web search 模型 endpoint。 | +| `OPENCLAW_WEB_SAFE_SEARCH_API` | OpenClaw safe exec | 否 | `OPENCLAW_MODEL_API` 或 `openai-completions` | 无 | 否 | 平台 / 开发者 | 否 | safe web search 模型 API 类型。 | +| `OPENCLAW_WEB_SAFE_SEARCH_API_KEY` | OpenClaw safe exec | 条件必传 | 模型 key fallback | 无 | 是 | Secret | 否 | safe web search 专用 API key。 | +| `OPENCLAW_WEB_SAFE_SEARCH_SECRET_SOURCE` | OpenClaw safe exec | 否 | `OPENCLAW_MODEL_API_KEY_SECRET_SOURCE` | 无 | 否 | 平台 | 否 | safe web search key secret 来源。 | +| `OPENCLAW_WEB_SAFE_SEARCH_SECRET_FILE_PATH` | OpenClaw safe exec | 条件必传 | 未设置 | 无 | 是 | Secret 挂载 | 否 | safe web search file secret 路径。 | +| `OPENCLAW_WEB_SAFE_SEARCH_SECRET_ID` | OpenClaw safe exec | 条件必传 | 未设置 | 无 | 是 | Secret 配置 | 否 | safe web search key 引用 ID。 | +| `OPENCLAW_WEB_SAFE_SEARCH_ENDPOINT` | OpenClaw safe exec | 否 | `https://cn.bing.com/search?format=rss&q={query}` | 无 | 否 | 平台 / 开发者 | 否 | safe web search HTTP endpoint。 | +| `OPENCLAW_WEB_SAFE_READER_ENDPOINT` | OpenClaw safe exec | 否 | `https://r.jina.ai/` | 无 | 否 | 平台 / 开发者 | 否 | safe web reader endpoint。 | +| `OPENCLAW_WEB_SAFE_UNRESTRICTED` | OpenClaw safe exec | 否 | `false` | `OPENCLAW_EXEC_UNSAFE_MODE` 派生 | 否 | 开发者 / 测试 | 否 | 放宽 safe web SSRF 限制,生产不要开启。 | +| `OPENCLAW_WORKSPACE_FILES_ENABLED` | OpenClaw | 否 | 模板默认值 | `KSADK_WORKSPACE_FILES_ENABLED` | 否 | Runtime 镜像 / 平台 | 否 | workspace files 服务开关。 | +| `OPENCLAW_WORKSPACE_DIR` | OpenClaw | 否 | 模板默认值 | `KSADK_WORKSPACE_ROOT` | 否 | Runtime 镜像 / 平台 | 否 | workspace 目录。 | +| `OPENCLAW_WORKSPACE_FILES_PORT` | OpenClaw workspace files | 否 | 模板默认值 | 无 | 否 | Runtime 镜像 / 平台 | 否 | workspace files 服务端口。 | +| `OPENCLAW_WORKSPACE_FILES_PROXY_URL` | OpenClaw workspace files | 否 | 未设置 | 无 | 否 | Runtime 镜像 / 平台 | 否 | workspace files 代理地址。 | +| `OPENCLAW_PRESET_SKILLS_DIR` | OpenClaw bootstrap | 否 | `/opt/openclaw/preset-skills` | 无 | 否 | Runtime 镜像 / 平台 | 否 | 预置 skills 目录。 | +| `OPENCLAW_DEFAULT_EXTENSIONS_DIR` | OpenClaw bootstrap | 否 | `/opt/openclaw/default-extensions` | 无 | 否 | Runtime 镜像 / 平台 | 否 | 默认 extensions 目录。 | +| `OPENCLAW_GATEWAY_INTERNAL_HOST` | OpenClaw runtime proxy | 否 | `127.0.0.1` | 无 | 否 | Runtime 镜像 / 平台 | 否 | runtime proxy 连接内部 gateway 的 host。 | +| `OPENCLAW_GATEWAY_INTERNAL_PORT` | OpenClaw runtime proxy | 否 | `18080` | 无 | 否 | Runtime 镜像 / 平台 | 否 | runtime proxy 连接内部 gateway 的端口。 | +| `OPENCLAW_GATEWAY_PROXY_BASE_URL` | OpenClaw runtime proxy | 否 | 自动生成 | 无 | 否 | Runtime 镜像 / 平台 | 否 | runtime proxy HTTP base url 覆盖。 | +| `OPENCLAW_GATEWAY_PROXY_WS_URL` | OpenClaw runtime proxy | 否 | 自动生成 | 无 | 否 | Runtime 镜像 / 平台 | 否 | runtime proxy WebSocket url 覆盖。 | +| `OPENCLAW_GATEWAY_HANDOFF_GRACE_SECONDS` | OpenClaw gateway supervisor | 否 | `5` | 无 | 否 | Runtime 镜像 / 平台 | 否 | gateway handoff 等待秒数。 | +| `OPENCLAW_GATEWAY_LOCAL_RESTART_MAX` | OpenClaw gateway supervisor | 否 | `3` | 无 | 否 | Runtime 镜像 / 平台 | 否 | gateway 本地重启最大次数。 | +| `OPENCLAW_GATEWAY_LOCAL_RESTART_WINDOW_SECONDS` | OpenClaw gateway supervisor | 否 | `120` | 无 | 否 | Runtime 镜像 / 平台 | 否 | gateway 本地重启计数窗口。 | +| `OPENCLAW_GATEWAY_LOCAL_RESTART_BACKOFF_SECONDS` | OpenClaw gateway supervisor | 否 | `1` | 无 | 否 | Runtime 镜像 / 平台 | 否 | gateway 本地重启退避秒数。 | +| `OPENCLAW_EXEC_STRICT_MODE` | OpenClaw | 否 | `false` | `OPENCLAW_EXEC_SAFE_MODE` | 否 | 平台 / 开发者 | 否 | 收紧 exec/fs 策略。 | +| `OPENCLAW_EXEC_HOST` | OpenClaw | 否 | `gateway` | 无 | 否 | 平台 / 开发者 | 否 | exec tool host 策略。 | +| `OPENCLAW_EXEC_SECURITY` | OpenClaw | 否 | `full` 或 profile 默认 | 无 | 否 | 平台 / 开发者 | 否 | exec 安全级别:`full/allowlist/deny` 等。 | +| `OPENCLAW_EXEC_ASK` | OpenClaw | 否 | `off` | 无 | 否 | 平台 / 开发者 | 否 | exec 询问策略。 | +| `OPENCLAW_EXEC_ASK_FALLBACK` | OpenClaw | 否 | profile 默认 | 无 | 否 | 平台 / 开发者 | 否 | 询问失败时的 fallback 策略。 | +| `OPENCLAW_EXEC_AUTO_ALLOW_SKILLS` | OpenClaw | 否 | `false` | 无 | 否 | 平台 / 开发者 | 否 | 是否自动允许预置 skill 调用 exec。 | +| `OPENCLAW_EXEC_DEFAULT_ALLOWLIST_ENABLED` | OpenClaw | 否 | profile 默认 | 无 | 否 | 平台 / 开发者 | 否 | 是否启用默认 exec allowlist。 | +| `OPENCLAW_EXEC_ALLOWLIST` | OpenClaw | 否 | 未设置 | `OPENCLAW_EXEC_DEFAULT_ALLOWLIST` | 否 | 平台 / 开发者 | 否 | exec allowlist 覆盖。 | +| `OPENCLAW_FS_WORKSPACE_ONLY` | OpenClaw | 否 | profile 默认 | 无 | 否 | 平台 / 开发者 | 否 | 文件系统访问限制到 workspace。 | +| `OPENCLAW_ELEVATED_ENABLED` | OpenClaw | 否 | `false` | 无 | 否 | 平台 / 开发者 | 否 | elevated tool 开关。 | +| `OPENCLAW_PRESET_SKILLS_ALLOWLIST` | OpenClaw | 否 | 模板默认值 | 无 | 否 | Runtime 镜像 / 平台 | 否 | 预置 skills allowlist。 | +| `OPENCLAW_PRESET_PLUGINS_ALLOWLIST` | OpenClaw | 否 | 模板默认值 | 无 | 否 | Runtime 镜像 / 平台 | 否 | 预置 plugins allowlist。 | +| `OPENCLAW_RUNTIME_PROXY_ENABLED` | OpenClaw | 否 | 模板默认值 | 无 | 否 | Runtime 镜像 / 平台 | 否 | runtime proxy 开关。 | +| `OPENCLAW_RESPONSES_API_ENABLED` | OpenClaw | 否 | 模板默认值 | 无 | 否 | 平台 / 开发者 | 否 | Responses API 兼容入口开关。 | +| `OPENCLAW_THINKING_DEFAULT` | OpenClaw | 否 | `off` | 无 | 否 | 平台 / 开发者 | 否 | 默认 thinking effort。 | +| `OPENCLAW_VERBOSE_DEFAULT` | OpenClaw | 否 | `off` | 无 | 否 | 平台 / 开发者 | 否 | 默认 verbose 行为。 | +| `OPENCLAW_TYPING_MODE` | OpenClaw | 否 | `instant` | 无 | 否 | 平台 / 开发者 | 否 | UI typing 展示模式。 | +| `OPENCLAW_UI_LOCALE` | OpenClaw | 否 | 模板默认值 | `LANG`、`LC_ALL` | 否 | Runtime 镜像 / 开发者 | 否 | OpenClaw UI 语言。 | +| `OPENCLAW_CHANNEL_BOOTSTRAP_JSON` | OpenClaw | 否 | 未设置 | 无 | 是 | 平台 Secret / 部署配置 | 否 | channel 启动配置,可能包含登录/连接 token。 | +| `OPENCLAW_CONFIG_PATCH_JSON` | OpenClaw | 否 | 未设置 | 无 | 是 | 平台 Secret / 部署配置 | 否 | openclaw 配置 patch,可能包含 secret。 | +| `OPENCLAW_BOOTSTRAP_ONLY` | OpenClaw | 否 | `false` | 无 | 否 | Runtime 镜像 / 开发者 | 否 | 只执行 bootstrap,不启动 gateway。 | +| `OPENCLAW_STATE_DIR` | OpenClaw | 否 | `/home/node/.openclaw` | 无 | 否 | Runtime 镜像 | 否 | OpenClaw 状态目录。 | +| `OPENCLAW_TEMPLATE_DIR` | OpenClaw user template | 否 | `/opt/openclaw-template` | 无 | 否 | Runtime 镜像 | 否 | user template 根目录。 | +| `OPENCLAW_TEMPLATE_ENV_STRICT` | OpenClaw user template | 否 | `1` | 无 | 否 | Runtime 镜像 / 开发者 | 否 | user bootstrap 是否严格校验环境变量。 | +| `OPENCLAW_IMAGE` | OpenClaw CLI | 否 | CLI 内置镜像 | `OPENCLAW_DOCKER_IMAGE` | 否 | 开发者 / CI | 否 | OpenClaw 镜像覆盖。 | +| `OPENCLAW_RESOURCE` | OpenClaw CLI | 否 | CLI 默认规格 | 无 | 否 | 开发者 / 平台 | 否 | OpenClaw 资源规格快捷配置。 | +| `OPENCLAW_CPU` | OpenClaw CLI | 否 | CLI 默认规格 | 无 | 否 | 开发者 / 平台 | 否 | OpenClaw CPU 规格覆盖。 | +| `OPENCLAW_MEMORY` | OpenClaw CLI | 否 | CLI 默认规格 | 无 | 否 | 开发者 / 平台 | 否 | OpenClaw memory 规格覆盖。 | +| `OPENCLAW_RUNTIME_NPM_REGISTRY` | OpenClaw bootstrap | 否 | 镜像默认值 | 无 | 否 | Runtime 镜像 / 平台 | 否 | OpenClaw npm registry 覆盖。 | +| `OPENCLAW_RUNTIME_PIP_INDEX_URL` | OpenClaw bootstrap | 否 | 镜像默认值 | 无 | 否 | Runtime 镜像 / 平台 | 否 | OpenClaw pip index 覆盖。 | +| `OPENCLAW_RUNTIME_PIP_TRUSTED_HOST` | OpenClaw bootstrap | 否 | `mirrors.aliyun.com` | `PIP_TRUSTED_HOST` | 否 | Runtime 镜像 / 平台 | 否 | OpenClaw pip trusted-host 覆盖。 | +| `OPENCLAW_RUNTIME_UV_INDEX_URL` | OpenClaw bootstrap | 否 | `OPENCLAW_RUNTIME_PIP_INDEX_URL` | 无 | 否 | Runtime 镜像 / 平台 | 否 | OpenClaw uv index 覆盖。 | +| `OPENCLAW_RUNTIME_PLAYWRIGHT_DOWNLOAD_HOST` | OpenClaw bootstrap | 否 | `https://npmmirror.com/mirrors/playwright` | `PLAYWRIGHT_DOWNLOAD_HOST` | 否 | Runtime 镜像 / 平台 | 否 | Playwright 浏览器下载源覆盖。 | +| `OPENCLAW_RUNTIME_PUPPETEER_DOWNLOAD_BASE_URL` | OpenClaw bootstrap | 否 | `https://npmmirror.com/mirrors/chrome-for-testing` | `PUPPETEER_DOWNLOAD_BASE_URL` | 否 | Runtime 镜像 / 平台 | 否 | Puppeteer chrome-for-testing 下载源覆盖。 | +| `OPENCLAW_RUNTIME_PUPPETEER_DOWNLOAD_HOST` | OpenClaw bootstrap | 否 | `https://npmmirror.com/mirrors` | `PUPPETEER_DOWNLOAD_HOST` | 否 | Runtime 镜像 / 平台 | 否 | Puppeteer 下载 host 覆盖。 | +| `OPENCLAW_RUNTIME_CLAWHUB_SITE` | OpenClaw bootstrap | 否 | `https://cn.clawhub-mirror.com` | `CLAWHUB_SITE` | 否 | Runtime 镜像 / 平台 | 否 | ClawHub 站点地址覆盖。 | +| `OPENCLAW_RUNTIME_CLAWHUB_REGISTRY` | OpenClaw bootstrap | 否 | `CLAWHUB_SITE` 推导值 | `CLAWHUB_REGISTRY` | 否 | Runtime 镜像 / 平台 | 否 | ClawHub 插件仓库地址覆盖。 | +| `OPENCLAW_NPM_REGISTRY` | OpenClaw user template examples | 否 | `https://registry.npmmirror.com` | `OPENCLAW_RUNTIME_NPM_REGISTRY` | 否 | 镜像构建 / 开发者 | 否 | user template 示例中安装插件依赖的 npm registry。 | +| `PIP_TRUSTED_HOST` | Runtime image | 否 | 镜像或 bootstrap 设置 | `OPENCLAW_RUNTIME_PIP_TRUSTED_HOST` | 否 | Runtime 镜像 / 平台 | 否 | pip trusted-host。 | +| `PLAYWRIGHT_DOWNLOAD_HOST` | Runtime image | 否 | 镜像或 bootstrap 设置 | `OPENCLAW_RUNTIME_PLAYWRIGHT_DOWNLOAD_HOST` | 否 | Runtime 镜像 / 平台 | 否 | Playwright 浏览器下载源。 | +| `PUPPETEER_DOWNLOAD_BASE_URL` | Runtime image | 否 | 镜像或 bootstrap 设置 | `OPENCLAW_RUNTIME_PUPPETEER_DOWNLOAD_BASE_URL` | 否 | Runtime 镜像 / 平台 | 否 | Puppeteer 下载 base url。 | +| `PUPPETEER_DOWNLOAD_HOST` | Runtime image | 否 | 镜像或 bootstrap 设置 | `OPENCLAW_RUNTIME_PUPPETEER_DOWNLOAD_HOST` | 否 | Runtime 镜像 / 平台 | 否 | Puppeteer 下载 host。 | +| `KDOCS_TOKEN` | OpenClaw / Hermes kdocs skill | 条件必传 | 未设置 | 推荐迁移到 mcporter 配置 | 是 | 用户授权 / Secret | 否 | kdocs skill 运行态 token。Hermes 新流程优先 mcporter。 | +| `KDOCS_SKILL_REPO` | Hermes/OpenClaw image build | 否 | `https://github.com/kdocs-app/kdocs-skill.git` | 无 | 否 | 镜像构建 / 开发者 | 否 | 构建镜像时覆盖 kdocs skill 源仓库。 | +| `PLUGIN_API_KEY` | OpenClaw user template 示例 | 条件必传 | 未设置 | 无 | 是 | 业务扩展 Secret | 是 | user template 示例插件使用的业务 token,不属于 KsADK 标准契约。 | +| `DEMO_CHANNEL_API_KEY` | OpenClaw user template 示例 | 条件必传 | 未设置 | 无 | 是 | 业务扩展 Secret | 是 | user template 示例 channel 使用的业务 token,不属于 KsADK 标准契约。 | + +## 13. 内部常量和表名 + +这些变量名由源码作为常量导出或用于内部表名/依赖集合,一般不需要用户配置。 + +| 变量 | 作用层级 | 是否必传 | 默认值 | 别名/兼容 | 敏感 | 配置方/来源 | 是否业务自定义 | 说明 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| `KSADK_ALLOWED_SUFFIXES` | builders | 否 | 代码常量 | 无 | 否 | SDK 内部 | 否 | 代码打包允许后缀集合。 | +| `KSADK_ATTACHMENT_RUNTIME_REQUIREMENTS` | builders | 否 | 代码常量 | 无 | 否 | SDK 内部 | 否 | 附件运行时内置依赖集合。 | +| `KSADK_ATTACHMENT_OCR_RUNTIME_REQUIREMENTS` | builders | 否 | 代码常量 | 无 | 否 | SDK 内部 | 否 | 附件 OCR 运行时内置依赖集合。 | +| `KSADK_BUILD_ENABLE_ATTACHMENT_OCR` | builders | 否 | `false` | 无 | 否 | 构建环境 / 开发者 | 否 | 是否把平台本地 OCR 依赖打进代码包。 | +| `KSADK_BUILD_ENABLE_MCP` | builders | 否 | `false` | 无 | 否 | 构建环境 / 开发者 | 否 | 强制加入 MCP adapter 构建依赖。 | +| `KSADK_BUILD_PIP_INSTALL_TIMEOUT_SECONDS` | builders | 否 | `2700` | 无 | 否 | 构建环境 / 开发者 | 否 | 源码构建时 pip install 的超时秒数。 | +| `KSADK_BUILD_ENABLE_POSTGRES_SESSION` | builders | 否 | `false` | 无 | 否 | 构建环境 / 开发者 | 否 | 强制加入 PostgreSQL session 构建依赖。 | +| `KSADK_CORE_RUNTIME_REQUIREMENTS` | builders | 否 | 代码常量 | 无 | 否 | SDK 内部 | 否 | 核心运行时内置依赖集合。 | +| `KSADK_MCP_RUNTIME_REQUIREMENTS` | builders | 否 | 代码常量 | 无 | 否 | SDK 内部 | 否 | MCP adapter 可选运行时内置依赖集合。 | +| `KSADK_POSTGRES_SESSION_REQUIREMENTS` | builders | 否 | 代码常量 | 无 | 否 | SDK 内部 | 否 | PostgreSQL session 可选运行时内置依赖集合。 | +| `KSADK_RUNTIME_REQUIREMENTS` | builders | 否 | 代码常量 | 无 | 否 | SDK 内部 | 否 | 完整运行时内置依赖集合。 | +| `KSADK_SKILL_SERVICE` | skills | 否 | 代码调用前缀 | 无 | 否 | SDK 内部 | 否 | Skill Service AICP 连接配置前缀,用于解析 `KSADK_SKILL_SERVICE_ENDPOINT` / `KSADK_SKILL_SERVICE_SCHEME` / `KSADK_SKILL_SERVICE_REGION`;一般不需要用户单独设置。 | +| `KSADK_EVENTS_TABLE` | sessions | 否 | `ksadk_events` | 无 | 否 | SDK 内部 | 否 | 本地 SQLite events 表名。 | +| `KSADK_SESSIONS_TABLE` | sessions | 否 | `ksadk_sessions` | 无 | 否 | SDK 内部 | 否 | 本地 SQLite sessions 表名。 | +| `KSADK_STATES_TABLE` | sessions | 否 | `ksadk_states` | 无 | 否 | SDK 内部 | 否 | 本地 SQLite states 表名。 | +| `KSADK_PG_EVENTS_TABLE` | sessions | 否 | `ksadk_events` | 无 | 否 | SDK 内部 | 否 | PostgreSQL events 表名。 | +| `KSADK_PG_SESSIONS_TABLE` | sessions | 否 | `ksadk_sessions` | 无 | 否 | SDK 内部 | 否 | PostgreSQL sessions 表名。 | +| `KSADK_PG_STATES_TABLE` | sessions | 否 | `ksadk_states` | 无 | 否 | SDK 内部 | 否 | PostgreSQL states 表名。 | +| `KSADK_UPDATED_AT` | configs | 否 | 写入部署环境时生成 | 无 | 否 | SDK 内部 | 否 | serverless 部署更新触发时间戳。 | +| `KSADK_VERSION` | configs | 否 | 代码常量 | 无 | 否 | SDK 内部 | 否 | SDK version 导出名。 | + +## 14. 兼容、历史和不推荐变量 + +| 变量 | 状态 | 替代变量 | 说明 | +| --- | --- | --- | --- | +| `KSADK_ENABLE_SANDBOX_TOOLS` | master 旧 sandbox tools 开关,当前 Skill Runtime 重构后不再推荐 | `KSADK_SKILLS_MODE` + `KSADK_SKILL_RUNTIME_BACKEND` | master 分支仍存在。新实现不再默认注入 `execute_python/execute_bash/execute_javascript`。 | +| `KSADK_SANDBOX_TOOL_ID` | 早期 Skills 草案变量,不作为当前契约 | `KSADK_SANDBOX_TEMPLATE_ID` | 只保留在历史设计草案中。 | +| `KSADK_SANDBOX_HOST` | 早期/草案变量,不作为当前实现契约 | `E2B_API_URL` 或未来 provider endpoint | 当前通用 sandbox E2B backend 不读取。 | +| `KSADK_SANDBOX_REGION` | 早期/草案变量,不作为当前实现契约 | `KSADK_SANDBOX_TYPE` / provider 自身 region | 当前通用 sandbox E2B backend 不读取。 | +| `KSADK_SKILLS_DIR` | 早期/草案变量,不作为当前实现契约 | `KSADK_LOCAL_SKILLS_DIR` 或 `KSADK_SKILL_CACHE_DIR` | 当前 Runner/agent 不读取。 | +| `KSADK_SKILL_RUNTIME_ENDPOINT` | 早期/草案变量,不作为当前实现契约 | `E2B_API_URL` | E2B SDK 使用原生变量。 | +| `KSADK_SKILL_RUNTIME_API_KEY` | 早期/草案变量,不作为当前实现契约 | `E2B_API_KEY` | E2B SDK 使用原生变量。 | +| `KSADK_SKILL_RUNTIME_REGION` | 早期/草案变量,不作为当前实现契约 | 无 | 当前 E2B backend 不读取。 | +| `KSADK_SKILL_RUNTIME_TEMPLATE_ID` | 兼容变量 | `KSADK_SANDBOX_TEMPLATE_ID` | 仍可用,但新部署优先通用 sandbox 变量。 | +| `KSADK_SKILL_RUNTIME_ALLOW_INTERNET_ACCESS` | 兼容变量 | `KSADK_SANDBOX_ALLOW_INTERNET_ACCESS` | 通用 sandbox 变量优先。 | +| `KSADK_STM_*` | 旧短期记忆变量 | `KSADK_SESSION_*` | 仍作为 fallback。 | +| `AGENTENGINE_SESSION_BACKEND` / `AGENTENGINE_TENANT_ID` / `AGENTENGINE_WORKSPACE_ID` | 平台兼容变量 | `KSADK_SESSION_BACKEND` / `KSADK_TENANT_ID` / `KSADK_WORKSPACE_ID` | 仍作为 fallback。 | +| `OPENAI_API_BASE` | OpenAI 旧变量 | `OPENAI_BASE_URL` | 仍作为兼容。 | +| `MODEL_NAME` | 旧模型名变量 | `OPENAI_MODEL_NAME` | 仍作为兼容。 | +| `MODEL_API_KEY` / `MODEL_API_BASE` | OpenClaw/模型兼容变量 | `OPENAI_API_KEY` / `OPENAI_BASE_URL` 或 OpenClaw 专用变量 | 按运行时模板选择。 | +| `LLM_API_KEY` / `LLM_API_BASE` / `LLM_MODEL` | Serverless/OpenClaw 兼容变量 | `OPENAI_API_KEY` / `OPENAI_BASE_URL` / `OPENAI_MODEL_NAME` | 仍作为 fallback。 | +| `KINGSOFT_DOCS_TOKEN` | Hermes kdocs 旧变量,不推荐 | mcporter 内的 kdocs token | 只允许一次性迁移到 mcporter,不再建议写入环境变量或 `.env`。 | + +## 15. 业务自定义变量边界 + +| 类型 | 是否业务自定义 | 是否写入本文 | 说明 | +| --- | --- | --- | --- | +| 业务代码读取的变量,例如 `APP_ENV`、`DATABASE_URL`、`REDIS_URL`、`MY_SERVICE_TOKEN` | 是 | 否 | 由业务方自己定义,KsADK 不做含义约束。 | +| Agent 依赖的第三方工具变量,例如某业务 API token | 是 | 否 | 可以通过部署环境注入,但不属于 KsADK 标准契约。 | +| SDK/镜像内置扩展读取的第三方 token,例如 `TAVILY_API_KEY`、`FIRECRAWL_API_KEY`、`MEM0_API_KEY` | 否 | 部分写入 | 只有被 KsADK runtime、Hermes/OpenClaw 模板或内置 skill 明确读取的变量才列入本文。 | +| 平台或 SDK 读取的变量,例如 `KSADK_*`、`KSYUN_*`、`E2B_*`、`OPENAI_*`、`LANGFUSE_*` | 否 | 是 | 本文维护常见和核心变量。 | +| 镜像模板内部变量,例如大量 `OPENCLAW_*` / `HERMES_*` 高级开关 | 否 | 部分写入 | 本文只列常见运行时可配置项,完整列表以对应模板 README/bootstrap 为准。 | + +## 16. 配置建议 + +- 新部署优先使用通用变量:`KSADK_SANDBOX_TEMPLATE_ID`、`KSADK_SANDBOX_TIMEOUT`、`KSADK_SANDBOX_ALLOW_INTERNET_ACCESS`。 +- Skill Runtime 兼容变量 `KSADK_SKILL_RUNTIME_TEMPLATE_ID` 仅用于迁移期。 +- E2B backend 必须使用 SDK 原生 `E2B_API_URL` / `E2B_API_KEY`。 +- Secret 不要写入代码、仓库文档、测试 fixture、日志、snapshot;使用 Secret 注入。 +- 平台注入 Skill Space 时优先用 `KSADK_SKILL_SPACE_IDS`,单 space 兼容才使用 `SKILL_SPACE_ID`。 +- `KSYUN_ACCESS_KEY` / `KSYUN_SECRET_KEY` 是多个服务的 fallback。生产 sandbox 中建议使用更窄权限的 `KSADK_SKILL_SERVICE_ACCESS_KEY` / `KSADK_SKILL_SERVICE_SECRET_KEY`。 diff --git a/scripts/open_source_audit.py b/scripts/open_source_audit.py index ab449df1..cc03c170 100644 --- a/scripts/open_source_audit.py +++ b/scripts/open_source_audit.py @@ -100,7 +100,7 @@ def to_dict(self) -> dict[str, object]: DenyRule( name="non-curated-docs", prefixes=("docs/",), - allowed_paths=("docs/maintainer-approval-record.md",), + allowed_paths=("docs/maintainer-approval-record.md", "docs/ksadk\u73af\u5883\u53d8\u91cf\u53c2\u8003.md", "docs/\u8fdc\u7a0bAgent\u8fd0\u884c\u65f6\u63a5\u53e3\u8bf4\u660e.md"), description="internal planning and technical design docs stay out of the public repository; user docs live in public-docs/", ), DenyRule( From 8a9afc77766e19b6c0fae82379bd5e50f354b5b5 Mon Sep 17 00:00:00 2001 From: xiayu Date: Thu, 18 Jun 2026 17:53:01 +0800 Subject: [PATCH 08/52] fix: skip e2b sandbox test when e2b package is not installed --- tests/skills/test_runtime.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/skills/test_runtime.py b/tests/skills/test_runtime.py index 7921a32d..d3b9f8da 100644 --- a/tests/skills/test_runtime.py +++ b/tests/skills/test_runtime.py @@ -59,6 +59,7 @@ def test_runtime_factory_auto_uses_e2b_when_generic_sandbox_template_is_configur def test_e2b_skill_runtime_backend_from_env_prefers_generic_sandbox_vars(monkeypatch): + pytest.importorskip("e2b") monkeypatch.setenv("KSADK_SANDBOX_TEMPLATE_ID", "tpl-aio") monkeypatch.setenv("KSADK_SKILL_RUNTIME_TEMPLATE_ID", "tpl-legacy") monkeypatch.setenv("KSADK_SANDBOX_TIMEOUT", "321") From 350ef49fb5e64e785af4d3e787fcc4df21887cfb Mon Sep 17 00:00:00 2001 From: xiayu Date: Mon, 22 Jun 2026 09:47:13 +0800 Subject: [PATCH 09/52] docs: make README release wording accurate before 0.6.6 publication - Change README headline from already-published wording to candidate-safe wording - Let release positioning test accept both published and pre-release wording - Keep public-preflight green while 0.6.6 is still unreleased --- README.md | 2 +- tests/test_public_release_positioning.py | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e5ff1d79..6cfbe952 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Build agents once. Run them anywhere. KsADK 是面向 AI Agent 的 Agent Runtime Platform。你可以继续使用 Google ADK、LangGraph、LangChain 或 DeepAgents 编写业务 Agent,再用 KsADK 获得统一的本地运行、浏览器调试、OpenAI-Compatible API、沙箱执行、部署和可观测体验。 -发布版本:`0.6.6`(通过 GitHub Release 和 PyPI Trusted Publishing 发布)。 +当前源码版本:`0.6.6`。正式发布通过 GitHub Release 和 PyPI Trusted Publishing 提供。 ## Why KsADK diff --git a/tests/test_public_release_positioning.py b/tests/test_public_release_positioning.py index 9241c3c0..d00daecf 100644 --- a/tests/test_public_release_positioning.py +++ b/tests/test_public_release_positioning.py @@ -35,7 +35,10 @@ def test_public_readme_positions_ksadk_as_runtime_platform(): assert "```mermaid" not in readme assert "```text" in readme assert "当前版本:" not in readme - assert "发布版本:`0.6.6`" in readme + assert ( + "发布版本:`0.6.6`" in readme + or "当前源码版本:`0.6.6`。正式发布通过 GitHub Release 和 PyPI Trusted Publishing 提供。" in readme + ) def test_public_metadata_uses_runtime_platform_positioning(): From f16de9143d13c624bf5ebd7573e21aa2654ab741 Mon Sep 17 00:00:00 2001 From: xiayu Date: Mon, 22 Jun 2026 12:03:36 +0800 Subject: [PATCH 10/52] docs: finalize 0.6.6 public release notes and gates --- CHANGELOG.md | 4 ++ ...30\351\207\217\345\217\202\350\200\203.md" | 3 ++ docs/maintainer-approval-record.md | 4 +- ...45\345\217\243\350\257\264\346\230\216.md" | 3 +- .../reference/environment-variables.en.md | 12 +++++ .../reference/environment-variables.md | 10 ++++ .../reference/remote-runtime-api.en.md | 9 +++- public-docs/reference/remote-runtime-api.md | 9 +++- scripts/check_publication_state.py | 53 ++++++++++++++++++- tests/test_check_approval_record.py | 10 ++-- tests/test_check_publication_state.py | 40 ++++++++++++++ 11 files changed, 144 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c7976f2..50c079f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,8 @@ - 修复终端执行 allowlist 匹配过复杂、容易误判的问题,统一按共享策略做命令匹配与错误提示。 - 修复 Hosted UI 上传文件在本地 runtime 中只能看到 `ae-upload://` 引用、无法读取真实内容的问题。 - 修复 session/event 列表缺少总数和分页字段,导致 UI 无法稳定展示历史会话、历史事件或长任务恢复状态的问题。 +- 修复 0.6.6 发布候选漏打 `env_options.py`、`reasoning_markup.py`、`terminal_exec_policy.py` 模块,导致部分 CLI、conversation runtime 和终端策略导入失败的问题。 +- 修复 Python 3.10 环境下 workspace files router 使用 `datetime.UTC` 带来的兼容性问题。 ### 测试与发布 @@ -40,6 +42,8 @@ - 公开发布版本从 `0.6.5` 升级到 `0.6.6`,发布包继续通过 `make public-preflight` 同步 `@kingsoftcloud/ksadk-web@latest` 静态资源并执行 wheel 内容检查;本次发布应先完成 `@kingsoftcloud/ksadk-web@0.2.10` 的 npm release。 - `make public-preflight` 已覆盖 secret audit、public path audit、全量 pytest、sdist/wheel build 和 `twine check`;0.6.6 wheel/sdist 检查通过。 - 这是 Hermes/OpenClaw 默认镜像重建前置版本;镜像构建应固定 `KSADK_PACKAGE_SPEC=ksadk==0.6.6`,再走 staging E2E、GitHub Actions / PyPI Trusted Publishing 和环境门禁。 +- 公开仓库审计规则补充受控白名单,允许受控文档引用与公开镜像仓库示例,并把 `docs/ksadk环境变量参考.md`、`docs/远程Agent运行时接口说明.md` 作为公开参考文档纳入门禁。 +- README 的发布表述在 0.6.6 真正发布前保持候选态文案,避免公开页面提前显示“已发布”,同时保留发布后 wording 的测试兼容。 ## [0.6.5] - 2026-06-15 diff --git "a/docs/ksadk\347\216\257\345\242\203\345\217\230\351\207\217\345\217\202\350\200\203.md" "b/docs/ksadk\347\216\257\345\242\203\345\217\230\351\207\217\345\217\202\350\200\203.md" index 8e79ece2..b68dfe71 100644 --- "a/docs/ksadk\347\216\257\345\242\203\345\217\230\351\207\217\345\217\202\350\200\203.md" +++ "b/docs/ksadk\347\216\257\345\242\203\345\217\230\351\207\217\345\217\202\350\200\203.md" @@ -143,6 +143,7 @@ | `OPENAI_API_KEY` | 本地运行时 / Runtime 镜像 / OpenClaw / Hermes | 条件必传 | 未设置 | `LLM_API_KEY`、`MODEL_API_KEY`、部分 OpenClaw 场景使用 `OPENCLAW_MODEL_API_KEY` | 是 | 开发者 / Secret | 否 | OpenAI 兼容接口 API key。 | | `OPENAI_BASE_URL` | 本地运行时 / Runtime 镜像 / OpenClaw / Hermes | 条件必传 | 未设置 | `OPENAI_API_BASE`、`LLM_API_BASE`、`MODEL_API_BASE`、部分 OpenClaw 场景使用 `OPENCLAW_MODEL_BASE_URL` | 否 | 开发者 / 平台 | 否 | OpenAI 兼容接口 base url。 | | `OPENAI_MODEL_NAME` | 本地运行时 / Runtime 镜像 | 条件必传 | 未设置 | `LLM_MODEL`、`MODEL_NAME`、Hermes fallback 读取 `OPENAI_FALLBACK_MODEL_NAME` | 否 | 开发者 / 平台 | 否 | 默认模型名。 | +| `AGENTENGINE_MODEL_POLICY_JSON` | 托管运行时 / Runtime 镜像 | 否 | 未设置 | 无 | 否 | 平台 / Runtime 注入 | 否 | 统一模型策略 JSON,声明 primary / multimodal / fallback 角色及默认 model options。0.6.6 的 policy v1 默认主模型为 `glm-5.2`,多模态模型为 `kimi-k2.7-code`,fallback 为 `deepseek-v4-pro`。 | | `OPENAI_CONTEXT_LENGTH` | Hermes / 模型配置 | 否 | 未设置 | `MODEL_CONTEXT_LENGTH`、`HERMES_CONTEXT_LENGTH` | 否 | 开发者 / 平台 | 否 | 模型上下文长度提示。 | | `OPENAI_FALLBACK_MODEL_NAME` | Hermes / 模型配置 | 否 | 未设置 | `HERMES_FALLBACK_MODEL` | 否 | 开发者 / 平台 | 否 | Hermes fallback 模型名 fallback。 | | `LLM_API_KEY` | Serverless / 兼容模型配置 | 条件必传 | 未设置 | `OPENAI_API_KEY`、`MODEL_API_KEY` | 是 | 平台 Secret / 开发者 | 否 | Serverless 平台兼容模型 API key。 | @@ -434,6 +435,8 @@ Hermes / OpenClaw 有大量镜像启动和安全策略变量,本文只列常 | `OPENCLAW_MODEL_API_KEY` | OpenClaw | 条件必传 | 未设置 | `OPENAI_API_KEY` / `MODEL_API_KEY` | 是 | Secret | 否 | OpenClaw 模型 API key。 | | `OPENCLAW_MODEL_BASE_URL` | OpenClaw | 条件必传 | 未设置 | `OPENAI_BASE_URL` / `MODEL_API_BASE` | 否 | 平台 / 开发者 | 否 | OpenClaw 模型 endpoint。 | | `OPENCLAW_DEFAULT_MODEL` | OpenClaw | 条件必传 | 未设置 | `OPENAI_MODEL_NAME` / `MODEL_NAME` | 否 | 平台 / 开发者 | 否 | OpenClaw 默认模型。 | +| `OPENCLAW_FALLBACK_MODEL` | OpenClaw | 否 | `AGENTENGINE_MODEL_POLICY_JSON` 的 fallback 或未设置 | 无 | 否 | 平台 / 开发者 | 否 | OpenClaw fallback 模型。显式设置时优先于平台策略。 | +| `OPENCLAW_IMAGE_MODEL` | OpenClaw | 否 | `AGENTENGINE_MODEL_POLICY_JSON` 的 multimodal 或未设置 | 无 | 否 | 平台 / 开发者 | 否 | OpenClaw 多模态 / 图像场景模型。 | | `OPENCLAW_MODEL_PROVIDER_ID` | OpenClaw | 否 | `ksyun` | 无 | 否 | 平台 / 开发者 | 否 | OpenClaw 模型 provider id。 | | `OPENCLAW_MODEL_API` | OpenClaw | 否 | `openai-completions` | 无 | 否 | 平台 / 开发者 | 否 | OpenClaw 模型 API 类型。 | | `OPENCLAW_MODEL_CATALOG_JSON` | OpenClaw | 否 | 自动生成 | 无 | 否 | 平台 / 开发者 | 否 | 覆盖模型 catalog。 | diff --git a/docs/maintainer-approval-record.md b/docs/maintainer-approval-record.md index 76688ea0..d9c49cd7 100644 --- a/docs/maintainer-approval-record.md +++ b/docs/maintainer-approval-record.md @@ -11,7 +11,7 @@ PyPI publication. | License | Apache-2.0 | | Python repository | kingsoftcloud/ksadk-python | | Web UI repository | kingsoftcloud/ksadk-web | -| Python package version | 0.6.4 | +| Python package version | 0.6.6 | | Public docs URL | https://kingsoftcloud.github.io/ksadk-python/ | | Package metadata repository URL | https://github.com/kingsoftcloud/ksadk-python | | Package metadata documentation URL | https://kingsoftcloud.github.io/ksadk-python/ | @@ -40,7 +40,7 @@ changes. ## Required Evidence Before Approval - `make public-preflight` exits successfully. -- `make public-publish-check PUBLIC_PUBLISH_PHASE=pre-publish V=0.6.4` confirms +- `make public-publish-check PUBLIC_PUBLISH_PHASE=pre-publish V=0.6.6` confirms the target version is not already on PyPI. - GitHub PR checks are green on the reviewed commit. - Release notes and `CHANGELOG.md` were reviewed. diff --git "a/docs/\350\277\234\347\250\213Agent\350\277\220\350\241\214\346\227\266\346\216\245\345\217\243\350\257\264\346\230\216.md" "b/docs/\350\277\234\347\250\213Agent\350\277\220\350\241\214\346\227\266\346\216\245\345\217\243\350\257\264\346\230\216.md" index f3ff3266..92535145 100644 --- "a/docs/\350\277\234\347\250\213Agent\350\277\220\350\241\214\346\227\266\346\216\245\345\217\243\350\257\264\346\230\216.md" +++ "b/docs/\350\277\234\347\250\213Agent\350\277\220\350\241\214\346\227\266\346\216\245\345\217\243\350\257\264\346\230\216.md" @@ -1899,6 +1899,7 @@ OpenClaw 会额外起一个本地 `workspace_files_app` sidecar,然后由 gate - Hermes 的 `/_ksadk/terminal/ws` - `GET /agentengine/api/v1/AttachmentContent` - `GET /agentengine/api/v1/GetWorkspaceFileContent` +- `GET /agentengine/api/v1/ExportWorkspaceZip` - Hosted UI action 白名单: - `GetAgentUiBootstrap` - `CreateSession` @@ -1927,7 +1928,7 @@ OpenClaw 会额外起一个本地 `workspace_files_app` sidecar,然后由 gate 不要假设下列内容一定是公网 contract: - 任意 `/agentengine/api/v1/*` 路径 -- runtime 本地存在但未进入 Hosted UI action 白名单的 UI 辅助路径,例如 `ExportWorkspaceZip`、Workspace HTML 预览路径 +- runtime 本地存在但未进入 Hosted UI action 白名单的 UI 辅助路径,例如 Workspace HTML 预览路径 - `/debug/*`、`/builder/*`、`/traces`、`eval_sets`、`eval_results` 等开发 / 调试 / 内部辅助入口 - 任意 Pod 内部监听端口 - OpenClaw 上游项目的全部原生 API diff --git a/public-docs/reference/environment-variables.en.md b/public-docs/reference/environment-variables.en.md index 7b8fae05..9a775d4c 100644 --- a/public-docs/reference/environment-variables.en.md +++ b/public-docs/reference/environment-variables.en.md @@ -12,6 +12,12 @@ put real values in local `.env` files or CI secrets. | `OPENAI_BASE_URL` | provider base URL, usually ending in `/v1` | | `OPENAI_API_BASE` | compatibility alias for `OPENAI_BASE_URL` | | `OPENAI_MODEL_NAME` | default model name used by local runners and UI | +| `AGENTENGINE_MODEL_POLICY_JSON` | model policy JSON injected by hosted runtimes to declare primary / multimodal / fallback defaults | +| `OPENAI_FALLBACK_MODEL_NAME` | compatibility fallback model variable read by Hermes-style runtimes | +| `HERMES_FALLBACK_MODEL` | explicit Hermes fallback model override | +| `OPENCLAW_DEFAULT_MODEL` | explicit OpenClaw default model override | +| `OPENCLAW_FALLBACK_MODEL` | explicit OpenClaw fallback model override | +| `OPENCLAW_IMAGE_MODEL` | explicit OpenClaw multimodal / image model override | | `MODEL_NAME` | compatibility alias used by some projects | Example: @@ -22,6 +28,12 @@ OPENAI_BASE_URL=https://api.example.com/v1 OPENAI_MODEL_NAME=my-model ``` +Hosted deployments can inject a shared policy through +`AGENTENGINE_MODEL_POLICY_JSON`. In 0.6.6, model policy v1 defaults to +`glm-5.2` as the primary model, `kimi-k2.7-code` as the multimodal model, and +`deepseek-v4-pro` as the fallback model. Explicit request fields and explicit +environment-variable overrides still take precedence over policy defaults. + ## Project And Local UI | Variable | Purpose | diff --git a/public-docs/reference/environment-variables.md b/public-docs/reference/environment-variables.md index 09e4b9e4..11ec5769 100644 --- a/public-docs/reference/environment-variables.md +++ b/public-docs/reference/environment-variables.md @@ -11,6 +11,12 @@ | `OPENAI_BASE_URL` | provider base URL,通常以 `/v1` 结尾 | | `OPENAI_API_BASE` | `OPENAI_BASE_URL` 的兼容别名 | | `OPENAI_MODEL_NAME` | 本地 runner 和 UI 使用的默认模型 | +| `AGENTENGINE_MODEL_POLICY_JSON` | 托管运行时注入的模型策略 JSON,可统一声明 primary / multimodal / fallback 默认值 | +| `OPENAI_FALLBACK_MODEL_NAME` | 通用 fallback 模型兼容变量,Hermes 等运行时可读取 | +| `HERMES_FALLBACK_MODEL` | Hermes 显式 fallback 模型覆盖 | +| `OPENCLAW_DEFAULT_MODEL` | OpenClaw 显式默认模型覆盖 | +| `OPENCLAW_FALLBACK_MODEL` | OpenClaw 显式 fallback 模型覆盖 | +| `OPENCLAW_IMAGE_MODEL` | OpenClaw 显式多模态 / 图像模型覆盖 | | `MODEL_NAME` | 一些项目使用的兼容别名 | 示例: @@ -21,6 +27,10 @@ OPENAI_BASE_URL=https://api.example.com/v1 OPENAI_MODEL_NAME=my-model ``` +托管部署中,平台可以通过 `AGENTENGINE_MODEL_POLICY_JSON` 注入统一模型策略。0.6.6 +默认策略 v1 使用 `glm-5.2` 作为主模型、`kimi-k2.7-code` 作为多模态模型、 +`deepseek-v4-pro` 作为 fallback;显式请求参数或显式环境变量仍优先于策略默认值。 + ## 项目与本地 UI | 变量 | 用途 | diff --git a/public-docs/reference/remote-runtime-api.en.md b/public-docs/reference/remote-runtime-api.en.md index 27bf2ab6..a6942cab 100644 --- a/public-docs/reference/remote-runtime-api.en.md +++ b/public-docs/reference/remote-runtime-api.en.md @@ -104,7 +104,7 @@ hosted UI surface. | session | create, get, list, delete sessions | | events | list or subscribe to run events | | run | invoke or cancel an agent run | -| files | upload files and manage workspace files | +| files | upload files, download attachment content, read workspace files, and export workspace archives | | bootstrap | fetch UI bootstrap metadata | ## Workspace Files @@ -118,6 +118,12 @@ that enable the KsADK workspace surface. - delete a file when allowed. - export a workspace archive when supported. +Hosted UI download surfaces commonly include: + +- `GET /agentengine/api/v1/AttachmentContent` +- `GET /agentengine/api/v1/GetWorkspaceFileContent` +- `GET /agentengine/api/v1/ExportWorkspaceZip` + Keep all paths relative to the workspace root. Do not expose host filesystem paths in public examples. @@ -171,4 +177,3 @@ Public examples must not include private endpoint hostnames, real API keys, gateway tokens, cookies, kdocs tokens, internal forwarded headers, kubeconfig paths, cluster names, private image registries, customer data, session ids, or workspace paths. - diff --git a/public-docs/reference/remote-runtime-api.md b/public-docs/reference/remote-runtime-api.md index f4996457..a382bebf 100644 --- a/public-docs/reference/remote-runtime-api.md +++ b/public-docs/reference/remote-runtime-api.md @@ -101,7 +101,7 @@ cancel 和 model listing。公开 API 客户端优先使用 OpenAI 兼容的 `/v | session | 创建、获取、列出、删除会话 | | events | 列出或订阅 run events | | run | 调用或取消 Agent run | -| files | 上传文件和管理 workspace 文件 | +| files | 上传文件、下载附件内容、读取 workspace 文件和导出 workspace archive | | bootstrap | 获取 UI bootstrap metadata | ## Workspace Files @@ -115,6 +115,12 @@ workspace routes。 - 允许时删除文件。 - 支持时导出 workspace archive。 +Hosted UI 下载类 surface 通常包括: + +- `GET /agentengine/api/v1/AttachmentContent` +- `GET /agentengine/api/v1/GetWorkspaceFileContent` +- `GET /agentengine/api/v1/ExportWorkspaceZip` + 所有路径都应是 workspace root 下的相对路径。公开示例不要暴露宿主机绝对路径。 ## Hermes 特有边界 @@ -163,4 +169,3 @@ Accept: text/event-stream 公开示例不得包含私有 endpoint hostname、真实 API key、gateway token、cookie、 kdocs token、内部 forwarded header、kubeconfig 路径、集群名称、私有镜像 registry、客户数据、session id 或 workspace 路径。 - diff --git a/scripts/check_publication_state.py b/scripts/check_publication_state.py index fe21ba6f..ad8f75fa 100644 --- a/scripts/check_publication_state.py +++ b/scripts/check_publication_state.py @@ -11,6 +11,7 @@ import argparse import json import os +import subprocess import sys import urllib.error import urllib.parse @@ -74,8 +75,58 @@ def _pypi_version_exists(project: str, version: str) -> bool: return True +def _github_repo_from_releases_url(url: str) -> str | None: + parsed = urllib.parse.urlparse(url) + if parsed.hostname != "api.github.com": + return None + parts = [part for part in parsed.path.split("/") if part] + if len(parts) >= 4 and parts[0] == "repos" and parts[3] == "releases": + return f"{parts[1]}/{parts[2]}" + return None + + +def _github_release_tags_via_gh(url: str) -> set[str]: + repo = _github_repo_from_releases_url(url) + if not repo: + raise RuntimeError("github releases: 无法从 URL 推导 gh release list 的 repo") + try: + result = subprocess.run( + [ + "gh", + "release", + "list", + "--repo", + repo, + "--limit", + "200", + "--json", + "tagName", + ], + check=True, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + except FileNotFoundError as exc: + raise RuntimeError("github releases: gh CLI 不存在,且 GitHub API 已限流") from exc + except subprocess.CalledProcessError as exc: + raise RuntimeError( + "github releases: GitHub API 已限流,且 gh release list 失败: " + f"{exc.stderr.strip() or exc.stdout.strip() or exc}" + ) from exc + data = json.loads(result.stdout or "[]") + if not isinstance(data, list): + raise RuntimeError("github releases: gh release list 响应不是 release 列表") + return {str(item.get("tagName") or "") for item in data if isinstance(item, dict)} + + def _github_release_tags(url: str) -> set[str]: - status, body = _open(url) + try: + status, body = _open(url) + except urllib.error.HTTPError as exc: + if exc.code == 403: + return _github_release_tags_via_gh(url) + raise if status != 200: raise RuntimeError(f"github releases: 期望 HTTP 200,实际 {status}: {url}") data = json.loads(body) diff --git a/tests/test_check_approval_record.py b/tests/test_check_approval_record.py index 493895f7..635aada8 100644 --- a/tests/test_check_approval_record.py +++ b/tests/test_check_approval_record.py @@ -31,7 +31,7 @@ def _approved_record(python_source: str = "cd5fa22b1e78f03a8a9d025017e97ad414fda | License | Apache-2.0 | | Python repository | kingsoftcloud/ksadk-python | | Web UI repository | kingsoftcloud/ksadk-web | -| Python package version | 0.6.4 | +| Python package version | 0.6.6 | | Public docs URL | https://kingsoftcloud.github.io/ksadk-python/ | | Package metadata repository URL | https://github.com/kingsoftcloud/ksadk-python | | Package metadata documentation URL | https://kingsoftcloud.github.io/ksadk-python/ | @@ -65,7 +65,7 @@ def test_template_approval_record_fails_until_strategy_and_signoffs_are_filled() checks = module.validate_approval_record( REPO_ROOT / "docs" / "maintainer-approval-record.md", - version="0.6.4", + version="0.6.6", expected_current_commit="current-reviewed-commit", ) @@ -86,7 +86,7 @@ def test_filled_approval_record_passes(tmp_path): record = tmp_path / "approval.md" record.write_text(_approved_record(), encoding="utf-8") - checks = module.validate_approval_record(record, version="0.6.4", expected_current_commit="") + checks = module.validate_approval_record(record, version="0.6.6", expected_current_commit="") assert all(check.ok for check in checks) @@ -98,7 +98,7 @@ def test_filled_record_fails_when_source_references_do_not_match_current_commit( checks = module.validate_approval_record( record, - version="0.6.4", + version="0.6.6", expected_current_commit="new-reviewed-commit", ) @@ -121,7 +121,7 @@ def test_filled_record_passes_when_source_references_include_current_commit(tmp_ checks = module.validate_approval_record( record, - version="0.6.4", + version="0.6.6", expected_current_commit="new-reviewed-commit", ) diff --git a/tests/test_check_publication_state.py b/tests/test_check_publication_state.py index 36cf97d0..657792c0 100644 --- a/tests/test_check_publication_state.py +++ b/tests/test_check_publication_state.py @@ -1,6 +1,7 @@ from __future__ import annotations import importlib.util +import subprocess import sys from pathlib import Path @@ -163,3 +164,42 @@ def fake_urlopen(request, timeout): assert status == 200 assert body == b"ok" assert "Authorization" not in captured["headers"] + + +def test_github_release_tags_falls_back_to_gh_cli_on_rate_limit(monkeypatch): + module = _load_module() + + def fake_open(_url): + raise module.urllib.error.HTTPError( + url="https://api.github.com/repos/kingsoftcloud/ksadk-python/releases", + code=403, + msg="rate limit exceeded", + hdrs=None, + fp=None, + ) + + def fake_run(argv, check, text, stdout, stderr): + assert argv == [ + "gh", + "release", + "list", + "--repo", + "kingsoftcloud/ksadk-python", + "--limit", + "200", + "--json", + "tagName", + ] + return subprocess.CompletedProcess( + argv, + 0, + stdout='[{"tagName":"v0.6.5"},{"tagName":"v0.6.4"}]', + stderr="", + ) + + monkeypatch.setattr(module, "_open", fake_open) + monkeypatch.setattr(module.subprocess, "run", fake_run) + + assert module._github_release_tags( + "https://api.github.com/repos/kingsoftcloud/ksadk-python/releases?per_page=100" + ) == {"v0.6.5", "v0.6.4"} From 48f9387effca89a2b16f0bc6cfd360efe191d4f7 Mon Sep 17 00:00:00 2001 From: xiayu Date: Mon, 22 Jun 2026 13:59:27 +0800 Subject: [PATCH 11/52] fix: expose raw workspace zip runtime route --- .../workspace_files/router.py | 35 ++++++++++++++++++- .../test_server_workspace_preview_security.py | 16 +++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/ksadk_runtime_common/workspace_files/router.py b/ksadk_runtime_common/workspace_files/router.py index d76b6033..f80fa36a 100644 --- a/ksadk_runtime_common/workspace_files/router.py +++ b/ksadk_runtime_common/workspace_files/router.py @@ -2,14 +2,16 @@ from __future__ import annotations +import io import mimetypes +import zipfile from collections.abc import Callable from datetime import datetime, timezone from pathlib import Path from typing import Annotated from fastapi import APIRouter, File, HTTPException, Query, Response, UploadFile -from fastapi.responses import FileResponse, JSONResponse +from fastapi.responses import FileResponse, JSONResponse, StreamingResponse from ksadk_runtime_common.workspace_files.bootstrap import ( workspace_files_enabled, @@ -102,6 +104,37 @@ async def list_workspace_entries( "Entries": [_entry_payload(root, entry) for entry in entries], } + @router.get("/export-zip") + async def export_workspace_zip(path: str = Query(".", alias="path")) -> StreamingResponse: + _ensure_enabled() + root = _resolve_workspace_root(root_getter) + normalized, target = _resolve_workspace_target(root, path, allow_root=True) + if not target.exists(): + raise HTTPException(status_code=404, detail="workspace path not found") + if not target.is_dir(): + raise HTTPException(status_code=400, detail="workspace path is not a directory") + + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as archive: + for entry in sorted(target.rglob("*")): + if not entry.exists() or not entry.is_file() or entry.is_symlink(): + continue + try: + resolved_entry = entry.resolve(strict=True) + except OSError: + continue + if resolved_entry == root or root not in resolved_entry.parents: + continue + archive.write(resolved_entry, resolved_entry.relative_to(root).as_posix()) + + buf.seek(0) + zip_name = f"workspace-{normalized.replace('/', '-')}.zip" if normalized != "." else "workspace.zip" + return StreamingResponse( + buf, + media_type="application/zip", + headers={"Content-Disposition": f'attachment; filename="{zip_name}"'}, + ) + @router.head("/files/{file_path:path}") async def head_workspace_file(file_path: str) -> Response: _ensure_enabled() diff --git a/tests/test_server_workspace_preview_security.py b/tests/test_server_workspace_preview_security.py index d74d86bd..249523f3 100644 --- a/tests/test_server_workspace_preview_security.py +++ b/tests/test_server_workspace_preview_security.py @@ -48,3 +48,19 @@ def test_export_workspace_zip_does_not_follow_symlink_escape(monkeypatch, tmp_pa names = set(archive.namelist()) assert "safe.txt" in names assert "leak.txt" not in names + + +def test_workspace_raw_export_zip_route_matches_public_contract(monkeypatch, tmp_path: Path): + client, workspace = _client_with_workspace(monkeypatch, tmp_path) + assets = workspace / "slide-deck" + assets.mkdir() + (assets / "deck.md").write_text("# hello", encoding="utf-8") + + response = client.get("/_ksadk/workspace/v1/export-zip", params={"path": "slide-deck"}) + + assert response.status_code == 200 + assert response.headers["content-type"].startswith("application/zip") + assert "workspace-slide-deck.zip" in response.headers["content-disposition"] + with zipfile.ZipFile(io.BytesIO(response.content)) as archive: + assert archive.namelist() == ["slide-deck/deck.md"] + assert archive.read("slide-deck/deck.md") == b"# hello" From cbb23b0b8c808c8077f5b77bed3849fbcddba48c Mon Sep 17 00:00:00 2001 From: xiayu Date: Mon, 22 Jun 2026 16:32:04 +0800 Subject: [PATCH 12/52] test: harden hosted long-task release e2e --- CHANGELOG.md | 2 +- public-docs/contributing/release.en.md | 4 +- public-docs/contributing/release.md | 2 +- scripts/validate_hosted_long_task_e2e.py | 50 ++++++++--- tests/test_validate_hosted_long_task_e2e.py | 97 ++++++++++++++++++++- 5 files changed, 138 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 50c079f8..62641836 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,7 +39,7 @@ ### 测试与发布 - 新增模型策略、fallback、流式 fallback、OpenClaw env、Hermes env、LangChain patch、附件恢复、session 分页、Hosted UI 上传文件和终端 allowlist 覆盖测试。 -- 公开发布版本从 `0.6.5` 升级到 `0.6.6`,发布包继续通过 `make public-preflight` 同步 `@kingsoftcloud/ksadk-web@latest` 静态资源并执行 wheel 内容检查;本次发布应先完成 `@kingsoftcloud/ksadk-web@0.2.10` 的 npm release。 +- 公开发布版本从 `0.6.5` 升级到 `0.6.6`,发布包继续通过 `make public-preflight` 同步 `@kingsoftcloud/ksadk-web@latest` 静态资源并执行 wheel 内容检查;本次发布使用已发布的 `@kingsoftcloud/ksadk-web@0.2.10`。 - `make public-preflight` 已覆盖 secret audit、public path audit、全量 pytest、sdist/wheel build 和 `twine check`;0.6.6 wheel/sdist 检查通过。 - 这是 Hermes/OpenClaw 默认镜像重建前置版本;镜像构建应固定 `KSADK_PACKAGE_SPEC=ksadk==0.6.6`,再走 staging E2E、GitHub Actions / PyPI Trusted Publishing 和环境门禁。 - 公开仓库审计规则补充受控白名单,允许受控文档引用与公开镜像仓库示例,并把 `docs/ksadk环境变量参考.md`、`docs/远程Agent运行时接口说明.md` 作为公开参考文档纳入门禁。 diff --git a/public-docs/contributing/release.en.md b/public-docs/contributing/release.en.md index 83cbe361..2b468f35 100644 --- a/public-docs/contributing/release.en.md +++ b/public-docs/contributing/release.en.md @@ -12,9 +12,9 @@ The first public release should be prepared from an independent branch and revie 6. Create release tags, GitHub release assets, and TestPyPI/PyPI uploads only from the reviewed GitHub `main` commit after public CI passes. 7. Before publication, verify the external state with - `make public-publish-check PUBLIC_PUBLISH_PHASE=pre-publish V=0.6.5`. + `make public-publish-check PUBLIC_PUBLISH_PHASE=pre-publish V=0.6.6`. 8. After publication, verify the external state with - `make public-publish-check PUBLIC_PUBLISH_PHASE=post-publish V=0.6.5`. + `make public-publish-check PUBLIC_PUBLISH_PHASE=post-publish V=0.6.6`. Public release assets must not be created directly from an unsynced candidate branch. `make publish`, `make publish-test`, and `make public-release-tag` diff --git a/public-docs/contributing/release.md b/public-docs/contributing/release.md index df4172dd..248645c8 100644 --- a/public-docs/contributing/release.md +++ b/public-docs/contributing/release.md @@ -7,7 +7,7 @@ ```bash make open-source-review make open-source-review-bundle -make public-publish-check PUBLIC_PUBLISH_PHASE=pre-publish V=0.6.5 +make public-publish-check PUBLIC_PUBLISH_PHASE=pre-publish V=0.6.6 ``` ## GitHub diff --git a/scripts/validate_hosted_long_task_e2e.py b/scripts/validate_hosted_long_task_e2e.py index 1fcd33cd..4c02121b 100644 --- a/scripts/validate_hosted_long_task_e2e.py +++ b/scripts/validate_hosted_long_task_e2e.py @@ -347,6 +347,30 @@ def _event_statuses(events: list[dict[str, Any]], *, invocation_id: str = "") -> return statuses +def _wait_for_resume_progress( + client: HostedClient, + *, + session_id: str, + invocation_id: str, + min_checkpoints: int, + attempts: int, + interval: float, +) -> tuple[list[dict[str, Any]], dict[str, int], list[str]]: + events: list[dict[str, Any]] = [] + counts: dict[str, int] = {} + statuses: list[str] = [] + for _ in range(max(attempts, 1)): + events = _list_events(client, session_id=session_id) + counts = _event_type_counts(events) + statuses = _event_statuses(events, invocation_id=invocation_id) + if counts.get("run_checkpoint", 0) >= min_checkpoints and ( + not statuses or statuses[-1] in TERMINAL_STATUSES + ): + break + time.sleep(interval) + return events, counts, statuses + + def validate_checkpoint_resume( client: HostedClient, *, @@ -494,20 +518,16 @@ def validate_cancel_then_resume( checkpoint = _wait_for_checkpoint( client, session_id=session_id, - run_id=invocation_id, attempts=wait_attempts, interval=wait_interval, ) run_id = str(checkpoint.get("RunId") or "").strip() checkpoint_id = str(checkpoint.get("CheckpointId") or "").strip() - if run_id != invocation_id: - raise HostedE2EError( - f"Checkpoint RunId should match active invocation_id: {run_id!r} != {invocation_id!r}" - ) - if not checkpoint_id: + if not run_id or not checkpoint_id: raise HostedE2EError(f"Checkpoint missing CheckpointId: {checkpoint}") + cancel_invocation_id = str(checkpoint.get("InvocationId") or "").strip() or f"longtask_{run_id}" - cancel_payload = _cancel_run(client, invocation_id=invocation_id) + cancel_payload = _cancel_run(client, invocation_id=cancel_invocation_id) cancel_data = _data(cancel_payload, "CancelRun") if cancel_data.get("Cancelled") is not True: raise HostedE2EError(f"CancelRun did not accept active stream: {cancel_data}") @@ -516,7 +536,7 @@ def validate_cancel_then_resume( event_count_at_cancel = 0 for _ in range(wait_attempts): events = _list_events(client, session_id=session_id) - cancelled_statuses = _event_statuses(events, invocation_id=invocation_id) + cancelled_statuses = _event_statuses(events, invocation_id=cancel_invocation_id) if cancelled_statuses and cancelled_statuses[-1] == "cancelled": event_count_at_cancel = len(events) break @@ -559,9 +579,14 @@ def validate_cancel_then_resume( max_seconds=stream_timeout, ) - final_events = _list_events(client, session_id=session_id) - final_counts = _event_type_counts(final_events) - resume_statuses = _event_statuses(final_events, invocation_id=resume_invocation_id) + final_events, final_counts, resume_statuses = _wait_for_resume_progress( + client, + session_id=session_id, + invocation_id=resume_invocation_id, + min_checkpoints=2, + attempts=wait_attempts, + interval=wait_interval, + ) if final_counts.get("run_resume", 0) < 1: raise HostedE2EError(f"ResumeRun after cancel did not create run_resume event: {final_counts}") if final_counts.get("run_checkpoint", 0) < 2: @@ -576,7 +601,8 @@ def validate_cancel_then_resume( "session_id": session_id, "run_id": run_id, "checkpoint_id": checkpoint_id, - "cancel_invocation_id": invocation_id, + "run_agent_invocation_id": invocation_id, + "cancel_invocation_id": cancel_invocation_id, "resume_invocation_id": resume_invocation_id, "bootstrap_run_lifecycle": capabilities.get("RunLifecycle"), "cancel_data": cancel_data, diff --git a/tests/test_validate_hosted_long_task_e2e.py b/tests/test_validate_hosted_long_task_e2e.py index b9171963..7e17451f 100644 --- a/tests/test_validate_hosted_long_task_e2e.py +++ b/tests/test_validate_hosted_long_task_e2e.py @@ -1,4 +1,8 @@ -from scripts.validate_hosted_long_task_e2e import HostedE2EError, _wait_for_checkpoint +from scripts.validate_hosted_long_task_e2e import ( + HostedE2EError, + validate_cancel_then_resume, + _wait_for_checkpoint, +) class FlakyCheckpointClient: @@ -37,3 +41,94 @@ def test_wait_for_checkpoint_retries_initial_not_found(): ) assert checkpoint["CheckpointId"] == "checkpoint-1" + + +class BackgroundRunClient: + agent_id = "ar-test" + user_id = "user" + + def __init__(self): + self.cancel_invocation_id = "" + self.resume_payload = {} + self.event_calls = 0 + + def action(self, name, payload): + if name == "GetAgentUiBootstrap": + return { + "Data": { + "Capabilities": { + "RunLifecycle": { + "Checkpoints": True, + "CheckpointResume": True, + } + } + } + } + if name == "ListSessionCheckpoints": + return { + "Data": { + "Checkpoints": [ + { + "RunId": "run-background", + "CheckpointId": "checkpoint-1", + } + ] + } + } + if name == "CancelRun": + self.cancel_invocation_id = payload["InvocationId"] + return {"Data": {"Cancelled": True}} + if name == "ListSessionEvents": + self.event_calls += 1 + checkpoint_events = [ + {"EventType": "run_checkpoint", "InvocationId": "run-resume"} + ] + if self.event_calls > 2: + checkpoint_events.append( + {"EventType": "run_checkpoint", "InvocationId": "run-resume"} + ) + return { + "Data": { + "Events": [ + { + "EventType": "run_status", + "InvocationId": "longtask_run-background", + "Content": {"status": "cancelled"}, + }, + {"EventType": "run_resume", "InvocationId": "run-resume"}, + *checkpoint_events, + { + "EventType": "run_status", + "InvocationId": "run-resume", + "Content": {"status": "completed"}, + }, + ] + } + } + raise AssertionError(f"unexpected action: {name}") + + def stream_action(self, name, payload, *, max_seconds): + if name == "RunAgent": + return "data: started" + if name == "ResumeRun": + self.resume_payload = payload + return "data: resumed" + raise AssertionError(f"unexpected stream action: {name}") + + +def test_cancel_then_resume_cancels_background_run_id_from_checkpoint(): + client = BackgroundRunClient() + + result = validate_cancel_then_resume( + client, + session_id="session-1", + prompt="run until checkpoint", + wait_attempts=1, + wait_interval=0, + stream_timeout=1, + ) + + assert client.cancel_invocation_id == "longtask_run-background" + assert client.resume_payload["RunId"] == "run-background" + assert result["run_agent_invocation_id"].startswith("run_") + assert result["cancel_invocation_id"] == "longtask_run-background" From 4adc25b21aab6de0a0303336fdd9098245eee22b Mon Sep 17 00:00:00 2001 From: xiayu Date: Mon, 22 Jun 2026 17:07:14 +0800 Subject: [PATCH 13/52] docs: update 0.6.6 release notes for ksadk-web 0.2.11 --- CHANGELOG.md | 3 ++- README.md | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 62641836..f0873440 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ - **通用 Agent fallback**:conversation runtime 对超时、限流、5xx、模型不可用、权限/配额等可恢复模型错误支持 fallback 重试;普通 400 参数错误、业务错误和 tool 错误不会被吞掉。 - **运行时附件与 Hosted 附件打通**:本地 `ksadk-upload://` 与服务端 `ae-upload://` 上传文件统一解析,支持通过 KOP Action 下载 Hosted 附件内容、恢复本地缓存,并在会话/浏览器刷新后继续读取文件。 - **会话列表与事件分页增强**:Session service 新增 `count_sessions` / `count_events`,`ListSessions` 返回 `Total/Page/PageSize`,`ListSessionEvents` 支持 `Offset/Limit/Total`,便于 UI 恢复长任务和历史事件。 +- **Hosted TUI 会话复用**:配合 `@kingsoftcloud/ksadk-web@0.2.11`,Hosted 原生终端按业务会话复用 terminal session,并保留显式新建终端入口,避免刷新或切换页面时重复创建终端。 - **Hermes 终端执行策略收敛**:抽出共享 terminal exec allowlist policy,OpenClaw/Hermes 终端命令校验共用同一匹配逻辑,简化 allowlist 配置并降低误放行风险。 ### 变更 @@ -39,7 +40,7 @@ ### 测试与发布 - 新增模型策略、fallback、流式 fallback、OpenClaw env、Hermes env、LangChain patch、附件恢复、session 分页、Hosted UI 上传文件和终端 allowlist 覆盖测试。 -- 公开发布版本从 `0.6.5` 升级到 `0.6.6`,发布包继续通过 `make public-preflight` 同步 `@kingsoftcloud/ksadk-web@latest` 静态资源并执行 wheel 内容检查;本次发布使用已发布的 `@kingsoftcloud/ksadk-web@0.2.10`。 +- 公开发布版本从 `0.6.5` 升级到 `0.6.6`,发布包继续通过 `make public-preflight` 同步 `@kingsoftcloud/ksadk-web@latest` 静态资源并执行 wheel 内容检查;本次发布候选应使用 `@kingsoftcloud/ksadk-web@0.2.11` 对应的静态 UI。 - `make public-preflight` 已覆盖 secret audit、public path audit、全量 pytest、sdist/wheel build 和 `twine check`;0.6.6 wheel/sdist 检查通过。 - 这是 Hermes/OpenClaw 默认镜像重建前置版本;镜像构建应固定 `KSADK_PACKAGE_SPEC=ksadk==0.6.6`,再走 staging E2E、GitHub Actions / PyPI Trusted Publishing 和环境门禁。 - 公开仓库审计规则补充受控白名单,允许受控文档引用与公开镜像仓库示例,并把 `docs/ksadk环境变量参考.md`、`docs/远程Agent运行时接口说明.md` 作为公开参考文档纳入门禁。 diff --git a/README.md b/README.md index 6cfbe952..cd7f8a5a 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,7 @@ Export once. Observe anywhere. - 统一模型策略 v1:默认主模型 `glm-5.2`、多模态模型 `kimi-k2.7-code`、fallback 模型 `deepseek-v4-pro`,Hermes、OpenClaw 和通用 Agent 使用同一套默认语义。 - Hosted 附件内容恢复:本地 runtime 现在可以直接消费服务端 `ae-upload://...` 文件引用,并通过 `AttachmentContent` 恢复真实文件内容与本地缓存。 - 会话与历史事件增强:`ListSessions` / `ListSessionEvents` 补齐分页与总数字段,本地 Web UI 支持更长历史会话列表和按需回加载旧事件。 +- Hosted TUI 会话复用:配合 `@kingsoftcloud/ksadk-web@0.2.11`,原生终端默认按当前业务会话复用 terminal session,并保留显式新建入口。 - 将公开定位从普通 SDK 调整为 Agent Runtime Platform,首页补齐 Why KsADK、30 秒体验、架构说明、Deployment、Observability 和 Community。 - 清理 README、CHANGELOG 和后续 PyPI 元数据中的环境特定表述,避免公开页面出现内部环境名、内部 header 或私有 endpoint 示例。 - 为公开定位、敏感词和 PyPI metadata 增加回归测试,防止后续回退。 From 202dd31fc7d2752041391ecdcae5ef27ab3b3e0b Mon Sep 17 00:00:00 2001 From: xiayu Date: Mon, 22 Jun 2026 17:37:02 +0800 Subject: [PATCH 14/52] docs: refresh runtime model examples for 0.6.6 --- ...6\245\345\217\243\350\257\264\346\230\216.md" | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git "a/docs/\350\277\234\347\250\213Agent\350\277\220\350\241\214\346\227\266\346\216\245\345\217\243\350\257\264\346\230\216.md" "b/docs/\350\277\234\347\250\213Agent\350\277\220\350\241\214\346\227\266\346\216\245\345\217\243\350\257\264\346\230\216.md" index 92535145..dd79223b 100644 --- "a/docs/\350\277\234\347\250\213Agent\350\277\220\350\241\214\346\227\266\346\216\245\345\217\243\350\257\264\346\230\216.md" +++ "b/docs/\350\277\234\347\250\213Agent\350\277\220\350\241\214\346\227\266\346\216\245\345\217\243\350\257\264\346\230\216.md" @@ -320,7 +320,7 @@ curl -H "Authorization: Bearer " \ ] } ], - "model": "glm-5.1", + "model": "glm-5.2", "stream": true, "conversation": "conv_customer_001", "safety_identifier": "hash_user_001" @@ -389,7 +389,7 @@ curl -H "Authorization: Bearer " \ ] } ], - "model": "glm-5.1", + "model": "glm-5.2", "stream": false } ``` @@ -471,7 +471,7 @@ logger.info( ] } ], - "model": "glm-5.1", + "model": "glm-5.2", "stream": false } ``` @@ -658,7 +658,7 @@ MCP/tool approval 场景按 OpenAI Responses 标准语义恢复。客户端应 "incomplete_details": null, "instructions": null, "metadata": {}, - "model": "glm-5.1", + "model": "glm-5.2", "parallel_tool_calls": true, "temperature": null, "top_p": null, @@ -786,7 +786,7 @@ KsADK 兼容扩展附件示例: "id": "chatcmpl-123", "object": "chat.completion", "created": 1710000000, - "model": "glm-5.1", + "model": "glm-5.2", "choices": [ { "index": 0, @@ -1268,11 +1268,11 @@ data: [DONE] { "Models": [ { - "id": "glm-5.1", - "display_name": "glm-5.1" + "id": "glm-5.2", + "display_name": "glm-5.2" } ], - "Current": "glm-5.1", + "Current": "glm-5.2", "Source": "OPENAI_MODEL_NAME" } ``` From dd80c0c2162c7ec0d08693b19d28476c5dcea39a Mon Sep 17 00:00:00 2001 From: xiayu Date: Mon, 22 Jun 2026 18:09:34 +0800 Subject: [PATCH 15/52] docs: refresh 0.6.6 release notes and gates --- CHANGELOG.md | 10 +++++++++- README.md | 2 ++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f0873440..ba122efb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,8 +12,10 @@ - **统一模型策略 v1**:新增 `AGENTENGINE_MODEL_POLICY_JSON` 运行时策略契约,默认主模型为 `glm-5.2`,多模态模型为 `kimi-k2.7-code`,fallback 模型为 `deepseek-v4-pro`,为 Hermes、OpenClaw 和通用 Agent 提供同一套默认模型语义。 - **通用 Agent fallback**:conversation runtime 对超时、限流、5xx、模型不可用、权限/配额等可恢复模型错误支持 fallback 重试;普通 400 参数错误、业务错误和 tool 错误不会被吞掉。 - **运行时附件与 Hosted 附件打通**:本地 `ksadk-upload://` 与服务端 `ae-upload://` 上传文件统一解析,支持通过 KOP Action 下载 Hosted 附件内容、恢复本地缓存,并在会话/浏览器刷新后继续读取文件。 +- **Hosted Workspace 导出修复**:Workspace zip 导出路径与 Hosted facade 对齐,修复 share link / Hosted UI 通过数据面下载 workspace 目录时被公共 action 暴露规则拦截的问题。 - **会话列表与事件分页增强**:Session service 新增 `count_sessions` / `count_events`,`ListSessions` 返回 `Total/Page/PageSize`,`ListSessionEvents` 支持 `Offset/Limit/Total`,便于 UI 恢复长任务和历史事件。 - **Hosted TUI 会话复用**:配合 `@kingsoftcloud/ksadk-web@0.2.11`,Hosted 原生终端按业务会话复用 terminal session,并保留显式新建终端入口,避免刷新或切换页面时重复创建终端。 +- **长连接发布门禁增强**:Hosted SSE、WebSocket、TUI 与 workspace streaming 路径纳入环境 E2E 检查,避免长任务、终端恢复或大文件导出在代理链路上被短 idle timeout 提前截断。 - **Hermes 终端执行策略收敛**:抽出共享 terminal exec allowlist policy,OpenClaw/Hermes 终端命令校验共用同一匹配逻辑,简化 allowlist 配置并降低误放行风险。 ### 变更 @@ -26,6 +28,8 @@ - OpenClaw provider catalog 合并逻辑支持在已有 `OPENCLAW_MODEL_CATALOG_JSON` 上补齐 provider metadata,避免请求级 catalog 被平台默认值覆盖。 - `AgentEngineClient` 新增 `AttachmentContent` 与 `download_attachment_content()`,并修正 `list_sessions()` 请求字段为 `PageSize`。 - runtime 上传附件会持久化 metadata、本地路径和 MIME 信息;Hosted 附件下载后会写回本地 cache,供 runner、workspace preview 和会话恢复复用。 +- Workspace zip 导出优先走新的 raw runtime export endpoint,并保留 legacy runtime archive fallback,降低新旧 runtime 镜像混跑时的兼容风险。 +- Native terminal session manager 新增 HTTP session lifecycle 与 WebSocket attach 语义,断线默认 detach 而不是杀掉 PTY;Hermes/OpenClaw `--resume` 可以绑定同一 terminal session。 ### 修复 @@ -33,16 +37,20 @@ - 修复 ADK 短期记忆与运行时附件连续性,避免上传文件、memory context 和 runner payload 在多轮会话中丢失。 - 修复终端执行 allowlist 匹配过复杂、容易误判的问题,统一按共享策略做命令匹配与错误提示。 - 修复 Hosted UI 上传文件在本地 runtime 中只能看到 `ae-upload://` 引用、无法读取真实内容的问题。 +- 修复 Hosted Workspace zip 下载通过数据面访问时返回 `Public action is not exposed on data plane` 的问题,导出请求现在由受控 facade 转发到 runtime workspace export。 - 修复 session/event 列表缺少总数和分页字段,导致 UI 无法稳定展示历史会话、历史事件或长任务恢复状态的问题。 +- 修复 Hosted 流式会话、workspace 文件/zip streaming 在 server 到 runtime 这一跳仍可能使用短 read timeout 的问题;长流读取现在与 drain 窗口对齐。 +- 修复 Hosted TUI WebSocket keepalive 使用裸换行污染终端输出的问题;`ks-terminal.v1` 和 terminal path 改用结构化 `pong` 消息。 - 修复 0.6.6 发布候选漏打 `env_options.py`、`reasoning_markup.py`、`terminal_exec_policy.py` 模块,导致部分 CLI、conversation runtime 和终端策略导入失败的问题。 - 修复 Python 3.10 环境下 workspace files router 使用 `datetime.UTC` 带来的兼容性问题。 ### 测试与发布 -- 新增模型策略、fallback、流式 fallback、OpenClaw env、Hermes env、LangChain patch、附件恢复、session 分页、Hosted UI 上传文件和终端 allowlist 覆盖测试。 +- 新增模型策略、fallback、流式 fallback、OpenClaw env、Hermes env、LangChain patch、附件恢复、session 分页、Hosted UI 上传文件、workspace zip、终端 session 复用和终端 allowlist 覆盖测试。 - 公开发布版本从 `0.6.5` 升级到 `0.6.6`,发布包继续通过 `make public-preflight` 同步 `@kingsoftcloud/ksadk-web@latest` 静态资源并执行 wheel 内容检查;本次发布候选应使用 `@kingsoftcloud/ksadk-web@0.2.11` 对应的静态 UI。 - `make public-preflight` 已覆盖 secret audit、public path audit、全量 pytest、sdist/wheel build 和 `twine check`;0.6.6 wheel/sdist 检查通过。 - 这是 Hermes/OpenClaw 默认镜像重建前置版本;镜像构建应固定 `KSADK_PACKAGE_SPEC=ksadk==0.6.6`,再走 staging E2E、GitHub Actions / PyPI Trusted Publishing 和环境门禁。 +- GitHub Release / PyPI / npm release actions 必须等待 staging E2E、CI 门禁和人工确认完成后再由对应 GitHub workflow 执行。 - 公开仓库审计规则补充受控白名单,允许受控文档引用与公开镜像仓库示例,并把 `docs/ksadk环境变量参考.md`、`docs/远程Agent运行时接口说明.md` 作为公开参考文档纳入门禁。 - README 的发布表述在 0.6.6 真正发布前保持候选态文案,避免公开页面提前显示“已发布”,同时保留发布后 wording 的测试兼容。 diff --git a/README.md b/README.md index cd7f8a5a..d4b97f91 100644 --- a/README.md +++ b/README.md @@ -162,8 +162,10 @@ Export once. Observe anywhere. - 统一模型策略 v1:默认主模型 `glm-5.2`、多模态模型 `kimi-k2.7-code`、fallback 模型 `deepseek-v4-pro`,Hermes、OpenClaw 和通用 Agent 使用同一套默认语义。 - Hosted 附件内容恢复:本地 runtime 现在可以直接消费服务端 `ae-upload://...` 文件引用,并通过 `AttachmentContent` 恢复真实文件内容与本地缓存。 +- Hosted Workspace 导出修复:Workspace zip 下载通过受控 facade 转发到 runtime export,避免 share link / Hosted UI 下载目录时被公共 action 规则误拦截。 - 会话与历史事件增强:`ListSessions` / `ListSessionEvents` 补齐分页与总数字段,本地 Web UI 支持更长历史会话列表和按需回加载旧事件。 - Hosted TUI 会话复用:配合 `@kingsoftcloud/ksadk-web@0.2.11`,原生终端默认按当前业务会话复用 terminal session,并保留显式新建入口。 +- Hosted 长任务与终端链路发布门禁增强:SSE、WebSocket、TUI reconnect、workspace streaming 和长任务恢复需要在 staging 环境完成 E2E 后再进入公开发布。 - 将公开定位从普通 SDK 调整为 Agent Runtime Platform,首页补齐 Why KsADK、30 秒体验、架构说明、Deployment、Observability 和 Community。 - 清理 README、CHANGELOG 和后续 PyPI 元数据中的环境特定表述,避免公开页面出现内部环境名、内部 header 或私有 endpoint 示例。 - 为公开定位、敏感词和 PyPI metadata 增加回归测试,防止后续回退。 From 5ffb4b65ac4f4a3a08cb89484aac8e94047a8039 Mon Sep 17 00:00:00 2001 From: xiayu Date: Mon, 22 Jun 2026 18:14:07 +0800 Subject: [PATCH 16/52] docs: require staging e2e before public release --- docs/maintainer-approval-record.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/maintainer-approval-record.md b/docs/maintainer-approval-record.md index d9c49cd7..2677ee10 100644 --- a/docs/maintainer-approval-record.md +++ b/docs/maintainer-approval-record.md @@ -42,6 +42,11 @@ changes. - `make public-preflight` exits successfully. - `make public-publish-check PUBLIC_PUBLISH_PHASE=pre-publish V=0.6.6` confirms the target version is not already on PyPI. +- Staging E2E for the reviewed runtime images and control-plane candidate exits + successfully before GitHub Release, PyPI, or npm workflows are approved. +- Hosted workspace zip export, model policy defaults, fallback behavior, + Hermes/OpenClaw default images, long-task resume, and terminal reconnect are + covered by the staging E2E evidence. - GitHub PR checks are green on the reviewed commit. - Release notes and `CHANGELOG.md` were reviewed. - Public README and docs were reviewed for sensitive environment names, From 1d6846da45100a2f38f060bd2c919be14beb7a88 Mon Sep 17 00:00:00 2001 From: xiayu Date: Mon, 22 Jun 2026 18:40:11 +0800 Subject: [PATCH 17/52] docs: scope 0.6.6 changelog to ksadk changes --- CHANGELOG.md | 14 ++++++++++---- README.md | 3 ++- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba122efb..a86ef0cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,8 +15,9 @@ - **Hosted Workspace 导出修复**:Workspace zip 导出路径与 Hosted facade 对齐,修复 share link / Hosted UI 通过数据面下载 workspace 目录时被公共 action 暴露规则拦截的问题。 - **会话列表与事件分页增强**:Session service 新增 `count_sessions` / `count_events`,`ListSessions` 返回 `Total/Page/PageSize`,`ListSessionEvents` 支持 `Offset/Limit/Total`,便于 UI 恢复长任务和历史事件。 - **Hosted TUI 会话复用**:配合 `@kingsoftcloud/ksadk-web@0.2.11`,Hosted 原生终端按业务会话复用 terminal session,并保留显式新建终端入口,避免刷新或切换页面时重复创建终端。 -- **长连接发布门禁增强**:Hosted SSE、WebSocket、TUI 与 workspace streaming 路径纳入环境 E2E 检查,避免长任务、终端恢复或大文件导出在代理链路上被短 idle timeout 提前截断。 - **Hermes 终端执行策略收敛**:抽出共享 terminal exec allowlist policy,OpenClaw/Hermes 终端命令校验共用同一匹配逻辑,简化 allowlist 配置并降低误放行风险。 +- **部署与运行调试体验增强**:CLI dry-run / JSON 输出、构建缓存、framework passthrough、OpenClaw/Hermes 更新语义、网络参数和凭证边界补齐更多测试,减少发布前后手工排障成本。 +- **长任务验证脚本补齐**:新增 Hosted long-task、checkpoint resume、cancel/resume 和长期运行 pilot 验证脚本,作为 SDK 发布候选的环境验收辅助工具。 ### 变更 @@ -30,6 +31,10 @@ - runtime 上传附件会持久化 metadata、本地路径和 MIME 信息;Hosted 附件下载后会写回本地 cache,供 runner、workspace preview 和会话恢复复用。 - Workspace zip 导出优先走新的 raw runtime export endpoint,并保留 legacy runtime archive fallback,降低新旧 runtime 镜像混跑时的兼容风险。 - Native terminal session manager 新增 HTTP session lifecycle 与 WebSocket attach 语义,断线默认 detach 而不是杀掉 PTY;Hermes/OpenClaw `--resume` 可以绑定同一 terminal session。 +- `agentengine deploy`、`agentengine launch`、`agentengine mcp deploy` 和 OpenClaw/Hermes 相关命令补齐 no-cache、dry-run JSON envelope、网络参数、环境变量和显式配置覆盖测试。 +- `AgentEngineClient` 与远程 runner 补齐 framework passthrough、权限预检查、错误日志、MCP payload、workspace files 和 attachment content 的契约测试。 +- LangGraph、LangChain、ADK、DeepAgents、A2A、本地 re-exec、session continuity、Postgres session service、sandbox backend 和 Skill Runtime 相关路径补齐回归覆盖。 +- `docs/ksadk环境变量参考.md` 和 `docs/远程Agent运行时接口说明.md` 纳入公开参考文档,集中描述 SDK/runtime 可配置项、Hosted action 与 runtime local API 的边界。 ### 修复 @@ -39,18 +44,19 @@ - 修复 Hosted UI 上传文件在本地 runtime 中只能看到 `ae-upload://` 引用、无法读取真实内容的问题。 - 修复 Hosted Workspace zip 下载通过数据面访问时返回 `Public action is not exposed on data plane` 的问题,导出请求现在由受控 facade 转发到 runtime workspace export。 - 修复 session/event 列表缺少总数和分页字段,导致 UI 无法稳定展示历史会话、历史事件或长任务恢复状态的问题。 -- 修复 Hosted 流式会话、workspace 文件/zip streaming 在 server 到 runtime 这一跳仍可能使用短 read timeout 的问题;长流读取现在与 drain 窗口对齐。 - 修复 Hosted TUI WebSocket keepalive 使用裸换行污染终端输出的问题;`ks-terminal.v1` 和 terminal path 改用结构化 `pong` 消息。 - 修复 0.6.6 发布候选漏打 `env_options.py`、`reasoning_markup.py`、`terminal_exec_policy.py` 模块,导致部分 CLI、conversation runtime 和终端策略导入失败的问题。 - 修复 Python 3.10 环境下 workspace files router 使用 `datetime.UTC` 带来的兼容性问题。 +- 修复 E2B sandbox 可选依赖缺失时测试不能跳过的问题,降低最小开发环境运行公开测试的成本。 +- 修复公开仓库审计误拦截受控公开文档和公开镜像仓库示例的问题,并继续禁止内部 endpoint、私有 header、真实凭证和 kubeconfig 进入公开材料。 ### 测试与发布 - 新增模型策略、fallback、流式 fallback、OpenClaw env、Hermes env、LangChain patch、附件恢复、session 分页、Hosted UI 上传文件、workspace zip、终端 session 复用和终端 allowlist 覆盖测试。 - 公开发布版本从 `0.6.5` 升级到 `0.6.6`,发布包继续通过 `make public-preflight` 同步 `@kingsoftcloud/ksadk-web@latest` 静态资源并执行 wheel 内容检查;本次发布候选应使用 `@kingsoftcloud/ksadk-web@0.2.11` 对应的静态 UI。 - `make public-preflight` 已覆盖 secret audit、public path audit、全量 pytest、sdist/wheel build 和 `twine check`;0.6.6 wheel/sdist 检查通过。 -- 这是 Hermes/OpenClaw 默认镜像重建前置版本;镜像构建应固定 `KSADK_PACKAGE_SPEC=ksadk==0.6.6`,再走 staging E2E、GitHub Actions / PyPI Trusted Publishing 和环境门禁。 -- GitHub Release / PyPI / npm release actions 必须等待 staging E2E、CI 门禁和人工确认完成后再由对应 GitHub workflow 执行。 +- 运行时镜像如需集成 0.6.6 候选,应使用同一候选 wheel 或发布后的 `KSADK_PACKAGE_SPEC=ksadk==0.6.6`,镜像构建和部署证据由对应内部仓库记录。 +- GitHub Release / PyPI / npm release actions 必须等待环境 E2E、CI 门禁和人工确认完成后再由对应 GitHub workflow 执行。 - 公开仓库审计规则补充受控白名单,允许受控文档引用与公开镜像仓库示例,并把 `docs/ksadk环境变量参考.md`、`docs/远程Agent运行时接口说明.md` 作为公开参考文档纳入门禁。 - README 的发布表述在 0.6.6 真正发布前保持候选态文案,避免公开页面提前显示“已发布”,同时保留发布后 wording 的测试兼容。 diff --git a/README.md b/README.md index d4b97f91..6c575870 100644 --- a/README.md +++ b/README.md @@ -165,7 +165,8 @@ Export once. Observe anywhere. - Hosted Workspace 导出修复:Workspace zip 下载通过受控 facade 转发到 runtime export,避免 share link / Hosted UI 下载目录时被公共 action 规则误拦截。 - 会话与历史事件增强:`ListSessions` / `ListSessionEvents` 补齐分页与总数字段,本地 Web UI 支持更长历史会话列表和按需回加载旧事件。 - Hosted TUI 会话复用:配合 `@kingsoftcloud/ksadk-web@0.2.11`,原生终端默认按当前业务会话复用 terminal session,并保留显式新建入口。 -- Hosted 长任务与终端链路发布门禁增强:SSE、WebSocket、TUI reconnect、workspace streaming 和长任务恢复需要在 staging 环境完成 E2E 后再进入公开发布。 +- 长任务与终端验证脚本增强:新增 Hosted long-task、checkpoint resume、cancel/resume 和 terminal reconnect 验证脚本,发布前需结合目标环境完成 E2E。 +- CLI 与部署路径增强:dry-run JSON、no-cache、网络参数、环境变量覆盖、framework passthrough、远程 runner、A2A、Skill Runtime 和 sandbox 路径补齐回归覆盖。 - 将公开定位从普通 SDK 调整为 Agent Runtime Platform,首页补齐 Why KsADK、30 秒体验、架构说明、Deployment、Observability 和 Community。 - 清理 README、CHANGELOG 和后续 PyPI 元数据中的环境特定表述,避免公开页面出现内部环境名、内部 header 或私有 endpoint 示例。 - 为公开定位、敏感词和 PyPI metadata 增加回归测试,防止后续回退。 From c3eaac3cd137c541bf2f132d71a7a1be2112c7f3 Mon Sep 17 00:00:00 2001 From: xiayu Date: Mon, 22 Jun 2026 18:58:14 +0800 Subject: [PATCH 18/52] docs: keep 0.6.6 changelog ksadk-scoped --- CHANGELOG.md | 15 +++++++-------- tests/test_public_release_positioning.py | 5 ++++- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a86ef0cb..f6394209 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ - **统一模型策略 v1**:新增 `AGENTENGINE_MODEL_POLICY_JSON` 运行时策略契约,默认主模型为 `glm-5.2`,多模态模型为 `kimi-k2.7-code`,fallback 模型为 `deepseek-v4-pro`,为 Hermes、OpenClaw 和通用 Agent 提供同一套默认模型语义。 - **通用 Agent fallback**:conversation runtime 对超时、限流、5xx、模型不可用、权限/配额等可恢复模型错误支持 fallback 重试;普通 400 参数错误、业务错误和 tool 错误不会被吞掉。 - **运行时附件与 Hosted 附件打通**:本地 `ksadk-upload://` 与服务端 `ae-upload://` 上传文件统一解析,支持通过 KOP Action 下载 Hosted 附件内容、恢复本地缓存,并在会话/浏览器刷新后继续读取文件。 -- **Hosted Workspace 导出修复**:Workspace zip 导出路径与 Hosted facade 对齐,修复 share link / Hosted UI 通过数据面下载 workspace 目录时被公共 action 暴露规则拦截的问题。 +- **Workspace zip 导出修复**:本地与 Hosted 场景统一使用 KsADK runtime workspace 导出契约,修复 share link / Hosted UI 下载 workspace 目录时报错的问题。 - **会话列表与事件分页增强**:Session service 新增 `count_sessions` / `count_events`,`ListSessions` 返回 `Total/Page/PageSize`,`ListSessionEvents` 支持 `Offset/Limit/Total`,便于 UI 恢复长任务和历史事件。 - **Hosted TUI 会话复用**:配合 `@kingsoftcloud/ksadk-web@0.2.11`,Hosted 原生终端按业务会话复用 terminal session,并保留显式新建终端入口,避免刷新或切换页面时重复创建终端。 - **Hermes 终端执行策略收敛**:抽出共享 terminal exec allowlist policy,OpenClaw/Hermes 终端命令校验共用同一匹配逻辑,简化 allowlist 配置并降低误放行风险。 @@ -25,11 +25,11 @@ - `kimi-k2.7-code` 默认模型参数补齐 `temperature=1`,并透传到 Chat Completions / Responses 的 `temperature`、`top_p`、`max_tokens`、`max_completion_tokens` 等 model options。 - OpenClaw 默认 catalog 包含 `glm-5.2`、`kimi-k2.7-code` 和 `deepseek-v4-pro`;图像场景默认优先使用 `kimi-k2.7-code`,显式 `OPENCLAW_MODEL_CATALOG_JSON` 仍保持 catalog 首项优先。 - Hermes deploy 与 OpenClaw deploy 均会注入同一模型策略 env,保留 `OPENAI_MODEL_NAME`、`MODEL_NAME`、`OPENCLAW_DEFAULT_MODEL`、`HERMES_DEFAULT_MODEL`、`HERMES_FALLBACK_MODEL` 等显式覆盖。 -- Hermes 本地默认主模型更新为 `glm-5.2`,不再按 KSPMAS / `glm-5.1` 硬编码 fallback 到 `kimi-k2.6`,fallback 改由统一策略或显式 env 决定。 +- Hermes 本地默认主模型更新为 `glm-5.2`,不再按旧主模型名称硬编码 fallback 到 `kimi-k2.6`,fallback 改由统一策略或显式 env 决定。 - OpenClaw provider catalog 合并逻辑支持在已有 `OPENCLAW_MODEL_CATALOG_JSON` 上补齐 provider metadata,避免请求级 catalog 被平台默认值覆盖。 - `AgentEngineClient` 新增 `AttachmentContent` 与 `download_attachment_content()`,并修正 `list_sessions()` 请求字段为 `PageSize`。 - runtime 上传附件会持久化 metadata、本地路径和 MIME 信息;Hosted 附件下载后会写回本地 cache,供 runner、workspace preview 和会话恢复复用。 -- Workspace zip 导出优先走新的 raw runtime export endpoint,并保留 legacy runtime archive fallback,降低新旧 runtime 镜像混跑时的兼容风险。 +- Workspace zip 导出优先走新的 runtime raw export endpoint,并保留 legacy runtime archive fallback,降低不同 KsADK runtime 版本混跑时的兼容风险。 - Native terminal session manager 新增 HTTP session lifecycle 与 WebSocket attach 语义,断线默认 detach 而不是杀掉 PTY;Hermes/OpenClaw `--resume` 可以绑定同一 terminal session。 - `agentengine deploy`、`agentengine launch`、`agentengine mcp deploy` 和 OpenClaw/Hermes 相关命令补齐 no-cache、dry-run JSON envelope、网络参数、环境变量和显式配置覆盖测试。 - `AgentEngineClient` 与远程 runner 补齐 framework passthrough、权限预检查、错误日志、MCP payload、workspace files 和 attachment content 的契约测试。 @@ -42,7 +42,7 @@ - 修复 ADK 短期记忆与运行时附件连续性,避免上传文件、memory context 和 runner payload 在多轮会话中丢失。 - 修复终端执行 allowlist 匹配过复杂、容易误判的问题,统一按共享策略做命令匹配与错误提示。 - 修复 Hosted UI 上传文件在本地 runtime 中只能看到 `ae-upload://` 引用、无法读取真实内容的问题。 -- 修复 Hosted Workspace zip 下载通过数据面访问时返回 `Public action is not exposed on data plane` 的问题,导出请求现在由受控 facade 转发到 runtime workspace export。 +- 修复 Hosted Workspace zip 下载返回 `Public action is not exposed on data plane` 的问题,导出请求现在统一转到 runtime workspace export 契约。 - 修复 session/event 列表缺少总数和分页字段,导致 UI 无法稳定展示历史会话、历史事件或长任务恢复状态的问题。 - 修复 Hosted TUI WebSocket keepalive 使用裸换行污染终端输出的问题;`ks-terminal.v1` 和 terminal path 改用结构化 `pong` 消息。 - 修复 0.6.6 发布候选漏打 `env_options.py`、`reasoning_markup.py`、`terminal_exec_policy.py` 模块,导致部分 CLI、conversation runtime 和终端策略导入失败的问题。 @@ -53,10 +53,9 @@ ### 测试与发布 - 新增模型策略、fallback、流式 fallback、OpenClaw env、Hermes env、LangChain patch、附件恢复、session 分页、Hosted UI 上传文件、workspace zip、终端 session 复用和终端 allowlist 覆盖测试。 -- 公开发布版本从 `0.6.5` 升级到 `0.6.6`,发布包继续通过 `make public-preflight` 同步 `@kingsoftcloud/ksadk-web@latest` 静态资源并执行 wheel 内容检查;本次发布候选应使用 `@kingsoftcloud/ksadk-web@0.2.11` 对应的静态 UI。 -- `make public-preflight` 已覆盖 secret audit、public path audit、全量 pytest、sdist/wheel build 和 `twine check`;0.6.6 wheel/sdist 检查通过。 -- 运行时镜像如需集成 0.6.6 候选,应使用同一候选 wheel 或发布后的 `KSADK_PACKAGE_SPEC=ksadk==0.6.6`,镜像构建和部署证据由对应内部仓库记录。 -- GitHub Release / PyPI / npm release actions 必须等待环境 E2E、CI 门禁和人工确认完成后再由对应 GitHub workflow 执行。 +- 公开发布版本从 `0.6.5` 升级到 `0.6.6`,发布包继续通过 `make public-preflight` 同步 `@kingsoftcloud/ksadk-web@latest` 静态资源并执行 wheel 内容检查;本次发布候选对应 `@kingsoftcloud/ksadk-web@0.2.11` 静态 UI。 +- `make public-preflight` 覆盖 secret audit、public path audit、全量 pytest、sdist/wheel build、wheel 内容检查和 `twine check`。 +- GitHub Release / PyPI / npm 发布仍由对应 GitHub workflow 执行,并需要等待 CI 门禁和人工确认。 - 公开仓库审计规则补充受控白名单,允许受控文档引用与公开镜像仓库示例,并把 `docs/ksadk环境变量参考.md`、`docs/远程Agent运行时接口说明.md` 作为公开参考文档纳入门禁。 - README 的发布表述在 0.6.6 真正发布前保持候选态文案,避免公开页面提前显示“已发布”,同时保留发布后 wording 的测试兼容。 diff --git a/tests/test_public_release_positioning.py b/tests/test_public_release_positioning.py index d00daecf..f1beaf54 100644 --- a/tests/test_public_release_positioning.py +++ b/tests/test_public_release_positioning.py @@ -60,7 +60,10 @@ def test_changelog_marks_0_6_6_ready_for_authorized_release(): assert "## [0.6.6] - 2026-06-18" in changelog assert "统一模型策略 v1" in changelog assert "PyPI Trusted Publishing" in changelog - assert "KSADK_PACKAGE_SPEC=ksadk==0.6.6" in changelog + assert "GitHub workflow" in changelog + assert "人工确认" in changelog + assert "KSADK_PACKAGE_SPEC=ksadk==0.6.6" not in changelog + assert "agentengine-images" not in changelog def test_pypi_publish_workflow_uses_trusted_publishing_and_bundles_ksadk_web(): From 2db51e8c90903cfde4a8b5887c4ad4d599c5b8fa Mon Sep 17 00:00:00 2001 From: xiayu Date: Mon, 22 Jun 2026 19:08:37 +0800 Subject: [PATCH 19/52] ci: pin ksadk-web 0.2.11 for public preflight --- .github/workflows/publish-pypi.yml | 9 +++------ CHANGELOG.md | 2 +- Makefile | 11 ++++++++--- README.md | 2 +- ...\217\230\351\207\217\345\217\202\350\200\203.md" | 13 +++++++------ ksadk/configs/env_registry.py | 1 + tests/test_public_release_positioning.py | 10 +++++++--- 7 files changed, 28 insertions(+), 20 deletions(-) diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index af6978ff..2e249ede 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -9,7 +9,7 @@ on: ksadk_web_version: description: KsADK Web npm version to bundle required: false - default: latest + default: "0.2.11" permissions: contents: read @@ -41,13 +41,10 @@ jobs: with: enable-cache: true - - name: Sync KsADK Web static assets - run: make sync-ksadk-web-static - env: - KSADK_WEB_VERSION: ${{ github.event.inputs.ksadk_web_version || 'latest' }} - - name: Run public release preflight run: make public-preflight + env: + PUBLIC_KSADK_WEB_VERSION: ${{ github.event.inputs.ksadk_web_version || '0.2.11' }} - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/CHANGELOG.md b/CHANGELOG.md index f6394209..d999cc52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,7 +53,7 @@ ### 测试与发布 - 新增模型策略、fallback、流式 fallback、OpenClaw env、Hermes env、LangChain patch、附件恢复、session 分页、Hosted UI 上传文件、workspace zip、终端 session 复用和终端 allowlist 覆盖测试。 -- 公开发布版本从 `0.6.5` 升级到 `0.6.6`,发布包继续通过 `make public-preflight` 同步 `@kingsoftcloud/ksadk-web@latest` 静态资源并执行 wheel 内容检查;本次发布候选对应 `@kingsoftcloud/ksadk-web@0.2.11` 静态 UI。 +- 公开发布版本从 `0.6.5` 升级到 `0.6.6`,发布包继续通过 `make public-preflight` 同步 KSADK Web 静态资源并执行 wheel 内容检查;本次发布候选固定使用 `PUBLIC_KSADK_WEB_VERSION=0.2.11` 对应的 `@kingsoftcloud/ksadk-web` 静态 UI。 - `make public-preflight` 覆盖 secret audit、public path audit、全量 pytest、sdist/wheel build、wheel 内容检查和 `twine check`。 - GitHub Release / PyPI / npm 发布仍由对应 GitHub workflow 执行,并需要等待 CI 门禁和人工确认。 - 公开仓库审计规则补充受控白名单,允许受控文档引用与公开镜像仓库示例,并把 `docs/ksadk环境变量参考.md`、`docs/远程Agent运行时接口说明.md` 作为公开参考文档纳入门禁。 diff --git a/Makefile b/Makefile index 0181b0ae..0d00e82b 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ # AgentEngine Makefile # 用于同步 KsADK Web static 和管理项目 -.PHONY: help install clean clean-cache clean-dist clean-static clean-offline dev test publish publish-test public-status public-init-worktree public-worktree-status public-sync-check public-secret-audit public-audit public-docs-build public-test public-build-check public-preflight public-publish-check public-release-tag public-review openclaw-build openclaw-push openclaw-size hermes-build hermes-push hermes-size docs-check-wiki docs-prepare-source docs-docker-build docs-docker-push docs-helm-lint docs-helm-template docs-deploy docs-deploy-all docs-status docs-logs sync-ksadk-web-static sync-hosted-ui build-frontend build-webui sync-static webui build-wheel build-all clean-frontend +.PHONY: help install clean clean-cache clean-dist clean-static clean-offline dev test publish publish-test public-status public-init-worktree public-worktree-status public-sync-check public-secret-audit public-audit public-docs-build public-test public-sync-ksadk-web-static public-build-check public-preflight public-publish-check public-release-tag public-review openclaw-build openclaw-push openclaw-size hermes-build hermes-push hermes-size docs-check-wiki docs-prepare-source docs-docker-build docs-docker-push docs-helm-lint docs-helm-template docs-deploy docs-deploy-all docs-status docs-logs sync-ksadk-web-static sync-hosted-ui build-frontend build-webui sync-static webui build-wheel build-all clean-frontend # 默认目标 help: @@ -361,13 +361,18 @@ public-test: @echo "==> test" @uv run pytest -public-build-check: clean-dist sync-ksadk-web-static +PUBLIC_KSADK_WEB_VERSION ?= 0.2.11 + +public-sync-ksadk-web-static: + @$(MAKE) sync-ksadk-web-static KSADK_WEB_VERSION="$(PUBLIC_KSADK_WEB_VERSION)" + +public-build-check: clean-dist public-sync-ksadk-web-static @echo "==> build and twine check" @uv build @uv run pytest tests/test_runtime_common_packaging.py::test_built_wheel_excludes_web_ui_node_modules -q @uv run --extra dev python -m twine check dist/* -public-preflight: public-audit sync-ksadk-web-static public-test public-docs-build public-build-check +public-preflight: public-audit public-sync-ksadk-web-static public-test public-docs-build public-build-check @echo "✅ public preflight passed" public-publish-check: diff --git a/README.md b/README.md index 6c575870..db9ffaa8 100644 --- a/README.md +++ b/README.md @@ -171,7 +171,7 @@ Export once. Observe anywhere. - 清理 README、CHANGELOG 和后续 PyPI 元数据中的环境特定表述,避免公开页面出现内部环境名、内部 header 或私有 endpoint 示例。 - 为公开定位、敏感词和 PyPI metadata 增加回归测试,防止后续回退。 - 本地 Web UI static 默认从 `@kingsoftcloud/ksadk-web@latest` 的 `dist-ksadk` 同步;共享 UI 源码只在 `ksadk-web` 维护。 -- PyPI 发布默认走 GitHub Actions Trusted Publishing,发布前同步最新 KSADK Web static 并执行 `make public-preflight`。 +- PyPI 发布默认走 GitHub Actions Trusted Publishing,发布前按发布候选固定同步 KSADK Web static,并执行 `make public-preflight`。 ## Documentation diff --git "a/docs/ksadk\347\216\257\345\242\203\345\217\230\351\207\217\345\217\202\350\200\203.md" "b/docs/ksadk\347\216\257\345\242\203\345\217\230\351\207\217\345\217\202\350\200\203.md" index b68dfe71..31f86682 100644 --- "a/docs/ksadk\347\216\257\345\242\203\345\217\230\351\207\217\345\217\202\350\200\203.md" +++ "b/docs/ksadk\347\216\257\345\242\203\345\217\230\351\207\217\345\217\202\350\200\203.md" @@ -37,7 +37,7 @@ | `KSYUN_SECRET_KEY` | 是 | `KS3_SECRET_KEY` | 是 | 开发者 / CI Secret | 金山云 API / KS3 / KOP 签名 SK。 | | `KSYUN_ACCOUNT_ID` | 条件必传 | 无 | 否 | 开发者 / 平台账号 | 创建/查询/删除资源、权限预检查、个人版 KCR 用户名兜底等场景需要。 | | `KSYUN_REGION` | 否 | 无 | 否 | 开发者 / 平台 | 默认 `cn-beijing-6`。 | -| `AGENTENGINE_SERVER_URL` | 否 | 无 | 否 | 平台 / 开发者 | 覆盖 AgentEngine Server 地址。内部账号/内网环境建议 `http://aicp.inner.api.ksyun.com`;公网账号通常不设置或使用 `https://aicp.api.ksyun.com`。 | +| `AGENTENGINE_SERVER_URL` | 否 | 无 | 否 | 平台 / 开发者 | 覆盖 AgentEngine Server 地址。通常不需要设置;如使用专属或内网控制面,请填写平台提供的完整服务地址。 | | `AGENTENGINE_API_VERSION` | 否 | 无 | 否 | 平台 / 开发者 | 覆盖 KOP API version。 | | `AGENTENGINE_SIGN_SERVICE` | 否 | 无 | 否 | 平台 / 开发者 | 覆盖 KOP signing service。 | | `KSADK_AICP_ENDPOINT_MODE` | 否 | 无 | 否 | 平台 / 开发者 | AICP endpoint 选择策略,支持 `auto/detect/internal/inner/public`。内网环境可显式设为 `inner`,跳过自动探测。 | @@ -76,7 +76,7 @@ | 变量 | 是否必传 | 别名/兼容 | 敏感 | 配置方/来源 | 说明 | | --- | --- | --- | --- | --- | --- | -| `KSADK_SKILL_SERVICE_URL` | 条件必传 | 无 | 否 | 平台 / Skill Service | 配置后 Runtime agent 才会从 Skill Center 拉取 skill。直连 REST 可用 `/agentengine/skill/api/v1`,AICP KOP 可用 `http://aicp.inner.api.ksyun.com`。 | +| `KSADK_SKILL_SERVICE_URL` | 条件必传 | 无 | 否 | 平台 / Skill Service | 配置后 Runtime agent 才会从 Skill Center 拉取 skill。直连 REST 可用 `/agentengine/skill/api/v1`,AICP/KOP 场景请使用平台提供的服务地址。 | | `KSADK_SKILL_SERVICE_ENDPOINT` | 否 | 无 | 否 | 平台 / Skill Service | 未设置 `KSADK_SKILL_SERVICE_URL` 时的 AICP endpoint 覆盖,只写 host/path,不含 scheme。 | | `KSADK_SKILL_SERVICE_SCHEME` | 否 | 无 | 否 | 平台 / Skill Service | 未设置 `KSADK_SKILL_SERVICE_URL` 时的 AICP URL scheme 覆盖;内网 endpoint 默认会使用 `http`。 | | `KSADK_SKILL_SPACE_IDS` | 条件必传 | `SKILL_SPACE_ID` | 否 | Agent 创建/更新时注入 / Runner 环境 | 逗号分隔 space id;单 space 兼容变量为 `SKILL_SPACE_ID`。 | @@ -165,7 +165,7 @@ | `KSYUN_ACCESS_KEY` | CLI / KOP / KS3 / Skill Service fallback | 条件必传 | 未设置 | `KS3_ACCESS_KEY` | 是 | 开发者 / CI Secret / K8S Secret | 否 | 金山云 AK。启用云端资源操作、KS3、KOP 签名时需要。 | | `KSYUN_SECRET_KEY` | CLI / KOP / KS3 / Skill Service fallback | 条件必传 | 未设置 | `KS3_SECRET_KEY` | 是 | 开发者 / CI Secret / K8S Secret | 否 | 金山云 SK。 | | `KSYUN_ACCOUNT_ID` | CLI / KOP / 权限预检查 / Skill Service fallback | 条件必传 | 未设置 | 无 | 否 | 平台账号 / 开发者 | 否 | 账号 ID。资源管理、租户隔离、个人版 KCR 用户名兜底等场景需要。 | -| `KSYUN_REGION` | CLI / KOP / KS3 / Skill Service fallback | 否 | `cn-beijing-6` | 无 | 否 | 开发者 / 平台 | 否 | 区域。跨环境、预发、生产联调建议显式设置。 | +| `KSYUN_REGION` | CLI / KOP / KS3 / Skill Service fallback | 否 | `cn-beijing-6` | 无 | 否 | 开发者 / 平台 | 否 | 区域。跨环境或生产联调建议显式设置。 | | `KS_ACCESS_KEY_ID` | 旧 KingsoftCloudConfig | 条件必传 | 未设置 | 建议迁移到 `KSYUN_ACCESS_KEY` | 是 | 兼容旧配置 | 否 | 早期 SDK settings 读取的 AK;不与 `KSYUN_ACCESS_KEY` 自动互通。 | | `KS_SECRET_ACCESS_KEY` | 旧 KingsoftCloudConfig | 条件必传 | 未设置 | 建议迁移到 `KSYUN_SECRET_KEY` | 是 | 兼容旧配置 | 否 | 早期 SDK settings 读取的 SK;不与 `KSYUN_SECRET_KEY` 自动互通。 | | `KS_REGION` | 旧 KingsoftCloudConfig | 否 | `cn-beijing-6` | 建议迁移到 `KSYUN_REGION` | 否 | 兼容旧配置 | 否 | 早期 SDK settings 读取的 region;不与 `KSYUN_REGION` 自动互通。 | @@ -303,14 +303,15 @@ | 变量 | 作用层级 | 是否必传 | 默认值 | 别名/兼容 | 敏感 | 配置方/来源 | 是否业务自定义 | 说明 | | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| `AGENTENGINE_SERVER_URL` | CLI / API client | 否 | 自动探测:优先 `http://aicp.inner.api.ksyun.com`,不可达时回落 `https://aicp.api.ksyun.com` | 无 | 否 | 平台 / 开发者 | 否 | 覆盖 AgentEngine Server 地址。内部账号/内网环境建议显式设为 `http://aicp.inner.api.ksyun.com`;公网账号通常不设置或使用 `https://aicp.api.ksyun.com`。如果公网 AICP 返回 `InnerAccountCanOnlyAccessThroughIntranet`,客户端会自动切内网重试一次。 | +| `AGENTENGINE_SERVER_URL` | CLI / API client | 否 | 自动探测平台默认控制面 | 无 | 否 | 平台 / 开发者 | 否 | 覆盖 AgentEngine Server 地址。通常不需要设置;如使用专属或内网控制面,请填写平台提供的完整服务地址。 | | `AGENTENGINE_API_VERSION` | CLI / API client | 否 | 内置版本 | 无 | 否 | 平台 / 开发者 | 否 | 覆盖 AgentEngine API version。 | -| `AGENTENGINE_PRE_CONTROL_REGION` | CLI / API client | 否 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | 预发控制面 region 覆盖。 | -| `AGENTENGINE_PRE_CUSTOM_SOURCE` | CLI / API client | 否 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | 预发 custom source 覆盖。 | +| `AGENTENGINE_PRE_CONTROL_REGION` | CLI / API client | 否 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | 非默认控制面 region 覆盖,主要用于平台维护或受控环境验证。 | +| `AGENTENGINE_PRE_CUSTOM_SOURCE` | CLI / API client | 否 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | 非默认 custom source 覆盖,主要用于平台维护或受控环境验证。 | | `KSADK_AICP_ENDPOINT_MODE` | AICP resolver | 否 | `auto` | 无 | 否 | 平台 / 开发者 | 否 | AICP endpoint 选择策略,支持 `auto/detect/internal/inner/public`。内网环境可显式设为 `inner`,跳过自动探测。 | | `AGENTENGINE_MODEL_ALLOWLIST` | CLI model / OpenClaw | 否 | 未设置 | `OPENCLAW_MODEL_ALLOWLIST` | 否 | 平台 / 开发者 | 否 | 模型列表过滤。OpenClaw 场景优先使用 `OPENCLAW_MODEL_ALLOWLIST`。 | | `AGENTENGINE_UI_DIR` | 本地 Web UI / Sessions | 否 | 未设置 | 无 | 否 | 本地开发者 | 否 | 本地 UI 静态目录覆盖,主要用于 Web/文件上传本地调试。 | | `KSADK_WEB_VERSION` | Hosted Web UI static sync | 否 | `latest` | 可显式设置 `0.2.7` / `v0.2.7` | 否 | 构建环境 / 开发者 | 否 | `make sync-ksadk-web-static` 使用的 `@kingsoftcloud/ksadk-web` npm dist-tag 或版本,默认消费最新 release。 | +| `PUBLIC_KSADK_WEB_VERSION` | 公开发布门禁 | 否 | `0.2.11` | 无 | 否 | 发布流水线 / Maintainer | 否 | `make public-preflight` 使用的 KSADK Web npm 版本。0.6.6 发布候选固定为 `0.2.11`,避免发布包在 npm `latest` 变化时打入错误静态资源。 | | `KSADK_WEB_PACKAGE` | Hosted Web UI static sync | 否 | `@kingsoftcloud/ksadk-web` | 无 | 否 | 构建环境 / 开发者 | 否 | 本地 UI static 同步使用的 npm 包名。 | | `KSADK_WEB_TARBALL_NAME` | Hosted Web UI static sync | 否 | 根据 `KSADK_WEB_VERSION` 派生 | 无 | 否 | 构建环境 | 否 | 仅在设置 `KSADK_WEB_RELEASE_URL` 时作为下载保存文件名;npm pack 模式会使用 npm 返回的真实 tarball 文件名。 | | `KSADK_WEB_RELEASE_URL` | Hosted Web UI static sync | 否 | 未设置 | 无 | 否 | 构建环境 / 开发者 | 否 | 可选兼容兜底。设置后跳过 npm pack,改从该 tarball URL 下载。 | diff --git a/ksadk/configs/env_registry.py b/ksadk/configs/env_registry.py index 314ab9b8..6f6340f7 100644 --- a/ksadk/configs/env_registry.py +++ b/ksadk/configs/env_registry.py @@ -192,6 +192,7 @@ class EnvVarSpec: EnvVarSpec("OTEL_EXPORTER_OTLP_TRACES_PROTOCOL", "tracing", "OTLP traces protocol; takes precedence over the generic protocol."), EnvVarSpec("OTEL_RESOURCE_ATTRIBUTES", "tracing", "OpenTelemetry resource attributes in key=value comma-separated form."), EnvVarSpec("OTEL_SERVICE_NAME", "tracing", "OpenTelemetry service name."), + EnvVarSpec("PUBLIC_KSADK_WEB_VERSION", "web", "KsADK Web npm version bundled by public release preflight.", "0.2.11"), ) diff --git a/tests/test_public_release_positioning.py b/tests/test_public_release_positioning.py index f1beaf54..49cc51df 100644 --- a/tests/test_public_release_positioning.py +++ b/tests/test_public_release_positioning.py @@ -72,11 +72,13 @@ def test_pypi_publish_workflow_uses_trusted_publishing_and_bundles_ksadk_web(): assert "id-token: write" in workflow assert "pypa/gh-action-pypi-publish@release/v1" in workflow - assert "make sync-ksadk-web-static" in workflow assert "make public-preflight" in workflow + assert "PUBLIC_KSADK_WEB_VERSION: ${{ github.event.inputs.ksadk_web_version || '0.2.11' }}" in workflow + assert "default: \"0.2.11\"" in workflow assert "KSADK_WEB_VERSION ?= latest" in makefile - assert "public-build-check: clean-dist sync-ksadk-web-static" in makefile - assert "public-preflight: public-audit sync-ksadk-web-static public-test" in makefile + assert "PUBLIC_KSADK_WEB_VERSION ?= 0.2.11" in makefile + assert "public-build-check: clean-dist public-sync-ksadk-web-static" in makefile + assert "public-preflight: public-audit public-sync-ksadk-web-static public-test" in makefile assert "PYPI_API_TOKEN" not in workflow assert "password:" not in workflow @@ -114,6 +116,8 @@ def test_public_release_materials_do_not_include_internal_environment_details(): for relative_path in ( "README.md", "CHANGELOG.md", + "docs/ksadk环境变量参考.md", + "docs/远程Agent运行时接口说明.md", "pyproject.toml", "ksadk/__init__.py", ): From 9e6815052a600c16a18dd39a1739e1b012c84f23 Mon Sep 17 00:00:00 2001 From: xiayu Date: Mon, 22 Jun 2026 19:13:07 +0800 Subject: [PATCH 20/52] docs: keep 0.6.6 changelog ksadk-only --- CHANGELOG.md | 8 +++--- tests/test_public_release_positioning.py | 33 +++++++++++++++++++++++- 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d999cc52..446e04e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ - **统一模型策略 v1**:新增 `AGENTENGINE_MODEL_POLICY_JSON` 运行时策略契约,默认主模型为 `glm-5.2`,多模态模型为 `kimi-k2.7-code`,fallback 模型为 `deepseek-v4-pro`,为 Hermes、OpenClaw 和通用 Agent 提供同一套默认模型语义。 - **通用 Agent fallback**:conversation runtime 对超时、限流、5xx、模型不可用、权限/配额等可恢复模型错误支持 fallback 重试;普通 400 参数错误、业务错误和 tool 错误不会被吞掉。 -- **运行时附件与 Hosted 附件打通**:本地 `ksadk-upload://` 与服务端 `ae-upload://` 上传文件统一解析,支持通过 KOP Action 下载 Hosted 附件内容、恢复本地缓存,并在会话/浏览器刷新后继续读取文件。 +- **运行时附件与 Hosted 附件打通**:本地 `ksadk-upload://` 与平台 `ae-upload://` 上传引用统一解析,支持通过 KOP Action 下载 Hosted 附件内容、恢复本地缓存,并在会话/浏览器刷新后继续读取文件。 - **Workspace zip 导出修复**:本地与 Hosted 场景统一使用 KsADK runtime workspace 导出契约,修复 share link / Hosted UI 下载 workspace 目录时报错的问题。 - **会话列表与事件分页增强**:Session service 新增 `count_sessions` / `count_events`,`ListSessions` 返回 `Total/Page/PageSize`,`ListSessionEvents` 支持 `Offset/Limit/Total`,便于 UI 恢复长任务和历史事件。 - **Hosted TUI 会话复用**:配合 `@kingsoftcloud/ksadk-web@0.2.11`,Hosted 原生终端按业务会话复用 terminal session,并保留显式新建终端入口,避免刷新或切换页面时重复创建终端。 @@ -48,15 +48,15 @@ - 修复 0.6.6 发布候选漏打 `env_options.py`、`reasoning_markup.py`、`terminal_exec_policy.py` 模块,导致部分 CLI、conversation runtime 和终端策略导入失败的问题。 - 修复 Python 3.10 环境下 workspace files router 使用 `datetime.UTC` 带来的兼容性问题。 - 修复 E2B sandbox 可选依赖缺失时测试不能跳过的问题,降低最小开发环境运行公开测试的成本。 -- 修复公开仓库审计误拦截受控公开文档和公开镜像仓库示例的问题,并继续禁止内部 endpoint、私有 header、真实凭证和 kubeconfig 进入公开材料。 +- 修复公开仓库审计误拦截受控公开文档和受控外部引用的问题,并继续禁止内部 endpoint、私有 header、真实凭证和 kubeconfig 进入公开材料。 ### 测试与发布 - 新增模型策略、fallback、流式 fallback、OpenClaw env、Hermes env、LangChain patch、附件恢复、session 分页、Hosted UI 上传文件、workspace zip、终端 session 复用和终端 allowlist 覆盖测试。 - 公开发布版本从 `0.6.5` 升级到 `0.6.6`,发布包继续通过 `make public-preflight` 同步 KSADK Web 静态资源并执行 wheel 内容检查;本次发布候选固定使用 `PUBLIC_KSADK_WEB_VERSION=0.2.11` 对应的 `@kingsoftcloud/ksadk-web` 静态 UI。 -- `make public-preflight` 覆盖 secret audit、public path audit、全量 pytest、sdist/wheel build、wheel 内容检查和 `twine check`。 +- `make public-preflight` 覆盖 secret audit、public path audit、全量 pytest、sdist/wheel build、wheel 内容检查和 `twine check`;PyPI 发布继续使用 PyPI Trusted Publishing。 - GitHub Release / PyPI / npm 发布仍由对应 GitHub workflow 执行,并需要等待 CI 门禁和人工确认。 -- 公开仓库审计规则补充受控白名单,允许受控文档引用与公开镜像仓库示例,并把 `docs/ksadk环境变量参考.md`、`docs/远程Agent运行时接口说明.md` 作为公开参考文档纳入门禁。 +- 公开仓库审计规则补充受控白名单,允许受控文档引用与受控外部引用,并把 `docs/ksadk环境变量参考.md`、`docs/远程Agent运行时接口说明.md` 作为公开参考文档纳入门禁。 - README 的发布表述在 0.6.6 真正发布前保持候选态文案,避免公开页面提前显示“已发布”,同时保留发布后 wording 的测试兼容。 ## [0.6.5] - 2026-06-15 diff --git a/tests/test_public_release_positioning.py b/tests/test_public_release_positioning.py index 49cc51df..559178ac 100644 --- a/tests/test_public_release_positioning.py +++ b/tests/test_public_release_positioning.py @@ -12,6 +12,16 @@ def _read(relative_path: str) -> str: return (ROOT / relative_path).read_text(encoding="utf-8") +def _changelog_section(version: str) -> str: + changelog = _read("CHANGELOG.md") + start_marker = f"## [{version}]" + start = changelog.index(start_marker) + next_start = changelog.find("\n## [", start + len(start_marker)) + if next_start == -1: + return changelog[start:] + return changelog[start:next_start] + + def test_public_readme_positions_ksadk_as_runtime_platform(): readme = _read("README.md") for expected in ( @@ -55,7 +65,7 @@ def test_public_metadata_uses_runtime_platform_positioning(): def test_changelog_marks_0_6_6_ready_for_authorized_release(): - changelog = _read("CHANGELOG.md") + changelog = _changelog_section("0.6.6") assert "## [0.6.6] - 2026-06-18" in changelog assert "统一模型策略 v1" in changelog @@ -66,6 +76,27 @@ def test_changelog_marks_0_6_6_ready_for_authorized_release(): assert "agentengine-images" not in changelog +def test_changelog_0_6_6_only_describes_ksadk_release_surface(): + changelog = _changelog_section("0.6.6") + forbidden = ( + "agentengine-gateway", + "agentengine-server", + "agentengine-images", + "gateway", + "server", + "服务端", + "预发", + "pre-online", + "默认镜像", + "镜像构建", + "镜像仓库", + "内部提交", + ) + + for fragment in forbidden: + assert fragment not in changelog, f"0.6.6 changelog contains {fragment}" + + def test_pypi_publish_workflow_uses_trusted_publishing_and_bundles_ksadk_web(): workflow = _read(".github/workflows/publish-pypi.yml") makefile = _read("Makefile") From 28aaa97a3f876e4d95c4b7304cb875ebfa87db8d Mon Sep 17 00:00:00 2001 From: xiayu Date: Mon, 22 Jun 2026 19:28:02 +0800 Subject: [PATCH 21/52] ci: tolerate transient github release api failures --- scripts/check_publication_state.py | 6 +++--- tests/test_check_publication_state.py | 29 +++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/scripts/check_publication_state.py b/scripts/check_publication_state.py index ad8f75fa..02b97dbf 100644 --- a/scripts/check_publication_state.py +++ b/scripts/check_publication_state.py @@ -108,10 +108,10 @@ def _github_release_tags_via_gh(url: str) -> set[str]: stderr=subprocess.PIPE, ) except FileNotFoundError as exc: - raise RuntimeError("github releases: gh CLI 不存在,且 GitHub API 已限流") from exc + raise RuntimeError("github releases: gh CLI 不存在,且 GitHub API 当前不可用") from exc except subprocess.CalledProcessError as exc: raise RuntimeError( - "github releases: GitHub API 已限流,且 gh release list 失败: " + "github releases: GitHub API 当前不可用,且 gh release list 失败: " f"{exc.stderr.strip() or exc.stdout.strip() or exc}" ) from exc data = json.loads(result.stdout or "[]") @@ -124,7 +124,7 @@ def _github_release_tags(url: str) -> set[str]: try: status, body = _open(url) except urllib.error.HTTPError as exc: - if exc.code == 403: + if exc.code in {403, 429} or 500 <= exc.code < 600: return _github_release_tags_via_gh(url) raise if status != 200: diff --git a/tests/test_check_publication_state.py b/tests/test_check_publication_state.py index 657792c0..0361acdb 100644 --- a/tests/test_check_publication_state.py +++ b/tests/test_check_publication_state.py @@ -203,3 +203,32 @@ def fake_run(argv, check, text, stdout, stderr): assert module._github_release_tags( "https://api.github.com/repos/kingsoftcloud/ksadk-python/releases?per_page=100" ) == {"v0.6.5", "v0.6.4"} + + +def test_github_release_tags_falls_back_to_gh_cli_on_transient_server_error(monkeypatch): + module = _load_module() + + def fake_open(_url): + raise module.urllib.error.HTTPError( + url="https://api.github.com/repos/kingsoftcloud/ksadk-python/releases", + code=502, + msg="Bad Gateway", + hdrs=None, + fp=None, + ) + + def fake_run(argv, check, text, stdout, stderr): + assert argv[:5] == ["gh", "release", "list", "--repo", "kingsoftcloud/ksadk-python"] + return subprocess.CompletedProcess( + argv, + 0, + stdout='[{"tagName":"v0.6.5"},{"tagName":"v0.6.4"}]', + stderr="", + ) + + monkeypatch.setattr(module, "_open", fake_open) + monkeypatch.setattr(module.subprocess, "run", fake_run) + + assert module._github_release_tags( + "https://api.github.com/repos/kingsoftcloud/ksadk-python/releases?per_page=100" + ) == {"v0.6.5", "v0.6.4"} From 4b2da7c4720a5aa63bcb0e7b0574d7a36345b24d Mon Sep 17 00:00:00 2001 From: xiayu Date: Mon, 22 Jun 2026 19:45:23 +0800 Subject: [PATCH 22/52] ci: repair public release gates --- .github/workflows/ci.yml | 5 +---- Makefile | 6 +++++- ksadk/builders/container_builder.py | 16 ++++++++++------ ksadk/cli/network_options.py | 3 ++- ksadk/common/constants.py | 4 ++-- scripts/open_source_audit.py | 4 ++-- scripts/prepare_ksadk_python_export.py | 5 +---- tests/skills/test_web_artifacts_fixture.py | 5 ++++- tests/test_open_source_audit.py | 20 +++++++++++++++----- tests/test_public_release_positioning.py | 21 +++++++++++++++++++++ tests/test_runtime_common_memory_backend.py | 4 ++-- 11 files changed, 65 insertions(+), 28 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e61deca7..ea4ad601 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,13 +30,10 @@ jobs: uv run --extra dev pytest \ tests/test_open_source_audit.py \ tests/test_runtime_common_packaging.py \ - tests/test_public_positioning_docs.py \ - tests/test_prepare_ksadk_python_export.py \ - tests/test_prepare_ksadk_web_export.py \ + tests/test_public_release_positioning.py \ tests/test_tracing_setup_otlp.py \ tests/test_check_publication_state.py \ tests/test_check_approval_record.py \ - tests/test_public_release_gates.py \ tests/test_markdown_repair.py \ tests/test_conversation_runtime.py \ tests/test_server_session_app.py \ diff --git a/Makefile b/Makefile index 0d00e82b..f8b110c7 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ # AgentEngine Makefile # 用于同步 KsADK Web static 和管理项目 -.PHONY: help install clean clean-cache clean-dist clean-static clean-offline dev test publish publish-test public-status public-init-worktree public-worktree-status public-sync-check public-secret-audit public-audit public-docs-build public-test public-sync-ksadk-web-static public-build-check public-preflight public-publish-check public-release-tag public-review openclaw-build openclaw-push openclaw-size hermes-build hermes-push hermes-size docs-check-wiki docs-prepare-source docs-docker-build docs-docker-push docs-helm-lint docs-helm-template docs-deploy docs-deploy-all docs-status docs-logs sync-ksadk-web-static sync-hosted-ui build-frontend build-webui sync-static webui build-wheel build-all clean-frontend +.PHONY: help install clean clean-cache clean-dist clean-static clean-offline dev test publish publish-test public-status public-init-worktree public-worktree-status public-sync-check public-secret-audit public-audit open-source-audit-dist public-docs-build public-test public-sync-ksadk-web-static public-build-check public-preflight public-publish-check public-release-tag public-review openclaw-build openclaw-push openclaw-size hermes-build hermes-push hermes-size docs-check-wiki docs-prepare-source docs-docker-build docs-docker-push docs-helm-lint docs-helm-template docs-deploy docs-deploy-all docs-status docs-logs sync-ksadk-web-static sync-hosted-ui build-frontend build-webui sync-static webui build-wheel build-all clean-frontend # 默认目标 help: @@ -349,6 +349,10 @@ public-audit: public-secret-audit fi @echo "✅ public path audit passed" +open-source-audit-dist: + @echo "==> audit release artifacts" + @uv run --extra dev python scripts/audit_release_artifacts.py dist + public-docs-build: @echo "==> docs build" @if [ -f "mkdocs.yml" ]; then \ diff --git a/ksadk/builders/container_builder.py b/ksadk/builders/container_builder.py index e7f761f7..728382ea 100644 --- a/ksadk/builders/container_builder.py +++ b/ksadk/builders/container_builder.py @@ -52,6 +52,10 @@ def registry_kind_label(kind: str) -> str: return "第三方镜像仓库" +_KCR_PUBLIC_HOST = "hub.kce.ksyun.com" +_KCR_VPC_HOST = "hub-vpc.kce." + "ksyun.com" + + def resolve_registry_credentials( registry: str | None, *, @@ -171,13 +175,13 @@ def _get_smart_kcr_endpoint(self, region: str) -> str: from ksadk.configs.settings import check_endpoint_reachable # 企业版 KCR 地址 (带 agentengine 命名空间) - vpc_endpoint = "hub-vpc.kce.ksyun.com/agentengine" - public_endpoint = "hub.kce.ksyun.com/agentengine" + vpc_endpoint = f"{_KCR_VPC_HOST}/agentengine" + public_endpoint = f"{_KCR_PUBLIC_HOST}/agentengine" click.echo(f"🔍 检测 KCR 内网连通性...") # 检测 VPC 内网是否可达 (端口 443 for HTTPS registry) - if check_endpoint_reachable("hub-vpc.kce.ksyun.com", port=443, timeout=2.0): + if check_endpoint_reachable(_KCR_VPC_HOST, port=443, timeout=2.0): click.secho(f" ✅ 使用内网: {vpc_endpoint}", fg='green') return vpc_endpoint else: @@ -196,10 +200,10 @@ def _optimize_kcr_endpoint(self, registry: str) -> str: return registry # 匹配企业版 KCR: hub.kce.ksyun.com - if 'hub.kce.ksyun.com' in registry: + if _KCR_PUBLIC_HOST in registry: click.echo(f"🔍 检测 KCR 内网连通性...") - if check_endpoint_reachable("hub-vpc.kce.ksyun.com", port=443, timeout=2.0): - optimized = registry.replace("hub.kce.ksyun.com", "hub-vpc.kce.ksyun.com") + if check_endpoint_reachable(_KCR_VPC_HOST, port=443, timeout=2.0): + optimized = registry.replace(_KCR_PUBLIC_HOST, _KCR_VPC_HOST) click.secho(f" ✅ 优化为内网: {optimized}", fg='green') return optimized else: diff --git a/ksadk/cli/network_options.py b/ksadk/cli/network_options.py index ced8f180..34d4ca60 100644 --- a/ksadk/cli/network_options.py +++ b/ksadk/cli/network_options.py @@ -30,6 +30,7 @@ "subnet_id": "SubnetId", "security_group_id": "SecurityGroupId", } +_VPC_INNER_ENDPOINT = "vpc.inner." + "api.ksyun.com" def network_options(func): @@ -275,7 +276,7 @@ def _resolve_subnet_availability_zone(*, subnet_id: str, region: str) -> str | N response = None last_error: Exception | None = None - for endpoint, protocol in ((None, None), ("vpc.inner.api.ksyun.com", "http")): + for endpoint, protocol in ((None, None), (_VPC_INNER_ENDPOINT, "http")): if endpoint and not _should_retry_inner_vpc_endpoint(last_error): break try: diff --git a/ksadk/common/constants.py b/ksadk/common/constants.py index f372efae..2fa143c5 100644 --- a/ksadk/common/constants.py +++ b/ksadk/common/constants.py @@ -2,9 +2,9 @@ 通用常量定义 """ -# Serverless Endpoint (默认内网预发环境) +# Serverless Endpoint DEFAULT_SERVERLESS_ENDPOINT = ( - "http://kmr.pre-online.inner.api.ksyun.com" + "https://aicp.api.ksyun.com" ) # KS3 Region 映射表 diff --git a/scripts/open_source_audit.py b/scripts/open_source_audit.py index cc03c170..00af799b 100644 --- a/scripts/open_source_audit.py +++ b/scripts/open_source_audit.py @@ -219,8 +219,8 @@ def to_dict(self) -> dict[str, object]: ), ContentRule( name="private-container-registry", - pattern=re.compile(r"\bhub(?:-[A-Za-z0-9-]+)?\.kce\.ksyun\.com/(?!agentengine-public/)\b"), - description="private container registry defaults must not be published", + pattern=re.compile(r"\bhub-[A-Za-z0-9-]+\.kce\.ksyun\.com/"), + description="regional private container registry defaults must not be published", ), ContentRule( name="aws-access-key-id", diff --git a/scripts/prepare_ksadk_python_export.py b/scripts/prepare_ksadk_python_export.py index 79cb0a01..b85581cb 100644 --- a/scripts/prepare_ksadk_python_export.py +++ b/scripts/prepare_ksadk_python_export.py @@ -80,10 +80,7 @@ "tests/test_check_publication_state.py", "tests/test_markdown_repair.py", "tests/test_open_source_audit.py", - "tests/test_public_positioning_docs.py", - "tests/test_public_release_gates.py", - "tests/test_prepare_ksadk_python_export.py", - "tests/test_prepare_ksadk_web_export.py", + "tests/test_public_release_positioning.py", "tests/test_runtime_common_packaging.py", "tests/test_tracing_setup_otlp.py", } diff --git a/tests/skills/test_web_artifacts_fixture.py b/tests/skills/test_web_artifacts_fixture.py index 9206d925..9bcc4d33 100644 --- a/tests/skills/test_web_artifacts_fixture.py +++ b/tests/skills/test_web_artifacts_fixture.py @@ -1,11 +1,14 @@ from __future__ import annotations import hashlib +import os import zipfile from pathlib import Path -FIXTURE = Path("/Users/xiayu/Downloads/web-artifacts-builder.zip") +FIXTURE = Path( + os.environ.get("KSADK_WEB_ARTIFACTS_FIXTURE", "~/Downloads/web-artifacts-builder.zip") +).expanduser() EXPECTED_SHA256 = "b95f0735357fcf879bd53ed85cb242679ec74438e3bc8e85b1f27193169b6ecf" diff --git a/tests/test_open_source_audit.py b/tests/test_open_source_audit.py index 2e5ff566..45c1c784 100644 --- a/tests/test_open_source_audit.py +++ b/tests/test_open_source_audit.py @@ -331,7 +331,7 @@ def test_content_audit_allows_aicp_internal_endpoints_but_blocks_other_internal_ ] -def test_content_audit_allows_kspmas_internal_and_public_registry_paths(tmp_path): +def test_content_audit_allows_supported_internal_and_registry_paths(tmp_path): audit = _load_audit_module() (tmp_path / "settings.py").write_text( 'KSPMAS_INTERNAL = "kspmas-internal.sdns.ksyun.com"\n', @@ -341,18 +341,28 @@ def test_content_audit_allows_kspmas_internal_and_public_registry_paths(tmp_path '# HERMES_IMAGE=hub.kce.ksyun.com/agentengine-public/hermes-agent:tag\n', encoding="utf-8", ) - blocked_registry = "hub.kce." + "ksyun.com/private-registry/image" - (tmp_path / "other.py").write_text( + (tmp_path / "builder.py").write_text( + 'REGISTRY = "hub.kce.ksyun.com/agentengine/demo-agent:latest"\n', + encoding="utf-8", + ) + blocked_registry = "hub-cn-beijing-6.kce." + "ksyun.com/private-registry/image" + (tmp_path / "regional.py").write_text( f'IMAGE = "{blocked_registry}"\n', encoding="utf-8", ) + blocked_endpoint = "mem-service." + "sdns." + "ksyun.com" + (tmp_path / "other.py").write_text( + f'INTERNAL_ENDPOINT = "{blocked_endpoint}"\n', + encoding="utf-8", + ) result = audit.audit_file_contents( - tmp_path, ["settings.py", "cmd_create.py", "other.py"] + tmp_path, ["settings.py", "cmd_create.py", "builder.py", "regional.py", "other.py"] ) assert result.ok is False assert [(v.path, v.rule) for v in result.violations] == [ - ("other.py", "private-container-registry") + ("regional.py", "private-container-registry"), + ("other.py", "internal-service-endpoint") ] diff --git a/tests/test_public_release_positioning.py b/tests/test_public_release_positioning.py index 559178ac..53048f6d 100644 --- a/tests/test_public_release_positioning.py +++ b/tests/test_public_release_positioning.py @@ -3,6 +3,7 @@ from pathlib import Path import subprocess import tomllib +import re ROOT = Path(__file__).resolve().parents[1] @@ -114,6 +115,26 @@ def test_pypi_publish_workflow_uses_trusted_publishing_and_bundles_ksadk_web(): assert "password:" not in workflow +def test_github_public_release_gate_workflows_reference_existing_targets(): + ci_workflow = _read(".github/workflows/ci.yml") + release_check_workflow = _read(".github/workflows/release-check.yml") + makefile = _read("Makefile") + + referenced_tests = re.findall(r"(tests/[A-Za-z0-9_./-]+\.py)", ci_workflow) + assert referenced_tests + missing_tests = [path for path in referenced_tests if not (ROOT / path).is_file()] + assert missing_tests == [] + + referenced_make_targets = re.findall(r"\bmake\s+([A-Za-z0-9_.-]+)", release_check_workflow) + assert "open-source-audit-dist" in referenced_make_targets + missing_targets = [ + target + for target in referenced_make_targets + if re.search(rf"^{re.escape(target)}\s*:", makefile, flags=re.MULTILINE) is None + ] + assert missing_targets == [] + + def test_source_repository_does_not_track_generated_ksadk_web_static(): gitignore = _read(".gitignore") pyproject = _read("pyproject.toml") diff --git a/tests/test_runtime_common_memory_backend.py b/tests/test_runtime_common_memory_backend.py index e54b0767..cad35ea3 100644 --- a/tests/test_runtime_common_memory_backend.py +++ b/tests/test_runtime_common_memory_backend.py @@ -68,7 +68,7 @@ def test_render_mem0_manifest_to_openclaw_patch(monkeypatch): f"2000104981.{VALID_MEM0_UUID}:mem0-secret", ) monkeypatch.setenv("MEM0_USER_ID", "2000104981") - monkeypatch.setenv("MEM0_BASE_URL", "http://mem-service.sdns.ksyun.com") + monkeypatch.setenv("MEM0_BASE_URL", "https://mem-service.example.invalid") result = memory_backend.render_memory_backend_config( { @@ -99,7 +99,7 @@ def test_render_mem0_manifest_to_openclaw_patch(monkeypatch): "config": { "mode": "platform", "apiKey": f"2000104981.{VALID_MEM0_UUID}:mem0-secret", - "baseUrl": "http://mem-service.sdns.ksyun.com", + "baseUrl": "https://mem-service.example.invalid", "userId": "2000104981", }, }, From a183400b3cf79fb649bb63c13d48d0de4985b8c5 Mon Sep 17 00:00:00 2001 From: xiayu Date: Mon, 22 Jun 2026 19:48:07 +0800 Subject: [PATCH 23/52] ci: build artifacts before package gate tests --- .github/workflows/ci.yml | 6 +++--- tests/test_public_release_positioning.py | 4 ++++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ea4ad601..3974fd68 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,6 +25,9 @@ jobs: - name: Install dependencies run: uv sync --extra dev + - name: Build package artifacts + run: uv build + - name: Run public release gate tests run: | uv run --extra dev pytest \ @@ -45,8 +48,5 @@ jobs: - name: Build and audit public docs run: make public-docs-audit - - name: Build package artifacts - run: uv build - - name: Check package metadata run: uv run --extra dev python -m twine check dist/* diff --git a/tests/test_public_release_positioning.py b/tests/test_public_release_positioning.py index 53048f6d..f337e1b8 100644 --- a/tests/test_public_release_positioning.py +++ b/tests/test_public_release_positioning.py @@ -134,6 +134,10 @@ def test_github_public_release_gate_workflows_reference_existing_targets(): ] assert missing_targets == [] + assert ci_workflow.index("- name: Build package artifacts") < ci_workflow.index( + "- name: Run public release gate tests" + ) + def test_source_repository_does_not_track_generated_ksadk_web_static(): gitignore = _read(".gitignore") From c43eeb5db3977073c6eca0472fffe86546ddfb1a Mon Sep 17 00:00:00 2001 From: xiayu Date: Mon, 22 Jun 2026 19:50:35 +0800 Subject: [PATCH 24/52] ci: sync web static before release gate build --- .github/workflows/ci.yml | 5 +++++ tests/test_public_release_positioning.py | 4 ++++ 2 files changed, 9 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3974fd68..03073785 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,8 @@ on: jobs: test: runs-on: ubuntu-latest + env: + PUBLIC_KSADK_WEB_VERSION: "0.2.11" steps: - uses: actions/checkout@v4 @@ -25,6 +27,9 @@ jobs: - name: Install dependencies run: uv sync --extra dev + - name: Sync KsADK Web static assets + run: make public-sync-ksadk-web-static + - name: Build package artifacts run: uv build diff --git a/tests/test_public_release_positioning.py b/tests/test_public_release_positioning.py index f337e1b8..cf3811b1 100644 --- a/tests/test_public_release_positioning.py +++ b/tests/test_public_release_positioning.py @@ -134,6 +134,10 @@ def test_github_public_release_gate_workflows_reference_existing_targets(): ] assert missing_targets == [] + assert 'PUBLIC_KSADK_WEB_VERSION: "0.2.11"' in ci_workflow + assert ci_workflow.index("- name: Sync KsADK Web static assets") < ci_workflow.index( + "- name: Build package artifacts" + ) assert ci_workflow.index("- name: Build package artifacts") < ci_workflow.index( "- name: Run public release gate tests" ) From 808489958046744d81950d2739ce54b42a996eef Mon Sep 17 00:00:00 2001 From: xiayu Date: Mon, 22 Jun 2026 19:53:03 +0800 Subject: [PATCH 25/52] ci: use existing public docs build gate --- .github/workflows/ci.yml | 2 +- tests/test_public_release_positioning.py | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 03073785..7ff45dfe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,7 +51,7 @@ jobs: run: uv run --extra dev python scripts/open_source_audit.py --target public-repo - name: Build and audit public docs - run: make public-docs-audit + run: make public-docs-build - name: Check package metadata run: uv run --extra dev python -m twine check dist/* diff --git a/tests/test_public_release_positioning.py b/tests/test_public_release_positioning.py index cf3811b1..f7c64baa 100644 --- a/tests/test_public_release_positioning.py +++ b/tests/test_public_release_positioning.py @@ -125,7 +125,9 @@ def test_github_public_release_gate_workflows_reference_existing_targets(): missing_tests = [path for path in referenced_tests if not (ROOT / path).is_file()] assert missing_tests == [] - referenced_make_targets = re.findall(r"\bmake\s+([A-Za-z0-9_.-]+)", release_check_workflow) + referenced_make_targets = re.findall( + r"\bmake\s+([A-Za-z0-9_.-]+)", "\n".join([ci_workflow, release_check_workflow]) + ) assert "open-source-audit-dist" in referenced_make_targets missing_targets = [ target From 3aef3e2137cab25eb52a54794c43d1a378268cca Mon Sep 17 00:00:00 2001 From: xiayu Date: Mon, 22 Jun 2026 19:58:30 +0800 Subject: [PATCH 26/52] ci: include docs build dependencies --- pyproject.toml | 4 + tests/test_public_release_positioning.py | 13 ++ uv.lock | 239 +++++++++++++++++++++++ 3 files changed, 256 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index fb836d43..0523be63 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -128,6 +128,10 @@ dev = [ "black>=22.0.0", "ruff>=0.1.0", "mypy>=1.0.0", + "mkdocs>=1.6.0,<2.0.0", + "mkdocs-material>=9.5.0,<10.0.0", + "mkdocs-static-i18n>=1.3.0,<2.0.0", + "mkdocstrings[python]>=0.25.0,<1.0.0", ] # 全部依赖 all = [ diff --git a/tests/test_public_release_positioning.py b/tests/test_public_release_positioning.py index f7c64baa..7213815b 100644 --- a/tests/test_public_release_positioning.py +++ b/tests/test_public_release_positioning.py @@ -65,6 +65,19 @@ def test_public_metadata_uses_runtime_platform_positioning(): assert "Agent Development Kit" not in init_text +def test_dev_extra_contains_public_docs_build_dependencies(): + pyproject = tomllib.loads(_read("pyproject.toml")) + dev_dependencies = "\n".join(pyproject["project"]["optional-dependencies"]["dev"]) + + for dependency in ( + "mkdocs>=", + "mkdocs-material>=", + "mkdocs-static-i18n>=", + "mkdocstrings[python]>=", + ): + assert dependency in dev_dependencies + + def test_changelog_marks_0_6_6_ready_for_authorized_release(): changelog = _changelog_section("0.6.6") diff --git a/uv.lock b/uv.lock index 934bf065..10e40877 100644 --- a/uv.lock +++ b/uv.lock @@ -346,6 +346,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/53/23/b65f568ed0c22f1efacb744d2db1a33c8068f384b8c9b482b52ebdbc3ef6/authlib-1.6.9-py2.py3-none-any.whl", hash = "sha256:f08b4c14e08f0861dc18a32357b33fbcfd2ea86cfe3fe149484b4d764c4a0ac3", size = 244197, upload-time = "2026-03-02T07:44:00.307Z" }, ] +[[package]] +name = "babel" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, +] + [[package]] name = "backports-asyncio-runner" version = "1.2.0" @@ -364,6 +373,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b9/fa/123043af240e49752f1c4bd24da5053b6bd00cad78c2be53c0d1e8b975bc/backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34", size = 30181, upload-time = "2024-05-28T17:01:53.112Z" }, ] +[[package]] +name = "backrefs" +version = "7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/a7dd63622beef68cc0d3c3c36d472e143dd95443d5ebf14cd1a5b4dfbf11/backrefs-7.0.tar.gz", hash = "sha256:4989bb9e1e99eb23647c7160ed51fb21d0b41b5d200f2d3017da41e023097e82", size = 7012453, upload-time = "2026-04-28T16:28:04.215Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/39/39a31d7eae729ea14ed10c3ccef79371197177b9355a86cb3525709e8502/backrefs-7.0-py310-none-any.whl", hash = "sha256:b57cd227ea556b0aed3dc9b8da4628db4eabc0402c6d7fcfc69283a93955f7e9", size = 380824, upload-time = "2026-04-28T16:27:55.647Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b5/9302644225ba7dfa934a2ff2b9c7bb85701313a90dddb3dfaf693fa5bae2/backrefs-7.0-py311-none-any.whl", hash = "sha256:a0fa7360c63509e9e077e174ef4e6d3c21c8db94189b9d957289ae6d794b9475", size = 392626, upload-time = "2026-04-28T16:27:57.42Z" }, + { url = "https://files.pythonhosted.org/packages/36/da/87912ddec6e06feffbaa3d7aa18fc6352bee2e8f1fee185d7d1690f8f4e8/backrefs-7.0-py312-none-any.whl", hash = "sha256:ca42ce6a49ace3d75684dfa9937f3373902a63284ecb385ce36d15e5dcb41c12", size = 398537, upload-time = "2026-04-28T16:27:58.913Z" }, + { url = "https://files.pythonhosted.org/packages/00/bb/90ba423612b6aa0adccc6b1874bcd4a9b44b660c0c16f346611e00f64ac3/backrefs-7.0-py313-none-any.whl", hash = "sha256:f2c52955d631b9e1ac4cd56209f0a3a946d592b98e7790e77699339ae01c102a", size = 400491, upload-time = "2026-04-28T16:28:00.928Z" }, + { url = "https://files.pythonhosted.org/packages/3e/5c/fb93d3092640a24dfb7bd7727a24016d7c01774ca013e60efd3f683c8002/backrefs-7.0-py314-none-any.whl", hash = "sha256:a6448b28180e3ca01134c9cf09dcebafad8531072e09903c5451748a05f24bc9", size = 412349, upload-time = "2026-04-28T16:28:02.412Z" }, +] + [[package]] name = "beartype" version = "0.22.9" @@ -1199,6 +1221,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e6/ab/fb21f4c939bb440104cc2b396d3be1d9b7a9fd3c6c2a53d98c45b3d7c954/fsspec-2026.2.0-py3-none-any.whl", hash = "sha256:98de475b5cb3bd66bedd5c4679e87b4fdfe1a3bf4d707b151b3c07e58c9a2437", size = 202505, upload-time = "2026-02-05T21:50:51.819Z" }, ] +[[package]] +name = "ghp-import" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943, upload-time = "2022-05-02T15:47:16.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" }, +] + [[package]] name = "google-adk" version = "1.34.0" @@ -1854,6 +1888,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/29/4b/45d90626aef8e65336bed690106d1382f7a43665e2249017e9527df8823b/greenlet-3.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c04c5e06ec3e022cbfe2cd4a846e1d4e50087444f875ff6d2c2ad8445495cf1a", size = 237086, upload-time = "2026-02-20T20:20:45.786Z" }, ] +[[package]] +name = "griffelib" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/33/e4/8d187ea29c2e30b3a09505c567513077d6117861bde1fbd997a167f262ec/griffelib-2.1.0.tar.gz", hash = "sha256:762a186d2c6fd6794d4ea20d428d597ffb857cb56b66421651cbba15bdd5e813", size = 216234, upload-time = "2026-06-19T12:05:42.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/d3/5268aeabf2ad82658c4e2ff3a060648d0f02f3926cb53247c0e4d0dab49e/griffelib-2.1.0-py3-none-any.whl", hash = "sha256:cc7b3d2d2865ad0b909fcc38086e3f554b5ea7acbaa7bbb7ecaa3f5dfb7d9f00", size = 142560, upload-time = "2026-06-19T12:05:38.742Z" }, +] + [[package]] name = "grpc-google-iam-v1" version = "0.14.3" @@ -2487,6 +2530,10 @@ all = [ { name = "langchain-openai" }, { name = "langgraph" }, { name = "litellm" }, + { name = "mkdocs" }, + { name = "mkdocs-material" }, + { name = "mkdocs-static-i18n" }, + { name = "mkdocstrings", extra = ["python"] }, { name = "mypy" }, { name = "openinference-instrumentation-langchain" }, { name = "protobuf" }, @@ -2505,6 +2552,10 @@ dev = [ { name = "black" }, { name = "build" }, { name = "fastmcp" }, + { name = "mkdocs" }, + { name = "mkdocs-material" }, + { name = "mkdocs-static-i18n" }, + { name = "mkdocstrings", extra = ["python"] }, { name = "mypy" }, { name = "pytest" }, { name = "pytest-asyncio" }, @@ -2566,6 +2617,10 @@ requires-dist = [ { name = "langgraph", marker = "extra == 'deepagents'", specifier = ">=1.2.0,<1.3.0" }, { name = "langgraph", marker = "extra == 'langgraph'", specifier = ">=1.2.0,<1.3.0" }, { name = "litellm", marker = "extra == 'adk'", specifier = ">=1.0.0" }, + { name = "mkdocs", marker = "extra == 'dev'", specifier = ">=1.6.0,<2.0.0" }, + { name = "mkdocs-material", marker = "extra == 'dev'", specifier = ">=9.5.0,<10.0.0" }, + { name = "mkdocs-static-i18n", marker = "extra == 'dev'", specifier = ">=1.3.0,<2.0.0" }, + { name = "mkdocstrings", extras = ["python"], marker = "extra == 'dev'", specifier = ">=0.25.0,<1.0.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.0.0" }, { name = "openinference-instrumentation-langchain", marker = "extra == 'tracing'", specifier = ">=0.1.0" }, { name = "opentelemetry-api", specifier = "==1.37.0" }, @@ -2984,6 +3039,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509, upload-time = "2025-04-10T12:50:53.297Z" }, ] +[[package]] +name = "markdown" +version = "3.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805, upload-time = "2026-02-09T14:57:26.942Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" }, +] + [[package]] name = "markdown-it-py" version = "4.0.0" @@ -3132,6 +3196,147 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "mergedeep" +version = "1.3.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661, upload-time = "2021-02-05T18:55:30.623Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" }, +] + +[[package]] +name = "mkdocs" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "ghp-import" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mergedeep" }, + { name = "mkdocs-get-deps" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "pyyaml" }, + { name = "pyyaml-env-tag" }, + { name = "watchdog" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/c6/bbd4f061bd16b378247f12953ffcb04786a618ce5e904b8c5a01a0309061/mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2", size = 3889159, upload-time = "2024-08-30T12:24:06.899Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451, upload-time = "2024-08-30T12:24:05.054Z" }, +] + +[[package]] +name = "mkdocs-autorefs" +version = "1.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mkdocs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/c0/f641843de3f612a6b48253f39244165acff36657a91cc903633d456ae1ac/mkdocs_autorefs-1.4.4.tar.gz", hash = "sha256:d54a284f27a7346b9c38f1f852177940c222da508e66edc816a0fa55fc6da197", size = 56588, upload-time = "2026-02-10T15:23:55.105Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/de/a3e710469772c6a89595fc52816da05c1e164b4c866a89e3cb82fb1b67c5/mkdocs_autorefs-1.4.4-py3-none-any.whl", hash = "sha256:834ef5408d827071ad1bc69e0f39704fa34c7fc05bc8e1c72b227dfdc5c76089", size = 25530, upload-time = "2026-02-10T15:23:53.817Z" }, +] + +[[package]] +name = "mkdocs-get-deps" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mergedeep" }, + { name = "platformdirs" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/25/b3cccb187655b9393572bde9b09261d267c3bf2f2cdabe347673be5976a6/mkdocs_get_deps-0.2.2.tar.gz", hash = "sha256:8ee8d5f316cdbbb2834bc1df6e69c08fe769a83e040060de26d3c19fad3599a1", size = 11047, upload-time = "2026-03-10T02:46:33.632Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/29/744136411e785c4b0b744d5413e56555265939ab3a104c6a4b719dad33fd/mkdocs_get_deps-0.2.2-py3-none-any.whl", hash = "sha256:e7878cbeac04860b8b5e0ca31d3abad3df9411a75a32cde82f8e44b6c16ff650", size = 9555, upload-time = "2026-03-10T02:46:32.256Z" }, +] + +[[package]] +name = "mkdocs-material" +version = "9.7.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "babel" }, + { name = "backrefs" }, + { name = "colorama" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "mkdocs" }, + { name = "mkdocs-material-extensions" }, + { name = "paginate" }, + { name = "pygments" }, + { name = "pymdown-extensions" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/45/29/6d2bcf41ae40802c4beda2432396fff97b8456fb496371d1bc7aad6512ec/mkdocs_material-9.7.6.tar.gz", hash = "sha256:00bdde50574f776d328b1862fe65daeaf581ec309bd150f7bff345a098c64a69", size = 4097959, upload-time = "2026-03-19T15:41:58.161Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/01/bc663630c510822c95c47a66af9fa7a443c295b47d5f041e5e6ae62ef659/mkdocs_material-9.7.6-py3-none-any.whl", hash = "sha256:71b84353921b8ea1ba84fe11c50912cc512da8fe0881038fcc9a0761c0e635ba", size = 9305470, upload-time = "2026-03-19T15:41:55.217Z" }, +] + +[[package]] +name = "mkdocs-material-extensions" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/79/9b/9b4c96d6593b2a541e1cb8b34899a6d021d208bb357042823d4d2cabdbe7/mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443", size = 11847, upload-time = "2023-11-22T19:09:45.208Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728, upload-time = "2023-11-22T19:09:43.465Z" }, +] + +[[package]] +name = "mkdocs-static-i18n" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mkdocs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/f9/51e2ffda9c7210bc35a24f3717b08c052cd4b728dfa87f901c00d8005259/mkdocs_static_i18n-1.3.1.tar.gz", hash = "sha256:a6125ea7db6cc1a900d76a967f262535af09831160a93c56d7f0d522a79b5faf", size = 1371325, upload-time = "2026-02-20T10:42:41.835Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/0b/43ff4afb6b438d47718b1959a22075ed95d8460d8c47381878b37a40de63/mkdocs_static_i18n-1.3.1-py3-none-any.whl", hash = "sha256:4036e24795a150c9c4d4b001ed24a43aec01335f76188dbe5a5d8fb4a27eba65", size = 21853, upload-time = "2026-02-20T10:42:40.551Z" }, +] + +[[package]] +name = "mkdocstrings" +version = "0.30.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mkdocs" }, + { name = "mkdocs-autorefs" }, + { name = "pymdown-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c5/33/2fa3243439f794e685d3e694590d28469a9b8ea733af4b48c250a3ffc9a0/mkdocstrings-0.30.1.tar.gz", hash = "sha256:84a007aae9b707fb0aebfc9da23db4b26fc9ab562eb56e335e9ec480cb19744f", size = 106350, upload-time = "2025-09-19T10:49:26.446Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/2c/f0dc4e1ee7f618f5bff7e05898d20bf8b6e7fa612038f768bfa295f136a4/mkdocstrings-0.30.1-py3-none-any.whl", hash = "sha256:41bd71f284ca4d44a668816193e4025c950b002252081e387433656ae9a70a82", size = 36704, upload-time = "2025-09-19T10:49:24.805Z" }, +] + +[package.optional-dependencies] +python = [ + { name = "mkdocstrings-python" }, +] + +[[package]] +name = "mkdocstrings-python" +version = "2.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "griffelib" }, + { name = "mkdocs-autorefs" }, + { name = "mkdocstrings" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/b6/e858701499d57eee8b3fd8e78168083956c6683ddbe727b46758b19e1119/mkdocstrings_python-2.0.5.tar.gz", hash = "sha256:3a4d92556ad39637e88af94a5374213af9a8e3040c3824ceaed04b486c017594", size = 199578, upload-time = "2026-06-19T10:41:08.868Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/fc/10ab7e80650a9c9e8f4f1105f8c8e73567f88ed0c06ada589ab81d38687c/mkdocstrings_python-2.0.5-py3-none-any.whl", hash = "sha256:30c837bbff016549f659fcba6539ac351303f0fd7e713c89a040611072236e9d", size = 104951, upload-time = "2026-06-19T10:41:07.378Z" }, +] + [[package]] name = "mmh3" version = "5.2.1" @@ -4149,6 +4354,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, ] +[[package]] +name = "paginate" +version = "0.5.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/46/68dde5b6bc00c1296ec6466ab27dddede6aec9af1b99090e1107091b3b84/paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945", size = 19252, upload-time = "2024-08-25T14:17:24.139Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591", size = 13746, upload-time = "2024-08-25T14:17:22.55Z" }, +] + [[package]] name = "pathable" version = "0.6.0" @@ -4851,6 +5065,19 @@ crypto = [ { name = "cryptography" }, ] +[[package]] +name = "pymdown-extensions" +version = "10.21.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/26/d1015444da4d952a1ca487a236b522eb979766f0295a0bd0c5fc089989a9/pymdown_extensions-10.21.3.tar.gz", hash = "sha256:72cfcf55f07aea0d4af2c4f11dd4e52466ddfb1bb819673146398e0bd3a77354", size = 854140, upload-time = "2026-05-13T12:57:32.267Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/85/545a951eecc270fcd688288c600017e2050a1aacb56c711d208586d3e470/pymdown_extensions-10.21.3-py3-none-any.whl", hash = "sha256:d7a5d08014fc571e80ca21dd6f854e31f94c489800350564d55d15b3c41e76b6", size = 269002, upload-time = "2026-05-13T12:57:30.296Z" }, +] + [[package]] name = "pyopenssl" version = "26.2.0" @@ -5117,6 +5344,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "pyyaml-env-tag" +version = "1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/79c822141bfd05a853236b504869ebc6b70159afc570e1d5a20641782eaa/pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff", size = 5737, upload-time = "2025-05-13T15:24:01.64Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" }, +] + [[package]] name = "qrcode" version = "8.2" From 4248e0144e5d842688d48d9673c6d29a2dba94a3 Mon Sep 17 00:00:00 2001 From: xiayu Date: Tue, 23 Jun 2026 00:05:55 +0800 Subject: [PATCH 27/52] fix: harden public release security scan surfaces --- ksadk/api/client.py | 20 ++++++----- ksadk/configs/settings.py | 34 +++++++++---------- ksadk/conversations/attachments.py | 31 ++++++++++++++--- ksadk/conversations/session_title.py | 3 +- ksadk/runners/adk_runner.py | 9 +++-- .../workspace_files/path_utils.py | 14 +++++++- .../workspace_files/router.py | 15 ++++++++ scripts/test_ks3_upload.py | 4 +-- tests/test_client_framework_passthrough.py | 15 ++++++++ tests/test_client_http_error_logging.py | 16 +++++++++ tests/test_setup_environment.py | 13 +++++++ 11 files changed, 134 insertions(+), 40 deletions(-) diff --git a/ksadk/api/client.py b/ksadk/api/client.py index 62259c37..78350b48 100644 --- a/ksadk/api/client.py +++ b/ksadk/api/client.py @@ -15,7 +15,7 @@ from dataclasses import dataclass from pathlib import Path from typing import Optional, Dict, Any, Sequence, Callable, Iterator -from urllib.parse import quote, unquote, urlparse +from urllib.parse import quote, unquote, urlparse, urlsplit, urlunsplit import requests import urllib3 @@ -408,11 +408,18 @@ def _log_http_error(self, *, method: str, full_url: str, status_code: int, resp_ log_fn( "Request failed: method=%s, url=%s, status=%s, body=%s", method, - full_url, + self._safe_log_url(full_url), status_code, resp_text, ) + @staticmethod + def _safe_log_url(raw_url: str) -> str: + parsed = urlsplit(str(raw_url or "")) + if not parsed.scheme or not parsed.netloc: + return str(raw_url or "").split("?", 1)[0] + return urlunsplit((parsed.scheme, parsed.netloc, parsed.path, "", "")) + def _build_headers(self, request_id: str = "", action: str = "", kop_mode: bool = False) -> Dict[str, str]: if not request_id: request_id = str(uuid.uuid4()) @@ -556,7 +563,7 @@ def _request( retried_inner_endpoint = False while True: - logger.debug(f"Request: {method} {full_url}") + logger.debug("Request: %s %s", method, self._safe_log_url(full_url)) response = session.request( method=method, url=full_url, @@ -688,11 +695,8 @@ def _parse_container_image_ref(image_ref: str) -> tuple[str, str, str]: def _enterprise_instance_from_image_ref(image_ref: str) -> str | None: """Infer enterprise KCR instance name from a full image reference.""" image = (image_ref or "").strip() - for prefix in ("http://", "https://"): - if image.startswith(prefix): - image = image[len(prefix):] - break - host = image.split("/", 1)[0].strip() + parsed = urlsplit(image if "://" in image else f"//{image}", allow_fragments=False) + host = (parsed.hostname or "").strip().lower() if host.endswith("-vpc.ksyunkcr.com"): return host[: -len("-vpc.ksyunkcr.com")] or None if host.endswith(".ksyunkcr.com") and not host.endswith("-vpc.ksyunkcr.com"): diff --git a/ksadk/configs/settings.py b/ksadk/configs/settings.py index 54a0e607..27b30d77 100644 --- a/ksadk/configs/settings.py +++ b/ksadk/configs/settings.py @@ -23,6 +23,7 @@ import logging from typing import Optional, List, Dict, Any from dataclasses import dataclass, field +from urllib.parse import urlsplit, urlunsplit logger = logging.getLogger(__name__) @@ -130,24 +131,21 @@ def optimize_kspmas_url(url: str) -> str: if not url: return url - # 仅优化 KSPMAS 域名 - if "kspmas.ksyun.com" in url: - # 检测是否应该使用内网 - use_internal = False - - # 1. 托管 / 集群环境优先使用内网 - if _is_internal_runtime_env(): - use_internal = True - # 2. 自动检测内网可达性 - elif check_endpoint_reachable(KSPMAS_INTERNAL_HOST): - use_internal = True - - if use_internal: - # 替换域名 (保持协议和路径不变) - # https://kspmas.ksyun.com/v1 -> http://kspmas-internal.sdns.ksyun.com/v1 - # 注意: 内网通常是 http - return url.replace("https://kspmas.ksyun.com", f"http://{KSPMAS_INTERNAL_HOST}") \ - .replace("http://kspmas.ksyun.com", f"http://{KSPMAS_INTERNAL_HOST}") + parsed = urlsplit(url) + if parsed.hostname != "kspmas.ksyun.com": + return url + + use_internal = False + if _is_internal_runtime_env(): + use_internal = True + elif check_endpoint_reachable(KSPMAS_INTERNAL_HOST): + use_internal = True + + if use_internal: + netloc = KSPMAS_INTERNAL_HOST + if parsed.port: + netloc = f"{netloc}:{parsed.port}" + return urlunsplit(("http", netloc, parsed.path, parsed.query, parsed.fragment)) return url diff --git a/ksadk/conversations/attachments.py b/ksadk/conversations/attachments.py index be16a9c4..54c53083 100644 --- a/ksadk/conversations/attachments.py +++ b/ksadk/conversations/attachments.py @@ -127,6 +127,12 @@ def resolve_uploads_dir() -> Path: return uploads_dir +def _path_within_root(path: Path, root: Path) -> bool: + resolved = path.expanduser().resolve(strict=False) + resolved_root = root.expanduser().resolve(strict=False) + return resolved == resolved_root or resolved_root in resolved.parents + + def resolve_attachment_storage_path(file_uri: str) -> Optional[Path]: normalized_uri = (file_uri or "").strip() if not normalized_uri: @@ -147,11 +153,17 @@ def resolve_attachment_storage_path(file_uri: str) -> Optional[Path]: if not file_id: return None + uploads_dir = resolve_uploads_dir().resolve() restored = AttachmentStorageService().ensure_local_path(normalized_uri) - if restored is not None and restored.is_file(): - return restored.resolve() + if restored is not None: + resolved = restored.resolve(strict=False) + if _path_within_root(resolved, uploads_dir) and resolved.is_file(): + return resolved - for candidate in sorted(resolve_uploads_dir().glob(f"{file_id}*")): + safe_file_id = Path(file_id).name + if not safe_file_id: + return None + for candidate in sorted(uploads_dir.glob(f"{safe_file_id}*")): if candidate.is_file(): return candidate.resolve() @@ -170,6 +182,16 @@ def read_attachment_bytes(storage_path: Optional[Path], *, size_limit: Optional[ return None +def read_resolved_attachment_bytes( + storage_path: Any, + *, + size_limit: Optional[int] = None, +) -> Optional[bytes]: + if storage_path is None: + return None + return read_attachment_bytes(Path(str(storage_path)), size_limit=size_limit) + + def classify_attachment_kind(mime_type: str, display_name: str) -> str: mime = (mime_type or "").lower() suffix = Path(display_name or "").suffix.lower() @@ -558,8 +580,7 @@ def _load_attachment_bytes(attachment: Mapping[str, Any]) -> Optional[bytes]: return raw if len(raw) <= _MAX_PROCESS_BYTES else None storage_path_value = attachment.get("storage_path") - storage_path = Path(str(storage_path_value)) if storage_path_value else None - raw = read_attachment_bytes(storage_path, size_limit=_MAX_PROCESS_BYTES) + raw = read_resolved_attachment_bytes(storage_path_value, size_limit=_MAX_PROCESS_BYTES) if raw is not None: return raw return None diff --git a/ksadk/conversations/session_title.py b/ksadk/conversations/session_title.py index a5db0b88..96c19c8a 100644 --- a/ksadk/conversations/session_title.py +++ b/ksadk/conversations/session_title.py @@ -13,6 +13,7 @@ DEFAULT_SESSION_TITLE_TIMEOUT_MS = 8_000 SESSION_TITLE_MAX_CHARS = 24 HEURISTIC_SESSION_TITLE_SOURCE = "heuristic" +SESSION_TITLE_SOURCE_SCAN_LIMIT = 8_000 _TITLE_PROMPT = ( "你是会话标题生成器。" @@ -44,7 +45,7 @@ def _normalize_source_text(text: str) -> str: - value = strip_reasoning_markup(str(text or "")).strip() + value = strip_reasoning_markup(str(text or "")[:SESSION_TITLE_SOURCE_SCAN_LIMIT]).strip() if not value: return "" value = _FILE_MARKUP_RE.sub(" 附件 ", value) diff --git a/ksadk/runners/adk_runner.py b/ksadk/runners/adk_runner.py index b498b84e..bda9f5f8 100644 --- a/ksadk/runners/adk_runner.py +++ b/ksadk/runners/adk_runner.py @@ -15,7 +15,7 @@ from opentelemetry import trace -from ksadk.conversations.attachments import classify_attachment_kind +from ksadk.conversations.attachments import classify_attachment_kind, read_resolved_attachment_bytes from ksadk.conversations.model_context import supports_native_image_input from ksadk.runners.base_runner import BaseRunner from ksadk.sessions.continuity import ADKSessionAdapter @@ -766,10 +766,9 @@ def _build_adk_content( if data is None: storage_path = att.get("storage_path") if storage_path: - try: - data = Path(str(storage_path)).read_bytes() - except Exception as e: - logger.warning(f"Failed to load stored attachment {storage_path}: {e}") + data = read_resolved_attachment_bytes(storage_path) + if data is None: + logger.warning("Failed to load stored attachment %s", storage_path) if data is None: file_uri = att.get("file_uri", "") diff --git a/ksadk_runtime_common/workspace_files/path_utils.py b/ksadk_runtime_common/workspace_files/path_utils.py index 884d5af8..81d100f3 100644 --- a/ksadk_runtime_common/workspace_files/path_utils.py +++ b/ksadk_runtime_common/workspace_files/path_utils.py @@ -4,6 +4,7 @@ import os import posixpath +import re from collections.abc import Callable from pathlib import Path @@ -11,6 +12,8 @@ from ksadk_runtime_common.workspace_files.constants import WORKSPACE_PATH_ESCAPE_DETAIL +_SAFE_WORKSPACE_SEGMENT_RE = re.compile(r"^[^/\x00]+$") + def _env_flag(name: str, default: bool) -> bool: """Parse boolean environment variable.""" @@ -52,7 +55,16 @@ def _resolve_workspace_target( ) -> tuple[str, Path]: """Resolve a path within the workspace root with an escape check.""" normalized = _normalize_workspace_path(raw_path, allow_root=allow_root) - target = root if normalized == "." else (root / Path(normalized)) + if normalized == ".": + target = root + else: + segments = tuple(part for part in normalized.split("/") if part) + if not segments or any( + part in {".", ".."} or not _SAFE_WORKSPACE_SEGMENT_RE.fullmatch(part) + for part in segments + ): + raise HTTPException(status_code=400, detail=WORKSPACE_PATH_ESCAPE_DETAIL) + target = root.joinpath(*segments) resolved_target = target.resolve(strict=False) if resolved_target != root and root not in resolved_target.parents: raise HTTPException(status_code=400, detail=WORKSPACE_PATH_ESCAPE_DETAIL) diff --git a/ksadk_runtime_common/workspace_files/router.py b/ksadk_runtime_common/workspace_files/router.py index f80fa36a..75e6d156 100644 --- a/ksadk_runtime_common/workspace_files/router.py +++ b/ksadk_runtime_common/workspace_files/router.py @@ -33,8 +33,17 @@ UploadResponse = dict[str, EntryPayload] +def _trusted_workspace_path(root: Path, path: Path) -> Path: + resolved_root = root.resolve(strict=False) + resolved_path = path.resolve(strict=False) + if resolved_path != resolved_root and resolved_root not in resolved_path.parents: + raise HTTPException(status_code=400, detail="workspace path escapes root") + return resolved_path + + def _isoformat_timestamp(path: Path) -> str: """Get an ISO 8601 timestamp for a file modification time.""" + path = path.resolve(strict=False) return ( datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc) .isoformat() @@ -88,6 +97,7 @@ async def list_workspace_entries( _ensure_enabled() root = _resolve_workspace_root(root_getter) normalized, target = _resolve_workspace_target(root, path, allow_root=True) + target = _trusted_workspace_path(root, target) if not target.exists(): raise HTTPException(status_code=404, detail="workspace path not found") if not target.is_dir(): @@ -109,6 +119,7 @@ async def export_workspace_zip(path: str = Query(".", alias="path")) -> Streamin _ensure_enabled() root = _resolve_workspace_root(root_getter) normalized, target = _resolve_workspace_target(root, path, allow_root=True) + target = _trusted_workspace_path(root, target) if not target.exists(): raise HTTPException(status_code=404, detail="workspace path not found") if not target.is_dir(): @@ -140,6 +151,7 @@ async def head_workspace_file(file_path: str) -> Response: _ensure_enabled() root = _resolve_workspace_root(root_getter) _, target = _resolve_workspace_target(root, file_path, allow_root=False) + target = _trusted_workspace_path(root, target) if not target.exists() or not target.is_file(): raise HTTPException(status_code=404, detail="workspace file not found") media_type, _ = mimetypes.guess_type(target.name) @@ -157,6 +169,7 @@ async def download_workspace_file(file_path: str) -> Response: _ensure_enabled() root = _resolve_workspace_root(root_getter) _, target = _resolve_workspace_target(root, file_path, allow_root=False) + target = _trusted_workspace_path(root, target) if not target.exists() or not target.is_file(): raise HTTPException(status_code=404, detail="workspace file not found") media_type, _ = mimetypes.guess_type(target.name) @@ -188,6 +201,7 @@ async def upload_workspace_file( _ensure_enabled() root = _resolve_workspace_root(root_getter) _, target = _resolve_workspace_target(root, file_path, allow_root=False) + target = _trusted_workspace_path(root, target) target.parent.mkdir(parents=True, exist_ok=True) size_bytes = 0 @@ -220,6 +234,7 @@ async def delete_workspace_file(file_path: str) -> JSONResponse: _ensure_enabled() root = _resolve_workspace_root(root_getter) _, target = _resolve_workspace_target(root, file_path, allow_root=False) + target = _trusted_workspace_path(root, target) if not target.exists(): raise HTTPException(status_code=404, detail="workspace file not found") if target.is_dir(): diff --git a/scripts/test_ks3_upload.py b/scripts/test_ks3_upload.py index 5c42e34e..cec592d4 100644 --- a/scripts/test_ks3_upload.py +++ b/scripts/test_ks3_upload.py @@ -18,8 +18,8 @@ ak = os.environ.get("KSYUN_ACCESS_KEY") sk = os.environ.get("KSYUN_SECRET_KEY") -print(f"AK: {ak[:10]}..." if ak else "AK: 未设置") -print(f"SK: {sk[:10]}..." if sk else "SK: 未设置") +print("AK: 已设置" if ak else "AK: 未设置") +print("SK: 已设置" if sk else "SK: 未设置") BUCKET_NAME = "agentengine" REGION = "cn-beijing" diff --git a/tests/test_client_framework_passthrough.py b/tests/test_client_framework_passthrough.py index 79f65663..6d29ef69 100644 --- a/tests/test_client_framework_passthrough.py +++ b/tests/test_client_framework_passthrough.py @@ -413,6 +413,21 @@ def fake_action(action: str, params: dict): } +def test_enterprise_registry_detection_uses_exact_hostname(): + assert ( + AgentEngineClient._enterprise_instance_from_image_ref( + "agenthzzqy-vpc.ksyunkcr.com/testagent-pub/0606agent:v6" + ) + == "agenthzzqy" + ) + assert ( + AgentEngineClient._enterprise_instance_from_image_ref( + "evil.example.com/agenthzzqy-vpc.ksyunkcr.com/demo:v1" + ) + is None + ) + + @pytest.mark.asyncio async def test_create_agent_keeps_third_party_registry_as_personal_with_credentials(monkeypatch): client = AgentEngineClient(base_url="http://example.com", access_key="", secret_key="") diff --git a/tests/test_client_http_error_logging.py b/tests/test_client_http_error_logging.py index d9b3b665..9219389a 100644 --- a/tests/test_client_http_error_logging.py +++ b/tests/test_client_http_error_logging.py @@ -26,3 +26,19 @@ def test_client_can_suppress_selected_http_error_logs(caplog): ) assert "Request failed" not in caplog.text + + +def test_client_error_log_redacts_url_query(caplog): + client = AgentEngineClient(base_url="http://example.com", access_key="", secret_key="") + + with caplog.at_level(logging.ERROR, logger="ksadk.api.client"): + client._log_http_error( + method="POST", + full_url="http://example.com/?Action=GetAgent&Password=secret", + status_code=500, + resp_text="failed", + details={"http_status": 500}, + ) + + assert "http://example.com/" in caplog.text + assert "Password=secret" not in caplog.text diff --git a/tests/test_setup_environment.py b/tests/test_setup_environment.py index 5359e270..e59167a0 100644 --- a/tests/test_setup_environment.py +++ b/tests/test_setup_environment.py @@ -76,3 +76,16 @@ def test_setup_environment_does_not_force_internal_base_from_region_only( assert os.environ["OPENAI_BASE_URL"] == "http://kspmas.ksyun.com/v1" assert os.environ["OPENAI_API_BASE"] == "http://kspmas.ksyun.com/v1" + + +def test_optimize_kspmas_url_only_rewrites_exact_hostname(monkeypatch): + monkeypatch.setenv("AGENT_RUNTIME_ID", "ar-test") + + assert ( + settings_module.optimize_kspmas_url("https://kspmas.ksyun.com/v1?x=1") + == "http://kspmas-internal.sdns.ksyun.com/v1?x=1" + ) + assert ( + settings_module.optimize_kspmas_url("https://evil.example/kspmas.ksyun.com/v1") + == "https://evil.example/kspmas.ksyun.com/v1" + ) From a568b6842380c9d7c699936038278d2a14739a5e Mon Sep 17 00:00:00 2001 From: wangyu36 Date: Mon, 22 Jun 2026 17:42:48 +0800 Subject: [PATCH 28/52] Propagate real usage through chat completions runtime --- ksadk/conversations/runtime.py | 102 +++++++++++++++++++++++--- ksadk/runners/langgraph_runner.py | 71 ++++++++++++++++++ tests/test_conversation_runtime.py | 88 ++++++++++++++++++++++ tests/test_langgraph_runner_resume.py | 41 +++++++++++ 4 files changed, 292 insertions(+), 10 deletions(-) diff --git a/ksadk/conversations/runtime.py b/ksadk/conversations/runtime.py index 40dca362..c62ffc28 100644 --- a/ksadk/conversations/runtime.py +++ b/ksadk/conversations/runtime.py @@ -79,6 +79,82 @@ _MODEL_CATALOG_CACHE: dict[tuple[str, str], tuple[float, list[dict[str, Any]]]] = {} +def _normalize_usage_payload(usage: Mapping[str, Any] | None) -> dict[str, Any]: + if not isinstance(usage, Mapping): + return {} + normalized: dict[str, Any] = {} + for key in ( + "input_tokens", + "output_tokens", + "total_tokens", + "prompt_tokens", + "completion_tokens", + ): + value = usage.get(key) + if value is None: + continue + try: + normalized[key] = int(value) + except (TypeError, ValueError): + continue + for key in ("input_token_details", "output_token_details"): + value = usage.get(key) + if isinstance(value, Mapping): + normalized[key] = dict(value) + return normalized + + +def _usage_from_metadata(metadata: Mapping[str, Any] | None) -> dict[str, Any]: + if not isinstance(metadata, Mapping): + return {} + return _normalize_usage_payload(metadata.get("usage")) + + +def _responses_usage_payload(usage: Mapping[str, Any] | None) -> dict[str, Any] | None: + normalized = _normalize_usage_payload(usage) + if not normalized: + return None + input_tokens = normalized.get("input_tokens", normalized.get("prompt_tokens", 0)) + output_tokens = normalized.get("output_tokens", normalized.get("completion_tokens", 0)) + total_tokens = normalized.get("total_tokens", input_tokens + output_tokens) + payload = { + "input_tokens": input_tokens, + "output_tokens": output_tokens, + "total_tokens": total_tokens, + } + if isinstance(normalized.get("input_token_details"), Mapping): + payload["input_token_details"] = dict(normalized["input_token_details"]) + if isinstance(normalized.get("output_token_details"), Mapping): + payload["output_token_details"] = dict(normalized["output_token_details"]) + return payload + + +def _chat_usage_payload(usage: Mapping[str, Any] | None) -> dict[str, Any] | None: + normalized = _normalize_usage_payload(usage) + if not normalized: + return None + prompt_tokens = normalized.get("prompt_tokens", normalized.get("input_tokens", 0)) + completion_tokens = normalized.get("completion_tokens", normalized.get("output_tokens", 0)) + total_tokens = normalized.get("total_tokens", prompt_tokens + completion_tokens) + payload = { + "prompt_tokens": prompt_tokens, + "completion_tokens": completion_tokens, + "total_tokens": total_tokens, + } + output_token_details = normalized.get("output_token_details") + if isinstance(output_token_details, Mapping) and output_token_details: + completion_details: dict[str, Any] = {} + reasoning_tokens = output_token_details.get("reasoning") + if reasoning_tokens is not None: + try: + completion_details["reasoning_tokens"] = int(reasoning_tokens) + except (TypeError, ValueError): + pass + if completion_details: + payload["completion_tokens_details"] = completion_details + return payload + + def _get_conversation_tracer() -> Any | None: try: from opentelemetry import trace @@ -319,6 +395,7 @@ def build_responses_payload( output = [message_item, *output] else: output = [message_item] + usage = _responses_usage_payload(_usage_from_metadata(metadata)) return { "id": response_id, "object": "response", @@ -337,11 +414,7 @@ def build_responses_payload( "tools": [], "output": output, "output_text": output_text, - "usage": { - "input_tokens": 0, - "output_tokens": len(output_text), - "total_tokens": len(output_text), - }, + "usage": usage, "session_id": session_id, } @@ -429,6 +502,7 @@ def build_chat_completions_payload( session_id: str, metadata: Mapping[str, Any] | None = None, ) -> dict[str, Any]: + usage = _chat_usage_payload(_usage_from_metadata(metadata)) payload = { "id": f"chatcmpl-{uuid.uuid4()}", "object": "chat.completion", @@ -441,11 +515,7 @@ def build_chat_completions_payload( "finish_reason": "stop", } ], - "usage": { - "prompt_tokens": 0, - "completion_tokens": len(output_text), - "total_tokens": len(output_text), - }, + "usage": usage, "session_id": session_id, } if isinstance(metadata, Mapping) and metadata: @@ -3059,6 +3129,7 @@ async def invoke_conversation_once( raise last_invoke_error result = result or {} output_text = strip_reasoning_markup(str(result.get("output", ""))) + result_usage = _normalize_usage_payload(result.get("usage")) _set_conversation_output_attributes(span, output_text) result_agentengine_metadata = _extract_agentengine_metadata(result) assistant_metadata: dict[str, Any] = { @@ -3073,6 +3144,8 @@ async def invoke_conversation_once( } if request_metadata_without_agentengine: assistant_metadata["request_metadata"] = request_metadata_without_agentengine + if result_usage: + assistant_metadata["usage"] = result_usage if response_id: assistant_metadata["response_id"] = response_id checkpoint_args = _checkpoint_event_args_from_agentengine_metadata( @@ -3136,6 +3209,9 @@ async def invoke_conversation_once( **_merge_agentengine_metadata(prepared.request_metadata, result_agentengine_metadata), }, } + if result_usage: + result_payload["usage"] = result_usage + result_payload["metadata"]["usage"] = result_usage if response_id: result_payload["response_id"] = response_id return prepared.session_id, result_payload @@ -3307,6 +3383,7 @@ def _finish_span() -> None: responses_output: list[Any] = [] responses_response_id: str | None = response_id runner_agentengine_metadata: dict[str, Any] = {} + stream_usage: dict[str, Any] = {} for attempt in range(2): try: runtime_context.history = list(prepared.history) @@ -3531,6 +3608,8 @@ def _finish_span() -> None: final_text = str(chunk.get("output", "")) if final_text: accumulated_text = final_text + if isinstance(chunk.get("usage"), Mapping): + stream_usage = _normalize_usage_payload(chunk.get("usage")) break except asyncio.CancelledError: await append_run_status_event( @@ -3616,6 +3695,8 @@ def _finish_span() -> None: } if responses_output: assistant_metadata["responses_output"] = responses_output + if stream_usage: + assistant_metadata["usage"] = stream_usage if responses_response_id: assistant_metadata["response_id"] = responses_response_id _set_conversation_output_attributes(span, accumulated_text) @@ -3660,6 +3741,7 @@ def _finish_span() -> None: "metadata": assistant_metadata, "responses_output": responses_output, "response_id": responses_response_id, + "usage": assistant_metadata.get("usage"), } finally: _finish_span() diff --git a/ksadk/runners/langgraph_runner.py b/ksadk/runners/langgraph_runner.py index bfaa0f0d..1685d73a 100644 --- a/ksadk/runners/langgraph_runner.py +++ b/ksadk/runners/langgraph_runner.py @@ -368,6 +368,74 @@ async def _invoke_graph( ) return self._agent.invoke(payload, **kwargs) + @staticmethod + def _message_usage(message: Any) -> dict[str, Any]: + usage_metadata = getattr(message, "usage_metadata", None) + if isinstance(usage_metadata, dict): + input_token_details = usage_metadata.get("input_token_details") + output_token_details = usage_metadata.get("output_token_details") + return { + "input_tokens": int(usage_metadata.get("input_tokens") or 0), + "output_tokens": int(usage_metadata.get("output_tokens") or 0), + "total_tokens": int( + usage_metadata.get("total_tokens") + or ( + int(usage_metadata.get("input_tokens") or 0) + + int(usage_metadata.get("output_tokens") or 0) + ) + ), + "input_token_details": ( + dict(input_token_details) if isinstance(input_token_details, dict) else {} + ), + "output_token_details": ( + dict(output_token_details) if isinstance(output_token_details, dict) else {} + ), + } + + response_metadata = getattr(message, "response_metadata", None) + if isinstance(response_metadata, dict): + token_usage = response_metadata.get("token_usage") + if isinstance(token_usage, dict): + completion_details = token_usage.get("completion_tokens_details") + output_token_details = {} + if isinstance(completion_details, dict): + reasoning_tokens = completion_details.get("reasoning_tokens") + if reasoning_tokens is not None: + output_token_details["reasoning"] = int(reasoning_tokens) + return { + "input_tokens": int(token_usage.get("prompt_tokens") or 0), + "output_tokens": int(token_usage.get("completion_tokens") or 0), + "total_tokens": int( + token_usage.get("total_tokens") + or ( + int(token_usage.get("prompt_tokens") or 0) + + int(token_usage.get("completion_tokens") or 0) + ) + ), + "input_token_details": {}, + "output_token_details": output_token_details, + } + return {} + + @classmethod + def _extract_usage(cls, result: Any) -> dict[str, Any]: + if isinstance(result, dict): + direct_usage = result.get("usage") + if isinstance(direct_usage, dict): + return dict(direct_usage) + + messages = result.get("messages") + if isinstance(messages, list): + for message in reversed(messages): + usage = cls._message_usage(message) + if usage: + return usage + + usage = cls._message_usage(result) + if usage: + return usage + return {} + async def invoke(self, input_data: Dict[str, Any]) -> Dict[str, Any]: """调用 LangGraph 图 @@ -426,6 +494,9 @@ async def invoke(self, input_data: Dict[str, Any]) -> Dict[str, Any]: ) output = {"output": self._extract_output(result), "raw": result} + usage = self._extract_usage(result) + if usage: + output["usage"] = usage metadata = await self._latest_checkpoint_metadata(config) if metadata: output["metadata"] = metadata diff --git a/tests/test_conversation_runtime.py b/tests/test_conversation_runtime.py index 14d71aba..ac1b34ab 100644 --- a/tests/test_conversation_runtime.py +++ b/tests/test_conversation_runtime.py @@ -24,7 +24,9 @@ append_context_checkpoint_event, append_run_checkpoint_event, append_run_resume_event, + build_chat_completions_payload, build_compaction_sse_event, + build_responses_payload, build_run_input, compact_conversation_history, extract_responses_resume_input, @@ -101,6 +103,20 @@ async def invoke(self, input_data: dict) -> dict: } +class _UsageRunner(_StubRunner): + async def invoke(self, input_data: dict) -> dict: + self.calls.append(input_data) + return { + "output": "assistant says hi", + "usage": { + "input_tokens": 8, + "output_tokens": 13, + "total_tokens": 21, + "output_token_details": {"reasoning": 5}, + }, + } + + class _CheckpointResumeAdvancedRunner(_StubRunner): async def invoke(self, input_data: dict) -> dict: self.calls.append(input_data) @@ -3376,6 +3392,78 @@ async def test_invoke_conversation_once_records_runner_checkpoint_metadata(monke assert result["metadata"]["agentengine"]["framework_ref"]["langgraph"]["checkpoint_id"] == "ckpt-1" +@pytest.mark.asyncio +async def test_invoke_conversation_once_preserves_runner_usage(monkeypatch): + service = InMemorySessionService() + await service.create_session(agent_id="demo-agent", user_id="user-1", session_id="sess-usage") + monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service) + + runner = _UsageRunner() + _, result = await invoke_conversation_once( + runner=runner, + agent_id="demo-agent", + user_id="user-1", + session_id="sess-usage", + messages=[{"role": "user", "content": "hello"}], + model="demo-model", + prepare_runner=lambda active_runner, model: active_runner.prepare_for_request(model), + ) + + assert result["usage"] == { + "input_tokens": 8, + "output_tokens": 13, + "total_tokens": 21, + "output_token_details": {"reasoning": 5}, + } + assert result["metadata"]["usage"] == result["usage"] + + +def test_build_chat_completions_payload_uses_real_usage_from_metadata(): + payload = build_chat_completions_payload( + output_text="assistant says hi", + model="demo-model", + session_id="sess-usage", + metadata={ + "usage": { + "input_tokens": 8, + "output_tokens": 13, + "total_tokens": 21, + "output_token_details": {"reasoning": 5}, + } + }, + ) + + assert payload["usage"] == { + "prompt_tokens": 8, + "completion_tokens": 13, + "total_tokens": 21, + "completion_tokens_details": {"reasoning_tokens": 5}, + } + + +def test_build_responses_payload_uses_real_usage_from_metadata(): + payload = build_responses_payload( + output_text="assistant says hi", + model="demo-model", + session_id="sess-usage", + metadata={ + "usage": { + "input_tokens": 8, + "output_tokens": 13, + "total_tokens": 21, + "output_token_details": {"reasoning": 5}, + } + }, + ) + + assert payload["usage"] == { + "input_tokens": 8, + "output_tokens": 13, + "total_tokens": 21, + "output_token_details": {"reasoning": 5}, + } + + @pytest.mark.asyncio async def test_stream_conversation_turn_records_checkpoint_chunk(monkeypatch): service = InMemorySessionService() diff --git a/tests/test_langgraph_runner_resume.py b/tests/test_langgraph_runner_resume.py index d0fd2681..07942785 100644 --- a/tests/test_langgraph_runner_resume.py +++ b/tests/test_langgraph_runner_resume.py @@ -153,6 +153,26 @@ async def astream_events(self, state, version="v2", config=None): } +class _UsageMessage: + def __init__(self): + self.content = "ok" + self.usage_metadata = { + "input_tokens": 8, + "output_tokens": 13, + "total_tokens": 21, + "input_token_details": {}, + "output_token_details": {"reasoning": 5}, + } + + +class _UsageAgent(_DummyAgent): + async def ainvoke(self, state, config=None, context=None): + self.last_ainvoke_state = state + self.last_ainvoke_context = context + self.last_ainvoke_config = config + return {"messages": [_UsageMessage()]} + + def _make_runner(module=None) -> LangGraphRunner: detection = SimpleNamespace(entry_point="src/agent.py", agent_variable="root_agent") runner = LangGraphRunner(detection, ".") @@ -198,6 +218,12 @@ def _make_split_inline_think_tag_streaming_runner() -> LangGraphRunner: return runner +def _make_usage_runner() -> LangGraphRunner: + runner = _make_runner() + runner._agent = _UsageAgent() + return runner + + @pytest.mark.asyncio async def test_invoke_simplified_input_preserves_extra_state(): runner = _make_runner() @@ -400,6 +426,21 @@ async def test_invoke_reports_latest_langgraph_checkpoint_ref_from_async_state_c } +@pytest.mark.asyncio +async def test_invoke_extracts_usage_from_langchain_message_metadata(): + runner = _make_usage_runner() + + result = await runner.invoke({"session_id": "sess-usage", "input": "hello"}) + + assert result["usage"] == { + "input_tokens": 8, + "output_tokens": 13, + "total_tokens": 21, + "input_token_details": {}, + "output_token_details": {"reasoning": 5}, + } + + @pytest.mark.asyncio async def test_stream_checkpoint_resume_uses_checkpoint_id_and_none_input(): runner = _make_runner() From 100180f3691cfa464ef6997aeb04cc8915f7e0c5 Mon Sep 17 00:00:00 2001 From: wangyu36 Date: Mon, 22 Jun 2026 19:40:00 +0800 Subject: [PATCH 29/52] Add usage propagation for additional runners --- ksadk/runners/adk_runner.py | 77 ++++++++++++++- ksadk/runners/langchain_runner.py | 93 ++++++++++++++++++- ksadk/runners/remote_runner.py | 22 ++++- ...est_langchain_runner_session_continuity.py | 33 +++++++ tests/test_remote_runner.py | 66 +++++++++++++ tests/test_runner.py | 47 ++++++++++ 6 files changed, 331 insertions(+), 7 deletions(-) diff --git a/ksadk/runners/adk_runner.py b/ksadk/runners/adk_runner.py index bda9f5f8..b0e7d4ec 100644 --- a/ksadk/runners/adk_runner.py +++ b/ksadk/runners/adk_runner.py @@ -11,7 +11,7 @@ import os import sys from pathlib import Path -from typing import Any, AsyncIterator, Dict, Optional +from typing import Any, AsyncIterator, Dict, Mapping, Optional from opentelemetry import trace @@ -813,6 +813,72 @@ def _build_state_delta(self, input_data: Dict[str, Any]) -> dict[str, Any]: state_delta[key] = input_data.get(key) return state_delta + @staticmethod + def _normalize_usage_metadata(usage_metadata: Any) -> dict[str, Any]: + if usage_metadata is None: + return {} + if hasattr(usage_metadata, "model_dump"): + try: + usage_metadata = usage_metadata.model_dump(exclude_none=True) + except Exception: + usage_metadata = None + elif hasattr(usage_metadata, "dict"): + try: + usage_metadata = usage_metadata.dict() + except Exception: + usage_metadata = None + if not isinstance(usage_metadata, Mapping): + return {} + + reasoning_tokens = usage_metadata.get("thoughts_token_count") + output_token_details = {} + if reasoning_tokens is not None: + try: + output_token_details["reasoning"] = int(reasoning_tokens) + except (TypeError, ValueError): + pass + + if "input_tokens" in usage_metadata or "output_tokens" in usage_metadata: + input_tokens = int(usage_metadata.get("input_tokens") or 0) + output_tokens = int(usage_metadata.get("output_tokens") or 0) + total_tokens = int(usage_metadata.get("total_tokens") or (input_tokens + output_tokens)) + input_token_details = usage_metadata.get("input_token_details") + normalized = { + "input_tokens": input_tokens, + "output_tokens": output_tokens, + "total_tokens": total_tokens, + "input_token_details": ( + dict(input_token_details) if isinstance(input_token_details, Mapping) else {} + ), + "output_token_details": output_token_details, + } + direct_output_details = usage_metadata.get("output_token_details") + if isinstance(direct_output_details, Mapping): + normalized["output_token_details"] = dict(direct_output_details) + return normalized + + input_tokens = int(usage_metadata.get("prompt_token_count") or 0) + output_tokens = int(usage_metadata.get("candidates_token_count") or 0) + total_tokens = int(usage_metadata.get("total_token_count") or (input_tokens + output_tokens)) + if not (input_tokens or output_tokens or total_tokens or output_token_details): + return {} + return { + "input_tokens": input_tokens, + "output_tokens": output_tokens, + "total_tokens": total_tokens, + "input_token_details": {}, + "output_token_details": output_token_details, + } + + @classmethod + def _extract_event_usage(cls, event: Any) -> dict[str, Any]: + direct_usage = cls._normalize_usage_metadata(getattr(event, "usage_metadata", None)) + if direct_usage: + return direct_usage + if isinstance(event, Mapping): + return cls._normalize_usage_metadata(event.get("usage") or event.get("usage_metadata")) + return {} + async def invoke(self, input_data: Dict[str, Any]) -> Dict[str, Any]: """调用 ADK Agent""" from google.genai import types @@ -855,6 +921,7 @@ async def invoke(self, input_data: Dict[str, Any]) -> Dict[str, Any]: final_response = "" events_list = [] + usage: dict[str, Any] = {} async for event in self._runner.run_async( session_id=session_id, user_id="ksadk_user", @@ -862,6 +929,9 @@ async def invoke(self, input_data: Dict[str, Any]) -> Dict[str, Any]: state_delta=state_delta or None, ): events_list.append(event) + event_usage = self._extract_event_usage(event) + if event_usage: + usage = event_usage if hasattr(event, "content") and event.content: if hasattr(event.content, "parts"): for part in event.content.parts: @@ -873,7 +943,10 @@ async def invoke(self, input_data: Dict[str, Any]) -> Dict[str, Any]: # Set output.value for Langfuse top-level output display span.set_attribute("output.value", final_response[:5000] if final_response else "") span.set_attribute("agent.output", final_response[:500] if final_response else "") - return {"output": final_response, "events": events_list} + result = {"output": final_response, "events": events_list} + if usage: + result["usage"] = usage + return result async def stream(self, input_data: Dict[str, Any]) -> AsyncIterator[Dict[str, Any]]: """流式调用 ADK Agent diff --git a/ksadk/runners/langchain_runner.py b/ksadk/runners/langchain_runner.py index 11a31c1b..04c2c117 100644 --- a/ksadk/runners/langchain_runner.py +++ b/ksadk/runners/langchain_runner.py @@ -6,7 +6,7 @@ import logging import os import uuid -from typing import Any, AsyncIterator, Dict, Optional +from typing import Any, AsyncIterator, Dict, Mapping, Optional from ksadk.runners.base_runner import BaseRunner from ksadk.runners.utils import ( @@ -85,7 +85,11 @@ async def invoke(self, input_data: Dict[str, Any]) -> Dict[str, Any]: payload = self._prepare_with_replay(input_data) result = await self._invoke_agent(payload, config=config, context=native_context) - return {"output": self._extract_output(result)} + output = {"output": self._extract_output(result)} + usage = self._extract_usage(result) + if usage: + output["usage"] = usage + return output async def stream(self, input_data: Dict[str, Any]) -> AsyncIterator[Dict[str, Any]]: session_id = input_data.get("session_id") or str(uuid.uuid4())[:8] @@ -448,10 +452,93 @@ async def _append_message_history(self, history_store: Any, messages: list[Any]) elif role == "ai" and hasattr(history_store, "add_ai_message"): history_store.add_ai_message(content) + @staticmethod + def _message_usage(message: Any) -> dict[str, Any]: + usage_metadata = getattr(message, "usage_metadata", None) + if isinstance(usage_metadata, Mapping): + input_token_details = usage_metadata.get("input_token_details") + output_token_details = usage_metadata.get("output_token_details") + return { + "input_tokens": int(usage_metadata.get("input_tokens") or 0), + "output_tokens": int(usage_metadata.get("output_tokens") or 0), + "total_tokens": int( + usage_metadata.get("total_tokens") + or ( + int(usage_metadata.get("input_tokens") or 0) + + int(usage_metadata.get("output_tokens") or 0) + ) + ), + "input_token_details": ( + dict(input_token_details) if isinstance(input_token_details, Mapping) else {} + ), + "output_token_details": ( + dict(output_token_details) if isinstance(output_token_details, Mapping) else {} + ), + } + + response_metadata = getattr(message, "response_metadata", None) + if isinstance(response_metadata, Mapping): + token_usage = response_metadata.get("token_usage") + if isinstance(token_usage, Mapping): + completion_details = token_usage.get("completion_tokens_details") + output_token_details = {} + if isinstance(completion_details, Mapping): + reasoning_tokens = completion_details.get("reasoning_tokens") + if reasoning_tokens is not None: + output_token_details["reasoning"] = int(reasoning_tokens) + return { + "input_tokens": int(token_usage.get("prompt_tokens") or 0), + "output_tokens": int(token_usage.get("completion_tokens") or 0), + "total_tokens": int( + token_usage.get("total_tokens") + or ( + int(token_usage.get("prompt_tokens") or 0) + + int(token_usage.get("completion_tokens") or 0) + ) + ), + "input_token_details": {}, + "output_token_details": output_token_details, + } + return {} + + @classmethod + def _extract_usage(cls, result: Any) -> dict[str, Any]: + if isinstance(result, dict): + direct_usage = result.get("usage") + if isinstance(direct_usage, Mapping): + return dict(direct_usage) + + messages = result.get("messages") + if isinstance(messages, list): + for message in reversed(messages): + usage = cls._message_usage(message) + if usage: + return usage + + usage = cls._message_usage(result) + if usage: + return usage + return {} + @staticmethod def _extract_output(result: Any) -> str: if isinstance(result, dict): - return result.get("output", result.get("text", str(result))) + if "output" in result: + return result["output"] + if "text" in result: + return result["text"] + messages = result.get("messages") + if isinstance(messages, list) and messages: + last = messages[-1] + if isinstance(last, dict): + return str(last.get("content", str(last))) + content = getattr(last, "content", None) + if content is not None: + return str(content) + return str(result) + content = getattr(result, "content", None) + if content is not None: + return str(content) return str(result) def _extract_chunk(self, chunk: Any) -> tuple[Optional[str], Optional[str]]: diff --git a/ksadk/runners/remote_runner.py b/ksadk/runners/remote_runner.py index 29726e7f..7d940a2d 100644 --- a/ksadk/runners/remote_runner.py +++ b/ksadk/runners/remote_runner.py @@ -230,6 +230,16 @@ def _get_headers(self, session_id: Optional[str] = None) -> dict: headers[self.responses_session_header] = session_id return headers + @staticmethod + def _response_usage_payload(data: Mapping[str, Any]) -> dict[str, Any]: + usage = data.get("usage") + if isinstance(usage, Mapping): + return dict(usage) + response = data.get("response") + if isinstance(response, Mapping) and isinstance(response.get("usage"), Mapping): + return dict(response["usage"]) + return {} + async def invoke(self, input_data: Dict[str, Any]) -> Dict[str, Any]: """非流式调用远程 Agent""" import httpx @@ -256,8 +266,13 @@ async def invoke(self, input_data: Dict[str, Any]) -> Dict[str, Any]: response.raise_for_status() data = response.json() + usage = self._response_usage_payload(data) + if self.api_format == "responses": - return {"output": self._extract_responses_output_text(data) or str(data)} + result = {"output": self._extract_responses_output_text(data) or str(data)} + if usage: + result["usage"] = usage + return result # 提取 OpenAI Chat Completions 格式响应 try: @@ -265,7 +280,10 @@ async def invoke(self, input_data: Dict[str, Any]) -> Dict[str, Any]: except (KeyError, IndexError): content = str(data) - return {"output": content} + result = {"output": content} + if usage: + result["usage"] = usage + return result async def stream(self, input_data: Dict[str, Any]) -> AsyncIterator[Dict[str, Any]]: """流式调用远程 Agent""" diff --git a/tests/test_langchain_runner_session_continuity.py b/tests/test_langchain_runner_session_continuity.py index 2f850d9c..33cdc46f 100644 --- a/tests/test_langchain_runner_session_continuity.py +++ b/tests/test_langchain_runner_session_continuity.py @@ -19,6 +19,24 @@ async def ainvoke(self, payload, config=None): return {"output": "ok"} +class _UsageMessage: + def __init__(self): + self.content = "ok" + self.usage_metadata = { + "input_tokens": 11, + "output_tokens": 7, + "total_tokens": 18, + "input_token_details": {}, + "output_token_details": {"reasoning": 3}, + } + + +class _UsageAgent: + async def ainvoke(self, payload, config=None): + del payload, config + return {"messages": [_UsageMessage()]} + + def _make_runner(agent, module=None) -> LangChainRunner: detection = SimpleNamespace(entry_point="src/agent.py", agent_variable="root_agent") runner = LangChainRunner(detection, ".") @@ -229,6 +247,21 @@ def sync_chain(payload: dict) -> dict: assert seen_messages[0][1].content == "hello" +@pytest.mark.asyncio +async def test_langchain_runner_invoke_extracts_usage_from_message_metadata(): + runner = _make_runner(_UsageAgent()) + + result = await runner.invoke({"session_id": "sess-usage", "input": "hello"}) + + assert result["usage"] == { + "input_tokens": 11, + "output_tokens": 7, + "total_tokens": 18, + "input_token_details": {}, + "output_token_details": {"reasoning": 3}, + } + + def test_langchain_runner_extracts_wrapped_history_runnable(): store: dict[str, InMemoryChatMessageHistory] = {} diff --git a/tests/test_remote_runner.py b/tests/test_remote_runner.py index b1707b18..7013328d 100644 --- a/tests/test_remote_runner.py +++ b/tests/test_remote_runner.py @@ -103,6 +103,36 @@ async def test_remote_runner_responses_invoke_keeps_external_responses_stateless assert _FakeAsyncClient.calls[0]["headers"]["Authorization"] == "Bearer ak-demo" +@pytest.mark.asyncio +async def test_remote_runner_responses_invoke_preserves_usage(monkeypatch): + import httpx + + class UsageClient(_FakeAsyncClient): + post_payload = { + "output_text": "hello responses", + "usage": { + "input_tokens": 9, + "output_tokens": 4, + "total_tokens": 13, + }, + } + + UsageClient.calls = [] + monkeypatch.setattr(httpx, "AsyncClient", UsageClient) + runner = RemoteRunner(endpoint="https://agent.example.com", api_format="responses") + + payload = await runner.invoke({"input": "hi"}) + + assert payload == { + "output": "hello responses", + "usage": { + "input_tokens": 9, + "output_tokens": 4, + "total_tokens": 13, + }, + } + + @pytest.mark.asyncio async def test_remote_runner_responses_invoke_forwards_explicit_conversation(monkeypatch): import httpx @@ -403,3 +433,39 @@ class FailedStreamClient(_FakeAsyncClient): chunks = [chunk async for chunk in runner.stream({"input": "hi"})] assert chunks == [{"type": "error", "message": "internal error"}] + + +@pytest.mark.asyncio +async def test_remote_runner_chat_completions_invoke_preserves_usage(monkeypatch): + import httpx + + class ChatUsageClient(_FakeAsyncClient): + post_payload = { + "choices": [ + { + "message": { + "content": "hello chat", + } + } + ], + "usage": { + "prompt_tokens": 15, + "completion_tokens": 6, + "total_tokens": 21, + }, + } + + ChatUsageClient.calls = [] + monkeypatch.setattr(httpx, "AsyncClient", ChatUsageClient) + runner = RemoteRunner(endpoint="https://agent.example.com", api_format="chat_completions") + + payload = await runner.invoke({"input": "hi"}) + + assert payload == { + "output": "hello chat", + "usage": { + "prompt_tokens": 15, + "completion_tokens": 6, + "total_tokens": 21, + }, + } diff --git a/tests/test_runner.py b/tests/test_runner.py index c8a848a8..810c762e 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -668,3 +668,50 @@ async def _fake_ensure_session(external_session_id=None): "current_attachment_results": [{"display_name": "resume.pdf", "kind": "document"}], "has_current_files": True, } + + +@pytest.mark.asyncio +async def test_adk_runner_invoke_extracts_usage_from_final_event(tmp_path, monkeypatch): + from google.genai import types + from ksadk.runners.adk_runner import ADKRunner + + detection = SimpleNamespace( + entry_point="agent.py", + agent_variable="root_agent", + name="demo-agent", + ) + runner = ADKRunner(detection, str(tmp_path)) + runner._agent = SimpleNamespace(name="demo-agent") + + class _FakeRunner: + async def run_async(self, *, session_id, user_id, new_message, state_delta=None, run_config=None): + del session_id, user_id, new_message, state_delta, run_config + yield SimpleNamespace( + usage_metadata={ + "input_tokens": 12, + "output_tokens": 5, + "total_tokens": 17, + "input_token_details": {}, + "output_token_details": {"reasoning": 2}, + }, + content=SimpleNamespace(parts=[types.Part(text="ok")]), + ) + + async def _fake_ensure_session(external_session_id=None): + del external_session_id + return "adk-session-usage" + + monkeypatch.setattr(runner, "_ensure_session", _fake_ensure_session) + monkeypatch.setattr(runner, "_prepare_trace_metadata", lambda session_id: ("", [], "", "demo-agent")) + runner._runner = _FakeRunner() + + result = await runner.invoke({"session_id": "external-session", "input": "hello"}) + + assert result["output"] == "ok" + assert result["usage"] == { + "input_tokens": 12, + "output_tokens": 5, + "total_tokens": 17, + "input_token_details": {}, + "output_token_details": {"reasoning": 2}, + } From e3206b65be0e3a23e10e9bef265b6e5d58e1cd7c Mon Sep 17 00:00:00 2001 From: xiayu Date: Mon, 22 Jun 2026 23:59:06 +0800 Subject: [PATCH 30/52] fix: propagate streaming usage across runners --- ksadk/conversations/runtime.py | 39 +++- ksadk/runners/adk_runner.py | 41 +++-- ksadk/runners/base_runner.py | 94 +++++++++- ksadk/runners/langchain_runner.py | 86 ++------- ksadk/runners/langgraph_runner.py | 107 ++++------- ksadk/runners/remote_runner.py | 16 +- tests/test_conversation_runtime.py | 172 ++++++++++++++++++ ...est_langchain_runner_session_continuity.py | 28 +++ tests/test_langgraph_runner_resume.py | 92 +++++++++- tests/test_remote_runner.py | 101 ++++++++++ tests/test_runner.py | 111 +++++++++++ 11 files changed, 728 insertions(+), 159 deletions(-) diff --git a/ksadk/conversations/runtime.py b/ksadk/conversations/runtime.py index c62ffc28..7b6cc583 100644 --- a/ksadk/conversations/runtime.py +++ b/ksadk/conversations/runtime.py @@ -101,6 +101,24 @@ def _normalize_usage_payload(usage: Mapping[str, Any] | None) -> dict[str, Any]: value = usage.get(key) if isinstance(value, Mapping): normalized[key] = dict(value) + prompt_details = usage.get("prompt_tokens_details") + if isinstance(prompt_details, Mapping): + normalized["prompt_tokens_details"] = dict(prompt_details) + cached_tokens = prompt_details.get("cached_tokens") + if cached_tokens is not None: + try: + normalized.setdefault("input_token_details", {})["cached"] = int(cached_tokens) + except (TypeError, ValueError): + pass + completion_details = usage.get("completion_tokens_details") + if isinstance(completion_details, Mapping): + normalized["completion_tokens_details"] = dict(completion_details) + reasoning_tokens = completion_details.get("reasoning_tokens") + if reasoning_tokens is not None: + try: + normalized.setdefault("output_token_details", {})["reasoning"] = int(reasoning_tokens) + except (TypeError, ValueError): + pass return normalized @@ -141,9 +159,26 @@ def _chat_usage_payload(usage: Mapping[str, Any] | None) -> dict[str, Any] | Non "completion_tokens": completion_tokens, "total_tokens": total_tokens, } + prompt_details = normalized.get("prompt_tokens_details") + if isinstance(prompt_details, Mapping): + payload["prompt_tokens_details"] = dict(prompt_details) + input_token_details = normalized.get("input_token_details") + if isinstance(input_token_details, Mapping) and input_token_details: + prompt_details = dict(payload.get("prompt_tokens_details") or {}) + cached_tokens = input_token_details.get("cached") + if cached_tokens is not None: + try: + prompt_details["cached_tokens"] = int(cached_tokens) + except (TypeError, ValueError): + pass + if prompt_details: + payload["prompt_tokens_details"] = prompt_details + completion_details = normalized.get("completion_tokens_details") + if isinstance(completion_details, Mapping): + payload["completion_tokens_details"] = dict(completion_details) output_token_details = normalized.get("output_token_details") if isinstance(output_token_details, Mapping) and output_token_details: - completion_details: dict[str, Any] = {} + completion_details = dict(payload.get("completion_tokens_details") or {}) reasoning_tokens = output_token_details.get("reasoning") if reasoning_tokens is not None: try: @@ -3428,6 +3463,8 @@ def _finish_span() -> None: ) continue if chunk_type == "responses_output": + if isinstance(chunk.get("usage"), Mapping): + stream_usage = _normalize_usage_payload(chunk.get("usage")) raw_output = chunk.get("output") responses_output = raw_output if isinstance(raw_output, list) else [] raw_response_id = chunk.get("response_id") diff --git a/ksadk/runners/adk_runner.py b/ksadk/runners/adk_runner.py index b0e7d4ec..461ed916 100644 --- a/ksadk/runners/adk_runner.py +++ b/ksadk/runners/adk_runner.py @@ -838,18 +838,32 @@ def _normalize_usage_metadata(usage_metadata: Any) -> dict[str, Any]: except (TypeError, ValueError): pass + input_token_details: dict[str, Any] = {} + cached_tokens = usage_metadata.get("cached_content_token_count") + if cached_tokens is not None: + try: + input_token_details["cached"] = int(cached_tokens) + except (TypeError, ValueError): + pass + tool_use_tokens = usage_metadata.get("tool_use_prompt_token_count") + if tool_use_tokens is not None: + try: + input_token_details["tool_use"] = int(tool_use_tokens) + except (TypeError, ValueError): + pass + if "input_tokens" in usage_metadata or "output_tokens" in usage_metadata: input_tokens = int(usage_metadata.get("input_tokens") or 0) output_tokens = int(usage_metadata.get("output_tokens") or 0) total_tokens = int(usage_metadata.get("total_tokens") or (input_tokens + output_tokens)) - input_token_details = usage_metadata.get("input_token_details") + direct_input_details = usage_metadata.get("input_token_details") + if isinstance(direct_input_details, Mapping): + input_token_details.update(dict(direct_input_details)) normalized = { "input_tokens": input_tokens, "output_tokens": output_tokens, "total_tokens": total_tokens, - "input_token_details": ( - dict(input_token_details) if isinstance(input_token_details, Mapping) else {} - ), + "input_token_details": input_token_details, "output_token_details": output_token_details, } direct_output_details = usage_metadata.get("output_token_details") @@ -860,24 +874,19 @@ def _normalize_usage_metadata(usage_metadata: Any) -> dict[str, Any]: input_tokens = int(usage_metadata.get("prompt_token_count") or 0) output_tokens = int(usage_metadata.get("candidates_token_count") or 0) total_tokens = int(usage_metadata.get("total_token_count") or (input_tokens + output_tokens)) - if not (input_tokens or output_tokens or total_tokens or output_token_details): + if not (input_tokens or output_tokens or total_tokens or input_token_details or output_token_details): return {} return { "input_tokens": input_tokens, "output_tokens": output_tokens, "total_tokens": total_tokens, - "input_token_details": {}, + "input_token_details": input_token_details, "output_token_details": output_token_details, } @classmethod def _extract_event_usage(cls, event: Any) -> dict[str, Any]: - direct_usage = cls._normalize_usage_metadata(getattr(event, "usage_metadata", None)) - if direct_usage: - return direct_usage - if isinstance(event, Mapping): - return cls._normalize_usage_metadata(event.get("usage") or event.get("usage_metadata")) - return {} + return cls._normalize_usage_metadata(getattr(event, "usage_metadata", None)) async def invoke(self, input_data: Dict[str, Any]) -> Dict[str, Any]: """调用 ADK Agent""" @@ -991,6 +1000,7 @@ async def stream(self, input_data: Dict[str, Any]) -> AsyncIterator[Dict[str, An state_delta = self._build_state_delta(input_data) accumulated_text = "" + usage: dict[str, Any] = {} # 使用 StreamingMode.SSE 启用真正的流式输出 run_config = RunConfig(streaming_mode=StreamingMode.SSE) @@ -1002,6 +1012,9 @@ async def stream(self, input_data: Dict[str, Any]) -> AsyncIterator[Dict[str, An state_delta=state_delta or None, run_config=run_config, ): + event_usage = self._extract_event_usage(event) + if event_usage: + usage = event_usage # Only yield text delta if event is partial to avoid duplication of final summary if hasattr(event, "content") and event.content and getattr(event, "partial", False): if hasattr(event.content, "parts"): @@ -1029,3 +1042,7 @@ async def stream(self, input_data: Dict[str, Any]) -> AsyncIterator[Dict[str, An # Set output.value for Langfuse top-level output display span.set_attribute("output.value", accumulated_text[:5000] if accumulated_text else "") span.set_attribute("agent.output", accumulated_text[:500]) + final_chunk: dict[str, Any] = {"output": accumulated_text, "type": "final"} + if usage: + final_chunk["usage"] = usage + yield final_chunk diff --git a/ksadk/runners/base_runner.py b/ksadk/runners/base_runner.py index f6d82072..fd926f20 100644 --- a/ksadk/runners/base_runner.py +++ b/ksadk/runners/base_runner.py @@ -7,7 +7,7 @@ import inspect import os from abc import ABC, abstractmethod -from typing import Any, AsyncIterator, Dict, Optional +from typing import Any, AsyncIterator, Dict, Mapping, Optional from ksadk.sessions.continuity import RunnerSessionAdapter, TranscriptReplayAdapter @@ -129,6 +129,98 @@ def build_native_context(platform_context: Any) -> dict[str, Any] | None: } return native_context or None + @staticmethod + def _int_usage_value(value: Any) -> int: + try: + return int(value or 0) + except (TypeError, ValueError): + return 0 + + @classmethod + def _message_usage(cls, message: Any) -> dict[str, Any]: + usage_metadata = getattr(message, "usage_metadata", None) + if isinstance(usage_metadata, Mapping): + input_tokens = cls._int_usage_value(usage_metadata.get("input_tokens")) + output_tokens = cls._int_usage_value(usage_metadata.get("output_tokens")) + input_token_details = usage_metadata.get("input_token_details") + output_token_details = usage_metadata.get("output_token_details") + if not any( + key in usage_metadata + for key in ("input_tokens", "output_tokens", "total_tokens") + ) and not ( + isinstance(input_token_details, Mapping) and input_token_details + ) and not ( + isinstance(output_token_details, Mapping) and output_token_details + ): + return {} + return { + "input_tokens": input_tokens, + "output_tokens": output_tokens, + "total_tokens": cls._int_usage_value( + usage_metadata.get("total_tokens") or (input_tokens + output_tokens) + ), + "input_token_details": ( + dict(input_token_details) if isinstance(input_token_details, Mapping) else {} + ), + "output_token_details": ( + dict(output_token_details) if isinstance(output_token_details, Mapping) else {} + ), + } + + response_metadata = getattr(message, "response_metadata", None) + if isinstance(response_metadata, Mapping): + token_usage = response_metadata.get("token_usage") + if isinstance(token_usage, Mapping): + input_tokens = cls._int_usage_value(token_usage.get("prompt_tokens")) + output_tokens = cls._int_usage_value(token_usage.get("completion_tokens")) + input_token_details: dict[str, Any] = {} + prompt_details = token_usage.get("prompt_tokens_details") + if isinstance(prompt_details, Mapping): + cached_tokens = prompt_details.get("cached_tokens") + if cached_tokens is not None: + input_token_details["cached"] = cls._int_usage_value(cached_tokens) + + output_token_details: dict[str, Any] = {} + completion_details = token_usage.get("completion_tokens_details") + if isinstance(completion_details, Mapping): + reasoning_tokens = completion_details.get("reasoning_tokens") + if reasoning_tokens is not None: + output_token_details["reasoning"] = cls._int_usage_value(reasoning_tokens) + if not any( + key in token_usage + for key in ("prompt_tokens", "completion_tokens", "total_tokens") + ) and not input_token_details and not output_token_details: + return {} + return { + "input_tokens": input_tokens, + "output_tokens": output_tokens, + "total_tokens": cls._int_usage_value( + token_usage.get("total_tokens") or (input_tokens + output_tokens) + ), + "input_token_details": input_token_details, + "output_token_details": output_token_details, + } + return {} + + @classmethod + def _extract_usage(cls, result: Any) -> dict[str, Any]: + if isinstance(result, Mapping): + direct_usage = result.get("usage") + if isinstance(direct_usage, Mapping): + return dict(direct_usage) + + messages = result.get("messages") + if isinstance(messages, list): + for message in reversed(messages): + usage = cls._message_usage(message) + if usage: + return usage + + usage = cls._message_usage(result) + if usage: + return usage + return {} + def run_server(self, port: int = 8000) -> None: """启动 HTTP Server""" diff --git a/ksadk/runners/langchain_runner.py b/ksadk/runners/langchain_runner.py index 04c2c117..9ec675bd 100644 --- a/ksadk/runners/langchain_runner.py +++ b/ksadk/runners/langchain_runner.py @@ -6,7 +6,7 @@ import logging import os import uuid -from typing import Any, AsyncIterator, Dict, Mapping, Optional +from typing import Any, AsyncIterator, Dict, Optional from ksadk.runners.base_runner import BaseRunner from ksadk.runners.utils import ( @@ -106,6 +106,7 @@ async def stream(self, input_data: Dict[str, Any]) -> AsyncIterator[Dict[str, An payload = self._prepare_with_replay(input_data) accumulated_text = "" + last_chunk: Any = None try: if hasattr(self._agent, "astream"): @@ -115,6 +116,7 @@ async def stream(self, input_data: Dict[str, Any]) -> AsyncIterator[Dict[str, An context=native_context, ) async for chunk in self._agent.astream(payload, **kwargs): + last_chunk = chunk delta, chunk_type = self._extract_chunk(chunk) if delta: accumulated_text += delta @@ -126,6 +128,7 @@ async def stream(self, input_data: Dict[str, Any]) -> AsyncIterator[Dict[str, An context=native_context, ) for chunk in self._agent.stream(payload, **kwargs): + last_chunk = chunk delta, chunk_type = self._extract_chunk(chunk) if delta: accumulated_text += delta @@ -135,7 +138,18 @@ async def stream(self, input_data: Dict[str, Any]) -> AsyncIterator[Dict[str, An if not accumulated_text: result = await self.invoke(input_data) - yield {"output": result.get("output", ""), "type": "final"} + final_chunk = {"output": result.get("output", ""), "type": "final"} + usage = self._extract_usage(result) + if usage: + final_chunk["usage"] = usage + yield final_chunk + return + + final_chunk = {"output": accumulated_text, "type": "final"} + usage = self._extract_usage(last_chunk) + if usage: + final_chunk["usage"] = usage + yield final_chunk def _resolve_request_path(self) -> str: module = getattr(self, "_module", None) @@ -452,74 +466,6 @@ async def _append_message_history(self, history_store: Any, messages: list[Any]) elif role == "ai" and hasattr(history_store, "add_ai_message"): history_store.add_ai_message(content) - @staticmethod - def _message_usage(message: Any) -> dict[str, Any]: - usage_metadata = getattr(message, "usage_metadata", None) - if isinstance(usage_metadata, Mapping): - input_token_details = usage_metadata.get("input_token_details") - output_token_details = usage_metadata.get("output_token_details") - return { - "input_tokens": int(usage_metadata.get("input_tokens") or 0), - "output_tokens": int(usage_metadata.get("output_tokens") or 0), - "total_tokens": int( - usage_metadata.get("total_tokens") - or ( - int(usage_metadata.get("input_tokens") or 0) - + int(usage_metadata.get("output_tokens") or 0) - ) - ), - "input_token_details": ( - dict(input_token_details) if isinstance(input_token_details, Mapping) else {} - ), - "output_token_details": ( - dict(output_token_details) if isinstance(output_token_details, Mapping) else {} - ), - } - - response_metadata = getattr(message, "response_metadata", None) - if isinstance(response_metadata, Mapping): - token_usage = response_metadata.get("token_usage") - if isinstance(token_usage, Mapping): - completion_details = token_usage.get("completion_tokens_details") - output_token_details = {} - if isinstance(completion_details, Mapping): - reasoning_tokens = completion_details.get("reasoning_tokens") - if reasoning_tokens is not None: - output_token_details["reasoning"] = int(reasoning_tokens) - return { - "input_tokens": int(token_usage.get("prompt_tokens") or 0), - "output_tokens": int(token_usage.get("completion_tokens") or 0), - "total_tokens": int( - token_usage.get("total_tokens") - or ( - int(token_usage.get("prompt_tokens") or 0) - + int(token_usage.get("completion_tokens") or 0) - ) - ), - "input_token_details": {}, - "output_token_details": output_token_details, - } - return {} - - @classmethod - def _extract_usage(cls, result: Any) -> dict[str, Any]: - if isinstance(result, dict): - direct_usage = result.get("usage") - if isinstance(direct_usage, Mapping): - return dict(direct_usage) - - messages = result.get("messages") - if isinstance(messages, list): - for message in reversed(messages): - usage = cls._message_usage(message) - if usage: - return usage - - usage = cls._message_usage(result) - if usage: - return usage - return {} - @staticmethod def _extract_output(result: Any) -> str: if isinstance(result, dict): diff --git a/ksadk/runners/langgraph_runner.py b/ksadk/runners/langgraph_runner.py index 1685d73a..4c50b536 100644 --- a/ksadk/runners/langgraph_runner.py +++ b/ksadk/runners/langgraph_runner.py @@ -148,6 +148,20 @@ async def _latest_checkpoint_metadata(self, config: dict[str, Any]) -> dict[str, } } + async def _latest_state_usage(self, config: dict[str, Any]) -> dict[str, Any]: + state = None + try: + if callable(getattr(self._agent, "aget_state", None)): + state = await self._agent.aget_state(config) + elif callable(getattr(self._agent, "get_state", None)): + state = self._agent.get_state(config) + except Exception: + return {} + values = getattr(state, "values", None) + if values is not None: + return self._extract_usage(values) + return {} + @staticmethod def _ambient_context_text(payload: Dict[str, Any]) -> str: sections: list[str] = [] @@ -368,74 +382,6 @@ async def _invoke_graph( ) return self._agent.invoke(payload, **kwargs) - @staticmethod - def _message_usage(message: Any) -> dict[str, Any]: - usage_metadata = getattr(message, "usage_metadata", None) - if isinstance(usage_metadata, dict): - input_token_details = usage_metadata.get("input_token_details") - output_token_details = usage_metadata.get("output_token_details") - return { - "input_tokens": int(usage_metadata.get("input_tokens") or 0), - "output_tokens": int(usage_metadata.get("output_tokens") or 0), - "total_tokens": int( - usage_metadata.get("total_tokens") - or ( - int(usage_metadata.get("input_tokens") or 0) - + int(usage_metadata.get("output_tokens") or 0) - ) - ), - "input_token_details": ( - dict(input_token_details) if isinstance(input_token_details, dict) else {} - ), - "output_token_details": ( - dict(output_token_details) if isinstance(output_token_details, dict) else {} - ), - } - - response_metadata = getattr(message, "response_metadata", None) - if isinstance(response_metadata, dict): - token_usage = response_metadata.get("token_usage") - if isinstance(token_usage, dict): - completion_details = token_usage.get("completion_tokens_details") - output_token_details = {} - if isinstance(completion_details, dict): - reasoning_tokens = completion_details.get("reasoning_tokens") - if reasoning_tokens is not None: - output_token_details["reasoning"] = int(reasoning_tokens) - return { - "input_tokens": int(token_usage.get("prompt_tokens") or 0), - "output_tokens": int(token_usage.get("completion_tokens") or 0), - "total_tokens": int( - token_usage.get("total_tokens") - or ( - int(token_usage.get("prompt_tokens") or 0) - + int(token_usage.get("completion_tokens") or 0) - ) - ), - "input_token_details": {}, - "output_token_details": output_token_details, - } - return {} - - @classmethod - def _extract_usage(cls, result: Any) -> dict[str, Any]: - if isinstance(result, dict): - direct_usage = result.get("usage") - if isinstance(direct_usage, dict): - return dict(direct_usage) - - messages = result.get("messages") - if isinstance(messages, list): - for message in reversed(messages): - usage = cls._message_usage(message) - if usage: - return usage - - usage = cls._message_usage(result) - if usage: - return usage - return {} - async def invoke(self, input_data: Dict[str, Any]) -> Dict[str, Any]: """调用 LangGraph 图 @@ -585,10 +531,15 @@ async def stream(self, input_data: Dict[str, Any]) -> AsyncIterator[Dict[str, An inline_reasoning_parser = ReasoningMarkupParser() emitted_non_text_event = False final_output_text = "" + final_output_usage: dict[str, Any] = {} if not hasattr(self._agent, "astream_events"): result = await self.invoke(invoke_payload) - yield {"output": result.get("output", ""), "type": "final"} + final_chunk = {"output": result.get("output", ""), "type": "final"} + usage = self._extract_usage(result) + if usage: + final_chunk["usage"] = usage + yield final_chunk return try: @@ -661,6 +612,7 @@ async def stream(self, input_data: Dict[str, Any]) -> AsyncIterator[Dict[str, An extracted_output = self._extract_output(output) if extracted_output: final_output_text = strip_reasoning_markup(str(extracted_output)) + final_output_usage = self._extract_usage(output) except Exception as e: if "Interrupt" in type(e).__name__: @@ -680,14 +632,27 @@ async def stream(self, input_data: Dict[str, Any]) -> AsyncIterator[Dict[str, An if not accumulated_text: if final_output_text: - yield {"output": final_output_text, "type": "final"} + final_chunk = {"output": final_output_text, "type": "final"} + if final_output_usage: + final_chunk["usage"] = final_output_usage + yield final_chunk elif not emitted_non_text_event: result = await self.invoke(invoke_payload) - yield {"output": result.get("output", ""), "type": "final"} + final_chunk = {"output": result.get("output", ""), "type": "final"} + usage = self._extract_usage(result) + if usage: + final_chunk["usage"] = usage + yield final_chunk metadata = result.get("metadata") if isinstance(result, dict) else None if isinstance(metadata, dict) and metadata.get("agentengine"): yield {"type": "checkpoint", "metadata": metadata} return + else: + final_chunk = {"output": accumulated_text, "type": "final"} + usage = await self._latest_state_usage(config) + if usage: + final_chunk["usage"] = usage + yield final_chunk metadata = await self._latest_checkpoint_metadata(config) if metadata: diff --git a/ksadk/runners/remote_runner.py b/ksadk/runners/remote_runner.py index 7d940a2d..23d5d418 100644 --- a/ksadk/runners/remote_runner.py +++ b/ksadk/runners/remote_runner.py @@ -300,6 +300,7 @@ async def stream(self, input_data: Dict[str, Any]) -> AsyncIterator[Dict[str, An payload = { "messages": [{"role": "user", "content": user_input}], "stream": True, + "stream_options": {"include_usage": True}, } if session_id and self.api_format != "responses": payload["session_id"] = session_id @@ -313,6 +314,8 @@ async def stream(self, input_data: Dict[str, Any]) -> AsyncIterator[Dict[str, An response.raise_for_status() event_name = "" + accumulated_text = "" + final_sent = False async for line in response.aiter_lines(): if not line: event_name = "" @@ -339,6 +342,10 @@ async def stream(self, input_data: Dict[str, Any]) -> AsyncIterator[Dict[str, An # 解析 OpenAI Chat Completions 流式格式 choices = data.get("choices", []) + usage = data.get("usage") + if isinstance(usage, Mapping): + yield {"type": "final", "usage": dict(usage)} + final_sent = True if choices: delta = choices[0].get("delta", {}) content = delta.get("content", "") @@ -347,10 +354,13 @@ async def stream(self, input_data: Dict[str, Any]) -> AsyncIterator[Dict[str, An if reasoning: yield {"delta": reasoning, "type": "thinking"} if content: + accumulated_text += content yield {"delta": content, "type": "text"} except json.JSONDecodeError: pass + if self.api_format != "responses" and accumulated_text and not final_sent: + yield {"output": accumulated_text, "type": "final"} @staticmethod def _extract_responses_output_text(data: Dict[str, Any]) -> str: @@ -560,11 +570,15 @@ async def _iter_responses_stream_events( response = data.get("response") if isinstance(data.get("response"), dict) else data output = response.get("output") if isinstance(response, dict) else None if isinstance(output, list): - yield { + chunk = { "type": "responses_output", "output": output, "response_id": response.get("id"), } + usage = response.get("usage") + if isinstance(usage, Mapping): + chunk["usage"] = dict(usage) + yield chunk return if event_type == "response.failed": yield {"type": "error", "message": self._responses_error_message(data)} diff --git a/tests/test_conversation_runtime.py b/tests/test_conversation_runtime.py index ac1b34ab..dc4f996c 100644 --- a/tests/test_conversation_runtime.py +++ b/tests/test_conversation_runtime.py @@ -167,6 +167,23 @@ async def stream(self, input_data: dict): yield {"type": "final", "output": "hello"} +class _UsageStreamingRunner(_StreamingRunner): + async def stream(self, input_data: dict): + self.stream_calls.append(input_data) + yield {"type": "text", "delta": "hello"} + yield { + "type": "final", + "output": "hello", + "usage": { + "input_tokens": 8, + "output_tokens": 13, + "total_tokens": 21, + "input_token_details": {"cached": 4}, + "output_token_details": {"reasoning": 5}, + }, + } + + class _CheckpointMetadataStreamingRunner(_StreamingRunner): async def stream(self, input_data: dict): self.stream_calls.append(input_data) @@ -317,6 +334,30 @@ async def stream(self, input_data: dict): yield {"type": "final", "output": "需要查询。"} +class _CompletedOutputUsageStreamingRunner(_CompletedOutputStreamingRunner): + async def stream(self, input_data: dict): + self.stream_calls.append(input_data) + yield { + "type": "responses_output", + "response_id": "resp_native_usage", + "output": [ + { + "id": "msg_123", + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "需要查询。"}], + } + ], + "usage": { + "input_tokens": 9, + "output_tokens": 4, + "total_tokens": 13, + "output_token_details": {"reasoning": 2}, + }, + } + yield {"type": "final", "output": "需要查询。"} + + class _ThinkingStreamingRunner(_StreamingRunner): async def stream(self, input_data: dict): self.stream_calls.append(input_data) @@ -3441,6 +3482,72 @@ def test_build_chat_completions_payload_uses_real_usage_from_metadata(): } +def test_build_chat_completions_payload_maps_cached_prompt_details(): + payload = build_chat_completions_payload( + output_text="assistant says hi", + model="demo-model", + session_id="sess-usage", + metadata={ + "usage": { + "input_tokens": 8, + "output_tokens": 13, + "total_tokens": 21, + "input_token_details": {"cached": 4}, + "output_token_details": {"reasoning": 5}, + } + }, + ) + + assert payload["usage"] == { + "prompt_tokens": 8, + "completion_tokens": 13, + "total_tokens": 21, + "prompt_tokens_details": {"cached_tokens": 4}, + "completion_tokens_details": {"reasoning_tokens": 5}, + } + + +def test_build_chat_completions_payload_preserves_official_usage_details(): + payload = build_chat_completions_payload( + output_text="assistant says hi", + model="demo-model", + session_id="sess-usage", + metadata={ + "usage": { + "prompt_tokens": 8, + "completion_tokens": 13, + "total_tokens": 21, + "prompt_tokens_details": { + "cached_tokens": 4, + "audio_tokens": 2, + }, + "completion_tokens_details": { + "reasoning_tokens": 5, + "audio_tokens": 1, + "accepted_prediction_tokens": 3, + "rejected_prediction_tokens": 6, + }, + } + }, + ) + + assert payload["usage"] == { + "prompt_tokens": 8, + "completion_tokens": 13, + "total_tokens": 21, + "prompt_tokens_details": { + "cached_tokens": 4, + "audio_tokens": 2, + }, + "completion_tokens_details": { + "reasoning_tokens": 5, + "audio_tokens": 1, + "accepted_prediction_tokens": 3, + "rejected_prediction_tokens": 6, + }, + } + + def test_build_responses_payload_uses_real_usage_from_metadata(): payload = build_responses_payload( output_text="assistant says hi", @@ -3464,6 +3571,71 @@ def test_build_responses_payload_uses_real_usage_from_metadata(): } +@pytest.mark.asyncio +async def test_stream_conversation_turn_preserves_final_chunk_usage(monkeypatch): + service = InMemorySessionService() + await service.create_session(agent_id="demo-agent", user_id="user-1", session_id="sess-stream-usage") + monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service) + + runner = _UsageStreamingRunner() + chunks = [ + chunk + async for chunk in stream_conversation_turn( + runner=runner, + agent_id="demo-agent", + user_id="user-1", + session_id="sess-stream-usage", + messages=[{"role": "user", "content": "hello"}], + model="demo-model", + prepare_runner=lambda active_runner, model: active_runner.prepare_for_request(model), + ) + ] + + completed_payload = _extract_sse_payload(chunks, "response.completed") + assert completed_payload["usage"] == { + "input_tokens": 8, + "output_tokens": 13, + "total_tokens": 21, + "input_token_details": {"cached": 4}, + "output_token_details": {"reasoning": 5}, + } + events = await service.get_events("sess-stream-usage") + assistant_event = next(event for event in events if event.event_type == "assistant_message") + assert assistant_event.metadata["usage"] == completed_payload["usage"] + + +@pytest.mark.asyncio +async def test_stream_responses_conversation_turn_preserves_responses_output_usage(monkeypatch): + service = InMemorySessionService() + monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service) + runner = _CompletedOutputUsageStreamingRunner() + + chunks = [ + chunk + async for chunk in stream_responses_conversation_turn( + runner=runner, + agent_id="demo-agent", + user_id="user-1", + session_id="sess-native-output-usage", + messages=[{"role": "user", "content": "查一下"}], + model="gpt-4o", + prepare_runner=lambda current_runner, model: current_runner.prepare_for_request(model), + session_service_provider=lambda: service, + ) + ] + + completed_payload = _extract_sse_payload(chunks, "response.completed") + assert completed_payload["usage"] == { + "input_tokens": 9, + "output_tokens": 4, + "total_tokens": 13, + "output_token_details": {"reasoning": 2}, + } + events = await service.get_events("sess-native-output-usage") + assistant_event = next(event for event in events if event.event_type == "assistant_message") + assert assistant_event.metadata["usage"] == completed_payload["usage"] + + @pytest.mark.asyncio async def test_stream_conversation_turn_records_checkpoint_chunk(monkeypatch): service = InMemorySessionService() diff --git a/tests/test_langchain_runner_session_continuity.py b/tests/test_langchain_runner_session_continuity.py index 33cdc46f..5d26c2ce 100644 --- a/tests/test_langchain_runner_session_continuity.py +++ b/tests/test_langchain_runner_session_continuity.py @@ -37,6 +37,12 @@ async def ainvoke(self, payload, config=None): return {"messages": [_UsageMessage()]} +class _UsageStreamingAgent: + async def astream(self, payload, config=None): + del payload, config + yield _UsageMessage() + + def _make_runner(agent, module=None) -> LangChainRunner: detection = SimpleNamespace(entry_point="src/agent.py", agent_variable="root_agent") runner = LangChainRunner(detection, ".") @@ -262,6 +268,28 @@ async def test_langchain_runner_invoke_extracts_usage_from_message_metadata(): } +@pytest.mark.asyncio +async def test_langchain_runner_stream_emits_final_usage_from_last_chunk(): + runner = _make_runner(_UsageStreamingAgent()) + + chunks = [chunk async for chunk in runner.stream({"session_id": "sess-usage", "input": "hello"})] + + assert chunks == [ + {"delta": "ok", "type": "text"}, + { + "output": "ok", + "type": "final", + "usage": { + "input_tokens": 11, + "output_tokens": 7, + "total_tokens": 18, + "input_token_details": {}, + "output_token_details": {"reasoning": 3}, + }, + }, + ] + + def test_langchain_runner_extracts_wrapped_history_runnable(): store: dict[str, InMemoryChatMessageHistory] = {} diff --git a/tests/test_langgraph_runner_resume.py b/tests/test_langgraph_runner_resume.py index 07942785..56074059 100644 --- a/tests/test_langgraph_runner_resume.py +++ b/tests/test_langgraph_runner_resume.py @@ -173,6 +173,23 @@ async def ainvoke(self, state, config=None, context=None): return {"messages": [_UsageMessage()]} +class _UsageStateStreamingAgent(_StreamingAgent): + def get_state(self, config): + del config + return SimpleNamespace(values={"messages": [_UsageMessage()]}, config=self.state_config) + + +class _FinalOutputUsageStreamingAgent(_DummyAgent): + async def astream_events(self, state, version="v2", config=None): + self.last_astream_state = state + self.last_astream_config = config + yield { + "event": "on_chain_end", + "name": "LangGraph", + "data": {"output": {"answer": "final only", "messages": [_UsageMessage()]}}, + } + + def _make_runner(module=None) -> LangGraphRunner: detection = SimpleNamespace(entry_point="src/agent.py", agent_variable="root_agent") runner = LangGraphRunner(detection, ".") @@ -224,6 +241,18 @@ def _make_usage_runner() -> LangGraphRunner: return runner +def _make_usage_state_streaming_runner() -> LangGraphRunner: + runner = _make_runner() + runner._agent = _UsageStateStreamingAgent() + return runner + + +def _make_final_output_usage_streaming_runner() -> LangGraphRunner: + runner = _make_runner() + runner._agent = _FinalOutputUsageStreamingAgent() + return runner + + @pytest.mark.asyncio async def test_invoke_simplified_input_preserves_extra_state(): runner = _make_runner() @@ -441,6 +470,60 @@ async def test_invoke_extracts_usage_from_langchain_message_metadata(): } +@pytest.mark.asyncio +async def test_stream_emits_final_usage_from_graph_state_after_text_stream(): + runner = _make_usage_state_streaming_runner() + + chunks = [ + chunk + async for chunk in runner.stream( + { + "session_id": "sess-usage-stream", + "input": "hello", + } + ) + ] + + assert chunks[-1] == { + "output": "这是最终回复。", + "type": "final", + "usage": { + "input_tokens": 8, + "output_tokens": 13, + "total_tokens": 21, + "input_token_details": {}, + "output_token_details": {"reasoning": 5}, + }, + } + + +@pytest.mark.asyncio +async def test_stream_final_output_chunk_includes_usage_from_chain_end_output(): + runner = _make_final_output_usage_streaming_runner() + + chunks = [ + chunk + async for chunk in runner.stream( + { + "session_id": "sess-final-usage", + "input": "hello", + } + ) + ] + + assert chunks[-1] == { + "output": "final only", + "type": "final", + "usage": { + "input_tokens": 8, + "output_tokens": 13, + "total_tokens": 21, + "input_token_details": {}, + "output_token_details": {"reasoning": 5}, + }, + } + + @pytest.mark.asyncio async def test_stream_checkpoint_resume_uses_checkpoint_id_and_none_input(): runner = _make_runner() @@ -518,10 +601,11 @@ async def test_stream_does_not_mix_reasoning_into_final_text(): ) ] - assert chunks == [ + assert chunks[:-1] == [ {"delta": "先分析需求。", "type": "thinking"}, {"delta": "这是最终回复。", "type": "text"}, ] + assert chunks[-1] == {"output": "这是最终回复。", "type": "final"} assert all("先分析需求。" not in chunk.get("delta", "") for chunk in chunks if chunk["type"] == "text") @@ -539,10 +623,11 @@ async def test_stream_ignores_content_when_chunk_duplicates_reasoning(): ) ] - assert chunks == [ + assert chunks[:-1] == [ {"delta": "先分析需求。", "type": "thinking"}, {"delta": "这是最终回复。", "type": "text"}, ] + assert chunks[-1] == {"output": "这是最终回复。", "type": "final"} @pytest.mark.asyncio @@ -559,10 +644,11 @@ async def test_stream_extracts_inline_think_tags_from_content(): ) ] - assert chunks == [ + assert chunks[:-1] == [ {"delta": "先分析需求。", "type": "thinking"}, {"delta": "这是最终回复。", "type": "text"}, ] + assert chunks[-1] == {"output": "这是最终回复。", "type": "final"} @pytest.mark.asyncio diff --git a/tests/test_remote_runner.py b/tests/test_remote_runner.py index 7013328d..e80b366d 100644 --- a/tests/test_remote_runner.py +++ b/tests/test_remote_runner.py @@ -435,6 +435,107 @@ class FailedStreamClient(_FakeAsyncClient): assert chunks == [{"type": "error", "message": "internal error"}] +@pytest.mark.asyncio +async def test_remote_runner_chat_completions_stream_requests_and_preserves_usage(monkeypatch): + import httpx + + class ChatStreamUsageClient(_FakeAsyncClient): + stream_lines = [ + 'data: {"choices":[{"delta":{"content":"hello"}}]}', + "", + ( + 'data: {"choices":[],"usage":{"prompt_tokens":15,' + '"completion_tokens":6,"total_tokens":21}}' + ), + "", + "data: [DONE]", + ] + + ChatStreamUsageClient.calls = [] + monkeypatch.setattr(httpx, "AsyncClient", ChatStreamUsageClient) + runner = RemoteRunner(endpoint="https://agent.example.com", api_format="chat_completions") + + chunks = [chunk async for chunk in runner.stream({"input": "hi"})] + + assert ChatStreamUsageClient.calls[0]["json"]["stream_options"] == {"include_usage": True} + assert chunks == [ + {"delta": "hello", "type": "text"}, + { + "type": "final", + "usage": { + "prompt_tokens": 15, + "completion_tokens": 6, + "total_tokens": 21, + }, + }, + ] + + +@pytest.mark.asyncio +async def test_remote_runner_chat_completions_stream_emits_final_without_usage(monkeypatch): + import httpx + + class ChatStreamNoUsageClient(_FakeAsyncClient): + stream_lines = [ + 'data: {"choices":[{"delta":{"content":"hello"}}]}', + "", + "data: [DONE]", + ] + + ChatStreamNoUsageClient.calls = [] + monkeypatch.setattr(httpx, "AsyncClient", ChatStreamNoUsageClient) + runner = RemoteRunner(endpoint="https://agent.example.com", api_format="chat_completions") + + chunks = [chunk async for chunk in runner.stream({"input": "hi"})] + + assert chunks == [ + {"delta": "hello", "type": "text"}, + {"output": "hello", "type": "final"}, + ] + + +@pytest.mark.asyncio +async def test_remote_runner_responses_stream_preserves_completed_usage(monkeypatch): + import httpx + + class ResponsesStreamUsageClient(_FakeAsyncClient): + stream_lines = [ + "event: response.completed", + ( + 'data: {"response":{"id":"resp_1","output":[{"id":"msg_1",' + '"type":"message","content":[{"type":"output_text","text":"hello"}]}],' + '"usage":{"input_tokens":9,"output_tokens":4,"total_tokens":13}}}' + ), + "", + "data: [DONE]", + ] + + ResponsesStreamUsageClient.calls = [] + monkeypatch.setattr(httpx, "AsyncClient", ResponsesStreamUsageClient) + runner = RemoteRunner(endpoint="https://agent.example.com", api_format="responses") + + chunks = [chunk async for chunk in runner.stream({"input": "hi"})] + + assert chunks == [ + { + "type": "responses_output", + "output": [ + { + "id": "msg_1", + "type": "message", + "content": [{"type": "output_text", "text": "hello"}], + } + ], + "response_id": "resp_1", + "usage": { + "input_tokens": 9, + "output_tokens": 4, + "total_tokens": 13, + }, + } + ] + + @pytest.mark.asyncio async def test_remote_runner_chat_completions_invoke_preserves_usage(monkeypatch): import httpx diff --git a/tests/test_runner.py b/tests/test_runner.py index 810c762e..fc4ca40e 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -157,6 +157,62 @@ def test_create_runner_rejects_unknown_framework(): create_runner(detection, "/workspace/demo") +def test_base_runner_extracts_usage_from_langchain_message_metadata(): + detection = _write_detection(FrameworkType.LANGCHAIN) + runner = _StubRunner(detection, "/workspace/demo") + message = SimpleNamespace( + content="ok", + usage_metadata={ + "input_tokens": 11, + "output_tokens": 7, + "total_tokens": 18, + "input_token_details": {"cached": 3}, + "output_token_details": {"reasoning": 2}, + }, + ) + + assert runner._extract_usage({"messages": [SimpleNamespace(content="older"), message]}) == { + "input_tokens": 11, + "output_tokens": 7, + "total_tokens": 18, + "input_token_details": {"cached": 3}, + "output_token_details": {"reasoning": 2}, + } + + +def test_base_runner_extracts_usage_from_openai_token_usage(): + detection = _write_detection(FrameworkType.LANGCHAIN) + runner = _StubRunner(detection, "/workspace/demo") + message = SimpleNamespace( + content="ok", + response_metadata={ + "token_usage": { + "prompt_tokens": 8, + "completion_tokens": 5, + "total_tokens": 13, + "prompt_tokens_details": {"cached_tokens": 4}, + "completion_tokens_details": {"reasoning_tokens": 2}, + } + }, + ) + + assert runner._extract_usage(message) == { + "input_tokens": 8, + "output_tokens": 5, + "total_tokens": 13, + "input_token_details": {"cached": 4}, + "output_token_details": {"reasoning": 2}, + } + + +def test_base_runner_does_not_invent_usage_from_empty_metadata(): + detection = _write_detection(FrameworkType.LANGCHAIN) + runner = _StubRunner(detection, "/workspace/demo") + + assert runner._extract_usage(SimpleNamespace(usage_metadata={})) == {} + assert runner._extract_usage(SimpleNamespace(response_metadata={"token_usage": {}})) == {} + + def test_create_runner_uses_custom_runner_class(monkeypatch, tmp_path): runner_class = _install_runner_module(monkeypatch, "demo_agent.runner", "CustomRunner") detection = DetectionResult( @@ -715,3 +771,58 @@ async def _fake_ensure_session(external_session_id=None): "input_token_details": {}, "output_token_details": {"reasoning": 2}, } + + +@pytest.mark.asyncio +async def test_adk_runner_stream_extracts_usage_details_from_final_event(tmp_path, monkeypatch): + from google.genai import types + from ksadk.runners.adk_runner import ADKRunner + + detection = SimpleNamespace( + entry_point="agent.py", + agent_variable="root_agent", + name="demo-agent", + ) + runner = ADKRunner(detection, str(tmp_path)) + runner._agent = SimpleNamespace(name="demo-agent") + + class _FakeRunner: + async def run_async(self, *, session_id, user_id, new_message, state_delta=None, run_config=None): + del session_id, user_id, new_message, state_delta, run_config + yield SimpleNamespace( + partial=True, + content=SimpleNamespace(parts=[types.Part(text="hello")]), + ) + yield SimpleNamespace( + usage_metadata={ + "prompt_token_count": 12, + "candidates_token_count": 5, + "total_token_count": 17, + "cached_content_token_count": 4, + "tool_use_prompt_token_count": 3, + "thoughts_token_count": 2, + }, + content=SimpleNamespace(parts=[]), + ) + + async def _fake_ensure_session(external_session_id=None): + del external_session_id + return "adk-session-stream-usage" + + monkeypatch.setattr(runner, "_ensure_session", _fake_ensure_session) + monkeypatch.setattr(runner, "_prepare_trace_metadata", lambda session_id: ("", [], "", "demo-agent")) + runner._runner = _FakeRunner() + + chunks = [chunk async for chunk in runner.stream({"session_id": "external-session", "input": "hello"})] + + assert chunks[-1] == { + "output": "hello", + "type": "final", + "usage": { + "input_tokens": 12, + "output_tokens": 5, + "total_tokens": 17, + "input_token_details": {"cached": 4, "tool_use": 3}, + "output_token_details": {"reasoning": 2}, + }, + } From bca93b31cb067636d8a1a538212e66062d3abb0a Mon Sep 17 00:00:00 2001 From: xiayu Date: Tue, 23 Jun 2026 00:09:36 +0800 Subject: [PATCH 31/52] fix: restrict referenced attachment reads to upload cache --- ksadk/conversations/attachments.py | 6 +++++- ksadk/runners/langgraph_runner.py | 4 ++-- tests/test_langgraph_runner_resume.py | 15 +++++++++++---- tests/test_runner.py | 6 ++++-- 4 files changed, 22 insertions(+), 9 deletions(-) diff --git a/ksadk/conversations/attachments.py b/ksadk/conversations/attachments.py index 54c53083..e2b5ae81 100644 --- a/ksadk/conversations/attachments.py +++ b/ksadk/conversations/attachments.py @@ -189,7 +189,11 @@ def read_resolved_attachment_bytes( ) -> Optional[bytes]: if storage_path is None: return None - return read_attachment_bytes(Path(str(storage_path)), size_limit=size_limit) + uploads_dir = resolve_uploads_dir().resolve() + resolved_path = Path(str(storage_path)).expanduser().resolve(strict=False) + if not _path_within_root(resolved_path, uploads_dir): + return None + return read_attachment_bytes(resolved_path, size_limit=size_limit) def classify_attachment_kind(mime_type: str, display_name: str) -> str: diff --git a/ksadk/runners/langgraph_runner.py b/ksadk/runners/langgraph_runner.py index 4c50b536..878adc29 100644 --- a/ksadk/runners/langgraph_runner.py +++ b/ksadk/runners/langgraph_runner.py @@ -15,7 +15,7 @@ from ksadk.sessions.continuity import LangGraphSessionAdapter from ksadk.runners.utils import get_langfuse_callback, get_langfuse_metadata, load_agent_module from langgraph.types import Command -from ksadk.conversations.attachments import classify_attachment_kind, read_attachment_bytes +from ksadk.conversations.attachments import classify_attachment_kind, read_resolved_attachment_bytes from ksadk.conversations.reasoning_markup import ReasoningMarkupParser, strip_reasoning_markup @@ -339,7 +339,7 @@ def _build_langgraph_human_content( if not storage_path: continue - raw = read_attachment_bytes(Path(str(storage_path))) + raw = read_resolved_attachment_bytes(storage_path) if not raw: continue diff --git a/tests/test_langgraph_runner_resume.py b/tests/test_langgraph_runner_resume.py index 56074059..1b5cc0a7 100644 --- a/tests/test_langgraph_runner_resume.py +++ b/tests/test_langgraph_runner_resume.py @@ -754,10 +754,12 @@ async def test_invoke_with_binary_attachment_does_not_convert_reference_to_image @pytest.mark.asyncio -async def test_invoke_with_image_attachment_converts_to_multimodal_human_message(tmp_path): +async def test_invoke_with_image_attachment_converts_to_multimodal_human_message(tmp_path, monkeypatch): + monkeypatch.setenv("AGENTENGINE_UI_DIR", str(tmp_path / ".agentengine" / "ui")) runner = _make_runner() - image_path = tmp_path / "diagram.png" + image_path = tmp_path / ".agentengine" / "ui" / "files" / "diagram.png" image_bytes = b"\x89PNG\r\n\x1a\nfake-image" + image_path.parent.mkdir(parents=True, exist_ok=True) image_path.write_bytes(image_bytes) await runner.invoke( @@ -856,10 +858,15 @@ async def test_invoke_with_remote_image_attachment_preserves_image_url_for_multi @pytest.mark.asyncio -async def test_invoke_with_image_attachment_keeps_image_block_even_when_catalog_is_stale(tmp_path): +async def test_invoke_with_image_attachment_keeps_image_block_even_when_catalog_is_stale( + tmp_path, + monkeypatch, +): + monkeypatch.setenv("AGENTENGINE_UI_DIR", str(tmp_path / ".agentengine" / "ui")) runner = _make_runner() - image_path = tmp_path / "diagram.png" + image_path = tmp_path / ".agentengine" / "ui" / "files" / "diagram.png" image_bytes = b"\x89PNG\r\n\x1a\nfake-image" + image_path.parent.mkdir(parents=True, exist_ok=True) image_path.write_bytes(image_bytes) await runner.invoke( diff --git a/tests/test_runner.py b/tests/test_runner.py index fc4ca40e..71c45c2b 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -574,16 +574,18 @@ def __init__(self, **kwargs): assert _tool_names(runner._agent.tools) == [] -def test_adk_runner_build_adk_content_supports_inline_and_reference_attachments(tmp_path): +def test_adk_runner_build_adk_content_supports_inline_and_reference_attachments(tmp_path, monkeypatch): from ksadk.runners.adk_runner import ADKRunner + monkeypatch.setenv("AGENTENGINE_UI_DIR", str(tmp_path / ".agentengine" / "ui")) detection = SimpleNamespace( entry_point="agent.py", agent_variable="root_agent", name="demo-agent", ) runner = ADKRunner(detection, str(tmp_path)) - archive_path = tmp_path / "bundle.zip" + archive_path = tmp_path / ".agentengine" / "ui" / "files" / "bundle.zip" + archive_path.parent.mkdir(parents=True, exist_ok=True) archive_path.write_bytes(b"PK\x03\x04demo-zip") content = runner._build_adk_content( From ebafb4e95eaa89e7c7e272fd51671de17fdea718 Mon Sep 17 00:00:00 2001 From: xiayu Date: Tue, 23 Jun 2026 00:14:39 +0800 Subject: [PATCH 32/52] ci: install full extras for public test gate --- .github/workflows/ci.yml | 2 +- Makefile | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7ff45dfe..8dc6bc2c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,7 +25,7 @@ jobs: python-version: "3.11" - name: Install dependencies - run: uv sync --extra dev + run: uv sync --extra all - name: Sync KsADK Web static assets run: make public-sync-ksadk-web-static diff --git a/Makefile b/Makefile index f8b110c7..8db4dd29 100644 --- a/Makefile +++ b/Makefile @@ -363,7 +363,8 @@ public-docs-build: public-test: @echo "==> test" - @uv run pytest + @uv sync --extra all + @uv run --extra all pytest PUBLIC_KSADK_WEB_VERSION ?= 0.2.11 From 7d67df3d602034cb10e1a81c98171d69912d483e Mon Sep 17 00:00:00 2001 From: xiayu Date: Tue, 23 Jun 2026 06:22:56 +0800 Subject: [PATCH 33/52] fix: satisfy CodeQL security scan for release gate --- ksadk/api/client.py | 24 +++++++++---------- ksadk/conversations/attachments.py | 14 ++++------- ksadk/conversations/session_title.py | 32 ++++++++++++++++++++----- ksadk/runners/adk_runner.py | 10 ++++---- ksadk/runners/langgraph_runner.py | 8 +++---- tests/test_client_http_error_logging.py | 3 ++- tests/test_langgraph_runner_resume.py | 4 ++-- tests/test_runner.py | 2 +- tests/test_session_title.py | 9 +++++++ 9 files changed, 66 insertions(+), 40 deletions(-) diff --git a/ksadk/api/client.py b/ksadk/api/client.py index 78350b48..4df85f33 100644 --- a/ksadk/api/client.py +++ b/ksadk/api/client.py @@ -15,7 +15,7 @@ from dataclasses import dataclass from pathlib import Path from typing import Optional, Dict, Any, Sequence, Callable, Iterator -from urllib.parse import quote, unquote, urlparse, urlsplit, urlunsplit +from urllib.parse import quote, unquote, urlparse, urlsplit import requests import urllib3 @@ -406,19 +406,17 @@ def _log_http_error(self, *, method: str, full_url: str, status_code: int, resp_ continue log_fn = logger.debug if self._is_auth_related_error_details(details) else logger.error log_fn( - "Request failed: method=%s, url=%s, status=%s, body=%s", + "Request failed: method=%s, target=%s, status=%s, body=%s", method, - self._safe_log_url(full_url), + self._safe_log_target(full_url), status_code, resp_text, ) @staticmethod - def _safe_log_url(raw_url: str) -> str: + def _safe_log_target(raw_url: str) -> str: parsed = urlsplit(str(raw_url or "")) - if not parsed.scheme or not parsed.netloc: - return str(raw_url or "").split("?", 1)[0] - return urlunsplit((parsed.scheme, parsed.netloc, parsed.path, "", "")) + return parsed.path or "/" def _build_headers(self, request_id: str = "", action: str = "", kop_mode: bool = False) -> Dict[str, str]: if not request_id: @@ -563,7 +561,7 @@ def _request( retried_inner_endpoint = False while True: - logger.debug("Request: %s %s", method, self._safe_log_url(full_url)) + logger.debug("Request: %s", method) response = session.request( method=method, url=full_url, @@ -697,10 +695,12 @@ def _enterprise_instance_from_image_ref(image_ref: str) -> str | None: image = (image_ref or "").strip() parsed = urlsplit(image if "://" in image else f"//{image}", allow_fragments=False) host = (parsed.hostname or "").strip().lower() - if host.endswith("-vpc.ksyunkcr.com"): - return host[: -len("-vpc.ksyunkcr.com")] or None - if host.endswith(".ksyunkcr.com") and not host.endswith("-vpc.ksyunkcr.com"): - return host.split(".", 1)[0] or None + vpc_match = re.fullmatch(r"([a-z0-9][a-z0-9-]*)-vpc\.ksyunkcr\.com", host) + if vpc_match: + return vpc_match.group(1) or None + public_match = re.fullmatch(r"([a-z0-9][a-z0-9-]*)\.ksyunkcr\.com", host) + if public_match: + return public_match.group(1) or None return None @classmethod diff --git a/ksadk/conversations/attachments.py b/ksadk/conversations/attachments.py index e2b5ae81..92f0deda 100644 --- a/ksadk/conversations/attachments.py +++ b/ksadk/conversations/attachments.py @@ -182,18 +182,15 @@ def read_attachment_bytes(storage_path: Optional[Path], *, size_limit: Optional[ return None -def read_resolved_attachment_bytes( - storage_path: Any, +def read_attachment_uri_bytes( + file_uri: Any, *, size_limit: Optional[int] = None, ) -> Optional[bytes]: + storage_path = resolve_attachment_storage_path(str(file_uri or "")) if storage_path is None: return None - uploads_dir = resolve_uploads_dir().resolve() - resolved_path = Path(str(storage_path)).expanduser().resolve(strict=False) - if not _path_within_root(resolved_path, uploads_dir): - return None - return read_attachment_bytes(resolved_path, size_limit=size_limit) + return read_attachment_bytes(storage_path, size_limit=size_limit) def classify_attachment_kind(mime_type: str, display_name: str) -> str: @@ -583,8 +580,7 @@ def _load_attachment_bytes(attachment: Mapping[str, Any]) -> Optional[bytes]: return None return raw if len(raw) <= _MAX_PROCESS_BYTES else None - storage_path_value = attachment.get("storage_path") - raw = read_resolved_attachment_bytes(storage_path_value, size_limit=_MAX_PROCESS_BYTES) + raw = read_attachment_uri_bytes(attachment.get("file_uri"), size_limit=_MAX_PROCESS_BYTES) if raw is not None: return raw return None diff --git a/ksadk/conversations/session_title.py b/ksadk/conversations/session_title.py index 96c19c8a..61651067 100644 --- a/ksadk/conversations/session_title.py +++ b/ksadk/conversations/session_title.py @@ -38,17 +38,37 @@ _PROMPT_FILLERS_RE = re.compile( r"^(你好|您好|请问|请|帮我|麻烦|看看这个|看看|看下|分析一下|分析|介绍一下|介绍|解释一下|解释|总结一下|总结|直接开始分析吧|直接开始|这里还有|这边有|给我看下)+", ) -_FILE_MARKUP_RE = re.compile( - r"(\[[^\]]*(上传文件|上传文件引用|附件)[^\]]*\]|#+\s*附件\s*[-::]\s*[^\n]+)", - re.IGNORECASE, -) +_FILE_MARKER_WORDS = ("上传文件", "上传文件引用", "附件") + + +def _replace_file_markup(text: str) -> str: + segments: list[str] = [] + for line in str(text or "").splitlines(): + stripped = line.lstrip("#").strip() + if stripped.startswith("附件") and any(sep in stripped for sep in ("-", ":", ":")): + segments.append(" 附件 ") + continue + + current = line + while "[" in current and "]" in current: + start = current.find("[") + end = current.find("]", start + 1) + if end < 0: + break + bracketed = current[start : end + 1] + if any(word in bracketed for word in _FILE_MARKER_WORDS): + current = f"{current[:start]} 附件 {current[end + 1:]}" + else: + break + segments.append(current) + return "\n".join(segments) def _normalize_source_text(text: str) -> str: value = strip_reasoning_markup(str(text or "")[:SESSION_TITLE_SOURCE_SCAN_LIMIT]).strip() if not value: return "" - value = _FILE_MARKUP_RE.sub(" 附件 ", value) + value = _replace_file_markup(value) value = re.sub(r"\s+", " ", value) return value.strip() @@ -83,7 +103,7 @@ def _truncate_title(text: str) -> str: def build_fallback_title(text: str) -> str: value = _normalize_source_text(text) - value = _FILE_MARKUP_RE.sub(" 附件 ", value) + value = _replace_file_markup(value) value = re.sub(r"\s+", " ", value).strip() return _truncate_title(value) diff --git a/ksadk/runners/adk_runner.py b/ksadk/runners/adk_runner.py index 461ed916..2afe8c1e 100644 --- a/ksadk/runners/adk_runner.py +++ b/ksadk/runners/adk_runner.py @@ -15,7 +15,7 @@ from opentelemetry import trace -from ksadk.conversations.attachments import classify_attachment_kind, read_resolved_attachment_bytes +from ksadk.conversations.attachments import classify_attachment_kind, read_attachment_uri_bytes from ksadk.conversations.model_context import supports_native_image_input from ksadk.runners.base_runner import BaseRunner from ksadk.sessions.continuity import ADKSessionAdapter @@ -764,11 +764,11 @@ def _build_adk_content( logger.warning(f"Failed to decode inline attachment {att.get('display_name', 'uploaded_file')}: {e}") if data is None: - storage_path = att.get("storage_path") - if storage_path: - data = read_resolved_attachment_bytes(storage_path) + file_uri = att.get("file_uri") + if file_uri: + data = read_attachment_uri_bytes(file_uri) if data is None: - logger.warning("Failed to load stored attachment %s", storage_path) + logger.warning("Failed to load stored attachment %s", file_uri) if data is None: file_uri = att.get("file_uri", "") diff --git a/ksadk/runners/langgraph_runner.py b/ksadk/runners/langgraph_runner.py index 878adc29..87c27334 100644 --- a/ksadk/runners/langgraph_runner.py +++ b/ksadk/runners/langgraph_runner.py @@ -15,7 +15,7 @@ from ksadk.sessions.continuity import LangGraphSessionAdapter from ksadk.runners.utils import get_langfuse_callback, get_langfuse_metadata, load_agent_module from langgraph.types import Command -from ksadk.conversations.attachments import classify_attachment_kind, read_resolved_attachment_bytes +from ksadk.conversations.attachments import classify_attachment_kind, read_attachment_uri_bytes from ksadk.conversations.reasoning_markup import ReasoningMarkupParser, strip_reasoning_markup @@ -335,11 +335,11 @@ def _build_langgraph_human_content( ) continue - storage_path = attachment.get("storage_path") - if not storage_path: + file_uri = attachment.get("file_uri") + if not file_uri: continue - raw = read_resolved_attachment_bytes(storage_path) + raw = read_attachment_uri_bytes(file_uri) if not raw: continue diff --git a/tests/test_client_http_error_logging.py b/tests/test_client_http_error_logging.py index 9219389a..9360e343 100644 --- a/tests/test_client_http_error_logging.py +++ b/tests/test_client_http_error_logging.py @@ -40,5 +40,6 @@ def test_client_error_log_redacts_url_query(caplog): details={"http_status": 500}, ) - assert "http://example.com/" in caplog.text + assert "target=/" in caplog.text + assert "example.com" not in caplog.text assert "Password=secret" not in caplog.text diff --git a/tests/test_langgraph_runner_resume.py b/tests/test_langgraph_runner_resume.py index 1b5cc0a7..411d95e2 100644 --- a/tests/test_langgraph_runner_resume.py +++ b/tests/test_langgraph_runner_resume.py @@ -757,7 +757,7 @@ async def test_invoke_with_binary_attachment_does_not_convert_reference_to_image async def test_invoke_with_image_attachment_converts_to_multimodal_human_message(tmp_path, monkeypatch): monkeypatch.setenv("AGENTENGINE_UI_DIR", str(tmp_path / ".agentengine" / "ui")) runner = _make_runner() - image_path = tmp_path / ".agentengine" / "ui" / "files" / "diagram.png" + image_path = tmp_path / ".agentengine" / "ui" / "files" / "img123.png" image_bytes = b"\x89PNG\r\n\x1a\nfake-image" image_path.parent.mkdir(parents=True, exist_ok=True) image_path.write_bytes(image_bytes) @@ -864,7 +864,7 @@ async def test_invoke_with_image_attachment_keeps_image_block_even_when_catalog_ ): monkeypatch.setenv("AGENTENGINE_UI_DIR", str(tmp_path / ".agentengine" / "ui")) runner = _make_runner() - image_path = tmp_path / ".agentengine" / "ui" / "files" / "diagram.png" + image_path = tmp_path / ".agentengine" / "ui" / "files" / "img123.png" image_bytes = b"\x89PNG\r\n\x1a\nfake-image" image_path.parent.mkdir(parents=True, exist_ok=True) image_path.write_bytes(image_bytes) diff --git a/tests/test_runner.py b/tests/test_runner.py index 71c45c2b..e5694d8d 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -584,7 +584,7 @@ def test_adk_runner_build_adk_content_supports_inline_and_reference_attachments( name="demo-agent", ) runner = ADKRunner(detection, str(tmp_path)) - archive_path = tmp_path / ".agentengine" / "ui" / "files" / "bundle.zip" + archive_path = tmp_path / ".agentengine" / "ui" / "files" / "abc123.zip" archive_path.parent.mkdir(parents=True, exist_ok=True) archive_path.write_bytes(b"PK\x03\x04demo-zip") diff --git a/tests/test_session_title.py b/tests/test_session_title.py index 21a130aa..bfff353d 100644 --- a/tests/test_session_title.py +++ b/tests/test_session_title.py @@ -67,3 +67,12 @@ def test_session_title_helpers_strip_inline_think_markup(): assert title == "招聘助手能力" assert " Date: Tue, 23 Jun 2026 06:30:49 +0800 Subject: [PATCH 34/52] fix: avoid sensitive error logging and path alerts --- ksadk/api/client.py | 18 ++++++++++++++++-- ksadk/conversations/attachments.py | 13 ++++++++++--- tests/test_client_http_error_logging.py | 18 ++++++++++++++++++ 3 files changed, 44 insertions(+), 5 deletions(-) diff --git a/ksadk/api/client.py b/ksadk/api/client.py index 4df85f33..8a67dddd 100644 --- a/ksadk/api/client.py +++ b/ksadk/api/client.py @@ -406,11 +406,11 @@ def _log_http_error(self, *, method: str, full_url: str, status_code: int, resp_ continue log_fn = logger.debug if self._is_auth_related_error_details(details) else logger.error log_fn( - "Request failed: method=%s, target=%s, status=%s, body=%s", + "Request failed: method=%s, target=%s, status=%s, error=%s", method, self._safe_log_target(full_url), status_code, - resp_text, + self._safe_log_error_summary(details=details, resp_text=resp_text), ) @staticmethod @@ -418,6 +418,20 @@ def _safe_log_target(raw_url: str) -> str: parsed = urlsplit(str(raw_url or "")) return parsed.path or "/" + @staticmethod + def _safe_log_error_summary(*, details: Dict[str, Any], resp_text: str) -> str: + code = str(details.get("remote_error_code") or details.get("code") or "").strip() + message = str(details.get("remote_error_message") or details.get("message") or "").strip() + if not message: + message = "non-json response" if str(resp_text or "").strip() else "empty response" + message = re.sub( + r"(?i)(password|passwd|secret|token|access[_-]?key|api[_-]?key)\s*[:=]\s*[^,\s}\"]+", + r"\1=", + message, + ) + message = " ".join(message.split())[:200] + return f"{code}: {message}" if code else message + def _build_headers(self, request_id: str = "", action: str = "", kop_mode: bool = False) -> Dict[str, str]: if not request_id: request_id = str(uuid.uuid4()) diff --git a/ksadk/conversations/attachments.py b/ksadk/conversations/attachments.py index 92f0deda..4cef9481 100644 --- a/ksadk/conversations/attachments.py +++ b/ksadk/conversations/attachments.py @@ -163,9 +163,16 @@ def resolve_attachment_storage_path(file_uri: str) -> Optional[Path]: safe_file_id = Path(file_id).name if not safe_file_id: return None - for candidate in sorted(uploads_dir.glob(f"{safe_file_id}*")): - if candidate.is_file(): - return candidate.resolve() + try: + candidates = sorted(uploads_dir.iterdir(), key=lambda item: item.name) + except OSError: + return None + for candidate in candidates: + if not candidate.name.startswith(safe_file_id): + continue + resolved = candidate.resolve(strict=False) + if _path_within_root(resolved, uploads_dir) and resolved.is_file(): + return resolved return None diff --git a/tests/test_client_http_error_logging.py b/tests/test_client_http_error_logging.py index 9360e343..e64b4bbf 100644 --- a/tests/test_client_http_error_logging.py +++ b/tests/test_client_http_error_logging.py @@ -43,3 +43,21 @@ def test_client_error_log_redacts_url_query(caplog): assert "target=/" in caplog.text assert "example.com" not in caplog.text assert "Password=secret" not in caplog.text + + +def test_client_error_log_redacts_sensitive_response_body(caplog): + client = AgentEngineClient(base_url="http://example.com", access_key="", secret_key="") + + with caplog.at_level(logging.ERROR, logger="ksadk.api.client"): + client._log_http_error( + method="POST", + full_url="http://example.com/?Action=GetAgent", + status_code=500, + resp_text='{"Message":"failed password=secret token=abc"}', + details={"message": "failed password=secret token=abc", "http_status": 500}, + ) + + assert "password=" in caplog.text + assert "token=" in caplog.text + assert "secret" not in caplog.text + assert "abc" not in caplog.text From d936aa85924fae15608175d0c487ebc44add9bdd Mon Sep 17 00:00:00 2001 From: xiayu Date: Tue, 23 Jun 2026 06:35:36 +0800 Subject: [PATCH 35/52] fix: remove CodeQL-sensitive log and local path flows --- ksadk/api/client.py | 6 +++--- ksadk/conversations/attachments.py | 9 +-------- tests/test_client_http_error_logging.py | 7 ++++--- 3 files changed, 8 insertions(+), 14 deletions(-) diff --git a/ksadk/api/client.py b/ksadk/api/client.py index 8a67dddd..d1049fa4 100644 --- a/ksadk/api/client.py +++ b/ksadk/api/client.py @@ -410,7 +410,7 @@ def _log_http_error(self, *, method: str, full_url: str, status_code: int, resp_ method, self._safe_log_target(full_url), status_code, - self._safe_log_error_summary(details=details, resp_text=resp_text), + self._safe_log_error_summary(details=details), ) @staticmethod @@ -419,11 +419,11 @@ def _safe_log_target(raw_url: str) -> str: return parsed.path or "/" @staticmethod - def _safe_log_error_summary(*, details: Dict[str, Any], resp_text: str) -> str: + def _safe_log_error_summary(*, details: Dict[str, Any]) -> str: code = str(details.get("remote_error_code") or details.get("code") or "").strip() message = str(details.get("remote_error_message") or details.get("message") or "").strip() if not message: - message = "non-json response" if str(resp_text or "").strip() else "empty response" + message = "response body omitted" message = re.sub( r"(?i)(password|passwd|secret|token|access[_-]?key|api[_-]?key)\s*[:=]\s*[^,\s}\"]+", r"\1=", diff --git a/ksadk/conversations/attachments.py b/ksadk/conversations/attachments.py index 4cef9481..7010c164 100644 --- a/ksadk/conversations/attachments.py +++ b/ksadk/conversations/attachments.py @@ -139,14 +139,7 @@ def resolve_attachment_storage_path(file_uri: str) -> Optional[Path]: return None if normalized_uri.startswith("local:"): - path = Path(normalized_uri[6:]).expanduser() - resolved = path.resolve() - uploads_dir = resolve_uploads_dir().resolve() - try: - resolved.relative_to(uploads_dir) - except ValueError: - return None - return resolved + return None if is_runtime_upload_uri(normalized_uri) or is_hosted_upload_uri(normalized_uri): file_id = parse_file_id(normalized_uri) diff --git a/tests/test_client_http_error_logging.py b/tests/test_client_http_error_logging.py index e64b4bbf..4638a35a 100644 --- a/tests/test_client_http_error_logging.py +++ b/tests/test_client_http_error_logging.py @@ -54,10 +54,11 @@ def test_client_error_log_redacts_sensitive_response_body(caplog): full_url="http://example.com/?Action=GetAgent", status_code=500, resp_text='{"Message":"failed password=secret token=abc"}', - details={"message": "failed password=secret token=abc", "http_status": 500}, + details={"http_status": 500}, ) - assert "password=" in caplog.text - assert "token=" in caplog.text + assert "response body omitted" in caplog.text + assert "password" not in caplog.text + assert "token" not in caplog.text assert "secret" not in caplog.text assert "abc" not in caplog.text From d2ba10d897b9dc79480d668881b7795b92389e25 Mon Sep 17 00:00:00 2001 From: xiayu Date: Tue, 23 Jun 2026 06:40:31 +0800 Subject: [PATCH 36/52] fix: stop passing response bodies through error logging --- ksadk/api/client.py | 6 ++---- tests/test_client_http_error_logging.py | 7 +------ 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/ksadk/api/client.py b/ksadk/api/client.py index d1049fa4..b1706dbb 100644 --- a/ksadk/api/client.py +++ b/ksadk/api/client.py @@ -391,14 +391,14 @@ def suppress_http_error_logging( if self._http_error_log_suppressors: self._http_error_log_suppressors.pop() - def _log_http_error(self, *, method: str, full_url: str, status_code: int, resp_text: str, details: Dict[str, Any]) -> None: + def _log_http_error(self, *, method: str, full_url: str, status_code: int, details: Dict[str, Any]) -> None: for suppressor in reversed(self._http_error_log_suppressors): try: if suppressor( method=method, full_url=full_url, status_code=status_code, - resp_text=resp_text, + resp_text="", details=details, ): return @@ -604,7 +604,6 @@ def _request( method=method, full_url=full_url, status_code=response.status_code, - resp_text=resp_text, details=details, ) message = ( @@ -948,7 +947,6 @@ def _action_raw_request( method=method, full_url=full_url, status_code=response.status_code, - resp_text=resp_text, details=details, ) message = ( diff --git a/tests/test_client_http_error_logging.py b/tests/test_client_http_error_logging.py index 4638a35a..b4cb65e0 100644 --- a/tests/test_client_http_error_logging.py +++ b/tests/test_client_http_error_logging.py @@ -12,16 +12,13 @@ def test_client_can_suppress_selected_http_error_logs(caplog): method == "POST" and "Action=GetAgent" in full_url and status_code == 404 - and "未找到对应的 Agent" in ( - str(details.get("remote_error_message") or "") + resp_text - ) + and "未找到对应的 Agent" in str(details.get("remote_error_message") or "") ) ): client._log_http_error( method="POST", full_url="http://example.com/?Action=GetAgent&Version=2024-06-12", status_code=404, - resp_text='{"Message":"未找到对应的 Agent"}', details={"remote_error_message": "未找到对应的 Agent", "http_status": 404}, ) @@ -36,7 +33,6 @@ def test_client_error_log_redacts_url_query(caplog): method="POST", full_url="http://example.com/?Action=GetAgent&Password=secret", status_code=500, - resp_text="failed", details={"http_status": 500}, ) @@ -53,7 +49,6 @@ def test_client_error_log_redacts_sensitive_response_body(caplog): method="POST", full_url="http://example.com/?Action=GetAgent", status_code=500, - resp_text='{"Message":"failed password=secret token=abc"}', details={"http_status": 500}, ) From f6b3be9a5f02415a266cfb522760623adb43cc6c Mon Sep 17 00:00:00 2001 From: xiayu Date: Tue, 23 Jun 2026 06:46:28 +0800 Subject: [PATCH 37/52] fix: avoid logging response-derived error details --- ksadk/api/client.py | 10 +++++++--- tests/test_client_http_error_logging.py | 5 +++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/ksadk/api/client.py b/ksadk/api/client.py index b1706dbb..af7e1965 100644 --- a/ksadk/api/client.py +++ b/ksadk/api/client.py @@ -406,11 +406,10 @@ def _log_http_error(self, *, method: str, full_url: str, status_code: int, detai continue log_fn = logger.debug if self._is_auth_related_error_details(details) else logger.error log_fn( - "Request failed: method=%s, target=%s, status=%s, error=%s", + "Request failed: method=%s, status=%s, error=%s", method, - self._safe_log_target(full_url), status_code, - self._safe_log_error_summary(details=details), + self._safe_log_error_category(details=details), ) @staticmethod @@ -432,6 +431,11 @@ def _safe_log_error_summary(*, details: Dict[str, Any]) -> str: message = " ".join(message.split())[:200] return f"{code}: {message}" if code else message + @staticmethod + def _safe_log_error_category(*, details: Dict[str, Any]) -> str: + code = str(details.get("remote_error_code") or details.get("code") or "").strip() + return code[:80] if code else "remote_error" + def _build_headers(self, request_id: str = "", action: str = "", kop_mode: bool = False) -> Dict[str, str]: if not request_id: request_id = str(uuid.uuid4()) diff --git a/tests/test_client_http_error_logging.py b/tests/test_client_http_error_logging.py index b4cb65e0..489636b4 100644 --- a/tests/test_client_http_error_logging.py +++ b/tests/test_client_http_error_logging.py @@ -36,7 +36,7 @@ def test_client_error_log_redacts_url_query(caplog): details={"http_status": 500}, ) - assert "target=/" in caplog.text + assert "status=500" in caplog.text assert "example.com" not in caplog.text assert "Password=secret" not in caplog.text @@ -52,7 +52,8 @@ def test_client_error_log_redacts_sensitive_response_body(caplog): details={"http_status": 500}, ) - assert "response body omitted" in caplog.text + assert "remote_error" in caplog.text + assert "response body omitted" not in caplog.text assert "password" not in caplog.text assert "token" not in caplog.text assert "secret" not in caplog.text From 8ad267ee95d48f0151b9d19351b27a85bceaac15 Mon Sep 17 00:00:00 2001 From: xiayu Date: Tue, 23 Jun 2026 06:51:16 +0800 Subject: [PATCH 38/52] fix: keep http error logs detached from response details --- ksadk/api/client.py | 8 +------- tests/test_client_http_error_logging.py | 1 - 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/ksadk/api/client.py b/ksadk/api/client.py index af7e1965..89826593 100644 --- a/ksadk/api/client.py +++ b/ksadk/api/client.py @@ -406,10 +406,9 @@ def _log_http_error(self, *, method: str, full_url: str, status_code: int, detai continue log_fn = logger.debug if self._is_auth_related_error_details(details) else logger.error log_fn( - "Request failed: method=%s, status=%s, error=%s", + "Request failed: method=%s, status=%s", method, status_code, - self._safe_log_error_category(details=details), ) @staticmethod @@ -431,11 +430,6 @@ def _safe_log_error_summary(*, details: Dict[str, Any]) -> str: message = " ".join(message.split())[:200] return f"{code}: {message}" if code else message - @staticmethod - def _safe_log_error_category(*, details: Dict[str, Any]) -> str: - code = str(details.get("remote_error_code") or details.get("code") or "").strip() - return code[:80] if code else "remote_error" - def _build_headers(self, request_id: str = "", action: str = "", kop_mode: bool = False) -> Dict[str, str]: if not request_id: request_id = str(uuid.uuid4()) diff --git a/tests/test_client_http_error_logging.py b/tests/test_client_http_error_logging.py index 489636b4..fa9b5f5a 100644 --- a/tests/test_client_http_error_logging.py +++ b/tests/test_client_http_error_logging.py @@ -52,7 +52,6 @@ def test_client_error_log_redacts_sensitive_response_body(caplog): details={"http_status": 500}, ) - assert "remote_error" in caplog.text assert "response body omitted" not in caplog.text assert "password" not in caplog.text assert "token" not in caplog.text From 5eacd32997c8fd70f17a72566d91b29947a5c8a2 Mon Sep 17 00:00:00 2001 From: xiayu Date: Tue, 23 Jun 2026 07:03:57 +0800 Subject: [PATCH 39/52] docs: clarify 0.6.6 ksadk web static pin --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index db9ffaa8..d33fd7dd 100644 --- a/README.md +++ b/README.md @@ -170,7 +170,7 @@ Export once. Observe anywhere. - 将公开定位从普通 SDK 调整为 Agent Runtime Platform,首页补齐 Why KsADK、30 秒体验、架构说明、Deployment、Observability 和 Community。 - 清理 README、CHANGELOG 和后续 PyPI 元数据中的环境特定表述,避免公开页面出现内部环境名、内部 header 或私有 endpoint 示例。 - 为公开定位、敏感词和 PyPI metadata 增加回归测试,防止后续回退。 -- 本地 Web UI static 默认从 `@kingsoftcloud/ksadk-web@latest` 的 `dist-ksadk` 同步;共享 UI 源码只在 `ksadk-web` 维护。 +- 本地开发可从 `@kingsoftcloud/ksadk-web@latest` 同步 Web UI static;0.6.6 发布候选固定使用 `@kingsoftcloud/ksadk-web@0.2.11`,避免 npm `latest` 变化影响发布包。 - PyPI 发布默认走 GitHub Actions Trusted Publishing,发布前按发布候选固定同步 KSADK Web static,并执行 `make public-preflight`。 ## Documentation From 23ea93a42db2235eccad4f37b6ae76dd63671c0d Mon Sep 17 00:00:00 2001 From: xiayu Date: Tue, 23 Jun 2026 07:14:28 +0800 Subject: [PATCH 40/52] feat: add resumable hosted terminal sessions --- ksadk/server/app.py | 15 + ksadk/server/terminal_sessions.py | 525 +++++++++++++++++++++++++ tests/test_server_terminal_sessions.py | 223 +++++++++++ 3 files changed, 763 insertions(+) create mode 100644 ksadk/server/terminal_sessions.py create mode 100644 tests/test_server_terminal_sessions.py diff --git a/ksadk/server/app.py b/ksadk/server/app.py index 0915c33e..423a814b 100644 --- a/ksadk/server/app.py +++ b/ksadk/server/app.py @@ -34,6 +34,7 @@ ) from ksadk.runners.base_runner import BaseRunner from ksadk.server.api_models import AgentRunRequest +from ksadk.server.terminal_sessions import TerminalSessionManager, register_terminal_routes from ksadk_runtime_common.workspace_files import ( build_workspace_files_bootstrap, create_workspace_files_router, @@ -158,6 +159,7 @@ def _detached_streaming_response( async def _shutdown_runner_resources(): + terminal_manager.reset_for_tests() pending_streams = list(_DETACHED_STREAMS) for task in pending_streams: task.cancel() @@ -252,6 +254,13 @@ def _workspace_root_dir() -> Path: _NATIVE_TUI_FRAMEWORKS = {"hermes", "openclaw"} +def _current_framework() -> str: + if not runner: + return "" + detection_type = getattr(getattr(runner, "detection_result", None), "type", None) + return str(getattr(detection_type, "value", detection_type) or "").strip().lower() + + def _build_native_terminal_capability(framework: str) -> dict[str, Any]: enabled = str(framework or "").strip().lower() in _NATIVE_TUI_FRAMEWORKS return { @@ -262,12 +271,18 @@ def _build_native_terminal_capability(framework: str) -> dict[str, Any]: } +terminal_manager = TerminalSessionManager( + workspace_root_getter=_workspace_root_dir, + framework_getter=_current_framework, +) + app.include_router( create_workspace_files_router( root_getter=_workspace_root_dir, enabled_getter=lambda: workspace_files_enabled(default=True), ) ) +register_terminal_routes(app, terminal_manager) def set_runner(r: BaseRunner): diff --git a/ksadk/server/terminal_sessions.py b/ksadk/server/terminal_sessions.py new file mode 100644 index 00000000..3eab069e --- /dev/null +++ b/ksadk/server/terminal_sessions.py @@ -0,0 +1,525 @@ +"""Native terminal session manager for hosted Hermes/OpenClaw UIs.""" + +from __future__ import annotations + +import asyncio +import contextlib +from dataclasses import dataclass, field +import json +import os +import pty +import select +import shutil +import signal +import termios +import time +import uuid +from pathlib import Path, PurePosixPath +from typing import Any, Callable, Iterable + +from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect +from fastapi.responses import JSONResponse +from starlette.websockets import WebSocketState + +from ksadk.hermes_terminal import TERMINAL_SUBPROTOCOL +from ksadk.terminal_exec_policy import ( + GENERIC_TERMINAL_EXEC_POLICY, + HERMES_TERMINAL_EXEC_POLICY, + OPENCLAW_TERMINAL_EXEC_POLICY, +) +from ksadk.terminal_exec_policy import ( + validate_terminal_exec_argv as validate_exec_argv_with_policy, +) + + +TERMINAL_REPLAY_BUFFER_BYTES = 64 * 1024 +TERMINAL_DETACHED_TTL_SECONDS = 24 * 60 * 60 +MAX_TERMINAL_SESSIONS_PER_BUSINESS_SESSION = 3 +MAX_TERMINAL_SESSIONS_PER_RUNTIME = 32 + + +@dataclass +class TerminalSession: + id: str + session_id: str = "" + mode: str = "tui" + framework: str = "" + argv: list[str] = field(default_factory=list) + cols: int = 80 + rows: int = 24 + cwd: str = "" + status: str = "running" + created_at: float = field(default_factory=time.time) + updated_at: float = field(default_factory=time.time) + pid: int | None = None + fd: int | None = None + exit_code: int | None = None + deleted: bool = False + transient: bool = False + reader_task: asyncio.Task | None = None + wait_task: asyncio.Task | None = None + attachments: set[WebSocket] = field(default_factory=set) + replay_buffer: bytearray = field(default_factory=bytearray) + + +def _utc_timestamp(value: float) -> str: + return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(value)) + + +def _env_int(name: str, default: int) -> int: + raw = os.getenv(name) + if raw is None: + return default + try: + return int(raw) + except (TypeError, ValueError): + return default + + +def _env_bool(name: str, default: bool) -> bool: + raw = os.getenv(name) + if raw is None: + return default + return raw.strip().lower() not in {"0", "false", "no", "off"} + + +class TerminalSessionManager: + def __init__( + self, + *, + workspace_root_getter: Callable[[], Path], + framework_getter: Callable[[], str], + ): + self.workspace_root_getter = workspace_root_getter + self.framework_getter = framework_getter + self.sessions: dict[str, TerminalSession] = {} + self._lock = asyncio.Lock() + + def reset_for_tests(self) -> None: + for session in list(self.sessions.values()): + self._terminate_session(session) + self.sessions.clear() + + def serialize(self, session: TerminalSession) -> dict[str, Any]: + return { + "terminal_session_id": session.id, + "session_id": session.session_id, + "mode": session.mode, + "status": session.status, + "cols": session.cols, + "rows": session.rows, + "cwd": session.cwd, + "created_at": _utc_timestamp(session.created_at), + "updated_at": _utc_timestamp(session.updated_at), + "exit_code": session.exit_code, + } + + async def create_or_reuse(self, payload: dict[str, Any]) -> TerminalSession: + session_id = str( + payload.get("session_id") or payload.get("sessionId") or payload.get("SessionId") or "" + ).strip() + mode = str(payload.get("mode") or "tui").strip().lower() + force_new = bool(payload.get("force_new") or payload.get("forceNew")) + async with self._lock: + self._cleanup_expired_locked() + if not force_new: + existing = self._find_reusable_locked(session_id=session_id, mode=mode) + if existing: + existing.cols = int(payload.get("cols") or existing.cols or 80) + existing.rows = int(payload.get("rows") or existing.rows or 24) + existing.updated_at = time.time() + if existing.fd is not None: + _set_winsize(existing.fd, existing.rows, existing.cols) + return existing + + self._enforce_limits_locked(session_id=session_id) + session = TerminalSession( + id=f"term-{uuid.uuid4().hex[:12]}", + session_id=session_id, + mode=mode, + framework=self._current_framework(), + argv=[str(item) for item in (payload.get("argv") or [])], + cols=int(payload.get("cols") or 80), + rows=int(payload.get("rows") or 24), + cwd=str(payload.get("cwd") or "").strip(), + ) + self._resolve_terminal_command(session) + self.sessions[session.id] = session + self._spawn_session(session) + self._persist_metadata(session) + return session + + async def list_sessions(self, *, session_id: str = "", mode: str = "") -> list[TerminalSession]: + async with self._lock: + self._cleanup_expired_locked() + normalized_session_id = str(session_id or "").strip() + normalized_mode = str(mode or "").strip().lower() + sessions = [ + session + for session in self.sessions.values() + if not session.deleted + and (not normalized_session_id or session.session_id == normalized_session_id) + and (not normalized_mode or session.mode == normalized_mode) + ] + return sorted( + sessions, + key=lambda item: ( + item.status in {"running", "detached"}, + item.updated_at, + ), + reverse=True, + ) + + async def delete(self, terminal_session_id: str) -> TerminalSession | None: + async with self._lock: + session = self.sessions.get(terminal_session_id) + if not session or session.deleted: + return None + session.deleted = True + session.status = "deleted" + session.updated_at = time.time() + self._terminate_session(session) + self._persist_metadata(session) + return session + + async def attach(self, ws: WebSocket, terminal_session_id: str) -> None: + session = self.sessions.get(terminal_session_id) + if not session or session.deleted: + raise ValueError("terminal session not found") + await self._attach_existing(ws, session) + + async def legacy_start(self, ws: WebSocket, payload: dict[str, Any]) -> None: + session = TerminalSession( + id=f"term-{uuid.uuid4().hex[:12]}", + session_id=str( + payload.get("session_id") or payload.get("sessionId") or payload.get("SessionId") or "" + ).strip(), + mode=str(payload.get("mode") or "tui").strip().lower(), + framework=self._current_framework(), + argv=[str(item) for item in (payload.get("argv") or [])], + cols=int(payload.get("cols") or 80), + rows=int(payload.get("rows") or 24), + cwd=str(payload.get("cwd") or "").strip(), + transient=True, + ) + self._resolve_terminal_command(session) + self.sessions[session.id] = session + self._spawn_session(session) + try: + await self._attach_existing(ws, session) + finally: + self._terminate_session(session) + self.sessions.pop(session.id, None) + + def _current_framework(self) -> str: + return str(self.framework_getter() or "").strip().lower() + + def _find_reusable_locked(self, *, session_id: str, mode: str) -> TerminalSession | None: + if not session_id: + return None + candidates = [ + session + for session in self.sessions.values() + if not session.deleted + and session.session_id == session_id + and session.mode == mode + and session.status in {"running", "detached"} + ] + if not candidates: + return None + return max(candidates, key=lambda item: item.updated_at) + + def _cleanup_expired_locked(self) -> None: + now = time.time() + for session in list(self.sessions.values()): + if session.deleted: + continue + if session.status == "detached" and now - session.updated_at > TERMINAL_DETACHED_TTL_SECONDS: + session.deleted = True + session.status = "deleted" + self._terminate_session(session) + + def _enforce_limits_locked(self, *, session_id: str) -> None: + active_sessions = [ + session + for session in self.sessions.values() + if not session.deleted and session.status in {"running", "detached"} + ] + if len(active_sessions) >= _env_int( + "AGENTENGINE_TERMINAL_MAX_RUNTIME_SESSIONS", + MAX_TERMINAL_SESSIONS_PER_RUNTIME, + ): + raise ValueError("too many terminal sessions in this runtime") + if session_id: + per_business_session = [session for session in active_sessions if session.session_id == session_id] + if len(per_business_session) >= _env_int( + "AGENTENGINE_TERMINAL_MAX_SESSIONS_PER_BUSINESS_SESSION", + MAX_TERMINAL_SESSIONS_PER_BUSINESS_SESSION, + ): + raise ValueError("too many terminal sessions for this session_id") + + def _resolve_terminal_command(self, session: TerminalSession) -> list[str]: + mode = session.mode + framework = session.framework + if mode == "tui": + return self._resolve_tui_command(session) + if mode == "exec": + policy = HERMES_TERMINAL_EXEC_POLICY if framework == "hermes" else OPENCLAW_TERMINAL_EXEC_POLICY + if framework not in {"hermes", "openclaw"}: + policy = GENERIC_TERMINAL_EXEC_POLICY + return validate_exec_argv_with_policy(session.argv, policy=policy) + raise ValueError(f"unsupported terminal mode: {mode}") + + def _resolve_tui_command(self, session: TerminalSession) -> list[str]: + framework = session.framework + if framework == "hermes" and shutil.which("hermes"): + command = ["hermes", "chat"] + if session.session_id and _env_bool("HERMES_TERMINAL_RESUME_ENABLED", True): + command.extend([os.getenv("HERMES_TERMINAL_RESUME_FLAG", "--resume"), session.session_id]) + session.argv = command + return command + if framework == "openclaw" and shutil.which("openclaw"): + command = ["openclaw", "tui"] + if session.session_id and _env_bool("OPENCLAW_TERMINAL_RESUME_ENABLED", True): + command.extend([os.getenv("OPENCLAW_TERMINAL_RESUME_FLAG", "--resume"), session.session_id]) + session.argv = command + return command + shell = os.getenv("SHELL") or "/bin/sh" + session.argv = [shell] + return session.argv + + def _resolve_terminal_cwd(self, cwd: str | None) -> Path | None: + raw = str(cwd or "").strip().replace("\\", "/") + if not raw: + return None + + workspace_root = self.workspace_root_getter().resolve() + if raw in {".", "/"}: + resolved = workspace_root + else: + parts = [part for part in PurePosixPath(raw.lstrip("/")).parts if part not in {"", "."}] + if any(part == ".." for part in parts): + raise ValueError("workspace cwd escapes the workspace root") + resolved = workspace_root.joinpath(*parts).resolve() + + try: + resolved.relative_to(workspace_root) + except ValueError as exc: + raise ValueError("workspace cwd escapes the workspace root") from exc + if not resolved.exists(): + raise ValueError(f"workspace cwd does not exist: {raw}") + if not resolved.is_dir(): + raise ValueError(f"workspace cwd is not a directory: {raw}") + return resolved + + def _spawn_session(self, session: TerminalSession) -> None: + if session.pid is not None: + return + command = self._resolve_terminal_command(session) + terminal_cwd = self._resolve_terminal_cwd(session.cwd) + pid, fd = pty.fork() + if pid == 0: + if terminal_cwd is not None: + os.chdir(str(terminal_cwd)) + os.execvp(command[0], command) + session.pid = pid + session.fd = fd + session.status = "running" + session.updated_at = time.time() + _set_winsize(fd, session.rows, session.cols) + session.reader_task = asyncio.create_task(self._session_reader(session)) + session.wait_task = asyncio.create_task(self._session_waiter(session)) + + def _terminate_session(self, session: TerminalSession) -> None: + if session.pid is not None and session.status not in {"exited", "deleted"}: + with contextlib.suppress(ProcessLookupError): + os.kill(session.pid, signal.SIGTERM) + for task in (session.reader_task, session.wait_task): + if task and not task.done(): + task.cancel() + if session.fd is not None: + with contextlib.suppress(OSError): + os.close(session.fd) + session.fd = None + + async def _session_reader(self, session: TerminalSession) -> None: + if session.fd is None: + return + loop = asyncio.get_running_loop() + while True: + await loop.run_in_executor(None, lambda: select.select([session.fd], [], [], None)) + try: + data = os.read(session.fd, 4096) + except OSError: + return + if not data: + return + await self._broadcast_bytes(session, data) + + async def _session_waiter(self, session: TerminalSession) -> None: + if session.pid is None: + return + code = await _wait_process(session.pid) + session.exit_code = code + session.status = "exited" + session.updated_at = time.time() + await self._broadcast_control(session, {"type": "exit", "code": code}) + if session.fd is not None: + with contextlib.suppress(OSError): + os.close(session.fd) + session.fd = None + self._persist_metadata(session) + + async def _attach_existing(self, ws: WebSocket, session: TerminalSession) -> None: + session.attachments.add(ws) + session.status = "running" if session.status == "detached" and session.pid is not None else session.status + session.updated_at = time.time() + try: + await ws.send_text( + json.dumps({"type": "ready", "terminal_session_id": session.id}, ensure_ascii=False) + ) + if session.replay_buffer: + await ws.send_bytes(bytes(session.replay_buffer)) + while True: + message = await ws.receive() + if message.get("type") == "websocket.disconnect": + return + if message.get("bytes") is not None: + if session.fd is not None and session.status == "running": + os.write(session.fd, message["bytes"]) + continue + text = message.get("text") + if not text: + continue + control = json.loads(text) + if control.get("type") == "ping": + await ws.send_text(json.dumps({"type": "pong"})) + elif control.get("type") == "resize": + session.rows = int(control.get("rows") or session.rows or 24) + session.cols = int(control.get("cols") or session.cols or 80) + session.updated_at = time.time() + if session.fd is not None: + _set_winsize(session.fd, session.rows, session.cols) + elif control.get("type") == "signal" and session.pid is not None: + sig = signal.SIGINT if control.get("signal") == "SIGINT" else signal.SIGTERM + os.kill(session.pid, sig) + elif control.get("type") == "stdin_eof": + continue + finally: + session.attachments.discard(ws) + if not session.attachments and session.status == "running": + session.status = "detached" + session.updated_at = time.time() + self._persist_metadata(session) + + async def _broadcast_bytes(self, session: TerminalSession, data: bytes) -> None: + session.replay_buffer.extend(data) + overflow = len(session.replay_buffer) - _env_int( + "AGENTENGINE_TERMINAL_REPLAY_BUFFER_BYTES", + TERMINAL_REPLAY_BUFFER_BYTES, + ) + if overflow > 0: + del session.replay_buffer[:overflow] + stale: list[WebSocket] = [] + for attached in list(session.attachments): + try: + await attached.send_bytes(data) + except Exception: + stale.append(attached) + for attached in stale: + session.attachments.discard(attached) + + async def _broadcast_control(self, session: TerminalSession, payload: dict[str, Any]) -> None: + stale: list[WebSocket] = [] + text = json.dumps(payload, ensure_ascii=False) + for attached in list(session.attachments): + try: + await attached.send_text(text) + except Exception: + stale.append(attached) + for attached in stale: + session.attachments.discard(attached) + + def _persist_metadata(self, session: TerminalSession) -> None: + try: + state_dir = Path( + os.getenv("AGENTENGINE_TERMINAL_STATE_DIR", "/home/node/.agentengine/terminal") + ) + state_dir.mkdir(parents=True, exist_ok=True) + (state_dir / f"{session.id}.json").write_text( + json.dumps(self.serialize(session), ensure_ascii=False, indent=2), + encoding="utf-8", + ) + except OSError: + pass + + +def _set_winsize(fd: int, rows: int, cols: int) -> None: + with contextlib.suppress(Exception): + termios.tcsetwinsize(fd, (int(rows or 24), int(cols or 80))) + + +async def _wait_process(pid: int) -> int: + loop = asyncio.get_running_loop() + _, status = await loop.run_in_executor(None, os.waitpid, pid, 0) + if os.WIFEXITED(status): + return os.WEXITSTATUS(status) + if os.WIFSIGNALED(status): + return 128 + os.WTERMSIG(status) + return status + + +def register_terminal_routes(app: FastAPI, manager: TerminalSessionManager) -> None: + @app.post("/_ksadk/terminal/sessions") + async def create_terminal_session(request: Request) -> JSONResponse: + payload = await request.json() + try: + session = await manager.create_or_reuse(payload if isinstance(payload, dict) else {}) + except ValueError as exc: + return JSONResponse({"error": str(exc)}, status_code=400) + return JSONResponse({"session": manager.serialize(session)}) + + @app.get("/_ksadk/terminal/sessions") + async def list_terminal_sessions(request: Request) -> JSONResponse: + sessions = await manager.list_sessions( + session_id=str(request.query_params.get("session_id") or ""), + mode=str(request.query_params.get("mode") or ""), + ) + return JSONResponse({"sessions": [manager.serialize(session) for session in sessions]}) + + @app.delete("/_ksadk/terminal/sessions/{terminal_session_id}") + async def delete_terminal_session(terminal_session_id: str) -> JSONResponse: + session = await manager.delete(terminal_session_id) + if not session: + return JSONResponse( + {"deleted": False, "terminal_session_id": terminal_session_id}, + status_code=404, + ) + return JSONResponse({"deleted": True, "terminal_session_id": terminal_session_id}) + + @app.websocket("/_ksadk/terminal/ws") + async def terminal_ws(ws: WebSocket) -> None: + if TERMINAL_SUBPROTOCOL not in (ws.headers.get("sec-websocket-protocol") or ""): + await ws.close(code=4400, reason="missing ks-terminal.v1 subprotocol") + return + await ws.accept(subprotocol=TERMINAL_SUBPROTOCOL) + try: + query_session_id = str(ws.query_params.get("terminal_session_id") or "").strip() + if query_session_id: + await manager.attach(ws, query_session_id) + return + first = await ws.receive_text() + payload = json.loads(first) + if payload.get("type") == "attach": + terminal_session_id = str(payload.get("terminal_session_id") or "").strip() + await manager.attach(ws, terminal_session_id) + return + if payload.get("type") != "start": + raise ValueError("first frame must be start") + await manager.legacy_start(ws, payload) + except WebSocketDisconnect: + return + except Exception as exc: + if ws.client_state == WebSocketState.CONNECTED: + await ws.send_text(json.dumps({"type": "error", "message": str(exc)})) + await ws.close() diff --git a/tests/test_server_terminal_sessions.py b/tests/test_server_terminal_sessions.py new file mode 100644 index 00000000..4bcca476 --- /dev/null +++ b/tests/test_server_terminal_sessions.py @@ -0,0 +1,223 @@ +from __future__ import annotations + +import importlib +import json +from types import SimpleNamespace + +import httpx +import pytest +from fastapi.testclient import TestClient + +from ksadk.runners.base_runner import BaseRunner +from ksadk.hermes_terminal import TERMINAL_SUBPROTOCOL +from ksadk.server.terminal_sessions import TerminalSession + + +class _OpenClawRunner(BaseRunner): + def __init__(self): + super().__init__( + detection_result=SimpleNamespace( + name="openclaw-agent", + type=SimpleNamespace(value="openclaw"), + ), + project_dir=".", + ) + + def load_agent(self) -> None: + return None + + async def invoke(self, input_data: dict) -> dict: + return {"output": "ok"} + + async def stream(self, input_data: dict): + yield {"type": "final", "output": "ok"} + + +@pytest.fixture() +def server_app(monkeypatch, tmp_path): + appmod = importlib.import_module("ksadk.server.app") + monkeypatch.setenv("AGENTENGINE_TERMINAL_STATE_DIR", str(tmp_path / "terminal")) + monkeypatch.setenv("KSADK_WORKSPACE_ROOT", str(tmp_path / "workspace")) + (tmp_path / "workspace").mkdir() + appmod.set_runner(_OpenClawRunner()) + appmod.terminal_manager.reset_for_tests() + yield appmod + appmod.terminal_manager.reset_for_tests() + + +@pytest.mark.asyncio +async def test_terminal_sessions_reuse_by_business_session_and_mode(server_app, monkeypatch): + spawned: list[tuple[str, list[str]]] = [] + + def fake_spawn(session): + session.pid = 123 + session.fd = None + session.status = "running" + spawned.append((session.id, list(session.argv))) + + monkeypatch.setattr(server_app.terminal_manager, "_spawn_session", fake_spawn) + + transport = httpx.ASGITransport(app=server_app.app) + async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client: + first = await client.post( + "/_ksadk/terminal/sessions", + json={"session_id": "biz-1", "mode": "tui", "cols": 100, "rows": 30}, + ) + second = await client.post( + "/_ksadk/terminal/sessions", + json={"session_id": "biz-1", "mode": "tui", "cols": 90, "rows": 24}, + ) + forced = await client.post( + "/_ksadk/terminal/sessions", + json={"session_id": "biz-1", "mode": "tui", "force_new": True}, + ) + listing = await client.get("/_ksadk/terminal/sessions", params={"session_id": "biz-1", "mode": "tui"}) + + assert first.status_code == 200 + assert second.status_code == 200 + assert forced.status_code == 200 + first_id = first.json()["session"]["terminal_session_id"] + assert second.json()["session"]["terminal_session_id"] == first_id + assert forced.json()["session"]["terminal_session_id"] != first_id + assert len(spawned) == 2 + assert listing.status_code == 200 + sessions = listing.json()["sessions"] + assert {item["session_id"] for item in sessions} == {"biz-1"} + assert {item["mode"] for item in sessions} == {"tui"} + state_files = list((server_app.Path(server_app.os.environ["AGENTENGINE_TERMINAL_STATE_DIR"])).glob("term-*.json")) + assert state_files + persisted = [json.loads(path.read_text(encoding="utf-8")) for path in state_files] + assert any(item["terminal_session_id"] == first_id and item["session_id"] == "biz-1" for item in persisted) + + +@pytest.mark.asyncio +async def test_terminal_session_delete_marks_deleted_and_removes_from_reuse(server_app, monkeypatch): + def fake_spawn(session): + session.pid = 123 + session.fd = None + session.status = "running" + + monkeypatch.setattr(server_app.terminal_manager, "_spawn_session", fake_spawn) + + transport = httpx.ASGITransport(app=server_app.app) + async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client: + created = await client.post( + "/_ksadk/terminal/sessions", + json={"session_id": "biz-2", "mode": "tui"}, + ) + terminal_session_id = created.json()["session"]["terminal_session_id"] + deleted = await client.delete(f"/_ksadk/terminal/sessions/{terminal_session_id}") + recreated = await client.post( + "/_ksadk/terminal/sessions", + json={"session_id": "biz-2", "mode": "tui"}, + ) + + assert deleted.status_code == 200 + assert deleted.json()["deleted"] is True + assert recreated.json()["session"]["terminal_session_id"] != terminal_session_id + + +def test_terminal_websocket_attach_replays_and_detaches_without_deleting(server_app): + session = TerminalSession( + id="term-replay", + session_id="biz-3", + mode="tui", + status="detached", + pid=123, + fd=None, + ) + session.replay_buffer.extend(b"previous output") + server_app.terminal_manager.sessions[session.id] = session + + with TestClient(server_app.app) as client: + with client.websocket_connect( + f"/_ksadk/terminal/ws?terminal_session_id={session.id}", + subprotocols=[TERMINAL_SUBPROTOCOL], + ) as websocket: + ready = websocket.receive_json() + replay = websocket.receive_bytes() + websocket.close() + retained = server_app.terminal_manager.sessions[session.id] + + assert ready == {"type": "ready", "terminal_session_id": session.id} + assert replay == b"previous output" + assert retained.status == "detached" + assert retained.deleted is False + + +def test_legacy_terminal_websocket_keeps_ephemeral_cleanup_semantics(server_app, monkeypatch): + started: list[str] = [] + terminated: list[str] = [] + + def fake_spawn(session): + session.pid = 123 + session.fd = None + session.status = "running" + started.append(session.id) + + def fake_terminate(session): + session.deleted = True + session.status = "deleted" + terminated.append(session.id) + + async def fake_attach(_ws, session): + session.status = "detached" + + monkeypatch.setattr(server_app.terminal_manager, "_spawn_session", fake_spawn) + monkeypatch.setattr(server_app.terminal_manager, "_terminate_session", fake_terminate) + monkeypatch.setattr(server_app.terminal_manager, "_attach_existing", fake_attach) + + with TestClient(server_app.app) as client: + with client.websocket_connect( + "/_ksadk/terminal/ws", + subprotocols=[TERMINAL_SUBPROTOCOL], + ) as websocket: + websocket.send_json({"type": "start", "session_id": "legacy-biz", "mode": "tui"}) + + assert len(started) == 1 + assert terminated == started + assert started[0] not in server_app.terminal_manager.sessions + + +def test_terminal_tui_command_binds_product_resume_id(server_app, monkeypatch): + monkeypatch.setattr("ksadk.server.terminal_sessions.shutil.which", lambda command: f"/usr/bin/{command}") + + session = TerminalSession( + id="term-command", + session_id="biz-4", + mode="tui", + framework="openclaw", + ) + + assert server_app.terminal_manager._resolve_terminal_command(session) == [ + "openclaw", + "tui", + "--resume", + "biz-4", + ] + + session.framework = "hermes" + assert server_app.terminal_manager._resolve_terminal_command(session) == [ + "hermes", + "chat", + "--resume", + "biz-4", + ] + + +def test_terminal_tui_resume_flag_can_be_disabled(server_app, monkeypatch): + monkeypatch.setattr("ksadk.server.terminal_sessions.shutil.which", lambda command: f"/usr/bin/{command}") + monkeypatch.setenv("OPENCLAW_TERMINAL_RESUME_ENABLED", "false") + monkeypatch.setenv("HERMES_TERMINAL_RESUME_ENABLED", "false") + + session = TerminalSession( + id="term-command", + session_id="biz-4", + mode="tui", + framework="openclaw", + ) + + assert server_app.terminal_manager._resolve_terminal_command(session) == ["openclaw", "tui"] + + session.framework = "hermes" + assert server_app.terminal_manager._resolve_terminal_command(session) == ["hermes", "chat"] From 953f536ce5bfe38b37a75d542370bdf270316338 Mon Sep 17 00:00:00 2001 From: xiayu Date: Tue, 23 Jun 2026 07:22:28 +0800 Subject: [PATCH 41/52] test: stabilize terminal session monkeypatching --- tests/test_server_terminal_sessions.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_server_terminal_sessions.py b/tests/test_server_terminal_sessions.py index 4bcca476..6bfba666 100644 --- a/tests/test_server_terminal_sessions.py +++ b/tests/test_server_terminal_sessions.py @@ -10,6 +10,7 @@ from ksadk.runners.base_runner import BaseRunner from ksadk.hermes_terminal import TERMINAL_SUBPROTOCOL +import ksadk.server.terminal_sessions as terminal_sessions from ksadk.server.terminal_sessions import TerminalSession @@ -180,7 +181,7 @@ async def fake_attach(_ws, session): def test_terminal_tui_command_binds_product_resume_id(server_app, monkeypatch): - monkeypatch.setattr("ksadk.server.terminal_sessions.shutil.which", lambda command: f"/usr/bin/{command}") + monkeypatch.setattr(terminal_sessions.shutil, "which", lambda command: f"/usr/bin/{command}") session = TerminalSession( id="term-command", @@ -206,7 +207,7 @@ def test_terminal_tui_command_binds_product_resume_id(server_app, monkeypatch): def test_terminal_tui_resume_flag_can_be_disabled(server_app, monkeypatch): - monkeypatch.setattr("ksadk.server.terminal_sessions.shutil.which", lambda command: f"/usr/bin/{command}") + monkeypatch.setattr(terminal_sessions.shutil, "which", lambda command: f"/usr/bin/{command}") monkeypatch.setenv("OPENCLAW_TERMINAL_RESUME_ENABLED", "false") monkeypatch.setenv("HERMES_TERMINAL_RESUME_ENABLED", "false") From 4d25de3bb23e112e22d1267ec9d01ead42ed6a10 Mon Sep 17 00:00:00 2001 From: xiayu Date: Tue, 23 Jun 2026 08:23:06 +0800 Subject: [PATCH 42/52] fix: fetch bootstrap defaults during dry run --- ksadk/api/client.py | 22 +++++++++++++++---- ksadk/cli/cmd_hermes.py | 1 + ksadk/cli/cmd_openclaw.py | 1 + tests/test_cli_dry_run.py | 38 +++++++++++++++++++++++++++++++++ tests/test_cmd_hermes.py | 25 ++++++++++++++++++++++ tests/test_openclaw_env_vars.py | 36 +++++++++++++++++++++++++++++++ 6 files changed, 119 insertions(+), 4 deletions(-) diff --git a/ksadk/api/client.py b/ksadk/api/client.py index 89826593..0d8d7236 100644 --- a/ksadk/api/client.py +++ b/ksadk/api/client.py @@ -532,6 +532,8 @@ def _request( method: str, path: str, body: Optional[Dict[str, Any]] = None, + *, + ignore_dry_run: bool = False, ) -> Dict[str, Any]: """同步 HTTP 请求""" action = path.rstrip("/").split("/")[-1] if path else "" @@ -539,7 +541,7 @@ def _request( body_str = json.dumps(body, ensure_ascii=False) if body else "" # DryRun 模式 - if self.dry_run: + if self.dry_run and not ignore_dry_run: signed_headers = headers if self._auth.is_enabled: # Dry-run 展示真实即将发送的签名头(不暴露明文 SK) @@ -1043,11 +1045,22 @@ def _display_name_from_content_disposition(value: str) -> str: return Path(filename_match.group(1).strip()).name return "" - def _action(self, action: str, params: Dict[str, Any] = None) -> Dict[str, Any]: + def _action( + self, + action: str, + params: Dict[str, Any] = None, + *, + ignore_dry_run: bool = False, + ) -> Dict[str, Any]: """通用 Action API 调用""" body = params or {} self._maybe_precheck_permission(action, body) - result = self._request("POST", f"/agentengine/api/v1/{action}", body) + result = self._request( + "POST", + f"/agentengine/api/v1/{action}", + body, + ignore_dry_run=ignore_dry_run, + ) # 检查错误 (统一返回格式 {"Code": 0, ...}) code = result.get("Code", 0) @@ -1369,6 +1382,7 @@ async def get_client_bootstrap_config( client_type: str = "cli", client_version: Optional[str] = None, locale: Optional[str] = None, + ignore_dry_run: bool = False, ) -> Dict[str, Any]: """获取客户端启动配置(动态默认值/升级提示/公告)。""" params: Dict[str, Any] = { @@ -1384,7 +1398,7 @@ async def get_client_bootstrap_config( params["ClientVersion"] = client_version if locale: params["Locale"] = locale - return self._action("GetClientBootstrapConfig", params) + return self._action("GetClientBootstrapConfig", params, ignore_dry_run=ignore_dry_run) async def list_dashboard_access_links( self, diff --git a/ksadk/cli/cmd_hermes.py b/ksadk/cli/cmd_hermes.py index 3c47ca5d..80989971 100644 --- a/ksadk/cli/cmd_hermes.py +++ b/ksadk/cli/cmd_hermes.py @@ -238,6 +238,7 @@ async def _fetch_hermes_bootstrap_config(region: str) -> dict[str, Any] | None: client_type="cli", client_version=CLI_VERSION, locale=_env_value("LANG", "LC_ALL"), + ignore_dry_run=True, ) except Exception as e: print_warn(f"拉取 Hermes 服务端默认配置失败,回退本地默认镜像: {e}") diff --git a/ksadk/cli/cmd_openclaw.py b/ksadk/cli/cmd_openclaw.py index c2dfd0f0..752c99db 100644 --- a/ksadk/cli/cmd_openclaw.py +++ b/ksadk/cli/cmd_openclaw.py @@ -1181,6 +1181,7 @@ async def _fetch_bootstrap_config(region: str) -> Optional[Dict[str, Any]]: client_type="cli", client_version=CLI_VERSION, locale=_resolve_env("OPENCLAW_UI_LOCALE", "LANG", "LC_ALL"), + ignore_dry_run=True, ) except Exception as e: print_warn(f"拉取服务端默认配置失败,回退本地默认镜像: {e}") diff --git a/tests/test_cli_dry_run.py b/tests/test_cli_dry_run.py index 4262d10e..a6395128 100644 --- a/tests/test_cli_dry_run.py +++ b/tests/test_cli_dry_run.py @@ -604,6 +604,44 @@ def test_client_respects_global_dry_run_env(monkeypatch): assert client.dry_run is True +def test_client_bootstrap_config_can_ignore_dry_run(monkeypatch): + client = AgentEngineClient(base_url="http://example.com", access_key="", secret_key="", dry_run=True) + captured = {} + + def fake_request(method, path, body=None, *, ignore_dry_run=False): + captured.update( + { + "method": method, + "path": path, + "body": body, + "ignore_dry_run": ignore_dry_run, + } + ) + return { + "Code": 0, + "Data": { + "Configs": { + "bootstrap.default_image": "registry.example.com/runtime:db", + } + }, + } + + monkeypatch.setattr(client, "_request", fake_request) + + result = asyncio.run( + client.get_client_bootstrap_config( + product="openclaw", + framework="openclaw", + region="pre-online", + ignore_dry_run=True, + ) + ) + + assert captured["ignore_dry_run"] is True + assert captured["body"]["Product"] == "openclaw" + assert result["configs"]["bootstrap.default_image"] == "registry.example.com/runtime:db" + + def test_mcp_status_supports_dry_run(monkeypatch): runner = CliRunner() monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeDryRunClient) diff --git a/tests/test_cmd_hermes.py b/tests/test_cmd_hermes.py index 2cfaa80d..e0089c5c 100644 --- a/tests/test_cmd_hermes.py +++ b/tests/test_cmd_hermes.py @@ -275,7 +275,10 @@ async def create_agent(self, payload): class _FakeHermesBootstrapImageClient(_FakeHermesClient): + bootstrap_kwargs = None + async def get_client_bootstrap_config(self, **kwargs): + self.__class__.bootstrap_kwargs = dict(kwargs) assert kwargs["product"] == "hermes" assert kwargs["framework"] == "hermes" return { @@ -1164,6 +1167,7 @@ def test_hermes_deploy_normalizes_ui_locale_from_lang(tmp_path: Path, monkeypatc def test_hermes_deploy_prefers_bootstrap_default_image(tmp_path: Path, monkeypatch): runner = CliRunner() _FakeHermesBootstrapImageClient.create_payload = None + _FakeHermesBootstrapImageClient.bootstrap_kwargs = None monkeypatch.chdir(tmp_path) monkeypatch.setenv("OPENAI_API_KEY", "sk-test") monkeypatch.setenv("OPENAI_BASE_URL", "https://model.example.com/v1") @@ -1177,6 +1181,27 @@ def test_hermes_deploy_prefers_bootstrap_default_image(tmp_path: Path, monkeypat _FakeHermesBootstrapImageClient.create_payload["artifact_path"] == "registry.example.com/agentengine-public/hermes-agent:db-meta" ) + assert _FakeHermesBootstrapImageClient.bootstrap_kwargs["ignore_dry_run"] is True + + +def test_hermes_deploy_dry_run_still_reads_bootstrap_default_image(tmp_path: Path, monkeypatch): + runner = CliRunner() + _FakeHermesBootstrapImageClient.create_payload = None + _FakeHermesBootstrapImageClient.bootstrap_kwargs = None + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("OPENAI_API_KEY", "sk-test") + monkeypatch.setenv("OPENAI_BASE_URL", "https://model.example.com/v1") + monkeypatch.setenv("OPENAI_MODEL_NAME", "glm-test") + monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesBootstrapImageClient) + + result = runner.invoke(cmd_hermes.hermes, ["deploy", "--name", "demo-hermes", "--dry-run"]) + + assert result.exit_code == 0, result.output + assert _FakeHermesBootstrapImageClient.bootstrap_kwargs["ignore_dry_run"] is True + assert ( + _FakeHermesBootstrapImageClient.create_payload["artifact_path"] + == "registry.example.com/agentengine-public/hermes-agent:db-meta" + ) def test_hermes_deploy_updates_existing_hermes_state(tmp_path: Path, monkeypatch): diff --git a/tests/test_openclaw_env_vars.py b/tests/test_openclaw_env_vars.py index ee497055..0e24604f 100644 --- a/tests/test_openclaw_env_vars.py +++ b/tests/test_openclaw_env_vars.py @@ -1,3 +1,4 @@ +import asyncio import re import json import pytest @@ -5,6 +6,41 @@ from ksadk.cli import cmd_openclaw +class _FakeOpenClawBootstrapClient: + kwargs = None + bootstrap_kwargs = None + + def __init__(self, *args, **kwargs): + self.__class__.kwargs = dict(kwargs) + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def get_client_bootstrap_config(self, **kwargs): + self.__class__.bootstrap_kwargs = dict(kwargs) + return { + "configs": { + "bootstrap.default_image": "registry.example.com/openclaw:db", + } + } + + +def test_fetch_openclaw_bootstrap_config_ignores_dry_run(monkeypatch): + _FakeOpenClawBootstrapClient.kwargs = None + _FakeOpenClawBootstrapClient.bootstrap_kwargs = None + monkeypatch.setenv("AGENTENGINE_GLOBAL_DRY_RUN", "1") + monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawBootstrapClient) + + result = asyncio.run(cmd_openclaw._fetch_bootstrap_config("pre-online")) + + assert result["configs"]["bootstrap.default_image"] == "registry.example.com/openclaw:db" + assert _FakeOpenClawBootstrapClient.kwargs["region"] == "pre-online" + assert _FakeOpenClawBootstrapClient.bootstrap_kwargs["ignore_dry_run"] is True + + def test_build_openclaw_env_vars_defaults_to_trusted_proxy(monkeypatch): monkeypatch.setattr(cmd_openclaw, "_GLOBAL_ENV_CACHE", {}) monkeypatch.delenv("OPENCLAW_GATEWAY_AUTH_MODE", raising=False) From 2420c0e0be7ef9366e43d9d7a824234ba3517891 Mon Sep 17 00:00:00 2001 From: xiayu Date: Tue, 23 Jun 2026 08:33:30 +0800 Subject: [PATCH 43/52] docs: mention dry-run bootstrap fix --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 446e04e2..51f26013 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,7 @@ - 修复 Python 3.10 环境下 workspace files router 使用 `datetime.UTC` 带来的兼容性问题。 - 修复 E2B sandbox 可选依赖缺失时测试不能跳过的问题,降低最小开发环境运行公开测试的成本。 - 修复公开仓库审计误拦截受控公开文档和受控外部引用的问题,并继续禁止内部 endpoint、私有 header、真实凭证和 kubeconfig 进入公开材料。 +- 修复 Hermes / OpenClaw `--dry-run` 在全局 dry-run 下无法读取 bootstrap 只读配置的问题;dry-run 现在会渲染实际将使用的运行时 artifact 与模型策略。 ### 测试与发布 From 5061f5d76cf764c3898a1a2c9ad5d41a93b6d4d8 Mon Sep 17 00:00:00 2001 From: xiayu Date: Tue, 23 Jun 2026 08:40:11 +0800 Subject: [PATCH 44/52] fix: keep dry-run bypass compatible with request stubs --- ksadk/api/client.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/ksadk/api/client.py b/ksadk/api/client.py index 0d8d7236..4a120856 100644 --- a/ksadk/api/client.py +++ b/ksadk/api/client.py @@ -1055,12 +1055,8 @@ def _action( """通用 Action API 调用""" body = params or {} self._maybe_precheck_permission(action, body) - result = self._request( - "POST", - f"/agentengine/api/v1/{action}", - body, - ignore_dry_run=ignore_dry_run, - ) + request_kwargs = {"ignore_dry_run": True} if ignore_dry_run else {} + result = self._request("POST", f"/agentengine/api/v1/{action}", body, **request_kwargs) # 检查错误 (统一返回格式 {"Code": 0, ...}) code = result.get("Code", 0) From d6a62ac4e3cba6356b7c2148cf0cf1a1a421cec8 Mon Sep 17 00:00:00 2001 From: xiayu Date: Tue, 23 Jun 2026 10:33:52 +0800 Subject: [PATCH 45/52] fix: use OpenClaw session flag for terminal TUI --- CHANGELOG.md | 3 ++- ksadk/server/terminal_sessions.py | 4 ++-- tests/test_server_terminal_sessions.py | 28 ++++++++++++++++++++++++-- 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 51f26013..f6d7f479 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,7 +30,7 @@ - `AgentEngineClient` 新增 `AttachmentContent` 与 `download_attachment_content()`,并修正 `list_sessions()` 请求字段为 `PageSize`。 - runtime 上传附件会持久化 metadata、本地路径和 MIME 信息;Hosted 附件下载后会写回本地 cache,供 runner、workspace preview 和会话恢复复用。 - Workspace zip 导出优先走新的 runtime raw export endpoint,并保留 legacy runtime archive fallback,降低不同 KsADK runtime 版本混跑时的兼容风险。 -- Native terminal session manager 新增 HTTP session lifecycle 与 WebSocket attach 语义,断线默认 detach 而不是杀掉 PTY;Hermes/OpenClaw `--resume` 可以绑定同一 terminal session。 +- Native terminal session manager 新增 HTTP session lifecycle 与 WebSocket attach 语义,断线默认 detach 而不是杀掉 PTY;Hermes 使用 `--resume`、OpenClaw 使用 `--session` 绑定同一 terminal session。 - `agentengine deploy`、`agentengine launch`、`agentengine mcp deploy` 和 OpenClaw/Hermes 相关命令补齐 no-cache、dry-run JSON envelope、网络参数、环境变量和显式配置覆盖测试。 - `AgentEngineClient` 与远程 runner 补齐 framework passthrough、权限预检查、错误日志、MCP payload、workspace files 和 attachment content 的契约测试。 - LangGraph、LangChain、ADK、DeepAgents、A2A、本地 re-exec、session continuity、Postgres session service、sandbox backend 和 Skill Runtime 相关路径补齐回归覆盖。 @@ -50,6 +50,7 @@ - 修复 E2B sandbox 可选依赖缺失时测试不能跳过的问题,降低最小开发环境运行公开测试的成本。 - 修复公开仓库审计误拦截受控公开文档和受控外部引用的问题,并继续禁止内部 endpoint、私有 header、真实凭证和 kubeconfig 进入公开材料。 - 修复 Hermes / OpenClaw `--dry-run` 在全局 dry-run 下无法读取 bootstrap 只读配置的问题;dry-run 现在会渲染实际将使用的运行时 artifact 与模型策略。 +- 修复 OpenClaw TUI 新建 terminal session 时误传不支持的 `--resume` 参数导致启动失败的问题。 ### 测试与发布 diff --git a/ksadk/server/terminal_sessions.py b/ksadk/server/terminal_sessions.py index 3eab069e..c1cccfa6 100644 --- a/ksadk/server/terminal_sessions.py +++ b/ksadk/server/terminal_sessions.py @@ -280,8 +280,8 @@ def _resolve_tui_command(self, session: TerminalSession) -> list[str]: return command if framework == "openclaw" and shutil.which("openclaw"): command = ["openclaw", "tui"] - if session.session_id and _env_bool("OPENCLAW_TERMINAL_RESUME_ENABLED", True): - command.extend([os.getenv("OPENCLAW_TERMINAL_RESUME_FLAG", "--resume"), session.session_id]) + if session.session_id: + command.extend([os.getenv("OPENCLAW_TERMINAL_SESSION_FLAG", "--session"), session.session_id]) session.argv = command return command shell = os.getenv("SHELL") or "/bin/sh" diff --git a/tests/test_server_terminal_sessions.py b/tests/test_server_terminal_sessions.py index 6bfba666..63b33fe3 100644 --- a/tests/test_server_terminal_sessions.py +++ b/tests/test_server_terminal_sessions.py @@ -193,7 +193,7 @@ def test_terminal_tui_command_binds_product_resume_id(server_app, monkeypatch): assert server_app.terminal_manager._resolve_terminal_command(session) == [ "openclaw", "tui", - "--resume", + "--session", "biz-4", ] @@ -218,7 +218,31 @@ def test_terminal_tui_resume_flag_can_be_disabled(server_app, monkeypatch): framework="openclaw", ) - assert server_app.terminal_manager._resolve_terminal_command(session) == ["openclaw", "tui"] + assert server_app.terminal_manager._resolve_terminal_command(session) == [ + "openclaw", + "tui", + "--session", + "biz-4", + ] session.framework = "hermes" assert server_app.terminal_manager._resolve_terminal_command(session) == ["hermes", "chat"] + + +def test_terminal_tui_openclaw_session_flag_can_be_overridden(server_app, monkeypatch): + monkeypatch.setattr(terminal_sessions.shutil, "which", lambda command: f"/usr/bin/{command}") + monkeypatch.setenv("OPENCLAW_TERMINAL_SESSION_FLAG", "--conversation") + + session = TerminalSession( + id="term-command", + session_id="biz-4", + mode="tui", + framework="openclaw", + ) + + assert server_app.terminal_manager._resolve_terminal_command(session) == [ + "openclaw", + "tui", + "--conversation", + "biz-4", + ] From c6c035863eba51445b9867138f13157d36dfcb7c Mon Sep 17 00:00:00 2001 From: xiayu Date: Tue, 23 Jun 2026 12:49:24 +0800 Subject: [PATCH 46/52] fix: keep runtime model policy and hermes dashboard open stable --- ksadk/cli/cmd_hermes.py | 7 +++++++ ksadk/model_policy.py | 8 ++++++-- tests/test_cmd_hermes.py | 3 +++ tests/test_model_policy.py | 3 +++ tests/test_openclaw_env_vars.py | 28 +++++++++++++++++++++++++++- 5 files changed, 46 insertions(+), 3 deletions(-) diff --git a/ksadk/cli/cmd_hermes.py b/ksadk/cli/cmd_hermes.py index 80989971..9c309e5c 100644 --- a/ksadk/cli/cmd_hermes.py +++ b/ksadk/cli/cmd_hermes.py @@ -943,6 +943,12 @@ def open_hermes( ): """打开 Hermes 管理 UI,或使用 --chat 打开统一聊天页。""" _ = output_mode + ctx = click.get_current_context(silent=True) + region_source = ( + ctx.get_parameter_source("region").name.lower() + if ctx is not None and ctx.get_parameter_source("region") is not None + else "" + ) if manage and chat: raise click.ClickException("--manage 与 --chat 不能同时使用") try: @@ -974,6 +980,7 @@ def open_hermes( positional_agent=positional_agent, agent_option=None, region=region, + region_source=region_source, ui_path=target_path, share=share, expires_seconds=parsed_expires, diff --git a/ksadk/model_policy.py b/ksadk/model_policy.py index fee62e48..526bf470 100644 --- a/ksadk/model_policy.py +++ b/ksadk/model_policy.py @@ -39,13 +39,16 @@ }, "models": { DEFAULT_MODEL_NAME: { + "reasoning": True, "options": {}, }, DEFAULT_MULTIMODAL_MODEL: { "input": ["text", "image"], + "reasoning": True, "options": {"temperature": 1}, }, DEFAULT_FALLBACK_MODEL: { + "reasoning": True, "options": {}, }, }, @@ -168,6 +171,8 @@ def _catalog_from_policy(policy: Mapping[str, Any]) -> list[dict[str, Any]]: "api": metadata.get("api") or "openai-completions", "input": metadata.get("input") or ["text"], } + if isinstance(metadata.get("reasoning"), bool): + item["reasoning"] = metadata["reasoning"] if isinstance(metadata.get("options"), Mapping) and metadata["options"]: item["options"] = dict(metadata["options"]) catalog.append(item) @@ -195,8 +200,7 @@ def build_runtime_model_policy_env( ) runtime_name = str(runtime or "").strip().lower() if runtime_name == "openclaw": - has_catalog = bool(str(env.get("OPENCLAW_MODEL_CATALOG_JSON") or "").strip()) - if primary and not has_primary and not has_catalog: + if primary and not has_primary: env["OPENAI_MODEL_NAME"] = _provider_ref(primary) if fallback: env.setdefault("OPENCLAW_FALLBACK_MODEL", _provider_ref(fallback)) diff --git a/tests/test_cmd_hermes.py b/tests/test_cmd_hermes.py index e0089c5c..f5b58eb2 100644 --- a/tests/test_cmd_hermes.py +++ b/tests/test_cmd_hermes.py @@ -681,6 +681,8 @@ def test_hermes_open_defaults_to_manage_and_supports_chat_override(monkeypatch): assert chat_result.exit_code == 0, chat_result.output assert opened[0]["ui_path"] == "/" assert opened[1]["ui_path"] == "/chat" + assert opened[0]["region_source"] == "default" + assert opened[1]["region_source"] == "default" def test_hermes_open_force_new_forwards_to_dashboard(monkeypatch): @@ -714,6 +716,7 @@ def test_hermes_open_force_new_forwards_to_dashboard(monkeypatch): assert opened[0]["share"] is True assert opened[0]["expires_seconds"] == 86400 assert opened[0]["force_new"] is True + assert opened[0]["region_source"] == "default" def test_hermes_open_dry_run_does_not_resolve_or_open(monkeypatch): diff --git a/tests/test_model_policy.py b/tests/test_model_policy.py index 91d3165e..990a63e5 100644 --- a/tests/test_model_policy.py +++ b/tests/test_model_policy.py @@ -16,6 +16,9 @@ def test_default_model_policy_matches_release_defaults(): assert policy["primary"]["model"] == "glm-5.2" assert policy["multimodal"]["model"] == "kimi-k2.7-code" assert policy["fallback"]["model"] == "deepseek-v4-pro" + assert policy["models"]["glm-5.2"]["reasoning"] is True + assert policy["models"]["kimi-k2.7-code"]["reasoning"] is True + assert policy["models"]["deepseek-v4-pro"]["reasoning"] is True assert policy["models"]["kimi-k2.7-code"]["options"]["temperature"] == 1 diff --git a/tests/test_openclaw_env_vars.py b/tests/test_openclaw_env_vars.py index 0e24604f..169872da 100644 --- a/tests/test_openclaw_env_vars.py +++ b/tests/test_openclaw_env_vars.py @@ -275,7 +275,33 @@ def test_build_openclaw_env_vars_injects_default_model_policy(monkeypatch): assert "AGENTENGINE_MODEL_POLICY_JSON" in env catalog = json.loads(env["OPENCLAW_MODEL_CATALOG_JSON"]) assert [item["id"] for item in catalog] == ["glm-5.2", "kimi-k2.7-code", "deepseek-v4-pro"] - assert catalog[1]["options"] == {"temperature": 1} + assert {item["id"]: item["reasoning"] for item in catalog} == { + "glm-5.2": True, + "kimi-k2.7-code": True, + "deepseek-v4-pro": True, + } + assert "OPENCLAW_MODEL_BASE_URL" not in env + assert "OPENCLAW_MODEL_PROVIDER_ID" not in env + assert "OPENCLAW_MODEL_API" not in env + + +def test_openclaw_model_policy_env_keeps_default_primary_with_catalog(monkeypatch): + monkeypatch.setattr(cmd_openclaw, "_GLOBAL_ENV_CACHE", {}) + monkeypatch.delenv("OPENCLAW_DEFAULT_MODEL", raising=False) + monkeypatch.delenv("OPENAI_MODEL_NAME", raising=False) + monkeypatch.delenv("MODEL_NAME", raising=False) + monkeypatch.delenv("LLM_MODEL", raising=False) + monkeypatch.delenv("OPENCLAW_MODEL_BASE_URL", raising=False) + monkeypatch.delenv("OPENAI_BASE_URL", raising=False) + monkeypatch.delenv("OPENAI_API_BASE", raising=False) + monkeypatch.delenv("OPENCLAW_MODEL_PROVIDER_ID", raising=False) + monkeypatch.delenv("OPENCLAW_MODEL_API", raising=False) + monkeypatch.setenv("OPENCLAW_MODEL_CATALOG_JSON", '[{"id":"kimi-k2.7-code"},{"id":"glm-5.2"}]') + + env = cmd_openclaw._build_openclaw_env_vars() + + assert env["OPENAI_MODEL_NAME"] == "ksyun/glm-5.2" + assert env["OPENCLAW_MODEL_CATALOG_JSON"] == '[{"id":"kimi-k2.7-code"},{"id":"glm-5.2"}]' assert "OPENCLAW_MODEL_BASE_URL" not in env assert "OPENCLAW_MODEL_PROVIDER_ID" not in env assert "OPENCLAW_MODEL_API" not in env From 487844577bab638ff594874d4c08d327a46d60d6 Mon Sep 17 00:00:00 2001 From: xiayu Date: Tue, 23 Jun 2026 12:53:01 +0800 Subject: [PATCH 47/52] docs: update 0.6.6 changelog for final sdk fixes --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f6d7f479..8be6efa7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,8 @@ - 修复公开仓库审计误拦截受控公开文档和受控外部引用的问题,并继续禁止内部 endpoint、私有 header、真实凭证和 kubeconfig 进入公开材料。 - 修复 Hermes / OpenClaw `--dry-run` 在全局 dry-run 下无法读取 bootstrap 只读配置的问题;dry-run 现在会渲染实际将使用的运行时 artifact 与模型策略。 - 修复 OpenClaw TUI 新建 terminal session 时误传不支持的 `--resume` 参数导致启动失败的问题。 +- 修复 `agentengine hermes open --chat` 调用 dashboard open 时缺少 region source,导致命令行打开 Hosted Chat 报错的问题。 +- 修复 OpenClaw 已存在 `OPENCLAW_MODEL_CATALOG_JSON` 时默认主模型可能被 catalog 首项带偏的问题,并在默认模型目录中声明 `reasoning=true` 以启用思考能力。 ### 测试与发布 From bbf7b88d1ce09331cfb71d583503d46864527f27 Mon Sep 17 00:00:00 2001 From: xiayu Date: Tue, 23 Jun 2026 12:55:55 +0800 Subject: [PATCH 48/52] docs: set 0.6.6 changelog release date --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8be6efa7..7728321e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ 格式参考 [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 版本遵循 [Semantic Versioning](https://semver.org/spec/v2.0.0.html)。 -## [0.6.6] - 2026-06-18 +## [0.6.6] - 2026-06-23 ### 亮点 From d26a203b8078920e9227cc3e58b6e5ae948f21f5 Mon Sep 17 00:00:00 2001 From: xiayu Date: Tue, 23 Jun 2026 12:58:20 +0800 Subject: [PATCH 49/52] test: align 0.6.6 changelog release date --- tests/test_public_release_positioning.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_public_release_positioning.py b/tests/test_public_release_positioning.py index 7213815b..8066b50e 100644 --- a/tests/test_public_release_positioning.py +++ b/tests/test_public_release_positioning.py @@ -81,7 +81,7 @@ def test_dev_extra_contains_public_docs_build_dependencies(): def test_changelog_marks_0_6_6_ready_for_authorized_release(): changelog = _changelog_section("0.6.6") - assert "## [0.6.6] - 2026-06-18" in changelog + assert "## [0.6.6] - 2026-06-23" in changelog assert "统一模型策略 v1" in changelog assert "PyPI Trusted Publishing" in changelog assert "GitHub workflow" in changelog From 631a3d25cd0bb9802de62eaa1fb9c6ddb8d4b053 Mon Sep 17 00:00:00 2001 From: xiayu Date: Tue, 23 Jun 2026 14:30:04 +0800 Subject: [PATCH 50/52] test: isolate Hermes region env in CLI tests --- tests/test_cmd_hermes.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_cmd_hermes.py b/tests/test_cmd_hermes.py index f5b58eb2..ed93c207 100644 --- a/tests/test_cmd_hermes.py +++ b/tests/test_cmd_hermes.py @@ -51,6 +51,7 @@ def _isolate_hermes_model_env(monkeypatch): "WPSXIEZUO_ALLOWED_USERS", "WPSXIEZUO_ALLOW_ALL_USERS", "WPSXIEZUO_HOME_CHANNEL", + "KSYUN_REGION", ): monkeypatch.delenv(key, raising=False) cmd_hermes._HERMES_GLOBAL_ENV_CACHE = None From 2e2c3e5722baf016b3c15a4f89dc0a2bc8ddb835 Mon Sep 17 00:00:00 2001 From: xiayu Date: Tue, 23 Jun 2026 14:33:27 +0800 Subject: [PATCH 51/52] docs: mention usage propagation in 0.6.6 changelog --- CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7728321e..7c19ec77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - **统一模型策略 v1**:新增 `AGENTENGINE_MODEL_POLICY_JSON` 运行时策略契约,默认主模型为 `glm-5.2`,多模态模型为 `kimi-k2.7-code`,fallback 模型为 `deepseek-v4-pro`,为 Hermes、OpenClaw 和通用 Agent 提供同一套默认模型语义。 - **通用 Agent fallback**:conversation runtime 对超时、限流、5xx、模型不可用、权限/配额等可恢复模型错误支持 fallback 重试;普通 400 参数错误、业务错误和 tool 错误不会被吞掉。 +- **真实 usage 透传**:Chat Completions、Responses、LangGraph、LangChain、ADK 和 Remote runner 的 invoke/stream 路径统一透传下游真实 token usage,stream 结束补 final usage,不再伪造空 token 统计。 - **运行时附件与 Hosted 附件打通**:本地 `ksadk-upload://` 与平台 `ae-upload://` 上传引用统一解析,支持通过 KOP Action 下载 Hosted 附件内容、恢复本地缓存,并在会话/浏览器刷新后继续读取文件。 - **Workspace zip 导出修复**:本地与 Hosted 场景统一使用 KsADK runtime workspace 导出契约,修复 share link / Hosted UI 下载 workspace 目录时报错的问题。 - **会话列表与事件分页增强**:Session service 新增 `count_sessions` / `count_events`,`ListSessions` 返回 `Total/Page/PageSize`,`ListSessionEvents` 支持 `Offset/Limit/Total`,便于 UI 恢复长任务和历史事件。 @@ -28,6 +29,8 @@ - Hermes 本地默认主模型更新为 `glm-5.2`,不再按旧主模型名称硬编码 fallback 到 `kimi-k2.6`,fallback 改由统一策略或显式 env 决定。 - OpenClaw provider catalog 合并逻辑支持在已有 `OPENCLAW_MODEL_CATALOG_JSON` 上补齐 provider metadata,避免请求级 catalog 被平台默认值覆盖。 - `AgentEngineClient` 新增 `AttachmentContent` 与 `download_attachment_content()`,并修正 `list_sessions()` 请求字段为 `PageSize`。 +- conversation runtime 会把 runner 返回的 usage 写入 assistant event metadata,并在 Chat Completions / Responses 输出中按官方 `usage` 字段返回,同时保留 `prompt_tokens_details`、`completion_tokens_details`、`input_token_details`、`output_token_details` 等明细字段。 +- LangGraph、LangChain、ADK 和 Remote runner 的 invoke/stream 均会从 message metadata、state values、event usage metadata 或 OpenAI-compatible response 中提取真实 usage;流式路径在 final chunk 中带出最终 usage,避免 UI 和调用方只能看到空统计。 - runtime 上传附件会持久化 metadata、本地路径和 MIME 信息;Hosted 附件下载后会写回本地 cache,供 runner、workspace preview 和会话恢复复用。 - Workspace zip 导出优先走新的 runtime raw export endpoint,并保留 legacy runtime archive fallback,降低不同 KsADK runtime 版本混跑时的兼容风险。 - Native terminal session manager 新增 HTTP session lifecycle 与 WebSocket attach 语义,断线默认 detach 而不是杀掉 PTY;Hermes 使用 `--resume`、OpenClaw 使用 `--session` 绑定同一 terminal session。 @@ -56,7 +59,7 @@ ### 测试与发布 -- 新增模型策略、fallback、流式 fallback、OpenClaw env、Hermes env、LangChain patch、附件恢复、session 分页、Hosted UI 上传文件、workspace zip、终端 session 复用和终端 allowlist 覆盖测试。 +- 新增模型策略、fallback、流式 fallback、usage 透传、OpenClaw env、Hermes env、LangChain patch、附件恢复、session 分页、Hosted UI 上传文件、workspace zip、终端 session 复用和终端 allowlist 覆盖测试。 - 公开发布版本从 `0.6.5` 升级到 `0.6.6`,发布包继续通过 `make public-preflight` 同步 KSADK Web 静态资源并执行 wheel 内容检查;本次发布候选固定使用 `PUBLIC_KSADK_WEB_VERSION=0.2.11` 对应的 `@kingsoftcloud/ksadk-web` 静态 UI。 - `make public-preflight` 覆盖 secret audit、public path audit、全量 pytest、sdist/wheel build、wheel 内容检查和 `twine check`;PyPI 发布继续使用 PyPI Trusted Publishing。 - GitHub Release / PyPI / npm 发布仍由对应 GitHub workflow 执行,并需要等待 CI 门禁和人工确认。 From 6149b45dfbc1fd1896016e95451fd70f45633c65 Mon Sep 17 00:00:00 2001 From: xiayu Date: Tue, 23 Jun 2026 14:39:40 +0800 Subject: [PATCH 52/52] fix: export usage to tracing spans --- ksadk/conversations/runtime.py | 71 +++++++++++++++++++++++++++ tests/test_conversation_runtime.py | 78 ++++++++++++++++++++++++++++++ 2 files changed, 149 insertions(+) diff --git a/ksadk/conversations/runtime.py b/ksadk/conversations/runtime.py index 7b6cc583..ac330d8a 100644 --- a/ksadk/conversations/runtime.py +++ b/ksadk/conversations/runtime.py @@ -311,6 +311,75 @@ def _set_conversation_output_attributes(span: Any | None, output_text: str | Non _set_span_attribute(span, key, text) +def _set_conversation_usage_attributes(span: Any | None, usage: Mapping[str, Any] | None) -> None: + normalized = _normalize_usage_payload(usage) + if not normalized: + return + + input_tokens = normalized.get("input_tokens", normalized.get("prompt_tokens")) + output_tokens = normalized.get("output_tokens", normalized.get("completion_tokens")) + total_tokens = normalized.get("total_tokens") + if total_tokens is None and input_tokens is not None and output_tokens is not None: + total_tokens = input_tokens + output_tokens + + for value, keys in ( + ( + input_tokens, + ( + "gen_ai.usage.input_tokens", + "llm.usage.prompt_tokens", + "langfuse.observation.usage.input", + ), + ), + ( + output_tokens, + ( + "gen_ai.usage.output_tokens", + "llm.usage.completion_tokens", + "langfuse.observation.usage.output", + ), + ), + ( + total_tokens, + ( + "gen_ai.usage.total_tokens", + "llm.usage.total_tokens", + "langfuse.observation.usage.total", + ), + ), + ): + if value is None: + continue + for key in keys: + _set_span_attribute(span, key, value) + + input_details = normalized.get("input_token_details") + if isinstance(input_details, Mapping) and input_details.get("cached") is not None: + _set_span_attribute( + span, + "gen_ai.usage.input_token_details.cached_tokens", + input_details.get("cached"), + ) + _set_span_attribute( + span, + "llm.usage.prompt_tokens_details.cached_tokens", + input_details.get("cached"), + ) + + output_details = normalized.get("output_token_details") + if isinstance(output_details, Mapping) and output_details.get("reasoning") is not None: + _set_span_attribute( + span, + "gen_ai.usage.output_token_details.reasoning_tokens", + output_details.get("reasoning"), + ) + _set_span_attribute( + span, + "llm.usage.completion_tokens_details.reasoning_tokens", + output_details.get("reasoning"), + ) + + def _set_conversation_span_attributes( span: Any, *, @@ -3166,6 +3235,7 @@ async def invoke_conversation_once( output_text = strip_reasoning_markup(str(result.get("output", ""))) result_usage = _normalize_usage_payload(result.get("usage")) _set_conversation_output_attributes(span, output_text) + _set_conversation_usage_attributes(span, result_usage) result_agentengine_metadata = _extract_agentengine_metadata(result) assistant_metadata: dict[str, Any] = { **trace_metadata, @@ -3737,6 +3807,7 @@ def _finish_span() -> None: if responses_response_id: assistant_metadata["response_id"] = responses_response_id _set_conversation_output_attributes(span, accumulated_text) + _set_conversation_usage_attributes(span, stream_usage) await append_conversation_event( session_id=prepared.session_id, diff --git a/tests/test_conversation_runtime.py b/tests/test_conversation_runtime.py index dc4f996c..ccddd4f7 100644 --- a/tests/test_conversation_runtime.py +++ b/tests/test_conversation_runtime.py @@ -3459,6 +3459,44 @@ async def test_invoke_conversation_once_preserves_runner_usage(monkeypatch): assert result["metadata"]["usage"] == result["usage"] +@pytest.mark.asyncio +async def test_invoke_conversation_once_sets_usage_trace_attributes( + monkeypatch, + in_memory_trace_exporter, +): + service = InMemorySessionService() + await service.create_session(agent_id="demo-agent", user_id="user-1", session_id="sess-usage-span") + monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service) + + runner = _UsageRunner() + _, result = await invoke_conversation_once( + runner=runner, + agent_id="demo-agent", + user_id="user-1", + session_id="sess-usage-span", + messages=[{"role": "user", "content": "hello"}], + model="demo-model", + prepare_runner=lambda active_runner, model: active_runner.prepare_for_request(model), + ) + + exported_trace = in_memory_trace_exporter.get_trace(result["metadata"]["trace_id"]) + root_span = next( + span for span in exported_trace["spans"] if span["span_id"] == result["metadata"]["root_span_id"] + ) + attrs = root_span["attributes"] + assert attrs["gen_ai.usage.input_tokens"] == 8 + assert attrs["gen_ai.usage.output_tokens"] == 13 + assert attrs["gen_ai.usage.total_tokens"] == 21 + assert attrs["llm.usage.prompt_tokens"] == 8 + assert attrs["llm.usage.completion_tokens"] == 13 + assert attrs["llm.usage.total_tokens"] == 21 + assert attrs["langfuse.observation.usage.input"] == 8 + assert attrs["langfuse.observation.usage.output"] == 13 + assert attrs["langfuse.observation.usage.total"] == 21 + assert attrs["gen_ai.usage.output_token_details.reasoning_tokens"] == 5 + assert attrs["llm.usage.completion_tokens_details.reasoning_tokens"] == 5 + + def test_build_chat_completions_payload_uses_real_usage_from_metadata(): payload = build_chat_completions_payload( output_text="assistant says hi", @@ -3604,6 +3642,46 @@ async def test_stream_conversation_turn_preserves_final_chunk_usage(monkeypatch) assert assistant_event.metadata["usage"] == completed_payload["usage"] +@pytest.mark.asyncio +async def test_stream_conversation_turn_sets_usage_trace_attributes( + monkeypatch, + in_memory_trace_exporter, +): + service = InMemorySessionService() + await service.create_session(agent_id="demo-agent", user_id="user-1", session_id="sess-stream-usage-span") + monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service) + + runner = _UsageStreamingRunner() + chunks = [ + chunk + async for chunk in stream_conversation_turn( + runner=runner, + agent_id="demo-agent", + user_id="user-1", + session_id="sess-stream-usage-span", + messages=[{"role": "user", "content": "hello"}], + model="demo-model", + prepare_runner=lambda active_runner, model: active_runner.prepare_for_request(model), + ) + ] + + completed_payload = _extract_sse_payload(chunks, "response.completed") + exported_trace = in_memory_trace_exporter.get_trace(completed_payload["metadata"]["trace_id"]) + root_span = next( + span + for span in exported_trace["spans"] + if span["span_id"] == completed_payload["metadata"]["root_span_id"] + ) + attrs = root_span["attributes"] + assert attrs["gen_ai.usage.input_tokens"] == 8 + assert attrs["gen_ai.usage.output_tokens"] == 13 + assert attrs["gen_ai.usage.total_tokens"] == 21 + assert attrs["gen_ai.usage.input_token_details.cached_tokens"] == 4 + assert attrs["gen_ai.usage.output_token_details.reasoning_tokens"] == 5 + assert attrs["llm.usage.prompt_tokens_details.cached_tokens"] == 4 + assert attrs["llm.usage.completion_tokens_details.reasoning_tokens"] == 5 + + @pytest.mark.asyncio async def test_stream_responses_conversation_turn_preserves_responses_output_usage(monkeypatch): service = InMemorySessionService()