From 148fbd6a1092f072dbbc1a7cdca13d1bbbebd8c1 Mon Sep 17 00:00:00 2001 From: xiayu Date: Wed, 8 Jul 2026 00:56:38 +0800 Subject: [PATCH 1/5] chore(release): prepare ksadk 0.6.9 public candidate --- CHANGELOG.md | 26 + ...30\351\207\217\345\217\202\350\200\203.md" | 619 +++++ docs/maintainer-approval-record.md | 6 +- ...30\351\207\217\345\217\202\350\200\203.md" | 1 + ...45\345\217\243\350\257\264\346\230\216.md" | 2061 +++++++++++++++++ ksadk/api/client.py | 83 +- ksadk/builders/code_builder.py | 2 +- ksadk/builders/ks3_uploader.py | 17 +- ksadk/cli/cmd_agent.py | 6 +- ksadk/cli/cmd_deploy.py | 2 +- ksadk/cli/cmd_destroy.py | 13 +- ksadk/cli/cmd_hermes.py | 31 +- ksadk/cli/cmd_launch.py | 2 +- ksadk/cli/cmd_status.py | 11 +- ksadk/configs/env_registry.py | 6 + ksadk/configs/global_config.py | 35 +- ksadk/conversations/run_kinds.py | 61 + ksadk/conversations/run_status.py | 53 + ksadk/conversations/runtime.py | 410 +++- ksadk/deployment/agent_access.py | 33 + ksadk/detection/detector.py | 123 +- ksadk/detection/mcp_detector.py | 117 +- ksadk/identity/__init__.py | 17 + ksadk/identity/resolver.py | 368 +++ ksadk/runners/adk_runner.py | 13 +- ksadk/runners/base_runner.py | 23 + ksadk/runners/langchain_runner.py | 9 + ksadk/runners/langgraph_runner.py | 16 +- ksadk/runners/usage_accumulator.py | 38 + ksadk/server/app.py | 376 ++- ksadk/sessions/base.py | 5 +- ksadk/sessions/in_memory.py | 21 +- ksadk/sessions/local_service.py | 48 +- ksadk/sessions/postgres_service.py | 122 +- ksadk/tracing/setup.py | 52 +- ksadk/version.py | 2 +- .../memory_backend/providers/lancedb.py | 123 +- .../memory_backend_manifest.schema.json | 257 +- pyproject.toml | 2 +- scripts/open_source_audit.py | 1 + tests/snapshots/workflow_help_snapshots.txt | 4 +- tests/test_background_run.py | 12 +- tests/test_check_approval_record.py | 12 +- tests/test_client_permission_precheck.py | 11 + tests/test_client_user_uuid_header.py | 179 ++ tests/test_cmd_hermes.py | 114 + tests/test_conversation_runtime.py | 58 +- tests/test_identity_resolver.py | 295 +++ ...est_langchain_runner_session_continuity.py | 9 + tests/test_langgraph_runner_resume.py | 18 + tests/test_open_source_audit.py | 6 +- tests/test_postgres_session_service.py | 48 + tests/test_public_release_positioning.py | 8 +- tests/test_runner.py | 71 +- tests/test_server_session_app.py | 67 +- tests/test_sessions_service.py | 71 + tests/test_tracing_setup_otlp.py | 3 +- tests/test_usage_accumulator.py | 43 + uv.lock | 2 +- 59 files changed, 5732 insertions(+), 510 deletions(-) create mode 100644 "docs/ksadk\347\216\257\345\242\203\345\217\230\351\207\217\345\217\202\350\200\203.md" 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/run_kinds.py create mode 100644 ksadk/conversations/run_status.py create mode 100644 ksadk/identity/__init__.py create mode 100644 ksadk/identity/resolver.py create mode 100644 ksadk/runners/usage_accumulator.py create mode 100644 tests/test_client_user_uuid_header.py create mode 100644 tests/test_identity_resolver.py create mode 100644 tests/test_usage_accumulator.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 792f10d6..70cd4501 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,32 @@ 格式参考 [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 版本遵循 [Semantic Versioning](https://semver.org/spec/v2.0.0.html)。 +## [0.6.9] - 2026-07-07 + +### 亮点 + +- **run 状态双维度字段**:新增 `run_mode`(background/foreground/unknown)和 `run_trigger`(new_run/checkpoint_resume/approval_resume/unknown)两个独立维度字段,区分"怎么跑"和"怎么开始",替代单字段 `run_kind` 的语义错误。后台长任务从 checkpoint 恢复时不再丢失"这是后台任务"的信息,前端可直接消费 `ActiveRunMode` / `ActiveRunTrigger` 判断长任务会话,无需从事件流推断。 +- **checkpoint 可恢复性聚合字段**:`ListSessionCheckpoints` 响应新增 `ResumableTotal` / `HasResumableCheckpoint`,解决 `Total > 0` 不能代表"可恢复"的误判(终态/过期/memory_local checkpoint 会让 Total 非空但不可恢复)。恢复按钮可用性应看 `HasResumableCheckpoint`。 +- **state_delta.active_run 对齐**:ksadk 与 agentengine-server 现都把 `run_mode` / `run_trigger` 写入 `state_delta.active_run`,Session 对象的 `ActiveRunMode` / `ActiveRunTrigger` 由 state 重建,刷新/分享链接/切 session 都能恢复一致状态。 + +### 新增 + +- 新增 `ksadk/conversations/run_kinds.py`:`run_mode` / `run_trigger` 枚举常量 + `validate_run_mode` / `validate_run_trigger` + `trigger_from_resume_input`(从 resume_input 推导 trigger)。 +- `append_run_status_event` 新增 `run_mode` / `run_trigger` 参数,写入事件 `metadata` 与 `state_delta.active_run`。 +- `PreparedConversationTurn` 新增 `run_mode` / `run_trigger` 字段,`build_run_input` 按 checkpoint_resume / approval_resume / new_run 分支回填。 +- `invoke_conversation_once` / `_iter_conversation_turn_events` / `stream_responses_conversation_turn` / `stream_conversation_turn` 透传 `run_mode`;18+ 处 `append_run_status_event` 调用点按 endpoint 语义传值。 +- 各 endpoint 按产品语义标记 run_mode:`RunAgent Background:true` 与 `ResumeRun Stream:true` 标 `background`;普通 `RunAgent Stream:true`、`ResumeRun Stream:false`、`/v1/responses`、`/v1/chat/completions`、`/run_sse` 标 `foreground`。 +- `_DetachedSSEStream` 构造与终态 fallback 写入 `run_mode` / `run_trigger`。 +- 新增 `_latest_session_run_metadata` helper(不改原 `_latest_session_run_status`,保护现有契约),`_session_to_action_payload` 顶层新增 `ActiveRunMode` / `ActiveRunTrigger`。 +- `_list_checkpoints_payload` 新增 `ResumableTotal` / `HasResumableCheckpoint` 聚合字段。规则:`IsResumable===true && ReplayAllowed!==false && IsTerminal!==true && CheckpointStatus not in {expired, disabled}`,不排除 `resumed`(已恢复过的仍计入,符合存档点可反复读)。 +- agentengine-server 侧新建 `app/services/run_kinds.py`(独立维护,不 import ksadk,用测试约束一致性),`_append_run_status` 与 `_serialize_session` 同步写入/读取新字段。 + +### 变更 + +- `run_status` 事件 `metadata` 与 `state_delta.active_run` 扩展为含 `run_mode` / `run_trigger`;旧 session 缺字段降级 `unknown`,不破坏现有 `ActiveInvocationId` / `ActiveRunStatus` 契约。 +- approval 续跑的 `run_mode` 跟随原 run(不写死 foreground),需从原 run 上下文透传。 +- server 侧 `run_status` 事件 `content` 仍为 `{status, detail}`,`run_mode` / `run_trigger` 只写进 `state_delta`,避免破坏现有消费方。 + ## [0.6.8] - 2026-07-03 ### 亮点 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..2206df11 --- /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,619 @@ +# 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 集成。 | +| `CLOUD_MONITOR_APP_KEY` | 条件必传 | 无 | 是 | 平台 Secret | 云监控 OTLP AppKey。 | +| `CLOUD_MONITOR_OTLP_ENDPOINT` | 条件必传 | 无 | 否 | 平台 / 开发者 | CloudMonitor 通用 OTLP HTTP endpoint。 | +| `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_CONNECT_TIMEOUT` | Sessions | 否 | `5` | `KSADK_SESSION_PG_CONNECT_TIMEOUT` | 否 | 开发者 / 平台 | 否 | PostgreSQL 会话 backend 连接超时秒数。 | +| `KSADK_SESSION_PG_CONNECT_TIMEOUT` | Sessions 旧兼容 | 否 | `5` | `KSADK_SESSION_CONNECT_TIMEOUT` | 否 | 兼容旧部署 | 否 | 旧 PostgreSQL session 连接超时变量。新部署优先 `KSADK_SESSION_CONNECT_TIMEOUT`。 | +| `KSADK_SESSION_NAMESPACE` | Sessions | 否 | 未设置 | `KSADK_WORKSPACE_ID`、`AGENTENGINE_WORKSPACE_ID`、`KSADK_TENANT_ID`、`AGENTENGINE_TENANT_ID` | 否 | 平台 | 否 | 会话 namespace。 | +| `KSADK_CHECKPOINT_BACKEND` | LangGraph checkpoint | 否 | `local` | `local` 等价本地 SQLite;也支持 `sqlite`、`memory`、`postgres` | 否 | 开发者 / 平台 | 否 | LangGraph checkpoint backend。`agentengine web` 本地调试默认优先使用 SQLite。 | +| `KSADK_CHECKPOINT_PATH` | LangGraph checkpoint | 否 | 项目目录下 `.agentengine/ui/checkpoints.sqlite` | 无 | 否 | 开发者 / 本地运行时 | 否 | 本地 SQLite checkpoint 文件路径。 | +| `KSADK_LANGGRAPH_CHECKPOINT_DSN` | LangGraph checkpoint | 条件必传 | 未设置 | 无 | 是 | Secret | 否 | `KSADK_CHECKPOINT_BACKEND=postgres` 时的 LangGraph checkpointer PostgreSQL DSN。 | +| `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_UI_PROFILE` | 本地 Web UI / Runtime bootstrap | 否 | `builtin` | 无 | 否 | 开发者 / 平台 | 否 | Agent UI profile。`custom` 时 runtime 会暴露自定义 UI bootstrap 信息。 | +| `KSADK_UI_PATH` | 本地 Web UI / Runtime bootstrap | 否 | `/` | 无 | 否 | 开发者 / 平台 | 否 | 自定义 UI 挂载路径,例如 `/research`。 | +| `KSADK_UI_URL` | Runtime bootstrap | 否 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | 外部自定义 UI URL。 | +| `KSADK_UI_BUNDLE_PATH` | Runtime bootstrap | 否 | 自动探测 `research-ui/dist` | 无 | 否 | 开发者 / 平台 | 否 | 自定义 UI 静态 bundle 相对项目路径。 | +| `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 开关。 | +| `CLOUD_MONITOR_APP_KEY` | CloudMonitor tracing | 条件必传 | 未设置 | 无 | 是 | 平台 Secret | 否 | 云监控 OTLP AppKey;启用 CloudMonitor OTLP 上报时需要。 | +| `CLOUD_MONITOR_OTLP_ENABLED` | CloudMonitor tracing | 否 | 自动判断 | 无 | 否 | 平台 / 开发者 | 否 | 显式启用或禁用 CloudMonitor OTLP exporter。 | +| `CLOUD_MONITOR_OTLP_ENDPOINT` | CloudMonitor tracing | 条件必传 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | CloudMonitor 通用 OTLP HTTP endpoint;未设置 traces endpoint 时会派生 `/v1/traces`。 | +| `CLOUD_MONITOR_OTLP_PROTOCOL` | CloudMonitor tracing | 否 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | CloudMonitor 通用 OTLP 协议,当前支持 `http/protobuf`。 | +| `CLOUD_MONITOR_OTLP_HEADERS` | CloudMonitor tracing | 否 | 未设置 | 无 | 是 | 平台 / 开发者 | 否 | CloudMonitor OTLP 附加 headers,逗号分隔且 URL encoded。 | +| `CLOUD_MONITOR_OTLP_TRACES_ENDPOINT` | CloudMonitor tracing | 否 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | CloudMonitor traces 专用 endpoint,优先于通用 endpoint。 | +| `CLOUD_MONITOR_OTLP_TRACES_PROTOCOL` | CloudMonitor tracing | 否 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | CloudMonitor traces 专用协议,优先于通用 protocol。 | +| `CLOUD_MONITOR_LANGFUSE_ENABLED` | CloudMonitor Langfuse callback | 否 | 自动判断 | 无 | 否 | 平台 / 开发者 | 否 | 显式启用或禁用 CloudMonitor Langfuse SDK callback。 | +| `CLOUD_MONITOR_LANGFUSE_HOST` | CloudMonitor Langfuse callback | 条件必传 | 未设置 | `CLOUD_MONITOR_OTLP_ENDPOINT` | 否 | 平台 / 开发者 | 否 | CloudMonitor AppMonitor Langfuse SDK host。 | +| `CLOUD_MONITOR_LANGFUSE_PUBLIC_KEY` | CloudMonitor Langfuse callback | 条件必传 | 未设置 | 无 | 是 | 平台 Secret | 否 | CloudMonitor AppMonitor Langfuse public key。 | +| `CLOUD_MONITOR_LANGFUSE_SECRET_KEY` | CloudMonitor Langfuse callback | 条件必传 | 未设置 | 无 | 是 | 平台 Secret | 否 | CloudMonitor AppMonitor Langfuse secret key。 | +| `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/docs/maintainer-approval-record.md b/docs/maintainer-approval-record.md index 8d07f6e3..3c283f9c 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.8 | +| Python package version | 0.6.9 | | 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.8` confirms +- `make public-publish-check PUBLIC_PUBLISH_PHASE=pre-publish V=0.6.9` confirms the target version is not already on PyPI. - Branch protection and publish environment are configured according to `.github/BRANCH_PROTECTION.md`. @@ -61,4 +61,4 @@ changes. | --- | --- | --- | --- | | Maintainer | xiayu | Approved for one-time public main rewrite | 2026-07-03 | | Security reviewer | automated public audit | Passed source, wheel, and sdist audits with 0 violations | 2026-07-03 | -| Release owner | xiayu | Approved GitHub Release / PyPI Trusted Publishing for 0.6.8 | 2026-07-03 | +| Release owner | xiayu | Approved GitHub Release / PyPI Trusted Publishing for 0.6.9 | 2026-07-03 | diff --git "a/docs/reference/ksadk\347\216\257\345\242\203\345\217\230\351\207\217\345\217\202\350\200\203.md" "b/docs/reference/ksadk\347\216\257\345\242\203\345\217\230\351\207\217\345\217\202\350\200\203.md" index a2517525..ac6c26b3 100644 --- "a/docs/reference/ksadk\347\216\257\345\242\203\345\217\230\351\207\217\345\217\202\350\200\203.md" +++ "b/docs/reference/ksadk\347\216\257\345\242\203\345\217\230\351\207\217\345\217\202\350\200\203.md" @@ -428,6 +428,7 @@ | `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。 | +| `KSADK_OTLP_MAX_EXPORT_BATCH_SIZE` | OTel | 否 | `64` | 无 | 否 | 平台 / 开发者 | 否 | 单次 OTLP export 的最大 span 数,降低 collector 413 风险。 | | `OTEL_SERVICE_NAME` | OTel | 否 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | service name。 | | `OTEL_RESOURCE_ATTRIBUTES` | OTel | 否 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | resource attributes。 | 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..322c880a --- /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,2061 @@ +# 远程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/GetCheckpointResumePreview` +- `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` +- `GetCheckpointResumePreview` +- `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`。控制台应优先读取 `RuntimeCapabilities.ResumeRun.ResumeMode`:`time_travel` 表示可选择历史 checkpoint 回档,`forward_only` 表示只能沿框架原生事件或 invocation 连续性继续,`none` 表示没有框架级恢复能力。当前 `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` + +说明: + +- 控制台使用该接口展示指定 session 的 checkpoint 列表。 +- 该接口在 0.6.7 起支持分页、可恢复性过滤和框架过滤。 + +请求体: + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `AgentId` | `string` | 是 | Agent ID | +| `SessionId` | `string` | 是 | 会话 ID | +| `RunId` | `string` | 否 | 只返回指定 run 的 checkpoint | +| `OnlyResumable` | `boolean` | 否 | 只返回可恢复 checkpoint | +| `Framework` | `string` | 否 | 按框架过滤,例如 `langgraph` | +| `Offset` | `integer` | 否 | 分页起始偏移 | +| `Limit` | `integer` | 否 | 分页大小,最大 `500` | + +响应 `Data.Checkpoints` 为 checkpoint 列表。checkpoint 来自 runtime session event 中的 `run_checkpoint`,不是客户端传入的状态。响应还包含 `Total`、`Offset` 和 `Limit`。 + +每个 checkpoint descriptor 至少包含: + +| 字段 | 说明 | +| --- | --- | +| `CheckpointId` / `RunId` | 恢复点和运行 ID,传给 `GetCheckpointResumePreview` / `ResumeRun` | +| `Framework` / `FrameworkRef` | 框架与原生 checkpoint 引用 | +| `IsResumable` / `ResumeStatus` / `ResumeDisabledReason` | 是否可恢复、恢复状态和禁用原因 | +| `IsTerminal` / `NextNode` | 是否终态、恢复后预期进入的下一个节点 | +| `StageKey` / `StageName` / `StageIndex` / `TotalStages` | 控制台展示阶段和进度 | +| `Backend` / `Scope` / `Durable` | checkpoint 后端、作用域和持久化能力 | +| `CreatedAt` / `ExpiresAt` | 创建时间和过期时间 | +| `LastResumedAt` / `ResumeCount` | 最近恢复时间和累计恢复次数 | +| `ReplayAllowed` | 是否允许重复从该 checkpoint 发起恢复 | +| `CheckpointStatus` | 当前状态,例如 `active`、`resumed`、`expired`、`disabled`、`terminal` | +| `ArtifactPreview` | 产物摘要或缩略信息 | + +`ListSessionCheckpoints` 会基于同 session 内的 `run_resume` 事件聚合 `LastResumedAt` 与 `ResumeCount`。若 `ExpiresAt` 已过期,或 `ReplayAllowed=false` 且该 checkpoint 已恢复过,服务端会将 `IsResumable=false` 并填充 `ResumeDisabledReason`,前端不需要重复推导这些禁用规则。 + +### `POST /agentengine/api/v1/GetCheckpointResumePreview` + +请求体: + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `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` + - `GetCheckpointResumePreview` + - `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/client.py b/ksadk/api/client.py index 4a120856..c02494d5 100644 --- a/ksadk/api/client.py +++ b/ksadk/api/client.py @@ -128,6 +128,9 @@ def __init__( self._session: Optional[requests.Session] = None self._http_error_log_suppressors: list[HttpErrorLogSuppressor] = [] + # 反查身份的实例缓存(避免同会话重复调 IAM);None=未尝试,ResolvedIdentity|None=已反查 + self._resolved_identity: Any = None + self._identity_resolve_attempted: bool = False @staticmethod def _ssl_verify_enabled() -> bool: @@ -446,21 +449,87 @@ def _build_headers(self, request_id: str = "", action: str = "", kop_mode: bool headers["X-Version"] = os.getenv("AGENTENGINE_API_VERSION", "2024-06-12") if self.custom_source: headers["X-KSC-CUSTOM-SOURCE"] = self.custom_source - - # 统一使用 X-Ksc-Account-Id (废弃 X-Ksc-Account-Id) - account_id = os.getenv("KSYUN_ACCOUNT_ID") - if account_id: - headers["X-Ksc-Account-Id"] = account_id + + # 先合并 extra_headers(key 归一为 Title-Case,避免大小写重复 header) if self.extra_headers: - headers.update(self.extra_headers) + for ek, ev in self.extra_headers.items(): + # 归一常见 identity header 大小写,避免 X-Ksc-User-uuid 与 x-ksc-user-uuid 共存 + normalized = ek + lower = str(ek or "").lower() + if lower == "x-ksc-user-uuid": + normalized = "X-Ksc-User-uuid" + elif lower == "x-ksc-account-id": + normalized = "X-Ksc-Account-Id" + headers[normalized] = ev + + # 注入子账号身份:extra_headers 显式 > 反查(反查结果不覆盖已显式设置的值) + if "X-Ksc-User-uuid" not in headers: + user_uuid = self._resolve_user_uuid() + if user_uuid: + headers["X-Ksc-User-uuid"] = user_uuid + if "X-Ksc-Account-Id" not in headers: + account_id = self._resolve_account_id() + if account_id: + headers["X-Ksc-Account-Id"] = account_id return headers + def _resolve_user_uuid(self) -> Optional[str]: + """解析子账号 user uuid(X-Ksc-User-uuid 值)。 + + 优先级:extra_headers 显式 > 实例缓存 > 反查(dry-run 只读缓存)。 + 反查失败返回 None(不抛异常,不阻塞主流程)。 + """ + # 1. extra_headers 显式覆盖 + for key, value in self.extra_headers.items(): + if key.lower() == "x-ksc-user-uuid" and str(value or "").strip(): + return str(value).strip() + # 2. 实例缓存(同会话已反查过) + if self._identity_resolve_attempted: + identity = self._resolved_identity + return identity.user_uuid if identity else None + # 3. 反查 + identity = self._get_resolved_identity() + return identity.user_uuid if identity else None + + def _get_resolved_identity(self) -> Any: + """反查身份并缓存到实例。dry-run 只读文件缓存不联网。""" + if self._identity_resolve_attempted: + return self._resolved_identity + self._identity_resolve_attempted = True + ak = getattr(self._auth, "access_key_id", "") or "" + sk = getattr(self._auth, "secret_access_key", "") or "" + if not ak or not sk: + self._resolved_identity = None + return None + try: + from ksadk.identity import get_cached_identity, resolve_identity + + if self.dry_run: + # dry-run 不联网,只读缓存 + self._resolved_identity = get_cached_identity(ak) + else: + self._resolved_identity = resolve_identity( + access_key=ak, secret_key=sk + ) + except Exception as e: + logger.warning("反查子账号身份失败: %s", e) + self._resolved_identity = None + return self._resolved_identity + def _resolve_account_id(self) -> Optional[str]: + # 1. extra_headers 显式 for key, value in self.extra_headers.items(): if key.lower() == "x-ksc-account-id" and str(value or "").strip(): return str(value).strip() + # 2. env KSYUN_ACCOUNT_ID account_id = os.getenv("KSYUN_ACCOUNT_ID", "").strip() - return account_id or None + if account_id: + return account_id + # 3. 反查主账号 ID(复用 _get_resolved_identity 的反查,不重复调) + identity = self._get_resolved_identity() + if identity and getattr(identity, "main_account_id", None): + return str(identity.main_account_id).strip() or None + return None def _resolve_permission_role_name(self, action: str, params: Dict[str, Any]) -> str: if action in {"CreateAgentProduct", "CreateAgent"}: diff --git a/ksadk/builders/code_builder.py b/ksadk/builders/code_builder.py index 15bcbf49..d7e95445 100644 --- a/ksadk/builders/code_builder.py +++ b/ksadk/builders/code_builder.py @@ -491,7 +491,7 @@ def _project_imports_any(self, module_names: set[str]) -> bool: if self._should_skip_project_file(py_file): continue try: - tree = ast.parse(py_file.read_text(encoding="utf-8")) + tree = ast.parse(py_file.read_text(encoding="utf-8"), filename=str(py_file)) except (OSError, SyntaxError, UnicodeDecodeError): continue for node in ast.walk(tree): diff --git a/ksadk/builders/ks3_uploader.py b/ksadk/builders/ks3_uploader.py index c1cf36e6..97f2757c 100644 --- a/ksadk/builders/ks3_uploader.py +++ b/ksadk/builders/ks3_uploader.py @@ -48,11 +48,24 @@ def __init__(self, region: str = "cn-beijing-6", bucket: str = None): else: # Bucket 名称格式: agentengine-{account_id}-{region} account_id = os.getenv("KSYUN_ACCOUNT_ID") + if not account_id: + # fallback: 从 AK/SK 反查主账号 ID + try: + from ksadk.cli.network_options import _resolve_ksyun_credentials + from ksadk.identity import resolve_identity + + ak, sk = _resolve_ksyun_credentials() + if ak and sk: + identity = resolve_identity(access_key=ak, secret_key=sk) + account_id = identity.main_account_id if identity else None + except Exception: + account_id = None if not account_id: raise ValueError( - "❌ 缺少 KSYUN_ACCOUNT_ID 环境变量\n" + "❌ 缺少 KSYUN_ACCOUNT_ID 且 AK/SK 反查主账号 ID 失败\n" " Bucket 名称格式必须为: agentengine-{account_id}-{region}\n" - " 请在 .env 文件中设置: KSYUN_ACCOUNT_ID=你的账号ID" + " 请在 .env 文件中设置: KSYUN_ACCOUNT_ID=你的账号ID\n" + " 或设置 KS3_BUCKET 显式指定 bucket 名称" ) self.bucket_name = f"agentengine-{account_id}-{region}" diff --git a/ksadk/cli/cmd_agent.py b/ksadk/cli/cmd_agent.py index 4b38c072..e1563630 100644 --- a/ksadk/cli/cmd_agent.py +++ b/ksadk/cli/cmd_agent.py @@ -22,7 +22,7 @@ def agent(): @agent.command("list", context_settings=CONTEXT_SETTINGS) @pagination_options(default_page=1, default_size=20) @click.option("--region", "-r", default="cn-beijing-6", envvar="KSYUN_REGION", help="区域") -@click.option("--account-id", envvar="KSYUN_ACCOUNT_ID", help="金山云账号 ID") +@click.option("--account-id", envvar="KSYUN_ACCOUNT_ID", help="金山云账号 ID(可选;未设置时从 AK/SK 反查)") @click.option("--framework", help="按框架过滤,支持逗号分隔多个值,如 langgraph,adk") @dry_run_option() @cli_output_option() @@ -59,7 +59,7 @@ def list_agents( @click.option("--watch", "-w", is_flag=True, help="Watch 模式,持续刷新") @click.option("--interval", "-i", default=2, help="Watch 刷新间隔 (秒)") @click.option("--region", "-r", default="cn-beijing-6", envvar="KSYUN_REGION", help="区域") -@click.option("--account-id", envvar="KSYUN_ACCOUNT_ID", help="金山云账号 ID") +@click.option("--account-id", envvar="KSYUN_ACCOUNT_ID", help="金山云账号 ID(可选;未设置时从 AK/SK 反查)") @dry_run_option() @cli_output_option() def status_agent( @@ -176,7 +176,7 @@ def invoke_agent( @click.option("--yes", "-y", "assume_yes", is_flag=True, help="跳过确认") @click.option("--force", "-f", "assume_yes", is_flag=True, hidden=True, help="(兼容) 跳过确认") @click.option("--region", "-r", default="cn-beijing-6", envvar="KSYUN_REGION", help="区域") -@click.option("--account-id", envvar="KSYUN_ACCOUNT_ID", help="金山云账号 ID") +@click.option("--account-id", envvar="KSYUN_ACCOUNT_ID", help="金山云账号 ID(可选;未设置时从 AK/SK 反查)") @dry_run_option() @cli_output_option() def delete_agent( diff --git a/ksadk/cli/cmd_deploy.py b/ksadk/cli/cmd_deploy.py index 30613ea7..bc1431ab 100644 --- a/ksadk/cli/cmd_deploy.py +++ b/ksadk/cli/cmd_deploy.py @@ -70,7 +70,7 @@ ) @click.option("--name", "-n", help="部署名称") @click.option("--region", "-r", default="cn-beijing-6", envvar="KSYUN_REGION", help="区域 (default: cn-beijing-6)") -@click.option("--account-id", envvar="KSYUN_ACCOUNT_ID", help="金山云账号 ID") +@click.option("--account-id", envvar="KSYUN_ACCOUNT_ID", help="金山云账号 ID(可选;未设置时从 AK/SK 反查)") @click.option( "--artifact-type", type=click.Choice(["Code", "Container"]), diff --git a/ksadk/cli/cmd_destroy.py b/ksadk/cli/cmd_destroy.py index 17c5b369..2bbac609 100644 --- a/ksadk/cli/cmd_destroy.py +++ b/ksadk/cli/cmd_destroy.py @@ -7,7 +7,7 @@ from pathlib import Path from ksadk.cli.agent_ref import resolve_agent_ref from ksadk.cli.dry_run import dry_run_option, run_async_with_dry_run, effective_dry_run -from ksadk.cli.error_utils import abort_with_cli_error, remote_error, resolution_error, usage_error, validation_error +from ksadk.cli.error_utils import abort_with_cli_error, remote_error, resolution_error, usage_error from ksadk.cli.resource_common import ( CONTEXT_SETTINGS, CompatibilityAliasCommand, @@ -52,12 +52,7 @@ def _destroy_impl( dry_run = effective_dry_run(dry_run) agents = _collect_agent_refs(agent_refs=agent_refs, agent_options=agent_options) - # 检查账号 ID - if not account_id: - raise validation_error( - "需要金山云账号 ID", - hints=["设置 KSYUN_ACCOUNT_ID 环境变量或使用 --account-id 参数。"], - ) + # account_id 由 client 层解析(env KSYUN_ACCOUNT_ID > AK/SK 反查),命令层不再强制校验 resolved_agent_ids = agents if not dry_run: @@ -223,7 +218,7 @@ def run_delete_command( @click.option("--force", "-f", "assume_yes", is_flag=True, help="跳过确认") @click.option("--yes", "-y", "assume_yes", is_flag=True, help="跳过确认") @click.option("--region", "-r", default="cn-beijing-6", envvar="KSYUN_REGION", help="区域") -@click.option("--account-id", envvar="KSYUN_ACCOUNT_ID", help="金山云账号 ID") +@click.option("--account-id", envvar="KSYUN_ACCOUNT_ID", help="金山云账号 ID(可选;未设置时从 AK/SK 反查)") @dry_run_option() def destroy( agent_refs: tuple[str, ...], @@ -256,7 +251,7 @@ def destroy( @click.option("--yes", "-y", "assume_yes", is_flag=True, help="跳过确认") @click.option("--force", "-f", "assume_yes", is_flag=True, help="跳过确认") @click.option("--region", "-r", default="cn-beijing-6", envvar="KSYUN_REGION", help="区域") -@click.option("--account-id", envvar="KSYUN_ACCOUNT_ID", help="金山云账号 ID") +@click.option("--account-id", envvar="KSYUN_ACCOUNT_ID", help="金山云账号 ID(可选;未设置时从 AK/SK 反查)") @dry_run_option() def delete( agent_refs: tuple[str, ...], diff --git a/ksadk/cli/cmd_hermes.py b/ksadk/cli/cmd_hermes.py index 9c309e5c..2c4973ea 100644 --- a/ksadk/cli/cmd_hermes.py +++ b/ksadk/cli/cmd_hermes.py @@ -46,6 +46,7 @@ ) from ksadk.deployment.agent_access import ( get_latest_agent_access, + is_agent_not_found_error, normalize_deployment_status, ) from ksadk.deployment.state import clear_state, load_state, save_state @@ -685,13 +686,29 @@ async def _deploy_hermes( include_env=include_env_on_update, include_storage=include_storage_on_update, ) - res = await client.update_agent(existing_agent_id, update_payload) - if res is None: - res = {} - res.setdefault("agent_id", existing_agent_id) - res.setdefault("endpoint", state.get("endpoint")) - res.setdefault("api_key", state.get("api_key")) - else: + try: + res = await client.update_agent(existing_agent_id, update_payload) + except Exception as update_err: + if not is_agent_not_found_error(update_err): + raise + # 本地 state 缓存的 agent_id 在服务端已不存在(已删除), + # 清掉失效 state 后回退为新建,避免 404 卡住用户。 + print_warn( + f"本地状态失效 ({existing_agent_id}),将自动回退为新建: {update_err}" + ) + cleared = clear_state(project_dir, key=existing_agent_id) + if cleared: + print_info("已清理失效的 .agentengine.state") + existing_agent_id = None + res = None + else: + if res is None: + res = {} + res.setdefault("agent_id", existing_agent_id) + res.setdefault("endpoint", state.get("endpoint")) + res.setdefault("api_key", state.get("api_key")) + + if not existing_agent_id: res = await client.create_agent(payload) if isinstance(res, dict): if res.get("order_id") and not res.get("agent_id"): diff --git a/ksadk/cli/cmd_launch.py b/ksadk/cli/cmd_launch.py index b9c2c04d..01f564f9 100644 --- a/ksadk/cli/cmd_launch.py +++ b/ksadk/cli/cmd_launch.py @@ -55,7 +55,7 @@ ) @click.option("--name", "-n", help="部署名称") @click.option("--region", "-r", default="cn-beijing-6", envvar="KSYUN_REGION", help="区域 (serverless)") -@click.option("--account-id", envvar="KSYUN_ACCOUNT_ID", help="金山云账号 ID") +@click.option("--account-id", envvar="KSYUN_ACCOUNT_ID", help="金山云账号 ID(可选;未设置时从 AK/SK 反查)") @click.option("--observability/--no-observability", default=True, help="是否启用可观测性") @click.option("--no-cache", is_flag=True, help="强制重新构建,不使用缓存") @click.option("--port", "-p", default=8000, help="服务端口 (default: 8000)") diff --git a/ksadk/cli/cmd_status.py b/ksadk/cli/cmd_status.py index e8ffe9be..e1e901ef 100644 --- a/ksadk/cli/cmd_status.py +++ b/ksadk/cli/cmd_status.py @@ -13,7 +13,7 @@ from ksadk.api.client import DryRunExit from ksadk.cli.agent_ref import merge_agent_inputs, resolve_agent_ref from ksadk.cli.dry_run import dry_run_option, run_async_with_dry_run, effective_dry_run -from ksadk.cli.error_utils import print_exception, resolution_error, usage_error, validation_error +from ksadk.cli.error_utils import print_exception, resolution_error, usage_error from ksadk.cli.resource_common import ( CONTEXT_SETTINGS, CompatibilityAliasCommand, @@ -55,7 +55,7 @@ @click.option("--watch", "-w", is_flag=True, help="Watch 模式,持续刷新") @click.option("--interval", "-i", default=2, help="Watch 刷新间隔 (秒)") @click.option("--region", "-r", default="cn-beijing-6", envvar="KSYUN_REGION", help="区域") -@click.option("--account-id", envvar="KSYUN_ACCOUNT_ID", help="金山云账号 ID") +@click.option("--account-id", envvar="KSYUN_ACCOUNT_ID", help="金山云账号 ID(可选;未设置时从 AK/SK 反查)") @dry_run_option() def status( agent_ref: str, @@ -131,12 +131,7 @@ def run_status_command( if resolved.source != "cli": print_info(f"未显式指定 Agent,使用 {resolved.source_text}: {agent}") - # 检查账号 ID - if not account_id: - raise validation_error( - "需要金山云账号 ID", - hints=["设置 KSYUN_ACCOUNT_ID 环境变量或使用 --account-id 参数。"], - ) + # account_id 由 client 层解析(env KSYUN_ACCOUNT_ID > AK/SK 反查),命令层不再强制校验 if watch and dry_run: raise usage_error("Watch 模式不支持 dry-run,请去掉 --watch 或取消 dry-run。") diff --git a/ksadk/configs/env_registry.py b/ksadk/configs/env_registry.py index e8c814ff..6873a968 100644 --- a/ksadk/configs/env_registry.py +++ b/ksadk/configs/env_registry.py @@ -263,6 +263,12 @@ class EnvVarSpec: EnvVarSpec("KSADK_USER_BACKEND_URL", "web", "User-facing backend URL used by hosted UI integrations."), 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( + "KSADK_OTLP_MAX_EXPORT_BATCH_SIZE", + "tracing", + "Maximum spans exported per OTLP batch; defaults to 64 to avoid collector request-size limits.", + "64", + ), EnvVarSpec("CLOUD_MONITOR_APP_KEY", "tracing", "CloudMonitor AppKey for optional OTLP ingestion.", sensitive=True), EnvVarSpec( "CLOUD_MONITOR_LANGFUSE_ENABLED", diff --git a/ksadk/configs/global_config.py b/ksadk/configs/global_config.py index 753b68e5..429ae404 100644 --- a/ksadk/configs/global_config.py +++ b/ksadk/configs/global_config.py @@ -39,6 +39,9 @@ "KSYUN_SECRET_KEY", "KSYUN_ACCOUNT_ID", "KSYUN_REGION", + # 反查身份缓存:{ak_fingerprint: {user_uuid, main_account_id, ...}} + # 嵌套对象,get_env_from_global_config 跳过它(不当 env-var),build 保留它 + "IDENTITY_CACHE", ], # 未来可扩展更多分组 # "observability": ["LANGFUSE_PUBLIC_KEY", ...], @@ -133,11 +136,14 @@ def get_env_from_global_config() -> Dict[str, str]: return {} env_vars = {} - + # 从各分组中提取环境变量 for group_name, keys in CONFIG_GROUPS.items(): group_config = config.get(group_name, {}) for key in keys: + if key == "IDENTITY_CACHE": + # 嵌套对象,不作为 env-var 暴露(由 identity 模块直接读写) + continue if key in group_config and group_config[key]: env_vars[key] = group_config[key] @@ -156,14 +162,39 @@ def build_global_config_from_env(env_vars: Dict[str, str]) -> Dict[str, Any]: dict: 全局配置结构 (嵌套格式) """ config = {} - + # 按分组构建嵌套结构 for group_name, keys in CONFIG_GROUPS.items(): group_config = {} for key in keys: + if key == "IDENTITY_CACHE": + continue # 由 identity 模块单独管理,不参与重建 if key in env_vars and env_vars[key]: group_config[key] = env_vars[key] if group_config: config[group_name] = group_config + # 保留现有 settings.json 里不在 CONFIG_GROUPS 的未知字段(含 IDENTITY_CACHE 嵌套对象) + # 避免 config set/wizard 保存时丢失 identity 缓存 + existing = load_global_config() + if isinstance(existing, dict): + for group_name, group_config in existing.items(): + if group_name == "version": + continue + if group_name not in CONFIG_GROUPS: + config[group_name] = group_config + continue + merged = dict(config.get(group_name) or {}) + for key, value in (group_config or {}).items(): + if key not in CONFIG_GROUPS[group_name] and key not in merged: + merged[key] = value + if merged: + config[group_name] = merged + # 保留 IDENTITY_CACHE(在 cloud 组但非 env-var,重建会丢,这里补回) + existing_cloud = existing.get("cloud") or {} + if isinstance(existing_cloud.get("IDENTITY_CACHE"), dict): + cloud_config = dict(config.get("cloud") or {}) + cloud_config["IDENTITY_CACHE"] = existing_cloud["IDENTITY_CACHE"] + config["cloud"] = cloud_config + return config diff --git a/ksadk/conversations/run_kinds.py b/ksadk/conversations/run_kinds.py new file mode 100644 index 00000000..24782db3 --- /dev/null +++ b/ksadk/conversations/run_kinds.py @@ -0,0 +1,61 @@ +"""run_status 事件的 run_mode / run_trigger 枚举常量。 + +canonical 定义,供 ksadk runtime 与 server 各自维护同名常量保持一致。 +两个维度独立: +- run_mode:怎么跑(background/foreground/unknown) +- run_trigger:怎么开始(new_run/checkpoint_resume/approval_resume/unknown) +""" + +from __future__ import annotations + +from typing import Literal, Mapping + +RunMode = Literal["background", "foreground", "unknown"] +RunTrigger = Literal["new_run", "checkpoint_resume", "approval_resume", "unknown"] + +RUN_MODE_BACKGROUND = "background" +RUN_MODE_FOREGROUND = "foreground" +RUN_MODE_UNKNOWN = "unknown" + +RUN_TRIGGER_NEW_RUN = "new_run" +RUN_TRIGGER_CHECKPOINT_RESUME = "checkpoint_resume" +RUN_TRIGGER_APPROVAL_RESUME = "approval_resume" +RUN_TRIGGER_UNKNOWN = "unknown" + +_VALID_RUN_MODES = {"background", "foreground", "unknown"} +_VALID_RUN_TRIGGERS = {"new_run", "checkpoint_resume", "approval_resume", "unknown"} + + +def validate_run_mode(value: str | None) -> str: + """非法值或 None 降级为 unknown,避免脏值落库。""" + return value if value in _VALID_RUN_MODES else RUN_MODE_UNKNOWN + + +def validate_run_trigger(value: str | None) -> str: + """非法值或 None 降级为 unknown,避免脏值落库。""" + return value if value in _VALID_RUN_TRIGGERS else RUN_TRIGGER_UNKNOWN + + +def trigger_from_resume_input(resume_input: dict | Mapping | None) -> str: + """从 resume_input 推导 run_trigger。 + + - checkpoint resume(agentengine.resume_checkpoint)→ checkpoint_resume + - approval resume(mcp_approval_response)→ approval_resume + - None / 无 type → new_run + - 其他未知 type → unknown + """ + if resume_input is None: + return RUN_TRIGGER_NEW_RUN + resume_type = str(resume_input.get("type") or "").strip() + if resume_type == "agentengine.resume_checkpoint": + return RUN_TRIGGER_CHECKPOINT_RESUME + if resume_type in { + "mcp_approval_response", + "function_call_output", + "ksadk.approval_response", + "ksadk_resume", + }: + return RUN_TRIGGER_APPROVAL_RESUME + if not resume_type: + return RUN_TRIGGER_NEW_RUN + return RUN_TRIGGER_UNKNOWN diff --git a/ksadk/conversations/run_status.py b/ksadk/conversations/run_status.py new file mode 100644 index 00000000..875e1a75 --- /dev/null +++ b/ksadk/conversations/run_status.py @@ -0,0 +1,53 @@ +"""Agent run 状态枚举。 + +run_status 事件 content.status / metadata.status 的合法值集合。 +用 Literal + frozenset 常量(非 Enum),因为值要直接序列化进 JSON 事件, +Enum 还需 .value 转换。 + +此模块刻意不依赖 ksadk 任何其他模块,避免 conversations.runtime ↔ server.app +之间的循环导入。canonical 定义在这里,runtime.py 和 server/app.py 都从本模块导入。 +""" + +from __future__ import annotations + +from typing import Literal + +RunStatus = Literal[ + # active + "in_progress", + "running", + "resuming", + "starting", + # terminal + "completed", + "failed", + "cancelled", + "interrupted", + "resume_failed", + # 派生 / 兼容别名 + "checkpointed", + "error", + "canceled", + "aborted", +] + +RUN_STATUS_TERMINAL: frozenset[str] = frozenset( + { + "completed", + "failed", + "error", + "cancelled", + "canceled", + "aborted", + "interrupted", + "resume_failed", + } +) +RUN_STATUS_ACTIVE: frozenset[str] = frozenset( + { + "in_progress", + "running", + "resuming", + "starting", + } +) diff --git a/ksadk/conversations/runtime.py b/ksadk/conversations/runtime.py index 88264240..eb3dfaf8 100644 --- a/ksadk/conversations/runtime.py +++ b/ksadk/conversations/runtime.py @@ -14,6 +14,7 @@ from fastapi import HTTPException from ksadk.conversations.attachments import compact_attachment_result_for_session +from ksadk.conversations.compaction_pipeline import build_working_set_metadata, run_pipeline from ksadk.conversations.context import ( TRANSCRIPT_EVENT_TYPES, build_history_from_events, @@ -36,7 +37,17 @@ normalize_kop_messages, ) from ksadk.conversations.reasoning_markup import strip_reasoning_markup -from ksadk.conversations.compaction_pipeline import build_working_set_metadata, run_pipeline +from ksadk.conversations.run_kinds import ( + RUN_MODE_FOREGROUND, + RUN_MODE_UNKNOWN, + RUN_TRIGGER_APPROVAL_RESUME, + RUN_TRIGGER_CHECKPOINT_RESUME, + RUN_TRIGGER_NEW_RUN, + RUN_TRIGGER_UNKNOWN, + trigger_from_resume_input, + validate_run_mode, + validate_run_trigger, +) from ksadk.conversations.semantic_summary import ( extract_pinned_state, find_pinned_group_indexes, @@ -55,7 +66,11 @@ 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, tool_execution_scope +from ksadk.runtime_context import ( + PlatformInvocationContext, + platform_invocation_scope, + tool_execution_scope, +) from ksadk.sessions import Session, SessionEvent, resolve_session_service from ksadk.tools.gateway import ( approval_interrupt_info_from_result, @@ -121,7 +136,9 @@ def _runtime_governance_from_env() -> RuntimeGovernanceState: ) -def _governance_error(reason: str, message: str, state: RuntimeGovernanceState) -> RuntimeCircuitOpen: +def _governance_error( + reason: str, message: str, state: RuntimeGovernanceState +) -> RuntimeCircuitOpen: return RuntimeCircuitOpen( reason, message, @@ -150,7 +167,9 @@ def _governance_record_turn_start(state: RuntimeGovernanceState) -> None: def _governance_record_tool_call(state: RuntimeGovernanceState) -> None: state.tool_calls += 1 if state.max_tool_calls and state.tool_calls > state.max_tool_calls: - raise _governance_error("max_tool_calls_exceeded", "runtime max_tool_calls limit exceeded", state) + raise _governance_error( + "max_tool_calls_exceeded", "runtime max_tool_calls limit exceeded", state + ) def _governance_record_tool_result(state: RuntimeGovernanceState, output: Any) -> None: @@ -160,17 +179,25 @@ def _governance_record_tool_result(state: RuntimeGovernanceState, output: Any) - state.max_consecutive_tool_failures and state.consecutive_tool_failures >= state.max_consecutive_tool_failures ): - raise _governance_error("consecutive_tool_failures", "runtime consecutive tool failure limit exceeded", state) + raise _governance_error( + "consecutive_tool_failures", "runtime consecutive tool failure limit exceeded", state + ) -def _governance_record_approval_response(state: RuntimeGovernanceState, approval: Mapping[str, Any]) -> None: +def _governance_record_approval_response( + state: RuntimeGovernanceState, approval: Mapping[str, Any] +) -> None: approved = bool(approval.get("approved") or approval.get("approve")) state.consecutive_approval_denials = 0 if approved else state.consecutive_approval_denials + 1 if ( state.max_consecutive_approval_denials and state.consecutive_approval_denials >= state.max_consecutive_approval_denials ): - raise _governance_error("consecutive_approval_denials", "runtime consecutive approval denial limit exceeded", state) + raise _governance_error( + "consecutive_approval_denials", + "runtime consecutive approval denial limit exceeded", + state, + ) def _governance_record_compact_failure(state: RuntimeGovernanceState) -> None: @@ -179,7 +206,11 @@ def _governance_record_compact_failure(state: RuntimeGovernanceState) -> None: state.max_consecutive_compact_failures and state.consecutive_compact_failures >= state.max_consecutive_compact_failures ): - raise _governance_error("consecutive_compact_failures", "runtime consecutive compact failure limit exceeded", state) + raise _governance_error( + "consecutive_compact_failures", + "runtime consecutive compact failure limit exceeded", + state, + ) def _governance_record_compact_success(state: RuntimeGovernanceState) -> None: @@ -201,8 +232,14 @@ async def _compact_conversation_history_with_governance( return checkpoint -def _tool_observability_metadata(tool_name: str, output: Any, *, duration_ms: int | None = None) -> dict[str, Any]: - output_text = json.dumps(output, ensure_ascii=False, sort_keys=True) if isinstance(output, Mapping) else str(output) +def _tool_observability_metadata( + tool_name: str, output: Any, *, duration_ms: int | None = None +) -> dict[str, Any]: + output_text = ( + json.dumps(output, ensure_ascii=False, sort_keys=True) + if isinstance(output, Mapping) + else str(output) + ) metadata: dict[str, Any] = { "tool_name": tool_name, "duration_ms": int(duration_ms or 0), @@ -216,7 +253,15 @@ def _tool_observability_metadata(tool_name: str, output: Any, *, duration_ms: in persisted = output.get("persisted") or output.get("persisted_outputs") metadata.update( { - "truncated": any(bool(output.get(key)) for key in ("truncated", "stdout_truncated", "stderr_truncated", "results_truncated")), + "truncated": any( + bool(output.get(key)) + for key in ( + "truncated", + "stdout_truncated", + "stderr_truncated", + "results_truncated", + ) + ), "persisted": bool(persisted), "exit_code": output.get("exit_code"), "error_type": str(output.get("error_type") or ""), @@ -290,7 +335,9 @@ def _normalize_usage_payload(usage: Mapping[str, Any] | None) -> dict[str, Any]: reasoning_tokens = completion_details.get("reasoning_tokens") if reasoning_tokens is not None: try: - normalized.setdefault("output_token_details", {})["reasoning"] = int(reasoning_tokens) + normalized.setdefault("output_token_details", {})["reasoning"] = int( + reasoning_tokens + ) except (TypeError, ValueError): pass return normalized @@ -413,7 +460,9 @@ def _span_current_context(span: Any | None): try: from opentelemetry.trace import use_span - return use_span(span, end_on_exit=False, record_exception=False, set_status_on_exception=False) + return use_span( + span, end_on_exit=False, record_exception=False, set_status_on_exception=False + ) except Exception: return nullcontext() @@ -548,6 +597,10 @@ class PreparedConversationTurn: compaction_trigger: str | None = None compacted_until_seq_id: int | None = None resume_input: dict[str, Any] | None = None + # 双维度 run 标识:run_mode=怎么跑(background/foreground), + # run_trigger=怎么开始(new_run/checkpoint_resume/approval_resume) + run_mode: str = RUN_MODE_FOREGROUND + run_trigger: str = RUN_TRIGGER_NEW_RUN @dataclass @@ -1166,7 +1219,9 @@ def _build_runner_request_payload( payload["checkpoint_id"] = str(prepared.resume_input.get("checkpoint_id") or "") payload["framework_ref"] = dict(prepared.resume_input.get("framework_ref") or {}) payload["metadata"] = dict(prepared.resume_input.get("metadata") or {}) - payload["checkpoint_metadata"] = dict(prepared.resume_input.get("checkpoint_metadata") or {}) + payload["checkpoint_metadata"] = dict( + prepared.resume_input.get("checkpoint_metadata") or {} + ) else: payload["input"] = prepared.resume_input payload["resume"] = True @@ -1184,7 +1239,9 @@ def _build_runner_request_payload( return payload -def _inject_runner_deferred_tools_for_request(runner: Any, prepared: PreparedConversationTurn) -> None: +def _inject_runner_deferred_tools_for_request( + runner: Any, prepared: PreparedConversationTurn +) -> None: deferred_tool_names = _extract_deferred_tool_names(prepared.request_metadata) if not deferred_tool_names: return @@ -1207,7 +1264,9 @@ def _attachment_summary_for_memory( continue summary = { "kind": str(item.get("kind") or "file"), - "display_name": str(item.get("display_name") or item.get("filename") or "uploaded_file"), + "display_name": str( + item.get("display_name") or item.get("filename") or "uploaded_file" + ), "mime_type": str(item.get("mime_type") or "application/octet-stream"), } summaries.append(summary) @@ -1430,6 +1489,43 @@ def _approval_request_events(events: Sequence[SessionEvent]) -> list[SessionEven ] +def _approval_resume_run_mode( + events: Sequence[SessionEvent], + resume_input: Mapping[str, Any], + *, + fallback: str, +) -> str: + target_ids = { + str(value) + for value in ( + resume_input.get("approval_request_id"), + resume_input.get("interrupt_id"), + resume_input.get("id"), + ) + if value + } + approval_events = _pending_approval_events(events) or _approval_request_events(events) + for approval_event in reversed(approval_events): + approval_id = _approval_request_id_from_event(approval_event) + if target_ids and approval_id not in target_ids: + continue + approval_invocation_id = str(approval_event.invocation_id or "") + if not approval_invocation_id: + continue + for event in reversed(events): + if event.event_type != "run_status" or event.invocation_id != approval_invocation_id: + continue + metadata = event.metadata or {} + state_delta = event.state_delta or {} + active_run = state_delta.get("active_run") if isinstance(state_delta, Mapping) else None + state_mode = active_run.get("run_mode") if isinstance(active_run, Mapping) else None + mode = validate_run_mode(str(metadata.get("run_mode") or state_mode or "")) + if mode != RUN_MODE_UNKNOWN: + return mode + break + return validate_run_mode(fallback) + + def _parse_approval_arguments(value: Any) -> dict[str, Any]: if isinstance(value, Mapping): return dict(value) @@ -1500,11 +1596,11 @@ def _normalize_approval_resume_input( return normalized approval_request_id = str( - normalized.get("approval_request_id") - or normalized.get("interrupt_id") - or "" + normalized.get("approval_request_id") or normalized.get("interrupt_id") or "" + ) + pending_events = ( + _approval_request_events(events) if include_resolved else _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: @@ -1797,6 +1893,17 @@ def _is_checkpoint_resume_input(resume_input: Mapping[str, Any]) -> bool: return str(resume_input.get("type") or "").strip() == "agentengine.resume_checkpoint" +def _failed_status_for_resume(resume_input: Mapping[str, Any] | None) -> str: + """checkpoint resume 失败时返回 resume_failed,否则返回 failed。 + + 仅 checkpoint resume 的失败才写 resume_failed(独立终态,触发 SSE [DONE] + 并让前端展示"恢复失败");approval/ksadk_resume 等其他 resume 失败仍写 failed。 + """ + if resume_input is not None and _is_checkpoint_resume_input(resume_input): + return "resume_failed" + return "failed" + + 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: @@ -1810,7 +1917,9 @@ def _normalize_checkpoint_resume_input(resume_input: Mapping[str, Any]) -> dict[ 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 = ( + 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")) @@ -1840,9 +1949,7 @@ def _normalize_checkpoint_resume_input(resume_input: Mapping[str, Any]) -> dict[ or resume_input.get("ResumeInstructionEnabled") ), "resume_instruction": str( - resume_input.get("resume_instruction") - or resume_input.get("ResumeInstruction") - or "" + resume_input.get("resume_instruction") or resume_input.get("ResumeInstruction") or "" ).strip(), } @@ -1933,7 +2040,9 @@ def _merge_agentengine_metadata( 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.get("framework_ref") + if isinstance(merged.get("framework_ref"), Mapping) + else {} ) merged["framework_ref"] = { **dict(existing_framework_ref), @@ -2134,7 +2243,9 @@ async def prime_session_metadata_for_user_turn( ) -> None: text = str(user_input or "").strip() if not text and messages: - text, _display, _content, _parts, _attachments, _attachment_results = _latest_user_turn(messages) + text, _display, _content, _parts, _attachments, _attachment_results = _latest_user_turn( + messages + ) await _update_session_metadata_after_user_turn( service=service, session=session, @@ -2361,7 +2472,14 @@ def _latest_user_turn( user_parts = list(latest_user_message.get("parts") or []) attachments = list(latest_user_message.get("attachments") or []) attachment_results = list(latest_user_message.get("attachment_results") or []) - return user_input, user_display_input, input_content, user_parts, attachments, attachment_results + return ( + user_input, + user_display_input, + input_content, + user_parts, + attachments, + attachment_results, + ) def _canonical_input_messages( @@ -2770,8 +2888,16 @@ async def append_run_status_event( detail: str | None = None, metadata: Mapping[str, Any] | None = None, session_service_provider: Callable[[], Any] | None = None, + run_mode: str = RUN_MODE_UNKNOWN, + run_trigger: str = RUN_TRIGGER_UNKNOWN, ) -> SessionEvent: - """记录运行态事件,供 UI/恢复逻辑区分 turn 生命周期。""" + """记录运行态事件,供 UI/恢复逻辑区分 turn 生命周期。 + + run_mode/run_trigger 是双维度字段(怎么跑/怎么开始),写入 metadata 与 + state_delta.active_run,供前端区分后台长任务、checkpoint 恢复、approval 续跑。 + """ + run_mode = validate_run_mode(run_mode) + run_trigger = validate_run_trigger(run_trigger) service = (session_service_provider or resolve_session_service)() if invocation_id: try: @@ -2790,7 +2916,25 @@ async def append_run_status_event( content = {"status": status} if detail: content["detail"] = detail - event_metadata = {"status": status, **({"detail": detail} if detail else {}), **dict(metadata or {})} + event_metadata = { + "status": status, + **({"detail": detail} if detail else {}), + "run_mode": run_mode, + "run_trigger": run_trigger, + **dict(metadata or {}), + } + # state_delta.active_run:与 agentengine-server _append_run_status 对齐, + # 让 session.state.active_run 反映当前 run 状态(postgres/local backend 会自动合并)。 + # server 侧 ActiveRunStatus 来源是 state_delta 而非扫事件,不写则 server 在 resume + # 期间仍持旧 active_run 值。run_mode/run_trigger 同步写入,供 _serialize_session 读取。 + state_delta = { + "active_run": { + "invocation_id": invocation_id or "", + "status": status, + "run_mode": run_mode, + "run_trigger": run_trigger, + } + } return await append_conversation_event( session_id=session_id, author=author, @@ -2800,6 +2944,7 @@ async def append_run_status_event( event_type="run_status", content=content, metadata=event_metadata, + state_delta=state_delta, session_service_provider=lambda: service, ) @@ -2812,10 +2957,14 @@ async def append_deferred_tools_event( invocation_id: Optional[str] = None, source_tool_name: str = "tool_search", session_service_provider: Callable[[], Any] | None = None, + run_mode: str = RUN_MODE_UNKNOWN, + run_trigger: str = RUN_TRIGGER_UNKNOWN, ) -> SessionEvent | None: names = _extract_deferred_tool_names({"deferred_tool_names": list(deferred_tool_names)}) if not names: return None + run_mode = validate_run_mode(run_mode) + run_trigger = validate_run_trigger(run_trigger) return await append_conversation_event( session_id=session_id, author=author, @@ -2827,9 +2976,19 @@ async def append_deferred_tools_event( metadata={ "status": "in_progress", "detail": "deferred_tools_selected", + "run_mode": run_mode, + "run_trigger": run_trigger, "source_tool_name": source_tool_name, "deferred_tool_names": names, }, + state_delta={ + "active_run": { + "invocation_id": invocation_id or "", + "status": "in_progress", + "run_mode": run_mode, + "run_trigger": run_trigger, + } + }, session_service_provider=session_service_provider, ) @@ -3141,7 +3300,9 @@ async def compact_conversation_history( "tokens_before": pipeline_result["tokens_before"], "tokens_after": pipeline_result["tokens_after"], "snip_stats": { - "removed_redundant_tool_results": pipeline_result["snip_stats"].removed_redundant_tool_results, + "removed_redundant_tool_results": pipeline_result[ + "snip_stats" + ].removed_redundant_tool_results, "snip_released_tokens": pipeline_result["snip_released_tokens"], "covered_seq_range": list(pipeline_result["snip_stats"].covered_seq_range or []), }, @@ -3177,8 +3338,15 @@ async def build_run_input( invocation_id: Optional[str] = None, governance_state: RuntimeGovernanceState | None = None, session_service_provider: Callable[[], Any] | None = None, + run_mode: str = RUN_MODE_FOREGROUND, ) -> PreparedConversationTurn: - """构建一次 turn 的标准运行输入,并在进入模型前做上下文投影/压缩。""" + """构建一次 turn 的标准运行输入,并在进入模型前做上下文投影/压缩。 + + run_mode 由 caller 按 endpoint 语义传入(Background:true→background,普通→foreground); + run_trigger 由 resume_input 推导(new_run/checkpoint_resume/approval_resume)。 + """ + caller_run_mode = validate_run_mode(run_mode) + caller_run_trigger = trigger_from_resume_input(resume_input) provider = session_service_provider or resolve_session_service service = provider() resolved_user_id = user_id @@ -3225,6 +3393,20 @@ async def build_run_input( invocation_id=resolved_invocation_id, session_service_provider=provider, ) + # 补写 run_status(resuming):让 ActiveRunStatus 在 resume 期间正确反映"恢复中"。 + # append_run_resume_event 写的是 run_resume 事件(status=resuming),而 + # _latest_session_run_status 只扫 run_status 事件 → 不补写则 + # resuming 不进 ActiveRunStatus。 + await append_run_status_event( + session_id=resolved_session_id, + author=agent_id, + status="resuming", + invocation_id=resolved_invocation_id, + detail="checkpoint_resume", + session_service_provider=provider, + run_mode=caller_run_mode, + run_trigger=RUN_TRIGGER_CHECKPOINT_RESUME, + ) history = build_history_from_events(await service.get_events(resolved_session_id)) return PreparedConversationTurn( session_id=resolved_session_id, @@ -3248,6 +3430,8 @@ async def build_run_input( **_agentengine_resume_metadata(normalized_resume_input), }, resume_input=normalized_resume_input, + run_mode=caller_run_mode, + run_trigger=RUN_TRIGGER_CHECKPOINT_RESUME, ) is_approval_resume = _is_approval_resume_input(normalized_resume_input) @@ -3276,8 +3460,15 @@ async def build_run_input( normalized_resume_input, existing_events, ) + caller_run_mode = _approval_resume_run_mode( + existing_events, + normalized_resume_input, + fallback=caller_run_mode, + ) if governance_state is not None: - governance_state.consecutive_approval_denials = _consecutive_approval_denials_from_events(existing_events) + governance_state.consecutive_approval_denials = ( + _consecutive_approval_denials_from_events(existing_events) + ) resume_text = _format_resume_response_text(normalized_resume_input) await append_conversation_event( @@ -3324,6 +3515,8 @@ async def build_run_input( instructions=normalized_instructions, request_metadata=normalized_request_metadata, resume_input=effective_resume_input, + run_mode=caller_run_mode, + run_trigger=RUN_TRIGGER_APPROVAL_RESUME, ) normalized_messages = _normalized_conversation_messages(messages) @@ -3428,6 +3621,8 @@ async def build_run_input( if checkpoint else None ), + run_mode=caller_run_mode, + run_trigger=caller_run_trigger, ) @@ -3460,6 +3655,7 @@ async def invoke_conversation_once( account_id: str | None = None, invocation_id: Optional[str] = None, session_service_provider: Callable[[], Any] | None = None, + run_mode: str = RUN_MODE_FOREGROUND, ) -> tuple[str, dict[str, Any]]: """非流式 turn 编排入口。 @@ -3470,6 +3666,9 @@ async def invoke_conversation_once( prepare_runner(runner, model) governance = _runtime_governance_from_env() _governance_record_turn_start(governance) + # 入口算 run_trigger(不依赖 prepared,build_run_input 失败时也能用) + entry_run_mode = validate_run_mode(run_mode) + entry_run_trigger = trigger_from_resume_input(resume_input) try: prepared = await build_run_input( agent_id=agent_id, @@ -3486,17 +3685,23 @@ async def invoke_conversation_once( invocation_id=invocation_id, governance_state=governance, session_service_provider=provider, + run_mode=entry_run_mode, ) + # prepared 之后的 run_status 写入复用 prepared 的 mode/trigger + run_mode = prepared.run_mode + run_trigger = prepared.run_trigger except RuntimeCircuitOpen as exc: if session_id: await append_run_status_event( session_id=session_id, author=_runner_name(runner), - status="failed", + status=_failed_status_for_resume(resume_input), invocation_id=invocation_id, detail=str(exc), metadata={"governance": exc.metadata}, session_service_provider=provider, + run_mode=entry_run_mode, + run_trigger=entry_run_trigger, ) raise _inject_runner_deferred_tools_for_request(runner, prepared) @@ -3545,6 +3750,8 @@ async def invoke_conversation_once( status="in_progress", invocation_id=prepared.invocation_id, session_service_provider=provider, + run_mode=run_mode, + run_trigger=run_trigger, ) result: dict[str, Any] | None = None @@ -3575,6 +3782,8 @@ async def invoke_conversation_once( invocation_id=prepared.invocation_id, detail="cancel_requested", session_service_provider=provider, + run_mode=run_mode, + run_trigger=run_trigger, ) raise except Exception as exc: @@ -3596,15 +3805,19 @@ async def invoke_conversation_once( await append_run_status_event( session_id=prepared.session_id, author=runner_name, - status="failed", + status=_failed_status_for_resume(resume_input), invocation_id=prepared.invocation_id, detail=str(circuit_exc), metadata={"governance": circuit_exc.metadata}, session_service_provider=provider, + run_mode=run_mode, + run_trigger=run_trigger, ) raise circuit_exc if checkpoint: - prepared = await _refresh_history(prepared, session_service_provider=provider) + 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) @@ -3623,16 +3836,20 @@ async def invoke_conversation_once( invocation_id=prepared.invocation_id, detail=f"fallback_model:{fallback_model}", session_service_provider=provider, + run_mode=run_mode, + run_trigger=run_trigger, ) continue last_invoke_error = exc await append_run_status_event( session_id=prepared.session_id, author=runner_name, - status="failed", + status=_failed_status_for_resume(resume_input), invocation_id=prepared.invocation_id, detail=str(exc), session_service_provider=provider, + run_mode=run_mode, + run_trigger=run_trigger, ) break @@ -3641,6 +3858,9 @@ async def invoke_conversation_once( result = result or {} output_text = strip_reasoning_markup(str(result.get("output", ""))) result_usage = _normalize_usage_payload(result.get("usage")) + result_last_usage = _normalize_usage_payload( + (result.get("metadata") or {}).get("last_usage") + ) or (result_usage if result_usage else {}) _set_conversation_output_attributes(span, output_text) result_agentengine_metadata = _extract_agentengine_metadata(result) assistant_metadata: dict[str, Any] = { @@ -3657,10 +3877,14 @@ async def invoke_conversation_once( assistant_metadata["request_metadata"] = request_metadata_without_agentengine if result_usage: assistant_metadata["usage"] = result_usage + if result_last_usage: + assistant_metadata["last_usage"] = result_last_usage 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, + assistant_metadata.get("agentengine") + if isinstance(assistant_metadata, Mapping) + else None, fallback_run_id=prepared.invocation_id, ) if checkpoint_args: @@ -3706,6 +3930,8 @@ async def invoke_conversation_once( status="completed", invocation_id=prepared.invocation_id, session_service_provider=provider, + run_mode=run_mode, + run_trigger=run_trigger, ) result_payload = { "output_text": output_text, @@ -3717,12 +3943,16 @@ async def invoke_conversation_once( for key, value in prepared.request_metadata.items() if key != "agentengine" }, - **_merge_agentengine_metadata(prepared.request_metadata, result_agentengine_metadata), + **_merge_agentengine_metadata( + prepared.request_metadata, result_agentengine_metadata + ), }, } if result_usage: result_payload["usage"] = result_usage result_payload["metadata"]["usage"] = result_usage + if result_last_usage: + result_payload["metadata"]["last_usage"] = result_last_usage if response_id: result_payload["response_id"] = response_id return prepared.session_id, result_payload @@ -3751,12 +3981,15 @@ async def _iter_conversation_turn_events( account_id: str | None = None, invocation_id: Optional[str] = None, session_service_provider: Callable[[], Any] | None = None, + run_mode: str = RUN_MODE_FOREGROUND, ) -> AsyncIterator[dict[str, Any]]: """Internal semantic event stream shared by protocol serializers.""" provider = session_service_provider or resolve_session_service prepare_runner(runner, model) governance = _runtime_governance_from_env() _governance_record_turn_start(governance) + entry_run_mode = validate_run_mode(run_mode) + entry_run_trigger = trigger_from_resume_input(resume_input) if resume_input is None: compaction_preview = await preview_auto_compaction( agent_id=agent_id, @@ -3802,17 +4035,23 @@ async def _iter_conversation_turn_events( invocation_id=invocation_id, governance_state=governance, session_service_provider=provider, + run_mode=entry_run_mode, ) + # prepared 之后的 run_status 写入复用 prepared 的 mode/trigger + run_mode = prepared.run_mode + run_trigger = prepared.run_trigger except RuntimeCircuitOpen as exc: if session_id: await append_run_status_event( session_id=session_id, author=_runner_name(runner), - status="failed", + status=_failed_status_for_resume(resume_input), invocation_id=invocation_id, detail=str(exc), metadata={"governance": exc.metadata}, session_service_provider=provider, + run_mode=entry_run_mode, + run_trigger=entry_run_trigger, ) yield {"type": "error", "message": str(exc) or "Agent 运行失败"} return @@ -3904,6 +4143,8 @@ def _finish_span() -> None: status="in_progress", invocation_id=prepared.invocation_id, session_service_provider=provider, + run_mode=run_mode, + run_trigger=run_trigger, ) accumulated_text = "" @@ -3914,6 +4155,7 @@ def _finish_span() -> None: responses_response_id: str | None = response_id runner_agentengine_metadata: dict[str, Any] = {} stream_usage: dict[str, Any] = {} + stream_last_usage: dict[str, Any] = {} reasoning_disabled = _model_options_disable_reasoning(prepared.model_options) for attempt in range(2): try: @@ -3944,11 +4186,17 @@ def _finish_span() -> None: 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 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( @@ -3967,15 +4215,22 @@ def _finish_span() -> None: if chunk_type == "responses_output": if isinstance(chunk.get("usage"), Mapping): stream_usage = _normalize_usage_payload(chunk.get("usage")) + chunk_last = (chunk.get("metadata") or {}).get("last_usage") + if isinstance(chunk_last, Mapping): + stream_last_usage = _normalize_usage_payload(chunk_last) or stream_usage raw_output = chunk.get("output") - responses_output = raw_output if isinstance(raw_output, list) else [] + responses_output = ( + raw_output if isinstance(raw_output, list) else [] + ) if reasoning_disabled: responses_output = _filter_responses_reasoning_output( responses_output ) raw_response_id = chunk.get("response_id") responses_response_id = ( - str(raw_response_id) if raw_response_id else responses_response_id + str(raw_response_id) + if raw_response_id + else responses_response_id ) if responses_output and not emitted_response_artifacts: for semantic_event in _semantic_events_from_responses_output( @@ -4053,7 +4308,9 @@ def _finish_span() -> None: continue if chunk_type in {"stage_tool_call", "stage_tool_result"}: emitted_response_artifacts = True - tool_name = str(chunk.get("tool_name") or chunk.get("name") or "tool") + tool_name = str( + chunk.get("tool_name") or chunk.get("name") or "tool" + ) tool_args = chunk.get("tool_args", chunk.get("args", {})) if not isinstance(tool_args, Mapping): tool_args = {} @@ -4130,6 +4387,8 @@ def _finish_span() -> None: invocation_id=prepared.invocation_id, detail="approval_required", session_service_provider=provider, + run_mode=run_mode, + run_trigger=run_trigger, ) emitted_anything = True yield { @@ -4150,7 +4409,9 @@ def _finish_span() -> None: "tool_name": tool_name, "tool_output": chunk.get("tool_output", ""), "run_id": tool_run_id, - "observability": _tool_observability_metadata(tool_name, chunk.get("tool_output", "")), + "observability": _tool_observability_metadata( + tool_name, chunk.get("tool_output", "") + ), "tool_receipt": _tool_receipt_metadata( session_id=prepared.session_id, run_id=tool_run_id, @@ -4170,7 +4431,9 @@ def _finish_span() -> None: }, session_service_provider=provider, ) - deferred_tool_names = _extract_deferred_tool_names(chunk.get("tool_output", "")) + deferred_tool_names = _extract_deferred_tool_names( + chunk.get("tool_output", "") + ) if tool_name == "tool_search" and deferred_tool_names: await append_deferred_tools_event( session_id=prepared.session_id, @@ -4178,8 +4441,12 @@ def _finish_span() -> None: deferred_tool_names=deferred_tool_names, invocation_id=prepared.invocation_id, session_service_provider=provider, + run_mode=run_mode, + run_trigger=run_trigger, ) - _governance_record_tool_result(governance, chunk.get("tool_output", "")) + _governance_record_tool_result( + governance, chunk.get("tool_output", "") + ) emitted_anything = True yield { "type": "tool_result", @@ -4207,6 +4474,8 @@ def _finish_span() -> None: invocation_id=prepared.invocation_id, detail="approval_required", session_service_provider=provider, + run_mode=run_mode, + run_trigger=run_trigger, ) emitted_anything = True yield { @@ -4223,6 +4492,9 @@ def _finish_span() -> None: accumulated_text = final_text if isinstance(chunk.get("usage"), Mapping): stream_usage = _normalize_usage_payload(chunk.get("usage")) + chunk_last = (chunk.get("metadata") or {}).get("last_usage") + if isinstance(chunk_last, Mapping): + stream_last_usage = _normalize_usage_payload(chunk_last) or stream_usage break except asyncio.CancelledError: await append_run_status_event( @@ -4232,6 +4504,8 @@ def _finish_span() -> None: invocation_id=prepared.invocation_id, detail="cancel_requested", session_service_provider=provider, + run_mode=run_mode, + run_trigger=run_trigger, ) yield { "type": "cancelled", @@ -4259,11 +4533,13 @@ def _finish_span() -> None: await append_run_status_event( session_id=prepared.session_id, author=runner_name, - status="failed", + status=_failed_status_for_resume(resume_input), invocation_id=prepared.invocation_id, detail=str(circuit_exc), metadata={"governance": circuit_exc.metadata}, session_service_provider=provider, + run_mode=run_mode, + run_trigger=run_trigger, ) yield {"type": "error", "message": str(circuit_exc) or "Agent 运行失败"} return @@ -4277,7 +4553,9 @@ def _finish_span() -> None: ) or None, } - prepared = await _refresh_history(prepared, session_service_provider=provider) + prepared = await _refresh_history( + prepared, session_service_provider=provider + ) runtime_context.history = list(prepared.history) continue if attempt == 0 and not emitted_anything: @@ -4299,22 +4577,30 @@ def _finish_span() -> None: invocation_id=prepared.invocation_id, detail=f"fallback_model:{fallback_model}", session_service_provider=provider, + run_mode=run_mode, + run_trigger=run_trigger, ) continue await append_run_status_event( session_id=prepared.session_id, author=runner_name, - status="failed", + status=_failed_status_for_resume(resume_input), invocation_id=prepared.invocation_id, detail=str(exc), - metadata={"governance": exc.metadata} if isinstance(exc, RuntimeCircuitOpen) else None, + metadata={"governance": exc.metadata} + if isinstance(exc, RuntimeCircuitOpen) + else None, session_service_provider=provider, + run_mode=run_mode, + run_trigger=run_trigger, ) yield {"type": "error", "message": str(exc) or "Agent 运行失败"} return request_metadata_without_agentengine = { - key: value for key, value in dict(request_metadata or {}).items() if key != "agentengine" + key: value + for key, value in dict(request_metadata or {}).items() + if key != "agentengine" } assistant_metadata = { **trace_metadata, @@ -4325,16 +4611,22 @@ def _finish_span() -> None: assistant_metadata["responses_output"] = responses_output if stream_usage: assistant_metadata["usage"] = stream_usage + if stream_last_usage: + assistant_metadata["last_usage"] = stream_last_usage + elif stream_usage: + assistant_metadata["last_usage"] = stream_usage if responses_response_id: assistant_metadata["response_id"] = responses_response_id if emitted_anything and not saw_final_chunk and not accumulated_text: await append_run_status_event( session_id=prepared.session_id, author=runner_name, - status="failed", + status=_failed_status_for_resume(resume_input), invocation_id=prepared.invocation_id, detail="runner_stream_ended_without_final_output", session_service_provider=provider, + run_mode=run_mode, + run_trigger=run_trigger, ) _finish_span() yield { @@ -4379,6 +4671,8 @@ def _finish_span() -> None: status="completed", invocation_id=prepared.invocation_id, session_service_provider=provider, + run_mode=run_mode, + run_trigger=run_trigger, ) _finish_span() yield { @@ -4413,6 +4707,7 @@ async def stream_conversation_turn( account_id: str | None = None, invocation_id: Optional[str] = None, session_service_provider: Callable[[], Any] | None = None, + run_mode: str = RUN_MODE_FOREGROUND, ) -> AsyncIterator[str]: """Legacy ksadk response SSE stream used by hosted chat and chat-completions.""" async for event in _iter_conversation_turn_events( @@ -4432,6 +4727,7 @@ async def stream_conversation_turn( account_id=account_id, invocation_id=invocation_id, session_service_provider=session_service_provider, + run_mode=run_mode, ): event_type = event.get("type") if event_type == "compaction": @@ -4525,6 +4821,7 @@ async def stream_responses_conversation_turn( account_id: str | None = None, invocation_id: Optional[str] = None, session_service_provider: Callable[[], Any] | None = None, + run_mode: str = RUN_MODE_FOREGROUND, ) -> AsyncIterator[str]: """OpenAI Responses-style SSE stream.""" response_id = f"resp_{uuid.uuid4().hex}" @@ -4603,6 +4900,7 @@ def _next_output_index() -> int: account_id=account_id, invocation_id=invocation_id, session_service_provider=session_service_provider, + run_mode=run_mode, ): event_metadata = event.get("metadata") if isinstance(event_metadata, Mapping): diff --git a/ksadk/deployment/agent_access.py b/ksadk/deployment/agent_access.py index f7411e15..94736b20 100644 --- a/ksadk/deployment/agent_access.py +++ b/ksadk/deployment/agent_access.py @@ -80,6 +80,39 @@ def is_agent_not_visible_yet_error(exc: Exception) -> bool: ) +def is_agent_not_found_error(exc: Exception) -> bool: + """判断异常是否表示目标 Agent 在服务端不存在(用于 deploy update 失效后回退 create)。 + + 优先用结构化属性(AgentEngineAPIError.code / details.http_status)判定,回退到文案匹配。 + 与 is_agent_not_visible_yet_error 的区别:后者专用于 create 后短窗口内的 GetAgent 404 + (要求状态码与文案同时命中),本函数面向 update/get 对已删除 agent 的 404, + 要求 404 状态码与 not-found 文案同时命中,避免误判其他 404(如鉴权/路由 404)。 + """ + if exc is None: + return False + + # 结构化判定:AgentEngineAPIError 的 code 或 details.http_status 为 404 + code = getattr(exc, "code", None) + if isinstance(code, int) and code == 404: + return True + details = getattr(exc, "details", None) + if isinstance(details, Mapping): + http_status = details.get("http_status") + try: + if int(http_status) == 404: + return True + except (TypeError, ValueError): + pass + + # 文案回退:覆盖 update_agent / get_agent 404 的常见文案(需状态码与文案同时命中) + text = str(exc or "").lower() + if not text: + return False + has_404 = "http 404" in text or "status=404" in text or "code: 404" in text + has_agent_not_found = "未找到对应的 agent" in text or "agent not found" in text + return has_404 and has_agent_not_found + + def should_suppress_transient_get_agent_not_found_log( *, method: str, diff --git a/ksadk/detection/detector.py b/ksadk/detection/detector.py index c2eff8fe..a50baa8e 100644 --- a/ksadk/detection/detector.py +++ b/ksadk/detection/detector.py @@ -4,16 +4,17 @@ import ast import json -import os from dataclasses import dataclass from enum import Enum from pathlib import Path from typing import Optional + import yaml class FrameworkType(Enum): """支持的框架类型""" + ADK = "adk" LANGCHAIN = "langchain" LANGGRAPH = "langgraph" @@ -25,6 +26,7 @@ class FrameworkType(Enum): @dataclass class DetectionResult: """检测结果""" + type: FrameworkType name: str entry_point: str @@ -32,7 +34,7 @@ class DetectionResult: agent_variable: str = "root_agent" runner_class: str = "" confidence: float = 0.0 - + @property def is_valid(self) -> bool: return self.type != FrameworkType.UNKNOWN @@ -40,14 +42,15 @@ def is_valid(self) -> bool: class FrameworkDetector: """框架检测器""" + _ENTRY_FILES = ("agent.py", "main.py", "app.py") - + def __init__(self, project_dir: str): self.project_dir = Path(project_dir).resolve() - + def detect(self) -> DetectionResult: """检测项目使用的框架类型""" - + # 1. 检查 ksadk.yaml 配置文件 (显式声明) config_result = self._check_config() if config_result: @@ -56,17 +59,14 @@ def detect(self) -> DetectionResult: graph_config_result = self._check_langgraph_json() if graph_config_result: return graph_config_result - + # 2. 查找 Python 包目录 package_path = self._find_package_dir() if not package_path: return DetectionResult( - type=FrameworkType.UNKNOWN, - name="Unknown", - entry_point="", - package_path="" + type=FrameworkType.UNKNOWN, name="Unknown", entry_point="", package_path="" ) - + # 3. 查找 agent.py 或 __init__.py agent_file = self._find_agent_file(package_path) if not agent_file: @@ -74,12 +74,12 @@ def detect(self) -> DetectionResult: type=FrameworkType.UNKNOWN, name="Unknown", entry_point="", - package_path=str(package_path) + package_path=str(package_path), ) - + # 4. 分析代码确定框架类型 return self._analyze_code(agent_file, package_path) - + def _check_config(self) -> Optional[DetectionResult]: """检查配置文件 (agentengine.yaml 或 ksadk.yaml)""" # 优先检查 agentengine.yaml @@ -88,15 +88,15 @@ def _check_config(self) -> Optional[DetectionResult]: config_path = self.project_dir / "ksadk.yaml" if not config_path.exists(): config_path = self.project_dir / "ksadk.yml" - + if not config_path.exists(): return None - + try: # 使用 utf-8-sig 自动处理 BOM,确保 Windows 兼容性 - with open(config_path, 'r', encoding='utf-8-sig') as f: + with open(config_path, "r", encoding="utf-8-sig") as f: config = yaml.safe_load(f) - + framework = config.get("framework", "adk").lower() framework_type = { "adk": FrameworkType.ADK, @@ -108,8 +108,7 @@ def _check_config(self) -> Optional[DetectionResult]: artifact_type = str(config.get("artifact_type") or "").strip().lower() is_hermes_container = ( - framework_type == FrameworkType.HERMES - and artifact_type == "container" + framework_type == FrameworkType.HERMES and artifact_type == "container" ) default_entry_point = ( "runtime/app.py" @@ -122,7 +121,9 @@ def _check_config(self) -> Optional[DetectionResult]: entry_path = self.project_dir / str(entry_point).replace("\\", "/") if not entry_path.exists() or not entry_path.is_file(): return None - if not is_hermes_container and not self._entry_exposes_variable(entry_path, agent_variable): + if not is_hermes_container and not self._entry_exposes_variable( + entry_path, agent_variable + ): return None package = str(config.get("package") or "").strip() if package: @@ -131,7 +132,7 @@ def _check_config(self) -> Optional[DetectionResult]: package_path = entry_path.parent else: package_path = self.project_dir / self.project_dir.name.replace("-", "_") - + return DetectionResult( type=framework_type, name=config.get("name", self.project_dir.name), @@ -139,35 +140,35 @@ def _check_config(self) -> Optional[DetectionResult]: package_path=str(package_path), agent_variable=agent_variable, runner_class=runner_class, - confidence=1.0 + confidence=1.0, ) except Exception: return None - + def _find_package_dir(self) -> Optional[Path]: """查找 Python 包目录""" # 优先查找与项目同名的包 (下划线版本) - expected_name = self.project_dir.name.replace('-', '_') + expected_name = self.project_dir.name.replace("-", "_") expected_path = self.project_dir / expected_name if expected_path.exists() and expected_path.is_dir(): if (expected_path / "__init__.py").exists() or any( (expected_path / entry_file).exists() for entry_file in self._ENTRY_FILES ): return expected_path - + # 查找任何包含 __init__.py 的子目录 for item in self.project_dir.iterdir(): if ( item.is_dir() - and not item.name.startswith('.') - and item.name not in ('tests', 'test', '__pycache__') + and not item.name.startswith(".") + and item.name not in ("tests", "test", "__pycache__") and ( (item / "__init__.py").exists() or any((item / entry_file).exists() for entry_file in self._ENTRY_FILES) ) ): return item - + # 当前目录本身也可能是一个包 if (self.project_dir / "__init__.py").exists(): return self.project_dir @@ -196,9 +197,9 @@ def _find_package_dir(self) -> Optional[Path]: ) ): return item - + return None - + def _find_agent_file(self, package_path: Path) -> Optional[Path]: """查找 agent.py 文件""" # 优先查找常见入口文件 @@ -206,7 +207,7 @@ def _find_agent_file(self, package_path: Path) -> Optional[Path]: entry_path = package_path / entry_file if entry_path.exists(): return entry_path - + # 检查 __init__.py 是否导出 root_agent init_py = package_path / "__init__.py" if init_py.exists(): @@ -216,7 +217,7 @@ def _find_agent_file(self, package_path: Path) -> Optional[Path]: return init_py except Exception: pass - + return None @staticmethod @@ -252,7 +253,10 @@ def _has_agent_variable(cls, content: str, agent_var: str) -> bool: rf"^\s*from\s+[\.\w]+\s+import\s+.*\b{escaped}\b", rf"^\s*import\s+[\.\w]+\s+as\s+{escaped}\b", ] - return any(re.search(pattern, content, re.MULTILINE) for pattern in patterns) or cls._detect_agent_variable(content) == agent_var + return ( + any(re.search(pattern, content, re.MULTILINE) for pattern in patterns) + or cls._detect_agent_variable(content) == agent_var + ) def _entry_exposes_variable(self, entry_path: Path, agent_var: str) -> bool: try: @@ -261,10 +265,12 @@ def _entry_exposes_variable(self, entry_path: Path, agent_var: str) -> bool: return False return self._has_agent_variable(content, agent_var) - def _framework_from_entry(self, entry_path: Path, fallback: FrameworkType = FrameworkType.UNKNOWN) -> FrameworkType: + def _framework_from_entry( + self, entry_path: Path, fallback: FrameworkType = FrameworkType.UNKNOWN + ) -> FrameworkType: try: content = entry_path.read_text(encoding="utf-8-sig") - tree = ast.parse(content) + tree = ast.parse(content, filename=str(entry_path)) imports = self._extract_imports(tree) except Exception: return fallback @@ -309,23 +315,23 @@ def _check_langgraph_json(self) -> Optional[DetectionResult]: confidence=0.95, ) return None - + def _analyze_code(self, agent_file: Path, package_path: Path) -> DetectionResult: """分析代码确定框架类型""" try: # 使用 utf-8-sig 自动处理 BOM,兼容 Windows/编辑器带 BOM 的脚本 content = agent_file.read_text(encoding="utf-8-sig") - tree = ast.parse(content) + tree = ast.parse(content, filename=str(agent_file)) except Exception: return DetectionResult( type=FrameworkType.UNKNOWN, name=self.project_dir.name, entry_point=str(agent_file.relative_to(self.project_dir)), - package_path=str(package_path) + package_path=str(package_path), ) - + imports = self._extract_imports(tree) - + # 检测 ADK if self._is_adk(imports, content): return DetectionResult( @@ -334,9 +340,9 @@ def _analyze_code(self, agent_file: Path, package_path: Path) -> DetectionResult entry_point=str(agent_file.relative_to(self.project_dir)), package_path=str(package_path), agent_variable="root_agent", - confidence=0.9 + confidence=0.9, ) - + # 检测 DeepAgents (LangChain 生态, 底层基于 LangGraph) if self._is_deepagents(imports, content): return DetectionResult( @@ -345,7 +351,7 @@ def _analyze_code(self, agent_file: Path, package_path: Path) -> DetectionResult entry_point=str(agent_file.relative_to(self.project_dir)), package_path=str(package_path), agent_variable="root_agent", - confidence=0.9 + confidence=0.9, ) # 检测 LangGraph @@ -356,9 +362,9 @@ def _analyze_code(self, agent_file: Path, package_path: Path) -> DetectionResult entry_point=str(agent_file.relative_to(self.project_dir)), package_path=str(package_path), agent_variable="root_agent", - confidence=0.9 + confidence=0.9, ) - + # 检测 LangChain if self._is_langchain(imports, content): return DetectionResult( @@ -367,35 +373,35 @@ def _analyze_code(self, agent_file: Path, package_path: Path) -> DetectionResult entry_point=str(agent_file.relative_to(self.project_dir)), package_path=str(package_path), agent_variable="root_agent", - confidence=0.8 + confidence=0.8, ) - + return DetectionResult( type=FrameworkType.UNKNOWN, name=self.project_dir.name, entry_point=str(agent_file.relative_to(self.project_dir)), - package_path=str(package_path) + package_path=str(package_path), ) - + def _extract_imports(self, tree: ast.AST) -> set: """提取导入的模块""" imports = set() for node in ast.walk(tree): if isinstance(node, ast.Import): for alias in node.names: - imports.add(alias.name.split('.')[0]) + imports.add(alias.name.split(".")[0]) elif isinstance(node, ast.ImportFrom): if node.module: - imports.add(node.module.split('.')[0]) + imports.add(node.module.split(".")[0]) return imports - + def _is_adk(self, imports: set, content: str) -> bool: """检测是否为 ADK 项目""" # 检查 google.adk 导入 if "google" in imports and ("google.adk" in content or "from google.adk" in content): return True return False - + def _is_langgraph(self, imports: set, content: str) -> bool: """检测是否为 LangGraph 项目""" if "langgraph" in imports: @@ -403,10 +409,15 @@ def _is_langgraph(self, imports: set, content: str) -> bool: if "StateGraph" in content or "langgraph.graph" in content: return True return False - + def _is_langchain(self, imports: set, content: str) -> bool: """检测是否为 LangChain 项目""" - langchain_modules = {"langchain", "langchain_openai", "langchain_core", "langchain_community"} + langchain_modules = { + "langchain", + "langchain_openai", + "langchain_core", + "langchain_community", + } if langchain_modules & imports: return True return False diff --git a/ksadk/detection/mcp_detector.py b/ksadk/detection/mcp_detector.py index f6d211d9..8e98b509 100644 --- a/ksadk/detection/mcp_detector.py +++ b/ksadk/detection/mcp_detector.py @@ -5,13 +5,15 @@ import ast from dataclasses import dataclass from pathlib import Path -from typing import Optional, List +from typing import List, Optional + import yaml @dataclass class MCPDetectionResult: """MCP 检测结果""" + is_mcp: bool name: str entry_point: str @@ -19,11 +21,11 @@ class MCPDetectionResult: mcp_variable: str = "mcp" tools: List[str] = None # 检测到的工具名称 confidence: float = 0.0 - + def __post_init__(self): if self.tools is None: self.tools = [] - + @property def is_valid(self) -> bool: return self.is_mcp @@ -31,44 +33,41 @@ def is_valid(self) -> bool: class MCPDetector: """MCP 检测器 - 检测 FastMCP 项目""" - + # FastMCP 特征模式 FASTMCP_PATTERNS = [ "from fastmcp import FastMCP", "from fastmcp import", "import fastmcp", ] - + MCP_DECORATORS = [ "@mcp.tool", "@mcp.resource", "@mcp.prompt", ] - + def __init__(self, project_dir: str): self.project_dir = Path(project_dir).resolve() - + def detect(self) -> MCPDetectionResult: """检测项目是否为 MCP 项目""" - + # 1. 检查配置文件中的显式声明 config_result = self._check_config() if config_result: return config_result - + # 2. 查找 Python 文件 mcp_file = self._find_mcp_file() if not mcp_file: return MCPDetectionResult( - is_mcp=False, - name=self.project_dir.name, - entry_point="", - package_path="" + is_mcp=False, name=self.project_dir.name, entry_point="", package_path="" ) - + # 3. 分析代码 return self._analyze_code(mcp_file) - + def _check_config(self) -> Optional[MCPDetectionResult]: """检查配置文件中的 MCP 声明""" config_paths = [ @@ -76,121 +75,129 @@ def _check_config(self) -> Optional[MCPDetectionResult]: self.project_dir / "ksadk.yaml", self.project_dir / "mcp.yaml", ] - + for config_path in config_paths: if not config_path.exists(): continue - + try: - with open(config_path, 'r', encoding='utf-8-sig') as f: + with open(config_path, "r", encoding="utf-8-sig") as f: config = yaml.safe_load(f) - + # 检查 type: mcp if config.get("type") == "mcp" or config.get("framework") == "mcp": return MCPDetectionResult( is_mcp=True, name=config.get("name", self.project_dir.name), entry_point=config.get("entry_point", "server.py"), - package_path=str(self.project_dir / config.get("package", self.project_dir.name.replace('-', '_'))), + package_path=str( + self.project_dir + / config.get("package", self.project_dir.name.replace("-", "_")) + ), mcp_variable=config.get("mcp_variable", "mcp"), - confidence=1.0 + confidence=1.0, ) except Exception: continue - + return None - + def _find_mcp_file(self) -> Optional[Path]: """查找 MCP 入口文件""" # 常见的 MCP 入口文件名 entry_files = ["server.py", "mcp_server.py", "main.py", "__init__.py"] - + # 首先在根目录查找 for entry in entry_files: file_path = self.project_dir / entry if file_path.exists() and self._is_mcp_file(file_path): return file_path - + # 在子包中查找 for item in self.project_dir.iterdir(): if item.is_dir() and (item / "__init__.py").exists(): - if item.name.startswith('.') or item.name in ('tests', 'test', '__pycache__'): + if item.name.startswith(".") or item.name in ("tests", "test", "__pycache__"): continue - + for entry in entry_files: file_path = item / entry if file_path.exists() and self._is_mcp_file(file_path): return file_path - + # 检查 __init__.py init_file = item / "__init__.py" if self._is_mcp_file(init_file): return init_file - + # 扫描所有 .py 文件 for py_file in self.project_dir.rglob("*.py"): path_str = str(py_file) - if "__pycache__" in path_str or "/.agentengine/" in path_str or "/.venv/" in path_str or "/venv/" in path_str: + if ( + "__pycache__" in path_str + or "/.agentengine/" in path_str + or "/.venv/" in path_str + or "/venv/" in path_str + ): continue if self._is_mcp_file(py_file): return py_file - + return None - + def _is_mcp_file(self, file_path: Path) -> bool: """检查文件是否包含 FastMCP 代码""" try: - content = file_path.read_text(encoding='utf-8') - + content = file_path.read_text(encoding="utf-8") + # 检查 FastMCP 导入 for pattern in self.FASTMCP_PATTERNS: if pattern in content: return True - + return False except Exception: return False - + def _analyze_code(self, mcp_file: Path) -> MCPDetectionResult: """分析 MCP 代码""" try: - content = mcp_file.read_text(encoding='utf-8') - tree = ast.parse(content) + content = mcp_file.read_text(encoding="utf-8") + tree = ast.parse(content, filename=str(mcp_file)) except Exception: return MCPDetectionResult( is_mcp=False, name=self.project_dir.name, entry_point=str(mcp_file.relative_to(self.project_dir)), - package_path="" + package_path="", ) - + # 检测 FastMCP 导入 has_fastmcp = False for pattern in self.FASTMCP_PATTERNS: if pattern in content: has_fastmcp = True break - + if not has_fastmcp: return MCPDetectionResult( is_mcp=False, name=self.project_dir.name, entry_point=str(mcp_file.relative_to(self.project_dir)), - package_path="" + package_path="", ) - + # 提取 MCP 实例变量名 mcp_variable = self._find_mcp_variable(tree, content) - + # 提取工具名称 - tools = self._extract_tools(content) - + tools = self._extract_tools(content, filename=str(mcp_file)) + # 确定包路径 package_path = mcp_file.parent if package_path == self.project_dir: # 文件在根目录 package_path = self.project_dir - + return MCPDetectionResult( is_mcp=True, name=self.project_dir.name, @@ -198,9 +205,9 @@ def _analyze_code(self, mcp_file: Path) -> MCPDetectionResult: package_path=str(package_path), mcp_variable=mcp_variable, tools=tools, - confidence=0.9 + confidence=0.9, ) - + def _find_mcp_variable(self, tree: ast.AST, content: str) -> str: """查找 FastMCP 实例变量名""" # 查找 xxx = FastMCP(...) 模式 @@ -213,16 +220,16 @@ def _find_mcp_variable(self, tree: ast.AST, content: str) -> str: # 返回左侧变量名 if node.targets and isinstance(node.targets[0], ast.Name): return node.targets[0].id - + # 默认返回 mcp return "mcp" - - def _extract_tools(self, content: str) -> List[str]: + + def _extract_tools(self, content: str, *, filename: str = "") -> List[str]: """提取 @mcp.tool 装饰的函数名""" tools = [] - + try: - tree = ast.parse(content) + tree = ast.parse(content, filename=filename) for node in ast.walk(tree): if isinstance(node, ast.FunctionDef): for decorator in node.decorator_list: @@ -239,5 +246,5 @@ def _extract_tools(self, content: str) -> List[str]: break except Exception: pass - + return tools diff --git a/ksadk/identity/__init__.py b/ksadk/identity/__init__.py new file mode 100644 index 00000000..37884ebd --- /dev/null +++ b/ksadk/identity/__init__.py @@ -0,0 +1,17 @@ +"""ksadk 身份反查模块:从 AK/SK 反查金山云子账号 user uuid + 主账号 ID。""" + +from ksadk.identity.resolver import ( + ResolvedIdentity, + get_cached_identity, + get_cached_user_uuid, + invalidate_cache, + resolve_identity, +) + +__all__ = [ + "ResolvedIdentity", + "get_cached_identity", + "get_cached_user_uuid", + "invalidate_cache", + "resolve_identity", +] diff --git a/ksadk/identity/resolver.py b/ksadk/identity/resolver.py new file mode 100644 index 00000000..50eda0d0 --- /dev/null +++ b/ksadk/identity/resolver.py @@ -0,0 +1,368 @@ +"""从 AK/SK 反查金山云子账号身份(user uuid + 主账号 ID)。 + +控制面 ctx.sub_account_id 只从请求头 X-Ksc-User-uuid 取,KOP 不会根据 AK/SK 自动注入。 +本模块用 AK/SK 调 IAM 的 ListAllUserAccessKeys + GetUser 两步反查: + 1. ListAllUserAccessKeys(无参)返回所有子用户 AK + UserName + 2. GetUser(UserName) 返回 User.UserId(子账号 uuid)+ User.Krn(含主账号 ID) + +反查结果缓存到 ~/.agentengine/settings.json 的 cloud.IDENTITY_CACHE(按 AK 指纹索引, +多账号安全)。任何失败返回 None,不抛异常,调用方降级为不注入 header(退化为当前行为)。 +""" + +from __future__ import annotations + +import hashlib +import json +import logging +import os +import re +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Optional +from urllib.parse import urlparse + +logger = logging.getLogger(__name__) + +# krn:ksc:iam::<主账号ID>:user/ +_KRN_ACCOUNT_RE = re.compile(r"krn:ksc:iam::([^:]+):user/") + + +@dataclass(frozen=True) +class ResolvedIdentity: + """AK/SK 反查到的身份信息。""" + + user_uuid: Optional[str] # 子账号 UserId(X-Ksc-User-uuid 值);主账号 AK 时 None + main_account_id: Optional[str] # 从 Krn 提取的主账号 ID + user_name: Optional[str] # 子账号 UserName(调试用) + krn: Optional[str] # 原始 Krn(调试用) + ak_fingerprint: str # sha256(AK)[:16],缓存 key + + +# --------------------------------------------------------------------------- +# AK 指纹 + Krn 解析 +# --------------------------------------------------------------------------- + + +def _ak_fingerprint(access_key: str) -> str: + """sha256(AK)[:16],防碰撞且不暴露明文 AK。""" + return hashlib.sha256(access_key.encode("utf-8")).hexdigest()[:16] + + +def _extract_main_account_id_from_krn(krn: Optional[str]) -> Optional[str]: + """从 krn:ksc:iam::<主账号ID>:user/ 提取主账号 ID。""" + if not krn: + return None + match = _KRN_ACCOUNT_RE.search(krn) + if not match: + return None + account_id = match.group(1).strip() + return account_id or None + + +# --------------------------------------------------------------------------- +# IAM endpoint 解析 +# --------------------------------------------------------------------------- + + +def _resolve_iam_endpoint() -> tuple[str, str]: + """解析 IAM endpoint,返回 (host, scheme)。优先级:KSYUN_IAM_URL > IAM_URL > 默认。""" + raw = (os.getenv("KSYUN_IAM_URL") or os.getenv("IAM_URL") or "").strip() + if not raw: + return "iam.api.ksyun.com", "https" + if "://" not in raw: + raw = "https://" + raw + parsed = urlparse(raw) + host = (parsed.netloc or parsed.path or "iam.api.ksyun.com").strip() + scheme = (parsed.scheme or "https").strip().lower() or "https" + return host, scheme + + +def _should_retry_intranet(error: Exception | None) -> bool: + """判断是否应回退到内网 endpoint(内部账号只能内网访问时)。""" + if error is None: + return False + return "InnerAccountCanOnlyAccessThroughIntranet" in str(error) + + +# --------------------------------------------------------------------------- +# ksyun SDK 惰性导入 +# --------------------------------------------------------------------------- + + +def _import_iam_sdk(): + """惰性导入 ksyun IAM SDK,失败返回 None。""" + try: + from ksyun.client.iam.v20151101.client import IamClient + from ksyun.client.iam.v20151101.models import ( + GetUserRequest, + ListAllUserAccessKeysRequest, + ) + from ksyun.common.credential import Credential + from ksyun.common.profile.client_profile import ClientProfile + from ksyun.common.profile.http_profile import HttpProfile + except Exception as exc: + logger.warning("导入 ksyun IAM SDK 失败: %s", exc) + return None + return ( + IamClient, + ListAllUserAccessKeysRequest, + GetUserRequest, + Credential, + ClientProfile, + HttpProfile, + ) + + +def _build_iam_client(*, access_key: str, secret_key: str, host: str, scheme: str, sdk_parts): + """构造 IamClient(复用服务端 iam/client.py 模式)。""" + IamClient, _, _, Credential, ClientProfile, HttpProfile = sdk_parts + http_profile = HttpProfile() + http_profile.endpoint = host + http_profile.reqMethod = "POST" + http_profile.reqTimeout = 30 + http_profile.scheme = scheme + client_profile = ClientProfile() + client_profile.httpProfile = http_profile + cred = Credential(access_key, secret_key) + return IamClient(cred, "cn-beijing-6", profile=client_profile) + + +# --------------------------------------------------------------------------- +# IAM 调用 +# --------------------------------------------------------------------------- + + +def _call_list_all_user_access_keys(client, sdk_parts) -> list[dict]: + """调 ListAllUserAccessKeys,返回 AccessKey 列表(每项含 AccessKey + UserName)。""" + _, ListAllUserAccessKeysRequest, _, _, _, _ = sdk_parts + resp = client.ListAllUserAccessKeys(ListAllUserAccessKeysRequest()) + if isinstance(resp, str): + resp = json.loads(resp) + if not isinstance(resp, dict): + return [] + keys = resp.get("AccessKeyList") or resp.get("AccessKeys") or [] + return [k for k in keys if isinstance(k, dict)] + + +def _call_get_user(client, user_name: str, sdk_parts) -> dict: + """调 GetUser(UserName),返回 User dict。""" + _, _, GetUserRequest, _, _, _ = sdk_parts + req = GetUserRequest() + req.UserName = user_name + resp = client.GetUser(req) + if isinstance(resp, str): + resp = json.loads(resp) + if not isinstance(resp, dict): + return {} + return resp.get("GetUserResult", {}).get("User", {}) or {} + + +def _find_username_by_ak(access_keys: list[dict], target_ak: str) -> Optional[str]: + """在 AccessKey 列表里找目标 AK 对应的 UserName。""" + for item in access_keys: + ak = str(item.get("AccessKey") or item.get("AccessKeyId") or "").strip() + if ak and ak == target_ak: + return str(item.get("UserName") or "").strip() or None + return None + + +# --------------------------------------------------------------------------- +# 缓存读写(settings.json 的 cloud.IDENTITY_CACHE) +# --------------------------------------------------------------------------- + + +def _load_identity_cache() -> dict: + """读 settings.json 的 cloud.IDENTITY_CACHE,失败返回空 dict。""" + try: + from ksadk.configs.global_config import load_global_config + + config = load_global_config() + except Exception as exc: + logger.warning("读取 global_config 失败: %s", exc) + return {} + cloud = config.get("cloud") or {} + cache = cloud.get("IDENTITY_CACHE") if isinstance(cloud, dict) else None + return cache if isinstance(cache, dict) else {} + + +def _save_identity_cache(cache: dict) -> None: + """写 settings.json 的 cloud.IDENTITY_CACHE(merge,保留其他字段)。""" + try: + from ksadk.configs.global_config import load_global_config, save_global_config + + config = load_global_config() + cloud = dict(config.get("cloud") or {}) + cloud["IDENTITY_CACHE"] = cache + config["cloud"] = cloud + save_global_config(config) + except Exception as exc: + logger.warning("写入 identity 缓存失败: %s", exc) + + +def _read_cache_entry(access_key: str) -> Optional[dict]: + """读指定 AK 的缓存条目,指纹不匹配返回 None。""" + if not access_key: + return None + fingerprint = _ak_fingerprint(access_key) + cache = _load_identity_cache() + entry = cache.get(fingerprint) + if not isinstance(entry, dict): + return None + if entry.get("ak_fingerprint") != fingerprint: + return None + return entry + + +def _write_cache_entry(access_key: str, entry: dict) -> None: + """写指定 AK 的缓存条目(merge,不破坏其他条目)。""" + if not access_key: + return + fingerprint = _ak_fingerprint(access_key) + cache = _load_identity_cache() + cache[fingerprint] = entry + _save_identity_cache(cache) + + +# --------------------------------------------------------------------------- +# 公开 API +# --------------------------------------------------------------------------- + + +def resolve_identity( + *, + access_key: str, + secret_key: str, + force_refresh: bool = False, +) -> Optional[ResolvedIdentity]: + """用 AK/SK 反查子账号身份。 + + 先读缓存命中即返回;未命中调 IAM 两步链路并写缓存。 + 任何失败返回 None(不抛异常,调用方降级)。 + """ + if not access_key or not secret_key: + return None + + fingerprint = _ak_fingerprint(access_key) + + # 1. 读缓存 + if not force_refresh: + entry = _read_cache_entry(access_key) + if entry: + return ResolvedIdentity( + user_uuid=entry.get("user_uuid"), + main_account_id=entry.get("main_account_id"), + user_name=entry.get("user_name"), + krn=entry.get("krn"), + ak_fingerprint=fingerprint, + ) + + # 2. 调 IAM 反查(公网失败时自动 fallback 内网,处理 InnerAccountCanOnlyAccessThroughIntranet) + sdk_parts = _import_iam_sdk() + if sdk_parts is None: + return None + + host, scheme = _resolve_iam_endpoint() + # 候选 endpoint 列表:(host, scheme, is_intranet);首个用解析出的默认,失败再试内网 + candidates = [(host, scheme)] + if host != "iam.inner.api.ksyun.com": + candidates.append(("iam.inner.api.ksyun.com", "http")) + + user_name: Optional[str] = None + user: dict = {} + last_exc: Optional[Exception] = None + for cand_host, cand_scheme in candidates: + try: + client = _build_iam_client( + access_key=access_key, + secret_key=secret_key, + host=cand_host, + scheme=cand_scheme, + sdk_parts=sdk_parts, + ) + access_keys = _call_list_all_user_access_keys(client, sdk_parts) + user_name = _find_username_by_ak(access_keys, access_key) + if not user_name: + # AK 不在子用户列表(可能是主账号 AK),无法反查 user uuid + logger.warning( + "AK 指纹 %s 未在 ListAllUserAccessKeys 找到匹配(可能是主账号 AK)", + fingerprint, + ) + return None + user = _call_get_user(client, user_name, sdk_parts) + host, scheme = cand_host, cand_scheme # 记录成功的 endpoint 用于缓存 + last_exc = None + break + except Exception as exc: + last_exc = exc + # 仅在"内部账号需内网访问"时才 fallback 到内网 endpoint + if not _should_retry_intranet(exc): + break + + if last_exc is not None: + logger.warning("反查子账号身份失败 (AK 指纹 %s): %s", fingerprint, last_exc) + return None + + user_uuid = str(user.get("UserId") or "").strip() or None + krn = str(user.get("Krn") or "").strip() or None + main_account_id = _extract_main_account_id_from_krn(krn) + + identity = ResolvedIdentity( + user_uuid=user_uuid, + main_account_id=main_account_id, + user_name=user_name, + krn=krn, + ak_fingerprint=fingerprint, + ) + + # 3. 写缓存 + _write_cache_entry( + access_key, + { + "ak_fingerprint": fingerprint, + "user_uuid": user_uuid, + "main_account_id": main_account_id, + "user_name": user_name, + "krn": krn, + "resolved_at": datetime.now(timezone.utc).isoformat(), + "iam_endpoint": host, + "iam_scheme": scheme, + }, + ) + + return identity + + +def get_cached_user_uuid(access_key: str) -> Optional[str]: + """只读缓存拿 user uuid,不触发反查(dry-run 用)。""" + entry = _read_cache_entry(access_key) + if not entry: + return None + uuid = entry.get("user_uuid") + return str(uuid).strip() or None if uuid else None + + +def get_cached_identity(access_key: str) -> Optional[ResolvedIdentity]: + """只读缓存拿完整身份(含 main_account_id),不触发反查(dry-run 用)。""" + if not access_key: + return None + entry = _read_cache_entry(access_key) + if not entry: + return None + return ResolvedIdentity( + user_uuid=entry.get("user_uuid"), + main_account_id=entry.get("main_account_id"), + user_name=entry.get("user_name"), + krn=entry.get("krn"), + ak_fingerprint=_ak_fingerprint(access_key), + ) + + +def invalidate_cache(access_key: Optional[str] = None) -> None: + """删除指定 AK 的缓存条目;access_key=None 清空所有。""" + cache = _load_identity_cache() + if access_key is None: + cache = {} + else: + fingerprint = _ak_fingerprint(access_key) + cache.pop(fingerprint, None) + _save_identity_cache(cache) diff --git a/ksadk/runners/adk_runner.py b/ksadk/runners/adk_runner.py index b6e6b653..aed95563 100644 --- a/ksadk/runners/adk_runner.py +++ b/ksadk/runners/adk_runner.py @@ -18,6 +18,7 @@ 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.runners.usage_accumulator import accumulate_usage from ksadk.sessions.continuity import ADKSessionAdapter logger = logging.getLogger(__name__) @@ -1009,6 +1010,7 @@ async def invoke(self, input_data: Dict[str, Any]) -> Dict[str, Any]: events_list = [] usage: dict[str, Any] = {} + last_usage: dict[str, Any] = {} async for event in self._runner.run_async( session_id=session_id, user_id="ksadk_user", @@ -1018,7 +1020,8 @@ async def invoke(self, input_data: Dict[str, Any]) -> Dict[str, Any]: events_list.append(event) event_usage = self._extract_event_usage(event) if event_usage: - usage = event_usage + usage = accumulate_usage(usage, event_usage) + last_usage = event_usage # 保留最后一个非空(窗口占用=最后一次 input) if hasattr(event, "content") and event.content: if hasattr(event.content, "parts"): for part in event.content.parts: @@ -1033,6 +1036,8 @@ async def invoke(self, input_data: Dict[str, Any]) -> Dict[str, Any]: result = {"output": final_response, "events": events_list} if usage: result["usage"] = usage + # last_usage = 最后一次 LLM 调用快照(input_tokens = 当前上下文窗口占用) + result.setdefault("metadata", {})["last_usage"] = last_usage return result async def stream(self, input_data: Dict[str, Any]) -> AsyncIterator[Dict[str, Any]]: @@ -1079,6 +1084,7 @@ async def stream(self, input_data: Dict[str, Any]) -> AsyncIterator[Dict[str, An accumulated_text = "" usage: dict[str, Any] = {} + last_usage: dict[str, Any] = {} # 使用 StreamingMode.SSE 启用真正的流式输出 run_config = RunConfig(streaming_mode=StreamingMode.SSE) @@ -1092,7 +1098,8 @@ async def stream(self, input_data: Dict[str, Any]) -> AsyncIterator[Dict[str, An ): event_usage = self._extract_event_usage(event) if event_usage: - usage = event_usage + usage = accumulate_usage(usage, event_usage) + last_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"): @@ -1123,4 +1130,6 @@ async def stream(self, input_data: Dict[str, Any]) -> AsyncIterator[Dict[str, An final_chunk: dict[str, Any] = {"output": accumulated_text, "type": "final"} if usage: final_chunk["usage"] = usage + # last_usage = 最后一次 LLM 调用快照(input_tokens = 当前上下文窗口占用) + final_chunk.setdefault("metadata", {})["last_usage"] = last_usage yield final_chunk diff --git a/ksadk/runners/base_runner.py b/ksadk/runners/base_runner.py index 0f5bfa24..a804e1a3 100644 --- a/ksadk/runners/base_runner.py +++ b/ksadk/runners/base_runner.py @@ -272,6 +272,29 @@ def _extract_usage(cls, result: Any) -> dict[str, Any]: return usage return {} + @classmethod + def _extract_last_usage(cls, result: Any) -> dict[str, Any]: + """取最后一次 LLM 调用的 usage 快照(作 last_usage,窗口占用=input_tokens)。 + + 方案 C:累积值走 stream on_chain_end 逐 event;invoke 非流式拿不到中间 call, + last_usage 作窗口占用(末次 input = 当前上下文占用)。优先 metadata.last_usage + (ADK 透传),否则 messages 末个。 + """ + if isinstance(result, Mapping): + metadata = result.get("metadata") + if isinstance(metadata, Mapping): + last_usage = metadata.get("last_usage") + if isinstance(last_usage, Mapping) and last_usage: + return dict(last_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) + return usage if usage else {} + 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 e0e920a8..d983033e 100644 --- a/ksadk/runners/langchain_runner.py +++ b/ksadk/runners/langchain_runner.py @@ -89,6 +89,9 @@ async def invoke(self, input_data: Dict[str, Any]) -> Dict[str, Any]: usage = self._extract_usage(result) if usage: output["usage"] = usage + last_usage = self._extract_last_usage(result) + if last_usage: + output.setdefault("metadata", {})["last_usage"] = last_usage return output async def stream(self, input_data: Dict[str, Any]) -> AsyncIterator[Dict[str, Any]]: @@ -142,6 +145,9 @@ async def stream(self, input_data: Dict[str, Any]) -> AsyncIterator[Dict[str, An usage = self._extract_usage(result) if usage: final_chunk["usage"] = usage + last_usage = self._extract_last_usage(result) + if last_usage: + final_chunk.setdefault("metadata", {})["last_usage"] = last_usage yield final_chunk return @@ -149,6 +155,9 @@ async def stream(self, input_data: Dict[str, Any]) -> AsyncIterator[Dict[str, An usage = self._extract_usage(last_chunk) if usage: final_chunk["usage"] = usage + last_usage = self._extract_last_usage(last_chunk) + if last_usage: + final_chunk.setdefault("metadata", {})["last_usage"] = last_usage yield final_chunk def _resolve_request_path(self) -> str: diff --git a/ksadk/runners/langgraph_runner.py b/ksadk/runners/langgraph_runner.py index bea2e656..7362b5d2 100644 --- a/ksadk/runners/langgraph_runner.py +++ b/ksadk/runners/langgraph_runner.py @@ -540,9 +540,12 @@ async def invoke(self, input_data: Dict[str, Any]) -> Dict[str, Any]: usage = self._extract_usage(result) if usage: output["usage"] = usage + last_usage = self._extract_last_usage(result) + if last_usage: + output.setdefault("metadata", {})["last_usage"] = last_usage metadata = await self._latest_checkpoint_metadata(config) if metadata: - output["metadata"] = metadata + output["metadata"] = {**(output.get("metadata") or {}), **metadata} return output except Exception as e: @@ -710,6 +713,9 @@ async def stream(self, input_data: Dict[str, Any]) -> AsyncIterator[Dict[str, An usage = self._extract_usage(result) if usage: final_chunk["usage"] = usage + last_usage = self._extract_last_usage(result) + if last_usage: + final_chunk.setdefault("metadata", {})["last_usage"] = last_usage yield final_chunk return @@ -784,6 +790,7 @@ async def stream(self, input_data: Dict[str, Any]) -> AsyncIterator[Dict[str, An if extracted_output: final_output_text = strip_reasoning_markup(str(extracted_output)) final_output_usage = self._extract_usage(output) + final_output_last_usage = self._extract_last_usage(output) except Exception as e: if "Interrupt" in type(e).__name__: @@ -806,6 +813,8 @@ async def stream(self, input_data: Dict[str, Any]) -> AsyncIterator[Dict[str, An final_chunk = {"output": final_output_text, "type": "final"} if final_output_usage: final_chunk["usage"] = final_output_usage + if final_output_last_usage: + final_chunk.setdefault("metadata", {})["last_usage"] = final_output_last_usage yield final_chunk elif not emitted_non_text_event: result = await self.invoke(invoke_payload) @@ -813,6 +822,9 @@ async def stream(self, input_data: Dict[str, Any]) -> AsyncIterator[Dict[str, An usage = self._extract_usage(result) if usage: final_chunk["usage"] = usage + last_usage = self._extract_last_usage(result) + if last_usage: + final_chunk.setdefault("metadata", {})["last_usage"] = last_usage yield final_chunk metadata = result.get("metadata") if isinstance(result, dict) else None if isinstance(metadata, dict) and metadata.get("agentengine"): @@ -823,6 +835,8 @@ async def stream(self, input_data: Dict[str, Any]) -> AsyncIterator[Dict[str, An usage = await self._latest_state_usage(config) if usage: final_chunk["usage"] = usage + # _latest_state_usage 返回末个 message usage,单次调用场景即 last_usage + final_chunk.setdefault("metadata", {})["last_usage"] = usage yield final_chunk metadata = await self._latest_checkpoint_metadata(config) diff --git a/ksadk/runners/usage_accumulator.py b/ksadk/runners/usage_accumulator.py new file mode 100644 index 00000000..fef7df2e --- /dev/null +++ b/ksadk/runners/usage_accumulator.py @@ -0,0 +1,38 @@ +"""usage 累加工具:逐字段求和(input/output/total + details 子键)。 + +用于 runner 本轮内多次 LLM 调用的 usage 累积。input_tokens 各 provider 均含 cache +(Gemini prompt_token_count 含 cached_content_token_count;OpenAI prompt_tokens 含 +cached_tokens;Anthropic 经 langchain_anthropic 转换后 input_tokens 含 cache_read+ +cache_creation),直接相加不重复;input_token_details 键名不统一(cached/cache_read/ +cache_creation),逐键求和作诊断明细。 +""" +from __future__ import annotations + +from typing import Any + +_MAIN_FIELDS = ("input_tokens", "output_tokens", "total_tokens") +_DETAIL_FIELDS = ("input_token_details", "output_token_details") + + +def accumulate_usage(acc: dict[str, Any], delta: dict[str, Any]) -> dict[str, Any]: + """把 delta 累加进 acc,返回新 dict(不改 acc)。""" + if not delta: + return dict(acc) + result = dict(acc) + for key in _MAIN_FIELDS: + result[key] = int(result.get(key) or 0) + int(delta.get(key) or 0) + for detail_key in _DETAIL_FIELDS: + delta_details = delta.get(detail_key) + if not isinstance(delta_details, dict): + continue + merged = dict(result.get(detail_key) or {}) + for k, v in delta_details.items(): + if v is None: + continue + try: + merged[k] = int(merged.get(k) or 0) + int(v) + except (TypeError, ValueError): + continue + if merged: + result[detail_key] = merged + return result diff --git a/ksadk/server/app.py b/ksadk/server/app.py index c712569c..609adaa3 100644 --- a/ksadk/server/app.py +++ b/ksadk/server/app.py @@ -3,16 +3,14 @@ FastAPI 应用 - 提供 HTTP API 接口 (ADK Web 兼容) """ +import asyncio import base64 -import httpx import io import json import logging -import mimetypes import os import time import uuid -import asyncio import zipfile from contextlib import asynccontextmanager from datetime import datetime, timezone @@ -20,7 +18,8 @@ from typing import Any, AsyncIterator, Dict, List, Mapping, Optional from urllib.parse import quote -from fastapi import FastAPI, HTTPException, Request, File, Form, Query, UploadFile +import httpx +from fastapi import FastAPI, File, Form, HTTPException, Query, Request, UploadFile from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, Response, StreamingResponse from pydantic import BaseModel, Field @@ -28,29 +27,30 @@ 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.model_context import normalize_model_metadata +from ksadk.conversations.run_kinds import ( + RUN_MODE_BACKGROUND, + RUN_MODE_FOREGROUND, + RUN_MODE_UNKNOWN, + RUN_TRIGGER_CHECKPOINT_RESUME, + RUN_TRIGGER_NEW_RUN, + RUN_TRIGGER_UNKNOWN, + trigger_from_resume_input, +) +from ksadk.conversations.run_status import RUN_STATUS_ACTIVE, RUN_STATUS_TERMINAL from ksadk.conversations.session_title import ( HEURISTIC_SESSION_TITLE_SOURCE, build_fallback_title, build_heuristic_title, ) -from ksadk.runtime_state import load_state as load_runtime_state from ksadk.runners.base_runner import BaseRunner +from ksadk.runtime_state import load_state as load_runtime_state from ksadk.server.api_models import AgentRunRequest from ksadk.server.terminal_sessions import ( TerminalSessionManager, native_terminal_supported, register_terminal_routes, ) -from ksadk_runtime_common.workspace_files import ( - build_workspace_files_bootstrap, - create_workspace_files_router, - workspace_files_enabled, -) -from ksadk_runtime_common.workspace_files.preview import ( - build_workspace_file_base_href, - build_workspace_preview_csp, - inject_workspace_html_preview, -) from ksadk.sessions import ( ConversationSessionCore, Session, @@ -60,10 +60,19 @@ ) from ksadk.sessions.errors import SessionBackendUnavailable from ksadk.sessions.local_service import resolve_local_session_dir -from ksadk.tracing import get_memory_exporter -from ksadk.conversations.model_context import normalize_model_metadata from ksadk.toolsets import describe_agentengine_tools +from ksadk.tracing import get_memory_exporter from ksadk.ui_config import UI_PROFILE_CUSTOM, resolve_ui_config +from ksadk_runtime_common.workspace_files import ( + build_workspace_files_bootstrap, + create_workspace_files_router, + workspace_files_enabled, +) +from ksadk_runtime_common.workspace_files.preview import ( + build_workspace_file_base_href, + build_workspace_preview_csp, + inject_workspace_html_preview, +) logger = logging.getLogger(__name__) @@ -75,16 +84,10 @@ _DETACHED_STREAMS_BY_INVOCATION: dict[str, "_DetachedSSEStream"] = {} _DETACHED_RESUME_KEYS_BY_INVOCATION: dict[str, tuple[str, str]] = {} _ACTIVE_DETACHED_RESUME_INVOCATION_BY_KEY: dict[tuple[str, str], str] = {} -_RUN_TERMINAL_STATUSES = { - "completed", - "failed", - "error", - "cancelled", - "canceled", - "aborted", - "interrupted", -} -_RUN_ACTIVE_STATUSES = {"in_progress", "running", "resuming", "starting"} +# run_status 事件状态集合:canonical 定义在 ksadk.conversations.run_status, +# 这里保留旧名做兼容别名(RUN_STATUS_TERMINAL 已含 resume_failed)。 +_RUN_TERMINAL_STATUSES = RUN_STATUS_TERMINAL +_RUN_ACTIVE_STATUSES = RUN_STATUS_ACTIVE def _parse_iso_datetime(value: Any) -> datetime | None: @@ -100,6 +103,8 @@ def _parse_iso_datetime(value: Any) -> datetime | None: if parsed.tzinfo is None: return parsed.replace(tzinfo=timezone.utc) return parsed.astimezone(timezone.utc) + + _RESERVED_UI_PATHS = {"/", "/chat", "/build", "/deploy"} _CUSTOM_API_PROXY_ENV_KEYS = ("KSADK_USER_BACKEND_URL", "LUOLUO_USER_BACKEND_URL") _HOP_BY_HOP_HEADERS = { @@ -125,10 +130,14 @@ def __init__( *, invocation_id: str | None = None, session_id: str | None = None, + run_mode: str = "unknown", + run_trigger: str = "unknown", ): self._source = source self.invocation_id = invocation_id self.session_id = session_id + self._run_mode = run_mode + self._run_trigger = run_trigger self._subscribers: set[asyncio.Queue[str | None]] = set() self._backlog: list[str] = [] self._done = False @@ -189,8 +198,12 @@ async def _consume(self) -> None: author="system", status=terminal_fallback_status, invocation_id=self.invocation_id or "", - detail=f"background_{terminal_fallback_status}:{self.invocation_id or ''}", + detail=( + f"background_{terminal_fallback_status}:{self.invocation_id or ''}" + ), session_service_provider=resolve_session_service, + run_mode=self._run_mode, + run_trigger=self._run_trigger, ) except Exception: logger.exception("failed to write background terminal status fallback") @@ -231,8 +244,16 @@ def _detached_streaming_response( invocation_id: str | None = None, session_id: str | None = None, resume_key: tuple[str, str] | None = None, + run_mode: str = "unknown", + run_trigger: str = "unknown", ) -> StreamingResponse: - detached = _DetachedSSEStream(source, invocation_id=invocation_id, session_id=session_id) + detached = _DetachedSSEStream( + source, + invocation_id=invocation_id, + session_id=session_id, + run_mode=run_mode, + run_trigger=run_trigger, + ) if invocation_id and resume_key: _DETACHED_RESUME_KEYS_BY_INVOCATION[invocation_id] = resume_key _ACTIVE_DETACHED_RESUME_INVOCATION_BY_KEY[resume_key] = invocation_id @@ -292,9 +313,9 @@ def _reject_if_detached_resume_active(resume_key: tuple[str, str] | None) -> Non detail={ "code": "resume_already_running", "message": "A checkpoint resume is already running for this session and run.", - "InvocationId": active_resume_invocation_id, - "SessionId": resume_key[0], - "RunId": resume_key[1], + "invocation_id": active_resume_invocation_id, + "session_id": resume_key[0], + "run_id": resume_key[1], }, ) @@ -344,6 +365,7 @@ async def _lifespan(_app: FastAPI) -> AsyncIterator[None]: lifespan=_lifespan, ) + # Middleware for disabling cache on frontend entry points @app.middleware("http") async def no_cache_frontend(request: Request, call_next): @@ -355,6 +377,7 @@ async def no_cache_frontend(request: Request, call_next): response.headers["Expires"] = "0" return response + # Configure CORS (permissive by default for ADK tools) app.add_middleware( CORSMiddleware, @@ -642,7 +665,7 @@ def _resolve_ui_static_response(request_path: str) -> Optional[FileResponse]: return FileResponse(index_file) if ui_path != "/" and not path.startswith(f"{ui_path}/"): return None - relative = path[len(ui_path):].lstrip("/") if ui_path != "/" else path.lstrip("/") + relative = path[len(ui_path) :].lstrip("/") if ui_path != "/" else path.lstrip("/") if not relative: return FileResponse(index_file) candidate = bundle_dir / relative @@ -736,19 +759,11 @@ def _custom_api_proxy_base_url() -> str: def _proxy_headers(headers: Mapping[str, str]) -> dict[str, str]: - return { - key: value - for key, value in headers.items() - if key.lower() not in _HOP_BY_HOP_HEADERS - } + return {key: value for key, value in headers.items() if key.lower() not in _HOP_BY_HOP_HEADERS} def _response_headers(headers: Mapping[str, str]) -> dict[str, str]: - return { - key: value - for key, value in headers.items() - if key.lower() not in _HOP_BY_HOP_HEADERS - } + return {key: value for key, value in headers.items() if key.lower() not in _HOP_BY_HOP_HEADERS} @app.api_route( @@ -776,7 +791,9 @@ async def custom_api_proxy(proxy_path: str, request: Request): headers=_proxy_headers(request.headers), ) except httpx.HTTPError as exc: - raise HTTPException(status_code=502, detail=f"Custom API backend unavailable: {exc}") from exc + raise HTTPException( + status_code=502, detail=f"Custom API backend unavailable: {exc}" + ) from exc return Response( content=upstream.content, @@ -786,7 +803,9 @@ async def custom_api_proxy(proxy_path: str, request: Request): ) -def _read_attachment_bytes(storage_path: Optional[Path], *, size_limit: Optional[int] = None) -> Optional[bytes]: +def _read_attachment_bytes( + storage_path: Optional[Path], *, size_limit: Optional[int] = None +) -> Optional[bytes]: if storage_path is None or not storage_path.is_file(): return None @@ -825,10 +844,7 @@ def _attachment_prompt_text(attachment: Dict[str, Any]) -> str: data_b64 = str(attachment.get("data") or "").strip() if len(data_b64) > _MAX_INLINE_BASE64_CHARS: return ( - "[上传文件: " - f"{display_name}, " - f"mime={mime_type or 'unknown'}, " - "内容过大,未直接展开]" + f"[上传文件: {display_name}, mime={mime_type or 'unknown'}, 内容过大,未直接展开]" ) try: @@ -885,11 +901,7 @@ def _attachment_prompt_text(attachment: Dict[str, Any]) -> str: ) file_uri = attachment.get("file_uri") or "" - return ( - "[上传文件引用: " - f"{display_name or file_uri}, " - f"mime={mime_type or 'unknown'}]" - ) + return f"[上传文件引用: {display_name or file_uri}, mime={mime_type or 'unknown'}]" def _extract_user_input_from_parts(parts: List[Any]) -> str: @@ -956,7 +968,9 @@ def _request_id() -> str: return f"req-{uuid.uuid4().hex[:12]}" -def _action_response(action: str, data: Any, *, request_id: Optional[str] = None, message: str = "Success") -> dict: +def _action_response( + action: str, data: Any, *, request_id: Optional[str] = None, message: str = "Success" +) -> dict: payload = { "Code": 0, "Message": message, @@ -992,7 +1006,9 @@ async def _workspace_runtime_request( payload = None if isinstance(payload, dict): detail = str(payload.get("detail") or detail) - raise HTTPException(status_code=response.status_code, detail=detail or "Workspace request failed") + raise HTTPException( + status_code=response.status_code, detail=detail or "Workspace request failed" + ) return response @@ -1044,6 +1060,7 @@ class ListSessionEventsActionRequest(BaseModel): SessionId: str Offset: Optional[int] = Field(None, ge=0) Limit: Optional[int] = Field(None, ge=1) + AfterSeqId: Optional[int] = Field(None, ge=0) class ListSessionCheckpointsActionRequest(BaseModel): @@ -1297,6 +1314,29 @@ def _latest_session_run_status(events: list[SessionEvent]) -> tuple[str, str]: return "", "" +def _latest_session_run_metadata( + events: list[SessionEvent], +) -> tuple[str, str, str, str]: + """返回 (invocation_id, status, run_mode, run_trigger)。 + + 与 _latest_session_run_status 同语义,但额外从最新 run_status 事件的 metadata + 读取 run_mode/run_trigger。旧事件缺字段降级 unknown。原 _latest_session_run_status + 不动,保护现有 ActiveInvocationId/ActiveRunStatus 契约。 + """ + invocation_id, status = _latest_session_run_status(events) + run_mode = RUN_MODE_UNKNOWN + run_trigger = RUN_TRIGGER_UNKNOWN + if invocation_id: + for event in reversed(events): + if event.event_type != "run_status" or _event_run_id(event) != invocation_id: + continue + metadata = event.metadata or {} + run_mode = str(metadata.get("run_mode") or RUN_MODE_UNKNOWN) + run_trigger = str(metadata.get("run_trigger") or RUN_TRIGGER_UNKNOWN) + break + return invocation_id, status, run_mode, run_trigger + + class WorkspaceDeleteActionRequest(BaseModel): AgentId: Optional[str] = None Path: str @@ -1323,7 +1363,12 @@ async def _session_to_action_payload(session: Session) -> dict[str, Any]: event_prompts = [prompt for prompt in event_prompts if prompt] first_prompt = session.first_prompt or (event_prompts[0] if event_prompts else "") last_prompt = session.last_prompt or (event_prompts[-1] if event_prompts else "") - active_invocation_id, active_run_status = _latest_session_run_status(events) + ( + active_invocation_id, + active_run_status, + active_run_mode, + active_run_trigger, + ) = _latest_session_run_metadata(events) title = session.title title_source = session.title_source if not title: @@ -1350,6 +1395,8 @@ async def _session_to_action_payload(session: Session) -> dict[str, Any]: "LastPrompt": _truncate_session_text(last_prompt), "ActiveInvocationId": active_invocation_id, "ActiveRunStatus": active_run_status, + "ActiveRunMode": active_run_mode, + "ActiveRunTrigger": active_run_trigger, "State": _sanitize_session_state_for_action(session.state), "CreatedAt": session.created_at, "UpdatedAt": session.updated_at, @@ -1460,7 +1507,9 @@ def _checkpoint_event_to_action_payload(event: SessionEvent) -> dict[str, Any] | "ResumeDisabledReason": disabled_reason, "NextNode": next_node, "StageKey": str(metadata.get("stage_key") or ""), - "StageName": str(metadata.get("stage_name") or metadata.get("stage") or metadata.get("title") or ""), + "StageName": str( + metadata.get("stage_name") or metadata.get("stage") or metadata.get("title") or "" + ), "StageIndex": metadata.get("stage_index"), "TotalStages": metadata.get("total_stages"), "Backend": backend, @@ -1489,7 +1538,9 @@ def _checkpoint_event_to_action_payload(event: SessionEvent) -> dict[str, Any] | return payload -def _resume_audit_by_checkpoint(events: list[SessionEvent]) -> dict[tuple[str, str], dict[str, Any]]: +def _resume_audit_by_checkpoint( + events: list[SessionEvent], +) -> dict[tuple[str, str], dict[str, Any]]: audit: dict[tuple[str, str], dict[str, Any]] = {} for event in events: if event.event_type != "run_resume": @@ -1630,7 +1681,9 @@ def _build_checkpoint_resume_preview( "Level": risk_level, "DuplicateSideEffectRisk": bool(side_effect_receipts), "SideEffectReceiptCount": len(side_effect_receipts), - "FailedReceiptCount": len([receipt for receipt in receipts if receipt["Status"] == "failed"]), + "FailedReceiptCount": len( + [receipt for receipt in receipts if receipt["Status"] == "failed"] + ), }, "Summary": { "RunId": run_id, @@ -1644,7 +1697,9 @@ def _build_checkpoint_resume_preview( def _checkpoint_resume_disabled_detail(checkpoint: Mapping[str, Any]) -> dict[str, Any] | None: if checkpoint.get("IsResumable") is not False: return None - reason = str(checkpoint.get("ResumeDisabledReason") or "").strip() or "Checkpoint is not resumable" + reason = ( + str(checkpoint.get("ResumeDisabledReason") or "").strip() or "Checkpoint is not resumable" + ) return { "code": "checkpoint_not_resumable", "reason": reason, @@ -1699,7 +1754,9 @@ async def _resolve_checkpoint_resume_input_from_session( 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") + raise HTTPException( + status_code=400, detail="Checkpoint resume requires run_id and checkpoint_id" + ) checkpoint = await _find_session_checkpoint( service=service, @@ -1725,9 +1782,7 @@ async def _resolve_checkpoint_resume_input_from_session( or resume_input.get("ResumeInstructionEnabled") ), "resume_instruction": str( - resume_input.get("resume_instruction") - or resume_input.get("ResumeInstruction") - or "" + resume_input.get("resume_instruction") or resume_input.get("ResumeInstruction") or "" ).strip(), } @@ -1769,7 +1824,10 @@ async def _find_feedback_assistant_event( if normalized_event_id and event.id != normalized_event_id: continue metadata = event.metadata or {} - if normalized_response_id and str(metadata.get("response_id") or "") != normalized_response_id: + if ( + normalized_response_id + and str(metadata.get("response_id") or "") != normalized_response_id + ): continue event_type = conversation.canonical_event_type( event.event_type, @@ -1789,7 +1847,9 @@ async def get_response_feedback_action(request: ResponseFeedbackRefActionRequest feedbacks = session.state.get("__ksadk_response_feedback__") feedback = None if isinstance(feedbacks, Mapping): - feedback = _feedback_payload_from_state(feedbacks.get(_feedback_state_key(request.ResponseId))) + feedback = _feedback_payload_from_state( + feedbacks.get(_feedback_state_key(request.ResponseId)) + ) return _action_response("GetResponseFeedback", {"Feedback": feedback}) @@ -1815,7 +1875,9 @@ async def upsert_response_feedback_action(request: UpsertResponseFeedbackActionR now = str(time.time()) existing_feedbacks = session.state.get("__ksadk_response_feedback__") feedbacks = dict(existing_feedbacks) if isinstance(existing_feedbacks, Mapping) else {} - existing = _feedback_payload_from_state(feedbacks.get(_feedback_state_key(request.ResponseId))) or {} + existing = ( + _feedback_payload_from_state(feedbacks.get(_feedback_state_key(request.ResponseId))) or {} + ) metadata = assistant_event.metadata or {} feedback = { "AgentId": request.AgentId, @@ -1999,8 +2061,9 @@ async def list_session_events_action(request: ListSessionEventsActionRequest): request.SessionId, offset=request.Offset, limit=request.Limit, + after_seq_id=request.AfterSeqId, ) - total = await service.count_events(request.SessionId) + total = await service.count_events(request.SessionId, after_seq_id=request.AfterSeqId) return _action_response( "ListSessionEvents", { @@ -2008,10 +2071,33 @@ async def list_session_events_action(request: ListSessionEventsActionRequest): "Total": total, "Offset": request.Offset or 0, "Limit": request.Limit if request.Limit is not None else len(events), + "AfterSeqId": request.AfterSeqId, }, ) +def _count_resumable_checkpoints(checkpoints: list[dict[str, Any]]) -> int: + """统计可恢复 checkpoint 数量。 + + 规则:IsResumable=True AND ReplayAllowed!=False AND IsTerminal!=True + AND CheckpointStatus not in {expired, disabled}。 + 不排除 resumed(已恢复过的仍计入,符合存档点可反复读的回档语义)。 + """ + resumable = 0 + for cp in checkpoints: + if cp.get("IsResumable") is not True: + continue + if cp.get("ReplayAllowed") is False: + continue + if cp.get("IsTerminal") is True: + continue + status = str(cp.get("CheckpointStatus") or "").strip().lower() + if status in {"expired", "disabled"}: + continue + resumable += 1 + return resumable + + async def _list_checkpoints_payload(request: ListSessionCheckpointsActionRequest) -> dict[str, Any]: service = resolve_session_service() session = await service.get_session(request.SessionId) @@ -2032,9 +2118,11 @@ async def _list_checkpoints_payload(request: ListSessionCheckpointsActionRequest continue if framework_filter and str(checkpoint["Framework"]).lower() != framework_filter: continue - if request.OnlyResumable and checkpoint.get("IsResumable") is not True: - continue + # ResumableTotal 在 OnlyResumable 过滤前统计全量可恢复数(RunId/Framework 范围内) checkpoints.append(checkpoint) + resumable_total = _count_resumable_checkpoints(checkpoints) + if request.OnlyResumable: + checkpoints = [cp for cp in checkpoints if cp.get("IsResumable") is True] total = len(checkpoints) offset = int(request.Offset or 0) if request.Limit is not None: @@ -2045,6 +2133,8 @@ async def _list_checkpoints_payload(request: ListSessionCheckpointsActionRequest return { "Checkpoints": checkpoints, "Total": total, + "ResumableTotal": resumable_total, + "HasResumableCheckpoint": resumable_total > 0, "Offset": offset, "Limit": request.Limit if request.Limit is not None else len(checkpoints), } @@ -2149,6 +2239,8 @@ async def resume_run_action(request: ResumeRunActionRequest): invocation_id=invocation_id, detail="resume_noop_terminal_checkpoint", session_service_provider=resolve_session_service, + run_mode=RUN_MODE_BACKGROUND, + run_trigger=RUN_TRIGGER_CHECKPOINT_RESUME, ) return _action_response( "ResumeRun", @@ -2196,9 +2288,12 @@ async def resume_run_action(request: ResumeRunActionRequest): invocation_id=resume_invocation_id, prepare_runner=_prepare_runner_for_model, session_service_provider=resolve_session_service, + run_mode=RUN_MODE_BACKGROUND, ), invocation_id=resume_invocation_id, resume_key=resume_key, + run_mode=RUN_MODE_BACKGROUND, + run_trigger=RUN_TRIGGER_CHECKPOINT_RESUME, ) response_id = f"resp_{uuid.uuid4().hex}" @@ -2217,6 +2312,7 @@ async def resume_run_action(request: ResumeRunActionRequest): invocation_id=str(resume_input["resume_attempt_id"]), prepare_runner=_prepare_runner_for_model, session_service_provider=resolve_session_service, + run_mode=RUN_MODE_FOREGROUND, ) payload = conversation.build_responses_payload( output_text=result["output_text"], @@ -2244,11 +2340,13 @@ async def event_generator() -> AsyncIterator[str]: last_seq_id = int(AfterSeqId or 0) deadline = time.monotonic() + 5 * 60 while True: - events = await service.get_events(session_id) + # 增量查询:把 after_seq_id 下推到后端,只取 seq_id > last_seq_id 的事件, + # 避免每轮全量拉取。invocation_id 过滤仍在 Python 侧。 + events = await service.get_events(session_id, after_seq_id=last_seq_id) matched_events = [ event for event in events - if event.seq_id > last_seq_id and event.invocation_id == invocation_id + if event.invocation_id == invocation_id ] for event in matched_events: last_seq_id = max(last_seq_id, event.seq_id) @@ -2262,19 +2360,25 @@ async def event_generator() -> AsyncIterator[str]: yield "data: [DONE]\n\n" return - latest_status = None - for event in events: - if event.invocation_id != invocation_id or event.event_type != "run_status": - continue - latest_status = str((event.content or {}).get("status") or "").strip().lower() - if latest_status in _RUN_TERMINAL_STATUSES: - yield "data: [DONE]\n\n" - return + # 重连兜底:本轮无新事件时,查全量确认 run 是否已有 terminal(客户端断连期间 run 已结束)。 + # 正常流式期间不触发此查询,保持增量收益。 + if not matched_events: + all_events = await service.get_events(session_id) + latest_status = None + for event in all_events: + if event.invocation_id != invocation_id or event.event_type != "run_status": + continue + latest_status = str((event.content or {}).get("status") or "").strip().lower() + if latest_status in _RUN_TERMINAL_STATUSES: + yield "data: [DONE]\n\n" + return if time.monotonic() > deadline: return await asyncio.sleep(0.25) return StreamingResponse(event_generator(), media_type="text/event-stream") + + @app.post("/agentengine/api/v1/UploadFile") async def upload_file_action(file: UploadFile = File(...)): file_id = uuid.uuid4().hex @@ -2295,7 +2399,7 @@ async def upload_file_action(file: UploadFile = File(...)): "mimeType": file.content_type or "application/octet-stream", "sizeBytes": len(data), } - } + }, ) @@ -2519,6 +2623,7 @@ def _normalize_model_catalog_items(raw_models: list[Any]) -> list[dict[str, Any] async def _build_models_payload() -> dict[str, Any]: import os + import httpx api_base = os.getenv("OPENAI_BASE_URL") or os.getenv("OPENAI_API_BASE") @@ -2556,7 +2661,9 @@ def _fallback_catalog() -> dict[str, Any]: models = _normalize_model_catalog_items(list(data)) else: models = _normalize_model_catalog_items(list(data.get("data", []))) - if current_model and all(str(item.get("id") or "").strip() != current_model for item in models): + if current_model and all( + str(item.get("id") or "").strip() != current_model for item in models + ): models = _normalize_model_catalog_items([*models, current_model]) return {"data": models, "current": current_model, "source": source} except Exception as e: @@ -2565,6 +2672,7 @@ def _fallback_catalog() -> dict[str, Any]: fallback["error"] = str(e) return fallback + class ListAgentModelsRequest(BaseModel): AgentId: Optional[str] = None Name: Optional[str] = None @@ -2620,9 +2728,7 @@ async def run_agent_action(request: RunAgentActionRequest): else: messages = conversation.normalize_kop_messages(request.Messages) request_metadata = ( - {"previous_response_id": request.PreviousResponseId} - if request.PreviousResponseId - else {} + {"previous_response_id": request.PreviousResponseId} if request.PreviousResponseId else {} ) if api_format == "responses": request_metadata["responses_conversation"] = True @@ -2651,6 +2757,8 @@ async def run_agent_action(request: RunAgentActionRequest): status="in_progress", invocation_id=invocation_id, session_service_provider=resolve_session_service, + run_mode=RUN_MODE_BACKGROUND, + run_trigger=trigger_from_resume_input(resume_input), ) resume_key = _detached_resume_key_from_input(resolved_background_session_id, resume_input) _reject_if_detached_resume_active(resume_key) @@ -2670,9 +2778,12 @@ async def run_agent_action(request: RunAgentActionRequest): invocation_id=invocation_id, prepare_runner=_prepare_runner_for_model, session_service_provider=resolve_session_service, + run_mode=RUN_MODE_BACKGROUND, ), invocation_id=invocation_id, session_id=resolved_background_session_id, + run_mode=RUN_MODE_BACKGROUND, + run_trigger=trigger_from_resume_input(resume_input), ) if invocation_id and resume_key: _DETACHED_RESUME_KEYS_BY_INVOCATION[invocation_id] = resume_key @@ -2683,6 +2794,7 @@ async def run_agent_action(request: RunAgentActionRequest): return _action_response( "RunAgent", { + "SessionId": resolved_background_session_id, "InvocationId": invocation_id, "Status": "running", "Background": True, @@ -2725,14 +2837,15 @@ async def run_agent_action(request: RunAgentActionRequest): invocation_id=request.InvocationId, prepare_runner=_prepare_runner_for_model, session_service_provider=resolve_session_service, + run_mode=RUN_MODE_FOREGROUND, ), invocation_id=request.InvocationId, resume_key=resume_key, + run_mode=RUN_MODE_FOREGROUND, + run_trigger=trigger_from_resume_input(resume_input), ) - responses_response_id = ( - f"resp_{uuid.uuid4().hex}" if api_format != "chat_completions" else None - ) + responses_response_id = f"resp_{uuid.uuid4().hex}" if api_format != "chat_completions" else None resolved_session_id, result = await conversation.invoke_conversation_once( runner=_resolve_active_runner(), agent_id=request.AgentId, @@ -2749,6 +2862,7 @@ async def run_agent_action(request: RunAgentActionRequest): invocation_id=request.InvocationId, prepare_runner=_prepare_runner_for_model, session_service_provider=resolve_session_service, + run_mode=RUN_MODE_FOREGROUND, ) output_text = result["output_text"] if api_format == "chat_completions": @@ -2764,7 +2878,9 @@ 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, + metadata=result.get("metadata") + if isinstance(result.get("metadata"), Mapping) + else None, ) return _action_response("RunAgent", payload) @@ -2884,7 +3000,9 @@ async def run_sse(request: AgentRunRequest): active_runner = _ensure_runner_loaded() _prepare_runner_for_model(active_runner, request.model) use_streaming = request.streaming - normalized_message = conversation.normalize_parts_content(request.newMessage.parts if request.newMessage else []) + normalized_message = conversation.normalize_parts_content( + request.newMessage.parts if request.newMessage else [] + ) user_message = { "role": "user", "content": str(normalized_message.get("content") or ""), @@ -2919,6 +3037,8 @@ async def run_sse(request: AgentRunRequest): status="in_progress", invocation_id=prepared_non_stream.invocation_id, session_service_provider=resolve_session_service, + run_mode=RUN_MODE_FOREGROUND, + run_trigger=RUN_TRIGGER_NEW_RUN, ) async def event_generator(): @@ -2992,6 +3112,8 @@ async def event_generator(): status="completed", invocation_id=invocation_id, session_service_provider=resolve_session_service, + run_mode=RUN_MODE_FOREGROUND, + run_trigger=RUN_TRIGGER_NEW_RUN, ) except Exception as e: @@ -3003,6 +3125,8 @@ async def event_generator(): invocation_id=invocation_id, detail=str(e), session_service_provider=resolve_session_service, + run_mode=RUN_MODE_FOREGROUND, + run_trigger=RUN_TRIGGER_NEW_RUN, ) error_event = { "id": str(uuid.uuid4()), @@ -3044,8 +3168,12 @@ async def event_generator(): phase="done", trigger=str(prepared.compaction_trigger or "auto"), compacted_until_seq_id=prepared.compacted_until_seq_id, - total_chars=compaction_preview.total_chars if compaction_preview.should_compact else None, - group_count=compaction_preview.group_count if compaction_preview.should_compact else None, + total_chars=compaction_preview.total_chars + if compaction_preview.should_compact + else None, + group_count=compaction_preview.group_count + if compaction_preview.should_compact + else None, ) session_id = prepared.session_id @@ -3073,6 +3201,8 @@ async def event_generator(): status="in_progress", invocation_id=invocation_id, session_service_provider=resolve_session_service, + run_mode=RUN_MODE_FOREGROUND, + run_trigger=RUN_TRIGGER_NEW_RUN, ) client_visible_text = "" @@ -3108,7 +3238,9 @@ async def event_generator(): raw_output = chunk.get("output") responses_output = raw_output if isinstance(raw_output, list) else [] raw_response_id = chunk.get("response_id") - responses_response_id = str(raw_response_id) if raw_response_id else responses_response_id + responses_response_id = ( + str(raw_response_id) if raw_response_id else responses_response_id + ) continue if chunk.get("type") == "thinking": delta = str(chunk.get("delta", "")) @@ -3120,7 +3252,10 @@ async def event_generator(): invocation_id=invocation_id, session_service_provider=resolve_session_service, ) - yield f"event: response.reasoning.delta\ndata: {json.dumps({'delta': delta}, ensure_ascii=False)}\n\n" + yield ( + "event: response.reasoning.delta\n" + f"data: {json.dumps({'delta': delta}, ensure_ascii=False)}\n\n" + ) continue if chunk.get("type") == "text": delta_text = chunk.get("delta", "") @@ -3140,7 +3275,15 @@ async def event_generator(): if chunk.get("type") == "tool_call": yield ( "event: response.tool_call\n" - f"data: {json.dumps({'name': chunk.get('tool_name'), 'args': chunk.get('tool_args', {})}, ensure_ascii=False)}\n\n" + "data: " + + json.dumps( + { + "name": chunk.get("tool_name"), + "args": chunk.get("tool_args", {}), + }, + ensure_ascii=False, + ) + + "\n\n" ) tool_event = { "id": event_id, @@ -3196,7 +3339,15 @@ async def event_generator(): ) yield ( "event: response.tool_result\n" - f"data: {json.dumps({'name': chunk.get('tool_name'), 'output': chunk.get('tool_output', {})}, ensure_ascii=False)}\n\n" + "data: " + + json.dumps( + { + "name": chunk.get("tool_name"), + "output": chunk.get("tool_output", {}), + }, + ensure_ascii=False, + ) + + "\n\n" ) continue if chunk.get("type") == "interrupt": @@ -3212,7 +3363,12 @@ async def event_generator(): ) yield ( "event: response.approval_request\n" - f"data: {json.dumps({'interrupt_info': chunk.get('interrupt_info')}, ensure_ascii=False)}\n\n" + "data: " + + json.dumps( + {"interrupt_info": chunk.get("interrupt_info")}, + ensure_ascii=False, + ) + + "\n\n" ) continue if chunk.get("type") == "final": @@ -3249,7 +3405,11 @@ async def event_generator(): event_type="assistant_message", metadata={ **({"responses_output": responses_output} if responses_output else {}), - **({"response_id": responses_response_id} if responses_response_id else {}), + **( + {"response_id": responses_response_id} + if responses_response_id + else {} + ), }, session_service_provider=resolve_session_service, ) @@ -3259,6 +3419,8 @@ async def event_generator(): status="completed", invocation_id=invocation_id, session_service_provider=resolve_session_service, + run_mode=RUN_MODE_FOREGROUND, + run_trigger=RUN_TRIGGER_NEW_RUN, ) except Exception as e: @@ -3270,6 +3432,8 @@ async def event_generator(): invocation_id=invocation_id, detail=str(e), session_service_provider=resolve_session_service, + run_mode=RUN_MODE_FOREGROUND, + run_trigger=RUN_TRIGGER_NEW_RUN, ) error_event = { "id": str(uuid.uuid4()), @@ -3415,7 +3579,9 @@ async def responses(request: ResponsesRequest): session_id=resolved_session_id, resume_input=resume_input, ) - messages = [] if resume_input is not None else conversation.normalize_responses_input(request.input) + messages = ( + [] if resume_input is not None else conversation.normalize_responses_input(request.input) + ) request_metadata = dict(request.metadata or {}) if request.previous_response_id: request_metadata.setdefault("previous_response_id", request.previous_response_id) @@ -3452,9 +3618,12 @@ async def responses(request: ResponsesRequest): invocation_id=invocation_id, prepare_runner=_prepare_runner_for_model, session_service_provider=resolve_session_service, + run_mode=RUN_MODE_FOREGROUND, ), invocation_id=invocation_id, resume_key=resume_key, + run_mode=RUN_MODE_FOREGROUND, + run_trigger=trigger_from_resume_input(resume_input), ) response_id = f"resp_{uuid.uuid4().hex}" @@ -3475,13 +3644,16 @@ async def responses(request: ResponsesRequest): invocation_id=invocation_id, prepare_runner=_prepare_runner_for_model, session_service_provider=resolve_session_service, + run_mode=RUN_MODE_FOREGROUND, ) return 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 request_metadata, + metadata=result.get("metadata") + if isinstance(result.get("metadata"), dict) + else request_metadata, ) @@ -3508,6 +3680,7 @@ async def chat_completions(request: ChatCompletionRequest): account_id=account_id, prepare_runner=_prepare_runner_for_model, session_service_provider=resolve_session_service, + run_mode=RUN_MODE_FOREGROUND, ), media_type="text/event-stream", ) @@ -3524,6 +3697,7 @@ async def chat_completions(request: ChatCompletionRequest): account_id=account_id, prepare_runner=_prepare_runner_for_model, session_service_provider=resolve_session_service, + run_mode=RUN_MODE_FOREGROUND, ) return conversation.build_chat_completions_payload( output_text=result["output_text"], diff --git a/ksadk/sessions/base.py b/ksadk/sessions/base.py index 6895e134..c0dfa7fb 100644 --- a/ksadk/sessions/base.py +++ b/ksadk/sessions/base.py @@ -317,11 +317,14 @@ async def get_events( session_id: str, offset: Optional[int] = None, limit: Optional[int] = None, + after_seq_id: Optional[int] = None, ) -> list[SessionEvent]: raise NotImplementedError @abc.abstractmethod - async def count_events(self, session_id: str) -> int: + async def count_events( + self, session_id: str, after_seq_id: Optional[int] = None + ) -> int: raise NotImplementedError @abc.abstractmethod diff --git a/ksadk/sessions/in_memory.py b/ksadk/sessions/in_memory.py index 5f401193..f96fb0b1 100644 --- a/ksadk/sessions/in_memory.py +++ b/ksadk/sessions/in_memory.py @@ -157,20 +157,31 @@ async def get_events( session_id: str, offset: Optional[int] = None, limit: Optional[int] = None, + after_seq_id: Optional[int] = None, ) -> list[SessionEvent]: async with self._lock: session = self._sessions.get(session_id) if not session: return [] - end = max(len(session.events) - (offset or 0), 0) + events = list(session.events) + if after_seq_id is not None: + events = [event for event in events if event.seq_id > after_seq_id] + end = max(len(events) - (offset or 0), 0) start = 0 if limit is None else max(end - limit, 0) - events = session.events[start:end] - return copy.deepcopy(events) + sliced = events[start:end] + return copy.deepcopy(sliced) - async def count_events(self, session_id: str) -> int: + async def count_events( + self, session_id: str, after_seq_id: Optional[int] = None + ) -> int: async with self._lock: session = self._sessions.get(session_id) - return len(session.events) if session else 0 + if not session: + return 0 + events = session.events + if after_seq_id is not None: + return sum(1 for event in events if event.seq_id > after_seq_id) + return len(events) async def get_state( self, diff --git a/ksadk/sessions/local_service.py b/ksadk/sessions/local_service.py index efb6d086..8d055571 100644 --- a/ksadk/sessions/local_service.py +++ b/ksadk/sessions/local_service.py @@ -134,13 +134,16 @@ async def get_events( session_id: str, offset: Optional[int] = None, limit: Optional[int] = None, + after_seq_id: Optional[int] = None, ) -> list[SessionEvent]: async with self._lock: - return await asyncio.to_thread(self._get_events_sync, session_id, offset, limit) + return await asyncio.to_thread(self._get_events_sync, session_id, offset, limit, after_seq_id) - async def count_events(self, session_id: str) -> int: + async def count_events( + self, session_id: str, after_seq_id: Optional[int] = None + ) -> int: async with self._lock: - return await asyncio.to_thread(self._count_events_sync, session_id) + return await asyncio.to_thread(self._count_events_sync, session_id, after_seq_id) async def get_state( self, @@ -667,12 +670,15 @@ def _get_events_sync( session_id: str, offset: Optional[int] = None, limit: Optional[int] = None, + after_seq_id: Optional[int] = None, *, connection: Optional[sqlite3.Connection] = None, ) -> list[SessionEvent]: owns_connection = connection is None connection = connection or self._connect() try: + # after_seq_id 先过滤 seq_id > N,再对结果集应用"最新 N 条" offset/limit 语义。 + seq_clause = "AND seq_id > ?" if after_seq_id is not None else "" if limit is not None: query = f""" SELECT id, session_id, author, event_type, content_json, timestamp, @@ -681,13 +687,16 @@ def _get_events_sync( SELECT id, session_id, author, event_type, content_json, timestamp, state_delta_json, seq_id, invocation_id, metadata_json FROM {KSADK_EVENTS_TABLE} - WHERE session_id = ? + WHERE session_id = ? {seq_clause} ORDER BY seq_id DESC LIMIT ? OFFSET ? ) ORDER BY seq_id ASC """ - params: list[object] = [session_id, limit, offset or 0] + params: list[object] = [session_id] + if after_seq_id is not None: + params.append(after_seq_id) + params.extend([limit, offset or 0]) elif offset is not None: query = f""" SELECT id, session_id, author, event_type, content_json, timestamp, @@ -696,22 +705,27 @@ def _get_events_sync( SELECT id, session_id, author, event_type, content_json, timestamp, state_delta_json, seq_id, invocation_id, metadata_json FROM {KSADK_EVENTS_TABLE} - WHERE session_id = ? + WHERE session_id = ? {seq_clause} ORDER BY seq_id DESC LIMIT -1 OFFSET ? ) ORDER BY seq_id ASC """ - params = [session_id, offset] + params = [session_id] + if after_seq_id is not None: + params.append(after_seq_id) + params.append(offset) else: query = f""" SELECT id, session_id, author, event_type, content_json, timestamp, state_delta_json, seq_id, invocation_id, metadata_json FROM {KSADK_EVENTS_TABLE} - WHERE session_id = ? + WHERE session_id = ? {seq_clause} ORDER BY seq_id ASC """ params = [session_id] + if after_seq_id is not None: + params.append(after_seq_id) rows = connection.execute(query, params).fetchall() return [ @@ -733,12 +747,20 @@ def _get_events_sync( if owns_connection: connection.close() - def _count_events_sync(self, session_id: str) -> int: + def _count_events_sync( + self, session_id: str, after_seq_id: Optional[int] = None + ) -> int: with self._connection() as connection: - row = connection.execute( - f"SELECT COUNT(*) AS total FROM {KSADK_EVENTS_TABLE} WHERE session_id = ?", - (session_id,), - ).fetchone() + if after_seq_id is not None: + row = connection.execute( + f"SELECT COUNT(*) AS total FROM {KSADK_EVENTS_TABLE} WHERE session_id = ? AND seq_id > ?", + (session_id, after_seq_id), + ).fetchone() + else: + 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( diff --git a/ksadk/sessions/postgres_service.py b/ksadk/sessions/postgres_service.py index 51c4294e..4dbcb323 100644 --- a/ksadk/sessions/postgres_service.py +++ b/ksadk/sessions/postgres_service.py @@ -330,54 +330,112 @@ async def get_events( session_id: str, offset: Optional[int] = None, limit: Optional[int] = None, + after_seq_id: Optional[int] = None, ) -> list[SessionEvent]: await self._ensure_schema() + # after_seq_id 先过滤 seq_id > N,再对结果集应用"最新 N 条" offset/limit 语义。 + # 占位符顺序:namespace=$1, session_id=$2, [after_seq_id=$3], limit=$N, offset=$M。 + seq_clause = "AND seq_id > $3" if after_seq_id is not None else "" async with self._pool.acquire() as connection: if limit is not None: - query = f""" - SELECT id, session_id, author, event_type, content_json, timestamp, - state_delta_json, seq_id, invocation_id, metadata_json - FROM ( + # $3=after_seq_id(可选), $4=limit, $5=offset —— 或 $3=limit, $4=offset(无 after_seq_id) + if after_seq_id is not None: + query = f""" SELECT id, session_id, author, event_type, content_json, timestamp, state_delta_json, seq_id, invocation_id, metadata_json - FROM {KSADK_PG_EVENTS_TABLE} - WHERE namespace = $1 AND session_id = $2 - ORDER BY seq_id DESC - LIMIT $3 OFFSET $4 - ) AS latest_events - ORDER BY seq_id ASC - """ - params: list[Any] = [self.namespace, session_id, limit, offset or 0] + FROM ( + SELECT id, session_id, author, event_type, content_json, timestamp, + state_delta_json, seq_id, invocation_id, metadata_json + FROM {KSADK_PG_EVENTS_TABLE} + WHERE namespace = $1 AND session_id = $2 {seq_clause} + ORDER BY seq_id DESC + LIMIT $4 OFFSET $5 + ) AS latest_events + ORDER BY seq_id ASC + """ + params: list[Any] = [self.namespace, session_id, after_seq_id, limit, offset or 0] + else: + query = f""" + SELECT id, session_id, author, event_type, content_json, timestamp, + state_delta_json, seq_id, invocation_id, metadata_json + FROM ( + SELECT id, session_id, author, event_type, content_json, timestamp, + state_delta_json, seq_id, invocation_id, metadata_json + FROM {KSADK_PG_EVENTS_TABLE} + WHERE namespace = $1 AND session_id = $2 + ORDER BY seq_id DESC + LIMIT $3 OFFSET $4 + ) AS latest_events + ORDER BY seq_id ASC + """ + params = [self.namespace, session_id, limit, offset or 0] elif offset is not None: - query = f""" - SELECT id, session_id, author, event_type, content_json, timestamp, - state_delta_json, seq_id, invocation_id, metadata_json - FROM ( + if after_seq_id is not None: + query = f""" + SELECT id, session_id, author, event_type, content_json, timestamp, + state_delta_json, seq_id, invocation_id, metadata_json + FROM ( + SELECT id, session_id, author, event_type, content_json, timestamp, + state_delta_json, seq_id, invocation_id, metadata_json + FROM {KSADK_PG_EVENTS_TABLE} + WHERE namespace = $1 AND session_id = $2 {seq_clause} + ORDER BY seq_id DESC + OFFSET $4 + ) AS latest_events + ORDER BY seq_id ASC + """ + params = [self.namespace, session_id, after_seq_id, offset] + else: + query = f""" + SELECT id, session_id, author, event_type, content_json, timestamp, + state_delta_json, seq_id, invocation_id, metadata_json + FROM ( + SELECT id, session_id, author, event_type, content_json, timestamp, + state_delta_json, seq_id, invocation_id, metadata_json + FROM {KSADK_PG_EVENTS_TABLE} + WHERE namespace = $1 AND session_id = $2 + ORDER BY seq_id DESC + OFFSET $3 + ) AS latest_events + ORDER BY seq_id ASC + """ + params = [self.namespace, session_id, offset] + else: + if after_seq_id is not None: + query = f""" + SELECT id, session_id, author, event_type, content_json, timestamp, + state_delta_json, seq_id, invocation_id, metadata_json + FROM {KSADK_PG_EVENTS_TABLE} + WHERE namespace = $1 AND session_id = $2 {seq_clause} + ORDER BY seq_id ASC + """ + params = [self.namespace, session_id, after_seq_id] + else: + query = f""" SELECT id, session_id, author, event_type, content_json, timestamp, state_delta_json, seq_id, invocation_id, metadata_json FROM {KSADK_PG_EVENTS_TABLE} WHERE namespace = $1 AND session_id = $2 - ORDER BY seq_id DESC - OFFSET $3 - ) AS latest_events - ORDER BY seq_id ASC - """ - params = [self.namespace, session_id, offset] - else: - query = f""" - SELECT id, session_id, author, event_type, content_json, timestamp, - state_delta_json, seq_id, invocation_id, metadata_json - FROM {KSADK_PG_EVENTS_TABLE} - WHERE namespace = $1 AND session_id = $2 - ORDER BY seq_id ASC - """ - params = [self.namespace, session_id] + ORDER BY seq_id ASC + """ + params = [self.namespace, session_id] 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: + async def count_events( + self, session_id: str, after_seq_id: Optional[int] = None + ) -> int: await self._ensure_schema() async with self._pool.acquire() as connection: + if after_seq_id is not None: + query = f""" + SELECT COUNT(*) AS total + FROM {KSADK_PG_EVENTS_TABLE} + WHERE namespace = $1 AND session_id = $2 AND seq_id > $3 + """ + return int( + await connection.fetchval(query, self.namespace, session_id, after_seq_id) or 0 + ) query = f""" SELECT COUNT(*) AS total FROM {KSADK_PG_EVENTS_TABLE} diff --git a/ksadk/tracing/setup.py b/ksadk/tracing/setup.py index d4bba855..17b043ba 100644 --- a/ksadk/tracing/setup.py +++ b/ksadk/tracing/setup.py @@ -15,6 +15,26 @@ logger = logging.getLogger(__name__) + +def _batch_span_processor_kwargs() -> dict: + """BatchSpanProcessor 参数:限制单次 export 体积,避免 OTLP collector 413。 + + 默认 max_export_batch_size=64(OTel 默认 512),单 batch 过大会触发 collector + `Request Entity Too Large (413)`。可用 KSADK_OTLP_MAX_EXPORT_BATCH_SIZE 覆盖。 + """ + try: + max_batch = int(os.environ.get("KSADK_OTLP_MAX_EXPORT_BATCH_SIZE", "64")) + except ValueError: + max_batch = 64 + if max_batch <= 0: + max_batch = 64 + return { + "max_queue_size": max(512, max_batch * 8), + "max_export_batch_size": max_batch, + "export_timeout_millis": 30000, + } + + _exporter_instance: Optional[InMemoryExporter] = None _langfuse_exporter: Optional[Any] = None _tracing_initialized: bool = False @@ -462,7 +482,9 @@ def _is_langfuse_otlp_endpoint(endpoint: str) -> bool: def _is_langfuse_callback_endpoint(endpoint: str) -> bool: - return _env_flag_enabled(os.getenv("LANGFUSE_USE_CALLBACK", "")) and _is_langfuse_otlp_endpoint(endpoint) + return _env_flag_enabled(os.getenv("LANGFUSE_USE_CALLBACK", "")) and _is_langfuse_otlp_endpoint( + endpoint + ) def _apply_langfuse_auth_fallback(endpoint: str, headers: dict[str, str]) -> bool: @@ -613,7 +635,12 @@ def setup_tracing( service_name=_get_service_name(), header_keys=sorted(generic_otlp_config["headers"]), ) - provider.add_span_processor(BatchSpanProcessor(otlp_exporter)) + provider.add_span_processor( + BatchSpanProcessor( + otlp_exporter, + **_batch_span_processor_kwargs(), + ) + ) logger.info( "Generic OTLP HTTP exporter enabled: %s (%s) headers=%s", generic_otlp_config["endpoint"], @@ -645,7 +672,12 @@ def setup_tracing( header_keys=sorted(cloud_monitor_config.headers), span_transform=_prepare_cloud_monitor_spans, ) - provider.add_span_processor(BatchSpanProcessor(cloud_monitor_exporter)) + provider.add_span_processor( + BatchSpanProcessor( + cloud_monitor_exporter, + **_batch_span_processor_kwargs(), + ) + ) logger.info( "CloudMonitor OTLP exporter enabled: endpoint=%s protocol=%s service_name=%s", cloud_monitor_config.endpoint, @@ -690,7 +722,12 @@ def setup_tracing( service_name=_get_service_name(), header_keys=sorted(config["headers"]), ) - provider.add_span_processor(BatchSpanProcessor(otlp_exporter)) + provider.add_span_processor( + BatchSpanProcessor( + otlp_exporter, + **_batch_span_processor_kwargs(), + ) + ) logger.info( "Langfuse OTLP exporter enabled: %s (%s) headers=%s", config["endpoint"], @@ -712,7 +749,12 @@ def setup_tracing( try: from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter otlp_exporter = OTLPSpanExporter(endpoint=otlp_endpoint) - provider.add_span_processor(BatchSpanProcessor(otlp_exporter)) + provider.add_span_processor( + BatchSpanProcessor( + otlp_exporter, + **_batch_span_processor_kwargs(), + ) + ) logger.info(f"OTLP exporter enabled: {otlp_endpoint}") except ImportError: logger.warning("OTLP exporter not installed") diff --git a/ksadk/version.py b/ksadk/version.py index 9b3a159d..786800f2 100644 --- a/ksadk/version.py +++ b/ksadk/version.py @@ -1,4 +1,4 @@ """KsADK 版本信息""" -VERSION = "0.6.8" +VERSION = "0.6.9" __version__ = VERSION diff --git a/ksadk_runtime_common/memory_backend/providers/lancedb.py b/ksadk_runtime_common/memory_backend/providers/lancedb.py index 47bea3df..67b5a500 100644 --- a/ksadk_runtime_common/memory_backend/providers/lancedb.py +++ b/ksadk_runtime_common/memory_backend/providers/lancedb.py @@ -12,10 +12,17 @@ class LanceDBProvider: """Provider for the in-process LanceDB OpenClaw memory plugin.""" def render(self, manifest: MemoryBackendManifest) -> RenderResult: - """Render LanceDB plugin config for OpenClaw.""" + """Render LanceDB plugin config for OpenClaw. + + Maps the platform manifest config fields onto the memory-lancedb + plugin schema. This must stay in sync with the inline renderer in + ``deploy/openclaw/bootstrap.sh`` so both code paths produce the same + plugin entry. + """ entry: dict[str, Any] = {"enabled": True} - if manifest.config: - entry["config"] = dict(manifest.config) + config = self._render_plugin_config(manifest.config or {}) + if config: + entry["config"] = config return RenderResult( backend_type="lancedb", @@ -32,3 +39,113 @@ def render(self, manifest: MemoryBackendManifest) -> RenderResult: plugin_ids=["memory-lancedb"], disabled_plugin_ids=["openclaw-mem0"], ) + + @staticmethod + def _first(config: dict[str, Any], *keys: str) -> Any: + for key in keys: + value = config.get(key) + if isinstance(value, str): + value = value.strip() + if value: + return value + elif value is not None: + return value + return None + + @classmethod + def _render_plugin_config(cls, config: dict[str, Any]) -> dict[str, Any]: + if not config: + return {} + + output: dict[str, Any] = {} + + embedding: dict[str, Any] = {} + for target_key, *source_keys in ( + ("provider", "provider", "embedding_provider"), + ("model", "model", "embedding_model"), + ("apiKey", "apiKey", "api_key", "embedding_api_key"), + ("baseUrl", "baseUrl", "base_url", "embedding_base_url"), + ): + value = cls._first(config, *source_keys) + if value is not None: + embedding[target_key] = value + dimensions = cls._first( + config, "dimensions", "embedding_dimensions" + ) + if isinstance(dimensions, int) or ( + isinstance(dimensions, str) and dimensions.strip().isdigit() + ): + embedding["dimensions"] = int(dimensions) + if embedding: + output["embedding"] = embedding + + db_path = cls._first( + config, "dbPath", "db_path", "data_path", + "database_uri", "databaseUri", "database_url", + ) + if db_path is not None: + output["dbPath"] = db_path + + for target_key, *source_keys in ( + ("autoCapture", "autoCapture", "auto_capture"), + ("autoRecall", "autoRecall", "auto_recall"), + ): + value = cls._first(config, *source_keys) + if isinstance(value, bool): + output[target_key] = value + elif isinstance(value, str): + normalized = value.strip().lower() + if normalized in {"1", "true", "yes", "on"}: + output[target_key] = True + elif normalized in {"0", "false", "no", "off"}: + output[target_key] = False + + for target_key, *source_keys in ( + ("captureMaxChars", "captureMaxChars", "capture_max_chars"), + ("recallMaxChars", "recallMaxChars", "recall_max_chars"), + ): + value = cls._first(config, *source_keys) + if isinstance(value, int) and not isinstance(value, bool): + output[target_key] = value + elif isinstance(value, str) and value.strip().lstrip("-").isdigit(): + output[target_key] = int(value) + + custom_triggers = config.get("customTriggers", config.get("custom_triggers")) + if isinstance(custom_triggers, list): + output["customTriggers"] = custom_triggers + + for target_key, *source_keys in ( + ("dreaming", "dreaming"), + ("storageOptions", "storageOptions", "storage_options"), + ): + value = cls._first(config, *source_keys) + if isinstance(value, dict): + output[target_key] = value + + storage_options = output.get("storageOptions") + if not isinstance(storage_options, dict): + storage_options = {} + for target_key, *source_keys in ( + ("endpoint", "storage_endpoint", "storageEndpoint"), + ("region", "storage_region", "storageRegion"), + ("bucket", "storage_bucket", "storageBucket"), + ("prefix", "storage_prefix", "storagePrefix"), + ("accessKeyId", "storage_access_key_id", "storageAccessKeyId"), + ("secretAccessKey", "storage_secret_access_key", "storageSecretAccessKey"), + ): + value = cls._first(config, *source_keys) + if value is not None: + storage_options[target_key] = value + allow_http = cls._first(config, "storage_allow_http", "storageAllowHttp") + if isinstance(allow_http, bool): + storage_options["allowHttp"] = "true" if allow_http else "false" + elif isinstance(allow_http, str): + normalized = allow_http.strip().lower() + if normalized in {"1", "true", "yes", "on"}: + storage_options["allowHttp"] = "true" + elif normalized in {"0", "false", "no", "off"}: + storage_options["allowHttp"] = "false" + if storage_options: + output["storageOptions"] = storage_options + + return output diff --git a/ksadk_runtime_common/schemas/memory_backend_manifest.schema.json b/ksadk_runtime_common/schemas/memory_backend_manifest.schema.json index 4c0360a2..7a83d280 100644 --- a/ksadk_runtime_common/schemas/memory_backend_manifest.schema.json +++ b/ksadk_runtime_common/schemas/memory_backend_manifest.schema.json @@ -1,100 +1,157 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://kingsoftcloud.github.io/ksadk-python/schemas/memory_backend_manifest/v1", - "title": "MemoryBackendManifest", - "description": "Memory backend manifest for OpenClaw runtime configuration.", - "type": "object", - "required": [ - "schema_version", - "backend_type" - ], - "additionalProperties": false, - "properties": { - "schema_version": { - "type": "string", - "const": "v1", - "description": "Schema version identifier. Must be 'v1'." - }, - "backend_type": { - "type": "string", - "enum": [ - "openclaw_default", - "mem0", - "lancedb" - ], - "description": "Memory backend type. Supports 'openclaw_default', 'mem0', and 'lancedb'." - }, - "config": { - "type": "object", - "description": "Backend-specific configuration.", - "additionalProperties": true, - "properties": { - "mem0_instance_id": { - "type": "string", - "format": "uuid", - "description": "Mem0 instance UUID (required when backend_type is 'mem0')." - }, - "mem0_instance_name": { - "type": "string", - "description": "Mem0 instance display name." - }, - "mem0_region": { - "type": "string", - "description": "Mem0 instance region." - }, - "dbPath": { - "type": "string", - "description": "Optional LanceDB plugin dbPath override." - }, - "embedding": { - "type": "object", - "description": "Optional LanceDB plugin embedding configuration.", - "additionalProperties": true - }, - "storageOptions": { - "type": "object", - "description": "Optional LanceDB plugin storage options.", - "additionalProperties": true - } - } - }, - "secrets_env": { - "type": "object", - "description": "Environment variable names for secrets. Values are env var names, not actual secrets.", - "additionalProperties": { - "type": "string" - }, - "properties": { - "api_key": { - "type": "string", - "description": "Environment variable name for API key (e.g., 'MEM0_API_KEY')." - }, - "memory_id": { - "type": "string", - "description": "Environment variable name for memory ID (e.g., 'MEM0_MEMORY_ID')." - }, - "embedding_api_key": { - "type": "string", - "description": "Reserved for future LanceDB embedding secrets." - } - } - } - }, - "allOf": [ - { - "if": { - "properties": { - "backend_type": { "const": "mem0" } - } - }, - "then": { - "required": ["config"], - "properties": { - "config": { - "required": ["mem0_instance_id"] - } - } - } - } - ] -} +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://kingsoftcloud.github.io/ksadk-python/schemas/memory_backend_manifest/v1", + "title": "MemoryBackendManifest", + "description": "Memory backend manifest for OpenClaw runtime configuration.", + "type": "object", + "required": [ + "schema_version", + "backend_type" + ], + "additionalProperties": false, + "properties": { + "schema_version": { + "type": "string", + "const": "v1", + "description": "Schema version identifier. Must be 'v1'." + }, + "backend_type": { + "type": "string", + "enum": [ + "openclaw_default", + "mem0", + "lancedb" + ], + "description": "Memory backend type. Supports 'openclaw_default', 'mem0', and 'lancedb'." + }, + "config": { + "type": "object", + "description": "Backend-specific configuration.", + "additionalProperties": true, + "properties": { + "mem0_instance_id": { + "type": "string", + "format": "uuid", + "description": "Mem0 instance UUID (required when backend_type is 'mem0')." + }, + "mem0_instance_name": { + "type": "string", + "description": "Mem0 instance display name." + }, + "mem0_region": { + "type": "string", + "description": "Mem0 instance region." + }, + "dbPath": { + "type": "string", + "description": "Optional LanceDB plugin dbPath override." + }, + "database_uri": { + "type": "string", + "description": "Database URI for LanceDB storage (e.g. s3://bucket/prefix). Mapped to dbPath when dbPath is absent." + }, + "embedding": { + "type": "object", + "description": "Optional LanceDB plugin embedding configuration.", + "additionalProperties": true + }, + "embedding_provider": { + "type": "string", + "description": "Embedding provider for LanceDB (e.g. openai)." + }, + "embedding_model": { + "type": "string", + "description": "Embedding model name for LanceDB." + }, + "embedding_dimensions": { + "type": "integer", + "description": "Embedding vector dimensions for LanceDB." + }, + "embedding_base_url": { + "type": "string", + "description": "Embedding provider base URL for LanceDB." + }, + "storageOptions": { + "type": "object", + "description": "Optional LanceDB plugin storage options.", + "additionalProperties": true + }, + "storage_endpoint": { + "type": "string", + "description": "S3-compatible storage endpoint URL. Mapped into storageOptions.endpoint." + }, + "storage_region": { + "type": "string", + "description": "S3-compatible storage region. Mapped into storageOptions.region." + }, + "storage_bucket": { + "type": "string", + "description": "S3-compatible storage bucket. Mapped into storageOptions.bucket." + }, + "storage_prefix": { + "type": "string", + "description": "S3-compatible storage key prefix. Mapped into storageOptions.prefix." + }, + "storage_access_key_id": { + "type": "string", + "description": "S3-compatible storage access key id. Mapped into storageOptions.accessKeyId." + }, + "storage_secret_access_key": { + "type": "string", + "description": "S3-compatible storage secret access key. Mapped into storageOptions.secretAccessKey." + }, + "storage_allow_http": { + "type": [ + "boolean", + "string" + ], + "description": "Allow plain HTTP for S3-compatible storage. Mapped into storageOptions.allowHttp as a string (\"true\"/\"false\")." + } + } + }, + "secrets_env": { + "type": "object", + "description": "Environment variable names for secrets. Values are env var names, not actual secrets.", + "additionalProperties": { + "type": "string" + }, + "properties": { + "api_key": { + "type": "string", + "description": "Environment variable name for API key (e.g., 'MEM0_API_KEY')." + }, + "memory_id": { + "type": "string", + "description": "Environment variable name for memory ID (e.g., 'MEM0_MEMORY_ID')." + }, + "embedding_api_key": { + "type": "string", + "description": "Reserved for future LanceDB embedding secrets." + } + } + } + }, + "allOf": [ + { + "if": { + "properties": { + "backend_type": { + "const": "mem0" + } + } + }, + "then": { + "required": [ + "config" + ], + "properties": { + "config": { + "required": [ + "mem0_instance_id" + ] + } + } + } + } + ] +} diff --git a/pyproject.toml b/pyproject.toml index 9560536b..3f4cb57f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "ksadk" -version = "0.6.8" +version = "0.6.9" description = "KsADK Agent Runtime Platform - unified runtime, debugging, deployment and observability for AI agents" readme = "README.md" requires-python = ">=3.10" diff --git a/scripts/open_source_audit.py b/scripts/open_source_audit.py index 61facdfc..21f6d077 100644 --- a/scripts/open_source_audit.py +++ b/scripts/open_source_audit.py @@ -220,6 +220,7 @@ def to_dict(self) -> dict[str, object]: r"(? str: | License | Apache-2.0 | | Python repository | kingsoftcloud/ksadk-python | | Web UI repository | kingsoftcloud/ksadk-web | -| Python package version | 0.6.8 | +| Python package version | 0.6.9 | | 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/ | @@ -104,7 +104,7 @@ def test_template_approval_record_fails_until_strategy_and_signoffs_are_filled(t checks = module.validate_approval_record( record, - version="0.6.8", + version="0.6.9", expected_current_commit="current-reviewed-commit", ) @@ -125,7 +125,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.8", expected_current_commit="") + checks = module.validate_approval_record(record, version="0.6.9", expected_current_commit="") assert all(check.ok for check in checks) @@ -137,7 +137,7 @@ def test_filled_record_fails_when_source_references_do_not_match_current_commit( checks = module.validate_approval_record( record, - version="0.6.8", + version="0.6.9", expected_current_commit="new-reviewed-commit", ) @@ -160,7 +160,7 @@ def test_filled_record_passes_when_source_references_include_current_commit(tmp_ checks = module.validate_approval_record( record, - version="0.6.8", + version="0.6.9", expected_current_commit="new-reviewed-commit", ) diff --git a/tests/test_client_permission_precheck.py b/tests/test_client_permission_precheck.py index 58ca5127..e15e77c8 100644 --- a/tests/test_client_permission_precheck.py +++ b/tests/test_client_permission_precheck.py @@ -18,6 +18,17 @@ def clear_permission_probe_cache(): cache.clear() +@pytest.fixture(autouse=True) +def _stub_identity_resolve(monkeypatch): + """这些测试用假 AK/SK,mock 掉身份反查避免联网 + warning 干扰 caplog 断言。""" + monkeypatch.setattr( + "ksadk.identity.resolve_identity", lambda **kw: None + ) + monkeypatch.setattr( + "ksadk.identity.get_cached_identity", lambda ak: None + ) + + def _build_client() -> AgentEngineClient: return AgentEngineClient( base_url="https://aicp.api.ksyun.com", diff --git a/tests/test_client_user_uuid_header.py b/tests/test_client_user_uuid_header.py new file mode 100644 index 00000000..c27e8486 --- /dev/null +++ b/tests/test_client_user_uuid_header.py @@ -0,0 +1,179 @@ +"""client.py X-Ksc-User-uuid header 注入测试。""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from ksadk.api.client import AgentEngineClient +from ksadk.identity.resolver import ResolvedIdentity + + +@pytest.fixture(autouse=True) +def _isolate_identity_env(monkeypatch): + """每个测试隔离 env + 缓存,避免互相影响。""" + monkeypatch.delenv("KSYUN_ACCOUNT_ID", raising=False) + monkeypatch.delenv("KSYUN_ACCESS_KEY", raising=False) + monkeypatch.delenv("KSYUN_SECRET_KEY", raising=False) + # 隔离缓存(patch resolve_identity/get_cached_identity 避免真实文件读写) + yield + + +def _make_client_with_creds(monkeypatch, *, access_key="AKLTtest", secret_key="SKtest", dry_run=False): + """构造带凭证的 client,绕过真实 AK/SK env 依赖。""" + client = AgentEngineClient(region="cn-beijing-6", dry_run=dry_run) + # 注入凭证到 _auth + client._auth.access_key_id = access_key + client._auth.secret_access_key = secret_key + return client + + +def test_build_headers_no_user_uuid_when_resolve_fails(monkeypatch): + """反查失败时不注入 X-Ksc-User-uuid,其他 header 正常。""" + client = _make_client_with_creds(monkeypatch) + monkeypatch.setattr("ksadk.identity.resolve_identity", lambda **kw: None) + + headers = client._build_headers(action="Test") + + assert "X-Ksc-User-uuid" not in headers + assert headers["X-Ksc-Source"] == "ksadk-cli" + assert "X-Ksc-Region" in headers + + +def test_build_headers_includes_user_uuid_after_resolve(monkeypatch): + """反查成功时注入 X-Ksc-User-uuid。""" + client = _make_client_with_creds(monkeypatch) + fake = ResolvedIdentity( + user_uuid="uuid-xyz", + main_account_id="2000003485", + user_name="xiayu", + krn="krn:ksc:iam::2000003485:user/xiayu", + ak_fingerprint="abc", + ) + monkeypatch.setattr("ksadk.identity.resolve_identity", lambda **kw: fake) + + headers = client._build_headers(action="Test") + + assert headers["X-Ksc-User-uuid"] == "uuid-xyz" + # account_id 也从反查拿到 + assert headers["X-Ksc-Account-Id"] == "2000003485" + + +def test_build_headers_extra_headers_override_user_uuid(monkeypatch): + """extra_headers 显式覆盖 user uuid。""" + client = _make_client_with_creds(monkeypatch) + client.extra_headers = {"X-Ksc-User-uuid": "custom-uuid", "X-Ksc-Account-Id": "custom-acct"} + monkeypatch.setattr("ksadk.identity.resolve_identity", lambda **kw: MagicMock(user_uuid="should-not-use")) + + headers = client._build_headers(action="Test") + + # extra_headers 在 _resolve_user_uuid 里优先返回,且 _build_headers 末尾 update 覆盖 + assert headers["X-Ksc-User-uuid"] == "custom-uuid" + assert headers["X-Ksc-Account-Id"] == "custom-acct" + + +def test_build_headers_lowercase_extra_headers_normalized(monkeypatch): + """extra_headers 用小写 key 时归一为 Title-Case,避免重复 header。""" + client = _make_client_with_creds(monkeypatch) + client.extra_headers = {"x-ksc-user-uuid": "custom", "x-ksc-account-id": "custom-acct"} + monkeypatch.setattr("ksadk.identity.resolve_identity", lambda **kw: MagicMock(user_uuid="should-not-use")) + + headers = client._build_headers(action="Test") + + # 只应有一个 X-Ksc-User-uuid(Title-Case),不应有小写 key 共存 + uuid_keys = [k for k in headers if k.lower() == "x-ksc-user-uuid"] + assert len(uuid_keys) == 1 + assert uuid_keys[0] == "X-Ksc-User-uuid" + assert headers["X-Ksc-User-uuid"] == "custom" + acct_keys = [k for k in headers if k.lower() == "x-ksc-account-id"] + assert len(acct_keys) == 1 + assert headers["X-Ksc-Account-Id"] == "custom-acct" + + +def test_dry_run_does_not_invoke_resolve(monkeypatch): + """dry-run 不调 resolve_identity,只读缓存。""" + client = _make_client_with_creds(monkeypatch, dry_run=True) + called = MagicMock() + monkeypatch.setattr("ksadk.identity.resolve_identity", lambda **kw: called()) + monkeypatch.setattr("ksadk.identity.get_cached_identity", lambda ak: None) + + client._build_headers(action="Test") + + assert called.call_count == 0 # dry-run 不联网反查 + + +def test_dry_run_uses_cached_identity(monkeypatch): + """dry-run 时从缓存读 identity 注入 header。""" + client = _make_client_with_creds(monkeypatch, dry_run=True) + fake = ResolvedIdentity( + user_uuid="cached-uuid", + main_account_id="2000003485", + user_name="u", + krn=None, + ak_fingerprint="abc", + ) + monkeypatch.setattr("ksadk.identity.resolve_identity", lambda **kw: None) + monkeypatch.setattr("ksadk.identity.get_cached_identity", lambda ak: fake) + + headers = client._build_headers(action="Test") + + assert headers["X-Ksc-User-uuid"] == "cached-uuid" + + +def test_resolve_user_uuid_cached_on_instance(monkeypatch): + """同一 client 多次 _build_headers 只反查一次。""" + client = _make_client_with_creds(monkeypatch) + call_count = {"n": 0} + + def fake_resolve(**kw): + call_count["n"] += 1 + return ResolvedIdentity( + user_uuid="uuid-x", main_account_id=None, user_name="u", krn=None, ak_fingerprint="abc" + ) + + monkeypatch.setattr("ksadk.identity.resolve_identity", fake_resolve) + + client._build_headers(action="Test1") + client._build_headers(action="Test2") + client._build_headers(action="Test3") + + assert call_count["n"] == 1 # 实例缓存,只调一次 + + +def test_account_id_env_overrides_resolve(monkeypatch): + """KSYUN_ACCOUNT_ID env 优先于反查。""" + monkeypatch.setenv("KSYUN_ACCOUNT_ID", "env-acct") + client = _make_client_with_creds(monkeypatch) + fake = ResolvedIdentity( + user_uuid="uuid-x", main_account_id="resolved-acct", user_name="u", krn=None, ak_fingerprint="abc" + ) + monkeypatch.setattr("ksadk.identity.resolve_identity", lambda **kw: fake) + + headers = client._build_headers(action="Test") + + assert headers["X-Ksc-Account-Id"] == "env-acct" # env 覆盖反查 + + +def test_account_id_falls_back_to_resolved_main_account(monkeypatch): + """无 env 时 X-Ksc-Account-Id 从反查 main_account_id 拿。""" + client = _make_client_with_creds(monkeypatch) + fake = ResolvedIdentity( + user_uuid="uuid-x", main_account_id="2000003485", user_name="u", krn=None, ak_fingerprint="abc" + ) + monkeypatch.setattr("ksadk.identity.resolve_identity", lambda **kw: fake) + + headers = client._build_headers(action="Test") + + assert headers["X-Ksc-Account-Id"] == "2000003485" + + +def test_no_credentials_no_user_uuid_no_account_id(monkeypatch): + """无 AK/SK 时 user_uuid/account_id 都为 None,不注入,不报错。""" + client = AgentEngineClient(region="cn-beijing-6") # 无凭证 + monkeypatch.setattr("ksadk.identity.resolve_identity", lambda **kw: None) + + headers = client._build_headers(action="Test") + + assert "X-Ksc-User-uuid" not in headers + assert "X-Ksc-Account-Id" not in headers diff --git a/tests/test_cmd_hermes.py b/tests/test_cmd_hermes.py index 657f48fd..21a95f47 100644 --- a/tests/test_cmd_hermes.py +++ b/tests/test_cmd_hermes.py @@ -1490,3 +1490,117 @@ def test_hermes_delete_resolves_name_to_agent_id_and_rejects_non_hermes(monkeypa assert non_hermes.exit_code != 0 assert _FakeHermesClient.deleted == [] + + +class _FakeHermesUpdateNotFoundClient(_FakeHermesClient): + """update_agent 对已删除 agent 抛 404,create_agent 返回新 agent。""" + + update_called = False + create_called = False + + async def update_agent(self, agent_id, payload): + self.__class__.update_called = True + raise AgentEngineAPIError( + 404, + "未找到对应的 Agent", + details={"http_status": 404, "remote_error_message": "未找到对应的 Agent"}, + ) + + async def create_agent(self, payload): + self.__class__.create_called = True + self.__class__.create_payload = payload + return { + "agent_id": "ar-hermes-recreated", + "name": payload["name"], + "endpoint": "https://recreated-hermes.example.com", + "api_key": "ak-recreated", + } + + +def test_hermes_deploy_falls_back_to_create_when_state_points_to_deleted_agent( + monkeypatch, tmp_path: Path +): + """本地 .agentengine.state 缓存的 agent 已在服务端删除时,deploy 应自动回退为新建。""" + runner = CliRunner() + monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesUpdateNotFoundClient) + monkeypatch.chdir(tmp_path) + _FakeHermesUpdateNotFoundClient.update_called = False + _FakeHermesUpdateNotFoundClient.create_called = False + + # 预置失效的 state(指向一个已删除的 agent) + (tmp_path / ".agentengine.state").write_text( + "agent_id: ar-20260623102747-3aef1bf8\n" + "api_key: ak-stale\n" + "endpoint: http://ar-20260623102747-3aef1bf8.agent-pre.kspmas.ksyun.com\n" + "framework: hermes\n" + "image: hub.kce.ksyun.com/agentengine-public/hermes-agent:stale\n" + "name: demo-hermes\n" + "region: pre-online\n" + "type: hermes\n" + "ui_path: /\n" + "ui_profile: hermes\n", + encoding="utf-8", + ) + + result = runner.invoke( + cmd_hermes.hermes, + [ + "deploy", + "--name", + "demo-hermes", + "--image", + "ghcr.io/kingsoftcloud/hermes-agent:test", + "--model-base-url", + "https://model.example.com/v1", + "--model-api-key", + "sk-demo", + "--default-model", + "glm-test", + ], + ) + + assert result.exit_code == 0, result.output + assert _FakeHermesUpdateNotFoundClient.update_called is True + assert _FakeHermesUpdateNotFoundClient.create_called is True + # state 应被清理并写入新 agent_id + state = (tmp_path / ".agentengine.state").read_text(encoding="utf-8") + assert "ar-20260623102747-3aef1bf8" not in state + assert "agent_id: ar-hermes-recreated" in state + assert "endpoint: https://recreated-hermes.example.com" in state + assert "api_key: ak-recreated" in state + # 应有回退提示 + assert "本地状态失效" in result.output + + +def test_is_agent_not_found_error_matches_structured_404(): + from ksadk.deployment.agent_access import is_agent_not_found_error + + # AgentEngineAPIError code=404 → 命中 + assert is_agent_not_found_error( + AgentEngineAPIError(404, "未找到对应的 Agent", details={"http_status": 404}) + ) is True + # details.http_status=404(code 非 int)→ 命中 + assert is_agent_not_found_error( + AgentEngineAPIError("NotFound", "x", details={"http_status": 404}) + ) is True + + +def test_is_agent_not_found_error_matches_text_fallback(): + from ksadk.deployment.agent_access import is_agent_not_found_error + + # 裸 Exception 文案含 404 + 未找到对应的 agent → 命中 + assert is_agent_not_found_error(Exception("HTTP 404: 未找到对应的 agent")) is True + # code: 404 大写 + agent not found → 命中 + assert is_agent_not_found_error(Exception("Code: 404 - agent not found")) is True + + +def test_is_agent_not_found_error_rejects_non_agent_404(): + from ksadk.deployment.agent_access import is_agent_not_found_error + + # 404 但文案不含 agent-not-found(裸 AgentEngineAPIError code=404 结构化判定仍命中, + # 因为 Action API code=404 语义就是 agent not found)—— 这是预期行为 + # 但纯文案 404 无 agent 文案 → 不命中(避免误判鉴权/路由 404) + assert is_agent_not_found_error(Exception("HTTP 404: Forbidden")) is False + assert is_agent_not_found_error(Exception("model not found")) is False + assert is_agent_not_found_error(Exception("network error")) is False + assert is_agent_not_found_error(None) is False # type: ignore[arg-type] diff --git a/tests/test_conversation_runtime.py b/tests/test_conversation_runtime.py index 71d4f8ad..f78bd54c 100644 --- a/tests/test_conversation_runtime.py +++ b/tests/test_conversation_runtime.py @@ -189,6 +189,15 @@ async def stream(self, input_data: dict): "input_token_details": {"cached": 4}, "output_token_details": {"reasoning": 5}, }, + "metadata": { + "last_usage": { + "input_tokens": 8, + "output_tokens": 13, + "total_tokens": 21, + "input_token_details": {"cached": 4}, + "output_token_details": {"reasoning": 5}, + }, + }, } @@ -2668,6 +2677,21 @@ async def test_stream_responses_conversation_turn_maps_ksadk_resume_to_runner_re await service.create_session( agent_id="demo-agent", user_id="user-1", session_id="sess-resume-stream" ) + await service.append_event( + "sess-resume-stream", + SessionEvent( + id="evt-background-status", + author="demo-agent", + event_type="run_status", + content={"status": "interrupted"}, + metadata={ + "status": "interrupted", + "run_mode": "background", + "run_trigger": "new_run", + }, + invocation_id="inv-approval", + ), + ) await service.append_event( "sess-resume-stream", SessionEvent( @@ -2708,6 +2732,9 @@ async def test_stream_responses_conversation_turn_maps_ksadk_resume_to_runner_re assert any(chunk.startswith("event: response.completed\n") for chunk in chunks) events = await service.get_events("sess-resume-stream") assert "approval_response" in [event.event_type for event in events] + run_status_events = [event for event in events if event.event_type == "run_status"] + assert run_status_events[-1].metadata["run_mode"] == "background" + assert run_status_events[-1].metadata["run_trigger"] == "approval_resume" @pytest.mark.asyncio @@ -2863,6 +2890,7 @@ async def test_stream_responses_conversation_turn_records_deferred_tools_from_to model="gpt-4o", prepare_runner=lambda current_runner, model: current_runner.prepare_for_request(model), session_service_provider=lambda: service, + run_mode="background", ) ] @@ -2875,6 +2903,10 @@ async def test_stream_responses_conversation_turn_records_deferred_tools_from_to and (event.metadata or {}).get("detail") == "deferred_tools_selected" ][-1] assert status.metadata["deferred_tool_names"] == ["read_workspace_file", "edit_workspace_file"] + assert status.metadata["run_mode"] == "background" + assert status.metadata["run_trigger"] == "new_run" + assert status.state_delta["active_run"]["run_mode"] == "background" + assert status.state_delta["active_run"]["run_trigger"] == "new_run" @pytest.mark.asyncio @@ -3402,6 +3434,9 @@ async def test_stream_responses_turn_maps_function_call_output_without_pending_a events = await service.get_events("sess-tool-output") assert "tool_result" in [event.event_type for event in events] assert "approval_response" not in [event.event_type for event in events] + run_status_events = [event for event in events if event.event_type == "run_status"] + assert run_status_events[-1].metadata["run_mode"] == "foreground" + assert run_status_events[-1].metadata["run_trigger"] == "approval_resume" @pytest.mark.asyncio @@ -3924,9 +3959,13 @@ async def test_checkpoint_resume_response_metadata_prefers_new_checkpoint(monkey assert [event.event_type for event in events if event.event_type.startswith("run_")] == [ "run_resume", "run_status", + "run_status", "run_checkpoint", "run_status", ] + # resume 现在先写 run_status(resuming) 再写 run_status(in_progress) + run_statuses = [e.content["status"] for e in events if e.event_type == "run_status"] + assert run_statuses == ["resuming", "in_progress", "completed"] @pytest.mark.asyncio @@ -4081,7 +4120,13 @@ def test_build_responses_payload_uses_real_usage_from_metadata(): "output_tokens": 13, "total_tokens": 21, "output_token_details": {"reasoning": 5}, - } + }, + "last_usage": { + "input_tokens": 8, + "output_tokens": 13, + "total_tokens": 21, + "input_token_details": {"cached": 4}, + }, }, ) @@ -4091,6 +4136,9 @@ def test_build_responses_payload_uses_real_usage_from_metadata(): "total_tokens": 21, "output_token_details": {"reasoning": 5}, } + # last_usage 透传到 metadata(供 server 取窗口占用) + assert payload["metadata"]["last_usage"]["input_tokens"] == 8 + assert payload["metadata"]["last_usage"]["input_token_details"]["cached"] == 4 @pytest.mark.asyncio @@ -4121,9 +4169,13 @@ async def test_stream_conversation_turn_preserves_final_chunk_usage(monkeypatch) "input_token_details": {"cached": 4}, "output_token_details": {"reasoning": 5}, } + # last_usage 透传到 response.completed 的 metadata(供 server 取窗口占用) + assert completed_payload["metadata"]["last_usage"]["input_tokens"] == 8 + assert completed_payload["metadata"]["last_usage"]["input_token_details"]["cached"] == 4 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"] + assert assistant_event.metadata["last_usage"]["input_tokens"] == 8 @pytest.mark.asyncio @@ -4271,7 +4323,9 @@ async def test_stream_checkpoint_resume_falls_back_to_original_run_id(monkeypatc for event in events if event.event_type == "run_status" ] - assert run_statuses == ["in_progress", "failed"] + # checkpoint resume 失败现在写 resume_failed(独立终态),而非 failed。 + # 状态序列:resuming(build_run_input 补写)→ in_progress → resume_failed(失败改写)。 + assert run_statuses == ["resuming", "in_progress", "resume_failed"] def test_build_history_from_events_prefers_latest_checkpoint_and_tail(): diff --git a/tests/test_identity_resolver.py b/tests/test_identity_resolver.py new file mode 100644 index 00000000..a67c39f8 --- /dev/null +++ b/tests/test_identity_resolver.py @@ -0,0 +1,295 @@ +"""identity resolver 单测:AK/SK 反查 + 缓存 + 内网 fallback。""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from ksadk.identity.resolver import ( + ResolvedIdentity, + _ak_fingerprint, + _extract_main_account_id_from_krn, + _find_username_by_ak, + _resolve_iam_endpoint, + _should_retry_intranet, + get_cached_identity, + get_cached_user_uuid, + invalidate_cache, + resolve_identity, +) + + +# --------------------------------------------------------------------------- +# 纯函数测试 +# --------------------------------------------------------------------------- + + +def test_ak_fingerprint_stable_and_unique(): + fp1 = _ak_fingerprint("AKLTtest123") + fp2 = _ak_fingerprint("AKLTtest123") + fp3 = _ak_fingerprint("AKLTtest456") + assert fp1 == fp2 # 稳定 + assert fp1 != fp3 # 不同 AK 不同指纹 + assert len(fp1) == 16 + + +@pytest.mark.parametrize( + "krn,expected", + [ + ("krn:ksc:iam::2000003485:user/xiayu", "2000003485"), + ("krn:ksc:iam::73398439:user/w_test", "73398439"), + ("not a krn", None), + ("", None), + (None, None), + ("krn:ksc:iam:::user/x", None), # 空主账号 ID + ], +) +def test_extract_main_account_id_from_krn(krn, expected): + assert _extract_main_account_id_from_krn(krn) == expected + + +def test_find_username_by_ak(): + keys = [ + {"AccessKey": "AKLTaaa", "UserName": "user1"}, + {"AccessKey": "AKLTbbb", "UserName": "user2"}, + ] + assert _find_username_by_ak(keys, "AKLTbbb") == "user2" + assert _find_username_by_ak(keys, "AKLTccc") is None # 不在列表(主账号 AK) + assert _find_username_by_ak([], "AKLTaaa") is None + + +def test_resolve_iam_endpoint_default(): + old = (os.environ.get("KSYUN_IAM_URL"), os.environ.get("IAM_URL")) + os.environ.pop("KSYUN_IAM_URL", None) + os.environ.pop("IAM_URL", None) + try: + assert _resolve_iam_endpoint() == ("iam.api.ksyun.com", "https") + finally: + for k, v in zip(("KSYUN_IAM_URL", "IAM_URL"), old): + if v is not None: + os.environ[k] = v + + +def test_resolve_iam_endpoint_from_env(): + old = os.environ.get("KSYUN_IAM_URL") + os.environ["KSYUN_IAM_URL"] = "http://iam.inner.api.ksyun.com" + try: + assert _resolve_iam_endpoint() == ("iam.inner.api.ksyun.com", "http") + finally: + if old is None: + os.environ.pop("KSYUN_IAM_URL", None) + else: + os.environ["KSYUN_IAM_URL"] = old + + +def test_should_retry_intranet(): + exc = Exception('{"Error":{"Code":"InnerAccountCanOnlyAccessThroughIntranet"}}') + assert _should_retry_intranet(exc) is True + assert _should_retry_intranet(Exception("other error")) is False + assert _should_retry_intranet(None) is False + + +# --------------------------------------------------------------------------- +# 缓存测试(monkeypatch 缓存路径到 tmp_path) +# --------------------------------------------------------------------------- + + +@pytest.fixture +def isolated_cache(monkeypatch, tmp_path): + """把缓存读写重定向到 tmp_path,避免污染真实 settings.json。""" + cache_file = tmp_path / "settings.json" + + def fake_load(): + if not cache_file.exists(): + return {} + return json.loads(cache_file.read_text(encoding="utf-8")) + + def fake_save(config): + cache_file.write_text(json.dumps(config, ensure_ascii=False, indent=2), encoding="utf-8") + + monkeypatch.setattr("ksadk.identity.resolver._load_identity_cache", lambda: fake_load().__getitem__("cloud").get("IDENTITY_CACHE", {}) if fake_load() else {}) + # 直接 patch _load/_save 更简单 + _cache = {} + + def load(): + return dict(_cache) + + def save(cache): + _cache.clear() + _cache.update(cache) + + monkeypatch.setattr("ksadk.identity.resolver._load_identity_cache", load) + monkeypatch.setattr("ksadk.identity.resolver._save_identity_cache", save) + return _cache + + +def test_get_cached_identity_miss(isolated_cache): + assert get_cached_identity("AKLTnone") is None + assert get_cached_user_uuid("AKLTnone") is None + + +def test_invalidate_cache(isolated_cache): + # 写入一个条目 + fp = _ak_fingerprint("AKLTtest") + isolated_cache[fp] = {"ak_fingerprint": fp, "user_uuid": "uuid-x"} + # 清空 + invalidate_cache("AKLTtest") + assert get_cached_user_uuid("AKLTtest") is None + # 全清 + isolated_cache[fp] = {"ak_fingerprint": fp, "user_uuid": "uuid-x"} + invalidate_cache(None) + assert get_cached_user_uuid("AKLTtest") is None + + +# --------------------------------------------------------------------------- +# resolve_identity 集成测试(mock IAM SDK) +# --------------------------------------------------------------------------- + + +def _mock_sdk_parts(): + """构造 mock 的 sdk_parts 元组。""" + return tuple(MagicMock() for _ in range(6)) + + +def test_resolve_identity_cache_hit_no_iam_call(isolated_cache, monkeypatch): + """缓存命中时不调 IAM。""" + fp = _ak_fingerprint("AKLTtest") + isolated_cache[fp] = { + "ak_fingerprint": fp, + "user_uuid": "uuid-cached", + "main_account_id": "2000003485", + "user_name": "cached-user", + "krn": "krn:ksc:iam::2000003485:user/cached-user", + } + called = MagicMock() + monkeypatch.setattr("ksadk.identity.resolver._import_iam_sdk", lambda: called()) + r = resolve_identity(access_key="AKLTtest", secret_key="SKtest") + assert called.call_count == 0 # 缓存命中,未调 IAM + assert r is not None + assert r.user_uuid == "uuid-cached" + assert r.main_account_id == "2000003485" + + +def test_resolve_identity_cache_miss_invokes_iam(isolated_cache, monkeypatch): + """缓存 miss 时调 IAM 两步链路并写缓存。""" + sdk_parts = _mock_sdk_parts() + IamClient = sdk_parts[0] + ListReq = sdk_parts[1] + GetReq = sdk_parts[2] + + # mock client 实例 + client = MagicMock() + IamClient.return_value = client + client.ListAllUserAccessKeys.return_value = json.dumps( + {"AccessKeyList": [{"AccessKey": "AKLTtest", "UserName": "xiayu"}]} + ) + client.GetUser.return_value = json.dumps( + {"GetUserResult": {"User": {"UserId": "uuid-new", "Krn": "krn:ksc:iam::2000003485:user/xiayu"}}} + ) + + monkeypatch.setattr("ksadk.identity.resolver._import_iam_sdk", lambda: sdk_parts) + + r = resolve_identity(access_key="AKLTtest", secret_key="SKtest") + assert r is not None + assert r.user_uuid == "uuid-new" + assert r.main_account_id == "2000003485" + assert r.user_name == "xiayu" + # 验证写缓存 + fp = _ak_fingerprint("AKLTtest") + assert fp in isolated_cache + assert isolated_cache[fp]["user_uuid"] == "uuid-new" + + +def test_resolve_identity_ak_not_in_list_returns_none(isolated_cache, monkeypatch): + """AK 不在子用户列表(主账号 AK)返回 None。""" + sdk_parts = _mock_sdk_parts() + IamClient = sdk_parts[0] + client = MagicMock() + IamClient.return_value = client + client.ListAllUserAccessKeys.return_value = json.dumps( + {"AccessKeyList": [{"AccessKey": "OTHER_AK", "UserName": "other"}]} + ) + monkeypatch.setattr("ksadk.identity.resolver._import_iam_sdk", lambda: sdk_parts) + + r = resolve_identity(access_key="AKLTtest", secret_key="SKtest") + assert r is None + client.GetUser.assert_not_called() # 没找到 AK 就不调 GetUser + + +def test_resolve_identity_network_failure_returns_none(isolated_cache, monkeypatch): + """IAM 调用异常返回 None 不抛。""" + sdk_parts = _mock_sdk_parts() + IamClient = sdk_parts[0] + IamClient.side_effect = Exception("network error") + monkeypatch.setattr("ksadk.identity.resolver._import_iam_sdk", lambda: sdk_parts) + + r = resolve_identity(access_key="AKLTtest", secret_key="SKtest") + assert r is None + + +def test_resolve_identity_intranet_fallback(isolated_cache, monkeypatch): + """公网失败(InnerAccountCanOnlyAccessThroughIntranet)时 fallback 内网。""" + sdk_parts = _mock_sdk_parts() + IamClient = sdk_parts[0] + client = MagicMock() + IamClient.return_value = client + # 第一次(公网)抛内网错误,第二次(内网)成功 + client.ListAllUserAccessKeys.side_effect = [ + Exception('{"Error":{"Code":"InnerAccountCanOnlyAccessThroughIntranet"}}'), + json.dumps({"AccessKeyList": [{"AccessKey": "AKLTtest", "UserName": "inner-user"}]}), + ] + client.GetUser.return_value = json.dumps( + {"GetUserResult": {"User": {"UserId": "uuid-inner", "Krn": "krn:ksc:iam::2000003485:user/inner-user"}}} + ) + monkeypatch.setattr("ksadk.identity.resolver._import_iam_sdk", lambda: sdk_parts) + + r = resolve_identity(access_key="AKLTtest", secret_key="SKtest") + assert r is not None + assert r.user_uuid == "uuid-inner" + # 验证调了两次(公网 + 内网) + assert client.ListAllUserAccessKeys.call_count == 2 + + +def test_resolve_identity_no_credentials_returns_none(isolated_cache): + assert resolve_identity(access_key="", secret_key="SK") is None + assert resolve_identity(access_key="AK", secret_key="") is None + + +def test_resolve_identity_force_refresh_bypasses_cache(isolated_cache, monkeypatch): + """force_refresh=True 绕过缓存重新反查。""" + fp = _ak_fingerprint("AKLTtest") + isolated_cache[fp] = {"ak_fingerprint": fp, "user_uuid": "old-uuid"} + sdk_parts = _mock_sdk_parts() + IamClient = sdk_parts[0] + client = MagicMock() + IamClient.return_value = client + client.ListAllUserAccessKeys.return_value = json.dumps( + {"AccessKeyList": [{"AccessKey": "AKLTtest", "UserName": "u"}]} + ) + client.GetUser.return_value = json.dumps( + {"GetUserResult": {"User": {"UserId": "new-uuid", "Krn": "krn:ksc:iam::2000003485:user/u"}}} + ) + monkeypatch.setattr("ksadk.identity.resolver._import_iam_sdk", lambda: sdk_parts) + + r = resolve_identity(access_key="AKLTtest", secret_key="SK", force_refresh=True) + assert r is not None + assert r.user_uuid == "new-uuid" # 用新值,不是缓存的 old-uuid + + +def test_get_cached_identity_returns_full_identity(isolated_cache): + fp = _ak_fingerprint("AKLTtest") + isolated_cache[fp] = { + "ak_fingerprint": fp, + "user_uuid": "uuid-x", + "main_account_id": "2000003485", + "user_name": "u", + "krn": "krn:ksc:iam::2000003485:user/u", + } + r = get_cached_identity("AKLTtest") + assert r is not None + assert r.user_uuid == "uuid-x" + assert r.main_account_id == "2000003485" diff --git a/tests/test_langchain_runner_session_continuity.py b/tests/test_langchain_runner_session_continuity.py index 8ef06b72..b6ac5e95 100644 --- a/tests/test_langchain_runner_session_continuity.py +++ b/tests/test_langchain_runner_session_continuity.py @@ -305,6 +305,15 @@ async def test_langchain_runner_stream_emits_final_usage_from_last_chunk(): "input_token_details": {}, "output_token_details": {"reasoning": 3}, }, + "metadata": { + "last_usage": { + "input_tokens": 11, + "output_tokens": 7, + "total_tokens": 18, + "input_token_details": {}, + "output_token_details": {"reasoning": 3}, + }, + }, }, ] diff --git a/tests/test_langgraph_runner_resume.py b/tests/test_langgraph_runner_resume.py index 9568d89d..600980bd 100644 --- a/tests/test_langgraph_runner_resume.py +++ b/tests/test_langgraph_runner_resume.py @@ -530,6 +530,15 @@ async def test_stream_emits_final_usage_from_graph_state_after_text_stream(): "input_token_details": {}, "output_token_details": {"reasoning": 5}, }, + "metadata": { + "last_usage": { + "input_tokens": 8, + "output_tokens": 13, + "total_tokens": 21, + "input_token_details": {}, + "output_token_details": {"reasoning": 5}, + }, + }, } @@ -557,6 +566,15 @@ async def test_stream_final_output_chunk_includes_usage_from_chain_end_output(): "input_token_details": {}, "output_token_details": {"reasoning": 5}, }, + "metadata": { + "last_usage": { + "input_tokens": 8, + "output_tokens": 13, + "total_tokens": 21, + "input_token_details": {}, + "output_token_details": {"reasoning": 5}, + }, + }, } diff --git a/tests/test_open_source_audit.py b/tests/test_open_source_audit.py index 45c1c784..90d8d66b 100644 --- a/tests/test_open_source_audit.py +++ b/tests/test_open_source_audit.py @@ -333,6 +333,10 @@ def test_content_audit_allows_aicp_internal_endpoints_but_blocks_other_internal_ def test_content_audit_allows_supported_internal_and_registry_paths(tmp_path): audit = _load_audit_module() + (tmp_path / "iam.py").write_text( + 'IAM_INNER = "iam.inner.api.ksyun.com"\n', + encoding="utf-8", + ) (tmp_path / "settings.py").write_text( 'KSPMAS_INTERNAL = "kspmas-internal.sdns.ksyun.com"\n', encoding="utf-8", @@ -357,7 +361,7 @@ def test_content_audit_allows_supported_internal_and_registry_paths(tmp_path): ) result = audit.audit_file_contents( - tmp_path, ["settings.py", "cmd_create.py", "builder.py", "regional.py", "other.py"] + tmp_path, ["iam.py", "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] == [ diff --git a/tests/test_postgres_session_service.py b/tests/test_postgres_session_service.py index 586052d1..b0f151a4 100644 --- a/tests/test_postgres_session_service.py +++ b/tests/test_postgres_session_service.py @@ -101,6 +101,54 @@ async def test_postgres_session_service_two_instances_share_sessions_events_and_ await service_b.aclose() +async def test_postgres_session_service_get_events_filters_by_after_seq_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 + + namespace = "pytest_after_seq" + service = PostgresSessionService(dsn=dsn, namespace=namespace) + session_id = "pytest-sess-after-seq" + + try: + await service.delete_session(session_id) + await service.create_session( + agent_id="demo-agent", + user_id="user-1", + session_id=session_id, + ) + for index in range(4): + await service.append_event( + session_id, + SessionEvent( + id=f"pytest-evt-after-{index + 1}", + author="user", + event_type="text", + content={"index": index}, + ), + ) + + all_events = await service.get_events(session_id) + assert [event.seq_id for event in all_events] == [1, 2, 3, 4] + + after2 = await service.get_events(session_id, after_seq_id=2) + assert [event.seq_id for event in after2] == [3, 4] + + after0 = await service.get_events(session_id, after_seq_id=0) + assert [event.seq_id for event in after0] == [1, 2, 3, 4] + + after_max = await service.get_events(session_id, after_seq_id=4) + assert [event.seq_id for event in after_max] == [] + + after_limit = await service.get_events(session_id, after_seq_id=2, limit=1) + assert [event.seq_id for event in after_limit] == [4] + finally: + await service.delete_session(session_id) + await service.aclose() + + async def test_postgres_session_service_namespaces_isolate_same_session_id(): dsn = os.getenv("KSADK_TEST_POSTGRES_DSN") if not dsn: diff --git a/tests/test_public_release_positioning.py b/tests/test_public_release_positioning.py index 0425339b..0265ed80 100644 --- a/tests/test_public_release_positioning.py +++ b/tests/test_public_release_positioning.py @@ -59,8 +59,8 @@ 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.8" - assert 'VERSION = "0.6.8"' in version_text + assert pyproject["project"]["version"] == "0.6.9" + assert 'VERSION = "0.6.9"' 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"] @@ -135,8 +135,8 @@ def test_public_ci_runs_gitleaks_and_documents_branch_protection(): def test_public_release_approval_template_tracks_current_version(): approval_record = _read("docs/maintainer-approval-record.md") - assert "| Python package version | 0.6.8 |" in approval_record - assert "make public-publish-check PUBLIC_PUBLISH_PHASE=pre-publish V=0.6.8" in approval_record + assert "| Python package version | 0.6.9 |" in approval_record + assert "make public-publish-check PUBLIC_PUBLISH_PHASE=pre-publish V=0.6.9" in approval_record def test_source_repository_does_not_track_generated_ksadk_web_static(): diff --git a/tests/test_runner.py b/tests/test_runner.py index 932b2090..08100e7f 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -938,13 +938,60 @@ async def _fake_ensure_session(external_session_id=None): result = await runner.invoke({"session_id": "external-session", "input": "hello"}) assert result["output"] == "ok" + # 累积后空 input_token_details 不保留(无意义),output_token_details 有值保留 assert result["usage"] == { "input_tokens": 12, "output_tokens": 5, "total_tokens": 17, - "input_token_details": {}, "output_token_details": {"reasoning": 2}, } + # last_usage = 最后一次调用快照(单 event 时 = usage 自身) + assert result["metadata"]["last_usage"]["input_tokens"] == 12 + + +@pytest.mark.asyncio +async def test_adk_runner_invoke_accumulates_usage_across_events(tmp_path, monkeypatch): + """多 event(agent loop 多次 LLM 调用)usage 累加,last_usage = 末个 event。""" + 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 + # 两次 LLM 调用(tool loop):第一次 input=4000,第二次 input=5000(含历史) + yield SimpleNamespace( + usage_metadata={"input_tokens": 4000, "output_tokens": 100, "total_tokens": 4100}, + content=SimpleNamespace(parts=[]), + ) + yield SimpleNamespace( + usage_metadata={"input_tokens": 5000, "output_tokens": 800, "total_tokens": 5800, + "input_token_details": {"cached": 4500}}, + content=SimpleNamespace(parts=[types.Part(text="final")]), + ) + + async def _fake_ensure_session(external_session_id=None): + return "adk-session-accum" + + 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"}) + + # 累积值:input/output/total 相加,details 逐键求和 + assert result["usage"]["input_tokens"] == 9000 + assert result["usage"]["output_tokens"] == 900 + assert result["usage"]["total_tokens"] == 9900 + assert result["usage"]["input_token_details"]["cached"] == 4500 + # last_usage = 最后一次调用(窗口占用 = 末次 input) + assert result["metadata"]["last_usage"]["input_tokens"] == 5000 + assert result["metadata"]["last_usage"]["input_token_details"]["cached"] == 4500 @pytest.mark.asyncio @@ -989,14 +1036,16 @@ async def _fake_ensure_session(external_session_id=None): 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}, - }, + final = chunks[-1] + assert final["output"] == "hello" + assert final["type"] == "final" + assert final["usage"] == { + "input_tokens": 12, + "output_tokens": 5, + "total_tokens": 17, + "input_token_details": {"cached": 4, "tool_use": 3}, + "output_token_details": {"reasoning": 2}, } + # last_usage = 最后一次调用快照 + assert final["metadata"]["last_usage"]["input_tokens"] == 12 + assert final["metadata"]["last_usage"]["input_token_details"]["cached"] == 4 diff --git a/tests/test_server_session_app.py b/tests/test_server_session_app.py index 98f9aaa4..1627de6b 100644 --- a/tests/test_server_session_app.py +++ b/tests/test_server_session_app.py @@ -377,7 +377,13 @@ async def test_run_sse_uses_new_session_service(monkeypatch): session = await service.get_session(session_id) assert session is not None - assert session.state == {"topic": "billing"} + # run_status 事件现在携带 state_delta.active_run(与 agentengine-server 对齐), + # completed 后 active_run 反映终态。 + assert session.state["topic"] == "billing" + assert session.state["active_run"]["status"] == "completed" + # active_run 现含 run_mode/run_trigger(普通前台 run 默认 foreground/new_run) + assert session.state["active_run"]["run_mode"] == "foreground" + assert session.state["active_run"]["run_trigger"] == "new_run" events = await service.get_events(session_id) assert [event.author for event in events] == ["user", "demo-agent", "demo-agent", "demo-agent"] @@ -2052,9 +2058,16 @@ async def test_responses_accepts_agentengine_checkpoint_resume_input(monkeypatch "run_checkpoint", "run_resume", "run_status", + "run_status", "assistant_message", "run_status", ] + # resume 现在会先写 run_status(resuming) 再写 run_status(in_progress) + assert [event.content["status"] for event in events if event.event_type == "run_status"] == [ + "resuming", + "in_progress", + "completed", + ] assert len([event for event in events if event.event_type == "run_checkpoint"]) == 1 @@ -2174,7 +2187,12 @@ def start_detached_stream_and_return_accepted(source, *, invocation_id=None, **k assert first_response.status_code == 202 assert second_response.status_code == 409 - assert second_response.json()["detail"]["code"] == "resume_already_running" + detail = second_response.json()["detail"] + assert detail["code"] == "resume_already_running" + # 409 detail 字段契约:snake_case(与 checkpoint_not_resumable 对齐) + assert isinstance(detail["session_id"], str) and detail["session_id"] + assert isinstance(detail["invocation_id"], str) and detail["invocation_id"] + assert isinstance(detail["run_id"], str) and detail["run_id"] @pytest.mark.asyncio @@ -2655,6 +2673,44 @@ async def test_runtime_local_list_session_events_returns_total_and_page(monkeypa assert [event["SeqId"] for event in data["Events"]] == [3, 4] +@pytest.mark.asyncio +async def test_runtime_local_list_session_events_filters_by_after_seq_id(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-after", + ) + for index in range(4): + await service.append_event( + "sess-events-after", + 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-after", + "AfterSeqId": 2, + }, + ) + + assert response.status_code == 200 + data = response.json()["Data"] + assert [event["SeqId"] for event in data["Events"]] == [3, 4] + assert data["Total"] == 2 + assert data["AfterSeqId"] == 2 + + @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") @@ -3499,7 +3555,7 @@ def start_detached_stream_and_return_accepted(source, *, invocation_id=None, **k for event in events if event.event_type == "run_status" ] - if statuses == ["in_progress"]: + if "in_progress" in statuses and "cancelled" not in statuses: break await asyncio.sleep(0.02) @@ -3522,7 +3578,7 @@ def start_detached_stream_and_return_accepted(source, *, invocation_id=None, **k for event in events if event.event_type == "run_status" ] - if statuses == ["in_progress", "cancelled"]: + if "cancelled" in statuses: break await asyncio.sleep(0.02) @@ -3532,7 +3588,8 @@ def start_detached_stream_and_return_accepted(source, *, invocation_id=None, **k for event in events if event.event_type == "run_status" ] - assert statuses == ["in_progress", "cancelled"] + # resume 现在先写 run_status(resuming) 再写 in_progress,cancel 后写 cancelled。 + assert statuses == ["resuming", "in_progress", "cancelled"] assert runner.cancel_requests == [invocation_id] diff --git a/tests/test_sessions_service.py b/tests/test_sessions_service.py index 6408b51f..c47100cf 100644 --- a/tests/test_sessions_service.py +++ b/tests/test_sessions_service.py @@ -144,6 +144,77 @@ async def test_local_session_service_get_events_pages_from_latest_and_returns_as assert [event.seq_id for event in without_latest] == [1, 2] +@pytest.mark.asyncio +async def test_in_memory_session_service_get_events_filters_by_after_seq_id(): + service = InMemorySessionService() + await service.create_session( + agent_id="demo-agent", + user_id="user-1", + session_id="sess-after", + ) + for index in range(4): + await service.append_event( + "sess-after", + SessionEvent( + id=f"evt-{index + 1}", + author="user", + event_type="text", + content={"index": index}, + ), + ) + + all_events = await service.get_events("sess-after") + assert [event.seq_id for event in all_events] == [1, 2, 3, 4] + + after2 = await service.get_events("sess-after", after_seq_id=2) + assert [event.seq_id for event in after2] == [3, 4] + + after0 = await service.get_events("sess-after", after_seq_id=0) + assert [event.seq_id for event in after0] == [1, 2, 3, 4] + + after_max = await service.get_events("sess-after", after_seq_id=4) + assert [event.seq_id for event in after_max] == [] + + # after_seq_id + limit: 先 seq 过滤得 [3,4],再"最新 1 条"得 [4] + after_limit = await service.get_events("sess-after", after_seq_id=2, limit=1) + assert [event.seq_id for event in after_limit] == [4] + + +@pytest.mark.asyncio +async def test_local_session_service_get_events_filters_by_after_seq_id(tmp_path): + service = LocalSessionService(db_path=tmp_path / "sessions.sqlite") + await service.create_session( + agent_id="demo-agent", + user_id="user-1", + session_id="sess-after", + ) + for index in range(4): + await service.append_event( + "sess-after", + SessionEvent( + id=f"evt-{index + 1}", + author="user", + event_type="text", + content={"index": index}, + ), + ) + + all_events = await service.get_events("sess-after") + assert [event.seq_id for event in all_events] == [1, 2, 3, 4] + + after2 = await service.get_events("sess-after", after_seq_id=2) + assert [event.seq_id for event in after2] == [3, 4] + + after0 = await service.get_events("sess-after", after_seq_id=0) + assert [event.seq_id for event in after0] == [1, 2, 3, 4] + + after_max = await service.get_events("sess-after", after_seq_id=4) + assert [event.seq_id for event in after_max] == [] + + after_limit = await service.get_events("sess-after", after_seq_id=2, limit=1) + assert [event.seq_id for event in after_limit] == [4] + + @pytest.mark.asyncio async def test_in_memory_session_service_create_session_is_idempotent_for_existing_explicit_id(): service = InMemorySessionService() diff --git a/tests/test_tracing_setup_otlp.py b/tests/test_tracing_setup_otlp.py index af85bdef..7b522e3a 100644 --- a/tests/test_tracing_setup_otlp.py +++ b/tests/test_tracing_setup_otlp.py @@ -66,8 +66,9 @@ def __init__(self, exporter): class _FakeBatchSpanProcessor: - def __init__(self, exporter): + def __init__(self, exporter, **kwargs): self.exporter = exporter + self.kwargs = kwargs class _FakeHttpOTLPSpanExporter: diff --git a/tests/test_usage_accumulator.py b/tests/test_usage_accumulator.py new file mode 100644 index 00000000..1f3f0b96 --- /dev/null +++ b/tests/test_usage_accumulator.py @@ -0,0 +1,43 @@ +"""usage_accumulator 单测:逐字段累加(input/output/total + details 子键)。""" +from __future__ import annotations + +from ksadk.runners.usage_accumulator import accumulate_usage + + +def test_accumulate_usage_sums_main_fields(): + acc = {} + acc = accumulate_usage(acc, {"input_tokens": 100, "output_tokens": 50, "total_tokens": 150}) + acc = accumulate_usage(acc, {"input_tokens": 200, "output_tokens": 80, "total_tokens": 280}) + assert acc["input_tokens"] == 300 + assert acc["output_tokens"] == 130 + assert acc["total_tokens"] == 430 + + +def test_accumulate_usage_sums_input_token_details(): + """details 键名不统一(cached/cache_read/cache_creation),逐键求和作诊断明细。""" + acc = {} + acc = accumulate_usage(acc, {"input_tokens": 100, "input_token_details": {"cached": 50}}) + acc = accumulate_usage(acc, {"input_tokens": 200, "input_token_details": {"cached": 30, "cache_read": 10}}) + assert acc["input_token_details"]["cached"] == 80 + assert acc["input_token_details"]["cache_read"] == 10 + + +def test_accumulate_usage_handles_empty_delta(): + acc = {"input_tokens": 100, "output_tokens": 50, "total_tokens": 150} + acc = accumulate_usage(acc, {}) + assert acc["input_tokens"] == 100 # 不变 + + +def test_accumulate_usage_does_not_mutate_input(): + """返回新 dict,不改 acc(避免共享状态)。""" + acc = {"input_tokens": 100} + result = accumulate_usage(acc, {"input_tokens": 200}) + assert acc["input_tokens"] == 100 # 原始未变 + assert result["input_tokens"] == 300 + + +def test_accumulate_usage_output_token_details(): + acc = {} + acc = accumulate_usage(acc, {"output_tokens": 50, "output_token_details": {"reasoning": 20}}) + acc = accumulate_usage(acc, {"output_tokens": 30, "output_token_details": {"reasoning": 10}}) + assert acc["output_token_details"]["reasoning"] == 30 diff --git a/uv.lock b/uv.lock index 4a3b51ac..8925eb05 100644 --- a/uv.lock +++ b/uv.lock @@ -2424,7 +2424,7 @@ wheels = [ [[package]] name = "ksadk" -version = "0.6.8" +version = "0.6.9" source = { editable = "." } dependencies = [ { name = "a2a-sdk" }, From 0a84a160d7ae3bb97685b15633efe8c6b7d22cb6 Mon Sep 17 00:00:00 2001 From: xiayu Date: Wed, 8 Jul 2026 01:07:33 +0800 Subject: [PATCH 2/5] feat(sessions): support before-seq runtime event paging --- ksadk/server/app.py | 9 +- ksadk/sessions/base.py | 6 +- ksadk/sessions/in_memory.py | 14 ++- ksadk/sessions/local_service.py | 77 +++++++++---- ksadk/sessions/postgres_service.py | 150 ++++++++++--------------- tests/test_postgres_session_service.py | 43 +++++++ tests/test_server_session_app.py | 39 +++++++ tests/test_sessions_service.py | 61 ++++++++++ 8 files changed, 280 insertions(+), 119 deletions(-) diff --git a/ksadk/server/app.py b/ksadk/server/app.py index 609adaa3..099fd0b4 100644 --- a/ksadk/server/app.py +++ b/ksadk/server/app.py @@ -1061,6 +1061,7 @@ class ListSessionEventsActionRequest(BaseModel): Offset: Optional[int] = Field(None, ge=0) Limit: Optional[int] = Field(None, ge=1) AfterSeqId: Optional[int] = Field(None, ge=0) + BeforeSeqId: Optional[int] = Field(None, ge=1) class ListSessionCheckpointsActionRequest(BaseModel): @@ -2062,8 +2063,13 @@ async def list_session_events_action(request: ListSessionEventsActionRequest): offset=request.Offset, limit=request.Limit, after_seq_id=request.AfterSeqId, + before_seq_id=request.BeforeSeqId, + ) + total = await service.count_events( + request.SessionId, + after_seq_id=request.AfterSeqId, + before_seq_id=request.BeforeSeqId, ) - total = await service.count_events(request.SessionId, after_seq_id=request.AfterSeqId) return _action_response( "ListSessionEvents", { @@ -2072,6 +2078,7 @@ async def list_session_events_action(request: ListSessionEventsActionRequest): "Offset": request.Offset or 0, "Limit": request.Limit if request.Limit is not None else len(events), "AfterSeqId": request.AfterSeqId, + "BeforeSeqId": request.BeforeSeqId, }, ) diff --git a/ksadk/sessions/base.py b/ksadk/sessions/base.py index c0dfa7fb..3b77af97 100644 --- a/ksadk/sessions/base.py +++ b/ksadk/sessions/base.py @@ -318,12 +318,16 @@ async def get_events( offset: Optional[int] = None, limit: Optional[int] = None, after_seq_id: Optional[int] = None, + before_seq_id: Optional[int] = None, ) -> list[SessionEvent]: raise NotImplementedError @abc.abstractmethod async def count_events( - self, session_id: str, after_seq_id: Optional[int] = None + self, + session_id: str, + after_seq_id: Optional[int] = None, + before_seq_id: Optional[int] = None, ) -> int: raise NotImplementedError diff --git a/ksadk/sessions/in_memory.py b/ksadk/sessions/in_memory.py index f96fb0b1..b2f68bc8 100644 --- a/ksadk/sessions/in_memory.py +++ b/ksadk/sessions/in_memory.py @@ -158,6 +158,7 @@ async def get_events( offset: Optional[int] = None, limit: Optional[int] = None, after_seq_id: Optional[int] = None, + before_seq_id: Optional[int] = None, ) -> list[SessionEvent]: async with self._lock: session = self._sessions.get(session_id) @@ -166,21 +167,28 @@ async def get_events( events = list(session.events) if after_seq_id is not None: events = [event for event in events if event.seq_id > after_seq_id] + if before_seq_id is not None: + events = [event for event in events if event.seq_id < before_seq_id] end = max(len(events) - (offset or 0), 0) start = 0 if limit is None else max(end - limit, 0) sliced = events[start:end] return copy.deepcopy(sliced) async def count_events( - self, session_id: str, after_seq_id: Optional[int] = None + self, + session_id: str, + after_seq_id: Optional[int] = None, + before_seq_id: Optional[int] = None, ) -> int: async with self._lock: session = self._sessions.get(session_id) if not session: return 0 - events = session.events + events = list(session.events) if after_seq_id is not None: - return sum(1 for event in events if event.seq_id > after_seq_id) + events = [event for event in events if event.seq_id > after_seq_id] + if before_seq_id is not None: + events = [event for event in events if event.seq_id < before_seq_id] return len(events) async def get_state( diff --git a/ksadk/sessions/local_service.py b/ksadk/sessions/local_service.py index 8d055571..ecd35147 100644 --- a/ksadk/sessions/local_service.py +++ b/ksadk/sessions/local_service.py @@ -135,15 +135,31 @@ async def get_events( offset: Optional[int] = None, limit: Optional[int] = None, after_seq_id: Optional[int] = None, + before_seq_id: Optional[int] = None, ) -> list[SessionEvent]: async with self._lock: - return await asyncio.to_thread(self._get_events_sync, session_id, offset, limit, after_seq_id) + return await asyncio.to_thread( + self._get_events_sync, + session_id, + offset, + limit, + after_seq_id, + before_seq_id, + ) async def count_events( - self, session_id: str, after_seq_id: Optional[int] = None + self, + session_id: str, + after_seq_id: Optional[int] = None, + before_seq_id: Optional[int] = None, ) -> int: async with self._lock: - return await asyncio.to_thread(self._count_events_sync, session_id, after_seq_id) + return await asyncio.to_thread( + self._count_events_sync, + session_id, + after_seq_id, + before_seq_id, + ) async def get_state( self, @@ -671,14 +687,23 @@ def _get_events_sync( offset: Optional[int] = None, limit: Optional[int] = None, after_seq_id: Optional[int] = None, + before_seq_id: Optional[int] = None, *, connection: Optional[sqlite3.Connection] = None, ) -> list[SessionEvent]: owns_connection = connection is None connection = connection or self._connect() try: - # after_seq_id 先过滤 seq_id > N,再对结果集应用"最新 N 条" offset/limit 语义。 - seq_clause = "AND seq_id > ?" if after_seq_id is not None else "" + # seq 过滤先应用,再对结果集应用"最新 N 条" offset/limit 语义。 + seq_clauses: list[str] = [] + seq_params: list[object] = [] + if after_seq_id is not None: + seq_clauses.append("AND seq_id > ?") + seq_params.append(after_seq_id) + if before_seq_id is not None: + seq_clauses.append("AND seq_id < ?") + seq_params.append(before_seq_id) + seq_clause = " ".join(seq_clauses) if limit is not None: query = f""" SELECT id, session_id, author, event_type, content_json, timestamp, @@ -693,9 +718,7 @@ def _get_events_sync( ) ORDER BY seq_id ASC """ - params: list[object] = [session_id] - if after_seq_id is not None: - params.append(after_seq_id) + params: list[object] = [session_id, *seq_params] params.extend([limit, offset or 0]) elif offset is not None: query = f""" @@ -711,9 +734,7 @@ def _get_events_sync( ) ORDER BY seq_id ASC """ - params = [session_id] - if after_seq_id is not None: - params.append(after_seq_id) + params = [session_id, *seq_params] params.append(offset) else: query = f""" @@ -723,9 +744,7 @@ def _get_events_sync( WHERE session_id = ? {seq_clause} ORDER BY seq_id ASC """ - params = [session_id] - if after_seq_id is not None: - params.append(after_seq_id) + params = [session_id, *seq_params] rows = connection.execute(query, params).fetchall() return [ @@ -748,19 +767,29 @@ def _get_events_sync( connection.close() def _count_events_sync( - self, session_id: str, after_seq_id: Optional[int] = None + self, + session_id: str, + after_seq_id: Optional[int] = None, + before_seq_id: Optional[int] = None, ) -> int: with self._connection() as connection: + seq_clauses: list[str] = [] + params: list[object] = [session_id] if after_seq_id is not None: - row = connection.execute( - f"SELECT COUNT(*) AS total FROM {KSADK_EVENTS_TABLE} WHERE session_id = ? AND seq_id > ?", - (session_id, after_seq_id), - ).fetchone() - else: - row = connection.execute( - f"SELECT COUNT(*) AS total FROM {KSADK_EVENTS_TABLE} WHERE session_id = ?", - (session_id,), - ).fetchone() + seq_clauses.append("AND seq_id > ?") + params.append(after_seq_id) + if before_seq_id is not None: + seq_clauses.append("AND seq_id < ?") + params.append(before_seq_id) + seq_clause = " ".join(seq_clauses) + row = connection.execute( + f""" + SELECT COUNT(*) AS total + FROM {KSADK_EVENTS_TABLE} + WHERE session_id = ? {seq_clause} + """, + params, + ).fetchone() return int(row["total"] if row else 0) def _get_state_sync( diff --git a/ksadk/sessions/postgres_service.py b/ksadk/sessions/postgres_service.py index 4dbcb323..31727dee 100644 --- a/ksadk/sessions/postgres_service.py +++ b/ksadk/sessions/postgres_service.py @@ -331,117 +331,87 @@ async def get_events( offset: Optional[int] = None, limit: Optional[int] = None, after_seq_id: Optional[int] = None, + before_seq_id: Optional[int] = None, ) -> list[SessionEvent]: await self._ensure_schema() - # after_seq_id 先过滤 seq_id > N,再对结果集应用"最新 N 条" offset/limit 语义。 - # 占位符顺序:namespace=$1, session_id=$2, [after_seq_id=$3], limit=$N, offset=$M。 - seq_clause = "AND seq_id > $3" if after_seq_id is not None else "" + # seq 过滤先应用,再对结果集应用"最新 N 条" offset/limit 语义。 + conditions = ["namespace = $1", "session_id = $2"] + params: list[Any] = [self.namespace, session_id] + if after_seq_id is not None: + params.append(after_seq_id) + conditions.append(f"seq_id > ${len(params)}") + if before_seq_id is not None: + params.append(before_seq_id) + conditions.append(f"seq_id < ${len(params)}") + where_clause = " AND ".join(conditions) async with self._pool.acquire() as connection: if limit is not None: - # $3=after_seq_id(可选), $4=limit, $5=offset —— 或 $3=limit, $4=offset(无 after_seq_id) - if after_seq_id is not None: - query = f""" - SELECT id, session_id, author, event_type, content_json, timestamp, - state_delta_json, seq_id, invocation_id, metadata_json - FROM ( - SELECT id, session_id, author, event_type, content_json, timestamp, - state_delta_json, seq_id, invocation_id, metadata_json - FROM {KSADK_PG_EVENTS_TABLE} - WHERE namespace = $1 AND session_id = $2 {seq_clause} - ORDER BY seq_id DESC - LIMIT $4 OFFSET $5 - ) AS latest_events - ORDER BY seq_id ASC - """ - params: list[Any] = [self.namespace, session_id, after_seq_id, limit, offset or 0] - else: - query = f""" - SELECT id, session_id, author, event_type, content_json, timestamp, - state_delta_json, seq_id, invocation_id, metadata_json - FROM ( - SELECT id, session_id, author, event_type, content_json, timestamp, - state_delta_json, seq_id, invocation_id, metadata_json - FROM {KSADK_PG_EVENTS_TABLE} - WHERE namespace = $1 AND session_id = $2 - ORDER BY seq_id DESC - LIMIT $3 OFFSET $4 - ) AS latest_events - ORDER BY seq_id ASC - """ - params = [self.namespace, session_id, limit, offset or 0] - elif offset is not None: - if after_seq_id is not None: - query = f""" - SELECT id, session_id, author, event_type, content_json, timestamp, - state_delta_json, seq_id, invocation_id, metadata_json - FROM ( - SELECT id, session_id, author, event_type, content_json, timestamp, - state_delta_json, seq_id, invocation_id, metadata_json - FROM {KSADK_PG_EVENTS_TABLE} - WHERE namespace = $1 AND session_id = $2 {seq_clause} - ORDER BY seq_id DESC - OFFSET $4 - ) AS latest_events - ORDER BY seq_id ASC - """ - params = [self.namespace, session_id, after_seq_id, offset] - else: - query = f""" - SELECT id, session_id, author, event_type, content_json, timestamp, - state_delta_json, seq_id, invocation_id, metadata_json - FROM ( - SELECT id, session_id, author, event_type, content_json, timestamp, - state_delta_json, seq_id, invocation_id, metadata_json - FROM {KSADK_PG_EVENTS_TABLE} - WHERE namespace = $1 AND session_id = $2 - ORDER BY seq_id DESC - OFFSET $3 - ) AS latest_events - ORDER BY seq_id ASC - """ - params = [self.namespace, session_id, offset] - else: - if after_seq_id is not None: - query = f""" + params.extend([limit, offset or 0]) + limit_param = len(params) - 1 + offset_param = len(params) + query = f""" + SELECT id, session_id, author, event_type, content_json, timestamp, + state_delta_json, seq_id, invocation_id, metadata_json + FROM ( SELECT id, session_id, author, event_type, content_json, timestamp, state_delta_json, seq_id, invocation_id, metadata_json FROM {KSADK_PG_EVENTS_TABLE} - WHERE namespace = $1 AND session_id = $2 {seq_clause} - ORDER BY seq_id ASC - """ - params = [self.namespace, session_id, after_seq_id] - else: - query = f""" + WHERE {where_clause} + ORDER BY seq_id DESC + LIMIT ${limit_param} OFFSET ${offset_param} + ) AS latest_events + ORDER BY seq_id ASC + """ + elif offset is not None: + params.append(offset) + offset_param = len(params) + query = f""" + SELECT id, session_id, author, event_type, content_json, timestamp, + state_delta_json, seq_id, invocation_id, metadata_json + FROM ( SELECT id, session_id, author, event_type, content_json, timestamp, state_delta_json, seq_id, invocation_id, metadata_json FROM {KSADK_PG_EVENTS_TABLE} - WHERE namespace = $1 AND session_id = $2 - ORDER BY seq_id ASC - """ - params = [self.namespace, session_id] + WHERE {where_clause} + ORDER BY seq_id DESC + OFFSET ${offset_param} + ) AS latest_events + ORDER BY seq_id ASC + """ + else: + query = f""" + SELECT id, session_id, author, event_type, content_json, timestamp, + state_delta_json, seq_id, invocation_id, metadata_json + FROM {KSADK_PG_EVENTS_TABLE} + WHERE {where_clause} + ORDER BY seq_id ASC + """ rows = await connection.fetch(query, *params) return [self._event_from_row(row) for row in rows] async def count_events( - self, session_id: str, after_seq_id: Optional[int] = None + self, + session_id: str, + after_seq_id: Optional[int] = None, + before_seq_id: Optional[int] = None, ) -> int: await self._ensure_schema() + conditions = ["namespace = $1", "session_id = $2"] + params: list[Any] = [self.namespace, session_id] + if after_seq_id is not None: + params.append(after_seq_id) + conditions.append(f"seq_id > ${len(params)}") + if before_seq_id is not None: + params.append(before_seq_id) + conditions.append(f"seq_id < ${len(params)}") + where_clause = " AND ".join(conditions) async with self._pool.acquire() as connection: - if after_seq_id is not None: - query = f""" - SELECT COUNT(*) AS total - FROM {KSADK_PG_EVENTS_TABLE} - WHERE namespace = $1 AND session_id = $2 AND seq_id > $3 - """ - return int( - await connection.fetchval(query, self.namespace, session_id, after_seq_id) or 0 - ) query = f""" SELECT COUNT(*) AS total FROM {KSADK_PG_EVENTS_TABLE} - WHERE namespace = $1 AND session_id = $2 + WHERE {where_clause} """ - return int(await connection.fetchval(query, self.namespace, session_id) or 0) + return int(await connection.fetchval(query, *params) or 0) async def get_state( self, diff --git a/tests/test_postgres_session_service.py b/tests/test_postgres_session_service.py index b0f151a4..d376136e 100644 --- a/tests/test_postgres_session_service.py +++ b/tests/test_postgres_session_service.py @@ -149,6 +149,49 @@ async def test_postgres_session_service_get_events_filters_by_after_seq_id(): await service.aclose() +async def test_postgres_session_service_get_events_filters_by_before_seq_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 + + namespace = "pytest_before_seq" + service = PostgresSessionService(dsn=dsn, namespace=namespace) + session_id = "pytest-sess-before-seq" + + try: + await service.delete_session(session_id) + await service.create_session( + agent_id="demo-agent", + user_id="user-1", + session_id=session_id, + ) + for index in range(5): + await service.append_event( + session_id, + SessionEvent( + id=f"pytest-evt-before-{index + 1}", + author="user", + event_type="text", + content={"index": index}, + ), + ) + + before4 = await service.get_events(session_id, before_seq_id=4) + assert [event.seq_id for event in before4] == [1, 2, 3] + assert await service.count_events(session_id, before_seq_id=4) == 3 + + before4_limit = await service.get_events(session_id, before_seq_id=4, limit=2) + assert [event.seq_id for event in before4_limit] == [2, 3] + + before1 = await service.get_events(session_id, before_seq_id=1) + assert before1 == [] + finally: + await service.delete_session(session_id) + await service.aclose() + + async def test_postgres_session_service_namespaces_isolate_same_session_id(): dsn = os.getenv("KSADK_TEST_POSTGRES_DSN") if not dsn: diff --git a/tests/test_server_session_app.py b/tests/test_server_session_app.py index 1627de6b..ceb2a77b 100644 --- a/tests/test_server_session_app.py +++ b/tests/test_server_session_app.py @@ -2711,6 +2711,45 @@ async def test_runtime_local_list_session_events_filters_by_after_seq_id(monkeyp assert data["AfterSeqId"] == 2 +@pytest.mark.asyncio +async def test_runtime_local_list_session_events_filters_by_before_seq_id(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-before", + ) + for index in range(5): + await service.append_event( + "sess-events-before", + 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-before", + "BeforeSeqId": 4, + "Limit": 2, + }, + ) + + assert response.status_code == 200 + data = response.json()["Data"] + assert [event["SeqId"] for event in data["Events"]] == [2, 3] + assert data["Total"] == 3 + assert data["BeforeSeqId"] == 4 + + @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") diff --git a/tests/test_sessions_service.py b/tests/test_sessions_service.py index c47100cf..1dc29351 100644 --- a/tests/test_sessions_service.py +++ b/tests/test_sessions_service.py @@ -180,6 +180,37 @@ async def test_in_memory_session_service_get_events_filters_by_after_seq_id(): assert [event.seq_id for event in after_limit] == [4] +@pytest.mark.asyncio +async def test_in_memory_session_service_get_events_filters_by_before_seq_id(): + service = InMemorySessionService() + await service.create_session( + agent_id="demo-agent", + user_id="user-1", + session_id="sess-before", + ) + for index in range(5): + await service.append_event( + "sess-before", + SessionEvent( + id=f"evt-{index + 1}", + author="user", + event_type="text", + content={"index": index}, + ), + ) + + before4 = await service.get_events("sess-before", before_seq_id=4) + assert [event.seq_id for event in before4] == [1, 2, 3] + assert await service.count_events("sess-before", before_seq_id=4) == 3 + + # before_seq_id + limit keeps the latest N events from the older-history window. + before4_limit = await service.get_events("sess-before", before_seq_id=4, limit=2) + assert [event.seq_id for event in before4_limit] == [2, 3] + + before1 = await service.get_events("sess-before", before_seq_id=1) + assert before1 == [] + + @pytest.mark.asyncio async def test_local_session_service_get_events_filters_by_after_seq_id(tmp_path): service = LocalSessionService(db_path=tmp_path / "sessions.sqlite") @@ -215,6 +246,36 @@ async def test_local_session_service_get_events_filters_by_after_seq_id(tmp_path assert [event.seq_id for event in after_limit] == [4] +@pytest.mark.asyncio +async def test_local_session_service_get_events_filters_by_before_seq_id(tmp_path): + service = LocalSessionService(db_path=tmp_path / "sessions.sqlite") + await service.create_session( + agent_id="demo-agent", + user_id="user-1", + session_id="sess-before", + ) + for index in range(5): + await service.append_event( + "sess-before", + SessionEvent( + id=f"evt-{index + 1}", + author="user", + event_type="text", + content={"index": index}, + ), + ) + + before4 = await service.get_events("sess-before", before_seq_id=4) + assert [event.seq_id for event in before4] == [1, 2, 3] + assert await service.count_events("sess-before", before_seq_id=4) == 3 + + before4_limit = await service.get_events("sess-before", before_seq_id=4, limit=2) + assert [event.seq_id for event in before4_limit] == [2, 3] + + before1 = await service.get_events("sess-before", before_seq_id=1) + assert before1 == [] + + @pytest.mark.asyncio async def test_in_memory_session_service_create_session_is_idempotent_for_existing_explicit_id(): service = InMemorySessionService() From 244753aea3a510d295642e634152cd615703e40f Mon Sep 17 00:00:00 2001 From: xiayu Date: Wed, 8 Jul 2026 11:56:12 +0800 Subject: [PATCH 3/5] chore(release): prepare ksadk 0.6.9 public candidate --- .github/pull_request_template.md | 2 +- .github/workflows/ci.yml | 4 +- .github/workflows/publish-pypi.yml | 8 +- .gitleaks.toml | 45 - AGENTS.md | 7 +- CHANGELOG.md | 14 + CONTRIBUTING.md | 10 +- Makefile | 151 +- README.en.md | 18 +- README.md | 10 +- README.zh-CN.md | 18 +- .../framework/guides/build-and-package.en.mdx | 4 +- .../framework/guides/build-and-package.mdx | 4 +- .../docs/references/contributing/index.en.mdx | 13 +- .../docs/references/contributing/index.mdx | 13 +- .../references/contributing/release.en.mdx | 5 +- .../docs/references/contributing/release.mdx | 5 +- .../references/contributing/testing.en.mdx | 13 +- .../docs/references/contributing/testing.mdx | 4 +- .../references/environment-variables.en.mdx | 16 + .../docs/references/environment-variables.mdx | 15 + .../references/openai-compatible-api.en.mdx | 26 +- .../docs/references/openai-compatible-api.mdx | 24 +- .../docs/references/remote-runtime-api.en.mdx | 126 +- .../docs/references/remote-runtime-api.mdx | 124 +- .../references/security-boundaries.en.mdx | 2 +- .../docs/references/troubleshooting.en.mdx | 6 +- docs-site/next.config.mjs | 2 +- docs-site/scripts/build-static.mjs | 2 +- docs-site/source.config.ts | 2 +- ...45\345\205\245\346\214\207\345\215\227.md" | 567 -- .../DeepAgents\350\257\264\346\230\216.md" | 54 - ...00\344\275\263\345\256\236\350\267\265.md" | 682 --- ...77\347\224\250\346\226\207\346\241\243.md" | 1036 ---- ...60\345\277\206\347\244\272\344\276\213.md" | 114 - ...77\347\224\250\346\214\207\345\215\227.md" | 227 - ...30\351\207\217\345\217\202\350\200\203.md" | 619 --- docs/maintainer-approval-record.md | 16 +- ...00\346\234\257\350\256\276\350\256\241.md" | 524 -- ...30\351\207\217\345\217\202\350\200\203.md" | 4 +- ...45\345\217\243\350\257\264\346\230\216.md" | 2168 -------- ...45\345\217\243\350\257\264\346\230\216.md" | 2061 ------- export-manifest.json | 619 +-- ksadk/configs/env_registry.py | 12 +- ksadk/identity/resolver.py | 25 +- .../memory_backend_manifest.schema.json | 17 +- scripts/check_publication_state.py | 29 + scripts/ci-frontend-check.sh | 24 - scripts/debug_aicp_memory.py | 353 -- scripts/generate_public_assets.py | 2 +- scripts/open_source_audit.py | 8 +- scripts/prepare_ksadk_python_export.py | 49 +- 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_loader_and_tools.py | 190 - tests/skills/test_package_store.py | 73 - tests/skills/test_runtime.py | 332 -- tests/skills/test_runtime_agent.py | 744 --- tests/skills/test_service_client_http.py | 396 -- tests/skills/test_skill_service_client.py | 90 - tests/skills/test_web_artifacts_fixture.py | 31 - tests/snapshots/error_hint_snapshots.txt | 44 - tests/snapshots/help_snapshots.txt | 350 -- tests/snapshots/resource_output_snapshots.txt | 73 - tests/snapshots/workflow_help_snapshots.txt | 146 - 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 | 431 -- tests/test_aicp_env.py | 83 - tests/test_attachment_pipeline.py | 106 - tests/test_attachment_storage.py | 169 - tests/test_background_run.py | 468 -- tests/test_builder_requirements_merge.py | 443 -- tests/test_builder_runtime_requirements.py | 21 - tests/test_check_publication_state.py | 31 + tests/test_cli_dry_run.py | 2634 --------- 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 | 460 -- tests/test_client_get_agent_name.py | 84 - tests/test_client_http_error_logging.py | 59 - tests/test_client_mcp_payloads.py | 221 - tests/test_client_permission_precheck.py | 434 -- tests/test_client_user_uuid_header.py | 179 - tests/test_client_workspace_files.py | 473 -- tests/test_cmd_build_upload_urls.py | 103 - tests/test_cmd_completion.py | 117 - tests/test_cmd_config_wizard.py | 108 - tests/test_cmd_create_from_agent.py | 507 -- tests/test_cmd_dashboard_fallback.py | 718 --- tests/test_cmd_deploy_no_cache.py | 521 -- tests/test_cmd_files.py | 942 ---- tests/test_cmd_hermes.py | 1606 ------ 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 | 127 - tests/test_compaction_pipeline.py | 309 -- tests/test_config_root_visibility.py | 51 - tests/test_container_registry_credentials.py | 136 - tests/test_conversation_runtime.py | 4847 ----------------- tests/test_deepagents_integration.py | 249 - tests/test_deepagents_runner_skill_runtime.py | 23 - tests/test_deploy_integration.py | 1082 ---- tests/test_error_utils_hints.py | 143 - tests/test_help_snapshots.py | 144 - tests/test_hermes_container_builder.py | 133 - tests/test_hermes_terminal.py | 510 -- tests/test_hermes_terminal_e2e.py | 157 - tests/test_identity_resolver.py | 295 - tests/test_json_contracts.py | 729 --- tests/test_ks3_uploader_urls.py | 261 - ...est_langchain_runner_session_continuity.py | 342 -- tests/test_langfuse_exporter.py | 115 - tests/test_langfuse_runner_utils.py | 49 - tests/test_langgraph_runner_resume.py | 1172 ---- tests/test_langgraph_runner_skill_runtime.py | 24 - tests/test_local_runtime_reexec.py | 199 - tests/test_long_task_pilot_validation.py | 240 - tests/test_mcp_runtime.py | 436 -- tests/test_model_policy.py | 59 - tests/test_open_source_audit.py | 21 +- tests/test_openai_protocol_e2e.py | 790 --- tests/test_openclaw_env_vars.py | 509 -- tests/test_openclaw_gateway.py | 127 - tests/test_orchestration_agents.py | 327 -- tests/test_patch_langchain.py | 93 - tests/test_platform_memory_tools.py | 142 - tests/test_postgres_session_service.py | 224 - tests/test_public_release_positioning.py | 94 +- tests/test_remote_runner.py | 643 --- tests/test_resource_output_snapshots.py | 231 - tests/test_runner.py | 1051 ---- tests/test_runner_langfuse_callbacks.py | 114 - tests/test_runtime_common_memory_backend.py | 178 - tests/test_sandbox_backend.py | 703 --- tests/test_semantic_circuit_breaker.py | 139 - tests/test_server_app_fastapi_compat.py | 40 - tests/test_server_file_upload_parsing.py | 129 - tests/test_server_session_app.py | 4338 --------------- tests/test_server_terminal_sessions.py | 281 - .../test_server_workspace_preview_security.py | 66 - tests/test_session_continuity.py | 178 - tests/test_session_title.py | 78 - tests/test_sessions_service.py | 508 -- tests/test_setup_environment.py | 113 - tests/test_stm_config.py | 223 - tests/test_storage_defaults.py | 65 - tests/test_tool_gateway.py | 119 - tests/test_tool_result_budget.py | 92 - tests/test_tracing_cloud_monitor_e2e.py | 116 - tests/test_tui_app.py | 11 - tests/test_tui_clipboard.py | 17 - tests/test_ui_config_resolution.py | 119 - tests/test_unified_agent_ui_local.py | 2254 -------- tests/test_usage_accumulator.py | 43 - tests/test_validate_hosted_long_task_e2e.py | 39 - tests/test_web_toolset.py | 264 - 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 | 1169 ---- 179 files changed, 789 insertions(+), 57394 deletions(-) delete mode 100644 .gitleaks.toml delete mode 100644 "docs/guides/Agent \345\274\200\345\217\221\350\200\205\344\270\212\344\270\213\346\226\207\346\216\245\345\205\245\346\214\207\345\215\227.md" delete mode 100644 "docs/guides/DeepAgents\350\257\264\346\230\216.md" delete mode 100644 "docs/guides/LangGraph\345\274\200\345\217\221\346\234\200\344\275\263\345\256\236\350\267\265.md" delete mode 100644 "docs/guides/ksadk\344\275\277\347\224\250\346\226\207\346\241\243.md" delete mode 100644 "docs/guides/\347\237\245\350\257\206\345\272\223\344\270\216\350\256\260\345\277\206\347\244\272\344\276\213.md" delete mode 100644 "docs/guides/\350\256\260\345\277\206\344\275\277\347\224\250\346\214\207\345\215\227.md" delete mode 100644 "docs/ksadk\347\216\257\345\242\203\345\217\230\351\207\217\345\217\202\350\200\203.md" delete mode 100644 "docs/reference/ksadk\346\212\200\346\234\257\350\256\276\350\256\241.md" delete mode 100644 "docs/reference/\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" delete 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" delete mode 100755 scripts/ci-frontend-check.sh delete mode 100644 scripts/debug_aicp_memory.py delete mode 100644 scripts/prepare_zread_source_snapshot.py delete mode 100644 scripts/test_ks3_upload.py delete mode 100644 scripts/validate_checkpoint_resume_e2e.py delete mode 100644 scripts/validate_hosted_long_task_e2e.py delete mode 100644 scripts/validate_long_task_pilot.py delete mode 100644 scripts/zread_subpath_proxy.py delete mode 100644 tests/long_task/__init__.py delete mode 100644 tests/long_task/test_checkpoint_resume.py delete mode 100644 tests/long_task/test_runtime_cancel.py delete mode 100644 tests/long_task/test_tool_idempotency.py delete mode 100644 tests/skills/__init__.py delete mode 100644 tests/skills/test_adk_runner_skill_runtime.py delete mode 100644 tests/skills/test_loader_and_tools.py delete mode 100644 tests/skills/test_package_store.py delete mode 100644 tests/skills/test_runtime.py delete mode 100644 tests/skills/test_runtime_agent.py delete mode 100644 tests/skills/test_service_client_http.py delete mode 100644 tests/skills/test_skill_service_client.py delete mode 100644 tests/skills/test_web_artifacts_fixture.py delete mode 100644 tests/snapshots/error_hint_snapshots.txt delete mode 100644 tests/snapshots/help_snapshots.txt delete mode 100644 tests/snapshots/resource_output_snapshots.txt delete mode 100644 tests/snapshots/workflow_help_snapshots.txt delete mode 100644 tests/test_a2a_cli.py delete mode 100644 tests/test_a2a_integration.py delete mode 100644 tests/test_agent.py delete mode 100644 tests/test_agent_access.py delete mode 100644 tests/test_agentengine_toolsets.py delete mode 100644 tests/test_aicp_env.py delete mode 100644 tests/test_attachment_pipeline.py delete mode 100644 tests/test_attachment_storage.py delete mode 100644 tests/test_background_run.py delete mode 100644 tests/test_builder_requirements_merge.py delete mode 100644 tests/test_builder_runtime_requirements.py delete mode 100644 tests/test_cli_dry_run.py delete mode 100644 tests/test_cli_global_options.py delete mode 100644 tests/test_cli_platform_refactor.py delete mode 100644 tests/test_cli_root_entrypoint.py delete mode 100644 tests/test_client_framework_passthrough.py delete mode 100644 tests/test_client_get_agent_name.py delete mode 100644 tests/test_client_http_error_logging.py delete mode 100644 tests/test_client_mcp_payloads.py delete mode 100644 tests/test_client_permission_precheck.py delete mode 100644 tests/test_client_user_uuid_header.py delete mode 100644 tests/test_client_workspace_files.py delete mode 100644 tests/test_cmd_build_upload_urls.py delete mode 100644 tests/test_cmd_completion.py delete mode 100644 tests/test_cmd_config_wizard.py delete mode 100644 tests/test_cmd_create_from_agent.py delete mode 100644 tests/test_cmd_dashboard_fallback.py delete mode 100644 tests/test_cmd_deploy_no_cache.py delete mode 100644 tests/test_cmd_files.py delete mode 100644 tests/test_cmd_hermes.py delete mode 100644 tests/test_cmd_invoke.py delete mode 100644 tests/test_cmd_launch_no_cache.py delete mode 100644 tests/test_cmd_mcp_no_cache.py delete mode 100644 tests/test_cmd_model.py delete mode 100644 tests/test_code_builder_binary_compat.py delete mode 100644 tests/test_code_builder_pip_indexes.py delete mode 100644 tests/test_code_builder_rebuild_fingerprint.py delete mode 100644 tests/test_code_builder_static_assets.py delete mode 100644 tests/test_compaction_pipeline.py delete mode 100644 tests/test_config_root_visibility.py delete mode 100644 tests/test_container_registry_credentials.py delete mode 100644 tests/test_conversation_runtime.py delete mode 100644 tests/test_deepagents_integration.py delete mode 100644 tests/test_deepagents_runner_skill_runtime.py delete mode 100644 tests/test_deploy_integration.py delete mode 100644 tests/test_error_utils_hints.py delete mode 100644 tests/test_help_snapshots.py delete mode 100644 tests/test_hermes_container_builder.py delete mode 100644 tests/test_hermes_terminal.py delete mode 100644 tests/test_hermes_terminal_e2e.py delete mode 100644 tests/test_identity_resolver.py delete mode 100644 tests/test_json_contracts.py delete mode 100644 tests/test_ks3_uploader_urls.py delete mode 100644 tests/test_langchain_runner_session_continuity.py delete mode 100644 tests/test_langfuse_exporter.py delete mode 100644 tests/test_langfuse_runner_utils.py delete mode 100644 tests/test_langgraph_runner_resume.py delete mode 100644 tests/test_langgraph_runner_skill_runtime.py delete mode 100644 tests/test_local_runtime_reexec.py delete mode 100644 tests/test_long_task_pilot_validation.py delete mode 100644 tests/test_mcp_runtime.py delete mode 100644 tests/test_model_policy.py delete mode 100644 tests/test_openai_protocol_e2e.py delete mode 100644 tests/test_openclaw_env_vars.py delete mode 100644 tests/test_openclaw_gateway.py delete mode 100644 tests/test_orchestration_agents.py delete mode 100644 tests/test_patch_langchain.py delete mode 100644 tests/test_platform_memory_tools.py delete mode 100644 tests/test_postgres_session_service.py delete mode 100644 tests/test_remote_runner.py delete mode 100644 tests/test_resource_output_snapshots.py delete mode 100644 tests/test_runner.py delete mode 100644 tests/test_runner_langfuse_callbacks.py delete mode 100644 tests/test_runtime_common_memory_backend.py delete mode 100644 tests/test_sandbox_backend.py delete mode 100644 tests/test_semantic_circuit_breaker.py delete mode 100644 tests/test_server_app_fastapi_compat.py delete mode 100644 tests/test_server_file_upload_parsing.py delete mode 100644 tests/test_server_session_app.py delete mode 100644 tests/test_server_terminal_sessions.py delete mode 100644 tests/test_server_workspace_preview_security.py delete mode 100644 tests/test_session_continuity.py delete mode 100644 tests/test_session_title.py delete mode 100644 tests/test_sessions_service.py delete mode 100644 tests/test_setup_environment.py delete mode 100644 tests/test_stm_config.py delete mode 100644 tests/test_storage_defaults.py delete mode 100644 tests/test_tool_gateway.py delete mode 100644 tests/test_tool_result_budget.py delete mode 100644 tests/test_tracing_cloud_monitor_e2e.py delete mode 100644 tests/test_tui_app.py delete mode 100644 tests/test_tui_clipboard.py delete mode 100644 tests/test_ui_config_resolution.py delete mode 100644 tests/test_unified_agent_ui_local.py delete mode 100644 tests/test_usage_accumulator.py delete mode 100644 tests/test_validate_hosted_long_task_e2e.py delete mode 100644 tests/test_web_toolset.py delete mode 100644 tests/test_workflow_common.py delete mode 100644 tests/test_workflow_help_snapshots.py delete mode 100644 tests/unit/knowledge_base/test_client_env.py delete mode 100644 tests/unit/memory/test_adk_memory_comprehensive.py diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 837761a5..a55a6a92 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -6,7 +6,7 @@ - [ ] `uv run --extra dev pytest -q` - [ ] `make public-audit` -- [ ] `make public-docs-build` +- [ ] `make docs-site-build` - [ ] `make open-source-audit-dist` if package artifacts changed. - [ ] `uv build` - [ ] `uv run --extra dev python -m twine check dist/*` diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e12216e1..21c1488e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,7 @@ jobs: test: runs-on: ubuntu-latest env: - KSADK_WEB_VERSION: "0.2.16" + KSADK_WEB_VERSION: "0.2.18" steps: - uses: actions/checkout@v4 @@ -63,7 +63,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-build + run: make docs-site-build - name: Check package metadata run: uv run --extra dev python -m twine check dist/* diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index bc2464d8..f9137159 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -9,7 +9,10 @@ on: ksadk_web_version: description: KsADK Web npm version to bundle required: false - default: "0.2.16" + default: "0.2.18" + approved_source_commit: + description: Reviewed source commit SHA recorded in docs/maintainer-approval-record.md + required: false permissions: contents: read @@ -26,7 +29,8 @@ jobs: environment: name: pypi env: - KSADK_WEB_VERSION: ${{ github.event.inputs.ksadk_web_version || '0.2.16' }} + KSADK_WEB_VERSION: ${{ github.event.inputs.ksadk_web_version || '0.2.18' }} + KSADK_APPROVED_SOURCE_COMMIT: ${{ github.event.inputs.approved_source_commit || vars.KSADK_APPROVED_SOURCE_COMMIT }} permissions: contents: read id-token: write diff --git a/.gitleaks.toml b/.gitleaks.toml deleted file mode 100644 index e77adb8b..00000000 --- a/.gitleaks.toml +++ /dev/null @@ -1,45 +0,0 @@ -# .gitleaks.toml — ksadk-python -# 继承 gitleaks 默认规则 (useDefault = true), 仅追加项目级 allowlist。 -# workflow 用法 (见 .github/workflows/secret-patterns.yml): -# gitleaks detect --source . --config .gitleaks.toml --no-banner --redact --verbose -# detect 子命令 + fetch-depth:0 扫全 history; allowlist 同时覆盖历史与当前 tree。 - -title = "ksadk-python gitleaks config" - -[extend] -useDefault = true - -# ---- allowlist 统一用 [[allowlists]] 新语法 (gitleaks 8.21+), 不能与 [allowlist] 旧语法混用 ---- - -# 测试 fixture 假 key (命中当前 tree 与历史 commit) -[[allowlists]] -description = "Allowlist test-fixture placeholders in hermes CLI tests" -regexes = [ - # tests/test_cmd_hermes.py 的假 Bearer token (curl-auth-header 规则命中) - '''sk-live-secret''', - # tests/test_cmd_hermes.py 的假 secret value (generic-api-key 兜底) - '''sk-test-secret''', -] -paths = [ - # 精确限定到测试文件, 避免误 allowlist 其它路径的真 key - '''tests/test_cmd_hermes\.py''', -] - -# ---- 历史已删文档中的 OPENAI_API_KEY (疑似真 kspmas key) ---- -# !!! 安全前置条件 !!! -# 启用本 allowlist 之前, 必须先在金山云 kspmas 控制台 rotate (吊销并重发) 该 key, -# 确认 4fd210b0-eee5-4c64-a23c-dc7fb3f86717 已失效。rotate 之前不要启用—— -# allowlist 只让 CI 不报, 不消除 "真 key 已暴露在 public history" 这个事实。 -# -# 已 rotate 后: 历史 commit 里的该 UUID 已是废值, allowlist 让 CI 对此噪声免疫, -# 同时保留 git scan 抓未来真新增泄漏的能力 (优于改 --no-git 的掩耳盗铃方案)。 -# 已删文档的历史 commit 路径: gitleaks 历史扫描时仍按该 commit 当时的路径报出, -# 故 allowlist 的 paths 规则能命中历史版本, 无需特殊语法。 -[[allowlists]] -description = "Historical rotated OPENAI_API_KEY in deleted hermes doc (safe only AFTER rotation)" -regexes = [ - '''4fd210b0-eee5-4c64-a23c-dc7fb3f86717''', -] -paths = [ - '''docs/hermes-agent-v2026\.4\.13_本地安装配置与ksadk接入流程\.md''', -] diff --git a/AGENTS.md b/AGENTS.md index 4af9da38..4e37ca04 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,13 +83,14 @@ - 未经用户明确批准,不得改 `pyproject.toml` / `ksadk/version.py` 版本号,不得新增或改写 CHANGELOG 发版条目。 - 用户批准发布后,正式 PyPI 发布优先走 GitHub Release / `workflow_dispatch` 触发的 Trusted Publishing;本地 `make publish` / `make publish-test` 仅作为明确批准的应急路径,不绕过 Makefile 手写上传命令。 - 不得在同一轮协作中擅自连续发布多个版本承载中间修复。 -- `master` 是内部开发主干;GitHub `main` 是公开主干。不得直接 `merge master -> main`,公开同步必须走 `release/public-x.y.z` 或等价候选分支。 -- 公开候选必须先推内部 ezone 审核,再推 GitHub、发 GitHub Release、上传 PyPI 或发布 Pages。 +- `master` 是内部开发主干;GitHub `main` 是公开主干。不得直接 `merge master -> main`,公开同步必须走 clean export candidate、GitHub PR 或等价的受审核公开候选流程。 +- GitHub 侧不得存在可写的 `master` 公开分支,也不得把内部 `master` 直接 push 到 GitHub;如果误推到了 `github/master`,第一时间删除远端分支并清理本地跟踪引用,再重新走公开候选流程。 +- 公开候选必须先通过 `make public-preflight` 和 review,再合入 GitHub `main`;npm、PyPI、GitHub Pages 都必须由可信 GitHub workflow 发布,不走本地 publish/upload。 - 公开发布前必须运行 `make public-preflight`。如果只做发布状态核对,运行 `make public-publish-check`。失败时不得发布。 - 每次公开 GitHub Release 对应的公开提交都必须打 tag 留痕,优先使用 `make public-release-tag V=x.y.z`。 - 公开分支长期工作树可以保留,但只能作为公开同步/发布工作区,不做日常内部开发。 - 不得把 `.pypirc`、私有 registry 凭证、kubeconfig、真实 API Key 或临时 token 放入仓库根目录;正式 PyPI 发布默认使用 Trusted Publishing,只有应急本地发布才允许 PyPI 凭证来自 `~/.pypirc`、环境变量或 CI Secret。 -- 完整公开同步流程见 `docs/release/public-release-workflow.md`;该文档优先于口头约定。 +- 完整公开同步流程见 `docs/public-release-workflow.md`;该文档优先于口头约定。 发布前必须检查: diff --git a/CHANGELOG.md b/CHANGELOG.md index 70cd4501..6d0fd8e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ - **run 状态双维度字段**:新增 `run_mode`(background/foreground/unknown)和 `run_trigger`(new_run/checkpoint_resume/approval_resume/unknown)两个独立维度字段,区分"怎么跑"和"怎么开始",替代单字段 `run_kind` 的语义错误。后台长任务从 checkpoint 恢复时不再丢失"这是后台任务"的信息,前端可直接消费 `ActiveRunMode` / `ActiveRunTrigger` 判断长任务会话,无需从事件流推断。 - **checkpoint 可恢复性聚合字段**:`ListSessionCheckpoints` 响应新增 `ResumableTotal` / `HasResumableCheckpoint`,解决 `Total > 0` 不能代表"可恢复"的误判(终态/过期/memory_local checkpoint 会让 Total 非空但不可恢复)。恢复按钮可用性应看 `HasResumableCheckpoint`。 - **state_delta.active_run 对齐**:ksadk 与 agentengine-server 现都把 `run_mode` / `run_trigger` 写入 `state_delta.active_run`,Session 对象的 `ActiveRunMode` / `ActiveRunTrigger` 由 state 重建,刷新/分享链接/切 session 都能恢复一致状态。 +- **会话事件续订与历史分页收敛**:runtime `ListSessionEvents` / `SubscribeRunEvents` 支持 `AfterSeqId` 增量续订,session backends 支持 `BeforeSeqId` 向前翻页,控制台可从最新窗口进入历史并稳定重连,不再依赖全量扫事件。 +- **真实 token usage 契约补齐**:ADK、LangChain、LangGraph runner 在单轮内累计多次 LLM 调用 usage,同时保留 `last_usage`,让服务端可以同时得到会话累计 token 消耗和最后一轮上下文窗口占用,避免用累计值误当窗口占用。 ### 新增 @@ -24,12 +26,24 @@ - 新增 `_latest_session_run_metadata` helper(不改原 `_latest_session_run_status`,保护现有契约),`_session_to_action_payload` 顶层新增 `ActiveRunMode` / `ActiveRunTrigger`。 - `_list_checkpoints_payload` 新增 `ResumableTotal` / `HasResumableCheckpoint` 聚合字段。规则:`IsResumable===true && ReplayAllowed!==false && IsTerminal!==true && CheckpointStatus not in {expired, disabled}`,不排除 `resumed`(已恢复过的仍计入,符合存档点可反复读)。 - agentengine-server 侧新建 `app/services/run_kinds.py`(独立维护,不 import ksadk,用测试约束一致性),`_append_run_status` 与 `_serialize_session` 同步写入/读取新字段。 +- 新增 `ksadk/runners/usage_accumulator.py`,统一归一化并累加 OpenAI/ADK/LangChain/LangGraph usage 字段,覆盖 `input_tokens`、`output_tokens`、`total_tokens` 及 token details。 +- runtime `ListSessionEvents` 新增 `AfterSeqId` / `BeforeSeqId` 过滤能力;`SubscribeRunEvents` 支持 `AfterSeqId`,用于断线后只推送已读序号之后的新事件。 +- `SessionService.get_events()` 在 in-memory、local SQLite、Postgres 后端补齐 `after_seq_id` / `before_seq_id` 过滤,保持最新窗口、向后增量、向前翻页三类语义一致。 ### 变更 - `run_status` 事件 `metadata` 与 `state_delta.active_run` 扩展为含 `run_mode` / `run_trigger`;旧 session 缺字段降级 `unknown`,不破坏现有 `ActiveInvocationId` / `ActiveRunStatus` 契约。 - approval 续跑的 `run_mode` 跟随原 run(不写死 foreground),需从原 run 上下文透传。 - server 侧 `run_status` 事件 `content` 仍为 `{status, detail}`,`run_mode` / `run_trigger` 只写进 `state_delta`,避免破坏现有消费方。 +- runner 返回 `metadata.usage` 继续表示单次响应的累计真实消耗;新增 `metadata.last_usage` 表示最后一次模型调用的 usage,供上层计算 `ContextUsage` 等窗口占用指标。 +- LangChain / LangGraph 的 final chunk metadata 增加 `usage` 与 `last_usage`,保留原响应内容结构,避免只取最后一个 chunk 或最后一次 LLM 调用造成 token 少算。 + +### 修复 + +- 修复 ADK runner 单轮内多次 LLM 调用时只保留最后一次 usage,导致会话累计 token 消耗少算的问题。 +- 修复 BaseRunner 从响应列表尾部反向取 usage,遇到多段模型调用时无法聚合的问题。 +- 修复 runtime 事件分页只支持 offset/limit,前端重连和历史向上翻页需要额外扫全量事件的问题。 +- 修复 `SubscribeRunEvents` hosted/runtime 双链路续订语义不一致,断线重连可能重复消费旧事件的问题。 ## [0.6.8] - 2026-07-03 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cc689158..33c43271 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,14 +17,16 @@ Run focused checks before sending a change: ```bash uv run --extra dev pytest -q make open-source-audit -make public-docs-audit +make docs-site-build +make public-audit uv build uv run --extra dev python -m twine check dist/* ``` -`public-docs-audit` builds the curated GitHub Pages candidate from -`public-docs/`. It must not publish `.zread/wiki`, `.zread/site`, internal -deployment notes, or private generated snapshots. +`docs-site-build` builds the Fumadocs GitHub Pages candidate from +`docs-site/`. `public-audit` checks that public repository candidates do not +publish `.zread/wiki`, `.zread/site`, internal deployment notes, or private +generated snapshots. `open-source-audit` checks the current public repository candidate for files that should not enter the open-source surface. diff --git a/Makefile b/Makefile index 45aa0aa1..aaba196b 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-version-gate public-docs-build public-docs-site-build public-test public-build-check public-preflight public-publish-check public-release-approval-check public-publish-gate public-release-tag public-review public-sync-ksadk-web-static open-source-audit-dist 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-version-gate docs-site-build docs-site-dev public-test public-build-check public-preflight public-publish-check public-release-approval-check public-publish-gate public-release-tag public-review public-sync-ksadk-web-static open-source-audit-dist openclaw-build openclaw-push openclaw-size hermes-build hermes-push hermes-size sync-ksadk-web-static sync-hosted-ui build-frontend build-webui sync-static webui build-wheel build-all clean-frontend # 默认目标 help: @@ -39,6 +39,8 @@ help: @echo " make public-release-tag V=x.y.z 创建公开 release 留痕 tag" @echo " make public-review 公开候选审核入口" @echo " make public-publish-check 发布状态核对" + @echo " make docs-site-build 本地构建 Fumadocs 静态站点" + @echo " make docs-site-dev 本地预览 Fumadocs 文档站" @echo "" @echo " \033[1;32m离线打包:\033[0m" @echo " make offline-current 当前平台离线包" @@ -52,11 +54,6 @@ help: @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 清理构建产物和本地测试缓存" @echo " make clean-cache 仅清理 Python/测试/类型检查缓存" @@ -350,18 +347,22 @@ public-audit: public-secret-audit @python3 scripts/open_source_audit.py --target public-repo @echo "✅ public path audit passed" -public-docs-build: public-docs-site-build - @echo "==> docs build (Fumadocs docs-site)" - -# Fumadocs 文档站 (docs-site/) 构建 + 类型检查, 公开发布前验证 -public-docs-site-build: +docs-site-build: @echo "==> docs-site (Fumadocs) build" @if [ -d "docs-site" ] && [ -f "docs-site/package.json" ]; then \ - cd docs-site && pnpm install --frozen-lockfile && pnpm build; \ + cd docs-site && pnpm install --frozen-lockfile && NEXT_PUBLIC_BASE_PATH=/ksadk-python pnpm build:static; \ else \ echo "⚠️ docs-site 不存在,跳过 Fumadocs build"; \ fi +docs-site-dev: + @echo "==> docs-site (Fumadocs) dev server" + @if [ -d "docs-site" ] && [ -f "docs-site/package.json" ]; then \ + cd docs-site && pnpm install --frozen-lockfile && pnpm dev; \ + else \ + echo "⚠️ docs-site 不存在,无法启动 Fumadocs dev server"; \ + fi + public-test: @echo "==> test" @uv sync --extra dev @@ -389,7 +390,7 @@ public-version-gate: @echo "==> release version gate (prevent downgrade/re-publish)" uv run python scripts/check_release_version.py -public-preflight: public-version-gate public-audit sync-ksadk-web-static public-test public-docs-build public-build-check +public-preflight: public-version-gate public-audit sync-ksadk-web-static public-test docs-site-build public-build-check @echo "✅ public preflight passed" public-publish-check: @@ -403,7 +404,12 @@ public-publish-check: public-release-approval-check: @echo "==> release approval record check" - @uv run python scripts/check_approval_record.py --expected-current-commit "$${KSADK_APPROVED_SOURCE_COMMIT:-}" + @if [ -z "$${KSADK_APPROVED_SOURCE_COMMIT:-}" ]; then \ + echo "❌ KSADK_APPROVED_SOURCE_COMMIT is required before external release writes"; \ + echo " Set it to the reviewed source commit recorded in docs/maintainer-approval-record.md"; \ + exit 1; \ + fi + @uv run python scripts/check_approval_record.py --expected-current-commit "$$KSADK_APPROVED_SOURCE_COMMIT" public-publish-gate: public-release-approval-check @echo "✅ public publish gate passed" @@ -530,123 +536,6 @@ openclaw-build openclaw-push openclaw-size hermes-build hermes-push hermes-size: @$(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 同步 diff --git a/README.en.md b/README.en.md index 513594fa..4f150f3b 100644 --- a/README.en.md +++ b/README.en.md @@ -16,7 +16,7 @@ License

-

Real KsADK CLI screenshot: agentengine -h

+

Real KsADK CLI screenshot: agentengine -h

## 30 Seconds Quick Start @@ -37,9 +37,9 @@ Start the local debugging Web UI: agentengine web . --no-open ``` -

Real KsADK Web UI debugging screenshot

+

Real KsADK Web UI debugging screenshot

-

Real local Web UI demo

+

Real local Web UI demo

## Why KsADK @@ -53,16 +53,16 @@ Most agent frameworks solve how to build agents. KsADK solves how to run, debug, ## Architecture -

KsADK Agent Runtime Platform architecture

+

KsADK Agent Runtime Platform architecture

## Docs And Examples - Documentation: -- Quick Start: -- Why KsADK: -- Architecture: -- Ecosystem Positioning: -- Observability: +- Quick Start: +- Why KsADK: +- Architecture: +- Ecosystem Positioning: +- Observability: - Samples: ## Related Projects diff --git a/README.md b/README.md index f4baf508..23a8d39b 100644 --- a/README.md +++ b/README.md @@ -58,11 +58,11 @@ agentengine web . --no-open ## 文档与样例 - 文档: -- 快速开始: -- 为什么需要 KsADK: -- 架构: -- 生态定位对比: -- 可观测: +- 快速开始: +- 为什么需要 KsADK: +- 架构: +- 生态定位对比: +- 可观测: - 样例仓库: ## 相关项目 diff --git a/README.zh-CN.md b/README.zh-CN.md index 4ec558f3..c212f115 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -16,7 +16,7 @@ License

-

KsADK 真实 CLI 截图:agentengine -h

+

KsADK 真实 CLI 截图:agentengine -h

## 30 秒快速体验 @@ -37,9 +37,9 @@ agentengine run -i agentengine web . --no-open ``` -

KsADK 真实 Web UI 调试截图

+

KsADK 真实 Web UI 调试截图

-

KsADK 真实本地 Web UI 演示

+

KsADK 真实本地 Web UI 演示

## 为什么需要 KsADK @@ -53,16 +53,16 @@ agentengine web . --no-open ## 架构 -

KsADK Agent Runtime Platform 架构

+

KsADK Agent Runtime Platform 架构

## 文档与样例 - 文档: -- 快速开始: -- 为什么需要 KsADK: -- 架构: -- 生态定位对比: -- 可观测: +- 快速开始: +- 为什么需要 KsADK: +- 架构: +- 生态定位对比: +- 可观测: - 样例仓库: ## 相关项目 diff --git a/docs-site/content/docs/framework/guides/build-and-package.en.mdx b/docs-site/content/docs/framework/guides/build-and-package.en.mdx index ef407f78..c2b28071 100644 --- a/docs-site/content/docs/framework/guides/build-and-package.en.mdx +++ b/docs-site/content/docs/framework/guides/build-and-package.en.mdx @@ -9,7 +9,7 @@ itself. ```bash uv run --extra dev python -m twine check dist/* -make open-source-review +make public-review ``` ## Web UI Artifacts @@ -69,7 +69,7 @@ The assertions guarantee: `make public-preflight` layers `public-audit`, `public-test`, and -`public-docs-build` on top of `public-build-check`. It is the mandatory local +`docs-site-build` on top of `public-build-check`. It is the mandatory local gate before pushing to GitHub, PyPI, or a Release. diff --git a/docs-site/content/docs/framework/guides/build-and-package.mdx b/docs-site/content/docs/framework/guides/build-and-package.mdx index 95644bdd..8c20b7a2 100644 --- a/docs-site/content/docs/framework/guides/build-and-package.mdx +++ b/docs-site/content/docs/framework/guides/build-and-package.mdx @@ -8,7 +8,7 @@ title: "构建与打包" ```bash uv run --extra dev python -m twine check dist/* -make open-source-review +make public-review ``` ## Web UI 产物 @@ -58,7 +58,7 @@ agentengine --dry-run deploy . - wheel 含同步后的 `ksadk/server/static/index.html` 及 `assets/` 入口,保证安装即可打开本地 UI。 -`make public-preflight` 在 `public-build-check` 之上追加 `public-audit`、`public-test`、`public-docs-build`,是推 GitHub/PyPI/Release 前必须通过的完整本地门禁。 +`make public-preflight` 在 `public-build-check` 之上追加 `public-audit`、`public-test`、`docs-site-build`,是推 GitHub/PyPI/Release 前必须通过的完整本地门禁。 ## Artifact 规则 diff --git a/docs-site/content/docs/references/contributing/index.en.mdx b/docs-site/content/docs/references/contributing/index.en.mdx index e1303f6b..30f74a5d 100644 --- a/docs-site/content/docs/references/contributing/index.en.mdx +++ b/docs-site/content/docs/references/contributing/index.en.mdx @@ -18,22 +18,21 @@ pytest Build docs locally: ```bash -make public-docs-build -make public-docs-serve +make docs-site-build ``` Run open-source checks: ```bash make open-source-audit -make public-docs-audit +make public-audit ``` For changes that touch packaging, public docs, release metadata, or repository layout, run the broader review target before asking for release approval: ```bash -make open-source-review +make public-preflight ``` ## Public CI Expectations @@ -43,7 +42,7 @@ Public CI must not require internal kubeconfig files, internal registries, inter Before submitting a public PR: - run focused tests for the changed area. -- run docs build when editing `public-docs/` or `mkdocs.yml`. +- run `make docs-site-build` when editing `docs-site/`; use `make docs-site-dev` for local preview. - update CLI docs when command behavior changes. - update release notes when changing packaging or public API behavior. - keep examples local-first unless a hosted feature is explicitly approved. @@ -59,7 +58,7 @@ KsADK keeps public confidence through layered tests: | ASGI service tests | validate FastAPI routes and session events without a real network server | service/session pytest files | | HTTP protocol E2E | validate `/v1/responses`, `/v1/chat/completions`, upload, and local Web UI action payloads | OpenAI protocol E2E tests | | browser E2E | validate built UI behavior when Chromium is available | browser-tagged E2E tests | -| open-source audits | verify public tree, docs, Pages artifact, sdist, wheel, and clean export boundaries | `make open-source-review` | +| open-source audits | verify public tree, docs, Pages artifact, sdist, wheel, and clean export boundaries | `make public-preflight` | When a change affects protocol shape, attachment handling, session events, or the local Web UI payload, prefer a test that crosses the same boundary a real @@ -98,7 +97,7 @@ Avoid: - references to generated `.zread/` output as the published source. Local zread wiki output can be useful as an engineering note source, but public -documentation should be curated Markdown under `public-docs/`. Do not publish +documentation should be curated Markdown under `docs-site/`. Do not publish the generated wiki directory or depend on it during public CI. ## Open-Source Review Boundary diff --git a/docs-site/content/docs/references/contributing/index.mdx b/docs-site/content/docs/references/contributing/index.mdx index ae1ae76d..00e1d4de 100644 --- a/docs-site/content/docs/references/contributing/index.mdx +++ b/docs-site/content/docs/references/contributing/index.mdx @@ -17,22 +17,21 @@ pytest 本地构建文档: ```bash -make public-docs-build -make public-docs-serve +make docs-site-build ``` 运行开源检查: ```bash make open-source-audit -make public-docs-audit +make public-audit ``` 如果变更影响 packaging、公开文档、release metadata 或仓库布局,请在请求 release approval 前运行更完整的审核目标: ```bash -make open-source-review +make public-preflight ``` ## 公开 CI 期望 @@ -42,7 +41,7 @@ make open-source-review 提交公开 PR 前: - 运行变更区域的 focused tests。 -- 修改 `public-docs/` 或 `mkdocs.yml` 时运行文档构建。 +- 修改 `docs-site/` 时运行 `make docs-site-build`,需要本地预览时运行 `make docs-site-dev`。 - 命令行为变化时更新 CLI 文档。 - packaging 或公开 API 变化时更新 release notes。 - 示例应保持本地优先,除非某个 hosted feature 已明确批准公开。 @@ -56,7 +55,7 @@ make open-source-review | ASGI service tests | 不启动真实网络 server 验证 FastAPI routes 和 session events | service/session pytest 文件 | | HTTP protocol E2E | 验证 `/v1/responses`、`/v1/chat/completions`、upload 和本地 Web UI action payload | OpenAI protocol E2E tests | | browser E2E | Chromium 可用时验证构建后的 UI 行为 | browser-tagged E2E tests | -| open-source audits | 验证公开 tree、docs、Pages artifact、sdist、wheel 和 clean export 边界 | `make open-source-review` | +| open-source audits | 验证公开 tree、docs、Pages artifact、sdist、wheel 和 clean export 边界 | `make public-preflight` | 当变更影响协议形态、附件处理、session event 或本地 Web UI payload 时,优先写一个 跨越真实客户端边界的测试。 @@ -78,7 +77,7 @@ make open-source-review - 真实 token 或客户数据。 - 把生成的 `.zread/` 输出当作发布源引用。 -本地 zread wiki 输出可以作为工程笔记来源,但公开文档应是 `public-docs/` 下经过整理的 +本地 zread wiki 输出可以作为工程笔记来源,但公开文档应是 `docs-site/` 下经过整理的 Markdown。不要发布生成的 wiki 目录,也不要让公开 CI 依赖它。 ## 开源审核边界 diff --git a/docs-site/content/docs/references/contributing/release.en.mdx b/docs-site/content/docs/references/contributing/release.en.mdx index 765c4147..ccef15a7 100644 --- a/docs-site/content/docs/references/contributing/release.en.mdx +++ b/docs-site/content/docs/references/contributing/release.en.mdx @@ -38,8 +38,7 @@ publication strategy, and maintainer sign-offs. ## Local Commands ```bash -make open-source-review -make open-source-review-bundle +make public-review python3 scripts/audit_public_history_paths.py --json --allow-violations git diff --check ``` @@ -93,7 +92,7 @@ The workflow runs the following steps: it into the packaging tree. When triggered via `workflow_dispatch`, the `ksadk_web_version` input pins a specific npm version (for example `1.2.3`). 2. **Release preflight**: `make public-preflight` chains `public-audit`, - `public-test`, `public-docs-build`, and `public-build-check`. + `public-test`, `docs-site-build`, and `public-build-check`. 3. **Build and content check**: inside `public-build-check`, `uv build` produces the artifacts and `twine check dist/*` validates wheel and sdist metadata. diff --git a/docs-site/content/docs/references/contributing/release.mdx b/docs-site/content/docs/references/contributing/release.mdx index dbb5bd37..378561ce 100644 --- a/docs-site/content/docs/references/contributing/release.mdx +++ b/docs-site/content/docs/references/contributing/release.mdx @@ -7,8 +7,7 @@ title: 发布流程 ## 发布前 ```bash -make open-source-review -make open-source-review-bundle +make public-review make public-publish-check PUBLIC_PUBLISH_PHASE=pre-publish V=0.6.7 ``` @@ -48,7 +47,7 @@ workflow 执行步骤如下: 通过 `workflow_dispatch` 触发时可传 `ksadk_web_version` 输入指定版本 (如 `1.2.3`)。 2. **发布前预检**:`make public-preflight`,串起 `public-audit`、 - `public-test`、`public-docs-build` 以及 `public-build-check`。 + `public-test`、`docs-site-build` 以及 `public-build-check`。 3. **构建与内容检查**:`public-build-check` 内执行 `uv build` 并 `twine check dist/*`,校验 wheel 与 sdist 元数据。 4. **OIDC 上传**:用 `pypa/gh-action-pypi-publish` 通过 Trusted Publishing diff --git a/docs-site/content/docs/references/contributing/testing.en.mdx b/docs-site/content/docs/references/contributing/testing.en.mdx index daeda5c7..4e26e59c 100644 --- a/docs-site/content/docs/references/contributing/testing.en.mdx +++ b/docs-site/content/docs/references/contributing/testing.en.mdx @@ -16,7 +16,7 @@ publishable artifacts. | ASGI service tests | FastAPI routes without opening a real port | service and session pytest files | | HTTP protocol E2E | `/v1/responses`, `/v1/chat/completions`, upload, session events | protocol E2E tests | | browser E2E | local Web UI request construction and upload behavior | browser-capable E2E tests | -| open-source audits | public tree, clean exports, Pages, sdist, wheel | `make open-source-review` | +| open-source audits | public tree, clean exports, Pages, sdist, wheel | `make public-preflight` | Use the narrowest test that proves the change, then run the broader gate when a change touches public behavior, packaging, docs, or release boundaries. @@ -31,15 +31,14 @@ uv run --extra dev pytest tests/ -q For documentation changes: ```bash -make public-docs-build -make public-docs-audit +make docs-site-build +make public-audit ``` For release or open-source boundary changes: ```bash -make open-source-review -make open-source-review-bundle +make public-review ``` ## Snapshot Tests @@ -88,7 +87,7 @@ Use browser E2E for: ## Open-Source Review Gate -`make open-source-review` is the local gate for the public release candidate. It +`make public-review` is the local gate for the public release candidate. It checks: - open-source contract tests. @@ -111,7 +110,7 @@ publication. | CLI help, options, or error text | focused CLI tests and affected snapshots | | runtime request/response behavior | focused runtime tests plus protocol E2E | | attachments or workspace files | protocol tests and workspace security tests | -| public docs | `make public-docs-build` and `make public-docs-audit` | +| public docs | `make docs-site-build` and `make public-audit` | | package metadata or release scripts | `uv build`, `twine check`, artifact audit | | open-source export policy | export tests, open-source audit, review bundle | diff --git a/docs-site/content/docs/references/contributing/testing.mdx b/docs-site/content/docs/references/contributing/testing.mdx index 81b6e13b..900f0940 100644 --- a/docs-site/content/docs/references/contributing/testing.mdx +++ b/docs-site/content/docs/references/contributing/testing.mdx @@ -8,8 +8,8 @@ title: 测试策略 ```bash uv run --extra dev pytest -uv run --extra dev python -m mkdocs build --strict -make open-source-review +make docs-site-build +make public-review ``` ## 覆盖重点 diff --git a/docs-site/content/docs/references/environment-variables.en.mdx b/docs-site/content/docs/references/environment-variables.en.mdx index f2400ca7..558b8a14 100644 --- a/docs-site/content/docs/references/environment-variables.en.mdx +++ b/docs-site/content/docs/references/environment-variables.en.mdx @@ -57,6 +57,21 @@ Hosted deployments can inject a shared policy through `AGENTENGINE_MODEL_POLICY_ | `KSADK_UI_BUNDLE_PATH` | custom UI static bundle path relative to project; local auto-detects `research-ui/dist` (0.6.7) | | `KSYUN_REGION` | region used by cloud actions and some SDK clients | +## Cloud Account Identity Resolution + +| Variable | Purpose | +| --- | --- | +| `KSYUN_IAM_ENDPOINT` | Optional public IAM endpoint override; defaults to `iam.api.ksyun.com` when unset | +| `KSYUN_IAM_INTRANET_URL` | Optional IAM intranet endpoint fallback override; defaults to `iam.inner.api.ksyun.com` when unset | +| `IAM_INTRANET_URL` | Compatibility alias for `KSYUN_IAM_INTRANET_URL` | + + + When public IAM returns an error such as “inner account can only access through + intranet”, the runtime retries the intranet endpoint. Public environments + normally do not hit this branch; internal environments can override the address + with `KSYUN_IAM_INTRANET_URL` or `IAM_INTRANET_URL`. + + ## Session Storage | Variable | Purpose | @@ -269,6 +284,7 @@ KSADK_MCP_SERVERS='[{"name":"docs","url":"http://127.0.0.1:9000/mcp"}]' | `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | traces-specific OTLP HTTP endpoint; takes precedence over the generic endpoint | | `OTEL_EXPORTER_OTLP_TRACES_PROTOCOL` | traces-specific OTLP protocol; takes precedence over the generic protocol | | `OTEL_EXPORTER_OTLP_TRACES_HEADERS` | traces-specific OTLP HTTP headers; takes precedence over generic headers and may contain auth data | +| `KSADK_OTLP_MAX_EXPORT_BATCH_SIZE` | maximum spans exported per OTLP batch, default `64`, to avoid oversized collector requests | | `LANGFUSE_PUBLIC_KEY` | compatibility path for older Langfuse tracing auto-configuration | | `LANGFUSE_SECRET_KEY` | Langfuse secret | | `LANGFUSE_BASE_URL` | Langfuse base URL | diff --git a/docs-site/content/docs/references/environment-variables.mdx b/docs-site/content/docs/references/environment-variables.mdx index c0eef32c..e024105f 100644 --- a/docs-site/content/docs/references/environment-variables.mdx +++ b/docs-site/content/docs/references/environment-variables.mdx @@ -57,6 +57,20 @@ OPENAI_MODEL_NAME=my-model | `KSADK_UI_BUNDLE_PATH` | 自定义 UI 静态 bundle 相对项目路径;本地默认自动探测 `research-ui/dist`(0.6.7) | | `KSYUN_REGION` | 云端 action 和部分 SDK client 使用的区域 | +## 云账号身份解析 + +| 变量 | 用途 | +| --- | --- | +| `KSYUN_IAM_ENDPOINT` | 可选:覆盖 IAM 公网 endpoint;未配置时使用 `iam.api.ksyun.com` | +| `KSYUN_IAM_INTRANET_URL` | 可选:覆盖 IAM 内网 endpoint fallback;未配置时使用默认 `iam.inner.api.ksyun.com` | +| `IAM_INTRANET_URL` | `KSYUN_IAM_INTRANET_URL` 的兼容别名 | + + + 当公网 IAM 返回“inner account can only access through intranet”这类错误时, + 运行时会尝试内网 endpoint。外部环境通常不会命中该分支;内部环境如需覆盖地址, + 可设置 `KSYUN_IAM_INTRANET_URL` 或 `IAM_INTRANET_URL`。 + + ## 会话存储 | 变量 | 用途 | @@ -269,6 +283,7 @@ KSADK_MCP_SERVERS='[{"name":"docs","url":"http://127.0.0.1:9000/mcp"}]' | `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | traces 专用 OTLP HTTP endpoint,优先于通用 endpoint | | `OTEL_EXPORTER_OTLP_TRACES_PROTOCOL` | traces 专用 OTLP protocol,优先于通用 protocol | | `OTEL_EXPORTER_OTLP_TRACES_HEADERS` | traces 专用 OTLP HTTP headers,优先于通用 headers,可能包含鉴权信息 | +| `KSADK_OTLP_MAX_EXPORT_BATCH_SIZE` | OTLP 单次 export 最大 span 数,默认 `64`,用于避免 collector 请求过大 | | `LANGFUSE_PUBLIC_KEY` | 兼容旧 Langfuse tracing 自动配置 | | `LANGFUSE_SECRET_KEY` | Langfuse secret | | `LANGFUSE_BASE_URL` | Langfuse base URL | diff --git a/docs-site/content/docs/references/openai-compatible-api.en.mdx b/docs-site/content/docs/references/openai-compatible-api.en.mdx index e1777131..b1ca8036 100644 --- a/docs-site/content/docs/references/openai-compatible-api.en.mdx +++ b/docs-site/content/docs/references/openai-compatible-api.en.mdx @@ -118,8 +118,8 @@ of KsADK extensions: | `status` | compatible | `completed` / `failed` / `incomplete` | | `model` | compatible | model or agent used for the request | | `output` | compatible | output item, usually an assistant message | -| `metadata` | compatible | caller metadata | -| `usage` | compatible-shaped | usage info when available | +| `metadata` | compatible | caller metadata; KsADK 0.6.9+ adds `metadata.last_usage` when available | +| `usage` | compatible-shaped | per-response accumulated usage when available, used for the real cost of this response | | `output_text` | KsADK extension | convenient concatenated text | | `session_id` | KsADK extension | local session id | @@ -139,6 +139,20 @@ of KsADK extensions: ] } ], + "usage": { + "input_tokens": 1250, + "output_tokens": 180, + "total_tokens": 1430, + "input_token_details": {"cached": 900} + }, + "metadata": { + "last_usage": { + "input_tokens": 980, + "output_tokens": 180, + "total_tokens": 1160, + "input_token_details": {"cached": 700} + } + }, "output_text": "This agent can...", "session_id": "local-demo-session" } @@ -147,6 +161,14 @@ of KsADK extensions: Consumers aiming for broad compatibility should read `output` first and treat `output_text` as a convenience field. + + `usage` keeps the OpenAI-style accumulated usage for this response; if an + agent loop calls the model multiple times, those calls are summed. The + KsADK-specific `metadata.last_usage` is the final model-call usage snapshot, + used by higher layers to compute current context-window occupancy. It is not a + cumulative session total. + + diff --git a/docs-site/content/docs/references/openai-compatible-api.mdx b/docs-site/content/docs/references/openai-compatible-api.mdx index ccb6ee07..358fb1e1 100644 --- a/docs-site/content/docs/references/openai-compatible-api.mdx +++ b/docs-site/content/docs/references/openai-compatible-api.mdx @@ -113,8 +113,8 @@ checkpoint resume 与 approval payload。 | `status` | compatible | `completed` / `failed` / `incomplete` | | `model` | compatible | 本次请求使用的模型或 Agent | | `output` | compatible | output item,通常是 assistant message | -| `metadata` | compatible | 调用方 metadata | -| `usage` | compatible-shaped | 可用时的 usage 信息 | +| `metadata` | compatible | 调用方 metadata;KsADK 0.6.9+ 会在可用时追加 `metadata.last_usage` | +| `usage` | compatible-shaped | 可用时的本轮累计 usage,用于统计本次响应真实消耗 | | `output_text` | KsADK 扩展 | 拼接后的便捷文本 | | `session_id` | KsADK 扩展 | 本地 session id | @@ -134,6 +134,20 @@ checkpoint resume 与 approval payload。 ] } ], + "usage": { + "input_tokens": 1250, + "output_tokens": 180, + "total_tokens": 1430, + "input_token_details": {"cached": 900} + }, + "metadata": { + "last_usage": { + "input_tokens": 980, + "output_tokens": 180, + "total_tokens": 1160, + "input_token_details": {"cached": 700} + } + }, "output_text": "This agent can...", "session_id": "local-demo-session" } @@ -141,6 +155,12 @@ checkpoint resume 与 approval payload。 追求广泛兼容的消费者应优先读取 `output`,把 `output_text` 视为便捷字段。 + + `usage` 保持 OpenAI 风格的本轮累计 usage(agent loop 内多次 LLM 调用会累加)。 + `metadata.last_usage` 是 KsADK 扩展,表示最后一次 LLM 调用的 usage 快照,用于 + 上层计算当前上下文窗口占用;它不是会话累计值。 + + diff --git a/docs-site/content/docs/references/remote-runtime-api.en.mdx b/docs-site/content/docs/references/remote-runtime-api.en.mdx index b4273c42..b4a01897 100644 --- a/docs-site/content/docs/references/remote-runtime-api.en.mdx +++ b/docs-site/content/docs/references/remote-runtime-api.en.mdx @@ -117,9 +117,9 @@ All action paths live under `POST /agentengine/api/v1/*` (download surfaces are | Group | Purpose | Actions | | --- | --- | --- | -| Sessions | Create, get, list, delete sessions | `CreateSession`, `GetSession`, `ListSessions`, `DeleteSession` | +| Sessions | Create, get, list, delete sessions, read projected chat messages | `CreateSession`, `GetSession`, `ListSessions`, `ListSessionMessages`, `DeleteSession` | | Runs | Invoke or cancel a run, subscribe to events | `RunAgent`, `SubscribeRunEvents`, `CancelRun` | -| Events | List historical session events | `ListSessionEvents` | +| Events | List historical session events with incremental resume and older-page cursors | `ListSessionEvents` | | Files | Upload, download attachments, workspace files, export archive | `UploadFile`, `AttachmentContent`, `ListWorkspaceFiles`, `AddWorkspaceFile`, `DeleteWorkspaceFile`, `GetWorkspaceFileContent`, `ExportWorkspaceZip` | | Models | List the available model catalog | `ListAgentModels` | | UI Bootstrap | Bootstrap metadata for UI rendering and capability discovery | `GetAgentUiBootstrap` | @@ -136,7 +136,7 @@ All action paths live under `POST /agentengine/api/v1/*` (download surfaces are 1. **Probe capabilities**: call `GetAgentUiBootstrap` first to check `Capabilities.WorkspaceFiles`, `Capabilities.ResumeRun`, etc. 2. **Create a session**: `CreateSession`, take `Session.SessionId`. -3. **Pull history**: `ListSessions` / `ListSessionEvents` to render the sidebar and timeline. +3. **Pull history**: `ListSessions` for the sidebar and `ListSessionMessages` for chat history; use `ListSessionEvents` directly only for debugging or raw timelines. 4. **Start a run**: `RunAgent` (foreground or background), subscribe to progress via `SubscribeRunEvents`. 5. **Reclaim a session**: `DeleteSession` to release persisted records. @@ -185,6 +185,12 @@ Creates (or reuses) a session. When `SessionId` is omitted, the runtime generate "LastPrompt": "", "ActiveInvocationId": "", "ActiveRunStatus": "", + "ActiveRunMode": "unknown", + "ActiveRunTrigger": "unknown", + "ActiveRunUpdatedAt": "", + "Model": null, + "ContextUsage": null, + "TokenUsage": null, "State": {}, "CreatedAt": 1719900000.0, "UpdatedAt": 1719900000.0, @@ -202,7 +208,12 @@ Creates (or reuses) a session. When `SessionId` is omitted, the runtime generate | `Title` / `TitleSource` | Session title and its source (`fallback_first_prompt`, `heuristic`, etc.) | | `Summary` | Runtime-maintained session summary | | `FirstPrompt` / `LastPrompt` | Truncated first/last user message preview | -| `ActiveInvocationId` / `ActiveRunStatus` | Active run invocation and status for this session | +| `ActiveInvocationId` / `ActiveRunStatus` | Active run invocation and status for this session; stale orphaned active runs are read as `interrupted` | +| `ActiveRunMode` / `ActiveRunTrigger` | 0.6.9+ two independent run dimensions: `background` / `foreground` / `unknown` and `new_run` / `checkpoint_resume` / `approval_resume` / `unknown` | +| `ActiveRunUpdatedAt` | 0.6.9+ timestamp from `active_run` itself, mainly for diagnostics; orphan detection uses the session `UpdatedAt` heartbeat | +| `Model` | Model metadata from the most recent run when available | +| `ContextUsage` | 0.6.9+ latest-turn context-window usage: `used_tokens`, `cached_tokens`, `context_window_tokens`, `percent` | +| `TokenUsage` | 0.6.9+ cumulative session token usage: `input_tokens`, `output_tokens`, `total_tokens`, `turns`, `last_response_id`, and detail fields | | `State` | Session state dict (sanitized) | | `CreatedAt` / `UpdatedAt` / `Version` | Timestamps and version | | `Continuity` | Optional: continuity info from the runner adapter | @@ -632,7 +643,9 @@ Request cancellation of an in-flight run (both detached stream and runner channe #### ListSessionEvents -Paginated list of historical events for a session, offset-based pagination. +Paginated list of historical events for a session. Without a seq cursor, +`Offset` / `Limit` page backward from the latest event window. `AfterSeqId` is +for reconnect/incremental reads; `BeforeSeqId` is for loading older history. @@ -644,6 +657,8 @@ Paginated list of historical events for a session, offset-based pagination. | `SessionId` | string | yes | Target session id | | `Offset` | int | no | 0-based offset, default `0` | | `Limit` | int | no | Page size, min `1` | +| `AfterSeqId` | int | no | 0.6.9+ return events with `SeqId > AfterSeqId` for reconnect catch-up | +| `BeforeSeqId` | int | no | 0.6.9+ return events with `SeqId < BeforeSeqId` for older pages | ```json title="request.json" { "SessionId": "local-demo-session", "Offset": 0, "Limit": 50 } @@ -662,7 +677,9 @@ Paginated list of historical events for a session, offset-based pagination. "Events": [ /* event payload, same as SubscribeRunEvents */ ], "Total": 128, "Offset": 0, - "Limit": 50 + "Limit": 50, + "AfterSeqId": null, + "BeforeSeqId": null } } ``` @@ -674,6 +691,103 @@ Pagination fields: | `Total` | Total event count for this session | | `Offset` | Current offset (defaults to `0`) | | `Limit` | Current page size (falls back to the returned count when not sent) | +| `AfterSeqId` / `BeforeSeqId` | Echoed seq cursors; they move in opposite directions and should not be mixed | + + + + +#### ListSessionMessages + +Project the raw event log into chat messages that the Web UI can render +directly. The server handles assistant snapshot de-duplication, reasoning +merging, tool / approval pairing, and attachment normalization so clients do not +need to reconstruct messages from `ListSessionEvents`. + + + + +`POST /agentengine/api/v1/ListSessionMessages`: + +| Field | Type | Required | Notes | +| --- | --- | --- | --- | +| `AgentId` | string | no | When present, the server fetches runtime `ListSessionEvents` and projects them in one place; when omitted, hosted reads the local session store | +| `SessionId` | string | yes | Target session id | +| `Limit` | int | no | Maximum messages to return, default `50`, range `1..200` | +| `AfterSeqId` | int | no | Return incremental messages with `SeqId > AfterSeqId`; used for reconnect catch-up and not truncated to `Limit` | +| `BeforeSeqId` | int | no | Return the latest page before `SeqId < BeforeSeqId`; used for loading older history | +| `IncludeReasoning` | bool | no | Include `Reasoning[]` inside assistant messages | +| `IncludeToolEvents` | bool | no | Include `ToolEvents[]` inside assistant messages | +| `IncludeAttachments` | bool | no | Include `Attachments[]` on user messages, default `true` | + +```json title="request.json" +{ + "SessionId": "local-demo-session", + "Limit": 50, + "IncludeAttachments": true +} +``` + + + + +```json title="response.json" +{ + "Code": 0, + "Message": "Success", + "RequestId": "req_abc123", + "Action": "ListSessionMessages", + "Data": { + "SessionId": "local-demo-session", + "Messages": [ + { + "MessageId": "evt_user_1", + "Role": "user", + "Content": {"text": "Summarize this file"}, + "SeqId": 21, + "InvocationId": "inv_demo_001", + "Timestamp": 1719900001.0, + "Attachments": [ + { + "file_uri": "ae-upload://abc123_report.pdf", + "name": "report.pdf", + "mime": "application/pdf", + "size": 204800, + "url": "/agentengine/api/v1/AttachmentContent?FileUri=ae-upload%3A%2F%2Fabc123_report.pdf", + "is_image": false + } + ] + }, + { + "MessageId": "evt_assistant_1", + "Role": "assistant", + "Content": {"text": "Here is the summary..."}, + "SeqId": 24, + "InvocationId": "inv_demo_001", + "ResponseId": "resp_a1b2c3", + "TraceId": "trace_abc", + "RootSpanId": "span_root" + } + ], + "LatestSeqId": 24, + "HasMore": true, + "NextCursor": 20 + } +} +``` + +Pagination fields: + +| Field | Notes | +| --- | --- | +| `LatestSeqId` | `SeqId` of the last message in the page; pass it to `SubscribeRunEvents(AfterSeqId=...)` as the reconnect starting point | +| `HasMore` | Whether older messages remain | +| `NextCursor` | Older-page cursor; pass it as `BeforeSeqId=NextCursor` on the next request | + + + `ListSessionMessages` only projects historical messages. To decide whether an + SSE stream should reconnect, read `GetSession.ActiveRunStatus` / + `ActiveInvocationId`, then call `SubscribeRunEvents(AfterSeqId=LatestSeqId)`. + diff --git a/docs-site/content/docs/references/remote-runtime-api.mdx b/docs-site/content/docs/references/remote-runtime-api.mdx index 3281b653..2f5e1304 100644 --- a/docs-site/content/docs/references/remote-runtime-api.mdx +++ b/docs-site/content/docs/references/remote-runtime-api.mdx @@ -112,9 +112,9 @@ cancel 和 model listing。公开 API 客户端优先使用 OpenAI 兼容的 `/v | 分组 | 用途 | Actions | | --- | --- | --- | -| 会话 Sessions | 创建、获取、列出、删除会话 | `CreateSession`、`GetSession`、`ListSessions`、`DeleteSession` | +| 会话 Sessions | 创建、获取、列出、删除会话,读取投影后的聊天消息 | `CreateSession`、`GetSession`、`ListSessions`、`ListSessionMessages`、`DeleteSession` | | 运行 Runs | 调用或取消 Agent run、订阅事件 | `RunAgent`、`SubscribeRunEvents`、`CancelRun` | -| 事件 Events | 列出 session 历史 events | `ListSessionEvents` | +| 事件 Events | 列出 session 历史 events,支持增量续订与向前翻页 | `ListSessionEvents` | | 文件 Files | 上传、下载附件、workspace 文件、导出 archive | `UploadFile`、`AttachmentContent`、`ListWorkspaceFiles`、`AddWorkspaceFile`、`DeleteWorkspaceFile`、`GetWorkspaceFileContent`、`ExportWorkspaceZip` | | 模型 Models | 列出可用模型目录 | `ListAgentModels` | | UI Bootstrap | 获取 UI 渲染与能力探测所需 metadata | `GetAgentUiBootstrap` | @@ -130,7 +130,7 @@ cancel 和 model listing。公开 API 客户端优先使用 OpenAI 兼容的 `/v 1. **探测能力**:先调用 `GetAgentUiBootstrap`,确认 `Capabilities.WorkspaceFiles`、`Capabilities.ResumeRun` 等。 2. **创建会话**:`CreateSession`,拿到 `Session.SessionId`。 -3. **拉取历史**:`ListSessions` / `ListSessionEvents` 渲染侧栏与时间线。 +3. **拉取历史**:`ListSessions` 渲染侧栏,`ListSessionMessages` 渲染聊天历史;只有调试或原始时间线才直接读 `ListSessionEvents`。 4. **发起运行**:`RunAgent`(前台或后台),用 `SubscribeRunEvents` 续订进度。 5. **回收会话**:`DeleteSession` 释放持久化记录。 @@ -179,6 +179,12 @@ cancel 和 model listing。公开 API 客户端优先使用 OpenAI 兼容的 `/v "LastPrompt": "", "ActiveInvocationId": "", "ActiveRunStatus": "", + "ActiveRunMode": "unknown", + "ActiveRunTrigger": "unknown", + "ActiveRunUpdatedAt": "", + "Model": null, + "ContextUsage": null, + "TokenUsage": null, "State": {}, "CreatedAt": 1719900000.0, "UpdatedAt": 1719900000.0, @@ -196,7 +202,12 @@ cancel 和 model listing。公开 API 客户端优先使用 OpenAI 兼容的 `/v | `Title` / `TitleSource` | 会话标题及其来源(`fallback_first_prompt`、`heuristic` 等) | | `Summary` | 运行时维护的会话摘要 | | `FirstPrompt` / `LastPrompt` | 截断后的首/末用户消息预览 | -| `ActiveInvocationId` / `ActiveRunStatus` | 当前会话活跃 run 的 invocation 与状态 | +| `ActiveInvocationId` / `ActiveRunStatus` | 当前会话活跃 run 的 invocation 与状态;读侧会把超时孤儿活跃态兜底显示为 `interrupted` | +| `ActiveRunMode` / `ActiveRunTrigger` | 0.6.9+ run 状态双维度:`background` / `foreground` / `unknown` 与 `new_run` / `checkpoint_resume` / `approval_resume` / `unknown` | +| `ActiveRunUpdatedAt` | 0.6.9+ active_run 自身更新时间,主要用于诊断;孤儿判定使用 session `UpdatedAt` 心跳 | +| `Model` | 最近一次 run 的模型 metadata(如可用) | +| `ContextUsage` | 0.6.9+ 最近一轮上下文窗口占用:`used_tokens`、`cached_tokens`、`context_window_tokens`、`percent` | +| `TokenUsage` | 0.6.9+ 会话累计 token 消耗:`input_tokens`、`output_tokens`、`total_tokens`、`turns`、`last_response_id` 与明细字段 | | `State` | 会话状态 dict(已脱敏) | | `CreatedAt` / `UpdatedAt` / `Version` | 时间戳与版本号 | | `Continuity` | 可选:runner 适配器描述的连续性信息 | @@ -619,7 +630,8 @@ data: [DONE] #### ListSessionEvents -分页列出某 session 的历史 events,offset-based 分页。 +分页列出某 session 的历史 events。无游标时 `Offset` / `Limit` 表示从最新事件窗口向前分页; +`AfterSeqId` 用于断线后增量读取,`BeforeSeqId` 用于向上翻更早历史。 @@ -631,6 +643,8 @@ data: [DONE] | `SessionId` | string | 是 | 目标会话 id | | `Offset` | int | 否 | 0-based 偏移,缺省 `0` | | `Limit` | int | 否 | 每页大小,最小 `1` | +| `AfterSeqId` | int | 否 | 0.6.9+ 返回 `SeqId > AfterSeqId` 的事件,用于重连补齐 | +| `BeforeSeqId` | int | 否 | 0.6.9+ 返回 `SeqId < BeforeSeqId` 的事件,用于向前翻页 | ```json title="request.json" { "SessionId": "local-demo-session", "Offset": 0, "Limit": 50 } @@ -649,7 +663,9 @@ data: [DONE] "Events": [ /* event payload,同 SubscribeRunEvents */ ], "Total": 128, "Offset": 0, - "Limit": 50 + "Limit": 50, + "AfterSeqId": null, + "BeforeSeqId": null } } ``` @@ -661,6 +677,102 @@ data: [DONE] | `Total` | 该 session 的事件总数 | | `Offset` | 当前偏移(缺省回填 `0`) | | `Limit` | 当前页大小(未传时回填为实际返回数) | +| `AfterSeqId` / `BeforeSeqId` | 请求中的 seq 游标原样回填;二者语义相反,不应混用 | + + + + +#### ListSessionMessages + +把原始 event log 投影为前端可直接渲染的聊天消息列表。它会在服务端完成 +assistant snapshot 去重、reasoning 归并、tool / approval 配对和附件规范化,避免客户端 +从 `ListSessionEvents` 自行筛消息。 + + + + +`POST /agentengine/api/v1/ListSessionMessages`: + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `AgentId` | string | 否 | 传入时 server 调 runtime `ListSessionEvents` 再统一投影;不传则 hosted 直连 session store | +| `SessionId` | string | 是 | 目标会话 id | +| `Limit` | int | 否 | 返回消息条数上限,默认 `50`,范围 `1..200` | +| `AfterSeqId` | int | 否 | 返回 `SeqId > AfterSeqId` 的增量消息;用于重连补齐,不截断到 `Limit` | +| `BeforeSeqId` | int | 否 | 返回 `SeqId < BeforeSeqId` 之前的最新一页消息;用于向上加载更早历史 | +| `IncludeReasoning` | bool | 否 | 是否在 assistant 消息中返回 `Reasoning[]` | +| `IncludeToolEvents` | bool | 否 | 是否在 assistant 消息中返回 `ToolEvents[]` | +| `IncludeAttachments` | bool | 否 | 是否在 user 消息中返回 `Attachments[]`,默认 `true` | + +```json title="request.json" +{ + "SessionId": "local-demo-session", + "Limit": 50, + "IncludeAttachments": true +} +``` + + + + +```json title="response.json" +{ + "Code": 0, + "Message": "Success", + "RequestId": "req_abc123", + "Action": "ListSessionMessages", + "Data": { + "SessionId": "local-demo-session", + "Messages": [ + { + "MessageId": "evt_user_1", + "Role": "user", + "Content": {"text": "总结这个文件"}, + "SeqId": 21, + "InvocationId": "inv_demo_001", + "Timestamp": 1719900001.0, + "Attachments": [ + { + "file_uri": "ae-upload://abc123_report.pdf", + "name": "report.pdf", + "mime": "application/pdf", + "size": 204800, + "url": "/agentengine/api/v1/AttachmentContent?FileUri=ae-upload%3A%2F%2Fabc123_report.pdf", + "is_image": false + } + ] + }, + { + "MessageId": "evt_assistant_1", + "Role": "assistant", + "Content": {"text": "文件摘要如下..."}, + "SeqId": 24, + "InvocationId": "inv_demo_001", + "ResponseId": "resp_a1b2c3", + "TraceId": "trace_abc", + "RootSpanId": "span_root" + } + ], + "LatestSeqId": 24, + "HasMore": true, + "NextCursor": 20 + } +} +``` + +分页语义: + +| 字段 | 说明 | +| --- | --- | +| `LatestSeqId` | 本页最后一条消息的 `SeqId`;可作为 `SubscribeRunEvents(AfterSeqId=...)` 的续订起点 | +| `HasMore` | 是否还有更早消息可取 | +| `NextCursor` | 向前翻页游标;下一次请求传 `BeforeSeqId=NextCursor` | + + + `ListSessionMessages` 只负责历史消息投影。是否需要重连运行中的 SSE,应读取 + `GetSession.ActiveRunStatus` / `ActiveInvocationId`,再用 + `SubscribeRunEvents(AfterSeqId=LatestSeqId)` 续订。 + diff --git a/docs-site/content/docs/references/security-boundaries.en.mdx b/docs-site/content/docs/references/security-boundaries.en.mdx index 15a2200c..9b25d1b1 100644 --- a/docs-site/content/docs/references/security-boundaries.en.mdx +++ b/docs-site/content/docs/references/security-boundaries.en.mdx @@ -14,7 +14,7 @@ The public repository is expected to contain: - Python SDK and CLI source. - local runtime adapters. - generated static assets required by `agentengine web`. -- curated public docs under `public-docs/`. +- curated public docs under `docs-site/`. - public CI, release checks, and contribution policy. It must not contain: diff --git a/docs-site/content/docs/references/troubleshooting.en.mdx b/docs-site/content/docs/references/troubleshooting.en.mdx index 3cc3bac2..74364718 100644 --- a/docs-site/content/docs/references/troubleshooting.en.mdx +++ b/docs-site/content/docs/references/troubleshooting.en.mdx @@ -234,14 +234,14 @@ short: Run: ```bash -uv run --extra dev python -m mkdocs build --strict +make docs-site-build ``` Common causes: - broken relative links. -- page added to `nav` but not created. -- duplicate Markdown extension entries. +- page added to the docs tree but not linked correctly. +- invalid MDX or duplicated frontmatter fields. - generated files under `site/` accidentally committed. - docs referencing private files excluded from the public repository. diff --git a/docs-site/next.config.mjs b/docs-site/next.config.mjs index 1999fe94..d332fb89 100644 --- a/docs-site/next.config.mjs +++ b/docs-site/next.config.mjs @@ -2,7 +2,7 @@ import { createMDX } from 'fumadocs-mdx/next'; const withMDX = createMDX(); -// For GitHub Pages project sites, set NEXT_PUBLIC_BASE_PATH=/veadk-python at +// For GitHub Pages project sites, set NEXT_PUBLIC_BASE_PATH=/ksadk-python at // build time. Left empty for local dev so the site is served from `/`. const basePath = process.env.NEXT_PUBLIC_BASE_PATH || ''; diff --git a/docs-site/scripts/build-static.mjs b/docs-site/scripts/build-static.mjs index 5b92dd66..9c6af6a6 100644 --- a/docs-site/scripts/build-static.mjs +++ b/docs-site/scripts/build-static.mjs @@ -6,7 +6,7 @@ // export build, restore it, and write a `.nojekyll` file so GitHub Pages serves // the `_next/` directory. // -// Usage: NEXT_PUBLIC_BASE_PATH=/veadk-python node scripts/build-static.mjs +// Usage: NEXT_PUBLIC_BASE_PATH=/ksadk-python node scripts/build-static.mjs import { execSync } from 'node:child_process'; import { existsSync, renameSync, writeFileSync, mkdirSync } from 'node:fs'; diff --git a/docs-site/source.config.ts b/docs-site/source.config.ts index 886f822a..d20a2164 100644 --- a/docs-site/source.config.ts +++ b/docs-site/source.config.ts @@ -20,7 +20,7 @@ export const docs = defineDocs({ }, }); -// GitHub Pages serves under a base path (e.g. /veadk-python). Next prefixes +// GitHub Pages serves under a base path (e.g. /ksadk-python). Next prefixes // `_next/` assets and next/link hrefs automatically, but NOT raw // from markdown. Prepend the base path to absolute image sources at build time. const BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH || ''; diff --git "a/docs/guides/Agent \345\274\200\345\217\221\350\200\205\344\270\212\344\270\213\346\226\207\346\216\245\345\205\245\346\214\207\345\215\227.md" "b/docs/guides/Agent \345\274\200\345\217\221\350\200\205\344\270\212\344\270\213\346\226\207\346\216\245\345\205\245\346\214\207\345\215\227.md" deleted file mode 100644 index 1910394f..00000000 --- "a/docs/guides/Agent \345\274\200\345\217\221\350\200\205\344\270\212\344\270\213\346\226\207\346\216\245\345\205\245\346\214\207\345\215\227.md" +++ /dev/null @@ -1,567 +0,0 @@ -# Agent 开发者上下文接入指南 - -本文档面向使用 `ksadk-python` 开发 Agent 业务逻辑的开发者,重点说明: - -- 通过 `/v1/responses`、Hosted UI 或 `RunAgent` 调用时,运行时会把哪些上下文喂给 Agent -- LangGraph、LangChain、ADK 这三类主路径里,开发者应该从哪里拿: - - 当前输入 - - 多轮历史 - - 图片 / 附件上下文 - - OCR / 文档抽取结果 - - 平台上下文 - - 知识库与长期记忆上下文 - - 模型能力元数据 -- 什么时候应该解析 `HumanMessage`,什么时候不应该 - -本文档不讨论云端部署、权限治理或外部产品逻辑;只聚焦 agent 业务代码如何接上下文。 - -## 1. 先给结论 - -如果你是 Agent 业务开发者,**最推荐的接入方式不是直接拆 `messages[-1]`,而是使用 `ksadk_prepare_state()` / `ksadk_prepare_input()` 明确接收平台传入的标准上下文。** - -优先级建议: - -1. `LangGraph` - - 自定义 `ksadk_prepare_state(payload, session_context)` - - 具体项目结构、interrupt / resume、Responses approval 写法见 [LangGraph开发最佳实践](./LangGraph开发最佳实践.md) -2. `LangChain` - - 自定义 `ksadk_prepare_input(payload, session_context)` -3. `ADK` - - 使用 runner 已构造好的 `Part` / session 能力 -4. 只有在你确实做 messages-native agent 时,再直接解析 `HumanMessage` - -## 2. 运行时到底会给 Agent 什么 - -进入 runner 前,`ksadk` 会把一次请求整理成标准运行输入。核心字段包括: - -- `input` -- `history` -- `input_content` -- `input_messages` -- `input_parts` -- `attachments` -- `attachment_results` -- `current_attachments` -- `current_attachment_results` -- `has_current_files` -- `model` -- `model_metadata` -- `platform_context` -- `kb_context` -- `memory_context` -- `instructions` - -这些字段不是每个 framework 都以同样方式消费,但它们是当前平台提供给 Agent 的标准上下文来源。 -`input_content` / `input_messages` 是 runner 默认 canonical 输入,使用 OpenAI Responses 风格 content blocks;`input_parts` 是 legacy/internal normalized parts,用于兼容已有 runner。`attachments`、`current_attachments`、`has_current_files` 等是 KsADK runner payload 扩展上下文,不属于 OpenAI 官方请求或响应字段。 - -对外协议不混写:`/v1/responses` 接收 OpenAI Responses 风格 `input_text / input_image / input_file`;`/v1/chat/completions` 保持 Chat Completions 风格 `messages`,官方多模态块优先使用 `text / image_url`。进入 runner 前,两条入口都会投影到同一套 `input_content / input_messages`,并额外生成兼容用 `input_parts`。KsADK 兼容扩展 `inlineData / fileData` 仍可用于老客户端,但不把它们声明成 OpenAI Chat 官方字段。 - -### 2.1 字段说明 - -| 字段 | 类型 | 说明 | -| --- | --- | --- | -| `input` | `str` | 当前这一轮的标准文本输入 | -| `history` | `list[dict]` | 当前多轮会话历史,已经过 transcript 投影 / compaction | -| `input_content` | `list[dict]` | 当前 user turn 的 OpenAI Responses content blocks,例如 `input_text / input_image / input_file` | -| `input_messages` | `list[dict]` | OpenAI Responses 风格 message/input items;需要完整 role/content 结构的 runner 优先读这里 | -| `input_parts` | `list[dict]` | legacy/internal 归一化片段,保留 `text / inlineData / fileData`;用于兼容已有 runner,不作为 OpenAI 官方协议字段暴露 | -| `invocation_id` | `str` | 当前运行 ID。0.6.5 起写入 runner payload,用于 transcript 分组、`SubscribeRunEvents` 续订、`CancelRun` 以及 trace 串联 | -| `attachments` | `list[dict]` | 当前会话最近有效附件上下文,兼容历史 fallback,不应用来判断本轮是否传文件 | -| `attachment_results` | `list[dict]` | 最近有效附件理解结果,例如 OCR / 文本提取 | -| `current_attachments` | `list[dict]` | 当前最新 user turn 解析出的附件列表,不包含历史 fallback | -| `current_attachment_results` | `list[dict]` | 当前最新 user turn 的附件理解结果 | -| `has_current_files` | `bool` | 当前最新 user turn 是否包含归一化后的 `inlineData` 或 `fileData`,包括 OpenAI `input_image / input_file` | -| `model` | `str` | 当前请求显式使用的模型名 | -| `model_metadata` | `dict` | 模型元数据,可能来自请求显式传入,也可能来自上游 `/v1/models` 自动解析 | -| `platform_context` | `dict` | 平台上下文,含 `agent_id / user_id / session_id / account_id` | -| `kb_context` | `dict` | 知识库召回构造的上下文 | -| `memory_context` | `dict` | 长期记忆构造的上下文 | -| `instructions` | `str` | 本轮额外系统/开发者指令 | - -## 3. 多轮会话历史是怎么来的 - -平台不是要求前端每次把完整对话都重传回来,而是通过: - -- `session_id` -- conversation event store -- transcript 投影 -- 必要时 compaction - -来恢复当前会话历史。 - -你在 Agent 业务里看到的 `history`,通常已经不是“原始数据库所有消息”,而是: - -- 更早历史被压缩成 summary checkpoint -- 最近若干轮保持原始 user / assistant 消息 -- 特殊事件(tool call / tool result / approval / attachment)保留成可解释文本占位 - -所以: - -- 如果你只需要“语义历史”,用 `history` -- 如果你需要 OpenAI Responses 风格当前输入结构,优先看 `input_content / input_messages` -- 如果你需要兼容老 runner 的内部结构,再看 `input_parts` -- 如果你需要附件理解结果,看 `current_attachments / current_attachment_results / attachments / attachment_results` - -## 4. 图片 / 附件上下文怎么来的 - -### 4.1 `attachments` - -`attachments` 是当前会话最近有效附件上下文。它可能来自当前轮,也可能来自同一 session 中最近一次带附件的 user turn,主要用于“继续围绕上个附件追问”的兼容场景。 - -如果业务需要判断“本次问答是否带文件”,不要看 `attachments`,直接看 `has_current_files`;如果要当前轮附件列表,看 `current_attachments`。 - -典型字段: - -```python -{ - "display_name": "diagram.png", - "mime_type": "image/png", - "transport": "reference", # 或 "inline" - "file_uri": "ksadk-upload://...", - "data": "", # 仅 transport="inline" 时存在 - "size_bytes": 1356, - "storage_path": "/tmp/.../diagram.png", - "is_text": False, -} -``` - -说明: - -- `transport="inline"`:调用方直接传了 `inlineData` -- `transport="reference"`:调用方先 `UploadFile`,再传 `fileData.fileUri` - -### 4.2 当前轮文件判断 - -推荐判断方式: - -```python -has_file = bool(payload.get("has_current_files")) -current_files = payload.get("current_attachments", []) -``` - -如果需要兼容旧版本 KsADK,可以退回检查 `input_parts`: - -```python -has_file = any( - isinstance(part, dict) - and (part.get("inlineData") is not None or part.get("fileData") is not None) - for part in payload.get("input_parts") or [] -) -``` - -### 4.3 `attachment_results` - -这是平台附件理解管线产出的结果,比 `attachments` 更适合业务逻辑消费。典型字段: - -```python -{ - "display_name": "diagram.png", - "mime_type": "image/png", - "transport": "reference", - "file_uri": "ksadk-upload://...", - "size_bytes": 1356, - "kind": "image", - "status": "ok", - "warnings": [], - "extraction_method": "image_ocr", - "text_excerpt": "KIMI E2E", - "text": "KIMI E2E", - "image": {"ocr_engine": "rapidocr_onnxruntime"} -} -``` - -推荐使用方式: - -- 想拿当前轮 OCR 文本:读 `current_attachment_results[*]["text"]` -- 想支持“围绕上次附件继续追问”:读 `attachment_results[*]["text"]` -- 想区分图片 / 文档 / 压缩包:读 `kind` -- 想看平台有没有降级或失败:读 `status / warnings / extraction_method` - -## 5. 模型能力元数据怎么来的 - -`model_metadata` 的来源优先级是: - -1. 请求里显式传入的 `model_metadata` -2. runtime 用 `OPENAI_BASE_URL / OPENAI_API_KEY` 查询上游 `/v1/models` -3. 本地默认兜底 - -当前最值得关注的字段是: - -```python -{ - "id": "kimi-k2.7-code", - "architecture": { - "input_modalities": ["文字", "图片", "视频"], - "output_modalities": ["文字"] - }, - "capabilities": { - "multimodal_input_image": True, - "multimodal_input_video": True, - "multimodal_input_file": False, - "function_calling": True, - "structured_output": True, - "context_caching": True - }, - "limits": {...}, - "pricing": {...} -} -``` - -!!! info "多模态默认模型来源" - 自策略 v1 起,运行时在请求未显式指定模型时,会按平台多模态默认模型策略挑选默认模型(含图片/视频输入能力的优先模型)。`model_metadata` 仍按下方来源优先级填充,业务侧不需要自己判断"默认模型是否多模态",直接读 `capabilities.multimodal_input_*` 即可。 - -业务代码里最常用的判断是: - -```python -supports_image = bool( - (((model_metadata or {}).get("capabilities") or {}).get("multimodal_input_image")) -) -``` - -## 6. LangGraph 怎么拿上下文 - -LangGraph 的完整开发写法已经内化到框架专属文档: - -- [LangGraph开发最佳实践](./LangGraph开发最佳实践.md) - -这里只保留平台上下文接入的核心边界: - -- 默认 messages-based 图可以不写 hook,运行时会自动构造 `messages` state -- 自定义 state 图推荐显式暴露 `ksadk_prepare_state(payload, session_context)` -- `ksadk_prepare_state` 必须在 `agentengine.yaml` 的 `entry_point` 对应模块顶层可见 -- 附件、OCR、原始输入片段优先从 `payload` 读取 -- 会话历史、平台身份、知识库、长期记忆优先从 `session_context` 读取 -- LangGraph `interrupt()` 的恢复由平台协议层判断,业务代码不需要自己猜下一轮是否要 `Command(resume=...)` - -最小推荐写法: - -```python -def ksadk_prepare_state(payload: dict, session_context: dict) -> dict: - if session_context.get("is_resume"): - return payload.get("input") - - return { - "query": payload["input"], - "history": session_context["history"], - "attachments": payload.get("attachments", []), - "attachment_results": payload.get("attachment_results", []), - "current_attachments": payload.get("current_attachments", []), - "current_attachment_results": payload.get("current_attachment_results", []), - "has_current_files": payload.get("has_current_files", False), - "platform_context": session_context.get("platform_context"), - "kb_context": session_context.get("kb_context"), - "memory_context": session_context.get("memory_context"), - "model_metadata": payload.get("model_metadata", {}), - } -``` - -如果涉及 human-in-the-loop / MCP 工具审批 / `interrupt()` 断点恢复,请优先阅读 [LangGraph开发最佳实践](./LangGraph开发最佳实践.md) 的 interrupt 与 Responses approval 章节。 - -## 7. LangChain 怎么拿上下文 - -推荐定义: - -```python -def ksadk_prepare_input(payload: dict, session_context: dict) -> dict: - return { - "question": payload["input"], - "history": session_context["history"], - "attachments": payload.get("attachments", []), - "attachment_results": payload.get("attachment_results", []), - "current_attachments": payload.get("current_attachments", []), - "current_attachment_results": payload.get("current_attachment_results", []), - "has_current_files": payload.get("has_current_files", False), - "input_content": payload.get("input_content", []), - "input_messages": payload.get("input_messages", []), - "input_parts": payload.get("input_parts", []), - "model_metadata": payload.get("model_metadata"), - } -``` - -然后业务链或 runnable 自己决定: - -- 要不要把 `history` 变成 prompt -- 要不要优先消费 `attachment_results[*]["text"]` -- 要不要在支持多模态的模型上直接消费 `input_content / input_messages` -- 是否需要兼容旧 runner 的 `input_parts` - -说明: - -- LangChain 当前不保证所有 agent 自动原生图片直通 -- 所以如果你要做稳定多模态 LangChain agent,建议自己在 hook 里显式处理 - -## 8. ADK 怎么拿上下文 - -ADK 路径下,平台会优先把附件转成底层 SDK 的 `Part`: - -- 文本 -> `types.Part(text=...)` -- 图片 / 其他附件 -> `types.Part.from_bytes(...)` - -所以对支持原生多模态的 ADK 模型,图片会优先按 bytes part 进入底层 SDK。 - -ADK 侧开发者通常更应该依赖: - -- ADK 自己的 session 机制 -- 当前传进来的 `new_message.parts` -- 平台附加的 state delta - -## 9. 通用运行时上下文:`get_current_invocation_context()` - -如果你在 tool、helper 或平台公共逻辑里,希望不通过 state/hook 也能拿到当前调用上下文,可以用: - -```python -from ksadk.runtime_context import get_current_invocation_context - -ctx = get_current_invocation_context() -if ctx: - print(ctx.agent_id) - print(ctx.user_id) - print(ctx.account_id) - print(ctx.session_id) - print(ctx.invocation_id) - print(ctx.model) - print(ctx.input_content) - print(ctx.input_messages) - print(ctx.attachments) - print(ctx.attachment_results) - print(ctx.current_attachments) - print(ctx.has_current_files) - print(ctx.kb_context) - print(ctx.memory_context) - print(ctx.model_metadata) -``` - -`PlatformInvocationContext` 当前包含: - -- `agent_id` -- `user_id` -- `account_id`(默认空串 `""`,未携带平台身份时为空) -- `session_id` -- `invocation_id` -- `history` -- `input_content` -- `input_messages` -- `input_parts` -- `attachments` -- `attachment_results` -- `current_attachments` -- `current_attachment_results` -- `has_current_files` -- `runner_type` -- `model` -- `kb_context` -- `memory_context` -- `model_metadata` - -### 9.1 不抛异常的安全读取入口(0.6.5 新增) - -`get_current_invocation_context()` 在没有上下文时返回 `None`,调用方仍需自己判空。如果你只想要某个具体字段、且希望在没有上下文时拿到一个显式默认值而不是 `None`,可以用下面三个安全入口: - -```python -from ksadk.runtime_context import ( - get_current_invocation_context_or_default, - get_current_user_id, - get_current_account_id, -) - -# 无上下文时返回一个空 PlatformInvocationContext 实例,不抛异常 -ctx = get_current_invocation_context_or_default() - -# 无上下文或字段缺失时返回传入的 default,不抛异常 -user_id = get_current_user_id(default="") -account_id = get_current_account_id(default="") -``` - -!!! tip "什么时候用安全入口" - - 在 tool / 回调 / 后台任务里取身份字段,不希望因为没有运行上下文就中断流程 - - 写库、打日志、埋点等"best-effort"消费场景,缺失身份时用空串兜底即可 - - 需要拿到完整 ctx 做多字段消费时,优先用 `get_current_invocation_context_or_default()` 拿到一个非空实例,再按字段读 - -!!! warning "不要用这些入口做权限决策" - 这三个入口返回的是"尽力而为"的运行上下文,适合观测、埋点、默认参数;涉及鉴权或租户隔离的判断仍应走平台协议层显式传入的 `platform_context`,不要只依赖 `get_current_account_id()` 的默认值。 - -### 9.2 Hosted 附件统一解析:`ae-upload://` - -Hosted 部署下,用户上传的附件会以 `ae-upload://` 协议的引用地址下发到 runner payload。运行时会按当前部署形态把这类引用统一解析成可消费的 `AttachmentContent`,业务代码不需要自己处理 `ae-upload://` 前缀: - -- 调用方先走平台 `UploadFile`,得到 `ae-upload://...` 引用 -- runner 进入前,平台把引用解析为 `AttachmentContent`(含 bytes 或本地可读路径) -- 业务代码统一从 `current_attachments / attachments` 消费,`transport` 字段标记是 `inline` 还是 `reference` - -如果业务代码需要主动下载某个附件引用(例如从 `attachment_results` 拿到 `file_uri` 后再取原始字节),使用 `AttachmentContent` 下载入口: - -```python -from ksadk.attachments import resolve_attachment_content - -# file_uri 可以是 ae-upload://... / ksadk-upload://... / https://... -content = resolve_attachment_content(file_uri) -# content.bytes: 原始字节 -# content.mime_type: 归一化后的 mime -# content.display_name: 展示名 -``` - -!!! info "公开口径" - `ae-upload://` 是平台 Hosted 附件引用协议,具体 Host 端点不对外暴露;示例里统一用 `example.com` 占位,业务代码只认协议前缀和 `AttachmentContent` 入口。 - -### 9.3 `ModelMetadata` 透传与多模态能力判断 - -`model_metadata` 会在 runner payload 和 `PlatformInvocationContext` 之间透传:请求显式传入的 `model_metadata` 优先,未传入时由 runtime 查询上游 `/v1/models` 自动解析并填充。因此无论 LangGraph / LangChain / ADK 哪条路径,业务代码都可以用同一种方式判断模型多模态能力: - -```python -from ksadk.runtime_context import get_current_invocation_context_or_default - -ctx = get_current_invocation_context_or_default() -capabilities = (ctx.model_metadata or {}).get("capabilities") or {} -supports_image = bool(capabilities.get("multimodal_input_image")) -supports_video = bool(capabilities.get("multimodal_input_video")) -supports_file = bool(capabilities.get("multimodal_input_file")) -``` - -多模态分流建议: - -- 支持图片/视频输入:优先消费 `input_content / input_messages` 中的原生多模态块 -- 不支持原生多模态:退回 `current_attachment_results[*]["text"]` 走 OCR / 文本提取降级 -- 需要判断当前轮是否真的带了文件:读 `has_current_files`,不要用 `attachments` 判断 - -## 10. `HumanMessage` 什么时候需要自己解析 - -只有在你明确做的是 messages-native graph / prompt pipeline 时,才建议自己拆 `HumanMessage`。 - -### 10.1 纯文本模型 - -```python -HumanMessage(content="请分析这张图片\\n\\n[上传文件引用: ...]") -``` - -### 10.2 原生多模态模型 - -```python -HumanMessage( - content=[ - {"type": "text", "text": "请分析这张图片"}, - {"type": "image_url", "image_url": {"url": "data:image/png;base64,..."}} - ] -) -``` - -### 10.3 推荐解析方式 - -```python -def parse_human_message_content(content): - result = {"texts": [], "images": []} - - if isinstance(content, str): - result["texts"].append(content) - return result - - if isinstance(content, list): - for block in content: - if not isinstance(block, dict): - continue - if block.get("type") == "text": - result["texts"].append(str(block.get("text") or "")) - elif block.get("type") == "image_url": - result["images"].append(str((block.get("image_url") or {}).get("url") or "")) - return result -``` - -但再次强调: - -- 如果你只是想拿图片 OCR 文本、附件摘要、平台上下文 -- 不推荐优先拆 `HumanMessage` -- 更推荐直接用 `has_current_files / current_attachments / attachment_results / session_context` - -## 11. 常见接入模式 - -### 模式 A:只关心最终文本输入 - -适合简单问答 agent: - -```python -def ksadk_prepare_state(payload: dict, session_context: dict) -> dict: - return {"query": payload["input"]} -``` - -### 模式 B:同时关心附件 OCR - -适合简历、票据、截图理解类 agent: - -```python -def ksadk_prepare_state(payload: dict, session_context: dict) -> dict: - results = payload.get("current_attachment_results") or payload.get("attachment_results") or [] - attachment_texts = [ - item.get("text", "") - for item in results - if isinstance(item, dict) and item.get("text") - ] - return { - "query": payload["input"], - "attachment_texts": attachment_texts, - } -``` - -### 模式 C:按模型能力分支 - -适合既支持多模态模型、又要兼容纯文本模型的 agent: - -```python -def ksadk_prepare_state(payload: dict, session_context: dict) -> dict: - model_metadata = payload.get("model_metadata") or {} - capabilities = model_metadata.get("capabilities") or {} - return { - "query": payload["input"], - "supports_image": bool(capabilities.get("multimodal_input_image")), - "attachments": payload.get("attachments", []), - "attachment_results": payload.get("attachment_results", []), - "current_attachments": payload.get("current_attachments", []), - "has_current_files": payload.get("has_current_files", False), - } -``` - -## 12. 常见坑 - -### 12.1 把 `HumanMessage.content` 当成永远是字符串 - -这是最常见坑。多模态模型下,它可能是 `list[block]`。 - -### 12.2 用 `attachments` 判断当前轮是否传文件 - -`attachments` 是最近有效附件上下文,可能来自历史 fallback。判断当前轮是否传文件用 `has_current_files`,当前轮附件列表用 `current_attachments`。 - -### 12.3 想拿业务上下文,却只盯着 `messages[-1]` - -更稳的做法是: - -- `history` 看多轮语义 -- `has_current_files / current_attachments` 看当前轮文件 -- `attachments / attachment_results` 看最近有效文件上下文和理解结果 -- `platform_context` 看平台身份 -- `kb_context / memory_context` 看召回上下文 - -### 12.4 以为客户端每轮都会重传完整历史 - -不会。多轮历史恢复主要靠 `session_id + server 侧 event store`。 - -### 12.5 以为图片一定会原生直通 - -不会。是否走原生图片输入取决于: - -- 请求显式 `model_metadata` -- 或 runtime 自动查到的模型目录能力 - -纯文本模型会走附件/OCR/文本降级。 - -## 13. 推荐实践 - -1. 对 LangGraph / LangChain,优先写 `ksadk_prepare_state()` / `ksadk_prepare_input()` -2. 只有 messages-native agent 才去直接拆 `HumanMessage` -3. 想理解图片内容时,优先读 `attachment_results` -4. 想做多模态分流时,读 `model_metadata.capabilities` -5. 想依赖多轮历史时,确保前端持续复用 `session_id` - -## 14. 相关文档 - -- [LangGraph开发最佳实践](./LangGraph开发最佳实践.md) -- [远程Agent运行时接口说明](../reference/远程Agent运行时接口说明.md) -- [ksadk使用文档](./ksadk使用文档.md) -- [ksadk技术设计](../reference/ksadk技术设计.md) diff --git "a/docs/guides/DeepAgents\350\257\264\346\230\216.md" "b/docs/guides/DeepAgents\350\257\264\346\230\216.md" deleted file mode 100644 index d18f4d44..00000000 --- "a/docs/guides/DeepAgents\350\257\264\346\230\216.md" +++ /dev/null @@ -1,54 +0,0 @@ -# DeepAgents说明 - -本文档说明 `ksadk` 当前对 `deepagents` 框架的接入方式。 - -## 1. 设计原则 - -- 最小适配:尽量复用现有 LangGraph 运行时路径 -- 统一入口:CLI、框架识别、构建依赖和部署参数都走 `deepagents` - -## 2. 当前实现 - -### 2.1 框架识别 - -当前支持: - -- 显式配置 `framework: deepagents` -- 代码特征识别: - - `from deepagents import ...` - - `import deepagents` - - `create_deep_agent(...)` - -### 2.2 运行时 - -当前 `DeepAgentsRunner` 沿用 LangGraph 路径,原因是 `create_deep_agent()` 返回 LangGraph 图对象,天然兼容现有 invoke / stream 语义。 - -### 2.3 平台能力 - -`deepagents` 当前与 `langgraph` 一样可以复用: - -- KB 检索能力 -- LTM 环境变量注入 -- 默认 storage 挂载基座 `/home/node/.agentengine` -- Hosted WorkspaceFiles capability - -## 3. 初始化示例 - -```bash -agentengine init my-agent -f deepagents -``` - -生成项目示意: - -```python -from deepagents import create_deep_agent -from langchain_openai import ChatOpenAI - -llm = ChatOpenAI(...) -root_agent = create_deep_agent(model=llm) -``` - -## 4. 相关文档 - -- [ksadk使用文档](./ksadk使用文档.md) -- [ksadk技术设计](../reference/ksadk技术设计.md) diff --git "a/docs/guides/LangGraph\345\274\200\345\217\221\346\234\200\344\275\263\345\256\236\350\267\265.md" "b/docs/guides/LangGraph\345\274\200\345\217\221\346\234\200\344\275\263\345\256\236\350\267\265.md" deleted file mode 100644 index 2b6943d9..00000000 --- "a/docs/guides/LangGraph\345\274\200\345\217\221\346\234\200\344\275\263\345\256\236\350\267\265.md" +++ /dev/null @@ -1,682 +0,0 @@ -# LangGraph 开发最佳实践 - -本文档面向使用 `ksadk-python` 开发 LangGraph Agent 的业务开发者,重点说明: - -- LangGraph 工程应该如何暴露 `root_agent` -- `ksadk_prepare_state(payload, session_context)` 应该放在哪里、怎么写 -- 平台上下文、附件、OCR、知识库、长期记忆如何进入 LangGraph state -- `interrupt()` / `Command(resume=...)` 在 AgentEngine 运行时里的职责边界 -- `/v1/responses` 下 MCP approval 与通用 human-in-the-loop 的恢复写法 - -本文档只讨论业务代码接入方式,不展开远程部署、网关鉴权和托管 UI 协议。接口字段 contract 见 [远程Agent运行时接口说明](../reference/远程Agent运行时接口说明.md)。 - -## 1. 推荐结论 - -LangGraph Agent 推荐按以下原则接入: - -1. `agentengine.yaml` 的 `framework` 写 `langgraph` -2. `entry_point` 指向真正加载 Agent 的 Python 模块 -3. 在 `entry_point` 模块顶层暴露 `root_agent` -4. 自定义 state 图优先写 `ksadk_prepare_state(payload, session_context)` -5. 业务节点只消费自己定义的 state 字段,不直接解析平台 event store -6. 如果使用 `interrupt()`,业务代码只定义暂停点和 resume payload 的业务含义 -7. 是否把下一次请求转成 `Command(resume=...)` 由平台协议层决定,不由业务代码猜测 - -`LangGraphRunner` 的设计目标是薄适配:尽量透传 LangGraph 原生能力,只在 `resume=True` 时把输入包装成 `langgraph.types.Command(resume=...)`。 - -## 2. 推荐目录结构 - -一个最小但清晰的项目可以这样组织: - -```text -my_agent/ - agent.py - state.py - nodes.py - prompts.py -agentengine.yaml -requirements.txt -``` - -其中: - -- `agent.py`:组装 `StateGraph`,暴露 `root_agent`,并 re-export `ksadk_prepare_state` -- `state.py`:定义 `TypedDict` / reducer -- `nodes.py`:放 LangGraph 节点逻辑 -- `prompts.py`:放 prompt 模板或系统指令 - -简单项目也可以只保留一个 `agent.py`。关键不是文件数量,而是 `entry_point` 模块必须能被 `ksadk` 直接加载。 - -## 3. agentengine.yaml - -示例: - -```yaml -name: langgraph-demo -framework: langgraph -entry_point: my_agent/agent.py -agent_variable: root_agent -``` - -字段说明: - -| 字段 | 说明 | -| --- | --- | -| `framework` | 必须为 `langgraph` | -| `entry_point` | Python 模块文件路径,运行时会加载这个模块 | -| `agent_variable` | 模块里的 LangGraph compiled graph 变量名,通常是 `root_agent` | - -## 4. root_agent 暴露方式 - -`root_agent` 应该是 LangGraph 编译后的图: - -```python -from langgraph.graph import END, StateGraph - -from .state import AgentState -from .nodes import answer -from .state_adapter import ksadk_prepare_state - - -workflow = StateGraph(AgentState) -workflow.add_node("answer", answer) -workflow.set_entry_point("answer") -workflow.add_edge("answer", END) - -root_agent = workflow.compile() -``` - -如果你把 `ksadk_prepare_state` 放在别的文件里,必须在 `entry_point` 模块 re-export: - -```python -from .state_adapter import ksadk_prepare_state -``` - -`ksadk` 不会全项目扫描这个函数。它只会在 `entry_point` 对应模块上执行类似下面的查找: - -```python -getattr(module, "ksadk_prepare_state", None) -``` - -所以以下写法不推荐: - -- 放在别的文件里,但没有从 `entry_point` 模块导入 -- 写成类方法 -- 写在函数内部 -- 运行时动态创建,但模块导入完成后顶层属性上拿不到 - -## 5. 平台给 LangGraph 的标准输入 - -进入 LangGraphRunner 前,平台会把一次请求整理成标准运行输入。常见字段如下: - -| 字段 | 类型 | 说明 | -| --- | --- | --- | -| `input` | `str` 或 `dict` | 当前输入;普通请求是文本,resume 请求是结构化恢复 payload | -| `history` | `list[dict]` | 多轮历史,已经过 transcript 投影和必要的 compaction | -| `input_content` | `list[dict]` | 当前 user turn 的 OpenAI Responses content blocks,例如 `input_text / input_image / input_file` | -| `input_messages` | `list[dict]` | OpenAI Responses 风格 message/input items;需要完整 role/content 结构时优先读这里 | -| `input_parts` | `list[dict]` | legacy/internal 归一化片段,保留 `text / inlineData / fileData`;用于兼容已有 runner | -| `attachments` | `list[dict]` | 当前会话最近有效附件上下文,兼容历史 fallback | -| `attachment_results` | `list[dict]` | 最近有效 OCR / 文本抽取 / 附件理解结果 | -| `current_attachments` | `list[dict]` | 当前最新 user turn 的附件列表,不包含历史 fallback | -| `current_attachment_results` | `list[dict]` | 当前最新 user turn 的附件理解结果 | -| `has_current_files` | `bool` | 当前最新 user turn 是否包含归一化后的 `inlineData` 或 `fileData`,包括 OpenAI `input_image / input_file` | -| `model` | `str` | 本轮显式模型名 | -| `model_metadata` | `dict` | 模型能力元数据 | -| `platform_context` | `dict` | `agent_id / user_id / account_id / session_id` 等平台身份(`PlatformInvocationContext.to_payload()`) | -| `kb_context` | `dict` | 知识库召回上下文 | -| `memory_context` | `dict` | 长期记忆上下文 | -| `instructions` | `str` | 请求级系统/开发者指令 | -| `resume` | `bool` | 平台判断本轮是否为断点恢复 | -| `invocation_id` | `str` | 0.6.5 新增。本次 invocation 的唯一标识,对应 `ToolExecutionContext.invocation_id`,可用于日志/trace 关联;透传给 `ksadk_prepare_state(payload, ...)` 的 `payload["invocation_id"]` | - -`input_content` / `input_messages` 是 runner 默认 canonical 输入,沿用 OpenAI Responses content block 形态;`input_parts` 是 legacy/internal normalized parts。`attachments / current_attachments / has_current_files` 等字段是 KsADK 提供给 LangGraph runner 的运行时上下文扩展,不属于 OpenAI 官方请求或响应字段。 - -对外协议不混写:`/v1/responses` 按 Responses 语义接收 `input_text / input_image / input_file`;`/v1/chat/completions` 保持 Chat Completions 语义,官方图片块使用 `text / image_url`。进入 runner 前,两条入口都会转换为 `input_content / input_messages`,同时生成兼容用 `input_parts`。KsADK 仍兼容 `inlineData / fileData` 老输入,但不要把它们当成 OpenAI Chat 官方字段。 - -## 6. 默认 messages-based 图 - -如果没有定义 `ksadk_prepare_state()`,运行时会自动把请求转换成 LangGraph 常见的 messages state: - -```python -{ - "attachments": [...], - "attachment_results": [...], - "current_attachments": [...], - "current_attachment_results": [...], - "has_current_files": True, - "input_content": [...], - "input_messages": [...], - "input_parts": [...], - "model_metadata": {...}, - "messages": [ - SystemMessage(...), - HumanMessage(...), - AIMessage(...), - HumanMessage(...), - ], -} -``` - -说明: - -- `messages` 是默认主上下文 -- `input_content / input_messages / input_parts / attachments / attachment_results / current_attachments / current_attachment_results / has_current_files` 保留在 state 顶层 -- 如果模型支持原生图片输入,最后一条 `HumanMessage.content` 可能是多模态 block 列表 -- 如果模型不支持原生图片输入,最后一条 `HumanMessage.content` 通常是字符串 - -messages-based 图适合快速迁移。但如果你的业务需要稳定消费附件、OCR、平台身份、知识库或长期记忆,推荐显式写 `ksadk_prepare_state()`。 - -## 7. 推荐:自定义 ksadk_prepare_state - -推荐在 `ksadk_prepare_state(payload, session_context)` 里把平台输入投影成业务 state。 - -```python -def ksadk_prepare_state(payload: dict, session_context: dict) -> dict: - if session_context.get("is_resume"): - return payload.get("input") - - return { - "query": payload["input"], - "history": session_context["history"], - "attachments": payload.get("attachments", []), - "attachment_results": payload.get("attachment_results", []), - "current_attachments": payload.get("current_attachments", []), - "current_attachment_results": payload.get("current_attachment_results", []), - "has_current_files": payload.get("has_current_files", False), - "input_content": payload.get("input_content", []), - "input_messages": payload.get("input_messages", []), - "input_parts": payload.get("input_parts", []), - "platform_context": session_context.get("platform_context"), - "kb_context": session_context.get("kb_context"), - "memory_context": session_context.get("memory_context"), - "model_metadata": payload.get("model_metadata", {}), - } -``` - -字段来源建议: - -| 你想拿什么 | 推荐来源 | -| --- | --- | -| 当前用户输入 | `payload["input"]` | -| 当前输入 OpenAI canonical content | `payload["input_content"]` | -| 当前输入 OpenAI canonical messages | `payload["input_messages"]` | -| 当前输入 legacy/internal parts | `payload["input_parts"]` | -| 当前轮是否带文件 | `payload["has_current_files"]` | -| 当前轮附件引用 | `payload["current_attachments"]` | -| 当前轮 OCR / 文档抽取结果 | `payload["current_attachment_results"]` | -| 最近有效附件上下文 | `payload["attachments"]` | -| 最近有效 OCR / 文档抽取结果 | `payload["attachment_results"]` | -| 会话历史 | `session_context["history"]` | -| 平台身份 | `session_context["platform_context"]` | -| account_id(计费/租户隔离) | `session_context["platform_context"]["account_id"]`,等价于 `PlatformInvocationContext.account_id` | -| invocation_id(本次调用标识) | `payload["invocation_id"]`,对应 `ToolExecutionContext.invocation_id` | -| 知识库上下文 | `session_context["kb_context"]` | -| 长期记忆上下文 | `session_context["memory_context"]` | -| 是否断点恢复 | `session_context["is_resume"]`,只建议在 adapter 中判断,用来返回 resume payload | - -不要在业务代码里读取平台内部 event store 来拼 history。平台已经把可喂给模型的历史投影成 `history`。 - -如果你的图使用 LangGraph `interrupt()`,`session_context["is_resume"]` 为 `True` 时,`ksadk_prepare_state` 的返回值会作为 `Command(resume=...)` 的值传回 interrupt 调用点,而不是作为新的 graph state 注入。因此推荐在 resume 分支直接返回 `payload["input"]`,不要继续返回完整业务 state。 - -!!! info "0.6.5 / 0.6.7 平台上下文字段" - `session_context["platform_context"]` 来自平台 `PlatformInvocationContext.to_payload()`,业务代码可通过它稳定拿到: - - - `agent_id` / `user_id` / `session_id`:运行时身份,用于多租户隔离、日志关联 - - `account_id`(`PlatformInvocationContext.account_id`):计费与租户归属,0.6.5 起在托管 runtime 里始终填充;本地裸跑可能为空字符串,消费时请用 `or ""` 兜底 - - `runner_type`:当前 runner 类型,例如 `langgraph` - - 本次 invocation 的唯一标识 `invocation_id` 不在 `platform_context` 内,而是作为 `payload["invocation_id"]` 透传给 `ksadk_prepare_state`,对应平台 `ToolExecutionContext.invocation_id`。需要把业务日志、trace span 或外部副作用(写库、调用下游)与单次请求关联时,优先读这个字段而不是自己生成 ID。 - - ```python - def ksadk_prepare_state(payload: dict, session_context: dict) -> dict: - platform_ctx = session_context.get("platform_context") or {} - return { - "query": payload.get("input", ""), - "account_id": str(platform_ctx.get("account_id") or ""), - "invocation_id": str(payload.get("invocation_id") or ""), - # ...其他字段 - } - ``` - -!!! tip "0.6.5 / 0.6.7 LangGraph checkpoint resume 保留 checkpoint_ns" - 当通过 `ResumeRun` 从历史 checkpoint 恢复运行时,LangGraph runner 会把 `framework_ref.langgraph.checkpoint_ns` 写回 `configurable.checkpoint_ns`: - - - **0.6.5**:runner 首次在 checkpoint resume 配置里保留 `checkpoint_ns`,此前多命名空间(subgraph)图回档会丢失命名空间上下文 - - **0.6.7**:runner 无条件保留 `checkpoint_ns`(即便上游未传也会补空串占位),并在 `run_checkpoint` 事件的 `framework_ref.langgraph.checkpoint_ns` 中回写,保证列表 / 预览 / 恢复三段链路一致 - - 业务代码不需要直接读 `checkpoint_ns`。如果你的图使用了 subgraph 并依赖命名空间隔离,恢复后 LangGraph 会自动定位到正确的 subgraph checkpoint。仅当你在自定义节点里手动调用 `agent.get_state(config)` / `agent.aget_state(config)` 做诊断时,才需要把 `checkpoint_ns` 一并带上。 - -!!! tip "0.6.5 / 0.6.7 time_travel ResumeMode 使用指导" - LangGraph runner 的 `RuntimeCapabilities.ResumeRun.ResumeMode` 声明为 `time_travel`,表示控制台可以选择任意历史 checkpoint 回档重放,而不是只能沿最新 invocation 续跑。使用要点: - - - 前端入口门控以 `GetAgentUiBootstrap.Capabilities.RuntimeCapabilities.Checkpoint` 与 `CheckpointResumeCapability` 为准,不要仅凭 `RunLifecycle.Resume` 判断 - - `ResumeMode=time_travel` 时优先展示 checkpoint 列表(`ListSessionCheckpoints`)让用户选点,再用 `GetCheckpointResumePreview` 预览、`ResumeRun` 恢复 - - 恢复语义是“同一 run 续跑”(`RunId` 不变),不是新建 run;终态 checkpoint 返回 `200 noop`,前端按正常完成态收敛 - - `time_travel` 依赖持久化 checkpointer(`postgres` / `sqlite`);`memory` 后端 `Scope=process_local` 不可恢复,控制台应隐藏恢复入口 - -## 8. 附件、OCR 和图片 - -`attachments` 更接近原始引用,但语义是最近有效附件上下文;如果只判断当前轮是否传了文件,请使用 `has_current_files` 或 `current_attachments`。 - -典型字段: - -```python -{ - "display_name": "diagram.png", - "mime_type": "image/png", - "transport": "reference", - "file_uri": "ksadk-upload://abc123.png", - "data": "", # 仅 transport="inline" 时存在 - "size_bytes": 1356, - "storage_path": "/tmp/.../diagram.png", - "is_text": False, -} -``` - -`attachment_results` 更适合业务逻辑消费,典型字段: - -```python -{ - "display_name": "diagram.png", - "mime_type": "image/png", - "kind": "image", - "status": "ok", - "extraction_method": "image_ocr", - "text": "KIMI E2E", - "text_excerpt": "KIMI E2E", -} -``` - -推荐模式: - -```python -def collect_attachment_texts(state: dict) -> list[str]: - results = state.get("current_attachment_results") or state.get("attachment_results") or [] - return [ - item.get("text", "") - for item in results - if isinstance(item, dict) and item.get("text") - ] -``` - -如果你要根据模型能力决定是否走原生多模态: - -```python -def supports_image_input(state: dict) -> bool: - model_metadata = state.get("model_metadata") or {} - capabilities = model_metadata.get("capabilities") or {} - return bool(capabilities.get("multimodal_input_image")) -``` - -如果只是需要当前轮图片 OCR 文本,不建议拆 `HumanMessage.content`,直接用 `current_attachment_results[*]["text"]` 更稳定;需要支持“继续分析上次附件”时再 fallback 到 `attachment_results`。 - -## 9. 知识库和长期记忆 - -平台可能按策略把知识库和长期记忆上下文注入: - -```python -kb_context = state.get("kb_context") or {} -memory_context = state.get("memory_context") or {} - -kb_text = kb_context.get("formatted_text", "") -memory_text = memory_context.get("formatted_text", "") -``` - -建议把它们当作外部上下文材料,而不是持久化状态源。业务图如果要写自己的记忆,应明确区分: - -- 平台长期记忆召回:`memory_context` -- LangGraph 图内部状态:你的 `AgentState` -- 业务数据库:你自己的外部存储 - -## 10. interrupt 与断点恢复 - -LangGraph 原生支持在图节点中调用 `interrupt()` 暂停,并在下一次 `invoke` / `stream` 时用 `Command(resume=...)` 恢复。 - -在 AgentEngine 运行时里,职责边界是: - -| 层级 | 职责 | -| --- | --- | -| LangGraph 业务代码 | 调用 `interrupt()`,定义暂停信息和 resume payload 的业务语义 | -| Responses / Hosted UI / API 层 | 接收用户审批或恢复输入,判断这是一次 resume | -| conversation runtime | 记录 `approval_request / approval_response`,向 runner 传 `resume=True` | -| LangGraphRunner | 薄适配:把 `resume=True` 转成 `Command(resume=...)` | - -如果你定义了 `ksadk_prepare_state`,resume 请求也会经过这个 hook。此时 hook 的返回值就是 `Command(resume=...)` 里的 `resume` 值。推荐写法是: - -```python -def ksadk_prepare_state(payload: dict, session_context: dict) -> dict: - if session_context.get("is_resume"): - return payload.get("input") - return build_normal_state(payload, session_context) -``` - -业务代码不应该: - -- 自己判断“上一轮是不是暂停了” -- 自己构造平台 event store 查询 -- 依赖 LangGraph 内部 `state.tasks[*].interrupts` 结构 -- 要求客户端直接传 Python `Command` - -业务代码应该: - -- 在节点中用 `interrupt(value)` 暂停 -- 让 `value` 包含前端或调用方需要展示的信息 -- 在恢复后从 `Command(resume=...)` 传回的值继续执行业务逻辑 - -## 11. MCP 工具审批:Responses 标准语义 - -当 interrupt 表示 MCP/tool approval 时,运行时会按 OpenAI Responses 风格输出 `mcp_approval_request`,并以 `response.incomplete` 结束本轮。 - -客户端恢复时,应调用 `/v1/responses`,传入同一个 `session_id`,并把 `input` 写成 `mcp_approval_response`: - -```json -{ - "session_id": "sess_xxx", - "previous_response_id": "resp_xxx", - "input": [ - { - "type": "mcp_approval_response", - "id": "mcprsp_xxx", - "approval_request_id": "appr_xxx", - "approve": true, - "reason": "approved by user" - } - ], - "stream": true -} -``` - -运行时会把它转换成 runner 输入: - -```python -{ - "session_id": "sess_xxx", - "resume": True, - "input": { - "type": "mcp_approval_response", - "id": "mcprsp_xxx", - "approval_request_id": "appr_xxx", - "approve": True, - "reason": "approved by user", - }, -} -``` - -LangGraphRunner 随后调用: - -```python -Command(resume={ - "type": "mcp_approval_response", - "id": "mcprsp_xxx", - "approval_request_id": "appr_xxx", - "approve": True, - "reason": "approved by user", -}) -``` - -注意: - -- `session_id` 是当前运行时定位 LangGraph thread 的关键字段 -- `previous_response_id` 按 Responses 语义保留到 metadata,但当前不能替代 `session_id` -- 客户端不需要知道 Python `Command` -- 业务代码只关心 resume payload 的业务含义 - -## 12. 泛化 human-in-the-loop:ksadk_resume - -如果 interrupt 不是 MCP/tool approval,而是普通人工确认、补充信息或业务分支选择,运行时会使用平台扩展事件: - -- 流式事件:`response.ksadk.approval_request` -- 结束事件:`response.incomplete` -- `incomplete_details.reason`: `approval_required` -- `incomplete_details.ksadk_interrupt`: 原始 interrupt 信息 - -恢复请求可以使用 `ksadk_resume`: - -```json -{ - "session_id": "sess_xxx", - "input": [ - { - "type": "ksadk_resume", - "interrupt_id": "intr_xxx", - "value": { - "approved": true, - "answer": "继续" - } - } - ], - "stream": true -} -``` - -runner 收到的输入会是: - -```python -{ - "session_id": "sess_xxx", - "resume": True, - "input": { - "type": "ksadk_resume", - "interrupt_id": "intr_xxx", - "value": { - "approved": True, - "answer": "继续", - }, - }, -} -``` - -业务节点恢复后应按自己约定解析 `value`。 - -## 13. checkpoint resume 用户向使用流程 - -前面几节讲的是 `interrupt()` / approval 的运行内恢复(同一 invocation 内续跑)。0.6.5 起 ksadk 还支持把整条 run 回档到某个历史 checkpoint 重新执行,这是面向控制台 / Hosted UI 的用户向操作,业务代码不需要写任何 resume 逻辑。 - -适用场景: - -- 用户想“回到第 3 步重新选一个分支” -- 某一步工具调用结果不对,想从那一步之前重跑 -- 长流程中途想换模型重试某一段 - -!!! info "0.6.5 / 0.6.7 checkpoint resume 能力门控" - 是否可回档以 `GetAgentUiBootstrap.Capabilities.RuntimeCapabilities` 为准: - - - `Checkpoint.Supported=true` 才有 checkpoint 列表 / 预览入口 - - `ResumeRun.Supported=true` 且 `ResumeRun.ResumeMode=time_travel` 才能选任意历史 checkpoint 回档 - - `CheckpointResumeCapability.Supported / Checkpoint / ResumeRun` 是整体能力总开关,前端应同时校验 - - LangGraph runner 在配置了持久化 checkpointer(`postgres` / `sqlite`)且 `ResumeMode=time_travel` 时点亮该链路;`memory` 后端 `Scope=process_local` 不支持跨进程恢复,恢复入口应隐藏。 - -用户向操作流程(控制台 / Hosted UI 视角): - -```mermaid -flowchart LR - A[ListSessionCheckpoints] --> B{IsResumable?} - B -- 是 --> C[GetCheckpointResumePreview] - C --> D[ResumeRun] - D --> E[SubscribeRunEvents] - B -- 否 --> F[引导选其他 checkpoint] - D -. 409 .-> F -``` - -步骤说明: - -1. **列表**:调 `ListSessionCheckpoints(AgentId, SessionId, OnlyResumable=true)` 拿到可恢复 checkpoint 列表。每个 descriptor 含 `CheckpointId / RunId / FrameworkRef / IsResumable / IsTerminal / NextNode / Backend / Scope` -2. **预览**:选中一个 checkpoint 后调 `GetCheckpointResumePreview(AgentId, SessionId, RunId, CheckpointId)`,展示“将从哪个节点继续、涉及哪些 tool receipt”,避免误恢复 -3. **恢复**:确认后调 `ResumeRun(AgentId, SessionId, RunId, CheckpointId, Stream=true)`。恢复语义是同一 `RunId` 续跑,不是新建 run;可带 `InvocationId` 便于 `SubscribeRunEvents` / `CancelRun` -4. **订阅**:`Stream=true` 返回 SSE,事件流与普通 run 一致;终态 checkpoint 返回 `200 noop`,前端按正常完成态收敛 UI - -```bash hl_lines="6" -# 恢复调用示例(占位符 endpoint / token) -curl -X POST https://example.com/agentengine/api/v1/ResumeRun \ - -H "Authorization: Bearer sk-test" \ - -H "Content-Type: application/json" \ - -d '{ - "AgentId": "agent_xxx", - "SessionId": "sess_xxx", - "RunId": "run_xxx", - "CheckpointId": "ckpt_xxx", - "Stream": true - }' -``` - -业务代码注意事项: - -- checkpoint resume **不会** 经过 `ksadk_prepare_state` 的 resume 分支。`session_context["is_resume"]` 描述的是 `interrupt()` 运行内恢复,与 checkpoint 回档是两条独立链路 -- LangGraph runner 会把 `framework_ref.langgraph.checkpoint_ns` 写回 `configurable.checkpoint_ns`(0.6.5 首次保留,0.6.7 无条件保留),subgraph 命名空间上下文自动恢复,业务节点不需要感知 -- 终态 checkpoint(`IsTerminal=true`)不可恢复,`ResumeRun` 返回 `200 noop`;非终态且 `IsResumable=false` 返回 `409 checkpoint_not_resumable`,前端应引导用户选其他 checkpoint 而不是无限重试 -- runtime 只信任服务端已保存的 `run_checkpoint` 事件解析 `framework_ref`,客户端不能自行伪造 checkpoint 状态 - -## 14. 完整可跑示例 - -下面示例演示: - -- 自定义 `AgentState` -- 使用 `ksadk_prepare_state` -- 消费附件 OCR、平台上下文和模型能力 -- 支持人工确认 interrupt / resume - -```python -from __future__ import annotations - -from typing import Annotated, Any, TypedDict -import operator - -from langgraph.graph import END, StateGraph -from langgraph.types import interrupt - - -class AgentState(TypedDict): - query: str | dict[str, Any] - history: list[dict[str, Any]] - attachments: list[dict[str, Any]] - attachment_results: list[dict[str, Any]] - current_attachments: list[dict[str, Any]] - current_attachment_results: list[dict[str, Any]] - has_current_files: bool - input_content: list[dict[str, Any]] - input_messages: list[dict[str, Any]] - input_parts: list[dict[str, Any]] - platform_context: dict[str, Any] | None - kb_context: dict[str, Any] | None - memory_context: dict[str, Any] | None - model_metadata: dict[str, Any] - messages: Annotated[list[dict[str, str]], operator.add] - - -def _attachment_texts(state: AgentState) -> list[str]: - return [ - item.get("text", "") - for item in state.get("attachment_results", []) - if isinstance(item, dict) and item.get("text") - ] - - -def _is_approved(resume_value: Any) -> bool: - if not isinstance(resume_value, dict): - return False - if resume_value.get("type") == "mcp_approval_response": - return bool(resume_value.get("approve")) - if resume_value.get("type") == "ksadk_resume": - value = resume_value.get("value") or {} - return isinstance(value, dict) and bool(value.get("approved")) - return bool(resume_value.get("approved")) - - -def answer(state: AgentState) -> AgentState: - query = state["query"] - attachment_texts = _attachment_texts(state) - model_metadata = state.get("model_metadata") or {} - supports_image = bool( - ((model_metadata.get("capabilities") or {}).get("multimodal_input_image")) - ) - - if "删除" in str(query): - resume_value = interrupt( - { - "id": "confirm-delete", - "message": "检测到删除操作,请确认是否继续。", - "operation": "delete", - } - ) - if not _is_approved(resume_value): - return {"messages": [{"role": "assistant", "content": "已取消删除操作。"}]} - - content = ( - f"query={query}; " - f"supports_image={supports_image}; " - f"attachment_texts={attachment_texts}" - ) - return {"messages": [{"role": "assistant", "content": content}]} - - -def ksadk_prepare_state(payload: dict, session_context: dict) -> dict: - if session_context.get("is_resume"): - return payload.get("input") - - return { - "query": payload.get("input", ""), - "history": session_context.get("history", []), - "attachments": payload.get("attachments", []), - "attachment_results": payload.get("attachment_results", []), - "current_attachments": payload.get("current_attachments", []), - "current_attachment_results": payload.get("current_attachment_results", []), - "has_current_files": payload.get("has_current_files", False), - "input_content": payload.get("input_content", []), - "input_messages": payload.get("input_messages", []), - "input_parts": payload.get("input_parts", []), - "platform_context": session_context.get("platform_context"), - "kb_context": session_context.get("kb_context"), - "memory_context": session_context.get("memory_context"), - "model_metadata": payload.get("model_metadata", {}), - "messages": [], - } - - -workflow = StateGraph(AgentState) -workflow.add_node("answer", answer) -workflow.set_entry_point("answer") -workflow.add_edge("answer", END) - -root_agent = workflow.compile() -``` - -## 15. 常见反模式 - -### 15.1 在业务代码里猜是否 resume - -不要通过读取数据库、检查上一轮输出文本、解析 event store 来判断是否恢复。平台会把恢复请求转成 `resume=True`。 - -### 15.2 让客户端直接传 LangGraph Command - -外部协议应该是 JSON。`Command(resume=...)` 是 Python / LangGraph runner 内部调用形态,不应该暴露给客户端。 - -### 15.3 依赖 LangGraph 内部状态结构 - -不要依赖 `state.tasks[*].interrupts` 这类内部结构做业务判断。LangGraph 版本升级后这些结构可能变化。 - -### 15.4 把 `HumanMessage.content` 当成永远是字符串 - -多模态模型下它可能是 content block 列表。除非你明确在做 messages-native agent,否则优先使用 `payload / session_context`。 - -### 15.5 用 attachments 判断当前轮是否传文件 - -`attachments` 是最近有效附件上下文,可能来自历史 fallback。当前轮是否传文件看 `has_current_files`,当前轮附件列表看 `current_attachments`;OCR、文档抽取、压缩包摘要仍优先看对应的 `current_attachment_results` 或 `attachment_results`。 - -## 16. 检查清单 - -上线前建议确认: - -- `agentengine.yaml` 的 `framework` 是 `langgraph` -- `entry_point` 指向包含 `root_agent` 的模块 -- `root_agent` 是 compiled graph -- `ksadk_prepare_state` 在 `entry_point` 模块顶层可见 -- 自定义 state 明确包含业务需要的上下文字段 -- 当前轮文件判断使用 `has_current_files / current_attachments` -- 附件理解优先读取 `current_attachment_results / attachment_results` -- 多模态分支读取 `model_metadata.capabilities` -- interrupt 恢复只依赖 resume payload,不依赖平台内部事件结构 -- `/v1/responses` 恢复调用传同一个 `session_id` -- checkpoint 回档依赖持久化 checkpointer(`postgres` / `sqlite`),`memory` 后端隐藏恢复入口 -- checkpoint resume 与 `interrupt()` 运行内恢复是两条独立链路,不要在 `ksadk_prepare_state` 里混判 diff --git "a/docs/guides/ksadk\344\275\277\347\224\250\346\226\207\346\241\243.md" "b/docs/guides/ksadk\344\275\277\347\224\250\346\226\207\346\241\243.md" deleted file mode 100644 index 642cdfb3..00000000 --- "a/docs/guides/ksadk\344\275\277\347\224\250\346\226\207\346\241\243.md" +++ /dev/null @@ -1,1036 +0,0 @@ -# ksadk使用文档 - -本文档面向使用 `agentengine` / `ksadk` 的开发者、SA 与交付同学,口径以当前仓库代码、CLI 帮助、测试断言和 Docker/Makefile 默认值为准。 - -## 1. 适用范围 - -当前文档覆盖这些主线能力: - -- 本地初始化、配置、运行与调试 -- `build / deploy / launch` 的构建与部署参数 -- `agentengine files` 的完整工作区文件管理链路 -- Hermes 与 OpenClaw 的部署和常用验证路径 -- PVC 默认值、默认挂载目录、容量约束 -- `agentengine agent invoke` 的 Hermes 远端 native 调用与本地目录同步 -- 统一模型策略与 fallback(`0.6.6` 引入,`0.6.7` 补齐 reasoning 与 thinking 兼容) -- Hosted 附件 `ae-upload://` scheme 与 `AttachmentContent` action -- `ListSessions` / `ListSessionEvents` 分页字段 -- Custom UI bundle 与 `RuntimeCapabilities` 能力位 -- `--env` / `--env-file` 运行时环境变量与 `.env` 构建上下文边界 -- KCR 企业版 / 个人版 / 第三方镜像仓库凭证收敛 -- 长任务恢复与 `CancelRun` / `ResumeRun` 用户向流程 - -## 2. 安装与入口 - -```bash -pip install -U ksadk -``` - -可选 extras: - -```bash -pip install "ksadk[langgraph]" -pip install "ksadk[langchain]" -pip install "ksadk[deepagents]" -pip install "ksadk[adk]" -pip install "ksadk[skills]" -``` - -知识库和长期记忆使用的 `kingsoftcloud-sdk-python` 已包含在默认依赖中,不需要额外安装 `ksadk[kb]`。 - -命令入口等价: - -```bash -agentengine --help -ksadk --help -``` - -## 3. CLI 全景 - -当前主线命令组包括: - -- `init` -- `config` -- `run` -- `web` -- `build` -- `deploy` -- `launch` -- `agent` -- `files` -- `dashboard` -- `hermes` -- `openclaw` -- `mcp` -- `a2a` - -全局选项包括: - -- `--output pretty|json` -- `--dry-run` -- `--no-color` - -```mermaid -flowchart LR - classDef client fill:#dbeafe,stroke:#1d4ed8,stroke-width:2px,color:#1e3a8a; - classDef control fill:#ede9fe,stroke:#7c3aed,stroke-width:2px,color:#581c87; - classDef data fill:#dcfce7,stroke:#16a34a,stroke-width:2px,color:#166534; - classDef runtime fill:#e2e8f0,stroke:#475569,stroke-width:2px,color:#1e293b; - - Init["init / config / run / web"]:::client --> Local["本地开发链路"]:::data - Build["build / deploy / launch"]:::client --> Server["agentengine-server"]:::control - Files["files / agent invoke"]:::client --> Runtime["远端 runtime / Hosted Action"]:::data - Hermes["hermes *"]:::client --> HermesRT["Hermes Runtime"]:::runtime - OpenClaw["openclaw *"]:::client --> OpenClawRT["OpenClaw Runtime"]:::runtime -``` - -## 4. 最短成功路径 - -### 4.1 本地项目初始化 - -```bash -agentengine init my-agent -f langgraph -cd my-agent -agentengine config -agentengine run -i -``` - -本地 Web UI: - -```bash -agentengine web --port 8080 -``` - -### 4.2 云端一键部署 - -```bash -agentengine launch . --target serverless -``` - -### 4.3 Hermes 云端部署 - -```bash -agentengine init my-hermes -f hermes -cd my-hermes -agentengine hermes deploy --name my-hermes -agentengine hermes status -``` - -### 4.4 OpenClaw 云端部署 - -```bash -agentengine init my-openclaw -f openclaw -cd my-openclaw -agentengine openclaw deploy -agentengine openclaw status -``` - -### 4.5 Skill Runtime 与内置工具接入 - -`0.6.2` 新增 `ksadk.toolsets`,开发者可以在 LangGraph、LangChain、DeepAgents 或自定义 runner 中显式绑定 AgentEngine 内置工具。推荐默认使用渐进式披露,避免把所有低频或高风险工具直接塞进模型上下文: - -```python -from ksadk.toolsets import describe_agentengine_tools, get_agentengine_tools - -tools = get_agentengine_tools(include=["focused", "agentengine_tool_dispatcher"]) -tool_specs = describe_agentengine_tools(include=["focused", "agentengine_tool_dispatcher"]) -``` - -`focused` 默认直接暴露这些高频工具: - -- Skill Space:`list_skills`、`search_skills`、`load_skill` -- Workspace:`workspace_status`、`search_workspace_files`、`edit_workspace_file`、`lint_workspace_file` -- Platform:`component_status` -- Sandbox:`sandbox_status` - -低频、高风险或上下文较重的工具通过 `agentengine_tool_dispatcher` 按需 `list` / `describe` / `call`: - -```python -from ksadk.toolsets import agentengine_tool_dispatcher - -agentengine_tool_dispatcher("describe", tool_name="run_code") -agentengine_tool_dispatcher( - "call", - tool_name="run_code", - arguments={"code": "print(42)", "language": "python"}, -) -``` - -`get_agentengine_tools()` 无参仍返回全量工具,兼容旧项目;新项目建议显式写 `include=["focused", "agentengine_tool_dispatcher"]`。如果只需要某个分组,也可以写 `include=["skill"]`、`include=["workspace"]`、`include=["platform"]` 或 `include=["sandbox"]`。 - -Skill Runtime 执行入口是 `execute_skills`。它只用于 workflow 型任务,普通 instruction-first Skill 推荐先 `load_skill` 读取 `SKILL.md`,再由外层 agent 按指令完成。隔离执行 backend 由环境变量决定: - -- `KSADK_SKILL_RUNTIME_BACKEND=local_process`:走本地 agent 进程 -- `KSADK_SKILL_RUNTIME_BACKEND=e2b`:走远程 sandbox / E2B backend -- 未设置 backend 但存在 `KSADK_SANDBOX_TEMPLATE_ID`:自动走 E2B -- 显式 `KSADK_SKILL_RUNTIME_BACKEND=disabled`:禁用隔离执行 - -Workspace 内置工具只访问 AgentEngine UI workspace,不访问任意宿主机路径。`edit_workspace_file` 是 exact snippet replacement;匹配不到返回 `snippet_not_found`,匹配次数不符合预期返回 `ambiguous_edit`。`lint_workspace_file` 提供 Python AST、JSON parse 和通用文本轻量检查。 - -Sandbox direct tools 只通过 configured isolated sandbox backend 执行。`run_command` / `run_code` 不会退化为宿主机 shell;未配置 sandbox 时会返回诊断。`execute_skills`、Workspace 写入/删除、sandbox command/code 等中高风险工具会经过 Tool Gateway;strict 模式下会返回 `approval_required`,由 UI 或调用方回传批准后继续。 - -## 5. `/v1/responses` OpenAI 兼容接口 - -本地 `agentengine run -i` 启动后,AgentEngine 暴露 `/v1/responses`。这一接口优先兼容 OpenAI Responses 的返回结构和 SSE 生命周期,同时保留少量 `ksadk` 扩展字段,方便会话和本地 CLI 继续工作。 - -### 5.1 非流式调用 - -```bash -curl http://127.0.0.1:8000/v1/responses \ - -H "Content-Type: application/json" \ - -d '{ - "model": "glm-5.2", - "input": "请用一句话介绍 AgentEngine", - "instructions": "只用中文回答,语气简洁", - "metadata": {"source": "local-doc"} - }' -``` - -当前支持的常用请求字段: - -- `input`:字符串或 OpenAI message/input item 列表。 -- `model`:本轮请求使用的模型,会同步到运行时环境。 -- `instructions`:本轮系统/开发者指令,不写入用户消息正文;LangGraph 会转为 system message,字符串输入类 runner 会作为 prompt 前缀。 -- `metadata`:请求元数据,会回显到 response object,并记录到本轮事件 metadata;不参与模型生成。 -- `stream`:`true` 时返回 SSE。 -- `session_id`:复用已有会话;未传时自动创建。 - -非流式响应包含官方风格字段:`id`、`object`、`created_at`、`status`、`model`、`output`、`metadata`、`usage`、`error`、`incomplete_details`。同时保留 `output_text` 和 `session_id` 作为 `ksadk` 扩展,兼容现有调用方。 - -### 5.2 流式调用 - -```bash -curl -N http://127.0.0.1:8000/v1/responses \ - -H "Content-Type: application/json" \ - -d '{ - "model": "glm-5.2", - "session_id": "sess-demo-001", - "input": [{"role": "user", "content": [{"type": "input_text", "text": "分析这个任务"}]}], - "stream": true - }' -``` - -`model` 和 `session_id` 在流式与非流式调用中都可传;`session_id` 用来复用同一会话,未传时自动创建。 - -流式事件按 Responses 生命周期输出: - -- `response.created` / `response.in_progress`:响应创建与开始运行。 -- `response.output_item.added` / `response.content_part.added`:开始输出 message、reasoning、function call 或 MCP approval request。 -- `response.output_text.delta` / `response.output_text.done`:正文增量与正文完成。 -- `response.reasoning.delta`:思考内容增量,前端可选择单独渲染。 -- `response.function_call_arguments.delta` / `response.function_call_arguments.done`:工具调用参数;底层一次性拿到参数时也会按一次 delta + done 输出。 -- `response.output_item.done` / `response.content_part.done`:输出项或内容块完成。 -- `response.completed`:本轮完成。 -- `response.failed`:运行失败。 -- `response.incomplete`:需要人工审核或中断恢复。 - -工具结果和人工审核的兼容策略: - -- `response.ksadk.tool_result`:工具执行结果。 -- 能明确识别为工具审批的 interrupt 会渲染为官方风格 `mcp_approval_request` output item。 -- 其他通用 interrupt 使用 `response.ksadk.approval_request` 扩展事件;最终 response 会以 `status: "incomplete"` 返回,并在 `incomplete_details.ksadk_interrupt` 中包含中断信息。 - -### 5.3 图片与附件输入 - -`/v1/responses` 是默认主维护协议,按 OpenAI Responses 语义接收 `input_text` / `input_image` / `input_file`。运行时会把这些输入块原样投影到 runner 的 `input_content` / `input_messages`,同时生成 legacy/internal 的 `input_parts` 兼容旧 runner。 - -当前 `/v1/responses` 推荐输入块: - -- `input_text` -- `input_image` -- `input_file` - -旧客户端仍可传 KOP 风格 part 数组,运行时会兼容: - -- `text` -- `inlineData` -- `fileData` - -推荐图片传法: - -1. 按 OpenAI Responses 官方形态传 `input_image.image_url`,其中 `image_url` 可以是远程图片 URL,也可以是 `data:image/...;base64,...` -2. 老客户端可先调用 `UploadFile` 上传图片,再通过兼容扩展 `fileData.fileUri` 引用 -3. 老客户端可直接把图片 base64 放进兼容扩展 `inlineData.data` - -OpenAI 风格 data URL 示例: - -```json -{ - "input": [ - { - "role": "user", - "content": [ - { "type": "input_text", "text": "请分析这张图片" }, - { - "type": "input_image", - "image_url": "data:image/png;base64," - } - ] - } - ], - "stream": false -} -``` - -运行时会保留这个官方输入块到 `input_content`,并额外归一化为内部附件上下文,因此业务代码可以继续通过 `has_current_files` / `current_attachments` 判断本轮是否带图。远程图片 URL 会作为引用保留,并可在支持原生图片输入的 LangGraph 路径下继续传给模型;KsADK 不会主动拉取远程图片做 OCR。需要平台提取、OCR 或本地附件内容时,请使用 data URL、`inlineData` 或 `fileData`。 - -多模态模型“看图”和平台 OCR 是两条不同链路:推荐让支持图片的模型直接消费 `input_image` / `input_content`,这样不需要在代码包里安装本地 OCR 依赖。平台本地 OCR 只用于需要把图片预先转成 `current_attachment_results[*].text` 的场景;源码构建默认不打包 OCR 二进制栈,如需启用请在构建环境设置 `KSADK_BUILD_ENABLE_ATTACHMENT_OCR=true`,或在项目 `requirements.txt` 中显式加入 OCR 相关依赖。 - -OpenAI 风格文件示例: - -```json -{ - "input": [ - { - "role": "user", - "content": [ - { "type": "input_text", "text": "请总结这个文件" }, - { - "type": "input_file", - "filename": "resume.txt", - "file_data": "" - } - ] - } - ] -} -``` - -`input_file.file_data` 会保留在 `input_content`,并归一化为内部 `inlineData`;`input_file.file_url` / `input_file.file_id` 会保留为引用,并归一化为内部 `fileData`。KsADK 不会主动拉取远程 `file_url` 内容;需要平台提取或 OCR 时,请使用 `file_data`、`inlineData` 或先上传后用 `fileData.fileUri`。 - -旧客户端先上传再引用的兼容示例: - -```json -{ - "input": [ - { - "role": "user", - "content": [ - { "text": "请分析这张图片" }, - { - "fileData": { - "fileUri": "ksadk-upload://abc123.png", - "displayName": "diagram.png", - "mimeType": "image/png" - } - } - ] - } - ], - "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 part 传给底层 SDK - - 如果模型支持原生多模态,可直接吃图 -- `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. 本地默认兜底(按文本模型处理) - -### 5.4 Hosted 附件与 `ae-upload://` - -!!! new "0.6.6 新增" - -Hosted 部署下,用户在 Hosted UI 上传的附件不再以本地 `ksadk-upload://` 落盘,而是由控制面托管,统一以 `ae-upload://` scheme 引用。两类 URI 的区别: - -| 附件 URI scheme | 来源 | 存储 | 读取方式 | -| --- | --- | --- | --- | -| `ksadk-upload://` | 本地 `agentengine run -i` / CLI 上传 | 本地 `files/` 目录 + KS3 兜底 | 本地直读或 KS3 回源 | -| `ae-upload://` | Hosted 控制面上传 | 控制面托管对象 | 经由 `AttachmentContent` action 拉取 | - -读取 `ae-upload://` 附件时,conversation runtime 会调用 Hosted 控制面的 `AttachmentContent` action: - -``` -GET /agentengine/api/v1/AttachmentContent?FileUri=ae-upload:// -``` - -返回内容包含二进制字节、`display_name` 与 `content_type`。拉取成功后,runtime 会把内容写回本地附件 cache 目录(`/files/`)并落一份 `.meta.json`,后续同一会话内再次读取该附件时优先命中本地 cache,避免重复远端拉取。 - -会话刷新(refresh / rehydrate)后,已写回本地 cache 的 Hosted 附件会继续以本地 cache 读取;未命中本地 cache 的 `ae-upload://` 附件会再次触发 `AttachmentContent` 拉取。`ensure_local_path` / `read` 会保证返回可用本地路径,业务代码无需关心附件原始来源。 - -### 5.5 会话与事件分页 - -`ListSessions` 与 `ListSessionEvents` 支持分页,字段对齐控制面 action 契约: - -| Action | 请求字段 | 响应字段 | -| --- | --- | --- | -| `ListSessions` | `AgentId`、`UserId`(默认 `user`)、`Page`(≥1)、`PageSize`(1~200,默认 20) | `Sessions`、`Total`、`Page`、`PageSize` | -| `ListSessionEvents` | `SessionId`、`Offset`(≥0)、`Limit`(≥1) | `Events`、`Total`、`Offset`、`Limit` | - -`ListSessions` 用 `Page` / `PageSize` 做页式分页,客户端按 `Total` 计算总页数;`ListSessionEvents` 用 `Offset` / `Limit` 做偏移分页,`Total` 为该会话事件总数。事件按追加顺序返回,分页只读取已落盘事件,不会阻塞正在写入的事件流。 - -### 5.6 当前不支持 - -本期不支持 `previous_response_id`、`store`、复杂 `reasoning/text` 控制、完整 tool schema 请求面,也不新增原生 LangGraph state endpoint。自定义 LangGraph State 请使用 `ksadk_prepare_state` 或 `agentengine init --from-agent` 自动生成的 adapter。 - -## 6. 统一模型策略与 fallback - -!!! new "0.6.7 新增" - -`0.6.6` 起引入统一模型策略契约,`0.6.7` 补齐 reasoning 声明与 thinking disabled 兼容。Hermes、OpenClaw 与通用 Agent 共用一套默认语义,避免三类 runtime 各自维护一份模型清单。 - -### 6.1 策略契约 - -策略以 JSON 描述,可通过环境变量 `AGENTENGINE_MODEL_POLICY_JSON` 整体覆盖。未设置时使用内置默认策略 `DEFAULT_MODEL_POLICY`(版本号 `v1`): - -```json -{ - "version": "v1", - "primary": {"model": "glm-5.2"}, - "multimodal": {"model": "kimi-k2.7-code"}, - "fallback": { - "model": "deepseek-v4-pro", - "fallback_errors": [ - "timeout", - "temporarily unavailable", - "model unavailable", - "rate limit", - "too many requests", - "503", - "504" - ], - "on_errors": [ - "timeout", - "temporarily unavailable", - "model unavailable", - "rate limit", - "too many requests", - "503", - "504" - ] - }, - "models": { - "glm-5.2": {"reasoning": true, "options": {}}, - "kimi-k2.7-code": {"input": ["text", "image"], "reasoning": true, "options": {"temperature": 1}}, - "deepseek-v4-pro": {"reasoning": true, "options": {}} - } -} -``` - -三个角色的语义: - -| 角色 | 默认模型 | 用途 | -| --- | --- | --- | -| `primary` | `glm-5.2` | 默认文本主模型,未显式指定 `model` 时使用 | -| `multimodal` | `kimi-k2.7-code` | 图片/多模态输入时路由到的模型 | -| `fallback` | `deepseek-v4-pro` | 主模型遇到可恢复错误时重试一次的目标模型 | - -构建期会把策略序列化后注入 runtime 环境变量,并按 runtime 类型分别填充对应的模型变量: - -- 通用 Agent:`OPENAI_MODEL_NAME`(primary)、`OPENAI_FALLBACK_MODEL_NAME`(fallback) -- `hermes`:`HERMES_DEFAULT_MODEL`(primary)、`HERMES_FALLBACK_MODEL`(fallback)、`HERMES_MODEL_CATALOG_JSON` -- `openclaw`:`OPENAI_MODEL_NAME`(primary,带 `ksyun/` provider 前缀)、`OPENCLAW_FALLBACK_MODEL`、`OPENCLAW_IMAGE_MODEL`(multimodal)、`OPENCLAW_MODEL_CATALOG_JSON` - -!!! info "策略合并" -`AGENTENGINE_MODEL_POLICY_JSON` 传入的 JSON 会与 `DEFAULT_MODEL_POLICY` 做深度合并(deep merge),未声明的字段保留默认值;只覆盖你想改的部分即可。`fallback.fallback_errors` / `fallback.on_errors` 会被同步成同一份列表。 - -### 6.2 自动 fallback 与重试 - -conversation runtime 在主模型调用失败时按错误信息判断是否自动 fallback 重试一次: - -- 可恢复错误会触发 fallback:超时、限流(rate limit / too many requests)、模型不可用、`503` / `504` 等临时不可用。 -- 不会吞掉的错误,直接抛回调用方:`400` 参数错误、`invalid request` / `bad request`、业务错误、tool 执行错误。 -- fallback 目标模型等于当前模型时不重试,避免空转。 -- 只重试一次;fallback 仍失败则按原错误返回。 - -### 6.3 reasoning 声明与 catalog - -!!! new "0.6.7 新增" - -`0.6.7` 起在 `models.` 中声明 `reasoning: true`。构建期生成的 model catalog(`OPENCLAW_MODEL_CATALOG_JSON` / `HERMES_MODEL_CATALOG_JSON`)会输出 `reasoning` 字段,Hosted UI 可据此判断是否渲染思考内容区。catalog 每条记录形如: - -```json -{ - "id": "glm-5.2", - "name": "glm-5.2", - "api": "openai-completions", - "input": ["text"], - "reasoning": true -} -``` - -`input` 字段用于声明多模态能力(如 `kimi-k2.7-code` 为 `["text", "image"]`);`options` 透传模型默认参数(如 temperature)。 - -### 6.4 thinking disabled 兼容 - -当本轮请求显式关闭思考(`reasoning.effort` 归一化为 `none` / `disabled`,或 `max_reasoning_tokens=0`)时,runtime 会向 OpenAI 兼容请求的 `extra_body` 注入: - -```json -{ - "enable_thinking": false, - "chat_template_kwargs": {"enable_thinking": false} -} -``` - -这套字段是 DeepSeek 系模型关闭 thinking 的兼容写法;对不识别该字段的模型无副作用。流式输出阶段会过滤 reasoning output item,确保关闭思考时不会向客户端回吐思考内容。 - -```mermaid -flowchart LR - classDef model fill:#dbeafe,stroke:#1d4ed8,stroke-width:2px,color:#1e3a8a; - classDef fallback fill:#fee2e2,stroke:#dc2626,stroke-width:2px,color:#7f1d1d; - classDef drop fill:#f3f4f6,stroke:#6b7280,stroke-width:2px,color:#374151; - - Req["请求 model=primary"]:::model --> Call1["调用 primary 模型"]:::model - Call1 -->|"超时/限流/503/504/模型不可用"| FB["fallback 重试一次 deepseek-v4-pro"]:::fallback - Call1 -->|"400/业务错误/tool 错误"| Err["直接抛回调用方,不 fallback"]:::drop - FB --> OK["返回结果"]:::model - Call1 -->|"成功"| OK - FB -->|"仍失败"| Err -``` - -## 7. Framework、挂盘与 workspace 约定 - -### 7.1 默认 PVC 规则 - -来自 `ksadk/cli/storage.py` 的统一约束: - -- 默认容量:`20Gi` -- 最小容量:`20Gi` -- 最大容量:`500Gi` - -### 7.2 默认挂载目录 - -| Framework | 默认挂载目录 | workspace 逻辑根 | 当前代码里可直接确认的绝对路径 | -| --- | --- | --- | --- | -| `adk` | `/home/node/.agentengine` | `workspace:/` | 运行时对外统一以逻辑根 `workspace` 暴露 | -| `langchain` | `/home/node/.agentengine` | `workspace:/` | 运行时对外统一以逻辑根 `workspace` 暴露 | -| `langgraph` | `/home/node/.agentengine` | `workspace:/` | 运行时对外统一以逻辑根 `workspace` 暴露 | -| `deepagents` | `/home/node/.agentengine` | `workspace:/` | 运行时对外统一以逻辑根 `workspace` 暴露 | -| `hermes` | `/home/node/.hermes` | `workspace:/` | `/home/node/.hermes/workspace` | -| `openclaw` | `/home/node/.openclaw` | `workspace:/` | `/home/node/.openclaw/workspace` | - -补充说明: - -- 本地 `ksadk server` 的 workspace 根目录是 `/.agentengine/ui/workspace`。 -- 对外 CLI 和 Hosted UI 一律展示逻辑根 `workspace:/...`。 -- 当运行时响应里带有 `workspace_real_root` 或 `workspace_path` 时,CLI 会同时显示“实际目录”。 - -```mermaid -flowchart TB - classDef runtime fill:#e2e8f0,stroke:#475569,stroke-width:2px,color:#1e293b; - classDef storage fill:#ffedd5,stroke:#ea580c,stroke-width:2px,color:#9a3412; - classDef data fill:#dcfce7,stroke:#16a34a,stroke-width:2px,color:#166534; - - Local["本地项目"]:::runtime --> LocalRoot[".agentengine/ui/workspace"]:::storage - Generic["adk / langchain / langgraph / deepagents"]:::runtime --> GenericMount["/home/node/.agentengine"]:::storage - Hermes["hermes"]:::runtime --> HermesRoot["/home/node/.hermes/workspace"]:::storage - OpenClaw["openclaw"]:::runtime --> OpenClawRoot["/home/node/.openclaw/workspace"]:::storage - LocalRoot --> Logical["逻辑展示统一为 workspace:/"]:::data - GenericMount --> Logical - HermesRoot --> Logical - OpenClawRoot --> Logical -``` - -## 8. `build / deploy / launch` 参数 - -### 8.1 构建体积与依赖策略 - -`agentengine build` 默认优先保持源码包轻量,不会把所有平台增强能力的重依赖都打进包: - -- 默认包含:KsADK runtime 必需依赖、附件基础解析依赖、`kingsoftcloud-sdk-python`、`requests-aws4auth`。 -- 推荐多模态图片写法:让支持图片的模型直接消费 OpenAI Responses `input_image` / runner `input_content`,不要为了“看图”默认启用本地 OCR。 -- 兼容 OCR 写法:如果业务明确需要平台先把图片转成 `current_attachment_results[*].text`,再设置 `KSADK_BUILD_ENABLE_ATTACHMENT_OCR=true`,或在项目 `requirements.txt` 显式写入 OCR 依赖。 -- MCP adapter:默认不打包;当项目 import `mcp` / `langchain_mcp_adapters`,或 `.env` 配置了非空 `KSADK_MCP_SERVERS` 时自动加入。自动发现不到时可设置 `KSADK_BUILD_ENABLE_MCP=true`。 -- PostgreSQL session:默认不打包 `asyncpg`;当 `.env` 设置 `KSADK_SESSION_BACKEND=postgres` 或 PostgreSQL DSN 时自动加入。自动发现不到时可设置 `KSADK_BUILD_ENABLE_POSTGRES_SESSION=true`。 - -构建会复用 `.agentengine/code_build/pip_cache`,依赖清单未变化时也会复用 `.agentengine/code_build/linux_deps`,避免第二次构建从头下载。`pip install` 默认超时为 45 分钟,可用 `KSADK_BUILD_PIP_INSTALL_TIMEOUT_SECONDS` 调整。 - -构建完成会打印 zip 体积、解压体积和 Top 体积来源。只有当解压体积超过 500 MB 或 zip 超过 300 MB 时,才会提示切换 container 模式: - -```bash -agentengine build . --mode container --push --registry -``` - -源码包里依赖本身很多时,优先建议业务拆分不必要依赖、使用环境变量显式关闭未用能力、或切到已有的 container 模式;本轮不建议把 `ksadk` 内置进固定 base 镜像,因为 SDK 更新频繁,固定 base 镜像会降低版本灵活性。 - -### 8.2 部署、存储与网络参数 - -以下参数在 `deploy`、`launch` 以及对应 framework 命令中统一存在: - -- `--storage-size-gi` -- `--storage-mount-path` -- `--no-storage` - -以下 network 参数在 `agentengine deploy`、`agentengine launch` 和 `agentengine openclaw deploy` 中统一存在: - -- `--enable-public-access / --disable-public-access` -- `--enable-vpc-access` -- `--vpc-id` -- `--subnet-id` -- `--security-group-id` -- `--availability-zone` - -示例: - -```bash -agentengine deploy . --target serverless --storage-size-gi 50 -agentengine launch . --target kce --storage-mount-path /home/node/.agentengine -agentengine hermes deploy --storage-size-gi 20 -agentengine openclaw deploy --no-storage -``` - -VPC 网络示例: - -```bash -agentengine deploy . \ - --target serverless \ - --disable-public-access \ - --enable-vpc-access \ - --vpc-id vpc-xxx \ - --subnet-id subnet-xxx \ - --security-group-id sg-xxx \ - --availability-zone cn-beijing-6a - -agentengine launch . \ - --enable-vpc-access \ - --vpc-id vpc-xxx \ - --subnet-id subnet-xxx \ - --security-group-id sg-xxx - -agentengine openclaw deploy \ - --enable-vpc-access \ - --vpc-id vpc-xxx \ - --subnet-id subnet-xxx \ - --security-group-id sg-xxx -``` - -配置文件也可写入 network。CLI 显式参数优先级高于配置文件: - -```yaml -network: - enable_public_access: false - enable_vpc_access: true - vpc_id: vpc-xxx - subnet_id: subnet-xxx - security_group_id: sg-xxx - availability_zone: cn-beijing-6a - -deploy: - network: - enable_public_access: false - enable_vpc_access: true - vpc_id: vpc-xxx - subnet_id: subnet-xxx - security_group_id: sg-xxx -``` - -行为要点: - -- 不传时使用框架默认挂载目录。 -- 容量会在客户端侧先做 `20~500Gi` 校验。 -- `--no-storage` 会显式关闭默认 PVC 挂载。 -- 只要开启 VPC 访问,或传入 `--vpc-id` / `--subnet-id` / `--security-group-id` 中任意一个,就必须同时具备 `VpcId`、`SubnetId`、`SecurityGroupId`。 -- `--availability-zone` 是可选字段,不替代子网或安全组。 - -### 8.3 运行时环境变量 `--env` / `--env-file` - -!!! new "0.6.7 新增" - -`agentengine deploy` 与 `agentengine launch` 支持显式透传运行时环境变量,不再只依赖项目根 `.env`: - -```bash -# 多次 --env 传 KEY=VALUE -agentengine deploy . --target serverless \ - --env MODEL_NAME=glm-5.2 \ - --env LOG_LEVEL=DEBUG - -# 或用一个 .env / JSON 对象文件 -agentengine launch . --target kce --env-file ./prod.env -``` - -- `--env` 可重复传入,格式 `KEY=VALUE`;变量名必须为合法环境变量名(`[A-Za-z_][A-Za-z0-9_]*`)。 -- `--env-file` 支持 `.env`(dotenv)或 JSON 对象文件;路径不存在或格式不合法会直接报错退出。 -- `--env` 与 `--env-file` 可同时使用,同名变量以 `--env` 为准(命令行优先级高于文件)。 - -`.env` 构建上下文边界: - -- 真实 `.env` 只通过 deploy payload 注入 runtime,不会进入镜像构建上下文或源码包。 -- 构建期会复制 `.env.example`(作为模板),但跳过真实 `.env`,避免凭证被打进镜像。 -- `.git`、`__pycache__`、`node_modules`、`.pytest_cache` 等同样不会进入构建上下文。 - -### 8.4 镜像仓库凭证(KCR 企业版 / 个人版 / 第三方) - -镜像拉取凭证按目标镜像仓库地址自动判别类型,避免企业版/第三方误用 `KSYUN_ACCOUNT_ID`: - -| 仓库类型 | 判别规则 | 凭证要求 | -| --- | --- | --- | -| 企业版 KCR | host 以 `.ksyunkcr.com` 结尾 | 必须配 `KCR_USERNAME` + `KCR_PASSWORD` | -| 个人版 KCR | host 以 `.kce.ksyun.com` 结尾 | `KCR_USERNAME` 可留空,运行时用 `KSYUN_ACCOUNT_ID` 兜底;`KCR_PASSWORD` 必填 | -| 第三方镜像仓库 | 其他 host | 必须配 `KCR_USERNAME` + `KCR_PASSWORD` | - -```bash -agentengine config -# 个人版 KCR 可留空 KCR_USERNAME,运行时使用 KSYUN_ACCOUNT_ID 作为用户名兜底 -# 企业版 KCR 和第三方镜像仓库必须配置 KCR_USERNAME + KCR_PASSWORD -``` - -行为要点: - -- 检测到 `KCR_PASSWORD` 但缺少 `KCR_USERNAME`,且仓库不是个人版 KCR 时,CLI 会忽略该凭证并给出告警,不会用错误的用户名去拉私有镜像。 -- 个人版 KCR 在缺 `KCR_USERNAME` 时,会用 `KSYUN_ACCOUNT_ID` 作为用户名兜底。 -- KCR 访问凭证获取:`https://kcr.console.ksyun.com/` → 访问凭证。 - -### 8.5 Custom UI 与 RuntimeCapabilities - -!!! new "0.6.7 新增" - -Agent 可以声明自己的 Hosted UI bundle,替代内置统一 UI。两种声明方式: - -1. 在项目根 `agentengine.yaml`(或 `ksadk.yaml` / `ksadk.yml`)中声明: - -```yaml -ui_profile: custom -ui_path: / -ui_bundle_path: research-ui/dist -``` - -2. 不写配置时,`agentengine web` 与 runtime 会自动探测项目根 `research-ui/dist/index.html`:存在即视为 custom UI bundle,自动启用 `ui_profile=custom`。 - -Hosted 部署下,bootstrap 会返回 `RuntimeCapabilities` 字段,声明当前 runtime 支持的能力位,Hosted UI 据此决定是否渲染对应入口。当前包含的能力位: - -| 能力字段 | 含义 | -| --- | --- | -| `CancelRun` | 是否支持取消正在运行的 run | -| `ResumeRun` | 是否支持从 checkpoint 恢复长任务(含 `Supported` 子字段) | - -`ResumeRun.Supported=true` 时,`ListSessionCheckpoints` 返回的每条 checkpoint 会带 `ResumeDisabled` / `ResumeDisabledReason`,标记哪些恢复点当前可用。已恢复过的 checkpoint 在当前策略下不允许重复恢复。 - -## 9. 长任务恢复与 CancelRun / ResumeRun - -!!! new "0.6.7 新增" - -长任务(长 LLM 生成、多步 tool 编排、流式任务)可能因超时、用户主动中断或异常退出而中断。`0.6.7` 起补齐用户向的恢复流程,避免长任务丢失进度。 - -用户侧典型流程: - -```mermaid -sequenceDiagram - participant U as 用户 - participant UI as Hosted / Local UI - participant RT as Conversation Runtime - - U->>UI: 发起长任务请求 - UI->>RT: /v1/responses (stream=true) - RT-->>UI: response.created / output_text.delta ... - U->>UI: 点击「取消」 - UI->>RT: CancelRun(InvocationId) - RT-->>UI: Status=cancelling - Note over RT: 运行被中断,写入 checkpoint - U->>UI: 刷新会话 - UI->>RT: ListSessionEvents(SessionId, Offset, Limit) - RT-->>UI: 已落盘事件 + Total - U->>UI: 选择「从恢复点继续」 - UI->>RT: ListSessionCheckpoints(AgentId, SessionId) - RT-->>UI: checkpoints (含 ResumeDisabled 标记) - UI->>RT: ResumeRun(AgentId, SessionId, RunId, CheckpointId) - RT-->>UI: 从恢复点继续生成 -``` - -关键 action: - -- `CancelRun`:传入 `InvocationId`(即 `run_id`)取消正在运行的流式任务。runtime 会先尝试取消进程内 detached stream,再调用 runner 的 cancel 接口;返回 `Cancelled`、`Found`、`Status`、`RunnerCancelStatus`。 -- `ListSessionCheckpoints`:列出某个会话可恢复的 checkpoint,支持 `OnlyResumable` 过滤、`Offset` / `Limit` 分页(`Limit` 上限 500)。 -- `ResumeRun`:传入 `AgentId` / `SessionId` / `RunId` / `CheckpointId` 从指定恢复点继续。同一 session+run 已有进行中的 resume 时会返回 `resume_already_running`,避免并发重复恢复。 - -!!! warning "不可恢复的 checkpoint" -- 已是终态的 checkpoint 不可恢复(`ResumeDisabledReason` 会提示「选择更早恢复点重跑」)。 -- 进程内 checkpoint 不能跨实例恢复。 -- 同一 checkpoint 在当前策略下不允许重复恢复。 - -## 10. `agentengine files` 工作区文件管理 - -### 10.1 子命令清单 - -- `agentengine files list` -- `agentengine files upload` -- `agentengine files download` -- `agentengine files delete` -- `agentengine files push` -- `agentengine files pull` - -### 10.2 路径语义 - -- 远端路径统一是 workspace 相对路径。 -- `.`、空字符串和 `/` 会被解释为逻辑根 `workspace:/`。 -- 输出里会同时给出: - - 逻辑路径:`workspace:/docs/readme.md` - - 真实路径:当运行时返回真实根目录时,显示绝对路径 - -### 10.3 常用示例 - -列目录: - -```bash -agentengine files list --path . -agentengine files list --path docs --recursive -``` - -上传单文件: - -```bash -agentengine files upload \ - --local-path ./report.md \ - --remote-path reports/report.md -``` - -下载文件: - -```bash -agentengine files download \ - --remote-path reports/report.md \ - --output-path ./downloads/report.md -``` - -删除文件: - -```bash -agentengine files delete --remote-path reports/report.md -``` - -推送目录: - -```bash -agentengine files push \ - --local-dir ./dist \ - --remote-path releases/current -``` - -拉取目录: - -```bash -agentengine files pull \ - --remote-path releases/current \ - --local-dir ./synced -``` - -### 10.4 `push / pull` 覆盖策略 - -- 默认不会强制覆盖已有文件。 -- 加 `--force` 时,已有同名文件会进入 `overwritten` 结果集。 -- 输出会区分: - - `created` - - `overwritten` - - `skipped` - -### 10.5 JSON 输出 - -所有 `files` 子命令都可配合 `--output json` 使用。典型字段包括: - -- `workspace_root` -- `workspace_display_path` -- `workspace_real_path` -- `entry_count` -- `size_bytes` -- `size_human` -- `transport_mode` -- `results.created / overwritten / skipped` - -### 10.6 大小限制 - -当前统一上限来自 `ksadk_runtime_common.workspace_files.constants`: - -- 单文件上传上限:`100MB` - -目录同步时还有两条额外限制: - -- 本地目录中任一单文件不能超过上限 -- 本地目录总大小也不能超过同一个上限 - -### 10.7 传输模式 - -CLI 内部会在两种模式间切换: - -- `runtime_direct`:直连 runtime 的 `/_ksadk/workspace/v1/*` -- `action_proxy`:经由控制面 Action 调用 - -当前代码里的实际策略: - -- 常规 agent:优先使用 `runtime_direct` -- OpenClaw:优先使用 `action_proxy` - -更完整的协议与安全说明见 [工作区文件技术设计](../internal/工作区文件技术设计.md)。 - -## 11. `agentengine agent invoke` - -`agentengine agent invoke` 是当前主线命令;`agentengine invoke` 仍保留为兼容别名。 - -### 11.1 常见用法 - -```bash -agentengine agent invoke my-agent -agentengine agent invoke my-agent --message "你好" -agentengine agent invoke my-agent --transport chat -``` - -### 11.2 Hermes 远端 native 模式 - -`--local-workspace` 只支持 Hermes 的远端 native 模式: - -```bash -agentengine agent invoke my-hermes \ - --transport native \ - --local-workspace ./local-workspace -``` - -可选指定远端目录: - -```bash -agentengine agent invoke my-hermes \ - --transport native \ - --local-workspace ./local-workspace \ - --remote-workspace-path demos/hermes-pre -``` - -当前行为: - -- 如果不传 `--remote-workspace-path`,默认使用本地目录名作为远端子目录名 -- 本地空目录不会被同步 -- 会先读取 `GetAgentUiBootstrap` 中的 `WorkspaceFiles.MaxUploadBytes` -- 如 bootstrap 获取失败,则回退到默认 `100MB` - -### 11.3 约束 - -- `--remote-workspace-path` 必须与 `--local-workspace` 一起使用 -- `--local-workspace` 不能和单次 `--message` 模式一起使用 -- 当前只支持 Hermes 远端 native 模式 - -## 12. Hermes 命令主线 - -常用命令: - -```bash -agentengine hermes deploy --name hermes-demo -agentengine hermes status -agentengine hermes open --chat -agentengine hermes connect -agentengine hermes exec -- status -``` - -部署相关默认值: - -- 默认 PVC 大小:`20Gi` -- 默认挂载目录:`/home/node/.hermes` -- 默认 workspace 根目录:`/home/node/.hermes/workspace` - -## 13. OpenClaw 命令主线 - -常用命令: - -```bash -agentengine openclaw deploy -agentengine openclaw list -agentengine openclaw status -agentengine openclaw gateway doctor -agentengine openclaw channel status --probe -``` - -### 13.1 当前支持的记忆参数 - -```bash -agentengine openclaw deploy --memory-system openclaw_default -agentengine openclaw deploy \ - --memory-system mem0 \ - --mem0-instance-id \ - --mem0-instance-name my-mem0 \ - --mem0-region cn-beijing-6 -``` - -约束: - -- `--memory-system mem0` 时必须传 `--mem0-instance-id` -- `--memory-system openclaw_default` 时不能再传 mem0 细节参数 -- 不显式传 `--memory-system` 时,CLI 不会主动覆盖现有服务端配置 - -### 13.2 当前 mem0 行为 - -- OpenClaw 镜像内置 mem0 插件资产 -- bootstrap 默认把 `openclaw-mem0` 视为延迟同步插件 -- 只有在存在 `MEMORY_BACKEND_MANIFEST` 且渲染结果要求该插件时,才会把插件真正同步到实例目录 -- 不使用 mem0 时,不会把该插件种到实例的持久化状态里 - -### 13.3 OpenClaw 存储默认值 - -- 默认 PVC 大小:`20Gi` -- 默认挂载目录:`/home/node/.openclaw` -- 默认 workspace 根目录:`/home/node/.openclaw/workspace` - -更多 OpenClaw 细节见 [OpenClaw一键部署指南](../reference/openclaw一键部署指南.md)。 - -## 14. 常见验证项 - -### 14.1 验证工作区文件 - -```bash -agentengine files list --output json -agentengine files push --local-dir ./workspace --remote-path demo -agentengine files pull --remote-path demo --local-dir ./downloaded --force -``` - -### 14.2 验证 Hermes remote workspace - -```bash -agentengine agent invoke hermes-demo \ - --transport native \ - --local-workspace ./workspace -``` - -### 14.3 验证 OpenClaw mem0 参数 - -```bash -agentengine openclaw deploy \ - --memory-system mem0 \ - --mem0-instance-id e52b7fac-e641-4b34-b9f7-6b0b9f190cd4 -``` - -## 15. 相关文档 - -- [ksadk技术设计](../reference/ksadk技术设计.md) -- [工作区文件技术设计](../internal/工作区文件技术设计.md) -- [记忆使用指南](./记忆使用指南.md) -- [知识库与记忆示例](./知识库与记忆示例.md) -- [OpenClaw一键部署指南](../reference/openclaw一键部署指南.md) -- [DeepAgents说明](./DeepAgents说明.md) diff --git "a/docs/guides/\347\237\245\350\257\206\345\272\223\344\270\216\350\256\260\345\277\206\347\244\272\344\276\213.md" "b/docs/guides/\347\237\245\350\257\206\345\272\223\344\270\216\350\256\260\345\277\206\347\244\272\344\276\213.md" deleted file mode 100644 index 0cb5216a..00000000 --- "a/docs/guides/\347\237\245\350\257\206\345\272\223\344\270\216\350\256\260\345\277\206\347\244\272\344\276\213.md" +++ /dev/null @@ -1,114 +0,0 @@ -# 知识库与记忆示例 - -本文档给出当前仓库可直接复用的知识库(KB)与长期记忆(LTM)组合示例,口径以现有示例目录、工具函数和测试为准。 - -## 1. 能力矩阵 - -| 能力 | 入口 | 典型用途 | -| --- | --- | --- | -| 知识库检索 | `search_knowledge` / `search_knowledge_base` | RAG、文档检索 | -| 平台长期记忆 | `load_memory` / `save_memory` | 用户偏好、跨会话结论 | -| ADK 长期记忆 | `LongTermMemory.from_env()` | ADK 场景下的自动检索与持久化 | - -## 2. 最小准备项 - -### 2.1 KB - -- `KSADK_KB_DATASET_ID` -- `KSADK_KB_ACCESS_KEY` -- `KSADK_KB_SECRET_KEY` - -可选: - -- `KSADK_KB_REGION` -- `KSADK_KB_ENDPOINT` -- `KSADK_KB_SCHEME` -- `KSADK_KB_TOP_K` - -### 2.2 LTM - -- `KSADK_LTM_BACKEND` - -后端相关: - -- `http`:`KSADK_LTM_HTTP_URL`、`KSADK_LTM_HTTP_TOKEN` -- `sdk`:`KSADK_LTM_ACCESS_KEY`、`KSADK_LTM_SECRET_KEY` 等 - -## 3. ADK 示例 - -参考目录:`examples/knowledge_base_adk/` - -```python -from google.adk.agents import Agent -from ksadk.knowledge_base.adk_tool import search_knowledge_base - -root_agent = Agent( - name="knowledge_base_assistant", - tools=[search_knowledge_base], -) -``` - -运行: - -```bash -cd examples/knowledge_base_adk -agentengine run . -``` - -## 4. 跨框架记忆工具示例 - -```python -from ksadk.memory.tool import load_memory, save_memory - -memory_text = load_memory("这个用户对输出风格有什么偏好") -save_memory("用户偏好:回答先给结论,再给分步说明") -``` - -## 5. KB + LTM 组合模式 - -推荐顺序: - -1. 先用 KB 回答客观事实 -2. 再用 LTM 补用户偏好与历史决策 -3. 回复结束后保存本轮结论 - -```mermaid -flowchart LR - classDef client fill:#dbeafe,stroke:#1d4ed8,stroke-width:2px,color:#1e3a8a; - classDef data fill:#dcfce7,stroke:#16a34a,stroke-width:2px,color:#166534; - classDef runtime fill:#e2e8f0,stroke:#475569,stroke-width:2px,color:#1e293b; - - Question["用户问题"]:::client --> KB["search_knowledge"]:::data - Question --> LTM["load_memory"]:::data - KB --> Reason["LLM 组织答案"]:::runtime - LTM --> Reason - Reason --> Save["save_memory"]:::data -``` - -## 6. OpenClaw 场景 - -OpenClaw deploy 当前支持: - -```bash -agentengine openclaw deploy --memory-system openclaw_default -agentengine openclaw deploy --memory-system mem0 --mem0-instance-id -``` - -说明: - -- `openclaw_default` 不需要 mem0 实例参数 -- `mem0` 由 server 注入环境变量并下发 manifest -- runtime 只负责渲染 patch 和按需同步插件 - -## 7. 建议验证入口 - -- `tests/test_platform_memory_tools.py` -- `tests/unit/memory/test_adk_memory_comprehensive.py` -- `tests/test_runtime_common_memory_backend.py` -- `tests/unit/knowledge_base/test_client_env.py` - -## 8. 相关文档 - -- [记忆使用指南](./记忆使用指南.md) -- [ksadk使用文档](./ksadk使用文档.md) -- [OpenClaw一键部署指南](../reference/openclaw一键部署指南.md) diff --git "a/docs/guides/\350\256\260\345\277\206\344\275\277\347\224\250\346\214\207\345\215\227.md" "b/docs/guides/\350\256\260\345\277\206\344\275\277\347\224\250\346\214\207\345\215\227.md" deleted file mode 100644 index 46ac67e2..00000000 --- "a/docs/guides/\350\256\260\345\277\206\344\275\277\347\224\250\346\214\207\345\215\227.md" +++ /dev/null @@ -1,227 +0,0 @@ -# 记忆使用指南 - -本文档说明 `ksadk-python` 当前代码里可直接使用的记忆能力,分为两条主线: - -1. 平台长期记忆工具:`load_memory` / `save_memory` -2. OpenClaw runtime memory backend:`openclaw_default` / `mem0` - -## 1. 能力分层 - -```mermaid -flowchart TB - classDef client fill:#dbeafe,stroke:#1d4ed8,stroke-width:2px,color:#1e3a8a; - classDef control fill:#ede9fe,stroke:#7c3aed,stroke-width:2px,color:#581c87; - classDef data fill:#dcfce7,stroke:#16a34a,stroke-width:2px,color:#166534; - classDef runtime fill:#e2e8f0,stroke:#475569,stroke-width:2px,color:#1e293b; - - Tool["load_memory / save_memory"]:::client --> Service["LongTermMemoryService.from_env()"]:::runtime - ADK["LongTermMemory.from_env()"]:::client --> Service - OpenClaw["MEMORY_BACKEND_MANIFEST"]:::control --> Backend["ksadk_runtime_common.memory_backend"]:::runtime - Service --> BackendImpl["local / http / sdk backend"]:::data - Backend --> Mem0["mem0 provider"]:::data - Backend --> Default["openclaw_default provider"]:::data -``` - -## 2. 平台长期记忆工具 - -### 2.1 入口 - -文件:`ksadk/memory/tool.py` - -- `load_memory(query: str) -> str` -- `save_memory(content: str) -> str` - -### 2.2 行为约束 - -- 依赖运行时上下文 `platform_invocation_context` -- 无上下文时不会盲写,而是返回诊断信息 -- 保存时会附带 `agent_id / user_id / session_id / runner_type` 等元数据 - -### 2.3 使用示例 - -```python -from ksadk.memory.tool import load_memory, save_memory - -history = load_memory("用户之前提过哪些偏好") -save_memory("用户偏好:先给结论,再给简短原因") -``` - -## 3. ADK 记忆服务 - -文件:`ksadk/memory/adk/long_term_memory.py` - -核心入口: - -```python -from ksadk.memory.adk.long_term_memory import LongTermMemory - -ltm = LongTermMemory.from_env(app_name="demo_app") -``` - -当前行为: - -- 只持久化用户事件 -- 会把检索结果转换成 ADK `MemoryEntry` -- 支持 `local`、`http`、`sdk` 三类后端 - -## 4. 环境变量 - -### 4.1 通用 LTM 环境变量 - -- `KSADK_LTM_BACKEND`:`local` / `http` / `sdk` -- `KSADK_LTM_TOP_K`:默认 `5` -- `KSADK_LTM_INDEX` -- `KSADK_LTM_APP_NAME` - -### 4.2 `http` 后端 - -- `KSADK_LTM_HTTP_URL` -- `KSADK_LTM_HTTP_TOKEN` - -### 4.3 `sdk` 后端 - -- `KSADK_LTM_ACCESS_KEY` -- `KSADK_LTM_SECRET_KEY` -- `KSADK_LTM_REGION` -- `KSADK_LTM_ENDPOINT` -- `KSADK_LTM_SCHEME` -- `KSADK_LTM_NAMESPACE`:环境变量名保持不变,值对应新版 SDK 的 `MemoryCollectionId` -- `KSADK_LTM_AGENT_ID` -- `KSADK_LTM_SCENE_ID`:默认 `_sys_general` -- `KSADK_LTM_AUTO_SAVE`:布尔开关。默认在 `KSADK_LTM_BACKEND=sdk` 且 `KSADK_LTM_NAMESPACE` 已设置时开启;设为 `false` / `0` / `off` 可关闭 runtime 每轮完成后的会话文本镜像。 - -自动镜像只写 user/assistant 文本和附件摘要,metadata 会包含 `agent_id`、`session_id`、`invocation_id`、`model`、`runner_type`。图片、文件 base64 和二进制内容不会写入长期记忆;完整附件回显仍由 AgentEngine conversation 存储负责。 - -!!! info "invocation_id 与账号边界从哪来(0.6.5 新增)" - `load_memory` / `save_memory` 不接收显式的 user/session 参数,而是通过 `ksadk.runtime_context.get_current_invocation_context()` 读取当前运行时上下文,取其中的 `user_id` / `session_id` / `runner_type` 等字段写入记忆元数据,`invocation_id` 即来自该上下文。`account_id` 字段同样来自此上下文,用于在多账号共享同一记忆后端时做账号隔离。 - - 业务侧若要在工具或自定义流程里直接拿到账号标识,可用 0.6.5 新增的便捷函数: - - ```python hl_lines="2" - from ksadk.runtime_context import get_current_account_id - account_id = get_current_account_id(default="") - ``` - - `get_current_account_id()` 同样基于 `get_current_invocation_context()`;上下文缺失时返回传入的 `default`(默认空串),不会抛异常。`get_current_user_id()` 是其姊妹函数,行为一致。 - -## 5. OpenClaw 的记忆后端声明 - -### 5.1 CLI 入口 - -```bash -agentengine openclaw deploy --memory-system openclaw_default -agentengine openclaw deploy --memory-system mem0 --mem0-instance-id -``` - -### 5.2 当前支持的 backend - -| backend_type | 含义 | -| --- | --- | -| `openclaw_default` | 保持 OpenClaw 默认记忆模式 | -| `mem0` | 使用 mem0 插件和平台注入的环境变量 | -| `lancedb` | 进程内 LanceDB OpenClaw 记忆插件(plugin id `memory-lancedb`)。通过 `MEMORY_BACKEND_MANIFEST` 直接声明,不走 `agentengine openclaw deploy --memory-system` CLI 入口;manifest 可选覆盖 `dbPath`、embedding 配置和 storage 选项。 | - -### 5.2.1 `lancedb` manifest 示例 - -`lancedb` 后端通过 `MEMORY_BACKEND_MANIFEST` 声明,manifest 经 JSON Schema 校验后由 `LanceDBProvider` 渲染成 `memory-lancedb` 插件配置。`config.dbPath` 可选,不传时使用插件默认路径: - -```json -{ - "schema_version": "v1", - "backend_type": "lancedb", - "config": { - "dbPath": "/home/node/.openclaw/memory/lancedb" - } -} -``` - -渲染后 `plugins.entries.memory-lancedb.config.dbPath` 会被原样透传,`plugins.slots.memory` 指向 `memory-lancedb`,并自动禁用 `openclaw-mem0` 插件。`config.embedding` 和 `config.storageOptions` 同样为可选覆盖项。 - -### 5.3 manifest 契约 - -`ksadk_runtime_common.memory_backend.manifest` 当前强制按 JSON Schema 校验。即使调用方直接传 `MemoryBackendManifest` 模型实例,也会先 `model_dump()` 再走 schema 校验。 - -### 5.4 `mem0` provider 约束 - -当前 `mem0` 渲染要求: - -- `config.mem0_instance_id` 必填 -- 运行时环境变量必须存在: - - `MEM0_API_KEY` - - `MEM0_USER_ID` - - `MEM0_BASE_URL` - -渲染结果会生成: - -- `plugins.slots.memory = "openclaw-mem0"` -- `plugins.entries.openclaw-mem0.enabled = true` -- `plugins.entries.openclaw-mem0.config.mode = "platform"` - -## 6. 服务端与 runtime 的分工 - -```mermaid -sequenceDiagram - autonumber - participant CLI as agentengine openclaw deploy - participant Server as agentengine-server - participant Runtime as OpenClaw bootstrap - participant Render as ksadk_runtime_common.memory_backend - - CLI->>Server: MemoryConfig - Server->>Server: 查询 mem0 实例并生成 env + manifest - Server-->>Runtime: MEMORY_BACKEND_MANIFEST + MEM0_* - Runtime->>Render: render_to_json() - Render-->>Runtime: config_patch + plugin_ids - Runtime->>Runtime: 写入 openclaw.json 并按需同步插件 -``` - -分工边界: - -- server 是 mem0 实例查询、校验和密钥拼装的事实源 -- runtime 只消费 manifest 和环境变量,不直接查询 mem0 控制面 - -## 7. 常见排查 - -### 7.1 `load_memory` 返回诊断文本 - -通常表示当前调用不在受支持的运行时上下文里。 - -### 7.2 `sdk` 后端初始化失败 - -优先检查: - -- 依赖是否安装 -- AK/SK 是否齐全 -- `KSADK_LTM_ENDPOINT` 与网络环境是否匹配 - -### 7.3 OpenClaw mem0 渲染失败 - -当前渲染失败会直接指出缺失的环境变量,例如: - -- `mem0 backend requires environment variable 'MEM0_API_KEY'` - -### 7.4 OpenClaw 启动后配置校验失败 - -优先检查: - -- `MEMORY_BACKEND_MANIFEST` 是否为合法 JSON -- manifest 中 `mem0_instance_id` 是否满足 schema 要求 -- runtime 中的 mem0 插件是否按渲染结果被同步 - -### 7.5 LanceDB `dbPath` 写入失败(0.6.7 新增) - -`lancedb` 后端在容器内初始化时,若指定的 `config.dbPath` 目录不存在或无写权限,`memory-lancedb` 插件启动会失败。优先检查: - -- `dbPath` 所在目录是否已挂载为可写卷,Pod 重启后路径是否仍持久化 -- 运行时进程对该路径是否有读写权限(OpenClaw runtime 通常以非 root 用户运行) -- `MEMORY_BACKEND_MANIFEST` 是否为合法 JSON,且 `config.dbPath` 是绝对路径 -- 若不传 `dbPath`,确认插件默认路径在当前部署环境可写 - -!!! warning "校验仍走 schema" - 即使 `dbPath` 目录不存在,manifest 也会先通过 JSON Schema 校验(schema 只校验字段类型,不校验路径可达性)。路径/权限问题在 runtime 拉起插件时才暴露,排查时以 runtime 日志为准。 - -## 8. 相关文档 - -- [知识库与记忆示例](./知识库与记忆示例.md) -- [ksadk技术设计](../reference/ksadk技术设计.md) -- [OpenClaw一键部署指南](../reference/openclaw一键部署指南.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" deleted file mode 100644 index 2206df11..00000000 --- "a/docs/ksadk\347\216\257\345\242\203\345\217\230\351\207\217\345\217\202\350\200\203.md" +++ /dev/null @@ -1,619 +0,0 @@ -# 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 集成。 | -| `CLOUD_MONITOR_APP_KEY` | 条件必传 | 无 | 是 | 平台 Secret | 云监控 OTLP AppKey。 | -| `CLOUD_MONITOR_OTLP_ENDPOINT` | 条件必传 | 无 | 否 | 平台 / 开发者 | CloudMonitor 通用 OTLP HTTP endpoint。 | -| `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_CONNECT_TIMEOUT` | Sessions | 否 | `5` | `KSADK_SESSION_PG_CONNECT_TIMEOUT` | 否 | 开发者 / 平台 | 否 | PostgreSQL 会话 backend 连接超时秒数。 | -| `KSADK_SESSION_PG_CONNECT_TIMEOUT` | Sessions 旧兼容 | 否 | `5` | `KSADK_SESSION_CONNECT_TIMEOUT` | 否 | 兼容旧部署 | 否 | 旧 PostgreSQL session 连接超时变量。新部署优先 `KSADK_SESSION_CONNECT_TIMEOUT`。 | -| `KSADK_SESSION_NAMESPACE` | Sessions | 否 | 未设置 | `KSADK_WORKSPACE_ID`、`AGENTENGINE_WORKSPACE_ID`、`KSADK_TENANT_ID`、`AGENTENGINE_TENANT_ID` | 否 | 平台 | 否 | 会话 namespace。 | -| `KSADK_CHECKPOINT_BACKEND` | LangGraph checkpoint | 否 | `local` | `local` 等价本地 SQLite;也支持 `sqlite`、`memory`、`postgres` | 否 | 开发者 / 平台 | 否 | LangGraph checkpoint backend。`agentengine web` 本地调试默认优先使用 SQLite。 | -| `KSADK_CHECKPOINT_PATH` | LangGraph checkpoint | 否 | 项目目录下 `.agentengine/ui/checkpoints.sqlite` | 无 | 否 | 开发者 / 本地运行时 | 否 | 本地 SQLite checkpoint 文件路径。 | -| `KSADK_LANGGRAPH_CHECKPOINT_DSN` | LangGraph checkpoint | 条件必传 | 未设置 | 无 | 是 | Secret | 否 | `KSADK_CHECKPOINT_BACKEND=postgres` 时的 LangGraph checkpointer PostgreSQL DSN。 | -| `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_UI_PROFILE` | 本地 Web UI / Runtime bootstrap | 否 | `builtin` | 无 | 否 | 开发者 / 平台 | 否 | Agent UI profile。`custom` 时 runtime 会暴露自定义 UI bootstrap 信息。 | -| `KSADK_UI_PATH` | 本地 Web UI / Runtime bootstrap | 否 | `/` | 无 | 否 | 开发者 / 平台 | 否 | 自定义 UI 挂载路径,例如 `/research`。 | -| `KSADK_UI_URL` | Runtime bootstrap | 否 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | 外部自定义 UI URL。 | -| `KSADK_UI_BUNDLE_PATH` | Runtime bootstrap | 否 | 自动探测 `research-ui/dist` | 无 | 否 | 开发者 / 平台 | 否 | 自定义 UI 静态 bundle 相对项目路径。 | -| `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 开关。 | -| `CLOUD_MONITOR_APP_KEY` | CloudMonitor tracing | 条件必传 | 未设置 | 无 | 是 | 平台 Secret | 否 | 云监控 OTLP AppKey;启用 CloudMonitor OTLP 上报时需要。 | -| `CLOUD_MONITOR_OTLP_ENABLED` | CloudMonitor tracing | 否 | 自动判断 | 无 | 否 | 平台 / 开发者 | 否 | 显式启用或禁用 CloudMonitor OTLP exporter。 | -| `CLOUD_MONITOR_OTLP_ENDPOINT` | CloudMonitor tracing | 条件必传 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | CloudMonitor 通用 OTLP HTTP endpoint;未设置 traces endpoint 时会派生 `/v1/traces`。 | -| `CLOUD_MONITOR_OTLP_PROTOCOL` | CloudMonitor tracing | 否 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | CloudMonitor 通用 OTLP 协议,当前支持 `http/protobuf`。 | -| `CLOUD_MONITOR_OTLP_HEADERS` | CloudMonitor tracing | 否 | 未设置 | 无 | 是 | 平台 / 开发者 | 否 | CloudMonitor OTLP 附加 headers,逗号分隔且 URL encoded。 | -| `CLOUD_MONITOR_OTLP_TRACES_ENDPOINT` | CloudMonitor tracing | 否 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | CloudMonitor traces 专用 endpoint,优先于通用 endpoint。 | -| `CLOUD_MONITOR_OTLP_TRACES_PROTOCOL` | CloudMonitor tracing | 否 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | CloudMonitor traces 专用协议,优先于通用 protocol。 | -| `CLOUD_MONITOR_LANGFUSE_ENABLED` | CloudMonitor Langfuse callback | 否 | 自动判断 | 无 | 否 | 平台 / 开发者 | 否 | 显式启用或禁用 CloudMonitor Langfuse SDK callback。 | -| `CLOUD_MONITOR_LANGFUSE_HOST` | CloudMonitor Langfuse callback | 条件必传 | 未设置 | `CLOUD_MONITOR_OTLP_ENDPOINT` | 否 | 平台 / 开发者 | 否 | CloudMonitor AppMonitor Langfuse SDK host。 | -| `CLOUD_MONITOR_LANGFUSE_PUBLIC_KEY` | CloudMonitor Langfuse callback | 条件必传 | 未设置 | 无 | 是 | 平台 Secret | 否 | CloudMonitor AppMonitor Langfuse public key。 | -| `CLOUD_MONITOR_LANGFUSE_SECRET_KEY` | CloudMonitor Langfuse callback | 条件必传 | 未设置 | 无 | 是 | 平台 Secret | 否 | CloudMonitor AppMonitor Langfuse secret key。 | -| `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/docs/maintainer-approval-record.md b/docs/maintainer-approval-record.md index 3c283f9c..fad17358 100644 --- a/docs/maintainer-approval-record.md +++ b/docs/maintainer-approval-record.md @@ -19,19 +19,19 @@ PyPI publication. ## Publication Strategy -Record exactly one approved source publication strategy: +Record exactly one approved source publication strategy. | Strategy | Approved | | --- | --- | | Reviewed GitHub pull request | No | -| Clean export from reviewed candidate | No | -| Rewritten Git history after secret scan | Yes | +| Clean export from reviewed candidate | Yes | +| Rewritten Git history after secret scan | No | The approved strategy must name the reviewed commit, tag, pull request, or export archive used for: -- `ksadk-python`: rewritten public `new-main` candidate `3bbf295e4f27a4f7f6a8b8cdf76a17227ad40033`, verified by `make public-preflight KSADK_WEB_VERSION=0.2.16` and public source/dist audits. -- `ksadk-web`: npm package `@kingsoftcloud/ksadk-web@0.2.16` (`latest` on 2026-07-03), bundled from npm during `make public-preflight KSADK_WEB_VERSION=0.2.16`. +- `ksadk-python`: clean export candidate from reviewed internal commit `1f3c0d844dc4df2630c8e1d410bcd9f7c9b595cc`; local candidate directory `/tmp/ksadk-python-export-candidate-0.6.9`; verified on 2026-07-08 with public source audit, registry-bundled `make public-preflight` in the public candidate worktree, Fumadocs static build, wheel/sdist build, twine check, and source/dist package audits. +- `ksadk-web`: npm package `@kingsoftcloud/ksadk-web@0.2.18` from commit `24551d0f290e5a4efc5b5d60d02fa298cccd2efa`; Python candidate commit `1f3c0d844dc4df2630c8e1d410bcd9f7c9b595cc`; published by the trusted GitHub npm workflow on 2026-07-08 and consumed from the npm registry during `make public-preflight`. Both approved source references must include the current commit SHA at approval time. This prevents a stale approval record from passing after candidate @@ -59,6 +59,6 @@ changes. | Role | Name | Decision | Date | | --- | --- | --- | --- | -| Maintainer | xiayu | Approved for one-time public main rewrite | 2026-07-03 | -| Security reviewer | automated public audit | Passed source, wheel, and sdist audits with 0 violations | 2026-07-03 | -| Release owner | xiayu | Approved GitHub Release / PyPI Trusted Publishing for 0.6.9 | 2026-07-03 | +| Maintainer | xiayu | Approved clean export candidate for ksadk 0.6.9 | 2026-07-08 | +| Security reviewer | automated public audit | Passed source, wheel, and sdist audits with 0 violations | 2026-07-08 | +| Release owner | xiayu | Approved trusted GitHub PyPI and Pages workflow for 0.6.9 | 2026-07-08 | diff --git "a/docs/reference/ksadk\346\212\200\346\234\257\350\256\276\350\256\241.md" "b/docs/reference/ksadk\346\212\200\346\234\257\350\256\276\350\256\241.md" deleted file mode 100644 index bcaf6d11..00000000 --- "a/docs/reference/ksadk\346\212\200\346\234\257\350\256\276\350\256\241.md" +++ /dev/null @@ -1,524 +0,0 @@ -# ksadk技术设计 - -本文档描述 `ksadk-python` 当前主线的正式技术设计。写法采用技术设计文档结构,但内容只记录已经体现在代码、测试、CLI 帮助、Dockerfile 与默认常量中的行为。 - -## 1. 目标与边界 - -`ksadk-python` 负责三类职责: - -1. 开发者入口 - 提供 `agentengine` / `ksadk` CLI,本地开发、调试、构建和部署都从这里进入。 -2. 本地运行时 - 提供本地 Web UI、会话存储、本地 workspace 数据面以及开发态调用能力。 -3. 托管运行时资产 - 提供 Hermes / OpenClaw 共享镜像、bootstrap 脚本、共享 workspace_files 与 memory_backend 源码。 -4. Skill Runtime 与内置工具消费 - 提供 Skill Space 运行时消费、Skill 包校验与加载、sandbox backend 编排、内置 toolset 绑定、Tool Gateway 审批 envelope。 - -不在本仓承担最终事实源的能力: - -- Agent 生命周期持久化 -- endpoint / api_key 写回 -- Hosted UI bootstrap -- Workspace Files Hosted Action -- OpenClaw `MEMORY_BACKEND_MANIFEST` 生成 -- Skill 注册、CRUD、版本治理和 marketplace -- Sandbox template、instance、token 与网络生命周期 - -这些分别由 `agentengine-server`、Skill Service、Sandbox Service 或平台控制面负责。 - -## 2. 总体架构 - -```mermaid -flowchart LR - classDef client fill:#dbeafe,stroke:#1d4ed8,stroke-width:2px,color:#1e3a8a; - classDef control fill:#ede9fe,stroke:#7c3aed,stroke-width:2px,color:#581c87; - classDef data fill:#dcfce7,stroke:#16a34a,stroke-width:2px,color:#166534; - classDef storage fill:#ffedd5,stroke:#ea580c,stroke-width:2px,color:#9a3412; - classDef runtime fill:#e2e8f0,stroke:#475569,stroke-width:2px,color:#1e293b; - - subgraph Client["开发者入口"] - CLI["agentengine CLI"]:::client - Web["agentengine web"]:::client - Invoke["agent invoke / files"]:::client - end - - subgraph Repo["ksadk-python"] - Dispatch["命令分发与框架识别"]:::runtime - Local["ksadk.server.app"]:::runtime - Common["ksadk_runtime_common"]:::runtime - Toolsets["ksadk.toolsets + Tool Gateway"]:::runtime - SkillRT["ksadk.skills.runtime"]:::runtime - Sandbox["ksadk.sandbox"]:::runtime - Assets["agentengine-images: deploy/hermes + deploy/openclaw"]:::runtime - end - - subgraph Control["控制面"] - Server["agentengine-server"]:::control - end - - subgraph Runtime["运行时"] - Generic["通用 runtime"]:::data - Hermes["Hermes"]:::data - OpenClaw["OpenClaw"]:::data - end - - subgraph Storage["持久化"] - LocalRoot[".agentengine/ui/workspace"]:::storage - PVC["PVC / 挂盘目录"]:::storage - end - - CLI --> Dispatch - Web --> Local - Invoke --> Server - Dispatch --> Local - Dispatch --> Server - Dispatch --> Toolsets - Toolsets --> SkillRT - Toolsets --> Sandbox - Common --> Local - Common --> Assets - Assets --> Hermes - Assets --> OpenClaw - Server --> Generic - Server --> Hermes - Server --> OpenClaw - Local --> LocalRoot - Generic --> PVC - Hermes --> PVC - OpenClaw --> PVC -``` - -## 3. 代码分层 - -### 3.1 CLI 层 - -入口在 `pyproject.toml`: - -```toml -agentengine = "ksadk.cli:main" -``` - -CLI 层负责: - -- framework 检测 -- 本地运行与调试 -- 构建产物准备 -- 调用 `agentengine-server` -- `files` 与 `agent invoke` 的传输选择 -- framework 级默认存储参数 - -### 3.2 本地运行时 - -本地运行时的核心是 `ksadk.server.app`,负责: - -- 本地会话 -- 本地统一 Web UI -- 本地附件上传 -- 本地 workspace files 路由 - -当前本地目录约定: - -- UI 根目录:`/.agentengine/ui` -- 本地会话:`/.agentengine/ui/sessions.sqlite` -- 本地 workspace:`/.agentengine/ui/workspace` - -### 3.3 托管运行时资产 - -Hermes / OpenClaw 运行时镜像资产曾在本仓库 `deploy/hermes/`、`deploy/openclaw/`、`deploy/openclaw-user-template/` 下维护,现已迁出至 `agentengine-images` 仓库。这些目录不仅是模板,还包含线上运行时镜像的事实约定,例如: - -- Hermes 的 `entrypoint.sh` -- OpenClaw 的 `bootstrap.sh` -- 共享 workspace sidecar 与 memory backend 渲染入口 - -### 3.4 Skill Runtime、Sandbox 与 Toolsets - -`0.6.2` 起,SDK 侧新增三层运行时消费抽象: - -- `ksadk.skills.runtime`:负责 workflow 请求解析、Skill 选择、远端 Skill 包下载、`sha256` 校验、安全解压、runtime agent 执行和 artifacts 汇总。 -- `ksadk.sandbox`:通用 Sandbox Runtime 底座,当前首个 backend 是 E2B-compatible sandbox;Skill Runtime 和 sandbox direct tools 共用这层,不把 sandbox 语义写死为 Skill 专用。 -- `ksadk.toolsets`:给 LangGraph、LangChain、DeepAgents、ADK 或自定义 runner 暴露内置工具,包括 Skill、Workspace、Platform、Sandbox 四组工具,以及聚合入口 `get_agentengine_tools()`。 - -推荐绑定方式是显式渐进式披露: - -```python -from ksadk.toolsets import get_agentengine_tools - -tools = get_agentengine_tools(include=["focused", "agentengine_tool_dispatcher"]) -``` - -`get_agentengine_tools()` 无参保持全量工具兼容。`focused/core` profile 只直接暴露 Skill 发现/加载、Workspace 状态/搜索/片段编辑/lint、组件状态和 sandbox 状态;`execute_skills`、`run_command`、`run_code`、Workspace 写入/删除等低频或高风险工具通过 `agentengine_tool_dispatcher` 按需 `list` / `describe` / `call`。 - -Tool Gateway 位于实际工具执行前,负责风险策略和人工确认 envelope。strict 模式下,中高风险工具返回 `approval_required`,由 Hosted/local UI 或调用方回传批准后继续;dispatcher 调用真实工具对象,不绕过 Tool Gateway。 - -```mermaid -flowchart LR - classDef agent fill:#dbeafe,stroke:#1d4ed8,stroke-width:2px,color:#1e3a8a; - classDef tool fill:#dcfce7,stroke:#16a34a,stroke-width:2px,color:#166534; - classDef runtime fill:#e2e8f0,stroke:#475569,stroke-width:2px,color:#1e293b; - classDef service fill:#ede9fe,stroke:#7c3aed,stroke-width:2px,color:#581c87; - - Agent["LangGraph / LangChain / ADK Agent"]:::agent --> Focused["focused tools"]:::tool - Agent --> Dispatcher["agentengine_tool_dispatcher"]:::tool - Focused --> Gateway["Tool Gateway"]:::runtime - Dispatcher --> Gateway - Gateway --> Skill["Skill tools / execute_skills"]:::tool - Gateway --> Workspace["Workspace tools"]:::tool - Gateway --> SandboxTools["Sandbox direct tools"]:::tool - Skill --> SkillService["Skill Service"]:::service - Skill --> SkillRuntime["Skill Runtime backend"]:::runtime - SkillRuntime --> Sandbox["E2B / Sandbox backend"]:::runtime - SandboxTools --> Sandbox -``` - -## 4. `ksadk_runtime_common` 同仓共享源码 - -这是当前主线的核心去重点。 - -```mermaid -flowchart TB - classDef runtime fill:#e2e8f0,stroke:#475569,stroke-width:2px,color:#1e293b; - classDef data fill:#dcfce7,stroke:#16a34a,stroke-width:2px,color:#166534; - classDef control fill:#ede9fe,stroke:#7c3aed,stroke-width:2px,color:#581c87; - - Common["ksadk_runtime_common"]:::runtime - WF["workspace_files"]:::data - MB["memory_backend"]:::control - Local["ksadk.server.app"]:::runtime - Hermes["agentengine-images: deploy/hermes/runtime/app.py"]:::runtime - OpenClaw["agentengine-images: deploy/openclaw/bootstrap.sh + workspace_files_app.py"]:::runtime - - Common --> WF - Common --> MB - WF --> Local - WF --> Hermes - WF --> OpenClaw - MB --> OpenClaw -``` - -当前共享源码包含两块: - -### 4.1 `workspace_files` - -职责: - -- 统一 runtime 路由前缀 -- 统一 Hosted bootstrap payload -- 统一路径逃逸拦截 -- 统一上传大小上限和动作常量 - -关键常量: - -- `WORKSPACE_ENTRY_ACTION = "ListWorkspaceFiles"` -- `WORKSPACE_UPLOAD_ACTION = "AddWorkspaceFile"` -- `WORKSPACE_CONTENT_PATH = "/agentengine/api/v1/GetWorkspaceFileContent"` -- `DEFAULT_WORKSPACE_MAX_UPLOAD_BYTES = 100MB` - -### 4.2 `memory_backend` - -职责: - -- 解析并校验 `MEMORY_BACKEND_MANIFEST` -- 基于 provider 渲染 OpenClaw 需要的配置 patch -- 返回需要同步的插件 ID 列表 - -当前 provider: - -- `openclaw_default` -- `mem0` - -当前 `mem0` 渲染所要求的环境变量: - -- `MEM0_API_KEY` -- `MEM0_USER_ID` -- `MEM0_BASE_URL` - -## 5. 存储与 workspace 设计 - -`ksadk/cli/storage.py` 统一定义了 framework 级默认值。 - -### 5.1 容量约束 - -- 默认:`20Gi` -- 最小:`20Gi` -- 最大:`500Gi` - -### 5.2 默认挂载目录 - -| Framework | 默认挂载目录 | -| --- | --- | -| `adk` | `/home/node/.agentengine` | -| `langchain` | `/home/node/.agentengine` | -| `langgraph` | `/home/node/.agentengine` | -| `deepagents` | `/home/node/.agentengine` | -| `hermes` | `/home/node/.hermes` | -| `openclaw` | `/home/node/.openclaw` | - -### 5.3 workspace 对外语义 - -- CLI 和 Hosted UI 统一把根目录表示为 `workspace:/` -- Hermes 明确把 `KSADK_WORKSPACE_ROOT` 绑定到 `HERMES_WORKDIR` -- OpenClaw 明确把 `KSADK_WORKSPACE_ROOT` 绑定到 `${OPENCLAW_STATE_DIR}/workspace` - -## 6. 文件访问与传输选择 - -```mermaid -sequenceDiagram - autonumber - participant U as 用户 - participant CLI as agentengine files - participant Client as AgentEngineClient - participant Server as agentengine-server - participant Runtime as runtime data plane - - U->>CLI: files list / upload / push - CLI->>Client: 规范化 agent_ref 与路径 - alt 常规 agent - Client->>Runtime: 直连 /_ksadk/workspace/v1/* - Runtime-->>Client: JSON / 文件流 - else OpenClaw 或 Hosted 场景 - Client->>Server: ListWorkspaceFiles / AddWorkspaceFile - Server->>Runtime: 代发请求 - Runtime-->>Server: JSON / 文件流 - Server-->>Client: ActionResponse - end - Client-->>CLI: pretty / json 输出 -``` - -当前策略重点: - -- 常规 agent 有 endpoint + api_key 时,优先 `runtime_direct` -- OpenClaw 默认优先 `action_proxy` -- CLI 会把逻辑路径和真实路径同时渲染到输出中 - -## 7. `agentengine agent invoke` 与本地目录同步 - -`agentengine agent invoke` 是远端交互入口;其中 Hermes native 模式额外接通了本地目录同步。 - -同步前会做四件事: - -1. 递归扫描本地目录 -2. 校验目录非空 -3. 校验任一单文件不超过 `MaxUploadBytes` -4. 校验目录总大小不超过同一个上限 - -如果不传 `--remote-workspace-path`,会默认使用本地目录名作为远端子目录名。 - -## 8. Hermes 运行时设计要点 - -Hermes runtime 的关键事实: - -- Docker 构建时从仓根复制 `ksadk_runtime_common` -- `PYTHONPATH=/opt` -- `HERMES_HOME=/home/node/.hermes` -- `HERMES_WORKDIR=/home/node/.hermes/workspace` -- `KSADK_WORKSPACE_ROOT` 默认跟随 `HERMES_WORKDIR` - -Hermes 既承载 dashboard,又承载: - -- `/v1/*` -- `/_ksadk/terminal/ws` -- `/_ksadk/workspace/v1/*` - -## 9. OpenClaw 运行时设计要点 - -OpenClaw runtime 的关键事实: - -- Docker 构建时从仓根复制 `ksadk_runtime_common` -- `PYTHONPATH=/opt` -- bootstrap 会启动 workspace files sidecar -- sidecar 默认监听 `127.0.0.1:8091` -- gateway 内部通过 `OPENCLAW_WORKSPACE_FILES_PROXY_URL` 转发到 sidecar - -### 9.1 memory backend 主链路 - -```mermaid -flowchart LR - classDef control fill:#ede9fe,stroke:#7c3aed,stroke-width:2px,color:#581c87; - classDef data fill:#dcfce7,stroke:#16a34a,stroke-width:2px,color:#166534; - classDef runtime fill:#e2e8f0,stroke:#475569,stroke-width:2px,color:#1e293b; - - Server["agentengine-server"]:::control --> Manifest["MEMORY_BACKEND_MANIFEST"]:::control - Manifest --> Render["python -m ksadk_runtime_common.memory_backend.render"]:::runtime - Render --> Patch["memory patch JSON + plugin_ids"]:::data - Patch --> Bootstrap["bootstrap.sh"]:::runtime - Bootstrap --> Config["openclaw.json"]:::data - Bootstrap --> Extensions["按需同步 default extension"]:::data -``` - -当前链路特征: - -- manifest 由控制面生成 -- 渲染在 runtime 内完成 -- `mem0` 需要环境变量齐全,否则 bootstrap 直接失败 -- 插件不是无条件落盘,而是由渲染结果驱动按需同步 -- `lancedb`(`backend_type=lancedb`)走同一 manifest→render 链路,但不依赖 mem0 环境变量;渲染产出 `memory-lancedb` 插件 entry,同时把 `openclaw-mem0` 加入 `disabled_plugin_ids`,避免两个 memory 插件同时生效。manifest 可选 `config.dbPath` / `config.embedding` / `config.storageOptions` 三个 LanceDB 专属字段,由 schema 校验,缺省时插件使用内置默认。 - -!!! info "0.6.7 新增 backend" - LanceDB 作为进程内向量存储 backend,给 OpenClaw 提供无需外部 mem0 实例的长期记忆能力;当 `backend_type=lancedb` 时,`secrets_env` 可留空。 - -## 10. Docker 构建与根上下文 - -当前 Hermes / OpenClaw 镜像都采用同仓共享源码 + 根上下文构建: - -- `COPY ksadk_runtime_common /opt/ksadk_runtime_common` -- `PYTHONPATH=/opt` -- 根目录 `.dockerignore` 负责排除 `.git`、`dist`、`build`、缓存目录和本地产物 - -收益: - -- 不依赖额外 wheel 仓发布 -- 共享源码与 runtime 资产同仓演进 -- Docker 构建可直接消费最新共享模块 - -## 11. 与服务端的协作边界 - -| 能力 | `ksadk-python` | `agentengine-server` | -| --- | --- | --- | -| CLI / 本地开发 | 负责 | 不负责 | -| Agent 生命周期 | 调用方 | 真相源 | -| Hosted UI bootstrap | 消费方 | 负责 | -| Workspace Files runtime data plane | 负责 | 不负责 | -| Workspace Files Hosted Action | 消费方 | 负责 | -| Memory manifest 渲染 | 负责(含 `openclaw_default` / `mem0` / `lancedb` 三种 backend_type) | 不负责 | -| Memory manifest 生成 | 不负责 | 负责 | -| endpoint / api_key 写回 | 不负责 | 负责 | - -## 12. 平台上下文与 invocation_id - -!!! new "0.6.5 新增" - 平台调用上下文(`PlatformInvocationContext`)与 `invocation_id` 贯穿 runner payload 与 OpenAI 兼容接口,为 Skill / Workspace / Sandbox / Memory 工具提供统一的账号边界读取入口。 - -### 12.1 PlatformInvocationContext - -`ksadk.runtime_context.PlatformInvocationContext` 在 runner 执行前由 conversation runtime 注入到 `ContextVar`,携带 `agent_id` / `user_id` / `session_id` / `account_id` 等字段。工具实现优先读取当前调用上下文,而不是裸环境变量。 - -```python -from ksadk.runtime_context import ( - get_current_invocation_context_or_default, - get_current_user_id, - get_current_account_id, -) - -ctx = get_current_invocation_context_or_default() -user_id = get_current_user_id() # ctx.user_id -account_id = get_current_account_id() # ctx.account_id,未注入时为空串 -``` - -`PlatformInvocationContext.account_id` 是 0.6.5 新增字段,用于把控制面透传的账号边界下沉到工具层。 - -### 12.2 invocation_id 与 account_id 透传 - -`invocation_id` 作为单次调用的稳定标识,由 runner payload 与 OpenAI 兼容接口共同透传: - -- runner payload 携带 `invocation_id`,由 conversation runtime 进入 `platform_invocation_scope` / `tool_execution_scope`。 -- `/v1/responses`、`/v1/chat/completions` 与 `RunAgent` action 都接收并透传 `account_id`,进入 `PlatformInvocationContext`。 -- 后台 stream(`Background=true`)使用 `invocation_id` 作为 detached stream 的索引键,供 `SubscribeRunEvents` 拉起始态。 - -```python hl_lines="3" -# RunAgent action 透传示例(伪代码) -result = await conversation.invoke_conversation_once( - runner=active_runner, - agent_id=agent_id, - user_id=run_user_id, - account_id=account_id, # 控制面透传的账号边界 - invocation_id=invocation_id, # 单次调用稳定标识 - session_id=resolved_session_id, -) -``` - -### 12.3 工具按账号边界读取当前调用上下文 - -Skill / Workspace / Sandbox / Memory 工具在执行时通过 `get_current_invocation_context_or_default()` 读取当前 `account_id` / `user_id` / `session_id`,使同一 runner 进程内的多账号调用互不串扰: - -- Memory 工具使用 `context.user_id` / `context.session_id` 限定长期记忆的读写范围。 -- Skill 工具通过 `KSYUN_ACCOUNT_ID` / `KSADK_SKILL_SERVICE_ACCOUNT_ID` 把账号写入 Skill Service 请求头 `X-Ksc-Account-Id`。 -- Workspace / Sandbox 工具通过 `tool_execution_scope` 读取 `session_id` / `run_id` / `invocation_id`,把执行范围绑定到当前调用。 - -!!! tip "工具实现建议" - 自定义工具优先调用 `get_current_invocation_context_or_default()`,不要直接读 `os.environ` 里的账号信息;前者反映当前调用边界,后者只反映进程启动环境。 - -## 13. Hosted/本地附件统一解析 - -!!! new "0.6.6 新增" - 附件 URI 统一为两种 scheme:本地 `ksadk-upload://` 与 Hosted `ae-upload://`。runtime 侧统一解析、按需下载并恢复本地 cache。 - -### 13.1 双 scheme 解析 - -`ksadk.conversations.attachment_storage` 同时识别两种 scheme: - -| Scheme | 来源 | 解析动作 | -| --- | --- | --- | -| `ksadk-upload://` | 本地上传或 KS3 回填 | 直接读本地 cache 或 KS3 object | -| `ae-upload://` | Hosted 控制面下发 | 走 KOP Action `AttachmentContent` 下载 | - -`parse_file_id()` 对两种 scheme 都返回去掉前缀后的 `file_id`;`is_runtime_upload_uri()` / `is_hosted_upload_uri()` 用于分支判断。 - -### 13.2 KOP Action 下载 - -Hosted 附件通过 `AgentEngineClient.download_attachment_content(file_uri)` 走签名后的 KOP Action API 拉取字节流,返回 `AttachmentContent`(`data` / `content_type` / `display_name`)。下载失败时返回 `None`,由上层决定是否降级。 - -### 13.3 本地 cache 恢复链路 - -`AttachmentStorageService.read()` 按以下顺序恢复附件字节: - -1. `ae-upload://` → 调用 KOP Action 下载,写入本地 cache 并落 `.meta.json`。 -2. `ksadk-upload://` 且 metadata 标记 `backend=ks3` → 读 KS3 object,失败时回退本地 cache。 -3. metadata 里有 `local_path` → 直接读本地文件。 -4. 上述都缺失 → 尝试 legacy 本地路径兜底。 - -`_restore_local_cache()` 负责把下载字节落盘到 session files 目录并回填 `local_path`,保证同一 `invocation_id` 内的重复读取不反复走网络。 - -## 14. 会话与事件分页 - -!!! new "0.6.6 新增" - `ListSessions` / `ListSessionEvents` 增加 `count_sessions` / `count_events`,返回 `Total` 供 UI 分页。 - -会话与事件查询在原有 `offset` / `limit` 基础上新增总数统计: - -| Action | 入参 | 出参新增 | -| --- | --- | --- | -| `ListSessions` | `Page` / `PageSize`(agent_id + user_id) | `Total`(`count_sessions`) | -| `ListSessionEvents` | `Offset` / `Limit`(session_id) | `Total`(`count_events`) | - -`SessionService` 基类与 `LocalSessionService` / `PostgresSessionService` 实现统一提供 `count_sessions` / `count_events`,保证本地 SQLite 与托管 PG 行为一致。 - -## 15. Workspace 导出 facade - -!!! new "0.6.6 新增" - `ExportWorkspaceZip` 作为统一 facade,把 workspace 目录打包成 zip 流式下载。 - -`GET /agentengine/api/v1/ExportWorkspaceZip?path=` 由 `ksadk_runtime_common.workspace_files.router` 提供,本地 `ksadk.server.app` 与 OpenClaw sidecar 共用同一 handler。`path` 默认为 `.`(workspace 根),导出前做路径逃逸拦截,符号链接逃逸会被拒绝。 - -## 16. Custom UI 配置体系与 checkpoint/resume - -!!! new "0.6.7 新增" - Custom UI profile 与 LangGraph checkpoint/resume 能力层在 0.6.7 稳定。 - -### 16.1 Custom UI 配置体系 - -`ksadk.ui_config.resolve_ui_config()` 按「CLI 参数 → `.agentengine.state` → framework 默认 → 全局默认」优先级合并出最终 `UIConfig`(`profile` / `path` / `url`)。`ui_profile=custom` 时: - -- `path` 默认 `/`(不复用 `/chat`)。 -- 本地 `agentengine web` / `agentengine dashboard` 通过 `_configure_custom_ui_env()` 解析 custom bundle 目录并挂载静态资源。 -- Hosted 侧由控制面把 `ui_profile` / `ui_path` / `ui_url` 写入 state,runtime 读取后路由到 custom bundle。 - -支持 profile 列表:`auto` / `adk` / `langchain` / `openclaw` / `hermes` / `custom`。 - -### 16.2 LangGraph checkpoint/resume 能力层 - -`LangGraphRunner` 暴露 checkpoint 描述与恢复能力: - -- `describe_checkpoint_capability()` 返回 `Supported` / `Backend` / `Scope` / `Durable` / `Reason`,供 UI 判断是否可 resume。 -- `_latest_checkpoint_metadata()` 从 `aget_state(config)` 提取 `thread_id` / `checkpoint_ns` / `checkpoint_id` / `next_node`,标注 `is_terminal` / `is_resumable`。 -- resume 时 `_apply_checkpoint_resume_config()` 把 `thread_id` / `checkpoint_ns` / `checkpoint_id` 写入 `configurable`,保留 `checkpoint_ns` 以命中正确的子图状态。 - -!!! warning "checkpoint_ns 必须保留" - LangGraph 的 checkpoint 在子图(subgraph)场景下按 `thread_id` + `checkpoint_ns` + `checkpoint_id` 定位;丢掉 `checkpoint_ns` 会错误恢复到父图状态。`_checkpoint_ref_from_state()` 只在 `checkpoint_ns` 非空时写入 `framework_ref.langgraph.checkpoint_ns`。 - -## 17. 文档索引 - -- [ksadk使用文档](../guides/ksadk使用文档.md) -- [工作区文件技术设计](../internal/工作区文件技术设计.md) -- [记忆使用指南](../guides/记忆使用指南.md) -- [OpenClaw一键部署指南](./openclaw一键部署指南.md) diff --git "a/docs/reference/ksadk\347\216\257\345\242\203\345\217\230\351\207\217\345\217\202\350\200\203.md" "b/docs/reference/ksadk\347\216\257\345\242\203\345\217\230\351\207\217\345\217\202\350\200\203.md" index ac6c26b3..8228fe4e 100644 --- "a/docs/reference/ksadk\347\216\257\345\242\203\345\217\230\351\207\217\345\217\202\350\200\203.md" +++ "b/docs/reference/ksadk\347\216\257\345\242\203\345\217\230\351\207\217\345\217\202\350\200\203.md" @@ -138,6 +138,7 @@ | `OTEL_EXPORTER_OTLP_TRACES_HEADERS` | 否 | 无 | 是 | 平台 / 开发者 | traces 专用 OTLP headers;设置后优先于通用 headers。 | | `OTEL_SERVICE_NAME` | 否 | 无 | 否 | 平台 / 开发者 | OTel service name。 | | `OTEL_RESOURCE_ATTRIBUTES` | 否 | 无 | 否 | 平台 / 开发者 | OTel resource attributes。 | +| `KSADK_OTLP_MAX_EXPORT_BATCH_SIZE` | 否 | 无 | 否 | 平台 / 开发者 | OTLP 单次 export 最大 span 数,默认 `64`,用于避免 collector 请求过大。 | ## 3. 通用模型与 LLM 变量 @@ -342,6 +343,7 @@ | `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` 补入,用于区分用户显式环境变量和全局配置默认值。 | +| `KSYUN_IAM_URL` | 身份反查 | 否 | `https://iam.api.ksyun.com` | 无 | 否 | CLI | 否 | 覆盖 IAM endpoint,用于 AK/SK 反查子账号 user uuid。内部账号 AK 公网访问被拒时,CLI 自动 fallback 到 `http://iam.inner.api.ksyun.com`。 | | `AGENTENGINE_LOCAL_RUNTIME_VENV_REEXEC` | 本地 runtime CLI | 否 | 自动判断 | 无 | 否 | 本地开发者 / 测试 | 否 | 控制本地 runtime 是否在虚拟环境中 re-exec。普通用户通常无需设置。 | | `AGENTENGINE_WEB_VENV_REEXEC` | 本地 Web CLI | 否 | 自动判断 | 无 | 否 | 本地开发者 / 测试 | 否 | 控制本地 Web 命令是否在虚拟环境中 re-exec。普通用户通常无需设置。 | | `AGENTENGINE_DEBUG` | CLI | 否 | 未设置 | 无 | 否 | 开发者 | 否 | 开启更详细错误输出。 | @@ -428,9 +430,9 @@ | `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。 | -| `KSADK_OTLP_MAX_EXPORT_BATCH_SIZE` | OTel | 否 | `64` | 无 | 否 | 平台 / 开发者 | 否 | 单次 OTLP export 的最大 span 数,降低 collector 413 风险。 | | `OTEL_SERVICE_NAME` | OTel | 否 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | service name。 | | `OTEL_RESOURCE_ATTRIBUTES` | OTel | 否 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | resource attributes。 | +| `KSADK_OTLP_MAX_EXPORT_BATCH_SIZE` | OTel | 否 | `64` | 无 | 否 | 平台 / 开发者 | 否 | OTLP 单次 export 最大 span 数,用于避免 collector 请求过大。 | ## 12. Hermes 和 OpenClaw 常见运行时变量 diff --git "a/docs/reference/\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/reference/\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" deleted file mode 100644 index 251e4781..00000000 --- "a/docs/reference/\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" +++ /dev/null @@ -1,2168 +0,0 @@ -# 远程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` -- Hermes / OpenClaw 运行时镜像资产(`runtime/app.py`、`README.md`、`bootstrap.sh`、用户镜像 `Dockerfile` 等)已迁出至 `agentengine-images` 仓库,原 `deploy/hermes/`、`deploy/openclaw/`、`deploy/openclaw-user-template/` 路径不再存在于本仓库 - -## 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` | `agentengine-images` 仓库内 `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/GetCheckpointResumePreview` -- `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` | -| `account_id` | `string` | 否 | 0.6.7 新增。账号 ID 透传;写入 `PlatformInvocationContext`,用于多租户隔离与审计 | - -最小请求示例: - -```json -{ - "input": "你好", - "stream": false -} -``` - -带会话与模型示例: - -```json -{ - "input": [ - { - "role": "user", - "content": [ - { - "text": "请总结一下这份设计" - } - ] - } - ], - "model": "glm-5.2", - "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.2", - "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.2", - "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开发最佳实践](../guides/LangGraph开发最佳实践.md) -- 平台公共上下文总览: [Agent 开发者上下文接入指南](../guides/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.2", - "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` | 否 | 当前代码接受,但不保证下游一定使用 | -| `account_id` | `string` | 否 | 0.6.7 新增。账号 ID 透传;写入 `PlatformInvocationContext`,用于多租户隔离与审计 | - -`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.2", - "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` -- `GetCheckpointResumePreview` -- `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`。控制台应优先读取 `RuntimeCapabilities.ResumeRun.ResumeMode`:`time_travel` 表示可选择历史 checkpoint 回档,`forward_only` 表示只能沿框架原生事件或 invocation 连续性继续,`none` 表示没有框架级恢复能力。当前 `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"]` | -| `Capabilities.RuntimeCapabilities.Checkpoint` | 0.6.7 新增。是否支持 checkpoint 列表 / 预览 | -| `Capabilities.RuntimeCapabilities.ResumeRun` | 0.6.7 新增。是否支持从 checkpoint 恢复运行;`ResumeMode` 取值 `time_travel` / `forward_only` / `none` | -| `Capabilities.RuntimeCapabilities.CancelRun` | 0.6.7 新增。是否支持运行取消 | -| `Capabilities.CheckpointResumeCapability.Supported` | 0.6.7 新增。是否整体支持 checkpoint 恢复链路 | -| `Capabilities.CheckpointResumeCapability.Checkpoint` | 0.6.7 新增。是否支持 checkpoint 列表 / 预览 | -| `Capabilities.CheckpointResumeCapability.ResumeRun` | 0.6.7 新增。是否支持从 checkpoint 恢复运行 | -| `Capabilities.CustomUI.Enabled` | 0.6.7 新增。是否启用自定义 UI | -| `Capabilities.CustomUI.Profile` | 0.6.7 新增。自定义 UI profile 标识 | -| `Capabilities.CustomUI.Path` | 0.6.7 新增。自定义 UI 本地静态资源相对路径 | -| `Capabilities.CustomUI.Url` | 0.6.7 新增。自定义 UI 远程 URL(与 `Path` 二选一) | -| `Capabilities.CustomUI.BundlePath` | 0.6.7 新增。自定义 UI 打包产物路径 | -| `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`;0.6.7 新增:无上限,可一次性拉取全量事件 | - -会话响应中 `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] -``` - -重连与保护超时: - -- **5 分钟保护超时**:单次订阅最长 5 分钟;超时后服务端会结束 SSE 流,客户端应使用最后一次收到的 `SeqId` 重新发起订阅续接。 -- **`AfterSeqId` 重连**:重连时传入已确认消费的最大 `SeqId`,服务端只推送 `SeqId > AfterSeqId` 且 `InvocationId` 匹配的事件,避免重复回放。 -- **terminal 后自动结束**:看到 terminal `run_status` 后,服务端会发送 `data: [DONE]` 并结束流,无需客户端再重连。 - -!!! tip "断线重连推荐做法" - 客户端应在本地维护“最近确认消费的 `SeqId`”,断线后用该值作为 `AfterSeqId` 重连;不要用时间戳或事件计数估算偏移。 - -## 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 上传元数据,再返回原始文件内容 -- 0.6.7 新增:下载 `ae-upload://...` 内容时,服务端会将其写回本地 cache,后续同 URI 读取可直接命中本地 cache,减少对 Hosted 存储的重复拉取 - -!!! info "0.6.7 新增:`ksadk-upload://` 与 `ae-upload://` 区别" - - `ksadk-upload://`:由本地 runtime `UploadFile` 生成,文件落在 runtime 本地 workspace 附件区,生命周期随 session/runtime。 - - `ae-upload://`:由 Hosted 上传链路生成,文件落在平台 Hosted 存储,跨 runtime 实例可见;下载时由服务端解析 Hosted 上传元数据并写回本地 cache。 - - 两者都可作为 `AttachmentContent` 的 `FileUri`;客户端不需要感知差异,但应理解 `ae-upload://` 在首次下载后会被本地 cache 命中加速。 - -## 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 白名单为准 -- 0.6.7 新增:服务端会对 `Path` 做安全过滤,拒绝绝对路径、包含 `..` 的相对路径,并跳过 symlink,避免越权读取 workspace 外的文件 - -请求参数: - -| 参数 | 必填 | 说明 | -| --- | --- | --- | -| `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.2", - "display_name": "glm-5.2" - } - ], - "Current": "glm-5.2", - "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` | 否 | 模型元数据;可包含 `reasoning`(推理控制)、`multimodal_input_image`(多模态图片输入能力)、`context_window_tokens`(上下文窗口 token 数),runtime 会据此调整压缩阈值与能力判断 | -| `Background` | `boolean` | 否 | 0.6.7 新增。是否后台运行;`true` 时不等待完整响应,配合 `InvocationId` + `SubscribeRunEvents` 异步消费 | -| `InvocationId` | `string` | 否 | 0.6.7 新增。本轮运行 ID;用于 `SubscribeRunEvents` / `CancelRun` 续接与取消 | -| `AccountId` | `string` | 否 | 0.6.7 新增。账号 ID 透传;写入 `PlatformInvocationContext`,用于多租户隔离与审计 | -| `PreviousResponseId` | `string` | 否 | 0.6.7 新增。OpenAI Responses 上一轮 response id;用于链式上下文,不能和 `conversation`/`SessionId` 语义混用 | -| `ModelOptions` | `object` | 否 | 0.6.7 新增。模型调用参数,例如 `temperature` / `max_tokens` 等 | - -请求示例: - -```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` - -说明: - -- 控制台使用该接口展示指定 session 的 checkpoint 列表。 -- 该接口在 0.6.7 起支持分页、可恢复性过滤和框架过滤。 - -请求体: - -| 字段 | 类型 | 必填 | 说明 | -| --- | --- | --- | --- | -| `AgentId` | `string` | 是 | Agent ID | -| `SessionId` | `string` | 是 | 会话 ID | -| `RunId` | `string` | 否 | 只返回指定 run 的 checkpoint | -| `OnlyResumable` | `boolean` | 否 | 只返回可恢复 checkpoint | -| `Framework` | `string` | 否 | 按框架过滤,例如 `langgraph` | -| `Offset` | `integer` | 否 | 分页起始偏移 | -| `Limit` | `integer` | 否 | 分页大小,最大 `500` | - -响应 `Data.Checkpoints` 为 checkpoint 列表。checkpoint 来自 runtime session event 中的 `run_checkpoint`,不是客户端传入的状态。响应还包含 `Total`、`Offset` 和 `Limit`。 - -每个 checkpoint descriptor 至少包含: - -| 字段 | 说明 | -| --- | --- | -| `CheckpointId` / `RunId` | 恢复点和运行 ID,传给 `GetCheckpointResumePreview` / `ResumeRun` | -| `Framework` / `FrameworkRef` | 框架与原生 checkpoint 引用 | -| `IsResumable` / `ResumeStatus` / `ResumeDisabledReason` | 是否可恢复、恢复状态和禁用原因 | -| `IsTerminal` / `NextNode` | 是否终态、恢复后预期进入的下一个节点 | -| `StageKey` / `StageName` / `StageIndex` / `TotalStages` | 控制台展示阶段和进度 | -| `Backend` / `Scope` / `Durable` | checkpoint 后端、作用域和持久化能力 | -| `CreatedAt` / `ExpiresAt` | 创建时间和过期时间 | -| `LastResumedAt` / `ResumeCount` | 最近恢复时间和累计恢复次数 | -| `ReplayAllowed` | 是否允许重复从该 checkpoint 发起恢复 | -| `CheckpointStatus` | 当前状态,例如 `active`、`resumed`、`expired`、`disabled`、`terminal` | -| `ArtifactPreview` | 产物摘要或缩略信息 | - -`ListSessionCheckpoints` 会基于同 session 内的 `run_resume` 事件聚合 `LastResumedAt` 与 `ResumeCount`。若 `ExpiresAt` 已过期,或 `ReplayAllowed=false` 且该 checkpoint 已恢复过,服务端会将 `IsResumable=false` 并填充 `ResumeDisabledReason`,前端不需要重复推导这些禁用规则。 - -!!! info "0.6.7 状态机" - `CheckpointStatus` 取值为 `active` / `resumed` / `expired` / `disabled` / `terminal`。`ResumeCount` 与 `LastResumedAt` 由服务端从 `run_resume` 事件聚合,不是客户端传入的状态。 - -```mermaid -stateDiagram-v2 - [*] --> active: checkpoint 创建 - active --> resumed: ResumeRun 成功 - active --> expired: ExpiresAt 过期 - active --> disabled: ReplayAllowed=false 且已恢复过 - resumed --> active: 允许重放 - resumed --> terminal: 进入终态节点 - expired --> [*] - disabled --> [*] - terminal --> [*] -``` - -### `POST /agentengine/api/v1/GetCheckpointResumePreview` - -请求体: - -| 字段 | 类型 | 必填 | 说明 | -| --- | --- | --- | --- | -| `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` | 否 | 可选模型调用参数 | -| `ResumeInstructionEnabled` | `boolean` | 否 | 0.6.7 新增。是否启用恢复指令注入;默认 `false` | -| `ResumeInstruction` | `string` | 否 | 0.6.7 新增。仅 `ResumeInstructionEnabled=true` 时生效;作为恢复后的指令追加给 runner | - -`Stream=true` 时返回 SSE,gateway 和 server 都按流式代理处理。runtime 只信任服务端已保存的 checkpoint 事件来解析 `framework_ref`,不会信任客户端传入的 framework 状态。 - -#### 禁用规则与错误码 - -!!! info "0.6.7 新增" - `ResumeRun` 引入了显式的可恢复性判断与互斥保护,前端不需要再自行推导禁用规则。 - -可恢复性判断以服务端 checkpoint 事件的 `IsResumable` 与 `ResumeStatus` 为准: - -- **非终态且不可恢复**:checkpoint `IsResumable=false` 且 `IsTerminal=false` 时,返回 `409 checkpoint_not_resumable`。响应体包含: - - `reason`:禁用原因 - - `checkpoint_id`:触发的 checkpoint ID - - `run_id`:原 run ID - - `resume_status`:当前恢复状态 - - `is_terminal`:是否终态(此处为 `false`) -- **终态 checkpoint**:`IsTerminal=true` 时不再报错,返回 `200 noop`。响应 `Data` 包含: - - `Reason`:`terminal_noop` - - `CheckpointId` - - `RunId` - - `ResumeAttemptId`:本次恢复尝试 ID -- **同 `(SessionId, RunId)` detached resume 互斥**:若同一 session+run 已有一个 detached resume 在进行中,重复发起返回 `409 resume_already_running`,响应体包含 `reason`、`checkpoint_id`、`run_id` 与当前活跃的 `resume_attempt_id`。 - -!!! warning "前端实现提示" - 终态 `noop` 不是错误;前端应按正常完成态收敛 UI,不要把 `200 noop` 当作失败重试。`409` 系列错误不要自动无限重试,应引导用户选择其他 checkpoint 或重新发起 run。 - -### `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` | 否 | 为兼容旧客户端可选;runtime 以 `InvocationId` 为取消主键 | -| `InvocationId` | `string` | 是 | 需要取消的运行 ID | - -响应 `Data` 关键字段: - -| 字段 | 说明 | -| --- | --- | -| `Cancelled` | 是否已请求取消;语义是“已请求取消”,不代表运行已真正终止 | -| `Found` | 是否找到对应的 active / detached 运行 | -| `Status` | 取消请求后的运行状态摘要,例如 `cancelling` / `cancelled` / `not_found` | -| `RunnerCancelStatus` | runner 层返回的细粒度取消状态;runner 不支持真正取消时可能为 `not_supported`,但 `Cancelled` 仍可能为 `true` | - -响应示例: - -```json -{ - "Code": 0, - "Message": "Success", - "RequestId": "req-xxxx", - "Action": "CancelRun", - "Data": { - "Cancelled": true, - "Found": true, - "Status": "cancelling", - "RunnerCancelStatus": "requested" - } -} -``` - -非流式返回: - -- 外层仍是 `ActionResponse` -- `Data` 直接放 runtime 返回的 payload -- 服务端会补齐 `session_id` - -!!! warning "终态以前端事件流为准" - 如果 runner 不支持真正取消,接口仍可能返回 `Cancelled=true`。前端不应仅凭 `Cancelled` 判断运行是否已终止,仍应以后续 `run_status` 或事件流终态为准。 - -## 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 副本保留 - -## 6.15 账号边界与调用上下文 - -!!! info "0.6.7 新增" - 三个运行入口统一透传 `account_id`,写入 `PlatformInvocationContext`,用于多租户隔离、审计与配额。 - -三入口 `account_id` 透传: - -| 入口 | 字段 | 说明 | -| --- | --- | --- | -| `POST /v1/responses` | `account_id` | 0.6.7 新增请求体字段 | -| `POST /v1/chat/completions` | `account_id` | 0.6.7 新增请求体字段 | -| `POST /agentengine/api/v1/RunAgent` | `AccountId` | 0.6.7 新增请求体字段 | - -写入与保留语义: - -- `account_id` 会写入 `PlatformInvocationContext`,作为本轮调用的账号上下文;runtime 在 runner、tool、审批、附件等环节统一从该上下文读取账号 -- 对 LangGraph 运行时,`account_id` 同时映射为 LangGraph checkpoint namespace(`checkpoint_ns`)的一部分,保证不同账号的 checkpoint 状态彼此隔离,不会被跨账号串读 -- 公网 `PublicEndpoint` 调用时,网关鉴权后注入的 `X-Auth-Account-Id` 与请求体 `account_id` 应一致;不一致以网关注入值为准,请求体 `account_id` 仅作为客户端提示,不能越权覆盖 -- `account_id` 不参与 OpenAI Responses / Chat Completions 官方语义,仅作为 ksadk 平台扩展透传 - -## 7. Hermes 运行时详细接口 - -底层实现:`agentengine-images` 仓库内 `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` - - `GetCheckpointResumePreview` - - `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/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" deleted file mode 100644 index 322c880a..00000000 --- "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" +++ /dev/null @@ -1,2061 +0,0 @@ -# 远程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/GetCheckpointResumePreview` -- `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` -- `GetCheckpointResumePreview` -- `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`。控制台应优先读取 `RuntimeCapabilities.ResumeRun.ResumeMode`:`time_travel` 表示可选择历史 checkpoint 回档,`forward_only` 表示只能沿框架原生事件或 invocation 连续性继续,`none` 表示没有框架级恢复能力。当前 `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` - -说明: - -- 控制台使用该接口展示指定 session 的 checkpoint 列表。 -- 该接口在 0.6.7 起支持分页、可恢复性过滤和框架过滤。 - -请求体: - -| 字段 | 类型 | 必填 | 说明 | -| --- | --- | --- | --- | -| `AgentId` | `string` | 是 | Agent ID | -| `SessionId` | `string` | 是 | 会话 ID | -| `RunId` | `string` | 否 | 只返回指定 run 的 checkpoint | -| `OnlyResumable` | `boolean` | 否 | 只返回可恢复 checkpoint | -| `Framework` | `string` | 否 | 按框架过滤,例如 `langgraph` | -| `Offset` | `integer` | 否 | 分页起始偏移 | -| `Limit` | `integer` | 否 | 分页大小,最大 `500` | - -响应 `Data.Checkpoints` 为 checkpoint 列表。checkpoint 来自 runtime session event 中的 `run_checkpoint`,不是客户端传入的状态。响应还包含 `Total`、`Offset` 和 `Limit`。 - -每个 checkpoint descriptor 至少包含: - -| 字段 | 说明 | -| --- | --- | -| `CheckpointId` / `RunId` | 恢复点和运行 ID,传给 `GetCheckpointResumePreview` / `ResumeRun` | -| `Framework` / `FrameworkRef` | 框架与原生 checkpoint 引用 | -| `IsResumable` / `ResumeStatus` / `ResumeDisabledReason` | 是否可恢复、恢复状态和禁用原因 | -| `IsTerminal` / `NextNode` | 是否终态、恢复后预期进入的下一个节点 | -| `StageKey` / `StageName` / `StageIndex` / `TotalStages` | 控制台展示阶段和进度 | -| `Backend` / `Scope` / `Durable` | checkpoint 后端、作用域和持久化能力 | -| `CreatedAt` / `ExpiresAt` | 创建时间和过期时间 | -| `LastResumedAt` / `ResumeCount` | 最近恢复时间和累计恢复次数 | -| `ReplayAllowed` | 是否允许重复从该 checkpoint 发起恢复 | -| `CheckpointStatus` | 当前状态,例如 `active`、`resumed`、`expired`、`disabled`、`terminal` | -| `ArtifactPreview` | 产物摘要或缩略信息 | - -`ListSessionCheckpoints` 会基于同 session 内的 `run_resume` 事件聚合 `LastResumedAt` 与 `ResumeCount`。若 `ExpiresAt` 已过期,或 `ReplayAllowed=false` 且该 checkpoint 已恢复过,服务端会将 `IsResumable=false` 并填充 `ResumeDisabledReason`,前端不需要重复推导这些禁用规则。 - -### `POST /agentengine/api/v1/GetCheckpointResumePreview` - -请求体: - -| 字段 | 类型 | 必填 | 说明 | -| --- | --- | --- | --- | -| `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` - - `GetCheckpointResumePreview` - - `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/export-manifest.json b/export-manifest.json index 4e9d46b2..dd7da282 100644 --- a/export-manifest.json +++ b/export-manifest.json @@ -1,477 +1,97 @@ { - "generatedAt": "2026-05-28T12:01:15.291017+00:00", + "generatedAt": "2026-07-08T03:54:48.013854+00:00", "targetRepository": "https://github.com/kingsoftcloud/ksadk-python", "documentation": "https://kingsoftcloud.github.io/ksadk-python/", - "exportPathCount": 574, - "excludedPathCount": 574, + "exportPathCount": 554, + "excludedPathCount": 202, "excludedPaths": [ - "docs/ksadk-web-export-plan.md", + ".gitleaks.toml", + "docs/Agent 开发者上下文接入指南.md", + "docs/DeepAgents说明.md", + "docs/archive/kb-memory/knowledge_base_integration_plan.md", + "docs/archive/kb-memory/knowledge_base_test_report.md", + "docs/archive/kb-memory/memory_adk_test_practice.md", + "docs/archive/kb-memory/memory_sdk_integration_plan.md", + "docs/archive/kb-memory/memory_test_report.md", + "docs/archive/versions/hermes-agent-v2026.4.16_本地安装配置与ksadk接入流程.md", + "docs/archive/workspace/agentengine-runtime-common-设计方案.md", + "docs/archive/workspace/openclaw_通用_memory_backend_bootstrap_设计.md", + "docs/archive/workspace/workspace_files_v1_实施说明.md", + "docs/archive/workspace/workspace_files_去重改造方案比较稿.md", + "docs/frameworks/LangGraph开发最佳实践.md", + "docs/guides/Agent 开发者上下文接入指南.md", + "docs/guides/DeepAgents说明.md", + "docs/guides/LangGraph开发最佳实践.md", + "docs/guides/ksadk使用文档.md", + "docs/guides/知识库与记忆示例.md", + "docs/guides/记忆使用指南.md", + "docs/hosted-ui-refactor-plan.md", + "docs/internal/Runner_Approval_Architecture.md", + "docs/internal/coding-agent-p0-tooling.md", + "docs/internal/dry_run_refactor.md", + "docs/internal/ksadk-skills-analysis-and-design.md", + "docs/internal/sandbox-runtime-design.md", + "docs/internal/skill-runtime-e2e.md", + "docs/ksadk-iteration-plan-condensed.md", + "docs/ksadk使用文档.md", "docs/ksadk开源准备计划.md", - "docs/maintainer-approval-record.md", - "docs/open-source-approval-request.md", - "docs/open-source-final-blockers.md", - "docs/open-source-requirements-traceability.md", - "docs/open-source-review-packet.md", - "docs/post-approval-commands.md", - "docs/publication-drafts.md", - "docs/release-checklist.md", - "ksadk/server/web-ui/.gitignore", - "ksadk/server/web-ui/README.md", - "ksadk/server/web-ui/components.json", - "ksadk/server/web-ui/dist-hosted/assets/ArtifactsPanel-DcF96dyy.js", - "ksadk/server/web-ui/dist-hosted/assets/CodeBlock-DmGYvtG5.js", - "ksadk/server/web-ui/dist-hosted/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2", - "ksadk/server/web-ui/dist-hosted/assets/KaTeX_AMS-Regular-DMm9YOAa.woff", - "ksadk/server/web-ui/dist-hosted/assets/KaTeX_AMS-Regular-DRggAlZN.ttf", - "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf", - "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff", - "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2", - "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff", - "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2", - "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf", - "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf", - "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff", - "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2", - "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Fraktur-Regular-CB_wures.ttf", - "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2", - "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff", - "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Main-Bold-Cx986IdX.woff2", - "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Main-Bold-Jm3AIy58.woff", - "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Main-Bold-waoOVXN0.ttf", - "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2", - "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf", - "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff", - "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Main-Italic-3WenGoN9.ttf", - "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Main-Italic-BMLOBm91.woff", - "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2", - "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Main-Regular-B22Nviop.woff2", - "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Main-Regular-Dr94JaBh.woff", - "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Main-Regular-ypZvNtVU.ttf", - "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf", - "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2", - "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff", - "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Math-Italic-DA0__PXp.woff", - "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Math-Italic-flOr_0UB.ttf", - "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Math-Italic-t53AETM-.woff2", - "ksadk/server/web-ui/dist-hosted/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf", - "ksadk/server/web-ui/dist-hosted/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2", - "ksadk/server/web-ui/dist-hosted/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff", - "ksadk/server/web-ui/dist-hosted/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2", - "ksadk/server/web-ui/dist-hosted/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff", - "ksadk/server/web-ui/dist-hosted/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf", - "ksadk/server/web-ui/dist-hosted/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf", - "ksadk/server/web-ui/dist-hosted/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff", - "ksadk/server/web-ui/dist-hosted/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2", - "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Script-Regular-C5JkGWo-.ttf", - "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Script-Regular-D3wIWfF6.woff2", - "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Script-Regular-D5yQViql.woff", - "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Size1-Regular-C195tn64.woff", - "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf", - "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2", - "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf", - "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2", - "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Size2-Regular-oD1tc_U0.woff", - "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Size3-Regular-CTq5MqoE.woff", - "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf", - "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Size4-Regular-BF-4gkZK.woff", - "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Size4-Regular-DWFBv043.ttf", - "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2", - "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff", - "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2", - "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf", - "ksadk/server/web-ui/dist-hosted/assets/MathMessageMarkdown-Bv7ZTOOa.css", - "ksadk/server/web-ui/dist-hosted/assets/MathMessageMarkdown-CEcBwtMM.js", - "ksadk/server/web-ui/dist-hosted/assets/MermaidBlock-DkpacQXs.js", - "ksadk/server/web-ui/dist-hosted/assets/NativeTerminalPanel-Bv4PsVQf.js", - "ksadk/server/web-ui/dist-hosted/assets/NativeTerminalPanel-CHOd7Rch.css", - "ksadk/server/web-ui/dist-hosted/assets/_basePickBy-Dyh6BT9q.js", - "ksadk/server/web-ui/dist-hosted/assets/_baseUniq-CI5m3e9k.js", - "ksadk/server/web-ui/dist-hosted/assets/addon-fit-D4rkLGFF.js", - "ksadk/server/web-ui/dist-hosted/assets/apl-BHCK7eTz.js", - "ksadk/server/web-ui/dist-hosted/assets/arc--eqaPxsr.js", - "ksadk/server/web-ui/dist-hosted/assets/architecture-PBZL5I3N-ifKrC9e8.js", - "ksadk/server/web-ui/dist-hosted/assets/architectureDiagram-2XIMDMQ5-Da-OVsVt.js", - "ksadk/server/web-ui/dist-hosted/assets/array-C5d_RYS0.js", - "ksadk/server/web-ui/dist-hosted/assets/asciiarmor-CKsFaQWn.js", - "ksadk/server/web-ui/dist-hosted/assets/asn1-KmlMb7Fb.js", - "ksadk/server/web-ui/dist-hosted/assets/asterisk-hsgVlmhh.js", - "ksadk/server/web-ui/dist-hosted/assets/blockDiagram-WCTKOSBZ-CS6N8tYT.js", - "ksadk/server/web-ui/dist-hosted/assets/brainfuck-yyj_k-Sc.js", - "ksadk/server/web-ui/dist-hosted/assets/c4Diagram-IC4MRINW-7odRJTNp.js", - "ksadk/server/web-ui/dist-hosted/assets/channel-B-DGJ4mZ.js", - "ksadk/server/web-ui/dist-hosted/assets/chunk-4BX2VUAB-C4REt2TD.js", - "ksadk/server/web-ui/dist-hosted/assets/chunk-55IACEB6-cT-BqZ0Y.js", - "ksadk/server/web-ui/dist-hosted/assets/chunk-7E7YKBS2-JMp1w35e.js", - "ksadk/server/web-ui/dist-hosted/assets/chunk-7R4GIKGN-Air9KZTr.js", - "ksadk/server/web-ui/dist-hosted/assets/chunk-C72U2L5F-PugnqXWF.js", - "ksadk/server/web-ui/dist-hosted/assets/chunk-CFjPhJqf.js", - "ksadk/server/web-ui/dist-hosted/assets/chunk-EGIJ26TM-DVHFvd-V.js", - "ksadk/server/web-ui/dist-hosted/assets/chunk-FMBD7UC4-DqqYxQOA.js", - "ksadk/server/web-ui/dist-hosted/assets/chunk-GEFDOKGD-5Yqg5d2s.js", - "ksadk/server/web-ui/dist-hosted/assets/chunk-GLR3WWYH-Dc6eCAmt.js", - "ksadk/server/web-ui/dist-hosted/assets/chunk-HHEYEP7N-CCiu6w2l.js", - "ksadk/server/web-ui/dist-hosted/assets/chunk-JSJVCQXG-Dq62NZ4F.js", - "ksadk/server/web-ui/dist-hosted/assets/chunk-KX2RTZJC-CCfxhk0o.js", - "ksadk/server/web-ui/dist-hosted/assets/chunk-KYZI473N-nWo7KLkb.js", - "ksadk/server/web-ui/dist-hosted/assets/chunk-L3YUKLVL-D56E_4dD.js", - "ksadk/server/web-ui/dist-hosted/assets/chunk-MX3YWQON-CyHya8ER.js", - "ksadk/server/web-ui/dist-hosted/assets/chunk-NQ4KR5QH-Bih7QuSt.js", - "ksadk/server/web-ui/dist-hosted/assets/chunk-O4XLMI2P-CP9jZjUR.js", - "ksadk/server/web-ui/dist-hosted/assets/chunk-OZEHJAEY-D7lrydD-.js", - "ksadk/server/web-ui/dist-hosted/assets/chunk-PQ6SQG4A-Z_QhQQla.js", - "ksadk/server/web-ui/dist-hosted/assets/chunk-PU5JKC2W-K1O2sVZ3.js", - "ksadk/server/web-ui/dist-hosted/assets/chunk-QZHKN3VN-B7yd4p4v.js", - "ksadk/server/web-ui/dist-hosted/assets/chunk-R5LLSJPH-BvUpKHhe.js", - "ksadk/server/web-ui/dist-hosted/assets/chunk-WL4C6EOR-DVwKonqO.js", - "ksadk/server/web-ui/dist-hosted/assets/chunk-XIRO2GV7-DT6hxxCu.js", - "ksadk/server/web-ui/dist-hosted/assets/chunk-XPW4576I-KV4DQO27.js", - "ksadk/server/web-ui/dist-hosted/assets/chunk-XZSTWKYB-B8wkT7VK.js", - "ksadk/server/web-ui/dist-hosted/assets/chunk-YBOYWFTD-DN2Gj4VJ.js", - "ksadk/server/web-ui/dist-hosted/assets/classDiagram-VBA2DB6C-3hv093K8.js", - "ksadk/server/web-ui/dist-hosted/assets/classDiagram-v2-RAHNMMFH-Y3XV6rOG.js", - "ksadk/server/web-ui/dist-hosted/assets/clike-ckx3YDb4.js", - "ksadk/server/web-ui/dist-hosted/assets/clipboard-CkO48gWZ.js", - "ksadk/server/web-ui/dist-hosted/assets/clojure-B4a1fekk.js", - "ksadk/server/web-ui/dist-hosted/assets/clone-5dGAc1NI.js", - "ksadk/server/web-ui/dist-hosted/assets/cmake-DOhLV2DE.js", - "ksadk/server/web-ui/dist-hosted/assets/cobol-zzTfSC0N.js", - "ksadk/server/web-ui/dist-hosted/assets/coffeescript-DAuaYAn2.js", - "ksadk/server/web-ui/dist-hosted/assets/commonlisp-nhcvZtb3.js", - "ksadk/server/web-ui/dist-hosted/assets/cose-bilkent-S5V4N54A-D0qw9djq.js", - "ksadk/server/web-ui/dist-hosted/assets/crystal-DkNzLZCu.js", - "ksadk/server/web-ui/dist-hosted/assets/css-CPAB6T5R.js", - "ksadk/server/web-ui/dist-hosted/assets/cypher-DMnb2BhJ.js", - "ksadk/server/web-ui/dist-hosted/assets/cytoscape.esm-GJAx1ET2.js", - "ksadk/server/web-ui/dist-hosted/assets/d-B8nwzSFl.js", - "ksadk/server/web-ui/dist-hosted/assets/dagre-KLK3FWXG-2uyh2Zqc.js", - "ksadk/server/web-ui/dist-hosted/assets/dagre-rKcAh6Ox.js", - "ksadk/server/web-ui/dist-hosted/assets/defaultLocale-CCuQb4WR.js", - "ksadk/server/web-ui/dist-hosted/assets/diagram-E7M64L7V-C0RQZ8fT.js", - "ksadk/server/web-ui/dist-hosted/assets/diagram-IFDJBPK2-BRg2mIY7.js", - "ksadk/server/web-ui/dist-hosted/assets/diagram-P4PSJMXO-CTTZjSH7.js", - "ksadk/server/web-ui/dist-hosted/assets/diff-D0_HBszs.js", - "ksadk/server/web-ui/dist-hosted/assets/dist-25djM0d3.js", - "ksadk/server/web-ui/dist-hosted/assets/dist-B7OnrKrq.js", - "ksadk/server/web-ui/dist-hosted/assets/dist-BE59Ppag.js", - "ksadk/server/web-ui/dist-hosted/assets/dist-BF-u2EEc.js", - "ksadk/server/web-ui/dist-hosted/assets/dist-BkzWsnxG.js", - "ksadk/server/web-ui/dist-hosted/assets/dist-BwT8q_F2.js", - "ksadk/server/web-ui/dist-hosted/assets/dist-C29qgn-6.js", - "ksadk/server/web-ui/dist-hosted/assets/dist-C7FgEVHm.js", - "ksadk/server/web-ui/dist-hosted/assets/dist-C7MGALiH.js", - "ksadk/server/web-ui/dist-hosted/assets/dist-CE9Q03aT.js", - "ksadk/server/web-ui/dist-hosted/assets/dist-CPaG3mhO.js", - "ksadk/server/web-ui/dist-hosted/assets/dist-D0IJChqR.js", - "ksadk/server/web-ui/dist-hosted/assets/dist-D0Smw0lF.js", - "ksadk/server/web-ui/dist-hosted/assets/dist-DALepkMR.js", - "ksadk/server/web-ui/dist-hosted/assets/dist-DD9A32xs.js", - "ksadk/server/web-ui/dist-hosted/assets/dist-DDi2AtwY.js", - "ksadk/server/web-ui/dist-hosted/assets/dist-DNMI0KpH.js", - "ksadk/server/web-ui/dist-hosted/assets/dist-DSxrMaGc.js", - "ksadk/server/web-ui/dist-hosted/assets/dist-DTGw-62V.js", - "ksadk/server/web-ui/dist-hosted/assets/dist-Dvmk6Zeb.js", - "ksadk/server/web-ui/dist-hosted/assets/dist-Wc3tKjhh.js", - "ksadk/server/web-ui/dist-hosted/assets/dist-arlyGqE5.js", - "ksadk/server/web-ui/dist-hosted/assets/dist-kJW2hJYm.js", - "ksadk/server/web-ui/dist-hosted/assets/dist-sXtNp4E2.js", - "ksadk/server/web-ui/dist-hosted/assets/dockerfile-BVWBOak5.js", - "ksadk/server/web-ui/dist-hosted/assets/dtd-CJAp6eHX.js", - "ksadk/server/web-ui/dist-hosted/assets/dylan-BI42aXIQ.js", - "ksadk/server/web-ui/dist-hosted/assets/ebnf-DM2tJvW9.js", - "ksadk/server/web-ui/dist-hosted/assets/ecl-C9osow-d.js", - "ksadk/server/web-ui/dist-hosted/assets/eiffel-DtelNSiF.js", - "ksadk/server/web-ui/dist-hosted/assets/elm-CX2Qvijm.js", - "ksadk/server/web-ui/dist-hosted/assets/erDiagram-INFDFZHY-PIxMm2X-.js", - "ksadk/server/web-ui/dist-hosted/assets/erlang-CKlrHK__.js", - "ksadk/server/web-ui/dist-hosted/assets/factor-aLlwLDKr.js", - "ksadk/server/web-ui/dist-hosted/assets/fcl-D6xm6kZS.js", - "ksadk/server/web-ui/dist-hosted/assets/flowDiagram-PKNHOUZH-BJgcCXMq.js", - "ksadk/server/web-ui/dist-hosted/assets/forth-httATuxy.js", - "ksadk/server/web-ui/dist-hosted/assets/fortran-BbTsNbWF.js", - "ksadk/server/web-ui/dist-hosted/assets/ganttDiagram-A5KZAMGK-ycuFYq_r.js", - "ksadk/server/web-ui/dist-hosted/assets/gas-iZxQduJ2.js", - "ksadk/server/web-ui/dist-hosted/assets/gherkin-B9XMQqDv.js", - "ksadk/server/web-ui/dist-hosted/assets/gitGraph-HDMCJU4V-B8YNcsGe.js", - "ksadk/server/web-ui/dist-hosted/assets/gitGraphDiagram-K3NZZRJ6-L3FJ4ob5.js", - "ksadk/server/web-ui/dist-hosted/assets/graphlib-BrgJZMdK.js", - "ksadk/server/web-ui/dist-hosted/assets/groovy-JJ86l90I.js", - "ksadk/server/web-ui/dist-hosted/assets/haskell-BS2dtm18.js", - "ksadk/server/web-ui/dist-hosted/assets/haxe-CqNcsArX.js", - "ksadk/server/web-ui/dist-hosted/assets/http-C184nxV5.js", - "ksadk/server/web-ui/dist-hosted/assets/idl-Cpn8Vp6F.js", - "ksadk/server/web-ui/dist-hosted/assets/index-CITidnqf.js", - "ksadk/server/web-ui/dist-hosted/assets/index-Dlt7i3uL.css", - "ksadk/server/web-ui/dist-hosted/assets/info-3K5VOQVL-B8oDQZLy.js", - "ksadk/server/web-ui/dist-hosted/assets/infoDiagram-LFFYTUFH-BtCiMY-S.js", - "ksadk/server/web-ui/dist-hosted/assets/init-B2r4ykR3.js", - "ksadk/server/web-ui/dist-hosted/assets/isArrayLikeObject-B2qkpvU2.js", - "ksadk/server/web-ui/dist-hosted/assets/isEmpty-9ZVSfg_R.js", - "ksadk/server/web-ui/dist-hosted/assets/ishikawaDiagram-PHBUUO56-DfT5mbSs.js", - "ksadk/server/web-ui/dist-hosted/assets/javascript-C0eIISeX.js", - "ksadk/server/web-ui/dist-hosted/assets/journeyDiagram-4ABVD52K-Bb_hfB_u.js", - "ksadk/server/web-ui/dist-hosted/assets/jsx-runtime-QQLjLlGf.js", - "ksadk/server/web-ui/dist-hosted/assets/julia-BTZidP6S.js", - "ksadk/server/web-ui/dist-hosted/assets/kanban-definition-K7BYSVSG-Ba2sdHd4.js", - "ksadk/server/web-ui/dist-hosted/assets/katex-REXy7PdG.js", - "ksadk/server/web-ui/dist-hosted/assets/line-BTNWGJWR.js", - "ksadk/server/web-ui/dist-hosted/assets/linear-zQ1VAIxs.js", - "ksadk/server/web-ui/dist-hosted/assets/livescript-DOF7XUnL.js", - "ksadk/server/web-ui/dist-hosted/assets/lua-DgJYygpl.js", - "ksadk/server/web-ui/dist-hosted/assets/math-DGSp8ro7.js", - "ksadk/server/web-ui/dist-hosted/assets/mathematica-C4sYzthQ.js", - "ksadk/server/web-ui/dist-hosted/assets/mbox-DmHIMCol.js", - "ksadk/server/web-ui/dist-hosted/assets/mermaid-parser.core-DgTF6uqg.js", - "ksadk/server/web-ui/dist-hosted/assets/mindmap-definition-YRQLILUH-BI_zWPaX.js", - "ksadk/server/web-ui/dist-hosted/assets/mirc-B4EYunMl.js", - "ksadk/server/web-ui/dist-hosted/assets/mllike-BDEz3sCm.js", - "ksadk/server/web-ui/dist-hosted/assets/modelica-BrnLS-wO.js", - "ksadk/server/web-ui/dist-hosted/assets/mscgen-DXoyi3vj.js", - "ksadk/server/web-ui/dist-hosted/assets/mumps-BnqKiOwF.js", - "ksadk/server/web-ui/dist-hosted/assets/nginx-3pYsPdp4.js", - "ksadk/server/web-ui/dist-hosted/assets/nsis-ZmBaQWtb.js", - "ksadk/server/web-ui/dist-hosted/assets/ntriples-BShxd_u8.js", - "ksadk/server/web-ui/dist-hosted/assets/octave-CMcOSszE.js", - "ksadk/server/web-ui/dist-hosted/assets/ordinal-jw163_Ud.js", - "ksadk/server/web-ui/dist-hosted/assets/oz-EGqN2mWx.js", - "ksadk/server/web-ui/dist-hosted/assets/packet-RMMSAZCW-C7OKhhXW.js", - "ksadk/server/web-ui/dist-hosted/assets/pascal-Do5RfXpW.js", - "ksadk/server/web-ui/dist-hosted/assets/path-BQp9Hq4z.js", - "ksadk/server/web-ui/dist-hosted/assets/perl-C_vcTXmu.js", - "ksadk/server/web-ui/dist-hosted/assets/pie-UPGHQEXC-Bbb6Wwdg.js", - "ksadk/server/web-ui/dist-hosted/assets/pieDiagram-SKSYHLDU-BNKvWD9T.js", - "ksadk/server/web-ui/dist-hosted/assets/pig-B9o6QnZc.js", - "ksadk/server/web-ui/dist-hosted/assets/powershell-Cd06g2Jm.js", - "ksadk/server/web-ui/dist-hosted/assets/properties-B2zjNYKR.js", - "ksadk/server/web-ui/dist-hosted/assets/protobuf-IwqMH2ly.js", - "ksadk/server/web-ui/dist-hosted/assets/pug-DOWcp-za.js", - "ksadk/server/web-ui/dist-hosted/assets/puppet-q98PbnCT.js", - "ksadk/server/web-ui/dist-hosted/assets/python-BNGi4pCU.js", - "ksadk/server/web-ui/dist-hosted/assets/q-BpMcxOIp.js", - "ksadk/server/web-ui/dist-hosted/assets/quadrantDiagram-337W2JSQ-slSGNumW.js", - "ksadk/server/web-ui/dist-hosted/assets/r-DCrVCh6t.js", - "ksadk/server/web-ui/dist-hosted/assets/radar-KQ55EAFF-ZwTAp0CD.js", - "ksadk/server/web-ui/dist-hosted/assets/requirementDiagram-Z7DCOOCP-BYzK1C_O.js", - "ksadk/server/web-ui/dist-hosted/assets/rough.esm-Cq1ZV0Dh.js", - "ksadk/server/web-ui/dist-hosted/assets/rpm--ZNB-3J4.js", - "ksadk/server/web-ui/dist-hosted/assets/ruby-Dx0I8uwI.js", - "ksadk/server/web-ui/dist-hosted/assets/sankeyDiagram-WA2Y5GQK-DgoIo_l4.js", - "ksadk/server/web-ui/dist-hosted/assets/sas-XEk3XXAE.js", - "ksadk/server/web-ui/dist-hosted/assets/scheme-D1b_80b3.js", - "ksadk/server/web-ui/dist-hosted/assets/sequenceDiagram-2WXFIKYE-BqkMKOpC.js", - "ksadk/server/web-ui/dist-hosted/assets/shell-BxPETWmT.js", - "ksadk/server/web-ui/dist-hosted/assets/sieve-D462Os_N.js", - "ksadk/server/web-ui/dist-hosted/assets/simple-mode-C5ZsRGe_.js", - "ksadk/server/web-ui/dist-hosted/assets/smalltalk-DWg-QJ88.js", - "ksadk/server/web-ui/dist-hosted/assets/solr-DirJfI87.js", - "ksadk/server/web-ui/dist-hosted/assets/sparql-C5f2gvL-.js", - "ksadk/server/web-ui/dist-hosted/assets/spreadsheet-BrMjukBr.js", - "ksadk/server/web-ui/dist-hosted/assets/sql-DbzescZo.js", - "ksadk/server/web-ui/dist-hosted/assets/src-Clx42DGz.js", - "ksadk/server/web-ui/dist-hosted/assets/stateDiagram-RAJIS63D-D_Ss5Ihh.js", - "ksadk/server/web-ui/dist-hosted/assets/stateDiagram-v2-FVOUBMTO-DfZ87Vqg.js", - "ksadk/server/web-ui/dist-hosted/assets/stex-CG80BKzl.js", - "ksadk/server/web-ui/dist-hosted/assets/stylus-omwnNXCW.js", - "ksadk/server/web-ui/dist-hosted/assets/swift-D289Orrh.js", - "ksadk/server/web-ui/dist-hosted/assets/tcl-BUjUwtPX.js", - "ksadk/server/web-ui/dist-hosted/assets/textile-CJ0u1GQx.js", - "ksadk/server/web-ui/dist-hosted/assets/tiddlywiki-BUfjyoYH.js", - "ksadk/server/web-ui/dist-hosted/assets/tiki-DxhKxrrK.js", - "ksadk/server/web-ui/dist-hosted/assets/timeline-definition-YZTLITO2-CNVzm-L1.js", - "ksadk/server/web-ui/dist-hosted/assets/toml-Bg2104qZ.js", - "ksadk/server/web-ui/dist-hosted/assets/treemap-KZPCXAKY-DWASSxAz.js", - "ksadk/server/web-ui/dist-hosted/assets/troff-Cs3PIRsT.js", - "ksadk/server/web-ui/dist-hosted/assets/ttcn-DZJ1Gz3y.js", - "ksadk/server/web-ui/dist-hosted/assets/ttcn-cfg-CHGoGicC.js", - "ksadk/server/web-ui/dist-hosted/assets/turtle-BiP2VmFo.js", - "ksadk/server/web-ui/dist-hosted/assets/vb-B-xEPVKJ.js", - "ksadk/server/web-ui/dist-hosted/assets/vbscript-DAJc-0Lz.js", - "ksadk/server/web-ui/dist-hosted/assets/velocity-D_e2TK0S.js", - "ksadk/server/web-ui/dist-hosted/assets/vennDiagram-LZ73GAT5-Dj45e83d.js", - "ksadk/server/web-ui/dist-hosted/assets/verilog-CkkvRlTm.js", - "ksadk/server/web-ui/dist-hosted/assets/vhdl-DSuVfsLS.js", - "ksadk/server/web-ui/dist-hosted/assets/webidl-COsQYX1v.js", - "ksadk/server/web-ui/dist-hosted/assets/x-BGAZi46_.js", - "ksadk/server/web-ui/dist-hosted/assets/xquery-avYAEakU.js", - "ksadk/server/web-ui/dist-hosted/assets/xterm-B8gQdswI.js", - "ksadk/server/web-ui/dist-hosted/assets/xychartDiagram-JWTSCODW-C_v35sT1.js", - "ksadk/server/web-ui/dist-hosted/assets/yacas-DH2Lk764.js", - "ksadk/server/web-ui/dist-hosted/assets/z80-wmjXpkcV.js", - "ksadk/server/web-ui/dist-hosted/favicon.svg", - "ksadk/server/web-ui/dist-hosted/icons.svg", - "ksadk/server/web-ui/dist-hosted/index.html", - "ksadk/server/web-ui/eslint.config.js", - "ksadk/server/web-ui/index.html", - "ksadk/server/web-ui/package-lock.json", - "ksadk/server/web-ui/package.json", - "ksadk/server/web-ui/postcss.config.js", - "ksadk/server/web-ui/public/favicon.svg", - "ksadk/server/web-ui/public/icons.svg", - "ksadk/server/web-ui/sandbox-poc.html", - "ksadk/server/web-ui/scripts/sync-static.mjs", - "ksadk/server/web-ui/src/App.css", - "ksadk/server/web-ui/src/App.tsx", - "ksadk/server/web-ui/src/__tests__/api-facade.test.ts", - "ksadk/server/web-ui/src/__tests__/composer-contract.test.ts", - "ksadk/server/web-ui/src/__tests__/error-boundary.test.ts", - "ksadk/server/web-ui/src/__tests__/native-terminal-panel.test.ts", - "ksadk/server/web-ui/src/__tests__/plugin-registry.test.ts", - "ksadk/server/web-ui/src/__tests__/run-engine.test.ts", - "ksadk/server/web-ui/src/__tests__/sandbox.test.ts", - "ksadk/server/web-ui/src/__tests__/sse-parser.test.ts", - "ksadk/server/web-ui/src/__tests__/stream-protocol.test.ts", - "ksadk/server/web-ui/src/__tests__/workspace.test.ts", - "ksadk/server/web-ui/src/api/bootstrap.ts", - "ksadk/server/web-ui/src/api/client.ts", - "ksadk/server/web-ui/src/api/errors.ts", - "ksadk/server/web-ui/src/api/events.ts", - "ksadk/server/web-ui/src/api/feedback.ts", - "ksadk/server/web-ui/src/api/model.ts", - "ksadk/server/web-ui/src/api/run.ts", - "ksadk/server/web-ui/src/api/session.ts", - "ksadk/server/web-ui/src/api/terminal.ts", - "ksadk/server/web-ui/src/api/upload.ts", - "ksadk/server/web-ui/src/api/workspace.ts", - "ksadk/server/web-ui/src/assets/hero.png", - "ksadk/server/web-ui/src/assets/react.svg", - "ksadk/server/web-ui/src/assets/vite.svg", - "ksadk/server/web-ui/src/components/ErrorBoundary.tsx", - "ksadk/server/web-ui/src/components/MessageMarkdown.tsx", - "ksadk/server/web-ui/src/components/ToastContainer.tsx", - "ksadk/server/web-ui/src/components/artifacts/ArtifactsPanel.tsx", - "ksadk/server/web-ui/src/components/chat/AttachmentPreview.tsx", - "ksadk/server/web-ui/src/components/chat/ChatComposer.tsx", - "ksadk/server/web-ui/src/components/chat/ChatHeader.tsx", - "ksadk/server/web-ui/src/components/chat/ChatMessageList.tsx", - "ksadk/server/web-ui/src/components/chat/ChatSidebar.tsx", - "ksadk/server/web-ui/src/components/chat/ConnectedComposer.tsx", - "ksadk/server/web-ui/src/components/chat/ConnectedMessageList.tsx", - "ksadk/server/web-ui/src/components/chat/ConnectedSidebar.tsx", - "ksadk/server/web-ui/src/components/chat/types.ts", - "ksadk/server/web-ui/src/components/markdown/CodeBlock.tsx", - "ksadk/server/web-ui/src/components/markdown/MathMessageMarkdown.tsx", - "ksadk/server/web-ui/src/components/markdown/MermaidBlock.tsx", - "ksadk/server/web-ui/src/components/native/NativeRuntimeLauncher.tsx", - "ksadk/server/web-ui/src/components/native/NativeTerminalPanel.tsx", - "ksadk/server/web-ui/src/components/ui/accordion.tsx", - "ksadk/server/web-ui/src/components/ui/alert.tsx", - "ksadk/server/web-ui/src/components/ui/avatar.tsx", - "ksadk/server/web-ui/src/components/ui/badge.tsx", - "ksadk/server/web-ui/src/components/ui/button.tsx", - "ksadk/server/web-ui/src/components/ui/card.tsx", - "ksadk/server/web-ui/src/components/ui/dialog.tsx", - "ksadk/server/web-ui/src/components/ui/input.tsx", - "ksadk/server/web-ui/src/components/ui/navigation-menu.tsx", - "ksadk/server/web-ui/src/components/ui/scroll-area.tsx", - "ksadk/server/web-ui/src/components/ui/separator.tsx", - "ksadk/server/web-ui/src/components/ui/sheet.tsx", - "ksadk/server/web-ui/src/components/ui/tabs.tsx", - "ksadk/server/web-ui/src/components/ui/textarea.tsx", - "ksadk/server/web-ui/src/components/workspace/FileEditor.tsx", - "ksadk/server/web-ui/src/components/workspace/FilePreview.tsx", - "ksadk/server/web-ui/src/components/workspace/HtmlPreview.tsx", - "ksadk/server/web-ui/src/components/workspace/MarkdownPreview.tsx", - "ksadk/server/web-ui/src/components/workspace/WorkspacePanel.tsx", - "ksadk/server/web-ui/src/components/workspace/WorkspacePanelContainer.tsx", - "ksadk/server/web-ui/src/core/api/facade.ts", - "ksadk/server/web-ui/src/core/api/index.ts", - "ksadk/server/web-ui/src/core/api/types.ts", - "ksadk/server/web-ui/src/core/capability/index.ts", - "ksadk/server/web-ui/src/core/capability/registry.ts", - "ksadk/server/web-ui/src/core/capability/types.ts", - "ksadk/server/web-ui/src/core/run/dispatcher.ts", - "ksadk/server/web-ui/src/core/run/engine.ts", - "ksadk/server/web-ui/src/core/run/index.ts", - "ksadk/server/web-ui/src/core/run/types.ts", - "ksadk/server/web-ui/src/core/stream/chat-completions-protocol.ts", - "ksadk/server/web-ui/src/core/stream/index.ts", - "ksadk/server/web-ui/src/core/stream/responses-protocol.ts", - "ksadk/server/web-ui/src/core/stream/types.ts", - "ksadk/server/web-ui/src/core/transport/index.ts", - "ksadk/server/web-ui/src/core/transport/sse-parser.ts", - "ksadk/server/web-ui/src/core/transport/sse-transport.ts", - "ksadk/server/web-ui/src/core/transport/types.ts", - "ksadk/server/web-ui/src/hooks/useBootstrap.ts", - "ksadk/server/web-ui/src/hooks/useFeedback.ts", - "ksadk/server/web-ui/src/hooks/useResponsiveViewport.ts", - "ksadk/server/web-ui/src/hooks/useRunAgent.ts", - "ksadk/server/web-ui/src/hooks/useSessionLifecycle.ts", - "ksadk/server/web-ui/src/index.css", - "ksadk/server/web-ui/src/lib/utils.ts", - "ksadk/server/web-ui/src/main.tsx", - "ksadk/server/web-ui/src/plugins/artifacts-plugin.ts", - "ksadk/server/web-ui/src/plugins/launcher-plugin.tsx", - "ksadk/server/web-ui/src/plugins/terminal-plugin.tsx", - "ksadk/server/web-ui/src/plugins/workspace-plugin.tsx", - "ksadk/server/web-ui/src/stores/artifact.ts", - "ksadk/server/web-ui/src/stores/bootstrap.ts", - "ksadk/server/web-ui/src/stores/message.ts", - "ksadk/server/web-ui/src/stores/model.ts", - "ksadk/server/web-ui/src/stores/session.ts", - "ksadk/server/web-ui/src/stores/streaming.ts", - "ksadk/server/web-ui/src/stores/ui.ts", - "ksadk/server/web-ui/src/stores/workspace.ts", - "ksadk/server/web-ui/src/types/api.ts", - "ksadk/server/web-ui/src/types/bootstrap.ts", - "ksadk/server/web-ui/src/types/capabilities.ts", - "ksadk/server/web-ui/src/types/input.ts", - "ksadk/server/web-ui/src/types/session-events.ts", - "ksadk/server/web-ui/src/utils/attachment.ts", - "ksadk/server/web-ui/src/utils/capabilities.js", - "ksadk/server/web-ui/src/utils/clipboard.js", - "ksadk/server/web-ui/src/utils/context.js", - "ksadk/server/web-ui/src/utils/error.ts", - "ksadk/server/web-ui/src/utils/feedback.js", - "ksadk/server/web-ui/src/utils/layout-constants.ts", - "ksadk/server/web-ui/src/utils/markdown.js", - "ksadk/server/web-ui/src/utils/mobile-layout.js", - "ksadk/server/web-ui/src/utils/model-options.js", - "ksadk/server/web-ui/src/utils/native-platform.js", - "ksadk/server/web-ui/src/utils/responses-stream.js", - "ksadk/server/web-ui/src/utils/run-state.js", - "ksadk/server/web-ui/src/utils/sandbox.ts", - "ksadk/server/web-ui/src/utils/session-events.js", - "ksadk/server/web-ui/src/utils/session-helpers.ts", - "ksadk/server/web-ui/src/utils/session-list.js", - "ksadk/server/web-ui/src/utils/session.js", - "ksadk/server/web-ui/src/utils/stream-control.js", - "ksadk/server/web-ui/src/utils/stream-parsing.ts", - "ksadk/server/web-ui/src/utils/terminal-session.js", - "ksadk/server/web-ui/src/utils/tool-display.js", - "ksadk/server/web-ui/src/utils/workspace.js", - "ksadk/server/web-ui/tailwind.config.ts", - "ksadk/server/web-ui/tests/capabilities.test.mjs", - "ksadk/server/web-ui/tests/feedback-utils.test.mjs", - "ksadk/server/web-ui/tests/hosted-ui-sync.test.mjs", - "ksadk/server/web-ui/tests/mobile-layout.test.mjs", - "ksadk/server/web-ui/tests/native-platform.test.mjs", - "ksadk/server/web-ui/tests/responses-stream.test.mjs", - "ksadk/server/web-ui/tests/run-state.test.mjs", - "ksadk/server/web-ui/tests/session-events.test.mjs", - "ksadk/server/web-ui/tests/session-list.test.mjs", - "ksadk/server/web-ui/tests/session-persistence.test.mjs", - "ksadk/server/web-ui/tests/sidebar-contract.test.mjs", - "ksadk/server/web-ui/tests/stream-control.test.mjs", - "ksadk/server/web-ui/tests/sync-static.test.mjs", - "ksadk/server/web-ui/tests/terminal-session.test.mjs", - "ksadk/server/web-ui/tests/tool-display.test.mjs", - "ksadk/server/web-ui/tests/ui-utils.test.mjs", - "ksadk/server/web-ui/tests/workspace-panel-contract.test.mjs", - "ksadk/server/web-ui/tests/workspace-utils.test.mjs", - "ksadk/server/web-ui/tsconfig.app.json", - "ksadk/server/web-ui/tsconfig.json", - "ksadk/server/web-ui/tsconfig.node.json", - "ksadk/server/web-ui/tsconfig.tsbuildinfo", - "ksadk/server/web-ui/vite.config.ts", - "scripts/audit_public_history_paths.py", - "scripts/check_approval_record.py", - "scripts/check_publication_state.py", + "docs/ksadk技术设计.md", + "docs/ksadk环境变量参考.md", + "docs/openclaw_client_one_click_deploy.html", + "docs/openclaw_gateway_channel_flow.svg", + "docs/openclaw_gateway_channel_flow_hd.png", + "docs/openclaw_gateway_technical.html", + "docs/openclaw一键部署指南.md", + "docs/preview/cli-demo/cli_real_terminal_demo.png", + "docs/preview/cli-demo/cli_screenshot_demo.sh", + "docs/preview/images/claw_logo.png", + "docs/preview/images/claw_robot.png", + "docs/preview/images/wps_support_group.jpg", + "docs/prompt-driven-agent-creation-draft.md", + "docs/public-release-workflow.md", + "docs/reference/ksadk技术设计.md", + "docs/reference/远程Agent运行时接口说明.md", + "docs/superpowers/plans/2026-04-16-hosted-hermes-gateway.md", + "docs/superpowers/plans/2026-04-20-workspace-files-pvc-implementation.md", + "docs/superpowers/plans/2026-05-07-thinking-user-control-and-e2e-plan.md", + "docs/superpowers/plans/2026-06-04-ksadk-0.6.2-public-candidate-audit.md", + "docs/superpowers/plans/2026-06-04-otel-first-observability-release.md", + "docs/superpowers/specs/2026-04-18-ksadk-support-model-design.md", + "docs/superpowers/specs/2026-04-19-agent-workspace-file-service-design.md", + "docs/veadk-benchmark-and-iteration-plan.md", + "docs/工作区文件技术设计.md", + "docs/平台可观测与用户反馈设计方案.md", + "docs/知识库与记忆示例.md", + "docs/记忆使用指南.md", + "docs/远程Agent运行时接口说明.md", "scripts/ci-frontend-check.sh", "scripts/debug_aicp_memory.py", - "scripts/plan_github_publication.py", - "scripts/prepare_open_source_review_bundle.py", - "scripts/prepare_zread_source_snapshot.py", "scripts/test_ks3_upload.py", - "scripts/verify_open_source_review_bundle.py", + "scripts/validate_checkpoint_resume_e2e.py", + "scripts/validate_hosted_long_task_e2e.py", + "scripts/validate_long_task_pilot.py", + "skills/agentengine-cli-ops/SKILL.md", + "skills/agentengine-cli-ops/agents/openai.yaml", + "skills/agentengine-cli-ops/references/prerequisites.md", + "skills/agentengine-cli-ops/references/routing.md", + "skills/agentengine-cli-ops/references/shared-defaults.md", + "skills/agentengine-cluster-debug/SKILL.md", + "skills/agentengine-cluster-debug/references/commands.md", + "skills/agentengine-hermes-lifecycle/SKILL.md", + "skills/agentengine-hermes-lifecycle/agents/openai.yaml", + "skills/agentengine-hermes-lifecycle/references/dashboard-links.md", + "skills/agentengine-hermes-lifecycle/references/hermes-lifecycle.md", + "skills/agentengine-hermes-lifecycle/references/troubleshooting.md", + "skills/agentengine-openclaw-oneclick-deploy/SKILL.md", + "skills/agentengine-openclaw-oneclick-deploy/agents/openai.yaml", + "skills/agentengine-openclaw-oneclick-deploy/references/channel-connect.md", + "skills/agentengine-openclaw-oneclick-deploy/references/dashboard-links.md", + "skills/agentengine-openclaw-oneclick-deploy/references/openclaw-lifecycle.md", + "skills/agentengine-openclaw-oneclick-deploy/references/troubleshooting.md", + "tests/long_task/__init__.py", + "tests/long_task/test_checkpoint_resume.py", + "tests/long_task/test_runtime_cancel.py", + "tests/long_task/test_tool_idempotency.py", "tests/skills/__init__.py", "tests/skills/test_adk_runner_skill_runtime.py", "tests/skills/test_loader_and_tools.py", @@ -489,12 +109,13 @@ "tests/test_a2a_integration.py", "tests/test_agent.py", "tests/test_agent_access.py", + "tests/test_agentengine_toolsets.py", "tests/test_aicp_env.py", "tests/test_attachment_pipeline.py", + "tests/test_attachment_storage.py", + "tests/test_background_run.py", "tests/test_builder_requirements_merge.py", "tests/test_builder_runtime_requirements.py", - "tests/test_check_approval_record.py", - "tests/test_check_publication_state.py", "tests/test_cli_dry_run.py", "tests/test_cli_global_options.py", "tests/test_cli_platform_refactor.py", @@ -504,6 +125,7 @@ "tests/test_client_http_error_logging.py", "tests/test_client_mcp_payloads.py", "tests/test_client_permission_precheck.py", + "tests/test_client_user_uuid_header.py", "tests/test_client_workspace_files.py", "tests/test_cmd_build_upload_urls.py", "tests/test_cmd_completion.py", @@ -521,47 +143,49 @@ "tests/test_code_builder_pip_indexes.py", "tests/test_code_builder_rebuild_fingerprint.py", "tests/test_code_builder_static_assets.py", - "tests/test_config_env_registry.py", + "tests/test_compaction_pipeline.py", "tests/test_config_root_visibility.py", + "tests/test_container_registry_credentials.py", "tests/test_conversation_runtime.py", "tests/test_deepagents_integration.py", + "tests/test_deepagents_runner_skill_runtime.py", "tests/test_deploy_integration.py", "tests/test_error_utils_hints.py", "tests/test_help_snapshots.py", "tests/test_hermes_container_builder.py", - "tests/test_hermes_dockerfile.py", - "tests/test_hermes_kdocs_skill.py", - "tests/test_hermes_runtime_template.py", "tests/test_hermes_terminal.py", "tests/test_hermes_terminal_e2e.py", + "tests/test_identity_resolver.py", "tests/test_json_contracts.py", "tests/test_ks3_uploader_urls.py", "tests/test_langchain_runner_session_continuity.py", "tests/test_langfuse_exporter.py", "tests/test_langfuse_runner_utils.py", "tests/test_langgraph_runner_resume.py", + "tests/test_langgraph_runner_skill_runtime.py", "tests/test_local_runtime_reexec.py", + "tests/test_long_task_pilot_validation.py", "tests/test_mcp_runtime.py", - "tests/test_open_source_docs_contract.py", + "tests/test_model_policy.py", "tests/test_openai_protocol_e2e.py", - "tests/test_openclaw_bootstrap_secretref.py", "tests/test_openclaw_env_vars.py", - "tests/test_openclaw_runtime_proxy.py", - "tests/test_openclaw_safe_exec.py", - "tests/test_openclaw_workspace_files_gating.py", + "tests/test_openclaw_gateway.py", "tests/test_orchestration_agents.py", "tests/test_patch_langchain.py", - "tests/test_plan_github_publication.py", "tests/test_platform_memory_tools.py", "tests/test_postgres_session_service.py", + "tests/test_prepare_ksadk_python_export.py", "tests/test_remote_runner.py", "tests/test_resource_output_snapshots.py", "tests/test_runner.py", + "tests/test_runner_langfuse_callbacks.py", "tests/test_runtime_common_memory_backend.py", "tests/test_sandbox_backend.py", + "tests/test_semantic_circuit_breaker.py", "tests/test_server_app_fastapi_compat.py", "tests/test_server_file_upload_parsing.py", "tests/test_server_session_app.py", + "tests/test_server_terminal_sessions.py", "tests/test_server_workspace_preview_security.py", "tests/test_session_continuity.py", "tests/test_session_title.py", @@ -569,12 +193,16 @@ "tests/test_setup_environment.py", "tests/test_stm_config.py", "tests/test_storage_defaults.py", - "tests/test_tracing_setup_otlp.py", + "tests/test_tool_gateway.py", + "tests/test_tool_result_budget.py", + "tests/test_tracing_cloud_monitor_e2e.py", "tests/test_tui_app.py", "tests/test_tui_clipboard.py", "tests/test_ui_config_resolution.py", "tests/test_unified_agent_ui_local.py", - "tests/test_verify_open_source_review_bundle.py", + "tests/test_usage_accumulator.py", + "tests/test_validate_hosted_long_task_e2e.py", + "tests/test_web_toolset.py", "tests/test_workflow_common.py", "tests/test_workflow_help_snapshots.py", "tests/unit/knowledge_base/test_client_env.py", @@ -584,6 +212,7 @@ "rootFiles": [ ".dockerignore", ".gitattributes", + ".github/BRANCH_PROTECTION.md", ".github/ISSUE_TEMPLATE/bug_report.md", ".github/ISSUE_TEMPLATE/feature_request.md", ".github/dependabot.yml", @@ -591,6 +220,7 @@ ".github/workflows/ci.yml", ".github/workflows/codeql.yml", ".github/workflows/pages.yml", + ".github/workflows/publish-pypi.yml", ".github/workflows/release-check.yml", ".github/workflows/secret-patterns.yml", ".gitignore", @@ -605,28 +235,41 @@ "README.md", "README.zh-CN.md", "SECURITY.md", - "mkdocs.yml", "pyproject.toml", "uv.lock" ], "prefixes": [ + "docs-site/", "ksadk/", - "ksadk_runtime_common/", - "public-docs/" + "ksadk_runtime_common/" + ], + "curatedDocs": [ + "docs/maintainer-approval-record.md" + ], + "curatedReferenceDocs": [ + "docs/reference/ksadk环境变量参考.md" ], - "curatedDocs": [], "scripts": [ "scripts/audit_release_artifacts.py", + "scripts/check_approval_record.py", + "scripts/check_publication_state.py", + "scripts/check_release_version.py", + "scripts/generate_public_assets.py", "scripts/open_source_audit.py", "scripts/prepare_ksadk_python_export.py", - "scripts/prepare_ksadk_web_export.py" + "scripts/prepare_ksadk_web_export.py", + "scripts/public_secret_audit.py" ], "tests": [ "tests/conftest.py", + "tests/test_check_approval_record.py", + "tests/test_check_publication_state.py", + "tests/test_config_env_registry.py", + "tests/test_markdown_repair.py", "tests/test_open_source_audit.py", - "tests/test_prepare_ksadk_python_export.py", - "tests/test_prepare_ksadk_web_export.py", - "tests/test_runtime_common_packaging.py" + "tests/test_public_release_positioning.py", + "tests/test_runtime_common_packaging.py", + "tests/test_tracing_setup_otlp.py" ] }, "notes": [ diff --git a/ksadk/configs/env_registry.py b/ksadk/configs/env_registry.py index 6873a968..5f39aeb2 100644 --- a/ksadk/configs/env_registry.py +++ b/ksadk/configs/env_registry.py @@ -263,12 +263,6 @@ class EnvVarSpec: EnvVarSpec("KSADK_USER_BACKEND_URL", "web", "User-facing backend URL used by hosted UI integrations."), 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( - "KSADK_OTLP_MAX_EXPORT_BATCH_SIZE", - "tracing", - "Maximum spans exported per OTLP batch; defaults to 64 to avoid collector request-size limits.", - "64", - ), EnvVarSpec("CLOUD_MONITOR_APP_KEY", "tracing", "CloudMonitor AppKey for optional OTLP ingestion.", sensitive=True), EnvVarSpec( "CLOUD_MONITOR_LANGFUSE_ENABLED", @@ -331,6 +325,12 @@ 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( + "KSADK_OTLP_MAX_EXPORT_BATCH_SIZE", + "tracing", + "Maximum spans exported per OTLP batch to avoid oversized collector requests.", + "64", + ), ) ENV_VAR_REGISTRY: tuple[EnvVarSpec, ...] = tuple( diff --git a/ksadk/identity/resolver.py b/ksadk/identity/resolver.py index 50eda0d0..15f55c23 100644 --- a/ksadk/identity/resolver.py +++ b/ksadk/identity/resolver.py @@ -77,6 +77,21 @@ def _resolve_iam_endpoint() -> tuple[str, str]: return host, scheme +def _resolve_iam_intranet_endpoint() -> tuple[str, str] | None: + """解析显式配置的 IAM 内网 endpoint。未配置时不启用内网 fallback。""" + raw = (os.getenv("KSYUN_IAM_INTRANET_URL") or os.getenv("IAM_INTRANET_URL") or "").strip() + if not raw: + return None + if "://" not in raw: + raw = "http://" + raw + parsed = urlparse(raw) + host = (parsed.netloc or parsed.path or "").strip() + if not host: + return None + scheme = (parsed.scheme or "http").strip().lower() or "http" + return host, scheme + + def _should_retry_intranet(error: Exception | None) -> bool: """判断是否应回退到内网 endpoint(内部账号只能内网访问时)。""" if error is None: @@ -256,16 +271,18 @@ def resolve_identity( ak_fingerprint=fingerprint, ) - # 2. 调 IAM 反查(公网失败时自动 fallback 内网,处理 InnerAccountCanOnlyAccessThroughIntranet) + # 2. 调 IAM 反查。公网失败时 fallback 内网 endpoint(内部账号只能内网访问时)。 + # 内网地址优先用显式配置的 IAM_INTRANET_URL,未配置时用默认 iam.inner.api.ksyun.com。 + # 该地址外部不可访问(内网专属),open_source_audit 白名单已放行,内部账号开箱即用。 sdk_parts = _import_iam_sdk() if sdk_parts is None: return None host, scheme = _resolve_iam_endpoint() - # 候选 endpoint 列表:(host, scheme, is_intranet);首个用解析出的默认,失败再试内网 candidates = [(host, scheme)] - if host != "iam.inner.api.ksyun.com": - candidates.append(("iam.inner.api.ksyun.com", "http")) + intranet_endpoint = _resolve_iam_intranet_endpoint() or ("iam.inner.api.ksyun.com", "http") + if intranet_endpoint not in candidates: + candidates.append(intranet_endpoint) user_name: Optional[str] = None user: dict = {} diff --git a/ksadk_runtime_common/schemas/memory_backend_manifest.schema.json b/ksadk_runtime_common/schemas/memory_backend_manifest.schema.json index 7a83d280..a81058e0 100644 --- a/ksadk_runtime_common/schemas/memory_backend_manifest.schema.json +++ b/ksadk_runtime_common/schemas/memory_backend_manifest.schema.json @@ -101,10 +101,7 @@ "description": "S3-compatible storage secret access key. Mapped into storageOptions.secretAccessKey." }, "storage_allow_http": { - "type": [ - "boolean", - "string" - ], + "type": ["boolean", "string"], "description": "Allow plain HTTP for S3-compatible storage. Mapped into storageOptions.allowHttp as a string (\"true\"/\"false\")." } } @@ -135,20 +132,14 @@ { "if": { "properties": { - "backend_type": { - "const": "mem0" - } + "backend_type": { "const": "mem0" } } }, "then": { - "required": [ - "config" - ], + "required": ["config"], "properties": { "config": { - "required": [ - "mem0_instance_id" - ] + "required": ["mem0_instance_id"] } } } diff --git a/scripts/check_publication_state.py b/scripts/check_publication_state.py index 02b97dbf..9e1f1579 100644 --- a/scripts/check_publication_state.py +++ b/scripts/check_publication_state.py @@ -48,6 +48,30 @@ def _expect_http_ok(name: str, url: str) -> None: print(f"{name}: HTTP {status}") +def _normalize_url(url: str) -> str: + parsed = urllib.parse.urlparse(url) + path = parsed.path or "/" + if not path.endswith("/"): + path = f"{path}/" + return urllib.parse.urlunparse((parsed.scheme, parsed.netloc, path, "", "", "")) + + +def _expect_github_repo_homepage(url: str, expected_docs_url: str) -> None: + status, body = _open(url) + if status != 200: + raise RuntimeError(f"github repo: 期望 HTTP 200,实际 {status}: {url}") + data = json.loads(body) + if not isinstance(data, dict): + raise RuntimeError("github repo: 响应不是 repo object") + homepage = str(data.get("homepage") or "") + if _normalize_url(homepage) != _normalize_url(expected_docs_url): + raise RuntimeError( + "github repo homepage 与文档地址不一致: " + f"homepage={homepage or ''}, expected={expected_docs_url}" + ) + print(f"github repo homepage: {homepage}") + + def _pypi_project_version(project: str) -> str | None: url = f"https://pypi.org/pypi/{project}/json" try: @@ -152,6 +176,10 @@ def main() -> int: parser.add_argument("--project", default="ksadk") parser.add_argument("--alias-project", default="agentengine-sdk-python") parser.add_argument("--docs-url", default="https://kingsoftcloud.github.io/ksadk-python/") + parser.add_argument( + "--github-repo-url", + default="https://api.github.com/repos/kingsoftcloud/ksadk-python", + ) parser.add_argument( "--github-releases-url", default="https://api.github.com/repos/kingsoftcloud/ksadk-python/releases?per_page=100", @@ -164,6 +192,7 @@ def main() -> int: args = parser.parse_args() _expect_http_ok("docs", args.docs_url) + _expect_github_repo_homepage(args.github_repo_url, args.docs_url) required_tags = [tag.strip() for tag in args.required_release_tags.split(",") if tag.strip()] _expect_release_history(args.github_releases_url, required_tags) diff --git a/scripts/ci-frontend-check.sh b/scripts/ci-frontend-check.sh deleted file mode 100755 index 37dec95c..00000000 --- a/scripts/ci-frontend-check.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/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 deleted file mode 100644 index f3a36e8d..00000000 --- a/scripts/debug_aicp_memory.py +++ /dev/null @@ -1,353 +0,0 @@ -#!/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/generate_public_assets.py b/scripts/generate_public_assets.py index 8113c501..36042d3f 100644 --- a/scripts/generate_public_assets.py +++ b/scripts/generate_public_assets.py @@ -24,7 +24,7 @@ ROOT = Path(__file__).resolve().parents[1] -ASSETS_DIR = ROOT / "public-docs" / "assets" +ASSETS_DIR = ROOT / "docs-site" / "public" / "assets" ARCH_SVG = ASSETS_DIR / "ksadk-runtime-architecture.svg" ARCH_PNG = ASSETS_DIR / "ksadk-runtime-architecture.png" HERO_PNG = ASSETS_DIR / "ksadk-runtime-platform-hero.png" diff --git a/scripts/open_source_audit.py b/scripts/open_source_audit.py index 21f6d077..12ad1213 100644 --- a/scripts/open_source_audit.py +++ b/scripts/open_source_audit.py @@ -105,7 +105,13 @@ def to_dict(self) -> dict[str, object]: DenyRule( name="non-curated-docs", prefixes=("docs/",), - 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", "docs/reference/ksadk\u6280\u672f\u8bbe\u8ba1.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", + "docs/reference/ksadk\u6280\u672f\u8bbe\u8ba1.md", + "docs/reference/ksadk\u73af\u5883\u53d8\u91cf\u53c2\u8003.md", + ), prefix_only=True, description="internal planning and technical design docs stay out of the public repository; public docs live in docs-site/ (Fumadocs)", ), diff --git a/scripts/prepare_ksadk_python_export.py b/scripts/prepare_ksadk_python_export.py index b85581cb..5d46f6ff 100644 --- a/scripts/prepare_ksadk_python_export.py +++ b/scripts/prepare_ksadk_python_export.py @@ -28,10 +28,12 @@ DEFAULT_OUTPUT_DIR = Path("/tmp/ksadk-python-export-candidate") CURATED_DOCS: set[str] = {"docs/maintainer-approval-record.md"} +CURATED_REFERENCE_DOCS: set[str] = {"docs/reference/ksadk环境变量参考.md"} ROOT_EXPORT_FILES = { ".dockerignore", ".gitattributes", + ".github/BRANCH_PROTECTION.md", ".github/ISSUE_TEMPLATE/bug_report.md", ".github/ISSUE_TEMPLATE/feature_request.md", ".github/dependabot.yml", @@ -39,6 +41,7 @@ ".github/workflows/ci.yml", ".github/workflows/codeql.yml", ".github/workflows/pages.yml", + ".github/workflows/publish-pypi.yml", ".github/workflows/release-check.yml", ".github/workflows/secret-patterns.yml", ".gitignore", @@ -53,31 +56,45 @@ "README.en.md", "README.zh-CN.md", "SECURITY.md", - "mkdocs.yml", "pyproject.toml", "uv.lock", } EXPORT_PREFIXES = ( + "docs-site/", "ksadk/", "ksadk_runtime_common/", - "public-docs/", ) +REQUIRED_PUBLIC_FILES = { + "AGENTS.md", + "CLAUDE.md", + "LICENSE", + "README.md", + "README.en.md", + "README.zh-CN.md", + "docs-site/package.json", + "docs-site/pnpm-lock.yaml", + "pyproject.toml", +} + SCRIPT_EXPORT_FILES = { "scripts/audit_release_artifacts.py", "scripts/check_approval_record.py", "scripts/check_publication_state.py", + "scripts/check_release_version.py", "scripts/generate_public_assets.py", "scripts/open_source_audit.py", "scripts/prepare_ksadk_python_export.py", "scripts/prepare_ksadk_web_export.py", + "scripts/public_secret_audit.py", } PUBLIC_TEST_FILES = { "tests/conftest.py", "tests/test_check_approval_record.py", "tests/test_check_publication_state.py", + "tests/test_config_env_registry.py", "tests/test_markdown_repair.py", "tests/test_open_source_audit.py", "tests/test_public_release_positioning.py", @@ -108,16 +125,6 @@ EXCLUDED_PATHS = { ".pypirc", ".pypirc.example", - "Dockerfile.docs", - "deploy/helm/ksadk-docs/Chart.yaml", - "deploy/helm/ksadk-docs/templates/_helpers.tpl", - "deploy/helm/ksadk-docs/templates/deployment.yaml", - "deploy/helm/ksadk-docs/templates/ingress.yaml", - "deploy/helm/ksadk-docs/templates/service.yaml", - "deploy/helm/ksadk-docs/values-online.yaml", - "deploy/helm/ksadk-docs/values-pre.yaml", - "deploy/helm/ksadk-docs/values.yaml", - "scripts/zread_subpath_proxy.py", } EXCLUDED_SUFFIXES = ( @@ -194,9 +201,9 @@ def is_excluded(path: str) -> bool: return True if normalized in EXCLUDED_PATHS: return True - if normalized.startswith("deploy/helm/ksadk-docs/"): + if normalized.startswith("docs/reference/") and normalized not in CURATED_REFERENCE_DOCS: return True - if normalized.startswith("docs/") and normalized not in CURATED_DOCS: + if normalized.startswith("docs/") and normalized not in CURATED_DOCS and normalized not in CURATED_REFERENCE_DOCS: return True if normalized.endswith(EXCLUDED_SUFFIXES): return True @@ -211,6 +218,7 @@ def is_included_by_policy(path: str) -> bool: return ( normalized in ROOT_EXPORT_FILES or normalized in CURATED_DOCS + or normalized in CURATED_REFERENCE_DOCS or normalized in SCRIPT_EXPORT_FILES or normalized in PUBLIC_TEST_FILES or normalized.startswith(EXPORT_PREFIXES) @@ -233,17 +241,7 @@ def build_export_plan(repo_root: Path) -> ExportPlan: export_paths = sorted(path for path in discovered if not is_excluded(path)) excluded_paths = sorted(path for path in discovered if is_excluded(path)) - required_paths = { - "AGENTS.md", - "CLAUDE.md", - "LICENSE", - "README.md", - "README.en.md", - "README.zh-CN.md", - "pyproject.toml", - "mkdocs.yml", - } - for required_path in sorted(required_paths): + for required_path in sorted(REQUIRED_PUBLIC_FILES): if required_path not in export_paths: violations.append(f"missing required public file: {required_path}") @@ -283,6 +281,7 @@ def copy_export(plan: ExportPlan, output_dir: Path) -> None: "rootFiles": sorted(ROOT_EXPORT_FILES), "prefixes": list(EXPORT_PREFIXES), "curatedDocs": sorted(CURATED_DOCS), + "curatedReferenceDocs": sorted(CURATED_REFERENCE_DOCS), "scripts": sorted(SCRIPT_EXPORT_FILES), "tests": sorted(PUBLIC_TEST_FILES), }, diff --git a/scripts/prepare_zread_source_snapshot.py b/scripts/prepare_zread_source_snapshot.py deleted file mode 100644 index e7111202..00000000 --- a/scripts/prepare_zread_source_snapshot.py +++ /dev/null @@ -1,64 +0,0 @@ -#!/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 deleted file mode 100644 index cec592d4..00000000 --- a/scripts/test_ks3_upload.py +++ /dev/null @@ -1,81 +0,0 @@ -#!/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("AK: 已设置" if ak else "AK: 未设置") -print("SK: 已设置" 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 deleted file mode 100644 index f3fac4f3..00000000 --- a/scripts/validate_checkpoint_resume_e2e.py +++ /dev/null @@ -1,747 +0,0 @@ -#!/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 deleted file mode 100644 index c35d21b9..00000000 --- a/scripts/validate_hosted_long_task_e2e.py +++ /dev/null @@ -1,699 +0,0 @@ -#!/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( - "GetCheckpointResumePreview", - { - "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 deleted file mode 100644 index 7e443175..00000000 --- a/scripts/validate_long_task_pilot.py +++ /dev/null @@ -1,493 +0,0 @@ -#!/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 deleted file mode 100644 index 311dd253..00000000 --- a/scripts/zread_subpath_proxy.py +++ /dev/null @@ -1,230 +0,0 @@ -#!/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 deleted file mode 100644 index acd33e3f..00000000 --- a/tests/long_task/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Long-task recovery acceptance tests.""" diff --git a/tests/long_task/test_checkpoint_resume.py b/tests/long_task/test_checkpoint_resume.py deleted file mode 100644 index 8dd51310..00000000 --- a/tests/long_task/test_checkpoint_resume.py +++ /dev/null @@ -1,158 +0,0 @@ -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 deleted file mode 100644 index 55b57a42..00000000 --- a/tests/long_task/test_runtime_cancel.py +++ /dev/null @@ -1,135 +0,0 @@ -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 deleted file mode 100644 index f57ba9ac..00000000 --- a/tests/long_task/test_tool_idempotency.py +++ /dev/null @@ -1,118 +0,0 @@ -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 deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/skills/test_adk_runner_skill_runtime.py b/tests/skills/test_adk_runner_skill_runtime.py deleted file mode 100644 index 38dddf1e..00000000 --- a/tests/skills/test_adk_runner_skill_runtime.py +++ /dev/null @@ -1,371 +0,0 @@ -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_loader_and_tools.py b/tests/skills/test_loader_and_tools.py deleted file mode 100644 index 6a603659..00000000 --- a/tests/skills/test_loader_and_tools.py +++ /dev/null @@ -1,190 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -import pytest - -from ksadk.skills.loader import load_local_skill -from ksadk.skills.tool_defs import build_execute_skills_tool, build_skills_tool -from ksadk.skills.runtime import SkillRuntimeResult - - -def test_load_local_skill_reads_frontmatter(tmp_path: Path): - root = tmp_path / "web-artifacts-builder" - root.mkdir() - (root / "SKILL.md").write_text( - "---\nname: web-artifacts-builder\ndescription: Build artifacts\n---\n# Body\n", - encoding="utf-8", - ) - - skill = load_local_skill(root) - - assert skill.name == "web-artifacts-builder" - assert skill.description == "Build artifacts" - assert skill.root_dir == root - - -def test_execute_skills_tool_delegates_to_runtime_without_leaking_secret(monkeypatch): - monkeypatch.setenv("E2B_API_KEY", "secret-token") - monkeypatch.setenv("KSADK_SKILL_SERVICE_URL", "https://skill.example/api/v1") - monkeypatch.setenv("KSADK_SKILL_SERVICE_SECRET_KEY", "skill-secret") - - class Backend: - def __init__(self): - self.calls = [] - - def run_workflow(self, workflow_prompt: str, **kwargs): - self.calls.append((workflow_prompt, kwargs)) - return SkillRuntimeResult( - runtime_id="sbx-1", - exit_code=0, - stdout="artifact ready\n", - stderr="", - duration_ms=15, - ) - - backend = Backend() - tool = build_execute_skills_tool(backend=backend, skill_space_ids=["ss-1"], session_id="sess-1") - - output = tool("build a page") - - assert output["stdout"] == "artifact ready\n" - assert output["runtime_id"] == "sbx-1" - assert "secret-token" not in repr(output) - assert backend.calls[0][0] == "build a page" - assert backend.calls[0][1]["skill_space_ids"] == ["ss-1"] - assert backend.calls[0][1]["env"]["KSADK_SKILL_SERVICE_URL"] == "https://skill.example/api/v1" - assert backend.calls[0][1]["env"]["KSADK_SKILL_SERVICE_SECRET_KEY"] == "skill-secret" - assert "E2B_API_KEY" not in backend.calls[0][1]["env"] - - -def test_execute_skills_tool_passes_explicit_skill_names_to_runtime(): - class Backend: - def __init__(self): - self.calls = [] - - def run_workflow(self, workflow_prompt: str, **kwargs): - self.calls.append((workflow_prompt, kwargs)) - return SkillRuntimeResult(exit_code=0) - - backend = Backend() - tool = build_execute_skills_tool(backend=backend, skill_space_ids=["ss-1"], session_id="sess-1") - - tool("build a page", skill_names=["demo-skill"]) - - assert backend.calls[0][1]["skill_names"] == ["demo-skill"] - - -def test_execute_skills_tool_maps_ksyun_fallbacks_to_skill_service_env(monkeypatch): - monkeypatch.delenv("KSADK_SKILL_SERVICE_ACCOUNT_ID", raising=False) - monkeypatch.delenv("KSADK_SKILL_SERVICE_REGION", raising=False) - monkeypatch.setenv("KSYUN_ACCOUNT_ID", "2000003485") - monkeypatch.setenv("KSYUN_REGION", "cn-beijing-6") - monkeypatch.setenv("KSYUN_ACCESS_KEY", "generic-ak-should-not-cross") - monkeypatch.setenv("KSYUN_SECRET_KEY", "generic-sk-should-not-cross") - - class Backend: - def __init__(self): - self.calls = [] - - def run_workflow(self, workflow_prompt: str, **kwargs): - self.calls.append((workflow_prompt, kwargs)) - return SkillRuntimeResult(exit_code=0) - - backend = Backend() - tool = build_execute_skills_tool(backend=backend, skill_space_ids=["ss-1"], session_id="sess-1") - - tool("build a page") - - env = backend.calls[0][1]["env"] - assert env["KSADK_SKILL_SERVICE_ACCOUNT_ID"] == "2000003485" - assert env["KSADK_SKILL_SERVICE_REGION"] == "cn-beijing-6" - assert "KSYUN_ACCESS_KEY" not in env - assert "KSYUN_SECRET_KEY" not in env - assert env["KSADK_SKILL_SERVICE_ACCESS_KEY"] == "generic-ak-should-not-cross" - assert env["KSADK_SKILL_SERVICE_SECRET_KEY"] == "generic-sk-should-not-cross" - - -def test_execute_skills_tool_auto_resolves_skill_service_url_for_runtime(monkeypatch): - monkeypatch.delenv("KSADK_SKILL_SERVICE_URL", raising=False) - monkeypatch.delenv("KSADK_SKILL_SERVICE_ENDPOINT", raising=False) - monkeypatch.delenv("KSADK_SKILL_SERVICE_SCHEME", raising=False) - monkeypatch.setenv("KSADK_AICP_ENDPOINT_MODE", "internal") - monkeypatch.setenv("KSYUN_ACCOUNT_ID", "2000003485") - monkeypatch.setenv("KSYUN_REGION", "cn-beijing-6") - - class Backend: - def __init__(self): - self.calls = [] - - def run_workflow(self, workflow_prompt: str, **kwargs): - self.calls.append((workflow_prompt, kwargs)) - return SkillRuntimeResult(exit_code=0) - - backend = Backend() - tool = build_execute_skills_tool(backend=backend, skill_space_ids=["ss-1"], session_id="sess-1") - - tool("build a page") - - env = backend.calls[0][1]["env"] - assert env["KSADK_SKILL_SERVICE_URL"] == "http://aicp.internal.api.ksyun.com" - assert env["KSADK_SKILL_SERVICE_ACCOUNT_ID"] == "2000003485" - assert env["KSADK_SKILL_SERVICE_REGION"] == "cn-beijing-6" - - -def test_execute_skills_tool_leaves_auto_endpoint_detection_to_runtime(monkeypatch): - monkeypatch.delenv("KSADK_SKILL_SERVICE_URL", raising=False) - monkeypatch.delenv("KSADK_SKILL_SERVICE_ENDPOINT", raising=False) - monkeypatch.delenv("KSADK_SKILL_SERVICE_SCHEME", raising=False) - monkeypatch.setenv("KSADK_AICP_ENDPOINT_MODE", "auto") - - class Backend: - def __init__(self): - self.calls = [] - - def run_workflow(self, workflow_prompt: str, **kwargs): - self.calls.append((workflow_prompt, kwargs)) - return SkillRuntimeResult(exit_code=0) - - backend = Backend() - tool = build_execute_skills_tool(backend=backend, skill_space_ids=["ss-1"], session_id="sess-1") - - tool("build a page") - - assert "KSADK_SKILL_SERVICE_URL" not in backend.calls[0][1]["env"] - - -def test_execute_skills_tool_passes_public_skill_allowlist_to_runtime(monkeypatch): - monkeypatch.setenv("KSADK_PUBLIC_SKILL_ALLOWLIST", "pdf,weather") - - class Backend: - def __init__(self): - self.calls = [] - - def run_workflow(self, workflow_prompt: str, **kwargs): - self.calls.append((workflow_prompt, kwargs)) - return SkillRuntimeResult(exit_code=0) - - backend = Backend() - tool = build_execute_skills_tool(backend=backend, skill_space_ids=["ss-1"], session_id="sess-1") - - tool("build a page") - - assert backend.calls[0][1]["env"]["KSADK_PUBLIC_SKILL_ALLOWLIST"] == "pdf,weather" - - -def test_skills_tool_reports_loaded_local_skills(tmp_path: Path): - root = tmp_path / "demo-skill" - root.mkdir() - (root / "SKILL.md").write_text( - "---\nname: demo-skill\ndescription: Demo skill\n---\n# Demo Body\nUse this carefully.\n", - encoding="utf-8", - ) - - tool = build_skills_tool([load_local_skill(root)]) - - result = tool("list") - - assert result["skills"][0]["name"] == "demo-skill" - assert result["skills"][0]["description"] == "Demo skill" - assert "Use this carefully" in result["skills"][0]["body"] diff --git a/tests/skills/test_package_store.py b/tests/skills/test_package_store.py deleted file mode 100644 index 6d27ad2d..00000000 --- a/tests/skills/test_package_store.py +++ /dev/null @@ -1,73 +0,0 @@ -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 deleted file mode 100644 index 7921a32d..00000000 --- a/tests/skills/test_runtime.py +++ /dev/null @@ -1,332 +0,0 @@ -from __future__ import annotations - -import json -import subprocess -import sys -from pathlib import Path - -import pytest - -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 - - -def test_runtime_factory_creates_disabled_backend_by_default(monkeypatch): - monkeypatch.delenv("KSADK_SKILL_RUNTIME_BACKEND", raising=False) - - backend = create_skill_runtime_backend() - - with pytest.raises(SkillRuntimeError, match="disabled"): - backend.run_workflow("hello", skill_space_ids=["ss-1"], session_id="s1") - - -def test_skill_workflow_request_is_public_runtime_protocol(): - request = SkillWorkflowRequest(workflow_prompt="build", skill_names=["demo-skill"]) - - assert request.workflow_prompt == "build" - assert request.skill_names == ["demo-skill"] - - -def test_runtime_factory_creates_local_process_backend(monkeypatch, tmp_path: Path): - agent = tmp_path / "agent.py" - agent.write_text("print('agent')", encoding="utf-8") - monkeypatch.setenv("KSADK_SKILL_RUNTIME_BACKEND", "local_process") - monkeypatch.setenv("KSADK_SKILL_RUNTIME_AGENT_PATH", str(agent)) - - backend = create_skill_runtime_backend() - - assert isinstance(backend, LocalProcessSkillRuntimeBackend) - - -def test_runtime_factory_auto_uses_e2b_when_generic_sandbox_template_is_configured(monkeypatch): - sentinel = object() - monkeypatch.delenv("KSADK_SKILL_RUNTIME_BACKEND", raising=False) - monkeypatch.setenv("KSADK_SANDBOX_TEMPLATE_ID", "tpl-aio") - monkeypatch.setattr( - "ksadk.skills.runtime.factory.E2BSkillRuntimeBackend.from_env", - lambda: sentinel, - ) - - backend = create_skill_runtime_backend() - - assert backend is sentinel - - -def test_e2b_skill_runtime_backend_from_env_prefers_generic_sandbox_vars(monkeypatch): - monkeypatch.setenv("KSADK_SANDBOX_TEMPLATE_ID", "tpl-aio") - monkeypatch.setenv("KSADK_SKILL_RUNTIME_TEMPLATE_ID", "tpl-legacy") - monkeypatch.setenv("KSADK_SANDBOX_TIMEOUT", "321") - monkeypatch.setenv("KSADK_SKILL_RUNTIME_TIMEOUT", "123") - monkeypatch.setenv("KSADK_SANDBOX_ALLOW_INTERNET_ACCESS", "false") - monkeypatch.setattr("e2b.Sandbox", object) - - backend = E2BSkillRuntimeBackend.from_env() - - assert backend.template_id == "tpl-aio" - assert backend.timeout == 321 - assert backend.allow_internet_access is False - - -def test_e2b_skill_runtime_backend_error_mentions_generic_template_var(): - with pytest.raises(SkillRuntimeError, match="KSADK_SANDBOX_TEMPLATE_ID"): - E2BSkillRuntimeBackend(template_id="") - - -def test_e2b_backend_uses_native_env_and_always_kills(monkeypatch): - calls: list[tuple[str, object]] = [] - - class FakeResult: - stdout = 'ok\nworkflow_result={"output_files":["/tmp/bundle.html"],"status":"ok"}\n' - stderr = "" - exit_code = 0 - - class FakeCommands: - def run(self, command: str, **kwargs): - calls.append(("run", command)) - calls.append(("run_kwargs", kwargs)) - return FakeResult() - - class FakeFiles: - def write(self, path, data): - calls.append(("file_write", (path, data))) - - 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 = E2BSkillRuntimeBackend(sandbox_cls=FakeSandbox, template_id="tpl-1", timeout=123) - - result = backend.run_workflow( - "build artifact", - skill_space_ids=["ss-1"], - skill_names=["demo-skill"], - session_id="sess-1", - ) - - assert result == SkillRuntimeResult( - runtime_id="sbx-123", - exit_code=0, - stdout='ok\nworkflow_result={"output_files":["/tmp/bundle.html"],"status":"ok"}\n', - stderr="", - duration_ms=result.duration_ms, - output_files=["/tmp/bundle.html"], - ) - assert calls[0] == ( - "create", - { - "template": "tpl-1", - "timeout": 123, - "metadata": { - "runtime": "ksadk", - "sandbox_type": "aio", - "component": "skill-runtime", - "session_id": "sess-1", - }, - "envs": { - "KSADK_SKILL_SPACE_IDS": "ss-1", - "SKILL_SPACE_ID": "ss-1", - "KSADK_SELECTED_SKILL_NAMES": "demo-skill", - }, - "allow_internet_access": True, - }, - ) - assert ( - "run_kwargs", - { - "timeout": 900, - "envs": { - "KSADK_SKILL_SPACE_IDS": "ss-1", - "SKILL_SPACE_ID": "ss-1", - "KSADK_SELECTED_SKILL_NAMES": "demo-skill", - }, - }, - ) in calls - 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", - "skill_names": ["demo-skill"], - } - assert calls[-1] == ("kill", "sbx-123") - - -def test_e2b_backend_preserves_public_skill_space_env(monkeypatch): - monkeypatch.setenv("KSADK_PUBLIC_SKILL_SPACE_IDS", "ss-public") - calls: list[tuple[str, object]] = [] - - class FakeResult: - stdout = "ok\n" - stderr = "" - exit_code = 0 - - class FakeCommands: - def run(self, command: str, **kwargs): - return FakeResult() - - class FakeFiles: - def write(self, path, data): - pass - - 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): - pass - - backend = E2BSkillRuntimeBackend(sandbox_cls=FakeSandbox, template_id="tpl-1") - - backend.run_workflow("build artifact", skill_space_ids=["ss-user"], session_id="sess-1") - - envs = calls[0][1]["envs"] - assert envs["KSADK_SKILL_SPACE_IDS"] == "ss-user" - assert envs["SKILL_SPACE_ID"] == "ss-user" - assert envs["KSADK_PUBLIC_SKILL_SPACE_IDS"] == "ss-public" - - -def test_e2b_backend_redacts_secret_from_errors(monkeypatch): - monkeypatch.setenv("E2B_API_KEY", "super-secret-token") - monkeypatch.setenv("KSADK_SKILL_SERVICE_TOKEN", "skill-service-token") - monkeypatch.setenv("KSADK_SKILL_SERVICE_SECRET_KEY", "skill-service-secret") - - class FakeSandbox: - @classmethod - def create(cls, **kwargs): - raise RuntimeError( - "failed with super-secret-token and skill-service-token and skill-service-secret" - ) - - backend = E2BSkillRuntimeBackend(sandbox_cls=FakeSandbox, template_id="tpl-1") - - result = backend.run_workflow("x", skill_space_ids=["ss-1"], session_id="sess-1") - - assert result.exit_code is None - assert result.error_type == "RuntimeError" - assert "super-secret-token" not in result.error_message - assert "skill-service-token" not in result.error_message - assert "skill-service-secret" not in result.error_message - assert "[REDACTED]" in result.error_message - - -def test_e2b_backend_writes_request_file_instead_of_shell_quoting_long_prompt(): - calls: list[tuple[str, object]] = [] - - 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)) - return FakeResult() - - class FakeSandbox: - sandbox_id = "sbx-123" - - def __init__(self): - self.files = FakeFiles() - self.commands = FakeCommands() - - @classmethod - def create(cls, **kwargs): - return cls() - - def kill(self): - calls.append(("kill", self.sandbox_id)) - - backend = E2BSkillRuntimeBackend(sandbox_cls=FakeSandbox, template_id="tpl-1") - - backend.run_workflow( - "hello 'quoted'", - skill_space_ids=["ss-1"], - skill_names=["demo-skill"], - session_id="sess-1", - ) - - 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" 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): - calls: list[dict[str, object]] = [] - agent = tmp_path / "agent.py" - agent.write_text("print('agent')", encoding="utf-8") - - def fake_run(args, **kwargs): - request_path = Path(args[-1]) - calls.append( - { - "args": args, - "request": json.loads(request_path.read_text(encoding="utf-8")), - "env": kwargs["env"], - } - ) - return subprocess.CompletedProcess(args=args, returncode=0, stdout="ok\n", stderr="") - - monkeypatch.setattr("ksadk.skills.runtime.backends.local.subprocess.run", fake_run) - backend = LocalProcessSkillRuntimeBackend(agent_path=agent) - - backend.run_workflow( - "build artifact", - skill_space_ids=["ss-1"], - skill_names=["demo-skill"], - session_id="sess-1", - ) - - assert calls[0]["args"][:3] == [sys.executable, "-u", str(agent)] - assert calls[0]["args"][3] == "--request-file" - assert calls[0]["request"] == { - "workflow_prompt": "build artifact", - "skill_names": ["demo-skill"], - } - assert calls[0]["env"]["KSADK_SELECTED_SKILL_NAMES"] == "demo-skill" diff --git a/tests/skills/test_runtime_agent.py b/tests/skills/test_runtime_agent.py deleted file mode 100644 index e0ed34fb..00000000 --- a/tests/skills/test_runtime_agent.py +++ /dev/null @@ -1,744 +0,0 @@ -from __future__ import annotations - -import hashlib -import io -import json -import subprocess -import zipfile -from pathlib import Path - -import httpx - -from ksadk.skills.loader import load_local_skill -from ksadk.skills.models import SkillRef -from ksadk.skills.runtime.registry import select_remote_skill_refs -from ksadk.skills.runtime import agent as runtime_agent -from ksadk.skills.runtime.agent import run_agent - - -def _zip_bytes(skill_name: str = "demo-skill") -> bytes: - buf = io.BytesIO() - with zipfile.ZipFile(buf, "w") as archive: - archive.writestr(f"{skill_name}/SKILL.md", f"---\nname: {skill_name}\ndescription: Demo\n---\n# Demo\n") - return buf.getvalue() - - -def test_runtime_agent_loads_active_skills_from_service(monkeypatch, tmp_path: Path, capsys): - archive = _zip_bytes() - digest = hashlib.sha256(archive).hexdigest() - - def handler(request: httpx.Request) -> httpx.Response: - if request.url.path.endswith("/ListSkillsBySpaceId"): - return httpx.Response( - 200, - json={ - "Data": { - "SkillSpaceId": "ss-1", - "Skills": [ - { - "SkillId": "sk-demo", - "VersionId": "sv-demo-v1", - "Version": "v1", - "Name": "demo-skill", - "Status": "Active", - "ContentHash": f"sha256:{digest}", - } - ], - } - }, - ) - if request.url.path.endswith("/GetSkillDownloadUrl"): - return httpx.Response(200, json={"Data": {"DownloadUrl": "https://download.example/demo.zip"}}) - if str(request.url) == "https://download.example/demo.zip": - return httpx.Response(200, content=archive) - return httpx.Response(404) - - monkeypatch.setenv("KSADK_SKILL_SPACE_IDS", "ss-1") - monkeypatch.setenv("KSADK_SKILL_SERVICE_URL", "https://skill.example/api/v1") - monkeypatch.setenv("KSADK_SKILL_CACHE_DIR", str(tmp_path / "cache")) - - code = run_agent( - ["使用 demo-skill build something"], - service_transport=httpx.MockTransport(handler), - ) - - out = capsys.readouterr().out - assert code == 0 - assert "workflow=使用 demo-skill build something" in out - assert "loaded_skills=demo-skill" in out - assert (tmp_path / "cache" / "sk-demo__sv-demo-v1" / "extracted" / "demo-skill" / "SKILL.md").exists() - - -def test_runtime_selects_remote_skill_by_alias_tag_and_description(): - skills = [ - SkillRef( - skill_id="sk-report", - version_id="v1", - version="1", - name="report-writer", - description="Write research reports", - aliases=("研究报告",), - tags=("research",), - ), - SkillRef( - skill_id="sk-web", - version_id="v1", - version="1", - name="web-builder", - description="Build web pages", - tags=("frontend",), - ), - ] - - assert [skill.name for skill in select_remote_skill_refs(skills, "帮我生成一份研究报告")] == ["report-writer"] - assert [skill.name for skill in select_remote_skill_refs(skills, "frontend artifact")] == ["web-builder"] - assert [skill.name for skill in select_remote_skill_refs(skills, "write a research report")] == ["report-writer"] - - -def test_runtime_agent_auto_resolves_aicp_skill_service_when_url_unset( - monkeypatch, - tmp_path: Path, - capsys, -): - archive = _zip_bytes() - digest = hashlib.sha256(archive).hexdigest() - seen_urls: list[str] = [] - - def handler(request: httpx.Request) -> httpx.Response: - seen_urls.append(str(request.url)) - if request.url.params.get("Action") == "ListSkillsBySpaceId": - return httpx.Response( - 200, - json={ - "Data": { - "SkillSpaceId": "ss-1", - "Skills": [ - { - "SkillId": "sk-demo", - "VersionId": "sv-demo-v1", - "Version": "v1", - "Name": "demo-skill", - "Status": "Active", - "ContentHash": f"sha256:{digest}", - } - ], - } - }, - ) - if request.url.params.get("Action") == "GetSkillDownloadUrl": - return httpx.Response(200, json={"Data": {"DownloadUrl": "https://download.example/demo.zip"}}) - if str(request.url) == "https://download.example/demo.zip": - return httpx.Response(200, content=archive) - return httpx.Response(404) - - monkeypatch.setenv("KSADK_SKILL_SPACE_IDS", "ss-1") - monkeypatch.delenv("KSADK_SKILL_SERVICE_URL", raising=False) - monkeypatch.delenv("KSADK_SKILL_SERVICE_ENDPOINT", raising=False) - monkeypatch.delenv("KSADK_SKILL_SERVICE_SCHEME", raising=False) - monkeypatch.setenv("KSADK_AICP_ENDPOINT_MODE", "internal") - monkeypatch.setenv("KSADK_SKILL_CACHE_DIR", str(tmp_path / "cache")) - - code = run_agent( - ["使用 demo-skill build something"], - service_transport=httpx.MockTransport(handler), - ) - - out = capsys.readouterr().out - assert code == 0 - assert "loaded_skills=demo-skill" in out - assert seen_urls[0].startswith( - "http://aicp.internal.api.ksyun.com/?Action=ListSkillsBySpaceId&Version=2024-06-12" - ) - - -def test_runtime_agent_downloads_only_prompted_remote_skill(monkeypatch, tmp_path: Path, capsys): - demo_archive = _zip_bytes("demo-skill") - unused_archive = _zip_bytes("unused-skill") - demo_digest = hashlib.sha256(demo_archive).hexdigest() - unused_digest = hashlib.sha256(unused_archive).hexdigest() - download_urls: list[str] = [] - - def handler(request: httpx.Request) -> httpx.Response: - if request.url.path.endswith("/ListSkillsBySpaceId"): - return httpx.Response( - 200, - json={ - "Data": { - "SkillSpaceId": "ss-1", - "Skills": [ - { - "SkillId": "sk-demo", - "VersionId": "sv-demo-v1", - "Version": "v1", - "Name": "demo-skill", - "Status": "Active", - "ContentHash": f"sha256:{demo_digest}", - }, - { - "SkillId": "sk-unused", - "VersionId": "sv-unused-v1", - "Version": "v1", - "Name": "unused-skill", - "Status": "Active", - "ContentHash": f"sha256:{unused_digest}", - }, - ], - } - }, - ) - if request.url.path.endswith("/GetSkillDownloadUrl"): - skill_id = request.url.params.get("SkillId") - return httpx.Response(200, json={"Data": {"DownloadUrl": f"https://download.example/{skill_id}.zip"}}) - if str(request.url) == "https://download.example/sk-demo.zip": - download_urls.append(str(request.url)) - return httpx.Response(200, content=demo_archive) - if str(request.url) == "https://download.example/sk-unused.zip": - download_urls.append(str(request.url)) - return httpx.Response(200, content=unused_archive) - return httpx.Response(404) - - monkeypatch.setenv("KSADK_SKILL_SPACE_IDS", "ss-1") - monkeypatch.setenv("KSADK_SKILL_SERVICE_URL", "https://skill.example/api/v1") - monkeypatch.setenv("KSADK_SKILL_CACHE_DIR", str(tmp_path / "cache")) - - code = run_agent( - ["请使用 demo-skill 处理这个任务"], - service_transport=httpx.MockTransport(handler), - ) - - out = capsys.readouterr().out - assert code == 0 - assert "loaded_skills=demo-skill" in out - assert download_urls == ["https://download.example/sk-demo.zip"] - assert (tmp_path / "cache" / "sk-demo__sv-demo-v1" / "extracted" / "demo-skill" / "SKILL.md").exists() - assert not (tmp_path / "cache" / "sk-unused__sv-unused-v1").exists() - - -def test_runtime_agent_downloads_explicit_remote_skill_even_when_prompt_omits_name( - monkeypatch, - tmp_path: Path, - capsys, -): - demo_archive = _zip_bytes("demo-skill") - unused_archive = _zip_bytes("unused-skill") - demo_digest = hashlib.sha256(demo_archive).hexdigest() - unused_digest = hashlib.sha256(unused_archive).hexdigest() - download_urls: list[str] = [] - - def handler(request: httpx.Request) -> httpx.Response: - if request.url.path.endswith("/ListSkillsBySpaceId"): - return httpx.Response( - 200, - json={ - "Data": { - "SkillSpaceId": "ss-1", - "Skills": [ - { - "SkillId": "sk-demo", - "VersionId": "sv-demo-v1", - "Version": "v1", - "Name": "demo-skill", - "Status": "Active", - "ContentHash": f"sha256:{demo_digest}", - }, - { - "SkillId": "sk-unused", - "VersionId": "sv-unused-v1", - "Version": "v1", - "Name": "unused-skill", - "Status": "Active", - "ContentHash": f"sha256:{unused_digest}", - }, - ], - } - }, - ) - if request.url.path.endswith("/GetSkillDownloadUrl"): - skill_id = request.url.params.get("SkillId") - return httpx.Response(200, json={"Data": {"DownloadUrl": f"https://download.example/{skill_id}.zip"}}) - if str(request.url) == "https://download.example/sk-demo.zip": - download_urls.append(str(request.url)) - return httpx.Response(200, content=demo_archive) - if str(request.url) == "https://download.example/sk-unused.zip": - download_urls.append(str(request.url)) - return httpx.Response(200, content=unused_archive) - return httpx.Response(404) - - monkeypatch.setenv("KSADK_SKILL_SPACE_IDS", "ss-1") - monkeypatch.setenv("KSADK_SKILL_SERVICE_URL", "https://skill.example/api/v1") - monkeypatch.setenv("KSADK_SKILL_CACHE_DIR", str(tmp_path / "cache")) - monkeypatch.setenv("KSADK_SELECTED_SKILL_NAMES", "demo-skill") - - code = run_agent( - ["请处理这个任务"], - service_transport=httpx.MockTransport(handler), - ) - - out = capsys.readouterr().out - assert code == 0 - assert "loaded_skills=demo-skill" in out - assert download_urls == ["https://download.example/sk-demo.zip"] - assert (tmp_path / "cache" / "sk-demo__sv-demo-v1" / "extracted" / "demo-skill" / "SKILL.md").exists() - assert not (tmp_path / "cache" / "sk-unused__sv-unused-v1").exists() - - -def test_runtime_agent_loads_all_public_skills_without_allowlist(monkeypatch, tmp_path: Path, capsys): - pdf_archive = _zip_bytes("pdf") - weather_archive = _zip_bytes("weather") - pdf_digest = hashlib.sha256(pdf_archive).hexdigest() - weather_digest = hashlib.sha256(weather_archive).hexdigest() - download_urls: list[str] = [] - - def handler(request: httpx.Request) -> httpx.Response: - if request.url.path.endswith("/ListAvailablePremadeSkills"): - return httpx.Response( - 200, - json={ - "Data": { - "SkillSpaceId": "ss-public", - "Skills": [ - { - "SkillId": "premade-pdf", - "VersionId": "", - "Version": "", - "Name": "pdf", - "Status": "AVAILABLE", - "ContentHash": pdf_digest, - }, - { - "SkillId": "premade-weather", - "VersionId": "", - "Version": "", - "Name": "weather", - "Status": "AVAILABLE", - "ContentHash": weather_digest, - }, - ], - } - }, - ) - if request.url.path.endswith("/GetPremadeSkillDownloadUrl"): - skill_id = request.url.params.get("SkillId") - return httpx.Response(200, json={"Data": {"DownloadUrl": f"https://download.example/{skill_id}.zip"}}) - if str(request.url) == "https://download.example/premade-pdf.zip": - download_urls.append(str(request.url)) - return httpx.Response(200, content=pdf_archive) - if str(request.url) == "https://download.example/premade-weather.zip": - download_urls.append(str(request.url)) - return httpx.Response(200, content=weather_archive) - return httpx.Response(404) - - monkeypatch.setenv("KSADK_PUBLIC_SKILL_SPACE_IDS", "ss-public") - monkeypatch.setenv("KSADK_SKILL_SERVICE_URL", "https://skill.example/api/v1") - monkeypatch.setenv("KSADK_SKILL_CACHE_DIR", str(tmp_path / "cache")) - - code = run_agent(["请处理这个任务"], service_transport=httpx.MockTransport(handler)) - - out = capsys.readouterr().out - assert code == 0 - assert "loaded_skills=pdf,weather" in out - assert download_urls == [ - "https://download.example/premade-pdf.zip", - "https://download.example/premade-weather.zip", - ] - assert (tmp_path / "cache" / f"premade-pdf__{pdf_digest}" / "extracted" / "pdf" / "SKILL.md").exists() - assert ( - tmp_path - / "cache" - / f"premade-weather__{weather_digest}" - / "extracted" - / "weather" - / "SKILL.md" - ).exists() - - -def test_runtime_agent_filters_public_skills_with_allowlist(monkeypatch, tmp_path: Path, capsys): - pdf_archive = _zip_bytes("pdf") - weather_archive = _zip_bytes("weather") - pdf_digest = hashlib.sha256(pdf_archive).hexdigest() - weather_digest = hashlib.sha256(weather_archive).hexdigest() - download_urls: list[str] = [] - - def handler(request: httpx.Request) -> httpx.Response: - if request.url.path.endswith("/ListAvailablePremadeSkills"): - return httpx.Response( - 200, - json={ - "Data": { - "SkillSpaceId": "ss-public", - "Skills": [ - { - "SkillId": "premade-pdf", - "VersionId": "", - "Version": "", - "Name": "pdf", - "Status": "AVAILABLE", - "ContentHash": pdf_digest, - }, - { - "SkillId": "premade-weather", - "VersionId": "", - "Version": "", - "Name": "weather", - "Status": "AVAILABLE", - "ContentHash": weather_digest, - }, - ], - } - }, - ) - if request.url.path.endswith("/GetPremadeSkillDownloadUrl"): - skill_id = request.url.params.get("SkillId") - return httpx.Response(200, json={"Data": {"DownloadUrl": f"https://download.example/{skill_id}.zip"}}) - if str(request.url) == "https://download.example/premade-pdf.zip": - download_urls.append(str(request.url)) - return httpx.Response(200, content=pdf_archive) - if str(request.url) == "https://download.example/premade-weather.zip": - download_urls.append(str(request.url)) - return httpx.Response(200, content=weather_archive) - return httpx.Response(404) - - monkeypatch.setenv("KSADK_PUBLIC_SKILL_SPACE_IDS", "ss-public") - monkeypatch.setenv("KSADK_PUBLIC_SKILL_ALLOWLIST", "weather") - monkeypatch.setenv("KSADK_SKILL_SERVICE_URL", "https://skill.example/api/v1") - monkeypatch.setenv("KSADK_SKILL_CACHE_DIR", str(tmp_path / "cache")) - - code = run_agent(["请处理这个任务"], service_transport=httpx.MockTransport(handler)) - - out = capsys.readouterr().out - assert code == 0 - assert "loaded_skills=weather" in out - assert download_urls == ["https://download.example/premade-weather.zip"] - assert not (tmp_path / "cache" / f"premade-pdf__{pdf_digest}").exists() - assert ( - tmp_path - / "cache" - / f"premade-weather__{weather_digest}" - / "extracted" - / "weather" - / "SKILL.md" - ).exists() - - -def test_runtime_agent_prefers_user_skill_over_same_name_public_skill( - monkeypatch, - tmp_path: Path, - capsys, -): - user_archive = _zip_bytes("demo-skill") - public_archive = _zip_bytes("demo-skill") + b"public" - user_digest = hashlib.sha256(user_archive).hexdigest() - public_digest = hashlib.sha256(public_archive).hexdigest() - download_urls: list[str] = [] - - def handler(request: httpx.Request) -> httpx.Response: - if request.url.path.endswith("/ListSkillsBySpaceId"): - space_id = request.url.params.get("SpaceId") - if space_id == "ss-user": - return httpx.Response( - 200, - json={ - "Data": { - "SkillSpaceId": "ss-user", - "Skills": [ - { - "SkillId": "sk-user-demo", - "VersionId": "sv-user-v1", - "Version": "v1", - "Name": "demo-skill", - "Status": "Active", - "ContentHash": f"sha256:{user_digest}", - } - ], - } - }, - ) - return httpx.Response(404) - if request.url.path.endswith("/ListAvailablePremadeSkills"): - return httpx.Response( - 200, - json={ - "Data": { - "SkillSpaceId": "ss-public", - "Skills": [ - { - "SkillId": "premade-demo", - "VersionId": "", - "Version": "", - "Name": "demo-skill", - "Status": "AVAILABLE", - "ContentHash": f"sha256:{public_digest}", - } - ], - } - }, - ) - if request.url.path.endswith("/GetSkillDownloadUrl"): - return httpx.Response(200, json={"Data": {"DownloadUrl": "https://download.example/user.zip"}}) - if request.url.path.endswith("/GetPremadeSkillDownloadUrl"): - return httpx.Response(200, json={"Data": {"DownloadUrl": "https://download.example/public.zip"}}) - if str(request.url) == "https://download.example/user.zip": - download_urls.append(str(request.url)) - return httpx.Response(200, content=user_archive) - if str(request.url) == "https://download.example/public.zip": - download_urls.append(str(request.url)) - return httpx.Response(200, content=public_archive) - return httpx.Response(404) - - monkeypatch.setenv("KSADK_SKILL_SPACE_IDS", "ss-user") - monkeypatch.setenv("KSADK_PUBLIC_SKILL_SPACE_IDS", "ss-public") - monkeypatch.setenv("KSADK_SKILL_SERVICE_URL", "https://skill.example/api/v1") - monkeypatch.setenv("KSADK_SKILL_CACHE_DIR", str(tmp_path / "cache")) - - code = run_agent(["请使用 demo-skill 处理任务"], service_transport=httpx.MockTransport(handler)) - - out = capsys.readouterr().out - assert code == 0 - assert "loaded_skills=demo-skill" in out - assert download_urls == ["https://download.example/user.zip"] - assert (tmp_path / "cache" / "sk-user-demo__sv-user-v1" / "extracted" / "demo-skill" / "SKILL.md").exists() - assert not (tmp_path / "cache" / f"premade-demo__{public_digest}").exists() - - -def test_runtime_agent_can_load_legacy_remote_skill_when_hash_mismatch_is_allowed( - monkeypatch, - tmp_path: Path, - capsys, -): - archive = _zip_bytes("legacy-skill") - download_urls: list[str] = [] - - def handler(request: httpx.Request) -> httpx.Response: - if request.url.path.endswith("/ListSkillsBySpaceId"): - return httpx.Response( - 200, - json={ - "Data": { - "SkillSpaceId": "ss-1", - "Skills": [ - { - "SkillId": "sk-legacy", - "VersionId": "sv-legacy-v1", - "Version": "v1", - "Name": "legacy-skill", - "Status": "Active", - "ContentHash": f"sha256:{'0' * 64}", - } - ], - } - }, - ) - if request.url.path.endswith("/GetSkillDownloadUrl"): - return httpx.Response(200, json={"Data": {"DownloadUrl": "https://download.example/legacy.zip"}}) - if str(request.url) == "https://download.example/legacy.zip": - download_urls.append(str(request.url)) - return httpx.Response(200, content=archive) - return httpx.Response(404) - - monkeypatch.setenv("KSADK_SKILL_SPACE_IDS", "ss-1") - monkeypatch.setenv("KSADK_SKILL_SERVICE_URL", "https://skill.example/api/v1") - monkeypatch.setenv("KSADK_SKILL_CACHE_DIR", str(tmp_path / "cache")) - monkeypatch.setenv("KSADK_SELECTED_SKILL_NAMES", "legacy-skill") - monkeypatch.setenv("KSADK_SKILL_ALLOW_HASH_MISMATCH", "true") - - code = run_agent( - ["请处理这个任务"], - service_transport=httpx.MockTransport(handler), - ) - - out = capsys.readouterr().out - assert code == 0 - assert "loaded_skills=legacy-skill" in out - assert "skill_warnings=" in out - assert "ContentHash mismatch for legacy-skill" in out - assert download_urls == ["https://download.example/legacy.zip"] - assert ( - tmp_path - / "cache" - / "unverified-sk-legacy__sv-legacy-v1" - / "extracted" - / "legacy-skill" - / "SKILL.md" - ).exists() - - -def test_runtime_agent_without_service_still_reports_workflow(monkeypatch, capsys): - monkeypatch.delenv("KSADK_SKILL_SERVICE_URL", raising=False) - monkeypatch.delenv("KSADK_SKILL_SPACE_IDS", raising=False) - monkeypatch.delenv("SKILL_SPACE_ID", raising=False) - monkeypatch.delenv("KSADK_PUBLIC_SKILL_SPACE_IDS", raising=False) - - code = run_agent(["noop"]) - - out = capsys.readouterr().out - assert code == 0 - assert "workflow=noop" in out - assert "loaded_skills=" in out - - -def test_runtime_agent_reads_prompt_file(tmp_path: Path, monkeypatch, capsys): - prompt_file = tmp_path / "prompt.txt" - prompt_file.write_text("from file", encoding="utf-8") - monkeypatch.delenv("KSADK_SKILL_SERVICE_URL", raising=False) - - code = run_agent(["--prompt-file", str(prompt_file)]) - - out = capsys.readouterr().out - assert code == 0 - assert "workflow=from file" in out - - -def test_runtime_agent_accepts_request_file_json(tmp_path: Path, monkeypatch, capsys): - request_file = tmp_path / "request.json" - request_file.write_text( - json.dumps( - { - "workflow_prompt": "from request", - "skill_names": ["generic-workflow"], - } - ), - encoding="utf-8", - ) - monkeypatch.delenv("KSADK_SKILL_SERVICE_URL", raising=False) - - code = run_agent(["--request-file", str(request_file)]) - - out = capsys.readouterr().out - assert code == 0 - assert "workflow=from request" in out - assert 'selected_skills=["generic-workflow"]' in out - - -def test_runtime_agent_rejects_prompt_file_and_request_file_together(tmp_path: Path, monkeypatch, capsys): - prompt_file = tmp_path / "prompt.txt" - prompt_file.write_text("from file", encoding="utf-8") - request_file = tmp_path / "request.json" - request_file.write_text(json.dumps({"workflow_prompt": "from request"}), encoding="utf-8") - monkeypatch.delenv("KSADK_SKILL_SERVICE_URL", raising=False) - - code = run_agent(["--prompt-file", str(prompt_file), "--request-file", str(request_file)]) - - out = capsys.readouterr().out - assert code == 1 - assert "workflow_result=" in out - payload = json.loads(out.split("workflow_result=", 1)[1]) - assert payload["status"] == "failed" - assert "cannot be used together" in payload["error"] - - -def test_runtime_agent_executes_generic_run_workflow_script(monkeypatch, tmp_path: Path): - skill_root = tmp_path / "skills" / "generic-workflow" - scripts_dir = skill_root / "scripts" - scripts_dir.mkdir(parents=True) - (skill_root / "SKILL.md").write_text( - "---\nname: generic-workflow\ndescription: Generic workflow\n---\n# Demo\n", - encoding="utf-8", - ) - (scripts_dir / "run-workflow.sh").write_text( - "#!/bin/bash\n" - "mkdir -p \"$KSADK_SKILL_WORKDIR/out\"\n" - "printf '%s' \"$KSADK_WORKFLOW_PROMPT\" > \"$KSADK_SKILL_WORKDIR/out/prompt.txt\"\n" - "echo \"artifact=$KSADK_SKILL_WORKDIR/out/prompt.txt\"\n", - encoding="utf-8", - ) - workdir = tmp_path / "work" - monkeypatch.setenv("KSADK_SKILL_WORKDIR", str(workdir)) - - result = runtime_agent._execute_workflow( - "run generic workflow", - [load_local_skill(skill_root)], - selected_skill_names=["generic-workflow"], - ) - - artifact = str(workdir / "out" / "prompt.txt") - assert result.status == "ok" - assert result.executed_skill == "generic-workflow" - assert result.selected_skills == ["generic-workflow"] - assert result.loaded_skills == ["generic-workflow"] - assert result.output_files == [artifact] - assert result.artifacts == [artifact] - assert result.commands[0]["exit_code"] == 0 - assert (workdir / "out" / "prompt.txt").read_text(encoding="utf-8") == "run generic workflow" - - -def test_runtime_agent_collects_generic_workflow_output_dir(monkeypatch, tmp_path: Path): - skill_root = tmp_path / "skills" / "output-dir-workflow" - scripts_dir = skill_root / "scripts" - scripts_dir.mkdir(parents=True) - (skill_root / "SKILL.md").write_text( - "---\nname: output-dir-workflow\ndescription: Output dir workflow\n---\n# Demo\n", - encoding="utf-8", - ) - (scripts_dir / "run-workflow.sh").write_text( - "#!/bin/bash\n" - "mkdir -p \"$KSADK_SKILL_OUTPUT_DIR\"\n" - "printf 'generated' > \"$KSADK_SKILL_OUTPUT_DIR/result.txt\"\n", - encoding="utf-8", - ) - workdir = tmp_path / "work" - monkeypatch.setenv("KSADK_SKILL_WORKDIR", str(workdir)) - - result = runtime_agent._execute_workflow( - "run output-dir workflow", - [load_local_skill(skill_root)], - selected_skill_names=["output-dir-workflow"], - ) - - artifact = str(workdir / "artifacts" / "result.txt") - assert result.status == "ok" - assert result.output_files == [artifact] - assert result.artifacts == [artifact] - - -def test_runtime_agent_warns_when_loaded_skill_has_no_workflow_entrypoint(tmp_path: Path): - skill_root = tmp_path / "skills" / "instruction-only" - skill_root.mkdir(parents=True) - (skill_root / "SKILL.md").write_text( - "---\nname: instruction-only\ndescription: Instruction only\n---\n# Demo\n", - encoding="utf-8", - ) - - result = runtime_agent._execute_workflow( - "run instruction-only", - [load_local_skill(skill_root)], - selected_skill_names=["instruction-only"], - ) - - assert result.status == "skipped" - assert result.selected_skills == ["instruction-only"] - assert result.loaded_skills == ["instruction-only"] - assert result.warnings == ["No loaded skill exposes an executable workflow entrypoint."] - - -def test_runtime_agent_executes_web_artifacts_builder_without_real_npm(monkeypatch, tmp_path: Path): - skill_root = tmp_path / "skills" / "web-artifacts-builder" - scripts_dir = skill_root / "scripts" - scripts_dir.mkdir(parents=True) - (skill_root / "SKILL.md").write_text( - "---\nname: web-artifacts-builder\ndescription: Build artifacts\n---\n# Demo\n", - encoding="utf-8", - ) - (scripts_dir / "init-artifact.sh").write_text("#!/bin/bash\n", encoding="utf-8") - (scripts_dir / "bundle-artifact.sh").write_text("#!/bin/bash\n", encoding="utf-8") - workdir = tmp_path / "work" - monkeypatch.setenv("KSADK_SKILL_WORKDIR", str(workdir)) - monkeypatch.setenv("KSADK_SKILL_ARTIFACT_PROJECT", "demo-artifact") - - def fake_run(args, **kwargs): - if str(args[-1]).endswith("demo-artifact"): - (workdir / "demo-artifact").mkdir(parents=True) - elif str(args[1]).endswith("bundle-artifact.sh"): - (workdir / "demo-artifact" / "bundle.html").write_text("", encoding="utf-8") - return subprocess.CompletedProcess(args=args, returncode=0, stdout="ok\n", stderr="") - - monkeypatch.setattr(runtime_agent.subprocess, "run", fake_run) - - result = runtime_agent._execute_workflow( - "使用 web-artifacts-builder 初始化并打包一个最小 artifact", - [load_local_skill(skill_root)], - ) - - assert result.status == "ok" - assert result.executed_skill == "web-artifacts-builder" - assert result.output_files == [str(workdir / "demo-artifact" / "bundle.html")] - assert [command["exit_code"] for command in result.commands] == [0, 0] diff --git a/tests/skills/test_service_client_http.py b/tests/skills/test_service_client_http.py deleted file mode 100644 index e4c0f21b..00000000 --- a/tests/skills/test_service_client_http.py +++ /dev/null @@ -1,396 +0,0 @@ -from __future__ import annotations - -import httpx -import pytest - -from ksadk.skills.service_client import SkillServiceClient - - -def test_service_client_lists_skills_and_downloads_archive_with_mock_transport(): - requests = [] - - def handler(request: httpx.Request) -> httpx.Response: - requests.append((request.method, str(request.url), request.headers.get("Authorization"))) - if request.url.path.endswith("/ListSkillsBySpaceId"): - return httpx.Response( - 200, - json={ - "Code": 200, - "RequestId": "req-list", - "Data": { - "Skills": [ - {"SkillId": "sk-1", "VersionId": "sv-1", "Version": "v1", "Name": "demo", "Status": "Active"} - ], - }, - }, - ) - if request.url.path.endswith("/GetSkillDownloadUrl"): - return httpx.Response(200, json={"Code": 200, "Data": {"DownloadUrl": "https://download.example/skill.zip"}}) - if str(request.url) == "https://download.example/skill.zip": - return httpx.Response(200, content=b"zip-bytes") - return httpx.Response(404) - - client = SkillServiceClient( - base_url="https://skill.example/api/v1", - token="secret-token", - transport=httpx.MockTransport(handler), - ) - - listing = client.list_skills_by_space_id("ss-1") - archive = client.download_skill_archive(listing.skills[0]) - - assert listing.request_id == "req-list" - assert listing.skills[0].skill_id == "sk-1" - assert archive == b"zip-bytes" - assert requests[0] == ( - "GET", - "https://skill.example/api/v1/ListSkillsBySpaceId?SpaceId=ss-1", - "Bearer secret-token", - ) - assert requests[1] == ( - "GET", - "https://skill.example/api/v1/GetSkillDownloadUrl?SkillId=sk-1&VersionId=sv-1", - "Bearer secret-token", - ) - assert requests[-1] == ("GET", "https://download.example/skill.zip", None) - - -def test_service_client_downloads_no_version_skill_from_premade_endpoint(): - requests = [] - - def handler(request: httpx.Request) -> httpx.Response: - requests.append((request.method, str(request.url))) - if request.url.path.endswith("/ListSkillsBySpaceId"): - return httpx.Response( - 200, - json={ - "Code": 200, - "RequestId": "req-list", - "Data": { - "Skills": [ - { - "SkillId": "premade-pdf", - "VersionId": "", - "Version": "", - "Name": "pdf", - "Status": "AVAILABLE", - } - ], - }, - }, - ) - if request.url.path.endswith("/GetPremadeSkillDownloadUrl"): - return httpx.Response( - 200, - json={"Code": 200, "Data": {"DownloadUrl": "https://download.example/pdf.zip"}}, - ) - if str(request.url) == "https://download.example/pdf.zip": - return httpx.Response(200, content=b"premade-zip-bytes") - return httpx.Response(404) - - client = SkillServiceClient( - base_url="https://skill.example/api/v1", - transport=httpx.MockTransport(handler), - ) - - listing = client.list_skills_by_space_id("ss-public") - archive = client.download_skill_archive(listing.skills[0]) - - assert archive == b"premade-zip-bytes" - assert requests[1] == ( - "GET", - "https://skill.example/api/v1/GetPremadeSkillDownloadUrl?SkillId=premade-pdf&VersionId=", - ) - - -def test_service_client_lists_available_premade_skills_from_dedicated_endpoint(): - requests = [] - - def handler(request: httpx.Request) -> httpx.Response: - requests.append((request.method, str(request.url))) - if request.url.path.endswith("/ListAvailablePremadeSkills"): - return httpx.Response( - 200, - json={ - "Code": 200, - "RequestId": "req-premade-list", - "Data": { - "Skills": [ - { - "SkillId": "premade-pdf", - "VersionId": "", - "Version": "", - "Name": "pdf", - "Status": "AVAILABLE", - "ContentHash": "abc123", - }, - { - "SkillId": "premade-xlsx", - "VersionId": "", - "Version": "", - "Name": "xlsx", - "Status": "AVAILABLE", - "ContentHash": "def456", - }, - ], - }, - }, - ) - return httpx.Response(404) - - client = SkillServiceClient( - base_url="https://skill.example/api/v1", - transport=httpx.MockTransport(handler), - ) - - listing = client.list_available_premade_skills() - - assert listing.request_id == "req-premade-list" - assert listing.space_id == "public" - assert [skill.name for skill in listing.active_skills()] == ["pdf", "xlsx"] - assert [skill.version_id for skill in listing.active_skills()] == ["", ""] - assert requests == [ - ("GET", "https://skill.example/api/v1/ListAvailablePremadeSkills"), - ] - - -def test_service_client_sends_account_header_for_direct_rest_service(): - seen_headers = {} - - def handler(request: httpx.Request) -> httpx.Response: - seen_headers.update(request.headers) - return httpx.Response(200, json={"Code": 200, "Data": {"Skills": []}}) - - client = SkillServiceClient( - base_url="https://skill.example/api/v1", - account_id="2000003485", - transport=httpx.MockTransport(handler), - ) - - client.list_skills_by_space_id("ss-1") - - assert seen_headers["x-ksc-account-id"] == "2000003485" - - -def test_service_client_supports_custom_action_paths(): - client = SkillServiceClient(base_url="https://skill.example/root/") - - assert client.action_url("ListSkillsBySpaceId") == "https://skill.example/root/ListSkillsBySpaceId" - - -def test_service_client_normalizes_docs_and_openapi_urls(): - docs_client = SkillServiceClient(base_url="https://skill.example/agentengine/skill/docs#/SkillSpace") - openapi_client = SkillServiceClient(base_url="https://skill.example/agentengine/skill/api/v1/openapi.json") - - assert ( - docs_client.action_url("ListSkillsBySpaceId") - == "https://skill.example/agentengine/skill/api/v1/ListSkillsBySpaceId" - ) - assert ( - openapi_client.action_url("ListSkillsBySpaceId") - == "https://skill.example/agentengine/skill/api/v1/ListSkillsBySpaceId" - ) - - -def test_service_client_lists_skill_spaces_with_real_query_contract(): - def handler(request: httpx.Request) -> httpx.Response: - assert request.method == "GET" - assert str(request.url) == "https://skill.example/api/v1/ListSkillSpaces?PageNumber=1&PageSize=20" - return httpx.Response( - 200, - json={ - "Code": 200, - "Message": "ok", - "RequestId": "req-space", - "Data": {"Items": [{"Id": "ss-1", "Name": "demo"}], "TotalCount": 1}, - }, - ) - - client = SkillServiceClient( - base_url="https://skill.example/api/v1", - transport=httpx.MockTransport(handler), - ) - - response = client.list_skill_spaces(page_number=1, page_size=20) - - assert response["Data"]["Items"][0]["Id"] == "ss-1" - - -def test_service_client_uses_registered_kop_action_for_aicp_skill_space_listing(): - requests = [] - - def handler(request: httpx.Request) -> httpx.Response: - requests.append((request.method, str(request.url), dict(request.headers))) - if request.url.params.get("Action") == "ListSkillsBySpaceId": - return httpx.Response( - 200, - json={ - "Code": 200, - "RequestId": "req-kop-list", - "Data": { - "Skills": [ - { - "SkillId": "sk-1", - "VersionId": "sv-1", - "Version": "v1", - "Name": "demo", - "Status": "AVAILABLE", - } - ], - }, - }, - ) - return httpx.Response(404) - - client = SkillServiceClient( - base_url="http://aicp.inner.api.ksyun.com", - account_id="2000003485", - transport=httpx.MockTransport(handler), - ) - - listing = client.list_skills_by_space_id("ss-1") - - assert listing.space_id == "ss-1" - assert listing.active_skills()[0].skill_id == "sk-1" - method, url, headers = requests[0] - assert method == "GET" - assert url == ( - "http://aicp.inner.api.ksyun.com/" - "?Action=ListSkillsBySpaceId&Version=2024-06-12" - "&SpaceId=ss-1&PageNumber=1&PageSize=100" - ) - assert headers["x-action"] == "ListSkillsBySpaceId" - assert headers["x-version"] == "2024-06-12" - assert headers["x-ksc-account-id"] == "2000003485" - - -def test_service_client_uses_kop_mode_for_internal_aicp_endpoint(): - requests = [] - - def handler(request: httpx.Request) -> httpx.Response: - requests.append((request.method, str(request.url), dict(request.headers))) - return httpx.Response( - 200, - json={ - "Code": 200, - "RequestId": "req-internal-kop", - "Data": {"Skills": []}, - }, - ) - - client = SkillServiceClient( - base_url="http://aicp.internal.api.ksyun.com", - account_id="2000003485", - transport=httpx.MockTransport(handler), - ) - - listing = client.list_skills_by_space_id("ss-1") - - assert listing.space_id == "ss-1" - method, url, headers = requests[0] - assert method == "GET" - assert url == ( - "http://aicp.internal.api.ksyun.com/" - "?Action=ListSkillsBySpaceId&Version=2024-06-12" - "&SpaceId=ss-1&PageNumber=1&PageSize=100" - ) - assert headers["x-action"] == "ListSkillsBySpaceId" - assert headers["x-version"] == "2024-06-12" - assert headers["x-ksc-account-id"] == "2000003485" - - -def test_service_client_routes_pre_online_kop_requests_with_custom_source(monkeypatch): - monkeypatch.setenv("KSADK_SKILL_SERVICE_REGION", "pre-online") - monkeypatch.setenv("AGENTENGINE_PRE_CONTROL_REGION", "cn-beijing-6") - monkeypatch.setenv("AGENTENGINE_PRE_CUSTOM_SOURCE", "pre") - requests = [] - - def handler(request: httpx.Request) -> httpx.Response: - requests.append((request.method, str(request.url), dict(request.headers))) - return httpx.Response( - 200, - json={ - "Code": 200, - "RequestId": "req-pre-kop", - "Data": {"Skills": []}, - }, - ) - - client = SkillServiceClient( - base_url="http://aicp.inner.api.ksyun.com", - account_id="73398439", - transport=httpx.MockTransport(handler), - ) - - listing = client.list_skills_by_space_id("ss-pre") - - assert listing.space_id == "ss-pre" - method, url, headers = requests[0] - assert method == "GET" - assert url == ( - "http://aicp.inner.api.ksyun.com/" - "?Action=ListSkillsBySpaceId&Version=2024-06-12" - "&SpaceId=ss-pre&PageNumber=1&PageSize=100" - ) - assert headers["x-ksc-region"] == "cn-beijing-6" - assert headers["x-ksc-custom-source"] == "pre" - assert headers["x-ksc-account-id"] == "73398439" - - -def test_service_client_uses_registered_kop_action_for_available_premade_skills(): - requests = [] - - def handler(request: httpx.Request) -> httpx.Response: - requests.append((request.method, str(request.url), dict(request.headers))) - if request.url.params.get("Action") == "ListAvailablePremadeSkills": - return httpx.Response( - 200, - json={ - "Code": 200, - "RequestId": "req-kop-premade", - "Data": { - "Skills": [ - { - "SkillId": "premade-pdf", - "VersionId": "", - "Version": "", - "Name": "pdf", - "Status": "AVAILABLE", - } - ], - }, - }, - ) - return httpx.Response(404) - - client = SkillServiceClient( - base_url="http://aicp.inner.api.ksyun.com", - account_id="2000003485", - transport=httpx.MockTransport(handler), - ) - - listing = client.list_available_premade_skills() - - assert listing.space_id == "public" - assert listing.active_skills()[0].skill_id == "premade-pdf" - method, url, headers = requests[0] - assert method == "GET" - assert url == ( - "http://aicp.inner.api.ksyun.com/" - "?Action=ListAvailablePremadeSkills&Version=2024-06-12" - ) - assert headers["x-action"] == "ListAvailablePremadeSkills" - assert headers["x-version"] == "2024-06-12" - assert headers["x-ksc-account-id"] == "2000003485" - - -def test_service_client_kop_requires_credentials_without_mock_transport(monkeypatch): - monkeypatch.delenv("KSADK_SKILL_SERVICE_ACCESS_KEY", raising=False) - monkeypatch.delenv("KSADK_SKILL_SERVICE_SECRET_KEY", raising=False) - monkeypatch.delenv("KSYUN_ACCESS_KEY", raising=False) - monkeypatch.delenv("KSYUN_SECRET_KEY", raising=False) - - client = SkillServiceClient(base_url="http://aicp.inner.api.ksyun.com") - - with pytest.raises(ValueError, match="requires signing credentials"): - client.list_skill_spaces() diff --git a/tests/skills/test_skill_service_client.py b/tests/skills/test_skill_service_client.py deleted file mode 100644 index 14738e82..00000000 --- a/tests/skills/test_skill_service_client.py +++ /dev/null @@ -1,90 +0,0 @@ -from __future__ import annotations - -from ksadk.skills.models import SkillListResponse - - -def test_parse_list_skills_by_space_id_preserves_progressive_disclosure_fields(): - response = SkillListResponse.from_payload( - { - "Code": 0, - "Message": "OK", - "RequestId": "req-1", - "Data": { - "SkillSpaceId": "ss-abc", - "SkillSpaceName": "office", - "Skills": [ - { - "SkillId": "sk-web", - "VersionId": "sv-web-v1", - "Version": "v1", - "Name": "web-artifacts-builder", - "Description": "Build web artifacts", - "Status": "Active", - "ContentHash": "sha256:b95f0735357fcf879bd53ed85cb242679ec74438e3bc8e85b1f27193169b6ecf", - "ArchiveUri": "ks3://agentengine-skills/skills/sk-web/v1/web-artifacts-builder.zip", - } - ], - }, - } - ) - - assert response.request_id == "req-1" - assert response.space_id == "ss-abc" - assert response.skills[0].skill_id == "sk-web" - assert response.skills[0].version_id == "sv-web-v1" - assert response.skills[0].version == "v1" - assert response.skills[0].content_hash.algorithm == "sha256" - assert response.skills[0].archive_uri == "ks3://agentengine-skills/skills/sk-web/v1/web-artifacts-builder.zip" - - -def test_parse_list_skills_preserves_discovery_metadata(): - response = SkillListResponse.from_payload( - { - "Data": { - "SkillSpaceId": "ss-abc", - "Skills": [ - { - "SkillId": "sk-report", - "VersionId": "sv-report-v1", - "Name": "report-writer", - "Description": "Write research reports", - "Aliases": ["研究报告", "deep report"], - "Tags": ["writing", "research"], - "Examples": ["生成一份行业研究报告"], - "InputSchema": {"type": "object"}, - "RuntimeRequirements": {"sandbox": True}, - } - ], - } - } - ) - - skill = response.skills[0] - assert skill.aliases == ("研究报告", "deep report") - assert skill.tags == ("writing", "research") - assert skill.examples == ("生成一份行业研究报告",) - assert skill.input_schema == {"type": "object"} - assert skill.runtime_requirements == {"sandbox": True} - - -def test_parse_list_skills_filters_inactive_by_default(): - response = SkillListResponse.from_payload( - { - "Data": { - "SkillSpaceId": "ss-abc", - "Skills": [ - {"SkillId": "sk-active", "VersionId": "v1", "Version": "1", "Name": "active", "Status": "Active"}, - { - "SkillId": "sk-available", - "VersionId": "v1", - "Version": "1", - "Name": "available", - "Status": "AVAILABLE", - }, - {"SkillId": "sk-disabled", "VersionId": "v2", "Version": "2", "Name": "disabled", "Status": "Disabled"}, - ], - } - } - ) - - assert [skill.skill_id for skill in response.active_skills()] == ["sk-active", "sk-available"] diff --git a/tests/skills/test_web_artifacts_fixture.py b/tests/skills/test_web_artifacts_fixture.py deleted file mode 100644 index 401dd00f..00000000 --- a/tests/skills/test_web_artifacts_fixture.py +++ /dev/null @@ -1,31 +0,0 @@ -from __future__ import annotations - -import hashlib -import os -import zipfile -from pathlib import Path - -import pytest - - -EXPECTED_SHA256 = "b95f0735357fcf879bd53ed85cb242679ec74438e3bc8e85b1f27193169b6ecf" - - -def test_web_artifacts_builder_zip_matches_skill_service_fixture_contract(): - fixture_env = os.environ.get("KSADK_WEB_ARTIFACTS_FIXTURE", "").strip() - if not fixture_env: - pytest.skip("KSADK_WEB_ARTIFACTS_FIXTURE not set; local/preprod fixture zip is not present in CI or clean environments") - fixture = Path(fixture_env) - if not fixture.exists(): - pytest.skip(f"fixture zip not found at {fixture}") - 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 deleted file mode 100644 index 68bb2e49..00000000 --- a/tests/snapshots/error_hint_snapshots.txt +++ /dev/null @@ -1,44 +0,0 @@ -=== 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/help_snapshots.txt b/tests/snapshots/help_snapshots.txt deleted file mode 100644 index 20092891..00000000 --- a/tests/snapshots/help_snapshots.txt +++ /dev/null @@ -1,350 +0,0 @@ -=== 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/snapshots/resource_output_snapshots.txt b/tests/snapshots/resource_output_snapshots.txt deleted file mode 100644 index e94eeb52..00000000 --- a/tests/snapshots/resource_output_snapshots.txt +++ /dev/null @@ -1,73 +0,0 @@ -=== 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 deleted file mode 100644 index 61dae382..00000000 --- a/tests/snapshots/workflow_help_snapshots.txt +++ /dev/null @@ -1,146 +0,0 @@ -=== 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(可选;未设置时从 AK/SK 反查) - --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 挂载目录(hermes/openclaw 默认按框架推导;adk/langgraph - 等其他框架默认不挂盘,需显式指定) - --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(可选;未设置时从 AK/SK 反查) - --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 挂载目录(hermes/openclaw 默认按框架推导;adk/langgraph - 等其他框架默认不挂盘,需显式指定) - --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 deleted file mode 100644 index cc8306d1..00000000 --- a/tests/test_a2a_cli.py +++ /dev/null @@ -1,125 +0,0 @@ -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 deleted file mode 100644 index 1aa5a54d..00000000 --- a/tests/test_a2a_integration.py +++ /dev/null @@ -1,242 +0,0 @@ -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 deleted file mode 100644 index 1097cebe..00000000 --- a/tests/test_agent.py +++ /dev/null @@ -1,68 +0,0 @@ -"""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 deleted file mode 100644 index 10f6c54c..00000000 --- a/tests/test_agent_access.py +++ /dev/null @@ -1,104 +0,0 @@ -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 deleted file mode 100644 index 6184bc4e..00000000 --- a/tests/test_agentengine_toolsets.py +++ /dev/null @@ -1,431 +0,0 @@ -from ksadk.toolsets import agentengine_tool_dispatcher -from ksadk.toolsets import get_agentengine_tools -from ksadk.toolsets import ( - clear_external_tools, - read_workspace_file, - edit_workspace_file, - list_workspace_files, - register_external_tools, - search_workspace_files, - tool_dispatcher, - tool_search, - multi_edit_workspace_file, -) -from ksadk.toolsets.workspace_state import clear_read_state -from ksadk.runtime_context import PlatformInvocationContext, platform_invocation_scope, tool_execution_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(): - result = agentengine_tool_dispatcher(action="list", include="file") - - assert result["ok"] is False - assert result["error_type"] == "unknown_tool" - assert result["tool_name"] == "file" - - -def test_tool_dispatcher_is_canonical_and_agentengine_dispatcher_is_alias(): - canonical = tool_dispatcher(action="describe", tool_name="component_status") - compat = agentengine_tool_dispatcher(action="describe", tool_name="component_status") - - assert canonical["ok"] is True - assert canonical == compat - names = {tool.name for tool in get_agentengine_tools(include=["tool_dispatcher"])} - assert names == {"tool_dispatcher"} - - -def test_tool_search_discovers_workspace_edit_tools(): - result = tool_search("edit a file after reading it", profile="coding", max_results=5) - - assert result["ok"] is True - names = [item["name"] for item in result["results"]] - assert "read_workspace_file" in names - assert "edit_workspace_file" in names - assert result["deferred_tool_names"][:2] - assert all("score" in item and item["score"] > 0 for item in result["results"]) - - -def test_tool_search_discovers_registered_framework_mcp_tools(): - class WeatherForecastTool: - name = "weather_forecast" - description = "Get weather forecast for a city from the weather MCP server." - args = {"city": {"type": "string", "description": "City name"}} - - clear_external_tools() - try: - registered = register_external_tools( - [WeatherForecastTool()], - group="mcp:weather", - boundary="framework_managed_mcp_tool", - ) - - result = tool_search("weather forecast", profile="coding", max_results=5) - finally: - clear_external_tools() - - assert registered == ["weather_forecast"] - forecast = next(item for item in result["results"] if item["name"] == "weather_forecast") - assert forecast["group"] == "mcp:weather" - assert forecast["boundary"] == "framework_managed_mcp_tool" - assert forecast["execution"] == "external" - assert forecast["args"]["city"]["type"] == "string" - assert "weather_forecast" in result["deferred_tool_names"] - - -def test_coding_profile_exposes_focused_direct_tools_and_deferred_mode_exposes_search_dispatcher(): - coding_names = {tool.name for tool in get_agentengine_tools(profile="coding", mode="direct")} - deferred_names = {tool.name for tool in get_agentengine_tools(profile="coding", mode="deferred")} - - assert {"read_workspace_file", "edit_workspace_file", "multi_edit_workspace_file", "tool_search"} <= coding_names - assert deferred_names == {"tool_search", "tool_dispatcher"} - - -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"] - - -def test_workspace_read_returns_line_metadata_and_records_state(monkeypatch, tmp_path): - clear_read_state() - monkeypatch.setattr("ksadk.toolsets.workspace.resolve_local_session_dir", lambda: tmp_path) - workspace = tmp_path / "workspace" - workspace.mkdir() - (workspace / "demo.py").write_text("one\ntwo\nthree\n", encoding="utf-8") - - result = read_workspace_file("demo.py", start_line=2, end_line=3) - - assert result["ok"] is True - assert result["content"] == "2 | two\n3 | three" - assert result["start_line"] == 2 - assert result["end_line"] == 3 - assert result["total_lines"] == 3 - assert result["line_count"] == 2 - assert result["read_range"] == {"start": 2, "end": 3} - assert result["suggested_action"] == "" - assert result["partial"] is True - assert result["mtime_ns"] > 0 - - -def test_workspace_edit_requires_prior_read(monkeypatch, tmp_path): - clear_read_state() - monkeypatch.setattr("ksadk.toolsets.workspace.resolve_local_session_dir", lambda: tmp_path) - workspace = tmp_path / "workspace" - workspace.mkdir() - (workspace / "demo.py").write_text("print('hello')\n", encoding="utf-8") - - result = edit_workspace_file("demo.py", "hello", "world") - - assert result["ok"] is False - assert result["error_type"] == "file_not_read" - assert "read_workspace_file" in result["suggested_action"] - - -def test_workspace_read_state_is_isolated_by_tool_session(monkeypatch, tmp_path): - clear_read_state() - monkeypatch.setattr("ksadk.toolsets.workspace.resolve_local_session_dir", lambda: tmp_path) - workspace = tmp_path / "workspace" - workspace.mkdir() - (workspace / "demo.py").write_text("print('hello')\n", encoding="utf-8") - - with tool_execution_scope(session_id="sess-a"): - assert read_workspace_file("demo.py")["ok"] is True - - with tool_execution_scope(session_id="sess-b"): - unread = edit_workspace_file("demo.py", "hello", "world") - - with tool_execution_scope(session_id="sess-a"): - edited = edit_workspace_file("demo.py", "hello", "world") - - assert unread["ok"] is False - assert unread["error_type"] == "file_not_read" - assert edited["ok"] is True - assert (workspace / "demo.py").read_text(encoding="utf-8") == "print('world')\n" - - -def test_workspace_read_state_default_context_supports_direct_read_then_edit(monkeypatch, tmp_path): - clear_read_state() - monkeypatch.setattr("ksadk.toolsets.workspace.resolve_local_session_dir", lambda: tmp_path) - workspace = tmp_path / "workspace" - workspace.mkdir() - (workspace / "demo.py").write_text("print('hello')\n", encoding="utf-8") - - assert read_workspace_file("demo.py")["ok"] is True - result = edit_workspace_file("demo.py", "hello", "world") - - assert result["ok"] is True - assert (workspace / "demo.py").read_text(encoding="utf-8") == "print('world')\n" - - -def test_workspace_edit_checks_mtime_and_updates_read_state(monkeypatch, tmp_path): - clear_read_state() - monkeypatch.setattr("ksadk.toolsets.workspace.resolve_local_session_dir", lambda: tmp_path) - workspace = tmp_path / "workspace" - workspace.mkdir() - target = workspace / "demo.py" - target.write_text("print('hello')\nprint('again')\n", encoding="utf-8") - assert read_workspace_file("demo.py")["ok"] is True - target.write_text("print('external')\n", encoding="utf-8") - - stale = edit_workspace_file("demo.py", "external", "changed") - - assert stale["ok"] is False - assert stale["error_type"] == "file_modified_since_read" - assert read_workspace_file("demo.py")["ok"] is True - first = edit_workspace_file("demo.py", "external", "changed") - second = edit_workspace_file("demo.py", "changed", "changed again") - assert first["ok"] is True - assert second["ok"] is True - assert "changed again" in target.read_text(encoding="utf-8") - - -def test_workspace_edit_supports_quote_normalization_and_replace_all(monkeypatch, tmp_path): - clear_read_state() - monkeypatch.setattr("ksadk.toolsets.workspace.resolve_local_session_dir", lambda: tmp_path) - workspace = tmp_path / "workspace" - workspace.mkdir() - target = workspace / "demo.txt" - target.write_text("“name”\n“name”\n", encoding="utf-8") - assert read_workspace_file("demo.txt")["ok"] is True - - ambiguous = edit_workspace_file("demo.txt", '"name"', "value") - replaced = edit_workspace_file("demo.txt", '"name"', "value", replace_all=True) - - assert ambiguous["ok"] is False - assert ambiguous["error_type"] == "ambiguous_edit" - assert replaced["ok"] is True - assert replaced["used_quote_normalization"] is True - assert replaced["replacements"] == 2 - assert target.read_text(encoding="utf-8") == "value\nvalue\n" - - -def test_workspace_edit_returns_match_diagnostics_for_not_found_and_ambiguous(monkeypatch, tmp_path): - clear_read_state() - monkeypatch.setattr("ksadk.toolsets.workspace.resolve_local_session_dir", lambda: tmp_path) - workspace = tmp_path / "workspace" - workspace.mkdir() - target = workspace / "demo.py" - target.write_text("alpha = 1\nalpha = 2\nalphabet = 3\n", encoding="utf-8") - assert read_workspace_file("demo.py")["ok"] is True - - missing = edit_workspace_file("demo.py", "alpah = 2", "alpha = 20") - ambiguous = edit_workspace_file("demo.py", "alpha = ", "beta = ") - - assert missing["ok"] is False - assert missing["error_type"] == "snippet_not_found" - assert missing["nearby_candidates"] - assert missing["nearby_candidates"][0]["line"] == 2 - assert ambiguous["ok"] is False - assert ambiguous["error_type"] == "ambiguous_edit" - assert [match["line"] for match in ambiguous["matches"]] == [1, 2] - - -def test_workspace_multi_edit_is_atomic_and_returns_budgeted_diff(monkeypatch, tmp_path): - clear_read_state() - monkeypatch.setenv("KSADK_TOOL_RESULT_DIR", str(tmp_path / "tool-results")) - monkeypatch.setattr("ksadk.toolsets.workspace.resolve_local_session_dir", lambda: tmp_path) - workspace = tmp_path / "workspace" - workspace.mkdir() - target = workspace / "demo.py" - target.write_text("one = 1\ntwo = 2\nthree = 3\n", encoding="utf-8") - assert read_workspace_file("demo.py")["ok"] is True - - failed = multi_edit_workspace_file( - "demo.py", - edits=[ - {"old_text": "one = 1", "new_text": "one = 10"}, - {"old_text": "missing = 0", "new_text": "missing = 1"}, - ], - ) - assert failed["ok"] is False - assert target.read_text(encoding="utf-8") == "one = 1\ntwo = 2\nthree = 3\n" - - result = multi_edit_workspace_file( - "demo.py", - edits=[ - {"old_text": "one = 1", "new_text": "one = 10"}, - {"old_text": "three = 3", "new_text": "three = 30"}, - ], - ) - - assert result["ok"] is True - assert result["edit_count"] == 2 - assert result["replacements"] == 2 - assert "one = 10" in target.read_text(encoding="utf-8") - assert "diff" in result - - -def test_workspace_search_supports_regex_glob_context_and_max_results(monkeypatch, tmp_path): - monkeypatch.setattr("ksadk.toolsets.workspace.shutil.which", lambda _name: None) - monkeypatch.setattr("ksadk.toolsets.workspace.resolve_local_session_dir", lambda: tmp_path) - workspace = tmp_path / "workspace" - workspace.mkdir() - (workspace / "a.py").write_text("alpha\nneedle_123\nomega\n", encoding="utf-8") - (workspace / "b.txt").write_text("needle_456\n", encoding="utf-8") - - result = search_workspace_files(r"needle_\d+", glob="*.py", is_regex=True, context_lines=1, max_results=1) - - assert result["ok"] is True - assert len(result["results"]) == 1 - assert result["results"][0]["path"] == "a.py" - assert result["results"][0]["line"] == 2 - assert "alpha" in result["results"][0]["context_before"] - assert "omega" in result["results"][0]["context_after"] - assert result["truncated"] is False - assert result["searched_path"] == "." - assert result["match_count"] == 1 - assert result["context_lines"] == 1 - assert result["search_backend"] == "python" - - -def test_workspace_search_marks_truncated_when_matches_exceed_limit(monkeypatch, tmp_path): - monkeypatch.setattr("ksadk.toolsets.workspace.shutil.which", lambda _name: None) - monkeypatch.setattr("ksadk.toolsets.workspace.resolve_local_session_dir", lambda: tmp_path) - workspace = tmp_path / "workspace" - workspace.mkdir() - (workspace / "a.py").write_text("needle\n", encoding="utf-8") - (workspace / "b.py").write_text("needle\n", encoding="utf-8") - - result = search_workspace_files("needle", glob="*.py", max_results=1) - - assert result["ok"] is True - assert len(result["results"]) == 1 - assert result["truncated"] is True - - -def test_workspace_list_supports_glob_sort_and_include_dirs(monkeypatch, tmp_path): - clear_read_state() - monkeypatch.setattr("ksadk.toolsets.workspace.resolve_local_session_dir", lambda: tmp_path) - workspace = tmp_path / "workspace" - (workspace / "pkg").mkdir(parents=True) - (workspace / "b.py").write_text("b", encoding="utf-8") - (workspace / "a.py").write_text("aaaa", encoding="utf-8") - (workspace / "pkg" / "c.txt").write_text("c", encoding="utf-8") - - result = list_workspace_files(".", glob="*.py", recursive=True, include_dirs=False, sort_by="size") - - assert result["ok"] is True - assert [entry["path"] for entry in result["entries"]] == ["b.py", "a.py"] diff --git a/tests/test_aicp_env.py b/tests/test_aicp_env.py deleted file mode 100644 index 49d965f5..00000000 --- a/tests/test_aicp_env.py +++ /dev/null @@ -1,83 +0,0 @@ -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 deleted file mode 100644 index d83c2579..00000000 --- a/tests/test_attachment_pipeline.py +++ /dev/null @@ -1,106 +0,0 @@ -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_attachment_storage.py b/tests/test_attachment_storage.py deleted file mode 100644 index 9102c52e..00000000 --- a/tests/test_attachment_storage.py +++ /dev/null @@ -1,169 +0,0 @@ -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_background_run.py b/tests/test_background_run.py deleted file mode 100644 index f70d86f1..00000000 --- a/tests/test_background_run.py +++ /dev/null @@ -1,468 +0,0 @@ -from __future__ import annotations - -import asyncio -import importlib -from types import SimpleNamespace - -import httpx -import pytest -from fastapi.testclient import TestClient -from httpx import ASGITransport - -from ksadk.runners.base_runner import BaseRunner - - -class _SlowBackgroundRunner(BaseRunner): - """runner 的 stream 产慢流,用于验证 background 立即返回。""" - - def __init__(self): - super().__init__( - detection_result=SimpleNamespace( - name="background-test-agent", - description="bg", - type=SimpleNamespace(value="langgraph"), - ), - project_dir=".", - ) - self.stream_started = asyncio.Event() - self.stream_finished = asyncio.Event() - - 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): - self.stream_started.set() - await asyncio.sleep(0.3) - yield {"type": "text", "delta": "hello"} - yield {"type": "final", "output": "hello world"} - self.stream_finished.set() - - -class _FailingBackgroundRunner(_SlowBackgroundRunner): - async def stream(self, input_data: dict): - self.stream_started.set() - raise RuntimeError("background boom") - yield # pragma: no cover - - -@pytest.fixture -def bg_client(monkeypatch, tmp_path): - monkeypatch.setenv("KSADK_SESSION_BACKEND", "memory") - monkeypatch.setenv("AGENTENGINE_UI_DIR", str(tmp_path / "ui")) - from ksadk.server import app, set_runner - - runner = _SlowBackgroundRunner() - set_runner(runner) - yield app, runner - - -@pytest.fixture -def failing_bg_client(monkeypatch, tmp_path): - monkeypatch.setenv("KSADK_SESSION_BACKEND", "memory") - monkeypatch.setenv("AGENTENGINE_UI_DIR", str(tmp_path / "ui")) - from ksadk.server import app, set_runner - - runner = _FailingBackgroundRunner() - set_runner(runner) - yield app, runner - - -async def _run_statuses(session_id: str, invocation_id: str) -> list[str]: - from ksadk.sessions import resolve_session_service - - events = await resolve_session_service().get_events(session_id) - return [ - (event.content or {}).get("status") - for event in events - if event.event_type == "run_status" and event.invocation_id == invocation_id - ] - - -async def _wait_for_terminal_statuses(session_id: str, invocation_id: str) -> list[str]: - for _ in range(40): - statuses = await _run_statuses(session_id, invocation_id) - if _terminal_statuses(statuses): - return statuses - await asyncio.sleep(0.05) - return await _run_statuses(session_id, invocation_id) - - -def _terminal_statuses(statuses: list[str]) -> list[str]: - return [status for status in statuses if status in {"completed", "cancelled", "failed"}] - - -def test_background_field_is_parsed(bg_client): - """RunAgentActionRequest 能解析 Background 字段(默认 False)。""" - app, _ = bg_client - client = TestClient(app) - # Background=true 应被接受(不报 422 校验错误),后续 Task 才验证行为 - # 这里只验证字段存在且可解析——发一个最小请求触发校验 - resp = client.post( - "/agentengine/api/v1/RunAgent", - json={"AgentId": "a", "Messages": [{"role": "user", "content": "hi"}], "Background": True}, - ) - # 非 422 即说明 Background 字段被模型接受 - assert resp.status_code != 422 - # 正向断言:字段确已落在模型上(pydantic 默认忽略 extra,需直接校验属性可读) - from ksadk.server.app import RunAgentActionRequest - - req = RunAgentActionRequest(AgentId="a", Messages=[], Background=True) - assert hasattr(req, "Background"), "RunAgentActionRequest 缺 Background 字段" - assert req.Background is True - # 默认 False(向后兼容) - assert RunAgentActionRequest(AgentId="a").Background is False - - -@pytest.mark.asyncio -async def test_run_agent_background_returns_immediately_with_job_handle(bg_client): - """Background=true 立即返回 job 句柄,不等后台 stream 跑完。""" - app, runner = bg_client - # 用 ASGITransport + AsyncClient 而非同步 TestClient:同步 TestClient 在 - # 请求返回后即拆除事件循环,asyncio.create_task 的 detached 后台任务无法存活 - # (会在 sleep 中被取消)。async client 让事件循环持续驱动 detached _consume。 - transport = ASGITransport(app=app) - async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client: - resp = await client.post( - "/agentengine/api/v1/RunAgent", - json={ - "AgentId": "a", - "SessionId": "sess-bg-1", - "Messages": [{"role": "user", "content": "研究 AI Agent 趋势"}], - "Background": True, - "Stream": False, - }, - ) - assert resp.status_code == 200, resp.text - data = resp.json()["Data"] - # 立即返回 running 状态(后台 stream 还在跑,stream_finished 未 set) - assert data["Status"] == "running" - assert data["Background"] is True - assert "InvocationId" in data and data["InvocationId"] - # SessionId 顶层字段(与 SubscribeUrl 一致,避免前端从 URL 反解) - assert "SessionId" in data and data["SessionId"] - # 关键:响应返回时后台慢流(0.3s)还没跑完,证明是立即返回而非阻塞 - assert not runner.stream_finished.is_set(), "background 应立即返回,不该等 stream 完成" - # InvocationId 落入 _DETACHED_STREAMS_BY_INVOCATION,CancelRun 能查到 - from ksadk.server.app import _DETACHED_STREAMS_BY_INVOCATION - - assert data["InvocationId"] in _DETACHED_STREAMS_BY_INVOCATION - - -@pytest.mark.asyncio -async def test_run_agent_background_primes_session_title_before_detached_stream_consumes(bg_client, monkeypatch): - """Background=true 返回 job 句柄前先写入首轮 prompt/title,刷新列表不显示空标题。""" - server_app_module = importlib.import_module("ksadk.server.app") - from ksadk.sessions import resolve_session_service - - class _IdleDetachedStream: - def __init__(self, source, *, invocation_id=None, session_id=None, run_mode="unknown", run_trigger="unknown"): - self.source = source - self.invocation_id = invocation_id - self.session_id = session_id - self._run_mode = run_mode - self._run_trigger = run_trigger - self._task = asyncio.Future() - - monkeypatch.setattr(server_app_module, "_DetachedSSEStream", _IdleDetachedStream) - - app, _runner = bg_client - transport = ASGITransport(app=app) - async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client: - resp = await client.post( - "/agentengine/api/v1/RunAgent", - json={ - "AgentId": "a", - "SessionId": "sess-bg-title", - "ResponsesInput": [ - { - "role": "user", - "content": [{"type": "input_text", "text": "调研 2026 企业 AI Agent 平台趋势"}], - } - ], - "ApiFormat": "responses", - "Background": True, - "Stream": False, - }, - ) - assert resp.status_code == 200, resp.text - invocation_id = resp.json()["Data"]["InvocationId"] - listed = await client.post( - "/agentengine/api/v1/ListSessions", - json={"AgentId": "a"}, - ) - assert listed.status_code == 200, listed.text - - session = await resolve_session_service().get_session("sess-bg-title") - assert session is not None - assert session.first_prompt == "调研 2026 企业 AI Agent 平台趋势" - assert session.last_prompt == "调研 2026 企业 AI Agent 平台趋势" - assert session.title - assert session.title != "sess-bg-title" - assert session.title_source == "fallback_first_prompt" - listed_session = listed.json()["Data"]["Sessions"][0] - assert listed_session["FirstPrompt"] == "调研 2026 企业 AI Agent 平台趋势" - assert listed_session["ActiveInvocationId"] == invocation_id - assert listed_session["ActiveRunStatus"] == "in_progress" - # Background:true 的 run 应标记 run_mode=background, run_trigger=new_run - assert listed_session["ActiveRunMode"] == "background" - assert listed_session["ActiveRunTrigger"] == "new_run" - - -@pytest.mark.asyncio -async def test_run_agent_background_subscribe_gets_terminal_status(bg_client): - """background 起任务后,SubscribeRunEvents 拉到 run_status 终态 + [DONE]。""" - app, runner = bg_client - transport = ASGITransport(app=app) - async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client: - resp = await client.post( - "/agentengine/api/v1/RunAgent", - json={ - "AgentId": "a", - "SessionId": "sess-bg-2", - "Messages": [{"role": "user", "content": "研究 X"}], - "Background": True, - "Stream": False, - }, - ) - invocation_id = resp.json()["Data"]["InvocationId"] - # 等后台慢流(0.3s)跑完写终态,再 subscribe(SubscribeRunEvents 也支持 - # 后到也能拉到历史 run_status 事件,但先等终态写入更稳) - await runner.stream_finished.wait() - # 用流式 GET 拉 SubscribeRunEvents,读到 [DONE] 终止符 - chunks: list[str] = [] - async with client.stream( - "GET", - f"/agentengine/api/v1/SubscribeRunEvents?SessionId=sess-bg-2&InvocationId={invocation_id}", - ) as response: - async for line in response.aiter_lines(): - chunks.append(line) - if "[DONE]" in line: - break - body = "\n".join(chunks) - assert "completed" in body or "run_status" in body, f"期望 run_status 终态,实际: {body[:500]}" - assert "[DONE]" in body, "期望 [DONE] 终止符" - assert _terminal_statuses(await _run_statuses("sess-bg-2", invocation_id)) == ["completed"] - - -@pytest.mark.asyncio -async def test_run_agent_background_writes_single_in_progress_status(bg_client): - """Background 起始态只由 conversation runtime 写一次,避免刷新/订阅看到重复 running。""" - app, runner = bg_client - transport = ASGITransport(app=app) - async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client: - resp = await client.post( - "/agentengine/api/v1/RunAgent", - json={ - "AgentId": "a", - "SessionId": "sess-bg-single-start", - "Messages": [{"role": "user", "content": "研究 X"}], - "Background": True, - "Stream": False, - }, - ) - assert resp.status_code == 200, resp.text - invocation_id = resp.json()["Data"]["InvocationId"] - await runner.stream_finished.wait() - - statuses = await _run_statuses("sess-bg-single-start", invocation_id) - assert statuses.count("in_progress") == 1, ( - f"期望同一 InvocationId 只有一个 in_progress,实际 statuses: {statuses}" - ) - assert _terminal_statuses(statuses) == ["completed"] - - -async def test_detached_stream_does_not_write_duplicate_completed_status(monkeypatch, tmp_path): - """_DetachedSSEStream 正常结束时不补写 completed,终态由 conversation stream 主写入。""" - monkeypatch.setenv("KSADK_SESSION_BACKEND", "memory") - monkeypatch.setenv("AGENTENGINE_UI_DIR", str(tmp_path / "ui")) - from ksadk.server.app import _DetachedSSEStream - from ksadk.conversations import append_run_status_event - from ksadk.sessions import resolve_session_service - - async def source(): - await append_run_status_event( - session_id=session_id, - author="runner", - status="in_progress", - invocation_id=invocation_id, - ) - yield "data: chunk1\n\n" - await append_run_status_event( - session_id=session_id, - author="runner", - status="completed", - invocation_id=invocation_id, - ) - yield "data: chunk2\n\n" - - invocation_id = "inv_test_completed" - session_id = "sess_test_completed" - service = resolve_session_service() - await service.create_session(agent_id="a", user_id="u", session_id=session_id) - detached = _DetachedSSEStream(source(), invocation_id=invocation_id, session_id=session_id) - # 等后台 _consume 跑完 - await detached._task - # 查 session 里的 run_status 事件 - statuses = await _run_statuses(session_id, invocation_id) - assert _terminal_statuses(statuses) == ["completed"], ( - f"期望只有 conversation stream 写入一个 completed,实际 statuses: {statuses}" - ) - - -async def test_detached_stream_writes_failed_fallback_only_when_source_raises(monkeypatch, tmp_path): - """_DetachedSSEStream 只在源流异常且没有已有终态时兜底写 failed。""" - monkeypatch.setenv("KSADK_SESSION_BACKEND", "memory") - monkeypatch.setenv("AGENTENGINE_UI_DIR", str(tmp_path / "ui")) - from ksadk.server.app import _DetachedSSEStream - from ksadk.sessions import resolve_session_service - - async def source(): - yield "data: chunk1\n\n" - raise RuntimeError("raw stream failed") - - invocation_id = "inv_test_raw_failed" - session_id = "sess_test_raw_failed" - service = resolve_session_service() - await service.create_session(agent_id="a", user_id="u", session_id=session_id) - detached = _DetachedSSEStream(source(), invocation_id=invocation_id, session_id=session_id) - - with pytest.raises(RuntimeError, match="raw stream failed"): - await detached._task - - statuses = await _run_statuses(session_id, invocation_id) - assert _terminal_statuses(statuses) == ["failed"], ( - f"期望 detached 异常兜底只写一个 failed,实际 statuses: {statuses}" - ) - - -@pytest.mark.asyncio -async def test_run_agent_background_lifecycle_does_not_create_checkpoints(bg_client): - """background lifecycle run_status 不应被 ListSessionCheckpoints 当成恢复点。""" - app, runner = bg_client - transport = ASGITransport(app=app) - async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client: - resp = await client.post( - "/agentengine/api/v1/RunAgent", - json={ - "AgentId": "a", - "SessionId": "sess-bg-no-checkpoints", - "Messages": [{"role": "user", "content": "研究 X"}], - "Background": True, - "Stream": False, - }, - ) - assert resp.status_code == 200, resp.text - await runner.stream_finished.wait() - checkpoints_resp = await client.post( - "/agentengine/api/v1/ListSessionCheckpoints", - json={"AgentId": "a", "SessionId": "sess-bg-no-checkpoints"}, - ) - - assert checkpoints_resp.status_code == 200, checkpoints_resp.text - checkpoints_data = checkpoints_resp.json()["Data"] - assert checkpoints_data["Checkpoints"] == [] - assert checkpoints_data["Total"] == 0 - # 无 checkpoint 时聚合字段应为 0/False - assert checkpoints_data["ResumableTotal"] == 0 - assert checkpoints_data["HasResumableCheckpoint"] is False - - -@pytest.mark.asyncio -async def test_run_agent_background_cancel_writes_cancelled_status(bg_client): - """CancelRun 对 background 任务生效,写 run_status=cancelled 终态。""" - from ksadk.server.app import _DETACHED_STREAMS_BY_INVOCATION - from ksadk.sessions import resolve_session_service - - app, runner = bg_client - transport = ASGITransport(app=app) - async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: - # 起 background 任务(慢流 0.3s,cancel 前后台还在 sleep) - resp = await client.post( - "/agentengine/api/v1/RunAgent", - json={ - "AgentId": "a", - "SessionId": "sess-bg-cancel", - "Messages": [{"role": "user", "content": "研究 X"}], - "Background": True, - "Stream": False, - }, - ) - assert resp.status_code == 200, resp.text - invocation_id = resp.json()["Data"]["InvocationId"] - assert invocation_id in _DETACHED_STREAMS_BY_INVOCATION - # CancelRun:detached 已注册进 dict,开箱即用 - cancel_resp = await client.post( - "/agentengine/api/v1/CancelRun", - json={"InvocationId": invocation_id}, - ) - cancel_data = cancel_resp.json()["Data"] - assert cancel_data["Found"] is True - # 等 detached task 处理取消 + finally 写终态 - detached = _DETACHED_STREAMS_BY_INVOCATION.get(invocation_id) - if detached is not None: - try: - await detached._task - except Exception: - pass - # 查 session 里的 run_status 事件 - service = resolve_session_service() - events = await service.get_events("sess-bg-cancel") - statuses = [ - (e.content or {}).get("status") - for e in events - if e.event_type == "run_status" and e.invocation_id == invocation_id - ] - assert _terminal_statuses(statuses) == ["cancelled"], ( - f"期望同一 InvocationId 只有一个 cancelled 终态,实际 statuses: {statuses}" - ) - - -@pytest.mark.asyncio -async def test_run_agent_background_failure_writes_single_terminal_status(failing_bg_client): - """background stream 失败时,同一 InvocationId 只有一个 failed 终态。""" - app, _runner = failing_bg_client - transport = ASGITransport(app=app) - async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: - resp = await client.post( - "/agentengine/api/v1/RunAgent", - json={ - "AgentId": "a", - "SessionId": "sess-bg-failed", - "Messages": [{"role": "user", "content": "研究 X"}], - "Background": True, - "Stream": False, - }, - ) - assert resp.status_code == 200, resp.text - invocation_id = resp.json()["Data"]["InvocationId"] - - statuses = await _wait_for_terminal_statuses("sess-bg-failed", invocation_id) - assert _terminal_statuses(statuses) == ["failed"], ( - f"期望同一 InvocationId 只有一个 failed 终态,实际 statuses: {statuses}" - ) - - -@pytest.mark.asyncio -async def test_run_agent_background_false_preserves_existing_behavior(bg_client): - """Background 不传(默认 false)+ Stream=false 走现有同步 invoke 路径,行为不变。""" - app, runner = bg_client - transport = ASGITransport(app=app) - async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: - resp = await client.post( - "/agentengine/api/v1/RunAgent", - json={ - "AgentId": "a", - "SessionId": "sess-bg-compat", - "Messages": [{"role": "user", "content": "普通问题"}], - # 不传 Background(默认 False),Stream=False - }, - ) - assert resp.status_code == 200, resp.text - data = resp.json()["Data"] - # 同步 invoke 路径返回普通 payload(含 output),不含 background 句柄字段 - assert "output" in data, f"同步路径应返回 output,实际 Data: {data}" - assert data.get("Background") is not True, "非 background 路径不该返回 Background:true" - assert data.get("Status") != "running", "非 background 路径不该返回 Status:running" diff --git a/tests/test_builder_requirements_merge.py b/tests/test_builder_requirements_merge.py deleted file mode 100644 index 4b0802fe..00000000 --- a/tests/test_builder_requirements_merge.py +++ /dev/null @@ -1,443 +0,0 @@ -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_builder_runtime_requirements.py b/tests/test_builder_runtime_requirements.py deleted file mode 100644 index 6f2fd668..00000000 --- a/tests/test_builder_runtime_requirements.py +++ /dev/null @@ -1,21 +0,0 @@ -from ksadk.builders.code_builder import CodeBuilder - - -def test_bundled_runtime_requirements_include_kingsoftcloud_sdk(): - assert "kingsoftcloud-sdk-python>=1.5.8.94" in CodeBuilder.BUNDLED_KSADK_RUNTIME_REQUIREMENTS - - -def test_bundled_runtime_requirements_include_python_multipart(): - assert "python-multipart>=0.0.9,<1.0.0" in CodeBuilder.BUNDLED_KSADK_RUNTIME_REQUIREMENTS - - -def test_bundled_runtime_requirements_keep_asyncpg_postgres_sessions_optional(): - assert "asyncpg>=0.30.0,<1.0.0" not in CodeBuilder.BUNDLED_KSADK_RUNTIME_REQUIREMENTS - assert "asyncpg>=0.30.0,<1.0.0" in CodeBuilder.BUNDLED_KSADK_POSTGRES_SESSION_REQUIREMENTS - - -def test_bundled_runtime_requirements_keep_mcp_adapters_optional(): - assert "mcp>=1.1.0" not in CodeBuilder.BUNDLED_KSADK_RUNTIME_REQUIREMENTS - assert "langchain-mcp-adapters>=0.0.1" not in CodeBuilder.BUNDLED_KSADK_RUNTIME_REQUIREMENTS - assert "mcp>=1.1.0" in CodeBuilder.BUNDLED_KSADK_MCP_RUNTIME_REQUIREMENTS - assert "langchain-mcp-adapters>=0.0.1" in CodeBuilder.BUNDLED_KSADK_MCP_RUNTIME_REQUIREMENTS diff --git a/tests/test_check_publication_state.py b/tests/test_check_publication_state.py index 0361acdb..b89cd2c0 100644 --- a/tests/test_check_publication_state.py +++ b/tests/test_check_publication_state.py @@ -34,6 +34,7 @@ def _run_main(monkeypatch, module, *, phase: str, version_exists: dict[tuple[str ], ) monkeypatch.setattr(module, "_expect_http_ok", lambda name, url: None) + monkeypatch.setattr(module, "_expect_github_repo_homepage", lambda url, expected_docs_url: None) monkeypatch.setattr( module, "_github_release_tags", @@ -98,6 +99,7 @@ def test_publication_state_fails_when_historical_github_release_is_missing(monke ], ) monkeypatch.setattr(module, "_expect_http_ok", lambda name, url: None) + monkeypatch.setattr(module, "_expect_github_repo_homepage", lambda url, expected_docs_url: None) monkeypatch.setattr(module, "_github_release_tags", lambda url: {"v0.6.1", "v0.6.3"}) with pytest.raises(RuntimeError, match="v0.6.2"): @@ -205,6 +207,35 @@ def fake_run(argv, check, text, stdout, stderr): ) == {"v0.6.5", "v0.6.4"} +def test_publication_state_fails_when_github_homepage_points_elsewhere(monkeypatch): + module = _load_module() + + def fake_open(_url): + return 200, b'{"homepage":"https://kingsoftcloud.github.io/ksadk-python/getting-started/quickstart/"}' + + monkeypatch.setattr(module, "_open", fake_open) + + with pytest.raises(RuntimeError, match="github repo homepage"): + module._expect_github_repo_homepage( + "https://api.github.com/repos/kingsoftcloud/ksadk-python", + "https://kingsoftcloud.github.io/ksadk-python/", + ) + + +def test_publication_state_accepts_github_homepage_without_trailing_slash(monkeypatch): + module = _load_module() + + def fake_open(_url): + return 200, b'{"homepage":"https://kingsoftcloud.github.io/ksadk-python"}' + + monkeypatch.setattr(module, "_open", fake_open) + + module._expect_github_repo_homepage( + "https://api.github.com/repos/kingsoftcloud/ksadk-python", + "https://kingsoftcloud.github.io/ksadk-python/", + ) + + def test_github_release_tags_falls_back_to_gh_cli_on_transient_server_error(monkeypatch): module = _load_module() diff --git a/tests/test_cli_dry_run.py b/tests/test_cli_dry_run.py deleted file mode 100644 index a6395128..00000000 --- a/tests/test_cli_dry_run.py +++ /dev/null @@ -1,2634 +0,0 @@ -import asyncio -import json -from contextlib import contextmanager -from pathlib import Path -from types import SimpleNamespace -from typing import Any, Dict - -import yaml -from click.testing import CliRunner - -from ksadk.api.client import AgentEngineAPIError, AgentEngineClient, DryRunExit -from ksadk.cli import _register_commands, cli, cmd_mcp -from ksadk.cli.cmd_agent import agent -from ksadk.cli.cmd_destroy import delete as destroy_delete -from ksadk.cli.cmd_destroy import destroy as destroy_cmd -from ksadk.cli.cmd_mcp import mcp -from ksadk.cli import cmd_openclaw -from ksadk.cli.cmd_openclaw import openclaw -from ksadk.cli.cmd_version import version -from ksadk.cli.dry_run import run_async_with_dry_run -from ksadk.deployment.base import DeployTarget -from ksadk.deployment.providers.serverless import ServerlessProvider - - -class _FakeDryRunClient: - last_init_kwargs: Dict[str, Any] = {} - - def __init__(self, *args, **kwargs): - _FakeDryRunClient.last_init_kwargs = kwargs - - async def __aenter__(self): - return self - - async def __aexit__(self, exc_type, exc, tb): - return False - - async def list_mcps(self, **kwargs): - raise DryRunExit("dry-run") - - async def get_mcp(self, *_args, **_kwargs): - raise DryRunExit("dry-run") - - async def delete_mcp(self, *_args, **_kwargs): - raise DryRunExit("dry-run") - - async def list_agents(self, **kwargs): - raise DryRunExit("dry-run") - - async def get_agent(self, **kwargs): - raise DryRunExit("dry-run") - - async def delete_agent(self, *_args, **_kwargs): - raise DryRunExit("dry-run") - - async def create_mcp(self, request): - raise DryRunExit("dry-run", payload={"body": request}) - - async def list_versions(self, *_args, **_kwargs): - raise DryRunExit("dry-run") - - async def release_version(self, *_args, **_kwargs): - raise DryRunExit("dry-run") - - async def rollback_version(self, *_args, **_kwargs): - raise DryRunExit("dry-run") - - async def close(self): - return None - - -class _FakeMCPDetectionResult: - is_valid = True - entry_point = "mcp_main.py" - mcp_variable = "mcp" - tools = ["test_tool"] - - -class _FakeMCPDetector: - def __init__(self, *_args, **_kwargs): - pass - - def detect(self): - return _FakeMCPDetectionResult() - - -class _FakeOpenClawListClient: - 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-demo-1", - "name": "demo-openclaw", - "status": "running", - "endpoint": "https://openclaw.example.com", - "region": "cn-beijing-6", - } - ], - "total": 145, - } - - async def close(self): - return None - - -class _FakeOpenClawDetailClient: - last_log_kwargs: Dict[str, Any] = {} - - def __init__(self, *args, **kwargs): - pass - - async def __aenter__(self): - return self - - async def __aexit__(self, exc_type, exc, tb): - return False - - async def get_agent(self, **_kwargs): - return { - "basic": { - "agent_id": "ar-demo-1", - "name": "demo-openclaw", - "status": "RUNNING", - "framework": "openclaw", - "region": "cn-beijing-6", - }, - "quick_access": { - "public_endpoint": "https://openclaw.example.com", - }, - "advanced": { - "observability_url": "https://trace.example.com/project/aropenclaw1/traces", - }, - } - - async def get_agent_logs(self, **kwargs): - _FakeOpenClawDetailClient.last_log_kwargs = kwargs - return { - "logs": ["line-1", "line-2"], - "total": 2, - "page": 1, - "page_size": 200, - "agent_id": "ar-demo-1", - "instance": kwargs.get("instance"), - "log_type": "Stdout", - } - - async def close(self): - return None - - -class _FakeOpenClawCreatingDetailClient(_FakeOpenClawDetailClient): - async def get_agent(self, **_kwargs): - payload = await super().get_agent(**_kwargs) - payload["basic"]["status"] = "CREATING" - return payload - - -class _FakeOpenClawFailedDetailClient(_FakeOpenClawDetailClient): - last_repair_kwargs: Dict[str, Any] = {} - - async def get_agent(self, **_kwargs): - payload = await super().get_agent(**_kwargs) - payload["basic"]["status"] = "FAILED" - return payload - - async def run_openclaw_repair(self, agent_id: str, *, repair_action: str = "doctor-fix"): - self.__class__.last_repair_kwargs = { - "agent_id": agent_id, - "repair_action": repair_action, - } - return { - "ok": True, - "agent_id": agent_id, - "repair_action": repair_action, - "status": "succeeded", - "exit_code": 0, - "logs": "fixed", - } - - -class _FakeGatewayClient: - applied_configs: list[Dict[str, Any]] = [] - last_wait_kwargs: Dict[str, Any] = {} - disconnect_waits: list[int] = [] - - def __init__(self, *args, **kwargs): - self.methods = ["channels.status", "config.get", "web.login.start", "web.login.wait"] - - async def build_access_info(self): - return SimpleNamespace( - access_url="https://dashboard.example.com/s/lnk-demo", - ws_url="wss://dashboard.example.com/", - link_id="lnk-demo", - expires_at="2026-03-23T12:00:00Z", - ) - - async def connect(self): - return {"features": {"methods": self.methods}} - - async def close(self): - return None - - async def wait_for_disconnect(self, *, timeout_ms=5_000): - self.__class__.disconnect_waits.append(timeout_ms) - return True - - async def channels_status(self, *, probe=False, timeout_ms=None): - return { - "channels": { - "weixin": {"connected": True, "probe": probe, "timeout_ms": timeout_ms}, - "feishu": {"enabled": True}, - "wps-xiezuo": {"enabled": True}, - } - } - - async def config_get(self): - return { - "hash": "cfg-1", - "exists": True, - "config": { - "plugins": {"entries": {}}, - "channels": {}, - }, - } - - async def web_login_start(self, *, force=False, timeout_ms=None): - return { - "qrDataUrl": "https://qr.example.com/weixin-login", - "sessionKey": "sess-1", - "message": "scan now", - } - - async def web_login_wait(self, *, account_id=None, session_key=None, timeout_ms=None): - self.__class__.last_wait_kwargs = { - "account_id": account_id, - "session_key": session_key, - "timeout_ms": timeout_ms, - } - return {"connected": True, "message": "connected"} - - async def config_apply(self, *, config, base_hash, note=None, session_key=None, restart_delay_ms=None): - self.__class__.applied_configs.append( - { - "config": config, - "base_hash": base_hash, - "note": note, - "session_key": session_key, - "restart_delay_ms": restart_delay_ms, - } - ) - return {"ok": True} - - -class _FakeConfigApplyReloadGatewayClient(_FakeGatewayClient): - async def config_apply(self, *, config, base_hash, note=None, session_key=None, restart_delay_ms=None): - await super().config_apply( - config=config, - base_hash=base_hash, - note=note, - session_key=session_key, - restart_delay_ms=restart_delay_ms, - ) - raise cmd_openclaw.OpenClawGatewayError( - "Gateway websocket receive failed: received 1011 (internal error) Bad Gateway" - ) - - -class _FakeDoctorGatewayClient(_FakeGatewayClient): - async def config_get(self): - return { - "hash": "cfg-1", - "exists": True, - "config": { - "plugins": { - "entries": { - "openclaw-weixin": {"enabled": True}, - "openclaw-lark": {"enabled": True}, - "wps-xiezuo": {"enabled": True}, - } - }, - "skills": {"allowBundled": ["wps365-skill"]}, - "channels": { - "feishu": {"enabled": True}, - "openclaw-weixin": {"accounts": {"default": {"enabled": True}}}, - "wps-xiezuo": {"enabled": True, "appId": "app-demo", "appSecret": "secret-demo"}, - }, - }, - } - - -class _FakeDoctorFreshGatewayClient(_FakeGatewayClient): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.methods = ["channels.status", "config.get"] - - async def channels_status(self, *, probe=False, timeout_ms=None): - return { - "channels": { - "openclaw-weixin": {"configured": False, "probe": probe, "timeout_ms": timeout_ms}, - } - } - - async def config_get(self): - return { - "hash": "cfg-1", - "exists": True, - "config": { - "plugins": { - "entries": { - "openclaw-weixin": {"enabled": True}, - "openclaw-lark": {"enabled": True}, - "wps-xiezuo": {"enabled": True}, - } - }, - "skills": {"allowBundled": ["wps365-skill"]}, - "channels": {}, - }, - } - - -class _FakeDoctorBrokenWeixinGatewayClient(_FakeDoctorFreshGatewayClient): - async def channels_status(self, *, probe=False, timeout_ms=None): - return { - "channels": { - "openclaw-weixin": {"configured": True, "probe": probe, "timeout_ms": timeout_ms}, - } - } - - async def config_get(self): - snapshot = await super().config_get() - snapshot["config"]["channels"] = { - "openclaw-weixin": {"accounts": {"default": {"enabled": True}}}, - } - return snapshot - - -class _FakeWeixinGatewayWithoutWebLoginClient(_FakeGatewayClient): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.methods = ["channels.status", "config.get"] - - async def web_login_start(self, *, force=False, timeout_ms=None): - raise AssertionError("web login RPC should not be called when method discovery is missing") - - async def channels_status(self, *, probe=False, timeout_ms=None): - return { - "channels": { - "openclaw-weixin": {"configured": True, "connected": False, "probe": probe, "timeout_ms": timeout_ms}, - } - } - - -class _FakeWeixinGatewayProviderUnavailableClient(_FakeGatewayClient): - async def web_login_start(self, *, force=False, timeout_ms=None): - raise cmd_openclaw.OpenClawGatewayRequestError( - "web login provider is not available", - code="INVALID_REQUEST", - ) - - async def channels_status(self, *, probe=False, timeout_ms=None): - return { - "channels": { - "openclaw-weixin": {"configured": True, "connected": False, "probe": probe, "timeout_ms": timeout_ms}, - } - } - - -class _FakeRestartingWeixinGatewayClient(_FakeGatewayClient): - async def web_login_start(self, *, force=False, timeout_ms=None): - if not self.__class__.disconnect_waits: - raise AssertionError("expected gateway restart wait before weixin login") - return await super().web_login_start(force=force, timeout_ms=timeout_ms) - - -class _FakeDeleteProvider: - def __init__(self): - self.calls = [] - - async def destroy(self, agent_id, deploy_target): - self.calls.append((agent_id, deploy_target)) - return True - - -class _FakeBatchDeleteClient: - deleted_agents = [] - deleted_mcps = [] - - def __init__(self, *args, **kwargs): - pass - - async def __aenter__(self): - return self - - async def __aexit__(self, exc_type, exc, tb): - return False - - async def delete_agent(self, agent_id): - self.deleted_agents.append(agent_id) - return True - - async def delete_mcp(self, mcp_id): - self.deleted_mcps.append(mcp_id) - return True - - async def close(self): - return None - - -class _FakeOpenClawCreateClient: - get_agent_calls = 0 - create_payload = None - update_payload = None - - def __init__(self, *args, **kwargs): - pass - - async def __aenter__(self): - return self - - async def __aexit__(self, exc_type, exc, tb): - return False - - async def create_agent(self, _data): - self.__class__.create_payload = _data - return { - "agent_id": "ar-created-1", - "endpoint": "https://ar-created-1.agent.kspmas.ksyun.com", - "api_key": "ak-created-1", - } - - async def update_agent(self, _agent_id, _data): - self.__class__.update_payload = _data - return { - "agent_id": _agent_id, - "endpoint": "https://ar-existing-1.agent.kspmas.ksyun.com", - "api_key": "ak-existing-1", - } - - async def get_agent(self, **_kwargs): - self.__class__.get_agent_calls += 1 - raise AssertionError("OpenClaw create response already contains complete quick access") - - async def close(self): - return None - - -class _FakeOpenClawImmediateAgentIdClient: - get_agent_calls = 0 - - def __init__(self, *args, **kwargs): - pass - - async def __aenter__(self): - return self - - async def __aexit__(self, exc_type, exc, tb): - return False - - async def create_agent(self, _data): - return { - "agent_id": "ar-created-2", - "endpoint": None, - "api_key": None, - "order_id": "ord-created-2", - } - - async def get_agent(self, **kwargs): - self.__class__.get_agent_calls += 1 - assert kwargs["agent_id"] == "ar-created-2" - assert kwargs["include_api_key"] is True - return { - "basic": { - "agent_id": "ar-created-2", - "name": "demo-openclaw", - "status": "RUNNING", - "framework": "openclaw", - "region": "cn-beijing-6", - }, - "quick_access": { - "public_endpoint": "https://fresh-openclaw.example.com", - "api_key": "ak-fresh-openclaw", - }, - } - - async def close(self): - return None - - -class _FakeOpenClawDelayedAccessClient: - get_agent_calls = 0 - suppression_used = False - - def __init__(self, *args, **kwargs): - pass - - async def __aenter__(self): - return self - - async def __aexit__(self, exc_type, exc, tb): - return False - - @contextmanager - def suppress_http_error_logging(self, predicate=None): - self.__class__.suppression_used = predicate is not None - yield - - async def create_agent(self, _data): - return { - "agent_id": "ar-created-delayed", - "endpoint": "https://created-openclaw.example.com", - "api_key": None, - "order_id": "ord-created-delayed", - } - - async def get_agent(self, **kwargs): - self.__class__.get_agent_calls += 1 - if self.__class__.get_agent_calls < 4: - raise AgentEngineAPIError( - 404, - "未找到对应的 Agent", - details={ - "http_status": 404, - "remote_error_message": "未找到对应的 Agent", - }, - ) - assert kwargs["agent_id"] == "ar-created-delayed" - assert kwargs["include_api_key"] is True - return { - "basic": { - "agent_id": "ar-created-delayed", - "name": "demo-openclaw", - "status": "RUNNING", - "framework": "openclaw", - "region": "cn-beijing-6", - }, - "quick_access": { - "public_endpoint": "https://ready-openclaw.example.com", - "api_key": "ak-ready-openclaw", - }, - "deployment": { - "framework": "openclaw", - "region": "cn-beijing-6", - }, - } - - async def close(self): - return None - - -class _FakeDeleteClient: - deleted_agents = [] - should_succeed = True - - def __init__(self, *args, **kwargs): - pass - - async def __aenter__(self): - return self - - async def __aexit__(self, exc_type, exc, tb): - return False - - async def delete_agent(self, agent_id): - self.deleted_agents.append(agent_id) - if self.should_succeed: - return True - raise RuntimeError("delete failed") - - async def close(self): - return None - - -class _FakePartialDeleteProvider: - def __init__(self, results: Dict[str, bool]): - self.results = dict(results) - self.calls = [] - - async def destroy(self, agent_id, deploy_target): - self.calls.append((agent_id, deploy_target)) - return self.results.get(agent_id, False) - - -def test_run_async_with_dry_run_handles_exit(capsys): - async def _boom(): - raise DryRunExit("done") - - result = run_async_with_dry_run(_boom(), dry_run=True) - assert result is None - out = capsys.readouterr().out - assert "Dry Run Completed" in out - - -def test_client_respects_global_dry_run_env(monkeypatch): - monkeypatch.setenv("AGENTENGINE_GLOBAL_DRY_RUN", "1") - client = AgentEngineClient(base_url="http://example.com", access_key="", secret_key="", dry_run=False) - 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) - - result = runner.invoke( - mcp, - ["status", "mcp-123", "--dry-run"], - env={"AGENTENGINE_SERVER_URL": "http://example.com"}, - ) - - assert result.exit_code == 0, result.output - assert "Dry Run Completed" in result.output - assert _FakeDryRunClient.last_init_kwargs.get("dry_run") is True - - -def test_mcp_deploy_dry_run_json_plan(monkeypatch, tmp_path: Path): - runner = CliRunner() - monkeypatch.chdir(tmp_path) - 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 build artifacts") - - monkeypatch.setattr(cmd_mcp, "_build_code_artifact", _should_not_build) - - result = runner.invoke( - mcp, - ["deploy", ".", "--dry-run", "--output", "json", "--ks3-bucket", "agentengine-test"], - env={"AGENTENGINE_SERVER_URL": "http://example.com"}, - ) - - assert result.exit_code == 0, result.output - payload = json.loads(result.output) - assert payload["resource"] == "workflow" - assert payload["action"] == "deploy" - assert payload["kind"] == "dry_run" - assert payload["request"]["body"]["artifact_type"] == "Code" - assert payload["plan"]["artifact"]["reference"].startswith("ks3://agentengine-test/") - - -def test_mcp_deploy_dry_run_includes_explicit_network(monkeypatch, tmp_path: Path): - runner = CliRunner() - monkeypatch.chdir(tmp_path) - 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 build artifacts") - - monkeypatch.setattr(cmd_mcp, "_build_code_artifact", _should_not_build) - - result = runner.invoke( - mcp, - [ - "deploy", - ".", - "--dry-run", - "--output", - "json", - "--ks3-bucket", - "agentengine-test", - "--disable-public-access", - "--enable-vpc-access", - "--vpc-id", - "vpc-cli", - "--subnet-id", - "subnet-cli", - "--security-group-id", - "sg-cli", - "--availability-zone", - "cn-beijing-6b", - ], - env={"AGENTENGINE_SERVER_URL": "http://example.com"}, - ) - - assert result.exit_code == 0, result.output - payload = json.loads(result.output) - assert payload["request"]["body"]["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", - } - - -def test_openclaw_list_supports_dry_run(monkeypatch): - runner = CliRunner() - monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeDryRunClient) - - result = runner.invoke(openclaw, ["list", "--dry-run"]) - - assert result.exit_code == 0, result.output - assert "Dry Run Completed" in result.output - assert _FakeDryRunClient.last_init_kwargs.get("dry_run") is True - - -def test_openclaw_list_shows_account_region_summary(monkeypatch): - runner = CliRunner() - monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawListClient) - monkeypatch.setattr("ksadk.cli.cmd_openclaw._GLOBAL_ENV_CACHE", {}) - - result = runner.invoke( - openclaw, - ["list", "--region", "cn-beijing-6"], - env={"KSYUN_ACCOUNT_ID": "2000003485"}, - ) - - assert result.exit_code == 0, result.output - assert "OpenClaw 列表" in result.output - assert "账号: 2000003485" in result.output - assert "region: cn-beijing-6" in result.output - assert "总计: 145" in result.output - - -def test_openclaw_help_exposes_channel_and_gateway_commands(): - runner = CliRunner() - - result = runner.invoke(openclaw, ["--help"]) - - assert result.exit_code == 0, result.output - assert "channel" in result.output - assert "gateway" in result.output - assert "tui" in result.output - - -def test_openclaw_tui_help_states_no_local_openclaw_cli_required(): - runner = CliRunner() - - result = runner.invoke(openclaw, ["tui", "--help"]) - - assert result.exit_code == 0, result.output - assert "不需要本机安装 OpenClaw CLI" in result.output - - -def test_openclaw_tui_dry_run_does_not_resolve_or_connect(monkeypatch): - runner = CliRunner() - - async def _forbidden_resolve(*_args, **_kwargs): - raise AssertionError("agent detail should not be resolved") - - async def _forbidden_terminal(**_kwargs): - raise AssertionError("remote terminal should not be called") - - monkeypatch.setattr(cmd_openclaw, "_resolve_openclaw_detail_or_raise", _forbidden_resolve) - monkeypatch.setattr(cmd_openclaw, "run_terminal_session", _forbidden_terminal, raising=False) - - result = runner.invoke( - openclaw, - [ - "tui", - "ar-demo-1", - "--gateway-token", - "gw-token", - "--message", - "你好", - "--thinking", - "medium", - "--history-limit", - "50", - "--timeout-ms", - "30000", - "--deliver", - "--dry-run", - "--output", - "json", - ], - ) - - assert result.exit_code == 0, result.output - payload = json.loads(result.output) - assert payload["kind"] == "dry_run" - assert payload["resource"] == "openclaw" - assert payload["action"] == "tui" - assert payload["request"]["agent_ref"] == "ar-demo-1" - assert payload["request"]["mode"] == "tui" - assert payload["request"]["gateway_token_provided"] is True - assert payload["request"]["options"] == { - "message": "你好", - "thinking": "medium", - "history_limit": 50, - "timeout_ms": 30000, - "deliver": True, - } - assert "gw-token" not in result.output - - -def test_openclaw_tui_uses_gateway_token_for_native_terminal(monkeypatch): - runner = CliRunner() - captured: Dict[str, Any] = {} - - async def _fake_resolve(agent_ref, *, region): - assert agent_ref == "ar-demo-1" - assert region == "pre-online" - return "pre-online", { - "agent_id": "ar-demo-1", - "name": "demo-openclaw", - "status": "RUNNING", - "framework": "openclaw", - "endpoint": "https://openclaw.example.com", - "api_key": "ak-agentengine", - "openclaw_auth_mode": "token", - } - - async def _fake_terminal(**kwargs): - captured.update(kwargs) - return 0 - - monkeypatch.setattr(cmd_openclaw, "_resolve_openclaw_detail_or_raise", _fake_resolve) - monkeypatch.setattr(cmd_openclaw, "run_terminal_session", _fake_terminal, raising=False) - - result = runner.invoke( - openclaw, - [ - "tui", - "ar-demo-1", - "--region", - "pre-online", - "--gateway-token", - "gw-token", - "--session", - "sess-1", - "--message", - "你好", - "--thinking", - "medium", - "--history-limit", - "50", - "--timeout-ms", - "30000", - "--deliver", - ], - ) - - assert result.exit_code == 0, result.output - assert captured["endpoint"] == "https://openclaw.example.com" - assert captured["api_key"] == "gw-token" - assert captured["session_id"] == "sess-1" - assert captured["mode"] == "tui" - assert captured["argv"] == [] - assert captured["options"] == { - "message": "你好", - "thinking": "medium", - "history_limit": 50, - "timeout_ms": 30000, - "deliver": True, - } - - -def test_openclaw_tui_uses_state_gateway_token_for_native_terminal(monkeypatch, tmp_path: Path): - (tmp_path / ".agentengine.state").write_text( - yaml.safe_dump( - { - "agent_id": "ar-demo-1", - "type": "openclaw", - "framework": "openclaw", - "endpoint": "https://openclaw.example.com", - "api_key": "ak-agentengine", - "openclaw_auth_mode": "token", - "openclaw_gateway_token": "gw-token-from-state", - } - ), - encoding="utf-8", - ) - runner = CliRunner() - captured: Dict[str, Any] = {} - - async def _fake_terminal(**kwargs): - captured.update(kwargs) - return 0 - - monkeypatch.chdir(tmp_path) - monkeypatch.delenv("OPENCLAW_GATEWAY_TOKEN", raising=False) - monkeypatch.delenv("OPENCLAW_GATEWAY_PASSWORD", raising=False) - monkeypatch.setattr(cmd_openclaw, "run_terminal_session", _fake_terminal, raising=False) - - result = runner.invoke(openclaw, ["tui"]) - - assert result.exit_code == 0, result.output - assert captured["endpoint"] == "https://openclaw.example.com" - assert captured["api_key"] == "gw-token-from-state" - assert "gw-token-from-state" not in result.output - - -def test_openclaw_tui_requires_gateway_token_for_token_auth(monkeypatch): - runner = CliRunner() - - async def _fake_resolve(_agent_ref, *, region): - return region or "pre-online", { - "agent_id": "ar-demo-1", - "status": "RUNNING", - "framework": "openclaw", - "endpoint": "https://openclaw.example.com", - "api_key": "ak-agentengine", - "openclaw_auth_mode": "token", - } - - async def _forbidden_terminal(**_kwargs): - raise AssertionError("remote terminal should not be called") - - monkeypatch.delenv("OPENCLAW_GATEWAY_TOKEN", raising=False) - monkeypatch.delenv("OPENCLAW_GATEWAY_PASSWORD", raising=False) - monkeypatch.setattr(cmd_openclaw, "_resolve_openclaw_detail_or_raise", _fake_resolve) - monkeypatch.setattr(cmd_openclaw, "run_terminal_session", _forbidden_terminal, raising=False) - - result = runner.invoke(openclaw, ["tui", "ar-demo-1"]) - - assert result.exit_code != 0 - assert "OPENCLAW_GATEWAY_TOKEN" in result.output - - -def test_openclaw_channel_connect_help_separates_channel_specific_options(): - runner = CliRunner() - - result = runner.invoke(openclaw, ["channel", "connect", "--help"]) - - assert result.exit_code == 0, result.output - assert "微信:扫码登录" in result.output - assert "飞书:启动官方 onboarding 流程" in result.output - assert "WPS 协作:写入开放平台 appId/appSecret" in result.output - assert "仅 WPS 协作:开放平台应用 ID" in result.output - assert "仅微信:在本地浏览器额外打开二维码链接" in result.output - assert "--dm-policy=open 表示允许所有用户私聊" in result.output - - -def test_openclaw_gateway_ws_url_prints_dashboard_and_ws(monkeypatch): - runner = CliRunner() - monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawDetailClient) - monkeypatch.setattr("ksadk.cli.cmd_openclaw.OpenClawGatewayClient", _FakeGatewayClient) - - result = runner.invoke(openclaw, ["gateway", "ws-url", "ar-demo-1"]) - - assert result.exit_code == 0, result.output - assert "dashboard.example.com/s/lnk-demo" in result.output - assert "wss://" in result.output - assert "cookie-session" in result.output - - -def test_openclaw_gateway_logs_reads_agent_logs(monkeypatch): - runner = CliRunner() - monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawDetailClient) - - result = runner.invoke( - openclaw, - ["gateway", "logs", "ar-demo-1", "--instance", "oc-0", "--log-type", "stdout"], - ) - - assert result.exit_code == 0, result.output - assert "line-1" in result.output - assert _FakeOpenClawDetailClient.last_log_kwargs["instance"] == "oc-0" - assert _FakeOpenClawDetailClient.last_log_kwargs["log_type"] == "stdout" - - -def test_openclaw_gateway_doctor_checks_short_link_and_ws(monkeypatch): - runner = CliRunner() - monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawDetailClient) - monkeypatch.setattr("ksadk.cli.cmd_openclaw.OpenClawGatewayClient", _FakeGatewayClient) - - result = runner.invoke(openclaw, ["gateway", "doctor", "ar-demo-1"]) - - assert result.exit_code == 0, result.output - assert "dashboard_short_link" in result.output - assert "cookie_ws_handshake" in result.output - assert "gateway_rpc" in result.output - - -def test_openclaw_gateway_ws_url_allows_creating_when_gateway_is_reachable(monkeypatch): - runner = CliRunner() - monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawCreatingDetailClient) - monkeypatch.setattr("ksadk.cli.cmd_openclaw.OpenClawGatewayClient", _FakeGatewayClient) - - result = runner.invoke(openclaw, ["gateway", "ws-url", "ar-demo-1"]) - - assert result.exit_code == 0, result.output - assert "dashboard.example.com/s/lnk-demo" in result.output - assert "wss://" in result.output - - -def test_openclaw_gateway_doctor_continues_probe_when_status_is_creating(monkeypatch): - runner = CliRunner() - monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawCreatingDetailClient) - monkeypatch.setattr("ksadk.cli.cmd_openclaw.OpenClawGatewayClient", _FakeGatewayClient) - - result = runner.invoke(openclaw, ["gateway", "doctor", "ar-demo-1"]) - - assert result.exit_code == 0, result.output - assert '"status": "CREATING"' in result.output - assert '"dashboard_short_link"' in result.output - assert '"ok": true' in result.output.lower() - - -def test_openclaw_gateway_doctor_fix_uses_control_plane_repair_for_failed_runtime(monkeypatch): - runner = CliRunner() - _FakeOpenClawFailedDetailClient.last_repair_kwargs = {} - monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawFailedDetailClient) - - result = runner.invoke(openclaw, ["gateway", "doctor", "ar-demo-1", "--fix"]) - - assert result.exit_code == 0, result.output - assert '"repair_action": "doctor-fix"' in result.output - assert _FakeOpenClawFailedDetailClient.last_repair_kwargs == { - "agent_id": "ar-demo-1", - "repair_action": "doctor-fix", - } - - -def test_openclaw_repair_command_runs_doctor_fix_via_control_plane(monkeypatch): - runner = CliRunner() - _FakeOpenClawFailedDetailClient.last_repair_kwargs = {} - monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawFailedDetailClient) - - result = runner.invoke(openclaw, ["repair", "ar-demo-1"]) - - assert result.exit_code == 0, result.output - assert '"repair_action": "doctor-fix"' in result.output - assert _FakeOpenClawFailedDetailClient.last_repair_kwargs == { - "agent_id": "ar-demo-1", - "repair_action": "doctor-fix", - } - - -def test_openclaw_channel_status_uses_gateway_snapshot(monkeypatch): - runner = CliRunner() - monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawDetailClient) - monkeypatch.setattr("ksadk.cli.cmd_openclaw.OpenClawGatewayClient", _FakeGatewayClient) - - result = runner.invoke(openclaw, ["channel", "status", "ar-demo-1", "--channel", "weixin", "--probe"]) - - assert result.exit_code == 0, result.output - assert '"connected": true' in result.output.lower() - assert '"probe": true' in result.output.lower() - - -def test_openclaw_channel_status_allows_creating_when_gateway_is_reachable(monkeypatch): - runner = CliRunner() - monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawCreatingDetailClient) - monkeypatch.setattr("ksadk.cli.cmd_openclaw.OpenClawGatewayClient", _FakeGatewayClient) - - result = runner.invoke(openclaw, ["channel", "status", "ar-demo-1", "--channel", "weixin"]) - - assert result.exit_code == 0, result.output - assert '"connected": true' in result.output.lower() - - -def test_openclaw_channel_enable_updates_weixin_account_config(monkeypatch): - runner = CliRunner() - _FakeGatewayClient.applied_configs = [] - async def _fake_sleep(*_args, **_kwargs): - return None - monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawDetailClient) - monkeypatch.setattr("ksadk.cli.cmd_openclaw.OpenClawGatewayClient", _FakeGatewayClient) - monkeypatch.setattr("ksadk.cli.cmd_openclaw.asyncio.sleep", _fake_sleep) - - result = runner.invoke( - openclaw, - ["channel", "enable", "ar-demo-1", "--channel", "weixin", "--account-id", "wx-demo"], - ) - - assert result.exit_code == 0, result.output - assert _FakeGatewayClient.applied_configs - config = _FakeGatewayClient.applied_configs[-1]["config"] - assert config["channels"]["openclaw-weixin"]["accounts"]["wx-demo"]["enabled"] is True - - -def test_openclaw_channel_connect_weixin_prints_qr_url(monkeypatch): - runner = CliRunner() - _FakeGatewayClient.applied_configs = [] - _FakeGatewayClient.last_wait_kwargs = {} - _FakeGatewayClient.disconnect_waits = [] - async def _fake_sleep(*_args, **_kwargs): - return None - monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawDetailClient) - monkeypatch.setattr("ksadk.cli.cmd_openclaw.OpenClawGatewayClient", _FakeGatewayClient) - monkeypatch.setattr("ksadk.cli.cmd_openclaw.asyncio.sleep", _fake_sleep) - - result = runner.invoke(openclaw, ["channel", "connect", "ar-demo-1", "--channel", "weixin"]) - - assert result.exit_code == 0, result.output - assert "https://qr.example.com/weixin-login" in result.output - assert _FakeGatewayClient.applied_configs - config = _FakeGatewayClient.applied_configs[-1]["config"] - assert config["plugins"]["entries"]["openclaw-weixin"]["enabled"] is True - assert config["channels"]["openclaw-weixin"]["accounts"]["default"]["enabled"] is True - assert _FakeGatewayClient.last_wait_kwargs["account_id"] == "sess-1" - - -def test_openclaw_channel_connect_weixin_waits_for_gateway_restart(monkeypatch): - runner = CliRunner() - _FakeRestartingWeixinGatewayClient.applied_configs = [] - _FakeRestartingWeixinGatewayClient.last_wait_kwargs = {} - _FakeRestartingWeixinGatewayClient.disconnect_waits = [] - - async def _fake_sleep(*_args, **_kwargs): - return None - - monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawDetailClient) - monkeypatch.setattr("ksadk.cli.cmd_openclaw.OpenClawGatewayClient", _FakeRestartingWeixinGatewayClient) - monkeypatch.setattr("ksadk.cli.cmd_openclaw.asyncio.sleep", _fake_sleep) - - result = runner.invoke(openclaw, ["channel", "connect", "ar-demo-1", "--channel", "weixin"]) - - assert result.exit_code == 0, result.output - assert _FakeRestartingWeixinGatewayClient.disconnect_waits == [5_000] - assert _FakeRestartingWeixinGatewayClient.last_wait_kwargs["account_id"] == "sess-1" - - -def test_openclaw_channel_connect_weixin_maps_session_key_to_account_id(monkeypatch): - runner = CliRunner() - _FakeGatewayClient.last_wait_kwargs = {} - _FakeGatewayClient.disconnect_waits = [] - - async def _fake_sleep(*_args, **_kwargs): - return None - - monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawDetailClient) - monkeypatch.setattr("ksadk.cli.cmd_openclaw.OpenClawGatewayClient", _FakeGatewayClient) - monkeypatch.setattr("ksadk.cli.cmd_openclaw.asyncio.sleep", _fake_sleep) - - result = runner.invoke(openclaw, ["channel", "connect", "ar-demo-1", "--channel", "weixin"]) - - assert result.exit_code == 0, result.output - assert _FakeGatewayClient.last_wait_kwargs == { - "account_id": "sess-1", - "session_key": None, - "timeout_ms": 120_000, - } - - -def test_openclaw_channel_connect_weixin_falls_back_to_remote_cli_without_web_login_rpc(monkeypatch): - runner = CliRunner() - _FakeWeixinGatewayWithoutWebLoginClient.applied_configs = [] - _FakeWeixinGatewayWithoutWebLoginClient.disconnect_waits = [] - captured: Dict[str, Any] = {} - - async def _fake_sleep(*_args, **_kwargs): - return None - - async def _fake_terminal(**kwargs): - captured.update(kwargs) - return 0 - - monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawDetailClient) - monkeypatch.setattr("ksadk.cli.cmd_openclaw.OpenClawGatewayClient", _FakeWeixinGatewayWithoutWebLoginClient) - monkeypatch.setattr("ksadk.cli.cmd_openclaw.asyncio.sleep", _fake_sleep) - monkeypatch.setattr(cmd_openclaw, "run_terminal_session", _fake_terminal, raising=False) - - result = runner.invoke(openclaw, ["channel", "connect", "ar-demo-1", "--channel", "weixin"]) - - assert result.exit_code == 0, result.output - assert captured["endpoint"] == "https://openclaw.example.com" - assert captured["api_key"] is None - assert captured["mode"] == "exec" - assert captured["argv"] == ["openclaw", "channels", "login", "--channel", "openclaw-weixin"] - assert _FakeWeixinGatewayWithoutWebLoginClient.applied_configs - config = _FakeWeixinGatewayWithoutWebLoginClient.applied_configs[-1]["config"] - assert config["plugins"]["entries"]["openclaw-weixin"]["enabled"] is True - assert config["channels"]["openclaw-weixin"]["accounts"]["default"]["enabled"] is True - assert '"mode": "remote_cli"' in result.output - - -def test_openclaw_channel_connect_weixin_falls_back_to_remote_cli_when_provider_unavailable(monkeypatch): - runner = CliRunner() - captured: Dict[str, Any] = {} - - async def _fake_sleep(*_args, **_kwargs): - return None - - async def _fake_terminal(**kwargs): - captured.update(kwargs) - return 0 - - monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawDetailClient) - monkeypatch.setattr("ksadk.cli.cmd_openclaw.OpenClawGatewayClient", _FakeWeixinGatewayProviderUnavailableClient) - monkeypatch.setattr("ksadk.cli.cmd_openclaw.asyncio.sleep", _fake_sleep) - monkeypatch.setattr(cmd_openclaw, "run_terminal_session", _fake_terminal, raising=False) - - result = runner.invoke(openclaw, ["channel", "connect", "ar-demo-1", "--channel", "weixin"]) - - assert result.exit_code == 0, result.output - assert captured["mode"] == "exec" - assert captured["argv"] == ["openclaw", "channels", "login", "--channel", "openclaw-weixin"] - assert "web login provider is not available" in result.output - - -def test_openclaw_channel_connect_feishu_applies_remote_config(monkeypatch): - runner = CliRunner() - _FakeGatewayClient.applied_configs = [] - - async def _fake_sleep(*_args, **_kwargs): - return None - - async def _fake_onboarding(existing_app_id): - assert existing_app_id is None - return { - "appId": "cli-app-id", - "appSecret": "cli-app-secret", - "domain": "lark", - "userInfo": {"openId": "ou_demo"}, - } - - monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawDetailClient) - monkeypatch.setattr("ksadk.cli.cmd_openclaw.OpenClawGatewayClient", _FakeGatewayClient) - monkeypatch.setattr("ksadk.cli.cmd_openclaw.asyncio.sleep", _fake_sleep) - monkeypatch.setattr("ksadk.cli.cmd_openclaw._run_feishu_onboarding", _fake_onboarding) - - result = runner.invoke(openclaw, ["channel", "connect", "ar-demo-1", "--channel", "feishu"]) - - assert result.exit_code == 0, result.output - assert _FakeGatewayClient.applied_configs - config = _FakeGatewayClient.applied_configs[-1]["config"] - assert config["plugins"]["entries"]["openclaw-lark"]["enabled"] is True - assert config["channels"]["feishu"]["enabled"] is True - assert config["channels"]["feishu"]["appId"] == "cli-app-id" - assert config["channels"]["feishu"]["appSecret"] == "cli-app-secret" - assert config["channels"]["feishu"]["domain"] == "lark" - assert config["channels"]["feishu"]["allowFrom"] == ["ou_demo"] - assert config["channels"]["feishu"]["groupAllowFrom"] == ["ou_demo"] - - -def test_should_auto_open_browser_on_local_macos(monkeypatch): - from ksadk.cli.cmd_openclaw import _should_auto_open_browser - - monkeypatch.delenv("SSH_TTY", raising=False) - monkeypatch.delenv("SSH_CONNECTION", raising=False) - monkeypatch.setattr("ksadk.cli.cmd_openclaw.sys.platform", "darwin") - - assert _should_auto_open_browser() is True - - -def test_should_not_auto_open_browser_over_ssh(monkeypatch): - from ksadk.cli.cmd_openclaw import _should_auto_open_browser - - monkeypatch.setenv("SSH_TTY", "/dev/pts/1") - monkeypatch.setattr("ksadk.cli.cmd_openclaw.sys.platform", "darwin") - - assert _should_auto_open_browser() is False - - -def test_openclaw_channel_connect_wps_xiezuo_applies_flat_remote_config(monkeypatch): - runner = CliRunner() - _FakeGatewayClient.applied_configs = [] - - async def _fake_sleep(*_args, **_kwargs): - return None - - monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawDetailClient) - monkeypatch.setattr("ksadk.cli.cmd_openclaw.OpenClawGatewayClient", _FakeGatewayClient) - monkeypatch.setattr("ksadk.cli.cmd_openclaw.asyncio.sleep", _fake_sleep) - - result = runner.invoke( - openclaw, - [ - "channel", - "connect", - "ar-demo-1", - "--channel", - "wps-xiezuo", - "--app-id", - "app-demo", - "--app-secret", - "secret-demo", - "--dm-policy", - "open", - ], - ) - - assert result.exit_code == 0, result.output - assert _FakeGatewayClient.applied_configs - config = _FakeGatewayClient.applied_configs[-1]["config"] - assert config["plugins"]["entries"]["wps-xiezuo"]["enabled"] is True - assert "wps-xiezuo" in config["plugins"]["allow"] - channel = config["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["dmPolicy"] == "open" - assert channel["allowFrom"] == ["*"] - assert channel["groupPolicy"] == "open" - assert channel["sdk"] == {"enabled": True, "logLevel": "info"} - 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 config["bindings"] == [ - {"type": "route", "agentId": "main", "match": {"channel": "wps-xiezuo"}} - ] - - -def test_openclaw_channel_connect_wps_xiezuo_tolerates_reload_disconnect(monkeypatch): - runner = CliRunner() - _FakeConfigApplyReloadGatewayClient.applied_configs = [] - - async def _fake_sleep(*_args, **_kwargs): - return None - - monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawDetailClient) - monkeypatch.setattr("ksadk.cli.cmd_openclaw.OpenClawGatewayClient", _FakeConfigApplyReloadGatewayClient) - monkeypatch.setattr("ksadk.cli.cmd_openclaw.asyncio.sleep", _fake_sleep) - - result = runner.invoke( - openclaw, - [ - "channel", - "connect", - "ar-demo-1", - "--channel", - "wps-xiezuo", - "--app-id", - "app-demo", - "--app-secret", - "secret-demo", - "--dm-policy", - "open", - ], - ) - - assert result.exit_code == 0, result.output - assert "gateway reload 期间连接短暂中断" in result.output - assert _FakeConfigApplyReloadGatewayClient.applied_configs - config = _FakeConfigApplyReloadGatewayClient.applied_configs[-1]["config"] - assert config["channels"]["wps-xiezuo"]["appId"] == "app-demo" - - -def test_openclaw_channel_connect_wps_xiezuo_rejects_non_default_account(monkeypatch): - runner = CliRunner() - _FakeGatewayClient.applied_configs = [] - - async def _fake_sleep(*_args, **_kwargs): - return None - - monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawDetailClient) - monkeypatch.setattr("ksadk.cli.cmd_openclaw.OpenClawGatewayClient", _FakeGatewayClient) - monkeypatch.setattr("ksadk.cli.cmd_openclaw.asyncio.sleep", _fake_sleep) - - result = runner.invoke( - openclaw, - [ - "channel", - "connect", - "ar-demo-1", - "--channel", - "wps-xiezuo", - "--app-id", - "app-demo", - "--app-secret", - "secret-demo", - "--account-id", - "tenant-a", - ], - ) - - assert result.exit_code != 0 - assert "仅支持 default" in result.output - - -def test_openclaw_channel_connect_wps_xiezuo_requires_app_secret_when_dm_disabled(monkeypatch): - runner = CliRunner() - - async def _fake_sleep(*_args, **_kwargs): - return None - - monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawDetailClient) - monkeypatch.setattr("ksadk.cli.cmd_openclaw.OpenClawGatewayClient", _FakeGatewayClient) - monkeypatch.setattr("ksadk.cli.cmd_openclaw.asyncio.sleep", _fake_sleep) - - result = runner.invoke( - openclaw, - [ - "channel", - "connect", - "ar-demo-1", - "--channel", - "wps-xiezuo", - "--app-id", - "app-demo", - "--dm-policy", - "disabled", - ], - ) - - assert result.exit_code != 0 - assert "必须提供 --app-secret" in result.output - - -def test_openclaw_channel_disable_wps_xiezuo_updates_flat_channel(monkeypatch): - runner = CliRunner() - _FakeGatewayClient.applied_configs = [] - - async def _fake_sleep(*_args, **_kwargs): - return None - - monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawDetailClient) - monkeypatch.setattr("ksadk.cli.cmd_openclaw.OpenClawGatewayClient", _FakeGatewayClient) - monkeypatch.setattr("ksadk.cli.cmd_openclaw.asyncio.sleep", _fake_sleep) - - result = runner.invoke(openclaw, ["channel", "disable", "ar-demo-1", "--channel", "wps-xiezuo"]) - - assert result.exit_code == 0, result.output - assert _FakeGatewayClient.applied_configs - config = _FakeGatewayClient.applied_configs[-1]["config"] - assert config["channels"]["wps-xiezuo"]["enabled"] is False - assert "accounts" not in config["channels"]["wps-xiezuo"] - - -def test_openclaw_channel_doctor_checks_snapshot_and_local_node(monkeypatch): - runner = CliRunner() - monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawDetailClient) - monkeypatch.setattr("ksadk.cli.cmd_openclaw.OpenClawGatewayClient", _FakeDoctorGatewayClient) - monkeypatch.setattr( - "ksadk.cli.cmd_openclaw.shutil.which", - lambda cmd: f"/usr/bin/{cmd}" if cmd in {"node", "npx"} else None, - ) - - result = runner.invoke(openclaw, ["channel", "doctor", "ar-demo-1", "--channel", "feishu"]) - - assert result.exit_code == 0, result.output - assert "feishu_plugin_visible" in result.output - assert "feishu_status_snapshot" in result.output - assert "feishu_local_node" in result.output - - -def test_openclaw_channel_doctor_checks_wps_xiezuo_plugin_and_deps(monkeypatch): - runner = CliRunner() - monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawDetailClient) - monkeypatch.setattr("ksadk.cli.cmd_openclaw.OpenClawGatewayClient", _FakeDoctorGatewayClient) - monkeypatch.setattr( - "ksadk.cli.cmd_openclaw._check_wps_xiezuo_local_deps", - lambda: { - "ok": True, - "node": "/usr/bin/node", - "npm": "/usr/bin/npm", - }, - ) - - result = runner.invoke(openclaw, ["channel", "doctor", "ar-demo-1", "--channel", "wps-xiezuo"]) - - assert result.exit_code == 0, result.output - assert "wps_xiezuo_plugin_visible" in result.output - assert "wps_xiezuo_status_snapshot" in result.output - assert "wps_xiezuo_local_deps" in result.output - - -def test_openclaw_channel_doctor_treats_unconfigured_channels_as_connect_required(monkeypatch): - runner = CliRunner() - monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawDetailClient) - monkeypatch.setattr("ksadk.cli.cmd_openclaw.OpenClawGatewayClient", _FakeDoctorFreshGatewayClient) - monkeypatch.setattr( - "ksadk.cli.cmd_openclaw.shutil.which", - lambda cmd: f"/usr/bin/{cmd}" if cmd in {"node", "npx"} else None, - ) - monkeypatch.setattr( - "ksadk.cli.cmd_openclaw._check_wps_xiezuo_local_deps", - lambda: {"ok": True, "node": "/usr/bin/node", "npm": "/usr/bin/npm"}, - ) - - result = runner.invoke(openclaw, ["channel", "doctor", "ar-demo-1", "--output", "json"]) - - assert result.exit_code == 0, result.output - payload = json.loads(result.output) - checks = {item["name"]: item for item in payload["checks"]} - assert payload["ok"] is False - assert checks["weixin_qr_rpc"]["ok"] is False - assert checks["weixin_qr_rpc"]["state"] == "connect_required" - assert checks["feishu_status_snapshot"]["state"] == "connect_required" - assert checks["wps_xiezuo_status_snapshot"]["state"] == "connect_required" - - -def test_openclaw_channel_doctor_keeps_configured_weixin_qr_rpc_as_hard_failure(monkeypatch): - runner = CliRunner() - monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawDetailClient) - monkeypatch.setattr("ksadk.cli.cmd_openclaw.OpenClawGatewayClient", _FakeDoctorBrokenWeixinGatewayClient) - monkeypatch.setattr( - "ksadk.cli.cmd_openclaw.shutil.which", - lambda cmd: f"/usr/bin/{cmd}" if cmd in {"node", "npx"} else None, - ) - monkeypatch.setattr( - "ksadk.cli.cmd_openclaw._check_wps_xiezuo_local_deps", - lambda: {"ok": True, "node": "/usr/bin/node", "npm": "/usr/bin/npm"}, - ) - - result = runner.invoke(openclaw, ["channel", "doctor", "ar-demo-1", "--channel", "weixin", "--output", "json"]) - - assert result.exit_code == 0, result.output - payload = json.loads(result.output) - checks = {item["name"]: item for item in payload["checks"]} - assert payload["ok"] is False - assert checks["weixin_qr_rpc"]["state"] == "missing" - - -def test_openclaw_deploy_supports_security_profile_flags(monkeypatch): - runner = CliRunner() - captured: Dict[str, Any] = {} - - async def _fake_deploy_openclaw(**kwargs): - captured.update(kwargs) - - monkeypatch.setattr("ksadk.cli.cmd_openclaw._deploy_openclaw", _fake_deploy_openclaw) - monkeypatch.setattr( - "ksadk.cli.cmd_openclaw.run_async_with_dry_run", - lambda coro, dry_run: asyncio.run(coro), - ) - - result = runner.invoke(openclaw, ["deploy", "--strictest"]) - - assert result.exit_code == 0, result.output - assert captured["security_profile"] == "strictest" - - -def test_openclaw_deploy_forwards_custom_env_pairs(monkeypatch): - runner = CliRunner() - captured: Dict[str, Any] = {} - - async def _fake_deploy_openclaw(**kwargs): - captured.update(kwargs) - - monkeypatch.setattr("ksadk.cli.cmd_openclaw._deploy_openclaw", _fake_deploy_openclaw) - monkeypatch.setattr( - "ksadk.cli.cmd_openclaw.run_async_with_dry_run", - lambda coro, dry_run: asyncio.run(coro), - ) - - result = runner.invoke( - openclaw, - [ - "deploy", - "--env", - "FOO=bar", - "--env", - "OPENCLAW_GATEWAY_PORT=9090", - ], - ) - - assert result.exit_code == 0, result.output - assert captured["extra_env"] == ("FOO=bar", "OPENCLAW_GATEWAY_PORT=9090") - - -def test_openclaw_deploy_forwards_explicit_memory_config(monkeypatch): - runner = CliRunner() - captured: Dict[str, Any] = {} - - async def _fake_deploy_openclaw(**kwargs): - captured.update(kwargs) - - monkeypatch.setattr("ksadk.cli.cmd_openclaw._deploy_openclaw", _fake_deploy_openclaw) - monkeypatch.setattr( - "ksadk.cli.cmd_openclaw.run_async_with_dry_run", - lambda coro, dry_run: asyncio.run(coro), - ) - - result = runner.invoke( - openclaw, - [ - "deploy", - "--memory-system", - "mem0", - "--mem0-instance-id", - "c17b20b1-faf7-4c98-91a7-38d1ee581ba1", - "--mem0-instance-name", - "mem-demo", - "--mem0-region", - "pre-online", - ], - ) - - assert result.exit_code == 0, result.output - assert captured["memory_system"] == "mem0" - assert captured["mem0_instance_id"] == "c17b20b1-faf7-4c98-91a7-38d1ee581ba1" - assert captured["mem0_instance_name"] == "mem-demo" - assert captured["mem0_region"] == "pre-online" - - -def test_openclaw_deploy_forwards_network_cli_options(monkeypatch): - runner = CliRunner() - captured: Dict[str, Any] = {} - - async def _fake_deploy_openclaw(**kwargs): - captured.update(kwargs) - - monkeypatch.setattr("ksadk.cli.cmd_openclaw._deploy_openclaw", _fake_deploy_openclaw) - monkeypatch.setattr( - "ksadk.cli.cmd_openclaw.run_async_with_dry_run", - lambda coro, dry_run: asyncio.run(coro), - ) - - result = runner.invoke( - openclaw, - [ - "deploy", - "--disable-public-access", - "--enable-vpc-access", - "--vpc-id", - "vpc-cli", - "--subnet-id", - "subnet-cli", - "--security-group-id", - "sg-cli", - "--availability-zone", - "cn-beijing-6b", - ], - ) - - assert result.exit_code == 0, result.output - assert captured["enable_public_access"] is False - assert captured["enable_vpc_access"] is True - assert captured["vpc_id"] == "vpc-cli" - assert captured["subnet_id"] == "subnet-cli" - assert captured["security_group_id"] == "sg-cli" - assert captured["availability_zone"] == "cn-beijing-6b" - - -def test_openclaw_deploy_rejects_mem0_without_instance_id(): - runner = CliRunner() - - result = runner.invoke( - openclaw, - [ - "deploy", - "--memory-system", - "mem0", - ], - ) - - assert result.exit_code != 0 - assert "--mem0-instance-id" in result.output - - -def test_openclaw_default_image_ref_tracks_current_runtime_tag(): - from ksadk.cli.cmd_openclaw import _resolve_image_ref - - assert _resolve_image_ref(None) == "ghcr.io/kingsoftcloud/agentengine-public/openclaw:2026.6.1" - - -def test_openclaw_deploy_create_payload_includes_network(monkeypatch, tmp_path): - runner = CliRunner() - monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawCreateClient) - monkeypatch.setattr("ksadk.cli.cmd_openclaw._GLOBAL_ENV_CACHE", {}) - monkeypatch.chdir(tmp_path) - _FakeOpenClawCreateClient.create_payload = None - _FakeOpenClawCreateClient.update_payload = None - _FakeOpenClawCreateClient.get_agent_calls = 0 - - result = runner.invoke( - openclaw, - [ - "deploy", - "--name", - "demo-openclaw", - "--image", - "ghcr.io/kingsoftcloud/agentengine-public/openclaw:test", - "--disable-public-access", - "--enable-vpc-access", - "--vpc-id", - "vpc-cli", - "--subnet-id", - "subnet-cli", - "--security-group-id", - "sg-cli", - "--availability-zone", - "cn-beijing-6b", - ], - ) - - assert result.exit_code == 0, result.output - assert _FakeOpenClawCreateClient.create_payload["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", - } - - -def test_openclaw_deploy_uses_init_project_name_when_name_is_omitted(monkeypatch, tmp_path): - runner = CliRunner() - monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawCreateClient) - monkeypatch.setattr("ksadk.cli.cmd_openclaw._GLOBAL_ENV_CACHE", {}) - monkeypatch.chdir(tmp_path) - (tmp_path / "agentengine.yaml").write_text( - yaml.safe_dump( - { - "name": "custom-openclaw", - "framework": "openclaw", - "entry_point": "custom_openclaw/agent.py", - } - ), - encoding="utf-8", - ) - _FakeOpenClawCreateClient.create_payload = None - _FakeOpenClawCreateClient.update_payload = None - _FakeOpenClawCreateClient.get_agent_calls = 0 - - result = runner.invoke( - openclaw, - [ - "deploy", - "--image", - "ghcr.io/kingsoftcloud/agentengine-public/openclaw:test", - ], - ) - - assert result.exit_code == 0, result.output - assert _FakeOpenClawCreateClient.create_payload["name"] == "custom-openclaw" - - -def test_openclaw_deploy_update_payload_includes_network(monkeypatch, tmp_path): - runner = CliRunner() - monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawCreateClient) - monkeypatch.setattr("ksadk.cli.cmd_openclaw._GLOBAL_ENV_CACHE", {}) - monkeypatch.chdir(tmp_path) - (tmp_path / ".agentengine.state").write_text( - yaml.safe_dump( - { - "type": "openclaw", - "agent_id": "ar-existing-1", - "name": "demo-openclaw", - "endpoint": "https://existing.example.com", - "api_key": "ak-existing", - } - ), - encoding="utf-8", - ) - _FakeOpenClawCreateClient.create_payload = None - _FakeOpenClawCreateClient.update_payload = None - _FakeOpenClawCreateClient.get_agent_calls = 0 - - result = runner.invoke( - openclaw, - [ - "deploy", - "--name", - "demo-openclaw", - "--image", - "ghcr.io/kingsoftcloud/agentengine-public/openclaw:test", - "--enable-vpc-access", - "--vpc-id", - "vpc-cli", - "--subnet-id", - "subnet-cli", - "--security-group-id", - "sg-cli", - ], - ) - - assert result.exit_code == 0, result.output - assert _FakeOpenClawCreateClient.create_payload is None - assert _FakeOpenClawCreateClient.update_payload["network"] == { - "enable_vpc_access": True, - "vpc_id": "vpc-cli", - "subnet_id": "subnet-cli", - "security_group_id": "sg-cli", - } - - -def test_openclaw_deploy_update_payload_preserves_existing_config_by_default(monkeypatch, tmp_path): - runner = CliRunner() - monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawCreateClient) - monkeypatch.setattr("ksadk.cli.cmd_openclaw._GLOBAL_ENV_CACHE", {}) - monkeypatch.chdir(tmp_path) - (tmp_path / ".agentengine.state").write_text( - yaml.safe_dump( - { - "type": "openclaw", - "agent_id": "ar-existing-1", - "name": "demo-openclaw", - "endpoint": "https://existing.example.com", - "api_key": "ak-existing", - } - ), - encoding="utf-8", - ) - _FakeOpenClawCreateClient.create_payload = None - _FakeOpenClawCreateClient.update_payload = None - _FakeOpenClawCreateClient.get_agent_calls = 0 - - result = runner.invoke( - openclaw, - [ - "deploy", - "--image", - "ghcr.io/kingsoftcloud/agentengine-public/openclaw:new", - ], - ) - - assert result.exit_code == 0, result.output - assert _FakeOpenClawCreateClient.create_payload is None - assert _FakeOpenClawCreateClient.update_payload["artifact_path"] == ( - "ghcr.io/kingsoftcloud/agentengine-public/openclaw:new" - ) - assert "env_vars" not in _FakeOpenClawCreateClient.update_payload - assert "storage" not in _FakeOpenClawCreateClient.update_payload - assert "network" not in _FakeOpenClawCreateClient.update_payload - assert "memory_config" not in _FakeOpenClawCreateClient.update_payload - - -def test_openclaw_deploy_update_payload_includes_explicit_config(monkeypatch, tmp_path): - runner = CliRunner() - monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawCreateClient) - monkeypatch.setattr("ksadk.cli.cmd_openclaw._GLOBAL_ENV_CACHE", {}) - monkeypatch.chdir(tmp_path) - (tmp_path / ".agentengine.state").write_text( - yaml.safe_dump( - { - "type": "openclaw", - "agent_id": "ar-existing-1", - "name": "demo-openclaw", - "endpoint": "https://existing.example.com", - "api_key": "ak-existing", - } - ), - encoding="utf-8", - ) - _FakeOpenClawCreateClient.create_payload = None - _FakeOpenClawCreateClient.update_payload = None - _FakeOpenClawCreateClient.get_agent_calls = 0 - - result = runner.invoke( - openclaw, - [ - "deploy", - "--image", - "ghcr.io/kingsoftcloud/agentengine-public/openclaw:new", - "--model-base-url", - "https://model.example.com/v1", - "--default-model", - "glm-test", - "--env", - "APP_MODE=prod", - "--storage-size-gi", - "50", - "--memory-system", - "openclaw_default", - ], - ) - - assert result.exit_code == 0, result.output - payload = _FakeOpenClawCreateClient.update_payload - assert any(item["Key"] == "APP_MODE" and item["Value"] == "prod" for item in payload["env_vars"]) - assert payload["storage"]["size_gi"] == 50 - assert payload["memory_config"] == {"memory_system": "openclaw_default"} - - -def test_openclaw_deploy_network_ids_imply_vpc_access(monkeypatch, tmp_path): - runner = CliRunner() - monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawCreateClient) - monkeypatch.setattr("ksadk.cli.cmd_openclaw._GLOBAL_ENV_CACHE", {}) - monkeypatch.chdir(tmp_path) - _FakeOpenClawCreateClient.create_payload = None - _FakeOpenClawCreateClient.update_payload = None - _FakeOpenClawCreateClient.get_agent_calls = 0 - - result = runner.invoke( - openclaw, - [ - "deploy", - "--name", - "demo-openclaw", - "--image", - "ghcr.io/kingsoftcloud/agentengine-public/openclaw:test", - "--vpc-id", - "vpc-cli", - "--subnet-id", - "subnet-cli", - "--security-group-id", - "sg-cli", - ], - ) - - assert result.exit_code == 0, result.output - assert _FakeOpenClawCreateClient.create_payload["network"]["enable_vpc_access"] is True - - -def test_openclaw_deploy_rejects_incomplete_vpc_network(): - runner = CliRunner() - - result = runner.invoke( - openclaw, - [ - "deploy", - "--enable-vpc-access", - "--vpc-id", - "vpc-cli", - ], - ) - - assert result.exit_code != 0 - assert "VpcId、SubnetId、SecurityGroupId" in result.output - - -def test_openclaw_deploy_does_not_query_get_agent_when_quick_access_is_already_complete(monkeypatch, tmp_path): - runner = CliRunner() - monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawCreateClient) - monkeypatch.setattr("ksadk.cli.cmd_openclaw._GLOBAL_ENV_CACHE", {}) - monkeypatch.chdir(tmp_path) - _FakeOpenClawCreateClient.get_agent_calls = 0 - - result = runner.invoke( - openclaw, - [ - "deploy", - "--name", - "demo-openclaw", - "--image", - "ghcr.io/kingsoftcloud/agentengine-public/openclaw:test", - ], - ) - - assert result.exit_code == 0, result.output - assert _FakeOpenClawCreateClient.get_agent_calls == 0 - - -def test_openclaw_deploy_persists_gateway_token_from_extra_env(monkeypatch, tmp_path): - runner = CliRunner() - monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawCreateClient) - monkeypatch.setattr("ksadk.cli.cmd_openclaw._GLOBAL_ENV_CACHE", {}) - monkeypatch.chdir(tmp_path) - _FakeOpenClawCreateClient.get_agent_calls = 0 - - result = runner.invoke( - openclaw, - [ - "deploy", - "--name", - "demo-openclaw", - "--image", - "ghcr.io/kingsoftcloud/agentengine-public/openclaw:test", - "--env", - "OPENCLAW_GATEWAY_AUTH_MODE=token", - "--env", - "OPENCLAW_GATEWAY_TOKEN=gw-token-from-deploy", - ], - ) - - assert result.exit_code == 0, result.output - assert "gw-token-from-deploy" not in result.output - state = yaml.safe_load((tmp_path / ".agentengine.state").read_text()) - assert state["openclaw_auth_mode"] == "token" - assert state["openclaw_gateway_token"] == "gw-token-from-deploy" - - -def test_openclaw_deploy_writes_only_configured_model_from_provider_catalog(monkeypatch, tmp_path): - runner = CliRunner() - monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawCreateClient) - monkeypatch.setattr("ksadk.cli.cmd_openclaw._GLOBAL_ENV_CACHE", {}) - monkeypatch.chdir(tmp_path) - monkeypatch.setenv("OPENAI_MODEL_NAME", "deepseek-v4-pro") - monkeypatch.setenv("OPENAI_BASE_URL", "https://kspmas.ksyun.com/v1") - monkeypatch.setenv("OPENAI_API_KEY", "sk-test") - _FakeOpenClawCreateClient.create_payload = None - _FakeOpenClawCreateClient.get_agent_calls = 0 - - async def _fake_fetch_provider_model_catalog(**_kwargs): - return [ - { - "id": "glm-5.1", - "context_window_tokens": 128_000, - "max_output_tokens": 8_192, - }, - { - "id": "deepseek-v4-pro", - "context_window_tokens": 1_000_000, - "max_output_tokens": 384_000, - }, - { - "id": "kimi-k2.6", - "context_window_tokens": 256_000, - "max_output_tokens": 32_000, - }, - ] - - monkeypatch.setattr(cmd_openclaw, "fetch_provider_model_catalog", _fake_fetch_provider_model_catalog) - - result = runner.invoke( - openclaw, - [ - "deploy", - "--name", - "demo-openclaw", - "--image", - "ghcr.io/kingsoftcloud/agentengine-public/openclaw:test", - ], - ) - - assert result.exit_code == 0, result.output - env_vars = { - item["Key"]: item["Value"] - for item in _FakeOpenClawCreateClient.create_payload["env_vars"] - } - catalog = json.loads(env_vars["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} - - -def test_openclaw_deploy_writes_allowlisted_models_from_provider_catalog(monkeypatch, tmp_path): - runner = CliRunner() - monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawCreateClient) - monkeypatch.setattr("ksadk.cli.cmd_openclaw._GLOBAL_ENV_CACHE", {}) - monkeypatch.chdir(tmp_path) - monkeypatch.setenv("OPENAI_MODEL_NAME", "deepseek-v4-pro") - monkeypatch.setenv("OPENAI_BASE_URL", "https://kspmas.ksyun.com/v1") - monkeypatch.setenv("OPENAI_API_KEY", "sk-test") - monkeypatch.setenv("OPENCLAW_MODEL_ALLOWLIST", "deepseek-v4-pro,glm-5.1") - _FakeOpenClawCreateClient.create_payload = None - _FakeOpenClawCreateClient.get_agent_calls = 0 - - async def _fake_fetch_provider_model_catalog(**_kwargs): - return [ - { - "id": "glm-5.1", - "context_window_tokens": 128_000, - "max_output_tokens": 8_192, - }, - { - "id": "deepseek-v4-pro", - "context_window_tokens": 1_000_000, - "max_output_tokens": 384_000, - }, - { - "id": "kimi-k2.6", - "context_window_tokens": 256_000, - "max_output_tokens": 32_000, - }, - ] - - monkeypatch.setattr(cmd_openclaw, "fetch_provider_model_catalog", _fake_fetch_provider_model_catalog) - - result = runner.invoke( - openclaw, - [ - "deploy", - "--name", - "demo-openclaw", - "--image", - "ghcr.io/kingsoftcloud/agentengine-public/openclaw:test", - ], - ) - - assert result.exit_code == 0, result.output - env_vars = { - item["Key"]: item["Value"] - for item in _FakeOpenClawCreateClient.create_payload["env_vars"] - } - catalog = json.loads(env_vars["OPENCLAW_MODEL_CATALOG_JSON"]) - 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} - - -def test_openclaw_deploy_refreshes_quick_access_when_agent_id_is_immediate(monkeypatch, tmp_path): - runner = CliRunner() - monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawImmediateAgentIdClient) - monkeypatch.setattr("ksadk.cli.cmd_openclaw._GLOBAL_ENV_CACHE", {}) - monkeypatch.chdir(tmp_path) - _FakeOpenClawImmediateAgentIdClient.get_agent_calls = 0 - - result = runner.invoke( - openclaw, - [ - "deploy", - "--name", - "demo-openclaw", - "--image", - "ghcr.io/kingsoftcloud/agentengine-public/openclaw:test", - ], - ) - - assert result.exit_code == 0, result.output - assert _FakeOpenClawImmediateAgentIdClient.get_agent_calls == 1 - state = yaml.safe_load((tmp_path / ".agentengine.state").read_text()) - assert state["agent_id"] == "ar-created-2" - assert state["endpoint"] == "https://fresh-openclaw.example.com" - assert state["api_key"] == "ak-fresh-openclaw" - - -def test_openclaw_deploy_retries_transient_get_agent_not_found_until_api_key_is_ready(monkeypatch, tmp_path): - runner = CliRunner() - monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawDelayedAccessClient) - monkeypatch.setattr("ksadk.cli.cmd_openclaw._GLOBAL_ENV_CACHE", {}) - monkeypatch.chdir(tmp_path) - _FakeOpenClawDelayedAccessClient.get_agent_calls = 0 - _FakeOpenClawDelayedAccessClient.suppression_used = False - - result = runner.invoke( - openclaw, - [ - "deploy", - "--name", - "demo-openclaw", - "--image", - "ghcr.io/kingsoftcloud/agentengine-public/openclaw:test", - ], - ) - - assert result.exit_code == 0, result.output - assert _FakeOpenClawDelayedAccessClient.suppression_used is True - assert _FakeOpenClawDelayedAccessClient.get_agent_calls == 4 - state = yaml.safe_load((tmp_path / ".agentengine.state").read_text()) - assert state["agent_id"] == "ar-created-delayed" - assert state["endpoint"] == "https://ready-openclaw.example.com" - assert state["api_key"] == "ak-ready-openclaw" - - -def test_openclaw_flatten_agent_detail_reads_framework_and_region_from_deployment(): - detail = cmd_openclaw._flatten_agent_detail( - { - "basic": { - "agent_id": "ar-openclaw-demo", - "name": "demo-openclaw", - "status": "running", - }, - "quick_access": { - "public_endpoint": "https://demo-openclaw.example.com", - "api_key": "ak-demo-openclaw", - }, - "deployment": { - "framework": "openclaw", - "region": "pre-online", - "artifact_path": "hub/openclaw:test", - }, - } - ) - - assert detail["framework"] == "openclaw" - assert detail["region"] == "pre-online" - assert detail["api_key"] == "ak-demo-openclaw" - - -def test_version_list_supports_dry_run(monkeypatch): - runner = CliRunner() - monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeDryRunClient) - monkeypatch.setenv("KSYUN_REGION", "cn-beijing-6") - - result = runner.invoke(version, ["list", "--agent", "demo-agent", "--dry-run"]) - - assert result.exit_code == 0, result.output - assert "Dry Run Completed" in result.output - assert _FakeDryRunClient.last_init_kwargs.get("dry_run") is True - - -def test_top_level_delete_accepts_force_alias(monkeypatch): - runner = CliRunner() - provider = _FakeDeleteProvider() - monkeypatch.setattr("ksadk.cli.cmd_destroy.DeploymentManager.get_provider", lambda *_args, **_kwargs: provider) - - result = runner.invoke( - destroy_delete, - ["ar-123", "--account-id", "2000003485", "--force", "--dry-run"], - ) - - assert result.exit_code == 0, result.output - assert provider.calls - assert provider.calls[0][0] == "ar-123" - - -def test_top_level_destroy_accepts_yes_alias(monkeypatch): - runner = CliRunner() - provider = _FakeDeleteProvider() - monkeypatch.setattr("ksadk.cli.cmd_destroy.DeploymentManager.get_provider", lambda *_args, **_kwargs: provider) - - result = runner.invoke( - destroy_cmd, - ["ar-456", "--account-id", "2000003485", "--yes", "--dry-run"], - ) - - assert result.exit_code == 0, result.output - assert provider.calls - assert provider.calls[0][0] == "ar-456" - - -def test_openclaw_destroy_accepts_force_alias(monkeypatch): - runner = CliRunner() - monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeDryRunClient) - - result = runner.invoke(openclaw, ["destroy", "ar-demo-1", "--force", "--dry-run"]) - - assert result.exit_code == 0, result.output - assert "Dry Run Completed" in result.output - assert _FakeDryRunClient.last_init_kwargs.get("dry_run") is True - - -def test_mcp_destroy_accepts_force_alias(monkeypatch): - runner = CliRunner() - monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeDryRunClient) - - result = runner.invoke( - mcp, - ["destroy", "mcp-123", "--force", "--dry-run"], - env={"AGENTENGINE_SERVER_URL": "http://example.com"}, - ) - - assert result.exit_code == 0, result.output - assert "Dry Run Completed" in result.output - assert _FakeDryRunClient.last_init_kwargs.get("dry_run") is True - - -def test_root_cli_registers_delete_alias(): - _register_commands() - assert "agent" in cli.commands - assert "delete" in cli.commands - assert "destroy" in cli.commands - assert cli.get_command(None, "delete").hidden is True - assert cli.get_command(None, "destroy").hidden is True - - -def test_root_help_shows_canonical_commands_only(): - runner = CliRunner() - _register_commands() - - result = runner.invoke(cli, ["--help"]) - - assert result.exit_code == 0, result.output - assert "agentengine agent" in result.output - assert "agentengine status" not in result.output - assert "agentengine invoke" not in result.output - assert "agentengine delete" not in result.output - assert "agentengine destroy" not in result.output - - -def test_agent_group_exposes_canonical_subcommands(): - runner = CliRunner() - - result = runner.invoke(agent, ["--help"]) - - assert result.exit_code == 0, result.output - assert "list" in result.output - assert "status" in result.output - assert "invoke" in result.output - assert "delete" in result.output - - -def test_root_status_all_routes_with_compatibility_hint(monkeypatch): - runner = CliRunner() - _register_commands() - monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeDryRunClient) - - result = runner.invoke( - cli, - ["status", "--all", "--account-id", "2000003485", "--dry-run"], - ) - - assert result.exit_code == 0, result.output - assert "agentengine agent list" in result.output - assert "Dry Run Completed" in result.output - - -def test_root_invoke_alias_still_callable_with_hint(monkeypatch): - runner = CliRunner() - _register_commands() - invoked = {} - - def fake_invoke_tui( - endpoint, - api_key, - session_id, - insecure, - model, - show_thinking, - api_format=None, - responses_session_header=None, - ): - return invoked.setdefault("endpoint", endpoint) - - monkeypatch.setattr( - "ksadk.cli.cmd_invoke._invoke_tui", - fake_invoke_tui, - ) - - result = runner.invoke(cli, ["invoke", "--endpoint", "http://demo.local"]) - - assert result.exit_code == 0, result.output - assert "agentengine agent invoke" in result.output - assert invoked["endpoint"] == "http://demo.local" - - -def test_legacy_root_help_points_to_canonical_commands(): - runner = CliRunner() - _register_commands() - - result = runner.invoke(cli, ["status", "--help"]) - - assert result.exit_code == 0, result.output - assert "这是兼容入口" in result.output - assert "agentengine agent status --help" in result.output - - -def test_top_level_delete_supports_multiple_ids(monkeypatch): - runner = CliRunner() - provider = _FakeDeleteProvider() - monkeypatch.setattr("ksadk.cli.cmd_destroy.DeploymentManager.get_provider", lambda *_args, **_kwargs: provider) - - result = runner.invoke( - destroy_delete, - ["ar-123", "ar-456", "--account-id", "2000003485", "--force", "--dry-run"], - ) - - assert result.exit_code == 0, result.output - assert [call[0] for call in provider.calls] == ["ar-123", "ar-456"] - - -def test_top_level_destroy_supports_repeated_agent_option(monkeypatch): - runner = CliRunner() - provider = _FakeDeleteProvider() - monkeypatch.setattr("ksadk.cli.cmd_destroy.DeploymentManager.get_provider", lambda *_args, **_kwargs: provider) - - result = runner.invoke( - destroy_cmd, - ["--agent", "ar-123", "--agent", "ar-456", "--account-id", "2000003485", "--yes", "--dry-run"], - ) - - assert result.exit_code == 0, result.output - assert [call[0] for call in provider.calls] == ["ar-123", "ar-456"] - - -def test_openclaw_destroy_supports_multiple_ids(monkeypatch): - runner = CliRunner() - _FakeBatchDeleteClient.deleted_agents = [] - monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeBatchDeleteClient) - monkeypatch.setattr( - "ksadk.cli.cmd_openclaw.run_async_with_dry_run", - lambda coro, dry_run: asyncio.run(coro), - ) - - result = runner.invoke(openclaw, ["destroy", "ar-demo-1", "ar-demo-2", "--force"]) - - assert result.exit_code == 0, result.output - assert _FakeBatchDeleteClient.deleted_agents == ["ar-demo-1", "ar-demo-2"] - - -def test_openclaw_delete_passes_result_styles_to_descriptor(monkeypatch): - runner = CliRunner() - _FakeBatchDeleteClient.deleted_agents = [] - captured = {} - - def _fake_render_descriptor_status(*args, **kwargs): - captured.update(kwargs) - - monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeBatchDeleteClient) - monkeypatch.setattr( - "ksadk.cli.cmd_openclaw.run_async_with_dry_run", - lambda coro, dry_run: asyncio.run(coro), - ) - monkeypatch.setattr( - "ksadk.cli.cmd_openclaw.render_descriptor_status", - _fake_render_descriptor_status, - ) - - result = runner.invoke(openclaw, ["delete", "ar-demo-1", "--yes"]) - - assert result.exit_code == 0, result.output - assert captured["fields"][1] == ("已删除", "ar-demo-1", "ok") - assert captured["fields"][2] == ("失败", "-", "muted") - assert captured["next_steps"] == ( - "agentengine openclaw list", - "agentengine openclaw deploy", - ) - - -def test_openclaw_status_shows_langfuse_trace_url(monkeypatch): - runner = CliRunner() - monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawDetailClient) - - result = runner.invoke(openclaw, ["status", "ar-demo-1"]) - - assert result.exit_code == 0, result.output - assert "Langfuse" in result.output - assert "https://trace.example.com/project/aropenclaw1/traces" in result.output - - -def test_mcp_destroy_supports_multiple_ids(monkeypatch): - runner = CliRunner() - _FakeBatchDeleteClient.deleted_mcps = [] - monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeBatchDeleteClient) - monkeypatch.setattr( - "ksadk.cli.cmd_mcp.run_async_with_dry_run", - lambda coro, dry_run: asyncio.run(coro), - ) - - result = runner.invoke( - mcp, - ["destroy", "mcp-123", "mcp-456", "--force"], - env={"AGENTENGINE_SERVER_URL": "http://example.com"}, - ) - - assert result.exit_code == 0, result.output - assert _FakeBatchDeleteClient.deleted_mcps == ["mcp-123", "mcp-456"] - - -def test_mcp_delete_passes_result_styles_to_descriptor(monkeypatch): - runner = CliRunner() - _FakeBatchDeleteClient.deleted_mcps = [] - captured = {} - - def _fake_render_descriptor_status(*args, **kwargs): - captured.update(kwargs) - - monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeBatchDeleteClient) - monkeypatch.setattr( - "ksadk.cli.cmd_mcp.run_async_with_dry_run", - lambda coro, dry_run: asyncio.run(coro), - ) - monkeypatch.setattr( - "ksadk.cli.cmd_mcp.render_descriptor_status", - _fake_render_descriptor_status, - ) - - result = runner.invoke( - mcp, - ["delete", "mcp-123", "--yes"], - env={"AGENTENGINE_SERVER_URL": "http://example.com"}, - ) - - assert result.exit_code == 0, result.output - assert captured["fields"][1] == ("已删除", "mcp-123", "ok") - assert captured["fields"][2] == ("失败", "-", "muted") - assert captured["next_steps"] == ( - "agentengine mcp list", - "agentengine mcp deploy", - ) - - -def test_agent_delete_json_requires_yes(monkeypatch): - runner = CliRunner() - _register_commands() - - async def _resolve(ids, _region, _account_id): - return ids - - monkeypatch.setattr("ksadk.cli.cmd_destroy._resolve_agent_ids", _resolve) - - result = runner.invoke( - cli, - ["--output", "json", "agent", "delete", "ar-123", "--account-id", "2000003485"], - ) - - assert result.exit_code == 2, result.output - payload = json.loads(result.output.strip()) - assert payload["ok"] is False - assert payload["error"]["code"] == "usage_error" - assert "--yes" in payload["error"]["message"] - - -def test_agent_delete_json_returns_error_on_partial_failure(monkeypatch): - runner = CliRunner() - _register_commands() - provider = _FakePartialDeleteProvider({"ar-1": True, "ar-2": False}) - - async def _resolve(ids, _region, _account_id): - return ids - - monkeypatch.setattr("ksadk.cli.cmd_destroy._resolve_agent_ids", _resolve) - monkeypatch.setattr("ksadk.cli.cmd_destroy.DeploymentManager.get_provider", lambda *_args, **_kwargs: provider) - - result = runner.invoke( - cli, - ["--output", "json", "agent", "delete", "ar-1", "ar-2", "--account-id", "2000003485", "--yes"], - ) - - assert result.exit_code == 6, result.output - payload = json.loads(result.output.strip()) - assert payload["ok"] is False - assert payload["error"]["code"] == "remote_error" - assert payload["error"]["details"]["deleted"] == ["ar-1"] - assert payload["error"]["details"]["failed"] == ["ar-2"] - - -def test_agent_delete_cancel_returns_cancelled_exit_code(monkeypatch): - runner = CliRunner() - _register_commands() - - async def _resolve(ids, _region, _account_id): - return ids - - monkeypatch.setattr("ksadk.cli.cmd_destroy._resolve_agent_ids", _resolve) - - result = runner.invoke( - cli, - ["agent", "delete", "ar-1", "--account-id", "2000003485"], - input="n\n", - ) - - assert result.exit_code == 7, result.output - assert "已取消" in result.output - - -def test_serverless_destroy_cleans_local_state_only_after_success(tmp_path, monkeypatch): - provider = ServerlessProvider() - state_file = tmp_path / ".agentengine.state" - state_file.write_text(yaml.safe_dump({"agent_id": "ar-demo"}), encoding="utf-8") - _FakeDeleteClient.deleted_agents = [] - _FakeDeleteClient.should_succeed = True - monkeypatch.chdir(tmp_path) - monkeypatch.setattr("ksadk.deployment.providers.serverless.AgentEngineClient", _FakeDeleteClient) - - success = asyncio.run( - provider.destroy( - "ar-demo", - DeployTarget(provider="serverless", region="cn-beijing-6", extra={"dry_run": False}), - ) - ) - - assert success is True - assert _FakeDeleteClient.deleted_agents == ["ar-demo"] - assert state_file.exists() is False - - -def test_serverless_destroy_keeps_local_state_on_dry_run(tmp_path, monkeypatch): - provider = ServerlessProvider() - state_file = tmp_path / ".agentengine.state" - state_file.write_text(yaml.safe_dump({"agent_id": "ar-demo"}), encoding="utf-8") - monkeypatch.chdir(tmp_path) - - class _DryRunDeleteClient(_FakeDeleteClient): - async def delete_agent(self, agent_id): - raise DryRunExit( - "dry-run", - payload={"method": "POST", "url": "https://example.com", "curl": "curl -X POST https://example.com"}, - ) - - monkeypatch.setattr("ksadk.deployment.providers.serverless.AgentEngineClient", _DryRunDeleteClient) - - try: - asyncio.run( - provider.destroy( - "ar-demo", - DeployTarget(provider="serverless", region="cn-beijing-6", extra={"dry_run": True}), - ) - ) - except DryRunExit: - pass - else: - raise AssertionError("DryRunExit should bubble for CLI handling") - - assert state_file.exists() is True - - -def test_serverless_destroy_keeps_local_state_when_remote_delete_fails(tmp_path, monkeypatch): - provider = ServerlessProvider() - state_file = tmp_path / ".agentengine.state" - state_file.write_text(yaml.safe_dump({"agent_id": "ar-demo"}), encoding="utf-8") - _FakeDeleteClient.deleted_agents = [] - _FakeDeleteClient.should_succeed = False - monkeypatch.chdir(tmp_path) - monkeypatch.setattr("ksadk.deployment.providers.serverless.AgentEngineClient", _FakeDeleteClient) - - success = asyncio.run( - provider.destroy( - "ar-demo", - DeployTarget(provider="serverless", region="cn-beijing-6", extra={"dry_run": False}), - ) - ) - - assert success is False - assert state_file.exists() is True - - -def test_serverless_destroy_uses_explicit_project_dir_for_state_cleanup(tmp_path, monkeypatch): - provider = ServerlessProvider() - project_dir = tmp_path / "project" - other_dir = tmp_path / "other" - project_dir.mkdir() - other_dir.mkdir() - - project_state = project_dir / ".agentengine.state" - project_state.write_text(yaml.safe_dump({"agent_id": "ar-demo"}), encoding="utf-8") - other_state = other_dir / ".agentengine.state" - other_state.write_text(yaml.safe_dump({"agent_id": "ar-demo"}), encoding="utf-8") - - _FakeDeleteClient.deleted_agents = [] - _FakeDeleteClient.should_succeed = True - monkeypatch.chdir(other_dir) - monkeypatch.setattr("ksadk.deployment.providers.serverless.AgentEngineClient", _FakeDeleteClient) - - success = asyncio.run( - provider.destroy( - "ar-demo", - DeployTarget( - provider="serverless", - region="cn-beijing-6", - extra={"dry_run": False, "project_dir": str(project_dir)}, - ), - ) - ) - - assert success is True - assert project_state.exists() is False - assert other_state.exists() is True diff --git a/tests/test_cli_global_options.py b/tests/test_cli_global_options.py deleted file mode 100644 index 7687a958..00000000 --- a/tests/test_cli_global_options.py +++ /dev/null @@ -1,156 +0,0 @@ -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 deleted file mode 100644 index 827e7d06..00000000 --- a/tests/test_cli_platform_refactor.py +++ /dev/null @@ -1,277 +0,0 @@ -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 deleted file mode 100644 index 473c3f39..00000000 --- a/tests/test_cli_root_entrypoint.py +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index 6d29ef69..00000000 --- a/tests/test_client_framework_passthrough.py +++ /dev/null @@ -1,460 +0,0 @@ -"""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", - } - - -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="") - 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 deleted file mode 100644 index 7a972078..00000000 --- a/tests/test_client_get_agent_name.py +++ /dev/null @@ -1,84 +0,0 @@ -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 deleted file mode 100644 index fa9b5f5a..00000000 --- a/tests/test_client_http_error_logging.py +++ /dev/null @@ -1,59 +0,0 @@ -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 "") - ) - ): - client._log_http_error( - method="POST", - full_url="http://example.com/?Action=GetAgent&Version=2024-06-12", - status_code=404, - details={"remote_error_message": "未找到对应的 Agent", "http_status": 404}, - ) - - 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, - details={"http_status": 500}, - ) - - assert "status=500" 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, - details={"http_status": 500}, - ) - - 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 - assert "abc" not in caplog.text diff --git a/tests/test_client_mcp_payloads.py b/tests/test_client_mcp_payloads.py deleted file mode 100644 index 04e05084..00000000 --- a/tests/test_client_mcp_payloads.py +++ /dev/null @@ -1,221 +0,0 @@ -"""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 deleted file mode 100644 index e15e77c8..00000000 --- a/tests/test_client_permission_precheck.py +++ /dev/null @@ -1,434 +0,0 @@ -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() - - -@pytest.fixture(autouse=True) -def _stub_identity_resolve(monkeypatch): - """这些测试用假 AK/SK,mock 掉身份反查避免联网 + warning 干扰 caplog 断言。""" - monkeypatch.setattr( - "ksadk.identity.resolve_identity", lambda **kw: None - ) - monkeypatch.setattr( - "ksadk.identity.get_cached_identity", lambda ak: None - ) - - -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_client_user_uuid_header.py b/tests/test_client_user_uuid_header.py deleted file mode 100644 index c27e8486..00000000 --- a/tests/test_client_user_uuid_header.py +++ /dev/null @@ -1,179 +0,0 @@ -"""client.py X-Ksc-User-uuid header 注入测试。""" - -from __future__ import annotations - -from unittest.mock import MagicMock - -import pytest - -from ksadk.api.client import AgentEngineClient -from ksadk.identity.resolver import ResolvedIdentity - - -@pytest.fixture(autouse=True) -def _isolate_identity_env(monkeypatch): - """每个测试隔离 env + 缓存,避免互相影响。""" - monkeypatch.delenv("KSYUN_ACCOUNT_ID", raising=False) - monkeypatch.delenv("KSYUN_ACCESS_KEY", raising=False) - monkeypatch.delenv("KSYUN_SECRET_KEY", raising=False) - # 隔离缓存(patch resolve_identity/get_cached_identity 避免真实文件读写) - yield - - -def _make_client_with_creds(monkeypatch, *, access_key="AKLTtest", secret_key="SKtest", dry_run=False): - """构造带凭证的 client,绕过真实 AK/SK env 依赖。""" - client = AgentEngineClient(region="cn-beijing-6", dry_run=dry_run) - # 注入凭证到 _auth - client._auth.access_key_id = access_key - client._auth.secret_access_key = secret_key - return client - - -def test_build_headers_no_user_uuid_when_resolve_fails(monkeypatch): - """反查失败时不注入 X-Ksc-User-uuid,其他 header 正常。""" - client = _make_client_with_creds(monkeypatch) - monkeypatch.setattr("ksadk.identity.resolve_identity", lambda **kw: None) - - headers = client._build_headers(action="Test") - - assert "X-Ksc-User-uuid" not in headers - assert headers["X-Ksc-Source"] == "ksadk-cli" - assert "X-Ksc-Region" in headers - - -def test_build_headers_includes_user_uuid_after_resolve(monkeypatch): - """反查成功时注入 X-Ksc-User-uuid。""" - client = _make_client_with_creds(monkeypatch) - fake = ResolvedIdentity( - user_uuid="uuid-xyz", - main_account_id="2000003485", - user_name="xiayu", - krn="krn:ksc:iam::2000003485:user/xiayu", - ak_fingerprint="abc", - ) - monkeypatch.setattr("ksadk.identity.resolve_identity", lambda **kw: fake) - - headers = client._build_headers(action="Test") - - assert headers["X-Ksc-User-uuid"] == "uuid-xyz" - # account_id 也从反查拿到 - assert headers["X-Ksc-Account-Id"] == "2000003485" - - -def test_build_headers_extra_headers_override_user_uuid(monkeypatch): - """extra_headers 显式覆盖 user uuid。""" - client = _make_client_with_creds(monkeypatch) - client.extra_headers = {"X-Ksc-User-uuid": "custom-uuid", "X-Ksc-Account-Id": "custom-acct"} - monkeypatch.setattr("ksadk.identity.resolve_identity", lambda **kw: MagicMock(user_uuid="should-not-use")) - - headers = client._build_headers(action="Test") - - # extra_headers 在 _resolve_user_uuid 里优先返回,且 _build_headers 末尾 update 覆盖 - assert headers["X-Ksc-User-uuid"] == "custom-uuid" - assert headers["X-Ksc-Account-Id"] == "custom-acct" - - -def test_build_headers_lowercase_extra_headers_normalized(monkeypatch): - """extra_headers 用小写 key 时归一为 Title-Case,避免重复 header。""" - client = _make_client_with_creds(monkeypatch) - client.extra_headers = {"x-ksc-user-uuid": "custom", "x-ksc-account-id": "custom-acct"} - monkeypatch.setattr("ksadk.identity.resolve_identity", lambda **kw: MagicMock(user_uuid="should-not-use")) - - headers = client._build_headers(action="Test") - - # 只应有一个 X-Ksc-User-uuid(Title-Case),不应有小写 key 共存 - uuid_keys = [k for k in headers if k.lower() == "x-ksc-user-uuid"] - assert len(uuid_keys) == 1 - assert uuid_keys[0] == "X-Ksc-User-uuid" - assert headers["X-Ksc-User-uuid"] == "custom" - acct_keys = [k for k in headers if k.lower() == "x-ksc-account-id"] - assert len(acct_keys) == 1 - assert headers["X-Ksc-Account-Id"] == "custom-acct" - - -def test_dry_run_does_not_invoke_resolve(monkeypatch): - """dry-run 不调 resolve_identity,只读缓存。""" - client = _make_client_with_creds(monkeypatch, dry_run=True) - called = MagicMock() - monkeypatch.setattr("ksadk.identity.resolve_identity", lambda **kw: called()) - monkeypatch.setattr("ksadk.identity.get_cached_identity", lambda ak: None) - - client._build_headers(action="Test") - - assert called.call_count == 0 # dry-run 不联网反查 - - -def test_dry_run_uses_cached_identity(monkeypatch): - """dry-run 时从缓存读 identity 注入 header。""" - client = _make_client_with_creds(monkeypatch, dry_run=True) - fake = ResolvedIdentity( - user_uuid="cached-uuid", - main_account_id="2000003485", - user_name="u", - krn=None, - ak_fingerprint="abc", - ) - monkeypatch.setattr("ksadk.identity.resolve_identity", lambda **kw: None) - monkeypatch.setattr("ksadk.identity.get_cached_identity", lambda ak: fake) - - headers = client._build_headers(action="Test") - - assert headers["X-Ksc-User-uuid"] == "cached-uuid" - - -def test_resolve_user_uuid_cached_on_instance(monkeypatch): - """同一 client 多次 _build_headers 只反查一次。""" - client = _make_client_with_creds(monkeypatch) - call_count = {"n": 0} - - def fake_resolve(**kw): - call_count["n"] += 1 - return ResolvedIdentity( - user_uuid="uuid-x", main_account_id=None, user_name="u", krn=None, ak_fingerprint="abc" - ) - - monkeypatch.setattr("ksadk.identity.resolve_identity", fake_resolve) - - client._build_headers(action="Test1") - client._build_headers(action="Test2") - client._build_headers(action="Test3") - - assert call_count["n"] == 1 # 实例缓存,只调一次 - - -def test_account_id_env_overrides_resolve(monkeypatch): - """KSYUN_ACCOUNT_ID env 优先于反查。""" - monkeypatch.setenv("KSYUN_ACCOUNT_ID", "env-acct") - client = _make_client_with_creds(monkeypatch) - fake = ResolvedIdentity( - user_uuid="uuid-x", main_account_id="resolved-acct", user_name="u", krn=None, ak_fingerprint="abc" - ) - monkeypatch.setattr("ksadk.identity.resolve_identity", lambda **kw: fake) - - headers = client._build_headers(action="Test") - - assert headers["X-Ksc-Account-Id"] == "env-acct" # env 覆盖反查 - - -def test_account_id_falls_back_to_resolved_main_account(monkeypatch): - """无 env 时 X-Ksc-Account-Id 从反查 main_account_id 拿。""" - client = _make_client_with_creds(monkeypatch) - fake = ResolvedIdentity( - user_uuid="uuid-x", main_account_id="2000003485", user_name="u", krn=None, ak_fingerprint="abc" - ) - monkeypatch.setattr("ksadk.identity.resolve_identity", lambda **kw: fake) - - headers = client._build_headers(action="Test") - - assert headers["X-Ksc-Account-Id"] == "2000003485" - - -def test_no_credentials_no_user_uuid_no_account_id(monkeypatch): - """无 AK/SK 时 user_uuid/account_id 都为 None,不注入,不报错。""" - client = AgentEngineClient(region="cn-beijing-6") # 无凭证 - monkeypatch.setattr("ksadk.identity.resolve_identity", lambda **kw: None) - - headers = client._build_headers(action="Test") - - assert "X-Ksc-User-uuid" not in headers - assert "X-Ksc-Account-Id" not in headers diff --git a/tests/test_client_workspace_files.py b/tests/test_client_workspace_files.py deleted file mode 100644 index a801933e..00000000 --- a/tests/test_client_workspace_files.py +++ /dev/null @@ -1,473 +0,0 @@ -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_build_upload_urls.py b/tests/test_cmd_build_upload_urls.py deleted file mode 100644 index 096b52d4..00000000 --- a/tests/test_cmd_build_upload_urls.py +++ /dev/null @@ -1,103 +0,0 @@ -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 deleted file mode 100644 index 6ce64957..00000000 --- a/tests/test_cmd_completion.py +++ /dev/null @@ -1,117 +0,0 @@ -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_config_wizard.py b/tests/test_cmd_config_wizard.py deleted file mode 100644 index e48b63cf..00000000 --- a/tests/test_cmd_config_wizard.py +++ /dev/null @@ -1,108 +0,0 @@ -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_create_from_agent.py b/tests/test_cmd_create_from_agent.py deleted file mode 100644 index e760254c..00000000 --- a/tests/test_cmd_create_from_agent.py +++ /dev/null @@ -1,507 +0,0 @@ -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 deleted file mode 100644 index 97ae803e..00000000 --- a/tests/test_cmd_dashboard_fallback.py +++ /dev/null @@ -1,718 +0,0 @@ -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"] == "/custom-chat" - - -def test_dashboard_open_uses_custom_ui_path_for_custom_ui_state(monkeypatch): - runner = CliRunner() - captured = {} - - monkeypatch.setattr( - cmd_dashboard, - "load_state", - lambda _cwd: { - "ui_profile": "custom", - "ui_path": "/research", - "ui_url": None, - }, - ) - 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"] == "/research" - - -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_private_expires_supports_one_year(monkeypatch): - runner = CliRunner() - captured = {} - - 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_agent_detail) - 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", "--expires-seconds", "31536000", "--no-open"], - ) - - assert result.exit_code == 0, result.output - assert captured["link_type"] == "private" - assert captured["expires_seconds"] == 31536000 - - -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 deleted file mode 100644 index 09ec31be..00000000 --- a/tests/test_cmd_deploy_no_cache.py +++ /dev/null @@ -1,521 +0,0 @@ -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 deleted file mode 100644 index 52146ab4..00000000 --- a/tests/test_cmd_files.py +++ /dev/null @@ -1,942 +0,0 @@ -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_hermes.py b/tests/test_cmd_hermes.py deleted file mode 100644 index 21a95f47..00000000 --- a/tests/test_cmd_hermes.py +++ /dev/null @@ -1,1606 +0,0 @@ -import asyncio -import json -from contextlib import contextmanager -from pathlib import Path - -import pytest -from click.testing import CliRunner - -from ksadk.api.client import AgentEngineAPIError, DryRunExit -from ksadk.cli import cmd_hermes -from ksadk.cli.ui import OUTPUT_MODE_PRETTY, configure_ui_runtime, status_rich_style - - -REPO_ROOT = Path(__file__).resolve().parents[1] -MAKEFILE = REPO_ROOT / "Makefile" - - -@pytest.fixture(autouse=True) -def _isolate_hermes_model_env(monkeypatch): - for key in ( - "OPENAI_API_KEY", - "OPENAI_BASE_URL", - "OPENAI_MODEL_NAME", - "HERMES_CONTEXT_LENGTH", - "OPENAI_CONTEXT_LENGTH", - "MODEL_CONTEXT_LENGTH", - "HERMES_FALLBACK_MODEL", - "OPENAI_FALLBACK_MODEL_NAME", - "HERMES_FALLBACK_BASE_URL", - "API_SERVER_KEY", - "HERMES_API_SERVER_KEY", - "LANGFUSE_PUBLIC_KEY", - "LANGFUSE_SECRET_KEY", - "LANGFUSE_BASE_URL", - "LANGFUSE_HOST", - "LANGFUSE_ENV", - "LANGFUSE_RELEASE", - "HERMES_LANGFUSE_PUBLIC_KEY", - "HERMES_LANGFUSE_SECRET_KEY", - "HERMES_LANGFUSE_BASE_URL", - "HERMES_LANGFUSE_ENV", - "HERMES_LANGFUSE_RELEASE", - "HERMES_LANGFUSE_SAMPLE_RATE", - "HERMES_LANGFUSE_MAX_CHARS", - "HERMES_LANGFUSE_DEBUG", - "KSYUN_REGION", - "WPSXIEZUO_APP_ID", - "WPSXIEZUO_APP_KEY", - "WPSXIEZUO_API_BASE", - "WPSXIEZUO_WS_ENDPOINT", - "WPSXIEZUO_GROUP_AT_ONLY", - "WPSXIEZUO_ALLOWED_USERS", - "WPSXIEZUO_ALLOW_ALL_USERS", - "WPSXIEZUO_HOME_CHANNEL", - ): - monkeypatch.delenv(key, raising=False) - cmd_hermes._HERMES_GLOBAL_ENV_CACHE = None - yield - cmd_hermes._HERMES_GLOBAL_ENV_CACHE = None - - -class _FakeHermesClient: - create_payload = None - update_payload = None - updated_agent_id = None - deleted = [] - - 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_agent(self, payload): - self.__class__.create_payload = payload - return { - "agent_id": "ar-hermes-1", - "name": payload["name"], - "endpoint": "https://hermes.example.com", - "api_key": "ak-hermes", - } - - async def update_agent(self, agent_id, payload): - self.__class__.updated_agent_id = agent_id - self.__class__.update_payload = payload - return { - "agent_id": agent_id, - "name": "demo-hermes", - "endpoint": "https://hermes.example.com", - } - - async def list_agents(self, **kwargs): - assert kwargs["framework"] == "hermes" - return { - "agents": [ - { - "agent_id": "ar-hermes-1", - "name": "demo-hermes", - "status": "RUNNING", - "endpoint": "https://hermes.example.com", - "region": kwargs["region"], - } - ], - "total": 1, - } - - async def get_client_bootstrap_config(self, **kwargs): - assert kwargs["product"] == "hermes" - assert kwargs["framework"] == "hermes" - return {"configs": {}} - - 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": "RUNNING", - "framework": "hermes", - "region": "cn-beijing-6", - }, - "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", - }, - } - - async def delete_agent(self, agent_id): - self.__class__.deleted.append(agent_id) - return True - - -class _FakeHermesOrderClient(_FakeHermesClient): - get_agent_calls = 0 - - async def create_agent(self, payload): - self.__class__.create_payload = payload - return {"order_id": "order-hermes-1"} - - async def get_agent(self, agent_id=None, name=None, include_api_key=False): - self.__class__.get_agent_calls += 1 - return { - "basic": { - "agent_id": "ar-hermes-from-order", - "name": name or "demo-hermes", - "status": "RUNNING", - "framework": "hermes", - "region": "cn-beijing-6", - }, - "quick_access": { - "public_endpoint": "https://order-hermes.example.com", - "api_key": "ak-order-hermes", - }, - } - - -class _FakeHermesImmediateAgentIdClient(_FakeHermesClient): - get_agent_calls = 0 - - async def create_agent(self, payload): - self.__class__.create_payload = payload - return { - "agent_id": "ar-hermes-immediate", - "name": payload["name"], - "endpoint": None, - "api_key": None, - "order_id": "order-hermes-2", - } - - async def get_agent(self, agent_id=None, name=None, include_api_key=False): - self.__class__.get_agent_calls += 1 - return { - "basic": { - "agent_id": agent_id or "ar-hermes-immediate", - "name": name or "demo-hermes", - "status": "RUNNING", - "framework": "hermes", - "region": "cn-beijing-6", - }, - "quick_access": { - "public_endpoint": "https://fresh-hermes.example.com", - "api_key": "ak-fresh-hermes" if include_api_key else None, - }, - } - - -class _FakeHermesDelayedAccessClient(_FakeHermesClient): - get_agent_calls = 0 - suppression_used = False - - async def create_agent(self, payload): - self.__class__.create_payload = payload - return { - "agent_id": "ar-hermes-delayed", - "name": payload["name"], - "endpoint": "https://created-hermes.example.com", - "api_key": None, - "status": 200, - } - - @contextmanager - def suppress_http_error_logging(self, predicate=None): - self.__class__.suppression_used = predicate is not None - yield - - async def get_agent(self, agent_id=None, name=None, include_api_key=False): - self.__class__.get_agent_calls += 1 - if self.__class__.get_agent_calls < 4: - raise AgentEngineAPIError( - 404, - "未找到对应的 Agent", - details={ - "http_status": 404, - "remote_error_message": "未找到对应的 Agent", - }, - ) - return { - "basic": { - "agent_id": agent_id or "ar-hermes-delayed", - "name": name or "demo-hermes", - "status": "RUNNING", - "framework": "hermes", - "region": "cn-beijing-6", - }, - "quick_access": { - "public_endpoint": "https://ready-hermes.example.com", - "api_key": "ak-ready-hermes" if include_api_key else None, - }, - } - - -class _FakeNonHermesClient(_FakeHermesClient): - async def get_agent(self, agent_id=None, name=None, include_api_key=False): - return { - "basic": { - "agent_id": agent_id or "ar-langgraph-1", - "name": name or "demo-langgraph", - "status": "RUNNING", - "framework": "langgraph", - }, - "quick_access": { - "public_endpoint": "https://langgraph.example.com", - }, - } - - -class _FakeHermesDryRunClient(_FakeHermesClient): - async def create_agent(self, payload): - raise DryRunExit( - "dry-run", - payload={ - "method": "POST", - "url": "http://example.com/?Action=CreateAgentProduct&Version=2024-06-12", - "headers": { - "Authorization": "Bearer sk-live-secret", - "Content-Type": "application/json", - }, - "body": { - "Advanced": { - "EnvironmentVariables": [ - {"Key": "OPENAI_API_KEY", "Value": "sk-test-secret", "IsSensitive": True}, - {"Key": "OPENAI_MODEL_NAME", "Value": "glm-test", "IsSensitive": False}, - ] - } - }, - "curl": """curl -X POST "http://example.com" \\ - -H "Authorization: Bearer sk-live-secret" \\ - -d '{"Advanced":{"EnvironmentVariables":[{"Key":"OPENAI_API_KEY","Value":"sk-test-secret","IsSensitive":true}]}}'""", - }, - ) - - -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 { - "configs": { - "bootstrap.default_image": "registry.example.com/agentengine-public/hermes-agent:db-meta" - } - } - - -def test_hermes_build_defaults_are_externalized_to_agentengine_images_repo(): - makefile = MAKEFILE.read_text(encoding="utf-8") - - 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') - - -def test_hermes_deploy_refreshes_quick_access_when_agent_id_is_immediate(monkeypatch, tmp_path: Path): - runner = CliRunner() - monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesImmediateAgentIdClient) - monkeypatch.chdir(tmp_path) - _FakeHermesImmediateAgentIdClient.get_agent_calls = 0 - - result = runner.invoke( - cmd_hermes.hermes, - [ - "deploy", - "--name", - "demo-hermes", - "--image", - "ghcr.io/kingsoftcloud/hermes-agent:test", - "--model-base-url", - "https://model.example.com/v1", - "--model-api-key", - "sk-demo", - "--default-model", - "glm-test", - ], - ) - - assert result.exit_code == 0, result.output - assert _FakeHermesImmediateAgentIdClient.get_agent_calls == 1 - state = (tmp_path / ".agentengine.state").read_text(encoding="utf-8") - assert "agent_id: ar-hermes-immediate" in state - assert "endpoint: https://fresh-hermes.example.com" in state - assert "api_key: ak-fresh-hermes" in state - - -def test_hermes_deploy_retries_transient_get_agent_not_found_without_showing_numeric_status( - monkeypatch, - tmp_path: Path, -): - runner = CliRunner() - monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesDelayedAccessClient) - monkeypatch.chdir(tmp_path) - _FakeHermesDelayedAccessClient.get_agent_calls = 0 - _FakeHermesDelayedAccessClient.suppression_used = False - - result = runner.invoke( - cmd_hermes.hermes, - [ - "deploy", - "--name", - "demo-hermes", - "--image", - "ghcr.io/kingsoftcloud/hermes-agent:test", - "--model-base-url", - "https://model.example.com/v1", - "--model-api-key", - "sk-demo", - "--default-model", - "glm-test", - ], - ) - - assert result.exit_code == 0, result.output - assert _FakeHermesDelayedAccessClient.suppression_used is True - assert _FakeHermesDelayedAccessClient.get_agent_calls == 4 - assert "当前状态: RUNNING" in result.output - assert "当前状态: 200" not in result.output - state = (tmp_path / ".agentengine.state").read_text(encoding="utf-8") - assert "agent_id: ar-hermes-delayed" in state - assert "endpoint: https://ready-hermes.example.com" in state - assert "api_key: ak-ready-hermes" in state - - -def test_hermes_exec_accepts_readonly_subcommand_and_uses_remote_terminal(monkeypatch): - runner = CliRunner() - captured = {} - - async def _fake_exec(**kwargs): - captured.update(kwargs) - - monkeypatch.setattr(cmd_hermes, "run_hermes_terminal_session", _fake_exec) - monkeypatch.setattr(cmd_hermes, "_resolve_hermes_access", lambda **_kwargs: { - "endpoint": "https://hermes.example.com", - "api_key": "ak-hermes", - }) - - result = runner.invoke(cmd_hermes.hermes, ["exec", "ar-hermes-1", "--", "status"]) - - assert result.exit_code == 0, result.output - assert captured["endpoint"] == "https://hermes.example.com" - assert captured["api_key"] == "ak-hermes" - assert captured["mode"] == "exec" - assert captured["argv"] == ["status"] - - -def test_hermes_exec_rejects_mutating_subcommand_before_remote_call(monkeypatch): - runner = CliRunner() - - async def _forbidden_exec(**_kwargs): - raise AssertionError("remote terminal should not be called") - - monkeypatch.setattr(cmd_hermes, "run_hermes_terminal_session", _forbidden_exec) - - result = runner.invoke(cmd_hermes.hermes, ["exec", "ar-hermes-1", "--", "gateway", "restart"]) - - assert result.exit_code != 0 - assert "不允许" in result.output or "not allowed" in result.output - - -def test_hermes_exec_exits_cleanly_on_keyboard_interrupt(monkeypatch): - runner = CliRunner() - - def _fake_exec(**_kwargs): - return object() - - def _raise_keyboard_interrupt(_awaitable): - raise KeyboardInterrupt - - monkeypatch.setattr(cmd_hermes, "run_hermes_terminal_session", _fake_exec) - monkeypatch.setattr(cmd_hermes, "_resolve_hermes_access", lambda **_kwargs: { - "endpoint": "https://hermes.example.com", - "api_key": "ak-hermes", - }) - monkeypatch.setattr(cmd_hermes.asyncio, "run", _raise_keyboard_interrupt) - - result = runner.invoke(cmd_hermes.hermes, ["exec", "ar-hermes-1", "--", "status"]) - - assert result.exit_code == 130 - assert "Traceback" not in result.output - - -def test_hermes_pairing_accepts_safe_subcommand_and_uses_remote_terminal(monkeypatch): - runner = CliRunner() - captured = {} - - async def _fake_pairing(**kwargs): - captured.update(kwargs) - - monkeypatch.setattr(cmd_hermes, "run_hermes_terminal_session", _fake_pairing) - monkeypatch.setattr( - cmd_hermes, - "_resolve_hermes_access", - lambda **_kwargs: { - "endpoint": "https://hermes.example.com", - "api_key": "ak-hermes", - }, - ) - - result = runner.invoke( - cmd_hermes.hermes, - ["pairing", "ar-hermes-1", "--", "approve", "feishu", "ABC123"], - ) - - assert result.exit_code == 0, result.output - assert captured["mode"] == "pairing" - assert captured["argv"] == ["approve", "feishu", "ABC123"] - - -def test_hermes_pairing_without_agent_ref_uses_state_resolution(monkeypatch): - runner = CliRunner() - captured = {} - resolved = {} - - async def _fake_pairing(**kwargs): - captured.update(kwargs) - - def _fake_resolve(**kwargs): - resolved.update(kwargs) - return { - "endpoint": "https://hermes.example.com", - "api_key": "ak-hermes", - } - - monkeypatch.setattr(cmd_hermes, "run_hermes_terminal_session", _fake_pairing) - monkeypatch.setattr(cmd_hermes, "_resolve_hermes_access", _fake_resolve) - - result = runner.invoke( - cmd_hermes.hermes, - ["pairing", "--", "approve", "feishu", "ABC123"], - ) - - assert result.exit_code == 0, result.output - assert resolved["agent_ref"] is None - assert captured["mode"] == "pairing" - assert captured["argv"] == ["approve", "feishu", "ABC123"] - - -def test_hermes_pairing_rejects_invalid_platform_before_remote_call(monkeypatch): - runner = CliRunner() - - async def _forbidden_pairing(**_kwargs): - raise AssertionError("remote terminal should not be called") - - monkeypatch.setattr(cmd_hermes, "run_hermes_terminal_session", _forbidden_pairing) - - result = runner.invoke( - cmd_hermes.hermes, - ["pairing", "ar-hermes-1", "--", "approve", "unknown-platform", "ABC123"], - ) - - assert result.exit_code != 0 - assert "不允许" in result.output or "not allowed" in result.output - - -def test_hermes_exec_without_agent_ref_uses_state_resolution(monkeypatch): - runner = CliRunner() - captured = {} - resolved = {} - - async def _fake_exec(**kwargs): - captured.update(kwargs) - - def _fake_resolve(**kwargs): - resolved.update(kwargs) - return { - "endpoint": "https://hermes.example.com", - "api_key": "ak-hermes", - } - - monkeypatch.setattr(cmd_hermes, "run_hermes_terminal_session", _fake_exec) - monkeypatch.setattr(cmd_hermes, "_resolve_hermes_access", _fake_resolve) - - result = runner.invoke( - cmd_hermes.hermes, - ["exec", "--", "status"], - ) - - assert result.exit_code == 0, result.output - assert resolved["agent_ref"] is None - assert captured["mode"] == "exec" - assert captured["argv"] == ["status"] - - -def test_hermes_connect_enters_remote_gateway_setup(monkeypatch): - runner = CliRunner() - captured = {} - - async def _fake_connect(**kwargs): - captured.update(kwargs) - - monkeypatch.setattr(cmd_hermes, "run_hermes_terminal_session", _fake_connect) - monkeypatch.setattr( - cmd_hermes, - "_resolve_hermes_access", - lambda **_kwargs: { - "endpoint": "https://hermes.example.com", - "api_key": "ak-hermes", - }, - ) - - result = runner.invoke(cmd_hermes.hermes, ["connect", "ar-hermes-1"]) - - assert result.exit_code == 0, result.output - assert captured["mode"] == "connect" - assert captured["endpoint"] == "https://hermes.example.com" - assert captured["api_key"] == "ak-hermes" - - -def test_hermes_exec_dry_run_does_not_resolve_or_connect(monkeypatch): - runner = CliRunner() - - async def _forbidden_exec(**_kwargs): - raise AssertionError("remote terminal should not be called") - - monkeypatch.setattr(cmd_hermes, "run_hermes_terminal_session", _forbidden_exec) - monkeypatch.setattr( - cmd_hermes, - "_resolve_hermes_access", - lambda **_kwargs: (_ for _ in ()).throw(AssertionError("agent access should not be resolved")), - ) - - result = runner.invoke( - cmd_hermes.hermes, - ["exec", "ar-hermes-1", "--dry-run", "--output", "json", "--", "status"], - ) - - assert result.exit_code == 0, result.output - payload = json.loads(result.output) - assert payload["kind"] == "dry_run" - assert payload["resource"] == "hermes" - assert payload["action"] == "exec" - assert payload["request"]["argv"] == ["status"] - - -def test_hermes_connect_dry_run_does_not_resolve_or_connect(monkeypatch): - runner = CliRunner() - - async def _forbidden_connect(**_kwargs): - raise AssertionError("remote terminal should not be called") - - monkeypatch.setattr(cmd_hermes, "run_hermes_terminal_session", _forbidden_connect) - monkeypatch.setattr( - cmd_hermes, - "_resolve_hermes_access", - lambda **_kwargs: (_ for _ in ()).throw(AssertionError("agent access should not be resolved")), - ) - - result = runner.invoke( - cmd_hermes.hermes, - ["connect", "ar-hermes-1", "--dry-run", "--output", "json"], - ) - - assert result.exit_code == 0, result.output - payload = json.loads(result.output) - assert payload["kind"] == "dry_run" - assert payload["resource"] == "hermes" - assert payload["action"] == "connect" - assert payload["request"]["mode"] == "connect" - - -def test_hermes_pairing_dry_run_does_not_resolve_or_connect(monkeypatch): - runner = CliRunner() - - async def _forbidden_pairing(**_kwargs): - raise AssertionError("remote terminal should not be called") - - monkeypatch.setattr(cmd_hermes, "run_hermes_terminal_session", _forbidden_pairing) - monkeypatch.setattr( - cmd_hermes, - "_resolve_hermes_access", - lambda **_kwargs: (_ for _ in ()).throw(AssertionError("agent access should not be resolved")), - ) - - result = runner.invoke( - cmd_hermes.hermes, - ["pairing", "ar-hermes-1", "--dry-run", "--output", "json", "--", "approve", "feishu", "ABC123"], - ) - - assert result.exit_code == 0, result.output - payload = json.loads(result.output) - assert payload["kind"] == "dry_run" - assert payload["resource"] == "hermes" - assert payload["action"] == "pairing" - assert payload["request"]["argv"] == ["approve", "feishu", "ABC123"] - - -def test_hermes_pairing_dry_run_accepts_wpsxiezuo_platform(monkeypatch): - runner = CliRunner() - - async def _forbidden_pairing(**_kwargs): - raise AssertionError("remote terminal should not be called") - - monkeypatch.setattr(cmd_hermes, "run_hermes_terminal_session", _forbidden_pairing) - monkeypatch.setattr( - cmd_hermes, - "_resolve_hermes_access", - lambda **_kwargs: (_ for _ in ()).throw(AssertionError("agent access should not be resolved")), - ) - - result = runner.invoke( - cmd_hermes.hermes, - ["pairing", "ar-hermes-1", "--dry-run", "--output", "json", "--", "approve", "wpsxiezuo", "WPS123"], - ) - - assert result.exit_code == 0, result.output - payload = json.loads(result.output) - assert payload["request"]["argv"] == ["approve", "wpsxiezuo", "WPS123"] - - -def test_hermes_open_defaults_to_manage_and_supports_chat_override(monkeypatch): - runner = CliRunner() - opened = [] - - monkeypatch.setattr( - cmd_hermes, - "_get_hermes_detail", - lambda *args, **kwargs: asyncio.sleep( - 0, - result={ - "agent_id": "ar-hermes-1", - "framework": "hermes", - }, - ), - ) - monkeypatch.setattr( - cmd_hermes, - "_open_dashboard", - lambda **kwargs: opened.append(kwargs), - ) - - manage_result = runner.invoke(cmd_hermes.hermes, ["open", "ar-hermes-1", "--manage", "--no-open"]) - chat_result = runner.invoke(cmd_hermes.hermes, ["open", "ar-hermes-1", "--chat", "--no-open"]) - - assert manage_result.exit_code == 0, manage_result.output - 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): - runner = CliRunner() - opened = [] - - monkeypatch.setattr( - cmd_hermes, - "_get_hermes_detail", - lambda *args, **kwargs: asyncio.sleep( - 0, - result={ - "agent_id": "ar-hermes-1", - "framework": "hermes", - }, - ), - ) - monkeypatch.setattr( - cmd_hermes, - "_open_dashboard", - lambda **kwargs: opened.append(kwargs), - ) - - result = runner.invoke( - cmd_hermes.hermes, - ["open", "ar-hermes-1", "--chat", "--share", "--expires-seconds", "86400", "--force-new", "--no-open"], - ) - - assert result.exit_code == 0, result.output - assert opened[0]["ui_path"] == "/chat" - 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): - runner = CliRunner() - - monkeypatch.setattr( - cmd_hermes, - "_get_hermes_detail", - lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("agent detail should not be resolved")), - ) - monkeypatch.setattr( - cmd_hermes, - "_open_dashboard", - lambda **kwargs: (_ for _ in ()).throw(AssertionError("dashboard should not open")), - ) - - result = runner.invoke( - cmd_hermes.hermes, - ["open", "ar-hermes-1", "--chat", "--dry-run", "--output", "json"], - ) - - assert result.exit_code == 0, result.output - payload = json.loads(result.output) - assert payload["kind"] == "dry_run" - assert payload["action"] == "open" - assert payload["request"]["path"] == "/chat" - - -def test_hermes_open_rejects_manage_and_chat_together(monkeypatch): - runner = CliRunner() - monkeypatch.setattr( - cmd_hermes, - "_get_hermes_detail", - lambda *args, **kwargs: asyncio.sleep( - 0, - result={ - "agent_id": "ar-hermes-1", - "framework": "hermes", - }, - ), - ) - - result = runner.invoke(cmd_hermes.hermes, ["open", "ar-hermes-1", "--manage", "--chat", "--no-open"]) - - assert result.exit_code != 0 - - -def test_hermes_deploy_creates_container_framework_and_persists_state(tmp_path: Path, monkeypatch): - runner = CliRunner() - _FakeHermesClient.create_payload = None - _FakeHermesClient.update_payload = None - _FakeHermesClient.updated_agent_id = 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", _FakeHermesClient) - - result = runner.invoke(cmd_hermes.hermes, ["deploy", "--name", "demo-hermes", "--image", "registry/hermes:test"]) - - assert result.exit_code == 0, result.output - assert _FakeHermesClient.create_payload["framework"] == "hermes" - assert _FakeHermesClient.create_payload["artifact_type"] == "Container" - assert _FakeHermesClient.create_payload["artifact_path"] == "registry/hermes:test" - assert _FakeHermesClient.create_payload["ui_config"] == {"profile": "hermes", "path": "/", "url": None} - assert any(item["Key"] == "OPENAI_API_KEY" and item["Value"] == "sk-test" for item in _FakeHermesClient.create_payload["env_vars"]) - assert "agent_id: ar-hermes-1" in (tmp_path / ".agentengine.state").read_text(encoding="utf-8") - - -def test_hermes_deploy_create_payload_includes_explicit_network(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", "https://model.example.com/v1") - monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesClient) - - result = runner.invoke( - cmd_hermes.hermes, - [ - "deploy", - "--name", - "demo-hermes", - "--image", - "registry/hermes:test", - "--disable-public-access", - "--enable-vpc-access", - "--vpc-id", - "vpc-cli", - "--subnet-id", - "subnet-cli", - "--security-group-id", - "sg-cli", - "--availability-zone", - "cn-beijing-6b", - ], - ) - - assert result.exit_code == 0, result.output - assert _FakeHermesClient.create_payload["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", - } - - -def test_hermes_deploy_infers_availability_zone_from_subnet(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", "https://model.example.com/v1") - monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesClient) - monkeypatch.setattr( - "ksadk.cli.network_options._resolve_subnet_availability_zone", - lambda *, subnet_id, region: ( - "cn-beijing-6e" - if subnet_id == "subnet-cli" and region == "cn-beijing-6" - else None - ), - ) - - result = runner.invoke( - cmd_hermes.hermes, - [ - "deploy", - "--name", - "demo-hermes", - "--region", - "cn-beijing-6", - "--image", - "registry/hermes:test", - "--enable-vpc-access", - "--vpc-id", - "vpc-cli", - "--subnet-id", - "subnet-cli", - "--security-group-id", - "sg-cli", - ], - ) - - assert result.exit_code == 0, result.output - assert _FakeHermesClient.create_payload["network"] == { - "enable_vpc_access": True, - "vpc_id": "vpc-cli", - "subnet_id": "subnet-cli", - "security_group_id": "sg-cli", - "availability_zone": "cn-beijing-6e", - } - - -def test_hermes_deploy_omits_network_when_not_configured(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", "https://model.example.com/v1") - monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesClient) - - result = runner.invoke( - cmd_hermes.hermes, - ["deploy", "--name", "demo-hermes", "--image", "registry/hermes:test"], - ) - - assert result.exit_code == 0, result.output - assert "network" not in _FakeHermesClient.create_payload - - -def test_hermes_deploy_defaults_model_base_url_and_omits_api_key(tmp_path: Path, monkeypatch): - runner = CliRunner() - _FakeHermesClient.create_payload = None - monkeypatch.chdir(tmp_path) - monkeypatch.setattr(cmd_hermes, "_get_hermes_global_env", lambda: {}, raising=False) - monkeypatch.delenv("OPENAI_API_KEY", raising=False) - monkeypatch.delenv("OPENAI_BASE_URL", raising=False) - monkeypatch.delenv("OPENAI_MODEL_NAME", raising=False) - monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesClient) - - result = runner.invoke(cmd_hermes.hermes, ["deploy"]) - - assert result.exit_code == 0, result.output - assert "https://kspmas.ksyun.com/v1/" 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.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"]) - - -def test_hermes_deploy_reads_model_config_from_global_settings(tmp_path: Path, monkeypatch): - runner = CliRunner() - _FakeHermesClient.create_payload = None - monkeypatch.chdir(tmp_path) - monkeypatch.setattr( - cmd_hermes, - "_get_hermes_global_env", - lambda: { - "OPENAI_API_KEY": "sk-global", - "OPENAI_BASE_URL": "https://model.example.com/v1", - "OPENAI_MODEL_NAME": "glm-global", - }, - raising=False, - ) - monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesClient) - - result = runner.invoke(cmd_hermes.hermes, ["deploy", "--name", "demo-hermes", "--image", "registry/hermes:test"]) - - assert result.exit_code == 0, result.output - assert any(item["Key"] == "OPENAI_API_KEY" and item["Value"] == "sk-global" for item in _FakeHermesClient.create_payload["env_vars"]) - assert any(item["Key"] == "OPENAI_BASE_URL" and item["Value"] == "https://model.example.com/v1" for item in _FakeHermesClient.create_payload["env_vars"]) - assert any(item["Key"] == "OPENAI_MODEL_NAME" and item["Value"] == "glm-global" for item in _FakeHermesClient.create_payload["env_vars"]) - - -def test_hermes_deploy_defaults_kspmas_base_url_when_missing(tmp_path: Path, monkeypatch): - runner = CliRunner() - _FakeHermesClient.create_payload = None - monkeypatch.chdir(tmp_path) - monkeypatch.setattr(cmd_hermes, "_get_hermes_global_env", lambda: {}, raising=False) - monkeypatch.setenv("OPENAI_MODEL_NAME", "glm-5.1") - monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesClient) - - result = runner.invoke(cmd_hermes.hermes, ["deploy", "--name", "demo-hermes"]) - - assert result.exit_code == 0, result.output - assert "https://kspmas.ksyun.com/v1/" 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"] - ) - - -def test_hermes_deploy_output_json_emits_result_envelope(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", "https://model.example.com/v1") - monkeypatch.setenv("OPENAI_MODEL_NAME", "glm-test") - monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesClient) - - result = runner.invoke( - cmd_hermes.hermes, - ["deploy", "--name", "demo-hermes", "--image", "registry/hermes:test", "--output", "json"], - ) - - assert result.exit_code == 0, result.output - payload = json.loads(result.output) - assert payload["kind"] == "result" - assert payload["resource"] == "hermes" - assert payload["action"] == "deploy" - assert payload["result"]["id"] == "ar-hermes-1" - assert payload["result"]["image"] == "registry/hermes:test" - assert payload["result"]["framework"] == "hermes" - assert payload["result"]["endpoint"] == "https://hermes.example.com" - - -def test_hermes_deploy_preserves_configured_public_kspmas_url(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-test") - monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesClient) - - result = runner.invoke(cmd_hermes.hermes, ["deploy", "--name", "demo-hermes"]) - - assert result.exit_code == 0, result.output - assert ( - _FakeHermesClient.create_payload["artifact_path"] - == "ghcr.io/kingsoftcloud/hermes-agent:2026.5.29.2-ksadk-v1" - ) - assert any( - item["Key"] == "OPENAI_BASE_URL" and item["Value"] == "http://kspmas.ksyun.com/v1" - for item in _FakeHermesClient.create_payload["env_vars"] - ) - - -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.2") - monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesClient) - - result = runner.invoke(cmd_hermes.hermes, ["deploy", "--name", "demo-hermes"]) - - assert result.exit_code == 0, result.output - 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): - 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("HERMES_FALLBACK_MODEL", "explicit-fallback") - monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesClient) - - result = runner.invoke(cmd_hermes.hermes, ["deploy", "--name", "demo-hermes"]) - - assert result.exit_code == 0, result.output - env_vars = {item["Key"]: item["Value"] for item in _FakeHermesClient.create_payload["env_vars"]} - assert env_vars["HERMES_FALLBACK_MODEL"] == "explicit-fallback" - assert env_vars["HERMES_FALLBACK_PROVIDER"] == "custom" - assert env_vars["HERMES_FALLBACK_BASE_URL"] == "http://kspmas.ksyun.com/v1" - - -def test_hermes_deploy_uses_provider_context_length_for_configured_model(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", "deepseek-v4-pro") - monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesClient) - - async def _fake_fetch_provider_model_metadata(**_kwargs): - return { - "id": "deepseek-v4-pro", - "context_window_tokens": 1_000_000, - "max_output_tokens": 384_000, - } - - monkeypatch.setattr( - cmd_hermes, - "fetch_provider_model_metadata", - _fake_fetch_provider_model_metadata, - ) - - 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"] == "1000000" - for item in _FakeHermesClient.create_payload["env_vars"] - ) - - -def test_hermes_deploy_forwards_langfuse_env_when_configured(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("LANGFUSE_PUBLIC_KEY", "pk-lf-test") - monkeypatch.setenv("LANGFUSE_SECRET_KEY", "sk-lf-test") - monkeypatch.setenv("LANGFUSE_BASE_URL", "https://langfuse.pre.example.com") - monkeypatch.setenv("LANGFUSE_ENV", "pre") - monkeypatch.setenv("HERMES_LANGFUSE_SAMPLE_RATE", "0.5") - monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesClient) - - result = runner.invoke(cmd_hermes.hermes, ["deploy", "--name", "demo-hermes"]) - - assert result.exit_code == 0, result.output - env_vars = { - item["Key"]: item for item in _FakeHermesClient.create_payload["env_vars"] - } - assert env_vars["HERMES_LANGFUSE_PUBLIC_KEY"]["Value"] == "pk-lf-test" - assert env_vars["HERMES_LANGFUSE_PUBLIC_KEY"]["IsSensitive"] is True - assert env_vars["HERMES_LANGFUSE_SECRET_KEY"]["Value"] == "sk-lf-test" - assert env_vars["HERMES_LANGFUSE_SECRET_KEY"]["IsSensitive"] is True - assert env_vars["HERMES_LANGFUSE_BASE_URL"]["Value"] == "https://langfuse.pre.example.com" - assert env_vars["HERMES_LANGFUSE_ENV"]["Value"] == "pre" - assert env_vars["HERMES_LANGFUSE_SAMPLE_RATE"]["Value"] == "0.5" - - -def test_hermes_deploy_forwards_wpsxiezuo_env_when_configured(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("WPSXIEZUO_APP_ID", "AK-wps-test") - monkeypatch.setenv("WPSXIEZUO_APP_KEY", "wps-app-key") - monkeypatch.setenv("WPSXIEZUO_API_BASE", "https://openapi.wps.cn") - monkeypatch.setenv("WPSXIEZUO_GROUP_AT_ONLY", "true") - monkeypatch.setenv("WPSXIEZUO_ALLOWED_USERS", "u1,u2") - monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesClient) - - result = runner.invoke(cmd_hermes.hermes, ["deploy", "--name", "demo-hermes"]) - - assert result.exit_code == 0, result.output - env_vars = {item["Key"]: item for item in _FakeHermesClient.create_payload["env_vars"]} - assert env_vars["WPSXIEZUO_APP_ID"]["Value"] == "AK-wps-test" - assert env_vars["WPSXIEZUO_APP_ID"]["IsSensitive"] is False - assert env_vars["WPSXIEZUO_APP_KEY"]["Value"] == "wps-app-key" - assert env_vars["WPSXIEZUO_APP_KEY"]["IsSensitive"] is True - assert env_vars["WPSXIEZUO_API_BASE"]["Value"] == "https://openapi.wps.cn" - assert env_vars["WPSXIEZUO_GROUP_AT_ONLY"]["Value"] == "true" - assert env_vars["WPSXIEZUO_ALLOWED_USERS"]["Value"] == "u1,u2" - - -def test_hermes_deploy_defaults_ui_locale_to_zh(tmp_path: Path, monkeypatch): - runner = CliRunner() - _FakeHermesClient.create_payload = None - monkeypatch.chdir(tmp_path) - monkeypatch.setattr(cmd_hermes, "_get_hermes_global_env", lambda: {}, raising=False) - monkeypatch.delenv("HERMES_UI_LOCALE", raising=False) - monkeypatch.delenv("LANG", raising=False) - monkeypatch.delenv("LC_ALL", raising=False) - 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_UI_LOCALE" and item["Value"] == "zh" - for item in _FakeHermesClient.create_payload["env_vars"] - ) - - -def test_hermes_deploy_normalizes_ui_locale_from_lang(tmp_path: Path, monkeypatch): - runner = CliRunner() - _FakeHermesClient.create_payload = None - monkeypatch.chdir(tmp_path) - monkeypatch.setattr(cmd_hermes, "_get_hermes_global_env", lambda: {}, raising=False) - monkeypatch.delenv("HERMES_UI_LOCALE", raising=False) - monkeypatch.setenv("LANG", "en_US.UTF-8") - 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_UI_LOCALE" and item["Value"] == "en" - for item in _FakeHermesClient.create_payload["env_vars"] - ) - - -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") - monkeypatch.setenv("OPENAI_MODEL_NAME", "glm-test") - monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesBootstrapImageClient) - - result = runner.invoke(cmd_hermes.hermes, ["deploy", "--name", "demo-hermes"]) - - assert result.exit_code == 0, result.output - assert ( - _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): - runner = CliRunner() - _FakeHermesClient.create_payload = None - _FakeHermesClient.update_payload = None - _FakeHermesClient.updated_agent_id = None - monkeypatch.chdir(tmp_path) - (tmp_path / ".agentengine.state").write_text( - "type: hermes\nframework: hermes\nagent_id: ar-hermes-existing\nname: demo-hermes\nendpoint: https://old.example.com\n", - encoding="utf-8", - ) - 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", _FakeHermesClient) - - result = runner.invoke(cmd_hermes.hermes, ["deploy", "--image", "registry/hermes:new"]) - - assert result.exit_code == 0, result.output - assert _FakeHermesClient.create_payload is None - assert _FakeHermesClient.updated_agent_id == "ar-hermes-existing" - assert _FakeHermesClient.update_payload["framework"] == "hermes" - assert _FakeHermesClient.update_payload["artifact_type"] == "Container" - assert _FakeHermesClient.update_payload["artifact_path"] == "registry/hermes:new" - - -def test_hermes_deploy_update_payload_preserves_existing_config_by_default(tmp_path: Path, monkeypatch): - runner = CliRunner() - _FakeHermesClient.create_payload = None - _FakeHermesClient.update_payload = None - _FakeHermesClient.updated_agent_id = None - monkeypatch.chdir(tmp_path) - (tmp_path / ".agentengine.state").write_text( - "type: hermes\nframework: hermes\nagent_id: ar-hermes-existing\nname: demo-hermes\nendpoint: https://old.example.com\n", - encoding="utf-8", - ) - monkeypatch.setenv("OPENAI_API_KEY", "sk-local-shell") - monkeypatch.setenv("OPENAI_BASE_URL", "https://local-shell.example.com/v1") - monkeypatch.setenv("OPENAI_MODEL_NAME", "local-shell-model") - monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesClient) - - result = runner.invoke(cmd_hermes.hermes, ["deploy", "--image", "registry/hermes:new"]) - - assert result.exit_code == 0, result.output - payload = _FakeHermesClient.update_payload - assert payload["artifact_path"] == "registry/hermes:new" - assert "env_vars" not in payload - assert "storage" not in payload - assert "network" not in payload - - -def test_hermes_deploy_update_payload_includes_explicit_config(tmp_path: Path, monkeypatch): - runner = CliRunner() - _FakeHermesClient.create_payload = None - _FakeHermesClient.update_payload = None - _FakeHermesClient.updated_agent_id = None - monkeypatch.chdir(tmp_path) - (tmp_path / ".agentengine.state").write_text( - "type: hermes\nframework: hermes\nagent_id: ar-hermes-existing\nname: demo-hermes\nendpoint: https://old.example.com\n", - encoding="utf-8", - ) - monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesClient) - - result = runner.invoke( - cmd_hermes.hermes, - [ - "deploy", - "--image", - "registry/hermes:new", - "--model-base-url", - "https://model.example.com/v1", - "--default-model", - "glm-test", - "--storage-size-gi", - "50", - "--enable-vpc-access", - "--vpc-id", - "vpc-cli", - "--subnet-id", - "subnet-cli", - "--security-group-id", - "sg-cli", - ], - ) - - assert result.exit_code == 0, result.output - payload = _FakeHermesClient.update_payload - assert any(item["Key"] == "OPENAI_MODEL_NAME" and item["Value"] == "glm-test" for item in payload["env_vars"]) - assert payload["storage"]["size_gi"] == 50 - assert payload["network"] == { - "enable_vpc_access": True, - "vpc_id": "vpc-cli", - "subnet_id": "subnet-cli", - "security_group_id": "sg-cli", - } - - -def test_hermes_deploy_dry_run_redacts_sensitive_values(monkeypatch, tmp_path: Path): - runner = CliRunner() - monkeypatch.chdir(tmp_path) - monkeypatch.setenv("OPENAI_BASE_URL", "https://model.example.com/v1") - monkeypatch.setenv("OPENAI_MODEL_NAME", "glm-test") - monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesDryRunClient) - - result = runner.invoke(cmd_hermes.hermes, ["deploy", "--name", "demo-hermes", "--dry-run"]) - - assert result.exit_code == 0, result.output - assert "sk-test-secret" not in result.output - assert "sk-live-secret" not in result.output - assert "***" in result.output - assert "glm-test" in result.output - - -def test_hermes_deploy_polls_order_until_agent_access_is_available(tmp_path: Path, monkeypatch): - runner = CliRunner() - _FakeHermesOrderClient.get_agent_calls = 0 - 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", _FakeHermesOrderClient) - - async def _fake_sleep(*_args, **_kwargs): - return None - - monkeypatch.setattr(cmd_hermes.asyncio, "sleep", _fake_sleep) - - result = runner.invoke(cmd_hermes.hermes, ["deploy", "--name", "demo-hermes", "--image", "registry/hermes:test"]) - - assert result.exit_code == 0, result.output - assert _FakeHermesOrderClient.get_agent_calls == 1 - state = (tmp_path / ".agentengine.state").read_text(encoding="utf-8") - assert "agent_id: ar-hermes-from-order" in state - assert "endpoint: https://order-hermes.example.com" in state - assert "api_key: ak-order-hermes" in state - - -def test_hermes_list_status_and_delete_use_hermes_resource(monkeypatch): - runner = CliRunner() - _FakeHermesClient.deleted = [] - monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesClient) - monkeypatch.setattr(cmd_hermes, "confirm_destructive", lambda **_kwargs: True) - - list_result = runner.invoke(cmd_hermes.hermes, ["list"]) - status_result = runner.invoke(cmd_hermes.hermes, ["status", "ar-hermes-1"]) - delete_result = runner.invoke(cmd_hermes.hermes, ["delete", "ar-hermes-1", "-y"]) - - assert list_result.exit_code == 0, list_result.output - assert status_result.exit_code == 0, status_result.output - assert delete_result.exit_code == 0, delete_result.output - assert "ar-hermes-1" in list_result.output - assert "RUNNING" in status_result.output - assert _FakeHermesClient.deleted == ["ar-hermes-1"] - - -def test_hermes_status_passes_status_style_to_descriptor(monkeypatch): - runner = CliRunner() - configure_ui_runtime(output_mode=OUTPUT_MODE_PRETTY, no_color=False, stdout_is_tty=True) - monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesClient) - captured = {} - - def _fake_render_descriptor_status(*args, **kwargs): - captured.update(kwargs) - - monkeypatch.setattr(cmd_hermes, "render_descriptor_status", _fake_render_descriptor_status) - - result = runner.invoke(cmd_hermes.hermes, ["status", "ar-hermes-1"]) - - assert result.exit_code == 0, result.output - assert captured["fields"][1] == ("状态", "RUNNING", status_rich_style("RUNNING")) - - -def test_hermes_status_shows_langfuse_trace_url(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 "Langfuse" in result.output - 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 = [] - monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesClient) - monkeypatch.setattr(cmd_hermes, "confirm_destructive", lambda **_kwargs: True) - - result = runner.invoke(cmd_hermes.hermes, ["delete", "ar-hermes-1", "-y"]) - - assert result.exit_code == 0, result.output - assert "agentengine hermes list" in result.output - assert "agentengine hermes deploy" in result.output - assert "agentengine hermes connect" not in result.output - assert "agentengine hermes pairing" not in result.output - - -def test_hermes_delete_passes_result_styles_to_descriptor(monkeypatch): - runner = CliRunner() - _FakeHermesClient.deleted = [] - monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesClient) - monkeypatch.setattr(cmd_hermes, "confirm_destructive", lambda **_kwargs: True) - captured = {} - - def _fake_render_descriptor_status(*args, **kwargs): - captured.update(kwargs) - - monkeypatch.setattr(cmd_hermes, "render_descriptor_status", _fake_render_descriptor_status) - - result = runner.invoke(cmd_hermes.hermes, ["delete", "ar-hermes-1", "-y"]) - - assert result.exit_code == 0, result.output - assert captured["fields"][1] == ("已删除", "ar-hermes-1", "ok") - assert captured["fields"][2] == ("失败", "-", "muted") - - -def test_hermes_delete_resolves_name_to_agent_id_and_rejects_non_hermes(monkeypatch): - runner = CliRunner() - _FakeHermesClient.deleted = [] - monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesClient) - monkeypatch.setattr(cmd_hermes, "confirm_destructive", lambda **_kwargs: True) - - delete_by_name = runner.invoke(cmd_hermes.hermes, ["delete", "demo-hermes", "-y"]) - - assert delete_by_name.exit_code == 0, delete_by_name.output - assert _FakeHermesClient.deleted == ["ar-hermes-1"] - - _FakeHermesClient.deleted = [] - monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeNonHermesClient) - - non_hermes = runner.invoke(cmd_hermes.hermes, ["delete", "ar-langgraph-1", "-y"]) - - assert non_hermes.exit_code != 0 - assert _FakeHermesClient.deleted == [] - - -class _FakeHermesUpdateNotFoundClient(_FakeHermesClient): - """update_agent 对已删除 agent 抛 404,create_agent 返回新 agent。""" - - update_called = False - create_called = False - - async def update_agent(self, agent_id, payload): - self.__class__.update_called = True - raise AgentEngineAPIError( - 404, - "未找到对应的 Agent", - details={"http_status": 404, "remote_error_message": "未找到对应的 Agent"}, - ) - - async def create_agent(self, payload): - self.__class__.create_called = True - self.__class__.create_payload = payload - return { - "agent_id": "ar-hermes-recreated", - "name": payload["name"], - "endpoint": "https://recreated-hermes.example.com", - "api_key": "ak-recreated", - } - - -def test_hermes_deploy_falls_back_to_create_when_state_points_to_deleted_agent( - monkeypatch, tmp_path: Path -): - """本地 .agentengine.state 缓存的 agent 已在服务端删除时,deploy 应自动回退为新建。""" - runner = CliRunner() - monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesUpdateNotFoundClient) - monkeypatch.chdir(tmp_path) - _FakeHermesUpdateNotFoundClient.update_called = False - _FakeHermesUpdateNotFoundClient.create_called = False - - # 预置失效的 state(指向一个已删除的 agent) - (tmp_path / ".agentengine.state").write_text( - "agent_id: ar-20260623102747-3aef1bf8\n" - "api_key: ak-stale\n" - "endpoint: http://ar-20260623102747-3aef1bf8.agent-pre.kspmas.ksyun.com\n" - "framework: hermes\n" - "image: hub.kce.ksyun.com/agentengine-public/hermes-agent:stale\n" - "name: demo-hermes\n" - "region: pre-online\n" - "type: hermes\n" - "ui_path: /\n" - "ui_profile: hermes\n", - encoding="utf-8", - ) - - result = runner.invoke( - cmd_hermes.hermes, - [ - "deploy", - "--name", - "demo-hermes", - "--image", - "ghcr.io/kingsoftcloud/hermes-agent:test", - "--model-base-url", - "https://model.example.com/v1", - "--model-api-key", - "sk-demo", - "--default-model", - "glm-test", - ], - ) - - assert result.exit_code == 0, result.output - assert _FakeHermesUpdateNotFoundClient.update_called is True - assert _FakeHermesUpdateNotFoundClient.create_called is True - # state 应被清理并写入新 agent_id - state = (tmp_path / ".agentengine.state").read_text(encoding="utf-8") - assert "ar-20260623102747-3aef1bf8" not in state - assert "agent_id: ar-hermes-recreated" in state - assert "endpoint: https://recreated-hermes.example.com" in state - assert "api_key: ak-recreated" in state - # 应有回退提示 - assert "本地状态失效" in result.output - - -def test_is_agent_not_found_error_matches_structured_404(): - from ksadk.deployment.agent_access import is_agent_not_found_error - - # AgentEngineAPIError code=404 → 命中 - assert is_agent_not_found_error( - AgentEngineAPIError(404, "未找到对应的 Agent", details={"http_status": 404}) - ) is True - # details.http_status=404(code 非 int)→ 命中 - assert is_agent_not_found_error( - AgentEngineAPIError("NotFound", "x", details={"http_status": 404}) - ) is True - - -def test_is_agent_not_found_error_matches_text_fallback(): - from ksadk.deployment.agent_access import is_agent_not_found_error - - # 裸 Exception 文案含 404 + 未找到对应的 agent → 命中 - assert is_agent_not_found_error(Exception("HTTP 404: 未找到对应的 agent")) is True - # code: 404 大写 + agent not found → 命中 - assert is_agent_not_found_error(Exception("Code: 404 - agent not found")) is True - - -def test_is_agent_not_found_error_rejects_non_agent_404(): - from ksadk.deployment.agent_access import is_agent_not_found_error - - # 404 但文案不含 agent-not-found(裸 AgentEngineAPIError code=404 结构化判定仍命中, - # 因为 Action API code=404 语义就是 agent not found)—— 这是预期行为 - # 但纯文案 404 无 agent 文案 → 不命中(避免误判鉴权/路由 404) - assert is_agent_not_found_error(Exception("HTTP 404: Forbidden")) is False - assert is_agent_not_found_error(Exception("model not found")) is False - assert is_agent_not_found_error(Exception("network error")) is False - assert is_agent_not_found_error(None) is False # type: ignore[arg-type] diff --git a/tests/test_cmd_invoke.py b/tests/test_cmd_invoke.py deleted file mode 100644 index 98d988f8..00000000 --- a/tests/test_cmd_invoke.py +++ /dev/null @@ -1,1595 +0,0 @@ -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 deleted file mode 100644 index 1338cf08..00000000 --- a/tests/test_cmd_launch_no_cache.py +++ /dev/null @@ -1,286 +0,0 @@ -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 deleted file mode 100644 index ab4e229b..00000000 --- a/tests/test_cmd_mcp_no_cache.py +++ /dev/null @@ -1,74 +0,0 @@ -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 deleted file mode 100644 index f16c05eb..00000000 --- a/tests/test_cmd_model.py +++ /dev/null @@ -1,170 +0,0 @@ -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 deleted file mode 100644 index 6e07852e..00000000 --- a/tests/test_code_builder_binary_compat.py +++ /dev/null @@ -1,31 +0,0 @@ -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 deleted file mode 100644 index 7c5f31ba..00000000 --- a/tests/test_code_builder_pip_indexes.py +++ /dev/null @@ -1,528 +0,0 @@ -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 deleted file mode 100644 index 27a7ec6e..00000000 --- a/tests/test_code_builder_rebuild_fingerprint.py +++ /dev/null @@ -1,313 +0,0 @@ -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 deleted file mode 100644 index 6b8a5e12..00000000 --- a/tests/test_code_builder_static_assets.py +++ /dev/null @@ -1,127 +0,0 @@ -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_project_custom_ui_dist(tmp_path): - (tmp_path / "agent.py").write_text("print('ok')\n", encoding="utf-8") - custom_dist = tmp_path / "research-ui" / "dist" / "assets" - custom_dist.mkdir(parents=True) - (tmp_path / "research-ui" / "dist" / "index.html").write_text("Custom UI", encoding="utf-8") - (custom_dist / "index.js").write_text("console.log('custom ui')\n", encoding="utf-8") - (tmp_path / "research-ui" / "node_modules").mkdir(parents=True) - (tmp_path / "research-ui" / "node_modules" / "ignored.js").write_text("ignored\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 "research-ui/dist/index.html" in names - assert "research-ui/dist/assets/index.js" in names - assert "research-ui/node_modules/ignored.js" not in names - - -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_compaction_pipeline.py b/tests/test_compaction_pipeline.py deleted file mode 100644 index 90d4146b..00000000 --- a/tests/test_compaction_pipeline.py +++ /dev/null @@ -1,309 +0,0 @@ -from __future__ import annotations - -from ksadk.conversations.compaction_pipeline import ( - SnipResult, - build_working_set_metadata, - candidate_tokens, - microcompact_cold_groups, - run_pipeline, - snip_redundant_groups, -) -from ksadk.sessions.base import SessionEvent - - -def _tool_call_event(seq: int, name: str, arguments: str | dict, invocation_id: str = "inv1", run_id: str | None = None) -> SessionEvent: - """构造真实 runtime 形态的 tool_call event。 - - 真实 runtime 把 tool_name/tool_args 放在 metadata(不是 content),content.role="model", - content.text 是工具名字符串。run_id 关联配对的 tool_result(同一 call/result 共享 run_id)。 - 这里严格对齐,确保 L2 在生产 event 上生效。 - """ - metadata = { - "tool_name": name, - "tool_args": arguments if isinstance(arguments, dict) else {"_raw": arguments}, - } - if run_id is not None: - metadata["run_id"] = run_id - return SessionEvent( - id=f"tc-{seq}", - seq_id=seq, - event_type="tool_call", - author="runner", - invocation_id=invocation_id, - content={"role": "model", "text": name}, - metadata=metadata, - ) - - -def _tool_result_event(seq: int, ok: bool = True, error_type: str = "", run_id: str | None = None) -> SessionEvent: - """构造真实 runtime 形态的 tool_result event。 - - 真实 runtime 的失败状态在 metadata.tool_receipt.status,不在 content.ok。 - run_id 关联配对的 tool_call(与 _tool_call_event 共享同一 run_id)。 - """ - content = {"role": "tool", "text": "result content"} - metadata = {"tool_name": "some_tool"} - if run_id is not None: - metadata["run_id"] = run_id - if not ok: - metadata["tool_receipt"] = {"status": "failed"} - if error_type: - metadata["error_type"] = error_type - return SessionEvent( - id=f"tr-{seq}", - seq_id=seq, - event_type="tool_result", - author="tool", - content=content, - metadata=metadata, - ) - - -def _assistant_event(seq: int, text: str) -> SessionEvent: - return SessionEvent( - id=f"a-{seq}", - seq_id=seq, - event_type="assistant_message", - author="assistant", - content={"role": "assistant", "text": text}, - ) - - -class TestSnipDoesNotMutateTranscript: - """Codex 纪律:L2 只作用于 candidate 投影,绝不改原 SessionEvent 实例和 transcript。""" - - def test_snip_returns_new_groups_not_original(self): - original_call = _tool_call_event(1, "search", "query=a") - original_result = _tool_result_event(2) - groups = [[original_call, original_result]] - original_call_id_before = id(original_call) - original_result_content_before = dict(original_result.content) - - result = snip_redundant_groups(groups) - - # 原 SessionEvent 实例 id 不变(没被替换)。 - assert id(groups[0][0]) == original_call_id_before - # 原 content 不变。 - assert groups[0][1].content == original_result_content_before - # 返回的是新 list(浅拷贝),不是原 groups 对象。 - assert result.groups is not groups - - def test_snip_does_not_call_service_or_delete_events(self): - # L2 是纯函数,不持有 service 引用,无法删 transcript。 - groups = [[_tool_call_event(1, "x", "1"), _tool_result_event(2)]] - result = snip_redundant_groups(groups) - # 原 groups 长度不变(transcript 没被删)。 - assert len(groups[0]) == 2 - # candidate 是新结构。 - assert isinstance(result, SnipResult) - assert isinstance(result.stats.tokens_before, int) - - -class TestSnipRedundancyRemoval: - def test_removes_tool_result_covered_by_later_group(self): - # 组1: search(query=a) [run_id=r1] → result [run_id=r1];组2: search(query=a) [run_id=r2] → result [run_id=r2](覆盖) - group1 = [_tool_call_event(1, "search", "query=a", run_id="r1"), _tool_result_event(2, run_id="r1")] - group2 = [_tool_call_event(3, "search", "query=a", run_id="r2"), _tool_result_event(4, run_id="r2")] - groups = [group1, group2] - - result = snip_redundant_groups(groups) - - # 组1 的 tool_call/tool_result 按 run_id 精确配对移除,组2 保留。 - flat = [e for g in result.groups for e in g] - seq_ids = [e.seq_id for e in flat] - assert 1 not in seq_ids # 旧 tool_call 被移除 - assert 2 not in seq_ids # 配对 tool_result(run_id=r1)被精确移除,不留孤儿 - assert 3 in seq_ids - assert 4 in seq_ids - assert result.stats.removed_redundant_tool_results >= 2 # call+result 成对 - - def test_parallel_tool_calls_no_orphan_result(self): - """Codex Finding 1:parallel_tool_calls=True 时同一轮多 tool_call 交错,按 run_id 精确配对不留孤儿。 - - 场景:同一组内两个 tool_call(search + web_fetch)交错,search 被后续组覆盖。 - 旧实现(邻接配对)会误删 web_fetch 的 result 或留 search 的孤儿 result; - 新实现(按 run_id 配对)只删 search 的 call+result,web_fetch 完整保留。 - """ - # 组1: 两个并行 tool_call + 交错 result(search[r1] 被 web_fetch[r2] 隔开) - group1 = [ - _tool_call_event(1, "search", "query=a", run_id="r1"), - _tool_call_event(2, "web_fetch", "url=x", run_id="r2"), - _tool_result_event(3, run_id="r1"), # search 的 result(被覆盖,应删) - _tool_result_event(4, run_id="r2"), # web_fetch 的 result(保留) - ] - # 组2: search(query=a) 覆盖组1 的 search - group2 = [_tool_call_event(5, "search", "query=a", run_id="r3"), _tool_result_event(6, run_id="r3")] - groups = [group1, group2] - - result = snip_redundant_groups(groups) - - flat = [e for g in result.groups for e in g] - seq_ids = [e.seq_id for e in flat] - # search[r1] 的 call(1) 和 result(3) 都被移除(按 run_id 精确配对)。 - assert 1 not in seq_ids - assert 3 not in seq_ids - # web_fetch[r2] 的 call(2) 和 result(4) 完整保留(不因邻接被误删)。 - assert 2 in seq_ids - assert 4 in seq_ids - # 组2 的 search[r3] 保留。 - assert 5 in seq_ids - assert 6 in seq_ids - - def test_missing_run_id_keeps_results_conservatively(self): - """run_id 缺失时(退化到 invocation_id 共享)保守不误删 tool_result。 - - 真实 LangGraph 路径 chunk 始终带 per-tool run_id,但若 chunk 无 run_id, - tool_result 退化到 prepared.invocation_id(整轮共享)。此时 covered_run_ids - 不收录(配对退化),tool_result 全部保留,避免误删未配对结果。 - """ - # 两个 tool_call 都无 run_id(模拟退化路径),即使 search 被覆盖,result 也保留。 - group1 = [_tool_call_event(1, "search", "query=a"), _tool_result_event(2)] # 无 run_id - group2 = [_tool_call_event(3, "search", "query=a"), _tool_result_event(4)] # 无 run_id - groups = [group1, group2] - - result = snip_redundant_groups(groups) - - flat = [e for g in result.groups for e in g] - seq_ids = [e.seq_id for e in flat] - # tool_call(1)被覆盖移除(signature 覆盖判定不依赖 run_id);但 tool_result(2)因无 run_id - # 无法精确配对,保守保留(不误删)。这是有意的保守行为,避免 run_id 缺失时误删 result。 - assert 1 not in seq_ids # tool_call 覆盖移除(不依赖 run_id) - assert 2 in seq_ids # tool_result 无 run_id,保守保留 - assert 3 in seq_ids and 4 in seq_ids - - def test_keeps_different_tool_results(self): - group1 = [_tool_call_event(1, "search", "query=a"), _tool_result_event(2)] - group2 = [_tool_call_event(3, "web_fetch", "url=x"), _tool_result_event(4)] - groups = [group1, group2] - - result = snip_redundant_groups(groups) - - flat = [e for g in result.groups for e in g] - assert len(flat) == 4 # 不同 tool 不覆盖,全保留 - - def test_disabled_via_env_returns_unchanged(self, monkeypatch): - monkeypatch.setenv("KSADK_COMPACT_SNIP_ENABLED", "false") - group1 = [_tool_call_event(1, "search", "query=a"), _tool_result_event(2)] - group2 = [_tool_call_event(3, "search", "query=a"), _tool_result_event(4)] - groups = [group1, group2] - - result = snip_redundant_groups(groups) - - flat = [e for g in result.groups for e in g] - assert len(flat) == 4 # 禁用后不移除 - assert result.stats.removed_redundant_tool_results == 0 - - -class TestMicrocompactProjection: - """Codex 纪律:L3 合成摘要 event 只存在于 candidate,不写回 transcript。""" - - def test_microcompact_replaces_cold_groups_with_summary_event(self): - # 5 组,默认 cold_rounds=3,前 2 组视为冷组。 - groups = [ - [_assistant_event(1, "old round 1")], - [_assistant_event(2, "old round 2")], - [_assistant_event(3, "tail 1")], - [_assistant_event(4, "tail 2")], - [_assistant_event(5, "tail 3")], - ] - - result = microcompact_cold_groups(groups) - - # candidate: [合成摘要组] + 尾部 3 组。 - assert len(result.groups) == 4 - # 第一组是合成摘要 event。 - summary_event = result.groups[0][0] - assert summary_event.event_type == "assistant_message" - assert summary_event.content.get("role") == "assistant" - assert "microcompact" in (summary_event.content.get("text") or "").lower() - assert summary_event.metadata.get("compaction_projection") is True - assert summary_event.metadata.get("compaction_stage") == "microcompact" - # 尾部组保留原 event。 - assert result.groups[1][0].seq_id == 3 - - def test_microcompact_does_not_mutate_original_groups(self): - groups = [[_assistant_event(1, "x")], [_assistant_event(2, "y")], [_assistant_event(3, "z")], [_assistant_event(4, "w")]] - original_len = len(groups) - - microcompact_cold_groups(groups) - - # 原 groups 列表不变。 - assert len(groups) == original_len - - def test_microcompact_preserved_receipts_collected(self): - cold_event = _assistant_event(1, "x") - cold_event.metadata = {"receipt_key": "deepresearch:web_search:v1"} - groups = [cold_event, _assistant_event(2, "y")], [[_assistant_event(3, "z")]], [[_assistant_event(4, "w")]], [[_assistant_event(5, "v")]] - # 构造 4+ 组让前几组变冷。 - flat_groups = [ - [cold_event, _assistant_event(2, "y")], - [_assistant_event(3, "z")], - [_assistant_event(4, "w")], - [_assistant_event(5, "v")], - ] - result = microcompact_cold_groups(flat_groups) - assert "deepresearch:web_search:v1" in result.stats.preserved_receipts - - def test_microcompact_disabled_via_env(self, monkeypatch): - monkeypatch.setenv("KSADK_COMPACT_MICROCOMPACT_ENABLED", "false") - groups = [[_assistant_event(1, "x")]] * 5 - result = microcompact_cold_groups(groups) - assert result.stats.compacted_groups == 0 - assert len(result.groups) == 5 # 原样返回 - - -class TestPipelineProgressiveStop: - def test_pipeline_stops_after_snip_if_under_threshold(self): - # 单组小内容,snip 后 token 远低于阈值。 - groups = [[_assistant_event(1, "small")]] - result = run_pipeline(groups, threshold_tokens=100000) - assert "snip" in result["pipeline_stages"] - assert "microcompact" not in result["pipeline_stages"] # 没超阈值,不进 L3 - - def test_pipeline_runs_microcompact_if_over_threshold(self): - # 5 组大内容,snip 后仍超低阈值,触发 L3。 - big = "x" * 5000 - groups = [[_assistant_event(i, big)] for i in range(1, 6)] - result = run_pipeline(groups, threshold_tokens=100) - assert "microcompact" in result["pipeline_stages"] - # L3 后 candidate 应含合成摘要 + 尾部组。 - assert len(result["candidate_groups"]) <= 4 - - def test_pipeline_returns_stats(self): - groups = [[_assistant_event(1, "x")], [_assistant_event(2, "y")]] - result = run_pipeline(groups, threshold_tokens=100000) - assert "snip_stats" in result - assert "tokens_before" in result - assert "tokens_after" in result - assert result["tokens_before"] >= result["tokens_after"] - - -class TestCandidateTokens: - def test_empty_groups_zero_tokens(self): - assert candidate_tokens([]) == 0 - - def test_counts_all_events(self): - groups = [[_assistant_event(1, "hello world")]] - tokens = candidate_tokens(groups) - assert tokens > 0 - - -class TestWorkingSetMetadata: - def test_conservative_metadata_no_file_content(self): - # Codex 纪律:L5 第一版只记 metadata,不读内容。 - ws = build_working_set_metadata( - pinned_state={"pending_approvals": ["appr1"], "current_user_goal": "deploy"}, - recent_files=[{"path": "/a/b.md", "mtime_ns": 1, "size_bytes": 100}], - active_tools=["web_search", "web_fetch"], - ) - assert ws["recent_files"] == [{"path": "/a/b.md", "mtime_ns": 1, "size_bytes": 100}] - assert "content" not in ws["recent_files"][0] # 不含文件内容 - assert ws["active_tools"] == ["web_search", "web_fetch"] - assert ws["pinned_approvals"] == ["appr1"] - assert ws["current_user_goal"] == "deploy" - - def test_caps_recent_files(self, monkeypatch): - monkeypatch.setenv("KSADK_WORKING_SET_MAX_FILES", "2") - files = [{"path": f"/f{i}"} for i in range(5)] - ws = build_working_set_metadata(pinned_state={}, recent_files=files) - assert len(ws["recent_files"]) == 2 # 截断到 2 diff --git a/tests/test_config_root_visibility.py b/tests/test_config_root_visibility.py deleted file mode 100644 index a56344ea..00000000 --- a/tests/test_config_root_visibility.py +++ /dev/null @@ -1,51 +0,0 @@ -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 deleted file mode 100644 index ae76e6ef..00000000 --- a/tests/test_container_registry_credentials.py +++ /dev/null @@ -1,136 +0,0 @@ -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_conversation_runtime.py b/tests/test_conversation_runtime.py deleted file mode 100644 index f78bd54c..00000000 --- a/tests/test_conversation_runtime.py +++ /dev/null @@ -1,4847 +0,0 @@ -from __future__ import annotations - -import asyncio -import base64 -import importlib -import json -import time -from pathlib import Path - -import httpx -import pytest -from opentelemetry import trace -from opentelemetry.sdk.trace import TracerProvider -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_chat_completions_payload, - build_compaction_sse_event, - build_responses_payload, - build_run_input, - compact_conversation_history, - extract_responses_resume_input, - invoke_conversation_once, - preview_auto_compaction, - stream_conversation_turn, - stream_responses_conversation_turn, -) -from ksadk.runtime_context import ( - PlatformInvocationContext, - get_current_tool_execution_context_or_default, - get_current_invocation_context_or_default, - get_current_account_id, - get_current_invocation_context, - get_current_user_id, - platform_invocation_scope, - tool_execution_scope, -) -from ksadk.sessions.base import SessionEvent -from ksadk.sessions.in_memory import InMemorySessionService -from ksadk.tracing.exporters.inmemory_exporter import InMemoryExporter - - -class _StubRunner: - def __init__(self): - self.detection_result = type("Detection", (), {"name": "demo-agent"})() - self.calls: list[dict] = [] - self.prepared_models: list[str | None] = [] - - def prepare_for_request(self, model): - self.prepared_models.append(model) - - async def invoke(self, input_data: dict) -> dict: - self.calls.append(input_data) - 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 _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) - 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__() - self.invocation_count = 0 - - async def invoke(self, input_data: dict) -> dict: - self.calls.append(input_data) - self.invocation_count += 1 - if self.invocation_count == 1: - raise RuntimeError("prompt-too-long") - 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__() - self.stream_calls: list[dict] = [] - - async def stream(self, input_data: dict): - self.stream_calls.append(input_data) - yield {"type": "text", "delta": "hello"} - yield {"type": "final", "output": "hello"} - - -class _PromptTooLongStreamingRunner(_StreamingRunner): - async def stream(self, input_data: dict): - self.stream_calls.append(input_data) - raise RuntimeError("prompt-too-long") - - -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}, - }, - "metadata": { - "last_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) - 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) - yield { - "type": "tool_call", - "tool_name": "write_workspace_file", - "tool_args": {"path": "notes.txt"}, - "run_id": "run-approval", - } - yield { - "type": "tool_result", - "tool_name": "write_workspace_file", - "tool_args": {"path": "notes.txt"}, - "tool_output": { - "ok": False, - "type": "approval_required", - "approval_request": { - "id": "appr_write", - "tool_name": "write_workspace_file", - "risk_level": "medium", - "side_effects": ["workspace_write"], - }, - }, - "run_id": "run-approval", - } - 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 _ToolSearchResultStreamingRunner(_StreamingRunner): - async def stream(self, input_data: dict): - self.stream_calls.append(input_data) - yield { - "type": "tool_result", - "tool_name": "tool_search", - "tool_args": {"query": "edit file"}, - "tool_output": { - "ok": True, - "deferred_tool_names": ["read_workspace_file", "edit_workspace_file"], - }, - "run_id": "run-tool-search", - } - yield {"type": "final", "output": "ready"} - - -class _ManyToolCallsStreamingRunner(_StreamingRunner): - async def stream(self, input_data: dict): - self.stream_calls.append(input_data) - for index in range(3): - yield { - "type": "tool_call", - "tool_name": "list_skills", - "tool_args": {"index": index}, - "run_id": f"run-tool-{index}", - } - yield {"type": "final", "output": "should stop before final"} - - -class _FailingToolResultsStreamingRunner(_StreamingRunner): - async def stream(self, input_data: dict): - self.stream_calls.append(input_data) - for index in range(2): - yield { - "type": "tool_result", - "tool_name": "run_command", - "tool_args": {"command": "false"}, - "tool_output": {"ok": False, "error_type": "boom"}, - "run_id": f"run-failure-{index}", - } - yield {"type": "final", "output": "should stop before final"} - - -class _StageActivityStreamingRunner(_StreamingRunner): - async def stream(self, input_data: dict): - self.stream_calls.append(input_data) - yield { - "type": "stage_tool_call", - "tool_name": "deepresearch_query_agent", - "tool_args": {"stage": "search_web", "query": "agent runtime"}, - "event_kind": "query", - "display_title": "检索公开网页", - "display_summary": "开始检索 agent runtime", - "run_id": "run-stage-activity", - } - yield { - "type": "stage_tool_result", - "tool_name": "deepresearch_query_agent", - "tool_args": {"stage": "search_web", "query": "agent runtime"}, - "tool_output": {"ok": True, "result_count": 3}, - "event_kind": "query", - "display_title": "检索公开网页", - "display_summary": "返回 3 条结果", - "run_id": "run-stage-activity", - } - yield {"type": "final", "output": "done"} - - -class _StageActivityNoFinalStreamingRunner(_StreamingRunner): - async def stream(self, input_data: dict): - self.stream_calls.append(input_data) - yield { - "type": "stage_tool_result", - "tool_name": "deepresearch_query_agent", - "tool_args": {"stage": "search_web", "query": "agent runtime"}, - "tool_output": {"ok": True, "result_count": 3}, - "event_kind": "query", - "display_title": "检索公开网页", - "display_summary": "返回 3 条结果", - "run_id": "run-stage-activity", - } - - -class _ResumeStreamingRunner(_StreamingRunner): - async def stream(self, input_data: dict): - self.stream_calls.append(input_data) - yield {"type": "final", "output": "resumed"} - - -class _CompletedOutputStreamingRunner(_StreamingRunner): - async def stream(self, input_data: dict): - self.stream_calls.append(input_data) - yield {"type": "text", "delta": "需要查询。"} - yield { - "type": "responses_output", - "response_id": "resp_native", - "output": [ - { - "id": "fc_123", - "type": "function_call", - "call_id": "call_123", - "name": "search", - "arguments": '{"q":"openclaw"}', - "status": "completed", - }, - { - "id": "rs_123", - "type": "reasoning", - "summary": [{"type": "summary_text", "text": "先查资料"}], - }, - ], - } - 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) - yield {"type": "thinking", "delta": "先分析问题"} - yield {"type": "text", "delta": "你好"} - yield {"type": "final", "output": "你好"} - - -class _ContextCapturingRunner(_StubRunner): - def __init__(self): - super().__init__() - self.captured_runtime_context = None - self.captured_tool_context = None - - async def invoke(self, input_data: dict) -> dict: - self.calls.append(input_data) - self.captured_runtime_context = get_current_invocation_context() - self.captured_tool_context = get_current_tool_execution_context_or_default() - return {"output": "captured"} - - -class _ToolContextCapturingStreamingRunner(_StreamingRunner): - def __init__(self): - super().__init__() - self.captured_tool_context = None - - async def stream(self, input_data: dict): - self.stream_calls.append(input_data) - self.captured_tool_context = get_current_tool_execution_context_or_default() - yield {"type": "text", "delta": "hello"} - yield {"type": "final", "output": "hello"} - - -class _FakeLongTermMemoryService: - instances: list["_FakeLongTermMemoryService"] = [] - - def __init__(self): - self.saved: list[dict] = [] - self.__class__.instances.append(self) - - def build_context(self, *, user_id: str, query: str, top_k=None) -> dict | None: - return None - - def save_event_strings(self, *, user_id: str, event_strings: list[str], metadata=None) -> bool: - self.saved.append( - { - "user_id": user_id, - "event_strings": event_strings, - "metadata": dict(metadata or {}), - } - ) - return True - - -class _ExternalModelsAsyncClient: - def __init__(self, *args, payload=None, error: Exception | None = None, **kwargs): - self._payload = payload - self._error = error - - async def __aenter__(self): - return self - - async def __aexit__(self, exc_type, exc, tb): - return None - - async def get(self, url: str, headers: dict | None = None): - if self._error is not None: - raise self._error - request = httpx.Request("GET", url, headers=headers) - return httpx.Response(200, json=self._payload, request=request) - - -def _extract_sse_payload(chunks: list[str], event_name: str) -> dict: - current_event = "" - for chunk in chunks: - for line in chunk.splitlines(): - if line.startswith("event: "): - current_event = line.removeprefix("event: ") - elif line.startswith("data: ") and current_event == event_name: - return json.loads(line.removeprefix("data: ")) - raise AssertionError(f"SSE event {event_name!r} not found") - - -def test_runtime_context_helpers_return_defaults_outside_invocation_scope(): - assert get_current_invocation_context() is None - context = get_current_invocation_context_or_default() - assert context.user_id == "" - assert context.account_id == "" - assert context.session_id == "" - assert context.history == [] - assert context.attachments == [] - assert get_current_user_id() == "" - assert get_current_account_id() == "" - assert get_current_user_id(default="anonymous") == "anonymous" - assert get_current_account_id(default="tenantless") == "tenantless" - - -def test_runtime_context_helpers_read_current_invocation_scope(): - context = PlatformInvocationContext( - agent_id="demo-agent", - user_id="user-1", - account_id="acct-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="mock", - ) - - with platform_invocation_scope(context): - assert get_current_user_id() == "user-1" - assert get_current_account_id() == "acct-1" - - -def test_tool_execution_context_helpers_return_defaults_and_scope_values(): - default_context = get_current_tool_execution_context_or_default() - - assert default_context.session_id == "" - assert default_context.run_id == "" - assert default_context.invocation_id == "" - - with tool_execution_scope(session_id="sess-1", run_id="run-1", invocation_id="inv-1"): - context = get_current_tool_execution_context_or_default() - assert context.session_id == "sess-1" - assert context.run_id == "run-1" - assert context.invocation_id == "inv-1" - - assert get_current_tool_execution_context_or_default().session_id == "" - - -@pytest.fixture -def in_memory_trace_exporter(): - provider = TracerProvider() - exporter = InMemoryExporter() - provider.add_span_processor(SimpleSpanProcessor(exporter)) - trace._TRACER_PROVIDER = None - trace._TRACER_PROVIDER_SET_ONCE._done = False - trace._set_tracer_provider(provider, log=False) - yield exporter - trace._TRACER_PROVIDER = None - trace._TRACER_PROVIDER_SET_ONCE._done = False - - -@pytest.fixture(autouse=True) -def _disable_session_title_ai(monkeypatch): - class _UnavailableTitleClient: - @property - def is_available(self): - return False - - monkeypatch.setattr( - "ksadk.conversations.runtime.resolve_session_title_client", - lambda: _UnavailableTitleClient(), - ) - - -def test_estimate_text_tokens_is_less_optimistic_for_cjk(): - assert estimate_text_tokens("") == 0 - assert estimate_text_tokens("hello world") == 3 - assert estimate_text_tokens("你好世界") == 4 - assert estimate_text_tokens("Agent平台设计") == 6 - - -def test_build_compaction_sse_event_returns_str_with_millisecond_timestamp(): - before_ms = int(time.time() * 1000) - event = build_compaction_sse_event( - phase="start", - trigger="auto", - compacted_until_seq_id=42, - total_chars=1200, - total_estimated_tokens=512, - group_count=9, - threshold_percentage=80, - ) - after_ms = int(time.time() * 1000) - - assert isinstance(event, str) - assert event.startswith("event: response.compaction.start\n") - payload_line = event.splitlines()[1] - assert payload_line.startswith("data: ") - payload = json.loads(payload_line.removeprefix("data: ")) - assert payload["phase"] == "start" - assert payload["trigger"] == "auto" - assert payload["compacted_until_seq_id"] == 42 - assert payload["total_chars"] == 1200 - assert payload["total_estimated_tokens"] == 512 - assert payload["group_count"] == 9 - assert payload["threshold_percentage"] == 80 - assert isinstance(payload["timestamp"], int) - assert before_ms <= payload["timestamp"] <= after_ms - - -def test_extract_responses_resume_input_accepts_openai_mcp_approval_response(): - resume_input = extract_responses_resume_input( - [ - { - "type": "mcp_approval_response", - "id": "mcprsp_123", - "approval_request_id": "appr_123", - "approve": True, - "reason": "looks safe", - } - ] - ) - - assert resume_input == { - "type": "mcp_approval_response", - "id": "mcprsp_123", - "approval_request_id": "appr_123", - "approve": True, - "reason": "looks safe", - } - - -def test_extract_responses_resume_input_accepts_ksadk_resume_extension(): - resume_input = extract_responses_resume_input( - [ - { - "type": "ksadk_resume", - "interrupt_id": "intr_123", - "value": {"answer": "继续", "approved": True}, - } - ] - ) - - assert resume_input == { - "type": "ksadk_resume", - "interrupt_id": "intr_123", - "value": {"answer": "继续", "approved": True}, - } - - -def test_extract_responses_resume_input_accepts_openai_function_call_output(): - resume_input = extract_responses_resume_input( - [ - { - "type": "function_call_output", - "call_id": "call_123", - "output": {"ok": True}, - } - ] - ) - - assert resume_input == { - "type": "function_call_output", - "call_id": "call_123", - "output": {"ok": True}, - } - - -def test_governance_records_approval_denials_and_trips_limit(): - runtime_module = importlib.import_module("ksadk.conversations.runtime") - state = runtime_module.RuntimeGovernanceState(max_consecutive_approval_denials=2) - - runtime_module._governance_record_approval_response(state, {"approved": False}) - try: - runtime_module._governance_record_approval_response(state, {"approved": False}) - except runtime_module.RuntimeCircuitOpen as exc: - assert exc.reason == "consecutive_approval_denials" - assert exc.metadata["consecutive_approval_denials"] == 2 - else: - raise AssertionError("expected RuntimeCircuitOpen") - - -def test_governance_records_compact_failures_and_trips_limit(): - runtime_module = importlib.import_module("ksadk.conversations.runtime") - state = runtime_module.RuntimeGovernanceState(max_consecutive_compact_failures=1) - - try: - runtime_module._governance_record_compact_failure(state) - except runtime_module.RuntimeCircuitOpen as exc: - assert exc.reason == "consecutive_compact_failures" - assert exc.metadata["consecutive_compact_failures"] == 1 - else: - raise AssertionError("expected RuntimeCircuitOpen") - - -@pytest.mark.asyncio -async def test_build_run_input_projects_history_from_append_only_events(monkeypatch): - service = InMemorySessionService() - await service.create_session(agent_id="demo-agent", user_id="user-1", session_id="sess-1") - await service.append_event( - "sess-1", - SessionEvent( - id="evt-1", - author="user", - event_type="user_message", - content={"role": "user", "parts": [{"text": "hello"}]}, - ), - ) - await service.append_event( - "sess-1", - SessionEvent( - id="evt-2", - author="demo-agent", - event_type="assistant_message", - content={"role": "model", "parts": [{"text": "hi"}]}, - ), - ) - - monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service) - - prepared = await build_run_input( - agent_id="demo-agent", - user_id="user-1", - session_id="sess-1", - messages=[{"role": "user", "content": "follow up"}], - ) - - assert prepared.history == [ - {"role": "user", "content": "hello"}, - {"role": "model", "content": "hi"}, - {"role": "user", "content": "follow up"}, - ] - - events = await service.get_events("sess-1") - assert [event.event_type for event in events] == [ - "user_message", - "assistant_message", - "user_message", - ] - - -@pytest.mark.asyncio -async def test_build_run_input_preserves_responses_request_history_when_runtime_session_is_empty( - monkeypatch, -): - service = InMemorySessionService() - monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service) - - prepared = await build_run_input( - agent_id="demo-agent", - user_id="user-1", - session_id="sess-responses-history", - messages=[ - {"role": "user", "content": "写一个python快排的示例"}, - {"role": "assistant", "content": "这是 Python 快速排序示例。"}, - {"role": "user", "content": "用go"}, - ], - ) - - assert prepared.user_input == "用go" - assert prepared.history == [ - {"role": "user", "content": "写一个python快排的示例"}, - {"role": "model", "content": "这是 Python 快速排序示例。"}, - {"role": "user", "content": "用go"}, - ] - - -@pytest.mark.asyncio -async def test_build_run_input_deduplicates_responses_request_history_against_session_events( - monkeypatch, -): - service = InMemorySessionService() - await service.create_session(agent_id="demo-agent", user_id="user-1", session_id="sess-dup") - await service.append_event( - "sess-dup", - SessionEvent( - id="evt-1", - author="user", - event_type="user_message", - content={"role": "user", "parts": [{"text": "写一个python快排的示例"}]}, - ), - ) - await service.append_event( - "sess-dup", - SessionEvent( - id="evt-2", - author="demo-agent", - event_type="assistant_message", - content={"role": "model", "parts": [{"text": "这是 Python 快速排序示例。"}]}, - ), - ) - monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service) - - prepared = await build_run_input( - agent_id="demo-agent", - user_id="user-1", - session_id="sess-dup", - messages=[ - {"role": "user", "content": "写一个python快排的示例"}, - {"role": "assistant", "content": "这是 Python 快速排序示例。"}, - {"role": "user", "content": "用go"}, - ], - ) - - assert prepared.history == [ - {"role": "user", "content": "写一个python快排的示例"}, - {"role": "model", "content": "这是 Python 快速排序示例。"}, - {"role": "user", "content": "用go"}, - ] - - -def test_set_conversation_span_attributes_sets_langfuse_and_standard_session_id(): - from ksadk.conversations.runtime import _set_conversation_span_attributes - - class _Span: - def __init__(self): - self.attributes = {} - - def set_attribute(self, key, value): - self.attributes[key] = value - - span = _Span() - - _set_conversation_span_attributes( - span, - agent_id="agent-demo", - user_id="user-demo", - session_id="sess-demo", - invocation_id="inv-demo", - runner_name="demo-agent", - model="glm-5.1", - response_id="resp-demo", - ) - - assert span.attributes["langfuse.session.id"] == "sess-demo" - assert span.attributes["session.id"] == "sess-demo" - assert span.attributes["langfuse.user.id"] == "user-demo" - assert span.attributes["user.id"] == "user-demo" - - -@pytest.mark.asyncio -async def test_build_run_input_persists_attachment_results_and_passes_them_to_runner(monkeypatch): - service = InMemorySessionService() - monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service) - - message = { - "role": "user", - "content": "[上传文件: resume.pdf]\n张三 8年经验", - "display_content": "请分析附件\n\n## 附件\n- resume.pdf", - "parts": [{"text": "请分析附件"}], - "attachments": [ - { - "display_name": "resume.pdf", - "mime_type": "application/pdf", - "transport": "reference", - "file_uri": "ksadk-upload://resume", - "size_bytes": 128, - } - ], - "attachment_results": [ - { - "display_name": "resume.pdf", - "mime_type": "application/pdf", - "transport": "reference", - "file_uri": "ksadk-upload://resume", - "size_bytes": 128, - "kind": "document", - "status": "ok", - "warnings": [], - "extraction_method": "pdf_native", - "text_excerpt": "张三 8年经验", - "text": "张三 8年经验", - "document": {"format": "pdf"}, - } - ], - } - - prepared = await build_run_input( - agent_id="demo-agent", - user_id="user-1", - session_id=None, - messages=[message], - ) - - assert prepared.attachments == message["attachments"] - assert prepared.attachment_results == message["attachment_results"] - - events = await service.get_events(prepared.session_id) - assert events[0].metadata["attachment_results"] == [ - { - "display_name": "resume.pdf", - "mime_type": "application/pdf", - "transport": "reference", - "file_uri": "ksadk-upload://resume", - "size_bytes": 128, - "kind": "document", - "status": "ok", - "warnings": [], - "extraction_method": "pdf_native", - "text_excerpt": "张三 8年经验", - "document": {"format": "pdf"}, - } - ] - - runner = _StubRunner() - session_id, result = await invoke_conversation_once( - runner=runner, - agent_id="demo-agent", - user_id="user-1", - session_id=prepared.session_id, - messages=[message], - model="gpt-4o", - prepare_runner=lambda current_runner, model: current_runner.prepare_for_request(model), - session_service_provider=lambda: service, - ) - - assert session_id == prepared.session_id - assert result["output_text"] == "assistant says hi" - assert runner.calls[-1]["attachment_results"] == message["attachment_results"] - - -@pytest.mark.asyncio -async def test_build_run_input_reuses_last_attachment_results_for_follow_up_turn(monkeypatch): - service = InMemorySessionService() - monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service) - - message = { - "role": "user", - "content": "[上传文件: resume.txt]\n张三 8年经验", - "display_content": "请分析附件\n\n## 附件\n- resume.txt", - "parts": [{"text": "请分析附件"}], - "attachments": [ - { - "display_name": "resume.txt", - "mime_type": "text/plain", - "transport": "reference", - "file_uri": "ksadk-upload://resume", - "size_bytes": 64, - } - ], - "attachment_results": [ - { - "display_name": "resume.txt", - "mime_type": "text/plain", - "transport": "reference", - "file_uri": "ksadk-upload://resume", - "size_bytes": 64, - "kind": "text", - "status": "ok", - "warnings": [], - "extraction_method": "text_decode", - "text_excerpt": "张三 8年经验", - "text": "张三 8年经验", - } - ], - } - - first = await build_run_input( - agent_id="demo-agent", - user_id="user-1", - session_id=None, - messages=[message], - ) - follow_up = await build_run_input( - agent_id="demo-agent", - user_id="user-1", - session_id=first.session_id, - messages=[{"role": "user", "content": "继续分析"}], - ) - - assert first.current_attachments == message["attachments"] - assert first.current_attachment_results == message["attachment_results"] - assert first.has_current_files is True - assert follow_up.attachments == message["attachments"] - assert follow_up.attachment_results == [ - { - "display_name": "resume.txt", - "mime_type": "text/plain", - "transport": "reference", - "file_uri": "ksadk-upload://resume", - "size_bytes": 64, - "kind": "text", - "status": "ok", - "warnings": [], - "extraction_method": "text_decode", - "text_excerpt": "张三 8年经验", - } - ] - assert follow_up.current_attachments == [] - assert follow_up.current_attachment_results == [] - assert follow_up.has_current_files is False - - runner = _StubRunner() - session_id, result = await invoke_conversation_once( - runner=runner, - agent_id="demo-agent", - user_id="user-1", - session_id=first.session_id, - 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 session_id == first.session_id - assert result["output_text"] == "assistant says hi" - assert runner.calls[-1]["attachment_results"] == follow_up.attachment_results - assert runner.calls[-1]["current_attachments"] == [] - assert runner.calls[-1]["current_attachment_results"] == [] - assert runner.calls[-1]["has_current_files"] is False - - -@pytest.mark.asyncio -async def test_build_run_input_stores_recent_attachment_context_without_inline_data(monkeypatch): - service = InMemorySessionService() - monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service) - - image_b64 = base64.b64encode(b"fake image bytes").decode("ascii") - first = await build_run_input( - agent_id="demo-agent", - user_id="user-1", - session_id=None, - messages=[ - { - "role": "user", - "content": [ - {"type": "input_text", "text": "看图"}, - { - "type": "input_image", - "image_url": f"data:image/png;base64,{image_b64}", - }, - ], - } - ], - ) - - session = await service.get_session(first.session_id) - attachment_context = session.state["__ksadk_attachment_context__"] - assert attachment_context["attachments"] == [ - { - "display_name": "uploaded_image", - "mime_type": "image/png", - "transport": "inline", - "size_bytes": len(b"fake image bytes"), - "is_text": False, - } - ] - assert "data" not in attachment_context["attachments"][0] - - follow_up = await build_run_input( - agent_id="demo-agent", - user_id="user-1", - session_id=first.session_id, - messages=[{"role": "user", "content": "继续"}], - ) - - assert follow_up.attachments == attachment_context["attachments"] - assert follow_up.current_attachments == [] - assert follow_up.has_current_files is False - - -@pytest.mark.asyncio -async def test_build_run_input_sanitizes_legacy_recent_attachment_context(monkeypatch): - service = InMemorySessionService() - monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service) - session = await service.create_session( - agent_id="demo-agent", - user_id="user-1", - session_id="sess-legacy-inline-context", - ) - await service.update_state( - agent_id="demo-agent", - user_id="user-1", - session_id=session.id, - scope="session", - state_delta={ - "__ksadk_attachment_context__": { - "attachments": [ - { - "display_name": "legacy.png", - "mime_type": "image/png", - "transport": "inline", - "data": base64.b64encode(b"legacy image").decode("ascii"), - "size_bytes": 12, - } - ], - "attachment_results": [ - { - "display_name": "legacy.png", - "mime_type": "image/png", - "transport": "inline", - "text": "图片摘要", - "text_excerpt": "图片摘要", - } - ], - } - }, - ) - - follow_up = await build_run_input( - agent_id="demo-agent", - user_id="user-1", - session_id=session.id, - messages=[{"role": "user", "content": "继续"}], - ) - - assert follow_up.attachments == [ - { - "display_name": "legacy.png", - "mime_type": "image/png", - "transport": "inline", - "size_bytes": 12, - } - ] - assert "data" not in json.dumps(follow_up.attachments, ensure_ascii=False) - assert follow_up.attachment_results == [ - { - "display_name": "legacy.png", - "mime_type": "image/png", - "transport": "inline", - "text_excerpt": "图片摘要", - } - ] - assert "text" not in follow_up.attachment_results[0] - assert follow_up.current_attachments == [] - assert follow_up.has_current_files is False - - -@pytest.mark.asyncio -async def test_build_run_input_detects_current_openai_input_image(monkeypatch): - service = InMemorySessionService() - monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service) - - image_b64 = "iVBORw0KGgo=" - prepared = await build_run_input( - agent_id="demo-agent", - user_id="user-1", - session_id=None, - messages=[ - { - "role": "user", - "content": [ - {"type": "input_text", "text": "请分析这张图"}, - { - "type": "input_image", - "image_url": f"data:image/png;base64,{image_b64}", - }, - ], - } - ], - model=None, - ) - - assert prepared.has_current_files is True - assert prepared.current_attachments == [ - { - "display_name": "uploaded_image", - "mime_type": "image/png", - "transport": "inline", - "data": image_b64, - "is_text": False, - "size_bytes": 8, - } - ] - assert prepared.attachments == prepared.current_attachments - assert prepared.user_parts[1] == { - "inlineData": { - "data": image_b64, - "mimeType": "image/png", - "displayName": "uploaded_image", - } - } - assert prepared.input_content == [ - {"type": "input_text", "text": "请分析这张图"}, - {"type": "input_image", "image_url": f"data:image/png;base64,{image_b64}"}, - ] - assert prepared.input_messages == [ - { - "role": "user", - "content": [ - {"type": "input_text", "text": "请分析这张图"}, - {"type": "input_image", "image_url": f"data:image/png;base64,{image_b64}"}, - ], - } - ] - - -@pytest.mark.asyncio -async def test_build_run_input_detects_openai_input_image_object_url(monkeypatch): - service = InMemorySessionService() - monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service) - - image_b64 = "YWJj" - prepared = await build_run_input( - agent_id="demo-agent", - user_id="user-1", - session_id=None, - messages=[ - { - "role": "user", - "content": [ - { - "type": "input_image", - "image_url": {"url": f"data:image/jpeg;base64,{image_b64}"}, - }, - ], - } - ], - ) - - assert prepared.has_current_files is True - assert prepared.current_attachments[0]["display_name"] == "uploaded_image" - assert prepared.current_attachments[0]["mime_type"] == "image/jpeg" - assert prepared.current_attachments[0]["data"] == image_b64 - - -@pytest.mark.asyncio -async def test_build_run_input_preserves_openai_input_image_remote_url(monkeypatch): - service = InMemorySessionService() - monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service) - - image_url = "https://example.com/diagram.png" - prepared = await build_run_input( - agent_id="demo-agent", - user_id="user-1", - session_id=None, - messages=[ - { - "role": "user", - "content": [ - { - "type": "input_image", - "image_url": image_url, - }, - ], - } - ], - ) - - assert prepared.has_current_files is True - assert prepared.current_attachments == [ - { - "display_name": "uploaded_image", - "mime_type": "image/*", - "transport": "reference", - "file_uri": image_url, - "is_text": False, - "size_bytes": None, - "storage_path": None, - } - ] - assert prepared.attachment_results[0]["warnings"] == [ - "附件内容无法读取,请重新上传或检查文件句柄是否仍可访问。" - ] - - -@pytest.mark.asyncio -async def test_build_run_input_detects_openai_input_file_data(monkeypatch): - service = InMemorySessionService() - monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service) - - file_text = "候选人简历内容" - file_b64 = base64.b64encode(file_text.encode("utf-8")).decode("ascii") - prepared = await build_run_input( - agent_id="demo-agent", - user_id="user-1", - session_id=None, - messages=[ - { - "role": "user", - "content": [ - {"type": "input_text", "text": "请总结附件"}, - { - "type": "input_file", - "filename": "resume.txt", - "file_data": file_b64, - }, - ], - } - ], - ) - - assert prepared.has_current_files is True - assert prepared.current_attachments == [ - { - "display_name": "resume.txt", - "mime_type": "text/plain", - "transport": "inline", - "data": file_b64, - "is_text": True, - "size_bytes": len(file_text.encode("utf-8")), - } - ] - assert prepared.attachment_results[0]["text"] == file_text - assert prepared.input_content == [ - {"type": "input_text", "text": "请总结附件"}, - { - "type": "input_file", - "filename": "resume.txt", - "file_data": file_b64, - }, - ] - - -@pytest.mark.asyncio -async def test_build_run_input_preserves_openai_input_file_references(monkeypatch): - service = InMemorySessionService() - monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service) - - prepared = await build_run_input( - agent_id="demo-agent", - user_id="user-1", - session_id=None, - messages=[ - { - "role": "user", - "content": [ - { - "type": "input_file", - "filename": "report.pdf", - "file_url": "https://example.com/report.pdf", - }, - { - "type": "input_file", - "filename": "uploaded.pdf", - "file_id": "file-abc123", - }, - ], - } - ], - ) - - assert prepared.has_current_files is True - assert prepared.current_attachments == [ - { - "display_name": "report.pdf", - "mime_type": "application/pdf", - "transport": "reference", - "file_uri": "https://example.com/report.pdf", - "is_text": False, - "size_bytes": None, - "storage_path": None, - }, - { - "display_name": "uploaded.pdf", - "mime_type": "application/pdf", - "transport": "reference", - "file_uri": "file-abc123", - "is_text": False, - "size_bytes": None, - "storage_path": None, - }, - ] - - -@pytest.mark.asyncio -async def test_invoke_conversation_once_persists_canonical_turn_events(monkeypatch): - service = InMemorySessionService() - 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=None, - messages=[{"role": "user", "content": "hello"}], - model="gpt-4o", - prepare_runner=lambda runner, model: runner.prepare_for_request(model), - ) - - assert result["output_text"] == "assistant says hi" - assert runner.prepared_models == ["gpt-4o"] - assert runner.calls[-1]["history"] == [{"role": "user", "content": "hello"}] - assert runner.calls[-1]["input_content"] == [{"type": "input_text", "text": "hello"}] - assert runner.calls[-1]["input_messages"] == [ - {"role": "user", "content": [{"type": "input_text", "text": "hello"}]} - ] - assert runner.calls[-1]["input_parts"] == [{"text": "hello"}] - - events = await service.get_events(session_id) - session = await service.get_session(session_id) - assert [event.event_type for event in events] == [ - "user_message", - "run_status", - "assistant_message", - "run_status", - ] - assert [event.author for event in events] == ["user", "demo-agent", "demo-agent", "demo-agent"] - assert session is not None - assert session.title == "hello" - assert session.title_source == "fallback_first_prompt" - assert session.first_prompt == "hello" - assert session.last_prompt == "hello" - assert session.summary == "assistant says hi" - - -@pytest.mark.asyncio -async def test_invoke_conversation_once_persists_responses_image_parts_for_replay(monkeypatch): - service = InMemorySessionService() - monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service) - runner = _StubRunner() - image_url = "data:image/png;base64,aW1hZ2U=" - - session_id, _ = await invoke_conversation_once( - runner=runner, - agent_id="demo-agent", - user_id="user-1", - session_id=None, - messages=[ - { - "role": "user", - "content": [ - {"type": "input_text", "text": "请分析这张图"}, - {"type": "input_image", "image_url": image_url}, - ], - } - ], - model=None, - prepare_runner=lambda runner, model: None, - ) - - events = await service.get_events(session_id) - assert events[0].event_type == "user_message" - assert events[0].content == { - "role": "user", - "parts": [ - {"type": "input_text", "text": "请分析这张图"}, - {"type": "input_image", "image_url": image_url}, - ], - } - - -@pytest.mark.asyncio -async def test_invoke_conversation_once_persists_response_id_on_assistant_event(monkeypatch): - service = InMemorySessionService() - 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=None, - messages=[{"role": "user", "content": "hello"}], - model="gpt-4o", - response_id="resp_feedback_nonstream", - prepare_runner=lambda runner, model: 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") - assert result["response_id"] == "resp_feedback_nonstream" - assert assistant_event.metadata["response_id"] == "resp_feedback_nonstream" - - -@pytest.mark.asyncio -async def test_invoke_conversation_once_persists_trace_metadata_for_feedback( - monkeypatch, - in_memory_trace_exporter, -): - service = InMemorySessionService() - 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=None, - messages=[{"role": "user", "content": "hello"}], - model="gpt-4o", - response_id="resp_trace_nonstream", - prepare_runner=lambda runner, model: 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") - trace_id = assistant_event.metadata.get("trace_id") - root_span_id = assistant_event.metadata.get("root_span_id") - - assert trace_id - assert root_span_id - assert result["metadata"]["trace_id"] == trace_id - assert result["metadata"]["root_span_id"] == root_span_id - exported_trace = in_memory_trace_exporter.get_trace(trace_id) - assert exported_trace is not None - assert any(span["span_id"] == root_span_id for span in exported_trace["spans"]) - - -@pytest.mark.asyncio -async def test_invoke_conversation_once_sets_langfuse_trace_io_attributes( - monkeypatch, - in_memory_trace_exporter, -): - service = InMemorySessionService() - 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=None, - messages=[{"role": "user", "content": "hello"}], - model="gpt-4o", - response_id="resp_trace_nonstream", - prepare_runner=lambda runner, model: runner.prepare_for_request(model), - ) - - exported_trace = in_memory_trace_exporter.get_trace(result["metadata"]["trace_id"]) - assert exported_trace is not None - root_span = next( - span for span in exported_trace["spans"] if span["span_id"] == result["metadata"]["root_span_id"] - ) - assert root_span["name"] == "demo-agent" - assert root_span["status"]["code"] != "StatusCode.ERROR" - assert root_span["attributes"]["langfuse.trace.name"] == "demo-agent" - assert root_span["attributes"]["langfuse.user.id"] == "user-1" - assert root_span["attributes"]["langfuse.session.id"] == session_id - assert root_span["attributes"]["langfuse.trace.input"] == "hello" - assert root_span["attributes"]["langfuse.trace.output"] == "assistant says hi" - assert root_span["attributes"]["langfuse.observation.input"] == "hello" - assert root_span["attributes"]["langfuse.observation.output"] == "assistant says hi" - assert root_span["attributes"]["input.value"] == "hello" - assert root_span["attributes"]["output.value"] == "assistant says hi" - - -@pytest.mark.asyncio -async def test_invoke_conversation_once_passes_session_id_to_runner(monkeypatch): - service = InMemorySessionService() - monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service) - runner = _StubRunner() - - session_id, _ = await invoke_conversation_once( - runner=runner, - agent_id="demo-agent", - user_id="user-1", - session_id=None, - messages=[{"role": "user", "content": "hello"}], - model="gpt-4o", - prepare_runner=lambda current_runner, model: current_runner.prepare_for_request(model), - ) - - assert runner.calls[-1]["session_id"] == session_id - - -@pytest.mark.asyncio -async def test_invoke_conversation_once_passes_model_options_to_runner(monkeypatch): - service = InMemorySessionService() - monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service) - runner = _StubRunner() - - await invoke_conversation_once( - runner=runner, - agent_id="demo-agent", - user_id="user-1", - session_id=None, - messages=[{"role": "user", "content": "hello"}], - model="gpt-4o", - model_options={"thinking": {"type": "disabled"}}, - prepare_runner=lambda current_runner, model: current_runner.prepare_for_request(model), - ) - - assert runner.calls[-1]["model_options"] == { - "thinking": {"type": "disabled"}, - "reasoning": {"effort": "none"}, - "max_reasoning_tokens": 0, - } - - -@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() - monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service) - monkeypatch.setenv("KSADK_LTM_BACKEND", "sdk") - monkeypatch.setenv("KSADK_LTM_NAMESPACE", "mem-demo") - monkeypatch.delenv("KSADK_LTM_AUTO_SAVE", raising=False) - _FakeLongTermMemoryService.instances.clear() - monkeypatch.setattr( - "ksadk.conversations.runtime.LongTermMemoryService.from_env", - lambda: _FakeLongTermMemoryService(), - ) - runner = _StubRunner() - - session_id, _ = await invoke_conversation_once( - runner=runner, - agent_id="demo-agent", - user_id="user-1", - session_id=None, - messages=[ - { - "role": "user", - "content": [ - {"type": "input_text", "text": "请记住我偏好简洁回答"}, - {"type": "input_image", "image_url": "data:image/png;base64,aW1hZ2U="}, - ], - } - ], - model="qwen3-vl-plus", - prepare_runner=lambda current_runner, model: current_runner.prepare_for_request(model), - ) - - memory_service = _FakeLongTermMemoryService.instances[-1] - assert len(memory_service.saved) == 1 - saved = memory_service.saved[0] - assert saved["user_id"] == "user-1" - assert saved["metadata"]["agent_id"] == "demo-agent" - assert saved["metadata"]["session_id"] == session_id - assert saved["metadata"]["model"] == "qwen3-vl-plus" - assert saved["metadata"]["runner_type"] - assert saved["metadata"]["invocation_id"] - - persisted_events = [json.loads(item) for item in saved["event_strings"]] - assert [event["role"] for event in persisted_events] == ["user", "assistant"] - assert persisted_events[0]["parts"] == [{"text": "请记住我偏好简洁回答"}] - assert persisted_events[0]["metadata"]["attachments"] == [ - {"kind": "image", "display_name": "uploaded_image", "mime_type": "image/png"} - ] - assert persisted_events[1]["parts"] == [{"text": "assistant says hi"}] - assert "base64" not in json.dumps(persisted_events, ensure_ascii=False) - - -@pytest.mark.asyncio -async def test_invoke_conversation_once_respects_ltm_auto_save_false(monkeypatch): - service = InMemorySessionService() - monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service) - monkeypatch.setenv("KSADK_LTM_BACKEND", "sdk") - monkeypatch.setenv("KSADK_LTM_NAMESPACE", "mem-demo") - monkeypatch.setenv("KSADK_LTM_AUTO_SAVE", "false") - _FakeLongTermMemoryService.instances.clear() - monkeypatch.setattr( - "ksadk.conversations.runtime.LongTermMemoryService.from_env", - lambda: _FakeLongTermMemoryService(), - ) - - await invoke_conversation_once( - runner=_StubRunner(), - agent_id="demo-agent", - user_id="user-1", - session_id=None, - messages=[{"role": "user", "content": "不要保存"}], - model="qwen3-vl-plus", - prepare_runner=lambda current_runner, model: current_runner.prepare_for_request(model), - ) - - assert _FakeLongTermMemoryService.instances == [] - - -@pytest.mark.asyncio -async def test_stream_conversation_turn_auto_saves_completed_turn(monkeypatch): - service = InMemorySessionService() - monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service) - monkeypatch.setenv("KSADK_LTM_BACKEND", "sdk") - monkeypatch.setenv("KSADK_LTM_NAMESPACE", "mem-demo") - monkeypatch.delenv("KSADK_LTM_AUTO_SAVE", raising=False) - _FakeLongTermMemoryService.instances.clear() - monkeypatch.setattr( - "ksadk.conversations.runtime.LongTermMemoryService.from_env", - lambda: _FakeLongTermMemoryService(), - ) - runner = _StreamingRunner() - - 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": "流式保存测试"}], - model="qwen3-vl-plus", - prepare_runner=lambda current_runner, model: current_runner.prepare_for_request(model), - session_service_provider=lambda: service, - ) - ] - - assert any("response.completed" in chunk for chunk in events) - memory_service = _FakeLongTermMemoryService.instances[-1] - persisted_events = [json.loads(item) for item in memory_service.saved[0]["event_strings"]] - assert [event["parts"][0]["text"] for event in persisted_events] == [ - "流式保存测试", - "hello", - ] - - -def test_normalize_model_options_maps_legacy_thinking_disabled_to_reasoning_none(): - normalized = normalize_model_options({"thinking": {"type": "disabled"}}) - - assert normalized["thinking"] == {"type": "disabled"} - assert normalized["reasoning"] == {"effort": "none"} - assert normalized["max_reasoning_tokens"] == 0 - - -def test_normalize_model_options_maps_enabled_thinking_to_default_reasoning_effort(): - normalized = normalize_model_options({"thinking": {"type": "enabled"}}) - - assert normalized["thinking"] == {"type": "enabled"} - assert normalized["reasoning"] == {"effort": "medium"} - assert "max_reasoning_tokens" not in normalized - - -@pytest.mark.asyncio -async def test_invoke_conversation_once_maps_mcp_approval_response_to_runner_resume(monkeypatch): - service = InMemorySessionService() - await service.create_session( - agent_id="demo-agent", user_id="user-1", session_id="sess-approval" - ) - await service.append_event( - "sess-approval", - 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_123", - "tool_name": "deploy", - "arguments": {"target": "preprod"}, - "run_id": "run_123", - } - }, - invocation_id="inv-approval", - ), - ) - 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-approval", - messages=[], - model="gpt-4o", - resume_input={ - "type": "mcp_approval_response", - "approval_request_id": "appr_123", - "approve": True, - "reason": "looks safe", - }, - prepare_runner=lambda current_runner, model: current_runner.prepare_for_request(model), - ) - - assert session_id == "sess-approval" - assert result["output_text"] == "assistant says hi" - assert runner.calls[-1]["resume"] is True - assert runner.calls[-1]["input"] == { - "type": "mcp_approval_response", - "approval_request_id": "appr_123", - "approve": True, - "reason": "looks safe", - "tool_name": "deploy", - "tool_args": { - "target": "preprod", - "approval": { - "approved": True, - "approval_request_id": "appr_123", - "reason": "looks safe", - }, - }, - "approval": { - "approved": True, - "approval_request_id": "appr_123", - "reason": "looks safe", - }, - "run_id": "run_123", - } - events = await service.get_events("sess-approval") - assert [event.event_type for event in events] == [ - "approval_request", - "approval_response", - "run_status", - "assistant_message", - "run_status", - ] - assert events[1].metadata["resume_input"]["approval_request_id"] == "appr_123" - - -@pytest.mark.asyncio -async def test_invoke_conversation_once_executes_approved_builtin_tool_resume( - 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-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( - 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() - - await invoke_conversation_once( - runner=runner, - agent_id="demo-agent", - user_id="user-1", - session_id="sess-tool-approval", - messages=[], - model="gpt-4o", - resume_input={ - "type": "mcp_approval_response", - "approval_request_id": "appr_write", - "approve": True, - }, - prepare_runner=lambda current_runner, model: current_runner.prepare_for_request(model), - ) - - assert (workspace_ui / "workspace" / "notes.txt").read_text(encoding="utf-8") == "hello" - assert runner.calls[-1]["resume"] is True - assert runner.calls[-1]["input"]["type"] == "function_call_output" - assert runner.calls[-1]["input"]["call_id"] == "call_write" - assert runner.calls[-1]["input"]["output"]["ok"] is True - events = await service.get_events("sess-tool-approval") - 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 -async def test_invoke_conversation_once_binds_platform_invocation_context_and_ambient_contexts( - monkeypatch, -): - service = InMemorySessionService() - monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service) - monkeypatch.setattr( - "ksadk.conversations.runtime._build_runner_ambient_contexts", - lambda **kwargs: { - "kb_context": {"formatted_text": "KB facts"}, - "memory_context": {"formatted_text": "Memory facts"}, - }, - ) - runner = _ContextCapturingRunner() - - 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="gpt-4o", - account_id="acct-1", - prepare_runner=lambda current_runner, model: current_runner.prepare_for_request(model), - session_service_provider=lambda: service, - ) - - assert result["output_text"] == "captured" - assert session_id - assert runner.calls[-1]["kb_context"] == {"formatted_text": "KB facts"} - assert runner.calls[-1]["memory_context"] == {"formatted_text": "Memory facts"} - assert runner.calls[-1]["platform_context"]["agent_id"] == "demo-agent" - assert runner.calls[-1]["platform_context"]["user_id"] == "user-1" - assert runner.calls[-1]["platform_context"]["account_id"] == "acct-1" - assert runner.calls[-1]["platform_context"]["session_id"] == session_id - assert runner.captured_runtime_context is not None - assert runner.captured_runtime_context.agent_id == "demo-agent" - assert runner.captured_runtime_context.user_id == "user-1" - assert runner.captured_runtime_context.account_id == "acct-1" - assert runner.captured_runtime_context.session_id == session_id - assert runner.captured_runtime_context.kb_context == {"formatted_text": "KB facts"} - assert runner.captured_runtime_context.memory_context == {"formatted_text": "Memory facts"} - assert runner.captured_tool_context is not None - assert runner.captured_tool_context.session_id == session_id - assert runner.captured_tool_context.run_id - assert runner.captured_tool_context.run_id == runner.captured_tool_context.invocation_id - assert get_current_invocation_context() is None - - -def test_build_runner_ambient_contexts_skips_memory_when_disabled(monkeypatch): - monkeypatch.setenv("KSADK_LTM_AMBIENT_ENABLED", "false") - monkeypatch.setattr( - "ksadk.conversations.runtime.LongTermMemoryService.is_configured", - staticmethod(lambda: True), - ) - monkeypatch.setattr( - "ksadk.conversations.runtime.LongTermMemoryService.from_env", - staticmethod( - lambda: (_ for _ in ()).throw(AssertionError("memory ambient should be skipped")) - ), - ) - monkeypatch.setattr( - "ksadk.conversations.runtime.KnowledgeBaseService.is_configured", - staticmethod(lambda: False), - ) - - contexts = _build_runner_ambient_contexts( - runner=_StubRunner(), - user_id="user-1", - user_input="hello", - ) - - assert contexts == {"kb_context": None, "memory_context": None} - - -def test_build_runner_ambient_contexts_skips_kb_when_disabled(monkeypatch): - monkeypatch.setenv("KSADK_KB_AMBIENT_ENABLED", "0") - monkeypatch.setattr( - "ksadk.conversations.runtime.KnowledgeBaseService.is_configured", - staticmethod(lambda: True), - ) - monkeypatch.setattr( - "ksadk.conversations.runtime.KnowledgeBaseService.from_env", - staticmethod(lambda: (_ for _ in ()).throw(AssertionError("kb ambient should be skipped"))), - ) - monkeypatch.setattr( - "ksadk.conversations.runtime.LongTermMemoryService.is_configured", - staticmethod(lambda: False), - ) - - contexts = _build_runner_ambient_contexts( - runner=_StubRunner(), - user_id="user-1", - user_input="hello", - ) - - assert contexts == {"kb_context": None, "memory_context": None} - - -def test_build_runner_ambient_contexts_default_on_demand_skips_chitchat(monkeypatch): - monkeypatch.delenv("KSADK_KB_AMBIENT_POLICY", raising=False) - monkeypatch.delenv("KSADK_LTM_AMBIENT_POLICY", raising=False) - monkeypatch.setattr( - "ksadk.conversations.runtime.KnowledgeBaseService.is_configured", - staticmethod(lambda: True), - ) - monkeypatch.setattr( - "ksadk.conversations.runtime.LongTermMemoryService.is_configured", - staticmethod(lambda: True), - ) - monkeypatch.setattr( - "ksadk.conversations.runtime.KnowledgeBaseService.from_env", - staticmethod( - lambda: (_ for _ in ()).throw(AssertionError("kb ambient should not run for chitchat")) - ), - ) - monkeypatch.setattr( - "ksadk.conversations.runtime.LongTermMemoryService.from_env", - staticmethod( - lambda: (_ for _ in ()).throw( - AssertionError("memory ambient should not run for chitchat") - ) - ), - ) - - contexts = _build_runner_ambient_contexts( - runner=_StubRunner(), - user_id="user-1", - user_input="你好,请介绍一下你自己", - ) - - assert contexts == {"kb_context": None, "memory_context": None} - - -def test_build_runner_ambient_contexts_non_adk_runner_name_does_not_disable_ambient(monkeypatch): - class _FakeKnowledgeBaseService: - def build_context(self, query: str): - return {"formatted_text": f"kb:{query}"} - - runner = _StubRunner() - runner.detection_result = type( - "Detection", - (), - { - "name": "adk-migration-helper", - "type": type("RunnerType", (), {"value": "langgraph"})(), - }, - )() - - monkeypatch.delenv("KSADK_KB_AMBIENT_POLICY", raising=False) - monkeypatch.setattr( - "ksadk.conversations.runtime.KnowledgeBaseService.is_configured", - staticmethod(lambda: True), - ) - monkeypatch.setattr( - "ksadk.conversations.runtime.KnowledgeBaseService.from_env", - staticmethod(lambda: _FakeKnowledgeBaseService()), - ) - monkeypatch.setattr( - "ksadk.conversations.runtime.LongTermMemoryService.is_configured", - staticmethod(lambda: False), - ) - - contexts = _build_runner_ambient_contexts( - runner=runner, - user_id="user-1", - user_input="解释一下 KCE 和 KCF 的区别", - ) - - assert contexts["kb_context"] == {"formatted_text": "kb:解释一下 KCE 和 KCF 的区别"} - assert contexts["memory_context"] is None - - -def test_build_runner_ambient_contexts_default_on_demand_loads_memory_for_explicit_recall( - monkeypatch, -): - class _FakeMemoryService: - def build_context(self, *, user_id: str, query: str): - return {"formatted_text": f"memory:{user_id}:{query}"} - - monkeypatch.delenv("KSADK_LTM_AMBIENT_POLICY", raising=False) - monkeypatch.setattr( - "ksadk.conversations.runtime.KnowledgeBaseService.is_configured", - staticmethod(lambda: False), - ) - monkeypatch.setattr( - "ksadk.conversations.runtime.LongTermMemoryService.is_configured", - staticmethod(lambda: True), - ) - monkeypatch.setattr( - "ksadk.conversations.runtime.LongTermMemoryService.from_env", - staticmethod(lambda: _FakeMemoryService()), - ) - - contexts = _build_runner_ambient_contexts( - runner=_StubRunner(), - user_id="user-1", - user_input="你还记得我上次说过的偏好吗?", - ) - - assert contexts["kb_context"] is None - assert contexts["memory_context"] == { - "formatted_text": "memory:user-1:你还记得我上次说过的偏好吗?" - } - - -def test_build_runner_ambient_contexts_default_on_demand_skips_memory_for_short_term_follow_up( - monkeypatch, -): - monkeypatch.delenv("KSADK_LTM_AMBIENT_POLICY", raising=False) - monkeypatch.setattr( - "ksadk.conversations.runtime.KnowledgeBaseService.is_configured", - staticmethod(lambda: False), - ) - monkeypatch.setattr( - "ksadk.conversations.runtime.LongTermMemoryService.is_configured", - staticmethod(lambda: True), - ) - monkeypatch.setattr( - "ksadk.conversations.runtime.LongTermMemoryService.from_env", - staticmethod( - lambda: (_ for _ in ()).throw( - AssertionError("memory ambient should not run for short-term follow-up") - ) - ), - ) - - contexts = _build_runner_ambient_contexts( - runner=_StubRunner(), - user_id="user-1", - user_input="把前面的回答翻译成英文", - ) - - assert contexts == {"kb_context": None, "memory_context": None} - - -def test_build_runner_ambient_contexts_default_on_demand_skips_memory_for_mixed_short_term_prompt( - monkeypatch, -): - monkeypatch.delenv("KSADK_LTM_AMBIENT_POLICY", raising=False) - monkeypatch.setattr( - "ksadk.conversations.runtime.KnowledgeBaseService.is_configured", - staticmethod(lambda: False), - ) - monkeypatch.setattr( - "ksadk.conversations.runtime.LongTermMemoryService.is_configured", - staticmethod(lambda: True), - ) - monkeypatch.setattr( - "ksadk.conversations.runtime.LongTermMemoryService.from_env", - staticmethod( - lambda: (_ for _ in ()).throw( - AssertionError("memory ambient should not run for mixed short-term prompt") - ) - ), - ) - - contexts = _build_runner_ambient_contexts( - runner=_StubRunner(), - user_id="user-1", - user_input="你还记得刚才的回答吗", - ) - - assert contexts == {"kb_context": None, "memory_context": None} - - -def test_build_runner_ambient_contexts_default_on_demand_loads_memory_for_profile_prompt( - monkeypatch, -): - class _FakeMemoryService: - def build_context(self, *, user_id: str, query: str): - return {"formatted_text": f"memory:{user_id}:{query}"} - - monkeypatch.delenv("KSADK_LTM_AMBIENT_POLICY", raising=False) - monkeypatch.setattr( - "ksadk.conversations.runtime.KnowledgeBaseService.is_configured", - staticmethod(lambda: False), - ) - monkeypatch.setattr( - "ksadk.conversations.runtime.LongTermMemoryService.is_configured", - staticmethod(lambda: True), - ) - monkeypatch.setattr( - "ksadk.conversations.runtime.LongTermMemoryService.from_env", - staticmethod(lambda: _FakeMemoryService()), - ) - - contexts = _build_runner_ambient_contexts( - runner=_StubRunner(), - user_id="user-1", - user_input="按照我的风格来写", - ) - - assert contexts["kb_context"] is None - assert contexts["memory_context"] == {"formatted_text": "memory:user-1:按照我的风格来写"} - - -def test_build_runner_ambient_contexts_default_on_demand_loads_kb_for_information_query( - monkeypatch, -): - class _FakeKnowledgeBaseService: - def build_context(self, query: str): - return {"formatted_text": f"kb:{query}"} - - monkeypatch.delenv("KSADK_KB_AMBIENT_POLICY", raising=False) - monkeypatch.setattr( - "ksadk.conversations.runtime.KnowledgeBaseService.is_configured", - staticmethod(lambda: True), - ) - monkeypatch.setattr( - "ksadk.conversations.runtime.KnowledgeBaseService.from_env", - staticmethod(lambda: _FakeKnowledgeBaseService()), - ) - monkeypatch.setattr( - "ksadk.conversations.runtime.LongTermMemoryService.is_configured", - staticmethod(lambda: False), - ) - - contexts = _build_runner_ambient_contexts( - runner=_StubRunner(), - user_id="user-1", - user_input="查一下云主机现在有哪些机型", - ) - - assert contexts["kb_context"] == {"formatted_text": "kb:查一下云主机现在有哪些机型"} - assert contexts["memory_context"] is None - - -def test_build_runner_ambient_contexts_default_on_demand_loads_kb_for_explanatory_query( - monkeypatch, -): - class _FakeKnowledgeBaseService: - def build_context(self, query: str): - return {"formatted_text": f"kb:{query}"} - - monkeypatch.delenv("KSADK_KB_AMBIENT_POLICY", raising=False) - monkeypatch.setattr( - "ksadk.conversations.runtime.KnowledgeBaseService.is_configured", - staticmethod(lambda: True), - ) - monkeypatch.setattr( - "ksadk.conversations.runtime.KnowledgeBaseService.from_env", - staticmethod(lambda: _FakeKnowledgeBaseService()), - ) - monkeypatch.setattr( - "ksadk.conversations.runtime.LongTermMemoryService.is_configured", - staticmethod(lambda: False), - ) - - contexts = _build_runner_ambient_contexts( - runner=_StubRunner(), - user_id="user-1", - user_input="帮我总结一下 AgentEngine 部署步骤", - ) - - assert contexts["kb_context"] == {"formatted_text": "kb:帮我总结一下 AgentEngine 部署步骤"} - assert contexts["memory_context"] is None - - -def test_build_runner_ambient_contexts_drops_kb_error_text_returned_by_service(monkeypatch): - class _BrokenKnowledgeBaseService: - def build_context(self, query: str): - return {"formatted_text": "知识库检索失败: timeout", "query": query} - - monkeypatch.delenv("KSADK_KB_AMBIENT_POLICY", raising=False) - monkeypatch.setattr( - "ksadk.conversations.runtime.KnowledgeBaseService.is_configured", - staticmethod(lambda: True), - ) - monkeypatch.setattr( - "ksadk.conversations.runtime.KnowledgeBaseService.from_env", - staticmethod(lambda: _BrokenKnowledgeBaseService()), - ) - monkeypatch.setattr( - "ksadk.conversations.runtime.LongTermMemoryService.is_configured", - staticmethod(lambda: False), - ) - - contexts = _build_runner_ambient_contexts( - runner=_StubRunner(), - user_id="user-1", - user_input="帮我总结一下 AgentEngine 部署步骤", - ) - - assert contexts == {"kb_context": None, "memory_context": None} - - -def test_build_runner_ambient_contexts_drops_memory_error_text_returned_by_service(monkeypatch): - class _BrokenMemoryService: - def build_context(self, *, user_id: str, query: str): - return {"formatted_text": "长期记忆检索失败: timeout", "query": query} - - monkeypatch.delenv("KSADK_LTM_AMBIENT_POLICY", raising=False) - monkeypatch.setattr( - "ksadk.conversations.runtime.KnowledgeBaseService.is_configured", - staticmethod(lambda: False), - ) - monkeypatch.setattr( - "ksadk.conversations.runtime.LongTermMemoryService.is_configured", - staticmethod(lambda: True), - ) - monkeypatch.setattr( - "ksadk.conversations.runtime.LongTermMemoryService.from_env", - staticmethod(lambda: _BrokenMemoryService()), - ) - - contexts = _build_runner_ambient_contexts( - runner=_StubRunner(), - user_id="user-1", - user_input="按照我的风格来写", - ) - - assert contexts == {"kb_context": None, "memory_context": None} - - -def test_build_runner_ambient_contexts_ambient_failures_degrade_quietly(monkeypatch): - class _BrokenKnowledgeBaseService: - def build_context(self, query: str): - raise RuntimeError(f"kb boom: {query}") - - class _BrokenMemoryService: - def build_context(self, *, user_id: str, query: str): - raise RuntimeError(f"memory boom: {user_id}:{query}") - - monkeypatch.delenv("KSADK_KB_AMBIENT_POLICY", raising=False) - monkeypatch.delenv("KSADK_LTM_AMBIENT_POLICY", raising=False) - monkeypatch.setattr( - "ksadk.conversations.runtime.KnowledgeBaseService.is_configured", - staticmethod(lambda: True), - ) - monkeypatch.setattr( - "ksadk.conversations.runtime.KnowledgeBaseService.from_env", - staticmethod(lambda: _BrokenKnowledgeBaseService()), - ) - monkeypatch.setattr( - "ksadk.conversations.runtime.LongTermMemoryService.is_configured", - staticmethod(lambda: True), - ) - monkeypatch.setattr( - "ksadk.conversations.runtime.LongTermMemoryService.from_env", - staticmethod(lambda: _BrokenMemoryService()), - ) - - contexts = _build_runner_ambient_contexts( - runner=_StubRunner(), - user_id="user-1", - user_input="你还记得我上次说过的偏好吗?", - ) - - assert contexts == {"kb_context": None, "memory_context": None} - - -def test_build_runner_ambient_contexts_always_policy_preserves_legacy_behavior(monkeypatch): - class _FakeMemoryService: - def build_context(self, *, user_id: str, query: str): - return {"formatted_text": f"memory:{query}"} - - monkeypatch.setenv("KSADK_LTM_AMBIENT_POLICY", "always") - monkeypatch.setattr( - "ksadk.conversations.runtime.KnowledgeBaseService.is_configured", - staticmethod(lambda: False), - ) - monkeypatch.setattr( - "ksadk.conversations.runtime.LongTermMemoryService.is_configured", - staticmethod(lambda: True), - ) - monkeypatch.setattr( - "ksadk.conversations.runtime.LongTermMemoryService.from_env", - staticmethod(lambda: _FakeMemoryService()), - ) - - contexts = _build_runner_ambient_contexts( - runner=_StubRunner(), - user_id="user-1", - user_input="你好", - ) - - assert contexts["memory_context"] == {"formatted_text": "memory:你好"} - - -@pytest.mark.asyncio -async def test_stream_conversation_turn_passes_session_id_to_runner(monkeypatch): - service = InMemorySessionService() - monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service) - runner = _StreamingRunner() - - session = await service.create_session( - agent_id="demo-agent", - user_id="user-1", - session_id="sess-stream", - ) - - events = [] - async for event in stream_conversation_turn( - runner=runner, - agent_id="demo-agent", - user_id="user-1", - session_id=session.id, - messages=[{"role": "user", "content": "继续"}], - model="gpt-4o", - prepare_runner=lambda current_runner, model: current_runner.prepare_for_request(model), - session_service_provider=lambda: service, - ): - events.append(event) - - assert events - assert runner.stream_calls[-1]["session_id"] == session.id - - -@pytest.mark.asyncio -async def test_stream_conversation_turn_binds_tool_execution_context(monkeypatch): - service = InMemorySessionService() - monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service) - runner = _ToolContextCapturingStreamingRunner() - session = await service.create_session( - agent_id="demo-agent", - user_id="user-1", - session_id="sess-stream-tool-context", - ) - - events = [ - event - async for event in stream_conversation_turn( - runner=runner, - agent_id="demo-agent", - user_id="user-1", - session_id=session.id, - messages=[{"role": "user", "content": "继续"}], - model="gpt-4o", - invocation_id="inv-stream-1", - prepare_runner=lambda current_runner, model: current_runner.prepare_for_request(model), - session_service_provider=lambda: service, - ) - ] - - assert events - assert runner.captured_tool_context is not None - assert runner.captured_tool_context.session_id == session.id - assert runner.captured_tool_context.run_id == "inv-stream-1" - assert runner.captured_tool_context.invocation_id == "inv-stream-1" - - -@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() - monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service) - runner = _ResumeStreamingRunner() - await service.create_session( - agent_id="demo-agent", user_id="user-1", session_id="sess-resume-stream" - ) - await service.append_event( - "sess-resume-stream", - SessionEvent( - id="evt-background-status", - author="demo-agent", - event_type="run_status", - content={"status": "interrupted"}, - metadata={ - "status": "interrupted", - "run_mode": "background", - "run_trigger": "new_run", - }, - invocation_id="inv-approval", - ), - ) - await service.append_event( - "sess-resume-stream", - SessionEvent( - id="evt-approval", - author="demo-agent", - event_type="approval_request", - content={"role": "model", "parts": [{"text": "need human input"}]}, - metadata={"interrupt_info": {"id": "intr_123"}}, - invocation_id="inv-approval", - ), - ) - - chunks = [ - chunk - async for chunk in stream_responses_conversation_turn( - runner=runner, - agent_id="demo-agent", - user_id="user-1", - session_id="sess-resume-stream", - messages=[], - model="gpt-4o", - resume_input={ - "type": "ksadk_resume", - "interrupt_id": "intr_123", - "value": {"answer": "继续", "approved": True}, - }, - prepare_runner=lambda current_runner, model: current_runner.prepare_for_request(model), - session_service_provider=lambda: service, - ) - ] - - assert runner.stream_calls[-1]["resume"] is True - assert runner.stream_calls[-1]["input"] == { - "type": "ksadk_resume", - "interrupt_id": "intr_123", - "value": {"answer": "继续", "approved": True}, - } - assert any(chunk.startswith("event: response.completed\n") for chunk in chunks) - events = await service.get_events("sess-resume-stream") - assert "approval_response" in [event.event_type for event in events] - run_status_events = [event for event in events if event.event_type == "run_status"] - assert run_status_events[-1].metadata["run_mode"] == "background" - assert run_status_events[-1].metadata["run_trigger"] == "approval_resume" - - -@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() - monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service) - runner = _ApprovalToolResultStreamingRunner() - - chunks = [ - chunk - async for chunk in stream_responses_conversation_turn( - runner=runner, - agent_id="demo-agent", - user_id="user-1", - session_id="sess-gateway-approval", - messages=[{"role": "user", "content": "写文件"}], - model="gpt-4o", - prepare_runner=lambda current_runner, model: current_runner.prepare_for_request(model), - session_service_provider=lambda: service, - ) - ] - - event_names = [ - line.removeprefix("event: ") - for chunk in chunks - for line in chunk.splitlines() - if line.startswith("event: ") - ] - assert "response.output_item.done" in event_names - assert "response.incomplete" in event_names - assert "response.completed" not in event_names - - incomplete = _extract_sse_payload(chunks, "response.incomplete") - interrupt = incomplete["incomplete_details"]["ksadk_interrupt"] - assert interrupt["approval_request_id"] == "appr_write" - assert interrupt["tool_name"] == "write_workspace_file" - assert interrupt["arguments"] == {"path": "notes.txt"} - - events = await service.get_events("sess-gateway-approval") - assert [event.event_type for event in events] == [ - "user_message", - "run_status", - "tool_call", - "approval_request", - "run_status", - ] - - -@pytest.mark.asyncio -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 = _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_records_deferred_tools_from_tool_search(monkeypatch): - service = InMemorySessionService() - monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service) - runner = _ToolSearchResultStreamingRunner() - - chunks = [ - chunk - async for chunk in stream_responses_conversation_turn( - runner=runner, - agent_id="demo-agent", - user_id="user-1", - session_id="sess-deferred-tools", - messages=[{"role": "user", "content": "find edit tools"}], - model="gpt-4o", - prepare_runner=lambda current_runner, model: current_runner.prepare_for_request(model), - session_service_provider=lambda: service, - run_mode="background", - ) - ] - - assert any(chunk.startswith("event: response.completed\n") for chunk in chunks) - events = await service.get_events("sess-deferred-tools") - status = [ - event - for event in events - if event.event_type == "run_status" - and (event.metadata or {}).get("detail") == "deferred_tools_selected" - ][-1] - assert status.metadata["deferred_tool_names"] == ["read_workspace_file", "edit_workspace_file"] - assert status.metadata["run_mode"] == "background" - assert status.metadata["run_trigger"] == "new_run" - assert status.state_delta["active_run"]["run_mode"] == "background" - assert status.state_delta["active_run"]["run_trigger"] == "new_run" - - -@pytest.mark.asyncio -async def test_stream_responses_conversation_turn_stops_at_max_tool_calls(monkeypatch): - service = InMemorySessionService() - monkeypatch.setenv("KSADK_MAX_TOOL_CALLS", "2") - monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service) - runner = _ManyToolCallsStreamingRunner() - - chunks = [ - chunk - async for chunk in stream_responses_conversation_turn( - runner=runner, - agent_id="demo-agent", - user_id="user-1", - session_id="sess-max-tools", - messages=[{"role": "user", "content": "call tools"}], - model="gpt-4o", - prepare_runner=lambda current_runner, model: current_runner.prepare_for_request(model), - session_service_provider=lambda: service, - ) - ] - - assert any("response.failed" in chunk for chunk in chunks) - assert not any("response.completed" in chunk for chunk in chunks) - events = await service.get_events("sess-max-tools") - statuses = [event for event in events if event.event_type == "run_status"] - assert statuses[-1].content["status"] == "failed" - assert statuses[-1].metadata["governance"]["reason"] == "max_tool_calls_exceeded" - - -@pytest.mark.asyncio -async def test_stream_responses_conversation_turn_stops_on_consecutive_tool_failures(monkeypatch): - service = InMemorySessionService() - monkeypatch.setenv("KSADK_MAX_CONSECUTIVE_TOOL_FAILURES", "2") - monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service) - runner = _FailingToolResultsStreamingRunner() - - chunks = [ - chunk - async for chunk in stream_responses_conversation_turn( - runner=runner, - agent_id="demo-agent", - user_id="user-1", - session_id="sess-tool-failures", - messages=[{"role": "user", "content": "run failing tools"}], - model="gpt-4o", - prepare_runner=lambda current_runner, model: current_runner.prepare_for_request(model), - session_service_provider=lambda: service, - ) - ] - - assert any("response.failed" in chunk for chunk in chunks) - assert not any("response.completed" in chunk for chunk in chunks) - events = await service.get_events("sess-tool-failures") - failed_status = [event for event in events if event.event_type == "run_status"][-1] - assert failed_status.metadata["governance"]["reason"] == "consecutive_tool_failures" - tool_results = [event for event in events if event.event_type == "tool_result"] - assert len(tool_results) == 2 - assert tool_results[-1].metadata["observability"]["tool_name"] == "run_command" - assert tool_results[-1].metadata["observability"]["error_type"] == "boom" - - -@pytest.mark.asyncio -async def test_stream_responses_resume_denial_trips_governance_on_main_path(monkeypatch): - service = InMemorySessionService() - await service.create_session( - agent_id="demo-agent", user_id="user-1", session_id="sess-denial-governance" - ) - await service.append_event( - "sess-denial-governance", - 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_deny", - "tool_name": "write_workspace_file", - "arguments": {"path": "notes.txt"}, - "run_id": "run_write", - } - }, - invocation_id="inv-approval", - ), - ) - monkeypatch.setenv("KSADK_MAX_CONSECUTIVE_APPROVAL_DENIALS", "1") - monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service) - runner = _StreamingRunner() - - chunks = [ - chunk - async for chunk in stream_responses_conversation_turn( - runner=runner, - agent_id="demo-agent", - user_id="user-1", - session_id="sess-denial-governance", - messages=[], - model="gpt-4o", - resume_input={ - "type": "mcp_approval_response", - "approval_request_id": "appr_deny", - "approve": False, - "reason": "not safe", - }, - prepare_runner=lambda current_runner, model: current_runner.prepare_for_request(model), - session_service_provider=lambda: service, - ) - ] - - assert any("response.failed" in chunk for chunk in chunks) - assert runner.stream_calls == [] - events = await service.get_events("sess-denial-governance") - assert [event.event_type for event in events][:2] == ["approval_request", "approval_response"] - failed_status = [event for event in events if event.event_type == "run_status"][-1] - assert failed_status.metadata["governance"]["reason"] == "consecutive_approval_denials" - - -@pytest.mark.asyncio -async def test_stream_responses_prompt_too_long_compaction_failure_trips_governance(monkeypatch): - service = InMemorySessionService() - monkeypatch.setenv("KSADK_MAX_CONSECUTIVE_COMPACT_FAILURES", "1") - monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service) - - async def _broken_compaction(**_kwargs): - raise RuntimeError("compact backend down") - - monkeypatch.setattr("ksadk.conversations.runtime.compact_conversation_history", _broken_compaction) - runner = _PromptTooLongStreamingRunner() - - chunks = [ - chunk - async for chunk in stream_responses_conversation_turn( - runner=runner, - agent_id="demo-agent", - user_id="user-1", - session_id="sess-compact-governance", - messages=[{"role": "user", "content": "large history"}], - model="gpt-4o", - prepare_runner=lambda current_runner, model: current_runner.prepare_for_request(model), - session_service_provider=lambda: service, - ) - ] - - assert any("response.failed" in chunk for chunk in chunks) - events = await service.get_events("sess-compact-governance") - failed_status = [event for event in events if event.event_type == "run_status"][-1] - assert failed_status.metadata["governance"]["reason"] == "consecutive_compact_failures" - - -@pytest.mark.asyncio -async def test_stream_responses_conversation_turn_preserves_tool_call_display_metadata(monkeypatch): - service = InMemorySessionService() - monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service) - - class DisplayToolCallRunner(_StubRunner): - async def stream(self, input_data): - yield { - "type": "tool_call", - "tool_name": "workspace.write", - "tool_args": {"stage": "write_report"}, - "run_id": "run-display-tool-call", - "stage": "write_report", - "event_kind": "artifact", - "display_title": "正在生成研究报告", - "display_summary": "7/7 生成研究报告:正在综合证据并写入工作区。", - } - yield {"type": "final", "output": "done"} - - chunks = [ - chunk - async for chunk in stream_responses_conversation_turn( - runner=DisplayToolCallRunner(), - agent_id="demo-agent", - user_id="user-1", - session_id="sess-tool-call-display", - messages=[{"role": "user", "content": "研究 agent runtime"}], - model="gpt-4o", - invocation_id="inv-tool-call-display", - prepare_runner=lambda current_runner, model: current_runner.prepare_for_request(model), - session_service_provider=lambda: service, - ) - ] - - assert any("正在生成研究报告" in chunk for chunk in chunks) - events = await service.get_events("sess-tool-call-display") - tool_call = next(event for event in events if event.event_type == "tool_call") - assert tool_call.metadata["stage"] == "write_report" - assert tool_call.metadata["event_kind"] == "artifact" - assert tool_call.metadata["display_title"] == "正在生成研究报告" - assert tool_call.metadata["display_summary"].startswith("7/7 生成研究报告") - - -@pytest.mark.asyncio -async def test_stream_responses_conversation_turn_persists_stage_activity_without_receipt(monkeypatch): - service = InMemorySessionService() - monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service) - runner = _StageActivityStreamingRunner() - - chunks = [ - chunk - async for chunk in stream_responses_conversation_turn( - runner=runner, - agent_id="demo-agent", - user_id="user-1", - session_id="sess-stage-activity", - messages=[{"role": "user", "content": "研究 agent runtime"}], - model="gpt-4o", - invocation_id="inv-stage-activity", - prepare_runner=lambda current_runner, model: current_runner.prepare_for_request(model), - session_service_provider=lambda: service, - ) - ] - - assert any("response.ksadk.stage_tool_call" in chunk for chunk in chunks) - assert any("response.ksadk.stage_tool_result" in chunk for chunk in chunks) - events = await service.get_events("sess-stage-activity") - activity_events = [ - event for event in events if event.event_type in {"stage_tool_call", "stage_tool_result"} - ] - assert [event.event_type for event in activity_events] == [ - "stage_tool_call", - "stage_tool_result", - ] - assert activity_events[0].metadata["tool_name"] == "deepresearch_query_agent" - assert activity_events[0].metadata["tool_args"]["query"] == "agent runtime" - assert activity_events[1].metadata["tool_output"]["result_count"] == 3 - assert "tool_receipt" not in activity_events[1].metadata - - -@pytest.mark.asyncio -async def test_stream_responses_conversation_turn_marks_non_final_stage_stream_failed(monkeypatch): - service = InMemorySessionService() - monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service) - runner = _StageActivityNoFinalStreamingRunner() - - chunks = [ - chunk - async for chunk in stream_responses_conversation_turn( - runner=runner, - agent_id="demo-agent", - user_id="user-1", - session_id="sess-stage-no-final", - messages=[{"role": "user", "content": "研究 agent runtime"}], - model="gpt-4o", - invocation_id="inv-stage-no-final", - prepare_runner=lambda current_runner, model: current_runner.prepare_for_request(model), - session_service_provider=lambda: service, - ) - ] - - assert any(chunk.startswith("event: response.failed\n") for chunk in chunks) - assert not any(chunk.startswith("event: response.completed\n") for chunk in chunks) - events = await service.get_events("sess-stage-no-final") - run_statuses = [ - event.content.get("status") - for event in events - if event.event_type == "run_status" and event.invocation_id == "inv-stage-no-final" - ] - assert run_statuses == ["in_progress", "failed"] - failed_event = [event for event in events if event.event_type == "run_status"][-1] - assert failed_event.metadata["detail"] == "runner_stream_ended_without_final_output" - - -@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 - async for chunk in stream_responses_conversation_turn( - runner=runner, - agent_id="demo-agent", - user_id="user-1", - session_id="sess-native-output", - messages=[{"role": "user", "content": "查一下"}], - model="gpt-4o", - prepare_runner=lambda current_runner, model: current_runner.prepare_for_request(model), - session_service_provider=lambda: service, - ) - ] - - event_names = [ - line.removeprefix("event: ") - for chunk in chunks - for line in chunk.splitlines() - if line.startswith("event: ") - ] - assert "response.function_call_arguments.done" in event_names - assert "response.reasoning.delta" in event_names - - completed_payload = None - current_event = "" - for chunk in chunks: - for line in chunk.splitlines(): - if line.startswith("event: "): - current_event = line.removeprefix("event: ") - elif line.startswith("data: ") and current_event == "response.completed": - completed_payload = json.loads(line.removeprefix("data: ")) - assert completed_payload is not None - assert completed_payload["id"] == "resp_native" - assert any(item.get("type") == "function_call" for item in completed_payload["output"]) - - -@pytest.mark.asyncio -async def test_stream_responses_conversation_turn_persists_outer_response_id_on_assistant_event( - monkeypatch, -): - service = InMemorySessionService() - monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service) - runner = _StreamingRunner() - - chunks = [ - chunk - async for chunk in stream_responses_conversation_turn( - runner=runner, - agent_id="demo-agent", - user_id="user-1", - session_id="sess-stream-feedback", - messages=[{"role": "user", "content": "hello"}], - model="gpt-4o", - prepare_runner=lambda current_runner, model: current_runner.prepare_for_request(model), - session_service_provider=lambda: service, - ) - ] - - created_payload = _extract_sse_payload(chunks, "response.created") - completed_payload = _extract_sse_payload(chunks, "response.completed") - events = await service.get_events("sess-stream-feedback") - assistant_event = next(event for event in events if event.event_type == "assistant_message") - assert completed_payload["id"] == created_payload["id"] - assert assistant_event.metadata["response_id"] == created_payload["id"] - - -@pytest.mark.asyncio -async def test_stream_responses_conversation_turn_persists_trace_metadata_for_feedback( - monkeypatch, - in_memory_trace_exporter, -): - service = InMemorySessionService() - monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service) - runner = _StreamingRunner() - - chunks = [ - chunk - async for chunk in stream_responses_conversation_turn( - runner=runner, - agent_id="demo-agent", - user_id="user-1", - session_id="sess-stream-trace-feedback", - messages=[{"role": "user", "content": "hello"}], - 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") - events = await service.get_events("sess-stream-trace-feedback") - assistant_event = next(event for event in events if event.event_type == "assistant_message") - trace_id = assistant_event.metadata.get("trace_id") - root_span_id = assistant_event.metadata.get("root_span_id") - - assert trace_id - assert root_span_id - assert completed_payload["metadata"]["trace_id"] == trace_id - assert completed_payload["metadata"]["root_span_id"] == root_span_id - exported_trace = in_memory_trace_exporter.get_trace(trace_id) - assert exported_trace is not None - assert any(span["span_id"] == root_span_id for span in exported_trace["spans"]) - - -@pytest.mark.asyncio -async def test_stream_responses_conversation_turn_emits_trace_metadata_from_created_event( - monkeypatch, - in_memory_trace_exporter, -): - service = InMemorySessionService() - monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service) - runner = _StreamingRunner() - - chunks = [ - chunk - async for chunk in stream_responses_conversation_turn( - runner=runner, - agent_id="demo-agent", - user_id="user-1", - session_id="sess-stream-created-trace", - messages=[{"role": "user", "content": "hello"}], - model="gpt-4o", - prepare_runner=lambda current_runner, model: current_runner.prepare_for_request(model), - session_service_provider=lambda: service, - ) - ] - - created_payload = _extract_sse_payload(chunks, "response.created") - completed_payload = _extract_sse_payload(chunks, "response.completed") - assert created_payload["metadata"]["trace_id"] - assert created_payload["metadata"]["root_span_id"] - assert created_payload["metadata"]["trace_id"] == completed_payload["metadata"]["trace_id"] - assert ( - created_payload["metadata"]["root_span_id"] - == completed_payload["metadata"]["root_span_id"] - ) - - exported_trace = in_memory_trace_exporter.get_trace(created_payload["metadata"]["trace_id"]) - root_span = next( - span - for span in exported_trace["spans"] - if span["span_id"] == created_payload["metadata"]["root_span_id"] - ) - assert root_span["name"] == "demo-agent" - assert root_span["attributes"]["langfuse.trace.input"] == "hello" - assert root_span["attributes"]["langfuse.trace.output"] == "hello" - - -@pytest.mark.asyncio -async def test_stream_responses_conversation_turn_persists_reasoning_events(monkeypatch): - service = InMemorySessionService() - monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service) - runner = _ThinkingStreamingRunner() - - chunks = [ - chunk - async for chunk in stream_responses_conversation_turn( - runner=runner, - agent_id="demo-agent", - user_id="user-1", - session_id="sess-reasoning", - 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(chunk.startswith("event: response.reasoning.delta\n") for chunk in chunks) - events = await service.get_events("sess-reasoning") - assert [event.event_type for event in events] == [ - "user_message", - "run_status", - "reasoning", - "assistant_message", - "run_status", - ] - assert events[2].content["parts"][0]["text"] == "先分析问题" - - -@pytest.mark.asyncio -async def test_stream_responses_conversation_turn_suppresses_reasoning_when_disabled(monkeypatch): - service = InMemorySessionService() - monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service) - runner = _CompletedOutputStreamingRunner() - - chunks = [ - chunk - async for chunk in stream_responses_conversation_turn( - runner=runner, - agent_id="demo-agent", - user_id="user-1", - session_id="sess-no-reasoning", - messages=[{"role": "user", "content": "查一下"}], - model="gpt-4o", - model_options={"thinking": {"type": "disabled"}}, - prepare_runner=lambda current_runner, model: current_runner.prepare_for_request(model), - session_service_provider=lambda: service, - ) - ] - - event_names = [ - line.removeprefix("event: ") - for chunk in chunks - for line in chunk.splitlines() - if line.startswith("event: ") - ] - assert "response.reasoning.delta" not in event_names - completed_payload = _extract_sse_payload(chunks, "response.completed") - assert not any(item.get("type") == "reasoning" for item in completed_payload["output"]) - events = await service.get_events("sess-no-reasoning") - assert "reasoning" not in [event.event_type for event in events] - - -@pytest.mark.asyncio -async def test_stream_responses_turn_maps_function_call_output_without_pending_approval( - monkeypatch, -): - service = InMemorySessionService() - monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service) - runner = _ResumeStreamingRunner() - await service.create_session( - agent_id="demo-agent", user_id="user-1", session_id="sess-tool-output" - ) - - chunks = [ - chunk - async for chunk in stream_responses_conversation_turn( - runner=runner, - agent_id="demo-agent", - user_id="user-1", - session_id="sess-tool-output", - messages=[], - model="gpt-4o", - resume_input={ - "type": "function_call_output", - "call_id": "call_123", - "output": {"ok": True}, - }, - prepare_runner=lambda current_runner, model: current_runner.prepare_for_request(model), - request_metadata={"previous_response_id": "resp_123"}, - session_service_provider=lambda: service, - ) - ] - - assert runner.stream_calls[-1]["resume"] is True - assert runner.stream_calls[-1]["input"] == { - "type": "function_call_output", - "call_id": "call_123", - "output": {"ok": True}, - } - assert runner.stream_calls[-1]["previous_response_id"] == "resp_123" - assert any(chunk.startswith("event: response.completed\n") for chunk in chunks) - events = await service.get_events("sess-tool-output") - assert "tool_result" in [event.event_type for event in events] - assert "approval_response" not in [event.event_type for event in events] - run_status_events = [event for event in events if event.event_type == "run_status"] - assert run_status_events[-1].metadata["run_mode"] == "foreground" - assert run_status_events[-1].metadata["run_trigger"] == "approval_resume" - - -@pytest.mark.asyncio -async def test_invoke_conversation_once_refines_session_title_after_first_turn(monkeypatch): - service = InMemorySessionService() - monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service) - - class _FakeTitleClient: - @property - def is_available(self): - return True - - async def generate_title(self, *, model, messages, timeout_ms): - assert model == "glm-5.1" - assert messages[0]["role"] == "system" - assert "你好,请介绍一下你自己" in messages[-1]["content"] - return "自我介绍", {"total_tokens": 12} - - monkeypatch.setattr( - "ksadk.conversations.runtime.resolve_session_title_client", - lambda: _FakeTitleClient(), - ) - - runner = _StubRunner() - session_id, _ = 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), - ) - - session = await service.get_session(session_id) - assert session is not None - assert session.first_prompt == "你好,请介绍一下你自己" - for _ in range(20): - if session.title == "自我介绍": - break - await asyncio.sleep(0.01) - session = await service.get_session(session_id) - assert session is not None - assert session.title == "自我介绍" - assert session.title_source == "ai" - - -@pytest.mark.asyncio -async def test_invoke_conversation_once_does_not_wait_for_ai_session_title(monkeypatch): - service = InMemorySessionService() - monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service) - - title_started = asyncio.Event() - release_title = asyncio.Event() - - class _BlockingTitleClient: - @property - def is_available(self): - return True - - async def generate_title(self, *, model, messages, timeout_ms): - title_started.set() - await release_title.wait() - return "自我介绍", {"total_tokens": 12} - - monkeypatch.setattr( - "ksadk.conversations.runtime.resolve_session_title_client", - lambda: _BlockingTitleClient(), - ) - - runner = _StubRunner() - invoke_task = asyncio.create_task( - 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), - ) - ) - await asyncio.wait_for(title_started.wait(), timeout=1) - - session_id, _ = await asyncio.wait_for(invoke_task, timeout=0.2) - session = await service.get_session(session_id) - assert session is not None - assert session.title == "Agent能力介绍" - assert session.title_source == "heuristic" - - release_title.set() - for _ in range(20): - session = await service.get_session(session_id) - assert session is not None - if session.title == "自我介绍": - break - await asyncio.sleep(0.01) - assert session.title == "自我介绍" - assert session.title_source == "ai" - - -@pytest.mark.asyncio -async def test_invoke_conversation_once_uses_heuristic_title_for_agent_intro(monkeypatch): - service = InMemorySessionService() - monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service) - - class _IntroRunner(_StubRunner): - async def invoke(self, input_data: dict) -> dict: - self.calls.append(input_data) - return { - "output": ( - "你好!我是企业高端招聘全流程助手,可以协助你完成职位分析、" - "候选人筛选和面试建议生成。" - ) - } - - runner = _IntroRunner() - session_id, _ = 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), - ) - - session = await service.get_session(session_id) - assert session is not None - assert session.title == "招聘助手能力" - 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, -): - service = InMemorySessionService() - monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service) - - class _ArchitectureRunner(_StubRunner): - async def invoke(self, input_data: dict) -> dict: - self.calls.append(input_data) - return { - "output": ( - "这张图展示了典型的微服务分层架构," - "包含网关、业务服务、数据库和异步消息链路。" - ) - } - - runner = _ArchitectureRunner() - session_id, _ = await invoke_conversation_once( - runner=runner, - agent_id="demo-agent", - user_id="user-1", - session_id=None, - messages=[ - { - "role": "user", - "content": [ - { - "type": "input_text", - "text": "看看这个上传文件,直接开始分析吧,这里还有他画的架构图", - }, - { - "type": "input_file", - "fileData": { - "fileUri": "ksadk-upload://arch.png", - "displayName": "架构.png", - "mimeType": "image/png", - }, - }, - ], - } - ], - model="glm-5.1", - prepare_runner=lambda current_runner, model: current_runner.prepare_for_request(model), - ) - - session = await service.get_session(session_id) - assert session is not None - assert session.title == "架构图分析" - assert session.title_source == "heuristic" - - -@pytest.mark.asyncio -async def test_append_context_checkpoint_event_records_compaction_boundary(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) - - event = await append_context_checkpoint_event( - session_id="sess-1", - author="demo-agent", - compacted_until_seq_id=8, - metadata={"reason": "auto-compact"}, - ) - - assert event.event_type == "context_checkpoint" - assert event.metadata["compacted_until_seq_id"] == 8 - assert event.metadata["reason"] == "auto-compact" - - -def test_session_event_infers_canonical_message_types(): - user_event = SessionEvent.from_dict( - { - "author": "user", - "content": {"role": "user", "parts": [{"text": "hello"}]}, - } - ) - assistant_event = SessionEvent.from_dict( - { - "author": "demo-agent", - "content": {"role": "model", "parts": [{"text": "hi"}]}, - } - ) - - assert user_event.event_type == "user_message" - 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 checkpoint.metadata["is_terminal"] is False - assert checkpoint.metadata["is_resumable"] is None - assert checkpoint.metadata["resume_status"] == "unknown" - assert checkpoint.metadata["resume_disabled_reason"] == "" - assert checkpoint.metadata["backend"] == "unknown" - assert checkpoint.metadata["scope"] == "unknown" - assert checkpoint.metadata["durable"] is False - assert checkpoint.content["is_terminal"] is False - assert checkpoint.content["resume_status"] == "unknown" - 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_status", - "run_checkpoint", - "run_status", - ] - # resume 现在先写 run_status(resuming) 再写 run_status(in_progress) - run_statuses = [e.content["status"] for e in events if e.event_type == "run_status"] - assert run_statuses == ["resuming", "in_progress", "completed"] - - -@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_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_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", - model="demo-model", - session_id="sess-usage", - metadata={ - "usage": { - "input_tokens": 8, - "output_tokens": 13, - "total_tokens": 21, - "output_token_details": {"reasoning": 5}, - }, - "last_usage": { - "input_tokens": 8, - "output_tokens": 13, - "total_tokens": 21, - "input_token_details": {"cached": 4}, - }, - }, - ) - - assert payload["usage"] == { - "input_tokens": 8, - "output_tokens": 13, - "total_tokens": 21, - "output_token_details": {"reasoning": 5}, - } - # last_usage 透传到 metadata(供 server 取窗口占用) - assert payload["metadata"]["last_usage"]["input_tokens"] == 8 - assert payload["metadata"]["last_usage"]["input_token_details"]["cached"] == 4 - - -@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}, - } - # last_usage 透传到 response.completed 的 metadata(供 server 取窗口占用) - assert completed_payload["metadata"]["last_usage"]["input_tokens"] == 8 - assert completed_payload["metadata"]["last_usage"]["input_token_details"]["cached"] == 4 - 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"] - assert assistant_event.metadata["last_usage"]["input_tokens"] == 8 - - -@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() - 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.error" in chunk for chunk in chunks) - assert not any("response.completed" in chunk for chunk in chunks) - run_statuses = [ - event.content.get("status") - for event in events - if event.event_type == "run_status" - ] - assert run_statuses == ["in_progress", "failed"] - - -@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.error" in chunk for chunk in chunks) - assert not any("response.completed" in chunk for chunk in chunks) - run_statuses = [ - event.content.get("status") - for event in events - if event.event_type == "run_status" - ] - # checkpoint resume 失败现在写 resume_failed(独立终态),而非 failed。 - # 状态序列:resuming(build_run_input 补写)→ in_progress → resume_failed(失败改写)。 - assert run_statuses == ["resuming", "in_progress", "resume_failed"] - - -def test_build_history_from_events_prefers_latest_checkpoint_and_tail(): - events = [ - SessionEvent( - id="evt-1", - author="user", - event_type="user_message", - content={"role": "user", "parts": [{"text": "hello"}]}, - seq_id=1, - ), - SessionEvent( - id="evt-2", - author="demo-agent", - event_type="assistant_message", - content={"role": "model", "parts": [{"text": "hi"}]}, - seq_id=2, - ), - SessionEvent( - id="evt-3", - author="demo-agent", - event_type="context_checkpoint", - content={ - "role": "model", - "parts": [{"text": "Earlier conversation summary:\nuser: hello | assistant: hi"}], - }, - seq_id=3, - metadata={"compacted_until_seq_id": 2}, - ), - SessionEvent( - id="evt-4", - author="user", - event_type="user_message", - content={"role": "user", "parts": [{"text": "follow up"}]}, - seq_id=4, - ), - ] - - assert build_history_from_events(events) == [ - {"role": "model", "content": "Earlier conversation summary:\nuser: hello | assistant: hi"}, - {"role": "user", "content": "follow up"}, - ] - - -@pytest.mark.asyncio -async def test_build_run_input_auto_compacts_old_rounds_into_checkpoint(monkeypatch): - model_context_module = importlib.import_module("ksadk.conversations.model_context") - service = InMemorySessionService() - await service.create_session(agent_id="demo-agent", user_id="user-1", session_id="sess-compact") - for turn in range(5): - await service.append_event( - "sess-compact", - SessionEvent( - id=f"u-{turn}", - author="user", - event_type="user_message", - content={"role": "user", "parts": [{"text": f"user-{turn} " + ("x" * 80)}]}, - invocation_id=f"inv-{turn}", - ), - ) - await service.append_event( - "sess-compact", - SessionEvent( - id=f"a-{turn}", - author="demo-agent", - event_type="assistant_message", - content={"role": "model", "parts": [{"text": f"assistant-{turn} " + ("y" * 80)}]}, - invocation_id=f"inv-{turn}", - ), - ) - - monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service) - monkeypatch.setattr(model_context_module, "AUTOCOMPACT_SUMMARY_RESERVE_TOKENS", 0) - monkeypatch.setattr(model_context_module, "AUTOCOMPACT_BUFFER_TOKENS", 20) - compact_model_metadata = { - "id": "glm-5.1", - "context_window_tokens": 120, - "max_output_tokens": 1, - } - - preview = await preview_auto_compaction( - agent_id="demo-agent", - user_id="user-1", - session_id="sess-compact", - messages=[{"role": "user", "content": "follow up"}], - model="glm-5.1", - model_metadata=compact_model_metadata, - session_service_provider=lambda: service, - ) - prepared = await build_run_input( - agent_id="demo-agent", - user_id="user-1", - session_id="sess-compact", - messages=[{"role": "user", "content": "follow up"}], - model="glm-5.1", - model_metadata=compact_model_metadata, - ) - - events = await service.get_events("sess-compact") - assert preview.should_compact is True - assert preview.total_estimated_tokens > 0 - assert "compaction_boundary" in [event.event_type for event in events] - assert "context_checkpoint" in [event.event_type for event in events] - assert prepared.compaction_triggered is True - assert prepared.compaction_trigger == "auto" - assert prepared.compacted_until_seq_id is not None - assert prepared.history[0]["role"] == "model" - assert "Earlier conversation summary:" in prepared.history[0]["content"] - assert prepared.history[-1] == {"role": "user", "content": "follow up"} - - -@pytest.mark.asyncio -async def test_auto_compaction_ignores_inline_image_base64_for_context_estimation(monkeypatch): - model_context_module = importlib.import_module("ksadk.conversations.model_context") - service = InMemorySessionService() - await service.create_session(agent_id="demo-agent", user_id="user-1", session_id="sess-image-compact") - large_image_data = "A" * 260_000 - - for turn in range(3): - await service.append_event( - "sess-image-compact", - SessionEvent( - id=f"img-{turn}", - author="user", - event_type="user_message", - content={"role": "user", "parts": [{"text": "分析这张图片"}]}, - metadata={ - "agent_input": json.dumps( - [ - { - "role": "user", - "content": [ - {"type": "input_text", "text": "分析这张图片"}, - { - "type": "input_image", - "image_url": f"data:image/png;base64,{large_image_data}", - }, - ], - } - ] - ) - }, - invocation_id=f"img-inv-{turn}", - ), - ) - - monkeypatch.setattr(model_context_module, "AUTOCOMPACT_SUMMARY_RESERVE_TOKENS", 0) - monkeypatch.setattr(model_context_module, "AUTOCOMPACT_BUFFER_TOKENS", 20) - preview = await preview_auto_compaction( - agent_id="demo-agent", - user_id="user-1", - session_id="sess-image-compact", - messages=[{"role": "user", "content": "继续分析"}], - model="qwen3-vl-plus", - model_metadata={ - "id": "qwen3-vl-plus", - "context_window_tokens": 200_000, - "max_output_tokens": 32_000, - }, - session_service_provider=lambda: service, - ) - - assert preview.should_compact is False - assert preview.total_estimated_tokens < 1_000 - - -@pytest.mark.asyncio -async def test_build_run_input_respects_explicit_model_metadata_for_auto_compaction(monkeypatch): - service = InMemorySessionService() - await service.create_session( - agent_id="demo-agent", user_id="user-1", session_id="sess-model-metadata" - ) - for turn in range(6): - await service.append_event( - "sess-model-metadata", - SessionEvent( - id=f"u-{turn}", - author="user", - event_type="user_message", - content={"role": "user", "parts": [{"text": f"user-{turn} " + ("x" * 30_000)}]}, - invocation_id=f"inv-{turn}", - ), - ) - await service.append_event( - "sess-model-metadata", - SessionEvent( - id=f"a-{turn}", - author="demo-agent", - event_type="assistant_message", - content={ - "role": "model", - "parts": [{"text": f"assistant-{turn} " + ("y" * 30_000)}], - }, - invocation_id=f"inv-{turn}", - ), - ) - - monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service) - - preview = await preview_auto_compaction( - agent_id="demo-agent", - user_id="user-1", - session_id="sess-model-metadata", - messages=[{"role": "user", "content": "follow up"}], - model="glm-5.1", - model_metadata={ - "id": "glm-5.1", - "context_length": "64k", - "max_completion_tokens": "8k", - }, - session_service_provider=lambda: service, - ) - prepared = await build_run_input( - agent_id="demo-agent", - user_id="user-1", - session_id="sess-model-metadata", - messages=[{"role": "user", "content": "follow up"}], - model="glm-5.1", - model_metadata={ - "id": "glm-5.1", - "context_length": "64k", - "max_completion_tokens": "8k", - }, - session_service_provider=lambda: service, - ) - - assert preview.should_compact is True - assert preview.auto_compact_threshold_tokens == 43000 - assert prepared.compaction_triggered is True - assert prepared.history[0]["role"] == "model" - assert "Earlier conversation summary:" in prepared.history[0]["content"] - - -@pytest.mark.asyncio -async def test_invoke_conversation_once_fetches_model_metadata_from_remote_catalog(monkeypatch): - service = InMemorySessionService() - runner = _StubRunner() - - monkeypatch.setenv("OPENAI_BASE_URL", "https://kspmas.ksyun.com/v1") - monkeypatch.setenv("OPENAI_API_KEY", "secret-key") - monkeypatch.setattr( - "httpx.AsyncClient", - lambda *args, **kwargs: _ExternalModelsAsyncClient( - *args, - payload={ - "data": [ - { - "id": "kimi-k2.6", - "architecture": { - "input_modalities": ["文字", "图片", "视频"], - "output_modalities": ["文字"], - }, - } - ] - }, - **kwargs, - ), - ) - - session_id, _ = await invoke_conversation_once( - runner=runner, - agent_id="demo-agent", - user_id="user-1", - session_id=None, - messages=[{"role": "user", "content": "请分析图片"}], - model="kimi-k2.6", - prepare_runner=lambda _runner, _model: None, - session_service_provider=lambda: service, - ) - - assert session_id - assert runner.calls[0]["model_metadata"]["id"] == "kimi-k2.6" - assert runner.calls[0]["model_metadata"]["architecture"]["input_modalities"] == [ - "文字", - "图片", - "视频", - ] - assert runner.calls[0]["model_metadata"]["capabilities"]["multimodal_input_image"] is True - - -@pytest.mark.asyncio -async def test_invoke_conversation_once_compacts_and_retries_on_prompt_too_long(monkeypatch): - model_context_module = importlib.import_module("ksadk.conversations.model_context") - service = InMemorySessionService() - await service.create_session(agent_id="demo-agent", user_id="user-1", session_id="sess-ptl") - for turn in range(4): - await service.append_event( - "sess-ptl", - SessionEvent( - id=f"u-{turn}", - author="user", - event_type="user_message", - content={"role": "user", "parts": [{"text": f"user-{turn} " + ("x" * 80)}]}, - invocation_id=f"inv-{turn}", - ), - ) - await service.append_event( - "sess-ptl", - SessionEvent( - id=f"a-{turn}", - author="demo-agent", - event_type="assistant_message", - content={"role": "model", "parts": [{"text": f"assistant-{turn} " + ("y" * 80)}]}, - invocation_id=f"inv-{turn}", - ), - ) - - monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service) - monkeypatch.setattr(model_context_module, "DEFAULT_CONTEXT_WINDOW_TOKENS", 120) - monkeypatch.setattr(model_context_module, "DEFAULT_MAX_OUTPUT_TOKENS", 0) - monkeypatch.setattr(model_context_module, "AUTOCOMPACT_SUMMARY_RESERVE_TOKENS", 0) - monkeypatch.setattr(model_context_module, "AUTOCOMPACT_BUFFER_TOKENS", 20) - runner = _PromptTooLongRunner() - - session_id, result = await invoke_conversation_once( - runner=runner, - agent_id="demo-agent", - user_id="user-1", - session_id="sess-ptl", - messages=[{"role": "user", "content": "new follow up"}], - model="gpt-4o", - prepare_runner=lambda current, model: current.prepare_for_request(model), - ) - - assert session_id == "sess-ptl" - assert result["output_text"] == "compacted answer" - assert len(runner.calls) == 2 - assert len(runner.calls[1]["history"]) < len(runner.calls[0]["history"]) - assert runner.calls[1]["history"][0]["role"] == "model" - assert "Earlier conversation summary:" in runner.calls[1]["history"][0]["content"] - - events = await service.get_events("sess-ptl") - assert "compaction_boundary" in [event.event_type for event in events] - assert "context_checkpoint" in [event.event_type for event in events] - - -@pytest.mark.asyncio -async def test_compact_conversation_history_prefers_semantic_summary_and_records_metadata( - monkeypatch, -): - service = InMemorySessionService() - await service.create_session( - agent_id="demo-agent", user_id="user-1", session_id="sess-semantic" - ) - - for turn in range(3): - await service.append_event( - "sess-semantic", - SessionEvent( - id=f"u-sem-{turn}", - author="user", - event_type="user_message", - content={"role": "user", "parts": [{"text": f"用户问题 {turn} " + ("甲" * 40)}]}, - invocation_id=f"sem-{turn}", - ), - ) - await service.append_event( - "sess-semantic", - SessionEvent( - id=f"a-sem-{turn}", - author="demo-agent", - event_type="assistant_message", - content={"role": "model", "parts": [{"text": f"助手回复 {turn} " + ("乙" * 40)}]}, - invocation_id=f"sem-{turn}", - ), - ) - - class _FakeSummaryClient: - is_available = True - - async def summarize(self, *, model, messages, timeout_ms): - assert model == "glm-5.1" - assert timeout_ms > 0 - assert any("当前用户目标" in item["content"] for item in messages) - return ( - "draft" - "当前用户目标\n- 修复语义压缩\n\n" - "关键约束与偏好\n- 质量优先\n\n" - "已完成进展\n- 已生成 checkpoint\n\n" - "重要决策/代码上下文\n- 保持 append-only 事件契约\n\n" - "未完成事项\n- 补更多回归测试\n\n" - "下一步工作位置\n- ksadk.conversations.runtime.compact_conversation_history" - "", - {"prompt_tokens": 120, "completion_tokens": 48, "total_tokens": 168}, - ) - - monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service) - monkeypatch.setattr( - "ksadk.conversations.semantic_summary.resolve_summary_model_client", - lambda: _FakeSummaryClient(), - ) - - checkpoint = await compact_conversation_history( - session_id="sess-semantic", - author="demo-agent", - invocation_id="inv-semantic", - model="glm-5.1", - force=True, - keep_tail_groups=1, - session_service_provider=lambda: service, - ) - - assert checkpoint is not None - assert checkpoint.event_type == "context_checkpoint" - assert "" not in checkpoint.content["parts"][0]["text"] - assert "当前用户目标" in checkpoint.content["parts"][0]["text"] - assert checkpoint.metadata["summary_strategy"] == "semantic" - assert checkpoint.metadata["summary_version"] == "v1" - assert checkpoint.metadata["summary_model"] == "glm-5.1" - assert checkpoint.metadata["summary_usage"]["total_tokens"] == 168 - - -@pytest.mark.asyncio -async def test_compact_conversation_history_falls_back_to_extractive_when_semantic_summary_fails( - monkeypatch, -): - service = InMemorySessionService() - await service.create_session( - agent_id="demo-agent", user_id="user-1", session_id="sess-fallback" - ) - - for turn in range(3): - await service.append_event( - "sess-fallback", - SessionEvent( - id=f"u-fb-{turn}", - author="user", - event_type="user_message", - content={"role": "user", "parts": [{"text": f"user-{turn} " + ("x" * 60)}]}, - invocation_id=f"fb-{turn}", - ), - ) - await service.append_event( - "sess-fallback", - SessionEvent( - id=f"a-fb-{turn}", - author="demo-agent", - event_type="assistant_message", - content={"role": "model", "parts": [{"text": f"assistant-{turn} " + ("y" * 60)}]}, - invocation_id=f"fb-{turn}", - ), - ) - - class _BrokenSummaryClient: - is_available = True - - async def summarize(self, *, model, messages, timeout_ms): - raise RuntimeError("summary backend down") - - monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service) - monkeypatch.setattr( - "ksadk.conversations.semantic_summary.resolve_summary_model_client", - lambda: _BrokenSummaryClient(), - ) - - checkpoint = await compact_conversation_history( - session_id="sess-fallback", - author="demo-agent", - invocation_id="inv-fallback", - model="glm-5.1", - force=True, - keep_tail_groups=1, - session_service_provider=lambda: service, - ) - - assert checkpoint is not None - assert checkpoint.metadata["summary_strategy"] == "extractive" - assert checkpoint.metadata["summary_version"] == "v1" - assert "summary backend down" in checkpoint.metadata["fallback_reason"] - assert "Earlier conversation summary:" in checkpoint.content["parts"][0]["text"] - - -def test_plan_compaction_keeps_pending_approval_group_out_of_checkpoint(): - runtime_module = importlib.import_module("ksadk.conversations.runtime") - events = [ - SessionEvent( - id="evt-1", - author="user", - event_type="user_message", - content={"role": "user", "parts": [{"text": "先看第一轮"}]}, - invocation_id="inv-1", - seq_id=1, - ), - SessionEvent( - id="evt-2", - author="demo-agent", - event_type="assistant_message", - content={"role": "model", "parts": [{"text": "第一轮回复"}]}, - invocation_id="inv-1", - seq_id=2, - ), - SessionEvent( - id="evt-3", - author="demo-agent", - event_type="approval_request", - content={"role": "model", "parts": [{"text": "请确认是否继续执行部署"}]}, - invocation_id="inv-2", - seq_id=3, - ), - SessionEvent( - id="evt-4", - author="user", - event_type="user_message", - content={"role": "user", "parts": [{"text": "顺便记录这个当前任务"}]}, - invocation_id="inv-3", - seq_id=4, - ), - ] - - plan = runtime_module._plan_compaction( - events, - force=True, - keep_tail_groups=1, - ) - - assert plan.should_compact is True - assert [[item.seq_id for item in group] for group in plan.groups_to_compact] == [[1, 2]] - assert plan.pinned_state["pending_approvals"] - assert "当前任务" in plan.pinned_state["current_user_goal"] diff --git a/tests/test_deepagents_integration.py b/tests/test_deepagents_integration.py deleted file mode 100644 index a36b6947..00000000 --- a/tests/test_deepagents_integration.py +++ /dev/null @@ -1,249 +0,0 @@ -"""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_deepagents_runner_skill_runtime.py b/tests/test_deepagents_runner_skill_runtime.py deleted file mode 100644 index 5c193f85..00000000 --- a/tests/test_deepagents_runner_skill_runtime.py +++ /dev/null @@ -1,23 +0,0 @@ -from __future__ import annotations - - -def _tool_names(tools): - return [getattr(tool, "name", None) or getattr(tool, "__name__", "") for tool in tools] - - -def test_deepagents_projects_can_use_agentengine_toolsets_before_compile(monkeypatch): - from ksadk.runners.deepagents_runner import DeepAgentsRunner - from ksadk.runners.langgraph_runner import LangGraphRunner - from ksadk.toolsets import get_agentengine_tools - - monkeypatch.setenv("KSADK_SKILL_RUNTIME_BACKEND", "disabled") - monkeypatch.delenv("KSADK_SANDBOX_TEMPLATE_ID", raising=False) - - tools = get_agentengine_tools(include=["skill", "workspace", "platform", "sandbox"]) - names = _tool_names(tools) - - assert issubclass(DeepAgentsRunner, LangGraphRunner) - assert "execute_skills" in names - assert "workspace_status" in names - assert "component_status" in names - assert "sandbox_status" in names diff --git a/tests/test_deploy_integration.py b/tests/test_deploy_integration.py deleted file mode 100644 index d722bc6e..00000000 --- a/tests/test_deploy_integration.py +++ /dev/null @@ -1,1082 +0,0 @@ -""" -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_uses_project_yaml_ui_bundle_path( - self, - temp_project_dir, - sample_package_info, - sample_deploy_target, - ): - provider = ServerlessProvider() - - (temp_project_dir / "agentengine.yaml").write_text( - yaml.dump( - { - "name": "test-agent", - "framework": "langgraph", - "ui_profile": "custom", - "ui_path": "/luoluo", - "ui_bundle_path": "frontend/dist", - } - ) - ) - (temp_project_dir / ".agentengine.state").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( - return_value={"basic": {"agent_id": "ar-20260119-existing", "name": "test-agent"}} - ) - - async def _fake_update_agent(agent_id, payload): - 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) - - env_vars = captured["payload"]["env_vars"] - assert env_vars["KSADK_UI_PROFILE"] == "custom" - assert env_vars["KSADK_UI_PATH"] == "/luoluo" - assert env_vars["KSADK_UI_BUNDLE_PATH"] == "frontend/dist" - - @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": "/", - "url": "https://ui.example.com/custom-ui/", - } - - @pytest.mark.asyncio - async def test_deploy_update_injects_custom_ui_runtime_env( - self, - temp_project_dir, - sample_package_info, - sample_deploy_target, - ): - provider = ServerlessProvider() - (temp_project_dir / ".agentengine.state").write_text( - yaml.dump( - { - "agent_id": "ar-20260119-existing", - "name": "test-agent", - "endpoint": "https://existing.kspmas.ksyun.com", - "ui_profile": "custom", - "ui_path": "/", - "ui_bundle_path": "research-ui/dist", - } - ) - ) - - 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["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_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["KSADK_UI_PROFILE"] == "custom" - assert env_vars["KSADK_UI_PATH"] == "/" - assert env_vars["KSADK_UI_BUNDLE_PATH"] == "research-ui/dist" - - @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_env_vars_default_timezone_to_shanghai( - self, - temp_project_dir, - ): - provider = ServerlessProvider() - (temp_project_dir / ".env").write_text("OPENAI_API_KEY=project-key\n", encoding="utf-8") - - with patch.dict(os.environ, {}, clear=True), patch( - "ksadk.deployment.providers.serverless.get_env_from_global_config", - return_value={}, - ): - env_vars, _, _ = provider._load_deploy_env_vars(temp_project_dir) - - assert env_vars["TZ"] == "Asia/Shanghai" - - def test_deploy_env_vars_preserve_explicit_timezone( - self, - temp_project_dir, - ): - provider = ServerlessProvider() - (temp_project_dir / ".env").write_text("TZ=UTC\n", encoding="utf-8") - - with patch.dict(os.environ, {"TZ": "Asia/Shanghai"}, clear=True), patch( - "ksadk.deployment.providers.serverless.get_env_from_global_config", - return_value={}, - ): - env_vars, _, _ = provider._load_deploy_env_vars( - temp_project_dir, - {"CUSTOM_RUNTIME_FLAG": "enabled"}, - ) - - assert env_vars["TZ"] == "UTC" - - 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 deleted file mode 100644 index c0d1ce33..00000000 --- a/tests/test_error_utils_hints.py +++ /dev/null @@ -1,143 +0,0 @@ -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 deleted file mode 100644 index e1601bc6..00000000 --- a/tests/test_help_snapshots.py +++ /dev/null @@ -1,144 +0,0 @@ -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 deleted file mode 100644 index d4a74591..00000000 --- a/tests/test_hermes_container_builder.py +++ /dev/null @@ -1,133 +0,0 @@ -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_packages_project_custom_ui_dist_without_node_modules(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") - custom_dist = project / "research-ui" / "dist" / "assets" - custom_dist.mkdir(parents=True) - (project / "research-ui" / "dist" / "index.html").write_text("Custom UI", encoding="utf-8") - (custom_dist / "index.js").write_text("console.log('custom ui')\n", encoding="utf-8") - (project / "research-ui" / "node_modules").mkdir(parents=True) - (project / "research-ui" / "node_modules" / "ignored.js").write_text("ignored\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 / "research-ui" / "dist" / "index.html").exists() - assert (build_dir / "research-ui" / "dist" / "assets" / "index.js").exists() - assert not (build_dir / "research-ui" / "node_modules" / "ignored.js").exists() - assert 'from ksadk.server import app, set_runner' in (build_dir / "entrypoint.py").read_text(encoding="utf-8") - - -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 deleted file mode 100644 index fe848dd8..00000000 --- a/tests/test_hermes_terminal.py +++ /dev/null @@ -1,510 +0,0 @@ -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 deleted file mode 100644 index 833263c1..00000000 --- a/tests/test_hermes_terminal_e2e.py +++ /dev/null @@ -1,157 +0,0 @@ -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_identity_resolver.py b/tests/test_identity_resolver.py deleted file mode 100644 index a67c39f8..00000000 --- a/tests/test_identity_resolver.py +++ /dev/null @@ -1,295 +0,0 @@ -"""identity resolver 单测:AK/SK 反查 + 缓存 + 内网 fallback。""" - -from __future__ import annotations - -import json -import os -from pathlib import Path -from unittest.mock import MagicMock, patch - -import pytest - -from ksadk.identity.resolver import ( - ResolvedIdentity, - _ak_fingerprint, - _extract_main_account_id_from_krn, - _find_username_by_ak, - _resolve_iam_endpoint, - _should_retry_intranet, - get_cached_identity, - get_cached_user_uuid, - invalidate_cache, - resolve_identity, -) - - -# --------------------------------------------------------------------------- -# 纯函数测试 -# --------------------------------------------------------------------------- - - -def test_ak_fingerprint_stable_and_unique(): - fp1 = _ak_fingerprint("AKLTtest123") - fp2 = _ak_fingerprint("AKLTtest123") - fp3 = _ak_fingerprint("AKLTtest456") - assert fp1 == fp2 # 稳定 - assert fp1 != fp3 # 不同 AK 不同指纹 - assert len(fp1) == 16 - - -@pytest.mark.parametrize( - "krn,expected", - [ - ("krn:ksc:iam::2000003485:user/xiayu", "2000003485"), - ("krn:ksc:iam::73398439:user/w_test", "73398439"), - ("not a krn", None), - ("", None), - (None, None), - ("krn:ksc:iam:::user/x", None), # 空主账号 ID - ], -) -def test_extract_main_account_id_from_krn(krn, expected): - assert _extract_main_account_id_from_krn(krn) == expected - - -def test_find_username_by_ak(): - keys = [ - {"AccessKey": "AKLTaaa", "UserName": "user1"}, - {"AccessKey": "AKLTbbb", "UserName": "user2"}, - ] - assert _find_username_by_ak(keys, "AKLTbbb") == "user2" - assert _find_username_by_ak(keys, "AKLTccc") is None # 不在列表(主账号 AK) - assert _find_username_by_ak([], "AKLTaaa") is None - - -def test_resolve_iam_endpoint_default(): - old = (os.environ.get("KSYUN_IAM_URL"), os.environ.get("IAM_URL")) - os.environ.pop("KSYUN_IAM_URL", None) - os.environ.pop("IAM_URL", None) - try: - assert _resolve_iam_endpoint() == ("iam.api.ksyun.com", "https") - finally: - for k, v in zip(("KSYUN_IAM_URL", "IAM_URL"), old): - if v is not None: - os.environ[k] = v - - -def test_resolve_iam_endpoint_from_env(): - old = os.environ.get("KSYUN_IAM_URL") - os.environ["KSYUN_IAM_URL"] = "http://iam.inner.api.ksyun.com" - try: - assert _resolve_iam_endpoint() == ("iam.inner.api.ksyun.com", "http") - finally: - if old is None: - os.environ.pop("KSYUN_IAM_URL", None) - else: - os.environ["KSYUN_IAM_URL"] = old - - -def test_should_retry_intranet(): - exc = Exception('{"Error":{"Code":"InnerAccountCanOnlyAccessThroughIntranet"}}') - assert _should_retry_intranet(exc) is True - assert _should_retry_intranet(Exception("other error")) is False - assert _should_retry_intranet(None) is False - - -# --------------------------------------------------------------------------- -# 缓存测试(monkeypatch 缓存路径到 tmp_path) -# --------------------------------------------------------------------------- - - -@pytest.fixture -def isolated_cache(monkeypatch, tmp_path): - """把缓存读写重定向到 tmp_path,避免污染真实 settings.json。""" - cache_file = tmp_path / "settings.json" - - def fake_load(): - if not cache_file.exists(): - return {} - return json.loads(cache_file.read_text(encoding="utf-8")) - - def fake_save(config): - cache_file.write_text(json.dumps(config, ensure_ascii=False, indent=2), encoding="utf-8") - - monkeypatch.setattr("ksadk.identity.resolver._load_identity_cache", lambda: fake_load().__getitem__("cloud").get("IDENTITY_CACHE", {}) if fake_load() else {}) - # 直接 patch _load/_save 更简单 - _cache = {} - - def load(): - return dict(_cache) - - def save(cache): - _cache.clear() - _cache.update(cache) - - monkeypatch.setattr("ksadk.identity.resolver._load_identity_cache", load) - monkeypatch.setattr("ksadk.identity.resolver._save_identity_cache", save) - return _cache - - -def test_get_cached_identity_miss(isolated_cache): - assert get_cached_identity("AKLTnone") is None - assert get_cached_user_uuid("AKLTnone") is None - - -def test_invalidate_cache(isolated_cache): - # 写入一个条目 - fp = _ak_fingerprint("AKLTtest") - isolated_cache[fp] = {"ak_fingerprint": fp, "user_uuid": "uuid-x"} - # 清空 - invalidate_cache("AKLTtest") - assert get_cached_user_uuid("AKLTtest") is None - # 全清 - isolated_cache[fp] = {"ak_fingerprint": fp, "user_uuid": "uuid-x"} - invalidate_cache(None) - assert get_cached_user_uuid("AKLTtest") is None - - -# --------------------------------------------------------------------------- -# resolve_identity 集成测试(mock IAM SDK) -# --------------------------------------------------------------------------- - - -def _mock_sdk_parts(): - """构造 mock 的 sdk_parts 元组。""" - return tuple(MagicMock() for _ in range(6)) - - -def test_resolve_identity_cache_hit_no_iam_call(isolated_cache, monkeypatch): - """缓存命中时不调 IAM。""" - fp = _ak_fingerprint("AKLTtest") - isolated_cache[fp] = { - "ak_fingerprint": fp, - "user_uuid": "uuid-cached", - "main_account_id": "2000003485", - "user_name": "cached-user", - "krn": "krn:ksc:iam::2000003485:user/cached-user", - } - called = MagicMock() - monkeypatch.setattr("ksadk.identity.resolver._import_iam_sdk", lambda: called()) - r = resolve_identity(access_key="AKLTtest", secret_key="SKtest") - assert called.call_count == 0 # 缓存命中,未调 IAM - assert r is not None - assert r.user_uuid == "uuid-cached" - assert r.main_account_id == "2000003485" - - -def test_resolve_identity_cache_miss_invokes_iam(isolated_cache, monkeypatch): - """缓存 miss 时调 IAM 两步链路并写缓存。""" - sdk_parts = _mock_sdk_parts() - IamClient = sdk_parts[0] - ListReq = sdk_parts[1] - GetReq = sdk_parts[2] - - # mock client 实例 - client = MagicMock() - IamClient.return_value = client - client.ListAllUserAccessKeys.return_value = json.dumps( - {"AccessKeyList": [{"AccessKey": "AKLTtest", "UserName": "xiayu"}]} - ) - client.GetUser.return_value = json.dumps( - {"GetUserResult": {"User": {"UserId": "uuid-new", "Krn": "krn:ksc:iam::2000003485:user/xiayu"}}} - ) - - monkeypatch.setattr("ksadk.identity.resolver._import_iam_sdk", lambda: sdk_parts) - - r = resolve_identity(access_key="AKLTtest", secret_key="SKtest") - assert r is not None - assert r.user_uuid == "uuid-new" - assert r.main_account_id == "2000003485" - assert r.user_name == "xiayu" - # 验证写缓存 - fp = _ak_fingerprint("AKLTtest") - assert fp in isolated_cache - assert isolated_cache[fp]["user_uuid"] == "uuid-new" - - -def test_resolve_identity_ak_not_in_list_returns_none(isolated_cache, monkeypatch): - """AK 不在子用户列表(主账号 AK)返回 None。""" - sdk_parts = _mock_sdk_parts() - IamClient = sdk_parts[0] - client = MagicMock() - IamClient.return_value = client - client.ListAllUserAccessKeys.return_value = json.dumps( - {"AccessKeyList": [{"AccessKey": "OTHER_AK", "UserName": "other"}]} - ) - monkeypatch.setattr("ksadk.identity.resolver._import_iam_sdk", lambda: sdk_parts) - - r = resolve_identity(access_key="AKLTtest", secret_key="SKtest") - assert r is None - client.GetUser.assert_not_called() # 没找到 AK 就不调 GetUser - - -def test_resolve_identity_network_failure_returns_none(isolated_cache, monkeypatch): - """IAM 调用异常返回 None 不抛。""" - sdk_parts = _mock_sdk_parts() - IamClient = sdk_parts[0] - IamClient.side_effect = Exception("network error") - monkeypatch.setattr("ksadk.identity.resolver._import_iam_sdk", lambda: sdk_parts) - - r = resolve_identity(access_key="AKLTtest", secret_key="SKtest") - assert r is None - - -def test_resolve_identity_intranet_fallback(isolated_cache, monkeypatch): - """公网失败(InnerAccountCanOnlyAccessThroughIntranet)时 fallback 内网。""" - sdk_parts = _mock_sdk_parts() - IamClient = sdk_parts[0] - client = MagicMock() - IamClient.return_value = client - # 第一次(公网)抛内网错误,第二次(内网)成功 - client.ListAllUserAccessKeys.side_effect = [ - Exception('{"Error":{"Code":"InnerAccountCanOnlyAccessThroughIntranet"}}'), - json.dumps({"AccessKeyList": [{"AccessKey": "AKLTtest", "UserName": "inner-user"}]}), - ] - client.GetUser.return_value = json.dumps( - {"GetUserResult": {"User": {"UserId": "uuid-inner", "Krn": "krn:ksc:iam::2000003485:user/inner-user"}}} - ) - monkeypatch.setattr("ksadk.identity.resolver._import_iam_sdk", lambda: sdk_parts) - - r = resolve_identity(access_key="AKLTtest", secret_key="SKtest") - assert r is not None - assert r.user_uuid == "uuid-inner" - # 验证调了两次(公网 + 内网) - assert client.ListAllUserAccessKeys.call_count == 2 - - -def test_resolve_identity_no_credentials_returns_none(isolated_cache): - assert resolve_identity(access_key="", secret_key="SK") is None - assert resolve_identity(access_key="AK", secret_key="") is None - - -def test_resolve_identity_force_refresh_bypasses_cache(isolated_cache, monkeypatch): - """force_refresh=True 绕过缓存重新反查。""" - fp = _ak_fingerprint("AKLTtest") - isolated_cache[fp] = {"ak_fingerprint": fp, "user_uuid": "old-uuid"} - sdk_parts = _mock_sdk_parts() - IamClient = sdk_parts[0] - client = MagicMock() - IamClient.return_value = client - client.ListAllUserAccessKeys.return_value = json.dumps( - {"AccessKeyList": [{"AccessKey": "AKLTtest", "UserName": "u"}]} - ) - client.GetUser.return_value = json.dumps( - {"GetUserResult": {"User": {"UserId": "new-uuid", "Krn": "krn:ksc:iam::2000003485:user/u"}}} - ) - monkeypatch.setattr("ksadk.identity.resolver._import_iam_sdk", lambda: sdk_parts) - - r = resolve_identity(access_key="AKLTtest", secret_key="SK", force_refresh=True) - assert r is not None - assert r.user_uuid == "new-uuid" # 用新值,不是缓存的 old-uuid - - -def test_get_cached_identity_returns_full_identity(isolated_cache): - fp = _ak_fingerprint("AKLTtest") - isolated_cache[fp] = { - "ak_fingerprint": fp, - "user_uuid": "uuid-x", - "main_account_id": "2000003485", - "user_name": "u", - "krn": "krn:ksc:iam::2000003485:user/u", - } - r = get_cached_identity("AKLTtest") - assert r is not None - assert r.user_uuid == "uuid-x" - assert r.main_account_id == "2000003485" diff --git a/tests/test_json_contracts.py b/tests/test_json_contracts.py deleted file mode 100644 index b276816b..00000000 --- a/tests/test_json_contracts.py +++ /dev/null @@ -1,729 +0,0 @@ -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 deleted file mode 100644 index 31c7ce9f..00000000 --- a/tests/test_ks3_uploader_urls.py +++ /dev/null @@ -1,261 +0,0 @@ -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 deleted file mode 100644 index b6ac5e95..00000000 --- a/tests/test_langchain_runner_session_continuity.py +++ /dev/null @@ -1,342 +0,0 @@ -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"} - - -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()]} - - -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, ".") - 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_standard_hook_receives_ksadk_builtin_tool_descriptors(monkeypatch): - agent = _RecordingAgent() - captured: list[dict] = [] - - def ksadk_prepare_input(payload: dict, session_context: dict) -> dict: - captured.append(session_context) - return payload - - monkeypatch.setenv("KSADK_BUILTIN_TOOLS_MODE", "dispatcher") - runner = _make_runner(agent, module=SimpleNamespace(ksadk_prepare_input=ksadk_prepare_input)) - - await runner.invoke({"session_id": "sess-tools", "input": "hello"}) - - tool_names = [tool["name"] for tool in captured[0]["ksadk_tools"]] - assert tool_names == ["tool_dispatcher"] - assert captured[0]["ksadk_builtin_tools_mode"] == "dispatcher" - - -@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" - - -@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}, - } - - -@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}, - }, - "metadata": { - "last_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] = {} - - 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 deleted file mode 100644 index 9e18d05a..00000000 --- a/tests/test_langfuse_exporter.py +++ /dev/null @@ -1,115 +0,0 @@ -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] = [] - self.scores: list[dict] = [] - - def trace(self, **kwargs): - self.traces.append(kwargs) - return SimpleNamespace(generation=lambda **_kwargs: None, span=lambda **_kwargs: None) - - def score(self, **kwargs): - self.scores.append(kwargs) - return None - - def flush(self): - return None - - -class _FakeSpan: - def __init__(self, attributes: dict[str, object], events: list[object] | None = None): - self.name = "demo-agent" - self.attributes = attributes - self.events = events or [] - 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" - - -def test_langfuse_exporter_converts_score_span_events_to_langfuse_scores(): - exporter, fake = _exporter_with_fake_client() - event = SimpleNamespace( - name="langfuse.score", - attributes={ - "score.id": "feedback:agent-demo:sess-1:resp_123", - "score.name": "hosted_ui_feedback", - "score.value": "down", - "score.data_type": "CATEGORICAL", - "score.comment": "不准确", - "langfuse.trace_id": "trace-from-event", - "langfuse.observation_id": "span-1", - "source": "hosted-ui", - }, - ) - - exporter._export_trace("trace-1", [_FakeSpan({}, events=[event])]) - - assert fake.scores == [ - { - "id": "feedback:agent-demo:sess-1:resp_123", - "trace_id": "trace-from-event", - "observation_id": "span-1", - "name": "hosted_ui_feedback", - "value": "down", - "data_type": "CATEGORICAL", - "comment": "不准确", - "metadata": {"source": "hosted-ui"}, - } - ] diff --git a/tests/test_langfuse_runner_utils.py b/tests/test_langfuse_runner_utils.py deleted file mode 100644 index 36b2a86f..00000000 --- a/tests/test_langfuse_runner_utils.py +++ /dev/null @@ -1,49 +0,0 @@ -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 deleted file mode 100644 index 600980bd..00000000 --- a/tests/test_langgraph_runner_resume.py +++ /dev/null @@ -1,1172 +0,0 @@ -from types import SimpleNamespace - -import pytest -from langgraph.types import Command -import base64 - -from ksadk.runners.langgraph_runner import LangGraphRunner - - -class _DummyAgent: - 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 - self.additional_kwargs = {} - if reasoning_content is not None: - self.additional_kwargs["reasoning_content"] = reasoning_content - - -class _StreamingAgent(_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(reasoning_content="先分析需求。")}, - } - yield { - "event": "on_chat_model_stream", - "data": {"chunk": _Chunk(content="这是最终回复。")}, - } - - -class _DuplicatedReasoningStreamingAgent(_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="先分析需求。", - reasoning_content="先分析需求。", - ) - }, - } - yield { - "event": "on_chat_model_stream", - "data": {"chunk": _Chunk(content="这是最终回复。")}, - } - - -class _ToolDictOutputStreamingAgent(_DummyAgent): - async def astream_events(self, state, version="v2", config=None): - self.last_astream_state = state - yield { - "event": "on_tool_end", - "name": "write_workspace_file", - "run_id": "run-approval", - "data": { - "output": { - "ok": False, - "type": "approval_required", - "approval_request": { - "id": "appr_write", - "tool_name": "write_workspace_file", - }, - } - }, - } - - -class _ToolThenAnswerStreamingAgent(_DummyAgent): - async def astream_events(self, state, version="v2", config=None): - self.last_astream_state = state - yield { - "event": "on_tool_start", - "name": "list_skills", - "run_id": "run-list-skills", - "data": {"input": {}}, - } - yield { - "event": "on_tool_end", - "name": "list_skills", - "run_id": "run-list-skills", - "data": {"output": {"ok": True, "skills": [{"name": "ppt-translator"}]}}, - } - yield { - "event": "on_chain_end", - "name": "LangGraph", - "data": { - "output": { - "answer": "已真实调用 `list_skills`。\n当前返回的 Skill:\n- ppt-translator", - "messages": [{"content": ""}], - } - }, - } - - -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="最终回复。")}, - } - - -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()]} - - -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()]}}, - } - - -class _CheckpointResumeUpdatesAgent(_DummyAgent): - def __init__(self): - super().__init__() - self.last_astream_stream_mode = None - self.astream_events_called = False - - async def astream(self, state, config=None, stream_mode=None): - self.last_astream_state = state - self.last_astream_config = config - self.last_astream_stream_mode = stream_mode - yield {"search": {"answer": "resumed via updates"}} - - async def astream_events(self, state, version="v2", config=None): - self.astream_events_called = True - if False: - yield {} - - def get_state(self, config): - del config - return SimpleNamespace( - values={"answer": "resumed via updates"}, - config={ - "configurable": { - "thread_id": "sess-1", - "checkpoint_id": "ckpt-after", - } - }, - next=("report",), - ) - - -def _make_runner(module=None) -> LangGraphRunner: - detection = SimpleNamespace(entry_point="src/agent.py", agent_variable="root_agent") - runner = LangGraphRunner(detection, ".") - runner._agent = _DummyAgent() - if module is not None: - runner._module = module - return runner - - -def _make_streaming_runner() -> LangGraphRunner: - runner = _make_runner() - runner._agent = _StreamingAgent() - return runner - - -def _make_duplicated_reasoning_streaming_runner() -> LangGraphRunner: - runner = _make_runner() - runner._agent = _DuplicatedReasoningStreamingAgent() - return runner - - -def _make_tool_dict_output_streaming_runner() -> LangGraphRunner: - runner = _make_runner() - runner._agent = _ToolDictOutputStreamingAgent() - return runner - - -def _make_tool_then_answer_streaming_runner() -> LangGraphRunner: - runner = _make_runner() - runner._agent = _ToolThenAnswerStreamingAgent() - 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 - - -def _make_usage_runner() -> LangGraphRunner: - runner = _make_runner() - runner._agent = _UsageAgent() - 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 - - -def _make_checkpoint_resume_updates_runner() -> LangGraphRunner: - runner = _make_runner() - runner._agent = _CheckpointResumeUpdatesAgent() - return runner - - -@pytest.mark.asyncio -async def test_invoke_simplified_input_preserves_extra_state(): - runner = _make_runner() - - await runner.invoke( - { - "session_id": "s1", - "input": "hello", - "history": [{"role": "user", "content": "prev"}], - "files": [{"name": "resume.txt"}], - } - ) - - state = runner._agent.last_ainvoke_state - assert "messages" in state - assert "files" in state - assert state["files"] == [{"name": "resume.txt"}] - assert len(state["messages"]) == 2 - - -@pytest.mark.asyncio -async def test_invoke_simplified_input_does_not_duplicate_current_user_message_when_history_contains_it(): - runner = _make_runner() - - await runner.invoke( - { - "session_id": "s1", - "input": "hello", - "history": [{"role": "user", "content": "hello"}], - } - ) - - messages = runner._agent.last_ainvoke_state["messages"] - user_messages = [ - message - for message in messages - if message.__class__.__name__ == "HumanMessage" and message.content == "hello" - ] - assert len(user_messages) == 1 - - -@pytest.mark.asyncio -async def test_invoke_simplified_input_preserves_attachment_contract_fields(): - runner = _make_runner() - - await runner.invoke( - { - "session_id": "s1", - "input": "请分析附件", - "history": [{"role": "user", "content": "上一轮"}], - "input_parts": [{"text": "请分析附件"}], - "attachments": [{"display_name": "resume.pdf"}], - "attachment_results": [{"display_name": "resume.pdf", "kind": "document"}], - } - ) - - state = runner._agent.last_ainvoke_state - assert state["input_parts"] == [{"text": "请分析附件"}] - assert state["attachments"] == [{"display_name": "resume.pdf"}] - assert state["attachment_results"] == [{"display_name": "resume.pdf", "kind": "document"}] - assert len(state["messages"]) == 2 - - -@pytest.mark.asyncio -async def test_stream_resume_uses_command(): - runner = _make_runner() - - chunks = [ - chunk - async for chunk in runner.stream( - { - "session_id": "s1", - "resume": True, - "input": {"approved": True}, - } - ) - ] - - assert isinstance(runner._agent.last_astream_state, Command) - assert runner._agent.last_astream_state.resume == {"approved": True} - assert isinstance(runner._agent.last_ainvoke_state, Command) - assert runner._agent.last_ainvoke_state.resume == {"approved": True} - 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_ns": "", - "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"}) - - agentengine_metadata = result["metadata"]["agentengine"] - assert agentengine_metadata["framework"] == "langgraph" - assert agentengine_metadata["framework_ref"]["langgraph"] == { - "thread_id": "tenant-a:agent-b:sess-1", - "checkpoint_id": "ckpt-after", - } - assert agentengine_metadata["is_terminal"] is True - assert agentengine_metadata["is_resumable"] is False - - -@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"}) - - agentengine_metadata = result["metadata"]["agentengine"] - assert agentengine_metadata["framework"] == "langgraph" - assert agentengine_metadata["framework_ref"]["langgraph"] == { - "thread_id": "tenant-a:agent-b:sess-async", - "checkpoint_id": "ckpt-async", - } - assert agentengine_metadata["is_terminal"] is True - assert agentengine_metadata["is_resumable"] is False - - -@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_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}, - }, - "metadata": { - "last_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}, - }, - "metadata": { - "last_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() - - 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_ns": "", - "checkpoint_id": "ckpt-456", - } - - -@pytest.mark.asyncio -async def test_stream_checkpoint_resume_prefers_astream_updates_over_events(): - runner = _make_checkpoint_resume_updates_runner() - - chunks = [ - chunk - async for chunk in runner.stream( - { - "session_id": "sess-1", - "checkpoint_resume": True, - "framework_ref": { - "langgraph": { - "checkpoint_id": "ckpt-before", - } - }, - } - ) - ] - - assert runner._agent.last_astream_state is None - assert runner._agent.last_astream_stream_mode == "updates" - assert runner._agent.astream_events_called is False - assert {"type": "final", "output": "resumed via updates"} in chunks - checkpoint = chunks[-1] - assert checkpoint["type"] == "checkpoint" - assert checkpoint["metadata"]["agentengine"]["framework_ref"]["langgraph"]["checkpoint_id"] == "ckpt-after" - assert checkpoint["metadata"]["agentengine"]["next_node"] == "report" - - -@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", - } - ) - ] - - checkpoint = chunks[-1] - assert checkpoint["type"] == "checkpoint" - agentengine_metadata = checkpoint["metadata"]["agentengine"] - assert agentengine_metadata["framework"] == "langgraph" - assert agentengine_metadata["framework_ref"]["langgraph"] == { - "thread_id": "tenant-a:agent-b:sess-1", - "checkpoint_id": "ckpt-stream", - } - assert agentengine_metadata["is_terminal"] is True - assert agentengine_metadata["is_resumable"] is False - - -@pytest.mark.asyncio -async def test_stream_does_not_mix_reasoning_into_final_text(): - runner = _make_streaming_runner() - - chunks = [ - chunk - async for chunk in runner.stream( - { - "session_id": "s1", - "input": "写一个python快排的示例", - } - ) - ] - - 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") - - -@pytest.mark.asyncio -async def test_stream_ignores_content_when_chunk_duplicates_reasoning(): - runner = _make_duplicated_reasoning_streaming_runner() - - chunks = [ - chunk - async for chunk in runner.stream( - { - "session_id": "s1", - "input": "写一个python快排的示例", - } - ) - ] - - assert chunks[:-1] == [ - {"delta": "先分析需求。", "type": "thinking"}, - {"delta": "这是最终回复。", "type": "text"}, - ] - assert chunks[-1] == {"output": "这是最终回复。", "type": "final"} - - -@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[:-1] == [ - {"delta": "先分析需求。", "type": "thinking"}, - {"delta": "这是最终回复。", "type": "text"}, - ] - assert chunks[-1] == {"output": "这是最终回复。", "type": "final"} - - -@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 deleted file mode 100644 index 31373eda..00000000 --- a/tests/test_long_task_pilot_validation.py +++ /dev/null @@ -1,240 +0,0 @@ -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 deleted file mode 100644 index 05788b3e..00000000 --- a/tests/test_mcp_runtime.py +++ /dev/null @@ -1,436 +0,0 @@ -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_registers_mcp_tool_descriptors_for_tool_search(monkeypatch, tmp_path): - import google.adk.runners as adk_runners - - from ksadk.runners.adk_runner import ADKRunner - from ksadk.toolsets import clear_external_tools, tool_search - - 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 FakeMCPTool: - name = "weather_forecast" - description = "Get weather forecast from the weather MCP server." - args = {"city": {"type": "string"}} - - class FakeToolset: - _ksadk_mcp_toolset_key = "https://example.com/mcp|weather" - _ksadk_mcp_server_name = "weather" - - def get_tools_with_prefix(self): - return [FakeMCPTool()] - - clear_external_tools() - 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()]) - - try: - runner = ADKRunner(detection, str(tmp_path)) - runner.load_agent() - result = tool_search("weather forecast", profile="coding", max_results=5) - finally: - clear_external_tools() - - forecast = next(item for item in result["results"] if item["name"] == "weather_forecast") - assert forecast["group"] == "mcp:weather" - assert forecast["boundary"] == "ksadk_managed_mcp_tool" - assert forecast["execution"] == "external" - - -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_model_policy.py b/tests/test_model_policy.py deleted file mode 100644 index 990a63e5..00000000 --- a/tests/test_model_policy.py +++ /dev/null @@ -1,59 +0,0 @@ -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"]["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 - - -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_open_source_audit.py b/tests/test_open_source_audit.py index 90d8d66b..ddc30788 100644 --- a/tests/test_open_source_audit.py +++ b/tests/test_open_source_audit.py @@ -117,6 +117,21 @@ def test_public_repo_audit_allows_curated_root_ai_guidance_files(): assert result.violations == [] +def test_public_repo_audit_allows_curated_environment_reference_doc(): + audit = _load_audit_module() + + result = audit.audit_paths( + "public-repo", + [ + "docs/maintainer-approval-record.md", + "docs/reference/ksadk环境变量参考.md", + ], + ) + + assert result.ok is True + assert result.violations == [] + + def test_wheel_audit_blocks_hosted_ui_bundle_and_zread_snapshot(): audit = _load_audit_module() @@ -333,10 +348,6 @@ def test_content_audit_allows_aicp_internal_endpoints_but_blocks_other_internal_ def test_content_audit_allows_supported_internal_and_registry_paths(tmp_path): audit = _load_audit_module() - (tmp_path / "iam.py").write_text( - 'IAM_INNER = "iam.inner.api.ksyun.com"\n', - encoding="utf-8", - ) (tmp_path / "settings.py").write_text( 'KSPMAS_INTERNAL = "kspmas-internal.sdns.ksyun.com"\n', encoding="utf-8", @@ -361,7 +372,7 @@ def test_content_audit_allows_supported_internal_and_registry_paths(tmp_path): ) result = audit.audit_file_contents( - tmp_path, ["iam.py", "settings.py", "cmd_create.py", "builder.py", "regional.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] == [ diff --git a/tests/test_openai_protocol_e2e.py b/tests/test_openai_protocol_e2e.py deleted file mode 100644 index f56463c3..00000000 --- a/tests/test_openai_protocol_e2e.py +++ /dev/null @@ -1,790 +0,0 @@ -from __future__ import annotations - -import base64 -import asyncio -import importlib -import json -import os -import socket -import shutil -import subprocess -import tempfile -import threading -import time -from contextlib import contextmanager -from pathlib import Path -from types import SimpleNamespace - -import httpx -import pytest -import uvicorn -import websockets - -from ksadk.runners.base_runner import BaseRunner -from ksadk.sessions.base import SessionEvent -from ksadk.sessions.in_memory import InMemorySessionService - - -class _E2ERunner(BaseRunner): - def __init__(self): - super().__init__( - detection_result=SimpleNamespace( - name="demo-agent", - description="demo agent", - type=SimpleNamespace(value="langgraph"), - ), - project_dir=".", - ) - self.calls: list[dict] = [] - self.load_agent_calls = 0 - - def load_agent(self) -> None: - self.load_agent_calls += 1 - - async def invoke(self, input_data: dict) -> dict: - self.calls.append(input_data) - return {"output": "assistant says hi"} - - async def stream(self, input_data: dict): - self.calls.append(input_data) - yield {"type": "final", "output": "assistant says hi"} - - -@contextmanager -def _run_real_http_server(app): - 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: - server.should_exit = True - thread.join(timeout=5) - raise RuntimeError("KsADK E2E server failed to start") - - try: - yield f"http://{host}:{port}" - finally: - server.should_exit = True - thread.join(timeout=5) - - -def _find_chromium_executable() -> str | None: - explicit_path = os.environ.get("KSADK_E2E_CHROMIUM") - if explicit_path and Path(explicit_path).is_file(): - return explicit_path - - candidates: list[Path] = [] - cache_roots = [ - Path.home() / "Library" / "Caches" / "ms-playwright", - Path.home() / ".cache" / "ms-playwright", - ] - for cache_root in cache_roots: - candidates.extend( - cache_root.glob( - "chromium-*/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing" - ) - ) - candidates.extend( - cache_root.glob( - "chromium-*/chrome-mac/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing" - ) - ) - candidates.extend(cache_root.glob("chromium-*/chrome-linux/chrome")) - - for candidate in candidates: - if candidate.is_file(): - return str(candidate) - - for executable_name in ( - "chromium", - "chromium-browser", - "google-chrome", - "google-chrome-stable", - "chrome", - ): - resolved = shutil.which(executable_name) - if resolved: - return resolved - return None - - -def _free_port() -> int: - sock = socket.socket() - sock.bind(("127.0.0.1", 0)) - _, port = sock.getsockname() - sock.close() - return int(port) - - -class _CdpBrowser: - def __init__(self, executable_path: str): - self.executable_path = executable_path - self.port = _free_port() - self._user_data_dir: tempfile.TemporaryDirectory[str] | None = None - self._process: subprocess.Popen | None = None - self._websocket = None - self._next_id = 0 - - async def __aenter__(self): - self._user_data_dir = tempfile.TemporaryDirectory() - self._process = subprocess.Popen( - [ - self.executable_path, - f"--remote-debugging-port={self.port}", - f"--user-data-dir={self._user_data_dir.name}", - "--headless=new", - "--disable-background-networking", - "--disable-dev-shm-usage", - "--disable-gpu", - "--no-first-run", - "--no-default-browser-check", - "--no-sandbox", - "about:blank", - ], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - - version_url = f"http://127.0.0.1:{self.port}/json/version" - deadline = time.time() + 10 - version_payload: dict[str, str] | None = None - async with httpx.AsyncClient(timeout=1, trust_env=False) as client: - while time.time() < deadline: - if self._process.poll() is not None: - raise RuntimeError("Chromium exited before DevTools was ready") - try: - response = await client.get(version_url) - if response.status_code == 200: - version_payload = response.json() - break - except Exception: - await asyncio.sleep(0.05) - if not version_payload or not version_payload.get("webSocketDebuggerUrl"): - raise RuntimeError("Chromium DevTools endpoint did not become ready") - - self._websocket = await websockets.connect( - version_payload["webSocketDebuggerUrl"], - max_size=None, - ) - return self - - async def __aexit__(self, exc_type, exc, tb): - if self._websocket is not None: - await self._websocket.close() - if self._process is not None: - self._process.terminate() - try: - self._process.wait(timeout=5) - except subprocess.TimeoutExpired: - self._process.kill() - self._process.wait(timeout=5) - if self._user_data_dir is not None: - self._user_data_dir.cleanup() - - async def send( - self, - method: str, - params: dict | None = None, - *, - session_id: str | None = None, - ) -> dict: - assert self._websocket is not None - self._next_id += 1 - message: dict[str, object] = {"id": self._next_id, "method": method} - if params is not None: - message["params"] = params - if session_id is not None: - message["sessionId"] = session_id - await self._websocket.send(json.dumps(message)) - - while True: - raw_message = await self._websocket.recv() - payload = json.loads(raw_message) - if payload.get("id") != self._next_id: - continue - if payload.get("error"): - raise RuntimeError(f"CDP {method} failed: {payload['error']}") - return payload.get("result") or {} - - async def new_page(self, url: str) -> "_CdpPage": - target = await self.send("Target.createTarget", {"url": "about:blank"}) - attached = await self.send( - "Target.attachToTarget", - {"targetId": target["targetId"], "flatten": True}, - ) - page = _CdpPage(self, attached["sessionId"]) - await page.enable() - await page.add_script_to_evaluate_on_new_document( - """ - (() => { - const originalFetch = window.fetch.bind(window); - window.__ksadkE2E = { runAgentBodies: [] }; - window.fetch = async (...args) => { - try { - const url = typeof args[0] === 'string' ? args[0] : args[0]?.url; - const init = args[1] || {}; - if (String(url || '').includes('/agentengine/api/v1/RunAgent')) { - window.__ksadkE2E.runAgentBodies.push(JSON.parse(String(init.body || '{}'))); - } - } catch (error) { - window.__ksadkE2E.fetchPatchError = String(error); - } - return originalFetch(...args); - }; - })(); - """ - ) - await page.navigate(url) - return page - - -class _CdpPage: - def __init__(self, browser: _CdpBrowser, session_id: str): - self.browser = browser - self.session_id = session_id - - async def enable(self) -> None: - await self.browser.send("Page.enable", session_id=self.session_id) - await self.browser.send("Runtime.enable", session_id=self.session_id) - - async def add_script_to_evaluate_on_new_document(self, source: str) -> None: - await self.browser.send( - "Page.addScriptToEvaluateOnNewDocument", - {"source": source}, - session_id=self.session_id, - ) - - async def navigate(self, url: str) -> None: - await self.browser.send("Page.navigate", {"url": url}, session_id=self.session_id) - - async def evaluate(self, expression: str, *, await_promise: bool = True): - result = await self.browser.send( - "Runtime.evaluate", - { - "expression": expression, - "awaitPromise": await_promise, - "returnByValue": True, - }, - session_id=self.session_id, - ) - if result.get("exceptionDetails"): - raise AssertionError(result["exceptionDetails"]) - remote_object = result.get("result") or {} - return remote_object.get("value") - - async def wait_for(self, expression: str, *, timeout: float = 10): - deadline = time.time() + timeout - while time.time() < deadline: - value = await self.evaluate(expression) - if value: - return value - await asyncio.sleep(0.1) - raise AssertionError(f"Timed out waiting for browser expression: {expression}") - - -@pytest.fixture -def real_http_runtime(monkeypatch, tmp_path): - server_app_module = importlib.import_module("ksadk.server.app") - - service = InMemorySessionService() - runner = _E2ERunner() - monkeypatch.setenv("AGENTENGINE_UI_DIR", str(tmp_path / ".agentengine" / "ui")) - monkeypatch.delenv("OPENAI_BASE_URL", raising=False) - monkeypatch.delenv("OPENAI_API_BASE", raising=False) - monkeypatch.delenv("OPENAI_API_KEY", raising=False) - monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service) - server_app_module.set_runner(runner) - - with _run_real_http_server(server_app_module.app) as base_url: - yield base_url, runner, service - - -@pytest.mark.asyncio -async def test_real_http_responses_image_and_file_reach_runner_canonical_fields( - real_http_runtime, -): - base_url, runner, _ = real_http_runtime - image_b64 = base64.b64encode(b"\x89PNG\r\n").decode("ascii") - file_text = "候选人简历内容" - file_b64 = base64.b64encode(file_text.encode("utf-8")).decode("ascii") - - async with httpx.AsyncClient(base_url=base_url, timeout=10, trust_env=False) as client: - response = await client.post( - "/v1/responses", - json={ - "input": [ - { - "role": "user", - "content": [ - {"type": "input_text", "text": "请分析图片和附件"}, - { - "type": "input_image", - "image_url": f"data:image/png;base64,{image_b64}", - }, - { - "type": "input_file", - "filename": "resume.txt", - "file_data": file_b64, - }, - ], - } - ], - "stream": False, - }, - ) - - payload = response.json() - assert response.status_code == 200 - assert payload["object"] == "response" - assert payload["output_text"] == "assistant says hi" - assert runner.calls[-1]["input_content"] == [ - {"type": "input_text", "text": "请分析图片和附件"}, - {"type": "input_image", "image_url": f"data:image/png;base64,{image_b64}"}, - { - "type": "input_file", - "filename": "resume.txt", - "file_data": file_b64, - }, - ] - assert runner.calls[-1]["input_messages"] == [ - { - "role": "user", - "content": runner.calls[-1]["input_content"], - } - ] - assert runner.calls[-1]["input_parts"][1] == { - "inlineData": { - "data": image_b64, - "mimeType": "image/png", - "displayName": "uploaded_image", - } - } - assert runner.calls[-1]["current_attachments"][0]["mime_type"] == "image/png" - assert runner.calls[-1]["current_attachment_results"][1]["text"] == file_text - assert runner.calls[-1]["has_current_files"] is True - - -@pytest.mark.asyncio -async def test_real_http_responses_approval_resume_executes_builtin_tool(real_http_runtime): - base_url, runner, service = real_http_runtime - await service.create_session(agent_id="demo-agent", user_id="user", session_id="sess-e2e-approval") - await service.append_event( - "sess-e2e-approval", - SessionEvent( - author="demo-agent", - event_type="approval_request", - content={"role": "model", "parts": [{"text": "confirm write"}]}, - metadata={ - "interrupt_info": { - "approval_request_id": "appr_e2e", - "tool_name": "write_workspace_file", - "arguments": {"path": "e2e.txt", "content": "approved"}, - "run_id": "call_e2e", - "server_label": "ksadk", - } - }, - invocation_id="inv-approval", - ), - ) - - async with httpx.AsyncClient(base_url=base_url, timeout=10, trust_env=False) as client: - response = await client.post( - "/v1/responses", - json={ - "session_id": "sess-e2e-approval", - "input": [ - { - "type": "mcp_approval_response", - "approval_request_id": "appr_e2e", - "approve": True, - } - ], - "stream": False, - }, - ) - - payload = response.json() - assert response.status_code == 200 - assert payload["status"] == "completed" - assert runner.calls[-1]["resume"] is True - assert runner.calls[-1]["input"]["type"] == "function_call_output" - assert runner.calls[-1]["input"]["call_id"] == "call_e2e" - output = runner.calls[-1]["input"]["output"] - assert output["ok"] is True - assert Path(output["absolute_path"]).read_text(encoding="utf-8") == "approved" - - -@pytest.mark.asyncio -async def test_real_http_chat_completions_keeps_chat_response_and_converts_image_block( - real_http_runtime, -): - base_url, runner, _ = real_http_runtime - image_url = "data:image/png;base64,aW1hZ2U=" - - async with httpx.AsyncClient(base_url=base_url, timeout=10, trust_env=False) as client: - response = await client.post( - "/v1/chat/completions", - json={ - "model": "gpt-4o", - "messages": [ - { - "role": "user", - "content": [ - {"type": "text", "text": "看图"}, - {"type": "image_url", "image_url": {"url": image_url}}, - ], - } - ], - "stream": False, - }, - ) - - payload = response.json() - assert response.status_code == 200 - assert payload["object"] == "chat.completion" - assert payload["choices"][0]["message"]["role"] == "assistant" - assert runner.calls[-1]["input_content"] == [ - {"type": "input_text", "text": "看图"}, - {"type": "input_image", "image_url": image_url}, - ] - assert runner.calls[-1]["input_messages"] == [ - { - "role": "user", - "content": [ - {"type": "input_text", "text": "看图"}, - {"type": "input_image", "image_url": image_url}, - ], - } - ] - - -@pytest.mark.asyncio -async def test_real_http_run_agent_uses_responses_input_and_uploaded_file_reference( - real_http_runtime, -): - base_url, runner, service = real_http_runtime - attachment_bytes = "真实上传文件内容".encode("utf-8") - - async with httpx.AsyncClient(base_url=base_url, timeout=10, trust_env=False) as client: - upload_response = await client.post( - "/agentengine/api/v1/UploadFile", - files={"file": ("report.txt", attachment_bytes, "text/plain")}, - ) - uploaded = upload_response.json()["Data"]["FileData"] - response = await client.post( - "/agentengine/api/v1/RunAgent", - json={ - "AgentId": "demo-agent", - "ApiFormat": "responses", - "Messages": [{"role": "user", "content": "SHOULD_NOT_USE"}], - "ResponsesInput": [ - { - "role": "user", - "content": [ - {"type": "input_text", "text": "请总结上传文件"}, - { - "type": "input_file", - "filename": uploaded["displayName"], - "file_url": uploaded["fileUri"], - }, - ], - } - ], - "Stream": False, - }, - ) - - payload = response.json() - assert upload_response.status_code == 200 - assert response.status_code == 200 - assert payload["Data"]["object"] == "response" - assert "SHOULD_NOT_USE" not in runner.calls[-1]["input"] - assert "真实上传文件内容" in runner.calls[-1]["input"] - assert runner.calls[-1]["input_content"] == [ - {"type": "input_text", "text": "请总结上传文件"}, - { - "type": "input_file", - "filename": "report.txt", - "file_url": uploaded["fileUri"], - }, - ] - assert runner.calls[-1]["current_attachments"][0]["file_uri"] == uploaded["fileUri"] - assert runner.calls[-1]["current_attachment_results"][0]["text"] == "真实上传文件内容" - - session_id = payload["Data"]["session_id"] - events = await service.get_events(session_id) - assert events[0].content["parts"] == [ - {"type": "input_text", "text": "请总结上传文件"}, - { - "type": "input_file", - "filename": "report.txt", - "file_url": uploaded["fileUri"], - }, - ] - assert events[0].metadata["attachments"] == [ - { - "display_name": "report.txt", - "file_uri": uploaded["fileUri"], - "is_text": True, - "mime_type": "text/plain", - "size_bytes": len(attachment_bytes), - "transport": "reference", - } - ] - assert events[0].metadata["attachment_results"][0]["text_excerpt"] == "真实上传文件内容" - - -@pytest.mark.asyncio -async def test_real_http_run_agent_accepts_responses_input_without_legacy_messages( - real_http_runtime, -): - base_url, runner, _ = real_http_runtime - responses_input = [ - { - "role": "user", - "content": [{"type": "input_text", "text": "只使用 ResponsesInput"}], - } - ] - - async with httpx.AsyncClient(base_url=base_url, timeout=10, trust_env=False) as client: - response = await client.post( - "/agentengine/api/v1/RunAgent", - json={ - "AgentId": "demo-agent", - "ApiFormat": "responses", - "ResponsesInput": responses_input, - "Stream": False, - }, - ) - - payload = response.json() - assert response.status_code == 200 - assert payload["Data"]["object"] == "response" - assert runner.calls[-1]["input_content"] == responses_input[0]["content"] - assert runner.calls[-1]["input_messages"] == responses_input - - -@pytest.mark.asyncio -async def test_real_http_run_agent_uses_user_id_for_responses_runtime_trace( - real_http_runtime, -): - base_url, runner, service = real_http_runtime - - async with httpx.AsyncClient(base_url=base_url, timeout=10, trust_env=False) as client: - response = await client.post( - "/agentengine/api/v1/RunAgent", - json={ - "AgentId": "demo-agent", - "UserId": "ui-user-1", - "ApiFormat": "responses", - "Messages": [{"role": "user", "content": "hello"}], - "ResponsesInput": [ - { - "role": "user", - "content": [{"type": "input_text", "text": "hello"}], - } - ], - "Stream": False, - }, - ) - - payload = response.json() - assert response.status_code == 200 - session_id = payload["Data"]["session_id"] - session = await service.get_session(session_id) - assert session is not None - assert session.user_id == "ui-user-1" - assert runner.calls[-1]["platform_context"]["user_id"] == "ui-user-1" - - -@pytest.mark.asyncio -async def test_real_http_static_ui_bundle_contains_responses_input_payload_builder( - real_http_runtime, -): - base_url, _, _ = real_http_runtime - - async with httpx.AsyncClient(base_url=base_url, timeout=10, trust_env=False) as client: - root_response = await client.get("/") - marker = 'src="./assets/' - start = root_response.text.index(marker) + len('src=".') - end = root_response.text.index('"', start) - asset_path = root_response.text[start:end] - asset_response = await client.get(asset_path) - - assert root_response.status_code == 200 - assert asset_response.status_code == 200 - assert "ResponsesInput" in asset_response.text - assert "file_url" in asset_response.text - - -@pytest.mark.asyncio -async def test_real_browser_hosted_ui_file_upload_sends_responses_input_to_runner( - real_http_runtime, -): - chromium = _find_chromium_executable() - if not chromium: - pytest.skip("Chromium is required for the real Hosted UI browser E2E test") - - base_url, runner, _ = real_http_runtime - - async with _CdpBrowser(chromium) as browser: - page = await browser.new_page(f"{base_url}/chat") - await page.wait_for( - """ - Boolean( - document.querySelector('textarea') && - document.querySelector('input[type="file"]') && - document.querySelector('button[type="submit"]') - ) - """ - ) - await page.evaluate( - """ - (async () => { - const fileInput = document.querySelector('input[type="file"]'); - const textarea = document.querySelector('textarea'); - const file = new File( - [new TextEncoder().encode('真实浏览器上传内容')], - 'browser-report.txt', - { type: 'text/plain' } - ); - const transfer = new DataTransfer(); - transfer.items.add(file); - Object.defineProperty(fileInput, 'files', { - value: transfer.files, - configurable: true, - }); - fileInput.dispatchEvent(new Event('change', { bubbles: true })); - await new Promise((resolve) => setTimeout(resolve, 100)); - - const valueSetter = Object.getOwnPropertyDescriptor( - HTMLTextAreaElement.prototype, - 'value' - ).set; - valueSetter.call(textarea, '请总结浏览器附件'); - textarea.dispatchEvent(new Event('input', { bubbles: true })); - await new Promise((resolve) => setTimeout(resolve, 50)); - textarea.form.requestSubmit(); - return true; - })() - """ - ) - - deadline = time.time() + 10 - while time.time() < deadline and not runner.calls: - await asyncio.sleep(0.1) - assert runner.calls, "Hosted UI did not reach RunAgent/runner" - run_agent_bodies = await page.wait_for( - "window.__ksadkE2E?.runAgentBodies?.length && window.__ksadkE2E.runAgentBodies", - ) - - run_agent_body = run_agent_bodies[0] - assert run_agent_body["ApiFormat"] == "responses" - assert run_agent_body["ResponsesInput"][0]["content"][0] == { - "type": "input_text", - "text": "请总结浏览器附件", - } - uploaded_part = run_agent_body["ResponsesInput"][0]["content"][1] - assert uploaded_part["type"] == "input_file" - assert uploaded_part["filename"] == "browser-report.txt" - assert uploaded_part["file_url"].startswith("ksadk-upload://") - assert run_agent_body["Messages"] == run_agent_body["ResponsesInput"] - - runner_payload = runner.calls[-1] - assert runner_payload["input_content"] == run_agent_body["ResponsesInput"][0]["content"] - assert runner_payload["current_attachments"][0]["display_name"] == "browser-report.txt" - assert runner_payload["current_attachment_results"][0]["text"] == "真实浏览器上传内容" - assert runner_payload["has_current_files"] is True - - -@pytest.mark.asyncio -async def test_real_browser_hosted_ui_image_upload_sends_input_image_to_runner( - real_http_runtime, -): - chromium = _find_chromium_executable() - if not chromium: - pytest.skip("Chromium is required for the real Hosted UI browser E2E test") - - base_url, runner, _ = real_http_runtime - - async with _CdpBrowser(chromium) as browser: - page = await browser.new_page(f"{base_url}/chat") - await page.wait_for( - """ - Boolean( - document.querySelector('textarea') && - document.querySelector('input[type="file"]') && - document.querySelector('button[type="submit"]') - ) - """ - ) - await page.evaluate( - """ - (async () => { - const fileInput = document.querySelector('input[type="file"]'); - const textarea = document.querySelector('textarea'); - const file = new File( - [new Uint8Array([0x89, 0x50, 0x4e, 0x47])], - 'browser-image.png', - { type: 'image/png' } - ); - const transfer = new DataTransfer(); - transfer.items.add(file); - Object.defineProperty(fileInput, 'files', { - value: transfer.files, - configurable: true, - }); - fileInput.dispatchEvent(new Event('change', { bubbles: true })); - await new Promise((resolve) => setTimeout(resolve, 100)); - - const valueSetter = Object.getOwnPropertyDescriptor( - HTMLTextAreaElement.prototype, - 'value' - ).set; - valueSetter.call(textarea, '请看看这张图'); - textarea.dispatchEvent(new Event('input', { bubbles: true })); - await new Promise((resolve) => setTimeout(resolve, 50)); - textarea.form.requestSubmit(); - return true; - })() - """ - ) - - deadline = time.time() + 10 - while time.time() < deadline and not runner.calls: - await asyncio.sleep(0.1) - assert runner.calls, "Hosted UI did not reach RunAgent/runner" - run_agent_bodies = await page.wait_for( - "window.__ksadkE2E?.runAgentBodies?.length && window.__ksadkE2E.runAgentBodies", - ) - - run_agent_body = run_agent_bodies[0] - assert run_agent_body["ApiFormat"] == "responses" - assert run_agent_body["ResponsesInput"][0]["content"][0] == { - "type": "input_text", - "text": "请看看这张图", - } - image_part = run_agent_body["ResponsesInput"][0]["content"][1] - assert image_part["type"] == "input_image" - assert image_part["image_url"].startswith("data:image/png;base64,") - assert "UploadFile" not in [ - body.get("Action") - for body in run_agent_bodies - if isinstance(body, dict) - ] - - runner_payload = runner.calls[-1] - assert runner_payload["input_content"] == run_agent_body["ResponsesInput"][0]["content"] - assert runner_payload["current_attachments"][0]["display_name"] == "uploaded_image" - assert runner_payload["current_attachments"][0]["mime_type"] == "image/png" - assert runner_payload["has_current_files"] is True diff --git a/tests/test_openclaw_env_vars.py b/tests/test_openclaw_env_vars.py deleted file mode 100644 index 169872da..00000000 --- a/tests/test_openclaw_env_vars.py +++ /dev/null @@ -1,509 +0,0 @@ -import asyncio -import re -import json -import pytest - -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) - 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 {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 - - -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_openclaw_gateway.py b/tests/test_openclaw_gateway.py deleted file mode 100644 index 4589e689..00000000 --- a/tests/test_openclaw_gateway.py +++ /dev/null @@ -1,127 +0,0 @@ -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 deleted file mode 100644 index e58fe1f1..00000000 --- a/tests/test_orchestration_agents.py +++ /dev/null @@ -1,327 +0,0 @@ -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_patch_langchain.py b/tests/test_patch_langchain.py deleted file mode 100644 index 177e07f1..00000000 --- a/tests/test_patch_langchain.py +++ /dev/null @@ -1,93 +0,0 @@ -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 payload["extra_body"]["enable_thinking"] is False - assert payload["extra_body"]["chat_template_kwargs"]["enable_thinking"] is False - 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 - assert payload["extra_body"]["enable_thinking"] is False - assert payload["extra_body"]["chat_template_kwargs"]["enable_thinking"] is False - - -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_platform_memory_tools.py b/tests/test_platform_memory_tools.py deleted file mode 100644 index beee3df0..00000000 --- a/tests/test_platform_memory_tools.py +++ /dev/null @@ -1,142 +0,0 @@ -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 deleted file mode 100644 index d376136e..00000000 --- a/tests/test_postgres_session_service.py +++ /dev/null @@ -1,224 +0,0 @@ -from __future__ import annotations - -import os -import sys -from types import SimpleNamespace - -import pytest - -from ksadk.sessions.errors import SessionBackendUnavailable -from ksadk.sessions.base import SessionEvent - -pytestmark = pytest.mark.asyncio - - -async def test_postgres_session_service_uses_configured_connect_timeout(monkeypatch): - from ksadk.sessions.postgres_service import PostgresSessionService - - observed: dict[str, object] = {} - - async def fake_create_pool(**kwargs): - observed.update(kwargs) - raise TimeoutError("connect timed out") - - monkeypatch.setitem(sys.modules, "asyncpg", SimpleNamespace(create_pool=fake_create_pool)) - - service = PostgresSessionService( - dsn="postgresql://ksadk:secret@db.example.test:5432/session", - connect_timeout=0.25, - ) - - with pytest.raises(SessionBackendUnavailable) as exc_info: - await service.create_session("demo-agent", "user-1") - - assert observed["timeout"] == 0.25 - assert "Postgres session backend unavailable" in str(exc_info.value) - assert "secret" not in str(exc_info.value) - - -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_get_events_filters_by_after_seq_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 - - namespace = "pytest_after_seq" - service = PostgresSessionService(dsn=dsn, namespace=namespace) - session_id = "pytest-sess-after-seq" - - try: - await service.delete_session(session_id) - await service.create_session( - agent_id="demo-agent", - user_id="user-1", - session_id=session_id, - ) - for index in range(4): - await service.append_event( - session_id, - SessionEvent( - id=f"pytest-evt-after-{index + 1}", - author="user", - event_type="text", - content={"index": index}, - ), - ) - - all_events = await service.get_events(session_id) - assert [event.seq_id for event in all_events] == [1, 2, 3, 4] - - after2 = await service.get_events(session_id, after_seq_id=2) - assert [event.seq_id for event in after2] == [3, 4] - - after0 = await service.get_events(session_id, after_seq_id=0) - assert [event.seq_id for event in after0] == [1, 2, 3, 4] - - after_max = await service.get_events(session_id, after_seq_id=4) - assert [event.seq_id for event in after_max] == [] - - after_limit = await service.get_events(session_id, after_seq_id=2, limit=1) - assert [event.seq_id for event in after_limit] == [4] - finally: - await service.delete_session(session_id) - await service.aclose() - - -async def test_postgres_session_service_get_events_filters_by_before_seq_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 - - namespace = "pytest_before_seq" - service = PostgresSessionService(dsn=dsn, namespace=namespace) - session_id = "pytest-sess-before-seq" - - try: - await service.delete_session(session_id) - await service.create_session( - agent_id="demo-agent", - user_id="user-1", - session_id=session_id, - ) - for index in range(5): - await service.append_event( - session_id, - SessionEvent( - id=f"pytest-evt-before-{index + 1}", - author="user", - event_type="text", - content={"index": index}, - ), - ) - - before4 = await service.get_events(session_id, before_seq_id=4) - assert [event.seq_id for event in before4] == [1, 2, 3] - assert await service.count_events(session_id, before_seq_id=4) == 3 - - before4_limit = await service.get_events(session_id, before_seq_id=4, limit=2) - assert [event.seq_id for event in before4_limit] == [2, 3] - - before1 = await service.get_events(session_id, before_seq_id=1) - assert before1 == [] - finally: - await service.delete_session(session_id) - await service.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_public_release_positioning.py b/tests/test_public_release_positioning.py index 0265ed80..074b2d7d 100644 --- a/tests/test_public_release_positioning.py +++ b/tests/test_public_release_positioning.py @@ -1,17 +1,38 @@ from __future__ import annotations from pathlib import Path +import re import subprocess import tomllib +from urllib.parse import urlparse ROOT = Path(__file__).resolve().parents[1] +DOCS_ROOT_URL = "https://kingsoftcloud.github.io/ksadk-python/" +ZH_DOC_URLS = { + f"{DOCS_ROOT_URL}cn/docs/framework/getting-started/quickstart/", + f"{DOCS_ROOT_URL}cn/docs/framework/getting-started/why-ksadk/", + f"{DOCS_ROOT_URL}cn/docs/framework/getting-started/architecture/", + f"{DOCS_ROOT_URL}cn/docs/framework/getting-started/comparison/", + f"{DOCS_ROOT_URL}cn/docs/framework/guides/observability-tracing/", +} +EN_DOC_URLS = { + f"{DOCS_ROOT_URL}en/docs/framework/getting-started/quickstart/", + f"{DOCS_ROOT_URL}en/docs/framework/getting-started/why-ksadk/", + f"{DOCS_ROOT_URL}en/docs/framework/getting-started/architecture/", + f"{DOCS_ROOT_URL}en/docs/framework/getting-started/comparison/", + f"{DOCS_ROOT_URL}en/docs/framework/guides/observability-tracing/", +} def _read(relative_path: str) -> str: return (ROOT / relative_path).read_text(encoding="utf-8") +def _github_pages_urls(markdown: str) -> set[str]: + return set(re.findall(r"https://kingsoftcloud\.github\.io/ksadk-python/[^>\s)\"]*", markdown)) + + def test_public_readme_positions_ksadk_as_runtime_platform(): readme = _read("README.md") for expected in ( @@ -41,10 +62,11 @@ def test_public_readme_positions_ksadk_as_runtime_platform(): def test_public_readme_language_variants_keep_homepage_shape(): + root_readme = _read("README.md") zh_readme = _read("README.zh-CN.md") en_readme = _read("README.en.md") - for text in (zh_readme, en_readme): + for text in (root_readme, zh_readme, en_readme): assert "Kingsoft Cloud Agent Development Kit" in text assert "ksadk-runtime-platform-hero-wide.png" in text assert "ksadk-web-ui-screenshot.png" in text @@ -53,6 +75,45 @@ def test_public_readme_language_variants_keep_homepage_shape(): assert "发布版本:" not in text assert "## 0.6." not in text + assert _github_pages_urls(root_readme) == {DOCS_ROOT_URL, *ZH_DOC_URLS} + assert _github_pages_urls(zh_readme) == {DOCS_ROOT_URL, *ZH_DOC_URLS} + assert _github_pages_urls(en_readme) == {DOCS_ROOT_URL, *EN_DOC_URLS} + for stale_path in ( + "ksadk-python/getting-started/quickstart/", + "ksadk-python/guides/observability-tracing/", + "ksadk-python/en/getting-started/quickstart/", + "ksadk-python/en/guides/observability-tracing/", + "public-docs/assets/", + ): + assert stale_path not in root_readme + assert stale_path not in zh_readme + assert stale_path not in en_readme + + +def test_public_readme_docs_links_match_fumadocs_routes(): + docs_site = ROOT / "docs-site" / "content" / "docs" + checks = { + "README.md": "cn", + "README.zh-CN.md": "cn", + "README.en.md": "en", + } + + for readme_path, expected_locale in checks.items(): + text = _read(readme_path) + urls = _github_pages_urls(text) + docs_urls = [url for url in urls if "/docs/" in url] + assert docs_urls, f"{readme_path} should link to Fumadocs pages" + for url in docs_urls: + path = urlparse(url).path.removeprefix("/ksadk-python/").strip("/") + parts = path.split("/") + assert parts[0] == expected_locale + assert parts[1] == "docs" + doc_segments = parts[2:] + suffix = ".en.mdx" if expected_locale == "en" else ".mdx" + candidate = docs_site.joinpath(*doc_segments).with_suffix(suffix) + index_candidate = docs_site.joinpath(*doc_segments, f"index{suffix}") + assert candidate.exists() or index_candidate.exists(), url + def test_public_metadata_uses_runtime_platform_positioning(): pyproject = tomllib.loads(_read("pyproject.toml")) @@ -92,13 +153,16 @@ def test_pypi_publish_workflow_uses_trusted_publishing_and_bundles_ksadk_web(): assert "release:" in workflow assert "- published" in workflow assert "workflow_dispatch:" in workflow - assert 'default: "0.2.16"' in workflow - assert "KSADK_WEB_VERSION: ${{ github.event.inputs.ksadk_web_version || '0.2.16' }}" in workflow + assert 'default: "0.2.18"' in workflow + assert "approved_source_commit:" in workflow + assert "Reviewed source commit SHA recorded in docs/maintainer-approval-record.md" in workflow + assert "KSADK_WEB_VERSION: ${{ github.event.inputs.ksadk_web_version || '0.2.18' }}" in workflow + assert "KSADK_APPROVED_SOURCE_COMMIT: ${{ github.event.inputs.approved_source_commit || vars.KSADK_APPROVED_SOURCE_COMMIT }}" in workflow assert "make sync-ksadk-web-static" in workflow assert "make public-preflight" in workflow assert "make public-publish-gate" in workflow assert "make open-source-audit-dist" in ci_workflow - assert 'KSADK_WEB_VERSION: "0.2.16"' in ci_workflow + assert 'KSADK_WEB_VERSION: "0.2.18"' in ci_workflow assert "PUBLIC_KSADK_WEB_VERSION" not in ci_workflow assert "KSADK_WEB_VERSION ?= latest" in makefile assert "PUBLIC_TEST_TARGETS ?= tests/test_public_release_positioning.py tests/test_config_env_registry.py" in makefile @@ -108,8 +172,11 @@ def test_pypi_publish_workflow_uses_trusted_publishing_and_bundles_ksadk_web(): assert "public-release-approval-check:" in makefile assert "public-publish-gate: public-release-approval-check" in makefile assert "scripts/check_approval_record.py" in makefile + assert '--expected-current-commit "$${KSADK_APPROVED_SOURCE_COMMIT:-}"' not in makefile + assert "KSADK_APPROVED_SOURCE_COMMIT is required" in makefile assert "public-build-check: clean-dist sync-ksadk-web-static" in makefile - assert "public-preflight: public-version-gate public-audit sync-ksadk-web-static public-test public-docs-build public-build-check" in makefile + assert "public-preflight: public-version-gate public-audit sync-ksadk-web-static public-test docs-site-build public-build-check" in makefile + assert "NEXT_PUBLIC_BASE_PATH=/ksadk-python pnpm build:static" in makefile assert "PYPI_API_TOKEN" not in workflow assert "password:" not in workflow @@ -142,13 +209,16 @@ def test_public_release_approval_template_tracks_current_version(): def test_source_repository_does_not_track_generated_ksadk_web_static(): gitignore = _read(".gitignore") pyproject = _read("pyproject.toml") - web_ui_files = subprocess.run( - ["git", "ls-files", "ksadk/server/web-ui/**"], - cwd=ROOT, - check=True, - text=True, - stdout=subprocess.PIPE, - ).stdout + if (ROOT / ".git").exists(): + web_ui_files = subprocess.run( + ["git", "ls-files", "ksadk/server/web-ui/**"], + cwd=ROOT, + check=True, + text=True, + stdout=subprocess.PIPE, + ).stdout + else: + web_ui_files = "\n".join(str(path.relative_to(ROOT)) for path in (ROOT / "ksadk/server/web-ui").glob("**/*") if path.is_file()) assert "ksadk/server/static/**" in gitignore assert "ksadk/server/web-ui/" in gitignore diff --git a/tests/test_remote_runner.py b/tests/test_remote_runner.py deleted file mode 100644 index 021b7823..00000000 --- a/tests/test_remote_runner.py +++ /dev/null @@ -1,643 +0,0 @@ -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_payload_injects_builtin_tool_schemas(monkeypatch): - import httpx - - _FakeAsyncClient.calls = [] - monkeypatch.setenv("KSADK_BUILTIN_TOOLS_MODE", "deferred") - monkeypatch.setenv("KSADK_BUILTIN_TOOLS_PROFILE", "coding") - monkeypatch.setattr(httpx, "AsyncClient", _FakeAsyncClient) - runner = RemoteRunner(endpoint="https://agent.example.com", api_format="responses") - - await runner.invoke({"input": "hi"}) - - tools = _FakeAsyncClient.calls[0]["json"]["tools"] - names = [tool["name"] for tool in tools] - assert names == ["tool_search", "tool_dispatcher"] - assert tools[0]["type"] == "function" - assert tools[0]["parameters"]["type"] == "object" - - -@pytest.mark.asyncio -async def test_remote_runner_responses_payload_injects_deferred_direct_tool_schemas(monkeypatch): - import httpx - - _FakeAsyncClient.calls = [] - monkeypatch.setenv("KSADK_BUILTIN_TOOLS_MODE", "deferred") - monkeypatch.setattr(httpx, "AsyncClient", _FakeAsyncClient) - runner = RemoteRunner(endpoint="https://agent.example.com", api_format="responses") - - await runner.invoke( - { - "input": "now edit the file", - "deferred_tool_names": ["read_workspace_file", "edit_workspace_file"], - } - ) - - names = [tool["name"] for tool in _FakeAsyncClient.calls[0]["json"]["tools"]] - assert names == ["tool_search", "tool_dispatcher", "read_workspace_file", "edit_workspace_file"] - - -@pytest.mark.asyncio -async def test_remote_runner_responses_does_not_inject_external_deferred_tools(monkeypatch): - import httpx - - from ksadk.toolsets import clear_external_tools, register_external_tools - - class WeatherForecastTool: - name = "weather_forecast" - description = "Get weather forecast from an MCP server." - args = {"city": {"type": "string"}} - - _FakeAsyncClient.calls = [] - monkeypatch.setenv("KSADK_BUILTIN_TOOLS_MODE", "deferred") - monkeypatch.setattr(httpx, "AsyncClient", _FakeAsyncClient) - clear_external_tools() - try: - register_external_tools([WeatherForecastTool()], group="mcp:weather") - runner = RemoteRunner(endpoint="https://agent.example.com", api_format="responses") - - await runner.invoke( - { - "input": "now check weather", - "deferred_tool_names": ["weather_forecast"], - } - ) - finally: - clear_external_tools() - - names = [tool["name"] for tool in _FakeAsyncClient.calls[0]["json"]["tools"]] - assert names == ["tool_search", "tool_dispatcher"] - - -@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 - - _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"}] - - -@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 - - 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_resource_output_snapshots.py b/tests/test_resource_output_snapshots.py deleted file mode 100644 index bc9c4da0..00000000 --- a/tests/test_resource_output_snapshots.py +++ /dev/null @@ -1,231 +0,0 @@ -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 deleted file mode 100644 index 08100e7f..00000000 --- a/tests/test_runner.py +++ /dev/null @@ -1,1051 +0,0 @@ -"""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_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_base_runner_default_runtime_capabilities_are_explicitly_unsupported(): - detection = _write_detection(FrameworkType.LANGCHAIN) - runner = _StubRunner(detection, "/workspace/demo") - - capabilities = runner.get_runtime_capabilities() - - assert capabilities["Framework"] == "langchain" - assert capabilities["CancelRun"]["Supported"] is False - assert capabilities["CancelRun"]["RequestResults"] == ["unsupported"] - assert capabilities["Checkpoint"]["Supported"] is False - assert capabilities["Checkpoint"]["Backend"] == "none" - assert capabilities["Checkpoint"]["Durable"] is False - assert capabilities["ResumeRun"]["Supported"] is False - assert capabilities["ResumeRun"]["ResumeMode"] == "none" - assert capabilities["SessionContinuity"]["Supported"] is True - assert capabilities["SessionContinuity"]["Type"] == "semantic_replay" - - -def test_base_runner_runtime_capabilities_detect_cancel_override(): - class _CancellableRunner(_StubRunner): - def request_cancel(self, invocation_id: str) -> str: - return "accepted" - - runner = _CancellableRunner(_write_detection(FrameworkType.LANGCHAIN), "/workspace/demo") - - capabilities = runner.get_runtime_capabilities() - - assert capabilities["CancelRun"]["Supported"] is True - assert capabilities["CancelRun"]["RequestResults"] == ["accepted", "not_found", "unsupported"] - - -def test_langgraph_runner_checkpoint_ref_extracts_next_node(): - from ksadk.runners.langgraph_runner import LangGraphRunner - - detection = _write_detection(FrameworkType.LANGGRAPH) - runner = LangGraphRunner(detection, "/workspace/demo") - state = SimpleNamespace( - config={ - "configurable": { - "thread_id": "sess-1", - "checkpoint_ns": "ns", - "checkpoint_id": "ckpt-1", - } - }, - next=("fetch_sources",), - ) - - framework_ref = runner._checkpoint_ref_from_state(state) - - assert framework_ref["langgraph"]["thread_id"] == "sess-1" - assert framework_ref["langgraph"]["checkpoint_id"] == "ckpt-1" - assert framework_ref["langgraph"]["checkpoint_ns"] == "ns" - assert framework_ref["langgraph"]["next_node"] == "fetch_sources" - assert framework_ref["langgraph"]["next_nodes"] == ["fetch_sources"] - - -def test_adk_runner_declares_native_session_continuity_without_checkpoint_resume(tmp_path): - from ksadk.runners.adk_runner import ADKRunner - - runner = ADKRunner(_write_detection(FrameworkType.ADK), str(tmp_path)) - runner._short_term_memory = object() - - capabilities = runner.get_runtime_capabilities() - - assert capabilities["Framework"] == "adk" - assert capabilities["SessionContinuity"]["Supported"] is True - assert capabilities["SessionContinuity"]["Type"] == "native_session" - assert capabilities["Checkpoint"]["Supported"] is False - assert capabilities["ResumeRun"]["Supported"] is False - assert capabilities["ResumeRun"]["ResumeMode"] == "forward_only" - assert "ADK native session" in capabilities["ResumeRun"]["Reason"] - - -def test_langgraph_runner_declares_time_travel_resume_mode(monkeypatch): - from ksadk.runners.langgraph_runner import LangGraphRunner - - detection = _write_detection(FrameworkType.LANGGRAPH) - runner = LangGraphRunner(detection, "/workspace/demo") - runner._agent = SimpleNamespace(checkpointer=object()) - monkeypatch.setenv("KSADK_CHECKPOINT_BACKEND", "postgres") - - capabilities = runner.get_runtime_capabilities() - - assert capabilities["Checkpoint"]["Supported"] is True - assert capabilities["Checkpoint"]["Backend"] == "postgres" - assert capabilities["ResumeRun"]["Supported"] is True - assert capabilities["ResumeRun"]["ResumeMode"] == "time_travel" - assert capabilities["ResumeRun"]["Reason"] == "" - - -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_injects_builtin_tools_when_enabled( - 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_BUILTIN_TOOLS_MODE", "deferred") - monkeypatch.setenv("KSADK_BUILTIN_TOOLS_PROFILE", "coding") - 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 == ["tool_search", "tool_dispatcher"] - assert "execute_bash" not in tool_names - assert "execute_python" not in tool_names - - -def test_adk_runner_injects_deferred_direct_tools_for_request(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_BUILTIN_TOOLS_MODE", "deferred") - 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() - runner.inject_deferred_tools_for_request(["read_workspace_file", "edit_workspace_file"]) - - assert _tool_names(runner._agent.tools) == [ - "tool_search", - "tool_dispatcher", - "read_workspace_file", - "edit_workspace_file", - ] - - -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, 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 / ".agentengine" / "ui" / "files" / "abc123.zip" - archive_path.parent.mkdir(parents=True, exist_ok=True) - 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, - } - - -@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" - # 累积后空 input_token_details 不保留(无意义),output_token_details 有值保留 - assert result["usage"] == { - "input_tokens": 12, - "output_tokens": 5, - "total_tokens": 17, - "output_token_details": {"reasoning": 2}, - } - # last_usage = 最后一次调用快照(单 event 时 = usage 自身) - assert result["metadata"]["last_usage"]["input_tokens"] == 12 - - -@pytest.mark.asyncio -async def test_adk_runner_invoke_accumulates_usage_across_events(tmp_path, monkeypatch): - """多 event(agent loop 多次 LLM 调用)usage 累加,last_usage = 末个 event。""" - 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 - # 两次 LLM 调用(tool loop):第一次 input=4000,第二次 input=5000(含历史) - yield SimpleNamespace( - usage_metadata={"input_tokens": 4000, "output_tokens": 100, "total_tokens": 4100}, - content=SimpleNamespace(parts=[]), - ) - yield SimpleNamespace( - usage_metadata={"input_tokens": 5000, "output_tokens": 800, "total_tokens": 5800, - "input_token_details": {"cached": 4500}}, - content=SimpleNamespace(parts=[types.Part(text="final")]), - ) - - async def _fake_ensure_session(external_session_id=None): - return "adk-session-accum" - - 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"}) - - # 累积值:input/output/total 相加,details 逐键求和 - assert result["usage"]["input_tokens"] == 9000 - assert result["usage"]["output_tokens"] == 900 - assert result["usage"]["total_tokens"] == 9900 - assert result["usage"]["input_token_details"]["cached"] == 4500 - # last_usage = 最后一次调用(窗口占用 = 末次 input) - assert result["metadata"]["last_usage"]["input_tokens"] == 5000 - assert result["metadata"]["last_usage"]["input_token_details"]["cached"] == 4500 - - -@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"})] - - final = chunks[-1] - assert final["output"] == "hello" - assert final["type"] == "final" - assert final["usage"] == { - "input_tokens": 12, - "output_tokens": 5, - "total_tokens": 17, - "input_token_details": {"cached": 4, "tool_use": 3}, - "output_token_details": {"reasoning": 2}, - } - # last_usage = 最后一次调用快照 - assert final["metadata"]["last_usage"]["input_tokens"] == 12 - assert final["metadata"]["last_usage"]["input_token_details"]["cached"] == 4 diff --git a/tests/test_runner_langfuse_callbacks.py b/tests/test_runner_langfuse_callbacks.py deleted file mode 100644 index ad6b9a64..00000000 --- a/tests/test_runner_langfuse_callbacks.py +++ /dev/null @@ -1,114 +0,0 @@ -import importlib -import sys -import types - -import pytest - - -@pytest.fixture(autouse=True) -def _isolate_langfuse_callback_env(monkeypatch): - for key in ( - "CLOUD_MONITOR_LANGFUSE_ENABLED", - "CLOUD_MONITOR_LANGFUSE_HOST", - "CLOUD_MONITOR_LANGFUSE_PUBLIC_KEY", - "CLOUD_MONITOR_LANGFUSE_SECRET_KEY", - "CLOUD_MONITOR_OTLP_ENDPOINT", - "LANGFUSE_BASE_URL", - "LANGFUSE_HOST", - "LANGFUSE_PUBLIC_KEY", - "LANGFUSE_SECRET_KEY", - "LANGFUSE_USE_CALLBACK", - "OTEL_SERVICE_NAME", - ): - monkeypatch.delenv(key, raising=False) - - -class _FakeLangfuse: - instances = [] - - def __init__(self, **kwargs): - self.kwargs = kwargs - self.__class__.instances.append(kwargs) - - -class _FakeCallbackHandler: - instances = [] - - def __init__(self, *, public_key=None, trace_context=None): - self.public_key = public_key - self.trace_context = trace_context - self.__class__.instances.append(self) - - -def _install_fake_langfuse(monkeypatch): - _FakeLangfuse.instances.clear() - _FakeCallbackHandler.instances.clear() - monkeypatch.setitem( - sys.modules, - "langfuse", - types.SimpleNamespace(Langfuse=_FakeLangfuse), - ) - monkeypatch.setitem( - sys.modules, - "langfuse.langchain", - types.SimpleNamespace(CallbackHandler=_FakeCallbackHandler), - ) - - -def _reload_module(monkeypatch): - module = importlib.import_module("ksadk.runners.utils.langfuse") - module = importlib.reload(module) - monkeypatch.setattr(module, "_langfuse_callback", None) - monkeypatch.setattr(module, "_cloud_monitor_langfuse_callback", None) - return module - - -def test_get_langfuse_callbacks_returns_primary_and_cloud_monitor(monkeypatch): - _install_fake_langfuse(monkeypatch) - monkeypatch.setenv("LANGFUSE_USE_CALLBACK", "true") - monkeypatch.setenv("LANGFUSE_PUBLIC_KEY", "pk-primary") - monkeypatch.setenv("LANGFUSE_SECRET_KEY", "sk-primary") - monkeypatch.setenv("LANGFUSE_BASE_URL", "https://trace-pre.example.com") - monkeypatch.setenv("CLOUD_MONITOR_LANGFUSE_PUBLIC_KEY", "pk-cloud") - monkeypatch.setenv("CLOUD_MONITOR_LANGFUSE_SECRET_KEY", "sk-cloud") - monkeypatch.setenv("CLOUD_MONITOR_LANGFUSE_HOST", "https://cn-beijing-6.otlp.ksyun.com:4318") - - module = _reload_module(monkeypatch) - - callbacks = module.get_langfuse_callbacks() - - assert [callback.public_key for callback in callbacks] == ["pk-primary", "pk-cloud"] - assert [ - {key: value for key, value in instance.items() if key != "tracer_provider"} - for instance in _FakeLangfuse.instances - ] == [ - { - "public_key": "pk-primary", - "secret_key": "sk-primary", - "base_url": "https://trace-pre.example.com", - }, - { - "public_key": "pk-cloud", - "secret_key": "sk-cloud", - "base_url": "https://cn-beijing-6.otlp.ksyun.com:4318", - }, - ] - assert ( - _FakeLangfuse.instances[0]["tracer_provider"] - is not _FakeLangfuse.instances[1]["tracer_provider"] - ) - - -def test_get_langfuse_callbacks_skips_incomplete_cloud_monitor_config(monkeypatch): - _install_fake_langfuse(monkeypatch) - monkeypatch.setenv("LANGFUSE_USE_CALLBACK", "true") - monkeypatch.setenv("LANGFUSE_PUBLIC_KEY", "pk-primary") - monkeypatch.setenv("LANGFUSE_SECRET_KEY", "sk-primary") - monkeypatch.setenv("LANGFUSE_BASE_URL", "https://trace-pre.example.com") - monkeypatch.setenv("CLOUD_MONITOR_LANGFUSE_PUBLIC_KEY", "pk-cloud") - - module = _reload_module(monkeypatch) - - callbacks = module.get_langfuse_callbacks() - - assert [callback.public_key for callback in callbacks] == ["pk-primary"] diff --git a/tests/test_runtime_common_memory_backend.py b/tests/test_runtime_common_memory_backend.py deleted file mode 100644 index caa8145a..00000000 --- a/tests/test_runtime_common_memory_backend.py +++ /dev/null @@ -1,178 +0,0 @@ -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", "memory-lancedb"], - "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.example.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.example.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_render_lancedb_manifest_to_openclaw_patch(): - memory_backend = _memory_backend_module() - - result = memory_backend.render_memory_backend_config( - { - "schema_version": "v1", - "backend_type": "lancedb", - } - ) - - assert result.model_dump() == { - "backend_type": "lancedb", - "config_patch": { - "plugins": { - "slots": { - "memory": "memory-lancedb", - }, - "entries": { - "memory-lancedb": { - "enabled": True, - }, - }, - } - }, - "required_env": [], - "plugin_ids": ["memory-lancedb"], - "disabled_plugin_ids": ["openclaw-mem0"], - "clear_plugin_slots": [], - } - - -def test_render_lancedb_manifest_passes_optional_config_to_plugin(): - memory_backend = _memory_backend_module() - - result = memory_backend.render_memory_backend_config( - { - "schema_version": "v1", - "backend_type": "lancedb", - "config": { - "dbPath": "/home/node/.openclaw/memory/lancedb", - }, - } - ) - - assert result.config_patch["plugins"]["entries"]["memory-lancedb"] == { - "enabled": True, - "config": { - "dbPath": "/home/node/.openclaw/memory/lancedb", - }, - } - - -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_sandbox_backend.py b/tests/test_sandbox_backend.py deleted file mode 100644 index 8af2cec4..00000000 --- a/tests/test_sandbox_backend.py +++ /dev/null @@ -1,703 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -import pytest - -from ksadk.sandbox import ( - E2BSandboxBackend, - LocalProcessSandboxBackend, - SandboxCommandResult, - SandboxError, - SandboxInputFile, - SandboxSpec, - SandboxType, - create_sandbox_backend, -) -from ksadk.runtime_context import tool_execution_scope -from ksadk.sandbox.registry import GLOBAL_SANDBOX_REGISTRY, SandboxRegistry -from ksadk.toolsets.sandbox import run_code, run_command, sandbox_status - - -@pytest.fixture(autouse=True) -def _reset_sandbox_registry(monkeypatch): - # 禁用后台 sweep 线程,避免测试间相互干扰。 - monkeypatch.setenv("KSADK_SANDBOX_SWEEP_INTERVAL_SECONDS", "0") - GLOBAL_SANDBOX_REGISTRY.reset_for_tests() - yield - GLOBAL_SANDBOX_REGISTRY.reset_for_tests() - - -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_run_code_returns_snippet_runner_boundary_metadata(monkeypatch): - class FakeSession: - sandbox_id = "sbx-code" - - def write_file(self, path, data): - self.path = path - self.data = data - - def run_command(self, command, timeout=None, env=None, cwd=None): - return SandboxCommandResult(stdout="42\n", stderr="", exit_code=0) - - class FakeBackend: - isolated = True - - def create_session(self, **_kwargs): - return FakeSession() - - monkeypatch.setattr("ksadk.toolsets.sandbox.create_sandbox_backend", lambda: FakeBackend()) - monkeypatch.setenv("KSADK_SANDBOX_BACKEND", "e2b") - monkeypatch.setenv("KSADK_SANDBOX_TEMPLATE_ID", "tpl-aio") - - result = run_code("print(42)", language="python") - - assert result["ok"] is True - assert result["execution_model"] == "snippet_runner" - assert result["boundary"] - assert result["sandbox_id"] == "sbx-code" - - -def test_run_code_requires_isolated_backend(monkeypatch, tmp_path): - monkeypatch.setenv("KSADK_SANDBOX_BACKEND", "local_process") - monkeypatch.setenv("AGENTENGINE_UI_DIR", str(tmp_path / "ui")) - - result = run_code("print('local')", language="python") - - assert result["ok"] is False - assert result["error_type"] == "isolated_sandbox_required" - assert "requires an isolated sandbox" in result["error_message"] - - -def test_sandbox_factory_refuses_e2b_without_template(monkeypatch): - monkeypatch.setenv("KSADK_SANDBOX_BACKEND", "e2b") - monkeypatch.delenv("KSADK_SANDBOX_TEMPLATE_ID", raising=False) - monkeypatch.delenv("KSADK_SKILL_RUNTIME_TEMPLATE_ID", raising=False) - - with pytest.raises(SandboxError, match="template id"): - create_sandbox_backend() - - -def test_sandbox_factory_creates_local_process_backend_when_explicit(monkeypatch): - monkeypatch.setenv("KSADK_SANDBOX_BACKEND", "local_process") - - backend = create_sandbox_backend() - - assert isinstance(backend, LocalProcessSandboxBackend) - assert backend.isolated is False - - -def test_sandbox_factory_requires_gate_for_pod_process(monkeypatch): - monkeypatch.setenv("KSADK_SANDBOX_BACKEND", "pod_process") - monkeypatch.delenv("KSADK_ALLOW_POD_PROCESS_TOOLS", raising=False) - - with pytest.raises(SandboxError, match="KSADK_ALLOW_POD_PROCESS_TOOLS"): - create_sandbox_backend() - - -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 - - -def test_local_process_backend_runs_inside_workspace(tmp_path): - backend = LocalProcessSandboxBackend(workspace_root=tmp_path) - session = backend.create_session(session_id="sess-1") - - result = session.run_command("pwd && python -c 'print(123)'") - - assert result.exit_code == 0 - assert str(tmp_path) in result.stdout - assert "123" in result.stdout - - -def test_local_process_backend_rejects_cwd_escape(tmp_path): - backend = LocalProcessSandboxBackend(workspace_root=tmp_path) - session = backend.create_session(session_id="sess-1") - - result = session.run_command("pwd", env={"KSADK_COMMAND_CWD": "/"}) - - assert result.exit_code == 126 - assert "cwd must stay inside" in result.stderr - - -def test_local_process_backend_kills_process_group_on_timeout(tmp_path): - backend = LocalProcessSandboxBackend(workspace_root=tmp_path) - session = backend.create_session(session_id="sess-1") - - result = session.run_command("python -c 'import subprocess, time; subprocess.Popen([\"sleep\", \"5\"]); time.sleep(5)'", timeout=1) - - assert result.exit_code == 124 - assert "command timed out" in result.stderr - - -def test_sandbox_registry_reuses_session_and_sweeps_idle_entries(): - calls: list[str] = [] - - class FakeSession: - sandbox_id = "fake-1" - - def kill(self): - calls.append("kill") - - class FakeBackend: - def create_session(self, *, session_id, env=None, input_files=None): - calls.append(f"create:{session_id}") - return FakeSession() - - registry = SandboxRegistry() - first, created_first = registry.get_or_create( - key="run-1", - backend_name="fake", - backend=FakeBackend(), - ttl_seconds=100, - idle_ttl_seconds=10, - isolated=True, - now=100.0, - ) - second, created_second = registry.get_or_create( - key="run-1", - backend_name="fake", - backend=FakeBackend(), - ttl_seconds=100, - idle_ttl_seconds=10, - isolated=True, - now=105.0, - ) - swept = registry.sweep(now=116.0, idle_ttl_seconds=10) - - assert first is second - assert created_first is True - assert created_second is False - assert swept == 1 - assert calls == ["create:run-1", "kill"] - - -def test_sandbox_registry_quota_reclaims_oldest_entry(): - killed: list[str] = [] - - class FakeSession: - def __init__(self, sandbox_id: str): - self.sandbox_id = sandbox_id - - def kill(self): - killed.append(self.sandbox_id) - - class FakeBackend: - def create_session(self, *, session_id, env=None, input_files=None): - return FakeSession(session_id) - - registry = SandboxRegistry() - registry.get_or_create(key="old", backend_name="fake", backend=FakeBackend(), ttl_seconds=100, isolated=True, now=100.0, max_sessions=2) - registry.get_or_create(key="middle", backend_name="fake", backend=FakeBackend(), ttl_seconds=100, isolated=True, now=101.0, max_sessions=2) - registry.get_or_create(key="new", backend_name="fake", backend=FakeBackend(), ttl_seconds=100, isolated=True, now=102.0, max_sessions=2) - - assert killed == ["old"] - assert {entry.key for entry in registry.entries()} == {"middle", "new"} - - -def test_run_command_syncs_workspace_files_to_new_sandbox(monkeypatch, tmp_path): - monkeypatch.setenv("AGENTENGINE_UI_DIR", str(tmp_path / "ui")) - monkeypatch.setenv("KSADK_SANDBOX_BACKEND", "e2b") - monkeypatch.setenv("KSADK_SANDBOX_TEMPLATE_ID", "tpl-aio") - workspace = tmp_path / "ui" / "workspace" - workspace.mkdir(parents=True) - (workspace / "app.py").write_text("print('synced')\n", encoding="utf-8") - - created_input_files: list[SandboxInputFile] = [] - - class FakeResult: - stdout = "ok\n" - stderr = "" - exit_code = 0 - - class FakeSession: - sandbox_id = "sbx-sync" - - def write_file(self, path, data): - pass - - def read_file(self, path): - return "" - - def run_command(self, command, *, timeout=None, env=None, cwd=None): - return FakeResult() - - def get_host(self, port): - return "https://example.com" - - def kill(self): - pass - - class FakeBackend: - def create_session(self, *, session_id, env=None, input_files=None): - created_input_files.extend(input_files or []) - return FakeSession() - - from ksadk.sandbox.registry import GLOBAL_SANDBOX_REGISTRY - - GLOBAL_SANDBOX_REGISTRY.clear() - monkeypatch.setattr("ksadk.toolsets.sandbox.create_sandbox_backend", lambda: FakeBackend()) - - result = run_command("python -V") - - assert result["ok"] is True - assert [(item.source, item.target_path) for item in created_input_files] == [ - (workspace / "app.py", "/workspace/app.py") - ] - - -def test_run_command_and_run_code_reuse_context_session_sandbox_key(monkeypatch, tmp_path): - monkeypatch.setenv("AGENTENGINE_UI_DIR", str(tmp_path / "ui")) - monkeypatch.setenv("KSADK_SANDBOX_BACKEND", "e2b") - monkeypatch.setenv("KSADK_SANDBOX_TEMPLATE_ID", "tpl-aio") - (tmp_path / "ui" / "workspace").mkdir(parents=True) - created_session_ids: list[str] = [] - commands: list[str] = [] - - class FakeResult: - stdout = "ok\n" - stderr = "" - exit_code = 0 - - class FakeSession: - sandbox_id = "sbx-shared" - - def write_file(self, path, data): - pass - - def run_command(self, command, *, timeout=None, env=None, cwd=None): - commands.append(command) - return FakeResult() - - def kill(self): - pass - - class FakeBackend: - def create_session(self, *, session_id, env=None, input_files=None): - created_session_ids.append(session_id) - return FakeSession() - - GLOBAL_SANDBOX_REGISTRY.clear() - monkeypatch.setattr("ksadk.toolsets.sandbox.create_sandbox_backend", lambda: FakeBackend()) - - with tool_execution_scope(session_id="sess-1", run_id="run-1", invocation_id="inv-1"): - command_result = run_command("python -V") - code_result = run_code("print(42)") - - assert command_result["ok"] is True - assert code_result["ok"] is True - assert created_session_ids == ["ksadk-session:sess-1"] - assert commands[0] == "python -V" - assert commands[1].startswith("python /tmp/ksadk-run-code-") - GLOBAL_SANDBOX_REGISTRY.clear() - - -def test_sandbox_context_key_isolated_by_session_and_env_override_wins(monkeypatch, tmp_path): - monkeypatch.setenv("AGENTENGINE_UI_DIR", str(tmp_path / "ui")) - monkeypatch.setenv("KSADK_SANDBOX_BACKEND", "e2b") - monkeypatch.setenv("KSADK_SANDBOX_TEMPLATE_ID", "tpl-aio") - (tmp_path / "ui" / "workspace").mkdir(parents=True) - created_session_ids: list[str] = [] - - class FakeResult: - stdout = "ok\n" - stderr = "" - exit_code = 0 - - class FakeSession: - def __init__(self, sandbox_id: str): - self.sandbox_id = sandbox_id - - def run_command(self, command, *, timeout=None, env=None, cwd=None): - return FakeResult() - - def kill(self): - pass - - class FakeBackend: - def create_session(self, *, session_id, env=None, input_files=None): - created_session_ids.append(session_id) - return FakeSession(f"sbx-{len(created_session_ids)}") - - GLOBAL_SANDBOX_REGISTRY.clear() - monkeypatch.setattr("ksadk.toolsets.sandbox.create_sandbox_backend", lambda: FakeBackend()) - - with tool_execution_scope(session_id="sess-a"): - assert run_command("python -V")["ok"] is True - with tool_execution_scope(session_id="sess-b"): - assert run_command("python -V")["ok"] is True - monkeypatch.setenv("KSADK_SANDBOX_SESSION_ID", "manual") - with tool_execution_scope(session_id="sess-c"): - assert run_command("python -V")["ok"] is True - - assert created_session_ids == ["ksadk-session:sess-a", "ksadk-session:sess-b", "manual"] - GLOBAL_SANDBOX_REGISTRY.clear() - - -def test_sandbox_direct_calls_retain_prefix_fallback_keys(monkeypatch, tmp_path): - monkeypatch.setenv("AGENTENGINE_UI_DIR", str(tmp_path / "ui")) - monkeypatch.setenv("KSADK_SANDBOX_BACKEND", "e2b") - monkeypatch.setenv("KSADK_SANDBOX_TEMPLATE_ID", "tpl-aio") - (tmp_path / "ui" / "workspace").mkdir(parents=True) - created_session_ids: list[str] = [] - - class FakeResult: - stdout = "ok\n" - stderr = "" - exit_code = 0 - - class FakeSession: - sandbox_id = "sbx-direct" - - def write_file(self, path, data): - pass - - def run_command(self, command, *, timeout=None, env=None, cwd=None): - return FakeResult() - - def kill(self): - pass - - class FakeBackend: - def create_session(self, *, session_id, env=None, input_files=None): - created_session_ids.append(session_id) - return FakeSession() - - GLOBAL_SANDBOX_REGISTRY.clear() - monkeypatch.setattr("ksadk.toolsets.sandbox.create_sandbox_backend", lambda: FakeBackend()) - - assert run_command("python -V")["ok"] is True - assert run_code("print(42)")["ok"] is True - - assert created_session_ids == ["ksadk-direct-shared", "ksadk-code-shared"] - GLOBAL_SANDBOX_REGISTRY.clear() - - -def test_sandbox_status_reports_idle_ttl_and_quota(monkeypatch): - monkeypatch.setenv("KSADK_SANDBOX_BACKEND", "local_process") - monkeypatch.setenv("KSADK_SANDBOX_IDLE_TTL_SECONDS", "12") - monkeypatch.setenv("KSADK_SANDBOX_MAX_SESSIONS", "3") - - result = sandbox_status() - - assert result["ok"] is True - assert result["isolated"] is False - assert result["idle_ttl_seconds"] == 12 - assert result["max_sessions"] == 3 - - -def test_sandbox_registry_clear_is_idempotent(): - # clear() 幂等:多次调用不抛异常(atexit 和 server shutdown 都可能调)。 - GLOBAL_SANDBOX_REGISTRY.clear() - GLOBAL_SANDBOX_REGISTRY.clear() - assert GLOBAL_SANDBOX_REGISTRY.entries() == [] - - -def test_sandbox_registry_sweep_thread_disabled_when_interval_zero(monkeypatch): - monkeypatch.setenv("KSADK_SANDBOX_SWEEP_INTERVAL_SECONDS", "0") - registry = SandboxRegistry() - registry._start_sweep_thread() - assert registry._sweep_thread is None - registry.reset_for_tests() - - -def test_sandbox_registry_sweep_thread_starts_when_interval_positive(monkeypatch): - monkeypatch.setenv("KSADK_SANDBOX_SWEEP_INTERVAL_SECONDS", "1") - registry = SandboxRegistry() - - class FakeSession: - sandbox_id = "sweep-1" - killed = False - - def kill(self): - self.killed = True - - class FakeBackend: - def create_session(self, *, session_id, env=None, input_files=None): - return FakeSession() - - # 首次 get_or_create 创建 entry 后应懒启动后台 sweep 线程。 - registry.get_or_create( - key="sweep-test", - backend_name="fake", - backend=FakeBackend(), - ttl_seconds=1, - idle_ttl_seconds=1, - isolated=True, - now=0.0, - ) - assert registry._sweep_thread is not None - assert registry._sweep_thread.is_alive() - registry.reset_for_tests() - assert registry._sweep_thread is None - assert registry.entries() == [] - - -def test_sandbox_registry_concurrent_get_or_create_does_not_deadlock(monkeypatch): - monkeypatch.setenv("KSADK_SANDBOX_SWEEP_INTERVAL_SECONDS", "0") - import threading - - class FakeSession: - def __init__(self, sid): - self.sandbox_id = sid - self.killed = False - - def kill(self): - self.killed = True - - class FakeBackend: - def __init__(self): - self._counter = 0 - self._lock = threading.Lock() - - def create_session(self, *, session_id, env=None, input_files=None): - with self._lock: - self._counter += 1 - return FakeSession(f"sbx-{session_id}-{self._counter}") - - registry = SandboxRegistry() - backend = FakeBackend() - errors: list[Exception] = [] - - def worker(idx: int): - try: - registry.get_or_create( - key=f"concurrent-{idx}", - backend_name="fake", - backend=backend, - ttl_seconds=100, - isolated=True, - ) - except Exception as exc: - errors.append(exc) - - threads = [threading.Thread(target=worker, args=(i,)) for i in range(8)] - for t in threads: - t.start() - for t in threads: - t.join(timeout=5.0) - assert not errors - assert len(registry.entries()) == 8 - registry.reset_for_tests() - - -def test_shutdown_runner_resources_clears_sandbox_registry(monkeypatch): - import asyncio - import sys - - import ksadk.server.app # noqa: F401 触发模块注册到 sys.modules - app_module = sys.modules["ksadk.server.app"] - - # 用一个带空 close() 的 mock runner,触发完整 shutdown 路径(含 sandbox clear)。 - class FakeRunner: - async def close(self): - return None - - monkeypatch.setattr(app_module, "runner", FakeRunner()) - calls: list[bool] = [] - monkeypatch.setattr( - GLOBAL_SANDBOX_REGISTRY, - "clear", - lambda: calls.append(True), - ) - - asyncio.run(app_module._shutdown_runner_resources()) - - assert calls == [True] diff --git a/tests/test_semantic_circuit_breaker.py b/tests/test_semantic_circuit_breaker.py deleted file mode 100644 index abfbf611..00000000 --- a/tests/test_semantic_circuit_breaker.py +++ /dev/null @@ -1,139 +0,0 @@ -"""L4 semantic 熔断单测:连续失败超阈值后跳过 LLM 直接走 extractive。 - -Codex 指出:summarize_compaction 捕获异常返回 extractive,外层看成功, -governance compact failure 不触发。因此 semantic 熔断需独立计数(本模块级)。 -""" - -from __future__ import annotations - -import asyncio -import pytest - -from ksadk.conversations import semantic_summary as ss -from ksadk.conversations.semantic_summary import ( - CompactionSummaryResult, - summarize_compaction, -) - - -class _FailingClient: - """模拟 LLM 调用总是失败的 client。""" - - @property - def is_available(self) -> bool: - return True - - async def summarize(self, *, model, messages, timeout_ms): - raise RuntimeError("simulated LLM timeout") - - -class _SuccessClient: - """模拟 LLM 调用成功的 client(返回带 块)。""" - - @property - def is_available(self) -> bool: - return True - - async def summarize(self, *, model, messages, timeout_ms): - return "xok summary", {"input_tokens": 10} - - -def _setup_client(monkeypatch, client): - monkeypatch.setattr(ss, "resolve_summary_model_client", lambda: client) - # 让 semantic_compaction_disabled 返回 False(确保走 semantic 路径)。 - monkeypatch.setattr(ss, "semantic_compaction_disabled", lambda: False) - - -@pytest.fixture(autouse=True) -def _reset_semantic_failures(): - """每个测试前重置 semantic 失败计数,避免测试间污染。""" - ss._reset_semantic_failures() - yield - ss._reset_semantic_failures() - - -@pytest.mark.asyncio -async def test_semantic_failure_increments_counter(monkeypatch): - _setup_client(monkeypatch, _FailingClient()) - # 确保不被熔断拦截(阈值设高)。 - monkeypatch.setenv("KSADK_MAX_CONSECUTIVE_SEMANTIC_FAILURES", "100") - - result = await summarize_compaction( - groups_to_compact=[], - previous_summary="", - pinned_state={}, - model_metadata=None, - model="test-model", - ) - - assert result.summary_strategy == "extractive" # 失败回退 extractive - assert result.fallback_reason == "simulated LLM timeout" - assert ss._semantic_summary_failures == 1 - - -@pytest.mark.asyncio -async def test_circuit_opens_after_threshold_failures(monkeypatch): - _setup_client(monkeypatch, _FailingClient()) - monkeypatch.setenv("KSADK_MAX_CONSECUTIVE_SEMANTIC_FAILURES", "2") - - # 第一次失败:计数=1,未熔断,走 try。 - r1 = await summarize_compaction( - groups_to_compact=[], previous_summary="", pinned_state={}, - model_metadata=None, model="m", - ) - assert r1.fallback_reason == "simulated LLM timeout" - assert ss._semantic_summary_failures == 1 - - # 第二次失败:计数=2,达到阈值。 - r2 = await summarize_compaction( - groups_to_compact=[], previous_summary="", pinned_state={}, - model_metadata=None, model="m", - ) - assert r2.fallback_reason == "simulated LLM timeout" - assert ss._semantic_summary_failures == 2 - - # 第三次:熔断已开,直接走 extractive 不调 LLM,fallback_reason 变成 circuit_open。 - r3 = await summarize_compaction( - groups_to_compact=[], previous_summary="", pinned_state={}, - model_metadata=None, model="m", - ) - assert r3.summary_strategy == "extractive" - assert r3.fallback_reason == "semantic_circuit_open" - # 熔断后不再调 LLM,计数不增加。 - assert ss._semantic_summary_failures == 2 - - -@pytest.mark.asyncio -async def test_success_resets_counter(monkeypatch): - _setup_client(monkeypatch, _FailingClient()) - monkeypatch.setenv("KSADK_MAX_CONSECUTIVE_SEMANTIC_FAILURES", "5") - - # 一次失败。 - await summarize_compaction( - groups_to_compact=[], previous_summary="", pinned_state={}, - model_metadata=None, model="m", - ) - assert ss._semantic_summary_failures == 1 - - # 切回成功 client。 - _setup_client(monkeypatch, _SuccessClient()) - r = await summarize_compaction( - groups_to_compact=[], previous_summary="", pinned_state={}, - model_metadata=None, model="m", - ) - assert r.summary_strategy == "semantic" - assert ss._semantic_summary_failures == 0 # 成功清零 - - -@pytest.mark.asyncio -async def test_circuit_disabled_when_threshold_zero(monkeypatch): - _setup_client(monkeypatch, _FailingClient()) - monkeypatch.setenv("KSADK_MAX_CONSECUTIVE_SEMANTIC_FAILURES", "0") - - # 阈值 0 = 禁用熔断,失败多次仍走 try(每次都调 LLM 失败)。 - for _ in range(5): - r = await summarize_compaction( - groups_to_compact=[], previous_summary="", pinned_state={}, - model_metadata=None, model="m", - ) - assert r.fallback_reason == "simulated LLM timeout" # 不是 circuit_open diff --git a/tests/test_server_app_fastapi_compat.py b/tests/test_server_app_fastapi_compat.py deleted file mode 100644 index 8ad01fea..00000000 --- a/tests/test_server_app_fastapi_compat.py +++ /dev/null @@ -1,40 +0,0 @@ -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 - - -def test_server_app_import_does_not_load_deploy_providers(): - sys.modules.pop("ksadk.server", None) - sys.modules.pop("ksadk.server.app", None) - sys.modules.pop("ksadk.deployment", None) - sys.modules.pop("ksadk.deployment.providers", None) - sys.modules.pop("ksadk.builders.ks3_uploader", None) - - module = importlib.import_module("ksadk.server.app") - - assert module.app is not None - assert "ksadk.deployment.providers" not in sys.modules - assert "ksadk.builders.ks3_uploader" not in sys.modules diff --git a/tests/test_server_file_upload_parsing.py b/tests/test_server_file_upload_parsing.py deleted file mode 100644 index a391c912..00000000 --- a/tests/test_server_file_upload_parsing.py +++ /dev/null @@ -1,129 +0,0 @@ -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_session_app.py b/tests/test_server_session_app.py deleted file mode 100644 index ceb2a77b..00000000 --- a/tests/test_server_session_app.py +++ /dev/null @@ -1,4338 +0,0 @@ -from __future__ import annotations - -import base64 -import asyncio -import importlib -import json -from types import SimpleNamespace - -import httpx -import pytest -from starlette.background import BackgroundTask -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 -from ksadk.sessions.errors import SessionBackendUnavailable -from ksadk.sessions.in_memory import InMemorySessionService - - -class _DummyRunner(BaseRunner): - def __init__(self): - super().__init__( - detection_result=SimpleNamespace( - name="demo-agent", - type=SimpleNamespace(value="mock"), - ), - project_dir=".", - ) - self.calls: list[dict] = [] - - def load_agent(self) -> None: - return None - - async def invoke(self, input_data: dict) -> dict: - self.calls.append(input_data) - 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 _CustomUiRunner(_DummyRunner): - def __init__(self, project_dir: str): - super().__init__() - self.project_dir = project_dir - self.detection_result.type = SimpleNamespace(value="langgraph") - - -class _CheckpointResumeRunner(_DummyRunner): - def describe_checkpoint_capability(self) -> dict: - return { - "Supported": True, - "Backend": "postgres", - "Scope": "shared", - "Durable": True, - "SharedAcrossPods": True, - "Reason": "", - } - - 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__( - 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) -> dict: - return {"output": "goodbye"} - - async def stream(self, input_data: dict): - yield {"type": "text", "delta": "hel"} - yield {"type": "text", "delta": "lo"} - yield {"type": "final", "output": "goodbye"} - - -class _ThinkingOnlyFinalRunner(_OverrideStreamingRunner): - async def stream(self, input_data: dict): - yield {"type": "thinking", "delta": "先想一下"} - yield {"type": "final", "output": "final answer"} - - -class _SlowStreamingRunner(_OverrideStreamingRunner): - async def stream(self, input_data: dict): - yield {"type": "text", "delta": "hel"} - await asyncio.sleep(0.05) - yield {"type": "text", "delta": "lo"} - yield {"type": "final", "output": "hello"} - - -class _UnavailableSessionService(InMemorySessionService): - async def create_session(self, *args, **kwargs): - raise SessionBackendUnavailable("Postgres session backend unavailable") - - async def list_sessions(self, *args, **kwargs): - raise SessionBackendUnavailable("Postgres session backend unavailable") - - async def count_sessions(self, *args, **kwargs): - raise SessionBackendUnavailable("Postgres session backend unavailable") - - -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__() - self.prepared_models: list[str | None] = [] - - def prepare_for_request(self, model: str | None) -> None: - self.prepared_models.append(model) - - -class _ExternalModelsAsyncClient: - """给 ListAgentModels 用的外部模型目录假客户端。""" - - def __init__(self, *args, payload=None, error: Exception | None = None, **kwargs): - self._payload = payload - self._error = error - - async def __aenter__(self): - return self - - async def __aexit__(self, exc_type, exc, tb): - return None - - async def get(self, url: str, headers: dict | None = None): - if self._error is not None: - raise self._error - request = httpx.Request("GET", url, headers=headers) - return httpx.Response(200, json=self._payload, request=request) - - -def _sse_payloads(response_text: str) -> list[dict]: - return [ - json.loads(line.removeprefix("data: ")) - for line in response_text.splitlines() - if line.startswith("data: ") - ] - - -def _sse_events(response_text: str) -> list[tuple[str, dict]]: - current_event = "message" - events: list[tuple[str, dict]] = [] - for line in response_text.splitlines(): - if line.startswith("event: "): - current_event = line.removeprefix("event: ").strip() or "message" - continue - if not line.startswith("data: "): - continue - payload = line.removeprefix("data: ").strip() - if not payload or payload == "[DONE]": - current_event = "message" - continue - events.append((current_event, json.loads(payload))) - current_event = "message" - 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 - capabilities = response.json()["Data"]["Capabilities"] - assert capabilities["RuntimeCapabilities"]["Framework"] == "mock" - assert capabilities["RuntimeCapabilities"]["Checkpoint"]["Supported"] is False - assert capabilities["RuntimeCapabilities"]["ResumeRun"]["ResumeMode"] == "none" - assert capabilities["CheckpointResumeCapability"]["Supported"] is False - - -@pytest.mark.asyncio -async def test_ui_bootstrap_exposes_custom_ui_metadata(monkeypatch): - server_app_module = importlib.import_module("ksadk.server.app") - service = InMemorySessionService() - project_dir = "/tmp/custom-ui-agent" - runner = _CustomUiRunner(project_dir) - - monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service) - server_app_module.set_runner(runner) - monkeypatch.setattr( - server_app_module, - "_resolve_agent_ui_spec", - lambda: { - "enabled": True, - "ui_profile": "custom", - "ui_path": "/", - "ui_url": None, - "ui_bundle_path": f"{project_dir}/research-ui/dist", - }, - ) - - 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 - payload = response.json() - assert payload["Data"]["SharePermissions"] == { - "Interactive": True, - "DefaultPath": "/", - "SharePath": "/", - } - assert payload["Data"]["CustomUI"] == { - "Enabled": True, - "Profile": "custom", - "Path": "/", - "Url": None, - "BundlePath": f"{project_dir}/research-ui/dist", - } - - -@pytest.mark.asyncio -async def test_ui_bootstrap_defaults_to_runtime_agent_id(monkeypatch): - server_app_module = importlib.import_module("ksadk.server.app") - service = InMemorySessionService() - runner = _DummyRunner() - - 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("/agentengine/api/v1/GetAgentUiBootstrap", json={}) - - assert response.status_code == 200 - assert response.json()["Data"]["Agent"]["AgentId"] == "ar-hosted-runtime" - - -def test_resolve_agent_ui_spec_uses_custom_ui_env_fallback(monkeypatch, tmp_path): - server_app_module = importlib.import_module("ksadk.server.app") - project_dir = tmp_path / "agent" - bundle_dir = project_dir / "research-ui" / "dist" - bundle_dir.mkdir(parents=True) - (bundle_dir / "index.html").write_text("Custom UI", encoding="utf-8") - runner = _CustomUiRunner(str(project_dir)) - - server_app_module.set_runner(runner) - monkeypatch.setenv("KSADK_UI_PROFILE", "custom") - monkeypatch.setenv("KSADK_UI_PATH", "/") - monkeypatch.setenv("KSADK_UI_BUNDLE_PATH", "research-ui/dist") - - spec = server_app_module._resolve_agent_ui_spec() - - assert spec["enabled"] is True - assert spec["ui_profile"] == "custom" - assert spec["ui_path"] == "/" - assert spec["source"] == "custom" - assert spec["ui_bundle_path"] == str(bundle_dir.resolve()) - - -def test_resolve_agent_ui_spec_auto_detects_project_custom_ui_bundle(monkeypatch, tmp_path): - server_app_module = importlib.import_module("ksadk.server.app") - project_dir = tmp_path / "agent" - bundle_dir = project_dir / "research-ui" / "dist" - bundle_dir.mkdir(parents=True) - (bundle_dir / "index.html").write_text("Custom UI", encoding="utf-8") - runner = _CustomUiRunner(str(project_dir)) - - server_app_module.set_runner(runner) - monkeypatch.delenv("KSADK_UI_PROFILE", raising=False) - monkeypatch.delenv("KSADK_UI_PATH", raising=False) - monkeypatch.delenv("KSADK_UI_BUNDLE_PATH", raising=False) - - spec = server_app_module._resolve_agent_ui_spec() - - assert spec["enabled"] is True - assert spec["ui_profile"] == "custom" - assert spec["ui_path"] == "/" - assert spec["source"] == "custom" - - -@pytest.mark.asyncio -async def test_run_sse_uses_new_session_service(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( - "/run_sse", - json=AgentRunRequest( - appName="demo-agent", - userId="user-1", - sessionId=None, - newMessage={"role": "user", "parts": [{"text": "hello"}]}, - streaming=False, - stateDelta={"topic": "billing"}, - ).model_dump(), - ) - - assert response.status_code == 200 - first_line = next(line for line in response.text.splitlines() if line.startswith("data: ")) - payload = json.loads(first_line.removeprefix("data: ")) - session_id = payload["sessionId"] - - session = await service.get_session(session_id) - assert session is not None - # run_status 事件现在携带 state_delta.active_run(与 agentengine-server 对齐), - # completed 后 active_run 反映终态。 - assert session.state["topic"] == "billing" - assert session.state["active_run"]["status"] == "completed" - # active_run 现含 run_mode/run_trigger(普通前台 run 默认 foreground/new_run) - assert session.state["active_run"]["run_mode"] == "foreground" - assert session.state["active_run"]["run_trigger"] == "new_run" - - events = await service.get_events(session_id) - assert [event.author for event in events] == ["user", "demo-agent", "demo-agent", "demo-agent"] - assert [event.event_type for event in events] == [ - "user_message", - "run_status", - "assistant_message", - "run_status", - ] - assert events[0].content["parts"][0]["text"] == "hello" - assert events[2].content["parts"][0]["text"] == "assistant says hi" - assert events[0].metadata["agent_input"] == "hello" - - assert runner.calls == [ - { - "session_id": session_id, - "input": "hello", - "history": [{"role": "user", "content": "hello"}], - "input_content": [{"type": "input_text", "text": "hello"}], - "input_messages": [ - {"role": "user", "content": [{"type": "input_text", "text": "hello"}]} - ], - "input_parts": [{"text": "hello"}], - "attachments": [], - "attachment_results": [], - "current_attachments": [], - "current_attachment_results": [], - "has_current_files": False, - "model": None, - } - ] - - -@pytest.mark.asyncio -async def test_run_sse_passes_attachment_results_to_runner(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( - "/run_sse", - json=AgentRunRequest( - appName="demo-agent", - userId="user-1", - sessionId=None, - newMessage={ - "role": "user", - "parts": [ - {"text": "请分析附件"}, - Part( - inlineData=InlineData( - displayName="resume.txt", - mimeType="text/plain", - data=base64.b64encode("候选人简历内容".encode("utf-8")).decode("ascii"), - ) - ).model_dump(exclude_none=True), - ], - }, - streaming=False, - ).model_dump(), - ) - - assert response.status_code == 200 - assert runner.calls[-1]["current_attachments"] == [ - { - "display_name": "resume.txt", - "mime_type": "text/plain", - "transport": "inline", - "data": base64.b64encode("候选人简历内容".encode("utf-8")).decode("ascii"), - "is_text": True, - "size_bytes": len("候选人简历内容".encode("utf-8")), - } - ] - assert runner.calls[-1]["current_attachment_results"] == [ - { - "display_name": "resume.txt", - "mime_type": "text/plain", - "transport": "inline", - "file_uri": "", - "size_bytes": len("候选人简历内容".encode("utf-8")), - "kind": "text", - "status": "ok", - "warnings": [], - "extraction_method": "text_decode", - "text_excerpt": "候选人简历内容", - "text": "候选人简历内容", - } - ] - assert runner.calls[-1]["has_current_files"] is True - assert runner.calls[-1]["attachment_results"] == [ - { - "display_name": "resume.txt", - "mime_type": "text/plain", - "transport": "inline", - "file_uri": "", - "size_bytes": len("候选人简历内容".encode("utf-8")), - "kind": "text", - "status": "ok", - "warnings": [], - "extraction_method": "text_decode", - "text_excerpt": "候选人简历内容", - "text": "候选人简历内容", - } - ] - - -@pytest.mark.asyncio -async def test_create_session_rejects_explicit_session_owned_by_other_agent_or_user(monkeypatch): - server_app_module = importlib.import_module("ksadk.server.app") - service = InMemorySessionService() - await service.create_session( - agent_id="other-agent", - user_id="other-user", - session_id="shared-session", - ) - - 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( - "/apps/demo-agent/users/user-1/sessions", - json={"sessionId": "shared-session"}, - ) - - assert response.status_code == 409 - assert "different agent or user" in response.json()["detail"] - - -@pytest.mark.asyncio -async def test_run_sse_rejects_explicit_session_owned_by_other_agent_or_user(monkeypatch): - server_app_module = importlib.import_module("ksadk.server.app") - service = InMemorySessionService() - runner = _DummyRunner() - await service.create_session( - agent_id="other-agent", - user_id="other-user", - session_id="shared-session", - ) - - 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( - "/run_sse", - json=AgentRunRequest( - appName="demo-agent", - userId="user-1", - sessionId="shared-session", - newMessage={"role": "user", "parts": [{"text": "hello"}]}, - streaming=False, - ).model_dump(), - ) - - assert response.status_code == 409 - assert "different agent or user" in response.json()["detail"] - assert runner.calls == [] - - -@pytest.mark.asyncio -async def test_attachment_content_route_serves_uploaded_binary(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)) - service = InMemorySessionService() - 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: - upload_response = await client.post( - "/agentengine/api/v1/UploadFile", - files={"file": ("arch.png", b"\x89PNG\r\n\x1a\nbinary", "image/png")}, - ) - - assert upload_response.status_code == 200 - file_uri = upload_response.json()["Data"]["FileData"]["fileUri"] - - 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\nbinary" - - -@pytest.mark.asyncio -async def test_workspace_files_runtime_routes_use_state_dir_workspace_root(monkeypatch, tmp_path): - server_app_module = importlib.import_module("ksadk.server.app") - ui_dir = tmp_path / ".agentengine" / "ui" - workspace_dir = ui_dir / "workspace" - workspace_dir.mkdir(parents=True, exist_ok=True) - (workspace_dir / "existing").mkdir(parents=True, exist_ok=True) - (workspace_dir / "existing" / "hello.txt").write_text("hello workspace", encoding="utf-8") - monkeypatch.setenv("AGENTENGINE_UI_DIR", str(ui_dir)) - - service = InMemorySessionService() - 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: - list_response = await client.get("/_ksadk/workspace/v1/entries", params={"path": "."}) - upload_response = await client.post( - "/_ksadk/workspace/v1/files/uploads/report.txt", - files={"file": ("report.txt", b"workspace upload", "text/plain")}, - ) - download_response = await client.get("/_ksadk/workspace/v1/files/uploads/report.txt") - - assert list_response.status_code == 200 - list_payload = list_response.json() - assert list_payload["Root"] == "workspace" - assert list_payload["Path"] == "." - assert {entry["Path"] for entry in list_payload["Entries"]} == {"existing"} - assert list_payload["Entries"][0]["Type"] == "directory" - - assert upload_response.status_code == 200 - assert upload_response.json()["Entry"]["Path"] == "uploads/report.txt" - assert (workspace_dir / "uploads" / "report.txt").read_text(encoding="utf-8") == "workspace upload" - - assert download_response.status_code == 200 - assert download_response.content == b"workspace upload" - assert download_response.headers["content-type"].startswith("text/plain") - - async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client: - delete_response = await client.delete("/_ksadk/workspace/v1/files/uploads/report.txt") - - assert delete_response.status_code == 200 - assert delete_response.json() == {"Deleted": True} - assert not (workspace_dir / "uploads" / "report.txt").exists() - - -@pytest.mark.asyncio -async def test_workspace_files_runtime_routes_delete_empty_directory(monkeypatch, tmp_path): - server_app_module = importlib.import_module("ksadk.server.app") - ui_dir = tmp_path / ".agentengine" / "ui" - workspace_dir = ui_dir / "workspace" - empty_dir = workspace_dir / "empty-folder" - empty_dir.mkdir(parents=True, exist_ok=True) - monkeypatch.setenv("AGENTENGINE_UI_DIR", str(ui_dir)) - - service = InMemorySessionService() - 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: - runtime_response = await client.delete("/_ksadk/workspace/v1/files/empty-folder") - - assert runtime_response.status_code == 200 - assert runtime_response.json() == {"Deleted": True} - assert not empty_dir.exists() - - -@pytest.mark.asyncio -async def test_workspace_files_runtime_routes_delete_empty_directory_with_trailing_slash( - monkeypatch, - tmp_path, -): - server_app_module = importlib.import_module("ksadk.server.app") - ui_dir = tmp_path / ".agentengine" / "ui" - workspace_dir = ui_dir / "workspace" - empty_dir = workspace_dir / "empty-folder" - empty_dir.mkdir(parents=True, exist_ok=True) - monkeypatch.setenv("AGENTENGINE_UI_DIR", str(ui_dir)) - - service = InMemorySessionService() - 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: - runtime_response = await client.delete("/_ksadk/workspace/v1/files/empty-folder/") - - assert runtime_response.status_code == 200 - assert runtime_response.json() == {"Deleted": True} - assert not empty_dir.exists() - - -@pytest.mark.asyncio -async def test_workspace_files_action_route_deletes_empty_directory(monkeypatch, tmp_path): - server_app_module = importlib.import_module("ksadk.server.app") - ui_dir = tmp_path / ".agentengine" / "ui" - workspace_dir = ui_dir / "workspace" - empty_dir = workspace_dir / "empty-folder" - empty_dir.mkdir(parents=True, exist_ok=True) - monkeypatch.setenv("AGENTENGINE_UI_DIR", str(ui_dir)) - - service = InMemorySessionService() - 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: - action_response = await client.post( - "/agentengine/api/v1/DeleteWorkspaceFile", - json={"AgentId": "demo-agent", "Path": "empty-folder"}, - ) - - assert action_response.status_code == 200 - assert action_response.json()["Data"] == {"Deleted": True} - assert not empty_dir.exists() - - -@pytest.mark.asyncio -async def test_workspace_files_runtime_routes_reject_non_empty_directory_delete(monkeypatch, tmp_path): - server_app_module = importlib.import_module("ksadk.server.app") - ui_dir = tmp_path / ".agentengine" / "ui" - workspace_dir = ui_dir / "workspace" - non_empty_dir = workspace_dir / "docs" - non_empty_dir.mkdir(parents=True, exist_ok=True) - (non_empty_dir / "readme.txt").write_text("keep me", encoding="utf-8") - monkeypatch.setenv("AGENTENGINE_UI_DIR", str(ui_dir)) - - service = InMemorySessionService() - 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.delete("/_ksadk/workspace/v1/files/docs") - - assert response.status_code == 409 - assert response.json()["detail"] == "workspace directory is not empty" - assert (non_empty_dir / "readme.txt").exists() - - -@pytest.mark.asyncio -async def test_workspace_files_runtime_route_serves_html_preview_inline(monkeypatch, tmp_path): - server_app_module = importlib.import_module("ksadk.server.app") - ui_dir = tmp_path / ".agentengine" / "ui" - workspace_dir = ui_dir / "workspace" - workspace_dir.mkdir(parents=True, exist_ok=True) - (workspace_dir / "showcase").mkdir(parents=True, exist_ok=True) - (workspace_dir / "showcase" / "index.html").write_text( - 'Features', - encoding="utf-8", - ) - monkeypatch.setenv("AGENTENGINE_UI_DIR", str(ui_dir)) - - service = InMemorySessionService() - 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.get("/_ksadk/workspace/v1/files/showcase/index.html") - - assert response.status_code == 200 - assert response.headers["content-type"].startswith("text/html") - assert "content-disposition" not in response.headers - csp = response.headers.get("content-security-policy", "") - assert "sandbox allow-scripts allow-downloads" in csp - assert "style-src 'unsafe-inline' data: 'self' https:" in csp - assert "img-src data: blob: 'self' https:" in csp - assert "connect-src 'none'" in csp - assert '' in response.text - assert "data-ksadk-preview-anchor-handler" in response.text - - -@pytest.mark.asyncio -async def test_workspace_files_runtime_routes_reject_path_escape(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)) - - service = InMemorySessionService() - 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.get( - "/_ksadk/workspace/v1/entries", - params={"path": "../outside"}, - ) - - assert response.status_code == 400 - assert response.json()["detail"] == "workspace path escapes the workspace root" - - -@pytest.mark.asyncio -async def test_workspace_files_action_routes_match_runtime_contract(monkeypatch, tmp_path): - server_app_module = importlib.import_module("ksadk.server.app") - ui_dir = tmp_path / ".agentengine" / "ui" - workspace_dir = ui_dir / "workspace" - workspace_dir.mkdir(parents=True, exist_ok=True) - (workspace_dir / "existing").mkdir(parents=True, exist_ok=True) - (workspace_dir / "existing" / "hello.txt").write_text("hello workspace", encoding="utf-8") - monkeypatch.setenv("AGENTENGINE_UI_DIR", str(ui_dir)) - - service = InMemorySessionService() - 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: - list_response = await client.post( - "/agentengine/api/v1/ListWorkspaceFiles", - json={"AgentId": "demo-agent", "Path": "."}, - ) - upload_response = await client.post( - "/agentengine/api/v1/AddWorkspaceFile", - data={"AgentId": "demo-agent", "Path": "uploads/report.txt"}, - files={"file": ("report.txt", b"workspace upload", "text/plain")}, - ) - download_response = await client.get( - "/agentengine/api/v1/GetWorkspaceFileContent", - params={"AgentId": "demo-agent", "FilePath": "uploads/report.txt"}, - ) - - assert list_response.status_code == 200 - list_payload = list_response.json()["Data"] - assert list_payload["Root"] == "workspace" - assert list_payload["Path"] == "." - assert {entry["Path"] for entry in list_payload["Entries"]} == {"existing"} - - assert upload_response.status_code == 200 - assert upload_response.json()["Data"]["Entry"]["Path"] == "uploads/report.txt" - assert (workspace_dir / "uploads" / "report.txt").read_text(encoding="utf-8") == "workspace upload" - - assert download_response.status_code == 200 - assert download_response.content == b"workspace upload" - assert download_response.headers["content-type"].startswith("text/plain") - - async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client: - delete_response = await client.post( - "/agentengine/api/v1/DeleteWorkspaceFile", - json={"AgentId": "demo-agent", "Path": "uploads/report.txt"}, - ) - - assert delete_response.status_code == 200 - assert delete_response.json()["Data"] == {"Deleted": True} - assert not (workspace_dir / "uploads" / "report.txt").exists() - - -@pytest.mark.asyncio -async def test_list_sessions_projects_heuristic_title_for_existing_fallback_session(monkeypatch): - server_app_module = importlib.import_module("ksadk.server.app") - service = InMemorySessionService() - created = await service.create_session( - agent_id="demo-agent", - user_id="user-1", - session_id="sess-heuristic-read", - ) - await service.update_session_metadata( - created.id, - title="你好,请介绍一下你自己", - title_source="fallback_first_prompt", - first_prompt="你好,请介绍一下你自己", - summary="你好!我是企业高端招聘全流程助手,可以协助你完成职位分析、候选人筛选和面试建议生成。", - ) - - 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"}, - ) - - assert response.status_code == 200 - session = response.json()["Data"]["Sessions"][0] - assert session["Title"] == "招聘助手能力" - assert session["TitleSource"] == "heuristic" - - -@pytest.mark.asyncio -async def test_list_sessions_prefers_active_resume_invocation_over_old_terminal_run(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-active-resume", - ) - await service.append_event( - "sess-active-resume", - SessionEvent( - author="demo", - event_type="run_status", - invocation_id="run-original", - content={"status": "completed"}, - metadata={"status": "completed", "run_id": "run-original"}, - ), - ) - await service.append_event( - "sess-active-resume", - SessionEvent( - author="demo", - event_type="run_resume", - invocation_id="run-resume-1", - content={"checkpoint_id": "ckpt-1"}, - metadata={ - "run_id": "run-original", - "checkpoint_id": "ckpt-1", - "resume_attempt_id": "run-resume-1", - }, - ), - ) - await service.append_event( - "sess-active-resume", - SessionEvent( - author="demo", - event_type="run_status", - invocation_id="run-resume-1", - content={"status": "in_progress"}, - metadata={"status": "in_progress", "run_id": "run-resume-1"}, - ), - ) - - 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"}, - ) - - assert response.status_code == 200 - session = response.json()["Data"]["Sessions"][0] - assert session["ActiveInvocationId"] == "run-resume-1" - assert session["ActiveRunStatus"] == "in_progress" - - -@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_list_sessions_hydrates_summary_from_event_log_when_session_row_is_empty(monkeypatch): - """ListSessions 不能只返回空壳 session;event log 已有事实时要回填标题与 active run。""" - 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-event-backed-summary", - ) - user_prompt = ( - "请启动 Deep Research 调研任务,不要只做普通聊天回答。\n\n" - "研究主题:调研 2026 Q2和Q3金山云股票风险和评估建议\n\n" - "研究深度:med。" - ) - await conversation_runtime.append_conversation_event( - session_id="sess-event-backed-summary", - author="user", - role="user", - text=user_prompt, - invocation_id="run_sess_event_backed_summary", - event_type="user_message", - metadata={"agent_input": user_prompt}, - session_service_provider=lambda: service, - ) - await conversation_runtime.append_run_status_event( - session_id="sess-event-backed-summary", - author="demo-agent", - status="in_progress", - invocation_id="run_sess_event_backed_summary", - session_service_provider=lambda: service, - ) - await conversation_runtime.append_conversation_event( - session_id="sess-event-backed-summary", - author="demo-agent", - role="model", - text="Planner 已生成 5 个检索 query。", - invocation_id="run_sess_event_backed_summary", - event_type="stage_tool_result", - metadata={ - "run_id": "run_sess_event_backed_summary", - "tool_output": { - "topic": "调研 2026 Q2和Q3金山云股票风险和评估建议", - }, - }, - session_service_provider=lambda: service, - ) - - 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"}, - ) - - assert response.status_code == 200 - session = response.json()["Data"]["Sessions"][0] - normalized_prompt = " ".join(user_prompt.split()) - assert session["SessionId"] == "sess-event-backed-summary" - assert session["FirstPrompt"] == normalized_prompt - assert session["LastPrompt"] == normalized_prompt - assert session["Title"] - assert session["Title"] != "sess-event-backed-summary" - assert session["ActiveInvocationId"] == "run_sess_event_backed_summary" - assert session["ActiveRunStatus"] == "in_progress" - - -@pytest.mark.asyncio -async def test_session_actions_prefer_latest_run_status_when_previous_run_completed(monkeypatch): - """切换/刷新会话时,新后台 run 进行中不能被同 session 的旧 completed run 盖掉。""" - server_app_module = importlib.import_module("ksadk.server.app") - conversation_runtime = importlib.import_module("ksadk.conversations.runtime") - service = InMemorySessionService() - session = await service.create_session( - agent_id="demo-agent", - user_id="user-1", - session_id="sess-new-run-active", - ) - - await conversation_runtime.append_run_status_event( - session_id=session.id, - author="demo-agent", - status="completed", - invocation_id="run_old_completed", - session_service_provider=lambda: service, - ) - await conversation_runtime.append_run_status_event( - session_id=session.id, - author="demo-agent", - status="in_progress", - invocation_id="run_new_active", - session_service_provider=lambda: service, - ) - - 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: - listed = await client.post( - "/agentengine/api/v1/ListSessions", - json={"AgentId": "demo-agent", "UserId": "user-1"}, - ) - fetched = await client.post( - "/agentengine/api/v1/GetSession", - json={"SessionId": session.id}, - ) - - assert listed.status_code == 200 - assert fetched.status_code == 200 - for payload in ( - listed.json()["Data"]["Sessions"][0], - fetched.json()["Data"]["Session"], - ): - assert payload["ActiveInvocationId"] == "run_new_active" - assert payload["ActiveRunStatus"] == "in_progress" - - -@pytest.mark.asyncio -async def test_session_actions_return_503_when_session_backend_unavailable(monkeypatch): - server_app_module = importlib.import_module("ksadk.server.app") - service = _UnavailableSessionService() - - 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: - create_response = await client.post( - "/agentengine/api/v1/CreateSession", - json={"AgentId": "demo-agent", "UserId": "user-1"}, - ) - list_response = await client.post( - "/agentengine/api/v1/ListSessions", - json={"AgentId": "demo-agent", "UserId": "user-1"}, - ) - - assert create_response.status_code == 503 - assert create_response.json()["detail"]["code"] == "session_backend_unavailable" - assert list_response.status_code == 503 - assert list_response.json()["detail"]["code"] == "session_backend_unavailable" - - -@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") - service = InMemorySessionService() - session = await service.create_session( - agent_id="demo-agent", - user_id="user-1", - session_id="sess-inline-state", - ) - await service.update_state( - agent_id="demo-agent", - user_id="user-1", - session_id=session.id, - scope="session", - state_delta={ - "__ksadk_attachment_context__": { - "attachments": [ - { - "display_name": "photo.png", - "mime_type": "image/png", - "transport": "inline", - "data": base64.b64encode(b"image bytes").decode("ascii"), - "size_bytes": 11, - } - ], - "attachment_results": [ - { - "display_name": "photo.png", - "mime_type": "image/png", - "transport": "inline", - "text": "识别出的文字", - "text_excerpt": "识别出的文字", - } - ], - } - }, - ) - - 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: - listed = await client.post( - "/agentengine/api/v1/ListSessions", - json={"AgentId": "demo-agent", "UserId": "user-1"}, - ) - fetched = await client.post( - "/agentengine/api/v1/GetSession", - json={"SessionId": session.id}, - ) - - assert listed.status_code == 200 - assert fetched.status_code == 200 - for payload in ( - listed.json()["Data"]["Sessions"][0], - fetched.json()["Data"]["Session"], - ): - state_context = payload["State"]["__ksadk_attachment_context__"] - attachment = state_context["attachments"][0] - assert attachment == { - "display_name": "photo.png", - "mime_type": "image/png", - "transport": "inline", - "size_bytes": 11, - } - assert "data" not in json.dumps(state_context, ensure_ascii=False) - assert state_context["attachment_results"][0]["text_excerpt"] == "识别出的文字" - assert "text" not in state_context["attachment_results"][0] - - -@pytest.mark.asyncio -async def test_local_feedback_actions_upsert_get_and_delete(monkeypatch): - server_app_module = importlib.import_module("ksadk.server.app") - service = InMemorySessionService() - session = await service.create_session( - agent_id="demo-agent", - user_id="user-1", - session_id="sess-local-feedback", - ) - assistant_event = await service.append_event( - session.id, - SessionEvent.from_dict( - { - "author": "demo-agent", - "event_type": "assistant_message", - "content": {"role": "model", "parts": [{"text": "assistant says hi"}]}, - "metadata": { - "response_id": "resp_local_feedback", - "trace_id": "trace-local", - "root_span_id": "span-local", - }, - }, - session_id=session.id, - ), - ) - - 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: - created = await client.post( - "/agentengine/api/v1/UpsertResponseFeedback", - json={ - "AgentId": "demo-agent", - "SessionId": session.id, - "ResponseId": "resp_local_feedback", - "EventId": assistant_event.id, - "Rating": "down", - "Comment": "不够具体", - }, - ) - fetched = await client.post( - "/agentengine/api/v1/GetResponseFeedback", - json={ - "AgentId": "demo-agent", - "SessionId": session.id, - "ResponseId": "resp_local_feedback", - }, - ) - deleted = await client.post( - "/agentengine/api/v1/DeleteResponseFeedback", - json={ - "AgentId": "demo-agent", - "SessionId": session.id, - "ResponseId": "resp_local_feedback", - }, - ) - fetched_after_delete = await client.post( - "/agentengine/api/v1/GetResponseFeedback", - json={ - "AgentId": "demo-agent", - "SessionId": session.id, - "ResponseId": "resp_local_feedback", - }, - ) - - assert created.status_code == 200 - feedback = created.json()["Data"]["Feedback"] - assert feedback["AgentId"] == "demo-agent" - assert feedback["SessionId"] == session.id - assert feedback["ResponseId"] == "resp_local_feedback" - assert feedback["EventId"] == assistant_event.id - assert feedback["Rating"] == "down" - assert feedback["Comment"] == "不够具体" - assert feedback["TraceId"] == "trace-local" - assert feedback["RootSpanId"] == "span-local" - - assert fetched.status_code == 200 - assert fetched.json()["Data"]["Feedback"]["Rating"] == "down" - assert deleted.status_code == 200 - assert deleted.json()["Data"] == {"Deleted": True} - assert fetched_after_delete.status_code == 200 - assert fetched_after_delete.json()["Data"]["Feedback"] is None - - -@pytest.mark.asyncio -async def test_run_sse_stream_emits_authoritative_final_event_when_output_overrides_partials( - 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( - "/run_sse", - json=AgentRunRequest( - appName="demo-agent", - userId="user-1", - sessionId=None, - newMessage={"role": "user", "parts": [{"text": "hello"}]}, - streaming=True, - ).model_dump(), - ) - - assert response.status_code == 200 - payloads = _sse_payloads(response.text) - assert [payload["content"]["parts"][0]["text"] for payload in payloads] == [ - "hel", - "lo", - "goodbye", - ] - assert payloads[0]["partial"] is True - assert payloads[1]["partial"] is True - assert "partial" not in payloads[2] - - session_id = payloads[0]["sessionId"] - events = await service.get_events(session_id) - assert [event.author for event in events] == ["user", "demo-agent", "demo-agent", "demo-agent"] - assert [event.event_type for event in events] == [ - "user_message", - "run_status", - "assistant_message", - "run_status", - ] - assert events[-2].content["parts"][0]["text"] == "goodbye" - - -@pytest.mark.asyncio -async def test_run_sse_stream_emits_compaction_status_events(monkeypatch): - server_app_module = importlib.import_module("ksadk.server.app") - conversation_runtime = importlib.import_module("ksadk.conversations.runtime") - model_context_module = importlib.import_module("ksadk.conversations.model_context") - service = InMemorySessionService() - runner = _OverrideStreamingRunner() - session = await service.create_session( - agent_id="demo-agent", - user_id="user-1", - session_id="session-with-history", - ) - - monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service) - server_app_module.set_runner(runner) - monkeypatch.setattr(conversation_runtime, "AUTOCOMPACT_KEEP_TAIL_GROUPS", 1) - monkeypatch.setattr(model_context_module, "DEFAULT_CONTEXT_WINDOW_TOKENS", 30) - monkeypatch.setattr(model_context_module, "DEFAULT_MAX_OUTPUT_TOKENS", 0) - monkeypatch.setattr(model_context_module, "AUTOCOMPACT_SUMMARY_RESERVE_TOKENS", 0) - monkeypatch.setattr(model_context_module, "AUTOCOMPACT_BUFFER_TOKENS", 2) - - for turn_index in range(2): - invocation_id = f"seed-{turn_index}" - seed_text = f"历史消息 {turn_index} " + ("很长 " * 12) - await conversation_runtime.append_conversation_event( - session_id=session.id, - author="user", - role="user", - text=seed_text, - invocation_id=invocation_id, - event_type="user_message", - session_service_provider=lambda: service, - metadata={"agent_input": seed_text}, - ) - await conversation_runtime.append_conversation_event( - session_id=session.id, - author="demo-agent", - role="model", - text=f"历史回复 {turn_index} " + ("继续 " * 12), - invocation_id=invocation_id, - event_type="assistant_message", - 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( - "/run_sse", - json=AgentRunRequest( - appName="demo-agent", - userId="user-1", - sessionId=session.id, - newMessage={"role": "user", "parts": [{"text": "请继续基于历史回答"}]}, - streaming=True, - ).model_dump(), - ) - - assert response.status_code == 200 - events = _sse_events(response.text) - event_names = [event_name for event_name, _ in events] - assert event_names[:2] == [ - "response.compaction.start", - "response.compaction.done", - ] - assert event_names.count("message") >= 2 - - persisted_events = await service.get_events(session.id) - assert [event.event_type for event in persisted_events] == [ - "user_message", - "assistant_message", - "user_message", - "assistant_message", - "user_message", - "compaction_boundary", - "context_checkpoint", - "run_status", - "assistant_message", - "run_status", - ] - - -@pytest.mark.asyncio -async def test_run_sse_stream_completes_and_persists_reasoning_when_no_text_deltas(monkeypatch): - server_app_module = importlib.import_module("ksadk.server.app") - service = InMemorySessionService() - runner = _ThinkingOnlyFinalRunner() - - 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( - "/run_sse", - json=AgentRunRequest( - appName="demo-agent", - userId="user-1", - sessionId="sess-run-sse-thinking", - newMessage={"role": "user", "parts": [{"text": "hello"}]}, - streaming=True, - ).model_dump(), - ) - - assert response.status_code == 200 - events = await service.get_events("sess-run-sse-thinking") - assert [event.event_type for event in events] == [ - "user_message", - "run_status", - "reasoning", - "assistant_message", - "run_status", - ] - assert events[2].content["parts"][0]["text"] == "先想一下" - assert events[-2].content["parts"][0]["text"] == "final answer" - assert events[-1].content["status"] == "completed" - - -@pytest.mark.asyncio -async def test_run_sse_prepares_runner_model_and_forwards_model_to_invoke(monkeypatch): - server_app_module = importlib.import_module("ksadk.server.app") - service = InMemorySessionService() - runner = _ModelAwareRunner() - - 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( - "/run_sse", - json=AgentRunRequest( - appName="demo-agent", - userId="user-1", - sessionId=None, - newMessage={"role": "user", "parts": [{"text": "hello"}]}, - streaming=False, - model="gpt-4o", - ).model_dump(), - ) - - assert response.status_code == 200 - assert runner.prepared_models == ["gpt-4o"] - assert runner.calls[-1]["model"] == "gpt-4o" - - -@pytest.mark.asyncio -async def test_chat_completions_forwards_model_to_runner(monkeypatch): - server_app_module = importlib.import_module("ksadk.server.app") - service = InMemorySessionService() - runner = _ModelAwareRunner() - - 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/chat/completions", - json={ - "messages": [{"role": "user", "content": "hello"}], - "stream": False, - "model": "glm-5.1", - "account_id": "acct-chat", - }, - ) - - assert response.status_code == 200 - assert runner.prepared_models == ["glm-5.1"] - assert runner.calls[-1]["model"] == "glm-5.1" - assert runner.calls[-1]["platform_context"]["account_id"] == "acct-chat" - - -@pytest.mark.asyncio -async def test_chat_completions_converts_chat_content_blocks_to_runner_responses_input(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) - - image_url = "data:image/png;base64,aW1hZ2U=" - 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/chat/completions", - json={ - "messages": [ - { - "role": "user", - "content": [ - {"type": "text", "text": "看图"}, - {"type": "image_url", "image_url": {"url": image_url}}, - ], - } - ], - "stream": False, - "model": "gpt-4o", - }, - ) - - payload = response.json() - assert response.status_code == 200 - assert payload["object"] == "chat.completion" - assert payload["choices"][0]["message"]["role"] == "assistant" - assert runner.calls[-1]["input_content"] == [ - {"type": "input_text", "text": "看图"}, - {"type": "input_image", "image_url": image_url}, - ] - assert runner.calls[-1]["input_messages"] == [ - { - "role": "user", - "content": [ - {"type": "input_text", "text": "看图"}, - {"type": "input_image", "image_url": image_url}, - ], - } - ] - assert runner.calls[-1]["input_parts"] == [ - {"text": "看图"}, - { - "inlineData": { - "data": "aW1hZ2U=", - "mimeType": "image/png", - "displayName": "uploaded_image", - } - }, - ] - - -@pytest.mark.asyncio -async def test_chat_completions_non_stream_preserves_response_feedback_metadata(monkeypatch): - server_app_module = importlib.import_module("ksadk.server.app") - runner = _ModelAwareRunner() - - async def _fake_invoke_conversation_once(**kwargs): - return "sess-trace", { - "output_text": "assistant says hi", - "metadata": { - "trace_id": "08c19ddddce0b1ddd29407dc637e1c89", - "root_span_id": "74cc406c8e9ded4a", - }, - } - - monkeypatch.setattr( - server_app_module.conversation, - "invoke_conversation_once", - _fake_invoke_conversation_once, - ) - 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/chat/completions", - json={ - "messages": [{"role": "user", "content": "hello"}], - "stream": False, - "model": "glm-5.1", - }, - ) - - assert response.status_code == 200 - payload = response.json() - assert payload["metadata"] == { - "trace_id": "08c19ddddce0b1ddd29407dc637e1c89", - "root_span_id": "74cc406c8e9ded4a", - } - - -@pytest.mark.asyncio -async def test_chat_completions_passes_attachment_results_to_runner(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) - - attachment_b64 = base64.b64encode("候选人简历内容".encode("utf-8")).decode("ascii") - 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/chat/completions", - json={ - "messages": [ - { - "role": "user", - "content": [ - {"text": "请分析附件"}, - { - "inlineData": { - "displayName": "resume.txt", - "mimeType": "text/plain", - "data": attachment_b64, - } - }, - ], - } - ], - "stream": False, - }, - ) - - assert response.status_code == 200 - assert runner.calls[-1]["attachment_results"] == [ - { - "display_name": "resume.txt", - "mime_type": "text/plain", - "transport": "inline", - "file_uri": "", - "size_bytes": len("候选人简历内容".encode("utf-8")), - "kind": "text", - "status": "ok", - "warnings": [], - "extraction_method": "text_decode", - "text_excerpt": "候选人简历内容", - "text": "候选人简历内容", - } - ] - - -@pytest.mark.asyncio -async def test_chat_completions_reuses_prior_attachment_results_on_follow_up_turn(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) - - attachment_b64 = base64.b64encode("候选人简历内容".encode("utf-8")).decode("ascii") - transport = httpx.ASGITransport(app=server_app_module.app) - async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client: - first_response = await client.post( - "/v1/chat/completions", - json={ - "messages": [ - { - "role": "user", - "content": [ - {"text": "请分析附件"}, - { - "inlineData": { - "displayName": "resume.txt", - "mimeType": "text/plain", - "data": attachment_b64, - } - }, - ], - } - ], - "stream": False, - }, - ) - first_payload = first_response.json() - session_id = first_payload["session_id"] - - second_response = await client.post( - "/v1/chat/completions", - json={ - "messages": [{"role": "user", "content": "继续分析"}], - "session_id": session_id, - "stream": False, - }, - ) - - assert first_response.status_code == 200 - assert second_response.status_code == 200 - assert runner.calls[-1]["attachment_results"] == [ - { - "display_name": "resume.txt", - "mime_type": "text/plain", - "transport": "inline", - "size_bytes": len("候选人简历内容".encode("utf-8")), - "kind": "text", - "status": "ok", - "warnings": [], - "extraction_method": "text_decode", - "text_excerpt": "候选人简历内容", - } - ] - - -@pytest.mark.asyncio -async def test_list_agent_models_action_normalizes_default_metadata(monkeypatch): - server_app_module = importlib.import_module("ksadk.server.app") - real_async_client = httpx.AsyncClient - monkeypatch.setenv("OPENAI_BASE_URL", "https://kspmas.ksyun.com/v1") - monkeypatch.setenv("OPENAI_API_KEY", "secret-key") - monkeypatch.setenv("OPENAI_MODEL_NAME", "glm-5.1") - monkeypatch.setattr( - "httpx.AsyncClient", - lambda *args, **kwargs: _ExternalModelsAsyncClient( - *args, - payload={"data": [{"id": "glm-5.1"}]}, - **kwargs, - ), - ) - - transport = httpx.ASGITransport(app=server_app_module.app) - async with real_async_client(transport=transport, base_url="http://ksadk.local") as client: - response = await client.post( - "/agentengine/api/v1/ListAgentModels", - json={"AgentId": "demo-agent"}, - ) - - assert response.status_code == 200 - payload = response.json()["Data"] - assert payload["Current"] == "glm-5.1" - assert payload["Models"] == [ - { - "id": "glm-5.1", - "display_name": "glm-5.1", - "context_window_tokens": 200000, - "max_output_tokens": 32000, - "auto_compact_threshold_tokens": 167000, - "auto_compact_threshold_percentage": 84, - "capabilities": { - "function_calling": True, - "structured_output": True, - "context_caching": True, - "multimodal_input_image": False, - "multimodal_input_video": False, - "multimodal_input_file": False, - }, - "limits": { - "context_window_tokens": 200000, - "max_input_tokens": 200000, - "max_output_tokens": 32000, - "max_reasoning_tokens": 32000, - "rpm": 500, - "tpm": 1000000, - }, - "pricing": { - "online_input_per_million": 4.0, - "online_output_per_million": 18.0, - "batch_input_per_million": 2.0, - "batch_output_per_million": 9.0, - "online_cache_hit_input_per_million": 1.0, - "batch_cache_hit_input_per_million": 1.0, - }, - } - ] - - -@pytest.mark.asyncio -async def test_list_agent_models_action_preserves_upstream_fields_and_normalizes_aliases(monkeypatch): - server_app_module = importlib.import_module("ksadk.server.app") - real_async_client = httpx.AsyncClient - monkeypatch.setenv("OPENAI_BASE_URL", "https://kspmas.ksyun.com/v1") - monkeypatch.setenv("OPENAI_MODEL_NAME", "kimi-k2.6") - monkeypatch.setattr( - "httpx.AsyncClient", - lambda *args, **kwargs: _ExternalModelsAsyncClient( - *args, - payload={ - "data": [ - { - "id": "kimi-k2.6", - "owned_by": "ksyun", - "context_length": 131072, - "max_tokens": 4096, - } - ] - }, - **kwargs, - ), - ) - - transport = httpx.ASGITransport(app=server_app_module.app) - async with real_async_client(transport=transport, base_url="http://ksadk.local") as client: - response = await client.post( - "/agentengine/api/v1/ListAgentModels", - json={"AgentId": "demo-agent"}, - ) - - assert response.status_code == 200 - item = response.json()["Data"]["Models"][0] - assert item["id"] == "kimi-k2.6" - assert item["owned_by"] == "ksyun" - assert item["context_length"] == 131072 - assert item["max_tokens"] == 4096 - assert item["context_window_tokens"] == 131072 - assert item["max_output_tokens"] == 4096 - assert item["limits"]["context_window_tokens"] == 131072 - assert item["limits"]["max_output_tokens"] == 4096 - - -@pytest.mark.asyncio -async def test_list_agent_models_action_normalizes_kspmas_string_token_limits(monkeypatch): - server_app_module = importlib.import_module("ksadk.server.app") - real_async_client = httpx.AsyncClient - monkeypatch.setenv("OPENAI_BASE_URL", "https://kspmas.ksyun.com/v1") - monkeypatch.setenv("OPENAI_MODEL_NAME", "glm-5.1") - monkeypatch.setattr( - "httpx.AsyncClient", - lambda *args, **kwargs: _ExternalModelsAsyncClient( - *args, - payload={ - "data": [ - { - "id": "glm-5.1", - "context_length": "200k", - "max_completion_tokens": "128k", - "architecture": { - "input_modalities": ["文字"], - "output_modalities": ["文字"], - }, - "pricing": { - "prompt": "6", - "completion": "24", - }, - }, - { - "id": "deepseek-v3.2", - "context_length": "128", - "max_completion_tokens": "32", - }, - ] - }, - **kwargs, - ), - ) - - transport = httpx.ASGITransport(app=server_app_module.app) - async with real_async_client(transport=transport, base_url="http://ksadk.local") as client: - response = await client.post( - "/agentengine/api/v1/ListAgentModels", - json={"AgentId": "demo-agent"}, - ) - - assert response.status_code == 200 - items = {item["id"]: item for item in response.json()["Data"]["Models"]} - assert items["glm-5.1"]["context_window_tokens"] == 200000 - assert items["glm-5.1"]["max_output_tokens"] == 128000 - assert items["glm-5.1"]["limits"]["context_window_tokens"] == 200000 - assert items["glm-5.1"]["limits"]["max_output_tokens"] == 128000 - assert items["glm-5.1"]["auto_compact_threshold_tokens"] == 167000 - assert items["glm-5.1"]["architecture"]["input_modalities"] == ["文字"] - assert items["glm-5.1"]["capabilities"]["multimodal_input_image"] is False - assert items["glm-5.1"]["pricing"]["prompt"] == "6" - assert items["deepseek-v3.2"]["context_window_tokens"] == 128000 - assert items["deepseek-v3.2"]["max_output_tokens"] == 32000 - assert items["deepseek-v3.2"]["auto_compact_threshold_tokens"] == 95000 - - -@pytest.mark.asyncio -async def test_list_agent_models_action_without_api_base_returns_default_metadata(monkeypatch): - server_app_module = importlib.import_module("ksadk.server.app") - monkeypatch.delenv("OPENAI_BASE_URL", raising=False) - monkeypatch.delenv("OPENAI_API_BASE", raising=False) - monkeypatch.setenv("OPENAI_MODEL_NAME", "glm-5.1") - - 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/ListAgentModels", - json={"AgentId": "demo-agent"}, - ) - - assert response.status_code == 200 - payload = response.json()["Data"] - assert payload["Current"] == "glm-5.1" - assert [item["id"] for item in payload["Models"]] == ["glm-5.1"] - assert payload["Models"][0]["context_window_tokens"] == 200000 - assert payload["Models"][0]["limits"]["max_output_tokens"] == 32000 - - -@pytest.mark.asyncio -async def test_openai_models_route_exposes_current_catalog(monkeypatch): - server_app_module = importlib.import_module("ksadk.server.app") - monkeypatch.delenv("OPENAI_BASE_URL", raising=False) - monkeypatch.delenv("OPENAI_API_BASE", raising=False) - monkeypatch.setenv("OPENAI_MODEL_NAME", "glm-5.1") - transport = httpx.ASGITransport(app=server_app_module.app) - - async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client: - response = await client.get("/v1/models") - - assert response.status_code == 200 - payload = response.json() - assert payload["object"] == "list" - assert payload["current"] == "glm-5.1" - assert [item["id"] for item in payload["data"]] == ["glm-5.1"] - - -@pytest.mark.asyncio -async def test_responses_fetches_remote_model_metadata_and_passes_to_runner(monkeypatch): - server_app_module = importlib.import_module("ksadk.server.app") - real_async_client = httpx.AsyncClient - service = InMemorySessionService() - runner = _DummyRunner() - - monkeypatch.setattr(server_app_module, "runner", runner) - monkeypatch.setattr(server_app_module, "_runner_loaded", True) - monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service) - monkeypatch.setenv("OPENAI_BASE_URL", "https://kspmas.ksyun.com/v1") - monkeypatch.setenv("OPENAI_API_KEY", "secret-key") - monkeypatch.setattr( - "httpx.AsyncClient", - lambda *args, **kwargs: _ExternalModelsAsyncClient( - *args, - payload={ - "data": [ - { - "id": "kimi-k2.6", - "architecture": { - "input_modalities": ["文字", "图片", "视频"], - "output_modalities": ["文字"], - }, - } - ] - }, - **kwargs, - ), - ) - - transport = httpx.ASGITransport(app=server_app_module.app) - async with real_async_client(transport=transport, base_url="http://ksadk.local") as client: - response = await client.post( - "/v1/responses", - json={ - "model": "kimi-k2.6", - "input": "请分析图片", - "stream": False, - }, - ) - - assert response.status_code == 200 - assert runner.calls[0]["model_metadata"]["id"] == "kimi-k2.6" - assert runner.calls[0]["model_metadata"]["architecture"]["input_modalities"] == ["文字", "图片", "视频"] - assert runner.calls[0]["model_metadata"]["capabilities"]["multimodal_input_image"] is True - - -@pytest.mark.asyncio -async def test_responses_uses_official_conversation_as_runtime_session(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-a", - "safety_identifier": "user-a", - "account_id": "acct-a", - "stream": False, - }, - ) - - assert response.status_code == 200 - payload = response.json() - assert payload["session_id"] == "conv-a" - session = await service.get_session("conv-a") - assert session is not None - assert session.user_id == "user-a" - assert runner.calls[-1]["session_id"] == "conv-a" - assert runner.calls[-1]["platform_context"]["user_id"] == "user-a" - 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_status", - "assistant_message", - "run_status", - ] - # resume 现在会先写 run_status(resuming) 再写 run_status(in_progress) - assert [event.content["status"] for event in events if event.event_type == "run_status"] == [ - "resuming", - "in_progress", - "completed", - ] - assert len([event for event in events if event.event_type == "run_checkpoint"]) == 1 - - -@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_stream_responses_checkpoint_resume_rejects_concurrent_resume_for_same_run(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-a", session_id="conv-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="conv-resume-stream", - author="demo-agent", - run_id="run-1", - checkpoint_id="ckpt-1", - framework="langgraph", - framework_ref={ - "langgraph": { - "thread_id": "tenant:agent:conv-resume-stream", - "checkpoint_id": "ckpt-1", - } - }, - invocation_id="inv-checkpoint", - session_service_provider=lambda: service, - ) - - original_detached_streaming_response = server_app_module._detached_streaming_response - - def start_detached_stream_and_return_accepted(source, *, invocation_id=None, **kwargs): - original_detached_streaming_response(source, invocation_id=invocation_id, **kwargs) - return Response(status_code=202) - - monkeypatch.setattr( - server_app_module, - "_detached_streaming_response", - start_detached_stream_and_return_accepted, - ) - - 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": "forged-client-thread", - "checkpoint_id": "forged-client-checkpoint", - } - }, - } - transport = httpx.ASGITransport(app=server_app_module.app) - async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client: - first_response = await client.post( - "/v1/responses", - json={ - "input": [resume_input], - "conversation": "conv-resume-stream", - "safety_identifier": "user-a", - "metadata": {"agentengine": {"invocation_id": "responses-resume-1"}}, - "stream": True, - }, - ) - second_response = await client.post( - "/v1/responses", - json={ - "input": [{**resume_input, "resume_attempt_id": "resume-2"}], - "conversation": "conv-resume-stream", - "safety_identifier": "user-a", - "metadata": {"agentengine": {"invocation_id": "responses-resume-2"}}, - "stream": True, - }, - ) - - assert first_response.status_code == 202 - assert second_response.status_code == 409 - detail = second_response.json()["detail"] - assert detail["code"] == "resume_already_running" - # 409 detail 字段契约:snake_case(与 checkpoint_not_resumable 对齐) - assert isinstance(detail["session_id"], str) and detail["session_id"] - assert isinstance(detail["invocation_id"], str) and detail["invocation_id"] - assert isinstance(detail["run_id"], str) and detail["run_id"] - - -@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_run_agent_stream_checkpoint_resume_rejects_concurrent_resume_for_same_run(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-runagent-resume-concurrent", - ) - 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-concurrent", - author="demo-agent", - run_id="run-1", - checkpoint_id="ckpt-1", - framework="langgraph", - framework_ref={ - "langgraph": { - "thread_id": "tenant:agent:sess-runagent-resume-concurrent", - "checkpoint_id": "ckpt-1", - } - }, - invocation_id="inv-checkpoint", - session_service_provider=lambda: service, - ) - - original_detached_streaming_response = server_app_module._detached_streaming_response - - def start_detached_stream_and_return_accepted(source, *, invocation_id=None, **kwargs): - original_detached_streaming_response(source, invocation_id=invocation_id, **kwargs) - return Response(status_code=202) - - monkeypatch.setattr( - server_app_module, - "_detached_streaming_response", - start_detached_stream_and_return_accepted, - ) - - 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": "forged-client-thread", - "checkpoint_id": "forged-client-checkpoint", - } - }, - } - ] - transport = httpx.ASGITransport(app=server_app_module.app) - async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client: - first_response = await client.post( - "/agentengine/api/v1/RunAgent", - json={ - "AgentId": "demo-agent", - "SessionId": "sess-runagent-resume-concurrent", - "UserId": "user-1", - "ApiFormat": "responses", - "Stream": True, - "InvocationId": "runagent-resume-1", - "ResponsesInput": resume_input, - }, - ) - second_response = await client.post( - "/agentengine/api/v1/RunAgent", - json={ - "AgentId": "demo-agent", - "SessionId": "sess-runagent-resume-concurrent", - "UserId": "user-1", - "ApiFormat": "responses", - "Stream": True, - "InvocationId": "runagent-resume-2", - "ResponsesInput": resume_input, - }, - ) - - assert first_response.status_code == 202 - assert second_response.status_code == 409 - assert second_response.json()["detail"]["code"] == "resume_already_running" - - -@pytest.mark.asyncio -async def test_responses_accepts_official_conversation_object(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": {"id": "conv-object"}, - "safety_identifier": "user-object", - "stream": False, - }, - ) - - assert response.status_code == 200 - assert response.json()["session_id"] == "conv-object" - session = await service.get_session("conv-object") - assert session is not None - 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, **kwargs): - del kwargs - 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") - 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-user", - "user": "deprecated-user", - "stream": False, - }, - ) - - assert response.status_code == 200 - session = await service.get_session("conv-user") - assert session is not None - 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") - 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-a", - "session_id": "legacy-b", - "stream": False, - }, - ) - - assert response.status_code == 400 - assert "conversation" in response.text - assert "session_id" in response.text - - -@pytest.mark.asyncio -async def test_responses_rejects_conversation_with_previous_response_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-a", - "previous_response_id": "resp_previous", - "stream": False, - }, - ) - - assert response.status_code == 400 - assert "conversation" in response.text - assert "previous_response_id" in response.text - - -@pytest.mark.asyncio -async def test_responses_legacy_session_id_still_works(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", - "session_id": "legacy-session", - "stream": False, - }, - ) - - assert response.status_code == 200 - assert response.json()["session_id"] == "legacy-session" - session = await service.get_session("legacy-session") - assert session is not None - assert session.user_id == "user" - - -@pytest.mark.asyncio -async def test_responses_events_are_visible_through_runtime_local_list_session_events(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: - run_response = await client.post( - "/v1/responses", - json={ - "input": "hello", - "conversation": "conv-events", - "safety_identifier": "user-events", - "stream": False, - }, - ) - events_response = await client.post( - "/agentengine/api/v1/ListSessionEvents", - json={"SessionId": "conv-events"}, - ) - - assert run_response.status_code == 200 - assert events_response.status_code == 200 - events = events_response.json()["Data"]["Events"] - message_events = [event for event in events if event["EventType"] in {"user_message", "assistant_message"}] - assert [event["Author"] for event in message_events] == ["user", "demo-agent"] - assert message_events[0]["Content"]["parts"][0]["text"] == "hello" - 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": 0, - "Limit": 2, - }, - ) - - assert response.status_code == 200 - data = response.json()["Data"] - assert data["Offset"] == 0 - assert data["Limit"] == 2 - assert data["Total"] == 4 - assert [event["SeqId"] for event in data["Events"]] == [3, 4] - - -@pytest.mark.asyncio -async def test_runtime_local_list_session_events_filters_by_after_seq_id(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-after", - ) - for index in range(4): - await service.append_event( - "sess-events-after", - 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-after", - "AfterSeqId": 2, - }, - ) - - assert response.status_code == 200 - data = response.json()["Data"] - assert [event["SeqId"] for event in data["Events"]] == [3, 4] - assert data["Total"] == 2 - assert data["AfterSeqId"] == 2 - - -@pytest.mark.asyncio -async def test_runtime_local_list_session_events_filters_by_before_seq_id(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-before", - ) - for index in range(5): - await service.append_event( - "sess-events-before", - 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-before", - "BeforeSeqId": 4, - "Limit": 2, - }, - ) - - assert response.status_code == 200 - data = response.json()["Data"] - assert [event["SeqId"] for event in data["Events"]] == [2, 3] - assert data["Total"] == 3 - assert data["BeforeSeqId"] == 4 - - -@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" - assert checkpoint["IsResumable"] is None - assert checkpoint["ResumeStatus"] == "unknown" - assert checkpoint["ResumeDisabledReason"] == "" - assert checkpoint["IsTerminal"] is False - assert checkpoint["Backend"] == "unknown" - assert checkpoint["Scope"] == "unknown" - assert checkpoint["Durable"] is False - - -@pytest.mark.asyncio -async def test_list_session_checkpoints_filters_resumable_and_framework(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-filter-checkpoints") - monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service) - await conversation_runtime.append_run_checkpoint_event( - session_id="sess-filter-checkpoints", - author="demo-agent", - run_id="run-1", - checkpoint_id="ckpt-resumable", - framework="langgraph", - framework_ref={ - "langgraph": { - "thread_id": "sess-filter-checkpoints", - "checkpoint_id": "ckpt-resumable", - "next_node": "search", - } - }, - metadata={"is_resumable": True, "backend": "postgres", "scope": "shared", "durable": True}, - session_service_provider=lambda: service, - ) - await conversation_runtime.append_run_checkpoint_event( - session_id="sess-filter-checkpoints", - author="demo-agent", - run_id="run-1", - checkpoint_id="ckpt-terminal", - framework="langgraph", - framework_ref={ - "langgraph": { - "thread_id": "sess-filter-checkpoints", - "checkpoint_id": "ckpt-terminal", - "next_node": "", - } - }, - metadata={"is_terminal": True, "is_resumable": False}, - session_service_provider=lambda: service, - ) - await conversation_runtime.append_run_checkpoint_event( - session_id="sess-filter-checkpoints", - author="demo-agent", - run_id="run-1", - checkpoint_id="ckpt-adk", - framework="adk", - framework_ref={"adk": {"session_id": "sess-filter-checkpoints"}}, - metadata={"is_resumable": False}, - 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-filter-checkpoints", - "Framework": "langgraph", - "OnlyResumable": True, - }, - ) - - assert response.status_code == 200 - checkpoints = response.json()["Data"]["Checkpoints"] - assert [item["CheckpointId"] for item in checkpoints] == ["ckpt-resumable"] - assert checkpoints[0]["IsResumable"] is True - assert checkpoints[0]["ResumeStatus"] == "resumable" - assert checkpoints[0]["NextNode"] == "search" - assert checkpoints[0]["Backend"] == "postgres" - - -@pytest.mark.asyncio -async def test_list_session_checkpoints_is_the_single_checkpoint_listing_endpoint(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-list-checkpoints") - monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service) - for index in range(3): - await conversation_runtime.append_run_checkpoint_event( - session_id="sess-list-checkpoints", - author="demo-agent", - run_id="run-page", - checkpoint_id=f"ckpt-{index}", - framework="langgraph", - framework_ref={ - "langgraph": { - "thread_id": "sess-list-checkpoints", - "checkpoint_id": f"ckpt-{index}", - "next_node": "stage", - } - }, - metadata={ - "is_resumable": True, - "backend": "postgres", - "scope": "shared", - "durable": True, - "stage_index": index + 1, - "total_stages": 3, - }, - session_service_provider=lambda: service, - ) - - payload = { - "AgentId": "demo-agent", - "SessionId": "sess-list-checkpoints", - "RunId": "run-page", - "Offset": 1, - "Limit": 1, - } - transport = httpx.ASGITransport(app=server_app_module.app) - async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client: - removed_response = await client.post("/agentengine/api/v1/ListCheckpoints", json=payload) - response = await client.post("/agentengine/api/v1/ListSessionCheckpoints", json=payload) - removed_preview_response = await client.post( - "/agentengine/api/v1/PreviewCheckpointResume", - json={**payload, "CheckpointId": "ckpt-1"}, - ) - - assert removed_response.status_code in {404, 405} - assert removed_preview_response.status_code in {404, 405} - assert response.status_code == 200 - data = response.json()["Data"] - assert data["Total"] == 3 - assert data["Offset"] == 1 - assert data["Limit"] == 1 - assert [item["CheckpointId"] for item in data["Checkpoints"]] == ["ckpt-1"] - checkpoint = data["Checkpoints"][0] - assert checkpoint["StageIndex"] == 2 - assert checkpoint["TotalStages"] == 3 - assert checkpoint["IsResumable"] is True - assert checkpoint["CreatedAt"] - - -@pytest.mark.asyncio -async def test_list_session_checkpoints_includes_resume_audit_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-checkpoint-audit") - monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service) - await conversation_runtime.append_run_checkpoint_event( - session_id="sess-checkpoint-audit", - author="demo-agent", - run_id="run-audit", - checkpoint_id="ckpt-audit", - framework="langgraph", - framework_ref={ - "langgraph": { - "thread_id": "sess-checkpoint-audit", - "checkpoint_id": "ckpt-audit", - "next_node": "report", - } - }, - metadata={ - "is_resumable": True, - "backend": "postgres", - "scope": "shared", - "durable": True, - "expires_at": "2099-06-30T12:00:00Z", - "checkpoint_status": "active", - }, - session_service_provider=lambda: service, - ) - for attempt_id in ("resume-1", "resume-2"): - await conversation_runtime.append_run_resume_event( - session_id="sess-checkpoint-audit", - author="demo-agent", - run_id="run-audit", - checkpoint_id="ckpt-audit", - resume_attempt_id=attempt_id, - framework="langgraph", - framework_ref={ - "langgraph": { - "thread_id": "sess-checkpoint-audit", - "checkpoint_id": "ckpt-audit", - } - }, - 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-checkpoint-audit"}, - ) - preview_response = await client.post( - "/agentengine/api/v1/GetCheckpointResumePreview", - json={ - "AgentId": "demo-agent", - "SessionId": "sess-checkpoint-audit", - "RunId": "run-audit", - "CheckpointId": "ckpt-audit", - }, - ) - - assert response.status_code == 200 - checkpoint = response.json()["Data"]["Checkpoints"][0] - assert checkpoint["ResumeCount"] == 2 - assert checkpoint["LastResumedAt"] - assert checkpoint["ReplayAllowed"] is True - assert checkpoint["ExpiresAt"] == "2099-06-30T12:00:00Z" - assert checkpoint["CheckpointStatus"] == "resumed" - assert checkpoint["Metadata"]["resume_count"] == 2 - assert checkpoint["Metadata"]["last_resumed_at"] == checkpoint["LastResumedAt"] - assert preview_response.status_code == 200 - preview_checkpoint = preview_response.json()["Data"]["Preview"]["Checkpoint"] - assert preview_checkpoint["ResumeCount"] == 2 - assert preview_checkpoint["LastResumedAt"] == checkpoint["LastResumedAt"] - - -@pytest.mark.asyncio -async def test_list_session_checkpoints_disables_expired_or_non_replayable_checkpoints(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-checkpoint-policy") - monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service) - await conversation_runtime.append_run_checkpoint_event( - session_id="sess-checkpoint-policy", - author="demo-agent", - run_id="run-policy", - checkpoint_id="ckpt-no-replay", - framework="langgraph", - framework_ref={"langgraph": {"thread_id": "sess-checkpoint-policy", "checkpoint_id": "ckpt-no-replay"}}, - metadata={"is_resumable": True, "replay_allowed": False}, - session_service_provider=lambda: service, - ) - await conversation_runtime.append_run_resume_event( - session_id="sess-checkpoint-policy", - author="demo-agent", - run_id="run-policy", - checkpoint_id="ckpt-no-replay", - resume_attempt_id="resume-1", - framework="langgraph", - framework_ref={"langgraph": {"thread_id": "sess-checkpoint-policy", "checkpoint_id": "ckpt-no-replay"}}, - session_service_provider=lambda: service, - ) - await conversation_runtime.append_run_checkpoint_event( - session_id="sess-checkpoint-policy", - author="demo-agent", - run_id="run-policy", - checkpoint_id="ckpt-expired", - framework="langgraph", - framework_ref={"langgraph": {"thread_id": "sess-checkpoint-policy", "checkpoint_id": "ckpt-expired"}}, - metadata={"is_resumable": True, "expires_at": "2000-01-01T00:00:00Z"}, - 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-checkpoint-policy"}, - ) - - assert response.status_code == 200 - checkpoints = { - item["CheckpointId"]: item - for item in response.json()["Data"]["Checkpoints"] - } - no_replay = checkpoints["ckpt-no-replay"] - assert no_replay["ReplayAllowed"] is False - assert no_replay["ResumeCount"] == 1 - assert no_replay["IsResumable"] is False - assert no_replay["ResumeStatus"] == "disabled" - assert no_replay["CheckpointStatus"] == "resumed" - assert "重复恢复" in no_replay["ResumeDisabledReason"] - expired = checkpoints["ckpt-expired"] - assert expired["IsResumable"] is False - assert expired["ResumeStatus"] == "disabled" - assert expired["CheckpointStatus"] == "expired" - assert "已过期" in expired["ResumeDisabledReason"] - - -@pytest.mark.asyncio -async def test_resume_run_rejects_checkpoint_policy_disabled_by_audit(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-policy") - 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-policy", - author="demo-agent", - run_id="run-policy", - checkpoint_id="ckpt-no-replay", - framework="langgraph", - framework_ref={"langgraph": {"thread_id": "sess-resume-policy", "checkpoint_id": "ckpt-no-replay"}}, - metadata={"is_resumable": True, "replay_allowed": False}, - session_service_provider=lambda: service, - ) - await conversation_runtime.append_run_resume_event( - session_id="sess-resume-policy", - author="demo-agent", - run_id="run-policy", - checkpoint_id="ckpt-no-replay", - resume_attempt_id="resume-1", - framework="langgraph", - framework_ref={"langgraph": {"thread_id": "sess-resume-policy", "checkpoint_id": "ckpt-no-replay"}}, - 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-policy", - "RunId": "run-policy", - "CheckpointId": "ckpt-no-replay", - "ResumeAttemptId": "resume-2", - "Stream": False, - }, - ) - - assert response.status_code == 409 - detail = response.json()["detail"] - assert detail["code"] == "checkpoint_not_resumable" - assert detail["resume_status"] == "disabled" - assert "重复恢复" in detail["reason"] - assert runner.calls == [] - - -@pytest.mark.asyncio -async def test_resume_run_rejects_expired_checkpoint(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-expired") - 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-expired", - author="demo-agent", - run_id="run-expired", - checkpoint_id="ckpt-expired", - framework="langgraph", - framework_ref={"langgraph": {"thread_id": "sess-resume-expired", "checkpoint_id": "ckpt-expired"}}, - metadata={"is_resumable": True, "expires_at": "2000-01-01T00:00:00Z"}, - 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-expired", - "RunId": "run-expired", - "CheckpointId": "ckpt-expired", - "ResumeAttemptId": "resume-expired", - "Stream": False, - }, - ) - - assert response.status_code == 409 - detail = response.json()["detail"] - assert detail["code"] == "checkpoint_not_resumable" - assert detail["resume_status"] == "disabled" - assert "已过期" in detail["reason"] - assert runner.calls == [] - - -@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", - metadata={ - "stage_key": "plan_research", - "stage_index": 1, - "total_stages": 7, - "artifact_preview": {"path": "research/x/01-plan.json", "kind": "json"}, - }, - 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" - assert runner.calls[-1]["metadata"]["stage_key"] == "plan_research" - assert runner.calls[-1]["metadata"]["stage_index"] == 1 - assert runner.calls[-1]["metadata"]["total_stages"] == 7 - assert runner.calls[-1]["metadata"]["artifact_preview"]["path"] == "research/x/01-plan.json" - 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_returns_noop_for_terminal_checkpoint(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-disabled") - 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-disabled", - author="demo-agent", - run_id="run-1", - checkpoint_id="ckpt-terminal", - framework="langgraph", - framework_ref={"langgraph": {"thread_id": "sess-resume-disabled", "checkpoint_id": "ckpt-terminal"}}, - metadata={ - "is_terminal": True, - "is_resumable": False, - "resume_disabled_reason": "该 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/ResumeRun", - json={ - "AgentId": "demo-agent", - "SessionId": "sess-resume-disabled", - "RunId": "run-1", - "CheckpointId": "ckpt-terminal", - "ResumeAttemptId": "resume-disabled", - }, - ) - - assert response.status_code == 200 - data = response.json()["Data"] - assert data["status"] == "noop" - assert data["Reason"] - assert runner.calls == [] - events = await service.get_events("sess-resume-disabled") - assert [event.event_type for event in events if event.event_type in {"run_resume", "run_status"}] == [ - "run_resume", - "run_status", - ] - assert events[-1].metadata["status"] == "completed" - - -@pytest.mark.asyncio -async def test_resume_run_action_rejects_process_local_checkpoint(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-local") - 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-local", - author="demo-agent", - run_id="run-1", - checkpoint_id="ckpt-local", - framework="langgraph", - framework_ref={"langgraph": {"thread_id": "sess-resume-local", "checkpoint_id": "ckpt-local"}}, - metadata={ - "is_resumable": False, - "backend": "memory", - "scope": "process_local", - "resume_disabled_reason": "进程内 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/ResumeRun", - json={ - "AgentId": "demo-agent", - "SessionId": "sess-resume-local", - "RunId": "run-1", - "CheckpointId": "ckpt-local", - "ResumeAttemptId": "resume-local", - }, - ) - - assert response.status_code == 409 - assert response.json()["detail"]["code"] == "checkpoint_not_resumable" - assert "进程内" in response.json()["detail"]["reason"] - assert runner.calls == [] - - -@pytest.mark.asyncio -async def test_get_checkpoint_resume_preview_reports_terminal_checkpoint_as_disabled(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-preview-terminal") - monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service) - await conversation_runtime.append_run_checkpoint_event( - session_id="sess-preview-terminal", - author="demo-agent", - run_id="run-1", - checkpoint_id="ckpt-terminal", - framework="langgraph", - framework_ref={"langgraph": {"thread_id": "sess-preview-terminal", "checkpoint_id": "ckpt-terminal"}}, - metadata={"is_terminal": True, "is_resumable": False}, - 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/GetCheckpointResumePreview", - json={ - "AgentId": "demo-agent", - "SessionId": "sess-preview-terminal", - "RunId": "run-1", - "CheckpointId": "ckpt-terminal", - }, - ) - - assert response.status_code == 200 - preview = response.json()["Data"]["Preview"] - assert preview["CanResume"] is False - assert preview["ExpectedAction"] == "disabled" - assert "终态" in preview["Reason"] - - -@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, **kwargs): - del kwargs - 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_passes_checkpoint_metadata_to_runner(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-metadata") - 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-metadata", - author="demo-agent", - run_id="run-1", - checkpoint_id="ckpt-1", - framework="langgraph", - framework_ref={"langgraph": {"thread_id": "tenant:agent:sess-resume-stream-metadata", "checkpoint_id": "ckpt-1"}}, - invocation_id="inv-checkpoint", - metadata={"stage_key": "plan_research", "stage_index": 1, "total_stages": 7}, - session_service_provider=lambda: service, - ) - - async def consume_stream(source): - async for _chunk in source: - pass - - def fake_detached_streaming_response(source, *, invocation_id=None, **kwargs): - del invocation_id, kwargs - return Response(status_code=202, background=BackgroundTask(consume_stream, source)) - - 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-metadata", - "RunId": "run-1", - "CheckpointId": "ckpt-1", - "ResumeAttemptId": "resume-1", - "InvocationId": "run-ui-resume-1", - "Stream": True, - }, - ) - - assert response.status_code == 202 - assert runner.calls[-1]["checkpoint_resume"] is True - assert runner.calls[-1]["metadata"]["stage_key"] == "plan_research" - assert runner.calls[-1]["metadata"]["stage_index"] == 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, **kwargs): - original_detached_streaming_response(source, invocation_id=invocation_id, **kwargs) - 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 "in_progress" in statuses and "cancelled" not in statuses: - 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 "cancelled" in statuses: - 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" - ] - # resume 现在先写 run_status(resuming) 再写 in_progress,cancel 后写 cancelled。 - assert statuses == ["resuming", "in_progress", "cancelled"] - assert runner.cancel_requests == [invocation_id] - - -@pytest.mark.asyncio -async def test_resume_run_action_stream_rejects_concurrent_resume_for_same_run(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-concurrent", - ) - 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-concurrent", - author="demo-agent", - run_id="run-1", - checkpoint_id="ckpt-1", - framework="langgraph", - framework_ref={ - "langgraph": { - "thread_id": "tenant:agent:sess-resume-concurrent", - "checkpoint_id": "ckpt-1", - } - }, - invocation_id="inv-checkpoint", - session_service_provider=lambda: service, - ) - - original_detached_streaming_response = server_app_module._detached_streaming_response - - def start_detached_stream_and_return_accepted(source, *, invocation_id=None, **kwargs): - original_detached_streaming_response(source, invocation_id=invocation_id, **kwargs) - 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: - first_response = await client.post( - "/agentengine/api/v1/ResumeRun", - json={ - "AgentId": "demo-agent", - "SessionId": "sess-resume-concurrent", - "RunId": "run-1", - "CheckpointId": "ckpt-1", - "ResumeAttemptId": "resume-1", - "InvocationId": "run-ui-resume-1", - "Stream": True, - }, - ) - second_response = await client.post( - "/agentengine/api/v1/ResumeRun", - json={ - "AgentId": "demo-agent", - "SessionId": "sess-resume-concurrent", - "RunId": "run-1", - "CheckpointId": "ckpt-1", - "ResumeAttemptId": "resume-2", - "InvocationId": "run-ui-resume-2", - "Stream": True, - }, - ) - - assert first_response.status_code == 202 - assert second_response.status_code == 409 - assert second_response.json()["detail"]["code"] == "resume_already_running" - - -@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_append_run_checkpoint_event_deduplicates_same_checkpoint(monkeypatch): - 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-dedupe-checkpoint", - ) - - first = await conversation_runtime.append_run_checkpoint_event( - session_id="sess-dedupe-checkpoint", - author="demo-agent", - run_id="run-1", - checkpoint_id="ckpt-1", - framework="langgraph", - framework_ref={ - "langgraph": { - "thread_id": "tenant:agent:sess-dedupe-checkpoint", - "checkpoint_id": "ckpt-1", - } - }, - phase="runner", - invocation_id="inv-runner", - session_service_provider=lambda: service, - ) - second = await conversation_runtime.append_run_checkpoint_event( - session_id="sess-dedupe-checkpoint", - author="demo-agent", - run_id="run-1", - checkpoint_id="ckpt-1", - framework="langgraph", - framework_ref={ - "langgraph": { - "thread_id": "tenant:agent:sess-dedupe-checkpoint", - "checkpoint_id": "ckpt-1", - } - }, - phase="stream", - invocation_id="inv-framework", - session_service_provider=lambda: service, - ) - - events = await service.get_events("sess-dedupe-checkpoint") - checkpoint_events = [event for event in events if event.event_type == "run_checkpoint"] - assert len(checkpoint_events) == 1 - assert second.id == first.id - - -@pytest.mark.asyncio -async def test_get_checkpoint_resume_preview_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/GetCheckpointResumePreview", - 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") - 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/RunAgent", - json={ - "AgentId": "demo-agent", - "Messages": [{"role": "user", "content": "hello"}], - "Stream": False, - "Model": "glm-5.1", - "ModelOptions": {"thinking": {"type": "disabled"}}, - }, - ) - - assert response.status_code == 200 - assert runner.calls[-1]["model_options"] == { - "thinking": {"type": "disabled"}, - "reasoning": {"effort": "none"}, - "max_reasoning_tokens": 0, - } - - -@pytest.mark.asyncio -async def test_subscribe_run_events_streams_events_appended_after_subscription(monkeypatch): - server_app_module = importlib.import_module("ksadk.server.app") - service = InMemorySessionService() - - monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service) - server_app_module.set_runner(_DummyRunner()) - - session = await service.create_session( - agent_id="demo-agent", - user_id="user-1", - session_id="sess-subscribe", - ) - in_progress = await service.append_event( - session.id, - SessionEvent.from_dict( - { - "author": "demo-agent", - "eventType": "run_status", - "invocationId": "inv-live", - "content": {"status": "in_progress"}, - }, - session_id=session.id, - ), - ) - - async def append_later(): - await asyncio.sleep(0.02) - await service.append_event( - session.id, - SessionEvent.from_dict( - { - "author": "demo-agent", - "eventType": "assistant_message", - "invocationId": "inv-live", - "content": {"role": "model", "parts": [{"text": "hello"}]}, - }, - session_id=session.id, - ), - ) - await service.append_event( - session.id, - SessionEvent.from_dict( - { - "author": "demo-agent", - "eventType": "run_status", - "invocationId": "inv-live", - "content": {"status": "completed"}, - }, - session_id=session.id, - ), - ) - - task = asyncio.create_task(append_later()) - transport = httpx.ASGITransport(app=server_app_module.app) - async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client: - response = await client.get( - "/agentengine/api/v1/SubscribeRunEvents", - params={ - "SessionId": session.id, - "InvocationId": "inv-live", - "AfterSeqId": str(in_progress.seq_id), - }, - ) - await task - - assert response.status_code == 200 - payloads = [ - json.loads(line.removeprefix("data: ")) - for line in response.text.splitlines() - if line.startswith("data: ") and line.strip() != "data: [DONE]" - ] - assert [payload["EventType"] for payload in payloads] == [ - "assistant_message", - "run_status", - ] - assert payloads[0]["Content"]["parts"][0]["text"] == "hello" - assert payloads[-1]["Content"]["status"] == "completed" - - -@pytest.mark.asyncio -async def test_subscribe_run_events_reconnects_without_replaying_consumed_events(monkeypatch): - server_app_module = importlib.import_module("ksadk.server.app") - service = InMemorySessionService() - - monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service) - server_app_module.set_runner(_DummyRunner()) - - session = await service.create_session( - agent_id="demo-agent", - user_id="user-1", - session_id="sess-subscribe-reconnect", - ) - in_progress = await service.append_event( - session.id, - SessionEvent.from_dict( - { - "author": "demo-agent", - "eventType": "run_status", - "invocationId": "inv-reconnect", - "content": {"status": "in_progress"}, - }, - session_id=session.id, - ), - ) - assistant = await service.append_event( - session.id, - SessionEvent.from_dict( - { - "author": "demo-agent", - "eventType": "assistant_message", - "invocationId": "inv-reconnect", - "content": {"role": "model", "parts": [{"text": "halfway"}]}, - }, - session_id=session.id, - ), - ) - await service.append_event( - session.id, - SessionEvent.from_dict( - { - "author": "demo-agent", - "eventType": "run_status", - "invocationId": "other-invocation", - "content": {"status": "completed"}, - }, - session_id=session.id, - ), - ) - - async def append_completed_later(): - await asyncio.sleep(0.02) - await service.append_event( - session.id, - SessionEvent.from_dict( - { - "author": "demo-agent", - "eventType": "run_status", - "invocationId": "inv-reconnect", - "content": {"status": "completed"}, - }, - session_id=session.id, - ), - ) - - task = asyncio.create_task(append_completed_later()) - transport = httpx.ASGITransport(app=server_app_module.app) - async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client: - first_response = await client.get( - "/agentengine/api/v1/SubscribeRunEvents", - params={ - "SessionId": session.id, - "InvocationId": "inv-reconnect", - "AfterSeqId": 0, - }, - ) - second_response = await client.get( - "/agentengine/api/v1/SubscribeRunEvents", - params={ - "SessionId": session.id, - "InvocationId": "inv-reconnect", - "AfterSeqId": str(assistant.seq_id), - }, - ) - await task - - assert first_response.status_code == 200 - first_payloads = [ - json.loads(line.removeprefix("data: ")) - for line in first_response.text.splitlines() - if line.startswith("data: ") and line.strip() != "data: [DONE]" - ] - assert [payload["SeqId"] for payload in first_payloads[:2]] == [ - in_progress.seq_id, - assistant.seq_id, - ] - - assert second_response.status_code == 200 - second_payloads = [ - json.loads(line.removeprefix("data: ")) - for line in second_response.text.splitlines() - if line.startswith("data: ") and line.strip() != "data: [DONE]" - ] - assert [payload["EventType"] for payload in second_payloads] == ["run_status"] - assert second_payloads[0]["SeqId"] > assistant.seq_id - assert second_payloads[0]["Content"]["status"] == "completed" - - -@pytest.mark.asyncio -async def test_run_agent_stream_continues_after_client_disconnect(monkeypatch): - server_app_module = importlib.import_module("ksadk.server.app") - service = InMemorySessionService() - runner = _SlowStreamingRunner() - - 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", - "/agentengine/api/v1/RunAgent", - json={ - "AgentId": "demo-agent", - "SessionId": "sess-detached-run", - "Messages": [{"role": "user", "content": "hello"}], - "Stream": True, - "ApiFormat": "responses", - }, - ) as response: - assert response.status_code == 200 - async for line in response.aiter_lines(): - if line.startswith("data: ") and "response.created" in line: - break - - for _ in range(20): - events = await service.get_events("sess-detached-run") - if events and events[-1].event_type == "run_status" and events[-1].content.get("status") == "completed": - break - await asyncio.sleep(0.02) - - events = await service.get_events("sess-detached-run") - assert [event.event_type for event in events] == [ - "user_message", - "run_status", - "assistant_message", - "run_status", - ] - 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_delete_session_cancels_active_detached_stream_before_removing_session(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-delete-detached" - session_id = "sess-delete-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=session_id, - model=None, - prepare_runner=lambda _runner, _model: None, - invocation_id=invocation_id, - session_service_provider=lambda: service, - ), - invocation_id=invocation_id, - session_id=session_id, - ) - - for _ in range(20): - events = await service.get_events(session_id) - if any(event.event_type == "run_status" for event in events): - break - await asyncio.sleep(0.02) - - response = await client.post( - "/agentengine/api/v1/DeleteSession", - json={"SessionId": session_id}, - ) - - assert response.status_code == 200 - assert response.json()["Data"]["Deleted"] is True - assert invocation_id not in server_app_module._DETACHED_STREAMS_BY_INVOCATION - assert await service.get_session(session_id) is None - - -@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_server_terminal_sessions.py b/tests/test_server_terminal_sessions.py deleted file mode 100644 index 9d635062..00000000 --- a/tests/test_server_terminal_sessions.py +++ /dev/null @@ -1,281 +0,0 @@ -from __future__ import annotations - -import importlib -import json -import subprocess -import sys -from types import SimpleNamespace - -import httpx -import pytest -from fastapi.testclient import TestClient - -import ksadk.server.terminal_sessions as terminal_sessions -from ksadk.hermes_terminal import TERMINAL_SUBPROTOCOL -from ksadk.runners.base_runner import BaseRunner -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"} - - -def test_server_app_imports_when_posix_terminal_modules_are_unavailable(): - code = """ -import builtins -real_import = builtins.__import__ -def guarded_import(name, globals=None, locals=None, fromlist=(), level=0): - if name in {'pty', 'termios'}: - raise ImportError(f'No module named {name!r}') - return real_import(name, globals, locals, fromlist, level) -builtins.__import__ = guarded_import -import ksadk.server.app -print('ok') -""" - result = subprocess.run( - [sys.executable, "-c", code], - cwd=".", - text=True, - capture_output=True, - timeout=20, - ) - - assert result.returncode == 0, result.stderr - assert result.stdout.strip() == "ok" - - -def test_native_terminal_support_reports_false_without_posix_modules(monkeypatch): - monkeypatch.setattr(terminal_sessions, "pty", None) - monkeypatch.setattr(terminal_sessions, "termios", None) - - assert terminal_sessions.native_terminal_supported() is False - - -@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(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", - "--session", - "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(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", - "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", - ] diff --git a/tests/test_server_workspace_preview_security.py b/tests/test_server_workspace_preview_security.py deleted file mode 100644 index 249523f3..00000000 --- a/tests/test_server_workspace_preview_security.py +++ /dev/null @@ -1,66 +0,0 @@ -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 - - -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" diff --git a/tests/test_session_continuity.py b/tests/test_session_continuity.py deleted file mode 100644 index 5f9855a3..00000000 --- a/tests/test_session_continuity.py +++ /dev/null @@ -1,178 +0,0 @@ -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 deleted file mode 100644 index bfff353d..00000000 --- a/tests/test_session_title.py +++ /dev/null @@ -1,78 +0,0 @@ -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 deleted file mode 100644 index 1bd8cc08..00000000 --- a/tests/test_storage_defaults.py +++ /dev/null @@ -1,65 +0,0 @@ -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, - } - # 非 hermes/openclaw 框架默认不挂盘(产品要求 adk/langgraph 这类默认不传挂盘参数) - assert build_storage_config("langgraph", target="kce") is None - assert build_storage_config("adk", target="serverless") is None - assert build_storage_config("langchain", target="serverless") is None - assert build_storage_config("deepagents", target="serverless") is None - # 用户显式指定 --storage-mount-path 时,任意框架都挂 - assert build_storage_config("langgraph", target="serverless", mount_path="/data") == { - "mount_path": "/data", - "size_gi": 20, - } - assert build_storage_config("adk", target="kce", mount_path="/home/node/.agentengine") == { - "mount_path": "/home/node/.agentengine", - "size_gi": 20, - } - # 仅显式 size、未指定 mount_path 时,非默认框架仍不挂(mount_path 才是挂盘意图的判据) - assert build_storage_config("langgraph", target="serverless", size_gi=50) is None - 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 deleted file mode 100644 index 1ac982ec..00000000 --- a/tests/test_tool_gateway.py +++ /dev/null @@ -1,119 +0,0 @@ -from __future__ import annotations - -from ksadk.tools.gateway import ( - ToolGateway, - ToolPolicy, - approval_interrupt_info_from_result, - build_tool_receipt_idempotency_key, - check_command_policy, - default_tool_gateway, - tool_policy_requires_approval, -) - - -def test_tool_gateway_imports_public_api(): - gateway = default_tool_gateway({"delete_file": ToolPolicy(risk_level="high")}) - - assert isinstance(gateway, ToolGateway) - - -def test_tool_policy_requires_approval_only_in_strict_mode(): - policy = ToolPolicy(risk_level="high") - - assert tool_policy_requires_approval(policy, approval_mode="off") is False - assert tool_policy_requires_approval(policy, approval_mode="permissive") is False - assert tool_policy_requires_approval(policy, approval_mode="strict") is True - - -def test_tool_gateway_returns_approval_request_in_strict_mode(monkeypatch): - monkeypatch.setenv("KSADK_TOOL_APPROVAL_MODE", "strict") - gateway = ToolGateway({"write_file": ToolPolicy(risk_level="medium", side_effects=("workspace_write",))}) - - result = gateway.invoke("write_file", lambda: {"ok": True}) - - assert result["type"] == "approval_required" - assert result["approval_required"] is True - assert result["approval_request"]["tool_name"] == "write_file" - assert result["approval_request"]["risk_level"] == "medium" - assert result["approval_request"]["side_effects"] == ["workspace_write"] - - -def test_tool_gateway_runs_approved_call_in_strict_mode(monkeypatch): - monkeypatch.setenv("KSADK_TOOL_APPROVAL_MODE", "strict") - gateway = ToolGateway({"write_file": ToolPolicy(risk_level="medium")}) - - assert gateway.invoke("write_file", lambda value: {"ok": True, "value": value}, 3, approval={"approved": True}) == { - "ok": True, - "value": 3, - } - - -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", - "approval_request": { - "id": "appr_123", - "tool_name": "write_file", - "tool_args": {"path": "demo.txt"}, - "risk_level": "medium", - "side_effects": ["workspace_write"], - }, - } - - interrupt = approval_interrupt_info_from_result(result, fallback_tool_name="fallback", run_id="run_1") - - assert interrupt == { - "id": "appr_123", - "approval_request_id": "appr_123", - "tool_name": "write_file", - "arguments": {"path": "demo.txt"}, - "risk_level": "medium", - "side_effects": ["workspace_write"], - "server_label": "ksadk", - "run_id": "run_1", - } - - -def test_check_command_policy_allows_read_only_git_commands(): - result = check_command_policy("git diff -- ksadk/toolsets/workspace.py") - - assert result["ok"] is True - assert result["decision"] == "allow" - - -def test_check_command_policy_rejects_dangerous_commands(): - result = check_command_policy("git reset --hard HEAD") - - assert result["ok"] is False - assert result["decision"] == "reject" - assert result["error_type"] == "command_rejected" - - -def test_check_command_policy_rejects_recursive_rm_without_force(): - result = check_command_policy("rm -r workspace") - - assert result["ok"] is False - assert result["decision"] == "reject" - assert result["error_type"] == "command_rejected" diff --git a/tests/test_tool_result_budget.py b/tests/test_tool_result_budget.py deleted file mode 100644 index 75b3ab00..00000000 --- a/tests/test_tool_result_budget.py +++ /dev/null @@ -1,92 +0,0 @@ -from __future__ import annotations - -import json - -from ksadk.conversations.context import project_model_messages -from ksadk.sessions import SessionEvent -from ksadk.tools.result_budget import ToolResultBudget, budget_tool_output - - -def test_budget_tool_output_persists_large_text(tmp_path): - budget = ToolResultBudget( - max_chars=20, - preview_chars=8, - persist_threshold_chars=12, - persist_dir=tmp_path, - ) - - result = budget_tool_output( - tool_name="run_command", - field_name="stdout", - value="abcdefghijklmnopqrstuvwxyz", - metadata={"tool_use_id": "call_123"}, - budget=budget, - ) - - assert result["stdout"] == "abcdefgh" - assert result["truncated"] is True - assert result["original_chars"] == 26 - assert result["preview_chars"] == 8 - assert result["persisted"]["mime_type"] == "text/plain" - persisted_path = tmp_path / "call_123.stdout.txt" - assert result["persisted"]["path"] == str(persisted_path) - assert persisted_path.read_text(encoding="utf-8") == "abcdefghijklmnopqrstuvwxyz" - - -def test_budget_tool_output_serializes_json_values(tmp_path): - budget = ToolResultBudget( - max_chars=10, - preview_chars=5, - persist_threshold_chars=8, - persist_dir=tmp_path, - ) - - result = budget_tool_output( - tool_name="web_search", - field_name="results", - value={"items": ["alpha", "beta"]}, - metadata={"tool_use_id": "search_1"}, - budget=budget, - ) - - assert result["results"].startswith("{") - assert result["persisted"]["mime_type"] == "application/json" - persisted = json.loads((tmp_path / "search_1.results.json").read_text(encoding="utf-8")) - assert persisted == {"items": ["alpha", "beta"]} - - -def test_project_model_messages_projects_persisted_tool_result_preview(): - events = [ - SessionEvent( - id="evt-1", - session_id="sess-1", - author="agent", - event_type="tool_result", - content={ - "role": "tool", - "parts": [ - { - "text": { - "stdout": "short preview", - "truncated": True, - "persisted": { - "path": "sessions/sess-1/tool-results/call.stdout.txt", - "mime_type": "text/plain", - }, - } - } - ], - }, - timestamp="2026-07-02T00:00:00Z", - seq_id=1, - ) - ] - - projected = project_model_messages(events) - - assert projected == [ - { - "role": "user", - "content": "[tool_result] short preview\n[persisted-output] sessions/sess-1/tool-results/call.stdout.txt (text/plain)", - } - ] diff --git a/tests/test_tracing_cloud_monitor_e2e.py b/tests/test_tracing_cloud_monitor_e2e.py deleted file mode 100644 index f9e517bf..00000000 --- a/tests/test_tracing_cloud_monitor_e2e.py +++ /dev/null @@ -1,116 +0,0 @@ -import json -import os -import subprocess -import sys -import textwrap -from pathlib import Path - - -def test_cloud_monitor_otlp_local_http_e2e(): - repo_root = Path(__file__).resolve().parents[1] - script = r""" -import json -import logging -import os -import threading -import time -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer - -for key in list(os.environ): - if key.startswith("OTEL_EXPORTER_OTLP") or key.startswith("LANGFUSE_"): - os.environ.pop(key, None) - -received = [] - - -class Handler(BaseHTTPRequestHandler): - def do_POST(self): - length = int(self.headers.get("Content-Length", "0") or "0") - body = self.rfile.read(length) - received.append( - { - "path": self.path, - "app_key": self.headers.get("Ksc-Appkey"), - "content_type": self.headers.get("Content-Type"), - "body_len": len(body), - } - ) - self.send_response(200) - self.end_headers() - - def log_message(self, *_args): - return - - -server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) -thread = threading.Thread(target=server.serve_forever, daemon=True) -thread.start() - -os.environ["OTEL_SERVICE_NAME"] = "ar-cloudmonitor-e2e" -os.environ["CLOUD_MONITOR_APP_KEY"] = "app-key-e2e" -os.environ["CLOUD_MONITOR_OTLP_ENDPOINT"] = f"http://127.0.0.1:{server.server_port}" - -logging.basicConfig(level=logging.INFO, format="%(levelname)s:%(name)s:%(message)s") - -from ksadk.tracing import setup_tracing -from opentelemetry import trace - -setup_tracing(enable_inmemory=False, enable_langfuse=False, enable_adk_instrumentation=False) -tracer = trace.get_tracer("ksadk-cloudmonitor-e2e") - -with tracer.start_as_current_span("cloudmonitor-e2e-span") as span: - span.set_attribute("ksadk.e2e", True) - span.set_attribute("ksadk.agent_id", "ar-cloudmonitor-e2e") - -provider = trace.get_tracer_provider() -flush_ok = provider.force_flush(timeout_millis=5000) if hasattr(provider, "force_flush") else True -if hasattr(provider, "shutdown"): - provider.shutdown() - -deadline = time.time() + 5 -while not received and time.time() < deadline: - time.sleep(0.05) - -server.shutdown() -server.server_close() - -print( - json.dumps( - { - "flush_ok": bool(flush_ok), - "received": len(received), - "first": received[0] if received else None, - }, - sort_keys=True, - ) -) -""" - env = os.environ.copy() - for key in list(env): - if key.startswith("OTEL_EXPORTER_OTLP") or key.startswith("LANGFUSE_"): - env.pop(key, None) - env["PYTHONPATH"] = f"{repo_root}{os.pathsep}{env.get('PYTHONPATH', '')}".rstrip(os.pathsep) - env["NO_PROXY"] = "127.0.0.1,localhost" - env["no_proxy"] = "127.0.0.1,localhost" - - completed = subprocess.run( - [sys.executable, "-c", textwrap.dedent(script)], - cwd=repo_root, - env=env, - capture_output=True, - text=True, - timeout=30, - ) - - assert completed.returncode == 0, completed.stderr - payload = json.loads(completed.stdout) - assert payload["flush_ok"] is True - assert payload["received"] == 1 - assert payload["first"]["path"] == "/v1/traces" - assert payload["first"]["app_key"] == "app-key-e2e" - assert payload["first"]["content_type"] == "application/x-protobuf" - assert payload["first"]["body_len"] > 0 - assert "CloudMonitor OTLP config resolved" in completed.stderr - assert "CloudMonitor OTLP exporter enabled" in completed.stderr - assert "CloudMonitor OTLP export started" in completed.stderr - assert "CloudMonitor OTLP export result" in completed.stderr diff --git a/tests/test_tui_app.py b/tests/test_tui_app.py deleted file mode 100644 index f75761cd..00000000 --- a/tests/test_tui_app.py +++ /dev/null @@ -1,11 +0,0 @@ -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 deleted file mode 100644 index 329cbf11..00000000 --- a/tests/test_tui_clipboard.py +++ /dev/null @@ -1,17 +0,0 @@ -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 deleted file mode 100644 index db56ff8f..00000000 --- a/tests/test_ui_config_resolution.py +++ /dev/null @@ -1,119 +0,0 @@ -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_custom_ui_profile_keeps_root_path_by_default(): - cfg = resolve_ui_config( - framework="langgraph", - state={ - "ui_profile": "custom", - }, - cli_profile=None, - cli_path=None, - cli_url=None, - ) - - assert cfg.profile == "custom" - assert cfg.path == "/" - assert cfg.url is None - - -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_unified_agent_ui_local.py b/tests/test_unified_agent_ui_local.py deleted file mode 100644 index d2c79c6d..00000000 --- a/tests/test_unified_agent_ui_local.py +++ /dev/null @@ -1,2254 +0,0 @@ -from __future__ import annotations - -import base64 -import importlib -import json -import os -import re -import subprocess -import sys -from pathlib import Path -from types import SimpleNamespace - -import httpx -import pytest -from fastapi.testclient import TestClient -from click.testing import CliRunner -from opentelemetry import trace -from opentelemetry.sdk.trace import TracerProvider - -from ksadk.runners.base_runner import BaseRunner -from ksadk.sessions.base import SessionEvent -from ksadk.sessions.in_memory import InMemorySessionService - - -class _UiRunner(BaseRunner): - def __init__(self): - super().__init__( - detection_result=SimpleNamespace( - name="demo-agent", - description="demo agent", - type=SimpleNamespace(value="langgraph"), - ), - project_dir=".", - ) - self.invocations: list[dict] = [] - self.run_server_calls: list[int] = [] - self.load_agent_calls = 0 - - def load_agent(self) -> None: - self.load_agent_calls += 1 - return None - - async def invoke(self, input_data: dict) -> dict: - self.invocations.append(input_data) - return {"output": "assistant says hi"} - - async def stream(self, input_data: dict): - self.invocations.append(input_data) - yield {"type": "tool_call", "tool_name": "resume_lookup", "tool_args": {"keyword": "jd"}} - yield {"type": "tool_result", "tool_name": "resume_lookup", "tool_output": '{"score": 91}'} - yield {"type": "thinking", "delta": "plan"} - yield {"type": "text", "delta": "hello"} - yield { - "type": "responses_output", - "response_id": "resp_demo", - "output": [ - { - "id": "fc_demo", - "type": "function_call", - "name": "resume_lookup", - "arguments": '{"keyword":"jd"}', - } - ], - } - yield {"type": "final", "output": "hello world"} - - def run_server(self, port: int = 8000) -> None: - self.run_server_calls.append(port) - - -class _BrokenLoadRunner(_UiRunner): - def load_agent(self) -> None: - self.load_agent_calls += 1 - raise RuntimeError("runner load failed") - - -class _InterruptRunner(_UiRunner): - async def stream(self, input_data: dict): - self.invocations.append(input_data) - yield {"type": "text", "delta": "need "} - yield { - "type": "interrupt", - "interrupt_info": {"message": "确认执行?", "tool_name": "delete_file"}, - } - - -class _GenericInterruptRunner(_UiRunner): - async def stream(self, input_data: dict): - self.invocations.append(input_data) - yield { - "type": "interrupt", - "interrupt_info": {"message": "需要人工确认"}, - } - - -class _FrameworkUiRunner(_UiRunner): - def __init__(self, framework: str): - super().__init__() - self.detection_result.type = SimpleNamespace(value=framework) - - -class _KeyboardInterruptServerRunner(_UiRunner): - def run_server(self, port: int = 8000) -> None: - self.run_server_calls.append(port) - raise KeyboardInterrupt - - -@pytest.fixture(autouse=True) -def _block_real_browser_open(monkeypatch): - import ksadk.cli.cmd_web as cmd_web_module - - monkeypatch.setattr(cmd_web_module.webbrowser, "open", lambda _url: None) - - -def _build_transport(monkeypatch): - server_app_module = importlib.import_module("ksadk.server.app") - service = InMemorySessionService() - runner = _UiRunner() - monkeypatch.delenv("KSADK_UI_PROFILE", raising=False) - monkeypatch.delenv("KSADK_UI_PATH", raising=False) - monkeypatch.delenv("KSADK_UI_URL", raising=False) - monkeypatch.delenv("KSADK_UI_BUNDLE_PATH", raising=False) - monkeypatch.delenv("OPENAI_BASE_URL", raising=False) - monkeypatch.delenv("OPENAI_API_BASE", raising=False) - monkeypatch.delenv("OPENAI_API_KEY", raising=False) - monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service) - server_app_module.set_runner(runner) - transport = httpx.ASGITransport(app=server_app_module.app) - return server_app_module, runner, service, transport - - -def _build_transport_with_runner(monkeypatch, runner): - server_app_module = importlib.import_module("ksadk.server.app") - service = InMemorySessionService() - monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service) - server_app_module.set_runner(runner) - transport = httpx.ASGITransport(app=server_app_module.app) - return server_app_module, runner, service, transport - - -@pytest.fixture -def active_trace_provider(): - provider = TracerProvider() - trace._TRACER_PROVIDER = None - trace._TRACER_PROVIDER_SET_ONCE._done = False - trace._set_tracer_provider(provider, log=False) - yield - trace._TRACER_PROVIDER = None - trace._TRACER_PROVIDER_SET_ONCE._done = False - - -@pytest.mark.asyncio -async def test_get_agent_ui_bootstrap_matches_local_shape_parity(monkeypatch): - monkeypatch.setenv("OPENAI_MODEL_NAME", "glm-5.1") - monkeypatch.delenv("KSADK_TOOL_APPROVAL_MODE", raising=False) - monkeypatch.delenv("KSADK_SANDBOX_BACKEND", raising=False) - monkeypatch.delenv("KSADK_SANDBOX_TEMPLATE_ID", raising=False) - monkeypatch.delenv("KSADK_SKILL_RUNTIME_BACKEND", raising=False) - monkeypatch.delenv("KSADK_SKILL_RUNTIME_TEMPLATE_ID", raising=False) - _, runner, _, transport = _build_transport(monkeypatch) - - 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", "SessionId": "sess-bootstrap"}, - ) - - assert response.status_code == 200 - payload = response.json() - assert payload["Code"] == 0 - assert set(payload["Data"].keys()) == { - "Agent", - "Modules", - "Capabilities", - "WorkspaceFiles", - "AccessMode", - "SharePermissions", - "ApiFormats", - "Stream", - "SessionId", - "SessionBackend", - "HostedRuntime", - "Model", - "CustomUI", - } - assert payload["Data"]["Agent"]["AgentId"] == "demo-agent" - assert payload["Data"]["Agent"]["Framework"] == "langgraph" - assert payload["Data"]["Modules"] == ["Chat", "Build", "Deploy"] - - 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, - "SupportsDelete": True, - "RootLabel": "workspace", - "EntryAction": "ListWorkspaceFiles", - "UploadAction": "AddWorkspaceFile", - "ContentPath": "/agentengine/api/v1/GetWorkspaceFileContent", - } - assert payload["Data"]["AccessMode"] == "Owner" - assert payload["Data"]["SharePermissions"] == { - "Interactive": True, - "DefaultPath": "/chat", - "SharePath": "/chat", - } - assert payload["Data"]["ApiFormats"] == ["responses", "chat_completions"] - assert payload["Data"]["Stream"] is True - assert payload["Data"]["SessionId"] == "sess-bootstrap" - assert payload["Data"]["HostedRuntime"] is None - assert payload["Data"]["Model"]["id"] == "glm-5.1" - assert payload["Data"]["Model"]["source"] == "OPENAI_MODEL_NAME" - assert runner.load_agent_calls == 0 - - -@pytest.mark.asyncio -@pytest.mark.parametrize("framework", ["hermes", "openclaw"]) -async def test_get_agent_ui_bootstrap_enables_tui_only_for_native_tui_frameworks( - monkeypatch, - framework, -): - _, _, _, transport = _build_transport_with_runner( - monkeypatch, - _FrameworkUiRunner(framework), - ) - - async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client: - response = await client.post( - "/agentengine/api/v1/GetAgentUiBootstrap", - json={"AgentId": f"{framework}-agent"}, - ) - - assert response.status_code == 200 - terminal = response.json()["Data"]["Capabilities"]["NativeTerminal"] - assert terminal == { - "Enabled": True, - "Mode": "tui", - "Protocol": "ks-terminal.v1", - "Path": "/_ksadk/terminal/ws", - } - - -@pytest.mark.asyncio -async def test_get_agent_ui_bootstrap_disables_tui_for_generic_frameworks(monkeypatch): - _, _, _, transport = _build_transport_with_runner( - monkeypatch, - _FrameworkUiRunner("langgraph"), - ) - - async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client: - response = await client.post( - "/agentengine/api/v1/GetAgentUiBootstrap", - json={"AgentId": "langgraph-agent"}, - ) - - assert response.status_code == 200 - terminal = response.json()["Data"]["Capabilities"]["NativeTerminal"] - assert terminal["Enabled"] is False - assert terminal["Mode"] is None - assert terminal["Path"] is None - - -@pytest.mark.asyncio -async def test_list_agent_models_action_uses_real_current_model_without_gemini_fallback(monkeypatch): - monkeypatch.setenv("OPENAI_MODEL_NAME", "glm-5.1") - monkeypatch.delenv("MODEL_NAME", raising=False) - monkeypatch.delenv("OPENAI_BASE_URL", raising=False) - monkeypatch.delenv("OPENAI_API_BASE", raising=False) - _, _, _, transport = _build_transport(monkeypatch) - - async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client: - response = await client.post( - "/agentengine/api/v1/ListAgentModels", - json={"AgentId": "demo-agent"}, - ) - - assert response.status_code == 200 - payload = response.json() - assert payload["Data"]["Current"] == "glm-5.1" - assert payload["Data"]["Source"] == "OPENAI_MODEL_NAME" - assert [item["id"] for item in payload["Data"]["Models"]] == ["glm-5.1"] - - -@pytest.mark.asyncio -async def test_list_agent_models_action_matches_hosted_shape(monkeypatch): - monkeypatch.setenv("OPENAI_MODEL_NAME", "glm-5.1") - _, _, _, transport = _build_transport(monkeypatch) - - async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client: - response = await client.post( - "/agentengine/api/v1/ListAgentModels", - json={"AgentId": "demo-agent"}, - ) - - assert response.status_code == 200 - payload = response.json() - assert payload["Code"] == 0 - assert payload["Data"]["Current"] == "glm-5.1" - assert payload["Data"]["Source"] == "OPENAI_MODEL_NAME" - assert [item["id"] for item in payload["Data"]["Models"]] == ["glm-5.1"] - - -@pytest.mark.asyncio -async def test_run_agent_action_returns_responses_payload_and_persists_session(monkeypatch): - _, runner, service, transport = _build_transport(monkeypatch) - - 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", - "Messages": [{"role": "user", "content": "hello"}], - "ApiFormat": "responses", - "Stream": False, - }, - ) - - assert response.status_code == 200 - payload = response.json() - assert payload["Code"] == 0 - assert payload["Data"]["object"] == "response" - assert payload["Data"]["status"] == "completed" - assert payload["Data"]["output_text"] == "assistant says hi" - - session_id = payload["Data"]["session_id"] - session = await service.get_session(session_id) - assert session is not None - events = await service.get_events(session_id) - assert [event.author for event in events] == ["user", "demo-agent", "demo-agent", "demo-agent"] - assert [event.event_type for event in events] == [ - "user_message", - "run_status", - "assistant_message", - "run_status", - ] - assert runner.invocations[-1]["history"] == [{"role": "user", "content": "hello"}] - assert runner.load_agent_calls == 1 - - -@pytest.mark.asyncio -async def test_run_agent_action_forwards_model_metadata_to_conversation_runtime(monkeypatch): - server_app_module, _, _, transport = _build_transport(monkeypatch) - captured: dict[str, object] = {} - - async def _fake_invoke_conversation_once(**kwargs): - captured.update(kwargs) - return "sess-model-metadata", {"output_text": "assistant says hi", "model": kwargs.get("model")} - - monkeypatch.setattr(server_app_module.conversation, "invoke_conversation_once", _fake_invoke_conversation_once) - - 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", - "Messages": [{"role": "user", "content": "hello"}], - "ApiFormat": "responses", - "Stream": False, - "Model": "glm-5.1", - "ModelMetadata": { - "id": "glm-5.1", - "context_length": "64k", - "max_completion_tokens": "8k", - }, - }, - ) - - assert response.status_code == 200 - assert response.json()["Code"] == 0 - assert captured["model"] == "glm-5.1" - assert captured["model_metadata"] == { - "id": "glm-5.1", - "context_length": "64k", - "max_completion_tokens": "8k", - } - - -@pytest.mark.asyncio -async def test_run_agent_action_streaming_responses_uses_responses_lifecycle(monkeypatch): - _, runner, service, transport = _build_transport(monkeypatch) - - 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-responses", - "Messages": [{"role": "user", "content": "hello"}], - "ApiFormat": "responses", - "Stream": True, - "Model": "glm-5.1", - }, - ) - - assert response.status_code == 200 - lines = [line for line in response.text.splitlines() if line.startswith("event: ")] - assert "event: response.created" in lines - assert "event: response.in_progress" in lines - assert "event: response.output_item.added" in lines - assert "event: response.function_call_arguments.delta" in lines - assert "event: response.ksadk.tool_result" in lines - assert "event: response.completed" in lines - assert "event: response.tool_call" not in lines - assert "event: response.tool_result" not in lines - assert runner.invocations[-1]["model"] == "glm-5.1" - assert runner.invocations[-1]["session_id"] == "sess-runagent-responses" - assert runner.invocations[-1]["responses_conversation"] is True - assert await service.get_session("sess-runagent-responses") is not None - stored_events = await service.get_events("sess-runagent-responses") - assistant_events = [event for event in stored_events if event.event_type == "assistant_message"] - assert assistant_events[-1].metadata["response_id"] == "resp_demo" - assert assistant_events[-1].metadata["responses_output"][0]["type"] == "function_call" - - current_event = "" - completed_payload = None - for line in response.text.splitlines(): - if line.startswith("event: "): - current_event = line.removeprefix("event: ") - elif line.startswith("data: ") and current_event == "response.completed": - completed_payload = json.loads(line.removeprefix("data: ")) - assert completed_payload is not None - assert completed_payload["model"] == "glm-5.1" - assert completed_payload["session_id"] == "sess-runagent-responses" - - -@pytest.mark.asyncio -async def test_run_agent_action_normalizes_structured_text_and_inline_attachment(monkeypatch): - _, runner, service, transport = _build_transport(monkeypatch) - attachment_bytes = "候选人简历内容".encode("utf-8") - attachment_b64 = base64.b64encode(attachment_bytes).decode("ascii") - - 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", - "Messages": [ - { - "role": "user", - "content": [ - {"type": "input_text", "text": "请总结附件"}, - { - "type": "input_file", - "inlineData": { - "displayName": "resume.txt", - "mimeType": "text/plain", - "data": attachment_b64, - }, - }, - ], - } - ], - "ApiFormat": "responses", - "Stream": False, - }, - ) - - assert response.status_code == 200 - payload = response.json() - normalized_input = runner.invocations[-1]["input"] - assert "请总结附件" in normalized_input - assert "resume.txt" in normalized_input - assert "候选人简历内容" in normalized_input - assert runner.invocations[-1]["attachments"] == [ - { - "display_name": "resume.txt", - "mime_type": "text/plain", - "transport": "inline", - "data": attachment_b64, - "is_text": True, - "size_bytes": len(attachment_bytes), - } - ] - assert runner.invocations[-1]["attachment_results"] == [ - { - "display_name": "resume.txt", - "mime_type": "text/plain", - "transport": "inline", - "file_uri": "", - "size_bytes": len(attachment_bytes), - "kind": "text", - "status": "ok", - "warnings": [], - "extraction_method": "text_decode", - "text_excerpt": "候选人简历内容", - "text": "候选人简历内容", - } - ] - - session_id = payload["Data"]["session_id"] - events = await service.get_events(session_id) - assert events[0].content["parts"] == [ - {"type": "input_text", "text": "请总结附件"}, - { - "type": "input_file", - "filename": "resume.txt", - "file_data": attachment_b64, - }, - ] - assert events[0].metadata["agent_input"] == normalized_input - assert events[0].event_type == "user_message" - - -@pytest.mark.asyncio -async def test_run_agent_action_passes_binary_zip_attachment_to_runner(monkeypatch): - _, runner, _, transport = _build_transport(monkeypatch) - archive_bytes = b"PK\x03\x04demo-zip" - archive_b64 = base64.b64encode(archive_bytes).decode("ascii") - - 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", - "Messages": [ - { - "role": "user", - "content": [ - {"type": "input_text", "text": "分析这个压缩包"}, - { - "type": "input_file", - "inlineData": { - "displayName": "bundle.zip", - "mimeType": "application/zip", - "data": archive_b64, - }, - }, - ], - } - ], - "ApiFormat": "responses", - "Stream": False, - }, - ) - - assert response.status_code == 200 - normalized_input = runner.invocations[-1]["input"] - assert "bundle.zip" in normalized_input - assert "ZIP 压缩包无法打开" in normalized_input - assert runner.invocations[-1]["attachments"] == [ - { - "display_name": "bundle.zip", - "mime_type": "application/zip", - "transport": "inline", - "data": archive_b64, - "is_text": False, - "size_bytes": len(archive_bytes), - } - ] - assert runner.invocations[-1]["attachment_results"] == [ - { - "display_name": "bundle.zip", - "mime_type": "application/zip", - "transport": "inline", - "file_uri": "", - "size_bytes": len(archive_bytes), - "kind": "archive", - "status": "failed", - "warnings": ["ZIP 压缩包无法打开,请确认文件未损坏后重试。"], - "extraction_method": "zip_enumeration", - "text_excerpt": "", - } - ] - - -@pytest.mark.asyncio -async def test_upload_file_action_returns_server_handle_and_stores_file(monkeypatch, tmp_path): - monkeypatch.setenv("AGENTENGINE_UI_DIR", str(tmp_path / ".agentengine" / "ui")) - _, _, _, transport = _build_transport(monkeypatch) - - async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client: - response = await client.post( - "/agentengine/api/v1/UploadFile", - files={"file": ("resume.txt", b"hello", "text/plain")}, - ) - - assert response.status_code == 200 - payload = response.json() - file_data = payload["Data"]["FileData"] - assert file_data["fileUri"].startswith("ksadk-upload://") - assert file_data["displayName"] == "resume.txt" - assert file_data["mimeType"] == "text/plain" - assert file_data["sizeBytes"] == 5 - - file_id = file_data["fileUri"].removeprefix("ksadk-upload://") - 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" - - -@pytest.mark.asyncio -async def test_run_agent_action_normalizes_uploaded_file_handle_and_persists_compact_metadata(monkeypatch, tmp_path): - monkeypatch.setenv("AGENTENGINE_UI_DIR", str(tmp_path / ".agentengine" / "ui")) - _, runner, service, transport = _build_transport(monkeypatch) - attachment_bytes = "候选人简历内容".encode("utf-8") - - async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client: - upload_response = await client.post( - "/agentengine/api/v1/UploadFile", - files={"file": ("resume.txt", attachment_bytes, "text/plain")}, - ) - uploaded = upload_response.json()["Data"]["FileData"] - - response = await client.post( - "/agentengine/api/v1/RunAgent", - json={ - "AgentId": "demo-agent", - "Messages": [ - { - "role": "user", - "content": [ - {"type": "input_text", "text": "请总结附件"}, - { - "type": "input_file", - "fileData": uploaded, - }, - ], - } - ], - "ApiFormat": "responses", - "Stream": False, - }, - ) - - assert response.status_code == 200 - normalized_input = runner.invocations[-1]["input"] - assert "请总结附件" in normalized_input - assert "resume.txt" in normalized_input - assert "候选人简历内容" in normalized_input - - attachment = runner.invocations[-1]["attachments"][0] - assert attachment["display_name"] == "resume.txt" - assert attachment["mime_type"] == "text/plain" - assert attachment["transport"] == "reference" - assert attachment["file_uri"] == uploaded["fileUri"] - assert attachment["size_bytes"] == len(attachment_bytes) - assert attachment["is_text"] is True - assert attachment["storage_path"].endswith(".txt") - assert "data" not in attachment - assert runner.invocations[-1]["attachment_results"] == [ - { - "display_name": "resume.txt", - "mime_type": "text/plain", - "transport": "reference", - "file_uri": uploaded["fileUri"], - "size_bytes": len(attachment_bytes), - "kind": "text", - "status": "ok", - "warnings": [], - "extraction_method": "text_decode", - "text_excerpt": "候选人简历内容", - "text": "候选人简历内容", - } - ] - - session_id = response.json()["Data"]["session_id"] - events = await service.get_events(session_id) - assert events[0].content["parts"] == [ - {"type": "input_text", "text": "请总结附件"}, - { - "type": "input_file", - "filename": "resume.txt", - "file_url": uploaded["fileUri"], - }, - ] - assert events[0].metadata["attachments"] == [ - { - "display_name": "resume.txt", - "mime_type": "text/plain", - "transport": "reference", - "size_bytes": len(attachment_bytes), - "is_text": True, - "file_uri": uploaded["fileUri"], - } - ] - assert "storage_path" not in events[0].metadata["attachments"][0] - assert events[0].event_type == "user_message" - - -@pytest.mark.asyncio -async def test_run_agent_action_uses_responses_input_for_normal_responses_run(monkeypatch): - _, runner, _, transport = _build_transport(monkeypatch) - - 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", - "ApiFormat": "responses", - "Messages": [{"role": "user", "content": "SHOULD_NOT_USE"}], - "ResponsesInput": [ - { - "role": "user", - "content": [{"type": "input_text", "text": "hello from responses input"}], - } - ], - "Stream": False, - }, - ) - - assert response.status_code == 200 - assert runner.invocations[-1]["input"] == "hello from responses input" - assert runner.invocations[-1]["input_content"] == [ - {"type": "input_text", "text": "hello from responses input"} - ] - assert runner.invocations[-1]["input_messages"] == [ - { - "role": "user", - "content": [{"type": "input_text", "text": "hello from responses input"}], - } - ] - - -@pytest.mark.asyncio -async def test_run_agent_action_long_history_generates_semantic_checkpoint(monkeypatch): - server_app_module, runner, service, transport = _build_transport(monkeypatch) - conversation_runtime = importlib.import_module("ksadk.conversations.runtime") - model_context_module = importlib.import_module("ksadk.conversations.model_context") - - session = await service.create_session( - agent_id="demo-agent", - user_id="user", - session_id="sess-ui-semantic", - ) - for turn_index in range(3): - invocation_id = f"ui-sem-{turn_index}" - user_text = f"长历史用户消息 {turn_index} " + ("甲方要求很多 " * 10) - assistant_text = f"长历史助手回复 {turn_index} " + ("当前已经分析过 " * 10) - await conversation_runtime.append_conversation_event( - session_id=session.id, - author="user", - role="user", - text=user_text, - invocation_id=invocation_id, - event_type="user_message", - metadata={"agent_input": user_text}, - session_service_provider=lambda: service, - ) - await conversation_runtime.append_conversation_event( - session_id=session.id, - author="demo-agent", - role="model", - text=assistant_text, - invocation_id=invocation_id, - event_type="assistant_message", - session_service_provider=lambda: service, - ) - - class _SemanticSummaryClient: - is_available = True - - async def summarize(self, *, model, messages, timeout_ms): - assert model == "glm-5.1" - assert timeout_ms > 0 - assert any("当前用户目标" in item["content"] for item in messages) - return ( - "draft当前用户目标\n- 继续处理默认 UI 长会话\n\n关键约束与偏好\n- 摘要质量优先\n\n已完成进展\n- 已为较早轮次生成 checkpoint\n\n重要决策/代码上下文\n- 仍然保留 append-only transcript\n\n未完成事项\n- 继续回答用户追问\n\n下一步工作位置\n- /agentengine/api/v1/RunAgent", - {"prompt_tokens": 88, "completion_tokens": 22, "total_tokens": 110}, - ) - - monkeypatch.setattr( - "ksadk.conversations.semantic_summary.resolve_summary_model_client", - lambda: _SemanticSummaryClient(), - ) - monkeypatch.setattr(conversation_runtime, "AUTOCOMPACT_KEEP_TAIL_GROUPS", 1) - monkeypatch.setattr(model_context_module, "DEFAULT_CONTEXT_WINDOW_TOKENS", 40) - monkeypatch.setattr(model_context_module, "DEFAULT_MAX_OUTPUT_TOKENS", 0) - monkeypatch.setattr(model_context_module, "AUTOCOMPACT_SUMMARY_RESERVE_TOKENS", 0) - monkeypatch.setattr(model_context_module, "AUTOCOMPACT_BUFFER_TOKENS", 2) - - 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", - "Messages": [{"role": "user", "content": "继续基于之前内容给结论"}], - "SessionId": session.id, - "Model": "glm-5.1", - "ApiFormat": "responses", - "Stream": False, - }, - ) - events_response = await client.post( - "/agentengine/api/v1/ListSessionEvents", - json={"SessionId": session.id}, - ) - - assert response.status_code == 200 - assert runner.invocations[-1]["history"][0]["role"] == "model" - assert "当前用户目标" in runner.invocations[-1]["history"][0]["content"] - event_items = events_response.json()["Data"]["Events"] - checkpoint = next(item for item in event_items if item["EventType"] == "context_checkpoint") - assert checkpoint["Metadata"]["summary_strategy"] == "semantic" - assert checkpoint["Metadata"]["summary_version"] == "v1" - assert checkpoint["Metadata"]["summary_model"] == "glm-5.1" - assert checkpoint["Metadata"]["summary_usage"]["total_tokens"] == 110 - assert "当前用户目标" in checkpoint["Content"]["parts"][0]["text"] - - -@pytest.mark.asyncio -async def test_session_kop_actions_crud_and_event_listing(monkeypatch): - _, _, _, transport = _build_transport(monkeypatch) - - async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client: - created = await client.post( - "/agentengine/api/v1/CreateSession", - json={"AgentId": "demo-agent", "UserId": "user-1"}, - ) - session_id = created.json()["Data"]["Session"]["SessionId"] - - await client.post( - "/agentengine/api/v1/RunAgent", - json={ - "AgentId": "demo-agent", - "Messages": [{"role": "user", "content": "hello"}], - "SessionId": session_id, - "ApiFormat": "responses", - }, - ) - - listed = await client.post( - "/agentengine/api/v1/ListSessions", - json={"AgentId": "demo-agent", "UserId": "user-1"}, - ) - fetched = await client.post( - "/agentengine/api/v1/GetSession", - json={"SessionId": session_id}, - ) - events = await client.post( - "/agentengine/api/v1/ListSessionEvents", - json={"SessionId": session_id}, - ) - deleted = await client.post( - "/agentengine/api/v1/DeleteSession", - json={"SessionId": session_id}, - ) - - assert created.status_code == 200 - assert listed.status_code == 200 - assert fetched.status_code == 200 - assert events.status_code == 200 - assert deleted.status_code == 200 - created_session = created.json()["Data"]["Session"] - fetched_session = fetched.json()["Data"]["Session"] - assert created_session["Title"] == "" - assert created_session["Summary"] == "" - assert created_session["FirstPrompt"] == "" - assert created_session["LastPrompt"] == "" - assert [item["SessionId"] for item in listed.json()["Data"]["Sessions"]] == [session_id] - assert fetched_session["SessionId"] == session_id - assert fetched_session["Title"] == "hello" - assert fetched_session["TitleSource"] == "fallback_first_prompt" - assert fetched_session["FirstPrompt"] == "hello" - assert fetched_session["LastPrompt"] == "hello" - assert fetched_session["Summary"] == "assistant says hi" - assert [item["Author"] for item in events.json()["Data"]["Events"]] == [ - "user", - "demo-agent", - "demo-agent", - "demo-agent", - ] - assert [item["EventType"] for item in events.json()["Data"]["Events"]] == [ - "user_message", - "run_status", - "assistant_message", - "run_status", - ] - assert deleted.json()["Data"]["Deleted"] is True - - -@pytest.mark.asyncio -async def test_responses_endpoint_streams_thinking_and_text_events(monkeypatch): - _, runner, service, transport = _build_transport(monkeypatch) - - async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client: - response = await client.post( - "/v1/responses", - json={ - "input": [{"role": "user", "content": [{"type": "input_text", "text": "hello"}]}], - "model": "glm-5.1", - "session_id": "sess-responses-stream", - "stream": True, - }, - ) - - assert response.status_code == 200 - lines = [line for line in response.text.splitlines() if line.startswith("event: ")] - assert "event: response.created" in lines - assert "event: response.in_progress" in lines - assert "event: response.output_item.added" in lines - assert "event: response.function_call_arguments.delta" in lines - assert "event: response.function_call_arguments.done" in lines - assert "event: response.ksadk.tool_result" in lines - assert "event: response.reasoning.delta" in lines - assert "event: response.output_text.delta" in lines - assert "event: response.output_text.done" in lines - assert "event: response.completed" in lines - added_indexes = [] - current_event = "" - for line in response.text.splitlines(): - if line.startswith("event: "): - current_event = line.removeprefix("event: ") - elif line.startswith("data: ") and current_event == "response.output_item.added": - added_indexes.append(json.loads(line.removeprefix("data: "))["output_index"]) - assert added_indexes == [0, 1, 2] - assert runner.invocations[-1]["model"] == "glm-5.1" - assert runner.invocations[-1]["session_id"] == "sess-responses-stream" - assert await service.get_session("sess-responses-stream") is not None - - completed_payloads = [] - current_event = "" - for line in response.text.splitlines(): - if line.startswith("event: "): - current_event = line.removeprefix("event: ") - elif line.startswith("data: ") and current_event == "response.completed": - completed_payloads.append(json.loads(line.removeprefix("data: "))) - assert completed_payloads[-1]["model"] == "glm-5.1" - assert completed_payloads[-1]["session_id"] == "sess-responses-stream" - - -@pytest.mark.asyncio -async def test_responses_endpoint_passes_full_request_history_to_runner(monkeypatch): - _, runner, _, transport = _build_transport(monkeypatch) - - async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client: - response = await client.post( - "/v1/responses", - json={ - "input": [ - {"role": "user", "content": "写一个python快排的示例"}, - {"role": "assistant", "content": "这是 Python 快速排序示例。"}, - {"role": "user", "content": "用go"}, - ], - "model": "glm-5.1", - "session_id": "sess-responses-history", - "stream": True, - }, - ) - - assert response.status_code == 200 - assert runner.invocations[-1]["input"] == "用go" - assert "responses_conversation" not in runner.invocations[-1] - assert runner.invocations[-1]["history"] == [ - {"role": "user", "content": "写一个python快排的示例"}, - {"role": "model", "content": "这是 Python 快速排序示例。"}, - {"role": "user", "content": "用go"}, - ] - - -@pytest.mark.asyncio -async def test_responses_endpoint_non_streaming_supports_instructions_and_metadata( - monkeypatch, - active_trace_provider, -): - _, runner, service, transport = _build_transport(monkeypatch) - - async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client: - response = await client.post( - "/v1/responses", - json={ - "input": "hello", - "instructions": "只用中文回答", - "metadata": {"trace_label": "demo"}, - "stream": False, - }, - ) - - assert response.status_code == 200 - payload = response.json() - assert payload["object"] == "response" - assert payload["status"] == "completed" - assert payload["metadata"]["trace_label"] == "demo" - assert payload["metadata"]["trace_id"] - assert payload["metadata"]["root_span_id"] - assert payload["output_text"] == "assistant says hi" - assert payload["session_id"] - assert runner.invocations[-1]["instructions"] == "只用中文回答" - assert "responses_conversation" not in runner.invocations[-1] - - events = await service.get_events(payload["session_id"]) - user_event = next(event for event in events if event.event_type == "user_message") - assert user_event.content["parts"][0]["text"] == "hello" - assert user_event.metadata["instructions"] == "只用中文回答" - assert user_event.metadata["request_metadata"] == {"trace_label": "demo"} - - -@pytest.mark.asyncio -async def test_responses_endpoint_streaming_interrupt_returns_incomplete(monkeypatch): - _, _, service, transport = _build_transport_with_runner(monkeypatch, _InterruptRunner()) - - async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client: - response = await client.post( - "/v1/responses", - json={"input": "delete it", "stream": True}, - ) - - assert response.status_code == 200 - lines = [line for line in response.text.splitlines() if line.startswith("event: ")] - assert "event: response.output_item.added" in lines - assert "event: response.incomplete" in lines - assert "event: response.completed" not in lines - - data_lines = [line.removeprefix("data: ") for line in response.text.splitlines() if line.startswith("data: ")] - assert any( - json.loads(line).get("item", {}).get("type") == "mcp_approval_request" - for line in data_lines - ) - incomplete_payload = next( - json.loads(line) - for line in data_lines - if json.loads(line).get("status") == "incomplete" - ) - assert incomplete_payload["incomplete_details"]["reason"] == "approval_required" - events = await service.get_events(incomplete_payload["session_id"]) - assert any(event.event_type == "approval_request" for event in events) - - -@pytest.mark.asyncio -async def test_responses_endpoint_streaming_generic_interrupt_uses_ksadk_extension(monkeypatch): - _, _, _, transport = _build_transport_with_runner(monkeypatch, _GenericInterruptRunner()) - - async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client: - response = await client.post( - "/v1/responses", - json={"input": "review it", "stream": True}, - ) - - assert response.status_code == 200 - lines = [line for line in response.text.splitlines() if line.startswith("event: ")] - assert "event: response.ksadk.approval_request" in lines - assert "event: response.incomplete" in lines - - -@pytest.mark.asyncio -async def test_responses_endpoint_accepts_mcp_approval_response_resume(monkeypatch): - _, runner, service, transport = _build_transport(monkeypatch) - await service.create_session(agent_id="demo-agent", user_id="user", session_id="sess-approval") - await service.append_event( - "sess-approval", - SessionEvent( - author="demo-agent", - event_type="approval_request", - content={"role": "model", "parts": [{"text": "confirm tool"}]}, - metadata={ - "interrupt_info": { - "approval_request_id": "appr_123", - "tool_name": "delete_file", - "arguments": {"path": "notes.txt"}, - } - }, - invocation_id="inv-approval", - ), - ) - - async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client: - response = await client.post( - "/v1/responses", - json={ - "session_id": "sess-approval", - "previous_response_id": "resp_previous", - "input": [ - { - "type": "mcp_approval_response", - "id": "mcprsp_123", - "approval_request_id": "appr_123", - "approve": True, - "reason": "approved", - } - ], - }, - ) - - assert response.status_code == 200 - payload = response.json() - assert payload["status"] == "completed" - assert payload["metadata"]["previous_response_id"] == "resp_previous" - assert runner.invocations[-1]["resume"] is True - assert runner.invocations[-1]["input"] == { - "type": "mcp_approval_response", - "id": "mcprsp_123", - "approval_request_id": "appr_123", - "approve": True, - "reason": "approved", - "tool_name": "delete_file", - "tool_args": { - "path": "notes.txt", - "approval": { - "approved": True, - "approval_request_id": "appr_123", - "reason": "approved", - }, - }, - "approval": { - "approved": True, - "approval_request_id": "appr_123", - "reason": "approved", - }, - } - events = await service.get_events("sess-approval") - assert [event.event_type for event in events[:2]] == ["approval_request", "approval_response"] - - -@pytest.mark.asyncio -async def test_responses_endpoint_streams_mcp_approval_response_resume(monkeypatch): - _, runner, service, transport = _build_transport(monkeypatch) - await service.create_session( - agent_id="demo-agent", user_id="user", session_id="sess-approval-stream" - ) - await service.append_event( - "sess-approval-stream", - SessionEvent( - author="demo-agent", - event_type="approval_request", - content={"role": "model", "parts": [{"text": "confirm tool"}]}, - metadata={ - "interrupt_info": { - "approval_request_id": "appr_stream", - "tool_name": "write_workspace_file", - "arguments": {"path": "notes.txt", "content": "hello"}, - "run_id": "run_stream", - } - }, - invocation_id="inv-approval", - ), - ) - - async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client: - response = await client.post( - "/v1/responses", - json={ - "session_id": "sess-approval-stream", - "previous_response_id": "resp_previous", - "input": [ - { - "type": "mcp_approval_response", - "approval_request_id": "appr_stream", - "approve": True, - } - ], - "stream": True, - }, - ) - - assert response.status_code == 200 - assert "event: response.completed" in response.text - assert runner.invocations[-1]["resume"] is True - assert runner.invocations[-1]["input"] == { - "type": "function_call_output", - "call_id": "run_stream", - "output": { - "ok": True, - "path": "notes.txt", - "absolute_path": runner.invocations[-1]["input"]["output"]["absolute_path"], - "size": 5, - }, - } - assert Path(runner.invocations[-1]["input"]["output"]["absolute_path"]).read_text( - encoding="utf-8" - ) == "hello" - - -@pytest.mark.asyncio -async def test_responses_endpoint_passes_attachment_results_to_runner(monkeypatch): - _, runner, _, transport = _build_transport(monkeypatch) - attachment_b64 = base64.b64encode("候选人简历内容".encode("utf-8")).decode("ascii") - - async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client: - response = await client.post( - "/v1/responses", - json={ - "input": [ - { - "role": "user", - "content": [ - {"type": "input_text", "text": "请分析附件"}, - { - "type": "input_file", - "inlineData": { - "displayName": "resume.txt", - "mimeType": "text/plain", - "data": attachment_b64, - }, - }, - ], - } - ], - "stream": False, - }, - ) - - assert response.status_code == 200 - assert runner.invocations[-1]["attachment_results"] == [ - { - "display_name": "resume.txt", - "mime_type": "text/plain", - "transport": "inline", - "file_uri": "", - "size_bytes": len("候选人简历内容".encode("utf-8")), - "kind": "text", - "status": "ok", - "warnings": [], - "extraction_method": "text_decode", - "text_excerpt": "候选人简历内容", - "text": "候选人简历内容", - } - ] - - -@pytest.mark.asyncio -async def test_responses_endpoint_maps_openai_input_image_to_current_attachments(monkeypatch): - _, runner, _, transport = _build_transport(monkeypatch) - image_b64 = base64.b64encode(b"\x89PNG\r\n").decode("ascii") - - async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client: - response = await client.post( - "/v1/responses", - json={ - "input": [ - { - "role": "user", - "content": [ - {"type": "input_text", "text": "请分析这张图"}, - { - "type": "input_image", - "image_url": f"data:image/png;base64,{image_b64}", - }, - ], - } - ], - "stream": False, - }, - ) - - assert response.status_code == 200 - assert runner.invocations[-1]["has_current_files"] is True - assert runner.invocations[-1]["current_attachments"] == [ - { - "display_name": "uploaded_image", - "mime_type": "image/png", - "transport": "inline", - "data": image_b64, - "is_text": False, - "size_bytes": len(b"\x89PNG\r\n"), - } - ] - assert runner.invocations[-1]["attachments"] == runner.invocations[-1]["current_attachments"] - assert runner.invocations[-1]["input_content"] == [ - {"type": "input_text", "text": "请分析这张图"}, - {"type": "input_image", "image_url": f"data:image/png;base64,{image_b64}"}, - ] - assert runner.invocations[-1]["input_parts"][1] == { - "inlineData": { - "data": image_b64, - "mimeType": "image/png", - "displayName": "uploaded_image", - } - } - - -@pytest.mark.asyncio -async def test_responses_endpoint_preserves_openai_input_image_remote_url(monkeypatch): - _, runner, _, transport = _build_transport(monkeypatch) - image_url = "https://example.com/diagram.png" - - async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client: - response = await client.post( - "/v1/responses", - json={ - "input": [ - { - "role": "user", - "content": [ - {"type": "input_text", "text": "请分析这张图"}, - { - "type": "input_image", - "image_url": image_url, - }, - ], - } - ], - "stream": False, - }, - ) - - assert response.status_code == 200 - assert runner.invocations[-1]["has_current_files"] is True - assert runner.invocations[-1]["current_attachments"][0]["file_uri"] == image_url - assert runner.invocations[-1]["current_attachments"][0]["mime_type"] == "image/*" - assert runner.invocations[-1]["current_attachments"][0]["storage_path"] is None - - -@pytest.mark.asyncio -async def test_responses_endpoint_maps_openai_input_file_data_to_current_attachments(monkeypatch): - _, runner, _, transport = _build_transport(monkeypatch) - file_text = "候选人简历内容" - file_b64 = base64.b64encode(file_text.encode("utf-8")).decode("ascii") - - async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client: - response = await client.post( - "/v1/responses", - json={ - "input": [ - { - "role": "user", - "content": [ - {"type": "input_text", "text": "请分析附件"}, - { - "type": "input_file", - "filename": "resume.txt", - "file_data": file_b64, - }, - ], - } - ], - "stream": False, - }, - ) - - assert response.status_code == 200 - assert runner.invocations[-1]["has_current_files"] is True - assert runner.invocations[-1]["current_attachments"] == [ - { - "display_name": "resume.txt", - "mime_type": "text/plain", - "transport": "inline", - "data": file_b64, - "is_text": True, - "size_bytes": len(file_text.encode("utf-8")), - } - ] - assert runner.invocations[-1]["attachment_results"][0]["text"] == file_text - - -@pytest.mark.asyncio -async def test_streaming_run_agent_fails_before_starting_sse_when_runner_load_fails(monkeypatch): - server_app_module = importlib.import_module("ksadk.server.app") - service = InMemorySessionService() - runner = _BrokenLoadRunner() - monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service) - server_app_module.set_runner(runner) - transport = httpx.ASGITransport(app=server_app_module.app, raise_app_exceptions=False) - - 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", - "Messages": [{"role": "user", "content": "hello"}], - "ApiFormat": "responses", - "Stream": True, - }, - ) - - assert response.status_code == 500 - assert runner.load_agent_calls == 1 - - -def test_cmd_web_launches_unified_local_server(monkeypatch, tmp_path): - runner = CliRunner() - fake_runner = _UiRunner() - project_dir = tmp_path / "demo-agent" - project_dir.mkdir() - opened = {} - - class _Detector: - def __init__(self, path: str): - self.path = path - - def detect(self): - return SimpleNamespace( - type=SimpleNamespace(value="langgraph"), - name="demo-agent", - entry_point="agent.py", - ) - - import ksadk.cli.cmd_web as cmd_web_module - - monkeypatch.setattr(cmd_web_module, "FrameworkDetector", _Detector, raising=False) - monkeypatch.setattr(cmd_web_module, "setup_environment", lambda path: None, raising=False) - monkeypatch.setattr( - "ksadk.cli.cmd_web.create_runner", - lambda result, project_dir: fake_runner, - raising=False, - ) - monkeypatch.setattr(cmd_web_module.webbrowser, "open", lambda url: opened.setdefault("url", url)) - monkeypatch.chdir(project_dir) - - result = runner.invoke(cmd_web_module.web, [str(project_dir), "--port", "8899"]) - - assert result.exit_code == 0, result.output - assert fake_runner.run_server_calls == [8899] - assert fake_runner.load_agent_calls == 0 - assert opened["url"] == "http://localhost:8899" - - -def test_cmd_web_can_skip_browser_open(monkeypatch, tmp_path): - runner = CliRunner() - fake_runner = _UiRunner() - project_dir = tmp_path / "demo-agent" - project_dir.mkdir() - opened = {} - - class _Detector: - def __init__(self, path: str): - self.path = path - - def detect(self): - return SimpleNamespace( - type=SimpleNamespace(value="langgraph"), - name="demo-agent", - entry_point="agent.py", - ) - - import ksadk.cli.cmd_web as cmd_web_module - - monkeypatch.setattr(cmd_web_module, "FrameworkDetector", _Detector, raising=False) - monkeypatch.setattr(cmd_web_module, "setup_environment", lambda path: None, raising=False) - monkeypatch.setattr( - "ksadk.cli.cmd_web.create_runner", - lambda result, project_dir: fake_runner, - raising=False, - ) - monkeypatch.setattr(cmd_web_module.webbrowser, "open", lambda url: opened.setdefault("url", url)) - monkeypatch.chdir(project_dir) - - result = runner.invoke(cmd_web_module.web, [str(project_dir), "--port", "8899", "--no-open"]) - - assert result.exit_code == 0, result.output - assert fake_runner.run_server_calls == [8899] - assert opened == {} - - -def test_cmd_web_reexecs_with_project_venv_python(monkeypatch, tmp_path): - runner = CliRunner() - project_dir = tmp_path / "demo-agent" - 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") - - import ksadk.cli.cmd_web as cmd_web_module - - 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) - - import ksadk.cli.local_runtime as local_runtime - - monkeypatch.delenv("AGENTENGINE_WEB_VENV_REEXEC", raising=False) - 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) - - result = runner.invoke(cmd_web_module.web, [str(project_dir), "--port", "8899"]) - - assert result.exit_code == 23 - 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:] == [ - "web", - str(project_dir.resolve()), - "--port", - "8899", - ] - env = captured["env"] - assert isinstance(env, dict) - assert env["AGENTENGINE_LOCAL_RUNTIME_VENV_REEXEC"] == "1" - assert str(Path(local_runtime.__file__).resolve().parents[2]) in args[2] - - -def test_cmd_web_does_not_reexec_inside_project_venv(monkeypatch, tmp_path): - runner = CliRunner() - fake_runner = _UiRunner() - project_dir = tmp_path / "demo-agent" - 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") - - class _Detector: - def __init__(self, path: str): - self.path = path - - def detect(self): - return SimpleNamespace( - type=SimpleNamespace(value="langgraph"), - name="demo-agent", - entry_point="agent.py", - ) - - import ksadk.cli.cmd_web as cmd_web_module - import ksadk.cli.local_runtime as local_runtime - - monkeypatch.setattr(local_runtime.sys, "executable", str(venv_python), raising=False) - monkeypatch.setattr( - local_runtime.os, - "execvpe", - lambda *_args, **_kwargs: pytest.fail("should not re-exec inside project venv"), - raising=False, - ) - monkeypatch.setattr(cmd_web_module, "FrameworkDetector", _Detector, raising=False) - monkeypatch.setattr(cmd_web_module, "setup_environment", lambda path: None, raising=False) - monkeypatch.setattr( - "ksadk.cli.cmd_web.create_runner", - lambda result, project_dir: fake_runner, - raising=False, - ) - monkeypatch.chdir(project_dir) - - result = runner.invoke(cmd_web_module.web, [str(project_dir), "--port", "8899"]) - - assert result.exit_code == 0, result.output - assert fake_runner.run_server_calls == [8899] - - -@pytest.mark.parametrize("framework", ["adk", "langgraph", "langchain", "deepagents"]) -def test_cmd_web_defaults_supported_framework_stm_to_persistent_sqlite( - monkeypatch, tmp_path, framework -): - runner = CliRunner() - fake_runner = _UiRunner() - project_dir = tmp_path / f"demo-{framework}-agent" - project_dir.mkdir() - - class _Detector: - def __init__(self, path: str): - self.path = path - - def detect(self): - return SimpleNamespace( - type=SimpleNamespace(value=framework), - name="demo-agent", - entry_point="agent.py", - ) - - import ksadk.cli.cmd_web as cmd_web_module - - monkeypatch.delenv("KSADK_STM_BACKEND", raising=False) - monkeypatch.delenv("KSADK_STM_PATH", raising=False) - monkeypatch.delenv("KSADK_STM_DB_PATH", raising=False) - monkeypatch.delenv("KSADK_STM_URL", raising=False) - monkeypatch.delenv("KSADK_STM_DB_URL", raising=False) - monkeypatch.delenv("KSADK_SESSION_BACKEND", raising=False) - monkeypatch.delenv("KSADK_SESSION_PATH", raising=False) - monkeypatch.delenv("KSADK_SESSION_DSN", raising=False) - monkeypatch.delenv("KSADK_CHECKPOINT_BACKEND", raising=False) - monkeypatch.delenv("KSADK_CHECKPOINT_PATH", raising=False) - monkeypatch.delenv("KSADK_LANGGRAPH_CHECKPOINT_DSN", raising=False) - monkeypatch.delenv("AGENTENGINE_UI_DIR", raising=False) - monkeypatch.delenv("KSADK_PROJECT_DIR", raising=False) - monkeypatch.setattr(cmd_web_module, "FrameworkDetector", _Detector, raising=False) - monkeypatch.setattr(cmd_web_module, "setup_environment", lambda path: None, raising=False) - monkeypatch.setattr( - "ksadk.cli.cmd_web.create_runner", - lambda result, project_dir: fake_runner, - raising=False, - ) - monkeypatch.chdir(project_dir) - - result = runner.invoke(cmd_web_module.web, [str(project_dir), "--port", "8899"]) - - assert result.exit_code == 0, result.output - assert fake_runner.run_server_calls == [8899] - assert os.environ["KSADK_STM_BACKEND"] == "sqlite" - assert os.environ["KSADK_STM_PATH"] == str( - project_dir / ".agentengine" / "ui" / "sessions.sqlite" - ) - assert os.environ["KSADK_SESSION_BACKEND"] == "local" - assert os.environ["KSADK_SESSION_PATH"] == str( - project_dir / ".agentengine" / "ui" / "sessions.sqlite" - ) - if framework == "langgraph": - assert os.environ["KSADK_CHECKPOINT_BACKEND"] == "sqlite" - assert os.environ["KSADK_CHECKPOINT_PATH"] == str( - project_dir / ".agentengine" / "ui" / "checkpoints.sqlite" - ) - - -def test_cmd_web_overrides_project_dotenv_postgres_session_for_local_debug( - monkeypatch, tmp_path -): - runner = CliRunner() - fake_runner = _UiRunner() - project_dir = tmp_path / "demo-langgraph-agent" - project_dir.mkdir() - - class _Detector: - def __init__(self, path: str): - self.path = path - - def detect(self): - return SimpleNamespace( - type=SimpleNamespace(value="langgraph"), - name="demo-agent", - entry_point="agent.py", - ) - - import ksadk.cli.cmd_web as cmd_web_module - - monkeypatch.delenv("KSADK_STM_BACKEND", raising=False) - monkeypatch.delenv("KSADK_STM_PATH", raising=False) - monkeypatch.delenv("KSADK_SESSION_BACKEND", raising=False) - monkeypatch.delenv("KSADK_SESSION_PATH", raising=False) - monkeypatch.delenv("KSADK_SESSION_DSN", raising=False) - monkeypatch.delenv("KSADK_CHECKPOINT_BACKEND", raising=False) - monkeypatch.delenv("KSADK_CHECKPOINT_PATH", raising=False) - monkeypatch.delenv("KSADK_LANGGRAPH_CHECKPOINT_DSN", raising=False) - monkeypatch.delenv("AGENTENGINE_UI_DIR", raising=False) - monkeypatch.delenv("KSADK_PROJECT_DIR", raising=False) - - def fake_setup_environment(_path): - os.environ["KSADK_SESSION_BACKEND"] = "postgres" - os.environ["KSADK_SESSION_DSN"] = "postgresql://ksadk:secret@db.example.test/session" - os.environ["KSADK_CHECKPOINT_BACKEND"] = "postgres" - os.environ["KSADK_LANGGRAPH_CHECKPOINT_DSN"] = "postgresql://ksadk:secret@db.example.test/checkpoints" - - monkeypatch.setattr(cmd_web_module, "FrameworkDetector", _Detector, raising=False) - monkeypatch.setattr(cmd_web_module, "setup_environment", fake_setup_environment, raising=False) - monkeypatch.setattr( - "ksadk.cli.cmd_web.create_runner", - lambda result, project_dir: fake_runner, - raising=False, - ) - monkeypatch.chdir(project_dir) - - result = runner.invoke(cmd_web_module.web, [str(project_dir), "--port", "8899"]) - - assert result.exit_code == 0, result.output - assert fake_runner.run_server_calls == [8899] - assert os.environ["KSADK_SESSION_BACKEND"] == "local" - assert os.environ["KSADK_SESSION_PATH"] == str( - project_dir / ".agentengine" / "ui" / "sessions.sqlite" - ) - assert "KSADK_SESSION_DSN" not in os.environ - assert os.environ["KSADK_CHECKPOINT_BACKEND"] == "sqlite" - assert os.environ["KSADK_CHECKPOINT_PATH"] == str( - project_dir / ".agentengine" / "ui" / "checkpoints.sqlite" - ) - assert "KSADK_LANGGRAPH_CHECKPOINT_DSN" not in os.environ - - -def test_cmd_web_overrides_dotenv_loaded_before_web_command(monkeypatch, tmp_path): - runner = CliRunner() - fake_runner = _UiRunner() - project_dir = tmp_path / "demo-langgraph-agent" - project_dir.mkdir() - dotenv_text = "\n".join( - [ - "KSADK_SESSION_BACKEND=postgres", - "KSADK_SESSION_DSN=postgresql://ksadk:secret@db.example.test/session", - "KSADK_LANGGRAPH_CHECKPOINT_DSN=postgresql://ksadk:secret@db.example.test/checkpoints", - ] - ) - (project_dir / ".env").write_text(dotenv_text, encoding="utf-8") - - class _Detector: - def __init__(self, path: str): - self.path = path - - def detect(self): - return SimpleNamespace( - type=SimpleNamespace(value="langgraph"), - name="demo-agent", - entry_point="agent.py", - ) - - import ksadk.cli.cmd_web as cmd_web_module - - monkeypatch.setenv("KSADK_SESSION_BACKEND", "postgres") - monkeypatch.setenv("KSADK_SESSION_DSN", "postgresql://ksadk:secret@db.example.test/session") - monkeypatch.setenv("KSADK_LANGGRAPH_CHECKPOINT_DSN", "postgresql://ksadk:secret@db.example.test/checkpoints") - monkeypatch.delenv("KSADK_SESSION_PATH", raising=False) - monkeypatch.delenv("KSADK_CHECKPOINT_BACKEND", raising=False) - monkeypatch.delenv("KSADK_CHECKPOINT_PATH", raising=False) - monkeypatch.delenv("AGENTENGINE_UI_DIR", raising=False) - monkeypatch.delenv("KSADK_PROJECT_DIR", raising=False) - monkeypatch.setattr(cmd_web_module, "FrameworkDetector", _Detector, raising=False) - monkeypatch.setattr(cmd_web_module, "setup_environment", lambda path: None, raising=False) - monkeypatch.setattr( - "ksadk.cli.cmd_web.create_runner", - lambda result, project_dir: fake_runner, - raising=False, - ) - monkeypatch.chdir(project_dir) - - result = runner.invoke(cmd_web_module.web, [str(project_dir), "--port", "8899"]) - - assert result.exit_code == 0, result.output - assert fake_runner.run_server_calls == [8899] - assert os.environ["KSADK_SESSION_BACKEND"] == "local" - assert os.environ["KSADK_SESSION_PATH"] == str( - project_dir / ".agentengine" / "ui" / "sessions.sqlite" - ) - assert "KSADK_SESSION_DSN" not in os.environ - assert os.environ["KSADK_CHECKPOINT_BACKEND"] == "sqlite" - assert os.environ["KSADK_CHECKPOINT_PATH"] == str( - project_dir / ".agentengine" / "ui" / "checkpoints.sqlite" - ) - assert "KSADK_LANGGRAPH_CHECKPOINT_DSN" not in os.environ - - -def test_cmd_web_overrides_project_dotenv_ui_dir_for_local_debug(monkeypatch, tmp_path): - runner = CliRunner() - fake_runner = _UiRunner() - project_dir = tmp_path / "demo-langgraph-agent" - project_dir.mkdir() - (project_dir / ".env").write_text( - "AGENTENGINE_UI_DIR=/home/node/.agentengine/ui\n", - encoding="utf-8", - ) - - class _Detector: - def __init__(self, path: str): - self.path = path - - def detect(self): - return SimpleNamespace( - type=SimpleNamespace(value="langgraph"), - name="demo-agent", - entry_point="agent.py", - ) - - import ksadk.cli.cmd_web as cmd_web_module - - monkeypatch.setenv("AGENTENGINE_UI_DIR", "/home/node/.agentengine/ui") - monkeypatch.setattr(cmd_web_module, "FrameworkDetector", _Detector, raising=False) - monkeypatch.setattr(cmd_web_module, "setup_environment", lambda path: None, raising=False) - monkeypatch.setattr( - "ksadk.cli.cmd_web.create_runner", - lambda result, project_dir: fake_runner, - raising=False, - ) - monkeypatch.chdir(project_dir) - - result = runner.invoke(cmd_web_module.web, [str(project_dir), "--port", "8899"]) - - assert result.exit_code == 0, result.output - assert fake_runner.run_server_calls == [8899] - assert os.environ["AGENTENGINE_UI_DIR"] == str(project_dir / ".agentengine" / "ui") - - -def test_cmd_web_preserves_explicit_ui_dir_for_local_debug(monkeypatch, tmp_path): - runner = CliRunner() - fake_runner = _UiRunner() - project_dir = tmp_path / "demo-langgraph-agent" - project_dir.mkdir() - (project_dir / ".env").write_text( - "AGENTENGINE_UI_DIR=/home/node/.agentengine/ui\n", - encoding="utf-8", - ) - explicit_ui_dir = str(tmp_path / "custom-ui-state") - - class _Detector: - def __init__(self, path: str): - self.path = path - - def detect(self): - return SimpleNamespace( - type=SimpleNamespace(value="langgraph"), - name="demo-agent", - entry_point="agent.py", - ) - - import ksadk.cli.cmd_web as cmd_web_module - - monkeypatch.setenv("AGENTENGINE_UI_DIR", explicit_ui_dir) - monkeypatch.setattr(cmd_web_module, "FrameworkDetector", _Detector, raising=False) - monkeypatch.setattr(cmd_web_module, "setup_environment", lambda path: None, raising=False) - monkeypatch.setattr( - "ksadk.cli.cmd_web.create_runner", - lambda result, project_dir: fake_runner, - raising=False, - ) - monkeypatch.chdir(project_dir) - - result = runner.invoke(cmd_web_module.web, [str(project_dir), "--port", "8899"]) - - assert result.exit_code == 0, result.output - assert fake_runner.run_server_calls == [8899] - assert os.environ["AGENTENGINE_UI_DIR"] == explicit_ui_dir - - -def test_cmd_web_exports_custom_ui_config_and_opens_custom_path(monkeypatch, tmp_path): - runner = CliRunner() - fake_runner = _UiRunner() - project_dir = tmp_path / "demo-langgraph-agent" - bundle_dir = project_dir / "research-ui" / "dist" - bundle_dir.mkdir(parents=True) - (bundle_dir / "index.html").write_text("Custom UI", encoding="utf-8") - (project_dir / "agentengine.yaml").write_text( - "\n".join( - [ - "name: demo-agent", - "framework: langgraph", - "entry_point: agent.py", - "ui_profile: custom", - "ui_path: /research", - "ui_bundle_path: research-ui/dist", - ] - ), - encoding="utf-8", - ) - opened = {} - - class _Detector: - def __init__(self, path: str): - self.path = path - - def detect(self): - return SimpleNamespace( - type=SimpleNamespace(value="langgraph"), - name="demo-agent", - entry_point="agent.py", - ) - - import ksadk.cli.cmd_web as cmd_web_module - - monkeypatch.delenv("KSADK_UI_PROFILE", raising=False) - monkeypatch.delenv("KSADK_UI_PATH", raising=False) - monkeypatch.delenv("KSADK_UI_BUNDLE_PATH", raising=False) - monkeypatch.setattr(cmd_web_module, "FrameworkDetector", _Detector, raising=False) - monkeypatch.setattr(cmd_web_module, "setup_environment", lambda path: None, raising=False) - monkeypatch.setattr( - "ksadk.cli.cmd_web.create_runner", - lambda result, project_dir: fake_runner, - raising=False, - ) - monkeypatch.setattr(cmd_web_module.webbrowser, "open", lambda url: opened.setdefault("url", url)) - monkeypatch.chdir(project_dir) - - result = runner.invoke(cmd_web_module.web, [str(project_dir), "--port", "8899"]) - - assert result.exit_code == 0, result.output - assert os.environ["KSADK_UI_PROFILE"] == "custom" - assert os.environ["KSADK_UI_PATH"] == "/research" - assert os.environ["KSADK_UI_BUNDLE_PATH"] == "research-ui/dist" - assert opened["url"] == "http://localhost:8899/research" - monkeypatch.delenv("KSADK_UI_PROFILE", raising=False) - monkeypatch.delenv("KSADK_UI_PATH", raising=False) - monkeypatch.delenv("KSADK_UI_BUNDLE_PATH", raising=False) - - -def test_server_serves_custom_ui_path_and_assets_from_env(monkeypatch, tmp_path): - server_app_module = importlib.import_module("ksadk.server.app") - project_dir = tmp_path / "agent" - bundle_dir = project_dir / "research-ui" / "dist" - assets_dir = bundle_dir / "assets" - assets_dir.mkdir(parents=True) - (bundle_dir / "index.html").write_text( - 'Custom UI', - encoding="utf-8", - ) - (assets_dir / "index.js").write_text("window.customUiLoaded = true;", encoding="utf-8") - runner = _UiRunner() - runner.project_dir = str(project_dir) - - server_app_module.set_runner(runner) - monkeypatch.setenv("KSADK_UI_PROFILE", "custom") - monkeypatch.setenv("KSADK_UI_PATH", "/research") - monkeypatch.setenv("KSADK_UI_BUNDLE_PATH", "research-ui/dist") - - client = TestClient(server_app_module.app) - shell_response = client.get("/research") - asset_response = client.get("/research/assets/index.js") - - assert shell_response.status_code == 200 - assert "Custom UI" in shell_response.text - assert asset_response.status_code == 200 - assert "customUiLoaded" in asset_response.text - monkeypatch.delenv("KSADK_UI_PROFILE", raising=False) - monkeypatch.delenv("KSADK_UI_PATH", raising=False) - monkeypatch.delenv("KSADK_UI_BUNDLE_PATH", raising=False) - server_app_module.set_runner(_UiRunner()) - - -def test_server_serves_custom_ui_spa_routes_from_env(monkeypatch, tmp_path): - server_app_module = importlib.import_module("ksadk.server.app") - project_dir = tmp_path / "agent" - bundle_dir = project_dir / "frontend" / "dist" - assets_dir = bundle_dir / "assets" - assets_dir.mkdir(parents=True) - (bundle_dir / "index.html").write_text( - 'Custom UI', - encoding="utf-8", - ) - runner = _UiRunner() - runner.project_dir = str(project_dir) - - server_app_module.set_runner(runner) - monkeypatch.setenv("KSADK_UI_PROFILE", "custom") - monkeypatch.setenv("KSADK_UI_PATH", "/luoluo") - monkeypatch.setenv("KSADK_UI_BUNDLE_PATH", "frontend/dist") - - client = TestClient(server_app_module.app) - shell_response = client.get("/luoluo/chat") - missing_asset_response = client.get("/luoluo/assets/missing.js") - - assert shell_response.status_code == 200 - assert "Custom UI" in shell_response.text - assert missing_asset_response.status_code == 404 - monkeypatch.delenv("KSADK_UI_PROFILE", raising=False) - monkeypatch.delenv("KSADK_UI_PATH", raising=False) - monkeypatch.delenv("KSADK_UI_BUNDLE_PATH", raising=False) - server_app_module.set_runner(_UiRunner()) - - -def test_cmd_web_preserves_explicit_stm_configuration(monkeypatch, tmp_path): - runner = CliRunner() - fake_runner = _UiRunner() - project_dir = tmp_path / "demo-langgraph-agent" - project_dir.mkdir() - - class _Detector: - def __init__(self, path: str): - self.path = path - - def detect(self): - return SimpleNamespace( - type=SimpleNamespace(value="langgraph"), - name="demo-agent", - entry_point="agent.py", - ) - - import ksadk.cli.cmd_web as cmd_web_module - - monkeypatch.setenv("KSADK_STM_BACKEND", "local") - monkeypatch.setenv("KSADK_STM_PATH", "/tmp/custom-sessions.db") - monkeypatch.setenv("KSADK_SESSION_BACKEND", "postgres") - monkeypatch.setenv("KSADK_SESSION_DSN", "postgresql://ksadk:secret@db.example.test/session") - monkeypatch.setenv("KSADK_CHECKPOINT_BACKEND", "postgres") - monkeypatch.setenv("KSADK_LANGGRAPH_CHECKPOINT_DSN", "postgresql://ksadk:secret@db.example.test/checkpoints") - monkeypatch.delenv("AGENTENGINE_UI_DIR", raising=False) - monkeypatch.delenv("KSADK_PROJECT_DIR", raising=False) - monkeypatch.setattr(cmd_web_module, "FrameworkDetector", _Detector, raising=False) - monkeypatch.setattr(cmd_web_module, "setup_environment", lambda path: None, raising=False) - monkeypatch.setattr( - "ksadk.cli.cmd_web.create_runner", - lambda result, project_dir: fake_runner, - raising=False, - ) - monkeypatch.chdir(project_dir) - - result = runner.invoke(cmd_web_module.web, [str(project_dir), "--port", "8899"]) - - assert result.exit_code == 0, result.output - assert fake_runner.run_server_calls == [8899] - assert os.environ["KSADK_STM_BACKEND"] == "local" - assert os.environ["KSADK_STM_PATH"] == "/tmp/custom-sessions.db" - assert os.environ["KSADK_SESSION_BACKEND"] == "postgres" - assert os.environ["KSADK_SESSION_DSN"] == "postgresql://ksadk:secret@db.example.test/session" - assert os.environ["KSADK_CHECKPOINT_BACKEND"] == "postgres" - assert os.environ["KSADK_LANGGRAPH_CHECKPOINT_DSN"] == "postgresql://ksadk:secret@db.example.test/checkpoints" - - -def test_cmd_web_treats_explicit_local_checkpoint_backend_as_sqlite(monkeypatch, tmp_path): - runner = CliRunner() - fake_runner = _UiRunner() - project_dir = tmp_path / "demo-langgraph-agent" - project_dir.mkdir() - - class _Detector: - def __init__(self, path: str): - self.path = path - - def detect(self): - return SimpleNamespace( - type=SimpleNamespace(value="langgraph"), - name="demo-agent", - entry_point="agent.py", - ) - - import ksadk.cli.cmd_web as cmd_web_module - - monkeypatch.setenv("KSADK_CHECKPOINT_BACKEND", "local") - monkeypatch.delenv("KSADK_CHECKPOINT_PATH", raising=False) - monkeypatch.delenv("KSADK_LANGGRAPH_CHECKPOINT_DSN", raising=False) - monkeypatch.setattr(cmd_web_module, "FrameworkDetector", _Detector, raising=False) - monkeypatch.setattr(cmd_web_module, "setup_environment", lambda path: None, raising=False) - monkeypatch.setattr( - "ksadk.cli.cmd_web.create_runner", - lambda result, project_dir: fake_runner, - raising=False, - ) - monkeypatch.chdir(project_dir) - - result = runner.invoke(cmd_web_module.web, [str(project_dir), "--port", "8899"]) - - assert result.exit_code == 0, result.output - assert fake_runner.run_server_calls == [8899] - assert os.environ["KSADK_CHECKPOINT_BACKEND"] == "sqlite" - assert os.environ["KSADK_CHECKPOINT_PATH"] == str( - project_dir / ".agentengine" / "ui" / "checkpoints.sqlite" - ) - - -def test_cmd_web_errors_when_langgraph_sqlite_checkpoint_package_missing( - monkeypatch, tmp_path -): - runner = CliRunner() - fake_runner = _UiRunner() - project_dir = tmp_path / "demo-langgraph-agent" - project_dir.mkdir() - - class _Detector: - def __init__(self, path: str): - self.path = path - - def detect(self): - return SimpleNamespace( - type=SimpleNamespace(value="langgraph"), - name="demo-agent", - entry_point="agent.py", - ) - - import builtins - import ksadk.cli.cmd_web as cmd_web_module - - original_import = builtins.__import__ - - def fake_import(name, globals=None, locals=None, fromlist=(), level=0): - if name == "langgraph.checkpoint.sqlite.aio": - raise ImportError("missing sqlite checkpointer") - return original_import(name, globals, locals, fromlist, level) - - monkeypatch.delenv("KSADK_CHECKPOINT_BACKEND", raising=False) - monkeypatch.delenv("KSADK_CHECKPOINT_PATH", raising=False) - monkeypatch.delenv("KSADK_LANGGRAPH_CHECKPOINT_DSN", raising=False) - monkeypatch.setattr(builtins, "__import__", fake_import) - monkeypatch.setattr(cmd_web_module, "FrameworkDetector", _Detector, raising=False) - monkeypatch.setattr(cmd_web_module, "setup_environment", lambda path: None, raising=False) - monkeypatch.setattr( - "ksadk.cli.cmd_web.create_runner", - lambda result, project_dir: fake_runner, - raising=False, - ) - monkeypatch.chdir(project_dir) - - result = runner.invoke(cmd_web_module.web, [str(project_dir), "--port", "8899"]) - - assert result.exit_code == 1 - assert "pip install langgraph-checkpoint-sqlite" in result.output - assert fake_runner.run_server_calls == [] - - -def test_cmd_web_preserves_partial_explicit_stm_configuration(monkeypatch, tmp_path): - runner = CliRunner() - fake_runner = _UiRunner() - project_dir = tmp_path / "demo-langchain-agent" - project_dir.mkdir() - - class _Detector: - def __init__(self, path: str): - self.path = path - - def detect(self): - return SimpleNamespace( - type=SimpleNamespace(value="langchain"), - name="demo-agent", - entry_point="agent.py", - ) - - import ksadk.cli.cmd_web as cmd_web_module - - monkeypatch.delenv("KSADK_STM_BACKEND", raising=False) - monkeypatch.setenv("KSADK_STM_DB_PATH", "/tmp/legacy-custom-sessions.db") - monkeypatch.delenv("KSADK_STM_PATH", raising=False) - monkeypatch.delenv("AGENTENGINE_UI_DIR", raising=False) - monkeypatch.delenv("KSADK_PROJECT_DIR", raising=False) - monkeypatch.setattr(cmd_web_module, "FrameworkDetector", _Detector, raising=False) - monkeypatch.setattr(cmd_web_module, "setup_environment", lambda path: None, raising=False) - monkeypatch.setattr( - "ksadk.cli.cmd_web.create_runner", - lambda result, project_dir: fake_runner, - raising=False, - ) - monkeypatch.chdir(project_dir) - - result = runner.invoke(cmd_web_module.web, [str(project_dir), "--port", "8899"]) - - assert result.exit_code == 0, result.output - assert fake_runner.run_server_calls == [8899] - assert "KSADK_STM_BACKEND" not in os.environ - assert "KSADK_STM_PATH" not in os.environ - assert os.environ["KSADK_STM_DB_PATH"] == "/tmp/legacy-custom-sessions.db" - - -def test_cmd_web_exits_quietly_on_keyboard_interrupt(monkeypatch, tmp_path): - runner = CliRunner() - fake_runner = _KeyboardInterruptServerRunner() - project_dir = tmp_path / "demo-agent" - project_dir.mkdir() - - class _Detector: - def __init__(self, path: str): - self.path = path - - def detect(self): - return SimpleNamespace( - type=SimpleNamespace(value="langgraph"), - name="demo-agent", - entry_point="agent.py", - ) - - import ksadk.cli.cmd_web as cmd_web_module - - monkeypatch.setattr(cmd_web_module, "FrameworkDetector", _Detector, raising=False) - monkeypatch.setattr(cmd_web_module, "setup_environment", lambda path: None, raising=False) - monkeypatch.setattr( - "ksadk.cli.cmd_web.create_runner", - lambda result, project_dir: fake_runner, - raising=False, - ) - monkeypatch.chdir(project_dir) - - result = runner.invoke(cmd_web_module.web, [str(project_dir), "--port", "8899"]) - - assert result.exit_code == 0, result.output - assert fake_runner.run_server_calls == [8899] - assert "Traceback" not in result.output - assert "统一 Web UI 启动失败" not in result.output - - -@pytest.mark.asyncio -async def test_static_routes_serve_unified_agent_ui_shell(monkeypatch): - _, _, _, transport = _build_transport(monkeypatch) - - async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client: - root_response = await client.get("/") - chat_response = await client.get("/chat") - script_match = re.search(r'src="(\./assets/[^"]+\.js)"', root_response.text) - style_match = re.search(r'href="(\./assets/[^"]+\.css)"', root_response.text) - assert script_match is not None - assert style_match is not None - js_response = await client.get(script_match.group(1).removeprefix(".")) - css_response = await client.get(style_match.group(1).removeprefix(".")) - - assert root_response.status_code == 200 - assert chat_response.status_code == 200 - assert js_response.status_code == 200 - assert css_response.status_code == 200 - assert '
' in root_response.text - assert '
' in chat_response.text - assert root_response.text == chat_response.text - assert 'type="module" crossorigin src="./assets/index-' in root_response.text - assert 'rel="stylesheet" crossorigin href="./assets/index-' in root_response.text - assert "/agentengine/api/v1" in js_response.text - for action_name in ( - "AttachmentContent", - "UploadFile", - "ListSessionEvents", - "ListAgentModels", - "RunAgent", - "ListWorkspaceFiles", - "AddWorkspaceFile", - "DeleteWorkspaceFile", - "GetWorkspaceFileContent", - ): - assert action_name in js_response.text - assert "/run_sse" not in js_response.text - assert "/agentengine/api/v1/models" not in js_response.text - assert "overflow" in css_response.text - - -def test_source_repository_does_not_track_local_web_ui_source(): - result = subprocess.run( - ["git", "ls-files", "ksadk/server/web-ui/**"], - check=True, - text=True, - stdout=subprocess.PIPE, - ) - - assert result.stdout == "" - - -def test_static_workbench_uses_openai_responses_content_for_inline_attachments(): - index_html = Path("ksadk/server/static/index.html").read_text(encoding="utf-8") - match = re.search(r'src="\.\/(assets\/index-[^"]+\.js)"', index_html) - assert match, "static index.html should reference the built Vite entry bundle" - source = Path("ksadk/server/static", match.group(1)).read_text(encoding="utf-8") - - assert "type:`input_image`" in source - assert "image_url:await this.imageFileToDataUrl" in source - assert "type:`input_file`" in source - assert "filename:" in source - assert "file_url:" in source - assert "inlineData: {" not in source diff --git a/tests/test_usage_accumulator.py b/tests/test_usage_accumulator.py deleted file mode 100644 index 1f3f0b96..00000000 --- a/tests/test_usage_accumulator.py +++ /dev/null @@ -1,43 +0,0 @@ -"""usage_accumulator 单测:逐字段累加(input/output/total + details 子键)。""" -from __future__ import annotations - -from ksadk.runners.usage_accumulator import accumulate_usage - - -def test_accumulate_usage_sums_main_fields(): - acc = {} - acc = accumulate_usage(acc, {"input_tokens": 100, "output_tokens": 50, "total_tokens": 150}) - acc = accumulate_usage(acc, {"input_tokens": 200, "output_tokens": 80, "total_tokens": 280}) - assert acc["input_tokens"] == 300 - assert acc["output_tokens"] == 130 - assert acc["total_tokens"] == 430 - - -def test_accumulate_usage_sums_input_token_details(): - """details 键名不统一(cached/cache_read/cache_creation),逐键求和作诊断明细。""" - acc = {} - acc = accumulate_usage(acc, {"input_tokens": 100, "input_token_details": {"cached": 50}}) - acc = accumulate_usage(acc, {"input_tokens": 200, "input_token_details": {"cached": 30, "cache_read": 10}}) - assert acc["input_token_details"]["cached"] == 80 - assert acc["input_token_details"]["cache_read"] == 10 - - -def test_accumulate_usage_handles_empty_delta(): - acc = {"input_tokens": 100, "output_tokens": 50, "total_tokens": 150} - acc = accumulate_usage(acc, {}) - assert acc["input_tokens"] == 100 # 不变 - - -def test_accumulate_usage_does_not_mutate_input(): - """返回新 dict,不改 acc(避免共享状态)。""" - acc = {"input_tokens": 100} - result = accumulate_usage(acc, {"input_tokens": 200}) - assert acc["input_tokens"] == 100 # 原始未变 - assert result["input_tokens"] == 300 - - -def test_accumulate_usage_output_token_details(): - acc = {} - acc = accumulate_usage(acc, {"output_tokens": 50, "output_token_details": {"reasoning": 20}}) - acc = accumulate_usage(acc, {"output_tokens": 30, "output_token_details": {"reasoning": 10}}) - assert acc["output_token_details"]["reasoning"] == 30 diff --git a/tests/test_validate_hosted_long_task_e2e.py b/tests/test_validate_hosted_long_task_e2e.py deleted file mode 100644 index b9171963..00000000 --- a/tests/test_validate_hosted_long_task_e2e.py +++ /dev/null @@ -1,39 +0,0 @@ -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_web_toolset.py b/tests/test_web_toolset.py deleted file mode 100644 index 50874992..00000000 --- a/tests/test_web_toolset.py +++ /dev/null @@ -1,264 +0,0 @@ -from __future__ import annotations - -import httpx - -from ksadk.toolsets import describe_agentengine_tools, get_agentengine_tools -from ksadk.toolsets.web import web_fetch, web_search - - -def test_web_tools_are_registered_in_default_toolset(): - names = {tool.name for tool in get_agentengine_tools()} - - assert "web_fetch" in names - assert "web_search" in names - - -def test_web_tool_descriptors_include_web_group(): - specs = {spec["name"]: spec for spec in describe_agentengine_tools(include=["web"])} - - assert specs["web_fetch"]["group"] == "web" - assert specs["web_search"]["group"] == "web" - - -def test_web_fetch_blocks_loopback_before_request(monkeypatch): - def _should_not_request(*_args, **_kwargs): - raise AssertionError("web_fetch must block loopback URLs before issuing requests") - - monkeypatch.setattr("ksadk.toolsets.web.httpx.Client.get", _should_not_request) - - result = web_fetch("http://127.0.0.1:8000/secret") - - assert result["ok"] is False - assert result["error_type"] == "blocked_by_ssrf_policy" - - -def test_web_fetch_blocks_private_link_local_and_metadata_addresses(monkeypatch): - def _should_not_request(*_args, **_kwargs): - raise AssertionError("web_fetch must block non-public URLs before issuing requests") - - monkeypatch.setattr("ksadk.toolsets.web.httpx.Client.get", _should_not_request) - - for url in ( - "http://10.0.0.2/admin", - "http://192.168.1.2/admin", - "http://169.254.169.254/latest/meta-data", - ): - result = web_fetch(url) - assert result["ok"] is False - assert result["error_type"] == "blocked_by_ssrf_policy" - - -def test_web_fetch_blocks_redirect_to_private_address(monkeypatch): - calls: list[str] = [] - - def _fake_get(self, url, **kwargs): - calls.append(url) - return httpx.Response( - 302, - headers={"location": "http://10.0.0.2/admin"}, - request=httpx.Request("GET", url), - ) - - monkeypatch.setattr("ksadk.toolsets.web.httpx.Client.get", _fake_get) - - result = web_fetch("https://example.com/redirect") - - assert result["ok"] is False - assert result["error_type"] == "blocked_by_ssrf_policy" - assert calls == ["https://example.com/redirect"] - - -def test_web_fetch_strips_html_and_budgets_large_content(monkeypatch, tmp_path): - def _fake_get(self, url, **kwargs): - return httpx.Response( - 200, - headers={"content-type": "text/html"}, - text="

Hello

" + ("world " * 20) + "

", - request=httpx.Request("GET", url), - ) - - monkeypatch.setenv("KSADK_WEB_SSRF_POLICY_JSON", '{"allow_private": true}') - monkeypatch.setenv("KSADK_TOOL_RESULT_DIR", str(tmp_path)) - monkeypatch.setattr("ksadk.toolsets.web.httpx.Client.get", _fake_get) - - result = web_fetch("https://example.com/docs", max_chars=25) - - assert result["ok"] is True - assert "bad()" not in result["text"] - assert "Hello" in result["text"] - assert result["truncated"] is True - assert result["persisted"]["mime_type"] == "text/plain" - - -def test_web_search_returns_provider_not_configured_without_provider(monkeypatch): - monkeypatch.delenv("KSADK_WEB_SEARCH_PROVIDER", raising=False) - monkeypatch.delenv("OPENCLAW_WEB_SEARCH_PROVIDER", raising=False) - - result = web_search("ksadk sandbox") - - assert result["ok"] is False - assert result["error_type"] == "provider_not_configured" - - -def test_web_search_fake_provider_returns_results(monkeypatch): - monkeypatch.setenv("KSADK_WEB_SEARCH_PROVIDER", "fake") - - result = web_search("ksadk sandbox", max_results=2) - - assert result["ok"] is True - assert result["provider"] == "fake" - assert result["results"] == [ - { - "title": "Fake result for ksadk sandbox", - "url": "https://example.com/search?q=ksadk+sandbox", - "snippet": "Fake search result generated by ksadk test provider.", - "rank": 1, - "provider": "fake", - } - ] - - -def test_web_search_http_provider_uses_configured_endpoint(monkeypatch): - captured = {} - - def _fake_get(self, url, **kwargs): - captured["url"] = url - captured["params"] = kwargs.get("params") - captured["headers"] = kwargs.get("headers") - return httpx.Response( - 200, - json={ - "results": [ - {"title": "Doc", "url": "https://example.com/doc", "snippet": "Snippet"} - ] - }, - request=httpx.Request("GET", url), - ) - - monkeypatch.setenv("KSADK_WEB_SEARCH_PROVIDER", "http") - monkeypatch.setenv("KSADK_WEB_SEARCH_BASE_URL", "https://search.example/api") - monkeypatch.setenv("KSADK_WEB_SEARCH_API_KEY", "secret") - monkeypatch.setattr("ksadk.toolsets.web.httpx.Client.get", _fake_get) - - result = web_search("ksadk tools", max_results=3, recency_days=7) - - assert result["ok"] is True - assert result["provider"] == "http" - assert captured["params"] == {"q": "ksadk tools", "max_results": 3, "recency_days": 7} - assert captured["headers"]["Authorization"] == "Bearer secret" - assert result["results"][0]["rank"] == 1 - - -def test_web_search_ksyun_provider_posts_to_ai_search_endpoint(monkeypatch): - captured = {} - - def _fake_post(self, url, **kwargs): - captured["url"] = url - captured["headers"] = kwargs.get("headers") - captured["json"] = kwargs.get("json") - return httpx.Response( - 200, - json={ - "webpages": [ - { - "title": "KsADK 文档", - "link": "https://example.com/ksadk", - "snippet": "KsADK 是金山云 Agent 开发工具包。", - "date": "2026年07月01日", - "position": 1, - } - ], - "credits": 3, - "total": 1, - }, - request=httpx.Request("POST", url), - ) - - monkeypatch.setenv("KSADK_WEB_SEARCH_PROVIDER", "ksyun") - monkeypatch.setenv("KSADK_WEB_SEARCH_API_KEY", "ksyun-token") - monkeypatch.delenv("KSADK_WEB_SEARCH_BASE_URL", raising=False) - monkeypatch.setattr("ksadk.toolsets.web.httpx.Client.post", _fake_post) - - result = web_search("ksadk", max_results=5) - - assert result["ok"] is True - assert result["provider"] == "ksyun" - # 金山云 AI搜索用 POST + JSON body,不是 GET + params。 - assert captured["url"] == "https://search.aipro.ksyun.com/v1/aisearch/search" - assert captured["headers"]["Authorization"] == "Bearer ksyun-token" - assert captured["json"] == {"q": "ksadk", "scope": "webpage", "size": 5} - assert len(result["results"]) == 1 - first = result["results"][0] - assert first["title"] == "KsADK 文档" - assert first["url"] == "https://example.com/ksadk" - assert first["snippet"].startswith("KsADK 是金山云") - assert first["date"] == "2026年07月01日" - assert first["rank"] == 1 - assert first["provider"] == "ksyun" - - -def test_web_search_ksyun_provider_reuses_metaso_credentials(monkeypatch): - # 不设 KSADK_WEB_SEARCH_API_KEY,应回退到 KSADK_MCP_KEY / KSC_AIPRO_API_KEY。 - monkeypatch.setenv("KSADK_WEB_SEARCH_PROVIDER", "ksyun") - monkeypatch.delenv("KSADK_WEB_SEARCH_API_KEY", raising=False) - monkeypatch.delenv("OPENCLAW_WEB_SEARCH_API_KEY", raising=False) - monkeypatch.setenv("KSADK_MCP_KEY", "shared-metaso-token") - - captured = {} - - def _fake_post(self, url, **kwargs): - captured["headers"] = kwargs.get("headers") - return httpx.Response( - 200, - json={"webpages": []}, - request=httpx.Request("POST", url), - ) - - monkeypatch.setattr("ksadk.toolsets.web.httpx.Client.post", _fake_post) - - result = web_search("ksadk") - - assert result["ok"] is True - assert captured["headers"]["Authorization"] == "Bearer shared-metaso-token" - - -def test_web_search_ksyun_provider_returns_not_configured_without_api_key(monkeypatch): - monkeypatch.setenv("KSADK_WEB_SEARCH_PROVIDER", "ksyun") - for key in ( - "KSADK_WEB_SEARCH_API_KEY", - "OPENCLAW_WEB_SEARCH_API_KEY", - "KSADK_MCP_KEY", - "KSC_AIPRO_API_KEY", - "KSC_AIPRO_APIKEY", - "AIPRO_API_KEY", - ): - monkeypatch.delenv(key, raising=False) - - result = web_search("ksadk") - - assert result["ok"] is False - assert result["error_type"] == "provider_not_configured" - assert "api key" in result["error_message"] - - -def test_web_search_ksyun_provider_respects_custom_scope(monkeypatch): - captured = {} - - def _fake_post(self, url, **kwargs): - captured["json"] = kwargs.get("json") - return httpx.Response( - 200, - json={"webpages": []}, - request=httpx.Request("POST", url), - ) - - monkeypatch.setenv("KSADK_WEB_SEARCH_PROVIDER", "ksyun") - monkeypatch.setenv("KSADK_WEB_SEARCH_API_KEY", "token") - monkeypatch.setenv("KSADK_WEB_SEARCH_SCOPE", "scholar") - monkeypatch.setattr("ksadk.toolsets.web.httpx.Client.post", _fake_post) - - result = web_search("transformer architecture", max_results=10) - - assert result["ok"] is True - assert captured["json"]["scope"] == "scholar" - assert captured["json"]["size"] == 10 diff --git a/tests/test_workflow_common.py b/tests/test_workflow_common.py deleted file mode 100644 index c3471591..00000000 --- a/tests/test_workflow_common.py +++ /dev/null @@ -1,221 +0,0 @@ -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 deleted file mode 100644 index 06b59d2a..00000000 --- a/tests/test_workflow_help_snapshots.py +++ /dev/null @@ -1,51 +0,0 @@ -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 deleted file mode 100644 index f83540d9..00000000 --- a/tests/unit/knowledge_base/test_client_env.py +++ /dev/null @@ -1,57 +0,0 @@ -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 deleted file mode 100644 index f5eb5ae9..00000000 --- a/tests/unit/memory/test_adk_memory_comprehensive.py +++ /dev/null @@ -1,1169 +0,0 @@ -"""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_SESSION_BACKEND", raising=False) - monkeypatch.delenv("KSADK_SESSION_PATH", raising=False) - monkeypatch.delenv("KSADK_SESSION_DSN", raising=False) - 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 2e50f6a82ad7591407b521a5fa4d7f5bf4b4a9d5 Mon Sep 17 00:00:00 2001 From: xiayu Date: Wed, 8 Jul 2026 12:07:01 +0800 Subject: [PATCH 4/5] fix(release): refresh public 0.6.9 candidate gates --- .gitleaks.toml | 45 +++++++++++++++++++ AGENTS.md | 2 +- docs/maintainer-approval-record.md | 4 +- ...30\351\207\217\345\217\202\350\200\203.md" | 2 +- export-manifest.json | 8 ++-- scripts/prepare_ksadk_python_export.py | 2 + tests/test_public_release_positioning.py | 19 ++++++++ 7 files changed, 74 insertions(+), 8 deletions(-) create mode 100644 .gitleaks.toml diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 00000000..e77adb8b --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,45 @@ +# .gitleaks.toml — ksadk-python +# 继承 gitleaks 默认规则 (useDefault = true), 仅追加项目级 allowlist。 +# workflow 用法 (见 .github/workflows/secret-patterns.yml): +# gitleaks detect --source . --config .gitleaks.toml --no-banner --redact --verbose +# detect 子命令 + fetch-depth:0 扫全 history; allowlist 同时覆盖历史与当前 tree。 + +title = "ksadk-python gitleaks config" + +[extend] +useDefault = true + +# ---- allowlist 统一用 [[allowlists]] 新语法 (gitleaks 8.21+), 不能与 [allowlist] 旧语法混用 ---- + +# 测试 fixture 假 key (命中当前 tree 与历史 commit) +[[allowlists]] +description = "Allowlist test-fixture placeholders in hermes CLI tests" +regexes = [ + # tests/test_cmd_hermes.py 的假 Bearer token (curl-auth-header 规则命中) + '''sk-live-secret''', + # tests/test_cmd_hermes.py 的假 secret value (generic-api-key 兜底) + '''sk-test-secret''', +] +paths = [ + # 精确限定到测试文件, 避免误 allowlist 其它路径的真 key + '''tests/test_cmd_hermes\.py''', +] + +# ---- 历史已删文档中的 OPENAI_API_KEY (疑似真 kspmas key) ---- +# !!! 安全前置条件 !!! +# 启用本 allowlist 之前, 必须先在金山云 kspmas 控制台 rotate (吊销并重发) 该 key, +# 确认 4fd210b0-eee5-4c64-a23c-dc7fb3f86717 已失效。rotate 之前不要启用—— +# allowlist 只让 CI 不报, 不消除 "真 key 已暴露在 public history" 这个事实。 +# +# 已 rotate 后: 历史 commit 里的该 UUID 已是废值, allowlist 让 CI 对此噪声免疫, +# 同时保留 git scan 抓未来真新增泄漏的能力 (优于改 --no-git 的掩耳盗铃方案)。 +# 已删文档的历史 commit 路径: gitleaks 历史扫描时仍按该 commit 当时的路径报出, +# 故 allowlist 的 paths 规则能命中历史版本, 无需特殊语法。 +[[allowlists]] +description = "Historical rotated OPENAI_API_KEY in deleted hermes doc (safe only AFTER rotation)" +regexes = [ + '''4fd210b0-eee5-4c64-a23c-dc7fb3f86717''', +] +paths = [ + '''docs/hermes-agent-v2026\.4\.13_本地安装配置与ksadk接入流程\.md''', +] diff --git a/AGENTS.md b/AGENTS.md index 4e37ca04..74fa3c70 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,7 +37,7 @@ - `ksadk.skills` / `ksadk.skills.runtime` 是 Skill Runtime 上层应用,负责 Skill Center 消费、包校验、安全解压、loader、工具定义和 `execute_skills` 编排。 - E2B backend 是当前优先实现路径;后续可扩展 KOP / 平台私有 backend,但业务逻辑不要写死到 E2B 特定对象。 - ADK Runner 可做自动工具注入;LangGraph / DeepAgents 等已编译 graph 默认提供 helper 或显式接入,不强行魔改用户 graph。 -- 沙箱镜像内最小 agent 交付物以 `deploy/skill-runtime/` 为准。 +- 沙箱镜像内最小 agent 交付物以 `ksadk/skills/runtime/agent.py` 为准;顶层 `deploy/` 已迁出本仓。 - Skill Service 管注册、CRUD、版本治理;KsADK 只消费运行时必要接口,例如 `ListSkillsBySpaceId`、`GetSkillDownloadUrl`。 ## 5. 跨仓边界 diff --git a/docs/maintainer-approval-record.md b/docs/maintainer-approval-record.md index fad17358..6d6d718b 100644 --- a/docs/maintainer-approval-record.md +++ b/docs/maintainer-approval-record.md @@ -30,8 +30,8 @@ Record exactly one approved source publication strategy. The approved strategy must name the reviewed commit, tag, pull request, or export archive used for: -- `ksadk-python`: clean export candidate from reviewed internal commit `1f3c0d844dc4df2630c8e1d410bcd9f7c9b595cc`; local candidate directory `/tmp/ksadk-python-export-candidate-0.6.9`; verified on 2026-07-08 with public source audit, registry-bundled `make public-preflight` in the public candidate worktree, Fumadocs static build, wheel/sdist build, twine check, and source/dist package audits. -- `ksadk-web`: npm package `@kingsoftcloud/ksadk-web@0.2.18` from commit `24551d0f290e5a4efc5b5d60d02fa298cccd2efa`; Python candidate commit `1f3c0d844dc4df2630c8e1d410bcd9f7c9b595cc`; published by the trusted GitHub npm workflow on 2026-07-08 and consumed from the npm registry during `make public-preflight`. +- `ksadk-python`: clean export candidate from reviewed internal commit `b90bbba9a8eff17ede3fd96d300427f0538a9ff9`; local candidate directory `/tmp/ksadk-python-export-candidate-0.6.9`; verified on 2026-07-08 with public source audit, registry-bundled `make public-preflight` in the public candidate worktree, Fumadocs static build, wheel/sdist build, twine check, and source/dist package audits. +- `ksadk-web`: npm package `@kingsoftcloud/ksadk-web@0.2.18` from commit `24551d0f290e5a4efc5b5d60d02fa298cccd2efa`; Python candidate commit `b90bbba9a8eff17ede3fd96d300427f0538a9ff9`; published by the trusted GitHub npm workflow on 2026-07-08 and consumed from the npm registry during `make public-preflight`. Both approved source references must include the current commit SHA at approval time. This prevents a stale approval record from passing after candidate diff --git "a/docs/reference/ksadk\347\216\257\345\242\203\345\217\230\351\207\217\345\217\202\350\200\203.md" "b/docs/reference/ksadk\347\216\257\345\242\203\345\217\230\351\207\217\345\217\202\350\200\203.md" index 8228fe4e..93dae208 100644 --- "a/docs/reference/ksadk\347\216\257\345\242\203\345\217\230\351\207\217\345\217\202\350\200\203.md" +++ "b/docs/reference/ksadk\347\216\257\345\242\203\345\217\230\351\207\217\345\217\202\350\200\203.md" @@ -2,7 +2,7 @@ 本文档面向部署、运行、运维和 SDK 集成排障。它不是业务代码 `.env` 模板;业务方自己的变量,例如 `APP_ENV`、`DB_URL`、`CUSTOM_API_KEY`,只要不是 KsADK / 平台运行时读取的变量,都属于业务自定义变量,不在本文逐项维护。 -本文档覆盖 `ksadk/`、`deploy/`、`tests/` 中已经注册或常见可配置的运行时变量,由 `tests/test_config_env_registry.py` 保证 `ENV_VAR_REGISTRY` 注册项与文档一致。测试专用变量、PID/marker/cache 等进程内部临时变量、镜像构建脚本内部常量不会逐项列入表格;如果要排查这些高级项,以对应脚本源码和模板 README 为准。 +本文档覆盖 `ksadk/` 中已经注册或常见可配置的运行时变量,由 `tests/test_config_env_registry.py` 保证 `ENV_VAR_REGISTRY` 注册项与文档一致。测试专用变量、PID/marker/cache 等进程内部临时变量、镜像构建脚本内部常量不会逐项列入表格;如果要排查这些高级项,以对应脚本源码和模板 README 为准。 ## 1. 阅读规则 diff --git a/export-manifest.json b/export-manifest.json index dd7da282..dd641065 100644 --- a/export-manifest.json +++ b/export-manifest.json @@ -1,11 +1,10 @@ { - "generatedAt": "2026-07-08T03:54:48.013854+00:00", + "generatedAt": "2026-07-08T04:05:37.590232+00:00", "targetRepository": "https://github.com/kingsoftcloud/ksadk-python", "documentation": "https://kingsoftcloud.github.io/ksadk-python/", - "exportPathCount": 554, - "excludedPathCount": 202, + "exportPathCount": 555, + "excludedPathCount": 201, "excludedPaths": [ - ".gitleaks.toml", "docs/Agent 开发者上下文接入指南.md", "docs/DeepAgents说明.md", "docs/archive/kb-memory/knowledge_base_integration_plan.md", @@ -224,6 +223,7 @@ ".github/workflows/release-check.yml", ".github/workflows/secret-patterns.yml", ".gitignore", + ".gitleaks.toml", "AGENTS.md", "CHANGELOG.md", "CLAUDE.md", diff --git a/scripts/prepare_ksadk_python_export.py b/scripts/prepare_ksadk_python_export.py index 5d46f6ff..ae6232c3 100644 --- a/scripts/prepare_ksadk_python_export.py +++ b/scripts/prepare_ksadk_python_export.py @@ -33,6 +33,7 @@ ROOT_EXPORT_FILES = { ".dockerignore", ".gitattributes", + ".gitleaks.toml", ".github/BRANCH_PROTECTION.md", ".github/ISSUE_TEMPLATE/bug_report.md", ".github/ISSUE_TEMPLATE/feature_request.md", @@ -73,6 +74,7 @@ "README.md", "README.en.md", "README.zh-CN.md", + ".gitleaks.toml", "docs-site/package.json", "docs-site/pnpm-lock.yaml", "pyproject.toml", diff --git a/tests/test_public_release_positioning.py b/tests/test_public_release_positioning.py index 074b2d7d..0d70937e 100644 --- a/tests/test_public_release_positioning.py +++ b/tests/test_public_release_positioning.py @@ -115,6 +115,25 @@ def test_public_readme_docs_links_match_fumadocs_routes(): assert candidate.exists() or index_candidate.exists(), url +def test_legacy_deploy_and_examples_are_not_tracked_in_source_repo(): + if not (ROOT / ".git").exists(): + return + + tracked = subprocess.run( + ["git", "ls-files", "deploy", "examples"], + cwd=ROOT, + check=True, + text=True, + stdout=subprocess.PIPE, + ).stdout + makefile = _read("Makefile") + + assert tracked == "" + assert "-f deploy/hermes/Dockerfile" not in makefile + assert "-f deploy/openclaw/Dockerfile" not in makefile + assert "-f deploy/openclaw-user-template/Dockerfile" not in makefile + + def test_public_metadata_uses_runtime_platform_positioning(): pyproject = tomllib.loads(_read("pyproject.toml")) init_text = _read("ksadk/__init__.py") From 5a08c48b3d55ba62f349702c9c09f980f7a74a68 Mon Sep 17 00:00:00 2001 From: xiayu Date: Wed, 8 Jul 2026 12:13:54 +0800 Subject: [PATCH 5/5] fix(release): align public ci with exported tests --- .github/workflows/ci.yml | 13 +------------ docs/maintainer-approval-record.md | 4 ++-- export-manifest.json | 2 +- tests/test_public_release_positioning.py | 3 +++ 4 files changed, 7 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 21c1488e..5176454b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,18 +46,7 @@ jobs: run: uv build - name: Run public release gate tests - run: | - uv run --extra dev pytest \ - tests/test_open_source_audit.py \ - tests/test_runtime_common_packaging.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_markdown_repair.py \ - tests/test_conversation_runtime.py \ - tests/test_server_session_app.py \ - -q + run: make public-test - name: Audit public repository candidate run: uv run --extra dev python scripts/open_source_audit.py --target public-repo diff --git a/docs/maintainer-approval-record.md b/docs/maintainer-approval-record.md index 6d6d718b..18261e23 100644 --- a/docs/maintainer-approval-record.md +++ b/docs/maintainer-approval-record.md @@ -30,8 +30,8 @@ Record exactly one approved source publication strategy. The approved strategy must name the reviewed commit, tag, pull request, or export archive used for: -- `ksadk-python`: clean export candidate from reviewed internal commit `b90bbba9a8eff17ede3fd96d300427f0538a9ff9`; local candidate directory `/tmp/ksadk-python-export-candidate-0.6.9`; verified on 2026-07-08 with public source audit, registry-bundled `make public-preflight` in the public candidate worktree, Fumadocs static build, wheel/sdist build, twine check, and source/dist package audits. -- `ksadk-web`: npm package `@kingsoftcloud/ksadk-web@0.2.18` from commit `24551d0f290e5a4efc5b5d60d02fa298cccd2efa`; Python candidate commit `b90bbba9a8eff17ede3fd96d300427f0538a9ff9`; published by the trusted GitHub npm workflow on 2026-07-08 and consumed from the npm registry during `make public-preflight`. +- `ksadk-python`: clean export candidate from reviewed internal commit `eb76b17d7d3c176f7cf6126ceb001ab00f5d651d`; local candidate directory `/tmp/ksadk-python-export-candidate-0.6.9`; verified on 2026-07-08 with public source audit, registry-bundled `make public-preflight` in the public candidate worktree, Fumadocs static build, wheel/sdist build, twine check, and source/dist package audits. +- `ksadk-web`: npm package `@kingsoftcloud/ksadk-web@0.2.18` from commit `24551d0f290e5a4efc5b5d60d02fa298cccd2efa`; Python candidate commit `eb76b17d7d3c176f7cf6126ceb001ab00f5d651d`; published by the trusted GitHub npm workflow on 2026-07-08 and consumed from the npm registry during `make public-preflight`. Both approved source references must include the current commit SHA at approval time. This prevents a stale approval record from passing after candidate diff --git a/export-manifest.json b/export-manifest.json index dd641065..b96167dd 100644 --- a/export-manifest.json +++ b/export-manifest.json @@ -1,5 +1,5 @@ { - "generatedAt": "2026-07-08T04:05:37.590232+00:00", + "generatedAt": "2026-07-08T04:10:29.360629+00:00", "targetRepository": "https://github.com/kingsoftcloud/ksadk-python", "documentation": "https://kingsoftcloud.github.io/ksadk-python/", "exportPathCount": 555, diff --git a/tests/test_public_release_positioning.py b/tests/test_public_release_positioning.py index 0d70937e..a6f6c074 100644 --- a/tests/test_public_release_positioning.py +++ b/tests/test_public_release_positioning.py @@ -181,6 +181,9 @@ def test_pypi_publish_workflow_uses_trusted_publishing_and_bundles_ksadk_web(): assert "make public-preflight" in workflow assert "make public-publish-gate" in workflow assert "make open-source-audit-dist" in ci_workflow + assert "make public-test" in ci_workflow + assert "tests/test_conversation_runtime.py" not in ci_workflow + assert "tests/test_server_session_app.py" not in ci_workflow assert 'KSADK_WEB_VERSION: "0.2.18"' in ci_workflow assert "PUBLIC_KSADK_WEB_VERSION" not in ci_workflow assert "KSADK_WEB_VERSION ?= latest" in makefile