From be599ae7247e454bd3883588a853ec0efe0b3c92 Mon Sep 17 00:00:00 2001 From: coso Date: Sun, 17 May 2026 03:09:19 +0800 Subject: [PATCH] chore: finalize v1.41.0 release --- AGENTS.md | 2 + RELEASE_NOTES.md | 21 +- docs/aiprompts/README.md | 2 + .../aiprompts/parallel-agent-collaboration.md | 28 + docs/images/lime-claw-readme.png | Bin 290470 -> 0 bytes docs/roadmap/agentapp/README.md | 182 +- docs/roadmap/agentapp/capability-sdk.md | 160 +- docs/roadmap/agentapp/content-factory-app.md | 278 +-- docs/roadmap/agentapp/implementation-plan.md | 533 ++--- .../agentapp/lime-capability-surface.md | 98 + .../p18-7-full-lime-capability-surface.md | 208 ++ .../agentapp/p18-7-parallel-validation.md | 161 ++ docs/roadmap/agentapp/p18-completion-audit.md | 191 +- .../agentapp/p18-typed-capability-sdk-gate.md | 158 +- .../agent-app-runtime-completion-audit.md | 17 +- .../agentruntime/app-surface-runtime.md | 77 +- .../agentruntime/host-agent-run-ui-sdk.md | 157 ++ scripts/agent-apps-smoke.mjs | 1388 +++++++++++++ scripts/at-command-registry-e2e.mjs | 53 +- src-tauri/crates/agent/src/runtime_queue.rs | 43 +- .../crates/agent/src/tools/skill_tool_gate.rs | 229 +++ src-tauri/crates/core/src/models/mod.rs | 3 + .../crates/core/src/models/skill_model.rs | 8 +- .../default-skills/article-writer/SKILL.md | 46 + .../default-skills/content-reviewer/SKILL.md | 35 + .../default-skills/knowledge-builder/SKILL.md | 36 + src-tauri/src/commands/agent_app_cmd.rs | 404 +++- .../src/commands/agent_app_runtime_cmd.rs | 1783 +---------------- .../commands/agent_app_runtime_cmd/events.rs | 437 ++++ .../agent_app_runtime_cmd/metadata.rs | 721 +++++++ .../agent_app_runtime_cmd/model_preference.rs | 249 +++ .../commands/agent_app_runtime_cmd/tests.rs | 552 +++++ .../commands/agent_app_runtime_cmd/types.rs | 149 ++ .../command_api/runtime_api.rs | 169 +- .../commands/aster_agent_cmd/runtime_turn.rs | 318 ++- src-tauri/src/skills/default_skills.rs | 29 +- src/components/AppSidebar.test.tsx | 49 + src/components/AppSidebar.tsx | 34 +- .../adapters/adapterCapabilityProfile.ts | 34 +- src/features/agent-app/index.ts | 48 +- .../agent-app/manifest/normalizeManifest.ts | 43 +- .../agent-app/manifest/parseManifest.test.ts | 329 ++- .../agent-app/manifest/parseManifest.ts | 115 +- .../readiness/checkReadiness.test.ts | 33 + .../agent-app/readiness/checkReadiness.ts | 141 +- .../readiness/hostCapabilityProfile.ts | 23 +- .../agentRuntimeCapabilityHost.test.ts | 191 ++ .../runtime/agentRuntimeCapabilityHost.ts | 176 +- .../runtime/agentRuntimeProcess.test.ts | 132 ++ .../agent-app/runtime/agentRuntimeProcess.ts | 1042 ++++++++++ .../runtime/capabilityDispatcher.test.ts | 1362 ++++++++++++- .../agent-app/runtime/capabilityDispatcher.ts | 1778 +++++++++++++++- .../agent-app/runtime/hostBridge.test.ts | 139 +- src/features/agent-app/runtime/hostBridge.ts | 332 ++- .../schema/referenceCliCrossCheck.test.ts | 419 ++-- .../agent-app/sdk/capabilityAdapters.test.ts | 65 +- .../agent-app/sdk/capabilityAdapters.ts | 292 +-- .../agent-app/sdk/capabilityCatalog.ts | 502 +++++ .../agent-app/sdk/capabilityContract.test.ts | 81 +- .../agent-app/sdk/capabilityContract.ts | 240 ++- src/features/agent-app/sdk/index.ts | 43 + .../agent-app/sdk/mockCapabilityProfile.ts | 26 +- .../agent-app/sdk/publicSdkSurface.test.ts | 12 +- src/features/agent-app/types.ts | 82 +- .../agent-app/ui/AgentAppRuntimePage.test.tsx | 581 +++++- .../agent-app/ui/AgentAppRuntimePage.tsx | 278 ++- .../agent-app/ui/AgentRunHostDrawer.tsx | 623 ++++++ src/i18n/resources/en-US/agent.json | 32 +- src/i18n/resources/ja-JP/agent.json | 32 +- src/i18n/resources/ko-KR/agent.json | 32 +- src/i18n/resources/zh-CN/agent.json | 34 +- src/i18n/resources/zh-TW/agent.json | 32 +- src/lib/dev-bridge/http-client.test.ts | 39 + src/lib/dev-bridge/http-client.ts | 8 + .../dev-bridge/mockPriorityCommands.test.ts | 16 +- src/lib/dev-bridge/mockPriorityCommands.ts | 6 +- 76 files changed, 15275 insertions(+), 3126 deletions(-) create mode 100644 docs/aiprompts/parallel-agent-collaboration.md delete mode 100644 docs/images/lime-claw-readme.png create mode 100644 docs/roadmap/agentapp/lime-capability-surface.md create mode 100644 docs/roadmap/agentapp/p18-7-full-lime-capability-surface.md create mode 100644 docs/roadmap/agentapp/p18-7-parallel-validation.md create mode 100644 docs/roadmap/agentruntime/host-agent-run-ui-sdk.md create mode 100644 src-tauri/resources/default-skills/article-writer/SKILL.md create mode 100644 src-tauri/resources/default-skills/content-reviewer/SKILL.md create mode 100644 src-tauri/resources/default-skills/knowledge-builder/SKILL.md create mode 100644 src-tauri/src/commands/agent_app_runtime_cmd/events.rs create mode 100644 src-tauri/src/commands/agent_app_runtime_cmd/metadata.rs create mode 100644 src-tauri/src/commands/agent_app_runtime_cmd/model_preference.rs create mode 100644 src-tauri/src/commands/agent_app_runtime_cmd/tests.rs create mode 100644 src-tauri/src/commands/agent_app_runtime_cmd/types.rs create mode 100644 src/features/agent-app/runtime/agentRuntimeProcess.test.ts create mode 100644 src/features/agent-app/runtime/agentRuntimeProcess.ts create mode 100644 src/features/agent-app/sdk/capabilityCatalog.ts create mode 100644 src/features/agent-app/ui/AgentRunHostDrawer.tsx diff --git a/AGENTS.md b/AGENTS.md index e1d6e3fe1..428373957 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,6 +18,7 @@ 3. **结果汇报优先** - 收尾说明做了什么、为什么这样做、验证了什么、还剩什么缺口;避免过程性礼貌汇报 4. **任务完成标准优先** - 以可编译、类型正确、测试通过、功能真实可用作为完成依据;实现细节服从项目既有模式和当前主线目标 5. **不主动扩大承诺** - 不在完成后追问“要不要继续做 X/Y/Z”;如存在自然下一刀,只简短列出建议,等待用户明确要求 +6. **并行协作先切写集** - 用户说明有其他 Agent / 进程同跑时,先只读盘点 `git status --short` 和目标 diff,声明本轮认领的窄写集;只修改认领文件,发现目标文件已被别人改动或出现未确认的未跟踪相邻产物时,立即切到只读审阅 / 验证,不夹写、不覆盖 ## 基础约束 @@ -78,6 +79,7 @@ - **状态 / 历史 / 遥测主链**:`docs/aiprompts/state-history-telemetry.md` - **任务分层 / 模型经济调度路线图**:`docs/roadmap/task/README.md` - **治理与收口**:`docs/aiprompts/governance.md` +- **并行 Agent 协作**:`docs/aiprompts/parallel-agent-collaboration.md` - **Harness Engine 治理**:`docs/aiprompts/harness-engine-governance.md` - **Playwright / GUI 续测**:`docs/aiprompts/playwright-e2e.md` - **计划与进度**:`docs/exec-plans/README.md` diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index bfcaf21f9..25ad3d685 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,7 +1,7 @@ ## Lime v1.41.0 发布日期:`2026-05-16` -递交范围:当前完整 worktree,包含 tracked 与新增文件;本次先按要求提交并推送 GitHub,完整发布门禁将在后续补跑后再更新结论。 +递交范围:当前完整 worktree,包含 tracked 与新增文件;本次已补齐 Cargo / NPM 静态检查、单测、契约检查与 GUI smoke 复核结论。 > 发布说明:上一版 release tag 为 `v1.40.0`。本版升级到 `v1.41.0`,并继续清理旧 `RELEASE_NOTES.md` 历史堆叠内容,只保留当前版本说明;旧 v1.40.0 发布说明不再作为当前 release note 事实源保留。 @@ -13,6 +13,7 @@ - Runtime evidence / handoff / review / replay 导出结果可投影回 Agent App task event,Evidence Pack、analysis、review decision、replay case 等制品不再停留在只读导出侧。 - Claw capability 复用从命令文件中拆出 catalog service,首批 `image / cover / research / report / pdf / summary` capability hint 只映射到既有 Claw launch metadata,不新增垂直 `content_factory_*` 命令。 - 正式 `Agent Apps` GUI smoke 形成独立证据,覆盖正式入口、registration blocker、Cloud install review、lifecycle disable / enable、runtime surface、uninstall rehearsal 和 flag-off regression。 +- 追加 P18.7 full Lime capability surface 更新:Agent App manifest / readiness / host profile / SDK catalog 开始对齐完整 Lime capability surface,Host Bridge 与 runtime dispatcher 不再只覆盖内容工厂最小子集。 ### 用户可见更新 @@ -20,21 +21,31 @@ - 内容工厂 App 可通过 Host Bridge 订阅 AgentRuntime task update,并从 artifact / snapshot / event payload 中回放 `workspacePatch` / `contentFactoryWorkspacePatch`。 - App 内任务结果支持展示 artifact、evidence、verification outcome 与运行进度,减少“任务 accepted 后长期 idle”的误导状态。 - Agent App hidden runtime session 不再污染普通最近对话,任务恢复和 Host response 继续使用 app / task scope。 +- Agent Apps 侧栏与 Runtime 页面补齐正式入口、capability readiness、runtime process 状态和 Host response 可见反馈,减少只在 Lab / mock 路径验证的断层。 +- `@配图` 命令 smoke 改为接受 current 的 `execute_skill` 直连 Skill 执行状态,覆盖 `正在执行 Skill: image_generate` 与图片预览可见性,避免继续等待旧内层 tool process 文案。 ### 开发者与治理更新 - 新增 `src/features/agent-app/sdk/` 相关 facade、contract、adapter 与 public surface 回归,固定 SDK-only consumer 边界。 +- 新增 `src/features/agent-app/sdk/capabilityCatalog.ts`、`src/features/agent-app/runtime/agentRuntimeProcess.ts` 及对应回归,补齐 capability catalog、runtime process lifecycle 与 Host Bridge 分发边界。 - 新增 `scripts/agent-app-package-handoff-check.mjs` 与 `scripts/lib/agent-app-package-handoff-core.mjs`,机械检查外部 Agent App package 的 dirty 状态、私有 bridge marker、SDK marker、高风险脚本和 `src -> dist` 产物漂移。 - 新增 `src-tauri/src/services/agent_app_runtime_capability_catalog_service.rs` 与 `src-tauri/src/services/runtime_evidence_projection_service.rs`,把 capability catalog 与 evidence projection 从 runtime command 里拆成单一职责服务。 +- `agent_app_runtime_cmd` 拆分为模块目录,降低单文件命令膨胀风险,并让 Runtime facade、event projection、Host response、content factory output contract 各自收敛到更清晰的边界。 +- 默认 Skills 追加 `article-writer`、`content-reviewer`、`knowledge-builder` 三类本地包,并补齐默认 Skill 嵌入、同步与 gate 回归。 - `docs/roadmap/agentapp/` 与 `docs/roadmap/agentruntime/` 补齐 P17.5、P18 handoff、package SDK migration、completion audit、raw worker pre-gate 与 AgentRuntime completion audit,当前事实源回到 versioned artifact。 - `agent_app_runtime_cmd` 增加模型偏好解析、最近成功 Agent run fallback、provider catalog fallback、runtime event emit、artifact replay、evidence projection 与 content factory output contract。 ### 当前校验状态 -- `cargo fmt --manifest-path "src-tauri/Cargo.toml" --all`:已通过。 -- `cargo test --manifest-path "src-tauri/Cargo.toml"`:已启动但按用户要求先提交,后台进程已终止;尚未形成通过结论。 -- `npm run verify:app-version`:待本次提交前快速复核。 -- `cargo clippy` / Rust lint、`npm run lint`、`npm test`、`npm run test:contracts`、`npm run verify:gui-smoke`:本次先提交 GitHub,尚未补跑完整结论。 +- `cargo fmt --manifest-path "src-tauri/Cargo.toml" --all`:已通过,并追加 `cargo fmt --manifest-path "src-tauri/Cargo.toml" --all -- --check` 复核通过。 +- `cargo clippy --manifest-path "src-tauri/Cargo.toml" --all-targets --all-features -- -D warnings`:已通过。 +- `cargo test --manifest-path "src-tauri/Cargo.toml"`:已通过;主库 `1347 passed / 1 ignored`,集成测试通过,真实联网测试按环境变量保持 ignored。 +- `npm run lint`:已通过。 +- `npm test`:已通过;`run-vitest-smart` 全部 `58/58` 批次通过。 +- `npm run verify:app-version`:已通过,版本一致性为 `1.41.0`。 +- `npm run test:contracts`:已通过,覆盖 agent runtime client 生成检查、命令契约、harness 契约、modality runtime contract 与 cleanup report contract。 +- `npm run smoke:at-command-registry -- --app-url http://127.0.0.1:1420/ --health-url http://127.0.0.1:3030/health --invoke-url http://127.0.0.1:3030/invoke --timeout-ms 180000 --interval-ms 1000`:已通过;`@配图` current 路由为 `execute_skill`,可见状态为 `正在执行 Skill: image_generate`,图片预览可见。 +- `npm run verify:gui-smoke`:已执行;workspace ready、browser runtime、site adapters、agent service skill entry、runtime tool surface、runtime surface page、at-command registry、Agent Apps smoke 均已通过;最终在 `smoke:claw-chat-ready-streaming` 的 live recovery turn 等待完成阶段超时,单独复跑同样停在 `等待恢复 turn 完成`,证据已写入 `.lime/qc/gui-evidence/claw-chat-ready-streaming/`。 --- diff --git a/docs/aiprompts/README.md b/docs/aiprompts/README.md index 960c9e59c..d5fcb6782 100644 --- a/docs/aiprompts/README.md +++ b/docs/aiprompts/README.md @@ -24,6 +24,7 @@ - `persistence-map.md` - Runtime 文件快照持久化主链、artifact sidecar/version/checkpoint 边界 - `state-history-telemetry.md` - State / History / Telemetry current 主链、session/thread/request/evidence/history 边界与 current/compat 分类 - `governance.md` - 新旧并存治理、迁移收口、禁止回流 +- `parallel-agent-collaboration.md` - 多 Agent / 多终端并行开发时的写集声明、避让和冲突处理协议 - `harness-engine-governance.md` - Harness Engine 事实源、evidence pack、replay / analysis / review 治理规范 - `quality-workflow.md` - 本地校验、GUI smoke、契约检查、CI 门禁 - `../tests/agent-ops-qc.md` - Agent 运营级测试体系、qcloop 场景、Evidence Pack 与发布证据门禁 @@ -82,6 +83,7 @@ - **做网页登录态访问 / 网页导出 / Markdown 落盘场景**:先读 `web-browser-scene-skill.md` - **改 Workspace / GUI 壳 / 主路径**:先读 `workspace.md`、`quality-workflow.md`、`playwright-e2e.md` - **做迁移 / 收口 / 去兼容层**:先读 `governance.md` +- **多个 Agent / 终端同跑一个任务**:先读 `parallel-agent-collaboration.md`,再声明本轮写集 - **改 handoff / evidence pack / replay / review / HarnessStatusPanel**:先读 `state-history-telemetry.md`,再看 `harness-engine-governance.md` 与 `governance.md` - **改 Provider / 凭证加载 / Token 刷新**:先读 `providers.md`、`credential-pool.md` - **做跨仓库联动**:先读 `limecore-collaboration-entry.md` diff --git a/docs/aiprompts/parallel-agent-collaboration.md b/docs/aiprompts/parallel-agent-collaboration.md new file mode 100644 index 000000000..da1ded125 --- /dev/null +++ b/docs/aiprompts/parallel-agent-collaboration.md @@ -0,0 +1,28 @@ +# 并行 Agent 协作 + +本文件约束多个 Agent / 终端进程同时开发 Lime 仓库时的协作方式。目标是减少猜测、避免夹写,并让每个进程都能明确知道自己能做什么。 + +## 启动协议 + +1. **先确认主线** - 用一句话复述当前路线图目标、阶段和本轮最小交付。 +2. **先只读盘点** - 在目标范围内执行 `git status --short -- ` 和 `git diff --name-only -- `;不要先改文件。 +3. **声明写集** - 在动手前明确说出“我认领哪些文件 / 目录”“我只读哪些文件 / 目录”“我不碰哪些文件 / 目录”。 +4. **写集要窄** - 优先按垂直切片认领最少文件;不能用“整个 `src/features/agent-app`”替代真实写集,除非任务确实需要。 +5. **测试可共享** - 定向测试、`typecheck`、`git diff --check` 可以由任一进程执行,但报告时要说明它验证的是当前工作树,不代表改动归属。 + +## 冲突处理 + +1. **目标文件已脏时不夹写** - 如果目标文件已经被其他进程修改,默认切到只读审阅 / 验证;除非用户明确要求接管该文件。 +2. **未跟踪相邻产物先确认** - 发现未确认的未跟踪文档、源码或生成物时,不合并、不删除、不改名,先说明风险和可选处理。 +3. **必须改同一文件时先汇报补丁点** - 如果主线必须触碰对方写集,先给出文件、函数、最小补丁意图和验证命令,等待合并窗口。 +4. **发现意外变化立即停下** - 修改过程中若同一文件出现非本人改动,停止写入并询问如何继续;不要用 checkout / reset 抹平。 +5. **不要抢占外部仓库** - 跨仓库联动时,除非用户明确授权当前进程负责外部仓库,否则只读外部仓库并把需要的变更写成 handoff。 + +## 汇报格式 + +并行任务收尾时必须补充: + +- **本轮认领写集**:列出实际修改的文件。 +- **避让写集**:列出发现但没有触碰的并行文件或未跟踪产物。 +- **验证口径**:说明跑过的命令验证了哪些边界。 +- **下一刀归属**:说明下一步适合由当前进程做,还是应交给正在持有相关写集的进程。 diff --git a/docs/images/lime-claw-readme.png b/docs/images/lime-claw-readme.png deleted file mode 100644 index 8104cf430f9198f7ec56c13e752a995c217e2d73..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 290470 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

wQr{$R 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�wle|C{@HV}4d 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%0Df^q2c3dHAMW)F*uA<&a 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

S=kgPrDEUrsa#&~4VI6_|l)SWC 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

qD}^U0pEP(h!^eyA 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ℜqKfdOXl?FtVxADMX_-P0ydA& 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

MSqpHbY6Er3Em2njLHkh+A_U&75h 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 N@=G=O@3JPr{{zskzD585 diff --git a/docs/roadmap/agentapp/README.md b/docs/roadmap/agentapp/README.md index 84b62e56b..9925f18cf 100644 --- a/docs/roadmap/agentapp/README.md +++ b/docs/roadmap/agentapp/README.md @@ -10,7 +10,7 @@ 服务端 / Cloud / LimeCore 的职责不写在本目录;对应文档在 `/Users/coso/Documents/dev/ai/limecloud/limecore/docs/roadmap/agentapp`。 -产品边界必须固定:Agent App 是业务工作台,Lime Agent 是 App 可编排的智能运行时。用户应该在 App 内完成业务流程,而不是为了生成、确认、交付或复盘被迫跳回 Lime 通用 Chat UI。Chat / Expert 只是 App 的一种入口或嵌入式协作方式,不是业务流程的唯一容器。 +产品边界必须固定:Agent App 是业务工作台,Lime Agent 是 App 可编排的智能运行时。用户应该在 App 内完成业务流程,而不是为了生成、确认、交付或复盘被迫跳回 Lime 通用 Chat UI。Lime Experts 是对话优先的专家模块,适合问答、咨询、分析和轻量任务;Chat / Expert 只是 Agent App 的一种入口或嵌入式协作方式,不是业务流程的唯一容器。 ```text Lime Cloud / LimeCore @@ -25,89 +25,91 @@ Agent App Runtime Package ## 当前事实源 -| 分类 | 对象 | 说明 | -|---|---|---| -| current | `/Users/coso/Documents/dev/ai/limecloud/agentapp` | Agent App v0.4 标准事实源;v0.3 覆盖 executable package / schema / reference CLI,v0.4 固定 Host Bridge v1 运行时事件协议。 | -| current | `docs/roadmap/agentapp/capability-sdk.md` | Lime Desktop 侧 Capability SDK 与 Host Bridge 方案。 | -| current | `docs/roadmap/agentapp/implementation-plan.md` | 客户端落地实施方案,覆盖 P0-P18 当前计划;P17.5 formal entry GUI smoke 已通过独立验证链路,P18 进入 Typed Capability SDK Gate,当前阻塞点是 P18.5.3 package-side SDK facade / verify。 | -| current | `docs/roadmap/agentapp/p0-technical-design.md` | P0 只读 App Host 技术设计,覆盖 manifest、projection、readiness、cleanup dry-run。 | -| current | `docs/roadmap/agentapp/p1-mock-capability-host.md` | P1 Mock Capability Host 技术设计,覆盖 SDK facade、mock artifact/evidence、uninstall delete-data。 | -| current | `docs/roadmap/agentapp/p2-adapter-capability-host.md` | P2 Adapter Capability Host 技术设计,覆盖本地 adapter store、knowledge / agent adapter、provenance 查询、delete-data 清理。 | -| current | `docs/roadmap/agentapp/p3-ui-extension-host.md` | P3 UI Extension Host 技术设计,覆盖受控 UI Host、sandbox、injected SDK bridge、Lab 预览。 | -| current | `docs/roadmap/agentapp/p4-content-factory-demo.md` | P4 内容工厂最小闭环,覆盖项目、知识、内容场景、内容资产、Artifact、Evidence、cleanup。 | -| current | `docs/roadmap/agentapp/p4-workflow-runtime.md` | P4.2 受控 Workflow Runtime,覆盖白名单 DSL、runtime policy、trace、cancel 和内容工厂 demo 迁移。 | -| current | `docs/roadmap/agentapp/content-factory-app.md` | 内容工厂作为客户端标杆案例的 UI / storage / workflow / artifact 方案。 | -| current | `docs/roadmap/agentapp/v0.3-rebaseline-plan.md` | v0.3 / 内容工厂升级后的实施重排计划,记录 P4-R rebaseline 与验证证据。 | -| current | `docs/roadmap/agentapp/p5-cloud-bootstrap.md` | P5 Cloud Bootstrap 客户端接入计划,覆盖 payload 校验、企业定制注册码、统一 package source、readiness 合并和 disable / offline / upgrade 回归。 | -| current | `docs/roadmap/agentapp/p6-schema-coverage.md` | P6 v0.3 projection / readiness schema coverage,覆盖 services、workflows、skills、tools、evals、secrets、overlays、lifecycle 和 setup checks。 | -| current | `docs/roadmap/agentapp/p7-schema-gate.md` | P7 本地 schema / snapshot gate,机械验证 projection / readiness 结构与 setup issue 字段。 | -| current | `docs/roadmap/agentapp/p8-setup-resolver.md` | P8 setup resolver / needs-setup 语义,把结构性 setup checks 推进到明确 readiness 状态。 | -| current | `docs/roadmap/agentapp/p9-setup-state-store.md` | P9 installed setup state store,把 setup resolver 输入推进为本地可查询、可清理的 binding 状态。 | -| current | `docs/roadmap/agentapp/p10-installed-state-persistence.md` | P10 installed app state snapshot,把 preview、setup、projection、readiness 汇总为可清理的可序列化安装状态。 | -| current | `docs/roadmap/agentapp/p11-local-persistence-adapter.md` | P11 Local Persistence Adapter,把 installed state 从 in-memory 推进到本地可恢复 persistence adapter。 | -| current | `docs/roadmap/agentapp/p12-package-cache-verify-rollback.md` | P12 Package Cache / Verify / Rollback,覆盖 package cache、hash verify、cached fallback、upgrade staging 和 rollback。 | -| current | `docs/roadmap/agentapp/p13-runtime-package-loader.md` | P13 Runtime Package Loader / UI Bundle Loader,覆盖已验证 package 的 runtime descriptor 和 UI bundle descriptor 加载边界。 | -| current | `docs/roadmap/agentapp/p14-entry-runtime-guard-permission-prompt.md` | P14 Entry Runtime Guard / Permission Prompt 实现,合并 entry readiness、permission、setup state、package verification 和 runtime policy。 | -| current | `docs/roadmap/agentapp/p15-lab-install-launch-flow.md` | P15 Lab Install / Launch Flow 实现,把安装审查、权限确认、启动和清理串成 Lab-only 端到端流程。 | -| current | `docs/roadmap/agentapp/p15-h-gui-smoke-cleanup-rehearsal.md` | P15-H GUI smoke / cleanup rehearsal hardening,记录 Lab 专用 smoke、证据输出、清理演练和 P16 入口 gate。 | -| current | `docs/roadmap/agentapp/p16-agent-app-manager-product-entry-gate.md` | P16 Agent App Manager / Product Entry Gate,记录实验岛内 App 管理面、entry launcher、lifecycle action、cleanup evidence 与剩余 P16-H 缺口。 | -| current | `docs/roadmap/agentapp/p16-h-multi-app-repository-lifecycle-hardening.md` | P16-H Multi-app repository / lifecycle hardening,记录多 App list、持久化 lifecycle、cleanup evidence export、residual audit 和 P17 前 gate。 | -| current | `docs/roadmap/agentapp/p17-formal-entry-gate-audit.md` | P17 正式入口前 Gate 审计,逐项映射 P16-H 证据、正式 Agent Apps 入口条件和剩余禁区。 | -| current | `docs/roadmap/agentapp/p17-formal-entry-contract.md` | P17.0 正式入口契约,固定 `agent-apps`、`agent-app-lab` 与 runtime surface 的职责边界、禁区、架构和后续 gates。 | -| current | `docs/roadmap/agentapp/p17-source-install-contract-hardening.md` | P17.2 Source / Install Contract Hardening 当前执行计划,固定 source state、install review、registration、Cloud release metadata、package verify、cached fallback 与 reference CLI cross-check 边界。 | -| current | `docs/roadmap/agentapp/p17-lifecycle-cleanup-contract-hardening.md` | P17.3 Lifecycle / Cleanup Contract Hardening 已完成执行计划,固定正式入口 enable / disable、uninstall rehearsal、cleanup evidence、residual audit 与 namespace 归属边界。 | -| current | `docs/roadmap/agentapp/p17-4-host-bridge-runtime.md` | P17.4-H Host Bridge Runtime 已完成执行计划,固定正式 App iframe 与 Lime Host 的 `lime.agentApp.bridge` 事件协议、主题同步、Host action、capability invoke、App-scoped Agent task 与结构化写回边界;P17.4.5 已通过完整 GUI smoke。 | -| current | `docs/roadmap/agentapp/p17-5-formal-entry-gui-smoke.md` | P17.5 Formal Entry GUI Smoke 已完成,记录正式 `agent-apps` 入口 install / registration / launch / disable / uninstall rehearsal / runtime surface / flag-off 的独立 smoke 证据。 | -| current | `docs/roadmap/agentapp/p18-typed-capability-sdk-gate.md` | P18 Typed Capability SDK Gate 计划,固定上游 v0.4 Host Bridge、SDK typed facade、stable error、mock / contract tests 与 App-scoped Agent task 边界;P18.1 SDK contract、P18.2 Host Bridge typed router、P18.3 Core capability adapters、P18.4 App-scoped Agent task SDK facade、P18.4-H handoff gate、P18.5.1 Lime-side SDK regression、P18.5.2 package-side read-only tests、P18.5-S Host Bridge SDK client 与 P18.6 raw worker 前 gate 已落地;2026-05-16 09:22 审计确认下一刀仍是 P18.5.3 package-side SDK facade / verify。 | -| current | `docs/roadmap/agentapp/p18-4-h-agentruntime-handoff-gate.md` | P18.4-H AgentRuntime Handoff Gate,记录 prompt-to-artifact checklist、隔壁 AgentRuntime current MVP 证据、P18 消费侧判定和仍归 AgentRuntime owner 的缺口。 | -| current | `docs/roadmap/agentapp/p18-5-content-factory-sdk-regression.md` | P18.5 内容工厂 SDK 化回归,记录 Lime-side content factory SDK contract、Host Bridge SDK client、外部 package read-only tests、dirty 边界和 package-side 剩余缺口。 | -| current | `docs/roadmap/agentapp/p18-5-3-package-sdk-migration-plan.md` | P18.5.3 package-side SDK facade 迁移计划,固定外部 `content-factory-app` 当前只读事实、推荐写集、迁移步骤、验收清单和回滚点。 | -| current | `docs/roadmap/agentapp/p18-5-3-owner-handoff.md` | P18.5.3 给外部 package owner 的执行单页,浓缩接管前命令、最小写集、禁止项、验证顺序和完成条件。 | -| current | `docs/roadmap/agentapp/p18-6-raw-worker-pre-gate.md` | P18.6 Raw Worker 前 Gate,记录 P18 不执行 raw worker / 外部代码 / 网络 / 文件系统 / secret value 的证据和 P19 后续退出条件。 | -| current | `docs/roadmap/agentapp/p18-completion-audit.md` | P18 completion audit / 协作防打架记录,映射 P18.1-P18.6 证据、外部 package 阻塞、完整 `verify:local` 已通过、2026-05-16 09:04 typecheck / contracts 复核、09:09 feature island 复核和下一刀判定。 | -| reference | `/Users/coso/Documents/dev/ai/limecloud/limecore/docs/roadmap/agentapp` | 服务端 catalog、release、tenant enablement、gateway、ToolHub 路线图;LimeCore 只做 control-plane metadata,真实 release 必须提供 `https` package URL 和完整 package / manifest hash,未激活注册码时不得下发可安装 package。 | +| 分类 | 对象 | 说明 | +| --------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| current | `/Users/coso/Documents/dev/ai/limecloud/agentapp` | Agent App 标准事实源;v0.4 固定 Host Bridge v1,v0.5 固定分层 manifest,v0.6 新增 `app.runtime.yaml` 与 Agent task runtime contract。Lime 已按 P18.7-B 跟上当前 v0.6,不再假定 reference sample 永远停在 v0.3/v0.4/v0.5。 | +| current | `docs/roadmap/agentapp/capability-sdk.md` | Lime Desktop 侧 Capability SDK 与 Host Bridge 方案。 | +| current | `docs/roadmap/agentapp/p18-7-full-lime-capability-surface.md` | P18.7 全量 Lime capability surface:把 UI、数据、AgentRuntime、模型、Token、Skills、Tools、MCP、浏览器、搜索、媒体、终端、记忆、凭证、策略、Evidence 等统一抽象为 `lime.*` 能力,并固定 App / Lime 主 App 边界与 v0.6 分层 manifest / runtime contract 兼容顺序。 | +| current | `docs/roadmap/agentapp/implementation-plan.md` | 客户端落地实施方案,覆盖 P0-P18 当前计划;P18.7-B Agent App v0.6 current compatibility 已完成,当前下一刀是 P18.7-C Host capability discovery。 | +| current | `docs/roadmap/agentapp/p0-technical-design.md` | P0 只读 App Host 技术设计,覆盖 manifest、projection、readiness、cleanup dry-run。 | +| current | `docs/roadmap/agentapp/p1-mock-capability-host.md` | P1 Mock Capability Host 技术设计,覆盖 SDK facade、mock artifact/evidence、uninstall delete-data。 | +| current | `docs/roadmap/agentapp/p2-adapter-capability-host.md` | P2 Adapter Capability Host 技术设计,覆盖本地 adapter store、knowledge / agent adapter、provenance 查询、delete-data 清理。 | +| current | `docs/roadmap/agentapp/p3-ui-extension-host.md` | P3 UI Extension Host 技术设计,覆盖受控 UI Host、sandbox、injected SDK bridge、Lab 预览。 | +| current | `docs/roadmap/agentapp/p4-content-factory-demo.md` | P4 内容工厂最小闭环,覆盖项目、知识、内容场景、内容资产、Artifact、Evidence、cleanup。 | +| current | `docs/roadmap/agentapp/p4-workflow-runtime.md` | P4.2 受控 Workflow Runtime,覆盖白名单 DSL、runtime policy、trace、cancel 和内容工厂 demo 迁移。 | +| current | `docs/roadmap/agentapp/content-factory-app.md` | 内容工厂作为客户端标杆案例的 UI / storage / workflow / artifact 方案。 | +| current | `docs/roadmap/agentapp/v0.3-rebaseline-plan.md` | v0.3 / 内容工厂升级后的实施重排计划,记录 P4-R rebaseline 与验证证据。 | +| current | `docs/roadmap/agentapp/p5-cloud-bootstrap.md` | P5 Cloud Bootstrap 客户端接入计划,覆盖 payload 校验、企业定制注册码、统一 package source、readiness 合并和 disable / offline / upgrade 回归。 | +| current | `docs/roadmap/agentapp/p6-schema-coverage.md` | P6 v0.3 projection / readiness schema coverage,覆盖 services、workflows、skills、tools、evals、secrets、overlays、lifecycle 和 setup checks。 | +| current | `docs/roadmap/agentapp/p7-schema-gate.md` | P7 本地 schema / snapshot gate,机械验证 projection / readiness 结构与 setup issue 字段。 | +| current | `docs/roadmap/agentapp/p8-setup-resolver.md` | P8 setup resolver / needs-setup 语义,把结构性 setup checks 推进到明确 readiness 状态。 | +| current | `docs/roadmap/agentapp/p9-setup-state-store.md` | P9 installed setup state store,把 setup resolver 输入推进为本地可查询、可清理的 binding 状态。 | +| current | `docs/roadmap/agentapp/p10-installed-state-persistence.md` | P10 installed app state snapshot,把 preview、setup、projection、readiness 汇总为可清理的可序列化安装状态。 | +| current | `docs/roadmap/agentapp/p11-local-persistence-adapter.md` | P11 Local Persistence Adapter,把 installed state 从 in-memory 推进到本地可恢复 persistence adapter。 | +| current | `docs/roadmap/agentapp/p12-package-cache-verify-rollback.md` | P12 Package Cache / Verify / Rollback,覆盖 package cache、hash verify、cached fallback、upgrade staging 和 rollback。 | +| current | `docs/roadmap/agentapp/p13-runtime-package-loader.md` | P13 Runtime Package Loader / UI Bundle Loader,覆盖已验证 package 的 runtime descriptor 和 UI bundle descriptor 加载边界。 | +| current | `docs/roadmap/agentapp/p14-entry-runtime-guard-permission-prompt.md` | P14 Entry Runtime Guard / Permission Prompt 实现,合并 entry readiness、permission、setup state、package verification 和 runtime policy。 | +| current | `docs/roadmap/agentapp/p15-lab-install-launch-flow.md` | P15 Lab Install / Launch Flow 实现,把安装审查、权限确认、启动和清理串成 Lab-only 端到端流程。 | +| current | `docs/roadmap/agentapp/p15-h-gui-smoke-cleanup-rehearsal.md` | P15-H GUI smoke / cleanup rehearsal hardening,记录 Lab 专用 smoke、证据输出、清理演练和 P16 入口 gate。 | +| current | `docs/roadmap/agentapp/p16-agent-app-manager-product-entry-gate.md` | P16 Agent App Manager / Product Entry Gate,记录实验岛内 App 管理面、entry launcher、lifecycle action、cleanup evidence 与剩余 P16-H 缺口。 | +| current | `docs/roadmap/agentapp/p16-h-multi-app-repository-lifecycle-hardening.md` | P16-H Multi-app repository / lifecycle hardening,记录多 App list、持久化 lifecycle、cleanup evidence export、residual audit 和 P17 前 gate。 | +| current | `docs/roadmap/agentapp/p17-formal-entry-gate-audit.md` | P17 正式入口前 Gate 审计,逐项映射 P16-H 证据、正式 Agent Apps 入口条件和剩余禁区。 | +| current | `docs/roadmap/agentapp/p17-formal-entry-contract.md` | P17.0 正式入口契约,固定 `agent-apps`、`agent-app-lab` 与 runtime surface 的职责边界、禁区、架构和后续 gates。 | +| current | `docs/roadmap/agentapp/p17-source-install-contract-hardening.md` | P17.2 Source / Install Contract Hardening 当前执行计划,固定 source state、install review、registration、Cloud release metadata、package verify、cached fallback 与 reference CLI cross-check 边界。 | +| current | `docs/roadmap/agentapp/p17-lifecycle-cleanup-contract-hardening.md` | P17.3 Lifecycle / Cleanup Contract Hardening 已完成执行计划,固定正式入口 enable / disable、uninstall rehearsal、cleanup evidence、residual audit 与 namespace 归属边界。 | +| current | `docs/roadmap/agentapp/p17-4-host-bridge-runtime.md` | P17.4-H Host Bridge Runtime 已完成执行计划,固定正式 App iframe 与 Lime Host 的 `lime.agentApp.bridge` 事件协议、主题同步、Host action、capability invoke、App-scoped Agent task 与结构化写回边界;P17.4.5 已通过完整 GUI smoke。 | +| current | `docs/roadmap/agentapp/p17-5-formal-entry-gui-smoke.md` | P17.5 Formal Entry GUI Smoke 已完成,记录正式 `agent-apps` 入口 install / registration / launch / disable / uninstall rehearsal / runtime surface / flag-off 的独立 smoke 证据。 | +| current | `docs/roadmap/agentapp/p18-typed-capability-sdk-gate.md` | P18 Typed Capability SDK Gate 计划,固定上游 Host Bridge、SDK typed facade、stable error、mock / contract tests 与 App-scoped Agent task 边界;P18.1-P18.6 已落地,P18.7-B 已对齐上游 `manifestVersion: 0.6.0`、分层 manifest 与 `app.runtime.yaml` reference sample,下一刀进入 P18.7-C Host capability discovery。 | +| current | `docs/roadmap/agentapp/p18-4-h-agentruntime-handoff-gate.md` | P18.4-H AgentRuntime Handoff Gate,记录 prompt-to-artifact checklist、隔壁 AgentRuntime current MVP 证据、P18 消费侧判定和仍归 AgentRuntime owner 的缺口。 | +| current | `docs/roadmap/agentapp/p18-5-content-factory-sdk-regression.md` | P18.5 内容工厂 SDK 化回归,记录 Lime-side content factory SDK contract、Host Bridge SDK client、外部 package read-only tests、dirty 边界和 package-side 剩余缺口。 | +| current | `docs/roadmap/agentapp/p18-5-3-package-sdk-migration-plan.md` | P18.5.3 package-side SDK facade 迁移计划,固定外部 `content-factory-app` 当前只读事实、推荐写集、迁移步骤、验收清单和回滚点。 | +| current | `docs/roadmap/agentapp/p18-5-3-owner-handoff.md` | P18.5.3 给外部 package owner 的执行单页,浓缩接管前命令、最小写集、禁止项、验证顺序和完成条件。 | +| current | `docs/roadmap/agentapp/p18-6-raw-worker-pre-gate.md` | P18.6 Raw Worker 前 Gate,记录 P18 不执行 raw worker / 外部代码 / 网络 / 文件系统 / secret value 的证据和 P19 后续退出条件。 | +| current | `docs/roadmap/agentapp/p18-completion-audit.md` | P18 completion audit / 协作防打架记录,映射 P18.1-P18.7 证据、外部 package 边界、验证命令、feature island 复核和下一刀判定。 | +| reference | `/Users/coso/Documents/dev/ai/limecloud/limecore/docs/roadmap/agentapp` | 服务端 catalog、release、tenant enablement、gateway、ToolHub 路线图;LimeCore 只做 control-plane metadata,真实 release 必须提供 `https` package URL 和完整 package / manifest hash,未激活注册码时不得下发可安装 package。 | ## 文档索引 -| 文档 | 说明 | -|---|---| -| [capability-sdk.md](./capability-sdk.md) | 客户端 Capability SDK、runtime bridge、能力注入、权限拦截和 mock host。 | -| [implementation-plan.md](./implementation-plan.md) | 客户端 App Host 落地实施方案:manifest parser、projection、installer、storage、UI host、worker runtime。 | -| [p0-technical-design.md](./p0-technical-design.md) | P0 技术设计:只读 package、manifest normalize、projection、readiness、cleanup dry-run、Lab 展示。 | -| [p1-mock-capability-host.md](./p1-mock-capability-host.md) | P1 技术设计:SDK facade、MockCapabilityHost、mock artifact/evidence、uninstall delete-data。 | -| [p2-adapter-capability-host.md](./p2-adapter-capability-host.md) | P2 技术设计:AdapterCapabilityHost、本地 adapter store、knowledge / agent adapter、provenance 查询、delete-data 清理。 | -| [p3-ui-extension-host.md](./p3-ui-extension-host.md) | P3 技术设计:受控 UI Host、sandbox policy、injected SDK bridge、Lab 预览。 | -| [p4-content-factory-demo.md](./p4-content-factory-demo.md) | P4 技术设计:内容工厂最小闭环、content table Artifact、Evidence、delete-data 清理。 | -| [p4-workflow-runtime.md](./p4-workflow-runtime.md) | P4.2 技术设计:受控 workflow runtime、白名单 DSL、trace、cancel、policy guard。 | -| [content-factory-app.md](./content-factory-app.md) | 内容工厂 Product-level Agent App 的客户端产品与实现方案。 | -| [v0.3-rebaseline-plan.md](./v0.3-rebaseline-plan.md) | Agent App v0.3 / 内容工厂升级后的 rebaseline 实施计划。 | -| [p5-cloud-bootstrap.md](./p5-cloud-bootstrap.md) | P5 技术计划:Cloud bootstrap 作为客户端 package source 输入,继续保持本地 install / projection / readiness / runtime / cleanup。 | -| [p6-schema-coverage.md](./p6-schema-coverage.md) | P6 技术计划:补齐 v0.3 projection / readiness schema coverage,但不进入正式主路径。 | -| [p7-schema-gate.md](./p7-schema-gate.md) | P7 技术计划:用本地 schema / snapshot gate 防止 projection / readiness 字段漂移。 | -| [p8-setup-resolver.md](./p8-setup-resolver.md) | P8 技术计划:增加 setup resolver 输入和 `needs-setup` readiness 语义。 | -| [p9-setup-state-store.md](./p9-setup-state-store.md) | P9 技术计划:本地 setup state store、readiness 输入和 cleanup preview 集成。 | -| [p10-installed-state-persistence.md](./p10-installed-state-persistence.md) | P10 技术计划:installed state snapshot、in-memory store、cleanup preview 集成。 | -| [p11-local-persistence-adapter.md](./p11-local-persistence-adapter.md) | P11 技术实现:本地 persistence adapter、启动恢复、坏文件隔离、delete-data 清理。 | -| [p12-package-cache-verify-rollback.md](./p12-package-cache-verify-rollback.md) | P12 技术实现:package cache、hash verify、cached fallback、upgrade staging 和 rollback。 | -| [p13-runtime-package-loader.md](./p13-runtime-package-loader.md) | P13 技术实现:runtime package loader、bundle manifest gate、UI host 接线前安全边界。 | -| [p14-entry-runtime-guard-permission-prompt.md](./p14-entry-runtime-guard-permission-prompt.md) | P14 技术计划:entry runtime guard、permission prompt descriptor、setup / readiness / policy 合并。 | -| [p15-lab-install-launch-flow.md](./p15-lab-install-launch-flow.md) | P15 技术实现:Lab install / launch flow、install review、permission prompt、runtime launch、cleanup preview。 | -| [p15-h-gui-smoke-cleanup-rehearsal.md](./p15-h-gui-smoke-cleanup-rehearsal.md) | P15-H 技术实现:Agent App Lab 专用 GUI smoke、证据输出、cleanup rehearsal 与 P16 gate。 | -| [p16-agent-app-manager-product-entry-gate.md](./p16-agent-app-manager-product-entry-gate.md) | P16 技术计划:Agent App Manager、entry launcher、lifecycle actions、cleanup evidence 和正式入口前 gate。 | -| [p16-h-multi-app-repository-lifecycle-hardening.md](./p16-h-multi-app-repository-lifecycle-hardening.md) | P16-H 技术计划:多 App repository list、持久化 lifecycle、cleanup evidence export、residual audit 和 P17 前 gate。 | -| [p17-formal-entry-gate-audit.md](./p17-formal-entry-gate-audit.md) | P17 Gate 审计:正式入口前 checklist、验证记录、进入条件和 P17.0 契约入口。 | -| [p17-formal-entry-contract.md](./p17-formal-entry-contract.md) | P17.0 技术计划:正式 `agent-apps` 入口契约、Lab 边界、runtime surface、架构 / 时序 / 流程、后续 gates。 | -| [p17-source-install-contract-hardening.md](./p17-source-install-contract-hardening.md) | P17.2 技术计划:正式入口 source state、install review、registration、Cloud release metadata 与客户端安装边界。 | -| [p17-lifecycle-cleanup-contract-hardening.md](./p17-lifecycle-cleanup-contract-hardening.md) | P17.3 技术计划:正式入口 lifecycle、cleanup rehearsal、evidence export、residual audit 与 namespace 分离。 | -| [p17-4-host-bridge-runtime.md](./p17-4-host-bridge-runtime.md) | P17.4-H 技术计划:Host Bridge v1、主题同步、Host action、capability invoke、iframe message 安全边界。 | -| [p17-5-formal-entry-gui-smoke.md](./p17-5-formal-entry-gui-smoke.md) | P17.5 验证记录:正式 `agent-apps` 入口独立 GUI smoke、cleanup dry-run evidence 与 flag-off regression。 | -| [p18-typed-capability-sdk-gate.md](./p18-typed-capability-sdk-gate.md) | P18 技术计划:Typed Capability SDK Gate、Host Bridge v1 调用信封、stable error、mock / contract tests、App-scoped Agent task。 | -| [p18-4-h-agentruntime-handoff-gate.md](./p18-4-h-agentruntime-handoff-gate.md) | P18.4-H 验证记录:AgentRuntime handoff checklist、证据映射、剩余 owner 缺口与 P18.5 进入判定。 | -| [p18-5-content-factory-sdk-regression.md](./p18-5-content-factory-sdk-regression.md) | P18.5 验证记录:内容工厂 Lime-side SDK regression、Host Bridge SDK client、package-side dirty 边界和下一刀。 | -| [p18-5-3-package-sdk-migration-plan.md](./p18-5-3-package-sdk-migration-plan.md) | P18.5.3 迁移计划:外部 package 写集、SDK facade 迁移步骤、验收和回滚点。 | -| [p18-5-3-owner-handoff.md](./p18-5-3-owner-handoff.md) | P18.5.3 owner handoff 单页:接管前命令、最小写集、禁止项、验证顺序和完成条件。 | -| [p18-6-raw-worker-pre-gate.md](./p18-6-raw-worker-pre-gate.md) | P18.6 验证记录:raw worker 禁止执行边界、受控 workflow DSL、policy 证据和 P19 退出条件。 | -| [p18-completion-audit.md](./p18-completion-audit.md) | P18 completion audit:完成项、阻塞项、验证命令、协作边界和下一刀。 | +| 文档 | 说明 | +| -------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| [capability-sdk.md](./capability-sdk.md) | 客户端 Capability SDK、runtime bridge、能力注入、权限拦截和 mock host。 | +| [implementation-plan.md](./implementation-plan.md) | 客户端 App Host 落地实施方案:manifest parser、projection、installer、storage、UI host、worker runtime。 | +| [p0-technical-design.md](./p0-technical-design.md) | P0 技术设计:只读 package、manifest normalize、projection、readiness、cleanup dry-run、Lab 展示。 | +| [p1-mock-capability-host.md](./p1-mock-capability-host.md) | P1 技术设计:SDK facade、MockCapabilityHost、mock artifact/evidence、uninstall delete-data。 | +| [p2-adapter-capability-host.md](./p2-adapter-capability-host.md) | P2 技术设计:AdapterCapabilityHost、本地 adapter store、knowledge / agent adapter、provenance 查询、delete-data 清理。 | +| [p3-ui-extension-host.md](./p3-ui-extension-host.md) | P3 技术设计:受控 UI Host、sandbox policy、injected SDK bridge、Lab 预览。 | +| [p4-content-factory-demo.md](./p4-content-factory-demo.md) | P4 技术设计:内容工厂最小闭环、content table Artifact、Evidence、delete-data 清理。 | +| [p4-workflow-runtime.md](./p4-workflow-runtime.md) | P4.2 技术设计:受控 workflow runtime、白名单 DSL、trace、cancel、policy guard。 | +| [content-factory-app.md](./content-factory-app.md) | 内容工厂 Product-level Agent App 的客户端产品与实现方案。 | +| [v0.3-rebaseline-plan.md](./v0.3-rebaseline-plan.md) | Agent App v0.3 / 内容工厂升级后的 rebaseline 实施计划。 | +| [p5-cloud-bootstrap.md](./p5-cloud-bootstrap.md) | P5 技术计划:Cloud bootstrap 作为客户端 package source 输入,继续保持本地 install / projection / readiness / runtime / cleanup。 | +| [p6-schema-coverage.md](./p6-schema-coverage.md) | P6 技术计划:补齐 v0.3 projection / readiness schema coverage,但不进入正式主路径。 | +| [p7-schema-gate.md](./p7-schema-gate.md) | P7 技术计划:用本地 schema / snapshot gate 防止 projection / readiness 字段漂移。 | +| [p8-setup-resolver.md](./p8-setup-resolver.md) | P8 技术计划:增加 setup resolver 输入和 `needs-setup` readiness 语义。 | +| [p9-setup-state-store.md](./p9-setup-state-store.md) | P9 技术计划:本地 setup state store、readiness 输入和 cleanup preview 集成。 | +| [p10-installed-state-persistence.md](./p10-installed-state-persistence.md) | P10 技术计划:installed state snapshot、in-memory store、cleanup preview 集成。 | +| [p11-local-persistence-adapter.md](./p11-local-persistence-adapter.md) | P11 技术实现:本地 persistence adapter、启动恢复、坏文件隔离、delete-data 清理。 | +| [p12-package-cache-verify-rollback.md](./p12-package-cache-verify-rollback.md) | P12 技术实现:package cache、hash verify、cached fallback、upgrade staging 和 rollback。 | +| [p13-runtime-package-loader.md](./p13-runtime-package-loader.md) | P13 技术实现:runtime package loader、bundle manifest gate、UI host 接线前安全边界。 | +| [p14-entry-runtime-guard-permission-prompt.md](./p14-entry-runtime-guard-permission-prompt.md) | P14 技术计划:entry runtime guard、permission prompt descriptor、setup / readiness / policy 合并。 | +| [p15-lab-install-launch-flow.md](./p15-lab-install-launch-flow.md) | P15 技术实现:Lab install / launch flow、install review、permission prompt、runtime launch、cleanup preview。 | +| [p15-h-gui-smoke-cleanup-rehearsal.md](./p15-h-gui-smoke-cleanup-rehearsal.md) | P15-H 技术实现:Agent App Lab 专用 GUI smoke、证据输出、cleanup rehearsal 与 P16 gate。 | +| [p16-agent-app-manager-product-entry-gate.md](./p16-agent-app-manager-product-entry-gate.md) | P16 技术计划:Agent App Manager、entry launcher、lifecycle actions、cleanup evidence 和正式入口前 gate。 | +| [p16-h-multi-app-repository-lifecycle-hardening.md](./p16-h-multi-app-repository-lifecycle-hardening.md) | P16-H 技术计划:多 App repository list、持久化 lifecycle、cleanup evidence export、residual audit 和 P17 前 gate。 | +| [p17-formal-entry-gate-audit.md](./p17-formal-entry-gate-audit.md) | P17 Gate 审计:正式入口前 checklist、验证记录、进入条件和 P17.0 契约入口。 | +| [p17-formal-entry-contract.md](./p17-formal-entry-contract.md) | P17.0 技术计划:正式 `agent-apps` 入口契约、Lab 边界、runtime surface、架构 / 时序 / 流程、后续 gates。 | +| [p17-source-install-contract-hardening.md](./p17-source-install-contract-hardening.md) | P17.2 技术计划:正式入口 source state、install review、registration、Cloud release metadata 与客户端安装边界。 | +| [p17-lifecycle-cleanup-contract-hardening.md](./p17-lifecycle-cleanup-contract-hardening.md) | P17.3 技术计划:正式入口 lifecycle、cleanup rehearsal、evidence export、residual audit 与 namespace 分离。 | +| [p17-4-host-bridge-runtime.md](./p17-4-host-bridge-runtime.md) | P17.4-H 技术计划:Host Bridge v1、主题同步、Host action、capability invoke、iframe message 安全边界。 | +| [p17-5-formal-entry-gui-smoke.md](./p17-5-formal-entry-gui-smoke.md) | P17.5 验证记录:正式 `agent-apps` 入口独立 GUI smoke、cleanup dry-run evidence 与 flag-off regression。 | +| [p18-typed-capability-sdk-gate.md](./p18-typed-capability-sdk-gate.md) | P18 技术计划:Typed Capability SDK Gate、Host Bridge v1 调用信封、stable error、mock / contract tests、App-scoped Agent task。 | +| [p18-4-h-agentruntime-handoff-gate.md](./p18-4-h-agentruntime-handoff-gate.md) | P18.4-H 验证记录:AgentRuntime handoff checklist、证据映射、剩余 owner 缺口与 P18.5 进入判定。 | +| [p18-5-content-factory-sdk-regression.md](./p18-5-content-factory-sdk-regression.md) | P18.5 验证记录:内容工厂 Lime-side SDK regression、Host Bridge SDK client、package-side dirty 边界和下一刀。 | +| [p18-5-3-package-sdk-migration-plan.md](./p18-5-3-package-sdk-migration-plan.md) | P18.5.3 迁移计划:外部 package 写集、SDK facade 迁移步骤、验收和回滚点。 | +| [p18-5-3-owner-handoff.md](./p18-5-3-owner-handoff.md) | P18.5.3 owner handoff 单页:接管前命令、最小写集、禁止项、验证顺序和完成条件。 | +| [p18-6-raw-worker-pre-gate.md](./p18-6-raw-worker-pre-gate.md) | P18.6 验证记录:raw worker 禁止执行边界、受控 workflow DSL、policy 证据和 P19 退出条件。 | +| [p18-7-full-lime-capability-surface.md](./p18-7-full-lime-capability-surface.md) | P18.7 路线图:全量 Lime capability surface、App / Lime 主 App 边界、v0.6 标准兼容、Host discovery 和后端接线顺序。 | +| [p18-completion-audit.md](./p18-completion-audit.md) | P18 completion audit:完成项、阻塞项、验证命令、协作边界和下一刀。 | ## 客户端边界 @@ -123,6 +125,7 @@ Agent App Runtime Package 10. 通用 Chat 不能成为 Agent App 主流程的强制回跳点;如果核心工作仍需在 Chat 里完成,说明 App 只是 Chat 包装壳,不是 product-level Agent App。 11. App 也不能为了避免回 Chat 而自建模型网关、凭证系统、权限系统、证据系统或工具调度器;这些属于 Lime capability。 12. Agent App SDK 不拥有 Claw / AgentRuntime capability catalog;Chat `@命令`、Agent App `lime.agent.startTask` 和 Automation job 都只是 surface adapter,不能在 App 侧复制 `*_skill_launch` 或新增垂直 `content_factory_*` 后端能力。 +13. 全量 Lime 功能必须先进入 `lime.* capability catalog`,再接 Host / AgentRuntime / ToolRuntime;业务 App 只能消费 capability,不能把 preview 能力 mock 成生产成功。 ## 主路线 @@ -145,7 +148,7 @@ flowchart TD ## 当前执行顺序 -路线图已从 Agent App v0.3 / 内容工厂 rebaseline 推进到上游 v0.4 Host Bridge 对齐;上游标准已补齐 discovery / installation、release metadata、runtime model、security model、overlay resolver、readiness runner、typed Capability SDK、public JSON Schema、reference CLI 与 Host Bridge v1。P4-R、P5、P6、P7、P8、P9、P10、P11、P12、P13、P14、P15、P15-H、P16、P16-H、P17 Gate、P17.0、P17.1、P17.2.1-P17.2.5、P17.3 lifecycle / cleanup contract、P17.4 runtime surface production hardening 与 P17.5 formal entry GUI smoke 已完成当前实现 / 专用验证 / 计划收口。当前进入 P18 Typed Capability SDK Gate,仍不做 marketplace、Cloud 管理台、真实 delete-data 或完整行业内容系统: +路线图已从 Agent App v0.3 / 内容工厂 rebaseline 推进到上游 v0.4 Host Bridge 对齐;当前上游示例已进入 `manifestVersion: 0.6.0` 并新增 `app.runtime.yaml`,P18.7-B 已完成 layered manifest / runtime contract reference cross-check 兼容,下一步继续后端 capability discovery / 接线。上游标准已补齐 discovery / installation、release metadata、runtime model、security model、overlay resolver、readiness runner、typed Capability SDK、public JSON Schema、reference CLI 与 Host Bridge v1。P4-R、P5、P6、P7、P8、P9、P10、P11、P12、P13、P14、P15、P15-H、P16、P16-H、P17 Gate、P17.0、P17.1、P17.2.1-P17.2.5、P17.3 lifecycle / cleanup contract、P17.4 runtime surface production hardening 与 P17.5 formal entry GUI smoke 已完成当前实现 / 专用验证 / 计划收口。当前进入 P18 Typed Capability SDK Gate,仍不做 marketplace、Cloud 管理台、真实 delete-data 或完整行业内容系统: ```text P0 manifest / projection / readiness / cleanup dry-run @@ -205,11 +208,11 @@ P0 manifest / projection / readiness / cleanup dry-run → P18.6 Raw Worker 前 Gate(已完成) ``` -不要先做市场页、Cloud 管理台、完整行业内容系统,也不要让 App 绕过 SDK;P17.5 只证明正式入口独立 smoke 可跑通,不等于发布 marketplace。当前 P18 功能面已收口:Lime-side regression、外部 package tests / validate / readiness、真实 package verify、dist 同步和 handoff gate 均有证据;下一步只做 owner handoff / 提交边界,不再扩大 P18 功能面。 +不要先做市场页、Cloud 管理台、完整行业内容系统,也不要让 App 绕过 SDK;P17.5 只证明正式入口独立 smoke 可跑通,不等于发布 marketplace。当前 P18 功能面已收口:Lime-side regression、外部 package tests / validate / readiness、真实 package verify、dist 同步和 handoff gate 均有证据;下一步只做 P18.7-C Host capability discovery,不扩大 marketplace、Cloud 管理台或垂直内容工厂后端。 ## 下一刀判定 -P5.0-P5.5、P6、P7、P8、P9、P10、P11、P12、P13、P14、P15、P15-H、P16、P16-H、P17 Gate、P17.0、P17.1、P17.2.1-P17.2.5、P17.3、P17.4.1-P17.4.5、P17.5、P18.1、P18.2、P18.3、P18.4、P18.4-H、P18.5.1、P18.5.2、P18.5-S、P18.5.3 与 P18.6 已通过定向 / 专用验证 / GUI smoke / 计划收口;2026-05-16 10:53 SDK seam / handoff core 定向测试 5 files / 17 tests passed,`typecheck`、`test:contracts` 与 `lint` 当前会话复核通过。当前只做 owner handoff: +P5.0-P5.5、P6、P7、P8、P9、P10、P11、P12、P13、P14、P15、P15-H、P16、P16-H、P17 Gate、P17.0、P17.1、P17.2.1-P17.2.5、P17.3、P17.4.1-P17.4.5、P17.5、P18.1、P18.2、P18.3、P18.4、P18.4-H、P18.5.1、P18.5.2、P18.5-S、P18.5.3 与 P18.6 已通过定向 / 专用验证 / GUI smoke / 计划收口;2026-05-16 10:53 SDK seam / handoff core 定向测试 5 files / 17 tests passed,`typecheck`、`test:contracts` 与 `lint` 当前会话复核通过。当前只做 P18.7-C Host capability discovery: 1. P18.5 只做内容工厂现有 package 的 SDK 化回归,不新增 marketplace、Cloud 管理台或完整行业系统。 2. `agent-apps` 是用户入口,`agent-app-lab` 是研发验证入口;P18 不能让 App 回跳通用 Chat 才能完成业务流程。 @@ -219,11 +222,11 @@ P5.0-P5.5、P6、P7、P8、P9、P10、P11、P12、P13、P14、P15、P15-H、P16 协作分工口径: -| 方向 | 负责 | P18 消费方式 | 防打架边界 | -|---|---|---|---| +| 方向 | 负责 | P18 消费方式 | 防打架边界 | +| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | | AgentRuntime current MVP | `agent_app_runtime_*` facade、`AgentRuntimeThreadReadModel`、Host response、`artifact:created` refs、跨刷新恢复、Artifact / Evidence 投影。 | P18.1 已抽象成 SDK 类型和 mock contract;P18.4 已包装为 `lime.agent` typed adapter;P18.4-H 已消费隔壁运行证据。 | 不在 P18.5 改 `src-tauri/*`、`src/lib/api/agentAppRuntime.ts`、`agentRuntimeCapabilityHost*` 实现逻辑。 | -| AgentRuntime 剩余缺口 | 后端 push subscribe、`content_factory.workspace_patch` producer、独立 capability catalog service、真实桌面 GUI smoke。 | 已在 P18.4-H 记录 owner 与退出条件;P18.5 只消费,不伪造生产级完成。 | 不由 Agent App SDK 伪造生产级完成。 | -| Agent App P18 | typed facade、stable error、mock host、capability envelope、SDK contract tests。 | 固定 App 作者只调用 `@lime/app-sdk`,不手写私有 bridge。 | 不扩 marketplace、Cloud 管理台、raw worker、真实 delete-data 或垂直内容工厂后端。 | +| AgentRuntime 剩余缺口 | 后端 push subscribe、`content_factory.workspace_patch` producer、独立 capability catalog service、真实桌面 GUI smoke。 | 已在 P18.4-H 记录 owner 与退出条件;P18.5 只消费,不伪造生产级完成。 | 不由 Agent App SDK 伪造生产级完成。 | +| Agent App P18 | typed facade、stable error、mock host、capability envelope、SDK contract tests。 | 固定 App 作者只调用 `@lime/app-sdk`,不手写私有 bridge。 | 不扩 marketplace、Cloud 管理台、raw worker、真实 delete-data 或垂直内容工厂后端。 | 当前计划更新口径: @@ -248,4 +251,5 @@ P5.0-P5.5、P6、P7、P8、P9、P10、P11、P12、P13、P14、P15、P15-H、P16 - P18.5.1 已完成:新增 Lime-side content factory SDK regression,验证 `lime.agent -> lime.storage -> lime.artifacts -> lime.evidence` 主链能由通用 SDK facade 表达。 - P18.5.2 已完成:外部 `content-factory-app` 只读 `npm test` 在 2026-05-16 09:05 owner handoff gate 复核为 46 tests passed,覆盖 Host Bridge task、stream、Host response、storage、artifact、evidence 写回;该结果不替代 package-side SDK facade / verify。 - P18.6 已完成:新增 raw worker 前 gate,固定 P18 不执行 raw worker、任意外部代码、网络、文件系统或 secret value,相关 runtime policy / guard / workflow tests 已通过。 -- 下一刀 P18.5.3:等待外部 content-factory package owner 稳定后,收敛 package-side 手写 bridge wrapper 并跑 package verify / Lime formal runtime 回归;owner 未确认前不改外部 `src/ui/host-bridge.js`、`tests/ui.test.mjs`、`package.json` 或 `dist/*`。 +- P18.7 已完成第一刀:新增 `src/features/agent-app/sdk/capabilityCatalog.ts`,把 `lime.ui / storage / files / agent / knowledge / tools / artifacts / workflow / policy / secrets / evidence / events / capabilities / models / usage / memory / skills / mcp / browser / search / documents / media / terminal / tasks / settings / workspace / context / connectors / automation / review` 收敛为唯一能力事实源;`capabilityContract`、p0/mock/adapter profile 和 SDK adapter 均从 catalog 派生,preview 能力默认不可用,不在业务 App 内伪造成功。 +- P18.7-B 已完成:Lime 已兼容上游 `manifestVersion: 0.6.0` reference sample、分层 manifest 与 `app.runtime.yaml`,`referenceCliCrossCheck.test.ts` 复绿;下一刀 P18.7-C 只做 Host capability discovery,不改外部 `agentapp` / `content-factory-app`。 diff --git a/docs/roadmap/agentapp/capability-sdk.md b/docs/roadmap/agentapp/capability-sdk.md index 0ee2de982..e2b518d69 100644 --- a/docs/roadmap/agentapp/capability-sdk.md +++ b/docs/roadmap/agentapp/capability-sdk.md @@ -40,22 +40,64 @@ 10. **Agent 不出 Lime 治理**:模型、工具、知识、文件、凭证、Artifact、Evidence、成本和权限都必须通过 `lime.*` capability。 11. **完整 Agent 能力不等于模型 API**:`LIME_GATEWAY_*` / `OPENAI_BASE_URL` 只能是低阶模型 executor 或 degraded fallback;`lime.agent` / `lime.workflow` 的生产事实源必须回到 `docs/roadmap/agentruntime/app-surface-runtime.md` 定义的 AgentRuntime Surface。 -## 能力地图 +## Lime 全功能能力化地图 -| Capability | P0 范围 | 典型 API | -|---|---|---| -| `lime.ui` | 注册 page、panel、command、settings、artifact viewer。 | `registerRoute`、`openPanel`、`openArtifact` | -| `lime.storage` | App namespace、CRUD、schema、migration。 | `namespace`、`table`、`migrate` | -| `lime.files` | 用户选中文件、读取 file ref、基础解析。 | `pick`、`read`、`parse` | -| `lime.agent` | 本地 Agent task、stream、cancel、retry、trace。 | `startTask`、`streamTask`、`cancelTask` | -| `lime.knowledge` | Knowledge binding、search、export、version。 | `bind`、`search`、`export` | -| `lime.tools` | Tool Broker 调用、权限、长任务状态。 | `invoke`、`getProgress` | -| `lime.artifacts` | 创建、读取、打开、导出 Artifact。 | `create`、`open`、`export` | -| `lime.workflow` | workflow state、human review、background task。 | `start`、`checkpoint`、`awaitHuman` | -| `lime.policy` | 权限、风险、成本、企业策略。 | `requestPermission`、`check` | -| `lime.evidence` | provenance、tool call、knowledge citation、eval。 | `record`、`linkArtifact` | -| `lime.secrets` | OAuth、API key、外部凭证槽位。 | `requestSecret`、`getHandle` | -| `lime.events` | App 内外事件,UI/worker 解耦。 | `emit`、`subscribe` | +详细执行路线图见 [P18.7 Full Lime Capability Surface](./p18-7-full-lime-capability-surface.md)。本文保留 SDK / Host Bridge 方案和能力地图摘要;后续代码实施顺序、v0.6 标准兼容和完成审计以 P18.7 文档为准。 + +代码事实源:`src/features/agent-app/sdk/capabilityCatalog.ts`。后续新增、迁移或下线 Lime 能力时,必须先更新该 catalog,再由 `capabilityContract.ts`、`hostCapabilityProfile.ts`、`mockCapabilityProfile.ts`、`adapterCapabilityProfile.ts` 和 SDK public surface 派生;禁止在业务 App、mock、adapter 或文档里再维护第二份能力清单。 + +这张表回答“所有 Lime 功能如何被 App 使用”:App 负责业务形态、状态递进和结果验收;Lime 主 App 负责 AgentRuntime、模型、工具、凭证、策略、证据、成本和平台资源。`current` 代表 SDK 名称和 profile 已进入单一事实源;`preview` 代表先占住统一抽象和边界,底层实现不得在业务 App 内私造。 + +| 分组 | Capability | 阶段 | Lime owner | App 做什么 | Lime 主 App 做什么 | +| ------------- | ------------------- | ------- | ----------------------- | --------------------------------------------- | ---------------------------------------------------------- | +| App surface | `lime.ui` | current | Desktop Host | 决定业务页面、提示和导航触发时机。 | 主题、语言、受控导航、下载、入口校验。 | +| App surface | `lime.events` | preview | Desktop Host | 订阅业务事件,不写私有 bridge。 | 事件路由、namespace 隔离、订阅生命周期。 | +| App surface | `lime.workspace` | preview | Desktop Host | 围绕当前 workspace 展示业务状态。 | workspace 身份、路径 ref、跨平台路径封装。 | +| Data | `lime.storage` | current | Desktop Host | 定义业务对象、schema、写回时机。 | namespace 隔离、持久化、provenance。 | +| Data | `lime.files` | current | Desktop Host | 声明文件类型,把解析结果转为业务草稿。 | 文件授权、file ref、解析器、安全边界。 | +| Data | `lime.knowledge` | current | Knowledge Runtime | 选择知识空间并消费检索结果。 | 知识索引、binding、版本、引用 provenance。 | +| Data | `lime.artifacts` | current | Artifact Runtime | 定义产物类型、内容结构和业务状态联动。 | 产物持久化、viewer/export、来源记录。 | +| Data | `lime.documents` | preview | Tool Runtime | 定义 PDF / Word / Markdown / PPT 的业务落点。 | 文档解析、格式转换、权限和 evidence。 | +| Agent runtime | `lime.agent` | current | AgentRuntime | 组装业务任务输入、期望产物、人工确认。 | session/thread/turn/task、Skills、Tools、模型、Evidence。 | +| Agent runtime | `lime.workflow` | current | AgentRuntime | 定义业务步骤、checkpoint 和人工介入点。 | workflow 状态、恢复、Host response、权限。 | +| Agent runtime | `lime.tools` | current | Tool Runtime | 声明工具需求并消费结构化结果。 | Tool Broker、权限、进度、超时、审计。 | +| Agent runtime | `lime.models` | preview | AgentRuntime | 表达任务偏好、质量/成本约束。 | 模型事实源、Provider 能力、路由和成本估算。 | +| Agent runtime | `lime.memory` | preview | AgentRuntime | 声明可读/可写记忆意图。 | `memory_runtime_*` / `unified_memory_*`、上下文压缩。 | +| Agent runtime | `lime.skills` | preview | AgentRuntime | 声明必需 Skill 和业务场景。 | Skill catalog、workspace binding、runtime gate、调用证据。 | +| Agent runtime | `lime.context` | preview | AgentRuntime | 显式提交当前业务选择和资源上下文。 | session/thread/turn context、压缩和恢复边界。 | +| Agent runtime | `lime.automation` | preview | AgentRuntime | 定义业务触发、输入和终止条件。 | automation job runtime、队列、权限、证据。 | +| Integration | `lime.mcp` | preview | Tool Runtime | 声明需要的 MCP capability。 | MCP bridge、inventory、命名、权限和审计。 | +| Integration | `lime.browser` | preview | Tool Runtime | 表达网页采集目标和用户授权。 | 浏览器 profile、自动化、截图、回放证据。 | +| Integration | `lime.search` | preview | Tool Runtime | 给出业务检索问题和筛选规则。 | 搜索/深搜 provider、来源去重、引用、成本。 | +| Integration | `lime.media` | preview | Tool Runtime | 给出图片/音频/视频 brief 和交付约束。 | 媒体 runtime、文件产物、安全策略、成本。 | +| Integration | `lime.terminal` | preview | Tool Runtime | 说明命令目的和输入。 | sandbox、approval、日志、危险操作拦截。 | +| Integration | `lime.connectors` | preview | Cloud Overlay / Desktop | 声明外部系统连接需求。 | OAuth、secret、tenant policy、连接器审计。 | +| Governance | `lime.policy` | current | Policy Runtime | 说明能力用途并处理拒绝/降级。 | 权限、风险、成本、企业策略和授权。 | +| Governance | `lime.secrets` | current | Policy Runtime | 只保存 secret ref,不读取明文。 | 凭证托管、授权、轮换、最小权限访问。 | +| Governance | `lime.settings` | preview | Desktop Host | 读/改自己的配置域。 | schema、workspace overlay、tenant 默认值、迁移。 | +| Governance | `lime.review` | preview | Policy Runtime | 把审核嵌入业务 UI。 | 审核证据、权限、发布门禁和决策记录。 | +| Governance | `lime.capabilities` | preview | Desktop Host | 根据 profile 做降级,不猜测底层实现。 | 发布能力目录、版本、readiness 和可用性。 | +| Observability | `lime.evidence` | current | Artifact Runtime | 声明证据类型并挂到产物/任务。 | evidence 持久化、导出、审计。 | +| Observability | `lime.usage` | preview | AgentRuntime | 展示业务任务成本并响应预算拦截。 | Token、费用、预算、request telemetry 归因。 | +| Observability | `lime.tasks` | preview | AgentRuntime | 展示本 App 相关后台任务。 | 任务中心、队列、恢复、事件订阅、审计。 | + +### 可用性口径 + +| 层 | current 事实源 | 说明 | +| -------------------------- | --------------------------------------------------------- | ------------------------------------------------------------------------------ | +| 名称 / 分组 / owner / 阶段 | `capabilityCatalog.ts` | 唯一允许新增 `lime.*` 名称的地方。 | +| TypeScript 调用契约 | `capabilityContract.ts` | 所有 capability 必须有 typed method;未实现也要返回 stable unavailable error。 | +| SDK facade | `capabilityAdapters.ts` | `createLimeCoreCapabilityAdapters()` 按 catalog 自动生成全部 adapter key。 | +| Host readiness profile | `hostCapabilityProfile.ts` | 基础 profile 覆盖所有 capability,默认 `enabled=false / implementation=none`。 | +| Mock / adapter profile | `mockCapabilityProfile.ts`、`adapterCapabilityProfile.ts` | 只从 catalog 标注的能力派生,不再各自维护数组。 | +| 真实执行 | AgentRuntime / ToolRuntime / Desktop Host | 业务 App 不拥有执行事实源;preview 能力未接入时必须显式不可用。 | + +### 边界结论 + +1. 业务 App 可以拥有多个页面、多个工作流、多个业务对象,但不能拥有第二套模型、工具、Skill、凭证、成本、证据或运行过程事实源。 +2. `lime.agent` 只是完整 Agent 能力入口之一;模型选择、Skill、MCP、浏览器、搜索、媒体、终端、记忆、用量和审计都必须继续向对应 `lime.*` capability 收敛。 +3. Claw 已有能力不能复制到 App 内;Chat `@命令`、Agent App task、Automation job 都应成为同一 AgentRuntime / capability catalog 的 surface adapter。 +4. preview capability 不是假入口:profile 默认关闭,调用必须返回 stable unavailable / policy error;只有 Host 真正接线后才可标记 `mock`、`adapter` 或 `native`。 ## 架构图 @@ -93,12 +135,12 @@ Agent App 边界固定如下: -| 层 | 做什么 | 不做什么 | -|---|---|---| -| `@lime/app-sdk` | 暴露 `lime.storage`、`lime.agent`、`lime.workflow` 等稳定 facade。 | 不暴露 Lime internal path,不执行模型和工具。 | -| Host Bridge | 安全传输、主题、语言、capability invoke、Host action。 | 不保存执行事实,不判断任务完成。 | -| Agent App Runtime Surface | 把 App task / workflow 映射成 AgentRuntime request,并附加 app provenance。 | 不复制 Claw skill launch,不新建第二套 runtime。 | -| AgentRuntime | 维护 session/thread/turn/task/event/read model/evidence。 | 不决定垂直 App UI 形态。 | +| 层 | 做什么 | 不做什么 | +| --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | +| `@lime/app-sdk` | 暴露 `lime.storage`、`lime.agent`、`lime.workflow` 等稳定 facade。 | 不暴露 Lime internal path,不执行模型和工具。 | +| Host Bridge | 安全传输、主题、语言、capability invoke、Host action。 | 不保存执行事实,不判断任务完成。 | +| Agent App Runtime Surface | 把 App task / workflow 映射成 AgentRuntime request,并附加 app provenance。 | 不复制 Claw skill launch,不新建第二套 runtime。 | +| AgentRuntime | 维护 session/thread/turn/task/event/read model/evidence。 | 不决定垂直 App UI 形态。 | | AgentRuntime Capability Catalog / Claw Capability Catalog | 把现有 `@配图`、`@搜索`、`@研报` 等能力注册为可复用 capability;Chat `@命令`、Agent App `lime.agent.startTask`、Automation job 都只是 surface adapter。 | 不再让能力只绑定 Chat/Inputbar,也不把 capability catalog owner 下放给 Agent App SDK。 | 生产期 `lime.agent.startTask` / `lime.workflow.start` 必须通过后端 AgentRuntime Surface;前端 `CapabilityHost` / `WorkflowRuntimeHost` 只能作为 adapter、mock 或本地预览,不得成为生产执行事实源。详细设计见: @@ -136,15 +178,15 @@ interface LimeAgentAppBridgeMessage { 首版 Host Bridge 覆盖: -| 方向 | 事件 | 说明 | -|---|---|---| -| Host -> App | `host:snapshot` / `theme:update` | 同步主题、语言、入口上下文、capability 摘要。 | -| Host -> App | `host:response` / `host:error` | 按 `requestId` 返回 SDK / Host action 结果。 | -| Host -> App | `host:visibility` | 页面可见性变化,供 App 暂停或恢复轻量同步。 | -| App -> Host | `app:ready` / `host:getSnapshot` | App 初始化和快照补偿。 | -| App -> Host | `host:toast` / `host:navigate` | 非技术提示和受控导航。 | -| App -> Host | `host:openExternal` / `host:download` | 受控外链和同源产物下载。 | -| App -> Host | `capability:invoke` | SDK capability 调用统一入口。 | +| 方向 | 事件 | 说明 | +| ----------- | ------------------------------------- | --------------------------------------------- | +| Host -> App | `host:snapshot` / `theme:update` | 同步主题、语言、入口上下文、capability 摘要。 | +| Host -> App | `host:response` / `host:error` | 按 `requestId` 返回 SDK / Host action 结果。 | +| Host -> App | `host:visibility` | 页面可见性变化,供 App 暂停或恢复轻量同步。 | +| App -> Host | `app:ready` / `host:getSnapshot` | App 初始化和快照补偿。 | +| App -> Host | `host:toast` / `host:navigate` | 非技术提示和受控导航。 | +| App -> Host | `host:openExternal` / `host:download` | 受控外链和同源产物下载。 | +| App -> Host | `capability:invoke` | SDK capability 调用统一入口。 | 安全规则: @@ -196,14 +238,14 @@ await lime.agent.startTask({ 客户端当前实现位置: -| 对象 | 作用 | -|---|---| -| `src/features/agent-app/sdk/hostBridgeClient.ts` | 把 typed SDK request 转成 Host Bridge v1 `capability:invoke` message,处理 `app:ready`、`host:getSnapshot`、`host:toast`、`host:navigate`、`host:openExternal`、`host:download`、`host:snapshot`、`theme:update`、`host:visibility`、`host:response` / `host:error`、trusted origin、timeout、pending cleanup,以及 `capability:subscribe / unsubscribe / event`。 | -| `src/features/agent-app/sdk/index.ts` | SDK-only public surface,只导出 capability facade、stable error、Host Bridge client、mock host 和 App package 需要的 task / storage / artifact / evidence 类型;不导出 UI、installer、repository 或 runtime host 内部实现。 | -| `src/features/agent-app/index.ts` | 当前 Lime repo public feature entry 已导出 `createLimeCoreCapabilityAdapters`、`createLimeHostBridgeCapabilityInvoker`、`LIME_AGENT_APP_BRIDGE_PROTOCOL` 与 `LIME_AGENT_APP_BRIDGE_VERSION`,作为后续正式 SDK package / package-local shim 的事实源。 | -| `src/features/agent-app/sdk/hostBridgeClient.test.ts` | 覆盖 ready / snapshot / theme / visibility / toast / navigate / openExternal / download、envelope、stable error、timeout cleanup、capability subscription 事件分发,以及内容工厂 task / storage / artifact / evidence 主链。 | -| `src/features/agent-app/sdk/publicSdkSurface.test.ts` | 固定 SDK-only public surface,确保后续正式 `@lime/app-sdk` 或 package-local shim 可以从窄导出清单取能力;同时从运行时 namespace 和源码 export 来源两侧禁止导出 UI、安装器、repository、runtime host、adapter、schema 或 dispatcher 内部对象。 | -| `src/features/agent-app/index.test.ts` | 固定 public feature entry 的 SDK export seam,防止后续 App 只能依赖 Lime 内部深路径。 | +| 对象 | 作用 | +| ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `src/features/agent-app/sdk/hostBridgeClient.ts` | 把 typed SDK request 转成 Host Bridge v1 `capability:invoke` message,处理 `app:ready`、`host:getSnapshot`、`host:toast`、`host:navigate`、`host:openExternal`、`host:download`、`host:snapshot`、`theme:update`、`host:visibility`、`host:response` / `host:error`、trusted origin、timeout、pending cleanup,以及 `capability:subscribe / unsubscribe / event`。 | +| `src/features/agent-app/sdk/index.ts` | SDK-only public surface,只导出 capability facade、stable error、Host Bridge client、mock host 和 App package 需要的 task / storage / artifact / evidence 类型;不导出 UI、installer、repository 或 runtime host 内部实现。 | +| `src/features/agent-app/index.ts` | 当前 Lime repo public feature entry 已导出 `createLimeCoreCapabilityAdapters`、`createLimeHostBridgeCapabilityInvoker`、`LIME_AGENT_APP_BRIDGE_PROTOCOL` 与 `LIME_AGENT_APP_BRIDGE_VERSION`,作为后续正式 SDK package / package-local shim 的事实源。 | +| `src/features/agent-app/sdk/hostBridgeClient.test.ts` | 覆盖 ready / snapshot / theme / visibility / toast / navigate / openExternal / download、envelope、stable error、timeout cleanup、capability subscription 事件分发,以及内容工厂 task / storage / artifact / evidence 主链。 | +| `src/features/agent-app/sdk/publicSdkSurface.test.ts` | 固定 SDK-only public surface,确保后续正式 `@lime/app-sdk` 或 package-local shim 可以从窄导出清单取能力;同时从运行时 namespace 和源码 export 来源两侧禁止导出 UI、安装器、repository、runtime host、adapter、schema 或 dispatcher 内部对象。 | +| `src/features/agent-app/index.test.ts` | 固定 public feature entry 的 SDK export seam,防止后续 App 只能依赖 Lime 内部深路径。 | 发布状态与退出条件:正式 `@lime/app-sdk` package 尚未从 Lime repo 独立发布;外部 App 仍不得 import `src/features/agent-app/*` 或其它 Lime internal path。P18.5.3 如果需要 package-local shim,只能镜像本节 public API,并在正式 SDK package 可安装后退出。 @@ -245,12 +287,12 @@ interface LimeAgentTaskRequest { 最小运行语义: -| 阶段 | App 责任 | Lime Host / Agent 责任 | -|---|---|---| -| Start | 从当前页面 / workflow 组装业务输入、期望结构和幂等键。 | 校验 manifest、entry readiness、permission、policy、cost。 | -| Stream | 在 App 内显示进度、引用、工具调用、错误和可取消状态。 | 发送 `taskId`、`traceId`、status、tool call、citation、partial artifact、blocked error。 | -| Review | 让用户编辑、确认、拒绝或重试结果。 | 保留 trace,确保重试和取消可审计。 | -| Write-back | 通过 `lime.storage` 写业务对象,通过 `lime.artifacts` / `lime.evidence` 写交付物和依据。 | 自动附加 appId、entryKey、package provenance、workspace / tenant 上下文。 | +| 阶段 | App 责任 | Lime Host / Agent 责任 | +| ---------- | ---------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | +| Start | 从当前页面 / workflow 组装业务输入、期望结构和幂等键。 | 校验 manifest、entry readiness、permission、policy、cost。 | +| Stream | 在 App 内显示进度、引用、工具调用、错误和可取消状态。 | 发送 `taskId`、`traceId`、status、tool call、citation、partial artifact、blocked error。 | +| Review | 让用户编辑、确认、拒绝或重试结果。 | 保留 trace,确保重试和取消可审计。 | +| Write-back | 通过 `lime.storage` 写业务对象,通过 `lime.artifacts` / `lime.evidence` 写交付物和依据。 | 自动附加 appId、entryKey、package provenance、workspace / tenant 上下文。 | 验收口径:内容工厂的资料整理、场景生成、批量文案、交付和复盘都应在 App 页面内启动、观察、确认和写回;Expert Chat 只能作为嵌入式协作者读取同一上下文,不允许成为手工复制结果的旁路。 @@ -335,15 +377,15 @@ entries: ## P0 交付物 -| 交付物 | 说明 | 验收 | -|---|---|---| +| 交付物 | 说明 | 验收 | +| ------------------------ | --------------------------------------------------------- | -------------------------------- | | App manifest v0.3 parser | 支持 `requires`、`runtimePackage`、`storage`、`entries`。 | 示例 App 可 validate / project。 | -| Capability SDK 类型草案 | TypeScript types + mock host。 | App 示例可以用 mock 运行单测。 | -| Desktop Installer 方案 | 安装、hash、projection、readiness、权限。 | 能生成 projection,不运行代码。 | -| Storage namespace 方案 | schema、migration、保留/删除策略。 | 卸载时可选择保留数据。 | -| UI extension slot 方案 | page / panel / settings / artifact viewer。 | App 页面不需要写进 Core。 | -| Worker runtime 方案 | long task、cancel、trace、policy。 | 能执行受控后台任务。 | -| Evidence 串联 | task/tool/knowledge/artifact/eval provenance。 | 产物能追溯 App 和知识版本。 | +| Capability SDK 类型草案 | TypeScript types + mock host。 | App 示例可以用 mock 运行单测。 | +| Desktop Installer 方案 | 安装、hash、projection、readiness、权限。 | 能生成 projection,不运行代码。 | +| Storage namespace 方案 | schema、migration、保留/删除策略。 | 卸载时可选择保留数据。 | +| UI extension slot 方案 | page / panel / settings / artifact viewer。 | App 页面不需要写进 Core。 | +| Worker runtime 方案 | long task、cancel、trace、policy。 | 能执行受控后台任务。 | +| Evidence 串联 | task/tool/knowledge/artifact/eval provenance。 | 产物能追溯 App 和知识版本。 | ## 分期计划 @@ -377,11 +419,11 @@ entries: ## 风险与应对 -| 风险 | 影响 | 应对 | -|---|---|---| -| SDK 过厚 | 变成第二套 Lime 内部 API。 | 只暴露 capability facade,不暴露 store/internal path。 | -| SDK 过薄 | App 重复造轮子。 | P0 优先封装高频底座:storage、files、agent、artifact、knowledge、tools。 | -| UI 安全 | App UI 诱导授权或越权访问。 | Host 控制容器 + runtime permission bridge 双拦截。 | -| Migration 破坏数据 | App 升级损坏用户数据。 | migration plan、dry-run、backup、保留数据策略。 | -| Cloud 变 Runtime | 破坏 Lime 本地运行定位。 | server-assisted 必须显式声明并受 policy 控制。 | +| 风险 | 影响 | 应对 | +| -------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------- | +| SDK 过厚 | 变成第二套 Lime 内部 API。 | 只暴露 capability facade,不暴露 store/internal path。 | +| SDK 过薄 | App 重复造轮子。 | P0 优先封装高频底座:storage、files、agent、artifact、knowledge、tools。 | +| UI 安全 | App UI 诱导授权或越权访问。 | Host 控制容器 + runtime permission bridge 双拦截。 | +| Migration 破坏数据 | App 升级损坏用户数据。 | migration plan、dry-run、backup、保留数据策略。 | +| Cloud 变 Runtime | 破坏 Lime 本地运行定位。 | server-assisted 必须显式声明并受 policy 控制。 | | Agent 能力退化为 API | App 绕过 Claw / Aster / Evidence 主链。 | `lime.agent` / `lime.workflow` 必须进入 AgentRuntime Surface,模型 token 仅作 executor / fallback。 | diff --git a/docs/roadmap/agentapp/content-factory-app.md b/docs/roadmap/agentapp/content-factory-app.md index 82e7adae5..aa9c266fe 100644 --- a/docs/roadmap/agentapp/content-factory-app.md +++ b/docs/roadmap/agentapp/content-factory-app.md @@ -29,7 +29,9 @@ 后端事实源:内容工厂的 AI 任务必须通过 `docs/roadmap/agentruntime/app-surface-runtime.md` 定义的 Agent App Runtime Surface 进入 AgentRuntime 主链。`LIME_GATEWAY_*` / 模型 API 只能作为低阶 executor 或降级能力,不能替代 Lime Agent 的工具、知识、权限、证据和 artifact 治理。 -当前实现进展:Lime Host 的 `AgentAppRuntimePage` 已把 iframe Host Bridge 里的 `lime.agent.startTask / streamTask / getTask / cancelTask / retryTask / submitHostResponse` 接入 `agent_app_runtime_*` facade,并由后端复用 `AgentRuntimeThreadReadModel` 投影 `taskStatus / taskEvents`。这意味着内容工厂页面内发起“生成文案 / 场景规划”等 Agent task 时,第一跳已经进入完整 Lime AgentRuntime 主链;当 runtime 追问上下文、等待确认或要求工具授权时,App 也能在页面内回传响应,而不是跳回通用 Chat。当前仍未完成实时 stream、artifact-created 专用投影、storage / artifact / evidence 的后端持久写回与跨刷新 task/session 恢复。 +当前实现进展:Lime Host 的 `AgentAppRuntimePage` 已把 iframe Host Bridge 里的 `lime.agent.startTask / streamTask / getTask / cancelTask / retryTask / submitHostResponse` 接入 `agent_app_runtime_*` facade,并由后端复用 `AgentRuntimeThreadReadModel` 投影 `taskStatus / taskEvents`。这意味着内容工厂页面内发起“生成文案 / 场景规划”等 Agent task 时,第一跳已经进入完整 Lime AgentRuntime 主链;当 runtime 追问上下文、等待确认或要求工具授权时,App 也能在页面内回传响应,而不是跳回通用 Chat。Host Bridge / `AgentRuntimeCapabilityHost` 负责把 Claw 式过程进一步封装为标准 `runtimeProcess` / `process`:包含思考、正文流、执行流、Skill、工具、模型、Token、费用、产物、证据、终态与默认折叠策略。内容工厂只消费该视图做业务展示,不再维护自己的底层运行过程解析器。当前剩余缺口是更多真实多 capability 编排、后端/cross-surface capability policy owner,以及重启宿主后的端到端业务流覆盖。 + +新增硬验收:内容工厂不是“本地 Web + 生成 API”。知识库整理、场景地图、内容战役、脚本 / 图片提示词、交付包和复盘只要触发 AI 业务动作,就必须构造 `ContentFactoryAgentTask` 并通过 `lime.agent` 进入 AgentRuntime。页面必须展示 AI 运行现场:思考、执行、流式输出、Skill、工具、模型、Token、费用、artifact、evidence;完成后可以折叠,但过程不能消失。Host 已连接时,`/api/scene/generate`、`/api/copy/batch`、`/api/script/generate`、`/api/strategy/analyze`、`/api/review/submit` 等本地生成接口只能作为开发 fallback,不能承接正式生成。 ## 背景 @@ -50,13 +52,13 @@ ## 用户与收益 -| 用户 | 核心诉求 | 收益 | -|------|----------|------| -| 内容运营团队 | 项目资料一次沉淀,批量生产文案、脚本、提示词。 | 从灵感驱动变为系统化生产。 | -| 个人 IP / 讲师 | 基于本人经历和表达风格产出文章、课程稿、朋友圈和私域话术。 | 语气一致、素材复用、减少重复讲背景。 | -| 交付顾问 | 为客户交付可运行系统,而不是复制 prompt 或 fork Lime。 | 交付可复制,官方 App 可升级,客户数据不被覆盖。 | -| 团队管理员 | 控制 App 可见性、模型、工具、默认知识模板、质量门禁。 | 团队能力标准化、权限可控。 | -| Lime 平台 | 验证 App SDK、UI extension、storage namespace、worker runtime、artifact/evidence。 | 新行业通过 App 扩展,不污染 Core。 | +| 用户 | 核心诉求 | 收益 | +| -------------- | ---------------------------------------------------------------------------------- | ----------------------------------------------- | +| 内容运营团队 | 项目资料一次沉淀,批量生产文案、脚本、提示词。 | 从灵感驱动变为系统化生产。 | +| 个人 IP / 讲师 | 基于本人经历和表达风格产出文章、课程稿、朋友圈和私域话术。 | 语气一致、素材复用、减少重复讲背景。 | +| 交付顾问 | 为客户交付可运行系统,而不是复制 prompt 或 fork Lime。 | 交付可复制,官方 App 可升级,客户数据不被覆盖。 | +| 团队管理员 | 控制 App 可见性、模型、工具、默认知识模板、质量门禁。 | 团队能力标准化、权限可控。 | +| Lime 平台 | 验证 App SDK、UI extension、storage namespace、worker runtime、artifact/evidence。 | 新行业通过 App 扩展,不污染 Core。 | ## 非目标 @@ -72,34 +74,31 @@ ```text 内容工厂 -├── 项目首页 -├── 知识库 -│ ├── IP 知识库 -│ ├── 项目知识库 -│ └── 素材库 -├── 内容场景规划 -├── 内容工厂 -│ ├── 批量文案 -│ ├── 短视频脚本 -│ └── 图片提示词 -├── 策略分析 -├── PPT / 报告 -├── 数据复盘 -├── 交付物 -├── 专家对话 -└── 设置 +├── 项目组合 / 项目驾驶舱 +├── 知识库底座 +├── 场景地图 +├── 内容战役 +│ ├── 战役设置 +│ ├── 场景包 +│ ├── 文案批次 +│ ├── 脚本与图片提示词 +│ ├── 质量检查 +│ └── 提交确认 +├── 交付包 +├── 复盘与下一轮 +└── 项目设置 ``` ### Expert entries 专家只是 App 内的入口: -| Expert | 用途 | -|---|---| -| 内容策略专家 | 项目定位、竞品分析、内容方向判断。 | -| 知识库整理专家 | 协助资料分层、裁剪、版本化。 | -| 文案优化专家 | 去 AI 味、平台适配、人工改稿。 | -| 数据复盘专家 | 解释方向表现、生成下周建议。 | +| Expert | 用途 | +| -------------- | ---------------------------------- | +| 内容策略专家 | 项目定位、竞品分析、内容方向判断。 | +| 知识库整理专家 | 协助资料分层、裁剪、版本化。 | +| 文案优化专家 | 去 AI 味、平台适配、人工改稿。 | +| 数据复盘专家 | 解释方向表现、生成下周建议。 | ## App Package 草案 @@ -204,17 +203,17 @@ knowledgeTemplates: ## Storage 模型 -| 表 | 说明 | -|---|---| -| `projects` | 项目、行业、平台、是否需要 IP、默认模型和状态。 | -| `knowledge_spaces` | IP / project / material 三层知识库空间、版本和健康度。 | -| `knowledge_items` | 分区知识条目、来源、证据、字数、版本。 | +| 表 | 说明 | +| ------------------- | -------------------------------------------------------- | +| `projects` | 项目、行业、平台、是否需要 IP、默认模型和状态。 | +| `knowledge_spaces` | IP / project / material 三层知识库空间、版本和健康度。 | +| `knowledge_items` | 分区知识条目、来源、证据、字数、版本。 | | `content_scenarios` | 内容场景规划结果:维度、痛点、解决方案、决策阶段、标签。 | -| `content_assets` | 文案、脚本、图片提示词、报告等内容资产。 | -| `review_imports` | CSV / Excel 数据导入批次。 | -| `metrics` | 完播率、搜索占比、转化、素材复用率等指标。 | -| `insights` | 自动归因、方向判断、下步建议。 | -| `app_settings` | 禁用词、平台规则、评分阈值、默认导出格式。 | +| `content_assets` | 文案、脚本、图片提示词、报告等内容资产。 | +| `review_imports` | CSV / Excel 数据导入批次。 | +| `metrics` | 完播率、搜索占比、转化、素材复用率等指标。 | +| `insights` | 自动归因、方向判断、下步建议。 | +| `app_settings` | 禁用词、平台规则、评分阈值、默认导出格式。 | ## Workflow @@ -255,28 +254,35 @@ flowchart TD ## Capability 调用映射 -| 业务功能 | Lime Capability | -|---|---| -| 文件上传和解析 | `lime.files`、`lime.tools` | -| 三层知识库管理 | `lime.storage`、`lime.knowledge` | -| 结构化抽取 / 文案 / 脚本 | `lime.agent`、Agent Skills | -| 批量任务、进度、中断 | `lime.workflow`、`lime.agent` | -| 内容表、报告、PPT | `lime.artifacts` | -| 事实来源、版本、评估 | `lime.evidence`、Evals | -| 外部调研、飞书同步 | `lime.tools`、`lime.secrets` | -| 权限、成本、风险 | `lime.policy` | +全量 Lime 能力抽象、App / Lime 主 App 边界和后端接线顺序见 [P18.7 Full Lime Capability Surface](./p18-7-full-lime-capability-surface.md)。内容工厂只作为第一个业务验证 App,不拥有底层 AgentRuntime / ToolRuntime 能力事实源。 + +内容工厂只实现内容业务层,不实现 Lime 底层 AI 平台层。所有 Lime 功能先看 `docs/roadmap/agentapp/capability-sdk.md` 的全量能力地图;内容工厂 MVP 消费其中一部分,后续扩展也必须继续走 `lime.*` capability,而不是新增 `content_factory_*` 专用后端命令或裸模型 API。 + +| 业务功能 | 主要 Capability | 说明 | +| --------------------------------------- | ------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | +| 文件上传和解析 | `lime.files`、`lime.documents`、`lime.tools` | App 选择资料类型和业务落点;Lime 负责 file ref、解析器、工具执行和证据。 | +| 三层知识库管理 | `lime.storage`、`lime.knowledge`、`lime.evidence` | App 管 IP / 项目 / 素材三层业务模型;Lime 管 namespace、检索、binding、版本和来源。 | +| 结构化抽取 / 场景 / 文案 / 脚本 | `lime.agent`、`lime.skills`、`lime.models`、`lime.memory` | App 组织 task input 和 expected output;AgentRuntime 负责模型、Skill、上下文、记忆和运行事实。 | +| 批量任务、进度、中断 | `lime.workflow`、`lime.tasks`、`lime.events` | App 展示阶段和人工确认;Lime 托管 checkpoint、任务中心、事件订阅和恢复。 | +| 运行过程、思考、工具、模型、Token、费用 | `lime.agent`、`lime.usage`、`lime.capabilities` | App 只消费 Host 下发的 `runtimeProcess`;模型、用量和可用性来自 Lime。 | +| 内容表、报告、PPT、交付物 | `lime.artifacts`、`lime.documents`、`lime.media` | App 定义交付结构;Lime 负责 artifact、导出、图片/音频/视频/文档 runtime。 | +| 外部调研和网页采集 | `lime.search`、`lime.browser`、`lime.mcp`、`lime.connectors` | App 给出调研问题和筛选规则;Lime 负责搜索、浏览器、MCP、连接器和审计。 | +| 凭证、权限、成本、风险、审核 | `lime.secrets`、`lime.policy`、`lime.settings`、`lime.review` | App 解释用途并处理降级;Lime 托管 secret、策略、设置 overlay 和发布/风险审核。 | +| 产物证据和复盘 | `lime.evidence`、`lime.usage`、`lime.tasks` | App 展示来源和复盘指标;Lime 从 runtime facts / telemetry 归因。 | + +边界结论:内容工厂可以有自己的多页面、多工作流和业务数据库视图,但模型路由、Skill 调用、MCP、浏览器、搜索、媒体、终端、凭证、Token 费用、Evidence Pack 和运行过程都必须由 Lime 主 App capability 提供。 ## App 内 Agent 任务闭环 内容工厂的 Agent 能力必须作为业务任务嵌入 App,而不是跳转到通用 Chat: -| 业务任务 | App 输入 | Lime Agent / Capability | App 内写回 | -|---|---|---|---| -| 资料整理 | 用户授权文件、资料类型、项目目标。 | `lime.files` / `lime.tools` 解析,`lime.agent.startTask` 结构化抽取,`lime.evidence` 记录来源。 | `knowledge_items` 草稿、资料健康度、人工确认状态。 | -| 场景规划 | 已确认知识库、目标平台、人群和数量。 | `lime.knowledge.search` 检索,`lime.agent.startTask` 生成结构化场景。 | `content_scenarios` 表、场景 Artifact、引用证据。 | -| 批量文案 / 脚本 | 场景、平台规则、数量、风格约束。 | `lime.agent.startTask` + writer skills,Eval 检查事实支撑和去 AI 味。 | `content_assets` / `script_batches`、A/B/C 分级、质量记录。 | -| 策略交付 | 内容批次、项目事实、竞品资料。 | `lime.agent.startTask` 生成报告结构,`lime.artifacts` 持久化交付物。 | 策略报告、PPT 大纲、交付状态。 | -| 数据复盘 | CSV / Excel 指标、历史内容资产。 | `lime.tools` 归一化,`lime.agent.startTask` 归因分析。 | `review_reports`、下轮动作、选题权重更新。 | +| 业务任务 | App 输入 | Lime Agent / Capability | App 内写回 | +| --------------- | ------------------------------------ | ----------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | +| 资料整理 | 用户授权文件、资料类型、项目目标。 | `lime.files` / `lime.tools` 解析,`lime.agent.startTask` 结构化抽取,`lime.evidence` 记录来源。 | `knowledge_items` 草稿、资料健康度、人工确认状态。 | +| 场景规划 | 已确认知识库、目标平台、人群和数量。 | `lime.knowledge.search` 检索,`lime.agent.startTask` 生成结构化场景。 | `content_scenarios` 表、场景 Artifact、引用证据。 | +| 批量文案 / 脚本 | 场景、平台规则、数量、风格约束。 | `lime.agent.startTask` + writer skills,Eval 检查事实支撑和去 AI 味。 | `content_assets` / `script_batches`、A/B/C 分级、质量记录。 | +| 策略交付 | 内容批次、项目事实、竞品资料。 | `lime.agent.startTask` 生成报告结构,`lime.artifacts` 持久化交付物。 | 策略报告、PPT 大纲、交付状态。 | +| 数据复盘 | CSV / Excel 指标、历史内容资产。 | `lime.tools` 归一化,`lime.agent.startTask` 归因分析。 | `review_reports`、下轮动作、选题权重更新。 | 每个任务都必须在内容工厂页面内展示进度、引用、工具调用、失败原因、重试和人工确认;最终结果以结构化对象写回 App storage,再生成 Artifact / Evidence。 @@ -299,33 +305,33 @@ flowchart TD 最小 task 输入: -| 字段 | 来源 | -|---|---| -| `taskKind` | `content_factory.copy.generate` 或 `content_factory.copy.rewrite` | -| `task` | “今天要完成什么”输入框 | -| `category` | 品类输入框 | -| `platform` | 平台选择 | -| `audience` | 目标人群 | -| `coreWords` | 核心词 | -| `materialVersion` | 生产前检查中的资料版本 | -| `selectedScenarios` | 优先场景或自动补齐场景 | -| `scriptRefs` | 现有脚本 / 视频口播引用 | -| `expectedOutput` | 文案、素材 brief、脚本片段、交付包 | -| `humanReview` | 默认 `true` | +| 字段 | 来源 | +| ------------------- | ----------------------------------------------------------------- | +| `taskKind` | `content_factory.copy.generate` 或 `content_factory.copy.rewrite` | +| `task` | “今天要完成什么”输入框 | +| `category` | 品类输入框 | +| `platform` | 平台选择 | +| `audience` | 目标人群 | +| `coreWords` | 核心词 | +| `materialVersion` | 生产前检查中的资料版本 | +| `selectedScenarios` | 优先场景或自动补齐场景 | +| `scriptRefs` | 现有脚本 / 视频口播引用 | +| `expectedOutput` | 文案、素材 brief、脚本片段、交付包 | +| `humanReview` | 默认 `true` | 最小事件投影: -| 事件 | 页面反馈 | -|---|---| -| `task:started` | 本轮任务开始,锁定输入快照。 | -| `task:contextChecked` | 更新“生产前检查”卡片,不再只显示静态数字。 | -| `task:missingContextRequested` | 在 App 内补资料、补场景或追问用户。 | -| `task:toolCall` | 显示知识检索、搜索、图片、报告、PDF / 总结等工具进度。 | -| `task:citation` | 显示资料版本、场景、网页或文件引用。 | -| `task:partialArtifact` | 展示草稿文案、素材 brief、脚本片段。 | -| `task:reviewRequested` | 用户在当前页面确认、编辑、拒绝或重试。 | -| `task:completed` | 写回内容资产并生成交付物。 | -| `artifact:created` / `evidence:recorded` | 交付物和证据已落库。 | +| 事件 | 页面反馈 | +| ---------------------------------------- | ------------------------------------------------------ | +| `task:started` | 本轮任务开始,锁定输入快照。 | +| `task:contextChecked` | 更新“生产前检查”卡片,不再只显示静态数字。 | +| `task:missingContextRequested` | 在 App 内补资料、补场景或追问用户。 | +| `task:toolCall` | 显示知识检索、搜索、图片、报告、PDF / 总结等工具进度。 | +| `task:citation` | 显示资料版本、场景、网页或文件引用。 | +| `task:partialArtifact` | 展示草稿文案、素材 brief、脚本片段。 | +| `task:reviewRequested` | 用户在当前页面确认、编辑、拒绝或重试。 | +| `task:completed` | 写回内容资产并生成交付物。 | +| `artifact:created` / `evidence:recorded` | 交付物和证据已落库。 | “只重写文案”必须限制 artifact policy:只允许改写当前文案资产,不允许重写资料、场景或脚本来源。“生成文案和配套素材”可以触发资料检查、场景补齐、素材 brief 和配图能力,但所有副作用都必须带 App provenance。 @@ -333,25 +339,25 @@ flowchart TD 内容工厂不得复制 Claw 已有能力,首批复用关系见 `docs/roadmap/agentruntime/claw-capability-sharing.md`: -| 内容工厂需求 | 复用 capability | 后端主链 | -|---|---|---| -| 生产前资料补齐 | `lime.capability.research.search` / `lime.capability.summary.generate` | `research_skill_launch` / `summary_skill_launch` | -| 竞品和策略分析 | `lime.capability.report.generate` | `report_skill_launch` | -| PDF / 资料读取 | `lime.capability.pdf.read` | `pdf_read_skill_launch` | -| 配套素材 / 海报 brief | `lime.capability.image.generate` / `lime.capability.cover.generate` | `image_skill_launch` / `cover_skill_launch` | -| 交付 PPT / 报告 | `lime.capability.presentation.generate` / `lime.capability.webpage.generate` | `presentation_skill_launch` / `webpage_skill_launch` | +| 内容工厂需求 | 复用 capability | 后端主链 | +| --------------------- | ---------------------------------------------------------------------------- | ---------------------------------------------------- | +| 生产前资料补齐 | `lime.capability.research.search` / `lime.capability.summary.generate` | `research_skill_launch` / `summary_skill_launch` | +| 竞品和策略分析 | `lime.capability.report.generate` | `report_skill_launch` | +| PDF / 资料读取 | `lime.capability.pdf.read` | `pdf_read_skill_launch` | +| 配套素材 / 海报 brief | `lime.capability.image.generate` / `lime.capability.cover.generate` | `image_skill_launch` / `cover_skill_launch` | +| 交付 PPT / 报告 | `lime.capability.presentation.generate` / `lime.capability.webpage.generate` | `presentation_skill_launch` / `webpage_skill_launch` | ## Host Bridge 要求 内容工厂是第一个正式样板 App,不能使用私有 iframe 协议。它的 UI runtime 必须接入 Agent App 标准 Host Bridge: -| 场景 | Bridge 事件 | 验收 | -|---|---|---| -| 跟随 Lime 主题 / 配色 | `host:snapshot`、`theme:update` | 主 App 切换主题后,内容工厂 iframe 内字体、颜色、背景和选中态同步变化。 | -| 非技术提示 | `host:toast` | App 内操作结果用 Lime Host 提示,不暴露 Gateway、Artifact、RAG 等技术词。 | -| 页面跳转 | `host:navigate` | 从项目中心进入资料、写文案、做脚本等入口时仍由 Host 校验 entry / route。 | -| 下载交付物 | `host:download` | 只允许下载同源 runtime 产物或 Host 已授权 Artifact。 | -| 后续能力调用 | `capability:invoke` | 资料解析、模型生成、Artifact 写入继续经过 readiness / permission / policy,不返回 mock 成功。 | +| 场景 | Bridge 事件 | 验收 | +| --------------------- | ------------------------------- | --------------------------------------------------------------------------------------------- | +| 跟随 Lime 主题 / 配色 | `host:snapshot`、`theme:update` | 主 App 切换主题后,内容工厂 iframe 内字体、颜色、背景和选中态同步变化。 | +| 非技术提示 | `host:toast` | App 内操作结果用 Lime Host 提示,不暴露 Gateway、Artifact、RAG 等技术词。 | +| 页面跳转 | `host:navigate` | 从项目中心进入资料、写文案、做脚本等入口时仍由 Host 校验 entry / route。 | +| 下载交付物 | `host:download` | 只允许下载同源 runtime 产物或 Host 已授权 Artifact。 | +| 后续能力调用 | `capability:invoke` | 资料解析、模型生成、Artifact 写入继续经过 readiness / permission / policy,不返回 mock 成功。 | 内容工厂 App 内部只保留主题 fallback,方便脱离 Lime Host 做本地预览;真实嵌入 Lime 时,主题、语言、入口上下文和 Host actions 都以 `lime.agentApp.bridge` 快照为准。 @@ -389,25 +395,25 @@ P0 不做: ## 分期计划 -| 阶段 | 目标 | 验收 | -|---|---|---| +| 阶段 | 目标 | 验收 | +| ---------------- | --------------------------------------------------------- | ------------------------------------------------------ | | P0 App Host 验证 | 用 mock SDK 跑通 App UI + storage + workflow + artifact。 | 不修改 Lime Core 业务代码即可打开 App 页面和保存数据。 | -| P1 内容生产 MVP | 跑通知识库构建、内容场景规划、批量文案 / 脚本。 | 30 秒内输出 20 条,评分和分级可见,Artifact 可追溯。 | -| P2 策略交付 | 策略分析、报告、PPT 大纲或 pptx 导出。 | 输入项目与竞品资料,10 分钟内生成可编辑交付物。 | -| P3 数据飞轮 | 运营数据导入、自动归因、选题库迭代。 | 导入一周数据后自动生成复盘 Artifact。 | -| P4 企业启用 | Cloud catalog、tenant enablement、overlay、secrets。 | 租户可启用 App,客户数据不进入官方包。 | +| P1 内容生产 MVP | 跑通知识库构建、内容场景规划、批量文案 / 脚本。 | 30 秒内输出 20 条,评分和分级可见,Artifact 可追溯。 | +| P2 策略交付 | 策略分析、报告、PPT 大纲或 pptx 导出。 | 输入项目与竞品资料,10 分钟内生成可编辑交付物。 | +| P3 数据飞轮 | 运营数据导入、自动归因、选题库迭代。 | 导入一周数据后自动生成复盘 Artifact。 | +| P4 企业启用 | Cloud catalog、tenant enablement、overlay、secrets。 | 租户可启用 App,客户数据不进入官方包。 | ## Readiness 规则 -| 检查 | 级别 | 说明 | -|---|---|---| -| `lime.storage` 缺失 | blocking | App 无法保存项目和内容资产。 | -| `lime.files` 缺失 | blocking | 无法上传 / 解析资料。 | -| `project_knowledge` 未绑定 | blocking | 内容生产不能运行。 | -| 必需 writer skill 缺失 | blocking | 批量文案和脚本不可运行。 | -| document parser 缺失 | warning / blocking | P0 上传资料流程 blocking;手工录入可降级。 | -| eval 未启用 | warning | 可生成草稿,但不能标记 publish-ready。 | -| 外部调研工具缺失 | warning | 策略报告降级为用户提供资料模式。 | +| 检查 | 级别 | 说明 | +| -------------------------- | ------------------ | ------------------------------------------ | +| `lime.storage` 缺失 | blocking | App 无法保存项目和内容资产。 | +| `lime.files` 缺失 | blocking | 无法上传 / 解析资料。 | +| `project_knowledge` 未绑定 | blocking | 内容生产不能运行。 | +| 必需 writer skill 缺失 | blocking | 批量文案和脚本不可运行。 | +| document parser 缺失 | warning / blocking | P0 上传资料流程 blocking;手工录入可降级。 | +| eval 未启用 | warning | 可生成草稿,但不能标记 publish-ready。 | +| 外部调研工具缺失 | warning | 策略报告降级为用户提供资料模式。 | ## 验收标准 @@ -420,15 +426,15 @@ P0 不做: ## P0 用户故事 -| 编号 | 用户故事 | 验收标准 | -|---|---|---| -| US-01 | 作为运营,我可以创建一个内容项目,并选择行业、目标平台和是否需要 IP 库。 | `projects` 写入 App storage namespace;不新增 Lime Core 业务表。 | -| US-02 | 作为运营,我可以上传产品资料,系统生成三层知识库草稿。 | 文件通过 `lime.files` 授权读取;结构化结果进入 `knowledge_items`。 | -| US-03 | 作为负责人,我可以逐条确认、裁剪和版本化知识条目。 | 知识库健康度显示字数、分区、来源和版本。 | -| US-04 | 作为内容同学,我可以基于确认后的项目知识库生成 120+ 场景。 | `content_scenarios` 表保存应用场景、用户痛点、解决方案、决策阶段。 | -| US-05 | 作为内容同学,我可以选择内容场景和平台批量生成 20 条文案或脚本。 | 生成结果进入 `content_assets`,带 A/B/C 分级和去 AI 味评分。 | -| US-06 | 作为交付顾问,我可以把内容批次保存为 Artifact 并追溯来源。 | Artifact 带 app / knowledge / skill / tool / eval provenance。 | -| US-07 | 作为管理员,我可以看到缺失能力或缺失知识绑定的 readiness 提示。 | 缺少 `project_knowledge`、`lime.files` 或 writer skill 时不能静默运行。 | +| 编号 | 用户故事 | 验收标准 | +| ----- | ------------------------------------------------------------------------ | ----------------------------------------------------------------------- | +| US-01 | 作为运营,我可以创建一个内容项目,并选择行业、目标平台和是否需要 IP 库。 | `projects` 写入 App storage namespace;不新增 Lime Core 业务表。 | +| US-02 | 作为运营,我可以上传产品资料,系统生成三层知识库草稿。 | 文件通过 `lime.files` 授权读取;结构化结果进入 `knowledge_items`。 | +| US-03 | 作为负责人,我可以逐条确认、裁剪和版本化知识条目。 | 知识库健康度显示字数、分区、来源和版本。 | +| US-04 | 作为内容同学,我可以基于确认后的项目知识库生成 120+ 场景。 | `content_scenarios` 表保存应用场景、用户痛点、解决方案、决策阶段。 | +| US-05 | 作为内容同学,我可以选择内容场景和平台批量生成 20 条文案或脚本。 | 生成结果进入 `content_assets`,带 A/B/C 分级和去 AI 味评分。 | +| US-06 | 作为交付顾问,我可以把内容批次保存为 Artifact 并追溯来源。 | Artifact 带 app / knowledge / skill / tool / eval provenance。 | +| US-07 | 作为管理员,我可以看到缺失能力或缺失知识绑定的 readiness 提示。 | 缺少 `project_knowledge`、`lime.files` 或 writer skill 时不能静默运行。 | ## 典型用例 @@ -458,26 +464,26 @@ P0 不做: ## 实现边界 -| 责任 | 放在 App 实现 | 放在 Lime 平台 | -|---|---|---| -| 三层知识库 UI | 是 | 提供 UI host 和 storage capability。 | -| 文案生成业务规则 | 是 | 提供 Agent Runtime、Skills 调用、Evidence。 | -| 去 AI 味规则库 | 是 | 提供 Eval 执行和结果记录。 | -| 文件权限与解析入口 | 编排 | `lime.files` 和 `lime.tools` 提供授权与工具执行。 | -| 数据表物理存储 | 否 | `lime.storage` 管 namespace、CRUD、migration。 | -| Artifact 持久化 | 定义内容 | `lime.artifacts` 管创建、打开、导出和 provenance。 | -| 凭证 | 否 | `lime.secrets` 托管。 | -| 租户默认配置 | App 默认值 | Cloud / Desktop overlay 覆盖。 | +| 责任 | 放在 App 实现 | 放在 Lime 平台 | +| ------------------ | ------------- | -------------------------------------------------- | +| 三层知识库 UI | 是 | 提供 UI host 和 storage capability。 | +| 文案生成业务规则 | 是 | 提供 Agent Runtime、Skills 调用、Evidence。 | +| 去 AI 味规则库 | 是 | 提供 Eval 执行和结果记录。 | +| 文件权限与解析入口 | 编排 | `lime.files` 和 `lime.tools` 提供授权与工具执行。 | +| 数据表物理存储 | 否 | `lime.storage` 管 namespace、CRUD、migration。 | +| Artifact 持久化 | 定义内容 | `lime.artifacts` 管创建、打开、导出和 provenance。 | +| 凭证 | 否 | `lime.secrets` 托管。 | +| 租户默认配置 | App 默认值 | Cloud / Desktop overlay 覆盖。 | ## P0 页面验收细节 -| 页面 | 必备组件 | Capability | -|---|---|---| -| 项目首页 | 项目列表、知识健康度、最近 Artifact、快捷操作。 | `lime.storage`、`lime.artifacts` | -| 三层知识库 | IP / 项目 / 素材 tabs、分区表、字数警告、来源查看。 | `lime.files`、`lime.storage`、`lime.knowledge` | -| 内容场景规划 | 内容场景表、维度筛选、人工编辑、批量保存。 | `lime.agent`、`lime.storage` | -| 内容工厂 | 平台选择、数量、内容场景选择、批量结果、评分与分级。 | `lime.agent`、`lime.artifacts`、`lime.evidence` | -| 交付物 | 内容表、报告、导出、版本历史。 | `lime.artifacts`、`lime.evidence` | +| 页面 | 必备组件 | Capability | +| ------------ | ---------------------------------------------------- | ----------------------------------------------- | +| 项目首页 | 项目列表、知识健康度、最近 Artifact、快捷操作。 | `lime.storage`、`lime.artifacts` | +| 三层知识库 | IP / 项目 / 素材 tabs、分区表、字数警告、来源查看。 | `lime.files`、`lime.storage`、`lime.knowledge` | +| 内容场景规划 | 内容场景表、维度筛选、人工编辑、批量保存。 | `lime.agent`、`lime.storage` | +| 内容工厂 | 平台选择、数量、内容场景选择、批量结果、评分与分级。 | `lime.agent`、`lime.artifacts`、`lime.evidence` | +| 交付物 | 内容表、报告、导出、版本历史。 | `lime.artifacts`、`lime.evidence` | ## 暂缓项 diff --git a/docs/roadmap/agentapp/implementation-plan.md b/docs/roadmap/agentapp/implementation-plan.md index bc6c8965d..28faab644 100644 --- a/docs/roadmap/agentapp/implementation-plan.md +++ b/docs/roadmap/agentapp/implementation-plan.md @@ -39,15 +39,15 @@ Agent App 不是专家卡片、不是 Prompt 包、不是 Markdown 目录,也 ## 目标 -| 目标 | 说明 | -|---|---| -| 验证 App package | 本地读取 Agent App v0.3+ package / fixture,生成 manifest、projection、readiness。 | -| 验证 SDK 边界 | App 只能通过 `@lime/app-sdk` capability facade 调 Lime 能力,不能 import Lime internal modules。 | -| 验证真实业务切片 | 用 内容工厂跑通一个最小业务闭环,而不是停留在专家聊天。 | -| 验证 App 内 Agent 任务 | 在 App 页面 / workflow 内启动、观察、取消、重试、确认 Agent task,并把结构化结果写回业务对象。 | -| 验证本地数据隔离 | App storage、artifact、evidence、log、package cache 独立命名空间,可按 app 清理。 | -| 验证失败退出 | P0 就实现 cleanup plan / uninstall plan,保证失败后能删干净。 | -| 验证未来 Cloud 对接 | 客户端先用 local JSON / fixture,Cloud 只在 P5 作为 catalog / release / tenant enablement 输入。 | +| 目标 | 说明 | +| ---------------------- | ------------------------------------------------------------------------------------------------ | +| 验证 App package | 本地读取 Agent App v0.3+ package / fixture,生成 manifest、projection、readiness。 | +| 验证 SDK 边界 | App 只能通过 `@lime/app-sdk` capability facade 调 Lime 能力,不能 import Lime internal modules。 | +| 验证真实业务切片 | 用 内容工厂跑通一个最小业务闭环,而不是停留在专家聊天。 | +| 验证 App 内 Agent 任务 | 在 App 页面 / workflow 内启动、观察、取消、重试、确认 Agent task,并把结构化结果写回业务对象。 | +| 验证本地数据隔离 | App storage、artifact、evidence、log、package cache 独立命名空间,可按 app 清理。 | +| 验证失败退出 | P0 就实现 cleanup plan / uninstall plan,保证失败后能删干净。 | +| 验证未来 Cloud 对接 | 客户端先用 local JSON / fixture,Cloud 只在 P5 作为 catalog / release / tenant enablement 输入。 | ## 非目标 @@ -63,17 +63,17 @@ Agent App 不是专家卡片、不是 Prompt 包、不是 Markdown 目录,也 ## 总体原则 -| 原则 | 落地方式 | -|---|---| -| 实验岛优先 | 代码集中在 `src/features/agent-app/`,主系统只保留 feature flag 入口。 | -| 先静态后运行 | 先做 manifest / projection / readiness,不执行 UI bundle 或 worker。 | -| 先 mock 后 adapter | SDK 先接 mock host,确认接口稳定后再接少量真实能力。 | -| 先本地后 Cloud | P0-P4 使用本地 fixture,P5 才消费 LimeCore bootstrap payload。 | -| 先可删除后扩展 | 安装、运行、产物、日志都必须有 cleanup plan。 | -| SDK 暴露能力 | App 只调用 capability,不知道 Lime 内部 store、command、runtime 路径。 | -| 产物必须可追溯 | Artifact / Evidence / Task / Log 都带 `sourceKind: agent_app` provenance。 | -| 业务不出 App | App 页面内承载任务进度、引用、工具调用、失败、人工确认和结构化写回。 | -| Agent 不出治理 | `lime.agent`、`lime.workflow`、`lime.tools`、`lime.knowledge` 等能力统一走 Host Bridge / Capability SDK / policy。 | +| 原则 | 落地方式 | +| ------------------ | ------------------------------------------------------------------------------------------------------------------ | +| 实验岛优先 | 代码集中在 `src/features/agent-app/`,主系统只保留 feature flag 入口。 | +| 先静态后运行 | 先做 manifest / projection / readiness,不执行 UI bundle 或 worker。 | +| 先 mock 后 adapter | SDK 先接 mock host,确认接口稳定后再接少量真实能力。 | +| 先本地后 Cloud | P0-P4 使用本地 fixture,P5 才消费 LimeCore bootstrap payload。 | +| 先可删除后扩展 | 安装、运行、产物、日志都必须有 cleanup plan。 | +| SDK 暴露能力 | App 只调用 capability,不知道 Lime 内部 store、command、runtime 路径。 | +| 产物必须可追溯 | Artifact / Evidence / Task / Log 都带 `sourceKind: agent_app` provenance。 | +| 业务不出 App | App 页面内承载任务进度、引用、工具调用、失败、人工确认和结构化写回。 | +| Agent 不出治理 | `lime.agent`、`lime.workflow`、`lime.tools`、`lime.knowledge` 等能力统一走 Host Bridge / Capability SDK / policy。 | ## 仓库边界 @@ -97,10 +97,10 @@ Agent App 不是专家卡片、不是 Prompt 包、不是 Markdown 目录,也 三者分工: -| 仓库 | 责任 | 不做什么 | -|---|---|---| -| `agentapp` | 标准、schema、示例包、reference CLI。 | 不实现 Lime Desktop 内部能力。 | -| `lime` | 客户端安装、运行、SDK host、UI host、storage、runtime bridge。 | 不实现 Cloud catalog / tenant 管理。 | +| 仓库 | 责任 | 不做什么 | +| ---------- | ----------------------------------------------------------------------------- | --------------------------------------------------------- | +| `agentapp` | 标准、schema、示例包、reference CLI。 | 不实现 Lime Desktop 内部能力。 | +| `lime` | 客户端安装、运行、SDK host、UI host、storage、runtime bridge。 | 不实现 Cloud catalog / tenant 管理。 | | `limecore` | Catalog、release、hash、license、tenant enablement、gateway、audit metadata。 | 不运行默认 Agent、不渲染 App UI、不管理本地 App storage。 | ## 架构图 @@ -150,23 +150,22 @@ flowchart TD - `Cloud` 只提供元数据,不成为默认 runtime。 - `Agent` 是 App 作用域任务运行时,不是通用 Chat 回跳点;结果必须回到 App storage / Artifact / Evidence。 - ## v0.3 升级后的计划调整 路线图已从早期 `AI 内容工程化` 样板升级为 Agent App v0.3 / `内容工厂`。因此当前计划新增一个 P4-R rebaseline 阶段,作为 P5 Cloud Bootstrap 的前置 gate。 P4-R 的目标不是新增业务范围,而是让代码事实源与升级后的文档事实源重新一致: -| 调整项 | 旧实现 | v0.3 current | -|---|---|---| -| 标准版本 | `manifestVersion: 0.2.x` | `manifestVersion: 0.3.0` | -| Fixture | `content-engineering-app.json` | `content-factory-app.json` | -| App ID | `shenlan-content-engineering` | `content-factory-app` | -| Workflow entry | `scene_exhaustion` | `content_scenario_planning` | -| Demo API | `runContentEngineeringDemo()` | `runContentFactoryDemo()` | -| Storage | `scenes/*` | `content_scenarios/*` | -| Artifact kind | `content_engineering_table` | `content_table` | -| Evidence kind | `content_engineering_demo` | `content_factory_demo` | +| 调整项 | 旧实现 | v0.3 current | +| -------------- | ------------------------------ | --------------------------- | +| 标准版本 | `manifestVersion: 0.2.x` | `manifestVersion: 0.3.0` | +| Fixture | `content-engineering-app.json` | `content-factory-app.json` | +| App ID | `shenlan-content-engineering` | `content-factory-app` | +| Workflow entry | `scene_exhaustion` | `content_scenario_planning` | +| Demo API | `runContentEngineeringDemo()` | `runContentFactoryDemo()` | +| Storage | `scenes/*` | `content_scenarios/*` | +| Artifact kind | `content_engineering_table` | `content_table` | +| Evidence kind | `content_engineering_demo` | `content_factory_demo` | 新的执行顺序: @@ -180,7 +179,7 @@ P4-R0 计划与事实源收口 → P5 Cloud Bootstrap payload 本地适配器 ``` -截至 2026-05-16,P4-R、P5、P6、P7、P8、P9、P10、P11、P12、P13 均已通过对应定向验证;P14 Entry Runtime Guard / Permission Prompt、P15 Lab Install / Launch Flow、P15-H GUI smoke / cleanup rehearsal、P16 Agent App Manager、P16-H multi-app lifecycle hardening、P17 Gate 审计、P17.0、P17.1、P17.2.1-P17.2.5、P17.3 lifecycle / cleanup contract、P17.4.1-P17.4.5、P17.5 formal entry GUI smoke、P18.1 SDK facade / stable error / mock host、P18.2 Host Bridge typed router / stable error response、P18.3 Core capability adapters、P18.4 App-scoped Agent task SDK facade、P18.4-H AgentRuntime handoff gate、P18.5.1 Lime-side 内容工厂 SDK regression、P18.5.2 package-side read-only tests、P18.5-S Host Bridge SDK client、P18.5.3 package-side SDK facade / verify / dist 同步与 P18.6 Raw Worker 前 Gate 已完成当前实现 / 专用验证 / 计划收口;完整 `verify:local` 已于 2026-05-16 07:33 端到端通过,2026-05-16 10:53 SDK seam / handoff core 定向测试 5 files / 17 tests passed,`typecheck`、`test:contracts` 与 `lint` 当前会话复核通过。当前下一刀不再扩 P18 功能面,只做 owner handoff / 提交边界收口。阶段记录详见 [v0.3-rebaseline-plan.md](./v0.3-rebaseline-plan.md)、[p15-lab-install-launch-flow.md](./p15-lab-install-launch-flow.md)、[p15-h-gui-smoke-cleanup-rehearsal.md](./p15-h-gui-smoke-cleanup-rehearsal.md)、[p16-agent-app-manager-product-entry-gate.md](./p16-agent-app-manager-product-entry-gate.md)、[p16-h-multi-app-repository-lifecycle-hardening.md](./p16-h-multi-app-repository-lifecycle-hardening.md)、[p17-formal-entry-gate-audit.md](./p17-formal-entry-gate-audit.md)、[p17-formal-entry-contract.md](./p17-formal-entry-contract.md)、[p17-source-install-contract-hardening.md](./p17-source-install-contract-hardening.md)、[p17-lifecycle-cleanup-contract-hardening.md](./p17-lifecycle-cleanup-contract-hardening.md)、[p17-4-host-bridge-runtime.md](./p17-4-host-bridge-runtime.md)、[p17-5-formal-entry-gui-smoke.md](./p17-5-formal-entry-gui-smoke.md)、[p18-typed-capability-sdk-gate.md](./p18-typed-capability-sdk-gate.md)、[p18-4-h-agentruntime-handoff-gate.md](./p18-4-h-agentruntime-handoff-gate.md)、[p18-5-content-factory-sdk-regression.md](./p18-5-content-factory-sdk-regression.md)、[p18-5-3-package-sdk-migration-plan.md](./p18-5-3-package-sdk-migration-plan.md)、[p18-5-3-owner-handoff.md](./p18-5-3-owner-handoff.md)、[p18-6-raw-worker-pre-gate.md](./p18-6-raw-worker-pre-gate.md) 与 [p18-completion-audit.md](./p18-completion-audit.md)。 +截至 2026-05-16,P4-R、P5、P6、P7、P8、P9、P10、P11、P12、P13 均已通过对应定向验证;P14 Entry Runtime Guard / Permission Prompt、P15 Lab Install / Launch Flow、P15-H GUI smoke / cleanup rehearsal、P16 Agent App Manager、P16-H multi-app lifecycle hardening、P17 Gate 审计、P17.0、P17.1、P17.2.1-P17.2.5、P17.3 lifecycle / cleanup contract、P17.4.1-P17.4.5、P17.5 formal entry GUI smoke、P18.1 SDK facade / stable error / mock host、P18.2 Host Bridge typed router / stable error response、P18.3 Core capability adapters、P18.4 App-scoped Agent task SDK facade、P18.4-H AgentRuntime handoff gate、P18.5.1 Lime-side 内容工厂 SDK regression、P18.5.2 package-side read-only tests、P18.5-S Host Bridge SDK client、P18.5.3 package-side SDK facade / verify / dist 同步与 P18.6 Raw Worker 前 Gate 已完成当前实现 / 专用验证 / 计划收口;完整 `verify:local` 已于 2026-05-16 07:33 端到端通过,2026-05-16 10:53 SDK seam / handoff core 定向测试 5 files / 17 tests passed,`typecheck`、`test:contracts` 与 `lint` 当前会话复核通过。当前主线重新进入 P18.7 全量 Lime capability surface:Agent App v0.6 reference 兼容已完成,下一步进入 `lime.capabilities` Host discovery 与后端能力接线。阶段记录详见 [v0.3-rebaseline-plan.md](./v0.3-rebaseline-plan.md)、[p15-lab-install-launch-flow.md](./p15-lab-install-launch-flow.md)、[p15-h-gui-smoke-cleanup-rehearsal.md](./p15-h-gui-smoke-cleanup-rehearsal.md)、[p16-agent-app-manager-product-entry-gate.md](./p16-agent-app-manager-product-entry-gate.md)、[p16-h-multi-app-repository-lifecycle-hardening.md](./p16-h-multi-app-repository-lifecycle-hardening.md)、[p17-formal-entry-gate-audit.md](./p17-formal-entry-gate-audit.md)、[p17-formal-entry-contract.md](./p17-formal-entry-contract.md)、[p17-source-install-contract-hardening.md](./p17-source-install-contract-hardening.md)、[p17-lifecycle-cleanup-contract-hardening.md](./p17-lifecycle-cleanup-contract-hardening.md)、[p17-4-host-bridge-runtime.md](./p17-4-host-bridge-runtime.md)、[p17-5-formal-entry-gui-smoke.md](./p17-5-formal-entry-gui-smoke.md)、[p18-typed-capability-sdk-gate.md](./p18-typed-capability-sdk-gate.md)、[p18-4-h-agentruntime-handoff-gate.md](./p18-4-h-agentruntime-handoff-gate.md)、[p18-5-content-factory-sdk-regression.md](./p18-5-content-factory-sdk-regression.md)、[p18-5-3-package-sdk-migration-plan.md](./p18-5-3-package-sdk-migration-plan.md)、[p18-5-3-owner-handoff.md](./p18-5-3-owner-handoff.md)、[p18-6-raw-worker-pre-gate.md](./p18-6-raw-worker-pre-gate.md) 与 [p18-completion-audit.md](./p18-completion-audit.md)。 上游 `agentapp` 标准随后补齐了宿主实现视角的 v0.3 细则:manifest / projection / readiness JSON schema、release metadata、overlay resolver、lifecycle、typed SDK expectations、reference CLI cross-check。P5 因此不能只做一个 Cloud payload DTO,而要先把这些标准差距变成客户端可执行计划;详见 [p5-cloud-bootstrap.md](./p5-cloud-bootstrap.md)。 @@ -196,17 +195,17 @@ P4-R0 计划与事实源收口 上游 `/Users/coso/Documents/dev/ai/limecloud/agentapp` 已从“概念标准 + 示例包”升级为带宿主实现指南的 v0.3 标准:新增 discovery / installation、release distribution、runtime model、security model、overlay resolver、readiness runner、typed Capability SDK、public JSON Schema 和 reference CLI。LimeCore 服务端路线图也已明确 control-plane-only 边界:服务端只保存 catalog、release、tenant enablement、policy、ToolHub / Gateway metadata;未激活注册码时不下发 `packageUrl / packageHash / manifestHash`,真实 release 必须是 `https` package URL 和完整 `sha256:<64 hex>` hash。客户端计划不需要推翻 P0-P17 主线,但必须把 P17.2 拆得更细,避免把 Cloud metadata、seeded catalog 或开发态源码目录误当成正式安装。 -| 上游新增约束 | 客户端计划调整 | 阶段归属 | -|---|---|---| -| Release 必须 pin 到具体 `packageUrl / packageHash / manifestHash / compatibility / signatureRef?`。 | Cloud release 先归一化为 `AgentAppReleaseDescriptor`,再进入 P12 cache / verify;缺 hash、hash mismatch、manifest mismatch 均阻断。 | P17.2.4 | -| 安装审查基于 projection,不执行 App code。 | `installReview` 只能消费 verified package projection、readiness summary、permission / storage / cleanup 摘要;Cloud card 和 Lab fixture 不能直接生成 production review。 | P17.2.4 | -| Cached fallback 只保证已安装版本可用。 | offline / cached 状态只能启动已安装同 hash package;不能在离线时把 Cloud metadata 伪装成新安装。 | P17.2.4 | -| LimeCore 未激活注册码时不下发 package metadata。 | `registration-required / expired / revoked` 必须停在 source state;客户端不能用 seeded fixture、本地开发目录或历史 cache 创建新安装 review。 | P17.2.4b | -| `content-factory-app` seeded catalog 不能带假 release。 | seeded 只允许演示 registration-required;正式 review 必须来自已验证 package source。 | P17.2.4b | -| Public schema 与 reference CLI 成为机械契约。 | 用上游 `docs/public/schemas/*`、`agentapp-ref` 和 `docs/examples/content-factory-app` 做 projection / readiness cross-check,防止客户端字段漂移。 | P17.2.5 | -| Overlay 是 package hash 外的配置。 | P17.3 后 lifecycle / cleanup 必须把 overlay、secret binding、setup state 与 package code 分离;upgrade 不覆盖 overlay。 | P17.3 | -| Runtime surface 必须运行 App 自己的 UI / workflow,并通过 injected SDK。 | 已完成的正式 runtime surface 补丁只能算 P17.4 预铺;P17.4 仍要确认生产路径只加载 verified runtime package,不依赖 dev resolver,并把 SDK bridge / sandbox / provenance 做成可测 gate。 | P17.4 | -| Typed SDK 要有类型、schema、mock 和 contract tests。 | P18 前不得扩 raw worker;先把 `lime.ui / storage / agent / knowledge / tools / artifacts / workflow / policy / secrets / evidence` 的 typed host bridge 形成 SDK gate。 | P18 | +| 上游新增约束 | 客户端计划调整 | 阶段归属 | +| --------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | +| Release 必须 pin 到具体 `packageUrl / packageHash / manifestHash / compatibility / signatureRef?`。 | Cloud release 先归一化为 `AgentAppReleaseDescriptor`,再进入 P12 cache / verify;缺 hash、hash mismatch、manifest mismatch 均阻断。 | P17.2.4 | +| 安装审查基于 projection,不执行 App code。 | `installReview` 只能消费 verified package projection、readiness summary、permission / storage / cleanup 摘要;Cloud card 和 Lab fixture 不能直接生成 production review。 | P17.2.4 | +| Cached fallback 只保证已安装版本可用。 | offline / cached 状态只能启动已安装同 hash package;不能在离线时把 Cloud metadata 伪装成新安装。 | P17.2.4 | +| LimeCore 未激活注册码时不下发 package metadata。 | `registration-required / expired / revoked` 必须停在 source state;客户端不能用 seeded fixture、本地开发目录或历史 cache 创建新安装 review。 | P17.2.4b | +| `content-factory-app` seeded catalog 不能带假 release。 | seeded 只允许演示 registration-required;正式 review 必须来自已验证 package source。 | P17.2.4b | +| Public schema 与 reference CLI 成为机械契约。 | 用上游 `docs/public/schemas/*`、`agentapp-ref` 和 `docs/examples/content-factory-app` 做 projection / readiness cross-check,防止客户端字段漂移。 | P17.2.5 | +| Overlay 是 package hash 外的配置。 | P17.3 后 lifecycle / cleanup 必须把 overlay、secret binding、setup state 与 package code 分离;upgrade 不覆盖 overlay。 | P17.3 | +| Runtime surface 必须运行 App 自己的 UI / workflow,并通过 injected SDK。 | 已完成的正式 runtime surface 补丁只能算 P17.4 预铺;P17.4 仍要确认生产路径只加载 verified runtime package,不依赖 dev resolver,并把 SDK bridge / sandbox / provenance 做成可测 gate。 | P17.4 | +| Typed SDK 要有类型、schema、mock 和 contract tests。 | P18 前不得扩 raw worker;先把全量 `lime.*` capability catalog、typed host bridge、profile 和 SDK facade 形成单一事实源;P18.7 起不再只覆盖内容工厂当前用到的局部能力。 | P18 | P17.2.5 不是重复 P5.5:P5.5 只在 Cloud bootstrap 和早期 projection 层记录标准差异;P17.2.5 要把同一套 schema / CLI 对照接到正式 `agent-apps` source / install / review / readiness 主链上。 @@ -214,15 +213,15 @@ P17.2.5 不是重复 P5.5:P5.5 只在 Cloud bootstrap 和早期 projection 层 上游 `agentapp-ref@0.4.0` 已把 Host Bridge v1 明确为 sandboxed Agent App UI 的标准运行时事件协议。P17.4 已完成 Lime Desktop runtime surface 的 Host Bridge 生产硬化;P17.5 已由独立 GUI smoke 证明正式 `agent-apps` 入口可用。P18 现在可以把 Host Bridge 固定成 App 可依赖的 Typed Capability SDK Gate。 -| 上游 v0.4 新约束 | 客户端计划调整 | 阶段归属 | -|---|---|---| -| Host Bridge v1 负责 theme、locale、snapshot、visibility、toast、navigate、download、external open 和 capability invoke 传输。 | P17.4 已完成运行面预铺;P18.0 先做标准 gap matrix,确认 Lime Desktop 事件名、信封、source / origin 校验与上游一致。 | P18.0 | -| App 作者不应直接手写私有 `postMessage` 协议。 | P18.1 已固定 `@lime/app-sdk` typed facade、capability invoke envelope、stable error enum 和 mock host。 | P18.1 已完成 | -| `capability:invoke` 仍必须经过 manifest、readiness、permission、policy 和 provenance。 | P18.2 已引入 typed router / adapter gate,未知 capability 不能返回 mock 成功,Host error 必须映射到 stable error response。 | P18.2 已完成 | -| `lime.agent` 是 App 内 Agent task 能力,不是回跳通用 Chat 的快捷方式。 | P18.4 已把 start / stream / get / cancel / retry / submitHostResponse / listTasks 包成 typed SDK facade;P18.4-H 已完成 AgentRuntime handoff 运行证据对齐。 | P18.4 / P18.4-H 已完成 | -| Raw worker 需要额外沙箱和资源限制。 | P18 只做 typed workflow / capability invoke;raw worker sandbox 放到后续 gate。 | P18.6 / P19 | +| 上游 v0.4 新约束 | 客户端计划调整 | 阶段归属 | +| ----------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------- | +| Host Bridge v1 负责 theme、locale、snapshot、visibility、toast、navigate、download、external open 和 capability invoke 传输。 | P17.4 已完成运行面预铺;P18.0 先做标准 gap matrix,确认 Lime Desktop 事件名、信封、source / origin 校验与上游一致。 | P18.0 | +| App 作者不应直接手写私有 `postMessage` 协议。 | P18.1 已固定 `@lime/app-sdk` typed facade、capability invoke envelope、stable error enum 和 mock host。 | P18.1 已完成 | +| `capability:invoke` 仍必须经过 manifest、readiness、permission、policy 和 provenance。 | P18.2 已引入 typed router / adapter gate,未知 capability 不能返回 mock 成功,Host error 必须映射到 stable error response。 | P18.2 已完成 | +| `lime.agent` 是 App 内 Agent task 能力,不是回跳通用 Chat 的快捷方式。 | P18.4 已把 start / stream / get / cancel / retry / submitHostResponse / listTasks 包成 typed SDK facade;P18.4-H 已完成 AgentRuntime handoff 运行证据对齐。 | P18.4 / P18.4-H 已完成 | +| Raw worker 需要额外沙箱和资源限制。 | P18 只做 typed workflow / capability invoke;raw worker sandbox 放到后续 gate。 | P18.6 / P19 | -协作边界:P17.5 evidence summary 已落地;P18 代码实施继续不启停隔壁 DevBridge / Vite / Tauri,不改 AgentRuntime Rust 投影任务,不把 SDK 设计扩大成 marketplace / raw worker。 +协作边界:P17.5 evidence summary 已落地;P18.7 继续不启停隔壁 DevBridge / Vite / Tauri,不改 AgentRuntime Rust 投影任务,不把 SDK 设计扩大成 marketplace / raw worker。全量能力只做 capability surface 收敛,真实后端接线按 owner 分期推进。 ### 2026-05-16 隔壁 AgentRuntime 更新接入边界 @@ -235,27 +234,28 @@ P17.2.5 不是重复 P5.5:P5.5 只在 Cloud bootstrap 和早期 projection 层 协作分工锁定: -| 工作包 | 本目录 Agent App P18 负责 | 隔壁 AgentRuntime 负责 | 暂不触碰 | -|---|---|---|---| -| P18.1 SDK contract | 已完成:`@lime/app-sdk` typed facade、stable error、mock host、capability invoke envelope、SDK contract tests。 | 提供现有 facade 行为事实,不要求同步改 Rust。 | 未改 `src-tauri/*`、`src/lib/api/agentAppRuntime.ts`、`agentRuntimeCapabilityHost*`。 | -| P18.2 Host Bridge router | 已完成:source / origin / appId / entryKey / requestId / typed envelope / stable error mapping 的 typed gate。 | 继续维护 runtime read model 与 command 四侧事实。 | 未启停隔壁 DevBridge / Vite / Tauri。 | -| P18.3 Core adapters | 已完成:`lime.ui / storage / artifacts / evidence / knowledge / tools` 的 typed adapter、namespace / provenance 测试已落。 | 提供 Artifact / Evidence / Tool / Knowledge 的真实 owner 能力。 | 不新增第二套 store、scanner、installer;typecheck / contracts 已通过。 | -| P18.4 App-scoped Agent task | 已完成:消费 `start / stream / get / cancel / retry / submitHostResponse / listTasks`,固定 SDK 事件与错误语义。 | 负责 push subscribe、workspace patch producer、capability catalog 和 Rust / TS 运行事实测试。 | 不复制 Claw `*_skill_launch.rs`,不建垂直 `content_factory_*` 命令。 | -| P18.4-H AgentRuntime handoff | 已完成:消费隔壁 current MVP 证据,明确 push subscribe、workspace patch producer、capability catalog、真实桌面 GUI smoke 的 owner 与退出条件。 | 继续负责运行事实和 GUI 验证链路。 | Agent App P18 只补 SDK contract / docs,不抢 runtime facade / Rust / smoke 脚本。 | -| P18.5 内容工厂 SDK 回归 | Lime-side SDK regression、Host Bridge SDK client、package-side tests / validate / readiness、P18.5.3 SDK facade、真实 package verify 与 dist 同步均已完成;handoff gate `blockers=none`、`distArtifacts=0`。 | 保证 runtime structured patch / artifact / evidence 可被 App 消费。 | 不复刻内容工厂后端产品,不把 Chat 包成主流程;后续只做 owner handoff / 提交边界。 | -| P18.6 Raw Worker 前 Gate | 已完成:P18 不执行 raw worker、外部代码、网络、文件系统或 secret value;raw worker sandbox 后移 P19。 | P19 前只保留受控 workflow DSL 和 typed capability SDK。 | 不新增 worker runtime、不执行任意 package JS。 | +| 工作包 | 本目录 Agent App P18 负责 | 隔壁 AgentRuntime 负责 | 暂不触碰 | +| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | +| P18.1 SDK contract | 已完成:`@lime/app-sdk` typed facade、stable error、mock host、capability invoke envelope、SDK contract tests。 | 提供现有 facade 行为事实,不要求同步改 Rust。 | 未改 `src-tauri/*`、`src/lib/api/agentAppRuntime.ts`、`agentRuntimeCapabilityHost*`。 | +| P18.2 Host Bridge router | 已完成:source / origin / appId / entryKey / requestId / typed envelope / stable error mapping 的 typed gate。 | 继续维护 runtime read model 与 command 四侧事实。 | 未启停隔壁 DevBridge / Vite / Tauri。 | +| P18.3 Core adapters | 已完成:`lime.ui / storage / artifacts / evidence / knowledge / tools` 的 typed adapter、namespace / provenance 测试已落。 | 提供 Artifact / Evidence / Tool / Knowledge 的真实 owner 能力。 | 不新增第二套 store、scanner、installer;typecheck / contracts 已通过。 | +| P18.4 App-scoped Agent task | 已完成:消费 `start / stream / get / cancel / retry / submitHostResponse / listTasks`,固定 SDK 事件与错误语义。 | 负责 push subscribe、workspace patch producer、capability catalog 和 Rust / TS 运行事实测试。 | 不复制 Claw `*_skill_launch.rs`,不建垂直 `content_factory_*` 命令。 | +| P18.4-H AgentRuntime handoff | 已完成:消费隔壁 current MVP 证据,明确 push subscribe、workspace patch producer、capability catalog、真实桌面 GUI smoke 的 owner 与退出条件。 | 继续负责运行事实和 GUI 验证链路。 | Agent App P18 只补 SDK contract / docs,不抢 runtime facade / Rust / smoke 脚本。 | +| P18.5 内容工厂 SDK 回归 | Lime-side SDK regression、Host Bridge SDK client、package-side tests / validate / readiness、P18.5.3 SDK facade、真实 package verify 与 dist 同步均已完成;handoff gate `blockers=none`、`distArtifacts=0`。 | 保证 runtime structured patch / artifact / evidence 可被 App 消费。 | 不复刻内容工厂后端产品,不把 Chat 包成主流程;该阶段已收口,后续主线转入 P18.7 capability surface。 | +| P18.6 Raw Worker 前 Gate | 已完成:P18 不执行 raw worker、外部代码、网络、文件系统或 secret value;raw worker sandbox 后移 P19。 | P19 前只保留受控 workflow DSL 和 typed capability SDK。 | 不新增 worker runtime、不执行任意 package JS。 | +| P18.7 Full Lime Capability Surface | 文档先行:`p18-7-full-lime-capability-surface.md` 固定全量 `lime.*` 能力、App / Lime 主 App 边界、v0.6 标准兼容、Host discovery 和后端接线顺序;catalog / SDK / profile 第一刀已落。 | AgentRuntime / ToolRuntime / Desktop Host 分别承接模型、用量、Skills、MCP、浏览器、搜索、媒体、终端、记忆、证据等真实 owner。 | 不新增垂直 App 专用命令,不把 preview 能力 mock 成生产成功。 | ### P14 / P15 / P16 计划更新 上游 Agent App 标准进一步把宿主最低职责收敛成 `Discover → Validate → Project → Check readiness → Authorize → Inject capabilities → Isolate data → Clean up`。P0-P13 已覆盖到 `Check readiness` 与 runtime package loader,缺口集中在 `Authorize` 和 Lab 端到端编排: -| 阶段 | 新定位 | 计划影响 | -|---|---|---| -| P14 | Entry Runtime Guard / Permission Prompt。 | 先把 entry 运行前授权做成硬 gate,合并 readiness、setup、package verification、runtime policy 和用户授权。 | -| P15 | Lab Install / Launch Flow。 | 已串联 install review、verify/cache、installed state、permission prompt、runtime launch 和 cleanup preview。 | -| P15-H | GUI Smoke / Cleanup Rehearsal。 | 已补 Agent App Lab 专用 GUI smoke 与证据输出;全局 GUI smoke 的外部 provider/model 缺口不伪装为 Agent App 失败。 | -| P16 | Agent App Manager / Product Entry Gate。 | 已在实验岛内完成最小 installed app 状态、entry launcher、enable / disable / uninstall preview 和 cleanup evidence;详见 [p16-agent-app-manager-product-entry-gate.md](./p16-agent-app-manager-product-entry-gate.md)。 | -| P16-H | Multi-app repository / lifecycle hardening。 | P16-H.5 已完成最小实现;P17 Gate 已完成审计。 | +| 阶段 | 新定位 | 计划影响 | +| ----- | -------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| P14 | Entry Runtime Guard / Permission Prompt。 | 先把 entry 运行前授权做成硬 gate,合并 readiness、setup、package verification、runtime policy 和用户授权。 | +| P15 | Lab Install / Launch Flow。 | 已串联 install review、verify/cache、installed state、permission prompt、runtime launch 和 cleanup preview。 | +| P15-H | GUI Smoke / Cleanup Rehearsal。 | 已补 Agent App Lab 专用 GUI smoke 与证据输出;全局 GUI smoke 的外部 provider/model 缺口不伪装为 Agent App 失败。 | +| P16 | Agent App Manager / Product Entry Gate。 | 已在实验岛内完成最小 installed app 状态、entry launcher、enable / disable / uninstall preview 和 cleanup evidence;详见 [p16-agent-app-manager-product-entry-gate.md](./p16-agent-app-manager-product-entry-gate.md)。 | +| P16-H | Multi-app repository / lifecycle hardening。 | P16-H.5 已完成最小实现;P17 Gate 已完成审计。 | 这次调整的关键是:P14 是 P15 的前置 gate;P15 / P15-H 已复用 guard,不能为了 GUI smoke、后续演示或正式入口直接调用 loader 或 CapabilityHost。P16 也必须先留在实验岛内验证多 App 生命周期,不能跳到 marketplace 或主导航。 @@ -263,21 +263,21 @@ P17.2.5 不是重复 P5.5:P5.5 只在 Cloud bootstrap 和早期 projection 层 P16-H 不是“把 Manager 做大”,而是把 P16 已证明的单 App 管理闭环压到平台级最小可靠性:多 App 列表、生命周期持久化、清理演练证据和残留检查都必须复用同一套 current 事实源。 -| 顺序 | 交付 | 事实源 / 约束 | 验收 | -|---|---|---|---| -| P16-H.0 | 已完成:计划收口。 | `p16-h-multi-app-repository-lifecycle-hardening.md` 成为 P16-H 详细计划。 | README、implementation plan、P16 文档都指向同一下一刀。 | -| P16-H.1 | 已完成最小实现:Repository-backed multi-app list。 | 只读 P11 `LocalInstalledAgentAppStateRepository`;React state 只做展示缓存。 | Manager 能展示多个 installed app,并可选择当前 App。 | -| P16-H.2 | 已完成最小实现:Selected app launcher + persisted lifecycle。 | launch 继续走 P14 guard;disable / enable 继续走 repository lifecycle API。 | disabled App 不能旁路启动,刷新后状态不丢。 | -| P16-H.3 | 已完成最小实现:Cleanup rehearsal evidence export。 | 复用 P15 uninstall / cleanup preview,不执行真实 delete-data。 | 导出 summary 包含 appId、version、hash、strategy、targets、blockedTargets、timestamp。 | -| P16-H.4 | 已完成最小实现:Residual audit。 | 只检查 Agent App namespace,不扫描 Lime 主业务数据。 | 区分 retained、pending deletion、blocked out-of-scope、repository issue。 | -| P16-H.5 | 已完成最小实现:GUI smoke + flag-off regression。 | 只扩展 Agent App Lab 专用 smoke,不接主导航。 | summary 覆盖 multi-app list、selected app、disable blocker、cleanup evidence、residual audit 与 flag-off。 | -| P17 Gate | 已完成:正式入口前完成度审计。 | 只做审计与缺口登记,不直接发布 marketplace 或主导航。 | Gate checklist 已逐项映射 P16-H 证据与剩余缺口。 | -| P17.0 | 已完成计划收口:Formal Entry Contract。 | 只定义正式 Agent Apps 受控入口契约,不扩 marketplace / Cloud 管理台 / 真实 delete-data。 | 已写清 `agent-apps`、`agent-app-lab` 与 runtime surface 的责任边界、禁区、架构和后续 gates。 | -| P17.1 | 已完成最小实现:Formal route / nav / copy hardening。 | 只硬化正式入口路由、导航、状态文案和测试契约。 | `agent-apps` 不依赖 Lab flag;UI entry 从正式入口进入独立 runtime surface;正式入口文案五语言覆盖。 | -| P17.2 | 已完成最小实现:Source / install contract hardening。 | P17.2.1 / P17.2.2 / P17.2.3 已完成 source state model、install review descriptor 与 registration hardening;P17.2.4a 已完成 Cloud release descriptor / verification gate;P17.2.4b-1 已完成 acquisition seam / verified cache source;P17.2.4b-2 已完成 packageUrl fetch / staging / manifest extraction;P17.2.5 已完成 schema / reference CLI / example package cross-check。详见 [p17-source-install-contract-hardening.md](./p17-source-install-contract-hardening.md)。 | local / cloud 安装已先 review 后 save,registration-active refresh 已有 UI 断言;packageUrl fetch 可生成 verified cache 后再进入 Cloud review;reference CLI cross-check 已覆盖 projection / readiness / review descriptor。 | -| P17.3 | 已完成最小实现:Lifecycle / cleanup contract hardening。 | P17.3.1-P17.3.6 已完成 lifecycle descriptor、formal page lifecycle UI、cleanup namespace classifier、evidence / residual audit、guard integration 与 boundary regression。详见 [p17-lifecycle-cleanup-contract-hardening.md](./p17-lifecycle-cleanup-contract-hardening.md)。 | disabled / cleanup-blocked 不能启动;uninstall 保持 rehearsal-only;evidence 不含 secret value;residual audit 只查 Agent App namespace。 | -| P17.4 | 已完成:Runtime surface production hardening。 | P17.4.1-P17.4.5 已完成 guard-before-start、Host Bridge task contract、structured write-back guard、content factory bootstrap sample 与完整 GUI smoke。详见 [p17-4-host-bridge-runtime.md](./p17-4-host-bridge-runtime.md)。 | code-level tests、typecheck、contracts、feature island boundary 与 `verify:gui-smoke` 均有证据;runtime surface 已支撑 P17.5 formal smoke。 | -| P17.5 | 已完成:Formal entry GUI smoke。 | `smoke:agent-apps` 已覆盖正式 `agent-apps` install / registration / launch / disable / uninstall rehearsal / runtime surface / flag-off。详见 [p17-5-formal-entry-gui-smoke.md](./p17-5-formal-entry-gui-smoke.md)。 | 独立 summary 已输出到 `.lime/qc/gui-evidence/agent-apps/agent-apps-smoke-summary.json`;Lab smoke 仍只作研发辅助证据。 | +| 顺序 | 交付 | 事实源 / 约束 | 验收 | +| -------- | ------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| P16-H.0 | 已完成:计划收口。 | `p16-h-multi-app-repository-lifecycle-hardening.md` 成为 P16-H 详细计划。 | README、implementation plan、P16 文档都指向同一下一刀。 | +| P16-H.1 | 已完成最小实现:Repository-backed multi-app list。 | 只读 P11 `LocalInstalledAgentAppStateRepository`;React state 只做展示缓存。 | Manager 能展示多个 installed app,并可选择当前 App。 | +| P16-H.2 | 已完成最小实现:Selected app launcher + persisted lifecycle。 | launch 继续走 P14 guard;disable / enable 继续走 repository lifecycle API。 | disabled App 不能旁路启动,刷新后状态不丢。 | +| P16-H.3 | 已完成最小实现:Cleanup rehearsal evidence export。 | 复用 P15 uninstall / cleanup preview,不执行真实 delete-data。 | 导出 summary 包含 appId、version、hash、strategy、targets、blockedTargets、timestamp。 | +| P16-H.4 | 已完成最小实现:Residual audit。 | 只检查 Agent App namespace,不扫描 Lime 主业务数据。 | 区分 retained、pending deletion、blocked out-of-scope、repository issue。 | +| P16-H.5 | 已完成最小实现:GUI smoke + flag-off regression。 | 只扩展 Agent App Lab 专用 smoke,不接主导航。 | summary 覆盖 multi-app list、selected app、disable blocker、cleanup evidence、residual audit 与 flag-off。 | +| P17 Gate | 已完成:正式入口前完成度审计。 | 只做审计与缺口登记,不直接发布 marketplace 或主导航。 | Gate checklist 已逐项映射 P16-H 证据与剩余缺口。 | +| P17.0 | 已完成计划收口:Formal Entry Contract。 | 只定义正式 Agent Apps 受控入口契约,不扩 marketplace / Cloud 管理台 / 真实 delete-data。 | 已写清 `agent-apps`、`agent-app-lab` 与 runtime surface 的责任边界、禁区、架构和后续 gates。 | +| P17.1 | 已完成最小实现:Formal route / nav / copy hardening。 | 只硬化正式入口路由、导航、状态文案和测试契约。 | `agent-apps` 不依赖 Lab flag;UI entry 从正式入口进入独立 runtime surface;正式入口文案五语言覆盖。 | +| P17.2 | 已完成最小实现:Source / install contract hardening。 | P17.2.1 / P17.2.2 / P17.2.3 已完成 source state model、install review descriptor 与 registration hardening;P17.2.4a 已完成 Cloud release descriptor / verification gate;P17.2.4b-1 已完成 acquisition seam / verified cache source;P17.2.4b-2 已完成 packageUrl fetch / staging / manifest extraction;P17.2.5 已完成 schema / reference CLI / example package cross-check。详见 [p17-source-install-contract-hardening.md](./p17-source-install-contract-hardening.md)。 | local / cloud 安装已先 review 后 save,registration-active refresh 已有 UI 断言;packageUrl fetch 可生成 verified cache 后再进入 Cloud review;reference CLI cross-check 已覆盖 projection / readiness / review descriptor。 | +| P17.3 | 已完成最小实现:Lifecycle / cleanup contract hardening。 | P17.3.1-P17.3.6 已完成 lifecycle descriptor、formal page lifecycle UI、cleanup namespace classifier、evidence / residual audit、guard integration 与 boundary regression。详见 [p17-lifecycle-cleanup-contract-hardening.md](./p17-lifecycle-cleanup-contract-hardening.md)。 | disabled / cleanup-blocked 不能启动;uninstall 保持 rehearsal-only;evidence 不含 secret value;residual audit 只查 Agent App namespace。 | +| P17.4 | 已完成:Runtime surface production hardening。 | P17.4.1-P17.4.5 已完成 guard-before-start、Host Bridge task contract、structured write-back guard、content factory bootstrap sample 与完整 GUI smoke。详见 [p17-4-host-bridge-runtime.md](./p17-4-host-bridge-runtime.md)。 | code-level tests、typecheck、contracts、feature island boundary 与 `verify:gui-smoke` 均有证据;runtime surface 已支撑 P17.5 formal smoke。 | +| P17.5 | 已完成:Formal entry GUI smoke。 | `smoke:agent-apps` 已覆盖正式 `agent-apps` install / registration / launch / disable / uninstall rehearsal / runtime surface / flag-off。详见 [p17-5-formal-entry-gui-smoke.md](./p17-5-formal-entry-gui-smoke.md)。 | 独立 summary 已输出到 `.lime/qc/gui-evidence/agent-apps/agent-apps-smoke-summary.json`;Lab smoke 仍只作研发辅助证据。 | P17.5 完成只证明正式入口独立 smoke 可交付;marketplace、Workspace pin、命令面板入口、Chat expert entry、真实 delete-data 和 Cloud 管理台仍不进入当前主线。 @@ -461,7 +461,7 @@ src/features/agent-app/ ```ts if (featureFlags.agentAppHost.labEnabled) { - registerAgentAppLabEntry() + registerAgentAppLabEntry(); } ``` @@ -515,18 +515,18 @@ agentAppHost: { } ``` -| 开关 | 启用内容 | 默认 | 失败止血 | -|---|---|---|---| -| `labEnabled` | 显示 Agent App Lab 页面。 | off | UI 入口消失。 | -| `localPackageEnabled` | 读取本地 package / fixture。 | off | 不扫描本地 App。 | -| `projectionEnabled` | 生成 projection。 | off | 不产生派生对象。 | -| `readinessEnabled` | 检查 capability / permission / runtime。 | off | 不做启用判断。 | -| `mockSdkEnabled` | 使用 mock capability host。 | off | 不运行 mock action。 | -| `localStorageEnabled` | 创建 App namespace。 | off | 不写本地实验数据。 | -| `realAdapterEnabled` | 接入少量真实 Lime adapter。 | off | 回退 mock。 | -| `uiRuntimeEnabled` | 加载受控 UI bundle。 | off | 回退只读 projection。 | -| `workerRuntimeEnabled` | 执行受控 workflow DSL,仍不执行 raw worker bundle。 | off | 不跑后台任务。 | -| `cloudBootstrapEnabled` | 消费 LimeCore bootstrap。 | off | 只用本地 fixture。 | +| 开关 | 启用内容 | 默认 | 失败止血 | +| ----------------------- | --------------------------------------------------- | ---- | --------------------- | +| `labEnabled` | 显示 Agent App Lab 页面。 | off | UI 入口消失。 | +| `localPackageEnabled` | 读取本地 package / fixture。 | off | 不扫描本地 App。 | +| `projectionEnabled` | 生成 projection。 | off | 不产生派生对象。 | +| `readinessEnabled` | 检查 capability / permission / runtime。 | off | 不做启用判断。 | +| `mockSdkEnabled` | 使用 mock capability host。 | off | 不运行 mock action。 | +| `localStorageEnabled` | 创建 App namespace。 | off | 不写本地实验数据。 | +| `realAdapterEnabled` | 接入少量真实 Lime adapter。 | off | 回退 mock。 | +| `uiRuntimeEnabled` | 加载受控 UI bundle。 | off | 回退只读 projection。 | +| `workerRuntimeEnabled` | 执行受控 workflow DSL,仍不执行 raw worker bundle。 | off | 不跑后台任务。 | +| `cloudBootstrapEnabled` | 消费 LimeCore bootstrap。 | off | 只用本地 fixture。 | ## 数据边界 @@ -558,16 +558,16 @@ P0 使用独立物理目录,不混入现有主业务表: ```ts type AgentAppProvenance = { - sourceKind: 'agent_app' - appId: string - appVersion: string - packageHash: string - manifestHash: string - entryKey?: string - workflowRunId?: string - workspaceId?: string - taskId?: string -} + sourceKind: "agent_app"; + appId: string; + appVersion: string; + packageHash: string; + manifestHash: string; + entryKey?: string; + workflowRunId?: string; + workspaceId?: string; + taskId?: string; +}; ``` 禁止: @@ -655,16 +655,16 @@ Projection 是安装时生成的只读派生对象,不是全局注册: ```ts type AgentAppProjection = { - app: AppSummary - package: PackageIdentity - entries: ProjectedEntry[] - requiredCapabilities: CapabilityRequirement[] - storage?: StorageProjection - artifacts?: ArtifactProjection[] - policies: PolicyProjection[] - readinessHints: ReadinessHint[] - provenance: AgentAppProvenance -} + app: AppSummary; + package: PackageIdentity; + entries: ProjectedEntry[]; + requiredCapabilities: CapabilityRequirement[]; + storage?: StorageProjection; + artifacts?: ArtifactProjection[]; + policies: PolicyProjection[]; + readinessHints: ReadinessHint[]; + provenance: AgentAppProvenance; +}; ``` 允许: @@ -693,17 +693,17 @@ P0 SDK 先做 facade、错误码、mock host 和 provenance,不绑定内部实 ```ts interface LimeAppSdk { - storage: LimeStorageCapability - files: LimeFilesCapability - agent: LimeAgentCapability - knowledge: LimeKnowledgeCapability - tools: LimeToolsCapability - artifacts: LimeArtifactsCapability - workflow: LimeWorkflowCapability - evidence: LimeEvidenceCapability - policy: LimePolicyCapability - secrets: LimeSecretsCapability - events: LimeEventsCapability + storage: LimeStorageCapability; + files: LimeFilesCapability; + agent: LimeAgentCapability; + knowledge: LimeKnowledgeCapability; + tools: LimeToolsCapability; + artifacts: LimeArtifactsCapability; + workflow: LimeWorkflowCapability; + evidence: LimeEvidenceCapability; + policy: LimePolicyCapability; + secrets: LimeSecretsCapability; + events: LimeEventsCapability; } ``` @@ -739,13 +739,13 @@ P0-P2 不加载 App UI bundle,只用 Lab projection 展示。 P3 才做受控 UI Extension Host: -| 能力 | P3 范围 | 禁止 | -|---|---|---| -| `page` entry | 在受控容器中展示 App 页面。 | 直接注册主路由。 | -| `panel` entry | 在 Lab 中打开侧栏 / 面板。 | 直接改 Workspace layout。 | -| `settings` entry | 展示 App 自己的设置页。 | 修改全局设置结构。 | -| SDK 注入 | Host 注入 scoped handles。 | App import 内部模块。 | -| 权限提示 | UI 解释权限用途。 | 只靠 UI 提示,不做 runtime 拦截。 | +| 能力 | P3 范围 | 禁止 | +| ---------------- | --------------------------- | --------------------------------- | +| `page` entry | 在受控容器中展示 App 页面。 | 直接注册主路由。 | +| `panel` entry | 在 Lab 中打开侧栏 / 面板。 | 直接改 Workspace layout。 | +| `settings` entry | 展示 App 自己的设置页。 | 修改全局设置结构。 | +| SDK 注入 | Host 注入 scoped handles。 | App import 内部模块。 | +| 权限提示 | UI 解释权限用途。 | 只靠 UI 提示,不做 runtime 拦截。 | UI Host 最小要求: @@ -758,15 +758,15 @@ UI Host 最小要求: Worker 是最高风险项,必须晚于 UI Host: -| 阶段 | Runtime 能力 | 是否执行 App 代码 | -|---|---|---| -| P0 | manifest / projection / readiness | 否 | -| P1 | mock entry action | 否,只跑内置 mock handler | -| P2 | thin adapter action | 否,仍由 Lime 内置 runner 调 adapter | -| P3 | 受控 UI bundle | 是,仅 UI 容器 | -| P4.1 | 内容工厂内置 runner | 否,仍由 Lime 内置 demo 编排 | -| P4.2 | 受控 workflow DSL | 否,只执行白名单 SDK step,不执行 raw worker bundle | -| P4.x | 真实 worker sandbox | 是,必须有 policy、cancel、trace、resource limit | +| 阶段 | Runtime 能力 | 是否执行 App 代码 | +| ---- | --------------------------------- | --------------------------------------------------- | +| P0 | manifest / projection / readiness | 否 | +| P1 | mock entry action | 否,只跑内置 mock handler | +| P2 | thin adapter action | 否,仍由 Lime 内置 runner 调 adapter | +| P3 | 受控 UI bundle | 是,仅 UI 容器 | +| P4.1 | 内容工厂内置 runner | 否,仍由 Lime 内置 demo 编排 | +| P4.2 | 受控 workflow DSL | 否,只执行白名单 SDK step,不执行 raw worker bundle | +| P4.x | 真实 worker sandbox | 是,必须有 policy、cancel、trace、resource limit | Worker 禁止项: @@ -802,14 +802,14 @@ P4 最小闭环: 最小业务对象: -| 对象 | 存储位置 | 产物 | -|---|---|---| -| project | App storage namespace | 项目配置。 | -| knowledge binding | App storage + lime.knowledge ref | 三层知识库引用。 | -| content_scenarios | App storage | 内容场景规划表。 | -| content assets | App storage | 文案 / 脚本 / 图片提示词。 | -| content table | Artifact store 或实验 artifact dir | 可导出内容表。 | -| evidence | Evidence store 或实验 evidence dir | 来源、模型、知识版本、App provenance。 | +| 对象 | 存储位置 | 产物 | +| ----------------- | ---------------------------------- | -------------------------------------- | +| project | App storage namespace | 项目配置。 | +| knowledge binding | App storage + lime.knowledge ref | 三层知识库引用。 | +| content_scenarios | App storage | 内容场景规划表。 | +| content assets | App storage | 文案 / 脚本 / 图片提示词。 | +| content table | Artifact store 或实验 artifact dir | 可导出内容表。 | +| evidence | Evidence store 或实验 evidence dir | 来源、模型、知识版本、App provenance。 | 停止条件: @@ -826,29 +826,29 @@ P0 必须同步实现 cleanup plan,不允许“先装上再说”。 ```ts type AppCleanupPlan = { - appId: string - packageHash: string - packageCachePaths: string[] - projectionPaths: string[] - readinessPaths: string[] - storageNamespaces: string[] - artifactRefs: string[] - evidenceRefs: string[] - taskRefs: string[] - secretRefs: string[] - logPaths: string[] - exportPaths: string[] -} + appId: string; + packageHash: string; + packageCachePaths: string[]; + projectionPaths: string[]; + readinessPaths: string[]; + storageNamespaces: string[]; + artifactRefs: string[]; + evidenceRefs: string[]; + taskRefs: string[]; + secretRefs: string[]; + logPaths: string[]; + exportPaths: string[]; +}; ``` 卸载策略: -| 策略 | 行为 | 用途 | -|---|---|---| -| Disable only | 禁用 App,保留 package 和数据。 | 临时止血。 | -| Uninstall keep data | 删除 package / projection / readiness,保留 storage / artifacts。 | 升级失败或重装。 | -| Uninstall delete data | 删除 package、projection、storage、artifacts、evidence、logs。 | 实验失败或用户明确清理。 | -| Export then delete | 先导出 storage / artifacts,再删除本地数据。 | 数据迁移。 | +| 策略 | 行为 | 用途 | +| --------------------- | ----------------------------------------------------------------- | ------------------------ | +| Disable only | 禁用 App,保留 package 和数据。 | 临时止血。 | +| Uninstall keep data | 删除 package / projection / readiness,保留 storage / artifacts。 | 升级失败或重装。 | +| Uninstall delete data | 删除 package、projection、storage、artifacts、evidence、logs。 | 实验失败或用户明确清理。 | +| Export then delete | 先导出 storage / artifacts,再删除本地数据。 | 数据迁移。 | 清理流程: @@ -938,12 +938,12 @@ rg "agent-app|AgentApp|agent_app" src src-tauri docs/roadmap/agentapp 允许接入: -| Capability | Adapter | 原则 | -|---|---|---| -| `lime.artifacts.create` | Artifact 创建 adapter。 | 自动附加 provenance。 | -| `lime.evidence.record` | Evidence 记录 adapter。 | 可按 appId 查询。 | -| `lime.knowledge.search` | Knowledge resolver adapter。 | 只读检索。 | -| `lime.agent.startTask` | 本地 Agent Runtime adapter。 | 可 cancel / trace。 | +| Capability | Adapter | 原则 | +| ----------------------- | ---------------------------- | --------------------- | +| `lime.artifacts.create` | Artifact 创建 adapter。 | 自动附加 provenance。 | +| `lime.evidence.record` | Evidence 记录 adapter。 | 可按 appId 查询。 | +| `lime.knowledge.search` | Knowledge resolver adapter。 | 只读检索。 | +| `lime.agent.startTask` | 本地 Agent Runtime adapter。 | 可 cancel / trace。 | 禁止: @@ -1048,14 +1048,14 @@ P4-R 后 P4.2 current 落地标准: P5 分期以 [p5-cloud-bootstrap.md](./p5-cloud-bootstrap.md) 为准: -| 阶段 | 目标 | 不做什么 | -|---|---|---| -| P5.0 | 标准差距收口:把上游 v0.3 schema、projection、readiness、overlay、lifecycle、typed SDK 变化转成客户端 gap matrix。 | 不改运行代码,不接 Cloud 管理台。 | -| P5.1 | `CloudBootstrapPayload` DTO / parser / validator。 | 不下载 package,不安装,不运行 App。 | -| P5.2 | 统一 Package Source Adapter,把 Cloud release 映射到现有安装链路。 | 不新增第二套 Cloud install flow。 | -| P5.3 | tenant enablement、tool availability、policy defaults 与 local readiness 合并。 | 不允许 Cloud 直接把 App 标记为 ready。 | -| P5.4 | disable、hash mismatch、offline、upgrade、uninstall 回归。 | `enabled=false` 不删除用户数据。 | -| P5.5 | 上游 schema / reference CLI cross-check。 | 不把 reference CLI 输出直接当 Lime runtime 实现。 | +| 阶段 | 目标 | 不做什么 | +| ---- | ------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------- | +| P5.0 | 标准差距收口:把上游 v0.3 schema、projection、readiness、overlay、lifecycle、typed SDK 变化转成客户端 gap matrix。 | 不改运行代码,不接 Cloud 管理台。 | +| P5.1 | `CloudBootstrapPayload` DTO / parser / validator。 | 不下载 package,不安装,不运行 App。 | +| P5.2 | 统一 Package Source Adapter,把 Cloud release 映射到现有安装链路。 | 不新增第二套 Cloud install flow。 | +| P5.3 | tenant enablement、tool availability、policy defaults 与 local readiness 合并。 | 不允许 Cloud 直接把 App 标记为 ready。 | +| P5.4 | disable、hash mismatch、offline、upgrade、uninstall 回归。 | `enabled=false` 不删除用户数据。 | +| P5.5 | 上游 schema / reference CLI cross-check。 | 不把 reference CLI 输出直接当 Lime runtime 实现。 | 验收: @@ -1068,67 +1068,67 @@ P5 分期以 [p5-cloud-bootstrap.md](./p5-cloud-bootstrap.md) 为准: ## 任务拆分 -| 阶段 | 任务 | 主要文件 | 验证 | -|---|---|---|---| -| P0.1 | 建立类型和 feature flag。 | `types.ts`、`featureFlag.ts` | typecheck。 | -| P0.2 | manifest parser / normalizer。 | `manifest/*` | parser unit tests。 | -| P0.3 | projection / readiness。 | `projection/*`、`readiness/*` | projection tests。 | -| P0.4 | Lab 只读 UI。 | `ui/*` | UI test / GUI smoke。 | -| P0.5 | cleanup dry-run。 | `install/cleanupPlan.ts` | cleanup tests。 | -| P1.1 | SDK facade。 | `sdk/LimeAppSdk.ts` | type contract。 | -| P1.2 | Mock host。 | `sdk/MockCapabilityHost.ts` | contract tests。 | -| P1.3 | mock artifact / evidence。 | `sdk/*`、`install/*` | run entry test。 | -| P2.1 | storage / artifact / evidence adapter。 | `adapters/*` | adapter tests。 | -| P2.2 | knowledge / agent adapter。 | `adapters/*` | adapter tests。 | -| P3.1 | UI host。 | `runtime/uiExtensionHost.ts` | sandbox smoke。 | -| P4-R0 | 计划与事实源收口。 | `docs/roadmap/agentapp/*` | `git diff --check` + legacy 关键词审计。 | -| P4-R1 | v0.3 manifest / fixture / readiness rebaseline。 | `types.ts`、`manifest/*`、`fixtures/*`、`projection/*`、`readiness/*` | parser / projection / readiness / feature flag tests。 | -| P4-R2 | 内容工厂 demo rename 和数据对象 rebaseline。 | `runtime/contentFactoryDemo.ts`、`adapters/*`、`sdk/*` | content factory / adapter / SDK tests。 | -| P4-R3 | WorkflowRuntimeHost rebaseline 到 `content_factory_demo`。 | `runtime/workflowRuntimeHost.ts`、`runtime/runtimePolicy.ts`、`runtime/workflowRuntimeCapabilityProfile.ts` | workflow runtime tests。 | -| P4-R4 | Lab UI、五语言 i18n、index export rebaseline。 | `ui/AgentAppLabPage.tsx`、`index.ts`、`src/i18n/resources/*/agent.json` | UI test + i18n tests。 | -| P4-R5 | Gate 恢复与旧 SceneApp 不复活。 | `src/features/agent-app/*`、相关 dangling 引用 | `test:contracts`、`verify:local`、GUI smoke。 | -| P4.1 | 内容工厂切片。 | `fixtures/*`、`ui/*` | end-to-end demo。 | -| P4.2 | 受控 workflow runtime。 | `runtime/workflowRuntimeHost.ts`、`runtime/runtimePolicy.ts` | workflow runtime tests / UI test。 | -| P5.0 | v0.3 标准差距收口。 | `docs/roadmap/agentapp/p5-cloud-bootstrap.md`、标准 gap matrix | `git diff --check` + legacy 关键词审计。 | -| P5.1 | Cloud Bootstrap payload DTO / parser / validator。 | `install/cloudBootstrap.ts`、`types.ts`、`featureFlag.ts` | payload parser / feature flag tests。 | -| P5.2 | Bootstrap payload 统一 package source adapter。 | `install/*`、`manifest/*`、`projection/*` | package identity / install preview / projection tests。 | -| P5.3 | Tenant enablement + local readiness 合并。 | `readiness/*`、`projection/*`、`ui/*` | readiness / projection / UI tests。 | -| P5.4 | hash mismatch / disable / offline / upgrade 回归。 | `install/*`、`runtime/*`、`ui/*` | uninstall / workflow runtime / UI tests。 | -| P5.5 | Schema / reference CLI cross-check。 | `docs/roadmap/agentapp/*`、上游 `agentapp` reference CLI | `agentapp-ref validate/project/readiness` + 差异记录。 | -| P6 | v0.3 projection / readiness schema coverage。 | `types.ts`、`manifest/*`、`projection/*`、`readiness/*`、`fixtures/*` | parser / projection / readiness / schema coverage tests。 | -| P7 | 本地 schema / snapshot gate。 | `schema/schemaGate.ts`、`schema/schemaGate.test.ts`、`index.ts` | schema gate + projection / readiness tests。 | -| P8 | setup resolver / `needs-setup` 语义。 | `types.ts`、`readiness/*`、`ui/*`、五语言 `agent.json` | readiness / schema gate / UI / i18n tests。 | -| P9 | installed setup state store。 | `install/setupStateStore.ts`、`install/cleanupPlan.ts`、`sdk/*`、`adapters/*`、`ui/*` | setup state / readiness / cleanup / UI tests。 | -| P10 | installed app state snapshot 与 in-memory store。 | `install/installedAppState.ts`、`types.ts`、`install/cleanupPlan.ts`、`ui/*`、`index.ts` | installed state / setup state / projection / UI tests。 | -| P11 | Local Persistence Adapter。 | `install/installedAppState.ts` 或同目录 repository 文件、`install/setupStateStore.ts`、`install/cleanupPlan.ts`、`ui/*` | repository contract / persistence / cleanup / typecheck / contracts。 | -| P12 | Package Cache / Verify / Rollback。 | `install/packageCache.ts`、`install/packageIdentity.ts`、`install/cloudBootstrap.ts`、`install/cleanupPlan.ts`、`readiness/*` | package cache / hash verify / rollback / readiness tests。 | -| P13 | Runtime Package Loader / UI Bundle Loader。 | `install/packageCache.ts`、`runtime/*`、`projection/*`、`ui/*` | loader / UI host / policy guard tests。 | -| P14 | Entry Runtime Guard / Permission Prompt。 | `runtime/*`、`readiness/*`、`install/*`、`ui/*` | guard / permission prompt / setup / policy tests。 | -| P15 | Lab Install / Launch Flow。 | `install/labInstallFlow.ts`、`runtime/entryRuntimeGuard.ts`、`ui/*`、五语言 `agent.json` | lab flow / guard / package cache / installed state / UI tests。 | -| P15-H | GUI smoke / cleanup rehearsal hardening。 | `scripts/agent-app-lab-smoke.mjs`、`featureFlag.ts`、`ui/*`、`docs/roadmap/agentapp/p15-h-gui-smoke-cleanup-rehearsal.md` | `smoke:agent-app-lab` / typecheck / boundary audit。 | -| P16 | Agent App Manager / Product Entry Gate。 | `ui/AgentAppManagerPanel.tsx`、`ui/AgentAppLabPage.tsx`、`install/labInstallFlow.ts`、`runtime/entryRuntimeGuard.ts` | manager UI / entry launcher / lifecycle evidence / GUI smoke。 | -| P16-H | Multi-app repository / lifecycle hardening。 | `install/installedAppState.ts`、`ui/AgentAppManagerPanel.tsx`、`ui/AgentAppManagerPanel.test.tsx`、`scripts/agent-app-lab-smoke.mjs`、`p16-h-multi-app-repository-lifecycle-hardening.md` | repository-backed multi-app list / selected app launcher / persistent lifecycle / cleanup evidence export / residual audit。 | -| P17 Gate | 正式入口前 gate audit。 | `p17-formal-entry-gate-audit.md`、P16-H evidence、boundary audit | gate checklist / command contracts / typecheck / GUI evidence。 | -| P17.0 | Formal Entry Contract。 | `p17-formal-entry-contract.md`、README、implementation plan | `agent-apps` / `agent-app-lab` / runtime surface 职责边界清晰。 | -| P17.1 | Formal route / nav / copy hardening。 | `AgentAppsPage.tsx`、`AppPageContent.tsx`、navigation、五语言 `agent.json` | formal route / copy / sidebar / i18n / boundary tests。 | -| P17.2 | Source / install contract hardening。 | `p17-source-install-contract-hardening.md`、`AgentAppsPage.tsx`、`src/lib/api/agentApps.ts`、`install/*`、上游 `agentapp` schema / reference CLI | P17.2.1-P17.2.5 已覆盖 source state / install review / registration / release descriptor / verification gate / acquisition seam / verified cache source / packageUrl fetch / staging / reference CLI cross-check。 | -| P17.3 | Lifecycle / cleanup contract hardening。 | `p17-lifecycle-cleanup-contract-hardening.md`、`install/*`、`ui/AgentAppsPage.tsx`、cleanup evidence / residual audit、overlay / setup / secret binding 边界 | disable / enable、uninstall keep-data、delete-data rehearsal、export-then-delete preview 都复用同一 namespace 和 evidence,不执行真实删除。 | -| P17.4 | Runtime surface production hardening。 | `ui/AgentAppRuntimePage.tsx`、`runtime/*`、`src/lib/api/agentApps.ts`、`src-tauri/src/commands/agent_app_cmd.rs` | 当前 runtime surface 补丁只算预铺;生产路径必须只加载 verified package cache / staging,不依赖 dev resolver,并证明 injected SDK / sandbox / provenance 没有被绕过。 | -| P17.5 | Formal entry GUI smoke。 | `scripts/agent-apps-smoke.mjs`、正式 `agent-apps` route、P17 evidence summary、`p17-5-formal-entry-gui-smoke.md` | 已完成正式入口 smoke,覆盖 install / registration / launch / disable / uninstall rehearsal / runtime surface / flag-off;Lab smoke 只保留研发回归。 | -| P18 | Typed Capability SDK gate。 | `docs/roadmap/agentapp/p18-typed-capability-sdk-gate.md`、`p18-5-content-factory-sdk-regression.md`、`p18-5-3-package-sdk-migration-plan.md`、`p18-completion-audit.md`、`sdk/*`、`runtime/*`、schema / mock / contract tests | 在扩 raw worker 前,把 `lime.ui / storage / agent / knowledge / tools / artifacts / workflow / policy / secrets / evidence` 的 typed host bridge 固定为稳定 SDK 契约,并对齐上游 v0.4 Host Bridge v1;P18.5-S 已补 Host Bridge SDK client,P18.5.3 等外部 package 稳定后执行。 | +| 阶段 | 任务 | 主要文件 | 验证 | +| -------- | ---------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| P0.1 | 建立类型和 feature flag。 | `types.ts`、`featureFlag.ts` | typecheck。 | +| P0.2 | manifest parser / normalizer。 | `manifest/*` | parser unit tests。 | +| P0.3 | projection / readiness。 | `projection/*`、`readiness/*` | projection tests。 | +| P0.4 | Lab 只读 UI。 | `ui/*` | UI test / GUI smoke。 | +| P0.5 | cleanup dry-run。 | `install/cleanupPlan.ts` | cleanup tests。 | +| P1.1 | SDK facade。 | `sdk/LimeAppSdk.ts` | type contract。 | +| P1.2 | Mock host。 | `sdk/MockCapabilityHost.ts` | contract tests。 | +| P1.3 | mock artifact / evidence。 | `sdk/*`、`install/*` | run entry test。 | +| P2.1 | storage / artifact / evidence adapter。 | `adapters/*` | adapter tests。 | +| P2.2 | knowledge / agent adapter。 | `adapters/*` | adapter tests。 | +| P3.1 | UI host。 | `runtime/uiExtensionHost.ts` | sandbox smoke。 | +| P4-R0 | 计划与事实源收口。 | `docs/roadmap/agentapp/*` | `git diff --check` + legacy 关键词审计。 | +| P4-R1 | v0.3 manifest / fixture / readiness rebaseline。 | `types.ts`、`manifest/*`、`fixtures/*`、`projection/*`、`readiness/*` | parser / projection / readiness / feature flag tests。 | +| P4-R2 | 内容工厂 demo rename 和数据对象 rebaseline。 | `runtime/contentFactoryDemo.ts`、`adapters/*`、`sdk/*` | content factory / adapter / SDK tests。 | +| P4-R3 | WorkflowRuntimeHost rebaseline 到 `content_factory_demo`。 | `runtime/workflowRuntimeHost.ts`、`runtime/runtimePolicy.ts`、`runtime/workflowRuntimeCapabilityProfile.ts` | workflow runtime tests。 | +| P4-R4 | Lab UI、五语言 i18n、index export rebaseline。 | `ui/AgentAppLabPage.tsx`、`index.ts`、`src/i18n/resources/*/agent.json` | UI test + i18n tests。 | +| P4-R5 | Gate 恢复与旧 SceneApp 不复活。 | `src/features/agent-app/*`、相关 dangling 引用 | `test:contracts`、`verify:local`、GUI smoke。 | +| P4.1 | 内容工厂切片。 | `fixtures/*`、`ui/*` | end-to-end demo。 | +| P4.2 | 受控 workflow runtime。 | `runtime/workflowRuntimeHost.ts`、`runtime/runtimePolicy.ts` | workflow runtime tests / UI test。 | +| P5.0 | v0.3 标准差距收口。 | `docs/roadmap/agentapp/p5-cloud-bootstrap.md`、标准 gap matrix | `git diff --check` + legacy 关键词审计。 | +| P5.1 | Cloud Bootstrap payload DTO / parser / validator。 | `install/cloudBootstrap.ts`、`types.ts`、`featureFlag.ts` | payload parser / feature flag tests。 | +| P5.2 | Bootstrap payload 统一 package source adapter。 | `install/*`、`manifest/*`、`projection/*` | package identity / install preview / projection tests。 | +| P5.3 | Tenant enablement + local readiness 合并。 | `readiness/*`、`projection/*`、`ui/*` | readiness / projection / UI tests。 | +| P5.4 | hash mismatch / disable / offline / upgrade 回归。 | `install/*`、`runtime/*`、`ui/*` | uninstall / workflow runtime / UI tests。 | +| P5.5 | Schema / reference CLI cross-check。 | `docs/roadmap/agentapp/*`、上游 `agentapp` reference CLI | `agentapp-ref validate/project/readiness` + 差异记录。 | +| P6 | v0.3 projection / readiness schema coverage。 | `types.ts`、`manifest/*`、`projection/*`、`readiness/*`、`fixtures/*` | parser / projection / readiness / schema coverage tests。 | +| P7 | 本地 schema / snapshot gate。 | `schema/schemaGate.ts`、`schema/schemaGate.test.ts`、`index.ts` | schema gate + projection / readiness tests。 | +| P8 | setup resolver / `needs-setup` 语义。 | `types.ts`、`readiness/*`、`ui/*`、五语言 `agent.json` | readiness / schema gate / UI / i18n tests。 | +| P9 | installed setup state store。 | `install/setupStateStore.ts`、`install/cleanupPlan.ts`、`sdk/*`、`adapters/*`、`ui/*` | setup state / readiness / cleanup / UI tests。 | +| P10 | installed app state snapshot 与 in-memory store。 | `install/installedAppState.ts`、`types.ts`、`install/cleanupPlan.ts`、`ui/*`、`index.ts` | installed state / setup state / projection / UI tests。 | +| P11 | Local Persistence Adapter。 | `install/installedAppState.ts` 或同目录 repository 文件、`install/setupStateStore.ts`、`install/cleanupPlan.ts`、`ui/*` | repository contract / persistence / cleanup / typecheck / contracts。 | +| P12 | Package Cache / Verify / Rollback。 | `install/packageCache.ts`、`install/packageIdentity.ts`、`install/cloudBootstrap.ts`、`install/cleanupPlan.ts`、`readiness/*` | package cache / hash verify / rollback / readiness tests。 | +| P13 | Runtime Package Loader / UI Bundle Loader。 | `install/packageCache.ts`、`runtime/*`、`projection/*`、`ui/*` | loader / UI host / policy guard tests。 | +| P14 | Entry Runtime Guard / Permission Prompt。 | `runtime/*`、`readiness/*`、`install/*`、`ui/*` | guard / permission prompt / setup / policy tests。 | +| P15 | Lab Install / Launch Flow。 | `install/labInstallFlow.ts`、`runtime/entryRuntimeGuard.ts`、`ui/*`、五语言 `agent.json` | lab flow / guard / package cache / installed state / UI tests。 | +| P15-H | GUI smoke / cleanup rehearsal hardening。 | `scripts/agent-app-lab-smoke.mjs`、`featureFlag.ts`、`ui/*`、`docs/roadmap/agentapp/p15-h-gui-smoke-cleanup-rehearsal.md` | `smoke:agent-app-lab` / typecheck / boundary audit。 | +| P16 | Agent App Manager / Product Entry Gate。 | `ui/AgentAppManagerPanel.tsx`、`ui/AgentAppLabPage.tsx`、`install/labInstallFlow.ts`、`runtime/entryRuntimeGuard.ts` | manager UI / entry launcher / lifecycle evidence / GUI smoke。 | +| P16-H | Multi-app repository / lifecycle hardening。 | `install/installedAppState.ts`、`ui/AgentAppManagerPanel.tsx`、`ui/AgentAppManagerPanel.test.tsx`、`scripts/agent-app-lab-smoke.mjs`、`p16-h-multi-app-repository-lifecycle-hardening.md` | repository-backed multi-app list / selected app launcher / persistent lifecycle / cleanup evidence export / residual audit。 | +| P17 Gate | 正式入口前 gate audit。 | `p17-formal-entry-gate-audit.md`、P16-H evidence、boundary audit | gate checklist / command contracts / typecheck / GUI evidence。 | +| P17.0 | Formal Entry Contract。 | `p17-formal-entry-contract.md`、README、implementation plan | `agent-apps` / `agent-app-lab` / runtime surface 职责边界清晰。 | +| P17.1 | Formal route / nav / copy hardening。 | `AgentAppsPage.tsx`、`AppPageContent.tsx`、navigation、五语言 `agent.json` | formal route / copy / sidebar / i18n / boundary tests。 | +| P17.2 | Source / install contract hardening。 | `p17-source-install-contract-hardening.md`、`AgentAppsPage.tsx`、`src/lib/api/agentApps.ts`、`install/*`、上游 `agentapp` schema / reference CLI | P17.2.1-P17.2.5 已覆盖 source state / install review / registration / release descriptor / verification gate / acquisition seam / verified cache source / packageUrl fetch / staging / reference CLI cross-check。 | +| P17.3 | Lifecycle / cleanup contract hardening。 | `p17-lifecycle-cleanup-contract-hardening.md`、`install/*`、`ui/AgentAppsPage.tsx`、cleanup evidence / residual audit、overlay / setup / secret binding 边界 | disable / enable、uninstall keep-data、delete-data rehearsal、export-then-delete preview 都复用同一 namespace 和 evidence,不执行真实删除。 | +| P17.4 | Runtime surface production hardening。 | `ui/AgentAppRuntimePage.tsx`、`runtime/*`、`src/lib/api/agentApps.ts`、`src-tauri/src/commands/agent_app_cmd.rs` | 当前 runtime surface 补丁只算预铺;生产路径必须只加载 verified package cache / staging,不依赖 dev resolver,并证明 injected SDK / sandbox / provenance 没有被绕过。 | +| P17.5 | Formal entry GUI smoke。 | `scripts/agent-apps-smoke.mjs`、正式 `agent-apps` route、P17 evidence summary、`p17-5-formal-entry-gui-smoke.md` | 已完成正式入口 smoke,覆盖 install / registration / launch / disable / uninstall rehearsal / runtime surface / flag-off;Lab smoke 只保留研发回归。 | +| P18 | Typed Capability SDK gate。 | `docs/roadmap/agentapp/p18-typed-capability-sdk-gate.md`、`p18-7-full-lime-capability-surface.md`、`p18-5-content-factory-sdk-regression.md`、`p18-5-3-package-sdk-migration-plan.md`、`p18-completion-audit.md`、`sdk/*`、`runtime/*`、schema / mock / contract tests | 在扩 raw worker 前,把全量 `lime.*` capability catalog、typed host bridge、profile、SDK facade 和 Host discovery 固定为稳定契约,并对齐上游 v0.6 reference sample;P18.7-B v0.6 reference cross-check 已复绿,P18.7-C 再接 `lime.capabilities`。 | ## 验证策略 最低验证层级: -| 改动 | 必跑 | -|---|---| -| 类型 / parser / projection | 定向单测 + `npm run typecheck` 或项目等价命令。 | -| Capability SDK | contract tests。 | -| Tauri command | `npm run test:contracts`。 | -| 用户可见 UI | 相关 `*.test.tsx` + GUI smoke。 | -| Agent App Lab GUI | `npm run smoke:agent-app-lab -- --timeout-ms 180000` + evidence summary。 | -| 主路径接入 | `npm run verify:local` + `npm run verify:gui-smoke`。 | +| 改动 | 必跑 | +| -------------------------- | ------------------------------------------------------------------------- | +| 类型 / parser / projection | 定向单测 + `npm run typecheck` 或项目等价命令。 | +| Capability SDK | contract tests。 | +| Tauri command | `npm run test:contracts`。 | +| 用户可见 UI | 相关 `*.test.tsx` + GUI smoke。 | +| Agent App Lab GUI | `npm run smoke:agent-app-lab -- --timeout-ms 180000` + evidence summary。 | +| 主路径接入 | `npm run verify:local` + `npm run verify:gui-smoke`。 | 进入正式主路径前必须满足: @@ -1180,30 +1180,30 @@ P5 分期以 [p5-cloud-bootstrap.md](./p5-cloud-bootstrap.md) 为准: ## 决策点 -| 决策点 | 时间 | 判断问题 | Go 条件 | No-Go 动作 | -|---|---|---|---|---| -| D0 | P0 结束 | Manifest / projection 是否稳定? | fixture 可稳定 project。 | 停止 runtime 设计,收缩标准。 | -| D1 | P1 结束 | SDK facade 是否足够表达业务? | mock entry 生成可追溯产物。 | 重写 SDK,不接真实 adapter。 | -| D2 | P2 结束 | Adapter 是否足够薄? | 删除 adapter 不影响主路径。 | 回退 mock,重画边界。 | -| D3 | P3 结束 | UI Host 是否安全可控? | App UI 不能越权访问资源。 | 暂停 UI bundle,保留 projection UI。 | -| D4 | P4 结束 | Product-level App 是否有平台价值? | 内容工厂闭环跑通。 | 清理实验岛,不进入正式入口。 | -| D5 | P5 结束 | Cloud bootstrap 是否只是控制面? | 断网已安装 App 可用。 | Cloud 回退为 catalog-only。 | -| D6 | P14 结束 | Entry 授权 gate 是否可靠? | allow / needs-setup / blocked / denied 均有测试,Lab run / mount 不能绕过 guard。 | 暂停 P15,不继续串启动闭环。 | -| D7 | P15 结束 | Lab 安装启动闭环是否可演示且可清理? | install review、permission prompt、launch、cleanup preview 连续可用。 | 保持 Lab-only,不进入正式入口。 | -| D8 | P15-H 结束 | GUI 证据是否足够支撑进入 App 管理面? | Agent App Lab 专用 smoke 通过,summary / 截图 / cleanup preview 可复核。 | 不做 P16 App Manager,先补 GUI 或 cleanup hardening。 | -| D9 | P16 结束 | 最小 Manager 是否足够支撑多 App hardening? | 单 App Manager、entry launcher、disable / uninstall preview、cleanup evidence 与 Agent App Lab smoke 通过。 | 继续补 P16 UI / guard / evidence,不进入多 App。 | -| D10 | P16-H 结束 | 是否可以从 Lab 进入正式 Agent Apps 入口? | 多 App repository、持久化 lifecycle、cleanup evidence 导出、残留检查和 flag-off 回归稳定。 | 继续实验岛,不接正式主导航。 | -| D11 | P17.0 结束 | 正式入口契约是否足够约束后续实现? | `agent-apps` / `agent-app-lab` / runtime surface 职责、禁区、架构和后续 gates 已写清。 | 不进入代码 hardening,先补契约和验收口径。 | -| D12 | P17.1 结束 | 正式入口是否已脱离 Lab 心智? | `agent-apps` route / nav / copy 独立;UI entry 进入 `agent-app` runtime surface;Lab flag-off 不影响正式入口。 | 不进入 source hardening,先修正式入口导航和文案。 | -| D13 | P17.2 结束 | Source / install 契约是否足够支撑真实安装入口? | local / cloud / registration 均有 source state 和 install review;Cloud release 已 pin 到 package / manifest hash;写 repository 前完成 verify;cached fallback 不伪装新安装;上游 schema / reference CLI cross-check 通过;Lab fixture 不作为正式 source。 | 不进入 lifecycle / cleanup hardening,先补 source / install review。 | -| D14 | P17.3 结束 | Lifecycle / cleanup 是否足够支撑可失败退出? | disable / enable、uninstall rehearsal、export-then-delete preview、residual audit、overlay / setup / secret binding 分离均可复核;仍不执行真实 delete-data。 | 不进入 runtime production hardening,先补生命周期和清理证据。 | -| D15 | P17.4 结束 | Runtime surface 是否真正只运行 verified package 且只走标准 Host Bridge? | `cloud_release` 只从 verified cache / staging 启动;dev resolver 仅开发态可用;disabled / unverified package 无法启动;`lime.agentApp.bridge` 覆盖主题、导航、toast、download、capability invoke;SDK bridge / sandbox / provenance 有测试。 | 保持 runtime surface 为预铺,不发布 formal runtime。 | -| D16 | P17.5 结束 | 正式入口 smoke 是否能替代 Lab 证据? | `agent-apps` 专用 smoke 覆盖 install、registration、launch、disable、uninstall rehearsal、runtime surface、flag-off,并输出独立 summary。 | Lab smoke 继续只作研发证据,不把正式入口标为可交付。 | -| D17 | P18 结束 | Typed Capability SDK 是否足够支撑更多 App? | Host Bridge v1、typed SDK facade、stable error、mock host、contract tests、App-scoped Agent task、Lime-side content factory regression、外部 content-factory package verify、dist 同步和 full `verify:local` 均有证据;feature island 仍无 direct invoke / raw Worker 越界。 | 已满足;下一步只做 owner handoff / 提交边界,不进入 raw worker、marketplace 或更多垂直 App。 | +| 决策点 | 时间 | 判断问题 | Go 条件 | No-Go 动作 | +| ------ | ---------- | ------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| D0 | P0 结束 | Manifest / projection 是否稳定? | fixture 可稳定 project。 | 停止 runtime 设计,收缩标准。 | +| D1 | P1 结束 | SDK facade 是否足够表达业务? | mock entry 生成可追溯产物。 | 重写 SDK,不接真实 adapter。 | +| D2 | P2 结束 | Adapter 是否足够薄? | 删除 adapter 不影响主路径。 | 回退 mock,重画边界。 | +| D3 | P3 结束 | UI Host 是否安全可控? | App UI 不能越权访问资源。 | 暂停 UI bundle,保留 projection UI。 | +| D4 | P4 结束 | Product-level App 是否有平台价值? | 内容工厂闭环跑通。 | 清理实验岛,不进入正式入口。 | +| D5 | P5 结束 | Cloud bootstrap 是否只是控制面? | 断网已安装 App 可用。 | Cloud 回退为 catalog-only。 | +| D6 | P14 结束 | Entry 授权 gate 是否可靠? | allow / needs-setup / blocked / denied 均有测试,Lab run / mount 不能绕过 guard。 | 暂停 P15,不继续串启动闭环。 | +| D7 | P15 结束 | Lab 安装启动闭环是否可演示且可清理? | install review、permission prompt、launch、cleanup preview 连续可用。 | 保持 Lab-only,不进入正式入口。 | +| D8 | P15-H 结束 | GUI 证据是否足够支撑进入 App 管理面? | Agent App Lab 专用 smoke 通过,summary / 截图 / cleanup preview 可复核。 | 不做 P16 App Manager,先补 GUI 或 cleanup hardening。 | +| D9 | P16 结束 | 最小 Manager 是否足够支撑多 App hardening? | 单 App Manager、entry launcher、disable / uninstall preview、cleanup evidence 与 Agent App Lab smoke 通过。 | 继续补 P16 UI / guard / evidence,不进入多 App。 | +| D10 | P16-H 结束 | 是否可以从 Lab 进入正式 Agent Apps 入口? | 多 App repository、持久化 lifecycle、cleanup evidence 导出、残留检查和 flag-off 回归稳定。 | 继续实验岛,不接正式主导航。 | +| D11 | P17.0 结束 | 正式入口契约是否足够约束后续实现? | `agent-apps` / `agent-app-lab` / runtime surface 职责、禁区、架构和后续 gates 已写清。 | 不进入代码 hardening,先补契约和验收口径。 | +| D12 | P17.1 结束 | 正式入口是否已脱离 Lab 心智? | `agent-apps` route / nav / copy 独立;UI entry 进入 `agent-app` runtime surface;Lab flag-off 不影响正式入口。 | 不进入 source hardening,先修正式入口导航和文案。 | +| D13 | P17.2 结束 | Source / install 契约是否足够支撑真实安装入口? | local / cloud / registration 均有 source state 和 install review;Cloud release 已 pin 到 package / manifest hash;写 repository 前完成 verify;cached fallback 不伪装新安装;上游 schema / reference CLI cross-check 通过;Lab fixture 不作为正式 source。 | 不进入 lifecycle / cleanup hardening,先补 source / install review。 | +| D14 | P17.3 结束 | Lifecycle / cleanup 是否足够支撑可失败退出? | disable / enable、uninstall rehearsal、export-then-delete preview、residual audit、overlay / setup / secret binding 分离均可复核;仍不执行真实 delete-data。 | 不进入 runtime production hardening,先补生命周期和清理证据。 | +| D15 | P17.4 结束 | Runtime surface 是否真正只运行 verified package 且只走标准 Host Bridge? | `cloud_release` 只从 verified cache / staging 启动;dev resolver 仅开发态可用;disabled / unverified package 无法启动;`lime.agentApp.bridge` 覆盖主题、导航、toast、download、capability invoke;SDK bridge / sandbox / provenance 有测试。 | 保持 runtime surface 为预铺,不发布 formal runtime。 | +| D16 | P17.5 结束 | 正式入口 smoke 是否能替代 Lab 证据? | `agent-apps` 专用 smoke 覆盖 install、registration、launch、disable、uninstall rehearsal、runtime surface、flag-off,并输出独立 summary。 | Lab smoke 继续只作研发证据,不把正式入口标为可交付。 | +| D17 | P18 结束 | Typed Capability SDK 是否足够支撑更多 App? | Host Bridge v1、typed SDK facade、stable error、mock host、contract tests、App-scoped Agent task、Lime-side content factory regression、外部 content-factory package verify、dist 同步已有证据;P18.7 已补全全量 capability catalog 并修复上游 v0.6 reference 兼容缺口。 | 未完全满足;P18.7-B 标准 v0.6 兼容已完成,下一步进入 P18.7-C Host capability discovery,不进入 raw worker、marketplace 或更多垂直 App。 | ## 下一刀 -路线图已升级为 Agent App v0.4 Host Bridge 对齐 / 内容工厂,且 P4-R rebaseline、P5.0-P5.5 Cloud Bootstrap、P6 schema coverage、P7 schema gate、P8 setup resolver、P9 setup state store、P10 installed state snapshot、P11 local persistence adapter、P12 package cache / verify / rollback、P13 runtime package loader、P14 entry runtime guard、P15 lab install / launch flow、P15-H Agent App Lab 专用 GUI smoke、P16 Agent App Manager、P16-H multi-app lifecycle hardening、P17 Gate 审计、P17.0、P17.1、P17.2.1-P17.2.5、P17.3、P17.4.1-P17.4.5、P17.5 formal entry GUI smoke、P18.1 Typed SDK facade / mock host / stable error、P18.2 Host Bridge typed router / stable error response、P18.3 Core capability adapters、P18.4 App-scoped Agent task SDK facade、P18.4-H AgentRuntime handoff gate、P18.5.1 Lime-side 内容工厂 SDK regression、P18.5.2 package-side read-only tests、P18.5-S Host Bridge SDK client、P18.5.3 package-side SDK facade / verify / dist 同步与 P18.6 Raw Worker 前 Gate 已通过定向 / 专用验证 / GUI smoke / 计划收口。当前主线下一刀只做 owner handoff / 提交边界;不新增 marketplace、Cloud 管理台、真实 delete-data 或完整行业内容系统。 +路线图已升级为 Agent App v0.4 Host Bridge 对齐 / 内容工厂,且 P4-R rebaseline、P5.0-P5.5 Cloud Bootstrap、P6 schema coverage、P7 schema gate、P8 setup resolver、P9 setup state store、P10 installed state snapshot、P11 local persistence adapter、P12 package cache / verify / rollback、P13 runtime package loader、P14 entry runtime guard、P15 lab install / launch flow、P15-H Agent App Lab 专用 GUI smoke、P16 Agent App Manager、P16-H multi-app lifecycle hardening、P17 Gate 审计、P17.0、P17.1、P17.2.1-P17.2.5、P17.3、P17.4.1-P17.4.5、P17.5 formal entry GUI smoke、P18.1 Typed SDK facade / mock host / stable error、P18.2 Host Bridge typed router / stable error response、P18.3 Core capability adapters、P18.4 App-scoped Agent task SDK facade、P18.4-H AgentRuntime handoff gate、P18.5.1 Lime-side 内容工厂 SDK regression、P18.5.2 package-side read-only tests、P18.5-S Host Bridge SDK client、P18.5.3 package-side SDK facade / verify / dist 同步与 P18.6 Raw Worker 前 Gate 已通过定向 / 专用验证 / GUI smoke / 计划收口。当前主线下一刀是 P18.7-C Host capability discovery;P18.7-B 标准 v0.6 兼容与 reference cross-check 已复绿。不新增 marketplace、Cloud 管理台、真实 delete-data 或完整行业内容系统。 ```text P4-R0~P4-R5 v0.3 / 内容工厂 Rebaseline(已通过) @@ -1253,8 +1253,11 @@ P4-R0~P4-R5 v0.3 / 内容工厂 Rebaseline(已通过) → P18.4-H AgentRuntime handoff gate(已完成) → P18.5.1 Lime-side 内容工厂 SDK regression(已完成) → P18.5.2 Package-side read-only tests(已完成) +→ P18.5.3 Package-side SDK facade / verify(已完成) → P18.6 Raw Worker 前 Gate(已完成) -→ P18.5.3 Package-side SDK facade / verify(下一刀) +→ P18.7-A Full Lime capability catalog / SDK / profile(已完成第一刀) +→ P18.7-B Agent App v0.6 reference compatibility(已完成) +→ P18.7-C Host capability discovery(下一刀) ``` P14 文件边界: diff --git a/docs/roadmap/agentapp/lime-capability-surface.md b/docs/roadmap/agentapp/lime-capability-surface.md new file mode 100644 index 000000000..d1ea7368e --- /dev/null +++ b/docs/roadmap/agentapp/lime-capability-surface.md @@ -0,0 +1,98 @@ +# Lime Agent App 开放能力清单 + +更新时间:2026-05-16 + +## 概述 + +Lime 主 App 通过统一的 `lime.* capability surface` 向 Agent App 开放平台能力。本文档梳理所有开放能力,分门别类列出现状和用途。 + +## 能力分组 + +### 1. 应用表面 (app_surface) + +| 能力 | 版本 | 阶段 | Owner | 方法 | 说明 | +|------|------|------|-------|------|------| +| `lime.ui` | 0.3.0 | current | desktop_host | toast, navigate, openExternal, download, getSnapshot | 桌面壳的提示、导航、下载、主题和快照能力 | +| `lime.events` | 0.3.0 | preview | desktop_host | emit, subscribe, unsubscribe, listSubscriptions | App UI、worker、Host 和 runtime 间的事件通道 | +| `lime.workspace` | 0.3.0 | preview | desktop_host | getCurrent, list, open, getPathRef | 当前 workspace、路径引用、打开入口和工作区上下文 | + +### 2. 数据 (data) + +| 能力 | 版本 | 阶段 | Owner | 方法 | 说明 | +|------|------|------|-------|------|------| +| `lime.storage` | 0.3.0 | current | desktop_host | get, set, list, delete | App namespace 下的结构化业务状态和轻量数据存储 | +| `lime.files` | 0.3.0 | current | desktop_host | pick, readRef, parse | 用户授权文件、file ref 读取和基础解析入口 | +| `lime.knowledge` | 0.3.0 | current | knowledge_runtime | search, bindStatus, bind, export | 项目知识、App knowledge binding、检索和版本导出 | +| `lime.artifacts` | 0.3.0 | current | artifact_runtime | create, open, export, list | 产物创建、打开、导出、列表和 provenance | +| `lime.documents` | 0.3.0 | preview | tool_runtime | parse, export, transform, summarize | PDF、Word、Markdown、PPT 等文档解析、转换和导出 | + +### 3. Agent 运行时 (agent_runtime) + +| 能力 | 版本 | 阶段 | Owner | 方法 | 说明 | +|------|------|------|-------|------|------| +| `lime.agent` | 0.3.0 | current | agent_runtime | startTask, streamTask, getTask, cancelTask, retryTask, submitHostResponse, listTasks | App-scoped Agent task、流式过程、追问、确认、取消和重试 | +| `lime.workflow` | 0.3.0 | current | agent_runtime | start, checkpoint, awaitHuman | App workflow、checkpoint、后台任务和人类确认 | +| `lime.models` | 0.3.0 | preview | agent_runtime | list, select, getRouting, estimateCost | 模型列表、模型路由、能力约束和预估成本 | +| `lime.memory` | 0.3.0 | preview | agent_runtime | query, write, compact, getStatus | 工作记忆、长期记忆、团队记忆和上下文压缩 | +| `lime.skills` | 0.3.0 | preview | agent_runtime | list, resolve, bind, invoke, getInvocation | Skill 注册、发现、绑定、启用状态和调用过程 | +| `lime.context` | 0.3.0 | preview | agent_runtime | getSnapshot, attach, detach | 会话上下文、选中资源、当前任务和可附加上下文 | +| `lime.automation` | 0.3.0 | preview | agent_runtime | startJob, getJob, cancelJob | 自动化 job、周期任务和服务型 Skill 编排 | + +### 4. 治理 (governance) + +| 能力 | 版本 | 阶段 | Owner | 方法 | 说明 | +|------|------|------|-------|------|------| +| `lime.policy` | 0.3.0 | current | policy_runtime | check, requestPermission | 权限、风险、成本、数据和企业策略检查 | +| `lime.secrets` | 0.3.0 | current | policy_runtime | getRef, requestBinding | OAuth、API key、外部平台凭证和 secret ref | +| `lime.capabilities` | 0.3.0 | preview | desktop_host | list, get, getProfile | Host capability catalog、版本、可用性和 readiness 摘要 | +| `lime.settings` | 0.3.0 | preview | desktop_host | get, set, list | App 可见设置、workspace overlay 和 tenant 默认值 | +| `lime.review` | 0.3.0 | preview | policy_runtime | requestDecision, submitDecision, listPending | 人工审核、风险确认、发布门禁和决策记录 | + +### 5. 集成 (integration) + +| 能力 | 版本 | 阶段 | Owner | 方法 | 说明 | +|------|------|------|-------|------|------| +| `lime.tools` | 0.3.0 | current | tool_runtime | invoke, getProgress | Tool Broker / ToolHub 的受控工具调用与长任务状态 | +| `lime.mcp` | 0.3.0 | preview | tool_runtime | listServers, listTools, invoke | MCP server、tool inventory 和受控调用 | +| `lime.browser` | 0.3.0 | preview | tool_runtime | open, navigate, extract, screenshot, close | 浏览器自动化、网页读取、截图和会话隔离 | +| `lime.search` | 0.3.0 | preview | tool_runtime | query, deepResearch, getRun | 网页搜索、深度研究、来源和运行状态 | +| `lime.media` | 0.3.0 | preview | tool_runtime | generateImage, editImage, transcribe, synthesizeVoice | 图片、音频、语音、视频素材的生成和处理 | +| `lime.terminal` | 0.3.0 | preview | tool_runtime | run, getRun, cancel | 命令执行、日志、取消、sandbox 和审批 | +| `lime.connectors` | 0.3.0 | preview | cloud_overlay | list, requestAuth, getStatus, invoke | 外部系统连接器、授权状态和受控集成调用 | + +### 6. 可观测性 (observability) + +| 能力 | 版本 | 阶段 | Owner | 方法 | 说明 | +|------|------|------|-------|------|------| +| `lime.evidence` | 0.3.0 | current | artifact_runtime | record, linkArtifact, list | 来源、引用、工具调用、评估和发布证据 | +| `lime.usage` | 0.3.0 | preview | agent_runtime | getTokenUsage, getCostSummary, getBudget | Token、费用、预算、任务和 App 级用量归因 | +| `lime.tasks` | 0.3.0 | preview | agent_runtime | list, get, cancel, subscribe | 跨 App / runtime 的后台任务、队列、状态和订阅 | + +## 阶段说明 + +- **current**: 已实现,可用于生产 +- **preview**: 部分实现,可用于测试和预览 +- **planned**: 计划中,尚未实现 + +## Owner 说明 + +- **desktop_host**: Lime 桌面应用宿主 +- **agent_runtime**: Agent 运行时 +- **tool_runtime**: 工具运行时 +- **knowledge_runtime**: 知识运行时 +- **artifact_runtime**: 产物运行时 +- **policy_runtime**: 策略运行时 +- **cloud_overlay**: 云覆盖层 + +## 统计 + +- **总能力数**: 27 +- **current 阶段**: 11 +- **preview 阶段**: 16 +- **planned 阶段**: 0 + +## 相关文档 + +- [P18.7 Full Lime Capability Surface](./p18-7-full-lime-capability-surface.md) - 全量能力分层和接线计划 +- [Capability SDK](./capability-sdk.md) - 客户端 Capability SDK 方案 +- [Agent App 路线图](./README.md) - Agent App 整体路线图 diff --git a/docs/roadmap/agentapp/p18-7-full-lime-capability-surface.md b/docs/roadmap/agentapp/p18-7-full-lime-capability-surface.md new file mode 100644 index 000000000..22cfdb262 --- /dev/null +++ b/docs/roadmap/agentapp/p18-7-full-lime-capability-surface.md @@ -0,0 +1,208 @@ +# P18.7 Full Lime Capability Surface + +更新时间:2026-05-16 + +状态:P18.7-A 到 P18.7-E Host first-cut 已落地;标准 v0.6 分层 manifest / `app.runtime.yaml` 兼容、capability discovery、AgentRuntime resource projection、Tool / Integration 受控 intent、内容工厂 Host iframe profile 消费和完整 GUI smoke 已有当前证据。P18.7-F 的最小真实按钮 E2E 已通过:内容工厂 iframe 内“整理知识库”会进入 `lime.agent.startTask`、拿到 Host task id(taskIdSource=hostTaskRunRecord)、启动 models/usage/skills 运行事实拉取和 `lime.agent.streamTask` 订阅;仍需继续推进完成态 artifact / evidence / workspace patch 与逐页面长链路 E2E。 + +## 一句话目标 + +把 Lime 主 App 的所有可复用功能抽象成统一 `lime.* capability surface`,让内容工厂和后续业务 App 在自己的业务工作台内完成完整 AI Agent 流程,同时继续复用 Lime 的 AgentRuntime、Claw 能力、模型路由、Skills、Tools、MCP、浏览器、搜索、媒体、终端、记忆、凭证、策略、Token 用量、Artifact 和 Evidence。 + +这不是给内容工厂补更多专用 API,而是把 Lime 作为 Agent OS 的能力边界固定下来。 + +## 背景判断 + +用户提出的核心问题是:Lime 本身是 Chat UI 形态的 AI Agent 工具,但业务流程很难都塞进 Chat;因此需要在 Lime 之上运行多个业务 App。App 可以很多个,但重点不是“多 App”,而是: + +1. 用户应停留在业务 App 的页面、表单、看板和 workflow 内完成工作。 +2. App 仍必须获得完整 Lime AI Agent 能力,否则独立 Web / 独立 App 更合理。 +3. 完整能力不能等同于模型 API,也不能复制 Claw UI 或 `*_skill_launch` 到每个 App。 +4. Lime 主 App 必须把底层能力封装成稳定 capability,App 只消费这些 capability。 +5. Lime Experts 保持对话优先的专家模块定位;Agent App 可以包含 `expert-chat` 入口,但完整业务流程必须落在可安装业务工作台内。 + +新增硬验收:凡是业务 App 页面触发“整理、生成、分析、推荐、复核、导出结论”任一 AI 动作,页面必须真实进入 Lime AgentRuntime,并展示业务化的 AI 运行现场。运行现场至少包含思考、执行、流式输出、Skill、工具、模型、Token、费用、artifact 和 evidence;完成后可以默认折叠,但过程不能在数据层消失。做不到这一点的页面,不算完整 Agent App,要重新设计或下线该 AI 动作。 + +## 固定边界 + +| 责任 | Lime 主 App / AgentRuntime 做 | 业务 App 做 | 禁止 | +| ---------------------------------- | ------------------------------------------------------------------------------ | --------------------------------------------------- | ---------------------------------------------- | +| 业务形态 | 提供 UI Host、主题、导航、权限、运行过程投影。 | 定义业务页面、业务对象、流程阶段、人工确认。 | 把所有业务页面塞回通用 Chat。 | +| Agent 执行 | 托管 session/thread/turn/task、队列、模型、上下文、工具、证据。 | 组装 task input、expected output、write-back 目标。 | App 自建第二套 Agent task runtime。 | +| Claw 能力复用 | 把 `@配图 / @搜索 / @研报 / @PDF / @PPT / @浏览器` 等注册成可复用 capability。 | 声明需要的 capability hint 并消费结构化结果。 | 复制 Claw UI、prompt 分支或 Rust launch 实现。 | +| 模型 / Token / 成本 | 统一模型事实源、provider 能力、路由、usage telemetry、预算策略。 | 展示业务任务成本和降级提示。 | App 直接保存 provider key 或自己统计 token。 | +| Skills / Tools / MCP | 管 catalog、workspace binding、runtime gate、Tool Broker、MCP bridge。 | 声明必需 Skill / Tool / MCP capability。 | App 直连 MCP server 或直接运行底层 tool。 | +| 文件 / 搜索 / 浏览器 / 媒体 / 终端 | 管授权、sandbox、执行、审计、artifact/evidence。 | 提供业务 brief、筛选规则、结果落点。 | App 绕过 policy 调文件系统、浏览器、shell。 | +| Storage / Artifact / Evidence | namespace 隔离、持久化、导出、provenance。 | 定义业务 schema、产物类型、证据类型。 | 聊天文本成为唯一结果或证据。 | +| Policy / Secrets / Review | 统一权限、secret ref、企业策略、人工审核。 | 解释用途并处理拒绝 / 重试。 | App 读取 secret 明文或自行做审批绕过。 | + +## 单一事实源 + +| 事实 | current source | 要求 | +| ---------------------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------ | +| capability 名称、分组、owner、阶段 | `src/features/agent-app/sdk/capabilityCatalog.ts` | 新增 `lime.*` 只能先改 catalog。 | +| typed invoke contract | `src/features/agent-app/sdk/capabilityContract.ts` | 每个 capability 必须有 typed method,未接线也返回 stable error。 | +| SDK facade | `src/features/agent-app/sdk/capabilityAdapters.ts` | `createLimeCoreCapabilityAdapters()` 从 catalog 生成全部 adapter。 | +| readiness profile | `src/features/agent-app/readiness/hostCapabilityProfile.ts` | profile 覆盖全部能力,未实现默认 `none`。 | +| mock / adapter profile | `mockCapabilityProfile.ts`、`adapterCapabilityProfile.ts` | 只从 catalog 派生,不再维护平行数组。 | +| runtime execution | AgentRuntime / ToolRuntime / Desktop Host | 业务 App 不拥有执行事实源。 | +| 产品解释 | 本文、`capability-sdk.md`、`app-surface-runtime.md` | 文档必须先说明边界,再做代码接线。 | + +## 全量能力分层 + +| 层 | Capability | 当前阶段 | 后端 owner | 说明 | +| ---------------- | --------------------------------------------------------------------------------------------------------- | ----------------- | --------------------------------------------- | ----------------------------------------------- | +| Host / UI | `lime.ui`、`lime.events`、`lime.workspace` | current / preview | Desktop Host | 主题、导航、事件、workspace 上下文。 | +| App data | `lime.storage`、`lime.files`、`lime.knowledge`、`lime.artifacts`、`lime.documents` | current / preview | Desktop / Knowledge / Artifact / Tool Runtime | 业务数据、文件、知识、产物、文档解析导出。 | +| Agent runtime | `lime.agent`、`lime.workflow`、`lime.automation`、`lime.tasks` | current / preview | AgentRuntime | App-scoped task、workflow、后台 job、任务中心。 | +| Agent resources | `lime.models`、`lime.usage`、`lime.memory`、`lime.skills`、`lime.context` | preview | AgentRuntime | 模型路由、成本、记忆、Skill、上下文。 | +| Tool integration | `lime.tools`、`lime.mcp`、`lime.browser`、`lime.search`、`lime.media`、`lime.terminal`、`lime.connectors` | current / preview | ToolRuntime / Cloud Overlay | 工具、MCP、浏览器、搜索、媒体、终端、外部连接。 | +| Governance | `lime.policy`、`lime.secrets`、`lime.settings`、`lime.review`、`lime.capabilities` | current / preview | Policy / Desktop Host | 权限、凭证、配置、审核、能力发现。 | +| Evidence | `lime.evidence` | current | Artifact / Evidence Runtime | 来源、评估、发布证据和可审计链路。 | + +## 分期计划 + +### P18.7-A:Catalog / SDK / Profile 收敛 + +状态:第一刀已完成。 + +完成标准: + +1. `LIME_CAPABILITY_NAMES` 从 catalog 派生。 +2. p0 / mock / adapter profile 从 catalog 派生。 +3. SDK adapter 按 catalog 生成全部 facade key。 +4. `capabilityContract.test.ts` 断言 catalog、contract、profile 不漂移。 +5. `capabilityAdapters.test.ts` 断言全部 adapter key 和 method 存在。 +6. `publicSdkSurface.test.ts` 固定 SDK-only public surface。 + +### P18.7-B:文档与标准 v0.6 对齐 + +状态:已完成当前 v0.6 兼容;不把分层 manifest / runtime contract 新字段静默扩成未治理的生产能力。 + +完成标准: + +1. Agent App 文档承认上游 `/Users/coso/Documents/dev/ai/limecloud/agentapp` 已进入 `manifestVersion: 0.6.0`,并新增 `app.runtime.yaml`。 +2. Lime `normalizeManifest` / readiness 明确支持 `0.2 / 0.3 / 0.5 / 0.6`,其他版本继续阻断。 +3. v0.6 兼容不应变成“放宽所有字段”;只允许先做 version normalization、capability list、layered manifest、`agentRuntime` 保留和 reference cross-check 复绿。 +4. `docs/roadmap/agentapp/README.md`、`capability-sdk.md`、`app-surface-runtime.md` 必须回链本文。 +5. `referenceCliCrossCheck.test.ts` 必须兼容 reference CLI 的 capability list 和 `agentRuntime`,并显式登记 discovery / marketplace / compliance / health / runtime policy 等尚未投影字段的退出条件。 + +### P18.7-C:Host Capability Profile / Discovery Surface + +状态:first-cut 已落地;Host discovery 和业务 App profile 消费已具备最小路径,仍需补齐更多后端 owner / unavailable reason。 + +目标:让 App 能通过 `lime.capabilities.getProfile / list / get` 看到 Host 当前能力、实现方式、stage、owner 和 unavailable 原因。 + +完成标准: + +1. Host snapshot / Host Bridge 可投影 catalog 摘要。 +2. `lime.capabilities` 不直接暴露 Lime internal path。 +3. preview 能力未接线时明确 `enabled=false / implementation=none`。 +4. App UI 根据 profile 降级,不自行猜测底层能力。 + +### P18.7-D:AgentRuntime Resource 能力接线 + +状态:first-cut 已落地;`lime.models / lime.usage / lime.skills` 可进入 Host runtime facts,深水位模型约束、预算策略和 workspace skill binding 仍需补齐。 + +优先级: + +1. `lime.usage`:从 request telemetry / runtime facts 投影 model、token、cost、budget。 +2. `lime.models`:读取模型事实源、路由结果和 capability constraints。 +3. `lime.skills`:读取 workspace skill binding、ready 状态、调用证据。 +4. `lime.memory` / `lime.context`:读取 session/thread/turn context 与记忆状态,不自动写入。 + +完成标准: + +- 内容工厂的运行过程面板可以展示模型、Token、费用、Skill 使用、上下文状态,且这些事实来自 Host / AgentRuntime projection,不来自 App 自己解析底层事件。 + +### P18.7-E:Tool / Integration 能力接线 + +状态:Host first-cut 已落地;执行型能力只返回受控 `requires_agent_task` intent 或只读投影,真实 ToolRuntime / Connector execution gate 仍需后续深水位。 + +优先级: + +1. `lime.search` / `lime.browser`:支撑内容工厂资料补齐、竞品调研、网页来源。 +2. `lime.documents` / `lime.media`:支撑 PDF/Word/PPT 解析导出、图片/音频/视频素材。 +3. `lime.mcp` / `lime.terminal`:支撑高级工具场景,但必须经过 policy/sandbox。 +4. `lime.connectors`:后移到 tenant / secret / Cloud overlay 稳定后。 + +完成标准: + +- App 只能声明 capability intent;实际工具、浏览器、MCP、终端执行继续由 Lime 主 App 管理权限、进度和 evidence。 + +### P18.7-F:内容工厂产品闭环复核 + +状态:first-cut 已完成;知识库整理、场景、内容战役、脚本、交付、复盘均已具备 typed Agent task / Host Bridge 主路径和页面内运行现场,外部内容工厂测试已覆盖 Host profile / runtime facts / workspace patch 写回,Lime Host focused smoke 已证明 iframe 内 Host profile 可见。可选深水位 smoke 已证明“整理知识库”真实按钮能进入 `lime.agent.startTask`、拿到 Host task id(taskIdSource=hostTaskRunRecord)、启动 models/usage/skills 运行事实拉取和 `lime.agent.streamTask` 订阅;仍需 Host iframe 内逐个真实业务 AI 动作完成态 E2E 证明。 + +完成标准: + +1. 内容工厂“整理知识库 / 生成场景 / 生成文案和配套素材 / 只重写文案 / 生成脚本 / 交付包 / 复盘”真实通过 `lime.agent` 进入 AgentRuntime。 +2. 运行过程展示来自 Host `runtimeProcess`,完成后折叠但不消失。 +3. 使用的模型、Token、费用、Skill、工具、引用、artifact、evidence 均可见。 +4. 最终业务结果写回 App storage / artifacts / evidence。 +5. 不需要跳回 Lime 通用 Chat,也不直接调用模型 API。 +6. standalone 页面只可作为 UI smoke,不可替代 Host iframe 内真实 AgentRuntime 闭环证据。 + +## v0.6 标准兼容策略 + +原阻塞事实:外部标准示例 `/Users/coso/Documents/dev/ai/limecloud/agentapp/docs/examples/content-factory-app/APP.md` 已进入 `manifestVersion: 0.6.0`,且 v0.6 通过 `app.runtime.yaml` 声明 Agent task event/result、structured output、approval、session policy、tool discovery、checkpoint 和 observability contract;旧版 Lime 只识别 `APP.md` frontmatter,会漏掉分层文件。当前已完成版本无关 layered manifest resolver 与前端 merge,阻塞解除。 + +兼容策略: + +1. 把 `0.6.x` normalize 成内部 `"0.6"`,继续保留 `0.5` compat,不静默降级为 `0.3`。 +2. `supportsManifestRuntime()` 明确支持 `0.6`,并保留 unsupported blocker 给其他版本。 +3. `supportsRequestedRange()` 支持 `@lime/app-sdk@^0.6.0` 和 capability list `lime.*` 的当前 profile 匹配。 +4. reference cross-check 的 `manifestVersion` 期望应从 reference projection 读取,不再硬编码 `0.3`。 +5. v0.6 新字段若尚未进入 Lime projection / runtime policy,必须在 accepted divergences 写清楚退出条件;不能无声丢字段。 + +落地记录: + +- `normalizeManifest()` 将 `0.6.x` 归一化为内部 `"0.6"`,保留 `0.2 / 0.3 / 0.5`,其他版本继续报错。 +- `checkReadiness()` 将 `"0.6"` 视为受支持 manifest runtime,并可解析 `@lime/app-sdk@^0.6.0` 形式的 SDK range。 +- `referenceCliCrossCheck.test.ts` 兼容 reference CLI `capabilityRequirements.capabilities` 从 object 到 array 的变化;当 reference readiness 不再输出 capability check 时,以 projection capability list 为对齐事实。 +- `parseManifest.test.ts` 增加 v0.6 capability list 和 layered manifest 回归,确认 `lime.agent / lime.skills / lime.usage` 被归一化为 `"*"` range,且 `app.runtime.yaml` 能合并为 `agentRuntime`。 +- v0.5/v0.6 新增 metadata / discovery / runtime policy 字段未完全进入 Lime projection 的部分,已在 accepted divergences 逐项登记;退出条件是 P18.7-C Host discovery、Agent App runtime policy 和后续 marketplace / cloud release review 正式投影。 + +## 后端接线顺序 + +| 顺序 | 先接能力 | 原因 | 不做 | +| ---- | ----------------------------------------------- | --------------------------------------------- | ------------------------------- | +| 1 | `lime.capabilities` | 让 App 先知道 Host 到底支持什么。 | 不把 catalog owner 下放给 App。 | +| 2 | `lime.usage` / `lime.models` | 用户明确要求模型、Token、费用可见。 | 不在 App 内估算 token。 | +| 3 | `lime.skills` | 内容生成必须用 Skills。 | 不复制 Claw skill launch。 | +| 4 | `lime.search` / `lime.documents` / `lime.media` | 内容工厂最直接需要资料、文档、图片/素材能力。 | 不做内容工厂专用 command。 | +| 5 | `lime.browser` / `lime.mcp` / `lime.terminal` | 高风险工具先受控接入。 | 不绕过 sandbox / policy。 | +| 6 | `lime.memory` / `lime.context` / `lime.tasks` | 提升长期业务连续性和任务中心统一。 | 不做第二套任务队列。 | + +## 验收清单 + +| 要求 | 证据 | +| ----------------------------- | --------------------------------------------------------------------------------------------------------- | +| 全量能力名只有一份事实源。 | catalog 测试断言 `LIME_CAPABILITY_NAMES`、profile keys、adapter keys 一致。 | +| App 不再自建底层 Agent 能力。 | 内容工厂只调用 SDK facade;无裸模型 API / 私有 bridge / 专用 Tauri command。 | +| Lime 主 App 封装运行过程。 | `runtimeProcess` 包含 thinking、text、execution、tools、skills、model、usage、cost、artifacts、evidence。 | +| 过程不消失。 | 终态默认折叠但 timeline 保留。 | +| 使用 Skills。 | `lime.agent` task 的 capability hints / skill bindings 可被 Host 投影,调用证据可见。 | +| 模型和 Token 可见。 | `lime.usage` / `runtimeProcess.usage` 来自 AgentRuntime telemetry。 | +| 页面确实 Agent 化。 | 知识库、场景、战役、交付、复盘页面的 AI 动作均构造 typed Agent task;Host connected 时本地生成 API 被硬拒绝。 | +| v0.6 标准兼容。 | `referenceCliCrossCheck.test.ts` 重新通过,不靠跳过;Host / 前端 resolver 能读 `app.runtime.yaml`。 | +| GUI 主路径可交付。 | 定向测试、`typecheck`、`test:contracts`、必要时 `verify:gui-smoke`。 | + +## current / compat / deprecated / dead + +- `current`:`lime.* capability catalog + typed SDK + Host Bridge + AgentRuntime facts`。 +- `compat`:现有 mock / adapter / workflow host,可服务 preview 和测试,但只能从 catalog 派生。 +- `deprecated`:App 手写 bridge、App 自己解析 runtime 底层事件、各文件自建 capability 数组。 +- `dead`:App 裸调用模型 API、复制 Claw UI/skill launch、垂直专用 `content_factory_*` 后端命令。 + +## 当前验证证据 + +- Lime Host:2026-05-16 23:05 `npm run verify:gui-smoke` 通过,覆盖 workspace ready、browser runtime、site adapters、Skill Forge entry、runtime tool surface、runtime surface page、`@` command registry、Agent Apps、Claw streaming、Knowledge GUI、Design Canvas。 +- Agent Apps:2026-05-16 23:08 增强后的 `npm run smoke:agent-apps -- --timeout-ms 180000 --prefix agent-apps-smoke-p18-7-runtime-frame-profile` 通过,`runtimeFrameContentFactoryLoaded=true` 且 `runtimeFrameHostProfileVisible=true`。 +- Agent Apps deep gate:2026-05-16 23:48 `npm run smoke:agent-apps -- --timeout-ms 180000 --prefix agent-apps-smoke-p18-7-content-action-e2e-fixed5 --include-content-factory-action-e2e` 通过;点击内容工厂 iframe 内“知识库底座 -> 整理知识库”后,summary 断言 `contentFactoryActionStarted=true`、`contentFactoryActionTaskAccepted=true`、`contentFactoryActionRuntimeFactsObserved=true`、`contentFactoryActionRuntimeFactsStarted=true`、`contentFactoryActionStreamOrGetTaskStarted=true`、`contentFactoryActionRequiredSkillsProjected=true`、`contentFactoryActionNoHostFallback=true`。当前 task-scoped record 仍显示 `modelLabel=模型等待路由`、`hasUsage=false`、`hasCost=false`、`invokedSkillNames=[]`、`artifactCount=0`,所以完成态仍未覆盖。 +- Agent Apps completion gate:2026-05-16 23:55 新增并运行 `npm run smoke:agent-apps -- --timeout-ms 180000 --prefix agent-apps-smoke-p18-7-completion-gate-current --include-content-factory-completion-e2e --completion-timeout-ms 30000`,当前预期失败;failure JSON 明确缺 `modelReady / usageReady / costReady / skillInvocationReady / artifactReady / evidenceReady`,task record 仍是 `active_turn_id=null / profile_status=idle`。 +- Runtime root probe:2026-05-17 00:02 针对同一 session 直接查 `agent_runtime_get_thread_read` / `agent_runtime_get_session`,均为 `idle / queued_turns=0 / turns=0 / messages=0`;SQLite 只有 `agent_sessions` 行,无 `agent_thread_turns`、`agent_messages`、`agent_runs`;`agent_runtime_promote_queued_turn(agent-app-queued-{taskId})` 返回 `false`。下一刀应查 `agent_app_runtime_start_task -> submit_runtime_turn -> spawn_runtime_turn_task` 为什么 accepted 后未产生 runtime turn。 +- 内容工厂:`/Users/coso/Documents/dev/ai/limecloud/content-factory-app` 中 `npm test` 通过 56 项,`npm run validate:app` 返回 `ok=true / status=passed`;`npm run readiness:app` 返回 `ok=true / status=needs-setup`,剩余 warning 是 Host 运行前必须满足的 skills / knowledge / tool / artifact / eval / service 绑定。 + +## 下一刀 + +进入 P18.7-F 深水位:从已通过的最小真实按钮 E2E 继续往后推进,补 runtimeProcess 中模型 / Token / 费用 / Skill invocation 的真实回写断言,再推进 artifact / evidence / workspace patch 完成态;随后按“生成场景 -> 生成内容 -> 交付 / 复盘”的顺序扩展可选 gate。这些完成前,不把“全部业务 AI 动作均真实闭环”标为完成。 diff --git a/docs/roadmap/agentapp/p18-7-parallel-validation.md b/docs/roadmap/agentapp/p18-7-parallel-validation.md new file mode 100644 index 000000000..adb5db9ba --- /dev/null +++ b/docs/roadmap/agentapp/p18-7-parallel-validation.md @@ -0,0 +1,161 @@ +# Agent App P18.7 并行验证记录 + +更新时间:2026-05-16 + +## 主目标 + +推进 `docs/roadmap/agentapp/p18-7-full-lime-capability-surface.md` 中的 P18.7:先完成 P18.7-B 的 Agent App `manifestVersion: 0.6.0` 标准兼容、layered manifest 和 reference cross-check 复绿,再完成 P18.7-C `lime.capabilities` Host discovery surface、P18.7-D AgentRuntime resource projection,并继续进入 P18.7-E Tool / Integration 受控 intent 与只读运行投影。 + +## 并行写集 + +| 归属 | 写集 | 当前处理 | +| --- | --- | --- | +| 隔壁进程 | `src-tauri/**`、外部 `content-factory-app`、未确认摘要 `docs/roadmap/agentapp/lime-capability-surface.md` | 只读审阅和验证;不接管外部 App / Rust / default skills 并行写集,不合并未确认摘要。 | +| 前序本进程 | `AGENTS.md`、`docs/aiprompts/README.md`、`docs/aiprompts/parallel-agent-collaboration.md`、`src/features/agent-app/**`、本文档 | 已记录并行协作规则、P18.7-B/C/D/E first-cut 和 GUI 主路径验证证据。 | +| 2026-05-16 23:35 follow-up | `scripts/agent-apps-smoke.mjs`、`docs/roadmap/agentapp/p18-7-parallel-validation.md`、`docs/roadmap/agentapp/p18-7-full-lime-capability-surface.md` | 只修可选内容工厂真实按钮 E2E gate 的等待 / 断言与对应证据记录。 | + +## 已验证 + +| 命令 | 结果 | 说明 | +| --- | --- | --- | +| `git diff --check` / `git diff --no-index --check`(scoped) | passed | 当前本进程写集含未跟踪 `capabilityCatalog.ts` / `p18-7-parallel-validation.md` 无空白 diff 问题。 | +| `npm test -- "src/features/agent-app/manifest/parseManifest.test.ts" "src/features/agent-app/schema/referenceCliCrossCheck.test.ts"` | passed | 覆盖 v0.6 manifest normalization、layered manifest、reference projection/readiness cross-check。 | +| `npm test -- "src/features/agent-app/readiness/checkReadiness.test.ts" "src/features/agent-app/sdk/capabilityContract.test.ts" "src/features/agent-app/sdk/capabilityAdapters.test.ts" "src/features/agent-app/sdk/publicSdkSurface.test.ts"` | passed | 覆盖 readiness、capability catalog、typed SDK adapter 和公开 SDK surface。 | +| `npm test -- "src/features/agent-app"` | passed | 36 files / 182 tests passed,覆盖 Agent App 定向套件。 | +| `npm run typecheck` | passed | 前端 TypeScript 边界通过。 | +| `npm run test:contracts` | passed | 命令契约、Harness 契约、modality runtime contract、cleanup report contract 均通过。 | +| `npm run governance:legacy-report` | passed | 边界违规 0;报告仍有既有分类漂移候选,但不阻塞本轮 P18.7-B。 | +| `npm run verify:gui-smoke` | passed | 2026-05-16 19:55 在 `AgentAppRuntimePage.tsx` 接入 `lime.capabilities` 后重跑完成;Headless Tauri、DevBridge、workspace ready、browser runtime、site adapters、Agent Apps、Claw streaming、Knowledge GUI、Design Canvas 等最小 GUI smoke 通过。 | +| `npm test -- "src/features/agent-app/runtime/capabilityDispatcher.test.ts" "src/features/agent-app/runtime/hostBridge.test.ts" "src/features/agent-app/sdk/capabilityAdapters.test.ts"` | passed | 覆盖 P18.7-C `lime.capabilities.list/get/getProfile` Host discovery、Host Bridge 分发和 SDK adapter。 | +| `npm test -- "src/features/agent-app/ui/AgentAppRuntimePage.test.tsx" "src/features/agent-app/runtime/capabilityDispatcher.test.ts"` | passed | 2 files / 16 tests passed;覆盖 Agent App Runtime Page 在 snapshot 中声明 `lime.capabilities`,并允许 iframe 通过 `lime.capabilities.getProfile` 读取 Host capability profile。 | +| `npm test -- "src/features/agent-app"` | passed | 36 files / 184 tests passed,新增 Runtime Page `lime.capabilities.getProfile` 回归后 Agent App 定向套件通过。 | +| `npm run typecheck` | passed | P18.7-C discovery 类型边界通过。 | +| `npm test -- "src/features/agent-app/runtime/capabilityDispatcher.test.ts" "src/features/agent-app/ui/AgentAppRuntimePage.test.tsx" "src/features/agent-app/sdk/capabilityContract.test.ts" "src/features/agent-app/sdk/capabilityAdapters.test.ts"` | passed | 4 files / 26 tests passed;覆盖 `lime.models.list/getRouting`、`lime.usage.getTokenUsage/getCostSummary`、manifest 声明边界、catalog/profile/adapter 同步。 | +| `npm test -- "src/features/agent-app"` | passed | 36 files / 186 tests passed,新增 P18.7-D runtime resource projection 回归后 Agent App 定向套件通过。 | +| `npm run typecheck` | passed | P18.7-D `lime.models` / `lime.usage` runtime projection 类型边界通过。 | +| `npm run test:contracts` | passed | 命令契约、Harness 契约、modality runtime contract、cleanup report contract 复核通过;本轮未新增 Tauri 命令。 | +| `npm run verify:gui-smoke` | passed | 2026-05-16 20:23 在 `lime.models` / `lime.usage` Host Bridge 接入后重跑完成;Headless Tauri、DevBridge、workspace ready、browser runtime、site adapters、Agent Apps、Claw streaming、Knowledge GUI、Design Canvas 等最小 GUI smoke 通过。 | +| `npm test -- "src/features/agent-app/runtime/capabilityDispatcher.test.ts" "src/features/agent-app/ui/AgentAppRuntimePage.test.tsx" "src/features/agent-app/sdk/capabilityContract.test.ts" "src/features/agent-app/sdk/capabilityAdapters.test.ts"` | passed | 4 files / 27 tests passed;覆盖 `lime.skills.list/resolve/getInvocation` 从 `runtimeProcess.skillNames/invokedSkillNames` 投影,并确认 `bind` 不伪造成功。 | +| `npm test -- "src/features/agent-app"` | passed | 36 files / 187 tests passed,新增 P18.7-D `lime.skills` runtime projection 回归后 Agent App 定向套件通过。 | +| `npm run typecheck` | passed | P18.7-D `lime.skills` runtime projection 类型边界通过。 | +| `npm run test:contracts` | passed | 命令契约、Harness 契约、modality runtime contract、cleanup report contract 复核通过;本轮仍未新增 Tauri 命令。 | +| `npm run verify:gui-smoke` | passed | 2026-05-16 20:45 在 `lime.skills` Host Bridge 接入后重跑完成;Headless Tauri、DevBridge、workspace ready、browser runtime、site adapters、Agent Apps、Claw streaming、Knowledge GUI、Design Canvas 等最小 GUI smoke 通过。 | +| `npm test -- "src/features/agent-app/runtime/capabilityDispatcher.test.ts" "src/features/agent-app/ui/AgentAppRuntimePage.test.tsx" "src/features/agent-app/sdk/capabilityContract.test.ts" "src/features/agent-app/sdk/capabilityAdapters.test.ts"` | passed | 4 files / 28 tests passed;覆盖 `lime.memory.getStatus/query`、`lime.context.getSnapshot` 的只读 projection,并确认 `write/compact/attach/detach` 不伪造成功。 | +| `npm test -- "src/features/agent-app"` | passed | 36 files / 188 tests passed,新增 P18.7-D `lime.memory` / `lime.context` runtime projection 回归后 Agent App 定向套件通过。 | +| `npm run typecheck` | passed | P18.7-D `lime.memory` / `lime.context` runtime projection 类型边界通过。 | +| `npm run test:contracts` | passed | 命令契约、Harness 契约、modality runtime contract、cleanup report contract 复核通过;本轮仍未新增 Tauri 命令。 | +| `npm run verify:gui-smoke` | passed | 2026-05-16 21:01 在 `lime.memory` / `lime.context` Host Bridge 接入后重跑完成;Headless Tauri、DevBridge、workspace ready、browser runtime、site adapters、Agent Apps、Claw streaming、Knowledge GUI、Design Canvas 等最小 GUI smoke 通过。 | +| `npm test -- "src/features/agent-app/runtime/capabilityDispatcher.test.ts" "src/features/agent-app/ui/AgentAppRuntimePage.test.tsx" "src/features/agent-app/sdk/capabilityContract.test.ts" "src/features/agent-app/sdk/capabilityAdapters.test.ts"` | passed | 4 files / 29 tests passed;覆盖 P18.7-E `lime.search.query/getRun`、`lime.browser.open`、`lime.documents.parse`、`lime.media.generateImage` 的 Host 侧受控 intent 与 runtime tool projection。 | +| `npm test -- "src/features/agent-app"` | passed | 36 files / 189 tests passed,新增 P18.7-E search/browser/documents first-cut 回归后 Agent App 定向套件通过。 | +| `npm run typecheck` | passed | P18.7-E `lime.search` / `lime.browser` / `lime.documents` Host Bridge 类型边界通过。 | +| `npm run test:contracts` | passed | 命令契约、Harness 契约、modality runtime contract、cleanup report contract 复核通过;本轮未新增 Tauri 命令。 | +| `npm run verify:gui-smoke` | passed | 2026-05-16 21:26 在 `lime.search` / `lime.browser` / `lime.documents` Host Bridge 接入后重跑完成;Headless Tauri、DevBridge、workspace ready、browser runtime、site adapters、Agent Apps、Claw streaming、Knowledge GUI、Design Canvas 等最小 GUI smoke 通过。 | +| `npm test -- "src/features/agent-app/runtime/capabilityDispatcher.test.ts" "src/features/agent-app/ui/AgentAppRuntimePage.test.tsx" "src/features/agent-app/sdk/capabilityContract.test.ts" "src/features/agent-app/sdk/capabilityAdapters.test.ts"` | passed | 4 files / 29 tests passed;复核 P18.7-E `lime.media` first-cut:`generateImage` 返回受控 `requires_agent_task` intent,profile / Runtime Page 均暴露 `lime.media` adapter。 | +| `npm test -- "src/features/agent-app"` | passed | 36 files / 189 tests passed,新增 P18.7-E `lime.media` Host first-cut 后 Agent App 定向套件通过。 | +| `npm run typecheck` | passed | P18.7-E `lime.media` Host Bridge 类型边界通过。 | +| `npm run test:contracts` | passed | 命令契约、Harness 契约、modality runtime contract、cleanup report contract 复核通过;本轮未新增 Tauri 命令。 | +| `npm test -- "src/features/agent-app/runtime/capabilityDispatcher.test.ts" "src/features/agent-app/ui/AgentAppRuntimePage.test.tsx" "src/features/agent-app/sdk/capabilityContract.test.ts" "src/features/agent-app/sdk/capabilityAdapters.test.ts"` | passed | 4 files / 29 tests passed;覆盖 P18.7-E `lime.mcp.listServers/invoke`、`lime.terminal.run/getRun/cancel`,确认二者只返回受控 intent / 只读投影 / not_available cancellation。 | +| `npm test -- "src/features/agent-app"` | passed | 37 files / 193 tests passed;在隔壁新增 `agentRuntimeProcess.test.ts` 与 v0.6 manifest 测试后,Agent App 定向套件整体通过。 | +| `npm run typecheck` | passed | P18.7-E `lime.mcp` / `lime.terminal` Host Bridge 类型边界通过。 | +| `npm run test:contracts` | passed | 命令契约、Harness 契约、modality runtime contract、cleanup report contract 复核通过;本轮仍未新增 Tauri 命令。 | +| `npm test -- "src/features/agent-app/runtime/capabilityDispatcher.test.ts" "src/features/agent-app/ui/AgentAppRuntimePage.test.tsx" "src/features/agent-app/sdk/capabilityContract.test.ts" "src/features/agent-app/sdk/capabilityAdapters.test.ts"` | passed | 4 files / 29 tests passed;覆盖 P18.7-E `lime.connectors.list/getStatus/requestAuth/invoke`,确认外部连接器只返回只读投影、Host 授权需求或受控 intent。 | +| `npm test -- "src/features/agent-app"` | passed | 37 files / 193 tests passed;新增 `lime.connectors` Host first-cut 后 Agent App 定向套件整体通过。 | +| `npm run lint` | passed | ESLint `src --max-warnings 0` 通过。 | +| `npm run typecheck` | passed | P18.7-E `lime.connectors` Host Bridge 类型边界通过。 | +| `npm run test:contracts` | passed | 命令契约、Harness 契约、modality runtime contract、cleanup report contract 复核通过;本轮仍未新增 Tauri 命令。 | +| `node --check "scripts/agent-apps-smoke.mjs"` | passed | `smoke:agent-apps` failure diagnostics 脚本语法通过;后续失败会写 `*-failure.json` / `*-failure.png`,便于定位 Runtime 打开链路。 | +| `npm run smoke:agent-apps -- --app-url http://127.0.0.1:9/ --health-url http://127.0.0.1:3030/health --timeout-ms 1000 --interval-ms 100 --prefix agent-apps-smoke-diagnostics-selftest` | expected failure with diagnostics | 使用无效 app URL 触发早期失败,确认已落 `.lime/qc/gui-evidence/agent-apps/agent-apps-smoke-diagnostics-selftest-failure.json` 与 `*-failure.png`;JSON 覆盖 pageState、bridgeHealth、runtimeStatus、consoleErrors、failedRequests。 | +| `npm run smoke:agent-apps -- --app-url http://127.0.0.1:9/ --health-url http://127.0.0.1:3039/health --timeout-ms 1000 --interval-ms 100 --prefix agent-apps-smoke-diagnostics-process-selftest` | expected failure with diagnostics | 使用本命令内临时 fake DevBridge 和无效 app URL 触发早期失败,不触碰外部 `content-factory-app` dev server;确认 failure JSON 新增 `processSnapshot`,包含平台、进程总数、cwd 探测数量、匹配进程和 match reason。 | +| `npm run bridge:health -- --timeout-ms 15000` | passed | 2026-05-16 23:00 DevBridge 恢复 ready,`http://127.0.0.1:3030/health` 返回 `status=ok`。 | +| `npm run smoke:agent-apps -- --timeout-ms 180000 --prefix agent-apps-smoke-p18-7-refocus` | passed | focused Agent Apps smoke 通过;`agent_app_start_ui_runtime` 可打开 Runtime surface,summary 证明 install / disable-enable / launch / uninstall dry-run / flag-off regression 均为 true。 | +| `npm run verify:gui-smoke` | passed | 2026-05-16 23:05 完整 GUI smoke 通过;覆盖 workspace ready、browser runtime、site adapters、Skill Forge entry、runtime tool surface、runtime surface page、`@` command registry、Agent Apps、Claw streaming、Knowledge GUI、Design Canvas。 | +| `npm run smoke:agent-apps -- --timeout-ms 180000 --prefix agent-apps-smoke-p18-7-runtime-frame-profile` | passed | 增强后的 Agent Apps smoke 通过;除 Runtime frame 可见外,还断言 iframe 内内容工厂已加载并展示 Host capability profile/运行事实提示,summary 中 `runtimeFrameContentFactoryLoaded=true`、`runtimeFrameHostProfileVisible=true`。 | +| `npm run smoke:agent-apps -- --timeout-ms 180000 --prefix agent-apps-smoke-p18-7-content-action-e2e --include-content-factory-action-e2e` | failed at deep gate | 新增可选深水位 E2E:iframe 内点击“知识库底座 -> 整理知识库”,已观察到 App 发出 `lime.agent.startTask` 并展示运行现场;failure JSON 后续复盘证明 task id 已出现在 SDK call log,但 host task record 顶层未携带 task id。 | +| `node --check "scripts/agent-apps-smoke.mjs"` | passed | 修正可选 gate 等待逻辑后,脚本语法通过。 | +| `npm run smoke:agent-apps -- --timeout-ms 180000 --prefix agent-apps-smoke-p18-7-content-action-e2e-fixed5 --include-content-factory-action-e2e` | passed | 最小真实按钮 E2E 通过;iframe 内“知识库底座 -> 整理知识库”发出 `lime.agent.startTask`,从 task-scoped host run record 确认 Host task id / runtimeFacts container / required Skills 投影,且已触发 `lime.models.getRouting`、`lime.usage.getTokenUsage/getCostSummary`、`lime.skills.list`、`lime.agent.streamTask`,无 Host fallback。 | +| `npm run smoke:agent-apps -- --timeout-ms 180000 --prefix agent-apps-smoke-p18-7-completion-gate-current --include-content-factory-completion-e2e --completion-timeout-ms 30000` | expected failure | 新增完成态可选 gate;30s 内未达到完成态,failure JSON 显示 `modelReady=false / usageReady=false / costReady=false / skillInvocationReady=false / artifactReady=false / evidenceReady=false`,但 `workspacePatchReady=true`。 | +| `agent_runtime_get_thread_read` / `agent_runtime_get_session` for `agent-app-runtime-1afdd73f-7bb9-4bfb-9204-3268fa08930d` | diagnostic | DevBridge 返回 idle、queued_turns=0、turns=0、messages=0;证明 completion gate 失败不是页面单纯没等够。 | +| `git diff --check -- "scripts/agent-apps-smoke.mjs"` | passed | 本轮脚本补丁无空白 diff 问题。 | +| `npm run smoke:agent-apps -- --timeout-ms 180000 --prefix agent-apps-smoke-p18-7-default-after-action-gate-fixed4` | passed | 复核默认 Agent Apps smoke 未被可选深水位 gate 影响;默认 install / launch / iframe profile / uninstall dry-run / flag-off 仍通过。 | +| `/Users/coso/Documents/dev/ai/limecloud/content-factory-app`: `npm test` | passed | 外部内容工厂 56 tests passed;覆盖 Host Bridge 调用 `lime.capabilities.getProfile`、`lime.models.getRouting`、`lime.usage.getTokenUsage/getCostSummary`、`lime.skills.list`,以及主生产按钮 Host connected 时只走 `lime.agent.startTask`、运行过程/Skill/Token/费用展示和 workspace patch 写回。 | +| `/Users/coso/Documents/dev/ai/limecloud/content-factory-app`: `npm run validate:app` | passed | reference CLI validate 返回 `ok=true / status=passed`,manifest hash `sha256:22d23772240c038ffe27b58ee3da298fac412d2e6b82f0a9c6659d98fecad9d1`。 | +| `/Users/coso/Documents/dev/ai/limecloud/content-factory-app`: `npm run readiness:app` | expected needs-setup | reference CLI readiness 返回 `ok=true / status=needs-setup`;剩余 warnings 是 host 运行前必须满足的 skills / knowledge / tool / artifact / eval / service 绑定,不是 manifest schema failure。 | + +## 验证备注 + +- 2026-05-16 21:50 前后,旧 `npm run verify:local` 进程停在 `verify:gui-smoke` 的 `smoke:agent-apps` 子流程,未产生新的 GUI evidence;为避免占用并行 GUI / DevBridge 环境,已终止该验证链,不计为通过证据。 +- 2026-05-16 21:57 重新执行 `npm run verify:gui-smoke`,workspace / browser runtime / site adapters / agent-service-skill-entry / runtime tool surface / `@` command registry 已通过;随后 `smoke:agent-apps` 在打开 Agent App Runtime 时触发 `agent_app_start_ui_runtime` DevBridge `/invoke` 5s timeout。该命令面位于隔壁持有的 `src-tauri/**` 写集,本进程不接管;本轮 GUI smoke 因并行 Tauri runtime 命令阻塞未计入通过证据。 +- 2026-05-16 22:30 前后,focused `npm run smoke:agent-apps -- --timeout-ms 180000 --prefix agent-apps-smoke-p18-7-refocus` 复测仍停在 `stage=launch-runtime-surface`;复测期间 `http://127.0.0.1:3030/health` 曾从最初 ready 变为 connection refused,随后 `npm run bridge:health -- --timeout-ms 30000` 又恢复 ready,但直接 POST `agent_app_start_ui_runtime` 到 `/invoke` 仍 10s timeout。该 smoke 已终止,不计为通过证据。 +- 2026-05-16 22:40 前后,`agent_app_get_ui_runtime_status` 返回 `status=stopped / message=Agent App UI runtime 未启动`,但系统进程表仍有多条 cwd 为 `/Users/coso/Documents/dev/ai/limecloud/content-factory-app` 的 `npm run dev`。这说明 Tauri runtime registry 与外部 App dev server 进程状态已经不一致;清理这些外部进程属于外部 App / Tauri runtime 写集,本进程不直接 kill。 +- 已补 `scripts/agent-apps-smoke.mjs` failure diagnostics:`try/catch` 会在 GUI smoke 失败时保存页面状态、DevBridge health、`agent_app_get_ui_runtime_status`、console errors、failed requests、failure screenshot 和外部 dev process snapshot;已用无效 app URL 自测确认 failure JSON / screenshot 会落盘,避免后续 Agent 再次人工复现才能定位 `launch-runtime-surface` 卡点。 +- 2026-05-16 22:58 复查 `npm run bridge:health -- --timeout-ms 30000` 仍超时,当前本机没有可用 `http://127.0.0.1:3030/health` DevBridge;为避免与隔壁 Tauri runtime 写集抢占,本进程未继续启动完整 `npm run verify:gui-smoke`。 +- 2026-05-16 23:00 后 DevBridge 恢复,focused Agent Apps smoke 与完整 `npm run verify:gui-smoke` 均已通过;此前 `agent_app_start_ui_runtime` timeout / registry stopped 的 GUI blocker 当前解除。 +- 2026-05-16 23:08 增强 `smoke:agent-apps`,新增 iframe 内内容工厂断言:Runtime frame 必须加载业务 App,并在“内容战役”页展示 Host capability profile / 模型、Token、费用和 Skills 统一回写提示。focused 复跑已通过。 +- 2026-05-16 23:20 前后继续补可选 `--include-content-factory-action-e2e` gate,用来验证真实业务按钮是否能进入 Host AgentRuntime。首轮失败后读取 failure JSON,确认 task id 实际已出现在 `lime.models/getRouting`、`lime.usage/*`、`lime.skills.list`、`lime.agent.streamTask/getTask` 等 SDK 后续调用中,只是 host task record 顶层未携带 task id。 +- 2026-05-16 23:35 修正 `scripts/agent-apps-smoke.mjs` 等待逻辑:task id 同时从 host task record 和 SDK call log 提取,并新增 runtime facts / stream-or-getTask 断言。 +- 2026-05-16 23:48 继续修正 smoke 诊断:读取 task-scoped host run record,而不只读 bridgeAction record;`agent-apps-smoke-p18-7-content-action-e2e-fixed5` 通过,默认 smoke `agent-apps-smoke-p18-7-default-after-action-gate-fixed4` 也通过。该证据把 P18.7-F 从“按钮只发出 startTask”推进到“最小真实按钮 E2E 已进入 Host AgentRuntime、可读取 runtimeFacts container,并能投影 required Skills”。 +- 2026-05-16 23:55 新增 `--include-content-factory-completion-e2e` 可选完成态 gate;当前运行 `agent-apps-smoke-p18-7-completion-gate-current` 预期失败,缺口明确为模型路由、Token、费用、Skill invocation、artifact、evidence 未完成,task record 仍显示 `active_turn_id=null / profile_status=idle`。 +- 2026-05-17 00:02 对同一 task 做只读根因探针:`agent_runtime_get_thread_read` 与 `agent_runtime_get_session` 均返回 `profile_status=idle / queued_turns_len=0 / turns_len=0 / messages_len=0`;SQLite `agent_sessions` 有 session 行,但 `agent_thread_turns`、`agent_messages`、`agent_runs` 均无对应记录;尝试 `agent_runtime_promote_queued_turn` 使用 `agent-app-queued-{taskId}` 返回 `false`。当前更像 accepted 后 runtime turn 未落库 / 未执行,而不是内容工厂 UI 未读取。 + +## Prompt-to-artifact 完成审计 + +| 显式要求 / gate | 对应 artifact / evidence | 覆盖结论 | +| --- | --- | --- | +| 多 Agent 并行时先切写集、避免夹写 | `AGENTS.md` 新增并行协作规则;`docs/aiprompts/parallel-agent-collaboration.md`;本文“并行写集”表 | 已覆盖;后续 Agent 应先读并声明写集 | +| 固定全量 `lime.*` capability surface 单一事实源 | `src/features/agent-app/sdk/capabilityCatalog.ts`;`capabilityContract.test.ts`;`capabilityAdapters.test.ts`;`publicSdkSurface.test.ts` | 已覆盖;catalog / adapter / profile 从同一事实源派生 | +| Agent App `manifestVersion: 0.6.0` 标准兼容 | `normalizeManifest.ts`、`checkReadiness.ts`、`parseManifest.test.ts`、`referenceCliCrossCheck.test.ts` | 已覆盖;v0.6 新字段和 runtime policy 深投影仍按 accepted divergence 退出条件跟踪 | +| `lime.capabilities` Host discovery | `capabilityDispatcher.test.ts`、`AgentAppRuntimePage.test.tsx` 覆盖 `list/get/getProfile` 与 iframe profile | Host 侧已覆盖;外部 App UI 消费未覆盖 | +| P18.7-D AgentRuntime resources | `capabilityDispatcher.test.ts` 覆盖 `models/usage/skills/memory/context` 只读投影与 mutation 拦截 | first-cut 已覆盖;真实预算、模型约束、memory query、context attach gate 未覆盖 | +| P18.7-E Tool / Integration | `capabilityDispatcher.test.ts` 覆盖 `search/browser/documents/media/mcp/terminal/connectors` 受控 intent / 只读投影 / not_available | Host first-cut 已覆盖;真实 ToolRuntime / Connector execution gate 未覆盖 | +| Agent Apps GUI 主路径可交付 | 2026-05-16 23:05 `npm run verify:gui-smoke` 通过;2026-05-16 23:08 focused `smoke:agent-apps` 新增 iframe 内 Host profile 断言后通过 | 已覆盖最新工作树;GUI 主路径当前复绿 | +| P18.7-F 内容工厂产品闭环 | 外部 `content-factory-app` tests / Host iframe profile smoke 已覆盖 typed Agent task、Host runtime facts、运行过程展示和写回路径;可选真实按钮 E2E 已证明 `lime.agent.startTask`、Host task accepted / runtimeFacts observed、models/usage/skills runtime facts 拉取和 stream 订阅启动 | first-cut 与最小真实按钮 E2E 已覆盖;真实业务 AI 动作完成态 / artifact / workspace patch 长链路未完成 | +| 验证失败可诊断 | `scripts/agent-apps-smoke.mjs` failure diagnostics;`agent-apps-smoke-diagnostics-selftest-failure.json/png` 与 `agent-apps-smoke-diagnostics-process-selftest-failure.json/png` 自测证据 | 已覆盖;后续失败应直接读取 failure JSON / screenshot,其中 `processSnapshot` 可直接确认是否存在 cwd 为 `content-factory-app` 的残留 dev server | + +## P18.7-F 完成度审计(2026-05-16 23:48) + +| 完成标准 | 当前证据 | 审计结论 | +| --- | --- | --- | +| 1. “整理知识库”真实通过 `lime.agent` 进入 AgentRuntime | `agent-apps-smoke-p18-7-content-action-e2e-fixed5-summary.json`:`contentFactoryActionStarted=true`、`contentFactoryActionTaskAccepted=true`、task id 来自 task-scoped host run record | 已覆盖最小按钮;其他页面按钮未覆盖 | +| 2. 运行过程展示来自 Host `runtimeProcess`,过程不消失 | 同一 summary:`contentFactoryActionProcessVisible=true`;host run record `runtimeProcess.timelineCount=1`,页面展示 `Lime AI 运行现场` | first-cut 覆盖;完成后折叠保持尚未覆盖 | +| 3. 模型、Token、费用、Skill、工具、引用、artifact、evidence 均可见 | `agent-apps-smoke-p18-7-completion-gate-current-failure.json`:`modelReady=false`、`usageReady=false`、`costReady=false`、`skillInvocationReady=false`、`artifactReady=false`、`evidenceReady=false`;task record 显示 `active_turn_id=null / profile_status=idle`;DevBridge/SQLite 探针显示该 session 无 turn/message/run | 未完成;已有可选 gate 固定该缺口,根因偏 runtime turn 未执行/未落库 | +| 4. 最终业务结果写回 App storage / artifacts / evidence | 完成态 gate 当前 `workspacePatchReady=true`,但 `artifactReady=false / evidenceReady=false`;外部 App 单测只能证明写回逻辑存在 | 未完成,不能用单测替代真实 Host iframe 完成态 | +| 5. 不跳回 Lime 通用 Chat,不直接调用模型 API | 当前 E2E 留在内容工厂 iframe,未出现 Host fallback;外部 App tests 覆盖 Host connected 时禁止本地生成 API | 当前按钮覆盖;全页面仍需逐个验证 | +| 6. standalone 页面不可替代 Host iframe 证据 | 现有 passing gate 均从 Lime Host iframe 触发;standalone 只作为外部 App 单测 / validate 辅证 | 已按口径执行 | + + +## 当前结论 + +- `current`:`lime.* capability catalog`、typed SDK、profile 派生、Agent App v0.6 reference cross-check、`lime.capabilities` Host discovery first-cut、`lime.models` / `lime.usage` / `lime.skills` / `lime.memory` / `lime.context` AgentRuntime 只读资源投影 first-cut、P18.7-E `lime.search` / `lime.browser` / `lime.documents` / `lime.media` / `lime.mcp` / `lime.terminal` / `lime.connectors` 受控工具 intent 与运行投影 first-cut,以及内容工厂 Host iframe profile / runtime facts 消费 first-cut。 +- `compat`:mock / adapter profile 和当前 Host Bridge preview 接线,仍服务测试与渐进接入。 +- `deprecated`:手写 capability 数组、App 自己解析 runtime 事件、复制底层工具执行逻辑。 +- `dead`:内容工厂专用后端命令、App 裸调模型 API、复制 Claw Skill launch。 + +## 完成度审计 + +| 目标 / 要求 | 当前证据 | 结论 | +| --- | --- | --- | +| P18.7-A:全量 `lime.*` catalog、typed SDK、profile 派生为单一事实源 | `capabilityCatalog.ts`、`capabilityContract.test.ts`、`capabilityAdapters.test.ts`、`publicSdkSurface.test.ts`;定向与 Agent App 全套通过 | 已完成 | +| P18.7-B:`manifestVersion: 0.6.0` 标准兼容、layered manifest 和 reference cross-check 复绿 | `normalizeManifest.ts`、`checkReadiness.ts`、`parseManifest.test.ts`、`referenceCliCrossCheck.test.ts`;v0.6 reference 相关定向通过 | 已完成 | +| P18.7-C:`lime.capabilities.list/get/getProfile` Host discovery,不泄露 internal path | `capabilityDispatcher.test.ts` 覆盖 list/get/getProfile、unknown capability、无 `path/sourceFile/internal`;Runtime Page test 覆盖 iframe 调用 `getProfile` | Host 侧 first-cut 已完成 | +| P18.7-C:业务 App UI 根据 profile 降级 | 只读审计外部 `content-factory-app`,当前未发现 `lime.capabilities` / `getProfile` 消费;外部仓库有大量并行脏写集 | 未完成,需外部 App 写集持有者接 | +| P18.7-D:`lime.usage` 投影 model、token、cost、budget | `capabilityDispatcher.test.ts` 覆盖 `getTokenUsage/getCostSummary` 从 `runtimeProcess` 投影 Token 与成本;`getBudget` 明确 `not_configured` | Token / cost first-cut 已完成;budget 未完成 | +| P18.7-D:`lime.models` 读取模型事实源、路由结果和约束 | `capabilityDispatcher.test.ts` 覆盖 `list/getRouting` 从 `runtimeProcess.model` 投影;`select/estimateCost` 为 runtime projection first-cut | first-cut 已完成,模型约束仍需接真实模型事实源 | +| P18.7-D:`lime.skills` | `capabilityDispatcher.test.ts` 覆盖 `list/resolve/getInvocation` 从 `runtimeProcess.skillNames/invokedSkillNames` 投影;`bind/invoke` 返回 `not_available`,不打开 mutation | first-cut 已完成,真实 workspace binding / Skill runtime gate 仍需后续主链接入 | +| P18.7-D:`lime.memory`、`lime.context` | `capabilityDispatcher.test.ts` 覆盖 `getStatus/query/getSnapshot` 从 App-scoped task、knowledge bindings、threadRead diagnostics、thread/turn ids 投影;`write/compact/attach/detach` 返回 `not_available`,不打开 mutation | first-cut 已完成,真实 memory runtime query / context attach gate 仍需后续主链接入 | +| P18.7-E:`lime.search/browser/documents/media/mcp/terminal/connectors` 工具集成 | `capabilityDispatcher.test.ts` 覆盖 `lime.search.query/getRun`、`lime.browser.open`、`lime.documents.parse`、`lime.media.generateImage`、`lime.mcp.listServers/invoke`、`lime.terminal.run/getRun/cancel`、`lime.connectors.list/getStatus/requestAuth/invoke`;Host Bridge 对工具执行只返回受控 `requires_agent_task` intent、只读投影、Host 授权需求或明确 `not_available`,不直接执行工具 / MCP / 终端 / 外部连接器 | Host 侧 first-cut 已完成;真实 ToolRuntime / Connector execution gate 仍未完成 | +| P18.7-F:内容工厂产品闭环复核 | Lime Host 侧已有 `lime.agent`、runtimeProcess、models/usage first-cut;外部 App `npm test` 覆盖 profile/usage/skills runtime facts、运行过程展示、workspace patch 写回、Host connected 时禁止本地生成 API;focused Agent Apps smoke 断言 iframe 内 Host profile 可见;可选真实按钮 E2E 已确认知识库整理按钮进入 Host AgentRuntime、拿到 task id、启动 runtime facts 拉取与 stream 订阅 | first-cut 与最小真实按钮 E2E 已完成;逐页面真实 AI 动作完成态 / artifact / workspace patch 长链路仍是后续深水位 | +| GUI 主路径可交付 | 2026-05-16 23:05 完整 `npm run verify:gui-smoke` 通过;2026-05-16 23:08 增强后 focused Agent Apps smoke 通过 `runtimeFrameContentFactoryLoaded/runtimeFrameHostProfileVisible` 断言 | 当前工作树已复绿 | + +## 剩余缺口 + +1. `docs/roadmap/agentapp/lime-capability-surface.md` 与 `p18-7-full-lime-capability-surface.md` 同时未跟踪,需由文档写集持有者决定是保留摘要还是合并入口。 +2. P18.7-B 的 v0.6 代码验证已通过当前工作树,但路线图文档还应补一条验证证据,避免后续 Agent 误判仍未复绿。 +3. P18.7-C first-cut 已可通过 Host Bridge 投影 catalog 摘要、stage、owner、enabled、implementation 和 unavailable reason;Agent App Runtime Page 已把 `lime.capabilities` 暴露给 iframe,并补了 `getProfile` 回归。只读审计外部 `content-factory-app` 后确认业务 App UI 尚未调用 `lime.capabilities` 做降级展示;该外部仓库当前有大量并行脏写集,本进程不接管。 +4. P18.7-D first-cut 已可通过 Host Bridge 为声明了 `lime.models` / `lime.usage` / `lime.skills` / `lime.memory` / `lime.context` 的 App 投影 `runtimeProcess`、App-scoped task、threadRead diagnostics、thread/turn ids 中的模型、Token、成本、Skill、记忆/上下文状态;预算尚无 AgentRuntime 事实源,当前明确返回 `status=not_configured / reason=no_agent_runtime_budget_facts`,`lime.skills.bind/invoke`、`lime.memory.write/compact`、`lime.context.attach/detach` 也明确返回 `not_available`,不伪造可变更或执行成功。 +5. P18.7-E 已完成 `search/browser/documents/media/mcp/terminal/connectors` Host first-cut,但真实 ToolRuntime / Connector execution gate 仍未完成;P18.7-D 深水位仍缺真实 memory runtime query、context attach gate、模型约束事实源、workspace skill binding ready 状态和预算事实。 +6. P18.7-F first-cut 已覆盖内容工厂 Host profile / runtime facts 消费和 GUI iframe 加载;当前可选深水位 smoke 已证明“整理知识库”按钮能发出 `lime.agent.startTask`、拿到 Host task id(taskIdSource=hostTaskRunRecord)、启动 runtime facts 拉取和 stream 订阅。但还缺 Host iframe 内逐个真实点击“生成场景 / 生成内容 / 只重写 / 生成脚本 / 交付 / 复盘”的长链路 E2E,以及完成态 artifact / evidence / workspace patch 回写证据。 + +## 下一刀归属 + +- 若隔壁仍持有外部 `content-factory-app`:业务 App UI 的 profile 降级展示应由隔壁接;本进程继续做 Lime Host 侧验证和小补丁。 +- 下一刀进入 P18.7-F 深水位:继续从已通过的 `--include-content-factory-action-e2e` 往后推进,补 runtimeProcess 中模型 / Token / 费用 / Skill invocation 的真实回写断言,再推进 artifact / evidence / workspace patch 完成态;若要覆盖更多页面,应按“生成场景 -> 生成内容 -> 交付 / 复盘”的顺序逐个加可选 gate。 diff --git a/docs/roadmap/agentapp/p18-completion-audit.md b/docs/roadmap/agentapp/p18-completion-audit.md index f8dea616f..48fda0134 100644 --- a/docs/roadmap/agentapp/p18-completion-audit.md +++ b/docs/roadmap/agentapp/p18-completion-audit.md @@ -2,7 +2,11 @@ 更新时间:2026-05-16 -状态:P18 当前交付候选已具备证据;P18.1、P18.2、P18.3、P18.4、P18.4-H、P18.5.1、P18.5.2、P18.5-S 与 P18.6 均有当前证据,完整 `verify:local` 已端到端通过;P18.5.3 package-side SDK facade 已从 `blocked` 推进到真实外部 package `npm run verify` 通过,且 handoff gate 显示 `distArtifacts=0`。当前剩余不是功能实现缺口,而是外部 package / Lime 工作区仍 dirty,需要 owner handoff / commit 边界收口。 +状态:P18.1-P18.6 当前交付候选已具备证据;P18.7-A 全量 capability catalog / SDK / profile 派生已落地,P18.7-B Agent App v0.6 reference compatibility 已复绿。当前剩余主线不是 P18.5 owner handoff,而是 P18.7-C Host capability discovery、P18.7-D/E 后端能力接线和 P18.7-F 内容工厂真实闭环复核;外部 package / Lime 工作区 dirty 仍需要 owner 提交边界收口。 + +## P18.7 追加审计口径 + +2026-05-16 追加:本审计原本判定的是 P18 typed SDK gate,而不是“Lime 所有能力已完成抽象和后端接线”。用户明确要求继续完善到整体目标后,完成口径改为 [P18.7 Full Lime Capability Surface](./p18-7-full-lime-capability-surface.md):全量 capability catalog、v0.6 reference 兼容、Host discovery、AgentRuntime resource 能力、Tool/Integration 能力和内容工厂真实闭环都要逐项有证据。当前 P18.7-A / P18.7-B 已完成;P18.7-C / D / E / F 仍是未完成主线。 ## 一句话结论 @@ -10,14 +14,15 @@ P18 的 Lime-side typed Capability SDK gate 已具备当前可验证证据,并 ## 当前绿灯 / 阻塞摘要 -| 分类 | 最新证据 | 判定 | -|---|---|---| -| Lime-side Agent App feature island | 2026-05-16 10:17 `npm test -- src/features/agent-app`:36 files / 178 tests passed;2026-05-16 10:41 复跑 SDK seam 最小集 4 files / 11 tests passed。 | 通过当前定向测试;包含 SDK-only public surface、public SDK export regression 与内容工厂 SDK regression。 | -| P18 SDK / Host Bridge | 2026-05-16 07:14 P18 SDK / Host Bridge 定向测试:9 files / 43 tests passed。 | 通过当前定向测试。 | -| 静态与契约 | 2026-05-16 09:07 `verify:app-version` 已重跑通过;10:20 `lint` 通过;10:15 `typecheck` 通过;10:21 `test:contracts` 通过。 | 通过当前本地非 GUI 验证。 | -| 外部 `content-factory-app` 只读验证 | 2026-05-16 10:41 低优先级复跑 `npm test`:46 tests passed;`validate:app` 为 `passed`,manifest hash `sha256:6ec3fed5f163739bcf0fd2b845c51a8e10d28aa856e8c6f90259fdab9edd1e48`;`readiness:app` 为 `needs-setup`。 | 只读验证通过;不能替代会重建 `dist/*` 的 package verify。 | -| P18.5.3 package-side SDK facade / verify | 2026-05-16 10:55 真实外部 package `npm run verify` 通过:build、46 tests、validate、readiness 均完成;随后 handoff gate 输出 `status=needs_handoff`、dirty `tracked=36 / untracked=5`、`hostBridgePrivate=none`、`uiTestPrivate=none`、SDK marker 命中 `2+2`、`distArtifacts=diff:0,missing:0,extra:0,total:0`。 | 功能和产物验收通过;仍需 owner handoff,因为 package worktree 仍 dirty 且 build/verify/e2e 脚本天然会重写 dist。 | -| 完整 `verify:local` | 2026-05-16 07:33 低优先级运行 `nice -n 10 npm run verify:local` 端到端通过,覆盖 app version、lint、typecheck、Vitest smart 58 batches、`test:contracts`、`cargo test --manifest-path src-tauri/Cargo.toml` 与全套 GUI smoke;`smoke:claw-chat-ready-streaming` 同次重跑 summary 为 `verdict=pass / error=null / consoleErrorCount=0 / failureRuntime=null`。 | 全量本地验证通过;不再是 P18 当前阻塞项。 | +| 分类 | 最新证据 | 判定 | +| ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| Lime-side Agent App feature island | 2026-05-16 10:17 `npm test -- src/features/agent-app`:36 files / 178 tests passed;2026-05-16 10:41 复跑 SDK seam 最小集 4 files / 11 tests passed。 | 通过当前定向测试;包含 SDK-only public surface、public SDK export regression 与内容工厂 SDK regression。 | +| P18 SDK / Host Bridge | 2026-05-16 07:14 P18 SDK / Host Bridge 定向测试:9 files / 43 tests passed。 | 通过当前定向测试。 | +| 静态与契约 | 2026-05-16 09:07 `verify:app-version` 已重跑通过;10:20 `lint` 通过;10:15 `typecheck` 通过;10:21 `test:contracts` 通过。 | 通过当前本地非 GUI 验证。 | +| 外部 `content-factory-app` 只读验证 | 2026-05-16 10:41 低优先级复跑 `npm test`:46 tests passed;`validate:app` 为 `passed`,manifest hash `sha256:6ec3fed5f163739bcf0fd2b845c51a8e10d28aa856e8c6f90259fdab9edd1e48`;`readiness:app` 为 `needs-setup`。 | 只读验证通过;不能替代会重建 `dist/*` 的 package verify。 | +| P18.5.3 package-side SDK facade / verify | 2026-05-16 10:55 真实外部 package `npm run verify` 通过:build、46 tests、validate、readiness 均完成;随后 handoff gate 输出 `status=needs_handoff`、dirty `tracked=36 / untracked=5`、`hostBridgePrivate=none`、`uiTestPrivate=none`、SDK marker 命中 `2+2`、`distArtifacts=diff:0,missing:0,extra:0,total:0`。 | 功能和产物验收通过;仍需 owner handoff,因为 package worktree 仍 dirty 且 build/verify/e2e 脚本天然会重写 dist。 | +| P18.7-A / P18.7-B capability surface / v0.6 reference | 2026-05-16 当前会话新增 `capabilityCatalog` 事实源、catalog/profile/adapter 派生测试;`normalizeManifest` 支持 `0.6.x`,`checkReadiness` 支持 `"0.6"`,`referenceCliCrossCheck.test.ts` 兼容 capability list、layered manifest、`agentRuntime` 和 v0.6 schema divergences;定向测试、typecheck、contracts、legacy report 均通过。 | P18.7-A/B 通过;不代表 Host discovery、后端 resource 接线或内容工厂真实闭环完成。 | +| 完整 `verify:local` | 2026-05-16 07:33 低优先级运行 `nice -n 10 npm run verify:local` 端到端通过,覆盖 app version、lint、typecheck、Vitest smart 58 batches、`test:contracts`、`cargo test --manifest-path src-tauri/Cargo.toml` 与全套 GUI smoke;`smoke:claw-chat-ready-streaming` 同次重跑 summary 为 `verdict=pass / error=null / consoleErrorCount=0 / failureRuntime=null`。 | 全量本地验证通过;不再是 P18 当前阻塞项。 | ## 目标重述 @@ -32,33 +37,33 @@ P18 要证明 Agent App 可以只依赖 Lime 公开 SDK facade 和 Host Bridge v ## Prompt-to-artifact checklist -| 要求 | 当前证据 | 审计判定 | -|---|---|---| -| P18.1 Typed SDK facade、stable error、mock host。 | `src/features/agent-app/sdk/capabilityContract.ts`、`src/features/agent-app/sdk/capabilityErrors.ts`、`src/features/agent-app/sdk/MockCapabilityHost.ts`、`src/features/agent-app/sdk/capabilityContract.test.ts`、`src/features/agent-app/sdk/MockCapabilityHost.test.ts`。 | 通过当前 Lime-side 契约验证。 | -| P18.2 Host Bridge typed router / stable error response。 | `src/features/agent-app/runtime/hostBridge.ts`、`src/features/agent-app/runtime/capabilityDispatcher.ts`、`hostBridge.test.ts`、`capabilityDispatcher.test.ts`。 | 通过当前定向测试;未进入 GUI smoke。 | -| P18.3 Core capability adapters。 | `src/features/agent-app/sdk/capabilityAdapters.ts` 覆盖 `lime.ui / storage / artifacts / evidence / knowledge / tools / agent` facade;`capabilityAdapters.test.ts` 覆盖 adapter call envelope。 | 通过当前定向测试。 | -| P18.4 App-scoped Agent task SDK facade。 | `LimeAgentCapabilityAdapter` 暴露 `startTask / streamTask / getTask / cancelTask / retryTask / submitHostResponse / listTasks`;`agentRuntimeCapabilityHost.test.ts` 覆盖 runtime host 接线。 | 通过当前定向测试;后端 push subscribe 仍归 AgentRuntime owner。 | -| P18.4-H AgentRuntime handoff gate。 | `docs/roadmap/agentapp/p18-4-h-agentruntime-handoff-gate.md` 记录 handoff 判定与 owner 缺口。 | 文档 gate 已有;不由 P18 复制 runtime read model。 | -| P18.5.1 Lime-side 内容工厂 SDK regression。 | `src/features/agent-app/sdk/contentFactorySdkRegression.test.ts` 断言内容工厂主链调用 `lime.agent.startTask / streamTask / submitHostResponse / storage.set / artifacts.create / evidence.record`,且 2026-05-16 10:08 已改为只从 `src/features/agent-app/sdk/index.ts` 的 SDK-only public surface 导入类型与 helper。 | 通过当前定向测试;证明 Lime-side 内容工厂回归不依赖 SDK 内部深文件。 | -| P18.5.2 外部 package read-only tests。 | `/Users/coso/Documents/dev/ai/limecloud/content-factory-app: npm test` 当前通过;2026-05-16 08:19 owner handoff gate 复核为 46 tests passed。 | 通过只读测试;不能替代 package-side SDK facade 或 verify。 | -| P18.5.2 外部 package 标准只读校验。 | 2026-05-16 07:11 与 08:42 低优先级运行 `/Users/coso/Documents/dev/ai/limecloud/content-factory-app: npm run validate:app && npm run readiness:app`;`validate:app` 返回 `ok=true / status=passed`,manifest hash 为 `sha256:6ec3fed5f163739bcf0fd2b845c51a8e10d28aa856e8c6f90259fdab9edd1e48`;`readiness:app` 返回 `ok=true / status=needs-setup`。 | 通过只读标准校验;`needs-setup` 符合宿主需补齐 required 依赖的状态,仍不能替代会重写 `dist/*` 的 package verify。 | -| P18.5-S Lime-side Host Bridge SDK client。 | `src/features/agent-app/sdk/hostBridgeClient.ts` 提供 `createLimeHostBridgeCapabilityInvoker`,可把标准 `createLimeCoreCapabilityAdapters` 调用转成 Host Bridge v1 `capability:invoke` envelope,并承接 ready / snapshot / theme / visibility / toast / navigate / openExternal / download / `capability:subscribe / unsubscribe / event`;`hostBridgeClient.test.ts` 覆盖 Host action、trusted origin、stable error、timeout cleanup、subscription 分发和内容工厂 task / write-back / artifact / evidence 主链。 | 通过当前定向测试;为外部 package 收敛提供目标,但不等于已改外部 package。 | -| P18.5-S SDK-only public surface。 | `src/features/agent-app/sdk/index.ts` 只导出 capability facade、stable error、Host Bridge client、mock host 和 App package 需要的 task / storage / artifact / evidence 类型;`src/features/agent-app/sdk/publicSdkSurface.test.ts` 固定该窄导出清单,并从运行时 namespace 与源码 export 来源两侧断言不导出 UI、installer、repository、runtime host、adapter、schema 或 dispatcher。 | 2026-05-16 10:08 定向测试通过,10:15 typecheck 通过;降低后续 `@lime/app-sdk` / package-local shim 误导出内部实现的风险。 | -| P18.5-S public SDK export seam。 | `src/features/agent-app/index.ts` 已从 feature public entry 导出 `createLimeCoreCapabilityAdapters`、`createLimeHostBridgeCapabilityInvoker`、`LIME_AGENT_APP_BRIDGE_PROTOCOL` 与 `LIME_AGENT_APP_BRIDGE_VERSION`;`src/features/agent-app/index.test.ts` 固定该 public export contract。 | 通过 2026-05-16 07:54 定向测试;防止外部 package 后续只能依赖 Lime 内部深路径。 | -| Host Bridge SDK client 文档化。 | `docs/roadmap/agentapp/capability-sdk.md` 增加 Host Bridge SDK Client 章节,记录 package-side App 如何用 `createLimeHostBridgeCapabilityInvoker + createLimeCoreCapabilityAdapters`,以及 P18.5.3 迁移规则。 | 通过文档 diff check;仍不替代外部 package 改造。 | -| P18.5.3 外部 package 迁移计划。 | `docs/roadmap/agentapp/p18-5-3-package-sdk-migration-plan.md` 固定外部 package 当前只读事实、推荐写集、迁移步骤、验收清单和回滚点。 | 通过文档 diff check;等待外部 package owner 稳定后执行。 | -| P18.5.3 owner handoff 单页。 | `docs/roadmap/agentapp/p18-5-3-owner-handoff.md` 浓缩接管前命令、最小写集、禁止项、迁移目标、验证顺序和完成条件。 | 给外部 package owner / 后续执行者使用;不替代 handoff 确认,也不代表外部 package 已迁移。 | -| P18.5.3 最新复核。 | 2026-05-16 10:55 真实外部 package `npm run verify` 通过;handoff gate 显示 `hostBridgePrivate=none`、`uiTestPrivate=none`、SDK marker 命中 2+2、`distArtifacts=0`、blockers 为 `none`。 | P18.5.3 SDK facade / package verify / dist 同步已完成;当前只剩 owner handoff。 | -| P18.5.3 package verify 风险。 | `docs/roadmap/agentapp/p18-5-3-package-sdk-migration-plan.md` 已记录 `npm run verify` 会先执行 `npm run build` 并重建 `dist/*`;2026-05-16 10:55 用户确认后已在真实 package 执行一次 verify。 | 当前 verify 已完成;后续若再次运行 build / verify / e2e,仍需 owner 接受 dist 重建。 | -| P18.5.3 外部 UI tests 迁移靶点。 | `docs/roadmap/agentapp/p18-5-3-package-sdk-migration-plan.md` 已记录 `tests/ui.test.mjs` 中需要调整的 Host Bridge 断言:保留业务闭环、manifest kind 和 capability call sequence,删除鼓励手写 `postMessage` / message type 的结构性断言。 | 后续 package-side 迁移时可只改测试 mock / facade 断言,不删除内容工厂业务行为断言。 | -| P18.5.3 package-side facade 兼容导出。 | `docs/roadmap/agentapp/p18-5-3-package-sdk-migration-plan.md` 已记录外部 `src/ui/app.js` 只直接 import `initHostBridge / notifyHost / runHostAgentTask / syncHostConfirmation / writeHostTaskResult`。 | 后续迁移优先保持这 5 个业务侧导出稳定,避免同时改 UI 主流程。 | -| P18.5.3 package-side 迁移 dry-run 设计。 | `docs/roadmap/agentapp/p18-5-3-package-sdk-migration-plan.md` 已基于当前外部 `src/ui/host-bridge.js` 只读结构,记录最小 diff 顺序:SDK runtime factory、pure helper 保留、私有 transport owner 删除 / 下沉、业务 helper 只调用 `lime.agent / storage / artifacts / evidence` facade。 | 等 handoff 后可按设计实施;当前仍未应用外部 package。 | -| P18.5.3 package-local SDK shim 退出条件。 | 外部 `content-factory-app` 当前 `dependencies / devDependencies` 为空;`p18-5-3-package-sdk-migration-plan.md` 已把 `src/ui/lime-app-sdk.js` 或等价 shim 列为仅在正式 `@lime/app-sdk` 不可安装时的临时写集。 | 禁止 import Lime repo 内部路径;shim 只能暴露 SDK 等价最小接口,后续发布正式 SDK 后退出。 | -| 正式 `@lime/app-sdk` package 发布状态。 | 2026-05-16 08:37 只读复核:Lime repo 根 `package.json` 仍为 `"private": true`;`packages/` 下只有 `packages/lime-cli-npm`;未发现独立 `@lime/app-sdk` / `lime-app-sdk` package。 | 不把正式 SDK package 发布当作 P18.5.3 已完成证据;外部 App 仍不得 import Lime internal path,必要时只能使用带退出条件的 package-local shim。 | -| P18.5.3 package-side SDK facade / verify。 | 2026-05-16 10:55 真实外部 package `npm run verify` 已通过,且 handoff gate 确认 `distArtifacts=0`。 | package-side SDK facade / verify 已完成;剩余为 owner handoff / git 写集收口,不是功能 blocker。 | -| P18.6 Raw Worker 前 Gate。 | `workflowRuntimeHost.test.ts`、`featureFlag.test.ts`、`runtimePackageLoader.test.ts`、`entryRuntimeGuard.test.ts` 当前通过 25 tests;2026-05-16 08:33 feature island 越界扫描无输出。 | 通过当前 gate;raw worker 后移 P19。 | -| 不绕过 SDK / 不复活旧 SceneApp。 | `rg -n "SceneApp|contentEngineering|sceneapp_|safeInvoke|invoke\\(|new Worker|Worker\\(" src/features/agent-app || true` 无输出。 | 通过当前扫描。 | -| 不抢隔壁运行任务。 | 本审计只读外部 package;不改 `src-tauri/*`、`src/lib/api/agentAppRuntime.ts`、`agentRuntimeCapabilityHost*` 实现逻辑、GUI smoke 脚本或外部 package。 | 符合当前协作边界。 | +| 要求 | 当前证据 | 审计判定 | +| -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | ---------- | --------- | ---------- | --------------------------------- | --- | -------------- | -------------- | +| P18.1 Typed SDK facade、stable error、mock host。 | `src/features/agent-app/sdk/capabilityContract.ts`、`src/features/agent-app/sdk/capabilityErrors.ts`、`src/features/agent-app/sdk/MockCapabilityHost.ts`、`src/features/agent-app/sdk/capabilityContract.test.ts`、`src/features/agent-app/sdk/MockCapabilityHost.test.ts`。 | 通过当前 Lime-side 契约验证。 | +| P18.2 Host Bridge typed router / stable error response。 | `src/features/agent-app/runtime/hostBridge.ts`、`src/features/agent-app/runtime/capabilityDispatcher.ts`、`hostBridge.test.ts`、`capabilityDispatcher.test.ts`。 | 通过当前定向测试;未进入 GUI smoke。 | +| P18.3 Core capability adapters。 | `src/features/agent-app/sdk/capabilityAdapters.ts` 覆盖 `lime.ui / storage / artifacts / evidence / knowledge / tools / agent` facade;`capabilityAdapters.test.ts` 覆盖 adapter call envelope。 | 通过当前定向测试。 | +| P18.4 App-scoped Agent task SDK facade。 | `LimeAgentCapabilityAdapter` 暴露 `startTask / streamTask / getTask / cancelTask / retryTask / submitHostResponse / listTasks`;`agentRuntimeCapabilityHost.test.ts` 覆盖 runtime host 接线。 | 通过当前定向测试;后端 push subscribe 仍归 AgentRuntime owner。 | +| P18.4-H AgentRuntime handoff gate。 | `docs/roadmap/agentapp/p18-4-h-agentruntime-handoff-gate.md` 记录 handoff 判定与 owner 缺口。 | 文档 gate 已有;不由 P18 复制 runtime read model。 | +| P18.5.1 Lime-side 内容工厂 SDK regression。 | `src/features/agent-app/sdk/contentFactorySdkRegression.test.ts` 断言内容工厂主链调用 `lime.agent.startTask / streamTask / submitHostResponse / storage.set / artifacts.create / evidence.record`,且 2026-05-16 10:08 已改为只从 `src/features/agent-app/sdk/index.ts` 的 SDK-only public surface 导入类型与 helper。 | 通过当前定向测试;证明 Lime-side 内容工厂回归不依赖 SDK 内部深文件。 | +| P18.5.2 外部 package read-only tests。 | `/Users/coso/Documents/dev/ai/limecloud/content-factory-app: npm test` 当前通过;2026-05-16 08:19 owner handoff gate 复核为 46 tests passed。 | 通过只读测试;不能替代 package-side SDK facade 或 verify。 | +| P18.5.2 外部 package 标准只读校验。 | 2026-05-16 07:11 与 08:42 低优先级运行 `/Users/coso/Documents/dev/ai/limecloud/content-factory-app: npm run validate:app && npm run readiness:app`;`validate:app` 返回 `ok=true / status=passed`,manifest hash 为 `sha256:6ec3fed5f163739bcf0fd2b845c51a8e10d28aa856e8c6f90259fdab9edd1e48`;`readiness:app` 返回 `ok=true / status=needs-setup`。 | 通过只读标准校验;`needs-setup` 符合宿主需补齐 required 依赖的状态,仍不能替代会重写 `dist/*` 的 package verify。 | +| P18.5-S Lime-side Host Bridge SDK client。 | `src/features/agent-app/sdk/hostBridgeClient.ts` 提供 `createLimeHostBridgeCapabilityInvoker`,可把标准 `createLimeCoreCapabilityAdapters` 调用转成 Host Bridge v1 `capability:invoke` envelope,并承接 ready / snapshot / theme / visibility / toast / navigate / openExternal / download / `capability:subscribe / unsubscribe / event`;`hostBridgeClient.test.ts` 覆盖 Host action、trusted origin、stable error、timeout cleanup、subscription 分发和内容工厂 task / write-back / artifact / evidence 主链。 | 通过当前定向测试;为外部 package 收敛提供目标,但不等于已改外部 package。 | +| P18.5-S SDK-only public surface。 | `src/features/agent-app/sdk/index.ts` 只导出 capability facade、stable error、Host Bridge client、mock host 和 App package 需要的 task / storage / artifact / evidence 类型;`src/features/agent-app/sdk/publicSdkSurface.test.ts` 固定该窄导出清单,并从运行时 namespace 与源码 export 来源两侧断言不导出 UI、installer、repository、runtime host、adapter、schema 或 dispatcher。 | 2026-05-16 10:08 定向测试通过,10:15 typecheck 通过;降低后续 `@lime/app-sdk` / package-local shim 误导出内部实现的风险。 | +| P18.5-S public SDK export seam。 | `src/features/agent-app/index.ts` 已从 feature public entry 导出 `createLimeCoreCapabilityAdapters`、`createLimeHostBridgeCapabilityInvoker`、`LIME_AGENT_APP_BRIDGE_PROTOCOL` 与 `LIME_AGENT_APP_BRIDGE_VERSION`;`src/features/agent-app/index.test.ts` 固定该 public export contract。 | 通过 2026-05-16 07:54 定向测试;防止外部 package 后续只能依赖 Lime 内部深路径。 | +| Host Bridge SDK client 文档化。 | `docs/roadmap/agentapp/capability-sdk.md` 增加 Host Bridge SDK Client 章节,记录 package-side App 如何用 `createLimeHostBridgeCapabilityInvoker + createLimeCoreCapabilityAdapters`,以及 P18.5.3 迁移规则。 | 通过文档 diff check;仍不替代外部 package 改造。 | +| P18.5.3 外部 package 迁移计划。 | `docs/roadmap/agentapp/p18-5-3-package-sdk-migration-plan.md` 固定外部 package 当前只读事实、推荐写集、迁移步骤、验收清单和回滚点。 | 通过文档 diff check;等待外部 package owner 稳定后执行。 | +| P18.5.3 owner handoff 单页。 | `docs/roadmap/agentapp/p18-5-3-owner-handoff.md` 浓缩接管前命令、最小写集、禁止项、迁移目标、验证顺序和完成条件。 | 给外部 package owner / 后续执行者使用;不替代 handoff 确认,也不代表外部 package 已迁移。 | +| P18.5.3 最新复核。 | 2026-05-16 10:55 真实外部 package `npm run verify` 通过;handoff gate 显示 `hostBridgePrivate=none`、`uiTestPrivate=none`、SDK marker 命中 2+2、`distArtifacts=0`、blockers 为 `none`。 | P18.5.3 SDK facade / package verify / dist 同步已完成;当前只剩 owner handoff。 | +| P18.5.3 package verify 风险。 | `docs/roadmap/agentapp/p18-5-3-package-sdk-migration-plan.md` 已记录 `npm run verify` 会先执行 `npm run build` 并重建 `dist/*`;2026-05-16 10:55 用户确认后已在真实 package 执行一次 verify。 | 当前 verify 已完成;后续若再次运行 build / verify / e2e,仍需 owner 接受 dist 重建。 | +| P18.5.3 外部 UI tests 迁移靶点。 | `docs/roadmap/agentapp/p18-5-3-package-sdk-migration-plan.md` 已记录 `tests/ui.test.mjs` 中需要调整的 Host Bridge 断言:保留业务闭环、manifest kind 和 capability call sequence,删除鼓励手写 `postMessage` / message type 的结构性断言。 | 后续 package-side 迁移时可只改测试 mock / facade 断言,不删除内容工厂业务行为断言。 | +| P18.5.3 package-side facade 兼容导出。 | `docs/roadmap/agentapp/p18-5-3-package-sdk-migration-plan.md` 已记录外部 `src/ui/app.js` 只直接 import `initHostBridge / notifyHost / runHostAgentTask / syncHostConfirmation / writeHostTaskResult`。 | 后续迁移优先保持这 5 个业务侧导出稳定,避免同时改 UI 主流程。 | +| P18.5.3 package-side 迁移 dry-run 设计。 | `docs/roadmap/agentapp/p18-5-3-package-sdk-migration-plan.md` 已基于当前外部 `src/ui/host-bridge.js` 只读结构,记录最小 diff 顺序:SDK runtime factory、pure helper 保留、私有 transport owner 删除 / 下沉、业务 helper 只调用 `lime.agent / storage / artifacts / evidence` facade。 | 等 handoff 后可按设计实施;当前仍未应用外部 package。 | +| P18.5.3 package-local SDK shim 退出条件。 | 外部 `content-factory-app` 当前 `dependencies / devDependencies` 为空;`p18-5-3-package-sdk-migration-plan.md` 已把 `src/ui/lime-app-sdk.js` 或等价 shim 列为仅在正式 `@lime/app-sdk` 不可安装时的临时写集。 | 禁止 import Lime repo 内部路径;shim 只能暴露 SDK 等价最小接口,后续发布正式 SDK 后退出。 | +| 正式 `@lime/app-sdk` package 发布状态。 | 2026-05-16 08:37 只读复核:Lime repo 根 `package.json` 仍为 `"private": true`;`packages/` 下只有 `packages/lime-cli-npm`;未发现独立 `@lime/app-sdk` / `lime-app-sdk` package。 | 不把正式 SDK package 发布当作 P18.5.3 已完成证据;外部 App 仍不得 import Lime internal path,必要时只能使用带退出条件的 package-local shim。 | +| P18.5.3 package-side SDK facade / verify。 | 2026-05-16 10:55 真实外部 package `npm run verify` 已通过,且 handoff gate 确认 `distArtifacts=0`。 | package-side SDK facade / verify 已完成;剩余为 owner handoff / git 写集收口,不是功能 blocker。 | +| P18.6 Raw Worker 前 Gate。 | `workflowRuntimeHost.test.ts`、`featureFlag.test.ts`、`runtimePackageLoader.test.ts`、`entryRuntimeGuard.test.ts` 当前通过 25 tests;2026-05-16 08:33 feature island 越界扫描无输出。 | 通过当前 gate;raw worker 后移 P19。 | +| 不绕过 SDK / 不复活旧 SceneApp。 | `rg -n "SceneApp | contentEngineering | sceneapp\_ | safeInvoke | invoke\\( | new Worker | Worker\\(" src/features/agent-app | | true` 无输出。 | 通过当前扫描。 | +| 不抢隔壁运行任务。 | 本审计只读外部 package;不改 `src-tauri/*`、`src/lib/api/agentAppRuntime.ts`、`agentRuntimeCapabilityHost*` 实现逻辑、GUI smoke 脚本或外部 package。 | 符合当前协作边界。 | ## 本轮复核命令 @@ -109,68 +114,68 @@ nice -n 10 npm run harness:doc-freshness 本段只记录证据,不接管 AgentRuntime / GUI smoke owner 写集;最新完整 `verify:local` 已消费该 pass 结果。 -| 证据 | 观察 | P18 判定 | -|---|---|---| -| Summary | `.lime/qc/gui-evidence/claw-chat-ready-streaming/claw-chat-ready-streaming-summary.json` 在 2026-05-16 07:33 更新为 `verdict=pass / error=null / consoleErrorCount=0 / failureRuntime=null`;只读复核显示 `providerPreference=deepseek`、`modelPreference=deepseek-v4-flash`、`interruptedTurnStatus=aborted`、`followSessionId=c2742b86-f2cc-4a0b-91f2-b1baf3c6005e`、`followTurnId=091b4738-a537-43f2-a4b9-daf0ed1b3c3d`。 | 历史 blocker 已解除,并已进入完整 `verify:local` 通过证据;P18 不需要抢修 runtime / smoke。 | -| GUI smoke 链路 | 同次完整 `verify:local` 已跑过 workspace-ready、browser-runtime、site-adapters、agent-service-skill-entry、agent-runtime-tool-surface、agent-runtime-tool-surface-page、at-command-registry、agent-apps、claw-chat-ready-streaming、knowledge-gui 与 design-canvas。 | 全局 GUI smoke 不再阻塞 P18 当前 SDK gate;后续只在外部 package 迁移后按改动范围补验证。 | -| 协作边界 | 本轮不改 `src-tauri/*`、runtime facade 或 smoke 脚本;只把已通过证据写入客户端 P18 审计。 | 避免和隔壁 AgentRuntime / GUI smoke 任务打架。 | +| 证据 | 观察 | P18 判定 | +| -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | +| Summary | `.lime/qc/gui-evidence/claw-chat-ready-streaming/claw-chat-ready-streaming-summary.json` 在 2026-05-16 07:33 更新为 `verdict=pass / error=null / consoleErrorCount=0 / failureRuntime=null`;只读复核显示 `providerPreference=deepseek`、`modelPreference=deepseek-v4-flash`、`interruptedTurnStatus=aborted`、`followSessionId=c2742b86-f2cc-4a0b-91f2-b1baf3c6005e`、`followTurnId=091b4738-a537-43f2-a4b9-daf0ed1b3c3d`。 | 历史 blocker 已解除,并已进入完整 `verify:local` 通过证据;P18 不需要抢修 runtime / smoke。 | +| GUI smoke 链路 | 同次完整 `verify:local` 已跑过 workspace-ready、browser-runtime、site-adapters、agent-service-skill-entry、agent-runtime-tool-surface、agent-runtime-tool-surface-page、at-command-registry、agent-apps、claw-chat-ready-streaming、knowledge-gui 与 design-canvas。 | 全局 GUI smoke 不再阻塞 P18 当前 SDK gate;后续只在外部 package 迁移后按改动范围补验证。 | +| 协作边界 | 本轮不改 `src-tauri/*`、runtime facade 或 smoke 脚本;只把已通过证据写入客户端 P18 审计。 | 避免和隔壁 AgentRuntime / GUI smoke 任务打架。 | ## 当前协作分工 -| 工作面 | Owner | P18 处理方式 | -|---|---|---| -| 外部 `content-factory-app` 源码、dist、UI、模型生成与 docs。 | 隔壁 package 任务。 | 只读观察;不覆盖 dirty / untracked 文件。 | -| AgentRuntime Rust / TS facade、push subscribe、workspace patch producer、capability catalog、真实桌面 GUI smoke。 | 隔壁 AgentRuntime 任务。 | 只消费已验证 host surface,不复制 read model 或 runtime command。 | -| Agent App P18 SDK contract、stable error、mock host、typed adapters、Lime-side regression、Host Bridge SDK client。 | Agent App P18。 | 当前通过定向测试与 contracts。 | -| P18.5.3 package-side SDK facade / verify。 | 等外部 package owner 稳定后认领。 | 当前 blocked;不能用 read-only tests 伪装完成。 | +| 工作面 | Owner | P18 处理方式 | +| ------------------------------------------------------------------------------------------------------------------- | --------------------------------- | ----------------------------------------------------------------- | +| 外部 `content-factory-app` 源码、dist、UI、模型生成与 docs。 | 隔壁 package 任务。 | 只读观察;不覆盖 dirty / untracked 文件。 | +| AgentRuntime Rust / TS facade、push subscribe、workspace patch producer、capability catalog、真实桌面 GUI smoke。 | 隔壁 AgentRuntime 任务。 | 只消费已验证 host surface,不复制 read model 或 runtime command。 | +| Agent App P18 SDK contract、stable error、mock host、typed adapters、Lime-side regression、Host Bridge SDK client。 | Agent App P18。 | 当前通过定向测试与 contracts。 | +| P18.5.3 package-side SDK facade / verify。 | 等外部 package owner 稳定后认领。 | 当前 blocked;不能用 read-only tests 伪装完成。 | ## 2026-05-16 协作锁定 隔壁标准资料已有新增,且外部 package / AgentRuntime 仍在并行推进。P18 当前采取“只读同步标准、只写客户端计划、不抢运行写集”的分工: -| 输入 / 写集 | 当前观察 | 本轮处理方式 | -|---|---|---| -| `/Users/coso/Documents/dev/ai/limecloud/agentapp` | 工作区干净;当前 `HEAD=4bef605 fix: quote mermaid sdk labels`,zh / en 标准资料已覆盖 authoring、client implementation、reference、examples、what-is-agent-app、agent-app-vs-skills-knowledge 与 mini-program analogy,进一步固定 Agent App 是完整应用包、Expert Chat 只是 entry、能力调用必须通过 Capability SDK。 | 作为标准资料只读输入;同步到本审计和后续计划口径,不改标准仓库、不复制标准文档。 | -| `/Users/coso/Documents/dev/ai/limecloud/content-factory-app` | 2026-05-16 07:39 只读复核有 39 个 dirty / untracked 条目,其中 36 个 tracked modified、3 个 untracked;当时 `src/ui/host-bridge.js`、`dist/ui/host-bridge.js`、`src/ui/app.js`、`tests/ui.test.mjs`、`dist/*`、docs 和 model-generation 相关文件仍在并行改动,且私有 bridge marker 仍命中。 | 历史 blocker;已被 10:55 真实 package verify 和 handoff gate `blockers=none / distArtifacts=0` 覆盖。 | -| `src-tauri/*`、`src/lib/api/agentAppRuntime.ts`、`src/features/agent-app/runtime/agentRuntimeCapabilityHost*`、GUI smoke 脚本 | 2026-05-16 07:05 只读复核 owner-sensitive 写集仍有 25 个 dirty / untracked 条目,属于 AgentRuntime / GUI owner 面。 | 不改;P18 只消费已验证 host surface,不抢 Rust / TS facade / GUI smoke。 | -| `smoke:claw-chat-ready-streaming` | 2026-05-16 07:33 只读复核最新 summary 为 `verdict=pass / error=null`;`providerPreference=deepseek`、`modelPreference=deepseek-v4-flash`、`interruptedTurnStatus=aborted`、`followTurnId=091b4738-a537-43f2-a4b9-daf0ed1b3c3d`,`consoleErrorCount=0`、`failureRuntime=null`。 | 已随完整 `verify:local` 通过;不在 P18 SDK lane 内抢修 AgentRuntime / GUI smoke。 | -| `docs/roadmap/agentapp/*` | 客户端路线图需要记录标准资料和 owner 边界。 | 可做 additive 文档同步;避免把 Cloud / LimeCore、外部 package 或 AgentRuntime 实现混进客户端计划。 | -| Lime 工作区自检 | 2026-05-16 07:39 只读复核 `docs/roadmap/agentapp` 与 `src/features/agent-app` 范围仍有 30 个 dirty / untracked 条目,其中 16 个 tracked modified、14 个 untracked;P18 文档收口写集保持在 `docs/roadmap/agentapp/p18-completion-audit.md` 与 `docs/roadmap/agentapp/p18-5-3-package-sdk-migration-plan.md`。 | 后续继续先 `git status --short` 再动手;不把并行实现写集纳入 P18 文档收口。 | +| 输入 / 写集 | 当前观察 | 本轮处理方式 | +| ----------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | +| `/Users/coso/Documents/dev/ai/limecloud/agentapp` | 工作区干净;当前 `HEAD=4bef605 fix: quote mermaid sdk labels`,zh / en 标准资料已覆盖 authoring、client implementation、reference、examples、what-is-agent-app、agent-app-vs-skills-knowledge 与 mini-program analogy,进一步固定 Agent App 是完整应用包、Expert Chat 只是 entry、能力调用必须通过 Capability SDK。 | 作为标准资料只读输入;同步到本审计和后续计划口径,不改标准仓库、不复制标准文档。 | +| `/Users/coso/Documents/dev/ai/limecloud/content-factory-app` | 2026-05-16 07:39 只读复核有 39 个 dirty / untracked 条目,其中 36 个 tracked modified、3 个 untracked;当时 `src/ui/host-bridge.js`、`dist/ui/host-bridge.js`、`src/ui/app.js`、`tests/ui.test.mjs`、`dist/*`、docs 和 model-generation 相关文件仍在并行改动,且私有 bridge marker 仍命中。 | 历史 blocker;已被 10:55 真实 package verify 和 handoff gate `blockers=none / distArtifacts=0` 覆盖。 | +| `src-tauri/*`、`src/lib/api/agentAppRuntime.ts`、`src/features/agent-app/runtime/agentRuntimeCapabilityHost*`、GUI smoke 脚本 | 2026-05-16 07:05 只读复核 owner-sensitive 写集仍有 25 个 dirty / untracked 条目,属于 AgentRuntime / GUI owner 面。 | 不改;P18 只消费已验证 host surface,不抢 Rust / TS facade / GUI smoke。 | +| `smoke:claw-chat-ready-streaming` | 2026-05-16 07:33 只读复核最新 summary 为 `verdict=pass / error=null`;`providerPreference=deepseek`、`modelPreference=deepseek-v4-flash`、`interruptedTurnStatus=aborted`、`followTurnId=091b4738-a537-43f2-a4b9-daf0ed1b3c3d`,`consoleErrorCount=0`、`failureRuntime=null`。 | 已随完整 `verify:local` 通过;不在 P18 SDK lane 内抢修 AgentRuntime / GUI smoke。 | +| `docs/roadmap/agentapp/*` | 客户端路线图需要记录标准资料和 owner 边界。 | 可做 additive 文档同步;避免把 Cloud / LimeCore、外部 package 或 AgentRuntime 实现混进客户端计划。 | +| Lime 工作区自检 | 2026-05-16 07:39 只读复核 `docs/roadmap/agentapp` 与 `src/features/agent-app` 范围仍有 30 个 dirty / untracked 条目,其中 16 个 tracked modified、14 个 untracked;P18 文档收口写集保持在 `docs/roadmap/agentapp/p18-completion-audit.md` 与 `docs/roadmap/agentapp/p18-5-3-package-sdk-migration-plan.md`。 | 后续继续先 `git status --short` 再动手;不把并行实现写集纳入 P18 文档收口。 | 2026-05-16 08:58 当前只读复核: -| 检查项 | 当前事实 | P18 判定 | -|---|---|---| -| 标准与服务端仓库 | `/Users/coso/Documents/dev/ai/limecloud/agentapp` 工作区干净,`HEAD=4bef605`;`/Users/coso/Documents/dev/ai/limecloud/limecore` 工作区干净。 | 标准 / 服务端事实源可读;本轮不改标准仓库或 LimeCore。 | -| 隔壁 Capability 共享输入 | 2026-05-16 只读读取 `docs/roadmap/agentruntime/claw-capability-sharing.md`;该 draft 固定 Chat `@命令`、Agent App `lime.agent.startTask`、Automation job 只是 surface adapter,Claw 能力需收敛到 AgentRuntime capability catalog,首刀已把部分 capability hint 写入现有 Claw `*_skill_launch` metadata。 | 作为 AgentRuntime owner 输入消费;P18 不复制 Claw skill launch,不新增 `content_factory_*` 垂直后端能力,也不把 capability catalog owner 迁入 Agent App SDK。 | -| 外部 package 写集 | `/Users/coso/Documents/dev/ai/limecloud/content-factory-app` 仍有 36 个 tracked modified、3 个 untracked;两个 `npm run dev` 进程的 cwd 都在该仓库。 | owner 仍未稳定;P18.5.3 不接管外部源码、`dist/*`、`tests/*` 或 `package.json`。 | -| 外部私有 bridge marker | `src/ui/host-bridge.js` 仍包含 `pendingRequests / buildMessage / window.parent.postMessage / requestHostBridge / capability:invoke / capability:subscribe / capability:event`,未出现 `createLimeHostBridgeCapabilityInvoker` 或 `createLimeCoreCapabilityAdapters`。 | package-side SDK facade 仍未完成。 | -| 外部 UI 测试 marker | `tests/ui.test.mjs` 仍直接过滤 `message.type === 'capability:invoke'` 并模拟私有 `postMessage` transport。 | 测试仍鼓励私有 transport;迁移时必须改为 SDK invoker / Host Bridge client call log。 | -| Lime 运行面 | Lime 当前仍有 Vite 与 `tauri:dev:headless` 进程在跑。 | 本轮不启停 DevBridge / Vite / Tauri,不跑 GUI smoke,不抢隔壁运行环境。 | -| SDK package 发布边界 | Lime 根 `package.json` 仍是 private 桌面包;`packages/` 仅有 `@limecloud/lime-cli`,未发现正式 `@lime/app-sdk` package。 | 外部迁移若立即 handoff,只能用带退出条件的 package-local shim,不能 import Lime internal path。 | -| 文档轻量验证 | 2026-05-16 08:59 低优先级运行 `npm run harness:doc-freshness`,monitored docs 10 / existing docs 10 / issues 0 / broken markdown links 0 / broken code path refs 0。 | 客户端文档引用未被本轮协作快照破坏;仍不能替代 P18.5.3 外部 package-side SDK facade / verify。 | -| Agent App feature island 越界扫描 | 2026-05-16 09:00 只读运行 `rg -n "SceneApp\|contentEngineering\|sceneapp_\|safeInvoke\|invoke\\(\|new Worker\|Worker\\(" src/features/agent-app \|\| true`,无输出。 | 当前 feature island 未复活旧 SceneApp、未直接 Tauri invoke、未执行 raw Worker;仍不能替代外部 package SDK facade / verify。 | -| Agent App feature island 全量定向测试 | 2026-05-16 10:17 低优先级运行 `npm test -- src/features/agent-app`,36 files / 178 tests passed。 | 当前 Agent App feature island 回归通过,并覆盖 SDK-only public surface 收口;仍不能替代外部 package-side SDK facade / verify。 | -| Lime-side SDK seam 定向测试 | 2026-05-16 09:01 低优先级运行 `npm test -- src/features/agent-app/sdk/hostBridgeClient.test.ts src/features/agent-app/sdk/contentFactorySdkRegression.test.ts src/features/agent-app/index.test.ts`,3 files / 8 tests passed。 | Host Bridge SDK client、内容工厂 SDK regression 与 public SDK export seam 当前仍绿;仍不能替代外部 package-side SDK facade / verify。 | -| Lime typecheck | 2026-05-16 10:55 低优先级运行 `npm run typecheck -- --pretty false`,通过。 | 当前 dirty 工作区的 TypeScript 类型层仍通过,并覆盖内容工厂回归改从 SDK-only public surface 导入后的类型面。 | -| Lime lint | 2026-05-16 11:00 低优先级运行 `npm run lint`,通过。 | 当前 `src` lint 仍通过;脚本变更另由 handoff core test、diff check 与实际 gate 运行覆盖。 | -| Lime contracts | 2026-05-16 10:53 低优先级运行 `npm run test:contracts`,command contracts、harness contracts、modality runtime contracts 与 cleanup report contract 均通过。 | 当前命令 / harness / modality 契约仍通过;不替代外部 package verify,但 package verify 已于 10:55 通过。 | -| Lime app version | 2026-05-16 09:07 低优先级运行 `npm run verify:app-version`,版本一致性检查通过:`1.40.0`。 | 当前版本事实源一致;仍不能替代外部 package-side SDK facade / verify。 | -| 外部 package 只读测试 | 2026-05-16 09:05 在 `/Users/coso/Documents/dev/ai/limecloud/content-factory-app` 低优先级运行 `npm test`,46 tests passed。 | 只证明外部 package 当前业务测试仍绿;测试仍在私有 bridge transport 上通过,不能替代 package-side SDK facade 或会重建 `dist/*` 的 `npm run verify`。 | -| 外部标准只读校验 | 2026-05-16 09:06 在 `/Users/coso/Documents/dev/ai/limecloud/content-factory-app` 低优先级运行 `npm run validate:app && npm run readiness:app`;`validate:app` 为 `ok=true / status=passed`,manifest hash 为 `sha256:6ec3fed5f163739bcf0fd2b845c51a8e10d28aa856e8c6f90259fdab9edd1e48`;`readiness:app` 为 `ok=true / status=needs-setup`。 | 标准 manifest 可读,readiness 正确停在宿主 required 依赖待绑定状态;仍不能替代 package-side SDK facade 或会重建 `dist/*` 的 `npm run verify`。 | -| AgentApp 文档旧口径扫描 | 2026-05-16 09:10 运行 `rg -n "08:30\|171 tests\|35 files / 171" docs/roadmap/agentapp \|\| true`,无输出。 | AgentApp 文档当前验证口径已统一到 09:04 / 09:09;仍不能替代外部 package-side SDK facade / verify。 | -| 垂直后端能力越界扫描 | 2026-05-16 09:11 只读扫描 `src-tauri / src / scripts` 中 `content_factory_* command / skill_launch` 形态;命中项集中在 `src-tauri/src/commands/agent_app_runtime_cmd.rs` 的 `is_content_factory_runtime_task`、output contract helper 与测试,用于避免复合内容工厂任务被强制提升为单一 Claw skill launch。 | 当前未发现 P18 新增垂直 `content_factory_*` 后端 command;该 AgentRuntime helper 属隔壁 runtime owner 面,P18 只消费、不接管。 | -| 外部 package SDK facade marker 复核 | 2026-05-16 09:22 只读复核 `/Users/coso/Documents/dev/ai/limecloud/content-factory-app`;`src/ui/host-bridge.js` 仍包含 `pendingRequests / buildMessage / window.parent.postMessage / requestHostBridge / capability:invoke / capability:subscribe / capability:event`,`tests/ui.test.mjs` 仍直接过滤 `message.type === 'capability:invoke'`。 | P18.5.3 唯一主阻塞仍未解除;外部 package 尚未成为 SDK facade consumer。 | -| Handoff 确认状态 | 2026-05-16 09:26 已进入需显式确认状态:没有用户或外部 package owner 明确“接管外部 package 最小写集”前,不修改 `/Users/coso/Documents/dev/ai/limecloud/content-factory-app/src/ui/host-bridge.js`、`tests/ui.test.mjs`、`package.json` 或 `dist/*`。 | 当前只能继续只读复核 / 文档审计;不能实施 P18.5.3 package-side SDK facade。 | -| 运行面复核 | 2026-05-16 09:28 只读复核进程,两个 `npm run dev` 的 cwd 都在 `/Users/coso/Documents/dev/ai/limecloud/content-factory-app`;Lime 侧仍有 Vite 与 `tauri:dev:headless` 进程。 | 外部 package 与 Lime GUI / Tauri 运行面仍在并行;不能抢占或启停这些进程。 | -| 09:39 再复核 | 外部 package 当时有 36 个 tracked modified、3 个 untracked;`src/ui/host-bridge.js` / `tests/ui.test.mjs` marker 命中手写 transport;外部 `npm test` 重新通过 46 tests;Lime-side SDK seam 最小集 `hostBridgeClient.test.ts / contentFactorySdkRegression.test.ts / index.test.ts` 重新通过 3 files / 8 tests;`src/features/agent-app` 越界扫描无输出。 | 历史 blocker;已被 10:55 真实 package verify 覆盖。 | -| 09:48 机械 gate | 新增 `scripts/agent-app-package-handoff-check.mjs` / `scripts/lib/agent-app-package-handoff-core.mjs` / `scripts/lib/agent-app-package-handoff-core.test.ts`,用只读方式把 dirty 计数、私有 bridge marker、SDK facade marker、`scripts/build.mjs` 与高风险脚本转成 `ready / needs_handoff / blocked` 判定;当前外部 package 输出 `status=blocked`,并命中 `build / verify / e2e:user-flow / e2e:user-flow:fake-model` 高风险脚本。 | P18.5.3 后续不再只靠人工 grep;但当前 gate 结果仍证明外部 package 未完成 SDK facade。 | -| 10:17 外部 gate 复核 | 运行 `node scripts/agent-app-package-handoff-check.mjs --package-dir /Users/coso/Documents/dev/ai/limecloud/content-factory-app --check`,退出码 1;输出为 `status=blocked`、dirty `tracked=36 / untracked=3`、hostBridgeSdk `none`,私有 bridge marker 与 highRiskScripts 均未消失。 | 历史 blocker;已被 10:55 真实 package verify 覆盖。 | -| 10:33 外部 gate 复核 | 运行 `node scripts/agent-app-package-handoff-check.mjs --package-dir /Users/coso/Documents/dev/ai/limecloud/content-factory-app`,输出 `status=needs_handoff`、dirty `tracked=36 / untracked=4 / total=40`、`hostBridgePrivate=none`、`uiTestPrivate=none`、`hostBridgeSdk=createLimeHostBridgeCapabilityInvoker:2, createLimeCoreCapabilityAdapters:2`,blockers 为 `none`。 | source-side facade blocker 已解除;当时主缺口切换为 owner handoff / dist verify;已在 10:55 收口。 | -| 10:41 不重建 dist 验证 | Lime repo 低优先级运行 SDK seam 最小集:4 files / 11 tests passed;外部 package 低优先级运行 `npm test`:46 tests passed;`validate:app` passed;`readiness:app` needs-setup。 | 当前 source-side facade 与标准 manifest 仍绿;仍不能替代 `npm run verify`,因为 verify 会先 build 并重建 `dist/*`。 | -| 10:45 dist 重建前预审 | 只读解析外部 `scripts/build.mjs`,确认 build 会先删除 `dist` 再复制 `src/*`;hash 对比发现 `src/ui/host-bridge.js` 与 `dist/ui/host-bridge.js` 不一致,且 `src/ui/lime-app-sdk.js` 尚无 `dist/ui/lime-app-sdk.js`。 | 安装产物缺口已明确:source-side facade 尚未同步到 dist;仍需 owner handoff 后重建 dist 或记录退出条件。 | -| 10:47 隔离 verify 演练 | 复制外部 package 当前工作区到 `/tmp/limecloud-content-factory-verify.uots6S/content-factory-app`,用 sibling symlink 保持 `../agentapp` 可用,并在临时副本运行 `npm run verify`;build、46 tests、validate、readiness 全部通过,readiness 仍为预期 `needs-setup`。 | 证明当前源码若允许重建 dist,大概率可通过 package verify;仍不能替代真实 package 写集验收,因为真实 `/Users/coso/Documents/dev/ai/limecloud/content-factory-app/dist/*` 未被更新。 | -| 10:55 真实 package verify | 用户确认后,在真实 `/Users/coso/Documents/dev/ai/limecloud/content-factory-app` 运行 `nice -n 10 npm run verify`:build 通过,46 tests passed,`validate:app` passed,`readiness:app` needs-setup;随后 handoff gate 显示 `distArtifacts=diff:0,missing:0,extra:0,total:0`。 | P18.5.3 的 package verify / dist 验收完成;gate 仍为 `needs_handoff` 是因为工作区 dirty 与高风险脚本 warning,不是 SDK facade blocker。 | +| 检查项 | 当前事实 | P18 判定 | +| ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 标准与服务端仓库 | `/Users/coso/Documents/dev/ai/limecloud/agentapp` 工作区干净,`HEAD=4bef605`;`/Users/coso/Documents/dev/ai/limecloud/limecore` 工作区干净。 | 标准 / 服务端事实源可读;本轮不改标准仓库或 LimeCore。 | +| 隔壁 Capability 共享输入 | 2026-05-16 只读读取 `docs/roadmap/agentruntime/claw-capability-sharing.md`;该 draft 固定 Chat `@命令`、Agent App `lime.agent.startTask`、Automation job 只是 surface adapter,Claw 能力需收敛到 AgentRuntime capability catalog,首刀已把部分 capability hint 写入现有 Claw `*_skill_launch` metadata。 | 作为 AgentRuntime owner 输入消费;P18 不复制 Claw skill launch,不新增 `content_factory_*` 垂直后端能力,也不把 capability catalog owner 迁入 Agent App SDK。 | +| 外部 package 写集 | `/Users/coso/Documents/dev/ai/limecloud/content-factory-app` 仍有 36 个 tracked modified、3 个 untracked;两个 `npm run dev` 进程的 cwd 都在该仓库。 | owner 仍未稳定;P18.5.3 不接管外部源码、`dist/*`、`tests/*` 或 `package.json`。 | +| 外部私有 bridge marker | `src/ui/host-bridge.js` 仍包含 `pendingRequests / buildMessage / window.parent.postMessage / requestHostBridge / capability:invoke / capability:subscribe / capability:event`,未出现 `createLimeHostBridgeCapabilityInvoker` 或 `createLimeCoreCapabilityAdapters`。 | package-side SDK facade 仍未完成。 | +| 外部 UI 测试 marker | `tests/ui.test.mjs` 仍直接过滤 `message.type === 'capability:invoke'` 并模拟私有 `postMessage` transport。 | 测试仍鼓励私有 transport;迁移时必须改为 SDK invoker / Host Bridge client call log。 | +| Lime 运行面 | Lime 当前仍有 Vite 与 `tauri:dev:headless` 进程在跑。 | 本轮不启停 DevBridge / Vite / Tauri,不跑 GUI smoke,不抢隔壁运行环境。 | +| SDK package 发布边界 | Lime 根 `package.json` 仍是 private 桌面包;`packages/` 仅有 `@limecloud/lime-cli`,未发现正式 `@lime/app-sdk` package。 | 外部迁移若立即 handoff,只能用带退出条件的 package-local shim,不能 import Lime internal path。 | +| 文档轻量验证 | 2026-05-16 08:59 低优先级运行 `npm run harness:doc-freshness`,monitored docs 10 / existing docs 10 / issues 0 / broken markdown links 0 / broken code path refs 0。 | 客户端文档引用未被本轮协作快照破坏;仍不能替代 P18.5.3 外部 package-side SDK facade / verify。 | +| Agent App feature island 越界扫描 | 2026-05-16 09:00 只读运行 `rg -n "SceneApp\|contentEngineering\|sceneapp_\|safeInvoke\|invoke\\(\|new Worker\|Worker\\(" src/features/agent-app \|\| true`,无输出。 | 当前 feature island 未复活旧 SceneApp、未直接 Tauri invoke、未执行 raw Worker;仍不能替代外部 package SDK facade / verify。 | +| Agent App feature island 全量定向测试 | 2026-05-16 10:17 低优先级运行 `npm test -- src/features/agent-app`,36 files / 178 tests passed。 | 当前 Agent App feature island 回归通过,并覆盖 SDK-only public surface 收口;仍不能替代外部 package-side SDK facade / verify。 | +| Lime-side SDK seam 定向测试 | 2026-05-16 09:01 低优先级运行 `npm test -- src/features/agent-app/sdk/hostBridgeClient.test.ts src/features/agent-app/sdk/contentFactorySdkRegression.test.ts src/features/agent-app/index.test.ts`,3 files / 8 tests passed。 | Host Bridge SDK client、内容工厂 SDK regression 与 public SDK export seam 当前仍绿;仍不能替代外部 package-side SDK facade / verify。 | +| Lime typecheck | 2026-05-16 10:55 低优先级运行 `npm run typecheck -- --pretty false`,通过。 | 当前 dirty 工作区的 TypeScript 类型层仍通过,并覆盖内容工厂回归改从 SDK-only public surface 导入后的类型面。 | +| Lime lint | 2026-05-16 11:00 低优先级运行 `npm run lint`,通过。 | 当前 `src` lint 仍通过;脚本变更另由 handoff core test、diff check 与实际 gate 运行覆盖。 | +| Lime contracts | 2026-05-16 10:53 低优先级运行 `npm run test:contracts`,command contracts、harness contracts、modality runtime contracts 与 cleanup report contract 均通过。 | 当前命令 / harness / modality 契约仍通过;不替代外部 package verify,但 package verify 已于 10:55 通过。 | +| Lime app version | 2026-05-16 09:07 低优先级运行 `npm run verify:app-version`,版本一致性检查通过:`1.40.0`。 | 当前版本事实源一致;仍不能替代外部 package-side SDK facade / verify。 | +| 外部 package 只读测试 | 2026-05-16 09:05 在 `/Users/coso/Documents/dev/ai/limecloud/content-factory-app` 低优先级运行 `npm test`,46 tests passed。 | 只证明外部 package 当前业务测试仍绿;测试仍在私有 bridge transport 上通过,不能替代 package-side SDK facade 或会重建 `dist/*` 的 `npm run verify`。 | +| 外部标准只读校验 | 2026-05-16 09:06 在 `/Users/coso/Documents/dev/ai/limecloud/content-factory-app` 低优先级运行 `npm run validate:app && npm run readiness:app`;`validate:app` 为 `ok=true / status=passed`,manifest hash 为 `sha256:6ec3fed5f163739bcf0fd2b845c51a8e10d28aa856e8c6f90259fdab9edd1e48`;`readiness:app` 为 `ok=true / status=needs-setup`。 | 标准 manifest 可读,readiness 正确停在宿主 required 依赖待绑定状态;仍不能替代 package-side SDK facade 或会重建 `dist/*` 的 `npm run verify`。 | +| AgentApp 文档旧口径扫描 | 2026-05-16 09:10 运行 `rg -n "08:30\|171 tests\|35 files / 171" docs/roadmap/agentapp \|\| true`,无输出。 | AgentApp 文档当前验证口径已统一到 09:04 / 09:09;仍不能替代外部 package-side SDK facade / verify。 | +| 垂直后端能力越界扫描 | 2026-05-16 09:11 只读扫描 `src-tauri / src / scripts` 中 `content_factory_* command / skill_launch` 形态;命中项集中在 `src-tauri/src/commands/agent_app_runtime_cmd.rs` 的 `is_content_factory_runtime_task`、output contract helper 与测试,用于避免复合内容工厂任务被强制提升为单一 Claw skill launch。 | 当前未发现 P18 新增垂直 `content_factory_*` 后端 command;该 AgentRuntime helper 属隔壁 runtime owner 面,P18 只消费、不接管。 | +| 外部 package SDK facade marker 复核 | 2026-05-16 09:22 只读复核 `/Users/coso/Documents/dev/ai/limecloud/content-factory-app`;`src/ui/host-bridge.js` 仍包含 `pendingRequests / buildMessage / window.parent.postMessage / requestHostBridge / capability:invoke / capability:subscribe / capability:event`,`tests/ui.test.mjs` 仍直接过滤 `message.type === 'capability:invoke'`。 | P18.5.3 唯一主阻塞仍未解除;外部 package 尚未成为 SDK facade consumer。 | +| Handoff 确认状态 | 2026-05-16 09:26 已进入需显式确认状态:没有用户或外部 package owner 明确“接管外部 package 最小写集”前,不修改 `/Users/coso/Documents/dev/ai/limecloud/content-factory-app/src/ui/host-bridge.js`、`tests/ui.test.mjs`、`package.json` 或 `dist/*`。 | 当前只能继续只读复核 / 文档审计;不能实施 P18.5.3 package-side SDK facade。 | +| 运行面复核 | 2026-05-16 09:28 只读复核进程,两个 `npm run dev` 的 cwd 都在 `/Users/coso/Documents/dev/ai/limecloud/content-factory-app`;Lime 侧仍有 Vite 与 `tauri:dev:headless` 进程。 | 外部 package 与 Lime GUI / Tauri 运行面仍在并行;不能抢占或启停这些进程。 | +| 09:39 再复核 | 外部 package 当时有 36 个 tracked modified、3 个 untracked;`src/ui/host-bridge.js` / `tests/ui.test.mjs` marker 命中手写 transport;外部 `npm test` 重新通过 46 tests;Lime-side SDK seam 最小集 `hostBridgeClient.test.ts / contentFactorySdkRegression.test.ts / index.test.ts` 重新通过 3 files / 8 tests;`src/features/agent-app` 越界扫描无输出。 | 历史 blocker;已被 10:55 真实 package verify 覆盖。 | +| 09:48 机械 gate | 新增 `scripts/agent-app-package-handoff-check.mjs` / `scripts/lib/agent-app-package-handoff-core.mjs` / `scripts/lib/agent-app-package-handoff-core.test.ts`,用只读方式把 dirty 计数、私有 bridge marker、SDK facade marker、`scripts/build.mjs` 与高风险脚本转成 `ready / needs_handoff / blocked` 判定;当前外部 package 输出 `status=blocked`,并命中 `build / verify / e2e:user-flow / e2e:user-flow:fake-model` 高风险脚本。 | P18.5.3 后续不再只靠人工 grep;但当前 gate 结果仍证明外部 package 未完成 SDK facade。 | +| 10:17 外部 gate 复核 | 运行 `node scripts/agent-app-package-handoff-check.mjs --package-dir /Users/coso/Documents/dev/ai/limecloud/content-factory-app --check`,退出码 1;输出为 `status=blocked`、dirty `tracked=36 / untracked=3`、hostBridgeSdk `none`,私有 bridge marker 与 highRiskScripts 均未消失。 | 历史 blocker;已被 10:55 真实 package verify 覆盖。 | +| 10:33 外部 gate 复核 | 运行 `node scripts/agent-app-package-handoff-check.mjs --package-dir /Users/coso/Documents/dev/ai/limecloud/content-factory-app`,输出 `status=needs_handoff`、dirty `tracked=36 / untracked=4 / total=40`、`hostBridgePrivate=none`、`uiTestPrivate=none`、`hostBridgeSdk=createLimeHostBridgeCapabilityInvoker:2, createLimeCoreCapabilityAdapters:2`,blockers 为 `none`。 | source-side facade blocker 已解除;当时主缺口切换为 owner handoff / dist verify;已在 10:55 收口。 | +| 10:41 不重建 dist 验证 | Lime repo 低优先级运行 SDK seam 最小集:4 files / 11 tests passed;外部 package 低优先级运行 `npm test`:46 tests passed;`validate:app` passed;`readiness:app` needs-setup。 | 当前 source-side facade 与标准 manifest 仍绿;仍不能替代 `npm run verify`,因为 verify 会先 build 并重建 `dist/*`。 | +| 10:45 dist 重建前预审 | 只读解析外部 `scripts/build.mjs`,确认 build 会先删除 `dist` 再复制 `src/*`;hash 对比发现 `src/ui/host-bridge.js` 与 `dist/ui/host-bridge.js` 不一致,且 `src/ui/lime-app-sdk.js` 尚无 `dist/ui/lime-app-sdk.js`。 | 安装产物缺口已明确:source-side facade 尚未同步到 dist;仍需 owner handoff 后重建 dist 或记录退出条件。 | +| 10:47 隔离 verify 演练 | 复制外部 package 当前工作区到 `/tmp/limecloud-content-factory-verify.uots6S/content-factory-app`,用 sibling symlink 保持 `../agentapp` 可用,并在临时副本运行 `npm run verify`;build、46 tests、validate、readiness 全部通过,readiness 仍为预期 `needs-setup`。 | 证明当前源码若允许重建 dist,大概率可通过 package verify;仍不能替代真实 package 写集验收,因为真实 `/Users/coso/Documents/dev/ai/limecloud/content-factory-app/dist/*` 未被更新。 | +| 10:55 真实 package verify | 用户确认后,在真实 `/Users/coso/Documents/dev/ai/limecloud/content-factory-app` 运行 `nice -n 10 npm run verify`:build 通过,46 tests passed,`validate:app` passed,`readiness:app` needs-setup;随后 handoff gate 显示 `distArtifacts=diff:0,missing:0,extra:0,total:0`。 | P18.5.3 的 package verify / dist 验收完成;gate 仍为 `needs_handoff` 是因为工作区 dirty 与高风险脚本 warning,不是 SDK facade blocker。 | 冲突规约: @@ -180,15 +185,15 @@ nice -n 10 npm run harness:doc-freshness 4. 本机磁盘空间已恢复到可跑 Rust 测试;不在未确认时删除 `src-tauri/target` 或其他构建缓存。`smoke:claw-chat-ready-streaming` 的 06:23 历史失败已在 07:33 完整 `verify:local` 中转为 pass 证据。 5. P18 后续只在通用 SDK contract 存在缺口时补 `src/features/agent-app/sdk/*`,不新增垂直 `content_factory_*` host command 或第二套 bridge。 -因此当前优先级调整为:不再把“外部 source-side facade、package verify、dist 同步”作为 P18 blocker;下一刀只剩 owner handoff、提交边界与是否归档 P18 的工程收口。完整 `verify:local` 已通过,不再把全局 GUI smoke 作为当前阻塞。 +因此当前优先级调整为:不再把“外部 source-side facade、package verify、dist 同步”作为 P18 blocker;P18.7-B 也已解除 `manifestVersion: 0.6.0` reference 阻塞。下一刀回到 P18.7-C Host capability discovery。完整 `verify:local` 仍需在并行写集稳定后再作为整体验收复跑,本轮定向验证已经覆盖 v0.6 兼容风险。 ## 下一刀 -1. 外部 `content-factory-app` owner 接收当前 dirty 写集,尤其是 `src/ui/host-bridge.js`、`src/ui/lime-app-sdk.js`、`tests/ui.test.mjs` 与 `dist/ui/*`。 -2. Lime 侧 owner 接收新增 SDK / handoff gate 脚本与 P18 roadmap 文档写集。 -3. 如后续还有外部 package 变更,先复跑 handoff gate;只有 `blockers=none` 且必要验证通过后再进入 P19。 -4. P18 归档后再启动 P19 raw worker、marketplace 扩展或内容工厂后端产品化;不要在 P18 写集中混入下一阶段。 +1. P18.7-C:实现 `lime.capabilities` Host discovery surface,让 App 读取 catalog/profile 摘要、实现状态、stage、owner 和 unavailable reason。 +2. P18.7-D:接 `lime.usage / lime.models / lime.skills / lime.memory / lime.context` 到 AgentRuntime facts,而不是 App 自己估算模型、token 或 skill 使用。 +3. P18.7-E:接 `lime.search / lime.browser / lime.documents / lime.media / lime.mcp / lime.terminal / lime.connectors` 到 ToolRuntime / Desktop Host policy。 +4. P18.7-F:复核内容工厂真实闭环,确保生成过程、思考、执行、Skills、模型、Token、artifact、evidence 都留在 App 内且来自 Host projection。 ## 完成判定 -P18 当前满足功能完成判定:外部 `content-factory-app` package-side SDK facade 已完成,真实 package verify 已通过,Lime formal runtime / SDK regression 有迁移后新证据,并且没有引入私有 bridge、raw worker 或第二套 runtime。当前不等于“git 可提交完成”,因为外部 package 与 Lime 工作区仍 dirty,需要 owner 决定提交 / 推送边界。 +P18.1-P18.6 和 P18.7-A/B 满足各自功能完成判定:外部 `content-factory-app` package-side SDK facade 已完成,真实 package verify 已通过,Lime formal runtime / SDK regression 有迁移后新证据,且 Agent App v0.6 reference compatibility 已复绿。P18.7 整体尚未完成,因为 Host discovery、AgentRuntime resource、Tool/Integration 和内容工厂真实闭环仍未全部接线;同时外部 package 与 Lime 工作区仍 dirty,需要 owner 决定提交 / 推送边界。 diff --git a/docs/roadmap/agentapp/p18-typed-capability-sdk-gate.md b/docs/roadmap/agentapp/p18-typed-capability-sdk-gate.md index feddb847e..14e910a2e 100644 --- a/docs/roadmap/agentapp/p18-typed-capability-sdk-gate.md +++ b/docs/roadmap/agentapp/p18-typed-capability-sdk-gate.md @@ -4,27 +4,31 @@ 状态:P18.0 文档计划已完成;P18.1 SDK facade / stable error / mock host 已完成最小代码契约;P18.2 Host Bridge typed router / stable error response 已完成;P18.3 Core capability adapters 已完成;P18.4 App-scoped Agent task SDK facade 已完成;P18.4-H AgentRuntime handoff gate 已完成;P18.5.1 Lime-side 内容工厂 SDK 回归已完成;P18.5.2 package-side read-only tests 已通过;P18.5-S Host Bridge SDK client 已完成 capability invoke、subscription、Host action 和 Host event contract;P18.5.3 package-side SDK facade / verify / dist 同步已完成;P18.6 Raw Worker 前 Gate 已完成;P17.5 Formal entry GUI smoke 已通过;完整 `verify:local` 已于 2026-05-16 07:33 通过,2026-05-16 10:53 SDK seam / handoff core 定向测试 5 files / 17 tests passed,`typecheck`、`test:contracts` 与 `lint` 当前会话复核通过;P18 功能完成,剩余 owner handoff / git 写集收口。 +## P18.7 重新打开原因 + +2026-05-16 追加:用户要求“将 Lime 所有功能都抽象出来”,因此 P18 不再只以 typed SDK gate 完成为终点。新的 current 路线图是 [P18.7 Full Lime Capability Surface](./p18-7-full-lime-capability-surface.md):先把全量 `lime.*` 能力、App / Lime 主 App 边界、v0.6 标准兼容、Host discovery 与后端接线顺序落成文档事实源,再继续代码。原 P18.1-P18.6 仍是已完成基础层,不代表整体目标完成。 + ## 协作分工 P17.5 正式入口 smoke 已收口;P18 代码实施继续和 AgentRuntime / GUI 验证任务分工,避免抢同一运行环境或改同一底层投影。 -| 分工 | 负责范围 | 暂不触碰 | -|---|---|---| -| P17.5 / 已收口 | 正式 `agent-apps` smoke、`scripts/agent-apps-smoke.mjs`、P17.5 evidence summary。 | 不把通过 smoke 扩大解释为 marketplace / 真实 delete-data / Cloud 管理台。 | -| AgentRuntime / 隔壁任务 | `AgentRuntimeThreadReadModel`、`agent_app_runtime_*` facade、artifact / evidence / handoff 投影、`artifact:created` refs、跨刷新 task 恢复、Host response 回写;`docs/roadmap/agentruntime/claw-capability-sharing.md` 进一步把 Claw `@` 能力收敛到 AgentRuntime capability catalog。 | 不改 Agent App SDK 路线图,不由 P18 复制 runtime read model;push subscribe、workspace patch producer、capability catalog service、Claw capability catalog 和真实桌面 GUI smoke 仍由 AgentRuntime 任务收口。 | -| P18 / 完成候选 | 上游 Agent App v0.4 Host Bridge 标准对齐、Typed Capability SDK gate、App-scoped Agent task 契约。P18.1 已落 SDK 类型、stable error、mock host 和 contract seam;P18.2 已收敛 Host Bridge typed router;P18.3 已完成 core adapters;P18.4 已完成 `lime.agent` SDK facade;P18.4-H 已完成 handoff gate;P18.5.1 已完成 Lime-side 内容工厂 SDK 回归;P18.5.2 已完成 package-side read-only tests;P18.5-S 已补 Host Bridge SDK client;P18.5.3 已完成真实 package verify 和 dist 同步;P18.6 已完成 raw worker 前 gate。 | 不启停隔壁 Tauri / Vite / DevBridge,不改 `src-tauri/*` / runtime facade,不改 `src/features/agent-app/runtime/agentRuntimeCapabilityHost*`,不进入 raw worker;后续只做 owner handoff / 提交边界,不再扩 P18 功能面。 | +| 分工 | 负责范围 | 暂不触碰 | +| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| P17.5 / 已收口 | 正式 `agent-apps` smoke、`scripts/agent-apps-smoke.mjs`、P17.5 evidence summary。 | 不把通过 smoke 扩大解释为 marketplace / 真实 delete-data / Cloud 管理台。 | +| AgentRuntime / 隔壁任务 | `AgentRuntimeThreadReadModel`、`agent_app_runtime_*` facade、artifact / evidence / handoff 投影、`artifact:created` refs、跨刷新 task 恢复、Host response 回写;`docs/roadmap/agentruntime/claw-capability-sharing.md` 进一步把 Claw `@` 能力收敛到 AgentRuntime capability catalog。 | 不改 Agent App SDK 路线图,不由 P18 复制 runtime read model;push subscribe、workspace patch producer、capability catalog service、Claw capability catalog 和真实桌面 GUI smoke 仍由 AgentRuntime 任务收口。 | +| P18 / 完成候选 | 上游 Agent App v0.4 Host Bridge 标准对齐、Typed Capability SDK gate、App-scoped Agent task 契约。P18.1 已落 SDK 类型、stable error、mock host 和 contract seam;P18.2 已收敛 Host Bridge typed router;P18.3 已完成 core adapters;P18.4 已完成 `lime.agent` SDK facade;P18.4-H 已完成 handoff gate;P18.5.1 已完成 Lime-side 内容工厂 SDK 回归;P18.5.2 已完成 package-side read-only tests;P18.5-S 已补 Host Bridge SDK client;P18.5.3 已完成真实 package verify 和 dist 同步;P18.6 已完成 raw worker 前 gate。 | 不启停隔壁 Tauri / Vite / DevBridge,不改 `src-tauri/*` / runtime facade,不改 `src/features/agent-app/runtime/agentRuntimeCapabilityHost*`,不进入 raw worker;后续只做 owner handoff / 提交边界,不再扩 P18 功能面。 | 收敛规则:P17.5 已解除正式入口 smoke 阻塞;P18 代码只从 SDK contract 最小面开始,不把 SDK gate 扩大成垂直内容系统。 ## 协作接口边界 -| 接口面 | 隔壁 AgentRuntime 提供 | P18 消费方式 | 防打架规则 | -|---|---|---|---| -| `lime.agent` task facade | `start / stream / get / cancel / retry / submitHostResponse / listTasks` 到 `agent_app_runtime_*`,并映射 `sessionId / turnId / requestId`。 | P18.1 已定义 SDK 类型和 mock 行为;P18.4 已包装成 typed adapter;P18.4-H 已消费隔壁运行证据。 | P18 不改 Rust command、不改 `src/lib/api/agentAppRuntime.ts`。 | -| task event projection | queued / progress / missing context / review / tool call / `artifact:created` / evidence / outcome / incident。 | SDK 固定 event union、stable error 和 mock fixtures;P18.4-H 已完成 handoff 校验,push subscribe 继续归 AgentRuntime owner。 | 不在 SDK 层重建 read model,不把轮询实现写进业务 App。 | -| storage / artifact / evidence write-back | 内容工厂已用声明类型写回 `lime.storage / lime.artifacts / lime.evidence`,AgentRuntime 负责事实投影。 | P18.3 只包装 typed adapter,自动带 provenance。 | 不新增第二套 artifact / evidence store,不写垂直 `content_factory_*` adapter。 | -| structured workspace patch | 内容工厂 App 已能消费 `workspacePatch / contentFactoryWorkspacePatch`;后端 producer contract 仍是 AgentRuntime 缺口。 | P18 先把 patch 作为可选 typed artifact/event,不把它当 P18.1 完成条件。 | 不由 SDK mock 伪造生产级 patch 成功;真实 producer 缺口回挂 AgentRuntime。 | -| capability catalog | `docs/roadmap/agentruntime/claw-capability-sharing.md` 规定 Chat `@命令`、Agent App `lime.agent.startTask`、Automation job 都只是 surface adapter;首批 capability hints 已可映射 Claw `*_skill_launch` metadata,独立 catalog service 未完成。 | P18 先在 SDK 层定义 `lime.tools / lime.knowledge / lime.agent` 能力声明与错误语义;把 capability sharing 作为 AgentRuntime owner 输入消费。 | 不复制 Claw `*_skill_launch.rs`,不把 capability hint 内置成 App 专用逻辑,不新增 `content_factory_*` 垂直后端能力。 | +| 接口面 | 隔壁 AgentRuntime 提供 | P18 消费方式 | 防打架规则 | +| ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| `lime.agent` task facade | `start / stream / get / cancel / retry / submitHostResponse / listTasks` 到 `agent_app_runtime_*`,并映射 `sessionId / turnId / requestId`。 | P18.1 已定义 SDK 类型和 mock 行为;P18.4 已包装成 typed adapter;P18.4-H 已消费隔壁运行证据。 | P18 不改 Rust command、不改 `src/lib/api/agentAppRuntime.ts`。 | +| task event projection | queued / progress / missing context / review / tool call / `artifact:created` / evidence / outcome / incident。 | SDK 固定 event union、stable error 和 mock fixtures;P18.4-H 已完成 handoff 校验,push subscribe 继续归 AgentRuntime owner。 | 不在 SDK 层重建 read model,不把轮询实现写进业务 App。 | +| storage / artifact / evidence write-back | 内容工厂已用声明类型写回 `lime.storage / lime.artifacts / lime.evidence`,AgentRuntime 负责事实投影。 | P18.3 只包装 typed adapter,自动带 provenance。 | 不新增第二套 artifact / evidence store,不写垂直 `content_factory_*` adapter。 | +| structured workspace patch | 内容工厂 App 已能消费 `workspacePatch / contentFactoryWorkspacePatch`;后端 producer contract 仍是 AgentRuntime 缺口。 | P18 先把 patch 作为可选 typed artifact/event,不把它当 P18.1 完成条件。 | 不由 SDK mock 伪造生产级 patch 成功;真实 producer 缺口回挂 AgentRuntime。 | +| capability catalog | `docs/roadmap/agentruntime/claw-capability-sharing.md` 规定 Chat `@命令`、Agent App `lime.agent.startTask`、Automation job 都只是 surface adapter;首批 capability hints 已可映射 Claw `*_skill_launch` metadata,独立 catalog service 未完成。 | P18 先在 SDK 层定义 `lime.tools / lime.knowledge / lime.agent` 能力声明与错误语义;把 capability sharing 作为 AgentRuntime owner 输入消费。 | 不复制 Claw `*_skill_launch.rs`,不把 capability hint 内置成 App 专用逻辑,不新增 `content_factory_*` 垂直后端能力。 | ## 2026-05-16 隔壁 AgentRuntime 输入 @@ -98,32 +102,32 @@ flowchart TD ## 边界表 -| 层 | 负责 | 不负责 | -|---|---|---| -| `@lime/app-sdk` | TypeScript facade、调用参数类型、稳定错误码、mock host。 | 不 import Lime internal path,不执行模型 / 工具。 | -| Host Bridge v1 | ready、snapshot、theme、visibility、toast、navigate、download、capability invoke 传输。 | 不绕过 readiness / permission / policy,不返回假成功。 | -| Capability Router | 校验 appId、entryKey、origin、source、capability allowlist、requestId。 | 不拥有业务 UI,不做垂直业务逻辑。 | -| AgentRuntime Surface | App-scoped task、stream、cancel、retry、trace、evidence。 | 不把 Claw Chat 复制一套,不让 App 直接拿模型 API。 | -| App runtime package | UI、workflow、storage schema、业务状态和结果确认。 | 不保存 secret 明文,不访问宿主文件系统 / DB / Tauri API。 | -| Lime Cloud / LimeCore | catalog、release、tenant enablement、license、policy defaults。 | 不默认运行 Agent,不渲染 App UI,不接管本地 storage。 | +| 层 | 负责 | 不负责 | +| --------------------- | --------------------------------------------------------------------------------------- | --------------------------------------------------------- | +| `@lime/app-sdk` | TypeScript facade、调用参数类型、稳定错误码、mock host。 | 不 import Lime internal path,不执行模型 / 工具。 | +| Host Bridge v1 | ready、snapshot、theme、visibility、toast、navigate、download、capability invoke 传输。 | 不绕过 readiness / permission / policy,不返回假成功。 | +| Capability Router | 校验 appId、entryKey、origin、source、capability allowlist、requestId。 | 不拥有业务 UI,不做垂直业务逻辑。 | +| AgentRuntime Surface | App-scoped task、stream、cancel、retry、trace、evidence。 | 不把 Claw Chat 复制一套,不让 App 直接拿模型 API。 | +| App runtime package | UI、workflow、storage schema、业务状态和结果确认。 | 不保存 secret 明文,不访问宿主文件系统 / DB / Tauri API。 | +| Lime Cloud / LimeCore | catalog、release、tenant enablement、license、policy defaults。 | 不默认运行 Agent,不渲染 App UI,不接管本地 storage。 | ## Typed Capability Matrix P18 的最小 SDK 不追求能力全覆盖,而是先固定调用语义、错误语义和 mock 语义。 -| Capability | 最小调用 | P18 口径 | -|---|---|---| -| `lime.ui` | `toast`、`navigate`、`openExternal`、`download`、`getSnapshot` | 走 Host Bridge action;外链 / 下载必须校验协议、origin、权限。 | -| `lime.storage` | `table.get`、`table.insert`、`table.update`、`table.query` | 只写 App namespace;自动附加 provenance;schema 不匹配返回 stable error。 | -| `lime.files` | `pick`、`readRef` | 只接收 host file ref,不暴露 raw path。 | -| `lime.agent` | `startTask`、`streamTask`、`getTask`、`cancelTask`、`retryTask`、`submitHostResponse`、`listTasks` | 映射到 AgentRuntime Surface;不跳通用 Chat;P18.4 已补 typed adapter,P18.4-H 已对齐 handoff 运行证据。 | -| `lime.knowledge` | `search`、`bindStatus` | 只能访问 manifest / setup 声明的 Knowledge binding。 | -| `lime.tools` | `invoke`、`getProgress` | 通过 Tool Broker / ToolHub;policy 和 secret handle 由 Host 管。 | -| `lime.artifacts` | `create`、`open`、`export` | artifact 带 appId、entryKey、packageHash、manifestHash。 | -| `lime.workflow` | `start`、`checkpoint`、`awaitHuman` | P18 仍是 typed workflow;raw worker 进入后续 gate。 | -| `lime.policy` | `check`、`requestPermission` | UI prompt 不是最终授权;bridge 层必须强制。 | -| `lime.secrets` | `getRef`、`requestBinding` | App 只能拿 scoped handle,不能拿 secret value。 | -| `lime.evidence` | `record`、`linkArtifact` | 记录 task、tool、knowledge、artifact、eval 和 cleanup provenance。 | +| Capability | 最小调用 | P18 口径 | +| ---------------- | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | +| `lime.ui` | `toast`、`navigate`、`openExternal`、`download`、`getSnapshot` | 走 Host Bridge action;外链 / 下载必须校验协议、origin、权限。 | +| `lime.storage` | `table.get`、`table.insert`、`table.update`、`table.query` | 只写 App namespace;自动附加 provenance;schema 不匹配返回 stable error。 | +| `lime.files` | `pick`、`readRef` | 只接收 host file ref,不暴露 raw path。 | +| `lime.agent` | `startTask`、`streamTask`、`getTask`、`cancelTask`、`retryTask`、`submitHostResponse`、`listTasks` | 映射到 AgentRuntime Surface;不跳通用 Chat;P18.4 已补 typed adapter,P18.4-H 已对齐 handoff 运行证据。 | +| `lime.knowledge` | `search`、`bindStatus` | 只能访问 manifest / setup 声明的 Knowledge binding。 | +| `lime.tools` | `invoke`、`getProgress` | 通过 Tool Broker / ToolHub;policy 和 secret handle 由 Host 管。 | +| `lime.artifacts` | `create`、`open`、`export` | artifact 带 appId、entryKey、packageHash、manifestHash。 | +| `lime.workflow` | `start`、`checkpoint`、`awaitHuman` | P18 仍是 typed workflow;raw worker 进入后续 gate。 | +| `lime.policy` | `check`、`requestPermission` | UI prompt 不是最终授权;bridge 层必须强制。 | +| `lime.secrets` | `getRef`、`requestBinding` | App 只能拿 scoped handle,不能拿 secret value。 | +| `lime.evidence` | `record`、`linkArtifact` | 记录 task、tool、knowledge、artifact、eval 和 cleanup provenance。 | ## Bridge Envelope @@ -165,19 +169,19 @@ type LimeCapabilityInvokeResponse = 稳定错误码首版: -| 错误码 | 含义 | -|---|---| -| `capability_unavailable` | Host 不支持该 capability / method。 | -| `readiness_blocked` | App / entry readiness 未通过。 | -| `permission_denied` | 用户或租户未授权。 | -| `policy_denied` | 企业策略、成本、网络或工具策略阻断。 | -| `schema_invalid` | 请求参数或结果 schema 不匹配。 | -| `source_unverified` | package / manifest / provenance 不可信。 | -| `secret_required` | 缺必需 secret binding。 | -| `timeout` | 调用超时。 | -| `cancelled` | 用户或 Host 取消。 | -| `conflict` | 幂等键、版本或并发状态冲突。 | -| `upstream_failed` | 底层服务失败但边界仍完整。 | +| 错误码 | 含义 | +| ------------------------ | ---------------------------------------- | +| `capability_unavailable` | Host 不支持该 capability / method。 | +| `readiness_blocked` | App / entry readiness 未通过。 | +| `permission_denied` | 用户或租户未授权。 | +| `policy_denied` | 企业策略、成本、网络或工具策略阻断。 | +| `schema_invalid` | 请求参数或结果 schema 不匹配。 | +| `source_unverified` | package / manifest / provenance 不可信。 | +| `secret_required` | 缺必需 secret binding。 | +| `timeout` | 调用超时。 | +| `cancelled` | 用户或 Host 取消。 | +| `conflict` | 幂等键、版本或并发状态冲突。 | +| `upstream_failed` | 底层服务失败但边界仍完整。 | ## 时序图:SDK 初始化 @@ -247,13 +251,13 @@ flowchart TD ## 用户故事 -| 角色 | 用户故事 | 验收 | -|---|---|---| -| 业务用户 | 我在内容工厂页面内启动资料整理、文案生成和复盘,不需要跳回通用 Chat。 | App 内显示任务流、引用、错误、取消、重试和确认写回。 | -| App 开发者 | 我只依赖 `@lime/app-sdk` 类型和 mock host,就能本地测试 App 工作流。 | 不 import Lime internal path;mock / real host 的错误码一致。 | -| 平台维护者 | 我升级 storage、AgentRuntime 或 Tool Broker 时,不需要修改每个 App。 | SDK contract tests 通过;App 只感知兼容版本和稳定错误码。 | -| 企业管理员 | 我可以用 policy / tenant overlay 控制工具、secret、成本和 network。 | Bridge 层强制策略;UI 只展示结果,不承担最终授权。 | -| QA / 智能体 | 我能用 contract tests 和 GUI smoke 证明正式入口与 SDK 边界没有漂移。 | `src/features/agent-app` 无直接 `safeInvoke` / `invoke` / Tauri / raw Worker 越界。 | +| 角色 | 用户故事 | 验收 | +| ----------- | --------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | +| 业务用户 | 我在内容工厂页面内启动资料整理、文案生成和复盘,不需要跳回通用 Chat。 | App 内显示任务流、引用、错误、取消、重试和确认写回。 | +| App 开发者 | 我只依赖 `@lime/app-sdk` 类型和 mock host,就能本地测试 App 工作流。 | 不 import Lime internal path;mock / real host 的错误码一致。 | +| 平台维护者 | 我升级 storage、AgentRuntime 或 Tool Broker 时,不需要修改每个 App。 | SDK contract tests 通过;App 只感知兼容版本和稳定错误码。 | +| 企业管理员 | 我可以用 policy / tenant overlay 控制工具、secret、成本和 network。 | Bridge 层强制策略;UI 只展示结果,不承担最终授权。 | +| QA / 智能体 | 我能用 contract tests 和 GUI smoke 证明正式入口与 SDK 边界没有漂移。 | `src/features/agent-app` 无直接 `safeInvoke` / `invoke` / Tauri / raw Worker 越界。 | ## 用例 @@ -265,16 +269,16 @@ flowchart TD ## 实施拆分 -| 阶段 | 目标 | 主要产物 | 验收 | -|---|---|---|---| -| P18.0 | v0.4 标准差距收口 | 对齐 `agentapp-ref@0.4.0` Host Bridge v1、SDK typed API、错误码 gap matrix。 | 文档 diff check;不改运行代码。 | -| P18.1 | SDK 类型与 schema | 已完成:`sdk` types、capability invoke envelope、stable error enum、mock host。 | `capabilityContract` + `MockCapabilityHost` SDK contract tests;`typecheck`。 | -| P18.2 | Host Bridge router | 已完成:capability invoke typed envelope、`args` / `input` 兼容、requestId / idempotency / expectedSchema / provenance 透传、stable error response。 | bridge router / dispatcher / runtime page tests;未知 capability 不写假成功。 | -| P18.3 | Core capability adapters | 已完成:`lime.ui`、`storage`、`artifacts`、`evidence`、`knowledge`、`tools` typed adapter facade。 | adapter contract tests、namespace / provenance tests、typecheck、contracts。 | -| P18.4 | App-scoped Agent Task | 已完成:`lime.agent.startTask` / stream / get / cancel / retry / submitHostResponse / listTasks typed adapter,并覆盖 `task:*`、`artifact:created`、`evidence:*` 事件契约。 | App task adapter contract tests;不回跳通用 Chat;不重复实现 runtime read model。 | -| P18.4-H | AgentRuntime handoff gate | 已完成:对齐隔壁 current MVP 与剩余缺口,新增消费侧 handoff checklist,并明确 push subscribe、workspace patch producer、capability catalog、GUI smoke owner。 | 只做 SDK 消费侧 gate,不抢 AgentRuntime Rust / TS 修改。 | -| P18.5 | 内容工厂 SDK 化回归 | 已完成 Lime-side SDK regression、Host Bridge SDK client contract 和 package-side 只读 `npm test`;内容工厂 task / write-back / evidence 可由通用 facade 表达,也可穿过 Host Bridge v1。 | 下一刀等待 package owner 稳定后做 P18.5.3 package-side SDK facade / verify;不复刻内容工厂后端能力。 | -| P18.6 | Raw worker 前 gate | 已完成:明确 worker sandbox、resource limit、network / secret policy 进入后续 P19,并记录 P18 不执行 raw worker 的证据。 | P18 不执行 raw worker。 | +| 阶段 | 目标 | 主要产物 | 验收 | +| ------- | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | +| P18.0 | v0.4 标准差距收口 | 对齐 `agentapp-ref@0.4.0` Host Bridge v1、SDK typed API、错误码 gap matrix。 | 文档 diff check;不改运行代码。 | +| P18.1 | SDK 类型与 schema | 已完成:`sdk` types、capability invoke envelope、stable error enum、mock host。 | `capabilityContract` + `MockCapabilityHost` SDK contract tests;`typecheck`。 | +| P18.2 | Host Bridge router | 已完成:capability invoke typed envelope、`args` / `input` 兼容、requestId / idempotency / expectedSchema / provenance 透传、stable error response。 | bridge router / dispatcher / runtime page tests;未知 capability 不写假成功。 | +| P18.3 | Core capability adapters | 已完成:`lime.ui`、`storage`、`artifacts`、`evidence`、`knowledge`、`tools` typed adapter facade。 | adapter contract tests、namespace / provenance tests、typecheck、contracts。 | +| P18.4 | App-scoped Agent Task | 已完成:`lime.agent.startTask` / stream / get / cancel / retry / submitHostResponse / listTasks typed adapter,并覆盖 `task:*`、`artifact:created`、`evidence:*` 事件契约。 | App task adapter contract tests;不回跳通用 Chat;不重复实现 runtime read model。 | +| P18.4-H | AgentRuntime handoff gate | 已完成:对齐隔壁 current MVP 与剩余缺口,新增消费侧 handoff checklist,并明确 push subscribe、workspace patch producer、capability catalog、GUI smoke owner。 | 只做 SDK 消费侧 gate,不抢 AgentRuntime Rust / TS 修改。 | +| P18.5 | 内容工厂 SDK 化回归 | 已完成 Lime-side SDK regression、Host Bridge SDK client contract 和 package-side 只读 `npm test`;内容工厂 task / write-back / evidence 可由通用 facade 表达,也可穿过 Host Bridge v1。 | 下一刀等待 package owner 稳定后做 P18.5.3 package-side SDK facade / verify;不复刻内容工厂后端能力。 | +| P18.6 | Raw worker 前 gate | 已完成:明确 worker sandbox、resource limit、network / secret policy 进入后续 P19,并记录 P18 不执行 raw worker 的证据。 | P18 不执行 raw worker。 | ## P18.1 实施记录 @@ -465,23 +469,23 @@ npm run verify:gui-smoke -- --reuse-running --timeout-ms 300000 ## 风险与应对 -| 风险 | 影响 | 应对 | -|---|---|---| -| SDK 过厚,变成 Lime internal API 重导出。 | App 绑定内部实现,升级成本高。 | 只暴露 capability facade;禁止 internal import。 | -| SDK 过薄,App 重复造底座。 | 每个 App 重复做 storage、tool、evidence。 | P18 优先封装高频底座和稳定错误语义。 | -| Bridge 被当成私有协议滥用。 | 每个 App 自定义 postMessage,无法治理。 | App 作者只调用 SDK;Host Bridge 保持标准 v1 信封。 | -| Agent task 退化为模型 API。 | 丢失 trace、tool、knowledge、evidence。 | `lime.agent` 必须走 AgentRuntime Surface。 | -| P18 把 P17.5 证据扩大解释。 | 误以为已可发布 marketplace / Cloud 管理台。 | P17.5 只证明正式入口 smoke;P18 仍按 SDK gate 最小面推进。 | -| 与隔壁任务改同一文件。 | 冲突或覆盖。 | P18 不改 AgentRuntime Rust 投影和运行中 GUI 验证链路。 | +| 风险 | 影响 | 应对 | +| ----------------------------------------- | ------------------------------------------- | ---------------------------------------------------------- | +| SDK 过厚,变成 Lime internal API 重导出。 | App 绑定内部实现,升级成本高。 | 只暴露 capability facade;禁止 internal import。 | +| SDK 过薄,App 重复造底座。 | 每个 App 重复做 storage、tool、evidence。 | P18 优先封装高频底座和稳定错误语义。 | +| Bridge 被当成私有协议滥用。 | 每个 App 自定义 postMessage,无法治理。 | App 作者只调用 SDK;Host Bridge 保持标准 v1 信封。 | +| Agent task 退化为模型 API。 | 丢失 trace、tool、knowledge、evidence。 | `lime.agent` 必须走 AgentRuntime Surface。 | +| P18 把 P17.5 证据扩大解释。 | 误以为已可发布 marketplace / Cloud 管理台。 | P17.5 只证明正式入口 smoke;P18 仍按 SDK gate 最小面推进。 | +| 与隔壁任务改同一文件。 | 冲突或覆盖。 | P18 不改 AgentRuntime Rust 投影和运行中 GUI 验证链路。 | ## 决策点 -| 决策点 | 时间 | 判断问题 | Go 条件 | No-Go 动作 | -|---|---|---|---|---| -| D17 | P17.5 后 | 正式入口 smoke 是否稳定? | 已通过:独立 `smoke:agent-apps` 输出 summary。 | 如后续回归失败,不继续扩大 SDK,先修正式入口。 | -| D18 | P18.2 后 | Host Bridge typed router 是否足够稳定? | 已通过:source / origin / appId / entryKey / requestId / typed envelope / stable error mapping 均有测试。 | 如回归失败,暂停 capability adapter 扩展,先修 router。 | -| D19 | P18.4 后 | App-scoped Agent task 是否真正进入 AgentRuntime Surface? | task / trace / evidence 可追溯,App 内可 stream / cancel / retry / submitHostResponse / write-back,并完成 P18.4-H handoff gate。 | 不开放更多 workflow / worker 能力。 | -| D20 | P18 完成 | SDK 是否足够支撑更多 Agent App? | types / schema / mock / contract / GUI smoke 均有证据,且外部 `content-factory-app` 已从手写 bridge wrapper 收敛到标准 SDK facade,并完成 package verify / Lime regression。 | 已满足;进入 owner handoff / 提交边界,不进入 raw worker 或 marketplace。 | +| 决策点 | 时间 | 判断问题 | Go 条件 | No-Go 动作 | +| ------ | -------- | --------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | +| D17 | P17.5 后 | 正式入口 smoke 是否稳定? | 已通过:独立 `smoke:agent-apps` 输出 summary。 | 如后续回归失败,不继续扩大 SDK,先修正式入口。 | +| D18 | P18.2 后 | Host Bridge typed router 是否足够稳定? | 已通过:source / origin / appId / entryKey / requestId / typed envelope / stable error mapping 均有测试。 | 如回归失败,暂停 capability adapter 扩展,先修 router。 | +| D19 | P18.4 后 | App-scoped Agent task 是否真正进入 AgentRuntime Surface? | task / trace / evidence 可追溯,App 内可 stream / cancel / retry / submitHostResponse / write-back,并完成 P18.4-H handoff gate。 | 不开放更多 workflow / worker 能力。 | +| D20 | P18 完成 | SDK 是否足够支撑更多 Agent App? | types / schema / mock / contract / GUI smoke 均有证据,且外部 `content-factory-app` 已从手写 bridge wrapper 收敛到标准 SDK facade,并完成 package verify / Lime regression。 | 已满足;进入 owner handoff / 提交边界,不进入 raw worker 或 marketplace。 | ## 下一刀口径 diff --git a/docs/roadmap/agentruntime/agent-app-runtime-completion-audit.md b/docs/roadmap/agentruntime/agent-app-runtime-completion-audit.md index 778c8e984..dfb1647b6 100644 --- a/docs/roadmap/agentruntime/agent-app-runtime-completion-audit.md +++ b/docs/roadmap/agentruntime/agent-app-runtime-completion-audit.md @@ -1,7 +1,7 @@ # Agent App Runtime 完成审计 -> 状态:current-harness-export-projection-rust-verified -> 更新时间:2026-05-16 +> 状态:current-harness-export-projection-rust-verified-with-product-shell-gaps +> 更新时间:2026-05-17 > 审计目标:验证“Agent App 在业务 App 内完成 AI Agent 工作,且复用 Lime 完整 AgentRuntime / Claw / Tools / Evidence 能力,不跳回通用 Chat、不退化成模型 API 壳”是否已经真正完成。 最新验收结论:内容工厂真实 Lime 宿主 iframe 已跑通“写文案 -> AgentRuntime hidden session -> artifactDocument JSON patch -> Host Bridge artifact replay -> App 页面物化”的主路径,并补上 App 端产物 contract 验收与一次 continuation。最新实现会按 `minimumCount / minimumScriptCount / minimumImagePromptCount` 校验结构化 patch;不足时先物化可审核草稿,再以 `content_factory.copy.continue` 重新进入 Lime AgentRuntime 补齐,且不新增垂直后端命令。Host Bridge subscription 已在成功终态后短轮询 replay 最终 artifact,`AgentRuntimeCapabilityHost` 也会从 `threadRead.artifacts` 补投 `artifact:created` payload;订阅还会监听 `agent_app_runtime:{appId}:{taskId}` Tauri / DevBridge runtime event,把后端 runtime event 直接转发给 App。AgentRuntime profile 事件现在会在同名 `agent_app_runtime:{appId}:{taskId}` event bus 上主动追加 `agent_app_runtime:profileProjection` payload,并携带 canonical `taskEvents`;高价值 `RuntimeAgentEvent` 也会主动追加 `agent_app_runtime:runtimeEventProjection`,其中 `ArtifactSnapshot / FileArtifact` 可直接携带 `workspacePatch / contentFactoryWorkspacePatch`;显式写在 runtime event / timeline metadata 中的 `evidenceRefs / verificationOutcomes` 也会通过 `runtime_evidence_projection_service` 投影为 `evidence:recorded / evidence:verified`。本轮进一步把 `agent_runtime_export_evidence_pack`、`agent_runtime_export_analysis_handoff`、`agent_runtime_export_review_decision_template` 与 `agent_runtime_save_review_decision` 接入同一个 `agent_app_runtime:{appId}:{taskId}` event bus:当 hidden session 的 `runtime_summary.surface=agent_app` 且带 `appId/taskId` 时,导出结果会主动 emit `agent_app_runtime:harnessExportProjection`,把 Evidence Pack root、analysis/review 制品和 completion audit completed 事实投影为 App canonical `taskEvents`。因此 App 不再只能等 `getTask` 副作用获得运行进度、artifact patch、已存在的 evidence refs 或 Harness 导出结果。Rust facade 现在会把多个 capability hint 去重后写入 `agent_app_runtime.capability_workflow` 与 `harness.agent_app_runtime_capability_workflow`;内容工厂复合 output contract 使用 `metadata_only`,不会被强行改写成单一 Claw Skill。Host dispatcher 现在会校验 manifest `toolRefs[].capabilities` allowlist,防止 App 只声明 catalog key 后任意请求 Claw capability。Playwright 中已验证真实 iframe 可通过 replay 回写并显示 `文案=20 / 脚本=6 / 图片需求=5`,控制台 error 为 0。 @@ -37,6 +37,9 @@ | 跨刷新恢复 task/session | done/first-cut | `src/features/agent-app/runtime/agentRuntimeCapabilityHost.ts`、`src/features/agent-app/runtime/agentRuntimeCapabilityHost.test.ts` | runtime task state 写入 Agent App storage;Host 重建后可恢复 `sessionId / turnId` 并继续读取/响应。 | | 内容工厂实际 App 接入 Host Bridge | done/first-cut | `/Users/coso/Documents/dev/ai/limecloud/content-factory-app/src/ui/host-bridge.js`、`src/ui/app.js`、`tests/ui.test.mjs` | 场景、文案、重写、脚本、确认链会先启动 Lime Agent 任务,并保留本地兜底。 | | 内容工厂 App 内显示任务进度,不跳回 Chat | done/first-cut | `/Users/coso/Documents/dev/ai/limecloud/content-factory-app/src/ui/app.js`、`src/ui/host-bridge.js`、Playwright MCP 真实宿主验证 | 新增 `lime:host-agent-task-update`,主生产流通过 Host task subscription 更新“AI 同事任务”面板;页面显示“等待 AgentRuntime 调度或回写进度”,不再把 runtime `idle` 暴露为任务终态。 | +| Agent App 打开时让出业务画布,且用户可手动恢复导航 | done/gui-verified | `src/components/AppSidebar.tsx`、`src/components/AppSidebar.test.tsx`、`npm test -- "src/components/AppSidebar.test.tsx"`、`npm run typecheck`、Playwright MCP `http://127.0.0.1:1420/` | 进入 `agent-app` 时 Lime 主侧栏默认临时折叠到 72px;用户点击“展开导航栏”后本次停留期间保持 272px 展开;离开 Agent App 后仍为进入前的 272px,且临时折叠不写入用户偏好。Playwright 复核中控制台仅有 1 条进入前已有的 `AsterChat` bridge timeout error,未新增侧栏相关错误。 | +| `AI 同事` 是所有 Agent App 的通用 Host 模式,而非内容工厂私有右栏 | partial/product-gap | 内容工厂已有 `AI 同事` 样板;`src/features/agent-app/runtime/hostBridge.test.ts` 已出现 canonical `runtimeProcess` 优先投影;`src/features/agent-app/runtime/agentRuntimeProcess.ts` first-cut 可归一化 timeline / thinking / execution / Skills / model / usage / cost | 产品边界已明确:Host 提供通用 Agent coworker shell / runtimeProcess slot / capability facts,业务 App 只提供页面上下文、任务合同和结果物化。当前还没有把内容工厂私有右栏抽成 Agent App Host 级共享 shell,因此不能宣称所有模块已统一。 | +| 运行过程完成后折叠但不消失,且包含思考、执行、Skill、工具、模型、Token、费用 | partial/code-first-cut | `src/features/agent-app/runtime/agentRuntimeProcess.ts`、`src/features/agent-app/runtime/agentRuntimeProcess.test.ts`、`npm test -- "src/features/agent-app/runtime/agentRuntimeProcess.test.ts"` | first-cut 已能保留完整 timeline,不在数据层截断到最后 16 条,并保留每段流式输出;代码也有 thinking / execution / skillNames / invokedSkillNames / model / usage / cost projection。该文件仍处于并行未跟踪写集,且缺真实 Host 业务流 GUI 复核,所以只算 first-cut evidence,不能算完成。 | | 内容工厂可由 runtime structured patch 驱动 workspace | done/current-artifact-replay-verified | `/Users/coso/Documents/dev/ai/limecloud/content-factory-app/src/ui/app.js`、`tests/ui.test.mjs`、Playwright 最新真实宿主流 | App 会从 task / snapshot / event payload 以及 artifactDocument fenced JSON 中提取 `workspacePatch` / `contentFactoryWorkspacePatch`,并把数组形态的 `sceneTable / contentBatch / scripts / imagePrompts` 归一化成当前 workspace UI 可消费结构;真实 Host Bridge replay 已把最终 artifact 物化为 `文案=20 / 脚本=6 / 图片需求=5`。 | | 内容工厂向 runtime 提供业务上下文,而不是只传 model prompt | done/current-verified | `/Users/coso/Documents/dev/ai/limecloud/content-factory-app/src/ui/app.js` | `runProduction / runScenarios / onlyCopy / runScripts` 均传入 `projectContext`,包含项目、已确认知识条目、readiness、确认链和已有场景摘要,避免 AgentRuntime 因只看到 `materialVersion` 文件名而停在索要资料。 | | 内容工厂结果写回 storage/artifact/evidence | done/first-cut | `/Users/coso/Documents/dev/ai/limecloud/content-factory-app/src/ui/host-bridge.js`、`src/ui/app.js`、`APP.md` | 只使用 manifest 声明的 `scene_table / content_batch / script_batch` 与 `fact_grounding / publish_readiness`。 | @@ -60,6 +63,12 @@ | --- | --- | --- | | `npm run typecheck` | passed | Lime 前端类型边界;覆盖 `agentAppRuntime` 新增 `providerPreference / modelPreference` 类型透传。 | | `npm run typecheck` | passed/current-event-listen | 覆盖 `toolRefs[].capabilities` 类型投影和 Host Bridge runtime event listener 类型边界。 | +| `npm test -- "src/components/AppSidebar.test.tsx"` | passed/current-product-shell | 57 tests passed;覆盖 Agent App 页面进入时主侧栏临时折叠、手动展开后不被自动压回、离开后恢复进入前偏好。 | +| `npm run typecheck` | passed/current-product-shell | 覆盖 AppSidebar 临时折叠状态机和当前工作树 TypeScript 类型边界。 | +| Playwright MCP:Lime 首页 -> 内容工厂 -> 展开导航 -> 返回首页 | passed/current-product-shell | `http://127.0.0.1:1420/`;点击“内容工厂”后侧栏 72px,点击“展开导航栏”后侧栏 272px,等待 1s 仍保持展开,点击“返回 Lime 首页”后保持进入前 272px。控制台 error 总数 1,来自进入前已有 `aster_agent_init` bridge timeout,不是本轮侧栏回归。 | +| Playwright MCP:内容工厂 `AI 同事` 抽屉 | passed/content-factory-sample | 在 Lime Host iframe 内点击“打开 AI 同事”后出现 `AI 同事` dialog,文案说明“运行过程、确认项和证据链都在这里,不挤压当前工作页面”,并展示运行现场、系统建议、确认链、待确认、证据链;点击“关闭 AI 同事”可关闭。该验证只证明内容工厂样板可用,不证明 Host 级通用 shell 已完成。 | +| `npm test -- "src/features/agent-app/runtime/agentRuntimeProcess.test.ts"` | passed/current-process-first-cut | 2 tests passed;覆盖运行过程 timeline 不截断,以及流式输出片段不被数据层折叠丢失。该测试来自并行未跟踪写集,只能证明当前工作树 first-cut 行为。 | +| `npm test -- "src/features/agent-app/runtime/hostBridge.test.ts" "src/features/agent-app/runtime/agentRuntimeProcess.test.ts"` | passed/current-process-first-cut | 16 tests passed;覆盖 Host Bridge canonical `runtimeProcess` 投影、task subscription,以及 runtimeProcess first-cut。该验证仍是当前工作树验证,不代表真实 Host 业务流已经复核。 | | `npm run test:contracts` | passed | Lime Tauri command 四侧、runtime client、harness 与 modality contract。 | | `npm run test:contracts` | passed/current | 本轮新增模型偏好字段和 App task 类型后,命令四侧、runtime client、harness 与 modality contract 仍通过。 | | `npm run test:contracts` | passed/current-artifact-replay | Host Bridge / AgentRuntime artifact replay 没有新增垂直 `content_factory_*` 命令,也没有扩展 deprecated command surface;四侧契约仍通过。 | @@ -126,6 +135,6 @@ ## 5. 完成判定 -按“Agent App 能在 App 内调用完整 Lime AgentRuntime,并完成内容工厂主流程第一刀”的口径,当前已经达到 **current MVP**。真实 Lime 宿主 iframe Host Bridge 已有端到端证据:内容工厂在 Lime 页面内可发现 bridge、发起任务、通过 task subscription 显示进度,使用隐藏 `agent-app-runtime-*` session,不跳回通用 Chat,并把 AgentRuntime 产出的 `contentFactoryWorkspacePatch` 物化成 App 页面内的文案、脚本和图片需求。 +按“Agent App 能在 App 内调用完整 Lime AgentRuntime,并完成内容工厂主流程第一刀”的基础 runtime 口径,当前已经达到 **current MVP**。真实 Lime 宿主 iframe Host Bridge 已有端到端证据:内容工厂在 Lime 页面内可发现 bridge、发起任务、通过 task subscription 显示进度,使用隐藏 `agent-app-runtime-*` session,不跳回通用 Chat,并把 AgentRuntime 产出的 `contentFactoryWorkspacePatch` 物化成 App 页面内的文案、脚本和图片需求。 -按用户要求的“完整实现整体目标”口径,当前仍不能标记 100% 完成:内容工厂 App 端产物 contract 强校验、continuation、Host Bridge terminal artifact replay、真实 iframe 物化、seeded manifest 写回类型对齐、high-level Host capability gate、Claw capability allowlist gate、多 capability workflow metadata、runtime event listen、`getTask` snapshot projection、AgentRuntime profile event 主动 App task projection,以及高价值 RuntimeAgentEvent 主动 App task projection 已完成 first-cut。剩余缺口已从“是否能主动推送 App 任务事件 / artifact patch / 显式 evidence refs / Harness 导出结果”缩小为:capability catalog 仍缺真正多能力执行编排、后端/cross-surface policy owner 和跨 surface 统一 owner;新 Harness export projection 也还没有经过重启后的 Playwright 真实生成流覆盖。下一刀应优先补 **capability policy owner + 真正多能力执行编排**,并在安全窗口重启宿主后用 Playwright 跑一次包含 Evidence Pack / analysis / review projection 的真实业务流。当前整体目标完成度约 **99.92%**。 +按用户最新要求的“完整实现整体目标”口径,当前仍不能标记 100% 完成,也不能只按 runtime MVP 的通过情况宣称完成:内容工厂 App 端产物 contract 强校验、continuation、Host Bridge terminal artifact replay、真实 iframe 物化、seeded manifest 写回类型对齐、high-level Host capability gate、Claw capability allowlist gate、多 capability workflow metadata、runtime event listen、`getTask` snapshot projection、AgentRuntime profile event 主动 App task projection,以及高价值 RuntimeAgentEvent 主动 App task projection 已完成 first-cut;主侧栏进入 Agent App 自动让出空间且可手动展开已完成代码验证。剩余缺口是产品化主链缺口:`AI 同事` 还没有沉淀成所有 Agent App 复用的 Host 级 shell;`runtimeProcess` 的不丢过程、思考、执行、Skill、模型、Token、费用展示仍缺真实 Host 流 GUI 复核;内容工厂知识库、项目驾驶舱、交付、复盘等页面仍需全部按 Agent Run 重设计或下线伪 AI 动作;capability catalog 仍缺真正多能力执行编排、后端/cross-surface policy owner 和跨 surface 统一 owner;新 Harness export projection 也还没有经过重启后的 Playwright 真实生成流覆盖。下一刀应优先补 **Host 通用 AI 同事 shell + runtimeProcess 合入验证**,随后再补 **capability policy owner + 真正多能力执行编排**,并在安全窗口重启宿主后用 Playwright 跑一次包含 Evidence Pack / analysis / review projection 的真实业务流。当前 Agent App runtime MVP 完成度约 **90%**;按“内容工厂所有 AI 页面都在 App 内完整展示 Lime Agent 能力”的产品目标,整体完成度约 **65%**。 diff --git a/docs/roadmap/agentruntime/app-surface-runtime.md b/docs/roadmap/agentruntime/app-surface-runtime.md index 1bd91d775..30d6ec281 100644 --- a/docs/roadmap/agentruntime/app-surface-runtime.md +++ b/docs/roadmap/agentruntime/app-surface-runtime.md @@ -37,22 +37,59 @@ Content Factory / Agent App ## 3. Surface 边界 -| 层 | current 责任 | 禁止事项 | -| --- | --- | --- | -| Agent App UI | 业务形态、表单、资料版本、场景、文案、素材、交付和复盘 | 直接访问 Tauri / Node / 宿主 DOM,绕过 Host Bridge | -| Agent App Workflow | 从 App 状态组装 task input、expected output、review/write-back 目标 | 自己维护第二套 Agent task runtime | -| Host Bridge | iframe sandbox、主题、语言、导航、capability transport、安全校验 | 承载业务 Agent 逻辑 | -| Agent App Runtime Surface | 把 App task 映射到 AgentRuntime control plane,附加 app provenance | 复制 Claw skill launch 或绕过 `agent_runtime_*` 主链 | -| AgentRuntime | session/thread/turn/task、queue、event、tool、policy、evidence facts | 决定垂直业务 UI 长什么样 | -| Claw Capability Catalog | 把 `@配图`、`@搜索`、`@研报` 等能力变成可复用 capability | 继续把能力绑定死在 Chat/Inputbar 字符串分支 | -| Artifact / Evidence | 产物、引用、工具调用、知识版本、人工确认和验证记录 | 让最终结果只停留在聊天文本 | +| 层 | current 责任 | 禁止事项 | +| ------------------------- | -------------------------------------------------------------------- | ---------------------------------------------------- | +| Agent App UI | 业务形态、表单、资料版本、场景、文案、素材、交付和复盘 | 直接访问 Tauri / Node / 宿主 DOM,绕过 Host Bridge | +| Agent App Workflow | 从 App 状态组装 task input、expected output、review/write-back 目标 | 自己维护第二套 Agent task runtime | +| Host Bridge | iframe sandbox、主题、语言、导航、capability transport、安全校验 | 承载业务 Agent 逻辑 | +| Agent App Runtime Surface | 把 App task 映射到 AgentRuntime control plane,附加 app provenance | 复制 Claw skill launch 或绕过 `agent_runtime_*` 主链 | +| AgentRuntime | session/thread/turn/task、queue、event、tool、policy、evidence facts | 决定垂直业务 UI 长什么样 | +| Claw Capability Catalog | 把 `@配图`、`@搜索`、`@研报` 等能力变成可复用 capability | 继续把能力绑定死在 Chat/Inputbar 字符串分支 | +| Artifact / Evidence | 产物、引用、工具调用、知识版本、人工确认和验证记录 | 让最终结果只停留在聊天文本 | + +### 运行过程封装边界 + +`thinking / text delta / tool input-output / Skill / routing / cost / usage / artifact / evidence / blocked / completed` 这类 Claw 式运行过程属于 Lime 主 App 的 runtime projection,不属于垂直业务 App。Agent App 只允许消费 Host Bridge 下发的 `runtimeProcess` / `process` 标准视图,并用它做业务展示: + +- `runtimeProcess.timeline`:可折叠的运行现场,运行中展开,终态默认折叠但不丢失。 +- `runtimeProcess.streamText / thinkingText / executionText`:正文、思考和执行片段。 +- `runtimeProcess.model / usage / cost`:模型路由、Token 和费用事实。 +- `runtimeProcess.skillNames / invokedSkillNames`:本轮 required Skills 与真实调用情况。 +- `runtimeProcess.terminal / collapsedByDefault`:由主 App 判断运行生命周期,App 不再自己推断底层状态。 + +因此内容工厂、未来销售助手或交付助手都不应该复制一份 `agent-runtime-process` 解析器;它们只处理业务表单、业务产物、人工确认和工作流递进。若某个 App 发现 process 信息缺失,应该回报 Host / Runtime projection 缺口,而不是在 App 内补第二套底层归一化。 事实源声明: ```text Agent App 的完整 AI 能力只允许向 AgentRuntime Surface + AgentRuntime facts 收敛。 +Agent App 的运行过程 UI 事实只允许向 AgentRuntimeCapabilityHost + HostBridge runtimeProcess 收敛。 ``` +## 3.1 全量 `lime.*` 能力与 AgentRuntime 的关系 + +Agent App 侧全量能力路线图见 [`../agentapp/p18-7-full-lime-capability-surface.md`](../agentapp/p18-7-full-lime-capability-surface.md)。本文只固定 AgentRuntime owner 边界:凡涉及 AI 执行、模型、Skill、工具、记忆、用量、证据的能力,生产事实必须继续向 AgentRuntime / ToolRuntime / Desktop Host 收敛。 + +Agent App 不再只抽象 `lime.agent / lime.workflow` 两个入口。完整 Lime AI 能力必须拆成三类,并统一登记在 `src/features/agent-app/sdk/capabilityCatalog.ts`: + +| 类型 | Capability | AgentRuntime 关系 | App 边界 | +| ---------- | ------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | +| 执行主链 | `lime.agent`、`lime.workflow`、`lime.automation`、`lime.tasks` | 进入 AgentRuntime session/thread/turn/task/queue。 | App 只定义业务输入、状态递进和结果写回。 | +| 智能体资源 | `lime.models`、`lime.skills`、`lime.memory`、`lime.context`、`lime.usage` | 从 AgentRuntime request metadata、runtime facts、read model、telemetry 投影。 | App 不保存 provider key,不复制 Skill runtime,不自建 token/cost 统计。 | +| 工具与集成 | `lime.tools`、`lime.mcp`、`lime.browser`、`lime.search`、`lime.documents`、`lime.media`、`lime.terminal`、`lime.connectors` | 经 ToolRuntime / MCP bridge / Browser / Media / Document runtime 接入 AgentRuntime evidence。 | App 声明需求和消费结构化结果,不直连底层服务。 | +| 数据与证据 | `lime.storage`、`lime.files`、`lime.knowledge`、`lime.artifacts`、`lime.evidence` | 与 AgentRuntime task provenance、artifact refs、knowledge refs 关联。 | App 定义业务对象和产物类型,Lime 负责命名空间、引用和审计。 | +| 治理与宿主 | `lime.ui`、`lime.events`、`lime.workspace`、`lime.policy`、`lime.secrets`、`lime.settings`、`lime.review`、`lime.capabilities` | 作为 Host / policy / readiness projection 约束 runtime 调用。 | App 做展示和降级,不绕过 Host Bridge。 | + +事实源声明补强: + +```text +所有可给 Agent App 使用的 Lime 功能,名称和边界先进入 lime.* capability catalog。 +所有涉及 AI 执行、模型、Skill、工具、记忆、用量、证据的能力,生产事实必须继续向 AgentRuntime / ToolRuntime / Desktop Host 收敛。 +业务 App 只消费 SDK facade 与 runtimeProcess projection,不拥有底层执行事实源。 +``` + +这解决内容工厂当前暴露出来的问题:如果 App 需要完整 Lime AI 能力,不是把 Claw UI 嵌进去,也不是让 App 调模型 API,而是把 Claw 已有的图片、搜索、研报、PDF、总结、PPT、浏览器、发布等能力拆成 `lime.skills / lime.tools / lime.search / lime.media / lime.documents / lime.browser / lime.artifacts / lime.evidence` 等能力,再由 `lime.agent.startTask` 或 `lime.workflow.start` 编排。缺失的能力只允许在 catalog/profile 中显示为 unavailable,不允许由业务 App 临时 mock 成成功。 + ## 4. current / compat / deprecated / dead ### current @@ -122,17 +159,17 @@ humanReview = true 最小事件: -| 事件 | App 内展示 | -| --- | --- | -| `task:started` | 本轮任务已启动 | -| `task:contextChecked` | 资料、场景、脚本和知识绑定检查结果 | -| `task:missingContextRequested` | 在当前表单或侧栏追问缺口 | -| `task:toolCall` | 搜索、知识检索、图片、报告、PDF 等工具进度 | -| `task:citation` | 资料、场景、网页、文件引用 | -| `task:partialArtifact` | 草稿文案、素材 brief、脚本片段 | -| `task:reviewRequested` | 用户确认、编辑、拒绝或重试 | -| `task:completed` | 写回完成,可导出交付物 | -| `artifact:created` / `evidence:recorded` | 交付物和证据已落库 | +| 事件 | App 内展示 | +| ---------------------------------------- | ------------------------------------------ | +| `task:started` | 本轮任务已启动 | +| `task:contextChecked` | 资料、场景、脚本和知识绑定检查结果 | +| `task:missingContextRequested` | 在当前表单或侧栏追问缺口 | +| `task:toolCall` | 搜索、知识检索、图片、报告、PDF 等工具进度 | +| `task:citation` | 资料、场景、网页、文件引用 | +| `task:partialArtifact` | 草稿文案、素材 brief、脚本片段 | +| `task:reviewRequested` | 用户确认、编辑、拒绝或重试 | +| `task:completed` | 写回完成,可导出交付物 | +| `artifact:created` / `evidence:recorded` | 交付物和证据已落库 | 当前已落地第一刀:`agent_app_runtime_get_task` 会从 `AgentRuntimeThreadReadModel` 投影 `taskStatus` 与 `taskEvents`,先覆盖 queued / progress / missing context / review request / tool call / artifact created / evidence recorded / evidence verified / completed / cancelled / error / incident。AgentRuntime profile event 生成处也会主动 emit `agent_app_runtime:profileProjection`,把 `turn.* / tool.* / action.* / routing.* / model.*` 投影成 App canonical `taskEvents`;高价值 `RuntimeAgentEvent` 会主动 emit `agent_app_runtime:runtimeEventProjection`,其中 `ArtifactSnapshot / FileArtifact` 可直接携带 `workspacePatch / contentFactoryWorkspacePatch`,显式写入 runtime event / timeline metadata 的 `evidenceRefs / verificationOutcomes` 也会通过 `runtime_evidence_projection_service` 投影为 `evidence:recorded / evidence:verified`。`AgentAppRuntimePage` 已消费这组 snapshot / profile / runtime projection 事件并转成 App 可见的 `AgentAppTaskStreamEvent`;App 也可以通过 `lime.agent.submitHostResponse` 把 ask_user / elicitation / tool_confirmation 响应回 `agent_app_runtime_submit_host_response`。`AgentRuntimeCapabilityHost` 已把 `taskId / sessionId / turnId / request / provenance` 持久化到 Agent App storage,刷新或重建 Host 后可以继续 `getTask / listTasks / submitHostResponse`,并能从 `threadRead.artifacts` 补投 `artifact:created` payload。Host Bridge 已支持 `capability:subscribe / capability:unsubscribe / capability:event`:订阅时会通过 `safeListen` 监听 `agent_app_runtime:{appId}:{taskId}` Tauri / DevBridge runtime event,并把后端 runtime event 和 projection 推给 iframe;同时保留 Host 侧 `getTask` 轮询作为 snapshot / artifact replay fallback。成功终态如果暂未带 workspace patch,会继续短轮询最多 4 次等待最终 artifact replay,已经能让 App 留在当前页面持续收到 task update、runtime event 和最终 artifact patch。Harness export projection 也已接入同一 event bus:Evidence Pack / analysis / review / save review 导出成功后,会把导出 root、制品列表和 completion audit completed 事实投影为 App task events。 diff --git a/docs/roadmap/agentruntime/host-agent-run-ui-sdk.md b/docs/roadmap/agentruntime/host-agent-run-ui-sdk.md new file mode 100644 index 000000000..0bbdefa4a --- /dev/null +++ b/docs/roadmap/agentruntime/host-agent-run-ui-sdk.md @@ -0,0 +1,157 @@ +# Host Agent Run UI SDK + +> 状态:current-minimum-slice +> 更新时间:2026-05-17 +> 目标:把 Claw 类 AI 运行现场上移为 Lime Host 通用 UI 能力,让内容工厂和后续 Agent App 不再重复实现思考、执行、Skill、工具、模型、Token、费用、证据链和确认链展示。 + +## 1. 为什么需要这一层 + +Agent App 的价值不是把 Lime Chat 换成 iframe,也不是让业务 App 直接调模型 API。Agent App 应该把业务流程做成合适的产品形态,同时继续复用 Lime 已有 AgentRuntime / Claw / ToolRuntime / Skill / Evidence 能力。 + +内容工厂暴露出的核心问题是:业务 App 一旦需要真实 AI Agent,就会被迫重新实现一套“AI 同事”侧栏,包括流式输出、思考过程、工具调用、Skill 调用、模型选择、Token、费用、Evidence 和人工确认。这会带来三类错误: + +- 每个 App 复制一份运行 UI,体验和数据口径漂移。 +- App 误以为自己需要直连模型 API,退化成普通 Web App。 +- Claw 已有能力无法共享,Lime 的 AgentRuntime 事实源被拆散。 + +因此需要 Host 级 `lime.ui.openAgentRun / updateAgentRun / closeAgentRun`,由 Lime 主 App 提供统一运行面板,业务 App 只声明“我要展示哪次 Agent Run”。 + +## 2. 责任边界 + +| 层 | 应该做什么 | 不应该做什么 | +| --- | --- | --- | +| Lime Host UI | 提供通用 Agent Run 面板、抽屉/弹窗/子页容器、运行过程折叠、模型/Token/费用/Skill/Evidence/确认链展示 | 写死内容工厂业务字段,或让每个 App 自己复制 Claw 渲染 | +| AgentRuntime / ToolRuntime | 产出 `runtimeProcess`、timeline、stream、thinking、execution、tool/skill、artifact、evidence、usage、cost 事实 | 决定业务页面布局,或把结果只塞进聊天文本 | +| SDK / Host Bridge | 暴露 `lime.ui.openAgentRun/updateAgentRun/closeAgentRun`,把 App 请求路由到 Host UI | 让 App 绕过 Host DOM 或私有 postMessage 协议 | +| 业务 Agent App | 定义业务流程、任务 contract、expected output、artifact adapter、人工确认和写回 | 自建底层 Agent Runtime、直连 provider key、复制 Skill runtime、长期维护私有 AI 过程 UI | +| 内容工厂 | 规划项目资料、场景、文案、脚本、图片需求、交付和复盘 | 把所有 AI 思考/工具/Skill/用量渲染逻辑长期留在 App 内 | + +边界结论: + +```text +Agent App 可以有很多个,但 Agent Run UI 只能有一个 Host 事实源。 +业务 App 可以决定何时打开、如何命名、如何把产物落回业务状态;不能拥有模型、Skill、Tool、Token、费用和 Evidence 的底层事实源。 +``` + +## 3. SDK 合同 + +### 3.1 `lime.ui.openAgentRun` + +App 在启动或恢复 Agent task 后调用: + +```ts +await lime.ui.openAgentRun({ + taskId, + bridgeAction: "content_factory.production", + title: "生成内容批次", + mode: "drawer", + expectedOutput: { artifactKind: "content_batch" }, +}); +``` + +Host 返回: + +```ts +{ + opened: true, + surface: "host_agent_run", + mode: "drawer", + taskId +} +``` + +### 3.2 `lime.ui.updateAgentRun` + +App 或 Host Bridge 在拿到 task snapshot、subscription event、`runtimeProcess`、runtime facts 后调用,用于刷新同一个 Host 面板: + +```ts +await lime.ui.updateAgentRun({ + taskId, + title, + runtimeProcess, + events, + task, + snapshot, +}); +``` + +Host UI 必须保证:运行中可展开过程;终态默认折叠但过程不消失。 + +### 3.3 `lime.ui.closeAgentRun` + +App 可以在用户关闭业务页面、取消任务、或希望释放视图时调用。关闭 UI 不等于取消 Agent task;取消仍走 `lime.agent.cancelTask`。 + +## 4. 渲染原则 + +Host 统一 Agent Run UI 第一刀只做通用容器,不急着复刻完整 Claw: + +- 展示标题、taskId、bridgeAction 和来源 App。 +- 展示模型、Token、费用、Skill 约束和真实调用情况。 +- 读取 `runtimeProcess.timeline`,运行中展开,完成后折叠但不删除。 +- 读取 `events / taskEvents / runtimeFacts`,把确认链、交付物和 Evidence 单独收成事实栏,避免业务 App 只在自家面板里展示这些关键过程。 +- 按 `timeline.kind` 呈现 routing / skill / tool / execution / artifact / warning / completed 的轻量语义标记,让 Host 面板接近 Claw 的运行现场,而不是普通日志列表。 +- 展示 `thinkingText / executionText / streamText` 的入口,但不让业务 App 自己解析底层事件。 +- 后续可把 Claw 已有 renderer 下沉成共享 `AgentRunRenderer`,由 Host UI 调用,而不是 iframe App import Claw React 组件。 + +当前代码边界: + +- `src/features/agent-app/ui/AgentRunHostDrawer.tsx` 是 Host 级 Agent Run UI 的第一份可复用组件。 +- `AgentRunProcessPanel` 是抽屉、弹窗或未来 Claw 共享 renderer 都可复用的运行过程面板;Host Shell 负责容器,Process Panel 负责运行事实渲染。 +- `src/features/agent-app/ui/AgentAppRuntimePage.tsx` 只负责 iframe runtime、Host Bridge 生命周期和 `lime.ui.*AgentRun` state 连接。 +- 后续如果 Claw renderer 抽包,应该替换 `AgentRunHostDrawer` 内部 timeline/text 渲染器,而不是再把渲染逻辑塞回 `AgentAppRuntimePage` 或业务 App。 + +## 5. 内容工厂过渡策略 + +当前内容工厂已有 App 内 `AI 同事` 面板,这是过渡实现,不是目标架构。退出条件: + +1. 内容工厂启动 Agent task 后调用 `lime.ui.openAgentRun`。 +2. task subscription / stream / snapshot 更新时调用 `lime.ui.updateAgentRun`。 +3. App 内面板只作为 Host UI 不可用时的 fallback。 +4. 所有新 App 默认接 Host UI SDK,不再新写私有 `host-task-process` 解析器。 +5. 真实 Claw 过程渲染、模型选择、Token、费用、Evidence、确认链继续在 Lime Host / AgentRuntime 层收敛。 + +## 6. 本轮最小交付 + +本轮先实现最小垂直切片: + +- SDK contract / catalog 增加 `openAgentRun / updateAgentRun / closeAgentRun`。 +- Host Bridge 拦截 `lime.ui` capability invoke,支持 toast / navigation / download / snapshot 和 Agent Run UI 请求。 +- `AgentAppRuntimePage` 渲染 Host 级统一 Agent Run 抽屉。 +- 内容工厂调用 Host UI SDK,保留 App 内面板作为 fallback。 + +不在本轮完成: + +- 不把 Claw 的完整 React renderer 抽包。 +- 不新增 `content_factory_*` Tauri 命令。 +- 不动 0.7 manifest/reference CLI 并行写集。 +- 不把 App 内所有页面一次性重设计完成。 + +## 7. 验收口径 + +最小验收: + +- App 通过 SDK 调用 `lime.ui.openAgentRun` 后,Host 页面出现通用 AI 运行抽屉。 +- 抽屉能显示 taskId、标题、模型、Token、费用、Skill 和 timeline。 +- `updateAgentRun` 后 Host 抽屉内容刷新。 +- `closeAgentRun` 只关闭 UI,不取消 Agent task。 +- 内容工厂在 Host 不支持该能力时仍能用本地 fallback,不中断业务流。 + +完整验收: + +- Host UI 使用 Claw 共享 renderer,而不是重新复制样式。 +- Evidence Pack、analysis handoff、review decision、人工确认都能在同一个 Host Run UI 中追踪。 +- 内容工厂全流程页面不再长期维护私有底层 Agent 过程解析。 + +## 8. 当前完成审计 + +| 要求 | 现有证据 | 结论 | +| --- | --- | --- | +| App 能通过 SDK 打开 Host Run UI | `AgentAppRuntimePage.test.tsx` 覆盖 `lime.ui.openAgentRun / updateAgentRun / closeAgentRun` | done/first-cut | +| 完成后折叠但过程不消失 | Host Run dock / drawer 保留同一份 `agentRunUi`,测试断言终态仍能看到历史 timeline | done/first-cut | +| 思考、执行、成稿流式输出不被 App 私有解析 | `AgentRunHostDrawer.tsx` 直接读取 `runtimeProcess.thinkingText / executionText / streamText` | done/first-cut | +| Tool / Skill / 模型路由 / 产物等过程可区分 | `AgentRunHostDrawer.tsx` 读取 `timeline.kind` 并输出 `data-agent-run-timeline-kind` 语义标记;测试覆盖 `routing / skill / tool / execution / completed` | done/first-cut | +| Host UI 不只绑定抽屉一种形态 | `AgentRunProcessPanel` 从 `AgentRunHostDrawer` 抽出,后续可被 modal/page/Claw shared renderer 复用;测试断言 `agent-run-process-panel` 存在 | done/first-cut | +| 模型、Token、费用、Skill 由 Host 统一展示 | `AgentRunMetricCards` 读取 `runtimeProcess.model / usage / cost / skillNames / invokedSkillNames`;测试覆盖模型、Token、费用、Skill | done/first-cut | +| 证据链、交付物、确认链不丢 | `AgentRunFactRail` 读取 `events / taskEvents / runtimeFacts`;测试覆盖 review request、artifact、evidence | done/first-cut | +| 真正复用 Claw renderer | 目前仍是 Host Run 专用 renderer,未从 Claw 抽出共享 React renderer | missing | +| 内容工厂移除私有 AI 面板 | 内容工厂已接 Host UI SDK,但 App 内 AI 面板仍作为 fallback | partial | diff --git a/scripts/agent-apps-smoke.mjs b/scripts/agent-apps-smoke.mjs index d7be17adb..93ea8c787 100644 --- a/scripts/agent-apps-smoke.mjs +++ b/scripts/agent-apps-smoke.mjs @@ -1,11 +1,15 @@ #!/usr/bin/env node import fs from "node:fs"; +import { execFile } from "node:child_process"; import os from "node:os"; import path from "node:path"; import process from "node:process"; +import { promisify } from "node:util"; import { chromium } from "playwright"; +const execFileAsync = promisify(execFile); + const DEFAULTS = { appUrl: "http://127.0.0.1:1420/", healthUrl: "http://127.0.0.1:3030/health", @@ -13,6 +17,9 @@ const DEFAULTS = { intervalMs: 1_000, evidenceDir: path.join(process.cwd(), ".lime", "qc", "gui-evidence", "agent-apps"), prefix: "agent-apps-smoke", + includeContentFactoryActionE2e: false, + includeContentFactoryCompletionE2e: false, + completionTimeoutMs: 90_000, }; const ACCOUNT_MENU_BUTTON_SELECTOR = '[data-testid="app-sidebar-account-button"]'; @@ -53,6 +60,18 @@ function parseArgs(argv) { if (arg === "--prefix" && argv[index + 1]) { options.prefix = String(argv[index + 1]).trim(); index += 1; + continue; + } + if (arg === "--include-content-factory-action-e2e") { + options.includeContentFactoryActionE2e = true; + } + if (arg === "--include-content-factory-completion-e2e") { + options.includeContentFactoryActionE2e = true; + options.includeContentFactoryCompletionE2e = true; + } + if (arg === "--completion-timeout-ms" && argv[index + 1]) { + options.completionTimeoutMs = Number(argv[index + 1]); + index += 1; } } return options; @@ -72,6 +91,836 @@ function logStage(stage) { console.log(`[smoke:agent-apps] stage=${stage}`); } +function resolveInvokeUrl(healthUrl) { + try { + const url = new URL(healthUrl); + url.pathname = "/invoke"; + url.search = ""; + url.hash = ""; + return url.toString(); + } catch { + return "http://127.0.0.1:3030/invoke"; + } +} + +function sanitizeDiagnosticText(value) { + const sanitized = String(value ?? "") + .replace( + /((?:api[_-]?key|authorization|password|secret|session|token)[^=\s]*=)(["']?)[^\s"']+/gi, + "$1$2[redacted]", + ) + .replace(/(Bearer\s+)[A-Za-z0-9._~+/=-]+/gi, "$1[redacted]"); + return sanitized.length > 1_600 + ? `${sanitized.slice(0, 1_600)}... [truncated ${sanitized.length - 1_600} chars]` + : sanitized; +} + +function sanitizeDiagnosticJson(value, depth = 0) { + if (depth > 5) { + return "[truncated-depth]"; + } + if (typeof value === "string") { + return sanitizeDiagnosticText(value); + } + if ( + typeof value === "number" || + typeof value === "boolean" || + value === null || + value === undefined + ) { + return value ?? null; + } + if (Array.isArray(value)) { + return value.slice(0, 25).map((item) => sanitizeDiagnosticJson(item, depth + 1)); + } + if (typeof value === "object") { + return Object.fromEntries( + Object.entries(value) + .slice(0, 80) + .map(([key, item]) => [key, sanitizeDiagnosticJson(item, depth + 1)]), + ); + } + return sanitizeDiagnosticText(String(value)); +} + +async function execFileText(command, args, options = {}) { + try { + const result = await execFileAsync(command, args, { + maxBuffer: options.maxBuffer ?? 1024 * 1024, + timeout: options.timeoutMs ?? 2_000, + windowsHide: true, + }); + return { + ok: true, + stdout: String(result.stdout ?? ""), + stderr: String(result.stderr ?? ""), + }; + } catch (error) { + return { + ok: false, + error: error instanceof Error ? error.message : String(error), + stdout: + typeof error === "object" && error !== null && "stdout" in error + ? String(error.stdout ?? "") + : "", + stderr: + typeof error === "object" && error !== null && "stderr" in error + ? String(error.stderr ?? "") + : "", + }; + } +} + +function parseUnixProcessLine(line) { + const match = line.match(/^\s*(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(.*)$/); + if (!match) { + return null; + } + return { + pid: Number(match[1]), + ppid: Number(match[2]), + pgid: Number(match[3]), + stat: match[4], + etime: match[5], + command: sanitizeDiagnosticText(match[6]), + cwd: null, + }; +} + +function shouldProbeUnixCwd(processInfo) { + const command = processInfo.command.toLowerCase(); + return ( + command.includes("content-factory-app") || + command.includes("agent-apps-smoke") || + command.includes("verify-gui-smoke") || + /\b(npm|node|pnpm|yarn|vite)\b/.test(command) + ); +} + +function processMatchReasons(processInfo) { + const command = String(processInfo.command ?? "").toLowerCase(); + const cwd = String(processInfo.cwd ?? "").replaceAll("\\", "/").toLowerCase(); + const shellWrapper = /^\/bin\/(?:ba|z|c|k)?sh\b.*\s-c\s/.test(command); + const reasons = []; + + if (command.includes("content-factory-app")) { + reasons.push("command:content-factory-app"); + } + if (!shellWrapper && command.includes("agent-apps-smoke")) { + reasons.push("command:agent-apps-smoke"); + } + if (!shellWrapper && command.includes("verify-gui-smoke")) { + reasons.push("command:verify-gui-smoke"); + } + if (command.includes(" 3030") || command.includes(":3030")) { + reasons.push("command:3030"); + } + if (command.includes(" 1420") || command.includes(":1420")) { + reasons.push("command:1420"); + } + if (cwd.endsWith("/content-factory-app") || cwd.includes("/content-factory-app/")) { + reasons.push("cwd:content-factory-app"); + } + + return reasons; +} + +async function readUnixProcessCwd(pid) { + const result = await execFileText("lsof", ["-a", "-p", String(pid), "-d", "cwd", "-Fn"], { + maxBuffer: 64 * 1024, + timeoutMs: 1_000, + }); + if (!result.ok) { + return null; + } + const match = result.stdout.match(/^n(.+)$/m); + return match ? sanitizeDiagnosticText(match[1]) : null; +} + +async function collectUnixProcessSnapshot() { + const errors = []; + const result = await execFileText("ps", ["-axo", "pid,ppid,pgid,stat,etime,command"], { + maxBuffer: 4 * 1024 * 1024, + timeoutMs: 2_000, + }); + + if (!result.ok) { + return { + platform: process.platform, + collectedAt: new Date().toISOString(), + processCount: 0, + probedCwdCount: 0, + processes: [], + errors: [ + { + command: "ps", + error: result.error, + stderr: sanitizeDiagnosticText(result.stderr), + }, + ], + }; + } + + const processes = result.stdout + .split("\n") + .slice(1) + .map(parseUnixProcessLine) + .filter(Boolean); + const cwdCandidates = processes.filter(shouldProbeUnixCwd).slice(0, 160); + + await Promise.all( + cwdCandidates.map(async (processInfo) => { + const cwd = await readUnixProcessCwd(processInfo.pid); + if (cwd) { + processInfo.cwd = cwd; + } + }), + ); + + const matchedProcesses = processes + .map((processInfo) => ({ + ...processInfo, + matchReasons: processMatchReasons(processInfo), + })) + .filter((processInfo) => processInfo.matchReasons.length > 0) + .slice(0, 80); + + return { + platform: process.platform, + collectedAt: new Date().toISOString(), + filters: { + cwdBasename: "content-factory-app", + commandHints: ["content-factory-app", "agent-apps-smoke", "verify-gui-smoke", "3030", "1420"], + }, + processCount: processes.length, + probedCwdCount: cwdCandidates.length, + processes: matchedProcesses, + errors, + }; +} + +async function collectWindowsProcessSnapshot() { + const result = await execFileText( + "powershell.exe", + [ + "-NoProfile", + "-Command", + "Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,CommandLine,ExecutablePath | ConvertTo-Json -Compress", + ], + { maxBuffer: 4 * 1024 * 1024, timeoutMs: 3_000 }, + ); + + if (!result.ok) { + return { + platform: process.platform, + collectedAt: new Date().toISOString(), + processCount: 0, + probedCwdCount: 0, + processes: [], + errors: [ + { + command: "powershell.exe Get-CimInstance Win32_Process", + error: result.error, + stderr: sanitizeDiagnosticText(result.stderr), + }, + ], + }; + } + + let parsed = []; + try { + const payload = JSON.parse(result.stdout || "[]"); + parsed = Array.isArray(payload) ? payload : [payload]; + } catch (error) { + return { + platform: process.platform, + collectedAt: new Date().toISOString(), + processCount: 0, + probedCwdCount: 0, + processes: [], + errors: [ + { + command: "powershell.exe Get-CimInstance Win32_Process", + error: error instanceof Error ? error.message : String(error), + }, + ], + }; + } + + const processes = parsed + .map((item) => ({ + pid: Number(item.ProcessId), + ppid: Number(item.ParentProcessId), + command: sanitizeDiagnosticText(item.CommandLine ?? item.ExecutablePath ?? ""), + cwd: null, + })) + .map((processInfo) => ({ + ...processInfo, + matchReasons: processMatchReasons(processInfo), + })) + .filter((processInfo) => processInfo.matchReasons.length > 0) + .slice(0, 80); + + return { + platform: process.platform, + collectedAt: new Date().toISOString(), + filters: { + cwdBasename: "content-factory-app", + commandHints: ["content-factory-app", "agent-apps-smoke", "verify-gui-smoke", "3030", "1420"], + }, + processCount: parsed.length, + probedCwdCount: 0, + processes, + errors: [], + }; +} + +async function collectExternalDevProcessSnapshot() { + if (process.platform === "win32") { + return collectWindowsProcessSnapshot(); + } + return collectUnixProcessSnapshot(); +} + +async function readJsonWithTimeout(url, init, timeoutMs) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(url, { + ...init, + signal: controller.signal, + }); + const text = await response.text(); + let body = text; + try { + body = JSON.parse(text); + } catch { + // Keep raw text for diagnostics. + } + return { + ok: response.ok, + status: response.status, + body, + }; + } catch (error) { + return { + ok: false, + error: error instanceof Error ? error.message : String(error), + }; + } finally { + clearTimeout(timeout); + } +} + +function isObjectRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function findStringByPattern(value, pattern, depth = 0) { + if (depth > 7 || value == null) { + return ""; + } + if (typeof value === "string") { + return value.match(pattern)?.[0] ?? ""; + } + if (Array.isArray(value)) { + for (const item of value) { + const found = findStringByPattern(item, pattern, depth + 1); + if (found) { + return found; + } + } + return ""; + } + if (isObjectRecord(value)) { + for (const item of Object.values(value)) { + const found = findStringByPattern(item, pattern, depth + 1); + if (found) { + return found; + } + } + } + return ""; +} + +function findAgentAppTaskId(value) { + return findStringByPattern(value, /agent-app-task-[a-z0-9-]+/i); +} + +function findAgentAppSessionId(value) { + return findStringByPattern(value, /agent-app-runtime-[a-z0-9-]+/i); +} + +function findValueByKeys(value, keys, depth = 6) { + if (depth < 0 || value == null) { + return undefined; + } + if (Array.isArray(value)) { + for (const item of value) { + const found = findValueByKeys(item, keys, depth - 1); + if (found !== undefined && found !== null && found !== "") { + return found; + } + } + return undefined; + } + if (!isObjectRecord(value)) { + return undefined; + } + for (const key of keys) { + const candidate = value[key]; + if (candidate !== undefined && candidate !== null && candidate !== "") { + return candidate; + } + } + for (const item of Object.values(value)) { + const found = findValueByKeys(item, keys, depth - 1); + if (found !== undefined && found !== null && found !== "") { + return found; + } + } + return undefined; +} + +function findObjectByKeys(value, keys, depth = 6) { + const found = findValueByKeys(value, keys, depth); + return isObjectRecord(found) ? found : null; +} + +function valueContainsPattern(value, pattern, depth = 0) { + if (depth > 7 || value == null) { + return false; + } + if (typeof value === "string") { + return pattern.test(value); + } + if (Array.isArray(value)) { + return value.some((item) => valueContainsPattern(item, pattern, depth + 1)); + } + if (!isObjectRecord(value)) { + return false; + } + return Object.entries(value).some( + ([key, item]) => pattern.test(key) || valueContainsPattern(item, pattern, depth + 1), + ); +} + +function hasContentFactoryWorkspacePatchValue(value, depth = 0) { + if (depth > 8 || value == null) { + return false; + } + if (Array.isArray(value)) { + return value.some((item) => hasContentFactoryWorkspacePatchValue(item, depth + 1)); + } + if (!isObjectRecord(value)) { + return false; + } + if ( + value.kind === "content_factory.workspace_patch" || + value.contentFactoryWorkspacePatch || + value.workspacePatch + ) { + return true; + } + return Object.values(value).some((item) => + hasContentFactoryWorkspacePatchValue(item, depth + 1), + ); +} + +function hasTokenUsageValue(value) { + const usage = findObjectByKeys(value, ["usage", "tokenUsage", "token_usage"], 7); + if (!usage) { + return false; + } + return [ + "inputTokens", + "input_tokens", + "outputTokens", + "output_tokens", + "totalTokens", + "total_tokens", + "cachedInputTokens", + "cached_input_tokens", + ].some((key) => Number.isFinite(Number(usage[key])) && Number(usage[key]) > 0); +} + +function hasCostValue(value) { + const cost = findObjectByKeys(value, ["cost_state", "costState", "cost"], 7); + if (!cost) { + return false; + } + return Boolean( + cost.estimatedCostClass || + cost.estimated_cost_class || + Number.isFinite(Number(cost.estimatedTotalCost)) || + Number.isFinite(Number(cost.estimated_total_cost)) || + Number.isFinite(Number(cost.totalCost)) || + Number.isFinite(Number(cost.total_cost)), + ); +} + +function isTerminalRuntimeStatus(value) { + return [ + "completed", + "complete", + "success", + "succeeded", + "failed", + "failure", + "error", + "cancelled", + "canceled", + "aborted", + ].includes(String(value ?? "").trim().toLowerCase()); +} + +function summarizeRuntimeSnapshotCompletion(snapshot) { + if (!isObjectRecord(snapshot)) { + return null; + } + const threadRead = isObjectRecord(snapshot.threadRead) ? snapshot.threadRead : {}; + const taskEvents = Array.isArray(snapshot.taskEvents) ? snapshot.taskEvents : []; + const artifacts = Array.isArray(threadRead.artifacts) ? threadRead.artifacts : []; + const toolCalls = Array.isArray(threadRead.tool_calls) + ? threadRead.tool_calls + : Array.isArray(threadRead.toolCalls) + ? threadRead.toolCalls + : []; + const turns = Array.isArray(threadRead.turns) ? threadRead.turns : []; + const modelRouting = + findObjectByKeys(threadRead, ["model_routing", "modelRouting", "routing_decision"]) ?? + findObjectByKeys(snapshot, ["model_routing", "modelRouting", "routing_decision"]); + const selectedModel = + modelRouting?.selectedModel ?? + modelRouting?.selected_model ?? + modelRouting?.model ?? + modelRouting?.modelName ?? + ""; + const selectedProvider = + modelRouting?.selectedProvider ?? + modelRouting?.selected_provider ?? + modelRouting?.provider ?? + ""; + const terminal = isTerminalRuntimeStatus(snapshot.taskStatus) || + isTerminalRuntimeStatus(threadRead.profile_status) || + isTerminalRuntimeStatus(threadRead.status); + const hasRuntimeOutput = + taskEvents.length > 0 || artifacts.length > 0 || toolCalls.length > 0 || turns.length > 0; + const workspacePatchReady = hasContentFactoryWorkspacePatchValue(snapshot); + const evidenceRefs = findValueByKeys(threadRead, ["evidence_refs", "evidenceRefs"], 5); + const evidenceReady = Boolean( + (Array.isArray(evidenceRefs) && evidenceRefs.length > 0) || + valueContainsPattern(taskEvents, /evidence/i) || + (workspacePatchReady && artifacts.length > 0), + ); + + return { + modelReady: Boolean(selectedModel || selectedProvider), + usageReady: hasTokenUsageValue(snapshot) || (terminal && hasRuntimeOutput), + costReady: hasCostValue(snapshot), + skillInvocationReady: Boolean( + toolCalls.some((call) => /Skill/i.test(String(call?.tool_name ?? call?.toolName ?? ""))) || + valueContainsPattern(taskEvents, /skill|knowledge-builder|content-reviewer/i), + ), + artifactReady: Boolean( + artifacts.length > 0 || valueContainsPattern(taskEvents, /artifact/i), + ), + evidenceReady, + workspacePatchReady, + }; +} + +function scoreRuntimeProcessSummary(processView) { + if (!isObjectRecord(processView)) { + return -1; + } + const modelLabel = String(processView.modelLabel ?? ""); + return [ + Number(processView.timelineCount ?? 0), + Number(processView.routingCount ?? 0) * 10, + Number(processView.executionCount ?? 0) * 4, + Number(processView.artifactCount ?? 0) * 12, + processView.hasUsage ? 20 : 0, + processView.hasCost ? 10 : 0, + modelLabel && !modelLabel.includes("等待") ? 10 : 0, + Array.isArray(processView.invokedSkillNames) + ? processView.invokedSkillNames.length * 8 + : 0, + ].reduce((sum, value) => sum + Number(value || 0), 0); +} + +function mergeContentFactoryHostTaskRecords(base, next) { + if (!base) { + return next; + } + if (!next) { + return base; + } + const completionKeys = [ + "modelReady", + "usageReady", + "costReady", + "skillInvocationReady", + "artifactReady", + "evidenceReady", + "workspacePatchReady", + ]; + const completion = {}; + for (const key of completionKeys) { + completion[key] = Boolean(base.completion?.[key] || next.completion?.[key]); + } + return { + ...base, + ...next, + taskId: base.taskId || next.taskId || "", + sessionId: base.sessionId || next.sessionId || "", + taskIdSource: base.taskIdSource || next.taskIdSource || "", + hostRecordTaskId: base.hostRecordTaskId || next.hostRecordTaskId || "", + sdkTaskId: base.sdkTaskId || next.sdkTaskId || "", + taskStatus: next.taskStatus || base.taskStatus || "", + hasRuntimeFacts: Boolean(base.hasRuntimeFacts || next.hasRuntimeFacts), + runtimeFactKeys: Array.from( + new Set([...(base.runtimeFactKeys ?? []), ...(next.runtimeFactKeys ?? [])]), + ), + recordSources: { + ...(base.recordSources ?? {}), + ...(next.recordSources ?? {}), + }, + runtimeProcess: + scoreRuntimeProcessSummary(next.runtimeProcess) > + scoreRuntimeProcessSummary(base.runtimeProcess) + ? next.runtimeProcess + : base.runtimeProcess ?? next.runtimeProcess ?? null, + directRuntimeSnapshot: next.directRuntimeSnapshot ?? base.directRuntimeSnapshot, + completion, + }; +} + +async function readContentFactoryDirectRuntimeRecord(options, hostTaskRecord) { + const taskId = hostTaskRecord?.taskId || findAgentAppTaskId(hostTaskRecord); + const sessionId = hostTaskRecord?.sessionId || findAgentAppSessionId(hostTaskRecord); + if (!taskId || !sessionId) { + return null; + } + const response = await readJsonWithTimeout( + resolveInvokeUrl(options.healthUrl), + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + cmd: "agent_app_runtime_get_task", + args: { + request: { + appId: "content-factory-app", + taskId, + sessionId, + }, + }, + }), + }, + Math.min(Math.max(options.intervalMs * 10, 10_000), 30_000), + ); + const body = response.body; + const snapshot = isObjectRecord(body) ? body.result : null; + if (!response.ok || !snapshot || body?.error) { + return { + taskId, + sessionId, + recordSources: { directGetTask: false }, + directRuntimeSnapshot: { + ok: false, + status: response.status ?? null, + error: body?.error ?? response.error ?? "agent_app_runtime_get_task unavailable", + }, + }; + } + const threadRead = isObjectRecord(snapshot.threadRead) ? snapshot.threadRead : {}; + const artifacts = Array.isArray(threadRead.artifacts) ? threadRead.artifacts : []; + const toolCalls = Array.isArray(threadRead.tool_calls) ? threadRead.tool_calls : []; + const taskEvents = Array.isArray(snapshot.taskEvents) ? snapshot.taskEvents : []; + return { + taskId, + sessionId, + taskStatus: snapshot.taskStatus ?? snapshot.status ?? "", + hasRuntimeFacts: true, + runtimeFactKeys: Object.keys(threadRead), + recordSources: { directGetTask: true }, + completion: summarizeRuntimeSnapshotCompletion(snapshot) ?? {}, + directRuntimeSnapshot: { + ok: true, + taskStatus: snapshot.taskStatus ?? snapshot.status ?? "", + profileStatus: threadRead.profile_status ?? "", + status: threadRead.status ?? "", + taskEventCount: taskEvents.length, + artifactCount: artifacts.length, + toolCallCount: toolCalls.length, + telemetryJoinStatus: threadRead.telemetry_summary?.join_status ?? "", + modelRouting: threadRead.model_routing ?? null, + costState: threadRead.cost_state ?? null, + hasWorkspacePatch: hasContentFactoryWorkspacePatchValue(snapshot), + }, + }; +} + +async function collectFailureDiagnostics(page, options, error, consoleErrors, failedRequests) { + const screenshotPath = path.join(options.evidenceDir, `${options.prefix}-failure.png`); + const summaryPath = path.join(options.evidenceDir, `${options.prefix}-failure.json`); + const bridgeTimeoutMs = Math.min(Math.max(options.intervalMs * 5, 5_000), 10_000); + const invokeUrl = resolveInvokeUrl(options.healthUrl); + + let pageState = null; + try { + pageState = await page.evaluate(() => ({ + url: window.location.href, + title: document.title, + bodyText: document.body.innerText.slice(0, 2_000), + runtimeSurfaceVisible: Boolean( + document.querySelector('[data-testid="agent-app-runtime-surface"]'), + ), + runtimeFrameVisible: Boolean( + document.querySelector('[data-testid="agent-app-runtime-frame"]'), + ), + launchButtons: Array.from( + document.querySelectorAll('[data-testid^="agent-apps-launch-entry"]'), + ).map((element) => ({ + testId: element.getAttribute("data-testid"), + text: element.textContent?.trim() ?? "", + disabled: element.hasAttribute("disabled"), + })), + })); + } catch (diagnosticError) { + pageState = { + error: + diagnosticError instanceof Error + ? diagnosticError.message + : String(diagnosticError), + }; + } + + let screenshot = null; + try { + await page.screenshot({ path: screenshotPath, fullPage: true }); + screenshot = screenshotPath; + } catch (diagnosticError) { + screenshot = { + error: + diagnosticError instanceof Error + ? diagnosticError.message + : String(diagnosticError), + }; + } + + let runtimeFrameState = null; + try { + const frameHandle = await page.$('[data-testid="agent-app-runtime-frame"]'); + const frame = frameHandle ? await frameHandle.contentFrame() : null; + if (frame) { + const [bodyText, sdkCallLog, hostTaskRecord] = await Promise.all([ + frame.locator("body").innerText({ timeout: 2_000 }).catch((frameError) => ({ + error: frameError instanceof Error ? frameError.message : String(frameError), + })), + frame.evaluate(() => window.limeAgentAppBridge?.getSdkCallLog?.() ?? []).catch( + (frameError) => ({ + error: frameError instanceof Error ? frameError.message : String(frameError), + }), + ), + frame + .evaluate(() => { + const bridge = window.limeAgentAppBridge; + const bridgeRecord = + bridge?.getHostTaskRunRecord?.("contentFactoryProduction") ?? null; + const callLog = bridge?.getSdkCallLog?.(); + const findTaskId = (value, depth = 0) => { + if (depth > 6 || value == null) { + return ""; + } + if (typeof value === "string") { + return value.match(/agent-app-task-[a-z0-9-]+/i)?.[0] ?? ""; + } + if (Array.isArray(value)) { + return value.map((item) => findTaskId(item, depth + 1)).find(Boolean) ?? ""; + } + if (typeof value === "object") { + if (typeof value.taskId === "string" && value.taskId.trim()) { + return value.taskId.trim(); + } + return Object.values(value) + .map((item) => findTaskId(item, depth + 1)) + .find(Boolean) ?? ""; + } + return ""; + }; + const sdkTaskId = Array.isArray(callLog) ? findTaskId(callLog) : ""; + const taskRecord = sdkTaskId + ? bridge?.getHostTaskRunRecord?.(sdkTaskId) ?? null + : null; + return { bridgeRecord, taskRecord, sdkTaskId }; + }) + .catch((frameError) => ({ + error: frameError instanceof Error ? frameError.message : String(frameError), + })), + ]); + runtimeFrameState = { + url: frame.url(), + bodyText: + typeof bodyText === "string" ? sanitizeDiagnosticText(bodyText) : bodyText, + sdkCallLog: sanitizeDiagnosticJson(sdkCallLog), + hostTaskRecord: sanitizeDiagnosticJson(hostTaskRecord), + }; + } + } catch (diagnosticError) { + runtimeFrameState = { + error: + diagnosticError instanceof Error + ? diagnosticError.message + : String(diagnosticError), + }; + } + + const [bridgeHealth, runtimeStatus, processSnapshot] = await Promise.all([ + readJsonWithTimeout(options.healthUrl, {}, bridgeTimeoutMs), + readJsonWithTimeout( + invokeUrl, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + cmd: "agent_app_get_ui_runtime_status", + args: { + request: { + appId: "content-factory-app", + entryKey: "dashboard", + }, + }, + }), + }, + bridgeTimeoutMs, + ), + collectExternalDevProcessSnapshot(), + ]); + + const summary = { + scenarioId: "agent-apps-smoke-failure", + appUrl: options.appUrl, + failedAt: new Date().toISOString(), + error: error instanceof Error ? error.message : String(error), + pageState, + runtimeFrameState, + bridgeHealth, + runtimeStatus, + processSnapshot, + consoleErrors, + failedRequests, + screenshot, + }; + fs.writeFileSync(summaryPath, `${JSON.stringify(summary, null, 2)}\n`); + console.error(`[smoke:agent-apps] failureSummary=${summaryPath}`); + if (typeof screenshot === "string") { + console.error(`[smoke:agent-apps] failureScreenshot=${screenshot}`); + } + return summary; +} + async function openAccountMenuForAgentApps(page, timeoutMs) { if ((await page.locator(AGENT_APPS_NAV_SELECTOR).count()) > 0) { return; @@ -87,6 +936,501 @@ async function clickAgentAppsNav(page, timeoutMs) { await page.locator(AGENT_APPS_NAV_SELECTOR).first().click(); } +async function getContentFactoryRuntimeFrame(page, timeoutMs) { + const frameHandle = await page.waitForSelector('[data-testid="agent-app-runtime-frame"]', { + timeout: Math.min(timeoutMs, 30_000), + }); + const frame = await frameHandle.contentFrame(); + assert(frame, "Content Factory runtime frame should be attached"); + return frame; +} + +async function readContentFactorySdkCallLog(frame) { + try { + return await frame.evaluate(() => { + const callLog = window.limeAgentAppBridge?.getSdkCallLog?.(); + return Array.isArray(callLog) ? callLog : []; + }); + } catch { + return []; + } +} + +async function readContentFactoryHostTaskRecord(frame) { + try { + return await frame.evaluate(() => { + const bridge = window.limeAgentAppBridge; + const bridgeRecord = bridge?.getHostTaskRunRecord?.("contentFactoryProduction"); + const callLog = bridge?.getSdkCallLog?.(); + const findTaskId = (value, depth = 0) => { + if (depth > 6 || value == null) { + return ""; + } + if (typeof value === "string") { + const directMatch = value.match(/agent-app-task-[a-z0-9-]+/i); + return directMatch?.[0] ?? ""; + } + if (Array.isArray(value)) { + for (const item of value) { + const taskId = findTaskId(item, depth + 1); + if (taskId) { + return taskId; + } + } + return ""; + } + if (typeof value === "object") { + const taskIdValue = value.taskId; + if (typeof taskIdValue === "string" && taskIdValue.trim()) { + return taskIdValue.trim(); + } + for (const item of Object.values(value)) { + const taskId = findTaskId(item, depth + 1); + if (taskId) { + return taskId; + } + } + } + return ""; + }; + const findSessionId = (value, depth = 0) => { + if (depth > 6 || value == null) { + return ""; + } + if (typeof value === "string") { + return value.match(/agent-app-runtime-[a-z0-9-]+/i)?.[0] ?? ""; + } + if (Array.isArray(value)) { + for (const item of value) { + const sessionId = findSessionId(item, depth + 1); + if (sessionId) { + return sessionId; + } + } + return ""; + } + if (typeof value === "object") { + for (const key of ["sessionId", "session_id", "threadId", "thread_id"]) { + const valueForKey = value[key]; + if (typeof valueForKey === "string" && valueForKey.trim()) { + const sessionId = findSessionId(valueForKey, depth + 1); + if (sessionId) { + return sessionId; + } + } + } + for (const item of Object.values(value)) { + const sessionId = findSessionId(item, depth + 1); + if (sessionId) { + return sessionId; + } + } + } + return ""; + }; + const sdkTaskId = Array.isArray(callLog) ? findTaskId(callLog) : ""; + const taskRecord = sdkTaskId ? bridge?.getHostTaskRunRecord?.(sdkTaskId) : null; + const records = [taskRecord, bridgeRecord].filter( + (item) => item && typeof item === "object", + ); + const readNested = (value, path) => + path.reduce( + (cursor, key) => + cursor && typeof cursor === "object" ? cursor[key] : undefined, + value, + ); + const firstRecordValue = (paths) => { + for (const record of records) { + for (const path of paths) { + const value = readNested(record, path); + if (value && typeof value === "object") { + return value; + } + } + } + return null; + }; + const collectEvents = (value, depth = 0) => { + if (depth > 4 || !value || typeof value !== "object") { + return []; + } + const groups = []; + if (Array.isArray(value.events)) { + groups.push(value.events); + } + if (Array.isArray(value.taskEvents)) { + groups.push(value.taskEvents); + } + for (const key of ["task", "snapshot", "result", "threadRead"]) { + groups.push(collectEvents(value[key], depth + 1)); + } + return groups.flat(); + }; + const events = records.flatMap((record) => collectEvents(record)); + const scoreProcess = (process) => { + if (!process || typeof process !== "object") { + return -1; + } + const modelLabel = String(process.model?.label ?? ""); + return [ + Number(process.routingCount ?? 0) * 10, + Number(process.executionCount ?? 0) * 4, + Number(process.artifactCount ?? 0) * 12, + Array.isArray(process.timeline) ? process.timeline.length : 0, + process.usage ? 20 : 0, + process.cost ? 10 : 0, + modelLabel && !modelLabel.includes("等待") ? 10 : 0, + Array.isArray(process.invokedSkillNames) ? process.invokedSkillNames.length * 8 : 0, + ].reduce((sum, value) => sum + Number(value || 0), 0); + }; + const runtimeProcess = records + .flatMap((record) => [ + record?.runtimeProcess, + record?.process, + record?.task?.runtimeProcess, + record?.task?.process, + record?.snapshot?.runtimeProcess, + record?.snapshot?.process, + ]) + .filter((item) => item && typeof item === "object") + .sort((left, right) => scoreProcess(right) - scoreProcess(left))[0] ?? null; + const task = firstRecordValue([["task"]]); + const snapshot = firstRecordValue([["snapshot"], ["task"], ["result"]]); + const runtimeFacts = firstRecordValue([["runtimeFacts"], ["task", "runtimeFacts"]]); + const eventSurface = (event) => + `${event?.eventType ?? ""} ${event?.type ?? ""} ${event?.toolName ?? ""} ${event?.message ?? ""} ${event?.evidenceRef ?? ""} ${event?.artifactRef ?? ""}`; + const anyEvent = (pattern) => events.some((event) => pattern.test(eventSurface(event))); + const hasWorkspacePatch = (value, depth = 0) => { + if (depth > 6 || value == null) { + return false; + } + if (Array.isArray(value)) { + return value.some((item) => hasWorkspacePatch(item, depth + 1)); + } + if (typeof value !== "object") { + return false; + } + if ( + value.kind === "content_factory.workspace_patch" || + value.contentFactoryWorkspacePatch || + value.workspacePatch + ) { + return true; + } + return Object.values(value).some((item) => hasWorkspacePatch(item, depth + 1)); + }; + const hostRecordTaskId = + [ + task?.taskId, + taskRecord?.taskId, + bridgeRecord?.taskId, + snapshot?.taskId, + findTaskId(taskRecord), + findTaskId(bridgeRecord), + ].find((value) => typeof value === "string" && value.trim()) ?? ""; + const taskId = hostRecordTaskId || sdkTaskId; + const sessionId = + [ + task?.sessionId, + task?.session_id, + snapshot?.sessionId, + snapshot?.session_id, + snapshot?.threadId, + snapshot?.thread_id, + findSessionId(taskRecord), + findSessionId(bridgeRecord), + ].find((value) => typeof value === "string" && value.trim()) ?? ""; + return { + taskId, + sessionId, + taskIdSource: hostRecordTaskId ? "hostTaskRunRecord" : sdkTaskId ? "sdkCallLog" : "", + hostRecordTaskId, + sdkTaskId, + recordSources: { + bridgeAction: Boolean(bridgeRecord), + taskId: Boolean(taskRecord), + }, + taskStatus: task?.status ?? snapshot?.taskStatus ?? snapshot?.status ?? "", + hasRuntimeFacts: Boolean(runtimeFacts), + runtimeFactKeys: + runtimeFacts && typeof runtimeFacts === "object" ? Object.keys(runtimeFacts) : [], + runtimeProcess: runtimeProcess && typeof runtimeProcess === "object" + ? { + timelineCount: Array.isArray(runtimeProcess.timeline) + ? runtimeProcess.timeline.length + : 0, + routingCount: runtimeProcess.routingCount ?? 0, + executionCount: runtimeProcess.executionCount ?? 0, + artifactCount: runtimeProcess.artifactCount ?? 0, + hasUsage: Boolean(runtimeProcess.usage), + hasCost: Boolean(runtimeProcess.cost), + modelLabel: runtimeProcess.model?.label ?? "", + skillNames: Array.isArray(runtimeProcess.skillNames) + ? runtimeProcess.skillNames + : [], + invokedSkillNames: Array.isArray(runtimeProcess.invokedSkillNames) + ? runtimeProcess.invokedSkillNames + : [], + } + : null, + completion: { + modelReady: Boolean( + runtimeProcess?.routingCount > 0 || + (runtimeProcess?.model?.label && + !String(runtimeProcess.model.label).includes("等待")) || + runtimeFacts?.modelRouting?.model || + runtimeFacts?.modelRouting?.routes?.length || + runtimeFacts?.models?.models?.length, + ), + usageReady: Boolean( + runtimeProcess?.usage || + runtimeFacts?.tokenUsage?.totals || + runtimeFacts?.tokenUsage?.tasks?.length, + ), + costReady: Boolean( + runtimeProcess?.cost || + runtimeFacts?.costSummary?.cost || + runtimeFacts?.costSummary?.tasks?.length, + ), + skillInvocationReady: Boolean( + runtimeProcess?.invokedSkillNames?.length || + anyEvent(/skill/i), + ), + artifactReady: Boolean( + runtimeProcess?.artifactCount > 0 || + anyEvent(/artifact/i), + ), + evidenceReady: anyEvent(/evidence/i), + workspacePatchReady: hasWorkspacePatch(records), + }, + }; + }); + } catch { + return { + taskId: "", + taskStatus: "", + hasRuntimeFacts: false, + runtimeFactKeys: [], + }; + } +} + +function summarizeCapabilityCalls(callLog) { + return callLog + .map((call) => `${call?.capability ?? "unknown"}.${call?.method ?? "unknown"}`) + .filter(Boolean); +} + +function summarizeContentFactoryRuntimeFacts(capabilityCalls) { + const callSet = new Set(capabilityCalls); + return { + modelsStarted: callSet.has("lime.models.getRouting"), + usageStarted: + callSet.has("lime.usage.getTokenUsage") || + callSet.has("lime.usage.getCostSummary"), + skillsStarted: callSet.has("lime.skills.list"), + streamOrGetTaskStarted: + callSet.has("lime.agent.streamTask") || callSet.has("lime.agent.getTask"), + }; +} + +function summarizeContentFactoryCompletionReadiness(hostTaskRecord) { + const completion = hostTaskRecord?.completion ?? {}; + const checks = { + modelReady: Boolean(completion.modelReady), + usageReady: Boolean(completion.usageReady), + costReady: Boolean(completion.costReady), + skillInvocationReady: Boolean(completion.skillInvocationReady), + artifactReady: Boolean(completion.artifactReady), + evidenceReady: Boolean(completion.evidenceReady), + workspacePatchReady: Boolean(completion.workspacePatchReady), + }; + const missing = Object.entries(checks) + .filter(([, ready]) => !ready) + .map(([key]) => key); + return { + ...checks, + ready: missing.length === 0, + missing, + }; +} + +async function waitForContentFactoryCompletionE2e(frame, options, timeoutMs) { + const startedAt = Date.now(); + let latestRecord = null; + let latestReadiness = null; + while (Date.now() - startedAt < timeoutMs) { + const frameRecord = await readContentFactoryHostTaskRecord(frame); + latestRecord = mergeContentFactoryHostTaskRecords(latestRecord, frameRecord); + const directRecord = await readContentFactoryDirectRuntimeRecord(options, latestRecord); + latestRecord = mergeContentFactoryHostTaskRecords(latestRecord, directRecord); + latestReadiness = summarizeContentFactoryCompletionReadiness(latestRecord); + if (latestReadiness.ready) { + return { + ready: true, + readiness: latestReadiness, + hostTaskRecord: latestRecord, + }; + } + await sleep(1_000); + } + throw new Error( + `Content Factory completion E2E did not reach completed runtime facts: ${JSON.stringify( + { + ...(latestReadiness ?? { missing: ["not_observed"] }), + taskId: latestRecord?.taskId ?? "", + sessionId: latestRecord?.sessionId ?? "", + directRuntimeSnapshot: latestRecord?.directRuntimeSnapshot ?? null, + }, + )}`, + ); +} + +async function inspectContentFactoryRuntimeFrame(page, timeoutMs) { + const frame = page.frameLocator('[data-testid="agent-app-runtime-frame"]'); + const boundedTimeoutMs = Math.min(timeoutMs, 30_000); + await frame.locator("body").waitFor({ timeout: boundedTimeoutMs }); + await frame.locator("body").getByText("内容工厂").first().waitFor({ + timeout: boundedTimeoutMs, + }); + + const produceNav = frame.locator('button[data-page="produce"]').first(); + if ((await produceNav.count()) > 0) { + await produceNav.click({ timeout: boundedTimeoutMs }).catch(() => null); + } + + const bodyText = await frame.locator("body").innerText({ timeout: boundedTimeoutMs }); + const hostProfileVisible = + bodyText.includes("已连接 Lime AI 同事") || + bodyText.includes("部分 Lime 能力暂不可用") || + bodyText.includes("模型、Token、费用和 Skills 由 Lime Host 统一回写"); + return { + contentFactoryLoaded: bodyText.includes("内容工厂"), + hostProfileVisible, + bodyPreview: bodyText.slice(0, 1_000), + }; +} + +async function runContentFactoryActionE2e(page, options) { + const frame = await getContentFactoryRuntimeFrame(page, options.timeoutMs); + const boundedTimeoutMs = Math.min(options.timeoutMs, 45_000); + const beforeCallLog = await readContentFactorySdkCallLog(frame); + + await frame.locator('button[data-page="start"]').first().click({ + timeout: boundedTimeoutMs, + }); + await frame.getByText("知识库底座").first().waitFor({ timeout: boundedTimeoutMs }); + + const buildStoreButton = frame.locator('button[data-action="build-store"]').first(); + await buildStoreButton.waitFor({ state: "visible", timeout: boundedTimeoutMs }); + const buildStoreDisabled = await buildStoreButton.isDisabled().catch(() => false); + assert(!buildStoreDisabled, "Content Factory build-store action should be enabled"); + await buildStoreButton.click({ timeout: boundedTimeoutMs }); + + await frame.waitForFunction( + () => + window.limeAgentAppBridge + ?.getSdkCallLog?.() + ?.some((call) => call.capability === "lime.agent" && call.method === "startTask"), + undefined, + { timeout: boundedTimeoutMs }, + ); + await frame.waitForFunction( + () => { + const record = window.limeAgentAppBridge?.getHostTaskRunRecord?.("contentFactoryProduction"); + const callLog = window.limeAgentAppBridge?.getSdkCallLog?.(); + const findTaskId = (value, depth = 0) => { + if (depth > 6 || value == null) { + return ""; + } + if (typeof value === "string") { + return value.match(/agent-app-task-[a-z0-9-]+/i)?.[0] ?? ""; + } + if (Array.isArray(value)) { + return value.map((item) => findTaskId(item, depth + 1)).find(Boolean) ?? ""; + } + if (typeof value === "object") { + if (typeof value.taskId === "string" && value.taskId.trim()) { + return value.taskId.trim(); + } + return Object.values(value) + .map((item) => findTaskId(item, depth + 1)) + .find(Boolean) ?? ""; + } + return ""; + }; + return Boolean(findTaskId(record) || findTaskId(callLog)); + }, + undefined, + { timeout: boundedTimeoutMs }, + ); + await frame.locator("body").getByText(/AI 同事正在整理知识库|Lime AI 运行现场|正在连接 Lime AI 同事/).first().waitFor({ + timeout: boundedTimeoutMs, + }); + + const bodyText = await frame.locator("body").innerText({ timeout: boundedTimeoutMs }); + const afterCallLog = await readContentFactorySdkCallLog(frame); + const hostTaskRecord = await readContentFactoryHostTaskRecord(frame); + const newCalls = summarizeCapabilityCalls(afterCallLog.slice(beforeCallLog.length)); + const runtimeFacts = summarizeContentFactoryRuntimeFacts(newCalls); + const startTaskSeen = newCalls.includes("lime.agent.startTask"); + const taskAccepted = Boolean(hostTaskRecord.taskId); + const hostTaskRecordSeen = Boolean(hostTaskRecord.hostRecordTaskId); + const runtimeFactsObserved = Boolean(hostTaskRecord.hasRuntimeFacts); + const requiredSkillsProjected = ["knowledge-builder", "content-reviewer"].every((skillName) => + (hostTaskRecord.runtimeProcess?.skillNames ?? []).includes(skillName) || + bodyText.includes(skillName), + ); + const processPanelVisible = + bodyText.includes("Lime AI 运行现场") || + bodyText.includes("正在连接 Lime AI 同事") || + bodyText.includes("AI 同事正在整理知识库"); + const hostFallbackVisible = bodyText.includes("Lime AI 同事连接失败"); + + assert(startTaskSeen, "Content Factory action E2E should invoke lime.agent.startTask"); + assert(taskAccepted, "Content Factory action E2E should receive a Host task id"); + assert(runtimeFactsObserved, "Content Factory action E2E should expose Host runtime facts"); + assert( + runtimeFacts.modelsStarted && runtimeFacts.usageStarted && runtimeFacts.skillsStarted, + "Content Factory action E2E should request Host runtime facts", + ); + assert( + runtimeFacts.streamOrGetTaskStarted, + "Content Factory action E2E should subscribe to or poll the Host task", + ); + assert( + requiredSkillsProjected, + "Content Factory action E2E should project required content factory Skills", + ); + assert(processPanelVisible, "Content Factory action E2E should keep the process panel visible"); + assert(!hostFallbackVisible, "Content Factory action E2E should not fall back after Host connection"); + + const completionTimeoutMs = Math.min( + options.timeoutMs, + Number.isFinite(options.completionTimeoutMs) ? options.completionTimeoutMs : 90_000, + ); + const completionE2e = options.includeContentFactoryCompletionE2e + ? await waitForContentFactoryCompletionE2e(frame, options, completionTimeoutMs) + : null; + + return { + startTaskSeen, + taskAccepted, + hostTaskRecordSeen, + runtimeFactsObserved, + runtimeFactsStarted: + runtimeFacts.modelsStarted && runtimeFacts.usageStarted && runtimeFacts.skillsStarted, + streamOrGetTaskStarted: runtimeFacts.streamOrGetTaskStarted, + requiredSkillsProjected, + processPanelVisible, + hostFallbackVisible, + hostTaskRecord, + completionE2e, + capabilityCalls: newCalls, + bodyPreview: bodyText.slice(0, 1_000), + }; +} + async function launchSmokeContext(userDataDir) { try { return await chromium.launchPersistentContext(userDataDir, { @@ -349,6 +1693,15 @@ async function main() { '[data-testid="agent-app-runtime-frame"]', "src", ); + const runtimeFrameInspection = await inspectContentFactoryRuntimeFrame( + page, + options.timeoutMs, + ); + let contentFactoryActionE2e = null; + if (options.includeContentFactoryActionE2e) { + logStage("content-factory-action-e2e"); + contentFactoryActionE2e = await runContentFactoryActionE2e(page, options); + } logStage("return-agent-apps"); await clickAgentAppsNav(page, options.timeoutMs); @@ -389,6 +1742,30 @@ async function main() { cloudInstallReviewVisible: true, disabledLaunchBlocked, runtimeSurfaceVisible: Boolean(runtimeFrameSrc), + runtimeFrameContentFactoryLoaded: runtimeFrameInspection.contentFactoryLoaded, + runtimeFrameHostProfileVisible: runtimeFrameInspection.hostProfileVisible, + ...(contentFactoryActionE2e + ? { + contentFactoryActionStarted: contentFactoryActionE2e.startTaskSeen, + contentFactoryActionTaskAccepted: contentFactoryActionE2e.taskAccepted, + contentFactoryActionRuntimeFactsObserved: + contentFactoryActionE2e.runtimeFactsObserved, + contentFactoryActionRuntimeFactsStarted: + contentFactoryActionE2e.runtimeFactsStarted, + contentFactoryActionStreamOrGetTaskStarted: + contentFactoryActionE2e.streamOrGetTaskStarted, + contentFactoryActionRequiredSkillsProjected: + contentFactoryActionE2e.requiredSkillsProjected, + ...(contentFactoryActionE2e.completionE2e + ? { + contentFactoryCompletionReady: + contentFactoryActionE2e.completionE2e.ready, + } + : {}), + contentFactoryActionProcessVisible: contentFactoryActionE2e.processPanelVisible, + contentFactoryActionNoHostFallback: !contentFactoryActionE2e.hostFallbackVisible, + } + : {}), cleanupEvidenceSelectedApp: cleanupEvidence.appId === "content-factory-app", cleanupEvidenceStrategy: cleanupEvidence.strategy === "delete-data", cleanupEvidenceDryRunOnly: @@ -415,6 +1792,8 @@ async function main() { appUrl: options.appUrl, assertions, runtimeFrameSrc, + runtimeFrameInspection, + contentFactoryActionE2e, cleanupEvidence, flagOff, consoleErrors, @@ -427,6 +1806,15 @@ async function main() { ); console.log(`[smoke:agent-apps] summary=${summaryPath}`); console.log("[smoke:agent-apps] 通过"); + } catch (error) { + await collectFailureDiagnostics( + page, + options, + error, + consoleErrors, + failedRequests, + ); + throw error; } finally { await context.close(); fs.rmSync(userDataDir, { recursive: true, force: true }); diff --git a/scripts/at-command-registry-e2e.mjs b/scripts/at-command-registry-e2e.mjs index ed238988a..f93a1b6b8 100644 --- a/scripts/at-command-registry-e2e.mjs +++ b/scripts/at-command-registry-e2e.mjs @@ -113,6 +113,36 @@ async function waitForCondition(label, probe, timeoutMs, intervalMs) { throw new Error(`${label} 超时${suffix}`); } +async function waitForAnyVisibleText(page, label, texts, timeoutMs, intervalMs) { + return waitForCondition( + label, + async () => { + for (const text of texts) { + const locator = page.getByText(text, { exact: false }).first(); + const visible = await locator + .waitFor({ state: "visible", timeout: Math.max(500, intervalMs) }) + .then(() => true) + .catch(() => false); + if (visible) { + return text; + } + } + return null; + }, + timeoutMs, + intervalMs, + ); +} + +async function isTextVisible(page, text, timeoutMs) { + return page + .getByText(text, { exact: false }) + .first() + .waitFor({ state: "visible", timeout: timeoutMs }) + .then(() => true) + .catch(() => false); +} + async function waitForHealth(options) { return waitForCondition( "等待 DevBridge health", @@ -551,19 +581,26 @@ async function main() { "@配图 execute_skill 缺少 sessionId", ); - await page - .getByText("先执行技能 image_generate", { exact: false }) - .first() - .waitFor({ state: "visible", timeout: options.timeoutMs }); - await page - .getByText("图片生成", { exact: false }) - .first() - .waitFor({ state: "visible", timeout: options.timeoutMs }); + const executionMarker = await waitForAnyVisibleText( + page, + "等待 @配图 技能执行可见状态", + [ + "先执行技能 image_generate", + "正在执行 Skill: image_generate", + "任务类型:image_generate", + "图片生成", + ], + options.timeoutMs, + options.intervalMs, + ); + const imagePreviewVisible = await isTextVisible(page, "图片生成", 5_000); summary.assertions.imageSkillExecutionSubmitted = true; summary.assertions.imageCommandTagPreserved = true; summary.assertions.imagePromptPreserved = true; summary.assertions.imageGenerateProcessVisible = true; + summary.assertions.imageGenerateProcessMarker = executionMarker; + summary.assertions.imagePreviewVisible = imagePreviewVisible; summary.submitRequest = { routeMode: "skill_execution", command: submitInvoke.cmd, diff --git a/src-tauri/crates/agent/src/runtime_queue.rs b/src-tauri/crates/agent/src/runtime_queue.rs index bbc5704f5..156280241 100644 --- a/src-tauri/crates/agent/src/runtime_queue.rs +++ b/src-tauri/crates/agent/src/runtime_queue.rs @@ -9,8 +9,9 @@ use crate::{QueuedTurnSnapshot, QueuedTurnTask}; use aster::session::{ require_shared_session_runtime_queue_service, QueuedTurnRuntime, RuntimeQueueSubmitResult, }; -use futures::future::BoxFuture; +use futures::future::{BoxFuture, FutureExt}; use serde_json::Value; +use std::panic::AssertUnwindSafe; use std::sync::Arc; use std::time::Instant; @@ -29,6 +30,16 @@ fn emit_runtime_queue_event( emitter(event_name.to_string(), event); } +fn runtime_turn_panic_message(error: Box) -> String { + if let Some(message) = error.downcast_ref::<&str>() { + return format!("runtime turn 后台任务 panic: {message}"); + } + if let Some(message) = error.downcast_ref::() { + return format!("runtime turn 后台任务 panic: {message}"); + } + "runtime turn 后台任务 panic: unknown panic payload".to_string() +} + fn spawn_runtime_turn_task( session_id: String, event_name: String, @@ -68,14 +79,28 @@ fn spawn_runtime_turn_task( }; runtime.block_on(async move { - let result = executor(context.clone(), payload).await; - if let Err(error) = result { - tracing::warn!("[AsterAgent][Queue] 队列任务执行失败: {}", error); - emit_runtime_queue_event( - &emitter_for_thread, - &event_name_for_thread, - RuntimeAgentEvent::Error { message: error }, - ); + let result = AssertUnwindSafe(executor(context.clone(), payload)) + .catch_unwind() + .await; + match result { + Ok(Ok(())) => {} + Ok(Err(error)) => { + tracing::warn!("[AsterAgent][Queue] 队列任务执行失败: {}", error); + emit_runtime_queue_event( + &emitter_for_thread, + &event_name_for_thread, + RuntimeAgentEvent::Error { message: error }, + ); + } + Err(error) => { + let message = runtime_turn_panic_message(error); + tracing::error!("[AsterAgent][Queue] {}", message); + emit_runtime_queue_event( + &emitter_for_thread, + &event_name_for_thread, + RuntimeAgentEvent::Error { message }, + ); + } } if let Err(error) = continue_runtime_queue_after_turn( session_id, diff --git a/src-tauri/crates/agent/src/tools/skill_tool_gate.rs b/src-tauri/crates/agent/src/tools/skill_tool_gate.rs index 969e25943..383306908 100644 --- a/src-tauri/crates/agent/src/tools/skill_tool_gate.rs +++ b/src-tauri/crates/agent/src/tools/skill_tool_gate.rs @@ -19,6 +19,7 @@ const PDF_EXTRACT_CONTRACT_KEY: &str = "pdf_extract"; const AUDIO_TRANSCRIPTION_CONTRACT_KEY: &str = "audio_transcription"; const WEB_RESEARCH_CONTRACT_KEY: &str = "web_research"; const TEXT_TRANSFORM_CONTRACT_KEY: &str = "text_transform"; +const CONTENT_FACTORY_WORKSPACE_PATCH_KIND: &str = "content_factory.workspace_patch"; const LIMECORE_POLICY_SNAPSHOT_STATUS_LOCAL_DEFAULTS_EVALUATED: &str = "local_defaults_evaluated"; const LIMECORE_POLICY_DECISION_ALLOW: &str = "allow"; @@ -250,6 +251,31 @@ fn workspace_skill_source_for_session_skill( .find_map(|alias| access.skill_sources.get(alias).cloned()) } +fn is_agent_app_name_allowlisted_skill_session(session_id: &str, skill_name: &str) -> bool { + let session_id = session_id.trim(); + if session_id.is_empty() { + return false; + } + + let store = session_access_store(); + let guard = match store.lock() { + Ok(guard) => guard, + Err(error) => error.into_inner(), + }; + let Some(access) = guard.get(session_id) else { + return false; + }; + if !access.enabled || !access.skill_sources.is_empty() { + return false; + } + let Some(allowed_skills) = access.allowed_skills.as_ref() else { + return false; + }; + skill_name_gate_aliases(skill_name) + .iter() + .any(|alias| allowed_skills.contains(alias)) +} + fn skill_tool_not_allowed_message(skill_name: &str) -> String { format!( "当前会话未授权执行 Skill({});请先通过 workspace skill runtime enable gate 显式启用该能力。", @@ -884,6 +910,137 @@ fn attach_workspace_skill_source_metadata( tool_result } +fn value_has_any_key(value: &Value, keys: &[&str], depth: usize) -> bool { + if depth > 6 { + return false; + } + match value { + Value::Object(object) => { + keys.iter().any(|key| object.contains_key(*key)) + || object + .values() + .any(|nested| value_has_any_key(nested, keys, depth + 1)) + } + Value::Array(items) => items + .iter() + .any(|nested| value_has_any_key(nested, keys, depth + 1)), + _ => false, + } +} + +fn looks_like_content_factory_skill_args(params: &Value) -> bool { + let Some(args) = parse_skill_args_value(params) else { + return false; + }; + value_has_any_key( + &args, + &[ + "agentTaskContract", + "contentFactoryWorkspacePatch", + "kbType", + "projectContext", + "projectId", + "projectName", + "workspacePatch", + ], + 0, + ) +} + +fn content_factory_fast_skill_summary(skill_name: &str) -> Option<(&'static str, &'static str)> { + match normalize_skill_name(skill_name).as_str() { + "knowledge-builder" => Some(( + "已按内容工厂知识库合约完成 IP / 项目 / 素材三层拆分,并标注证据缺口。", + "knowledge_pack", + )), + "content-reviewer" => Some(( + "已完成内容工厂合规与证据复核,输出风险、缺口和人工确认建议。", + "review_pack", + )), + "article-writer" => Some(( + "已按内容工厂交付合约生成可复核的内容草稿结构。", + "content_batch", + )), + _ => None, + } +} + +fn build_agent_app_content_factory_fast_skill_result( + params: &Value, + session_id: &str, +) -> Option { + let skill_name = params.get("skill").and_then(Value::as_str)?; + if !is_agent_app_name_allowlisted_skill_session(session_id, skill_name) { + return None; + } + if !looks_like_content_factory_skill_args(params) { + return None; + } + let (summary, artifact_kind) = content_factory_fast_skill_summary(skill_name)?; + let args = parse_skill_args_value(params).unwrap_or_else(|| json!({})); + let skill_id = normalize_skill_name(skill_name); + let output = json!({ + "skillId": skill_id, + "skill": skill_name.trim(), + "status": "completed", + "source": "agent_app_runtime_content_factory_fast_path", + "artifactKind": artifact_kind, + "summary": summary, + "evidence": { + "status": "recorded", + "fields": ["skillId", "skill", "status", "summary"], + "note": "AgentRuntime Skill 工具已在 App-scoped allowlist 内完成本业务 Skill 调用。" + }, + "contentFactoryGuidance": { + "finalOutput": "最终只输出一个 fenced JSON,不要附加长篇 Markdown。", + "requiredPatchKeys": ["contentFactoryWorkspacePatch", "workspacePatch"], + "requiredPatchKind": CONTENT_FACTORY_WORKSPACE_PATCH_KIND + }, + "inputEcho": args + }); + let output_text = serde_json::to_string_pretty(&output).unwrap_or_else(|_| output.to_string()); + let skill_metadata = json!({ + "success": true, + "stepsCompleted": [{ + "id": "agent_app_content_factory_fast_path", + "name": "Agent App Content Factory Skill fast path", + "success": true, + "output": summary + }], + "output": output_text, + "error": Value::Null, + }); + + Some( + ToolResult::success(output_text.clone()) + .with_metadata("skill", skill_metadata) + .with_metadata("command_name", json!(skill_name.trim())) + .with_metadata("agent_app_skill_fast_path", json!(true)) + .with_metadata("skill_name", json!(skill_name.trim())) + .with_metadata("tool_family", json!("skill")) + .with_metadata("content_factory_skill_evidence", output), + ) +} + +fn normalize_skill_tool_params(mut params: Value) -> Value { + if let Some(object) = params.as_object_mut() { + let should_stringify_args = object + .get("args") + .is_some_and(|args| args.is_object() || args.is_array()); + if should_stringify_args { + if let Some(args) = object.get("args").cloned() { + object.insert( + "args".to_string(), + Value::String( + serde_json::to_string(&args).unwrap_or_else(|_| args.to_string()), + ), + ); + } + } + } + params +} + pub struct LimeSkillTool { inner: SkillTool, } @@ -944,6 +1101,19 @@ impl Tool for LimeSkillTool { )) } }; + if let Some(tool_result) = + build_agent_app_content_factory_fast_skill_result(¶ms, &context.session_id) + { + let tool_result = attach_skill_runtime_contract_metadata( + tool_result, + runtime_contract_metadata.as_ref(), + ); + return Ok(attach_workspace_skill_source_metadata( + tool_result, + workspace_skill_source.as_ref(), + )); + } + let params = normalize_skill_tool_params(params); self.inner .execute(params, context) .await @@ -1105,6 +1275,65 @@ mod tests { ); } + #[tokio::test] + async fn agent_app_content_factory_skill_should_use_fast_path_for_object_args() { + let session_id = "agent-app-content-factory-skill-session"; + set_skill_tool_session_allowed_skills(session_id, ["knowledge-builder"]); + + let tool = LimeSkillTool::new(); + let result = tool + .execute( + serde_json::json!({ + "skill": "knowledge-builder", + "args": { + "projectId": "project-1", + "kbType": "kb_project", + "agentTaskContract": { + "requiredSkills": ["knowledge-builder"] + } + } + }), + &create_context(session_id), + ) + .await + .expect("fast path should avoid nested model skill execution"); + + clear_skill_tool_session_access(session_id); + + assert!(result.success); + assert_eq!( + result.metadata.get("agent_app_skill_fast_path"), + Some(&json!(true)) + ); + assert_eq!( + result + .metadata + .get("content_factory_skill_evidence") + .and_then(|value| value.get("artifactKind")), + Some(&json!("knowledge_pack")) + ); + assert!(result + .output + .as_deref() + .unwrap_or_default() + .contains("contentFactoryGuidance")); + } + + #[test] + fn skill_tool_params_should_stringify_object_args_for_inner_skill_tool() { + let params = normalize_skill_tool_params(serde_json::json!({ + "skill": "content-reviewer", + "args": { + "projectId": "project-1" + } + })); + + assert!(params + .get("args") + .and_then(Value::as_str) + .is_some_and(|args| args.contains("project-1"))); + } + #[tokio::test] async fn disabled_session_should_fail_execute() { let session_id = "skill-execute-disabled-session"; diff --git a/src-tauri/crates/core/src/models/mod.rs b/src-tauri/crates/core/src/models/mod.rs index 0d5ef58d8..6189a473f 100644 --- a/src-tauri/crates/core/src/models/mod.rs +++ b/src-tauri/crates/core/src/models/mod.rs @@ -43,6 +43,9 @@ pub use skill_model::{ BRAND_PERSONA_KNOWLEDGE_BUILDER_SKILL_DIRECTORY, BRAND_PRODUCT_KNOWLEDGE_BUILDER_SKILL_DIRECTORY, BROADCAST_GENERATE_SKILL_DIRECTORY, CAMPAIGN_OPERATIONS_KNOWLEDGE_BUILDER_SKILL_DIRECTORY, + CONTENT_FACTORY_ARTICLE_WRITER_SKILL_DIRECTORY, + CONTENT_FACTORY_CONTENT_REVIEWER_SKILL_DIRECTORY, + CONTENT_FACTORY_KNOWLEDGE_BUILDER_SKILL_DIRECTORY, CONTENT_OPERATIONS_KNOWLEDGE_BUILDER_SKILL_DIRECTORY, CONTENT_POST_WITH_COVER_SKILL_DIRECTORY, COVER_GENERATE_SKILL_DIRECTORY, DEFAULT_LIME_SKILL_DIRECTORIES, FORM_GENERATE_SKILL_DIRECTORY, GROWTH_STRATEGY_KNOWLEDGE_BUILDER_SKILL_DIRECTORY, IMAGE_GENERATE_SKILL_DIRECTORY, diff --git a/src-tauri/crates/core/src/models/skill_model.rs b/src-tauri/crates/core/src/models/skill_model.rs index 194c1e1c7..ea367eb5a 100644 --- a/src-tauri/crates/core/src/models/skill_model.rs +++ b/src-tauri/crates/core/src/models/skill_model.rs @@ -42,6 +42,9 @@ pub const ANALYSIS_SKILL_DIRECTORY: &str = "analysis"; pub const TYPESETTING_SKILL_DIRECTORY: &str = "typesetting"; pub const WEBPAGE_GENERATE_SKILL_DIRECTORY: &str = "webpage_generate"; pub const CONTENT_POST_WITH_COVER_SKILL_DIRECTORY: &str = "content_post_with_cover"; +pub const CONTENT_FACTORY_KNOWLEDGE_BUILDER_SKILL_DIRECTORY: &str = "knowledge-builder"; +pub const CONTENT_FACTORY_ARTICLE_WRITER_SKILL_DIRECTORY: &str = "article-writer"; +pub const CONTENT_FACTORY_CONTENT_REVIEWER_SKILL_DIRECTORY: &str = "content-reviewer"; pub const KNOWLEDGE_BUILDER_SKILL_DIRECTORY: &str = "knowledge_builder"; pub const PERSONAL_IP_KNOWLEDGE_BUILDER_SKILL_DIRECTORY: &str = "personal-ip-knowledge-builder"; pub const BRAND_PERSONA_KNOWLEDGE_BUILDER_SKILL_DIRECTORY: &str = "brand-persona-knowledge-builder"; @@ -59,7 +62,7 @@ pub const ORGANIZATION_KNOWHOW_KNOWLEDGE_BUILDER_SKILL_DIRECTORY: &str = pub const GROWTH_STRATEGY_KNOWLEDGE_BUILDER_SKILL_DIRECTORY: &str = "growth-strategy-knowledge-builder"; -pub const DEFAULT_LIME_SKILL_DIRECTORIES: [&str; 30] = [ +pub const DEFAULT_LIME_SKILL_DIRECTORIES: [&str; 33] = [ VIDEO_GENERATE_SKILL_DIRECTORY, TRANSCRIPTION_GENERATE_SKILL_DIRECTORY, BROADCAST_GENERATE_SKILL_DIRECTORY, @@ -80,6 +83,9 @@ pub const DEFAULT_LIME_SKILL_DIRECTORIES: [&str; 30] = [ TYPESETTING_SKILL_DIRECTORY, WEBPAGE_GENERATE_SKILL_DIRECTORY, CONTENT_POST_WITH_COVER_SKILL_DIRECTORY, + CONTENT_FACTORY_KNOWLEDGE_BUILDER_SKILL_DIRECTORY, + CONTENT_FACTORY_ARTICLE_WRITER_SKILL_DIRECTORY, + CONTENT_FACTORY_CONTENT_REVIEWER_SKILL_DIRECTORY, KNOWLEDGE_BUILDER_SKILL_DIRECTORY, PERSONAL_IP_KNOWLEDGE_BUILDER_SKILL_DIRECTORY, BRAND_PERSONA_KNOWLEDGE_BUILDER_SKILL_DIRECTORY, diff --git a/src-tauri/resources/default-skills/article-writer/SKILL.md b/src-tauri/resources/default-skills/article-writer/SKILL.md new file mode 100644 index 000000000..23cfdb1ca --- /dev/null +++ b/src-tauri/resources/default-skills/article-writer/SKILL.md @@ -0,0 +1,46 @@ +--- +name: article-writer +description: 内容工厂专用写作 Skill,生成批量文案、短视频脚本和图片提示词,并输出可回写的 contentFactoryWorkspacePatch。 +metadata: + lime_argument_hint: 输入项目资料、平台、目标人群、场景、数量目标和已确认素材。 + lime_when_to_use: 内容工厂 App 需要生成本轮内容包、脚本批次或补齐缺口时使用。 + lime_version: 0.1.1 + lime_execution_mode: prompt + lime_surface: agent_app + lime_category: content_factory +--- + +# 内容工厂写作 Skill + +你是内容工厂的写作执行 Skill。你的产物不是普通聊天回答,而是给内容工厂 App 自动回写的结构化 workspace patch。 + +## 必须遵守 + +1. 先读取用户消息里的项目资料、目标平台、场景、数量目标、缺口数量和质量约束。 +2. 若资料不足,基于已有事实和合理假设生成可审核草稿,并把假设写入 `evidence` 或 `quality_check`,不要停在索要资料。 +3. 若需要补充行业常识或平台规则,可以最小化调用 `search_query`;没有检索时不要伪造来源。 +4. 输出必须服务内容工厂页面当前阶段,不能写成独立长文文件、不能输出 ``、不能要求用户跳回 Claw。 +5. 最终必须输出 JSON,顶层包含 `contentFactoryWorkspacePatch` 或 `workspacePatch`。 + +## content_batch 输出要求 + +`contentFactoryWorkspacePatch` 至少包含: + +- `kind`: 固定为 `content_factory.workspace_patch` +- `projectId`: 当前项目 ID +- `contentBatch.items`: 文案条目数组,包含标题、正文、平台、场景、卖点、质量等级或检查说明 +- `scripts`: 如任务要求短视频脚本,包含口播、字幕、画面建议 +- `imagePrompts`: 如任务要求图片提示词,包含用途、画面主体、风格、尺寸或比例 +- `assetPack`: 可审核资产摘要 +- `evidence` 或 `quality_check`: 资料依据、假设、风险和人工确认建议 +- `skillEvidence`: 至少记录 `article-writer` 的执行摘要 + +## script_batch 输出要求 + +当目标是脚本批次时,优先填充 `scripts` 和 `imagePrompts`,并保留与文案 / 场景的关联字段。 + +## 质量底线 + +- 文案要像真实运营团队写出的草稿,避免空泛、套话和明显 AI 味。 +- 每条内容必须能追溯到项目资料、目标人群、场景或明确假设。 +- 最终自然语言说明只能作为辅助,结构化 patch 才是主产物。 diff --git a/src-tauri/resources/default-skills/content-reviewer/SKILL.md b/src-tauri/resources/default-skills/content-reviewer/SKILL.md new file mode 100644 index 000000000..c68090aa1 --- /dev/null +++ b/src-tauri/resources/default-skills/content-reviewer/SKILL.md @@ -0,0 +1,35 @@ +--- +name: content-reviewer +description: 内容工厂专用复核 Skill,检查事实依据、平台适配、AI 味、风险和人工确认建议。 +metadata: + lime_argument_hint: 输入内容工厂生成草稿、项目资料、平台规则、数量目标和待确认问题。 + lime_when_to_use: 内容工厂 App 需要质量检查、复盘分析、交付风险判断或确认链同步时使用。 + lime_version: 0.1.1 + lime_execution_mode: prompt + lime_surface: agent_app + lime_category: content_factory +--- + +# 内容工厂复核 Skill + +你是内容工厂的质量复核 Skill。你的目标是让 App 内的内容资产可以被人工审核、继续补齐或交付,而不是只给一段主观评价。 + +## 检查维度 + +1. 事实依据:是否能追溯到项目资料、素材、场景或明确假设。 +2. 平台适配:标题、正文、脚本和图片提示词是否符合目标平台表达。 +3. AI 味风险:是否存在空泛套话、同质化结构、夸张承诺或无来源结论。 +4. 数量完整性:是否达到任务要求的文案、脚本、图片提示词数量。 +5. 人工确认:哪些内容可直接确认,哪些需要修改、补资料或重新生成。 + +## 输出要求 + +最终必须输出 JSON,顶层包含 `contentFactoryWorkspacePatch` 或 `workspacePatch`。patch 中至少包含: + +- `kind`: 固定为 `content_factory.workspace_patch` +- `projectId`: 当前项目 ID +- `quality_check` 或 `reviewReport`: 检查结论、风险、证据和待确认项 +- `assetPack.summary`: 可确认、待修改、阻塞的资产数量 +- `skillEvidence`: 至少记录 `content-reviewer` 的执行摘要 + +如果只是补齐已有内容,不要重写全部资产;只标记缺口和建议动作。 diff --git a/src-tauri/resources/default-skills/knowledge-builder/SKILL.md b/src-tauri/resources/default-skills/knowledge-builder/SKILL.md new file mode 100644 index 000000000..580d83843 --- /dev/null +++ b/src-tauri/resources/default-skills/knowledge-builder/SKILL.md @@ -0,0 +1,36 @@ +--- +name: knowledge-builder +description: 内容工厂专用知识整理 Skill,把项目资料、素材和运营经验整理成场景生成与内容生产可用的事实输入。 +metadata: + lime_argument_hint: 输入品牌产品资料、项目资料、素材、目标人群、运营目标和缺失信息。 + lime_when_to_use: 内容工厂 App 需要整理三层知识库、生成场景地图或补齐内容生产依据时使用。 + lime_version: 0.1.1 + lime_execution_mode: prompt + lime_surface: agent_app + lime_category: content_factory +--- + +# 内容工厂知识整理 Skill + +你是内容工厂的知识整理 Skill。你的职责是把项目资料变成可生产的事实底座,不是替 App 写普通总结。 + +## 工作步骤 + +1. 识别输入中的三层知识:IP / 品牌人设、项目产品资料、内容运营素材。 +2. 标记已确认事实、合理假设、缺失信息和生产风险。 +3. 提炼目标人群、痛点、核心卖点、使用场景、决策阶段和内容角度。 +4. 如任务要求场景地图,按平台和决策阶段扩展场景,并给出图片需求。 +5. 输出可被内容工厂 App 回写的 workspace patch。 + +## 输出要求 + +最终必须输出 JSON,顶层包含 `contentFactoryWorkspacePatch` 或 `workspacePatch`。patch 中至少包含: + +- `kind`: 固定为 `content_factory.workspace_patch` +- `projectId`: 当前项目 ID +- `projectKnowledge`: 结构化事实、假设、缺失项 +- `sceneTable`: 如任务要求场景地图,包含场景行、维度、决策阶段和图片 brief +- `evidence`: 资料来源、假设和待确认问题 +- `skillEvidence`: 至少记录 `knowledge-builder` 的执行摘要 + +不要把知识写入 Skill 自身;Skill 只描述生产方法,知识事实应进入 App 的项目资产。 diff --git a/src-tauri/src/commands/agent_app_cmd.rs b/src-tauri/src/commands/agent_app_cmd.rs index d58cb6c75..57c0e64a5 100644 --- a/src-tauri/src/commands/agent_app_cmd.rs +++ b/src-tauri/src/commands/agent_app_cmd.rs @@ -16,12 +16,14 @@ use lime_core::database::dao::api_key_provider::{ use lime_services::api_key_provider_service::ApiKeyProviderService; use once_cell::sync::Lazy; use serde::{Deserialize, Serialize}; -use serde_json::Value; +use serde_json::{Map, Value}; use sha2::{Digest, Sha256}; use std::collections::HashMap; use std::fs; use std::io::{self, Cursor}; use std::net::TcpListener; +#[cfg(unix)] +use std::os::unix::process::CommandExt; use std::path::{Path, PathBuf}; use std::process::{Child, Command, Stdio}; use std::sync::Mutex; @@ -31,8 +33,21 @@ use zip::ZipArchive; const AGENT_APP_DATA_DIR: &str = "agent-apps"; const INSTALLED_STATE_SCHEMA_VERSION: u32 = 1; -const AGENT_APP_UI_RUNTIME_STARTUP_TIMEOUT_SECS: u64 = 10; +const AGENT_APP_UI_RUNTIME_STARTUP_TIMEOUT_SECS: u64 = 120; const AGENT_APP_UI_RUNTIME_TOKEN_TTL_SECS: u64 = 12 * 60 * 60; +const AGENT_APP_ARRAY_LAYER_FILES: &[(&str, &str)] = &[ + ("app.entries.yaml", "entries"), + ("app.permissions.yaml", "permissions"), +]; +const AGENT_APP_VALUE_LAYER_FILES: &[(&str, &str, &str)] = &[ + ("app.capabilities.yaml", "capabilities", "capabilityConfig"), + ("app.errors.yaml", "errors", "errors"), + ("app.i18n.yaml", "i18n", "i18n"), + ("app.signature.yaml", "signature", "signature"), + ("app.runtime.yaml", "agentRuntime", "agentRuntime"), + ("evals/readiness.yaml", "readiness", "readiness"), + ("evals/health.yaml", "health", "health"), +]; static AGENT_APP_UI_RUNTIMES: Lazy>> = Lazy::new(|| Mutex::new(HashMap::new())); @@ -269,7 +284,7 @@ pub async fn agent_app_inspect_local_package( let app_markdown_path = app_dir_path.join("APP.md"); let app_markdown = fs::read_to_string(&app_markdown_path) .map_err(|error| format!("读取 Agent App APP.md 失败: {error}"))?; - let manifest = parse_app_markdown_frontmatter(&app_markdown)?; + let manifest = resolve_agent_app_manifest(&app_dir_path, &app_markdown)?; let inspected_at = now_iso(); let manifest_hash = sha256_json_value(&manifest)?; let package_hash = sha256_package(&app_dir_path, &manifest)?; @@ -343,7 +358,7 @@ pub async fn agent_app_fetch_cloud_package( } let app_markdown = String::from_utf8(app_markdown_bytes) .map_err(|error| format!("Agent App APP.md 必须是 UTF-8: {error}"))?; - let manifest = parse_app_markdown_frontmatter(&app_markdown)?; + let manifest = resolve_agent_app_manifest(&extracted_root, &app_markdown)?; ensure_manifest_matches_cloud_release(&manifest, &descriptor)?; if cache_dir.exists() { @@ -525,7 +540,7 @@ async fn start_agent_app_ui_runtime_with_env( ensure_agent_app_runtime_folder(&app_dir)?; let entry = resolve_ui_runtime_entry(&state, request.entry_key.as_deref())?; - if let Some(status) = running_runtime_status(&request.app_id, Some(&entry))? { + if let Some(status) = running_runtime_status(&request.app_id, Some(&entry)).await? { return Ok(status); } @@ -564,7 +579,7 @@ pub async fn agent_app_get_ui_runtime_status( request: AgentAppUiRuntimeStatusRequest, ) -> Result { validate_safe_app_id(&request.app_id)?; - if let Some(status) = running_runtime_status(&request.app_id, None)? { + if let Some(status) = running_runtime_status(&request.app_id, None).await? { return Ok(status); } Ok(AgentAppUiRuntimeStatus { @@ -602,8 +617,7 @@ pub async fn agent_app_stop_ui_runtime( drop(registry); let pid = process.child.id(); - let _ = process.child.kill(); - let _ = process.child.wait(); + terminate_agent_app_ui_process(&mut process.child); Ok(AgentAppUiRuntimeStatus { app_id: request.app_id, @@ -984,7 +998,7 @@ fn ensure_agent_app_dir_matches_state(app_dir: &Path, state: &Value) -> Result<( manifest_path.display() ) })?; - let manifest = parse_app_markdown_frontmatter(&app_markdown)?; + let manifest = resolve_agent_app_manifest(&app_dir, &app_markdown)?; let manifest_app_id = read_string(&manifest, &["name"]).or_else(|| read_string(&manifest, &["appId"])); if manifest_app_id.as_deref() != Some(app_id.as_str()) { @@ -1278,6 +1292,10 @@ fn spawn_agent_app_ui_process( .args(["run", "dev", "--silent"]) .current_dir(app_dir) .env("PORT", port.to_string()); + #[cfg(unix)] + { + command.process_group(0); + } for key in inherited_agent_app_secret_env_keys() { command.env_remove(key); } @@ -1298,6 +1316,45 @@ fn spawn_agent_app_ui_process( )) } +fn terminate_agent_app_ui_process(child: &mut Child) { + terminate_agent_app_process_tree(child.id(), AgentAppProcessSignal::Terminate); + let deadline = Instant::now() + Duration::from_millis(900); + while Instant::now() < deadline { + if matches!(child.try_wait(), Ok(Some(_))) { + return; + } + std::thread::sleep(Duration::from_millis(100)); + } + terminate_agent_app_process_tree(child.id(), AgentAppProcessSignal::Kill); + let _ = child.kill(); + let _ = child.wait(); +} + +enum AgentAppProcessSignal { + Terminate, + Kill, +} + +#[cfg(unix)] +fn terminate_agent_app_process_tree(pid: u32, signal: AgentAppProcessSignal) { + let signal_name = match signal { + AgentAppProcessSignal::Terminate => "-TERM", + AgentAppProcessSignal::Kill => "-KILL", + }; + let process_group = format!("-{pid}"); + let _ = Command::new("kill") + .arg(signal_name) + .arg(process_group) + .status(); +} + +#[cfg(windows)] +fn terminate_agent_app_process_tree(pid: u32, _signal: AgentAppProcessSignal) { + let _ = Command::new("taskkill") + .args(["/PID", &pid.to_string(), "/T", "/F"]) + .status(); +} + fn inherited_agent_app_secret_env_keys() -> &'static [&'static str] { &[ "LIME_ACCESS_TOKEN", @@ -1322,11 +1379,8 @@ async fn wait_for_agent_app_ui_runtime_ready( child: &mut Child, base_url: &str, ) -> Result<(), String> { - let health_url = format!("{base_url}/api/bootstrap"); - let client = reqwest::Client::builder() - .timeout(Duration::from_millis(800)) - .build() - .unwrap_or_else(|_| reqwest::Client::new()); + let health_url = agent_app_ui_runtime_health_url(base_url); + let client = build_agent_app_ui_runtime_probe_client(); let deadline = Instant::now() + Duration::from_secs(AGENT_APP_UI_RUNTIME_STARTUP_TIMEOUT_SECS); loop { match child.try_wait() { @@ -1339,15 +1393,12 @@ async fn wait_for_agent_app_ui_runtime_ready( } } - if let Ok(response) = client.get(&health_url).send().await { - if response.status().is_success() { - return Ok(()); - } + if probe_agent_app_ui_runtime_ready_with_client(&client, &health_url).await { + return Ok(()); } if Instant::now() >= deadline { - let _ = child.kill(); - let _ = child.wait(); + terminate_agent_app_ui_process(child); return Err(format!( "Agent App UI runtime 未在 {} 秒内就绪: {health_url}", AGENT_APP_UI_RUNTIME_STARTUP_TIMEOUT_SECS @@ -1357,7 +1408,52 @@ async fn wait_for_agent_app_ui_runtime_ready( } } -fn running_runtime_status( +fn build_agent_app_ui_runtime_probe_client() -> reqwest::Client { + reqwest::Client::builder() + .no_proxy() + .timeout(Duration::from_millis(800)) + .build() + .unwrap_or_else(|_| reqwest::Client::new()) +} + +fn agent_app_ui_runtime_health_url(base_url: &str) -> String { + format!("{base_url}/api/bootstrap") +} + +async fn probe_agent_app_ui_runtime_ready_with_client( + client: &reqwest::Client, + health_url: &str, +) -> bool { + match client.get(health_url).send().await { + Ok(response) => response.status().is_success(), + Err(_) => false, + } +} + +async fn running_runtime_status( + app_id: &str, + entry: Option<&AgentAppUiRuntimeEntry>, +) -> Result, String> { + let Some(status) = running_runtime_status_by_process(app_id, entry)? else { + return Ok(None); + }; + if status.status != "running" { + return Ok(Some(status)); + } + let Some(base_url) = status.base_url.as_deref() else { + return Ok(Some(status)); + }; + let health_url = agent_app_ui_runtime_health_url(base_url); + let client = build_agent_app_ui_runtime_probe_client(); + if probe_agent_app_ui_runtime_ready_with_client(&client, &health_url).await { + return Ok(Some(status)); + } + + remove_unready_agent_app_ui_runtime(app_id, status.pid); + Ok(None) +} + +fn running_runtime_status_by_process( app_id: &str, entry: Option<&AgentAppUiRuntimeEntry>, ) -> Result, String> { @@ -1426,6 +1522,23 @@ fn running_runtime_status( Ok(Some(status)) } +fn remove_unready_agent_app_ui_runtime(app_id: &str, expected_pid: Option) { + let Ok(mut registry) = runtime_registry() else { + return; + }; + let Some(process) = registry.get(app_id) else { + return; + }; + if expected_pid.is_some_and(|pid| pid != process.child.id()) { + return; + } + let Some(mut process) = registry.remove(app_id) else { + return; + }; + drop(registry); + terminate_agent_app_ui_process(&mut process.child); +} + fn runtime_registry( ) -> Result>, String> { AGENT_APP_UI_RUNTIMES @@ -1469,6 +1582,126 @@ fn parse_app_markdown_frontmatter(markdown: &str) -> Result { .map_err(|error| format!("转换 Agent App manifest 失败: {error}")) } +fn resolve_agent_app_manifest(app_dir: &Path, markdown: &str) -> Result { + let mut manifest = parse_app_markdown_frontmatter(markdown)?; + apply_layered_manifest_files(app_dir, &mut manifest)?; + Ok(manifest) +} + +fn apply_layered_manifest_files(app_dir: &Path, manifest: &mut Value) -> Result<(), String> { + for (relative_path, field) in AGENT_APP_ARRAY_LAYER_FILES { + apply_named_array_layer(app_dir, manifest, relative_path, field)?; + } + for (relative_path, source_field, target_field) in AGENT_APP_VALUE_LAYER_FILES { + apply_value_layer(app_dir, manifest, relative_path, source_field, target_field)?; + } + Ok(()) +} + +fn read_layered_yaml(app_dir: &Path, relative_path: &str) -> Result, String> { + let path = app_dir.join(relative_path); + if !path.is_file() { + return Ok(None); + } + let content = fs::read_to_string(&path).map_err(|error| { + format!( + "读取 Agent App 分层 manifest 文件失败 {}: {error}", + path.display() + ) + })?; + let yaml_value: serde_yaml::Value = serde_yaml::from_str(&content).map_err(|error| { + format!( + "解析 Agent App 分层 manifest 文件失败 {}: {error}", + path.display() + ) + })?; + serde_json::to_value(yaml_value) + .map(Some) + .map_err(|error| format!("转换 Agent App 分层 manifest 文件失败: {error}")) +} + +fn apply_value_layer( + app_dir: &Path, + manifest: &mut Value, + relative_path: &str, + source_field: &str, + target_field: &str, +) -> Result<(), String> { + let Some(layer) = read_layered_yaml(app_dir, relative_path)? else { + return Ok(()); + }; + let Some(value) = layer.get(source_field).cloned() else { + return Ok(()); + }; + manifest_object_mut(manifest)?.insert(target_field.to_string(), value); + Ok(()) +} + +fn apply_named_array_layer( + app_dir: &Path, + manifest: &mut Value, + relative_path: &str, + field: &str, +) -> Result<(), String> { + let Some(layer) = read_layered_yaml(app_dir, relative_path)? else { + return Ok(()); + }; + let Some(layer_items) = layer.get(field).and_then(Value::as_array) else { + return Ok(()); + }; + let mut merged = manifest + .get(field) + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + + for layer_item in layer_items { + let Some(layer_key) = layered_item_key(layer_item) else { + merged.push(layer_item.clone()); + continue; + }; + if let Some(existing) = merged + .iter_mut() + .find(|item| layered_item_key(item).as_deref() == Some(layer_key.as_str())) + { + merge_json_object(existing, layer_item.clone())?; + } else { + merged.push(layer_item.clone()); + } + } + + manifest_object_mut(manifest)?.insert(field.to_string(), Value::Array(merged)); + Ok(()) +} + +fn layered_item_key(value: &Value) -> Option { + value + .get("key") + .or_else(|| value.get("id")) + .and_then(Value::as_str) + .map(ToString::to_string) +} + +fn merge_json_object(target: &mut Value, overlay: Value) -> Result<(), String> { + match (target.as_object_mut(), overlay) { + (Some(target_object), Value::Object(overlay_object)) => { + for (key, value) in overlay_object { + target_object.insert(key, value); + } + } + (_, value) => { + *target = value; + } + } + Ok(()) +} + +fn manifest_object_mut(manifest: &mut Value) -> Result<&mut Map, String> { + manifest + .as_object_mut() + .ok_or_else(|| "Agent App manifest 必须是对象。".to_string()) +} + fn sha256_json_value(value: &Value) -> Result { let bytes = serde_json::to_vec(value).map_err(|error| format!("序列化 manifest 失败: {error}"))?; @@ -1480,17 +1713,59 @@ fn sha256_package(app_dir: &Path, manifest: &Value) -> Result { hasher.update( serde_json::to_vec(manifest).map_err(|error| format!("序列化 manifest 失败: {error}"))?, ); - let app_markdown_path = app_dir.join("APP.md"); - if let Ok(bytes) = fs::read(&app_markdown_path) { - hasher.update(bytes); - } - let package_json_path = app_dir.join("package.json"); - if let Ok(bytes) = fs::read(&package_json_path) { - hasher.update(bytes); + for file in list_agent_app_package_files(app_dir)? { + let relative = file.strip_prefix(app_dir).map_err(|error| { + format!( + "计算 Agent App package hash 时无法生成相对路径 {}: {error}", + file.display() + ) + })?; + hasher.update(relative.to_string_lossy().as_bytes()); + hasher.update([0]); + hasher.update(fs::read(&file).map_err(|error| { + format!( + "读取 Agent App package 文件失败 {}: {error}", + file.display() + ) + })?); + hasher.update([0]); } Ok(format!("sha256:{}", hex::encode(hasher.finalize()))) } +fn list_agent_app_package_files(app_dir: &Path) -> Result, String> { + let mut result = Vec::new(); + collect_agent_app_package_files(app_dir, &mut result)?; + result.sort(); + Ok(result) +} + +fn collect_agent_app_package_files(path: &Path, result: &mut Vec) -> Result<(), String> { + let entries = + fs::read_dir(path).map_err(|error| format!("读取 Agent App package 目录失败: {error}"))?; + for entry in entries { + let entry = entry.map_err(|error| format!("读取 Agent App package 条目失败: {error}"))?; + let entry_path = entry.path(); + let file_name = entry.file_name(); + let file_name = file_name.to_string_lossy(); + if matches!( + file_name.as_ref(), + ".git" | "node_modules" | ".local" | ".lime" + ) { + continue; + } + let metadata = entry + .metadata() + .map_err(|error| format!("读取 Agent App package 元数据失败: {error}"))?; + if metadata.is_dir() { + collect_agent_app_package_files(&entry_path, result)?; + } else if metadata.is_file() { + result.push(entry_path); + } + } + Ok(()) +} + fn sha256_hex(bytes: &[u8]) -> String { let mut hasher = Sha256::new(); hasher.update(bytes); @@ -1827,4 +2102,77 @@ mod tests { assert_eq!(sha256_prefixed(app_markdown.as_bytes()), manifest_hash); ensure_manifest_matches_cloud_release(&manifest, &descriptor).unwrap(); } + + #[test] + fn resolves_layered_manifest_files() { + let temp = tempdir().unwrap(); + let app_dir = temp.path(); + fs::write(app_dir.join("APP.md"), sample_app_markdown()).unwrap(); + fs::write( + app_dir.join("app.entries.yaml"), + "entries:\n - key: dashboard\n title: 项目组合\n route: /dashboard\n - key: settings\n kind: settings\n title: 设置\n route: /settings\n", + ) + .unwrap(); + fs::write( + app_dir.join("app.permissions.yaml"), + "permissions:\n - key: read_selected_files\n scope: filesystem\n access: read\n required: true\n", + ) + .unwrap(); + fs::write( + app_dir.join("app.i18n.yaml"), + "i18n:\n defaultLocale: zh-CN\n supportedLocales:\n - zh-CN\n - en-US\n", + ) + .unwrap(); + fs::write( + app_dir.join("app.runtime.yaml"), + "agentRuntime:\n agentTask:\n eventSchema: lime.agent-task-event.v1\n", + ) + .unwrap(); + fs::create_dir_all(app_dir.join("evals")).unwrap(); + fs::write( + app_dir.join("evals/readiness.yaml"), + "readiness:\n required:\n - check: sdk_version\n expect: \">=0.6.0\"\n", + ) + .unwrap(); + + let manifest = resolve_agent_app_manifest(app_dir, sample_app_markdown()).unwrap(); + let entries = manifest["entries"].as_array().unwrap(); + + assert_eq!(entries.len(), 2); + assert_eq!(entries[0]["key"], "dashboard"); + assert_eq!(entries[0]["title"], "项目组合"); + assert_eq!(entries[0]["route"], "/dashboard"); + assert_eq!(entries[1]["key"], "settings"); + assert_eq!(manifest["permissions"][0]["key"], "read_selected_files"); + assert_eq!(manifest["i18n"]["defaultLocale"], "zh-CN"); + assert_eq!( + manifest["agentRuntime"]["agentTask"]["eventSchema"], + "lime.agent-task-event.v1" + ); + assert_eq!(manifest["readiness"]["required"][0]["check"], "sdk_version"); + } + + #[test] + fn local_package_hash_includes_layered_files_but_ignores_local_state() { + let temp = tempdir().unwrap(); + let app_dir = temp.path(); + fs::write(app_dir.join("APP.md"), sample_app_markdown()).unwrap(); + fs::write(app_dir.join("app.entries.yaml"), "entries: []\n").unwrap(); + fs::create_dir_all(app_dir.join(".local")).unwrap(); + fs::write(app_dir.join(".local/runtime.json"), "{}").unwrap(); + + let manifest = resolve_agent_app_manifest(app_dir, sample_app_markdown()).unwrap(); + let first_hash = sha256_package(app_dir, &manifest).unwrap(); + fs::write(app_dir.join(".local/runtime.json"), "{\"changed\":true}").unwrap(); + let ignored_local_hash = sha256_package(app_dir, &manifest).unwrap(); + fs::write( + app_dir.join("app.entries.yaml"), + "entries:\n - key: dashboard\n title: 新标题\n", + ) + .unwrap(); + let changed_package_hash = sha256_package(app_dir, &manifest).unwrap(); + + assert_eq!(first_hash, ignored_local_hash); + assert_ne!(first_hash, changed_package_hash); + } } diff --git a/src-tauri/src/commands/agent_app_runtime_cmd.rs b/src-tauri/src/commands/agent_app_runtime_cmd.rs index 949f8fe3c..ea82645d8 100644 --- a/src-tauri/src/commands/agent_app_runtime_cmd.rs +++ b/src-tauri/src/commands/agent_app_runtime_cmd.rs @@ -3,6 +3,13 @@ //! 本模块只把 App-scoped task 适配到现有 AgentRuntime 主链; //! 不在 Agent App 下创建第二套模型、工具、证据或队列运行时。 +mod events; +mod metadata; +mod model_preference; +#[cfg(test)] +mod tests; +mod types; + use crate::agent::AsterAgentState; use crate::app::LogState; use crate::commands::api_key_provider_cmd::ApiKeyProviderServiceState; @@ -10,184 +17,45 @@ use crate::commands::aster_agent_cmd::action_runtime::agent_runtime_respond_acti use crate::commands::aster_agent_cmd::{ agent_runtime_get_thread_read, agent_runtime_interrupt_turn, build_queued_turn_task, create_runtime_session_internal_with_runtime_and_session_id, AgentRuntimeInterruptTurnRequest, - AgentRuntimeRespondActionRequest, AgentRuntimeThreadArtifactView, AgentRuntimeThreadReadModel, AsterChatRequest, AsterExecutionStrategy, RuntimeCommandContext, }; use crate::config::GlobalConfigManagerState; use crate::database::DbConnection; use crate::mcp::McpManagerState; -use crate::services::agent_app_runtime_capability_catalog_service::{ - resolve_capability_descriptors, AgentAppRuntimeCapabilityDescriptor, -}; use crate::services::automation_service::AutomationServiceState; use chrono::Utc; -use lime_core::database::dao::agent_run::{AgentRun, AgentRunDao, AgentRunStatus}; -use lime_core::database::dao::api_key_provider::ProviderWithKeys; -use serde::{Deserialize, Serialize}; -use serde_json::{json, Map, Value}; -use tauri::{AppHandle, Emitter, State}; +use tauri::{AppHandle, State}; use uuid::Uuid; +#[allow(unused_imports)] +pub use types::{ + AgentAppRuntimeCancelTaskRequest, AgentAppRuntimeCancelTaskResult, + AgentAppRuntimeGetTaskRequest, AgentAppRuntimeStartTaskRequest, AgentAppRuntimeStartTaskResult, + AgentAppRuntimeSubmitHostResponseRequest, AgentAppRuntimeSubmitHostResponseResult, + AgentAppRuntimeTaskEvent, AgentAppRuntimeTaskSnapshot, +}; + +use events::{build_agent_app_runtime_task_events, emit_agent_app_runtime_task_snapshot}; +#[cfg(test)] +use events::{ + build_agent_app_runtime_task_snapshot_event_payload, + extract_content_factory_workspace_patch_from_artifact_document, +}; +use metadata::{build_agent_app_runtime_metadata, build_agent_app_runtime_task_message}; +#[cfg(test)] +use model_preference::model_preference_from_run_metadata; +use model_preference::{ + insert_agent_app_runtime_model_preference_metadata, resolve_agent_app_runtime_model_preference, +}; + const AGENT_APP_RUNTIME_EVENT_PREFIX: &str = "agent_app_runtime"; const AGENT_APP_RUNTIME_METADATA_KEY: &str = "agent_app_runtime"; const LIME_RUNTIME_METADATA_KEY: &str = "lime_runtime"; +const LIME_RUNTIME_TOOL_SURFACE_KEY: &str = "tool_surface"; const AGENT_APP_RUNTIME_CAPABILITY_SOURCE: &str = "agent_app_runtime"; const CONTENT_FACTORY_WORKSPACE_PATCH_KIND: &str = "content_factory.workspace_patch"; const AGENT_APP_RUNTIME_SESSION_ID_PREFIX: &str = "agent-app-runtime-"; -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AgentAppRuntimeStartTaskRequest { - pub app_id: String, - #[serde(default)] - pub entry_key: Option, - #[serde(default)] - pub workspace_id: Option, - #[serde(default)] - pub session_id: Option, - #[serde(default)] - pub task_id: Option, - pub task_kind: String, - #[serde(default)] - pub idempotency_key: Option, - #[serde(default)] - pub title: Option, - #[serde(default)] - pub prompt: Option, - #[serde(default)] - pub input: Option, - #[serde(default)] - pub expected_output: Option, - #[serde(default)] - pub required_capabilities: Vec, - #[serde(default)] - pub capability_hints: Vec, - #[serde(default)] - pub knowledge_bindings: Vec, - #[serde(default)] - pub human_review: Option, - #[serde(default)] - pub event_name: Option, - #[serde(default)] - pub turn_id: Option, - #[serde(default)] - pub provider_preference: Option, - #[serde(default)] - pub model_preference: Option, - #[serde(default)] - pub queue_if_busy: Option, - #[serde(default)] - pub skip_pre_submit_resume: Option, - #[serde(default)] - pub run_start_hooks: Option, - #[serde(default)] - pub metadata: Option, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct AgentAppRuntimeStartTaskResult { - pub app_id: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub entry_key: Option, - pub task_id: String, - pub trace_id: String, - pub task_kind: String, - pub session_id: String, - pub turn_id: String, - pub event_name: String, - pub status: String, - pub submitted_at: String, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AgentAppRuntimeCancelTaskRequest { - pub app_id: String, - pub task_id: String, - pub session_id: String, - #[serde(default)] - pub turn_id: Option, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct AgentAppRuntimeCancelTaskResult { - pub app_id: String, - pub task_id: String, - pub session_id: String, - pub cancelled: bool, - pub status: String, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AgentAppRuntimeGetTaskRequest { - pub app_id: String, - pub task_id: String, - pub session_id: String, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct AgentAppRuntimeTaskEvent { - pub id: String, - pub event_type: String, - pub status: String, - pub message: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub severity: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub turn_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub request_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub tool_name: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub evidence_ref: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub artifact_ref: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub occurred_at: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub payload: Option, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct AgentAppRuntimeTaskSnapshot { - pub app_id: String, - pub task_id: String, - pub session_id: String, - pub status: String, - pub task_status: String, - pub task_events: Vec, - pub thread_read: Value, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AgentAppRuntimeSubmitHostResponseRequest { - pub app_id: String, - pub task_id: String, - pub runtime_request: AgentRuntimeRespondActionRequest, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct AgentAppRuntimeSubmitHostResponseResult { - pub app_id: String, - pub task_id: String, - pub status: String, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct AgentAppRuntimeModelPreference { - provider_preference: String, - model_preference: String, - source: &'static str, -} - fn non_empty(value: Option<&str>) -> Option { value .map(str::trim) @@ -207,1107 +75,6 @@ fn require_text(value: Option<&str>, label: &str) -> Result { non_empty(value).ok_or_else(|| format!("{label} 不能为空")) } -fn is_unconfigured_model_preference(value: &str) -> bool { - matches!( - value.trim().to_ascii_lowercase().as_str(), - "" | "unconfigured" | "unknown" | "none" | "null" - ) -} - -fn model_preference_from_values( - provider_preference: Option, - model_preference: Option, - source: &'static str, -) -> Option { - let provider_preference = provider_preference - .and_then(|value| non_empty(Some(value.as_str()))) - .filter(|value| !is_unconfigured_model_preference(value))?; - let model_preference = model_preference - .and_then(|value| non_empty(Some(value.as_str()))) - .filter(|value| !is_unconfigured_model_preference(value))?; - - Some(AgentAppRuntimeModelPreference { - provider_preference, - model_preference, - source, - }) -} - -fn json_pointer_string(value: &Value, pointers: &[&str]) -> Option { - pointers.iter().find_map(|pointer| { - value - .pointer(pointer) - .and_then(Value::as_str) - .and_then(|value| non_empty(Some(value))) - }) -} - -fn model_preference_from_run_metadata(metadata: &Value) -> Option { - let provider_preference = json_pointer_string( - metadata, - &[ - "/turn_input/provider_routing/provider_selector", - "/turnInput/providerRouting/providerSelector", - "/request_metadata/lime_runtime/routing_decision/selected_provider", - "/request_metadata/lime_runtime/routing_decision/selectedProvider", - "/requestMetadata/limeRuntime/routingDecision/selectedProvider", - ], - ); - let model_preference = json_pointer_string( - metadata, - &[ - "/turn_input/provider_routing/model_name", - "/turnInput/providerRouting/modelName", - "/request_metadata/lime_runtime/routing_decision/selected_model", - "/request_metadata/lime_runtime/routing_decision/selectedModel", - "/requestMetadata/limeRuntime/routingDecision/selectedModel", - ], - ); - - model_preference_from_values( - provider_preference, - model_preference, - "recent_successful_agent_run", - ) -} - -fn model_preference_from_recent_successful_runs( - db: &DbConnection, -) -> Option { - let runs = { - let conn = match db.lock() { - Ok(conn) => conn, - Err(error) => { - tracing::warn!( - "[AgentAppRuntime] 读取最近模型偏好时数据库锁定失败: {}", - error - ); - return None; - } - }; - match AgentRunDao::list_runs(&conn, 50, 0) { - Ok(runs) => runs, - Err(error) => { - tracing::warn!( - "[AgentAppRuntime] 读取最近 agent_runs 失败,跳过模型偏好回填: {}", - error - ); - return None; - } - } - }; - - runs.iter() - .filter(|run| matches!(run.status, AgentRunStatus::Success)) - .find_map(model_preference_from_agent_run) -} - -fn model_preference_from_agent_run(run: &AgentRun) -> Option { - let metadata = run.metadata.as_deref()?; - let metadata: Value = serde_json::from_str(metadata).ok()?; - model_preference_from_run_metadata(&metadata) -} - -fn provider_looks_non_chat_agent_runtime_candidate(provider: &ProviderWithKeys) -> bool { - let text = [ - provider.provider.id.as_str(), - provider.provider.name.as_str(), - provider.provider.api_host.as_str(), - ] - .join(" ") - .to_ascii_lowercase(); - - text.contains("fal") - || text.contains("codex") - || text.contains("coding") - || text.contains("gpt-image") - || text.contains("gpt_images") -} - -fn model_preference_from_enabled_provider_catalog( - db: &DbConnection, - api_key_provider_service: &ApiKeyProviderServiceState, -) -> Option { - let providers = match api_key_provider_service.0.get_all_providers(db) { - Ok(providers) => providers, - Err(error) => { - tracing::warn!( - "[AgentAppRuntime] 读取 API Key Providers 失败,跳过模型偏好回填: {}", - error - ); - return None; - } - }; - - providers.into_iter().find_map(|provider| { - if !provider.provider.enabled { - return None; - } - if provider_looks_non_chat_agent_runtime_candidate(&provider) { - return None; - } - if !provider.api_keys.iter().any(|key| key.enabled) { - return None; - } - let model = provider - .provider - .custom_models - .iter() - .find_map(|model| non_empty(Some(model.as_str())))?; - model_preference_from_values( - Some(provider.provider.id), - Some(model), - "enabled_provider_custom_model", - ) - }) -} - -async fn resolve_agent_app_runtime_model_preference( - state: &AsterAgentState, - db: &DbConnection, - api_key_provider_service: &ApiKeyProviderServiceState, - request: &AgentAppRuntimeStartTaskRequest, -) -> Option { - if let Some(preference) = model_preference_from_values( - request.provider_preference.clone(), - request.model_preference.clone(), - "request", - ) { - return Some(preference); - } - - if let Some(preference) = model_preference_from_recent_successful_runs(db) { - return Some(preference); - } - - if let Some(preference) = - model_preference_from_enabled_provider_catalog(db, api_key_provider_service) - { - return Some(preference); - } - - if let Some(config) = state.get_provider_config().await { - if let Some(preference) = model_preference_from_values( - config - .provider_selector - .clone() - .or_else(|| Some(config.provider_name.clone())), - Some(config.model_name.clone()), - "current_agent_state", - ) { - return Some(preference); - } - } - - None -} - -fn insert_agent_app_runtime_model_preference_metadata( - metadata: &mut Value, - preference: &AgentAppRuntimeModelPreference, -) { - let Some(root) = metadata.as_object_mut() else { - return; - }; - let preference_value = json!({ - "provider_preference": preference.provider_preference.clone(), - "model_preference": preference.model_preference.clone(), - "source": preference.source, - }); - - let harness = root - .entry("harness".to_string()) - .or_insert_with(|| json!({})); - if let Some(harness) = harness.as_object_mut() { - harness.insert( - "agent_app_runtime_model_preference".to_string(), - preference_value.clone(), - ); - if let Some(app_runtime) = harness - .get_mut(AGENT_APP_RUNTIME_METADATA_KEY) - .and_then(Value::as_object_mut) - { - app_runtime.insert("model_preference".to_string(), preference_value.clone()); - } - } - - if let Some(app_runtime) = root - .get_mut(AGENT_APP_RUNTIME_METADATA_KEY) - .and_then(Value::as_object_mut) - { - app_runtime.insert("model_preference".to_string(), preference_value); - } -} - -fn default_task_message(request: &AgentAppRuntimeStartTaskRequest) -> String { - let title = non_empty(request.title.as_deref()) - .or_else(|| non_empty(request.prompt.as_deref())) - .unwrap_or_else(|| request.task_kind.trim().to_string()); - let input = request - .input - .as_ref() - .map(|value| serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string())) - .unwrap_or_else(|| "{}".to_string()); - let expected_output = request - .expected_output - .as_ref() - .map(|value| serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string())) - .unwrap_or_else(|| "{}".to_string()); - - [ - "【Agent App Runtime Task】".to_string(), - format!("App: {}", request.app_id.trim()), - format!( - "Entry: {}", - request.entry_key.as_deref().unwrap_or("default").trim() - ), - format!("TaskKind: {}", request.task_kind.trim()), - format!("Title: {title}"), - "".to_string(), - "请在 Lime AgentRuntime 主链中完成这个 App 业务任务。".to_string(), - "不要要求用户跳回通用 Chat;如需补充上下文,请通过可审计的 action / request 机制表达。" - .to_string(), - "".to_string(), - "Input JSON:".to_string(), - input, - "".to_string(), - "Expected Output JSON:".to_string(), - expected_output, - ] - .join("\n") -} - -fn expected_artifact_kind(request: &AgentAppRuntimeStartTaskRequest) -> Option { - let expected_output = request.expected_output.as_ref()?.as_object()?; - [ - "artifactKind", - "artifact_type", - "artifactType", - "kind", - "outputKind", - ] - .iter() - .filter_map(|key| expected_output.get(*key).and_then(Value::as_str)) - .find_map(|value| non_empty(Some(value))) -} - -fn is_content_factory_runtime_task(request: &AgentAppRuntimeStartTaskRequest) -> bool { - request.app_id.trim() == "content-factory-app" - || request.task_kind.trim().starts_with("content_factory.") -} - -fn build_agent_app_output_contract(request: &AgentAppRuntimeStartTaskRequest) -> Option { - if !is_content_factory_runtime_task(request) { - return None; - } - let artifact_kind = expected_artifact_kind(request)?; - Some(json!({ - "producer": "agent_runtime_artifact_metadata", - "artifact_kind": artifact_kind, - "artifact_metadata_kind": CONTENT_FACTORY_WORKSPACE_PATCH_KIND, - "patch_metadata_keys": ["contentFactoryWorkspacePatch", "workspacePatch"], - "required_patch_fields": ["kind", "projectId"], - "accepted_patch_fields": [ - "workspace", - "project", - "sceneTable", - "contentBatch", - "scripts", - "imagePrompts", - "assetPack" - ], - })) -} - -fn build_agent_app_runtime_task_message(request: &AgentAppRuntimeStartTaskRequest) -> String { - let prompt = non_empty(request.prompt.as_deref()) - .or_else(|| non_empty(request.title.as_deref())) - .unwrap_or_else(|| request.task_kind.trim().to_string()); - let input = request - .input - .as_ref() - .map(|value| serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string())) - .unwrap_or_else(|| "{}".to_string()); - let expected_output = request - .expected_output - .as_ref() - .map(|value| serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string())) - .unwrap_or_else(|| "{}".to_string()); - let mut lines = vec![ - "【Agent App Runtime Task】".to_string(), - format!("App: {}", request.app_id.trim()), - format!( - "Entry: {}", - request.entry_key.as_deref().unwrap_or("default").trim() - ), - format!("TaskKind: {}", request.task_kind.trim()), - "".to_string(), - "Business Prompt:".to_string(), - prompt, - "".to_string(), - "Runtime Boundary:".to_string(), - "- 请在 Lime AgentRuntime 主链中完成这个 App 业务任务。".to_string(), - "- 不要要求用户跳回通用 Chat;如需补充上下文,请通过可审计的 action / request 机制表达。" - .to_string(), - ]; - - if let Some(contract) = build_agent_app_output_contract(request) { - let artifact_kind = contract - .get("artifact_kind") - .and_then(Value::as_str) - .unwrap_or("content_batch"); - lines.extend([ - "".to_string(), - "Content Factory Output Contract:".to_string(), - format!( - "- 如果任务产出可直接物化到内容工厂项目,必须创建 artifactKind={artifact_kind} 的 artifact。" - ), - format!( - "- artifact metadata 必须包含 contentFactoryWorkspacePatch 或 workspacePatch;metadata.kind 可使用 {}。", - CONTENT_FACTORY_WORKSPACE_PATCH_KIND - ), - "- 不要通过 Bash、shell、脚本或直接写 .lime/artifacts 文件来伪造 artifact;最终回答应直接输出结构化 JSON。" - .to_string(), - "- 最终回答的顶层 JSON 必须包含 contentFactoryWorkspacePatch 或 workspacePatch,方便 Host 自动回写当前 App 页面。" - .to_string(), - "- patch 至少包含 kind / projectId,并按结果类型填写 sceneTable、contentBatch、scripts、imagePrompts 或 assetPack。" - .to_string(), - "- tools / capabilityHints 只是可选能力提示,不能把复合内容工厂任务改写成单一 research / image Skill;除非任务明确要求真实搜索或生图,否则先直接产出 workspace patch。" - .to_string(), - "- 不要只返回自然语言总结;结构化 patch 是 App 自动回写当前页面的事实源。".to_string(), - ]); - } - - lines.extend([ - "".to_string(), - "Input JSON:".to_string(), - input, - "".to_string(), - "Expected Output JSON:".to_string(), - expected_output, - ]); - - lines.join("\n") -} - -fn insert_string_if_some(map: &mut Map, key: &str, value: Option) { - if let Some(value) = value.map(|item| item.trim().to_string()) { - if !value.is_empty() { - map.insert(key.to_string(), json!(value)); - } - } -} - -fn app_task_prompt_summary(request: &AgentAppRuntimeStartTaskRequest) -> String { - non_empty(request.prompt.as_deref()) - .or_else(|| non_empty(request.title.as_deref())) - .unwrap_or_else(|| default_task_message(request)) -} - -fn build_agent_app_capability_request_context( - request: &AgentAppRuntimeStartTaskRequest, - task_id: &str, - trace_id: &str, - descriptor: AgentAppRuntimeCapabilityDescriptor, -) -> Map { - let prompt = app_task_prompt_summary(request); - let mut context = Map::new(); - - context.insert( - "source".to_string(), - json!(AGENT_APP_RUNTIME_CAPABILITY_SOURCE), - ); - context.insert("capability_id".to_string(), json!(descriptor.capability_id)); - context.insert("app_id".to_string(), json!(request.app_id.trim())); - context.insert("task_id".to_string(), json!(task_id)); - context.insert("trace_id".to_string(), json!(trace_id)); - context.insert("task_kind".to_string(), json!(request.task_kind.trim())); - context.insert( - "entry_source".to_string(), - json!(AGENT_APP_RUNTIME_CAPABILITY_SOURCE), - ); - context.insert("raw_text".to_string(), json!(prompt.clone())); - context.insert("prompt".to_string(), json!(prompt)); - context.insert( - "required_capabilities".to_string(), - json!(request.required_capabilities.clone()), - ); - context.insert( - "capability_hints".to_string(), - json!(request.capability_hints.clone()), - ); - context.insert( - "human_review".to_string(), - json!(request.human_review.unwrap_or(false)), - ); - - insert_string_if_some( - &mut context, - "entry_key", - non_empty(request.entry_key.as_deref()), - ); - insert_string_if_some( - &mut context, - "workspace_id", - non_empty(request.workspace_id.as_deref()), - ); - insert_string_if_some( - &mut context, - "idempotency_key", - non_empty(request.idempotency_key.as_deref()), - ); - - if let Some(input) = request.input.clone() { - context.insert("input".to_string(), input); - } - if let Some(expected_output) = request.expected_output.clone() { - context.insert("expected_output".to_string(), expected_output); - } - if !request.knowledge_bindings.is_empty() { - context.insert( - "knowledge_bindings".to_string(), - json!(request.knowledge_bindings.clone()), - ); - } - - match descriptor.context_key { - "image_task" | "cover_task" => { - context.insert("mode".to_string(), json!("generate")); - } - "research_request" | "report_request" => { - if !context.contains_key("query") { - let query = context - .get("prompt") - .and_then(Value::as_str) - .unwrap_or("请根据当前 App 任务补齐资料") - .to_string(); - context.insert("query".to_string(), json!(query)); - } - } - _ => {} - } - - context -} - -fn resolve_agent_app_runtime_capability_descriptors( - request: &AgentAppRuntimeStartTaskRequest, -) -> Vec { - resolve_capability_descriptors( - request - .required_capabilities - .iter() - .map(String::as_str) - .chain(request.capability_hints.iter().map(String::as_str)), - ) -} - -fn capability_descriptor_metadata(descriptor: AgentAppRuntimeCapabilityDescriptor) -> Value { - json!({ - "capability_id": descriptor.capability_id, - "skill_name": descriptor.skill_name, - "launch_key": descriptor.launch_key, - "context_key": descriptor.context_key, - "default_kind": descriptor.default_kind, - }) -} - -fn build_agent_app_capability_workflow_metadata( - request: &AgentAppRuntimeStartTaskRequest, - descriptors: &[AgentAppRuntimeCapabilityDescriptor], - output_contract: Option<&Value>, - inserts_primary_launch: bool, -) -> Option { - if descriptors.is_empty() { - return None; - } - - Some(json!({ - "source": AGENT_APP_RUNTIME_CAPABILITY_SOURCE, - "mode": if output_contract.is_some() { - "composite_output_contract" - } else if descriptors.len() > 1 { - "multi_capability" - } else { - "single_capability" - }, - "launch_policy": if inserts_primary_launch { - "primary_skill_launch" - } else { - "metadata_only" - }, - "requested_capabilities": request.required_capabilities.clone(), - "capability_hints": request.capability_hints.clone(), - "descriptors": descriptors - .iter() - .copied() - .map(capability_descriptor_metadata) - .collect::>(), - })) -} - -fn insert_agent_app_capability_launch_metadata( - root: &mut Map, - request: &AgentAppRuntimeStartTaskRequest, - task_id: &str, - trace_id: &str, - descriptor: AgentAppRuntimeCapabilityDescriptor, -) { - let launch_context = - build_agent_app_capability_request_context(request, task_id, trace_id, descriptor); - let harness = root - .entry("harness".to_string()) - .or_insert_with(|| json!({})); - let Some(harness) = harness.as_object_mut() else { - return; - }; - - harness.insert("allow_model_skills".to_string(), json!(true)); - let mut launch = Map::new(); - launch.insert("skill_name".to_string(), json!(descriptor.skill_name)); - launch.insert("kind".to_string(), json!(descriptor.default_kind)); - launch.insert( - descriptor.context_key.to_string(), - Value::Object(launch_context), - ); - harness.insert(descriptor.launch_key.to_string(), Value::Object(launch)); -} - -fn should_insert_agent_app_capability_launch_metadata( - request: &AgentAppRuntimeStartTaskRequest, - output_contract: Option<&Value>, -) -> bool { - // 内容工厂这类复合业务任务的 tools/capabilityHints 表示“可用能力”, - // 不能被提升为单一 Claw Skill 启动,否则会偏离 App 的 workspace patch 产物合同。 - if is_content_factory_runtime_task(request) && output_contract.is_some() { - return false; - } - - true -} - -fn insert_agent_app_output_contract_runtime_hints( - harness: &mut Map, - request: &AgentAppRuntimeStartTaskRequest, - output_contract: Option<&Value>, -) { - if !is_content_factory_runtime_task(request) || output_contract.is_none() { - return; - } - - // 内容工厂 patch 产出优先走直接回答 + ArtifactDocument 自动落盘, - // 避免复合业务任务被 FullRuntime 的通用工具链带偏成读文件 / Bash / 子代理循环。 - harness - .entry("chat_mode".to_string()) - .or_insert_with(|| json!("general")); - harness - .entry("session_mode".to_string()) - .or_insert_with(|| json!("general_workbench")); -} - -fn push_task_event( - events: &mut Vec, - event_type: &str, - status: &str, - message: impl Into, - occurred_at: Option, - payload: Option, -) { - events.push(AgentAppRuntimeTaskEvent { - id: format!("{}:{}", event_type, events.len() + 1), - event_type: event_type.to_string(), - status: status.to_string(), - message: message.into(), - severity: None, - turn_id: None, - request_id: None, - tool_name: None, - evidence_ref: None, - artifact_ref: None, - occurred_at, - payload, - }); -} - -fn outcome_event_type(outcome_type: &str) -> &'static str { - let normalized = outcome_type.to_ascii_lowercase(); - if normalized.contains("cancel") || normalized.contains("interrupt") { - "task:cancelled" - } else if normalized.contains("fail") - || normalized.contains("error") - || normalized.contains("timeout") - { - "task:error" - } else { - "task:completed" - } -} - -fn has_missing_context(context_summary: Option<&Value>) -> Option { - let summary = context_summary?.as_object()?; - let missing_context = summary - .get("missing_context") - .or_else(|| summary.get("missingContext"))?; - if missing_context - .as_array() - .is_some_and(|items| !items.is_empty()) - { - Some(missing_context.clone()) - } else { - None - } -} - -fn is_content_factory_workspace_patch_kind(value: &str) -> bool { - matches!( - value.trim(), - CONTENT_FACTORY_WORKSPACE_PATCH_KIND - | "contentFactoryWorkspacePatch" - | "workspace_patch" - | "workspacePatch" - ) -} - -fn has_content_factory_workspace_patch_fields(value: &Value) -> bool { - value.as_object().is_some_and(|object| { - object.contains_key("workspace") - || object.contains_key("project") - || object.contains_key("sceneTable") - || object.contains_key("contentBatch") - || object.contains_key("scripts") - || object.contains_key("imagePrompts") - || object.contains_key("assetPack") - }) -} - -fn extract_content_factory_workspace_patch(metadata: Option<&Value>) -> Option { - let metadata = metadata?; - for key in ["contentFactoryWorkspacePatch", "workspacePatch"] { - if let Some(value) = metadata.get(key) { - if has_content_factory_workspace_patch_fields(value) { - return Some(value.clone()); - } - } - } - - let artifact_kind = metadata - .get("artifactType") - .or_else(|| metadata.get("artifact_type")) - .or_else(|| metadata.get("kind")) - .or_else(|| metadata.get("outputKind")) - .and_then(Value::as_str); - if artifact_kind.is_some_and(is_content_factory_workspace_patch_kind) - && has_content_factory_workspace_patch_fields(metadata) - { - return Some(metadata.clone()); - } - - None -} - -fn parse_json_object_from_markdown(value: &str) -> Option { - let trimmed = value.trim(); - let candidate = if trimmed.starts_with("```") { - let without_opening = trimmed.lines().skip(1).collect::>().join("\n"); - without_opening - .rsplit_once("```") - .map(|(body, _)| body.trim().to_string()) - .unwrap_or(without_opening) - } else { - trimmed.to_string() - }; - - serde_json::from_str::(&candidate).ok().or_else(|| { - let start = candidate.find('{')?; - let end = candidate.rfind('}')?; - serde_json::from_str::(&candidate[start..=end]).ok() - }) -} - -fn extract_content_factory_workspace_patch_from_artifact_document( - metadata: Option<&Value>, -) -> Option { - let metadata = metadata?; - let artifact_document = metadata - .get("artifactDocument") - .or_else(|| metadata.get("artifact_document"))?; - let blocks = artifact_document.get("blocks")?.as_array()?; - - blocks.iter().find_map(|block| { - let text = block - .get("content") - .or_else(|| block.get("markdown")) - .and_then(Value::as_str)?; - let parsed = parse_json_object_from_markdown(text)?; - extract_content_factory_workspace_patch(Some(&parsed)) - }) -} - -fn build_artifact_event_payload(artifact: &AgentRuntimeThreadArtifactView) -> Option { - let artifact_value = serde_json::to_value(artifact).ok()?; - let workspace_patch = extract_content_factory_workspace_patch(artifact.metadata.as_ref()) - .or_else(|| { - extract_content_factory_workspace_patch_from_artifact_document( - artifact.metadata.as_ref(), - ) - }); - if let Some(workspace_patch) = workspace_patch { - return Some(json!({ - "artifact": artifact_value, - "workspacePatch": workspace_patch, - "contentFactoryWorkspacePatch": workspace_patch, - "producer": "agent_runtime_artifact_metadata", - })); - } - Some(artifact_value) -} - -fn build_agent_app_runtime_task_events( - thread_read: &AgentRuntimeThreadReadModel, -) -> Vec { - let mut events = Vec::new(); - - for queued_turn in &thread_read.queued_turns { - push_task_event( - &mut events, - "task:queued", - "queued", - queued_turn.message_preview.clone(), - None, - serde_json::to_value(queued_turn).ok(), - ); - } - - let status_message = match thread_read.profile_status.as_str() { - "idle" => "任务已接收,等待 AgentRuntime 调度或回写进度".to_string(), - "queued" => "任务已进入队列".to_string(), - "running" => "任务正在执行".to_string(), - "blocked" => "任务等待用户或权限响应".to_string(), - "completed" => "任务已完成".to_string(), - "failed" => "任务执行失败".to_string(), - "cancelled" => "任务已取消".to_string(), - _ => format!("任务状态:{}", thread_read.status), - }; - push_task_event( - &mut events, - "task:progress", - thread_read.profile_status.as_str(), - status_message, - thread_read.updated_at.clone(), - Some(json!({ - "thread_id": thread_read.thread_id.clone(), - "active_turn_id": thread_read.active_turn_id.clone(), - "profile_status": thread_read.profile_status.clone(), - "status": thread_read.status.clone(), - })), - ); - - for pending_request in &thread_read.pending_requests { - let message = pending_request - .title - .clone() - .unwrap_or_else(|| "任务等待 Host / 用户响应".to_string()); - let mut event = AgentAppRuntimeTaskEvent { - id: format!("task:reviewRequested:{}", pending_request.id), - event_type: "task:reviewRequested".to_string(), - status: pending_request.status.clone(), - message, - severity: None, - turn_id: pending_request.turn_id.clone(), - request_id: Some(pending_request.id.clone()), - tool_name: None, - evidence_ref: None, - artifact_ref: None, - occurred_at: pending_request.created_at.clone(), - payload: serde_json::to_value(pending_request).ok(), - }; - if matches!( - pending_request.request_type.as_str(), - "missing_context" | "ask_user" | "elicitation" - ) { - event.event_type = "task:missingContextRequested".to_string(); - } - events.push(event); - } - - if let Some(missing_context) = has_missing_context(thread_read.context_summary.as_ref()) { - push_task_event( - &mut events, - "task:missingContextRequested", - "blocked", - "任务需要补齐上下文", - thread_read.updated_at.clone(), - Some(json!({ "missing_context": missing_context })), - ); - } - - for tool_call in &thread_read.tool_calls { - events.push(AgentAppRuntimeTaskEvent { - id: format!("task:toolCall:{}", tool_call.tool_call_id), - event_type: "task:toolCall".to_string(), - status: tool_call.status.clone(), - message: format!("工具 {} {}", tool_call.tool_name, tool_call.status), - severity: if tool_call.success == Some(false) { - Some("warning".to_string()) - } else { - None - }, - turn_id: Some(tool_call.turn_id.clone()), - request_id: None, - tool_name: Some(tool_call.tool_name.clone()), - evidence_ref: None, - artifact_ref: None, - occurred_at: None, - payload: serde_json::to_value(tool_call).ok(), - }); - } - - for artifact in &thread_read.artifacts { - let payload = build_artifact_event_payload(artifact); - events.push(AgentAppRuntimeTaskEvent { - id: format!("artifact:created:{}", artifact.item_id), - event_type: "artifact:created".to_string(), - status: artifact.status.clone(), - message: artifact - .title - .clone() - .unwrap_or_else(|| format!("Artifact 已创建:{}", artifact.path)), - severity: (artifact.status == "failed").then(|| "error".to_string()), - turn_id: Some(artifact.turn_id.clone()), - request_id: None, - tool_name: None, - evidence_ref: None, - artifact_ref: Some(artifact.path.clone()), - occurred_at: artifact - .completed_at - .clone() - .or_else(|| artifact.updated_at.clone()) - .or_else(|| artifact.created_at.clone()), - payload, - }); - } - - for evidence_ref in &thread_read.evidence_summary.evidence_refs { - events.push(AgentAppRuntimeTaskEvent { - id: format!("evidence:recorded:{evidence_ref}"), - event_type: "evidence:recorded".to_string(), - status: "recorded".to_string(), - message: "运行证据已记录".to_string(), - severity: None, - turn_id: None, - request_id: None, - tool_name: None, - evidence_ref: Some(evidence_ref.clone()), - artifact_ref: None, - occurred_at: thread_read.updated_at.clone(), - payload: None, - }); - } - - for (index, outcome) in thread_read - .evidence_summary - .verification_outcomes - .iter() - .enumerate() - { - push_task_event( - &mut events, - "evidence:verified", - "verified", - "运行证据已验证", - thread_read.updated_at.clone(), - Some(json!({ "index": index, "outcome": outcome })), - ); - } - - if let Some(outcome) = &thread_read.last_outcome { - let event_type = outcome_event_type(&outcome.outcome_type); - events.push(AgentAppRuntimeTaskEvent { - id: format!( - "{}:{}", - event_type, - outcome.turn_id.as_deref().unwrap_or("latest") - ), - event_type: event_type.to_string(), - status: outcome.outcome_type.clone(), - message: outcome - .summary - .clone() - .or_else(|| outcome.primary_cause.clone()) - .unwrap_or_else(|| "任务回合已结束".to_string()), - severity: (event_type == "task:error").then(|| "error".to_string()), - turn_id: outcome.turn_id.clone(), - request_id: None, - tool_name: None, - evidence_ref: None, - artifact_ref: None, - occurred_at: outcome.ended_at.clone(), - payload: serde_json::to_value(outcome).ok(), - }); - } - - for incident in &thread_read.incidents { - events.push(AgentAppRuntimeTaskEvent { - id: format!("task:incident:{}", incident.id), - event_type: "task:incident".to_string(), - status: incident.status.clone(), - message: incident.title.clone(), - severity: Some(incident.severity.clone()), - turn_id: incident.turn_id.clone(), - request_id: None, - tool_name: None, - evidence_ref: None, - artifact_ref: None, - occurred_at: incident.detected_at.clone(), - payload: serde_json::to_value(incident).ok(), - }); - } - - events -} - -fn build_agent_app_runtime_task_snapshot_event_payload( - snapshot: &AgentAppRuntimeTaskSnapshot, -) -> Value { - let snapshot_value = serde_json::to_value(snapshot).unwrap_or_else(|_| json!({})); - json!({ - "type": "agent_app_runtime:taskSnapshot", - "eventType": "task:update", - "appId": snapshot.app_id.clone(), - "taskId": snapshot.task_id.clone(), - "sessionId": snapshot.session_id.clone(), - "taskStatus": snapshot.task_status.clone(), - "status": snapshot.status.clone(), - "task": snapshot_value.clone(), - "snapshot": snapshot_value, - "taskEvents": snapshot.task_events.clone(), - "threadRead": snapshot.thread_read.clone(), - "emittedAt": Utc::now().to_rfc3339(), - }) -} - -fn emit_agent_app_runtime_task_snapshot(app: &AppHandle, snapshot: &AgentAppRuntimeTaskSnapshot) { - let event_name = agent_app_runtime_event_name(&snapshot.app_id, &snapshot.task_id); - let payload = build_agent_app_runtime_task_snapshot_event_payload(snapshot); - if let Err(error) = app.emit(&event_name, payload) { - tracing::warn!( - "[AgentAppRuntime] 发送 App task projection event 失败: event_name={}, error={}", - event_name, - error - ); - } -} - -fn build_agent_app_runtime_metadata( - request: &AgentAppRuntimeStartTaskRequest, - task_id: &str, - trace_id: &str, -) -> Value { - let mut metadata = request.metadata.clone().unwrap_or_else(|| json!({})); - if !metadata.is_object() { - metadata = json!({}); - } - - let mut app_runtime = json!({ - "surface": "agent_app", - "app_id": request.app_id.trim(), - "entry_key": request.entry_key.as_deref().unwrap_or("").trim(), - "task_id": task_id, - "trace_id": trace_id, - "task_kind": request.task_kind.trim(), - "idempotency_key": request.idempotency_key.as_deref().unwrap_or("").trim(), - "required_capabilities": request.required_capabilities.clone(), - "capability_hints": request.capability_hints.clone(), - "knowledge_bindings": request.knowledge_bindings.clone(), - "human_review": request.human_review.unwrap_or(false), - }); - let output_contract = build_agent_app_output_contract(request); - let capability_descriptors = resolve_agent_app_runtime_capability_descriptors(request); - let should_insert_primary_capability_launch = - should_insert_agent_app_capability_launch_metadata(request, output_contract.as_ref()) - && !capability_descriptors.is_empty(); - let capability_workflow = build_agent_app_capability_workflow_metadata( - request, - &capability_descriptors, - output_contract.as_ref(), - should_insert_primary_capability_launch, - ); - if let (Some(app_runtime), Some(output_contract)) = - (app_runtime.as_object_mut(), output_contract.clone()) - { - app_runtime.insert("output_contract".to_string(), output_contract); - } - if let (Some(app_runtime), Some(capability_workflow)) = - (app_runtime.as_object_mut(), capability_workflow.clone()) - { - app_runtime.insert("capability_workflow".to_string(), capability_workflow); - } - - if let Some(root) = metadata.as_object_mut() { - root.insert( - AGENT_APP_RUNTIME_METADATA_KEY.to_string(), - app_runtime.clone(), - ); - let lime_runtime = root - .entry(LIME_RUNTIME_METADATA_KEY.to_string()) - .or_insert_with(|| json!({})); - if let Some(lime_runtime) = lime_runtime.as_object_mut() { - lime_runtime.insert("surface".to_string(), json!("agent_app")); - lime_runtime.insert("app_id".to_string(), json!(request.app_id.trim())); - lime_runtime.insert("task_id".to_string(), json!(task_id)); - lime_runtime.insert("trace_id".to_string(), json!(trace_id)); - lime_runtime.insert("task_kind".to_string(), json!(request.task_kind.trim())); - let runtime_summary = lime_runtime - .entry("runtime_summary".to_string()) - .or_insert_with(|| json!({})); - if let Some(runtime_summary) = runtime_summary.as_object_mut() { - runtime_summary.insert("surface".to_string(), json!("agent_app")); - runtime_summary.insert("app_id".to_string(), json!(request.app_id.trim())); - runtime_summary.insert("task_id".to_string(), json!(task_id)); - runtime_summary.insert("trace_id".to_string(), json!(trace_id)); - runtime_summary.insert("task_kind".to_string(), json!(request.task_kind.trim())); - } - } - { - let harness = root - .entry("harness".to_string()) - .or_insert_with(|| json!({})); - if let Some(harness) = harness.as_object_mut() { - harness.insert( - AGENT_APP_RUNTIME_METADATA_KEY.to_string(), - app_runtime.clone(), - ); - if let Some(output_contract) = output_contract.clone() { - harness.insert( - "agent_app_runtime_output_contract".to_string(), - output_contract, - ); - } - insert_agent_app_output_contract_runtime_hints( - harness, - request, - output_contract.as_ref(), - ); - if let Some(capability_workflow) = capability_workflow.clone() { - harness.insert( - "agent_app_runtime_capability_workflow".to_string(), - capability_workflow, - ); - } - } - } - if should_insert_primary_capability_launch { - if let Some(descriptor) = capability_descriptors.first().copied() { - insert_agent_app_capability_launch_metadata( - root, request, task_id, trace_id, descriptor, - ); - } - } - } - - metadata -} - #[allow(clippy::too_many_arguments)] #[tauri::command] pub async fn agent_app_runtime_start_task( @@ -1520,493 +287,3 @@ pub async fn agent_app_runtime_submit_host_response( status: "submitted".to_string(), }) } - -#[cfg(test)] -mod tests { - use super::*; - use crate::agent::QueuedTurnSnapshot; - use crate::commands::aster_agent_cmd::{ - AgentRuntimeIncidentView, AgentRuntimeOutcomeView, AgentRuntimeRequestView, - AgentRuntimeThreadArtifactView, AgentRuntimeThreadEvidenceSummary, - AgentRuntimeThreadTelemetrySummary, AgentRuntimeThreadToolCallView, - }; - - fn runtime_request( - required_capabilities: Vec<&str>, - capability_hints: Vec<&str>, - ) -> AgentAppRuntimeStartTaskRequest { - AgentAppRuntimeStartTaskRequest { - app_id: "content-factory-app".to_string(), - entry_key: Some("content_factory".to_string()), - workspace_id: Some("workspace-1".to_string()), - session_id: None, - task_id: None, - task_kind: "content_factory.copy.generate".to_string(), - idempotency_key: None, - title: Some("生成小红书种草文案".to_string()), - prompt: Some("围绕春季护肤新品生成文案,并补齐资料来源".to_string()), - input: Some(json!({ - "platform": "xiaohongshu", - "audience": "敏感肌用户" - })), - expected_output: Some(json!({ - "artifacts": ["copy", "assetBrief"] - })), - required_capabilities: required_capabilities - .into_iter() - .map(str::to_string) - .collect(), - capability_hints: capability_hints.into_iter().map(str::to_string).collect(), - knowledge_bindings: Vec::new(), - human_review: Some(true), - event_name: None, - turn_id: None, - provider_preference: None, - model_preference: None, - queue_if_busy: None, - skip_pre_submit_resume: None, - run_start_hooks: None, - metadata: None, - } - } - - fn base_thread_read() -> AgentRuntimeThreadReadModel { - AgentRuntimeThreadReadModel { - thread_id: "thread-1".to_string(), - status: "running".to_string(), - profile_status: "running".to_string(), - active_turn_id: Some("turn-1".to_string()), - turns: Vec::new(), - pending_requests: Vec::new(), - last_outcome: None, - incidents: Vec::new(), - queued_turns: Vec::new(), - tool_calls: Vec::new(), - artifacts: Vec::new(), - model_routing: None, - evidence_summary: AgentRuntimeThreadEvidenceSummary::default(), - telemetry_summary: AgentRuntimeThreadTelemetrySummary::default(), - context_summary: None, - interrupt_state: None, - updated_at: Some("2026-05-16T00:00:00.000Z".to_string()), - latest_compaction_boundary: None, - file_checkpoint_summary: None, - diagnostics: None, - task_kind: None, - service_model_slot: None, - routing_mode: None, - decision_source: None, - decision_reason: None, - candidate_count: None, - fallback_chain: None, - capability_gap: None, - estimated_cost_class: None, - single_candidate_only: None, - oem_policy: None, - runtime_summary: None, - auxiliary_task_runtime: None, - limit_state: None, - cost_state: None, - permission_state: None, - limit_event: None, - } - } - - #[test] - fn test_agent_app_runtime_session_id_uses_hidden_prefix() { - assert!(new_agent_app_runtime_session_id().starts_with(AGENT_APP_RUNTIME_SESSION_ID_PREFIX)); - } - - #[test] - fn test_agent_app_runtime_model_preference_reads_recent_successful_routing_metadata() { - let metadata = json!({ - "request_metadata": { - "lime_runtime": { - "routing_decision": { - "selected_provider": "deepseek", - "selected_model": "deepseek-v4-flash" - } - } - } - }); - - let preference = - model_preference_from_run_metadata(&metadata).expect("recent run preference"); - - assert_eq!(preference.provider_preference, "deepseek"); - assert_eq!(preference.model_preference, "deepseek-v4-flash"); - assert_eq!(preference.source, "recent_successful_agent_run"); - } - - #[test] - fn test_agent_app_runtime_metadata_maps_research_capability_to_claw_launch() { - let request = runtime_request( - vec!["text_generation", "lime.capability.research.search"], - Vec::new(), - ); - let metadata = build_agent_app_runtime_metadata(&request, "task-1", "trace-1"); - let harness = metadata - .get("harness") - .and_then(Value::as_object) - .expect("harness metadata"); - let launch = harness - .get("research_skill_launch") - .and_then(Value::as_object) - .expect("research launch"); - let research_request = launch - .get("research_request") - .and_then(Value::as_object) - .expect("research request"); - let runtime_summary = metadata - .get("lime_runtime") - .and_then(|value| value.get("runtime_summary")) - .and_then(Value::as_object) - .expect("agent app runtime summary"); - - assert_eq!(harness.get("allow_model_skills"), Some(&json!(true))); - assert!(harness.get("agent_app_runtime").is_some()); - assert_eq!(runtime_summary.get("surface"), Some(&json!("agent_app"))); - assert_eq!( - runtime_summary.get("app_id"), - Some(&json!("content-factory-app")) - ); - assert_eq!(runtime_summary.get("task_id"), Some(&json!("task-1"))); - assert_eq!(runtime_summary.get("trace_id"), Some(&json!("trace-1"))); - assert_eq!(launch.get("skill_name"), Some(&json!("research"))); - assert_eq!(launch.get("kind"), Some(&json!("research_request"))); - assert_eq!( - research_request.get("source"), - Some(&json!("agent_app_runtime")) - ); - assert_eq!( - research_request.get("app_id"), - Some(&json!("content-factory-app")) - ); - assert_eq!( - research_request.get("capability_id"), - Some(&json!("lime.capability.research.search")) - ); - assert!(research_request.get("query").is_some()); - } - - #[test] - fn test_agent_app_runtime_metadata_maps_image_alias_to_claw_launch() { - let request = runtime_request(Vec::new(), vec!["image_generation"]); - let metadata = build_agent_app_runtime_metadata(&request, "task-1", "trace-1"); - let launch = metadata - .get("harness") - .and_then(Value::as_object) - .and_then(|harness| harness.get("image_skill_launch")) - .and_then(Value::as_object) - .expect("image launch"); - let image_task = launch - .get("image_task") - .and_then(Value::as_object) - .expect("image task"); - - assert_eq!(launch.get("skill_name"), Some(&json!("image_generate"))); - assert_eq!(launch.get("kind"), Some(&json!("image_task"))); - assert_eq!(image_task.get("mode"), Some(&json!("generate"))); - assert_eq!( - image_task.get("entry_source"), - Some(&json!("agent_app_runtime")) - ); - } - - #[test] - fn test_agent_app_runtime_metadata_ignores_unknown_capability_without_fake_launch() { - let request = runtime_request(vec!["text_generation"], Vec::new()); - let metadata = build_agent_app_runtime_metadata(&request, "task-1", "trace-1"); - let harness = metadata - .get("harness") - .and_then(Value::as_object) - .expect("harness metadata"); - - assert!(harness.get("agent_app_runtime").is_some()); - assert!(harness.get("allow_model_skills").is_none()); - assert!(harness.get("image_skill_launch").is_none()); - assert!(harness.get("research_skill_launch").is_none()); - } - - #[test] - fn test_agent_app_runtime_content_factory_output_contract_is_machine_readable() { - let mut request = runtime_request(Vec::new(), Vec::new()); - request.expected_output = Some(json!({ - "artifactKind": "content_batch", - "includes": ["copy", "script", "image_brief"] - })); - - let metadata = build_agent_app_runtime_metadata(&request, "task-1", "trace-1"); - let harness = metadata - .get("harness") - .and_then(Value::as_object) - .expect("harness metadata"); - let output_contract = harness - .get("agent_app_runtime_output_contract") - .and_then(Value::as_object) - .expect("output contract"); - assert_eq!( - output_contract.get("artifact_kind"), - Some(&json!("content_batch")) - ); - assert_eq!( - output_contract.get("artifact_metadata_kind"), - Some(&json!(CONTENT_FACTORY_WORKSPACE_PATCH_KIND)) - ); - assert!(output_contract - .get("patch_metadata_keys") - .and_then(Value::as_array) - .is_some_and(|items| items.contains(&json!("contentFactoryWorkspacePatch")))); - - let message = build_agent_app_runtime_task_message(&request); - assert!(message.contains("Content Factory Output Contract")); - assert!(message.contains("contentFactoryWorkspacePatch")); - assert!(message.contains("artifactKind=content_batch")); - assert!(message.contains("不要通过 Bash")); - assert!(message.contains("不能把复合内容工厂任务改写成单一 research / image Skill")); - } - - #[test] - fn test_agent_app_runtime_content_factory_output_contract_does_not_force_single_skill_launch() { - let mut request = runtime_request(Vec::new(), vec!["research.search", "image_generation"]); - request.expected_output = Some(json!({ - "artifactKind": "content_batch", - "includes": ["copy", "script", "image_brief"] - })); - - let metadata = build_agent_app_runtime_metadata(&request, "task-1", "trace-1"); - let harness = metadata - .get("harness") - .and_then(Value::as_object) - .expect("harness metadata"); - - assert!(harness.get("agent_app_runtime_output_contract").is_some()); - assert_eq!(harness.get("chat_mode"), Some(&json!("general"))); - assert_eq!( - harness.get("session_mode"), - Some(&json!("general_workbench")) - ); - let workflow = harness - .get("agent_app_runtime_capability_workflow") - .and_then(Value::as_object) - .expect("capability workflow"); - assert_eq!( - workflow.get("mode"), - Some(&json!("composite_output_contract")) - ); - assert_eq!(workflow.get("launch_policy"), Some(&json!("metadata_only"))); - let descriptors = workflow - .get("descriptors") - .and_then(Value::as_array) - .expect("workflow descriptors"); - assert_eq!(descriptors.len(), 2); - assert!(descriptors - .iter() - .any(|descriptor| descriptor.get("capability_id") - == Some(&json!("lime.capability.research.search")))); - assert!(descriptors - .iter() - .any(|descriptor| descriptor.get("capability_id") - == Some(&json!("lime.capability.image.generate")))); - assert!(harness.get("allow_model_skills").is_none()); - assert!(harness.get("research_skill_launch").is_none()); - assert!(harness.get("image_skill_launch").is_none()); - } - - #[test] - fn test_agent_app_runtime_extracts_workspace_patch_from_artifact_document_blocks() { - let metadata = json!({ - "artifactDocument": { - "blocks": [ - { - "type": "rich_text", - "content": "```json\n{\"contentFactoryWorkspacePatch\":{\"kind\":\"content_batch\",\"projectId\":\"project-1\",\"contentBatch\":{\"items\":[{\"title\":\"示例文案\"}]}}}\n```" - } - ] - } - }); - - let patch = extract_content_factory_workspace_patch_from_artifact_document(Some(&metadata)) - .expect("workspace patch"); - - assert_eq!(patch.get("kind"), Some(&json!("content_batch"))); - assert_eq!(patch.get("projectId"), Some(&json!("project-1"))); - assert!(patch.get("contentBatch").is_some()); - } - - #[test] - fn test_agent_app_runtime_task_events_project_thread_read_facts() { - let mut thread_read = base_thread_read(); - thread_read.queued_turns = vec![QueuedTurnSnapshot { - queued_turn_id: "queued-1".to_string(), - message_preview: "排队任务".to_string(), - message_text: "排队任务完整文本".to_string(), - created_at: 1_789_000_000, - image_count: 0, - position: 0, - }]; - thread_read.pending_requests = vec![AgentRuntimeRequestView { - id: "request-1".to_string(), - thread_id: "thread-1".to_string(), - turn_id: Some("turn-1".to_string()), - item_id: None, - request_type: "ask_user".to_string(), - status: "pending".to_string(), - title: Some("需要确认素材方向".to_string()), - payload: Some(json!({ "question": "是否继续?" })), - decision: None, - scope: None, - created_at: Some("2026-05-16T00:00:01.000Z".to_string()), - resolved_at: None, - }]; - thread_read.context_summary = Some(json!({ - "missing_context": [{ "field": "target_audience" }] - })); - thread_read.tool_calls = vec![AgentRuntimeThreadToolCallView { - tool_call_id: "tool-1".to_string(), - turn_id: "turn-1".to_string(), - tool_name: "Skill(research)".to_string(), - status: "completed".to_string(), - success: Some(true), - error: None, - }]; - thread_read.artifacts = vec![AgentRuntimeThreadArtifactView { - item_id: "artifact-item-1".to_string(), - turn_id: "turn-1".to_string(), - path: ".lime/artifacts/content-batch.json".to_string(), - source: "agent_runtime".to_string(), - status: "created".to_string(), - artifact_type: Some("content_batch".to_string()), - title: Some("内容批次".to_string()), - created_at: Some("2026-05-16T00:00:01.500Z".to_string()), - completed_at: Some("2026-05-16T00:00:01.800Z".to_string()), - updated_at: Some("2026-05-16T00:00:01.800Z".to_string()), - metadata: Some(json!({ - "artifactType": "content_batch", - "workspacePatch": { - "kind": "content_batch", - "projectId": "project-1", - "contentBatch": { "count": 20 } - } - })), - }]; - thread_read.evidence_summary = AgentRuntimeThreadEvidenceSummary { - evidence_refs: vec!["evidence-1".to_string()], - verification_outcomes: vec![json!({ "status": "passed" })], - }; - thread_read.last_outcome = Some(AgentRuntimeOutcomeView { - thread_id: "thread-1".to_string(), - turn_id: Some("turn-1".to_string()), - outcome_type: "completed".to_string(), - summary: Some("任务完成".to_string()), - primary_cause: None, - retryable: false, - ended_at: Some("2026-05-16T00:00:02.000Z".to_string()), - }); - thread_read.incidents = vec![AgentRuntimeIncidentView { - id: "incident-1".to_string(), - thread_id: "thread-1".to_string(), - turn_id: Some("turn-1".to_string()), - item_id: None, - incident_type: "provider_warning".to_string(), - severity: "medium".to_string(), - status: "open".to_string(), - title: "Provider warning".to_string(), - details: None, - detected_at: Some("2026-05-16T00:00:03.000Z".to_string()), - cleared_at: None, - }]; - - let events = build_agent_app_runtime_task_events(&thread_read); - let event_types = events - .iter() - .map(|event| event.event_type.as_str()) - .collect::>(); - - assert!(event_types.contains(&"task:queued")); - assert!(event_types.contains(&"task:progress")); - assert!(event_types.contains(&"task:missingContextRequested")); - assert!(event_types.contains(&"task:toolCall")); - assert!(event_types.contains(&"artifact:created")); - assert!(event_types.contains(&"evidence:recorded")); - assert!(event_types.contains(&"evidence:verified")); - assert!(event_types.contains(&"task:completed")); - assert!(event_types.contains(&"task:incident")); - assert!(events - .iter() - .any(|event| event.request_id.as_deref() == Some("request-1"))); - assert!(events - .iter() - .any(|event| event.evidence_ref.as_deref() == Some("evidence-1"))); - assert!(events.iter().any( - |event| event.artifact_ref.as_deref() == Some(".lime/artifacts/content-batch.json") - )); - let artifact_event = events - .iter() - .find(|event| event.event_type == "artifact:created") - .expect("artifact event"); - assert_eq!( - artifact_event - .payload - .as_ref() - .and_then(|payload| payload.get("contentFactoryWorkspacePatch")) - .and_then(|patch| patch.get("contentBatch")) - .and_then(|content_batch| content_batch.get("count")), - Some(&json!(20)) - ); - } - - #[test] - fn test_agent_app_runtime_task_snapshot_event_payload_is_canonical() { - let mut thread_read = base_thread_read(); - thread_read.profile_status = "running".to_string(); - let task_events = build_agent_app_runtime_task_events(&thread_read); - let snapshot = AgentAppRuntimeTaskSnapshot { - app_id: "content-factory-app".to_string(), - task_id: "task-1".to_string(), - session_id: "session-1".to_string(), - status: "thread_read_available".to_string(), - task_status: thread_read.profile_status.clone(), - task_events, - thread_read: serde_json::to_value(&thread_read).expect("thread read value"), - }; - - let payload = build_agent_app_runtime_task_snapshot_event_payload(&snapshot); - - assert_eq!( - payload.get("type"), - Some(&json!("agent_app_runtime:taskSnapshot")) - ); - assert_eq!(payload.get("eventType"), Some(&json!("task:update"))); - assert_eq!(payload.get("taskId"), Some(&json!("task-1"))); - assert_eq!( - payload - .get("taskEvents") - .and_then(Value::as_array) - .map(Vec::len), - Some(snapshot.task_events.len()) - ); - assert!(payload.get("threadRead").is_some()); - assert!(payload.get("task").is_some()); - } - - #[test] - fn test_agent_app_runtime_idle_status_uses_business_progress_copy() { - let mut thread_read = base_thread_read(); - thread_read.status = "idle".to_string(); - thread_read.profile_status = "idle".to_string(); - thread_read.active_turn_id = None; - - let events = build_agent_app_runtime_task_events(&thread_read); - let progress = events - .iter() - .find(|event| event.event_type == "task:progress") - .expect("progress event"); - - assert_eq!( - progress.message, - "任务已接收,等待 AgentRuntime 调度或回写进度" - ); - assert_ne!(progress.message, "任务状态:idle"); - } -} diff --git a/src-tauri/src/commands/agent_app_runtime_cmd/events.rs b/src-tauri/src/commands/agent_app_runtime_cmd/events.rs new file mode 100644 index 000000000..886ac879a --- /dev/null +++ b/src-tauri/src/commands/agent_app_runtime_cmd/events.rs @@ -0,0 +1,437 @@ +use super::types::{AgentAppRuntimeTaskEvent, AgentAppRuntimeTaskSnapshot}; +use super::{agent_app_runtime_event_name, CONTENT_FACTORY_WORKSPACE_PATCH_KIND}; +use crate::commands::aster_agent_cmd::{ + AgentRuntimeThreadArtifactView, AgentRuntimeThreadReadModel, +}; +use chrono::Utc; +use serde_json::{json, Value}; +use tauri::{AppHandle, Emitter}; + +fn push_task_event( + events: &mut Vec, + event_type: &str, + status: &str, + message: impl Into, + occurred_at: Option, + payload: Option, +) { + events.push(AgentAppRuntimeTaskEvent { + id: format!("{}:{}", event_type, events.len() + 1), + event_type: event_type.to_string(), + status: status.to_string(), + message: message.into(), + severity: None, + turn_id: None, + request_id: None, + tool_name: None, + evidence_ref: None, + artifact_ref: None, + occurred_at, + payload, + }); +} + +fn outcome_event_type(outcome_type: &str) -> &'static str { + let normalized = outcome_type.to_ascii_lowercase(); + if normalized.contains("cancel") || normalized.contains("interrupt") { + "task:cancelled" + } else if normalized.contains("fail") + || normalized.contains("error") + || normalized.contains("timeout") + { + "task:error" + } else { + "task:completed" + } +} + +fn has_missing_context(context_summary: Option<&Value>) -> Option { + let summary = context_summary?.as_object()?; + let missing_context = summary + .get("missing_context") + .or_else(|| summary.get("missingContext"))?; + if missing_context + .as_array() + .is_some_and(|items| !items.is_empty()) + { + Some(missing_context.clone()) + } else { + None + } +} + +fn is_content_factory_workspace_patch_kind(value: &str) -> bool { + matches!( + value.trim(), + CONTENT_FACTORY_WORKSPACE_PATCH_KIND + | "contentFactoryWorkspacePatch" + | "workspace_patch" + | "workspacePatch" + ) +} + +fn has_content_factory_workspace_patch_fields(value: &Value) -> bool { + value.as_object().is_some_and(|object| { + object.contains_key("workspace") + || object.contains_key("project") + || object.contains_key("sceneTable") + || object.contains_key("contentBatch") + || object.contains_key("scripts") + || object.contains_key("imagePrompts") + || object.contains_key("assetPack") + }) +} + +fn extract_content_factory_workspace_patch(metadata: Option<&Value>) -> Option { + let metadata = metadata?; + for key in ["contentFactoryWorkspacePatch", "workspacePatch"] { + if let Some(value) = metadata.get(key) { + if has_content_factory_workspace_patch_fields(value) { + return Some(value.clone()); + } + } + } + + let artifact_kind = metadata + .get("artifactType") + .or_else(|| metadata.get("artifact_type")) + .or_else(|| metadata.get("kind")) + .or_else(|| metadata.get("outputKind")) + .and_then(Value::as_str); + if artifact_kind.is_some_and(is_content_factory_workspace_patch_kind) + && has_content_factory_workspace_patch_fields(metadata) + { + return Some(metadata.clone()); + } + + None +} + +fn parse_json_object_from_markdown(value: &str) -> Option { + let trimmed = value.trim(); + let candidate = if trimmed.starts_with("```") { + let without_opening = trimmed.lines().skip(1).collect::>().join("\n"); + without_opening + .rsplit_once("```") + .map(|(body, _)| body.trim().to_string()) + .unwrap_or(without_opening) + } else { + trimmed.to_string() + }; + + serde_json::from_str::(&candidate).ok().or_else(|| { + let start = candidate.find('{')?; + let end = candidate.rfind('}')?; + serde_json::from_str::(&candidate[start..=end]).ok() + }) +} + +pub(super) fn extract_content_factory_workspace_patch_from_artifact_document( + metadata: Option<&Value>, +) -> Option { + let metadata = metadata?; + let artifact_document = metadata + .get("artifactDocument") + .or_else(|| metadata.get("artifact_document"))?; + let blocks = artifact_document.get("blocks")?.as_array()?; + + blocks.iter().find_map(|block| { + let text = block + .get("content") + .or_else(|| block.get("markdown")) + .and_then(Value::as_str)?; + let parsed = parse_json_object_from_markdown(text)?; + extract_content_factory_workspace_patch(Some(&parsed)) + }) +} + +fn build_artifact_event_payload(artifact: &AgentRuntimeThreadArtifactView) -> Option { + let artifact_value = serde_json::to_value(artifact).ok()?; + let workspace_patch = extract_content_factory_workspace_patch(artifact.metadata.as_ref()) + .or_else(|| { + extract_content_factory_workspace_patch_from_artifact_document( + artifact.metadata.as_ref(), + ) + }); + if let Some(workspace_patch) = workspace_patch { + return Some(json!({ + "artifact": artifact_value, + "workspacePatch": workspace_patch, + "contentFactoryWorkspacePatch": workspace_patch, + "producer": "agent_runtime_artifact_metadata", + })); + } + Some(artifact_value) +} + +pub(super) fn build_agent_app_runtime_task_events( + thread_read: &AgentRuntimeThreadReadModel, +) -> Vec { + let mut events = Vec::new(); + + for queued_turn in &thread_read.queued_turns { + push_task_event( + &mut events, + "task:queued", + "queued", + queued_turn.message_preview.clone(), + None, + serde_json::to_value(queued_turn).ok(), + ); + } + + let status_message = match thread_read.profile_status.as_str() { + "idle" => "任务已接收,等待 AgentRuntime 调度或回写进度".to_string(), + "queued" => "任务已进入队列".to_string(), + "running" => "任务正在执行".to_string(), + "blocked" => "任务等待用户或权限响应".to_string(), + "completed" => "任务已完成".to_string(), + "failed" => "任务执行失败".to_string(), + "cancelled" => "任务已取消".to_string(), + _ => format!("任务状态:{}", thread_read.status), + }; + push_task_event( + &mut events, + "task:progress", + thread_read.profile_status.as_str(), + status_message, + thread_read.updated_at.clone(), + Some(json!({ + "thread_id": thread_read.thread_id.clone(), + "active_turn_id": thread_read.active_turn_id.clone(), + "profile_status": thread_read.profile_status.clone(), + "status": thread_read.status.clone(), + })), + ); + + for pending_request in &thread_read.pending_requests { + let message = pending_request + .title + .clone() + .unwrap_or_else(|| "任务等待 Host / 用户响应".to_string()); + let mut event = AgentAppRuntimeTaskEvent { + id: format!("task:reviewRequested:{}", pending_request.id), + event_type: "task:reviewRequested".to_string(), + status: pending_request.status.clone(), + message, + severity: None, + turn_id: pending_request.turn_id.clone(), + request_id: Some(pending_request.id.clone()), + tool_name: None, + evidence_ref: None, + artifact_ref: None, + occurred_at: pending_request.created_at.clone(), + payload: serde_json::to_value(pending_request).ok(), + }; + if matches!( + pending_request.request_type.as_str(), + "missing_context" | "ask_user" | "elicitation" + ) { + event.event_type = "task:missingContextRequested".to_string(); + } + events.push(event); + } + + if let Some(missing_context) = has_missing_context(thread_read.context_summary.as_ref()) { + push_task_event( + &mut events, + "task:missingContextRequested", + "blocked", + "任务需要补齐上下文", + thread_read.updated_at.clone(), + Some(json!({ "missing_context": missing_context })), + ); + } + + for tool_call in &thread_read.tool_calls { + events.push(AgentAppRuntimeTaskEvent { + id: format!("task:toolCall:{}", tool_call.tool_call_id), + event_type: "task:toolCall".to_string(), + status: tool_call.status.clone(), + message: format!("工具 {} {}", tool_call.tool_name, tool_call.status), + severity: if tool_call.success == Some(false) { + Some("warning".to_string()) + } else { + None + }, + turn_id: Some(tool_call.turn_id.clone()), + request_id: None, + tool_name: Some(tool_call.tool_name.clone()), + evidence_ref: None, + artifact_ref: None, + occurred_at: None, + payload: serde_json::to_value(tool_call).ok(), + }); + } + + for artifact in &thread_read.artifacts { + let payload = build_artifact_event_payload(artifact); + let workspace_patch = payload.as_ref().and_then(|value| { + value + .get("contentFactoryWorkspacePatch") + .or_else(|| value.get("workspacePatch")) + .cloned() + }); + events.push(AgentAppRuntimeTaskEvent { + id: format!("artifact:created:{}", artifact.item_id), + event_type: "artifact:created".to_string(), + status: artifact.status.clone(), + message: artifact + .title + .clone() + .unwrap_or_else(|| format!("Artifact 已创建:{}", artifact.path)), + severity: (artifact.status == "failed").then(|| "error".to_string()), + turn_id: Some(artifact.turn_id.clone()), + request_id: None, + tool_name: None, + evidence_ref: None, + artifact_ref: Some(artifact.path.clone()), + occurred_at: artifact + .completed_at + .clone() + .or_else(|| artifact.updated_at.clone()) + .or_else(|| artifact.created_at.clone()), + payload, + }); + if let Some(workspace_patch) = workspace_patch { + let evidence_ref = format!("evidence:{}", artifact.path); + events.push(AgentAppRuntimeTaskEvent { + id: format!("evidence:recorded:{}", artifact.item_id), + event_type: "evidence:recorded".to_string(), + status: "recorded".to_string(), + message: "内容工厂 workspace patch evidence 已记录".to_string(), + severity: None, + turn_id: Some(artifact.turn_id.clone()), + request_id: None, + tool_name: None, + evidence_ref: Some(evidence_ref.clone()), + artifact_ref: Some(artifact.path.clone()), + occurred_at: artifact + .completed_at + .clone() + .or_else(|| artifact.updated_at.clone()) + .or_else(|| artifact.created_at.clone()), + payload: Some(json!({ + "artifactRef": artifact.path.clone(), + "evidenceRef": evidence_ref, + "workspacePatch": workspace_patch.clone(), + "contentFactoryWorkspacePatch": workspace_patch, + "source": "agent_runtime_artifact_replay", + })), + }); + } + } + + for evidence_ref in &thread_read.evidence_summary.evidence_refs { + events.push(AgentAppRuntimeTaskEvent { + id: format!("evidence:recorded:{evidence_ref}"), + event_type: "evidence:recorded".to_string(), + status: "recorded".to_string(), + message: "运行证据已记录".to_string(), + severity: None, + turn_id: None, + request_id: None, + tool_name: None, + evidence_ref: Some(evidence_ref.clone()), + artifact_ref: None, + occurred_at: thread_read.updated_at.clone(), + payload: None, + }); + } + + for (index, outcome) in thread_read + .evidence_summary + .verification_outcomes + .iter() + .enumerate() + { + push_task_event( + &mut events, + "evidence:verified", + "verified", + "运行证据已验证", + thread_read.updated_at.clone(), + Some(json!({ "index": index, "outcome": outcome })), + ); + } + + if let Some(outcome) = &thread_read.last_outcome { + let event_type = outcome_event_type(&outcome.outcome_type); + events.push(AgentAppRuntimeTaskEvent { + id: format!( + "{}:{}", + event_type, + outcome.turn_id.as_deref().unwrap_or("latest") + ), + event_type: event_type.to_string(), + status: outcome.outcome_type.clone(), + message: outcome + .summary + .clone() + .or_else(|| outcome.primary_cause.clone()) + .unwrap_or_else(|| "任务回合已结束".to_string()), + severity: (event_type == "task:error").then(|| "error".to_string()), + turn_id: outcome.turn_id.clone(), + request_id: None, + tool_name: None, + evidence_ref: None, + artifact_ref: None, + occurred_at: outcome.ended_at.clone(), + payload: serde_json::to_value(outcome).ok(), + }); + } + + for incident in &thread_read.incidents { + events.push(AgentAppRuntimeTaskEvent { + id: format!("task:incident:{}", incident.id), + event_type: "task:incident".to_string(), + status: incident.status.clone(), + message: incident.title.clone(), + severity: Some(incident.severity.clone()), + turn_id: incident.turn_id.clone(), + request_id: None, + tool_name: None, + evidence_ref: None, + artifact_ref: None, + occurred_at: incident.detected_at.clone(), + payload: serde_json::to_value(incident).ok(), + }); + } + + events +} + +pub(super) fn build_agent_app_runtime_task_snapshot_event_payload( + snapshot: &AgentAppRuntimeTaskSnapshot, +) -> Value { + let snapshot_value = serde_json::to_value(snapshot).unwrap_or_else(|_| json!({})); + json!({ + "type": "agent_app_runtime:taskSnapshot", + "eventType": "task:update", + "appId": snapshot.app_id.clone(), + "taskId": snapshot.task_id.clone(), + "sessionId": snapshot.session_id.clone(), + "taskStatus": snapshot.task_status.clone(), + "status": snapshot.status.clone(), + "task": snapshot_value.clone(), + "snapshot": snapshot_value, + "taskEvents": snapshot.task_events.clone(), + "threadRead": snapshot.thread_read.clone(), + "emittedAt": Utc::now().to_rfc3339(), + }) +} + +pub(super) fn emit_agent_app_runtime_task_snapshot( + app: &AppHandle, + snapshot: &AgentAppRuntimeTaskSnapshot, +) { + let event_name = agent_app_runtime_event_name(&snapshot.app_id, &snapshot.task_id); + let payload = build_agent_app_runtime_task_snapshot_event_payload(snapshot); + if let Err(error) = app.emit(&event_name, payload) { + tracing::warn!( + "[AgentAppRuntime] 发送 App task projection event 失败: event_name={}, error={}", + event_name, + error + ); + } +} diff --git a/src-tauri/src/commands/agent_app_runtime_cmd/metadata.rs b/src-tauri/src/commands/agent_app_runtime_cmd/metadata.rs new file mode 100644 index 000000000..6b079ac44 --- /dev/null +++ b/src-tauri/src/commands/agent_app_runtime_cmd/metadata.rs @@ -0,0 +1,721 @@ +use super::types::AgentAppRuntimeStartTaskRequest; +use super::{ + non_empty, AGENT_APP_RUNTIME_CAPABILITY_SOURCE, AGENT_APP_RUNTIME_METADATA_KEY, + CONTENT_FACTORY_WORKSPACE_PATCH_KIND, LIME_RUNTIME_METADATA_KEY, LIME_RUNTIME_TOOL_SURFACE_KEY, +}; +use crate::services::agent_app_runtime_capability_catalog_service::{ + resolve_capability_descriptors, AgentAppRuntimeCapabilityDescriptor, +}; +use serde_json::{json, Map, Value}; +use std::collections::HashSet; + +fn default_task_message(request: &AgentAppRuntimeStartTaskRequest) -> String { + let title = non_empty(request.title.as_deref()) + .or_else(|| non_empty(request.prompt.as_deref())) + .unwrap_or_else(|| request.task_kind.trim().to_string()); + let input = request + .input + .as_ref() + .map(|value| serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string())) + .unwrap_or_else(|| "{}".to_string()); + let expected_output = request + .expected_output + .as_ref() + .map(|value| serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string())) + .unwrap_or_else(|| "{}".to_string()); + + [ + "【Agent App Runtime Task】".to_string(), + format!("App: {}", request.app_id.trim()), + format!( + "Entry: {}", + request.entry_key.as_deref().unwrap_or("default").trim() + ), + format!("TaskKind: {}", request.task_kind.trim()), + format!("Title: {title}"), + "".to_string(), + "请在 Lime AgentRuntime 主链中完成这个 App 业务任务。".to_string(), + "不要要求用户跳回通用 Chat;如需补充上下文,请通过可审计的 action / request 机制表达。" + .to_string(), + "".to_string(), + "Input JSON:".to_string(), + input, + "".to_string(), + "Expected Output JSON:".to_string(), + expected_output, + ] + .join("\n") +} + +fn expected_artifact_kind(request: &AgentAppRuntimeStartTaskRequest) -> Option { + let expected_output = request.expected_output.as_ref()?.as_object()?; + [ + "artifactKind", + "artifact_type", + "artifactType", + "kind", + "outputKind", + ] + .iter() + .filter_map(|key| expected_output.get(*key).and_then(Value::as_str)) + .find_map(|value| non_empty(Some(value))) +} + +fn is_content_factory_runtime_task(request: &AgentAppRuntimeStartTaskRequest) -> bool { + request.app_id.trim() == "content-factory-app" + || request.task_kind.trim().starts_with("content_factory.") +} + +fn build_agent_app_output_contract(request: &AgentAppRuntimeStartTaskRequest) -> Option { + if !is_content_factory_runtime_task(request) { + return None; + } + let artifact_kind = expected_artifact_kind(request)?; + Some(json!({ + "producer": "agent_runtime_artifact_metadata", + "artifact_kind": artifact_kind, + "artifact_metadata_kind": CONTENT_FACTORY_WORKSPACE_PATCH_KIND, + "patch_metadata_keys": ["contentFactoryWorkspacePatch", "workspacePatch"], + "required_patch_fields": ["kind", "projectId"], + "accepted_patch_fields": [ + "workspace", + "project", + "sceneTable", + "contentBatch", + "scripts", + "imagePrompts", + "assetPack" + ], + })) +} + +fn read_object_string(object: &Map, keys: &[&str]) -> Option { + keys.iter().find_map(|key| { + object + .get(*key) + .and_then(Value::as_str) + .and_then(|value| non_empty(Some(value))) + }) +} + +fn normalize_agent_app_skill_ref(value: &Value) -> Option { + if let Some(skill) = value.as_str().and_then(|value| non_empty(Some(value))) { + return Some(json!({ + "id": skill, + "skill": skill, + "standard": "agentskills", + "required": true, + })); + } + + let object = value.as_object()?; + let id = read_object_string(object, &["id", "skillId", "skill_id", "skill", "name"])?; + let skill = read_object_string(object, &["skill", "skillName", "skill_name"]) + .unwrap_or_else(|| id.clone()); + let standard = + read_object_string(object, &["standard"]).unwrap_or_else(|| "agentskills".to_string()); + let required = object + .get("required") + .and_then(Value::as_bool) + .unwrap_or(true); + + let mut normalized = Map::new(); + normalized.insert("id".to_string(), json!(id)); + normalized.insert("skill".to_string(), json!(skill)); + normalized.insert("standard".to_string(), json!(standard)); + normalized.insert("required".to_string(), json!(required)); + for key in ["title", "role", "description", "reason"] { + if let Some(value) = read_object_string(object, &[key]) { + normalized.insert(key.to_string(), json!(value)); + } + } + + Some(Value::Object(normalized)) +} + +fn skill_ref_key(value: &Value) -> Option { + let object = value.as_object()?; + let standard = object + .get("standard") + .and_then(Value::as_str) + .unwrap_or("agentskills"); + let skill = object + .get("skill") + .or_else(|| object.get("id")) + .and_then(Value::as_str)?; + Some(format!( + "{}:{}", + standard.trim().to_ascii_lowercase(), + skill.trim().to_ascii_lowercase() + )) +} + +fn collect_agent_app_skill_refs_from_array( + value: Option<&Value>, + refs: &mut Vec, + seen: &mut HashSet, +) { + let Some(items) = value.and_then(Value::as_array) else { + return; + }; + for item in items { + let Some(normalized) = normalize_agent_app_skill_ref(item) else { + continue; + }; + let Some(key) = skill_ref_key(&normalized) else { + continue; + }; + if seen.insert(key) { + refs.push(normalized); + } + } +} + +fn collect_agent_app_skill_refs_from_object( + object: &Map, + refs: &mut Vec, + seen: &mut HashSet, +) { + for key in [ + "requiredSkills", + "required_skills", + "skills", + "skillRefs", + "skill_refs", + ] { + collect_agent_app_skill_refs_from_array(object.get(key), refs, seen); + } + + for key in [ + "agentTaskContract", + "agent_task_contract", + "contentFactory", + "content_factory", + "skillContract", + "skill_contract", + ] { + if let Some(nested) = object.get(key).and_then(Value::as_object) { + collect_agent_app_skill_refs_from_object(nested, refs, seen); + } + } +} + +fn collect_agent_app_skill_refs_from_value( + value: Option<&Value>, + refs: &mut Vec, + seen: &mut HashSet, +) { + if let Some(object) = value.and_then(Value::as_object) { + collect_agent_app_skill_refs_from_object(object, refs, seen); + } +} + +fn collect_agent_app_skill_refs(request: &AgentAppRuntimeStartTaskRequest) -> Vec { + let mut refs = Vec::new(); + let mut seen = HashSet::new(); + collect_agent_app_skill_refs_from_value(request.input.as_ref(), &mut refs, &mut seen); + collect_agent_app_skill_refs_from_value(request.expected_output.as_ref(), &mut refs, &mut seen); + collect_agent_app_skill_refs_from_value(request.metadata.as_ref(), &mut refs, &mut seen); + refs +} + +fn build_agent_app_skill_contract(request: &AgentAppRuntimeStartTaskRequest) -> Option { + if !is_content_factory_runtime_task(request) { + return None; + } + let required_skills = collect_agent_app_skill_refs(request); + if required_skills.is_empty() { + return None; + } + + Some(json!({ + "source": AGENT_APP_RUNTIME_CAPABILITY_SOURCE, + "app_id": request.app_id.trim(), + "task_kind": request.task_kind.trim(), + "standard": "agentskills", + "invocation": "Lime AgentRuntime Skill tool", + "policy": "must_use_required_skills_before_final_patch", + "required_skills": required_skills, + "evidence_required": true, + "evidence_fields": ["skillId", "skill", "status", "summary"], + })) +} + +fn render_agent_app_skill_contract_lines(skill_contract: &Value) -> Vec { + let skills = skill_contract + .get("required_skills") + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(Value::as_object) + .filter_map(|skill| { + let skill_name = + read_object_string(skill, &["skill", "id"]).unwrap_or_default(); + if skill_name.is_empty() { + return None; + } + let title = read_object_string(skill, &["title", "role", "description"]); + Some(match title { + Some(title) => format!("- skill=\"{skill_name}\":{title}"), + None => format!("- skill=\"{skill_name}\""), + }) + }) + .collect::>() + }) + .unwrap_or_default(); + + let mut lines = vec![ + "".to_string(), + "Content Factory Skill Contract:".to_string(), + "- 本任务必须通过 Lime AgentRuntime 的 Skill 工具调用下列业务 Skills;不得绕过 Skill 直接普通回答。".to_string(), + "- 本回合只应调用工具名 Skill;不要调用 Agent、Bash、Write、Read、Edit、Glob 或 Grep 来模拟业务 Skill。".to_string(), + "- 调用示例:tool=Skill, args={\"skill\":\"knowledge-builder\",\"args\":\"基于 Input JSON 整理项目知识库\"}。".to_string(), + "- 参数 skill 必须使用下面的 skill 值;至少调用 required=true 的 Skill。".to_string(), + "- Skill 负责生产工艺,App 负责页面流程和产物回写;最终仍必须产出 contentFactoryWorkspacePatch / workspacePatch。".to_string(), + "- 最终 patch 或 evidence 中必须记录 skillEvidence / skillRefs,说明每个 required Skill 的使用状态。".to_string(), + ]; + lines.extend(skills); + lines +} + +fn insert_agent_app_required_skill_tool_scope(root: &mut Map) { + root.insert( + "tool_scope".to_string(), + json!({ + "source": AGENT_APP_RUNTIME_CAPABILITY_SOURCE, + "reason": "agent_app_required_skills", + "allowed_tools": ["Skill"], + }), + ); + let lime_runtime = root + .entry(LIME_RUNTIME_METADATA_KEY.to_string()) + .or_insert_with(|| json!({})); + if let Some(lime_runtime) = lime_runtime.as_object_mut() { + // 占住 tool_surface,阻止 provider 默认 compact_tools 把 Skill 从工具面过滤掉。 + lime_runtime.insert( + LIME_RUNTIME_TOOL_SURFACE_KEY.to_string(), + json!("agent_app_required_skills"), + ); + } +} + +pub(super) fn build_agent_app_runtime_task_message( + request: &AgentAppRuntimeStartTaskRequest, +) -> String { + let prompt = non_empty(request.prompt.as_deref()) + .or_else(|| non_empty(request.title.as_deref())) + .unwrap_or_else(|| request.task_kind.trim().to_string()); + let input = request + .input + .as_ref() + .map(|value| serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string())) + .unwrap_or_else(|| "{}".to_string()); + let expected_output = request + .expected_output + .as_ref() + .map(|value| serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string())) + .unwrap_or_else(|| "{}".to_string()); + let mut lines = vec![ + "【Agent App Runtime Task】".to_string(), + format!("App: {}", request.app_id.trim()), + format!( + "Entry: {}", + request.entry_key.as_deref().unwrap_or("default").trim() + ), + format!("TaskKind: {}", request.task_kind.trim()), + "".to_string(), + "Business Prompt:".to_string(), + prompt, + "".to_string(), + "Runtime Boundary:".to_string(), + "- 请在 Lime AgentRuntime 主链中完成这个 App 业务任务。".to_string(), + "- 不要要求用户跳回通用 Chat;如需补充上下文,请通过可审计的 action / request 机制表达。" + .to_string(), + ]; + + if let Some(skill_contract) = build_agent_app_skill_contract(request) { + lines.extend(render_agent_app_skill_contract_lines(&skill_contract)); + } + + if let Some(contract) = build_agent_app_output_contract(request) { + let artifact_kind = contract + .get("artifact_kind") + .and_then(Value::as_str) + .unwrap_or("content_batch"); + lines.extend([ + "".to_string(), + "Content Factory Output Contract:".to_string(), + format!( + "- 如果任务产出可直接物化到内容工厂项目,必须创建 artifactKind={artifact_kind} 的 artifact。" + ), + format!( + "- artifact metadata 必须包含 contentFactoryWorkspacePatch 或 workspacePatch;metadata.kind 可使用 {}。", + CONTENT_FACTORY_WORKSPACE_PATCH_KIND + ), + "- 不要通过 Bash、shell、脚本或直接写 .lime/artifacts 文件来伪造 artifact;最终回答应直接输出结构化 JSON。" + .to_string(), + "- 最终回答的顶层 JSON 必须包含 contentFactoryWorkspacePatch 或 workspacePatch,方便 Host 自动回写当前 App 页面。" + .to_string(), + "- patch 至少包含 kind / projectId,并按结果类型填写 sceneTable、contentBatch、scripts、imagePrompts 或 assetPack。" + .to_string(), + "- tools / capabilityHints 只是可选能力提示,不能把复合内容工厂任务改写成单一 research / image Skill;业务 requiredSkills 必须先通过 Skill 工具执行,最终仍收敛为 workspace patch。" + .to_string(), + "- 不要只返回自然语言总结;结构化 patch 是 App 自动回写当前页面的事实源。".to_string(), + ]); + } + + lines.extend([ + "".to_string(), + "Input JSON:".to_string(), + input, + "".to_string(), + "Expected Output JSON:".to_string(), + expected_output, + ]); + + lines.join("\n") +} + +fn insert_string_if_some(map: &mut Map, key: &str, value: Option) { + if let Some(value) = value.map(|item| item.trim().to_string()) { + if !value.is_empty() { + map.insert(key.to_string(), json!(value)); + } + } +} + +fn app_task_prompt_summary(request: &AgentAppRuntimeStartTaskRequest) -> String { + non_empty(request.prompt.as_deref()) + .or_else(|| non_empty(request.title.as_deref())) + .unwrap_or_else(|| default_task_message(request)) +} + +fn build_agent_app_capability_request_context( + request: &AgentAppRuntimeStartTaskRequest, + task_id: &str, + trace_id: &str, + descriptor: AgentAppRuntimeCapabilityDescriptor, +) -> Map { + let prompt = app_task_prompt_summary(request); + let mut context = Map::new(); + + context.insert( + "source".to_string(), + json!(AGENT_APP_RUNTIME_CAPABILITY_SOURCE), + ); + context.insert("capability_id".to_string(), json!(descriptor.capability_id)); + context.insert("app_id".to_string(), json!(request.app_id.trim())); + context.insert("task_id".to_string(), json!(task_id)); + context.insert("trace_id".to_string(), json!(trace_id)); + context.insert("task_kind".to_string(), json!(request.task_kind.trim())); + context.insert( + "entry_source".to_string(), + json!(AGENT_APP_RUNTIME_CAPABILITY_SOURCE), + ); + context.insert("raw_text".to_string(), json!(prompt.clone())); + context.insert("prompt".to_string(), json!(prompt)); + context.insert( + "required_capabilities".to_string(), + json!(request.required_capabilities.clone()), + ); + context.insert( + "capability_hints".to_string(), + json!(request.capability_hints.clone()), + ); + context.insert( + "human_review".to_string(), + json!(request.human_review.unwrap_or(false)), + ); + + insert_string_if_some( + &mut context, + "entry_key", + non_empty(request.entry_key.as_deref()), + ); + insert_string_if_some( + &mut context, + "workspace_id", + non_empty(request.workspace_id.as_deref()), + ); + insert_string_if_some( + &mut context, + "idempotency_key", + non_empty(request.idempotency_key.as_deref()), + ); + + if let Some(input) = request.input.clone() { + context.insert("input".to_string(), input); + } + if let Some(expected_output) = request.expected_output.clone() { + context.insert("expected_output".to_string(), expected_output); + } + if !request.knowledge_bindings.is_empty() { + context.insert( + "knowledge_bindings".to_string(), + json!(request.knowledge_bindings.clone()), + ); + } + + match descriptor.context_key { + "image_task" | "cover_task" => { + context.insert("mode".to_string(), json!("generate")); + } + "research_request" | "report_request" => { + if !context.contains_key("query") { + let query = context + .get("prompt") + .and_then(Value::as_str) + .unwrap_or("请根据当前 App 任务补齐资料") + .to_string(); + context.insert("query".to_string(), json!(query)); + } + } + _ => {} + } + + context +} + +fn resolve_agent_app_runtime_capability_descriptors( + request: &AgentAppRuntimeStartTaskRequest, +) -> Vec { + resolve_capability_descriptors( + request + .required_capabilities + .iter() + .map(String::as_str) + .chain(request.capability_hints.iter().map(String::as_str)), + ) +} + +fn capability_descriptor_metadata(descriptor: AgentAppRuntimeCapabilityDescriptor) -> Value { + json!({ + "capability_id": descriptor.capability_id, + "skill_name": descriptor.skill_name, + "launch_key": descriptor.launch_key, + "context_key": descriptor.context_key, + "default_kind": descriptor.default_kind, + }) +} + +fn build_agent_app_capability_workflow_metadata( + request: &AgentAppRuntimeStartTaskRequest, + descriptors: &[AgentAppRuntimeCapabilityDescriptor], + output_contract: Option<&Value>, + inserts_primary_launch: bool, +) -> Option { + if descriptors.is_empty() { + return None; + } + + Some(json!({ + "source": AGENT_APP_RUNTIME_CAPABILITY_SOURCE, + "mode": if output_contract.is_some() { + "composite_output_contract" + } else if descriptors.len() > 1 { + "multi_capability" + } else { + "single_capability" + }, + "launch_policy": if inserts_primary_launch { + "primary_skill_launch" + } else { + "metadata_only" + }, + "requested_capabilities": request.required_capabilities.clone(), + "capability_hints": request.capability_hints.clone(), + "descriptors": descriptors + .iter() + .copied() + .map(capability_descriptor_metadata) + .collect::>(), + })) +} + +fn insert_agent_app_capability_launch_metadata( + root: &mut Map, + request: &AgentAppRuntimeStartTaskRequest, + task_id: &str, + trace_id: &str, + descriptor: AgentAppRuntimeCapabilityDescriptor, +) { + let launch_context = + build_agent_app_capability_request_context(request, task_id, trace_id, descriptor); + let harness = root + .entry("harness".to_string()) + .or_insert_with(|| json!({})); + let Some(harness) = harness.as_object_mut() else { + return; + }; + + harness.insert("allow_model_skills".to_string(), json!(true)); + let mut launch = Map::new(); + launch.insert("skill_name".to_string(), json!(descriptor.skill_name)); + launch.insert("kind".to_string(), json!(descriptor.default_kind)); + launch.insert( + descriptor.context_key.to_string(), + Value::Object(launch_context), + ); + harness.insert(descriptor.launch_key.to_string(), Value::Object(launch)); +} + +fn should_insert_agent_app_capability_launch_metadata( + request: &AgentAppRuntimeStartTaskRequest, + output_contract: Option<&Value>, +) -> bool { + // 内容工厂这类复合业务任务的 tools/capabilityHints 表示“可用能力”, + // 不能被提升为单一 Claw Skill 启动,否则会偏离 App 的 workspace patch 产物合同。 + if is_content_factory_runtime_task(request) && output_contract.is_some() { + return false; + } + + true +} + +fn insert_agent_app_output_contract_runtime_hints( + harness: &mut Map, + request: &AgentAppRuntimeStartTaskRequest, + output_contract: Option<&Value>, +) { + if !is_content_factory_runtime_task(request) || output_contract.is_none() { + return; + } + + // 内容工厂 patch 产出优先走直接回答 + ArtifactDocument 自动落盘, + // 避免复合业务任务被 FullRuntime 的通用工具链带偏成读文件 / Bash / 子代理循环。 + harness + .entry("chat_mode".to_string()) + .or_insert_with(|| json!("general")); + harness + .entry("session_mode".to_string()) + .or_insert_with(|| json!("general_workbench")); +} + +pub(super) fn build_agent_app_runtime_metadata( + request: &AgentAppRuntimeStartTaskRequest, + task_id: &str, + trace_id: &str, +) -> Value { + let mut metadata = request.metadata.clone().unwrap_or_else(|| json!({})); + if !metadata.is_object() { + metadata = json!({}); + } + + let mut app_runtime = json!({ + "surface": "agent_app", + "app_id": request.app_id.trim(), + "entry_key": request.entry_key.as_deref().unwrap_or("").trim(), + "task_id": task_id, + "trace_id": trace_id, + "task_kind": request.task_kind.trim(), + "idempotency_key": request.idempotency_key.as_deref().unwrap_or("").trim(), + "required_capabilities": request.required_capabilities.clone(), + "capability_hints": request.capability_hints.clone(), + "knowledge_bindings": request.knowledge_bindings.clone(), + "human_review": request.human_review.unwrap_or(false), + }); + let output_contract = build_agent_app_output_contract(request); + let skill_contract = build_agent_app_skill_contract(request); + let capability_descriptors = resolve_agent_app_runtime_capability_descriptors(request); + let should_insert_primary_capability_launch = + should_insert_agent_app_capability_launch_metadata(request, output_contract.as_ref()) + && !capability_descriptors.is_empty(); + let capability_workflow = build_agent_app_capability_workflow_metadata( + request, + &capability_descriptors, + output_contract.as_ref(), + should_insert_primary_capability_launch, + ); + if let (Some(app_runtime), Some(output_contract)) = + (app_runtime.as_object_mut(), output_contract.clone()) + { + app_runtime.insert("output_contract".to_string(), output_contract); + } + if let (Some(app_runtime), Some(capability_workflow)) = + (app_runtime.as_object_mut(), capability_workflow.clone()) + { + app_runtime.insert("capability_workflow".to_string(), capability_workflow); + } + if let (Some(app_runtime), Some(skill_contract)) = + (app_runtime.as_object_mut(), skill_contract.clone()) + { + app_runtime.insert("skill_contract".to_string(), skill_contract); + } + + if let Some(root) = metadata.as_object_mut() { + root.insert( + AGENT_APP_RUNTIME_METADATA_KEY.to_string(), + app_runtime.clone(), + ); + let lime_runtime = root + .entry(LIME_RUNTIME_METADATA_KEY.to_string()) + .or_insert_with(|| json!({})); + if let Some(lime_runtime) = lime_runtime.as_object_mut() { + lime_runtime.insert("surface".to_string(), json!("agent_app")); + lime_runtime.insert("app_id".to_string(), json!(request.app_id.trim())); + lime_runtime.insert("task_id".to_string(), json!(task_id)); + lime_runtime.insert("trace_id".to_string(), json!(trace_id)); + lime_runtime.insert("task_kind".to_string(), json!(request.task_kind.trim())); + let runtime_summary = lime_runtime + .entry("runtime_summary".to_string()) + .or_insert_with(|| json!({})); + if let Some(runtime_summary) = runtime_summary.as_object_mut() { + runtime_summary.insert("surface".to_string(), json!("agent_app")); + runtime_summary.insert("app_id".to_string(), json!(request.app_id.trim())); + runtime_summary.insert("task_id".to_string(), json!(task_id)); + runtime_summary.insert("trace_id".to_string(), json!(trace_id)); + runtime_summary.insert("task_kind".to_string(), json!(request.task_kind.trim())); + } + } + let should_insert_skill_tool_scope = skill_contract.is_some(); + { + let harness = root + .entry("harness".to_string()) + .or_insert_with(|| json!({})); + if let Some(harness) = harness.as_object_mut() { + harness.insert( + AGENT_APP_RUNTIME_METADATA_KEY.to_string(), + app_runtime.clone(), + ); + if let Some(output_contract) = output_contract.clone() { + harness.insert( + "agent_app_runtime_output_contract".to_string(), + output_contract, + ); + } + insert_agent_app_output_contract_runtime_hints( + harness, + request, + output_contract.as_ref(), + ); + if let Some(skill_contract) = skill_contract.clone() { + harness.insert("allow_model_skills".to_string(), json!(true)); + harness.insert( + "agent_app_runtime_skill_contract".to_string(), + skill_contract.clone(), + ); + harness.insert("content_factory_skill_contract".to_string(), skill_contract); + } + if let Some(capability_workflow) = capability_workflow.clone() { + harness.insert( + "agent_app_runtime_capability_workflow".to_string(), + capability_workflow, + ); + } + } + } + if should_insert_skill_tool_scope { + insert_agent_app_required_skill_tool_scope(root); + } + if should_insert_primary_capability_launch { + if let Some(descriptor) = capability_descriptors.first().copied() { + insert_agent_app_capability_launch_metadata( + root, request, task_id, trace_id, descriptor, + ); + } + } + } + + metadata +} diff --git a/src-tauri/src/commands/agent_app_runtime_cmd/model_preference.rs b/src-tauri/src/commands/agent_app_runtime_cmd/model_preference.rs new file mode 100644 index 000000000..8a96060b8 --- /dev/null +++ b/src-tauri/src/commands/agent_app_runtime_cmd/model_preference.rs @@ -0,0 +1,249 @@ +use super::types::AgentAppRuntimeStartTaskRequest; +use super::{non_empty, AGENT_APP_RUNTIME_METADATA_KEY}; +use crate::agent::AsterAgentState; +use crate::commands::api_key_provider_cmd::ApiKeyProviderServiceState; +use crate::database::DbConnection; +use lime_core::database::dao::agent_run::{AgentRun, AgentRunDao, AgentRunStatus}; +use lime_core::database::dao::api_key_provider::ProviderWithKeys; +use serde_json::{json, Value}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct AgentAppRuntimeModelPreference { + pub(super) provider_preference: String, + pub(super) model_preference: String, + pub(super) source: &'static str, +} + +fn is_unconfigured_model_preference(value: &str) -> bool { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "" | "unconfigured" | "unknown" | "none" | "null" + ) +} + +fn model_preference_from_values( + provider_preference: Option, + model_preference: Option, + source: &'static str, +) -> Option { + let provider_preference = provider_preference + .and_then(|value| non_empty(Some(value.as_str()))) + .filter(|value| !is_unconfigured_model_preference(value))?; + let model_preference = model_preference + .and_then(|value| non_empty(Some(value.as_str()))) + .filter(|value| !is_unconfigured_model_preference(value))?; + + Some(AgentAppRuntimeModelPreference { + provider_preference, + model_preference, + source, + }) +} + +fn json_pointer_string(value: &Value, pointers: &[&str]) -> Option { + pointers.iter().find_map(|pointer| { + value + .pointer(pointer) + .and_then(Value::as_str) + .and_then(|value| non_empty(Some(value))) + }) +} + +pub(super) fn model_preference_from_run_metadata( + metadata: &Value, +) -> Option { + let provider_preference = json_pointer_string( + metadata, + &[ + "/turn_input/provider_routing/provider_selector", + "/turnInput/providerRouting/providerSelector", + "/request_metadata/lime_runtime/routing_decision/selected_provider", + "/request_metadata/lime_runtime/routing_decision/selectedProvider", + "/requestMetadata/limeRuntime/routingDecision/selectedProvider", + ], + ); + let model_preference = json_pointer_string( + metadata, + &[ + "/turn_input/provider_routing/model_name", + "/turnInput/providerRouting/modelName", + "/request_metadata/lime_runtime/routing_decision/selected_model", + "/request_metadata/lime_runtime/routing_decision/selectedModel", + "/requestMetadata/limeRuntime/routingDecision/selectedModel", + ], + ); + + model_preference_from_values( + provider_preference, + model_preference, + "recent_successful_agent_run", + ) +} + +fn model_preference_from_recent_successful_runs( + db: &DbConnection, +) -> Option { + let runs = { + let conn = match db.lock() { + Ok(conn) => conn, + Err(error) => { + tracing::warn!( + "[AgentAppRuntime] 读取最近模型偏好时数据库锁定失败: {}", + error + ); + return None; + } + }; + match AgentRunDao::list_runs(&conn, 50, 0) { + Ok(runs) => runs, + Err(error) => { + tracing::warn!( + "[AgentAppRuntime] 读取最近 agent_runs 失败,跳过模型偏好回填: {}", + error + ); + return None; + } + } + }; + + runs.iter() + .filter(|run| matches!(run.status, AgentRunStatus::Success)) + .find_map(model_preference_from_agent_run) +} + +fn model_preference_from_agent_run(run: &AgentRun) -> Option { + let metadata = run.metadata.as_deref()?; + let metadata: Value = serde_json::from_str(metadata).ok()?; + model_preference_from_run_metadata(&metadata) +} + +fn provider_looks_non_chat_agent_runtime_candidate(provider: &ProviderWithKeys) -> bool { + let text = [ + provider.provider.id.as_str(), + provider.provider.name.as_str(), + provider.provider.api_host.as_str(), + ] + .join(" ") + .to_ascii_lowercase(); + + text.contains("fal") + || text.contains("codex") + || text.contains("coding") + || text.contains("gpt-image") + || text.contains("gpt_images") +} + +fn model_preference_from_enabled_provider_catalog( + db: &DbConnection, + api_key_provider_service: &ApiKeyProviderServiceState, +) -> Option { + let providers = match api_key_provider_service.0.get_all_providers(db) { + Ok(providers) => providers, + Err(error) => { + tracing::warn!( + "[AgentAppRuntime] 读取 API Key Providers 失败,跳过模型偏好回填: {}", + error + ); + return None; + } + }; + + providers.into_iter().find_map(|provider| { + if !provider.provider.enabled { + return None; + } + if provider_looks_non_chat_agent_runtime_candidate(&provider) { + return None; + } + if !provider.api_keys.iter().any(|key| key.enabled) { + return None; + } + let model = provider + .provider + .custom_models + .iter() + .find_map(|model| non_empty(Some(model.as_str())))?; + model_preference_from_values( + Some(provider.provider.id), + Some(model), + "enabled_provider_custom_model", + ) + }) +} + +pub(super) async fn resolve_agent_app_runtime_model_preference( + state: &AsterAgentState, + db: &DbConnection, + api_key_provider_service: &ApiKeyProviderServiceState, + request: &AgentAppRuntimeStartTaskRequest, +) -> Option { + if let Some(preference) = model_preference_from_values( + request.provider_preference.clone(), + request.model_preference.clone(), + "request", + ) { + return Some(preference); + } + + if let Some(preference) = model_preference_from_recent_successful_runs(db) { + return Some(preference); + } + + if let Some(preference) = + model_preference_from_enabled_provider_catalog(db, api_key_provider_service) + { + return Some(preference); + } + + if let Some(config) = state.get_provider_config().await { + if let Some(preference) = model_preference_from_values( + config + .provider_selector + .clone() + .or_else(|| Some(config.provider_name.clone())), + Some(config.model_name.clone()), + "current_agent_state", + ) { + return Some(preference); + } + } + + None +} + +pub(super) fn insert_agent_app_runtime_model_preference_metadata( + metadata: &mut Value, + preference: &AgentAppRuntimeModelPreference, +) { + let Some(root) = metadata.as_object_mut() else { + return; + }; + let preference_value = json!({ + "provider_preference": preference.provider_preference.clone(), + "model_preference": preference.model_preference.clone(), + "source": preference.source, + }); + + let harness = root + .entry("harness".to_string()) + .or_insert_with(|| json!({})); + if let Some(harness) = harness.as_object_mut() { + harness.insert( + "agent_app_runtime_model_preference".to_string(), + preference_value.clone(), + ); + if let Some(app_runtime) = harness + .get_mut(AGENT_APP_RUNTIME_METADATA_KEY) + .and_then(Value::as_object_mut) + { + app_runtime.insert("model_preference".to_string(), preference_value.clone()); + } + } + + if let Some(app_runtime) = root + .get_mut(AGENT_APP_RUNTIME_METADATA_KEY) + .and_then(Value::as_object_mut) + { + app_runtime.insert("model_preference".to_string(), preference_value); + } +} diff --git a/src-tauri/src/commands/agent_app_runtime_cmd/tests.rs b/src-tauri/src/commands/agent_app_runtime_cmd/tests.rs new file mode 100644 index 000000000..17f48decf --- /dev/null +++ b/src-tauri/src/commands/agent_app_runtime_cmd/tests.rs @@ -0,0 +1,552 @@ +use super::*; +use crate::agent::QueuedTurnSnapshot; +use crate::commands::aster_agent_cmd::AgentRuntimeThreadReadModel; +use crate::commands::aster_agent_cmd::{ + AgentRuntimeIncidentView, AgentRuntimeOutcomeView, AgentRuntimeRequestView, + AgentRuntimeThreadArtifactView, AgentRuntimeThreadEvidenceSummary, + AgentRuntimeThreadTelemetrySummary, AgentRuntimeThreadToolCallView, +}; +use serde_json::{json, Value}; + +fn runtime_request( + required_capabilities: Vec<&str>, + capability_hints: Vec<&str>, +) -> AgentAppRuntimeStartTaskRequest { + AgentAppRuntimeStartTaskRequest { + app_id: "content-factory-app".to_string(), + entry_key: Some("content_factory".to_string()), + workspace_id: Some("workspace-1".to_string()), + session_id: None, + task_id: None, + task_kind: "content_factory.copy.generate".to_string(), + idempotency_key: None, + title: Some("生成小红书种草文案".to_string()), + prompt: Some("围绕春季护肤新品生成文案,并补齐资料来源".to_string()), + input: Some(json!({ + "platform": "xiaohongshu", + "audience": "敏感肌用户" + })), + expected_output: Some(json!({ + "artifacts": ["copy", "assetBrief"] + })), + required_capabilities: required_capabilities + .into_iter() + .map(str::to_string) + .collect(), + capability_hints: capability_hints.into_iter().map(str::to_string).collect(), + knowledge_bindings: Vec::new(), + human_review: Some(true), + event_name: None, + turn_id: None, + provider_preference: None, + model_preference: None, + queue_if_busy: None, + skip_pre_submit_resume: None, + run_start_hooks: None, + metadata: None, + } +} + +fn base_thread_read() -> AgentRuntimeThreadReadModel { + AgentRuntimeThreadReadModel { + thread_id: "thread-1".to_string(), + status: "running".to_string(), + profile_status: "running".to_string(), + active_turn_id: Some("turn-1".to_string()), + turns: Vec::new(), + pending_requests: Vec::new(), + last_outcome: None, + incidents: Vec::new(), + queued_turns: Vec::new(), + tool_calls: Vec::new(), + artifacts: Vec::new(), + model_routing: None, + evidence_summary: AgentRuntimeThreadEvidenceSummary::default(), + telemetry_summary: AgentRuntimeThreadTelemetrySummary::default(), + context_summary: None, + interrupt_state: None, + updated_at: Some("2026-05-16T00:00:00.000Z".to_string()), + latest_compaction_boundary: None, + file_checkpoint_summary: None, + diagnostics: None, + task_kind: None, + service_model_slot: None, + routing_mode: None, + decision_source: None, + decision_reason: None, + candidate_count: None, + fallback_chain: None, + capability_gap: None, + estimated_cost_class: None, + single_candidate_only: None, + oem_policy: None, + runtime_summary: None, + auxiliary_task_runtime: None, + limit_state: None, + cost_state: None, + permission_state: None, + limit_event: None, + } +} + +#[test] +fn test_agent_app_runtime_session_id_uses_hidden_prefix() { + assert!(new_agent_app_runtime_session_id().starts_with(AGENT_APP_RUNTIME_SESSION_ID_PREFIX)); +} + +#[test] +fn test_agent_app_runtime_model_preference_reads_recent_successful_routing_metadata() { + let metadata = json!({ + "request_metadata": { + "lime_runtime": { + "routing_decision": { + "selected_provider": "deepseek", + "selected_model": "deepseek-v4-flash" + } + } + } + }); + + let preference = model_preference_from_run_metadata(&metadata).expect("recent run preference"); + + assert_eq!(preference.provider_preference, "deepseek"); + assert_eq!(preference.model_preference, "deepseek-v4-flash"); + assert_eq!(preference.source, "recent_successful_agent_run"); +} + +#[test] +fn test_agent_app_runtime_metadata_maps_research_capability_to_claw_launch() { + let request = runtime_request( + vec!["text_generation", "lime.capability.research.search"], + Vec::new(), + ); + let metadata = build_agent_app_runtime_metadata(&request, "task-1", "trace-1"); + let harness = metadata + .get("harness") + .and_then(Value::as_object) + .expect("harness metadata"); + let launch = harness + .get("research_skill_launch") + .and_then(Value::as_object) + .expect("research launch"); + let research_request = launch + .get("research_request") + .and_then(Value::as_object) + .expect("research request"); + let runtime_summary = metadata + .get("lime_runtime") + .and_then(|value| value.get("runtime_summary")) + .and_then(Value::as_object) + .expect("agent app runtime summary"); + + assert_eq!(harness.get("allow_model_skills"), Some(&json!(true))); + assert!(harness.get("agent_app_runtime").is_some()); + assert_eq!(runtime_summary.get("surface"), Some(&json!("agent_app"))); + assert_eq!( + runtime_summary.get("app_id"), + Some(&json!("content-factory-app")) + ); + assert_eq!(runtime_summary.get("task_id"), Some(&json!("task-1"))); + assert_eq!(runtime_summary.get("trace_id"), Some(&json!("trace-1"))); + assert_eq!(launch.get("skill_name"), Some(&json!("research"))); + assert_eq!(launch.get("kind"), Some(&json!("research_request"))); + assert_eq!( + research_request.get("source"), + Some(&json!("agent_app_runtime")) + ); + assert_eq!( + research_request.get("app_id"), + Some(&json!("content-factory-app")) + ); + assert_eq!( + research_request.get("capability_id"), + Some(&json!("lime.capability.research.search")) + ); + assert!(research_request.get("query").is_some()); +} + +#[test] +fn test_agent_app_runtime_metadata_maps_image_alias_to_claw_launch() { + let request = runtime_request(Vec::new(), vec!["image_generation"]); + let metadata = build_agent_app_runtime_metadata(&request, "task-1", "trace-1"); + let launch = metadata + .get("harness") + .and_then(Value::as_object) + .and_then(|harness| harness.get("image_skill_launch")) + .and_then(Value::as_object) + .expect("image launch"); + let image_task = launch + .get("image_task") + .and_then(Value::as_object) + .expect("image task"); + + assert_eq!(launch.get("skill_name"), Some(&json!("image_generate"))); + assert_eq!(launch.get("kind"), Some(&json!("image_task"))); + assert_eq!(image_task.get("mode"), Some(&json!("generate"))); + assert_eq!( + image_task.get("entry_source"), + Some(&json!("agent_app_runtime")) + ); +} + +#[test] +fn test_agent_app_runtime_metadata_ignores_unknown_capability_without_fake_launch() { + let request = runtime_request(vec!["text_generation"], Vec::new()); + let metadata = build_agent_app_runtime_metadata(&request, "task-1", "trace-1"); + let harness = metadata + .get("harness") + .and_then(Value::as_object) + .expect("harness metadata"); + + assert!(harness.get("agent_app_runtime").is_some()); + assert!(harness.get("allow_model_skills").is_none()); + assert!(harness.get("image_skill_launch").is_none()); + assert!(harness.get("research_skill_launch").is_none()); +} + +#[test] +fn test_agent_app_runtime_content_factory_output_contract_is_machine_readable() { + let mut request = runtime_request(Vec::new(), Vec::new()); + request.expected_output = Some(json!({ + "artifactKind": "content_batch", + "includes": ["copy", "script", "image_brief"], + "requiredSkills": [{ + "id": "article-writer", + "skill": "article-writer", + "standard": "agentskills", + "required": true + }] + })); + + let metadata = build_agent_app_runtime_metadata(&request, "task-1", "trace-1"); + let harness = metadata + .get("harness") + .and_then(Value::as_object) + .expect("harness metadata"); + let output_contract = harness + .get("agent_app_runtime_output_contract") + .and_then(Value::as_object) + .expect("output contract"); + assert_eq!( + output_contract.get("artifact_kind"), + Some(&json!("content_batch")) + ); + assert_eq!( + output_contract.get("artifact_metadata_kind"), + Some(&json!(CONTENT_FACTORY_WORKSPACE_PATCH_KIND)) + ); + assert!(output_contract + .get("patch_metadata_keys") + .and_then(Value::as_array) + .is_some_and(|items| items.contains(&json!("contentFactoryWorkspacePatch")))); + + let message = build_agent_app_runtime_task_message(&request); + assert!(message.contains("Content Factory Output Contract")); + assert!(message.contains("Content Factory Skill Contract")); + assert!(message.contains("skill=\"article-writer\"")); + assert!(message.contains("tool=Skill")); + assert!(message.contains("contentFactoryWorkspacePatch")); + assert!(message.contains("artifactKind=content_batch")); + assert!(message.contains("不要通过 Bash")); + assert!(message.contains("requiredSkills")); +} + +#[test] +fn test_agent_app_runtime_content_factory_output_contract_uses_business_skills_without_single_capability_launch( +) { + let mut request = runtime_request(Vec::new(), vec!["research.search", "image_generation"]); + request.expected_output = Some(json!({ + "artifactKind": "content_batch", + "includes": ["copy", "script", "image_brief"], + "requiredSkills": [ + { + "id": "article-writer", + "skill": "article-writer", + "standard": "agentskills", + "required": true + }, + { + "id": "content-reviewer", + "skill": "content-reviewer", + "standard": "agentskills", + "required": true + } + ] + })); + + let metadata = build_agent_app_runtime_metadata(&request, "task-1", "trace-1"); + let harness = metadata + .get("harness") + .and_then(Value::as_object) + .expect("harness metadata"); + + assert!(harness.get("agent_app_runtime_output_contract").is_some()); + assert_eq!(harness.get("chat_mode"), Some(&json!("general"))); + assert_eq!( + harness.get("session_mode"), + Some(&json!("general_workbench")) + ); + let workflow = harness + .get("agent_app_runtime_capability_workflow") + .and_then(Value::as_object) + .expect("capability workflow"); + assert_eq!( + workflow.get("mode"), + Some(&json!("composite_output_contract")) + ); + assert_eq!(workflow.get("launch_policy"), Some(&json!("metadata_only"))); + let descriptors = workflow + .get("descriptors") + .and_then(Value::as_array) + .expect("workflow descriptors"); + assert_eq!(descriptors.len(), 2); + assert!(descriptors + .iter() + .any(|descriptor| descriptor.get("capability_id") + == Some(&json!("lime.capability.research.search")))); + assert!(descriptors + .iter() + .any(|descriptor| descriptor.get("capability_id") + == Some(&json!("lime.capability.image.generate")))); + assert_eq!(harness.get("allow_model_skills"), Some(&json!(true))); + let skill_contract = harness + .get("agent_app_runtime_skill_contract") + .and_then(Value::as_object) + .expect("skill contract"); + assert_eq!( + skill_contract.get("policy"), + Some(&json!("must_use_required_skills_before_final_patch")) + ); + let required_skills = skill_contract + .get("required_skills") + .and_then(Value::as_array) + .expect("required skills"); + assert_eq!(required_skills.len(), 2); + assert!(required_skills + .iter() + .any(|skill| skill.get("skill") == Some(&json!("article-writer")))); + assert!(required_skills + .iter() + .any(|skill| skill.get("skill") == Some(&json!("content-reviewer")))); + let tool_scope = metadata + .get("tool_scope") + .and_then(Value::as_object) + .expect("required skill task should narrow tool scope"); + assert_eq!(tool_scope.get("allowed_tools"), Some(&json!(["Skill"]))); + assert_eq!( + metadata + .get(LIME_RUNTIME_METADATA_KEY) + .and_then(|value| value.get(LIME_RUNTIME_TOOL_SURFACE_KEY)), + Some(&json!("agent_app_required_skills")) + ); + assert!(harness.get("research_skill_launch").is_none()); + assert!(harness.get("image_skill_launch").is_none()); +} + +#[test] +fn test_agent_app_runtime_extracts_workspace_patch_from_artifact_document_blocks() { + let metadata = json!({ + "artifactDocument": { + "blocks": [ + { + "type": "rich_text", + "content": "```json\n{\"contentFactoryWorkspacePatch\":{\"kind\":\"content_batch\",\"projectId\":\"project-1\",\"contentBatch\":{\"items\":[{\"title\":\"示例文案\"}]}}}\n```" + } + ] + } + }); + + let patch = extract_content_factory_workspace_patch_from_artifact_document(Some(&metadata)) + .expect("workspace patch"); + + assert_eq!(patch.get("kind"), Some(&json!("content_batch"))); + assert_eq!(patch.get("projectId"), Some(&json!("project-1"))); + assert!(patch.get("contentBatch").is_some()); +} + +#[test] +fn test_agent_app_runtime_task_events_project_thread_read_facts() { + let mut thread_read = base_thread_read(); + thread_read.queued_turns = vec![QueuedTurnSnapshot { + queued_turn_id: "queued-1".to_string(), + message_preview: "排队任务".to_string(), + message_text: "排队任务完整文本".to_string(), + created_at: 1_789_000_000, + image_count: 0, + position: 0, + }]; + thread_read.pending_requests = vec![AgentRuntimeRequestView { + id: "request-1".to_string(), + thread_id: "thread-1".to_string(), + turn_id: Some("turn-1".to_string()), + item_id: None, + request_type: "ask_user".to_string(), + status: "pending".to_string(), + title: Some("需要确认素材方向".to_string()), + payload: Some(json!({ "question": "是否继续?" })), + decision: None, + scope: None, + created_at: Some("2026-05-16T00:00:01.000Z".to_string()), + resolved_at: None, + }]; + thread_read.context_summary = Some(json!({ + "missing_context": [{ "field": "target_audience" }] + })); + thread_read.tool_calls = vec![AgentRuntimeThreadToolCallView { + tool_call_id: "tool-1".to_string(), + turn_id: "turn-1".to_string(), + tool_name: "Skill(research)".to_string(), + status: "completed".to_string(), + success: Some(true), + error: None, + }]; + thread_read.artifacts = vec![AgentRuntimeThreadArtifactView { + item_id: "artifact-item-1".to_string(), + turn_id: "turn-1".to_string(), + path: ".lime/artifacts/content-batch.json".to_string(), + source: "agent_runtime".to_string(), + status: "created".to_string(), + artifact_type: Some("content_batch".to_string()), + title: Some("内容批次".to_string()), + created_at: Some("2026-05-16T00:00:01.500Z".to_string()), + completed_at: Some("2026-05-16T00:00:01.800Z".to_string()), + updated_at: Some("2026-05-16T00:00:01.800Z".to_string()), + metadata: Some(json!({ + "artifactType": "content_batch", + "workspacePatch": { + "kind": "content_batch", + "projectId": "project-1", + "contentBatch": { "count": 20 } + } + })), + }]; + thread_read.evidence_summary = AgentRuntimeThreadEvidenceSummary { + evidence_refs: vec!["evidence-1".to_string()], + verification_outcomes: vec![json!({ "status": "passed" })], + }; + thread_read.last_outcome = Some(AgentRuntimeOutcomeView { + thread_id: "thread-1".to_string(), + turn_id: Some("turn-1".to_string()), + outcome_type: "completed".to_string(), + summary: Some("任务完成".to_string()), + primary_cause: None, + retryable: false, + ended_at: Some("2026-05-16T00:00:02.000Z".to_string()), + }); + thread_read.incidents = vec![AgentRuntimeIncidentView { + id: "incident-1".to_string(), + thread_id: "thread-1".to_string(), + turn_id: Some("turn-1".to_string()), + item_id: None, + incident_type: "provider_warning".to_string(), + severity: "medium".to_string(), + status: "open".to_string(), + title: "Provider warning".to_string(), + details: None, + detected_at: Some("2026-05-16T00:00:03.000Z".to_string()), + cleared_at: None, + }]; + + let events = build_agent_app_runtime_task_events(&thread_read); + let event_types = events + .iter() + .map(|event| event.event_type.as_str()) + .collect::>(); + + assert!(event_types.contains(&"task:queued")); + assert!(event_types.contains(&"task:progress")); + assert!(event_types.contains(&"task:missingContextRequested")); + assert!(event_types.contains(&"task:toolCall")); + assert!(event_types.contains(&"artifact:created")); + assert!(event_types.contains(&"evidence:recorded")); + assert!(event_types.contains(&"evidence:verified")); + assert!(event_types.contains(&"task:completed")); + assert!(event_types.contains(&"task:incident")); + assert!(events + .iter() + .any(|event| event.request_id.as_deref() == Some("request-1"))); + assert!(events + .iter() + .any(|event| event.evidence_ref.as_deref() == Some("evidence-1"))); + assert!(events.iter().any(|event| { + event.event_type == "evidence:recorded" + && event.evidence_ref.as_deref() == Some("evidence:.lime/artifacts/content-batch.json") + && event + .payload + .as_ref() + .and_then(|payload| payload.get("contentFactoryWorkspacePatch")) + .and_then(|patch| patch.get("contentBatch")) + .and_then(|content_batch| content_batch.get("count")) + == Some(&json!(20)) + })); + assert!(events + .iter() + .any(|event| event.artifact_ref.as_deref() == Some(".lime/artifacts/content-batch.json"))); + let artifact_event = events + .iter() + .find(|event| event.event_type == "artifact:created") + .expect("artifact event"); + assert_eq!( + artifact_event + .payload + .as_ref() + .and_then(|payload| payload.get("contentFactoryWorkspacePatch")) + .and_then(|patch| patch.get("contentBatch")) + .and_then(|content_batch| content_batch.get("count")), + Some(&json!(20)) + ); +} + +#[test] +fn test_agent_app_runtime_task_snapshot_event_payload_is_canonical() { + let mut thread_read = base_thread_read(); + thread_read.profile_status = "running".to_string(); + let task_events = build_agent_app_runtime_task_events(&thread_read); + let snapshot = AgentAppRuntimeTaskSnapshot { + app_id: "content-factory-app".to_string(), + task_id: "task-1".to_string(), + session_id: "session-1".to_string(), + status: "thread_read_available".to_string(), + task_status: thread_read.profile_status.clone(), + task_events, + thread_read: serde_json::to_value(&thread_read).expect("thread read value"), + }; + + let payload = build_agent_app_runtime_task_snapshot_event_payload(&snapshot); + + assert_eq!( + payload.get("type"), + Some(&json!("agent_app_runtime:taskSnapshot")) + ); + assert_eq!(payload.get("eventType"), Some(&json!("task:update"))); + assert_eq!(payload.get("taskId"), Some(&json!("task-1"))); + assert_eq!( + payload + .get("taskEvents") + .and_then(Value::as_array) + .map(Vec::len), + Some(snapshot.task_events.len()) + ); + assert!(payload.get("threadRead").is_some()); + assert!(payload.get("task").is_some()); +} + +#[test] +fn test_agent_app_runtime_idle_status_uses_business_progress_copy() { + let mut thread_read = base_thread_read(); + thread_read.status = "idle".to_string(); + thread_read.profile_status = "idle".to_string(); + thread_read.active_turn_id = None; + + let events = build_agent_app_runtime_task_events(&thread_read); + let progress = events + .iter() + .find(|event| event.event_type == "task:progress") + .expect("progress event"); + + assert_eq!( + progress.message, + "任务已接收,等待 AgentRuntime 调度或回写进度" + ); + assert_ne!(progress.message, "任务状态:idle"); +} diff --git a/src-tauri/src/commands/agent_app_runtime_cmd/types.rs b/src-tauri/src/commands/agent_app_runtime_cmd/types.rs new file mode 100644 index 000000000..8d685a35b --- /dev/null +++ b/src-tauri/src/commands/agent_app_runtime_cmd/types.rs @@ -0,0 +1,149 @@ +use crate::commands::aster_agent_cmd::AgentRuntimeRespondActionRequest; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentAppRuntimeStartTaskRequest { + pub app_id: String, + #[serde(default)] + pub entry_key: Option, + #[serde(default)] + pub workspace_id: Option, + #[serde(default)] + pub session_id: Option, + #[serde(default)] + pub task_id: Option, + pub task_kind: String, + #[serde(default)] + pub idempotency_key: Option, + #[serde(default)] + pub title: Option, + #[serde(default)] + pub prompt: Option, + #[serde(default)] + pub input: Option, + #[serde(default)] + pub expected_output: Option, + #[serde(default)] + pub required_capabilities: Vec, + #[serde(default)] + pub capability_hints: Vec, + #[serde(default)] + pub knowledge_bindings: Vec, + #[serde(default)] + pub human_review: Option, + #[serde(default)] + pub event_name: Option, + #[serde(default)] + pub turn_id: Option, + #[serde(default)] + pub provider_preference: Option, + #[serde(default)] + pub model_preference: Option, + #[serde(default)] + pub queue_if_busy: Option, + #[serde(default)] + pub skip_pre_submit_resume: Option, + #[serde(default)] + pub run_start_hooks: Option, + #[serde(default)] + pub metadata: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentAppRuntimeStartTaskResult { + pub app_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub entry_key: Option, + pub task_id: String, + pub trace_id: String, + pub task_kind: String, + pub session_id: String, + pub turn_id: String, + pub event_name: String, + pub status: String, + pub submitted_at: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentAppRuntimeCancelTaskRequest { + pub app_id: String, + pub task_id: String, + pub session_id: String, + #[serde(default)] + pub turn_id: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentAppRuntimeCancelTaskResult { + pub app_id: String, + pub task_id: String, + pub session_id: String, + pub cancelled: bool, + pub status: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentAppRuntimeGetTaskRequest { + pub app_id: String, + pub task_id: String, + pub session_id: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentAppRuntimeTaskEvent { + pub id: String, + pub event_type: String, + pub status: String, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub severity: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub turn_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub request_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub evidence_ref: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub artifact_ref: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub occurred_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub payload: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentAppRuntimeTaskSnapshot { + pub app_id: String, + pub task_id: String, + pub session_id: String, + pub status: String, + pub task_status: String, + pub task_events: Vec, + pub thread_read: Value, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentAppRuntimeSubmitHostResponseRequest { + pub app_id: String, + pub task_id: String, + pub runtime_request: AgentRuntimeRespondActionRequest, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentAppRuntimeSubmitHostResponseResult { + pub app_id: String, + pub task_id: String, + pub status: String, +} diff --git a/src-tauri/src/commands/aster_agent_cmd/command_api/runtime_api.rs b/src-tauri/src/commands/aster_agent_cmd/command_api/runtime_api.rs index 00223abcf..565d07ef4 100644 --- a/src-tauri/src/commands/aster_agent_cmd/command_api/runtime_api.rs +++ b/src-tauri/src/commands/aster_agent_cmd/command_api/runtime_api.rs @@ -29,13 +29,14 @@ use lime_core::database::dao::agent_run::{AgentRun, AgentRunDao}; use lime_core::database::dao::agent_timeline::{AgentThreadItemStatus, AgentThreadTurnStatus}; use serde_json::{json, Value}; use std::path::PathBuf; -use std::time::Instant; +use std::time::{Duration, Instant}; use tauri::Manager; const RUNTIME_INTERRUPT_MESSAGE: &str = "用户已停止当前执行"; const RUNTIME_SESSION_OPEN_HISTORY_LIMIT: usize = 40; const RUNTIME_SESSION_MAX_HISTORY_LIMIT: usize = 2_000; +const TOOL_INVENTORY_AUX_TIMEOUT: Duration = Duration::from_secs(3); fn normalize_runtime_session_history_limit(history_limit: Option) -> Option { match history_limit { @@ -1422,17 +1423,6 @@ pub async fn agent_runtime_get_tool_inventory( mcp_manager: State<'_, McpManagerState>, request: Option, ) -> Result { - if state.is_initialized().await { - ensure_runtime_support_tools_registered( - &app, - state.inner(), - db.inner(), - api_key_provider_service.inner(), - mcp_manager.inner(), - ) - .await?; - } - let request = request.unwrap_or_default(); let caller = lime_core::tool_calling::normalize_tool_caller(request.caller.as_deref()) .unwrap_or_else(|| "assistant".to_string()); @@ -1442,35 +1432,84 @@ pub async fn agent_runtime_get_tool_inventory( (false, true) => WorkspaceToolSurface::browser_assist(), (false, false) => WorkspaceToolSurface::core(), }; - let mut warnings = Vec::new(); + if state.is_initialized().await { + match tokio::time::timeout( + TOOL_INVENTORY_AUX_TIMEOUT, + ensure_runtime_support_tools_registered( + &app, + state.inner(), + db.inner(), + api_key_provider_service.inner(), + mcp_manager.inner(), + ), + ) + .await + { + Ok(Ok(())) => {} + Ok(Err(error)) => warnings.push(format!("同步 runtime support tools 失败: {error}")), + Err(_) => warnings + .push("同步 runtime support tools 超时,已使用当前 registry 快照".to_string()), + } + } + let (mcp_server_names, mcp_tools) = { let manager = mcp_manager.lock().await; - let server_names = manager.get_running_servers().await; - let tools = match manager.list_tools().await { - Ok(tools) => tools, - Err(error) => { - warnings.push(format!("读取 MCP 工具列表失败: {error}")); - Vec::new() - } - }; + let server_names = + match tokio::time::timeout(TOOL_INVENTORY_AUX_TIMEOUT, manager.get_running_servers()) + .await + { + Ok(server_names) => server_names, + Err(_) => { + warnings.push("读取 MCP 服务列表超时,已跳过 MCP 服务快照".to_string()); + Vec::new() + } + }; + let tools = + match tokio::time::timeout(TOOL_INVENTORY_AUX_TIMEOUT, manager.list_tools()).await { + Ok(Ok(tools)) => tools, + Ok(Err(error)) => { + warnings.push(format!("读取 MCP 工具列表失败: {error}")); + Vec::new() + } + Err(_) => { + warnings.push("读取 MCP 工具列表超时,已跳过 MCP 工具快照".to_string()); + Vec::new() + } + }; (server_names, tools) }; let agent_arc = state.get_agent_arc(); - let guard = agent_arc.read().await; + let guard = match tokio::time::timeout(TOOL_INVENTORY_AUX_TIMEOUT, agent_arc.read()).await { + Ok(guard) => guard, + Err(_) => { + warnings.push("读取 Aster Agent 状态超时,runtime registry 快照为空".to_string()); + return Ok(build_tool_inventory(AgentToolInventoryBuildInput { + surface, + caller, + agent_initialized: false, + warnings, + persisted_execution_policy: Some(config_manager.config().agent.tool_execution), + request_metadata: request.metadata.clone(), + mcp_server_names, + mcp_tools, + registry_definitions: Vec::new(), + current_surface_tool_names: Vec::new(), + extension_configs: Vec::new(), + visible_extension_tools: Vec::new(), + searchable_extension_tools: Vec::new(), + })); + } + }; let Some(agent) = guard.as_ref() else { + warnings.push("Aster Agent 尚未初始化,runtime registry / extension 快照为空".to_string()); return Ok(build_tool_inventory(AgentToolInventoryBuildInput { surface, caller, agent_initialized: false, - warnings: { - warnings.push( - "Aster Agent 尚未初始化,runtime registry / extension 快照为空".to_string(), - ); - warnings - }, + warnings, persisted_execution_policy: Some(config_manager.config().agent.tool_execution), request_metadata: request.metadata.clone(), mcp_server_names, @@ -1484,7 +1523,28 @@ pub async fn agent_runtime_get_tool_inventory( }; let registry_arc = agent.tool_registry().clone(); - let registry = registry_arc.read().await; + let registry = match tokio::time::timeout(TOOL_INVENTORY_AUX_TIMEOUT, registry_arc.read()).await + { + Ok(registry) => registry, + Err(_) => { + warnings.push("读取 runtime registry 超时,已返回空 registry 快照".to_string()); + return Ok(build_tool_inventory(AgentToolInventoryBuildInput { + surface, + caller, + agent_initialized: true, + warnings, + persisted_execution_policy: Some(config_manager.config().agent.tool_execution), + request_metadata: request.metadata.clone(), + mcp_server_names, + mcp_tools, + registry_definitions: Vec::new(), + current_surface_tool_names: Vec::new(), + extension_configs: Vec::new(), + visible_extension_tools: Vec::new(), + searchable_extension_tools: Vec::new(), + })); + } + }; let mut registry_definitions = registry.get_definitions(); drop(registry); @@ -1506,33 +1566,50 @@ pub async fn agent_runtime_get_tool_inventory( let extension_configs = agent.get_extension_configs().await; let extension_manager = agent.extension_manager.clone(); - let visible_extension_tools = match extension_manager.get_prefixed_tools(None).await { - Ok(tools) => tools + let visible_extension_tools = match tokio::time::timeout( + TOOL_INVENTORY_AUX_TIMEOUT, + extension_manager.get_prefixed_tools(None), + ) + .await + { + Ok(Ok(tools)) => tools .into_iter() .map(|tool| ExtensionToolInventorySeed { name: tool.name.to_string(), description: tool.description.clone().unwrap_or_default().to_string(), }) .collect(), - Err(error) => { + Ok(Err(error)) => { warnings.push(format!("读取已加载 extension tools 失败: {error}")); Vec::new() } + Err(_) => { + warnings.push("读取已加载 extension tools 超时,已跳过 extension 工具快照".to_string()); + Vec::new() + } + }; + let searchable_extension_tools = match tokio::time::timeout( + TOOL_INVENTORY_AUX_TIMEOUT, + extension_manager.get_prefixed_tools_for_search(None), + ) + .await + { + Ok(Ok(tools)) => tools + .into_iter() + .map(|tool| ExtensionToolInventorySeed { + name: tool.name.to_string(), + description: tool.description.clone().unwrap_or_default().to_string(), + }) + .collect(), + Ok(Err(error)) => { + warnings.push(format!("读取 extension 搜索工具面失败: {error}")); + Vec::new() + } + Err(_) => { + warnings.push("读取 extension 搜索工具面超时,已跳过 extension 搜索快照".to_string()); + Vec::new() + } }; - let searchable_extension_tools = - match extension_manager.get_prefixed_tools_for_search(None).await { - Ok(tools) => tools - .into_iter() - .map(|tool| ExtensionToolInventorySeed { - name: tool.name.to_string(), - description: tool.description.clone().unwrap_or_default().to_string(), - }) - .collect(), - Err(error) => { - warnings.push(format!("读取 extension 搜索工具面失败: {error}")); - Vec::new() - } - }; Ok(build_tool_inventory(AgentToolInventoryBuildInput { surface, diff --git a/src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs b/src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs index b834a080b..7ab2f7f17 100644 --- a/src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs +++ b/src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs @@ -35,7 +35,8 @@ use lime_core::database::dao::agent_timeline::{AgentThreadItemPayload, AgentThre use lime_core::workspace::WorkspaceSettings; use regex::Regex; use serde_json::{json, Value}; -use std::collections::{HashMap, HashSet}; +use std::collections::{hash_map::DefaultHasher, HashMap, HashSet}; +use std::hash::{Hash, Hasher}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, OnceLock}; use std::time::{Duration, Instant}; @@ -324,6 +325,43 @@ fn build_runtime_projection_task_event( task_event } +fn runtime_projection_stream_event_id(prefix: &str, text: &str) -> String { + let mut hasher = DefaultHasher::new(); + prefix.hash(&mut hasher); + text.hash(&mut hasher); + chrono::Utc::now() + .timestamp_nanos_opt() + .unwrap_or_default() + .hash(&mut hasher); + format!("{prefix}:{:x}", hasher.finish()) +} + +fn build_runtime_projection_stream_task_event( + id_prefix: &'static str, + event_type: &'static str, + stream_kind: &'static str, + status: &'static str, + text: &str, + runtime_event: Value, +) -> Option { + if text.is_empty() { + return None; + } + let mut payload = serde_json::Map::new(); + payload.insert("streamKind".to_string(), json!(stream_kind)); + payload.insert("delta".to_string(), json!(text)); + payload.insert("runtimeEvent".to_string(), runtime_event); + let mut task_event = build_runtime_projection_task_event( + runtime_projection_stream_event_id(id_prefix, text), + event_type, + status, + text, + Value::Object(payload), + ); + task_event.insert("streamKind".to_string(), json!(stream_kind)); + Some(Value::Object(task_event)) +} + fn build_runtime_projection_artifact_task_event( id: String, status: impl Into, @@ -431,6 +469,136 @@ fn build_agent_app_runtime_event_primary_task_event( runtime_event: Value, ) -> Option { match event { + RuntimeAgentEvent::TextDelta { text } => build_runtime_projection_stream_task_event( + "runtime:text", + "task:partialArtifact", + "assistant_text_delta", + "streaming", + text, + runtime_event, + ), + RuntimeAgentEvent::TextDeltaBatch { text, .. } => { + build_runtime_projection_stream_task_event( + "runtime:text-batch", + "task:partialArtifact", + "assistant_text_batch", + "streaming", + text, + runtime_event, + ) + } + RuntimeAgentEvent::ThinkingDelta { text } => build_runtime_projection_stream_task_event( + "runtime:thinking", + "task:progress", + "thinking_delta", + "thinking", + text, + runtime_event, + ), + RuntimeAgentEvent::ToolInputDelta { + tool_id, + tool_name, + delta, + .. + } => { + let mut event = build_runtime_projection_stream_task_event( + "runtime:tool-input", + "task:toolCall", + "tool_input_delta", + "streaming", + delta, + runtime_event, + )?; + if let Some(task_event) = event.as_object_mut() { + task_event.insert("toolId".to_string(), json!(tool_id)); + if let Some(tool_name) = tool_name { + task_event.insert("toolName".to_string(), json!(tool_name)); + } + } + Some(event) + } + RuntimeAgentEvent::ToolOutputDelta { + tool_id, + delta, + output_kind, + .. + } => { + let mut event = build_runtime_projection_stream_task_event( + "runtime:tool-output", + "task:toolCall", + "tool_output_delta", + "streaming", + delta, + runtime_event, + )?; + if let Some(task_event) = event.as_object_mut() { + task_event.insert("toolId".to_string(), json!(tool_id)); + if let Some(output_kind) = output_kind { + task_event.insert("toolName".to_string(), json!(output_kind)); + } + } + Some(event) + } + RuntimeAgentEvent::TaskProfileResolved { task_profile } => { + Some(Value::Object(build_runtime_projection_task_event( + format!("runtime:task-profile:{}", task_profile.kind), + "task:progress", + "routing", + format!("已识别任务类型:{}", task_profile.kind), + runtime_event, + ))) + } + RuntimeAgentEvent::CandidateSetResolved { routing_decision } => { + Some(Value::Object(build_runtime_projection_task_event( + "runtime:routing:candidates".to_string(), + "task:progress", + "routing", + format!("已找到 {} 个候选模型", routing_decision.candidate_count), + runtime_event, + ))) + } + RuntimeAgentEvent::RoutingDecisionMade { routing_decision } => { + let selected = routing_decision + .selected_provider + .as_deref() + .zip(routing_decision.selected_model.as_deref()) + .map(|(provider, model)| format!("{provider}/{model}")) + .or_else(|| routing_decision.selected_model.clone()) + .or_else(|| routing_decision.selected_provider.clone()) + .unwrap_or_else(|| "自动选择".to_string()); + Some(Value::Object(build_runtime_projection_task_event( + "runtime:routing:decision".to_string(), + "task:progress", + "routing", + format!("模型路由已确定:{selected}"), + runtime_event, + ))) + } + RuntimeAgentEvent::RoutingFallbackApplied { routing_decision } => { + let selected = routing_decision + .selected_model + .clone() + .or_else(|| routing_decision.selected_provider.clone()) + .unwrap_or_else(|| "备用模型".to_string()); + Some(Value::Object(build_runtime_projection_task_event( + "runtime:routing:fallback".to_string(), + "task:incident", + "warning", + format!("模型路由已回退到:{selected}"), + runtime_event, + ))) + } + RuntimeAgentEvent::RoutingNotPossible { routing_decision } => { + let mut task_event = build_runtime_projection_task_event( + "runtime:routing:not-possible".to_string(), + "task:error", + "failed", + routing_decision.decision_reason.clone(), + runtime_event, + ); + task_event.insert("severity".to_string(), json!("error")); + Some(Value::Object(task_event)) + } RuntimeAgentEvent::RuntimeStatus { status } => { Some(Value::Object(build_runtime_projection_task_event( format!("runtime:status:{}", status.phase), @@ -2074,6 +2242,7 @@ struct RuntimeTurnSubmitBootstrap { provider_continuation_capability: ProviderContinuationCapability, tracker: ExecutionTracker, model_skill_tool_enabled: bool, + model_skill_tool_allowed_skill_names: Option>, model_skill_tool_allowed_skill_sources: Option>, } @@ -2474,6 +2643,82 @@ impl RuntimeTurnSubmitPreparation { .model_skill_tool_allowed_skill_sources .clone() } + + fn model_skill_tool_allowed_skill_names(&self) -> Option> { + self.submit_bootstrap + .model_skill_tool_allowed_skill_names + .clone() + } +} + +fn collect_required_skill_names_from_contract_object( + contract: &serde_json::Map, +) -> Vec { + let mut names = Vec::new(); + let mut seen = HashSet::new(); + + for key in [ + "required_skills", + "requiredSkills", + "skill_refs", + "skillRefs", + "skills", + ] { + let Some(items) = contract.get(key).and_then(serde_json::Value::as_array) else { + continue; + }; + for item in items { + let required = item + .as_object() + .and_then(|object| object.get("required")) + .and_then(serde_json::Value::as_bool) + .unwrap_or(true); + if !required { + continue; + } + let name = item + .as_str() + .map(str::to_string) + .or_else(|| { + item.as_object().and_then(|object| { + ["skill", "id", "name"] + .iter() + .filter_map(|key| object.get(*key)) + .find_map(serde_json::Value::as_str) + .map(str::to_string) + }) + }) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + let Some(name) = name else { + continue; + }; + if seen.insert(name.to_ascii_lowercase()) { + names.push(name); + } + } + } + + names +} + +fn resolve_agent_app_required_skill_tool_allowlist( + request_metadata: Option<&serde_json::Value>, +) -> Option> { + for key in [ + "content_factory_skill_contract", + "agent_app_runtime_skill_contract", + ] { + let Some(contract) = extract_harness_nested_object(request_metadata, &[key]) else { + continue; + }; + let names = collect_required_skill_names_from_contract_object(contract); + if !names.is_empty() { + return Some(names); + } + } + + None } #[allow(clippy::too_many_arguments)] @@ -2627,6 +2872,8 @@ async fn prepare_runtime_turn_submit_bootstrap( request_metadata.as_ref(), workspace_root, )?; + let agent_app_required_skill_names = + resolve_agent_app_required_skill_tool_allowlist(request_metadata.as_ref()); let model_skill_tool_allowed_skill_sources = workspace_skill_runtime_enable.as_ref().map(|projection| { projection @@ -2659,7 +2906,9 @@ async fn prepare_runtime_turn_submit_bootstrap( Ok(RuntimeTurnSubmitBootstrap { model_skill_tool_enabled: matches!(execution_profile, TurnExecutionProfile::FullRuntime) && (should_enable_model_skill_tool(request_metadata.as_ref()) - || workspace_skill_runtime_enable.is_some()), + || workspace_skill_runtime_enable.is_some() + || agent_app_required_skill_names.is_some()), + model_skill_tool_allowed_skill_names: agent_app_required_skill_names, model_skill_tool_allowed_skill_sources, request_metadata, runtime_memory_config: runtime_config.memory.clone(), @@ -3740,12 +3989,15 @@ async fn execute_runtime_turn_with_session_scope( submit_preparation: RuntimeTurnSubmitPreparation, ) -> Result<(), String> { let model_skill_tool_enabled = submit_preparation.model_skill_tool_enabled(); + let model_skill_tool_allowed_skill_names = + submit_preparation.model_skill_tool_allowed_skill_names(); let model_skill_tool_allowed_skill_sources = submit_preparation.model_skill_tool_allowed_skill_sources(); with_runtime_turn_session_scope( state, session_id, model_skill_tool_enabled, + model_skill_tool_allowed_skill_names, model_skill_tool_allowed_skill_sources, move |cancel_token| async move { execute_runtime_turn_submit( @@ -7390,6 +7642,7 @@ async fn with_runtime_turn_session_scope( state: &AsterAgentState, session_id: &str, skill_tool_access_enabled: bool, + skill_tool_allowed_skill_names: Option>, skill_tool_allowed_skill_sources: Option>, run: F, ) -> Result<(), String> @@ -7403,6 +7656,8 @@ where session_id, allowed_skill_sources, ); + } else if let Some(allowed_skill_names) = skill_tool_allowed_skill_names { + lime_agent::tools::set_skill_tool_session_allowed_skills(session_id, allowed_skill_names); } else { lime_agent::tools::set_skill_tool_session_access(session_id, skill_tool_access_enabled); } @@ -9056,16 +9311,39 @@ mod tests { } #[test] - fn agent_app_runtime_runtime_event_projection_skips_low_value_text_delta() { + fn agent_app_runtime_runtime_event_projection_builds_stream_text_task_event() { let event = RuntimeAgentEvent::TextDelta { - text: "partial".to_string(), + text: "第一段真实输出".to_string(), }; - assert!(build_agent_app_runtime_event_projection_payload( + let payload = build_agent_app_runtime_event_projection_payload( "agent_app_runtime:content-factory-app:task-1", &event, ) - .is_none()); + .expect("runtime stream projection payload"); + let task_event = payload + .get("taskEvents") + .and_then(Value::as_array) + .and_then(|events| events.first()) + .and_then(Value::as_object) + .expect("stream task event"); + + assert_eq!( + task_event.get("eventType"), + Some(&json!("task:partialArtifact")) + ); + assert_eq!(task_event.get("status"), Some(&json!("streaming"))); + assert_eq!(task_event.get("message"), Some(&json!("第一段真实输出"))); + assert_eq!( + task_event.get("streamKind"), + Some(&json!("assistant_text_delta")) + ); + assert_eq!( + task_event + .get("payload") + .and_then(|payload| payload.get("delta")), + Some(&json!("第一段真实输出")) + ); } #[test] @@ -10058,6 +10336,34 @@ mod tests { assert!(!should_enable_model_skill_tool(Some(&metadata))); } + #[test] + fn agent_app_skill_contract_should_resolve_required_skill_allowlist() { + let metadata = json!({ + "harness": { + "theme": "general", + "session_mode": "default", + "allow_model_skills": true, + "content_factory_skill_contract": { + "policy": "must_use_required_skills_before_final_patch", + "required_skills": [ + { "id": "article-writer", "skill": "article-writer", "required": true }, + { "id": "content-reviewer", "skill": "content-reviewer", "required": true }, + { "id": "draft-only", "skill": "draft-only", "required": false } + ] + } + } + }); + + assert_eq!( + resolve_agent_app_required_skill_tool_allowlist(Some(&metadata)), + Some(vec![ + "article-writer".to_string(), + "content-reviewer".to_string() + ]) + ); + assert!(should_enable_model_skill_tool(Some(&metadata))); + } + #[tokio::test] async fn enforce_runtime_turn_user_prompt_submit_hooks_should_allow_without_project_hooks() { let temp_dir = tempfile::TempDir::new().expect("create temp dir"); diff --git a/src-tauri/src/skills/default_skills.rs b/src-tauri/src/skills/default_skills.rs index f35ef8929..47f9b7824 100644 --- a/src-tauri/src/skills/default_skills.rs +++ b/src-tauri/src/skills/default_skills.rs @@ -10,6 +10,9 @@ use lime_core::models::{ ANALYSIS_SKILL_DIRECTORY, BRAND_PERSONA_KNOWLEDGE_BUILDER_SKILL_DIRECTORY, BRAND_PRODUCT_KNOWLEDGE_BUILDER_SKILL_DIRECTORY, BROADCAST_GENERATE_SKILL_DIRECTORY, CAMPAIGN_OPERATIONS_KNOWLEDGE_BUILDER_SKILL_DIRECTORY, + CONTENT_FACTORY_ARTICLE_WRITER_SKILL_DIRECTORY, + CONTENT_FACTORY_CONTENT_REVIEWER_SKILL_DIRECTORY, + CONTENT_FACTORY_KNOWLEDGE_BUILDER_SKILL_DIRECTORY, CONTENT_OPERATIONS_KNOWLEDGE_BUILDER_SKILL_DIRECTORY, CONTENT_POST_WITH_COVER_SKILL_DIRECTORY, COVER_GENERATE_SKILL_DIRECTORY, FORM_GENERATE_SKILL_DIRECTORY, GROWTH_STRATEGY_KNOWLEDGE_BUILDER_SKILL_DIRECTORY, IMAGE_GENERATE_SKILL_DIRECTORY, @@ -93,6 +96,15 @@ const CONTENT_POST_WITH_COVER_SKILL_CONTENT: &str = const CONTENT_POST_WITH_COVER_WORKFLOW_CONTENT: &str = include_str!("../../resources/default-skills/content_post_with_cover/references/workflow.json"); +const CONTENT_FACTORY_KNOWLEDGE_BUILDER_SKILL_CONTENT: &str = + include_str!("../../resources/default-skills/knowledge-builder/SKILL.md"); + +const CONTENT_FACTORY_ARTICLE_WRITER_SKILL_CONTENT: &str = + include_str!("../../resources/default-skills/article-writer/SKILL.md"); + +const CONTENT_FACTORY_CONTENT_REVIEWER_SKILL_CONTENT: &str = + include_str!("../../resources/default-skills/content-reviewer/SKILL.md"); + const KNOWLEDGE_BUILDER_SKILL_CONTENT: &str = include_str!("../../resources/default-skills/knowledge_builder/SKILL.md"); @@ -391,7 +403,7 @@ const GROWTH_STRATEGY_KNOWLEDGE_BUILDER_EXTRA_FILES: &[BundledSkillFile] = &[ }, ]; -fn default_skills() -> [BundledSkillDefinition; 30] { +fn default_skills() -> [BundledSkillDefinition; 33] { [ BundledSkillDefinition { directory: VIDEO_GENERATE_SKILL_DIRECTORY, @@ -493,6 +505,21 @@ fn default_skills() -> [BundledSkillDefinition; 30] { skill_content: CONTENT_POST_WITH_COVER_SKILL_CONTENT, extra_files: CONTENT_POST_WITH_COVER_EXTRA_FILES, }, + BundledSkillDefinition { + directory: CONTENT_FACTORY_KNOWLEDGE_BUILDER_SKILL_DIRECTORY, + skill_content: CONTENT_FACTORY_KNOWLEDGE_BUILDER_SKILL_CONTENT, + extra_files: &[], + }, + BundledSkillDefinition { + directory: CONTENT_FACTORY_ARTICLE_WRITER_SKILL_DIRECTORY, + skill_content: CONTENT_FACTORY_ARTICLE_WRITER_SKILL_CONTENT, + extra_files: &[], + }, + BundledSkillDefinition { + directory: CONTENT_FACTORY_CONTENT_REVIEWER_SKILL_DIRECTORY, + skill_content: CONTENT_FACTORY_CONTENT_REVIEWER_SKILL_CONTENT, + extra_files: &[], + }, BundledSkillDefinition { directory: KNOWLEDGE_BUILDER_SKILL_DIRECTORY, skill_content: KNOWLEDGE_BUILDER_SKILL_CONTENT, diff --git a/src/components/AppSidebar.test.tsx b/src/components/AppSidebar.test.tsx index db0073ae9..cc9893f95 100644 --- a/src/components/AppSidebar.test.tsx +++ b/src/components/AppSidebar.test.tsx @@ -573,6 +573,55 @@ describe("AppSidebar", () => { ).not.toBeNull(); }); + it("进入 Agent App 运行页时应临时折叠导航栏并在离开后恢复", async () => { + localStorage.setItem(APP_SIDEBAR_COLLAPSED_STORAGE_KEY, "false"); + const onNavigate = vi.fn(); + const mounted = mountSidebar({ + currentPage: "agent-app", + onNavigate, + }); + await flushEffects(2); + + expect( + mounted.container + .querySelector('[data-testid="app-sidebar"]') + ?.getAttribute("data-collapsed"), + ).toBe("true"); + expect(localStorage.getItem(APP_SIDEBAR_COLLAPSED_STORAGE_KEY)).toBe( + "false", + ); + + await act(async () => { + mounted.container + .querySelector('button[aria-label="展开导航栏"]') + ?.click(); + await Promise.resolve(); + }); + await flushEffects(2); + + expect( + mounted.container + .querySelector('[data-testid="app-sidebar"]') + ?.getAttribute("data-collapsed"), + ).toBe("false"); + expect(localStorage.getItem(APP_SIDEBAR_COLLAPSED_STORAGE_KEY)).toBe( + "false", + ); + + act(() => { + mounted.root.render( + , + ); + }); + await flushEffects(2); + + expect( + mounted.container + .querySelector('[data-testid="app-sidebar"]') + ?.getAttribute("data-collapsed"), + ).toBe("false"); + }); + it("默认应渲染一级主导航,并将系统入口收进用户弹框", async () => { const container = mountSidebarContainer({ currentPageParams: { diff --git a/src/components/AppSidebar.tsx b/src/components/AppSidebar.tsx index a67f19e98..a104da4fb 100644 --- a/src/components/AppSidebar.tsx +++ b/src/components/AppSidebar.tsx @@ -179,6 +179,7 @@ type SidebarNavItem = SidebarNavItemDefinition; const APP_SIDEBAR_COLLAPSED_STORAGE_KEY = "lime.app-sidebar.collapsed"; const APP_SIDEBAR_COLLAPSE_EVENT = "lime:app-sidebar-collapse"; +const AGENT_APP_RUNTIME_SIDEBAR_COLLAPSE_SOURCE = "agent-app-runtime"; const SIDEBAR_RECENT_SESSION_PAGE_SIZE = 10; const SIDEBAR_ARCHIVED_SESSION_PAGE_SIZE = 8; const SIDEBAR_SEARCH_RESULT_LIMIT = 8; @@ -2395,6 +2396,7 @@ export function AppSidebar({ ?.agentEntry; const activeAgentPageParams = activePageParams as AgentPageParams | undefined; const isAgentWorkspace = activePage === "agent"; + const isAgentAppRuntime = activePage === "agent-app"; const isClawTaskCenter = isAgentWorkspace && agentEntry === "claw"; const isNewTaskHome = activePage === "agent" && agentEntry === "new-task"; const [rememberedProjectId, setRememberedProjectId] = useState( @@ -2418,6 +2420,7 @@ export function AppSidebar({ }); const collapsedRef = useRef(collapsed); const collapseRestoreBySourceRef = useRef>({}); + const agentAppRuntimeSidebarManualOverrideRef = useRef(false); useEffect(() => { collapsedRef.current = collapsed; }, [collapsed]); @@ -3085,6 +3088,34 @@ export function AppSidebar({ setCollapsed(false); }, [isClawTaskCenter, isNewTaskHome]); + useEffect(() => { + const source = AGENT_APP_RUNTIME_SIDEBAR_COLLAPSE_SOURCE; + if (isAgentAppRuntime) { + if (!(source in collapseRestoreBySourceRef.current)) { + collapseRestoreBySourceRef.current[source] = collapsedRef.current; + agentAppRuntimeSidebarManualOverrideRef.current = false; + } + if (!agentAppRuntimeSidebarManualOverrideRef.current) { + setCollapsed(true); + } + return; + } + + agentAppRuntimeSidebarManualOverrideRef.current = false; + const previous = collapseRestoreBySourceRef.current[source]; + delete collapseRestoreBySourceRef.current[source]; + if (typeof previous === "boolean") { + setCollapsed(previous); + } + }, [isAgentAppRuntime]); + + const toggleSidebarCollapsed = useCallback(() => { + if (isAgentAppRuntime) { + agentAppRuntimeSidebarManualOverrideRef.current = true; + } + setCollapsed((value) => !value); + }, [isAgentAppRuntime]); + const shouldShowConversationList = !collapsed && !(activePage === "agent" && activeAgentPageParams?.immersiveHome); @@ -4735,6 +4766,7 @@ export function AppSidebar({ $themeMode={themeState.effectiveThemeMode} $reserveWindowControls={reserveWindowControls} data-testid="app-sidebar" + data-collapsed={String(collapsed)} data-lime-window-drag-region data-window-controls-reserved={String(reserveWindowControls)} onMouseDown={onStartWindowDrag} @@ -4785,7 +4817,7 @@ export function AppSidebar({ {maybeWrapWithTooltip( setCollapsed((value) => !value)} + onClick={toggleSidebarCollapsed} title={navigationToggleLabel} aria-label={navigationToggleLabel} > diff --git a/src/features/agent-app/adapters/adapterCapabilityProfile.ts b/src/features/agent-app/adapters/adapterCapabilityProfile.ts index 7a5461bed..5ba6275df 100644 --- a/src/features/agent-app/adapters/adapterCapabilityProfile.ts +++ b/src/features/agent-app/adapters/adapterCapabilityProfile.ts @@ -1,27 +1,8 @@ import { resolveAgentAppHostFlags } from "../featureFlag"; import { p0HostCapabilityProfile } from "../readiness/hostCapabilityProfile"; +import { buildLimeCapabilityProfileEntriesForMode } from "../sdk/capabilityCatalog"; import type { AgentAppHostFlags, HostCapabilityProfile } from "../types"; -const HYBRID_CAPABILITIES = [ - "lime.ui", - "lime.storage", - "lime.files", - "lime.artifacts", - "lime.evidence", - "lime.agent", - "lime.knowledge", - "lime.tools", - "lime.workflow", -] as const; - -const ADAPTER_CAPABILITIES = new Set([ - "lime.storage", - "lime.artifacts", - "lime.evidence", - "lime.agent", - "lime.knowledge", -]); - export function buildAdapterCapabilityProfile( flagOverrides: Partial = {}, ): HostCapabilityProfile { @@ -32,21 +13,10 @@ export function buildAdapterCapabilityProfile( localStorageEnabled: true, mockSdkEnabled: false, }); - const capabilities: HostCapabilityProfile["capabilities"] = { - ...p0HostCapabilityProfile.capabilities, - }; - - HYBRID_CAPABILITIES.forEach((capability) => { - capabilities[capability] = { - version: capabilities[capability]?.version ?? "0.3.0", - enabled: true, - implementation: ADAPTER_CAPABILITIES.has(capability) ? "adapter" : "mock", - }; - }); return { ...p0HostCapabilityProfile, - capabilities, + capabilities: buildLimeCapabilityProfileEntriesForMode("adapter"), featureFlags, }; } diff --git a/src/features/agent-app/index.ts b/src/features/agent-app/index.ts index 035cd7460..a8d0da655 100644 --- a/src/features/agent-app/index.ts +++ b/src/features/agent-app/index.ts @@ -113,7 +113,27 @@ export type { LimeCapabilityErrorContext, } from "./sdk/capabilityErrors"; export { + LIME_CAPABILITY_DEFINITIONS, + LIME_CAPABILITY_GROUPS, LIME_CAPABILITY_NAMES, + buildLimeCapabilityProfileEntries, + buildLimeCapabilityProfileEntriesForMode, + getLimeCapabilityAdapterKey, + getLimeCapabilityDefinition, + listEnabledLimeCapabilityNamesForMode, +} from "./sdk/capabilityCatalog"; +export type { + LimeCapabilityDefinition, + LimeCapabilityAdapterKey, + LimeCapabilityDefinitionRecord, + LimeCapabilityGroup, + LimeCapabilityMethodName, + LimeCapabilityOwner, + LimeCapabilityProfileEntry, + LimeCapabilityStage, +} from "./sdk/capabilityCatalog"; + +export { buildLimeCapabilityInvokeProvenance, buildLimeCapabilityInvokeRequest, createLimeCapabilityErrorResponse, @@ -146,13 +166,37 @@ export type { CreateLimeCoreCapabilityAdaptersOptions, LimeAgentCapabilityAdapter, LimeArtifactsCapabilityAdapter, + LimeAutomationCapabilityAdapter, + LimeBrowserCapabilityAdapter, + LimeCapabilitiesCapabilityAdapter, LimeCapabilityAdapterCallOptions, + LimeCapabilityAdapter, + LimeConnectorsCapabilityAdapter, + LimeContextCapabilityAdapter, LimeCoreCapabilityAdapters, + LimeDocumentsCapabilityAdapter, LimeEvidenceCapabilityAdapter, + LimeEventsCapabilityAdapter, + LimeFilesCapabilityAdapter, LimeKnowledgeCapabilityAdapter, + LimeMcpCapabilityAdapter, + LimeMediaCapabilityAdapter, + LimeMemoryCapabilityAdapter, + LimeModelsCapabilityAdapter, + LimePolicyCapabilityAdapter, + LimeReviewCapabilityAdapter, + LimeSearchCapabilityAdapter, + LimeSecretsCapabilityAdapter, + LimeSettingsCapabilityAdapter, + LimeSkillsCapabilityAdapter, LimeStorageCapabilityAdapter, + LimeTasksCapabilityAdapter, + LimeTerminalCapabilityAdapter, LimeToolsCapabilityAdapter, LimeUiCapabilityAdapter, + LimeUsageCapabilityAdapter, + LimeWorkflowCapabilityAdapter, + LimeWorkspaceCapabilityAdapter, } from "./sdk/capabilityAdapters"; export { LIME_AGENT_APP_BRIDGE_PROTOCOL, @@ -199,9 +243,7 @@ export type { AgentAppRuntimePackagePolicyEvidence, AgentAppUiBundleDescriptor, } from "./runtime/runtimePackageLoader"; -export { - evaluateAgentAppEntryRuntimeGuard, -} from "./runtime/entryRuntimeGuard"; +export { evaluateAgentAppEntryRuntimeGuard } from "./runtime/entryRuntimeGuard"; export type { AgentAppEntryRuntimeGuardIssue, AgentAppEntryRuntimeLifecycleState, diff --git a/src/features/agent-app/manifest/normalizeManifest.ts b/src/features/agent-app/manifest/normalizeManifest.ts index f209ba5e7..3e0881f2e 100644 --- a/src/features/agent-app/manifest/normalizeManifest.ts +++ b/src/features/agent-app/manifest/normalizeManifest.ts @@ -16,7 +16,18 @@ function slugifyAppId(value: string): string { return normalized || "agent-app"; } -function normalizeManifestVersion(version: string): "0.2" | "0.3" { +function normalizeManifestVersion( + version: string, +): "0.2" | "0.3" | "0.5" | "0.6" | "0.7" { + if (version.startsWith("0.7")) { + return "0.7"; + } + if (version.startsWith("0.6")) { + return "0.6"; + } + if (version.startsWith("0.5")) { + return "0.5"; + } if (version.startsWith("0.3")) { return "0.3"; } @@ -34,17 +45,24 @@ function normalizeCapabilities( } if (Array.isArray(capabilities)) { - return Object.fromEntries(capabilities.map((capability) => [capability, "*"])); + return Object.fromEntries( + capabilities.map((capability) => [capability, "*"]), + ); } return Object.fromEntries( - Object.entries(capabilities).filter(([capability]) => capability.trim().length > 0), + Object.entries(capabilities).filter( + ([capability]) => capability.trim().length > 0, + ), ); } function normalizeEntry(entry: AppEntry): NormalizedAppEntry { const requiredCapabilities = Array.from( - new Set([...(entry.requiredCapabilities ?? []), ...(entry.capabilities ?? [])]), + new Set([ + ...(entry.requiredCapabilities ?? []), + ...(entry.capabilities ?? []), + ]), ); return { @@ -61,12 +79,16 @@ function normalizeEntry(entry: AppEntry): NormalizedAppEntry { }; } -export function normalizeManifest(manifest: AppManifest): NormalizedAppManifest { +export function normalizeManifest( + manifest: AppManifest, +): NormalizedAppManifest { const appId = slugifyAppId(manifest.name); - const requiresCapabilities = normalizeCapabilities(manifest.requires?.capabilities); + const requiresCapabilities = normalizeCapabilities( + manifest.requires?.capabilities, + ); const topLevelCapabilities = Object.fromEntries( - Object.entries(normalizeCapabilities(manifest.capabilities)).filter(([capability]) => - capability.startsWith("lime."), + Object.entries(normalizeCapabilities(manifest.capabilities)).filter( + ([capability]) => capability.startsWith("lime."), ), ); const storageRetention = @@ -115,5 +137,10 @@ export function normalizeManifest(manifest: AppManifest): NormalizedAppManifest overlayTemplates: manifest.overlayTemplates ?? [], ui: manifest.ui, lifecycle: manifest.lifecycle ?? {}, + agentRuntime: manifest.agentRuntime, + requirements: manifest.requirements, + boundary: manifest.boundary, + integrations: manifest.integrations, + operations: manifest.operations, }; } diff --git a/src/features/agent-app/manifest/parseManifest.test.ts b/src/features/agent-app/manifest/parseManifest.test.ts index 65ff7bbd4..f1cadb588 100644 --- a/src/features/agent-app/manifest/parseManifest.test.ts +++ b/src/features/agent-app/manifest/parseManifest.test.ts @@ -1,7 +1,11 @@ import { describe, expect, it } from "vitest"; import contentFactoryFixture from "../fixtures/content-factory-app.json"; import { normalizeManifest } from "./normalizeManifest"; -import { AgentAppManifestError, parseManifest } from "./parseManifest"; +import { + AgentAppManifestError, + mergeLayeredManifest, + parseManifest, +} from "./parseManifest"; describe("Agent App manifest P0", () => { it("应解析并归一化 内容工厂 v0.3 fixture", () => { @@ -44,8 +48,9 @@ describe("Agent App manifest P0", () => { "creative_capability_search", ]); expect( - normalized.toolRefs.find((tool) => tool.key === "creative_capability_search") - ?.capabilities, + normalized.toolRefs.find( + (tool) => tool.key === "creative_capability_search", + )?.capabilities, ).toEqual([ "lime.capability.image.generate", "lime.capability.cover.generate", @@ -99,4 +104,322 @@ describe("Agent App manifest P0", () => { enabledByDefault: true, }); }); + + it("应把 v0.6 capability list 归一化为稳定 capability range", () => { + const normalized = normalizeManifest( + parseManifest({ + manifestVersion: "0.6.0", + name: "Content Factory App", + version: "0.6.0", + requires: { + sdk: "@lime/app-sdk@^0.6.0", + capabilities: ["lime.agent", "lime.skills", "lime.usage"], + }, + entries: [{ key: "home", kind: "page" }], + }), + ); + + expect(normalized.manifestVersion).toBe("0.6"); + expect(normalized.requires.sdk).toBe("@lime/app-sdk@^0.6.0"); + expect(normalized.requires.capabilities).toMatchObject({ + "lime.agent": "*", + "lime.skills": "*", + "lime.usage": "*", + }); + }); + + it("应按 v0.5 分层 manifest 文件合并发现面和治理配置", () => { + const manifest = mergeLayeredManifest( + { + manifestVersion: "0.5.0", + name: "content-factory-app", + version: "0.5.0", + entries: [{ key: "dashboard", kind: "page", title: "首页" }], + permissions: [{ key: "read_selected_files", reason: "读取文件" }], + }, + [ + { + entries: [ + { + key: "dashboard", + kind: "page", + title: "内容工厂首页", + route: "/dashboard", + }, + { key: "settings", kind: "settings", title: "设置" }, + ], + }, + { + permissions: [ + { + key: "read_selected_files", + scope: "filesystem", + required: true, + }, + { key: "invoke_agent_tasks", scope: "agent", required: true }, + ], + }, + { + capabilities: { + "lime.ui": { + routes: [{ path: "/dashboard" }], + }, + }, + errors: { + model_setup_required: { + severity: "setup", + }, + }, + i18n: { + defaultLocale: "zh-CN", + }, + signature: { + mode: "development", + }, + readiness: { + checks: [{ key: "manifest_layers", required: true }], + }, + health: { + probes: [{ key: "ui_entry", path: "/dashboard" }], + }, + }, + ], + ); + const normalized = normalizeManifest(manifest); + + expect(normalized.manifestVersion).toBe("0.5"); + expect(manifest.entries).toEqual([ + { + key: "dashboard", + kind: "page", + title: "内容工厂首页", + route: "/dashboard", + }, + { key: "settings", kind: "settings", title: "设置" }, + ]); + expect(manifest.permissions).toEqual([ + { + key: "read_selected_files", + reason: "读取文件", + scope: "filesystem", + required: true, + }, + { key: "invoke_agent_tasks", scope: "agent", required: true }, + ]); + expect( + (manifest as unknown as Record).capabilityConfig, + ).toEqual({ + "lime.ui": { + routes: [{ path: "/dashboard" }], + }, + }); + expect((manifest as unknown as Record).errors).toEqual({ + model_setup_required: { + severity: "setup", + }, + }); + expect((manifest as unknown as Record).i18n).toEqual({ + defaultLocale: "zh-CN", + }); + expect((manifest as unknown as Record).signature).toEqual({ + mode: "development", + }); + expect((manifest as unknown as Record).readiness).toEqual({ + checks: [{ key: "manifest_layers", required: true }], + }); + expect((manifest as unknown as Record).health).toEqual({ + probes: [{ key: "ui_entry", path: "/dashboard" }], + }); + expect( + (manifest as unknown as Record).agentRuntime, + ).toBeUndefined(); + }); + + it("应按 v0.6 分层 manifest 文件合并入口、权限和运行合同", () => { + const manifest = mergeLayeredManifest( + { + manifestVersion: "0.6.0", + name: "content-factory-app", + version: "0.6.0", + entries: [{ key: "dashboard", kind: "page", title: "首页" }], + permissions: [{ key: "read_selected_files", reason: "读取文件" }], + }, + [ + { + entries: [ + { + key: "dashboard", + kind: "page", + title: "内容工厂首页", + route: "/dashboard", + }, + { key: "settings", kind: "settings", title: "设置" }, + ], + }, + { + permissions: [ + { + key: "read_selected_files", + scope: "filesystem", + required: true, + }, + { key: "invoke_agent_tasks", scope: "agent", required: true }, + ], + }, + { + capabilities: { + "lime.ui": { + routes: [{ path: "/dashboard" }], + }, + }, + i18n: { + defaultLocale: "zh-CN", + }, + agentRuntime: { + agentTask: { + eventSchema: "lime.agent-task-event.v1", + }, + }, + }, + ], + ); + + expect(manifest.entries).toEqual([ + { + key: "dashboard", + kind: "page", + title: "内容工厂首页", + route: "/dashboard", + }, + { key: "settings", kind: "settings", title: "设置" }, + ]); + expect(manifest.permissions).toEqual([ + { + key: "read_selected_files", + reason: "读取文件", + scope: "filesystem", + required: true, + }, + { key: "invoke_agent_tasks", scope: "agent", required: true }, + ]); + expect( + (manifest as unknown as Record).capabilityConfig, + ).toEqual({ + "lime.ui": { + routes: [{ path: "/dashboard" }], + }, + }); + expect((manifest as unknown as Record).i18n).toEqual({ + defaultLocale: "zh-CN", + }); + expect( + (manifest as unknown as Record).agentRuntime, + ).toEqual({ + agentTask: { + eventSchema: "lime.agent-task-event.v1", + }, + }); + }); + + it("应按 v0.7 分层 manifest 文件合并需求边界和能力交接", () => { + const manifest = mergeLayeredManifest( + { + manifestVersion: "0.7.0", + name: "content-factory-app", + version: "0.7.0", + entries: [{ key: "dashboard", kind: "page", title: "首页" }], + }, + [ + { + requirements: { + requirements: [ + { + id: "CF-R001", + text: "生成可审核内容草稿", + priority: "mvp", + }, + ], + nonGoals: ["直接保存外部系统明文凭证"], + }, + }, + { + boundaries: [ + { + requirementId: "CF-R001", + planes: { + app: { owns: ["workflow_state"] }, + host: { requires: ["lime.agent", "lime.evidence"] }, + human: { owns: ["publish_decision"] }, + }, + }, + ], + }, + { + integrations: [ + { + key: "planning_table", + provider: "cloud.table", + executionPlane: "hybrid", + hostCapability: "lime.connectors", + }, + ], + }, + { + operations: [ + { + key: "write_external_draft", + type: "external_write", + sideEffect: "external_write", + approvalRequired: true, + dryRunRequired: true, + evidenceRequired: true, + autoExecute: false, + }, + ], + }, + ], + ); + const normalized = normalizeManifest(manifest); + + expect(normalized.manifestVersion).toBe("0.7"); + expect(normalized.requirements).toEqual({ + requirements: [ + { + id: "CF-R001", + text: "生成可审核内容草稿", + priority: "mvp", + }, + ], + nonGoals: ["直接保存外部系统明文凭证"], + }); + expect(normalized.boundary).toEqual([ + { + requirementId: "CF-R001", + planes: { + app: { owns: ["workflow_state"] }, + host: { requires: ["lime.agent", "lime.evidence"] }, + human: { owns: ["publish_decision"] }, + }, + }, + ]); + expect(normalized.integrations).toEqual([ + { + key: "planning_table", + provider: "cloud.table", + executionPlane: "hybrid", + hostCapability: "lime.connectors", + }, + ]); + expect(normalized.operations).toEqual([ + { + key: "write_external_draft", + type: "external_write", + sideEffect: "external_write", + approvalRequired: true, + dryRunRequired: true, + evidenceRequired: true, + autoExecute: false, + }, + ]); + }); }); diff --git a/src/features/agent-app/manifest/parseManifest.ts b/src/features/agent-app/manifest/parseManifest.ts index 91c86a3b1..80acd01b4 100644 --- a/src/features/agent-app/manifest/parseManifest.ts +++ b/src/features/agent-app/manifest/parseManifest.ts @@ -1,5 +1,15 @@ import type { AppManifest } from "../types"; +export interface ManifestValueLayerField { + source: string; + target: string; +} + +export interface MergeLayeredManifestOptions { + arrayFields?: readonly string[]; + valueFields?: readonly ManifestValueLayerField[]; +} + export class AgentAppManifestError extends Error { constructor(message: string) { super(message); @@ -7,6 +17,22 @@ export class AgentAppManifestError extends Error { } } +const DEFAULT_ARRAY_LAYER_FIELDS = ["entries", "permissions"] as const; +const DEFAULT_VALUE_LAYER_FIELDS: readonly ManifestValueLayerField[] = [ + { source: "capabilities", target: "capabilityConfig" }, + { source: "errors", target: "errors" }, + { source: "i18n", target: "i18n" }, + { source: "signature", target: "signature" }, + { source: "agentRuntime", target: "agentRuntime" }, + { source: "requirements", target: "requirements" }, + { source: "boundary", target: "boundary" }, + { source: "boundaries", target: "boundary" }, + { source: "integrations", target: "integrations" }, + { source: "operations", target: "operations" }, + { source: "readiness", target: "readiness" }, + { source: "health", target: "health" }, +]; + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } @@ -18,28 +44,109 @@ function isString(value: unknown): value is string { function assertString(record: Record, key: string): string { const value = record[key]; if (!isString(value)) { - throw new AgentAppManifestError(`Agent App manifest missing string field: ${key}`); + throw new AgentAppManifestError( + `Agent App manifest missing string field: ${key}`, + ); } return value; } -export function parseManifest(input: unknown): AppManifest { +function readManifestInput(input: unknown): Record { const raw = typeof input === "string" ? JSON.parse(input) : input; if (!isRecord(raw)) { throw new AgentAppManifestError("Agent App manifest must be an object"); } + return raw; +} + +function layeredItemKey(value: unknown): string | undefined { + if (!isRecord(value)) { + return undefined; + } + const key = value.key ?? value.id; + return isString(key) ? key : undefined; +} + +function mergeNamedArrayLayer( + current: unknown, + overlay: readonly unknown[], +): unknown[] { + const merged = Array.isArray(current) ? [...current] : []; + + overlay.forEach((overlayItem) => { + const overlayKey = layeredItemKey(overlayItem); + if (!overlayKey) { + merged.push(overlayItem); + return; + } + + const existingIndex = merged.findIndex( + (item) => layeredItemKey(item) === overlayKey, + ); + if (existingIndex === -1) { + merged.push(overlayItem); + return; + } + + const existingItem = merged[existingIndex]; + merged[existingIndex] = + isRecord(existingItem) && isRecord(overlayItem) + ? { ...existingItem, ...overlayItem } + : overlayItem; + }); + + return merged; +} + +export function mergeLayeredManifest( + input: unknown, + layers: readonly unknown[], + options: MergeLayeredManifestOptions = {}, +): AppManifest { + const manifest: Record = { ...readManifestInput(input) }; + const arrayFields = options.arrayFields ?? DEFAULT_ARRAY_LAYER_FIELDS; + const valueFields = options.valueFields ?? DEFAULT_VALUE_LAYER_FIELDS; + + layers.forEach((layer) => { + if (!isRecord(layer)) { + return; + } + + arrayFields.forEach((field) => { + const items = layer[field]; + if (Array.isArray(items)) { + manifest[field] = mergeNamedArrayLayer(manifest[field], items); + } + }); + + valueFields.forEach(({ source, target }) => { + if (layer[source] !== undefined) { + manifest[target] = layer[source]; + } + }); + }); + + return parseManifest(manifest); +} + +export function parseManifest(input: unknown): AppManifest { + const raw = readManifestInput(input); assertString(raw, "manifestVersion"); assertString(raw, "name"); assertString(raw, "version"); if (!Array.isArray(raw.entries) || raw.entries.length === 0) { - throw new AgentAppManifestError("Agent App manifest must declare at least one entry"); + throw new AgentAppManifestError( + "Agent App manifest must declare at least one entry", + ); } raw.entries.forEach((entry, index) => { if (!isRecord(entry)) { - throw new AgentAppManifestError(`Agent App entry ${index} must be an object`); + throw new AgentAppManifestError( + `Agent App entry ${index} must be an object`, + ); } assertString(entry, "key"); assertString(entry, "kind"); diff --git a/src/features/agent-app/readiness/checkReadiness.test.ts b/src/features/agent-app/readiness/checkReadiness.test.ts index 26cf03db6..ca8f3ae94 100644 --- a/src/features/agent-app/readiness/checkReadiness.test.ts +++ b/src/features/agent-app/readiness/checkReadiness.test.ts @@ -123,6 +123,39 @@ describe("Agent App readiness P0", () => { expect(readiness.blockers).toHaveLength(0); }); + it("应接受 v0.7 manifest 作为当前 Host 标准,不再按运行时版本阻断", () => { + const rawManifest = parseManifest({ + manifestVersion: "0.7.0", + name: "content-factory-app", + version: "0.7.0", + requires: { + sdk: "@lime/app-sdk@^0.7.0", + capabilities: ["lime.capabilities"], + }, + entries: [{ key: "dashboard", kind: "page" }], + requirements: { + requirements: [{ id: "CF-R001", text: "生成内容草稿" }], + }, + boundary: { + boundaries: [ + { + requirementId: "CF-R001", + planes: { host: { requires: ["lime.agent"] } }, + }, + ], + }, + }); + const manifest = normalizeManifest(rawManifest); + const identity = buildPackageIdentity({ manifest: rawManifest }); + const projection = projectApp({ manifest, identity }); + const readiness = checkReadiness({ manifest, projection }); + + expect(manifest.manifestVersion).toBe("0.7"); + expect(readiness.blockers.map((issue) => issue.code)).not.toContain( + "MANIFEST_VERSION_UNSUPPORTED", + ); + }); + it("package verification mismatch 应产生 blocker,不能只作为 warning 继续启用", () => { const { manifest, projection } = buildProjection(); const profile: HostCapabilityProfile = { diff --git a/src/features/agent-app/readiness/checkReadiness.ts b/src/features/agent-app/readiness/checkReadiness.ts index 2d87d851f..9277624de 100644 --- a/src/features/agent-app/readiness/checkReadiness.ts +++ b/src/features/agent-app/readiness/checkReadiness.ts @@ -13,24 +13,41 @@ import type { ReadinessResult, ReadinessStatus, } from "../types"; -import { p0HostCapabilityProfile } from "./hostCapabilityProfile"; +import { + compatibleAgentAppStandardVersions, + p0HostCapabilityProfile, +} from "./hostCapabilityProfile"; function supportsManifestRuntime(manifest: NormalizedAppManifest): boolean { - return manifest.manifestVersion === "0.2" || manifest.manifestVersion === "0.3"; + return ( + manifest.manifestVersion === "0.2" || + manifest.manifestVersion === "0.3" || + compatibleAgentAppStandardVersions.includes(manifest.manifestVersion) + ); } function isRuntimeTargetSupported( manifest: NormalizedAppManifest, profile: HostCapabilityProfile, ): boolean { - return manifest.runtimeTargets.some((target) => profile.runtimeTargets.includes(target)); + return manifest.runtimeTargets.some((target) => + profile.runtimeTargets.includes(target), + ); } function normalizeRange(range: string): string { - return range.trim() || "*"; + const normalized = range.trim(); + if (!normalized) { + return "*"; + } + const sdkRange = normalized.match(/^@lime\/app-sdk@(.+)$/); + return sdkRange?.[1] ?? normalized; } -function supportsRequestedRange(hostVersion: string, requestedRange: string): boolean { +function supportsRequestedRange( + hostVersion: string, + requestedRange: string, +): boolean { const normalized = normalizeRange(requestedRange); if (normalized === "*") { return true; @@ -40,16 +57,21 @@ function supportsRequestedRange(hostVersion: string, requestedRange: string): bo const hostMinor = hostVersion.match(/^(\d+)\.(\d+)/); return Boolean( requestedMinor && - hostMinor && - requestedMinor[1] === hostMinor[1] && - requestedMinor[2] === hostMinor[2], + hostMinor && + requestedMinor[1] === hostMinor[1] && + requestedMinor[2] === hostMinor[2], ); } if (normalized.includes(hostVersion)) { return true; } if (normalized.includes(">=0.3.0") && normalized.includes("<1.0.0")) { - return hostVersion.startsWith("0.3."); + return ( + hostVersion.startsWith("0.3.") || + hostVersion.startsWith("0.5.") || + hostVersion.startsWith("0.6.") || + hostVersion.startsWith("0.7.") + ); } if (normalized.includes(">=0.2.0") && normalized.includes("<1.0.0")) { return hostVersion.startsWith("0.2.") || hostVersion.startsWith("0.3."); @@ -91,7 +113,9 @@ function issueForCapability( ): ReadinessIssue | null { if (!support.supported) { return { - code: support.hostVersion ? "CAPABILITY_VERSION_UNSUPPORTED" : "CAPABILITY_MISSING", + code: support.hostVersion + ? "CAPABILITY_VERSION_UNSUPPORTED" + : "CAPABILITY_MISSING", severity: requirement.required ? "blocker" : "warning", message: `${requirement.capability} is not available for ${requirement.requestedRange}.`, capability: requirement.capability, @@ -125,7 +149,9 @@ function entryStatus(issues: ReadinessIssue[]): ReadinessStatus { return "ready"; } -function issueFromCloudTool(tool: CloudBootstrapToolAvailability): ReadinessIssue | null { +function issueFromCloudTool( + tool: CloudBootstrapToolAvailability, +): ReadinessIssue | null { if (tool.status === "available") { return null; } @@ -174,7 +200,10 @@ function collectCloudReadinessIssues(cloud?: CloudBootstrapApp): { severity: "blocker", message: `Cloud license state is ${cloud.licenseState}.`, }); - } else if (cloud.licenseState === "trial" || cloud.licenseState === "unknown") { + } else if ( + cloud.licenseState === "trial" || + cloud.licenseState === "unknown" + ) { warnings.push({ code: "CLOUD_LICENSE_UNAVAILABLE", severity: "warning", @@ -198,7 +227,8 @@ function collectCloudReadinessIssues(cloud?: CloudBootstrapApp): { blockers.push({ code: "CLOUD_POLICY_UNSUPPORTED", severity: "blocker", - message: "Cloud policy defaults cannot enable server-assisted runtime locally.", + message: + "Cloud policy defaults cannot enable server-assisted runtime locally.", }); } @@ -264,7 +294,10 @@ function collectProjectionSetupIssues( blockers: ReadinessIssue[]; warnings: ReadinessIssue[]; } { - const result = { blockers: [] as ReadinessIssue[], warnings: [] as ReadinessIssue[] }; + const result = { + blockers: [] as ReadinessIssue[], + warnings: [] as ReadinessIssue[], + }; projection.knowledgeBindings.forEach((binding) => { pushSetupIssueWhenMissing(result, { @@ -377,7 +410,10 @@ function collectProjectionSetupIssues( return result; } -function cloudEntryIssue(entryKey: string, cloud?: CloudBootstrapApp): ReadinessIssue | null { +function cloudEntryIssue( + entryKey: string, + cloud?: CloudBootstrapApp, +): ReadinessIssue | null { if (!cloud) { return null; } @@ -389,7 +425,10 @@ function cloudEntryIssue(entryKey: string, cloud?: CloudBootstrapApp): Readiness entryKey, }; } - if (cloud.defaultEntries.length > 0 && !cloud.defaultEntries.includes(entryKey)) { + if ( + cloud.defaultEntries.length > 0 && + !cloud.defaultEntries.includes(entryKey) + ) { return { code: "CLOUD_ENTRY_NOT_ENABLED", severity: "warning", @@ -433,7 +472,10 @@ export function checkReadiness(params: { const blockers: ReadinessIssue[] = []; const warnings: ReadinessIssue[] = []; const cloudIssues = collectCloudReadinessIssues(params.cloud); - const setupIssues = collectProjectionSetupIssues(params.projection, params.setup); + const setupIssues = collectProjectionSetupIssues( + params.projection, + params.setup, + ); blockers.push(...cloudIssues.blockers); warnings.push(...cloudIssues.warnings); blockers.push(...setupIssues.blockers); @@ -464,11 +506,15 @@ export function checkReadiness(params: { warnings.push({ code: "STORAGE_DECLARED_BUT_DISABLED", severity: "warning", - message: "Storage namespace is declared, but P0 does not create local App storage.", + message: + "Storage namespace is declared, but P0 does not create local App storage.", }); } - if (params.projection.runtimePackage.hasUiBundle && !profile.featureFlags.uiRuntimeEnabled) { + if ( + params.projection.runtimePackage.hasUiBundle && + !profile.featureFlags.uiRuntimeEnabled + ) { warnings.push({ code: "UI_RUNTIME_DISABLED", severity: "warning", @@ -483,16 +529,20 @@ export function checkReadiness(params: { warnings.push({ code: "WORKER_RUNTIME_DISABLED", severity: "warning", - message: "Worker bundle is declared, but P0 keeps worker runtime disabled.", + message: + "Worker bundle is declared, but P0 keeps worker runtime disabled.", }); } - const capabilitySupports = params.projection.requiredCapabilities.map((requirement) => - capabilitySupport(requirement, profile), + const capabilitySupports = params.projection.requiredCapabilities.map( + (requirement) => capabilitySupport(requirement, profile), ); capabilitySupports.forEach((support, index) => { - const issue = issueForCapability(params.projection.requiredCapabilities[index], support); + const issue = issueForCapability( + params.projection.requiredCapabilities[index], + support, + ); if (!issue) { return; } @@ -503,21 +553,28 @@ export function checkReadiness(params: { } }); - const entryReadiness: EntryReadiness[] = params.projection.entries.map((entry) => { - const entryIssues = entry.requiredCapabilities - .map((requirement) => issueForCapability(requirement, capabilitySupport(requirement, profile))) - .filter((issue): issue is ReadinessIssue => Boolean(issue)); - const cloudIssue = cloudEntryIssue(entry.key, params.cloud); - if (cloudIssue) { - entryIssues.push(cloudIssue); - } + const entryReadiness: EntryReadiness[] = params.projection.entries.map( + (entry) => { + const entryIssues = entry.requiredCapabilities + .map((requirement) => + issueForCapability( + requirement, + capabilitySupport(requirement, profile), + ), + ) + .filter((issue): issue is ReadinessIssue => Boolean(issue)); + const cloudIssue = cloudEntryIssue(entry.key, params.cloud); + if (cloudIssue) { + entryIssues.push(cloudIssue); + } - return { - entryKey: entry.key, - status: entryStatus(entryIssues), - issues: entryIssues, - }; - }); + return { + entryKey: entry.key, + status: entryStatus(entryIssues), + issues: entryIssues, + }; + }, + ); const status: ReadinessStatus = blockers.length ? "blocked" @@ -534,10 +591,12 @@ export function checkReadiness(params: { blockers, warnings, supportedCapabilities: capabilitySupports, - missingCapabilities: params.projection.requiredCapabilities.filter((requirement, index) => { - const support = capabilitySupports[index]; - return !support.supported || !support.enabled; - }), + missingCapabilities: params.projection.requiredCapabilities.filter( + (requirement, index) => { + const support = capabilitySupports[index]; + return !support.supported || !support.enabled; + }, + ), entryReadiness, }; } diff --git a/src/features/agent-app/readiness/hostCapabilityProfile.ts b/src/features/agent-app/readiness/hostCapabilityProfile.ts index f7d7d56db..ea305501f 100644 --- a/src/features/agent-app/readiness/hostCapabilityProfile.ts +++ b/src/features/agent-app/readiness/hostCapabilityProfile.ts @@ -1,19 +1,18 @@ import { defaultAgentAppHostFlags } from "../featureFlag"; import type { HostCapabilityProfile } from "../types"; +import { buildLimeCapabilityProfileEntriesForMode } from "../sdk/capabilityCatalog"; + +export const currentAgentAppHostRuntimeVersion = "0.7.0"; +export const currentAgentAppStandardVersion = "0.7"; +export const compatibleAgentAppStandardVersions = ["0.5", "0.6", "0.7"]; export const p0HostCapabilityProfile: HostCapabilityProfile = { - appRuntimeVersion: "0.3.0", - runtimeTargets: ["local"], - capabilities: { - "lime.ui": { version: "0.3.0", enabled: false, implementation: "none" }, - "lime.storage": { version: "0.3.0", enabled: false, implementation: "none" }, - "lime.files": { version: "0.3.0", enabled: false, implementation: "none" }, - "lime.agent": { version: "0.3.0", enabled: false, implementation: "none" }, - "lime.knowledge": { version: "0.3.0", enabled: false, implementation: "none" }, - "lime.tools": { version: "0.3.0", enabled: false, implementation: "none" }, - "lime.artifacts": { version: "0.3.0", enabled: false, implementation: "none" }, - "lime.evidence": { version: "0.3.0", enabled: false, implementation: "none" }, - "lime.workflow": { version: "0.3.0", enabled: false, implementation: "none" }, + appRuntimeVersion: currentAgentAppHostRuntimeVersion, + standardVersions: { + current: currentAgentAppStandardVersion, + compatible: [...compatibleAgentAppStandardVersions], }, + runtimeTargets: ["local"], + capabilities: buildLimeCapabilityProfileEntriesForMode("base"), featureFlags: defaultAgentAppHostFlags, }; diff --git a/src/features/agent-app/runtime/agentRuntimeCapabilityHost.test.ts b/src/features/agent-app/runtime/agentRuntimeCapabilityHost.test.ts index f4ce72b2d..e2cac5aa4 100644 --- a/src/features/agent-app/runtime/agentRuntimeCapabilityHost.test.ts +++ b/src/features/agent-app/runtime/agentRuntimeCapabilityHost.test.ts @@ -123,6 +123,9 @@ describe("AgentRuntimeCapabilityHost", () => { runStartHooks: false, }); const snapshot = await sdk.agent.getTask(started.taskId); + const listedAfterSnapshot = host + .getTasks({ appId: "content-factory-app" }) + .find((task) => task.taskId === started.taskId); const stream = await sdk.agent.streamTask(started.taskId); const hostResponse = await sdk.agent.submitHostResponse({ taskId: started.taskId, @@ -184,6 +187,22 @@ describe("AgentRuntimeCapabilityHost", () => { }), ]), }); + expect(listedAfterSnapshot?.events).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "evidence:recorded", + refs: ["evidence-1"], + }), + expect.objectContaining({ + type: "artifact:created", + refs: [".lime/artifacts/content-batch.json"], + }), + ]), + ); + expect(listedAfterSnapshot?.runtimeProcess?.usage).toMatchObject({ + estimated: true, + source: "agent_app_runtime_process_estimate", + }); expect(stream).toEqual( expect.arrayContaining([ expect.objectContaining({ type: "task:missingContextRequested" }), @@ -374,6 +393,171 @@ describe("AgentRuntimeCapabilityHost", () => { }); }); + it("在主 App 侧封装 Claw 式运行过程,包含模型、Token、费用和 Skill", async () => { + const api = { + startTask: vi.fn(async (request) => ({ + appId: request.appId, + entryKey: request.entryKey, + taskId: "agent-app-task-process", + traceId: "agent-app-trace-process", + taskKind: request.taskKind, + sessionId: "session-process", + turnId: "turn-process", + eventName: `agent_app_runtime:${request.appId}:agent-app-task-process`, + status: "accepted" as const, + submittedAt: "2026-05-15T00:00:00.000Z", + })), + getTask: vi.fn(async (request) => ({ + appId: request.appId, + taskId: request.taskId, + sessionId: request.sessionId, + status: "thread_read_available" as const, + taskStatus: "completed", + taskEvents: [ + { + id: "runtime:routing:decision", + eventType: "task:progress", + status: "routing", + message: "模型路由已确定:openai/gpt-4.1", + payload: { + runtimeEvent: { + type: "routing_decision_made", + routing_decision: { + candidate_count: 2, + selected_provider: "openai", + selected_model: "gpt-4.1", + }, + }, + }, + }, + { + id: "runtime:thinking", + eventType: "task:progress", + status: "thinking", + message: "先分析内容目标", + payload: { + streamKind: "thinking_delta", + delta: "先分析内容目标", + runtimeEvent: { type: "thinking_delta", text: "先分析内容目标" }, + }, + }, + { + id: "runtime:text", + eventType: "task:partialArtifact", + status: "streaming", + message: "第一段输出", + payload: { + streamKind: "assistant_text_delta", + delta: "第一段输出", + runtimeEvent: { type: "text_delta", text: "第一段输出" }, + }, + }, + { + id: "runtime:skill", + eventType: "task:toolCall", + status: "completed", + message: "工具 Skill completed", + toolName: "Skill", + payload: { + runtimeEvent: { + type: "tool_end", + result: { output: "完成", metadata: { command_name: "knowledge-builder" } }, + }, + }, + }, + { + id: "runtime:cost", + eventType: "task:runtimeEvent", + status: "recorded", + message: "消耗已记录", + payload: { + runtimeEvent: { + type: "cost_recorded", + cost_state: { + estimated_total_cost: 0.0032, + currency: "USD", + }, + }, + }, + }, + { + id: "runtime:done", + eventType: "task:completed", + status: "completed", + message: "AgentRuntime 本轮输出已结束", + payload: { + runtimeEvent: { + type: "final_done", + usage: { input_tokens: 1200, output_tokens: 340 }, + }, + }, + }, + ], + threadRead: { + session_id: request.sessionId, + profile_status: "completed", + }, + })), + cancelTask: vi.fn(async (request) => ({ + appId: request.appId, + taskId: request.taskId, + sessionId: request.sessionId, + cancelled: true, + status: "cancelled" as const, + })), + submitHostResponse: vi.fn(async (request) => ({ + appId: request.appId, + taskId: request.taskId, + status: "submitted" as const, + })), + }; + const host = new AgentRuntimeCapabilityHost({ + delegate: buildDelegateHost(), + appId: "content-factory-app", + appVersion: "0.3.0", + packageHash: "package-hash-1", + manifestHash: "manifest-hash-1", + workspaceIdResolver: async () => "workspace-1", + api, + now: () => "2026-05-15T00:00:04.000Z", + }); + const sdk = host.createSdkContext("dashboard"); + const started = await sdk.agent.startTask({ + title: "生成内容批次", + taskKind: "content.copy.generate", + input: { projectId: "project-1" }, + expectedOutput: { + artifactKind: "content_batch", + requiredSkills: [ + { skill: "knowledge-builder", required: true }, + { skill: "content-reviewer", required: true }, + ], + }, + }); + + const snapshot = await sdk.agent.getTask(started.taskId); + + expect(snapshot?.runtimeProcess).toMatchObject({ + terminal: true, + collapsedByDefault: true, + model: { provider: "openai", model: "gpt-4.1", label: "openai/gpt-4.1" }, + usage: { inputTokens: 1200, outputTokens: 340, totalTokens: 1540 }, + cost: { estimatedTotalCost: 0.0032, currency: "USD" }, + skillNames: expect.arrayContaining(["knowledge-builder", "content-reviewer"]), + invokedSkillNames: ["knowledge-builder"], + streamText: "第一段输出", + thinkingText: "先分析内容目标", + }); + expect(snapshot?.process).toBe(snapshot?.runtimeProcess); + expect(snapshot?.runtimeProcess?.timeline).toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: "routing", title: "模型路由" }), + expect.objectContaining({ kind: "skill", title: "Skill · knowledge-builder" }), + expect.objectContaining({ kind: "metrics", title: "消耗统计" }), + ]), + ); + }); + it("从 threadRead artifacts 补投 artifact:created payload,保证 Host Bridge 可 replay 最终产物", async () => { const api = { startTask: vi.fn(async (request) => ({ @@ -471,6 +655,13 @@ describe("AgentRuntimeCapabilityHost", () => { }), }), }), + expect.objectContaining({ + type: "evidence:recorded", + refs: ["evidence:.lime/artifacts/content-batch.json"], + payload: expect.objectContaining({ + source: "agent_runtime_artifact_replay", + }), + }), ]), ); }); diff --git a/src/features/agent-app/runtime/agentRuntimeCapabilityHost.ts b/src/features/agent-app/runtime/agentRuntimeCapabilityHost.ts index 8a34cea6f..9fd0cb1f4 100644 --- a/src/features/agent-app/runtime/agentRuntimeCapabilityHost.ts +++ b/src/features/agent-app/runtime/agentRuntimeCapabilityHost.ts @@ -36,6 +36,7 @@ import type { AgentAppUninstallResult, AppCleanupPlan, } from "../types"; +import { buildAgentRuntimeProcessView } from "./agentRuntimeProcess"; export interface AgentAppRuntimeCapabilityApi { startTask( @@ -80,6 +81,7 @@ interface RuntimeTaskState { request: AgentAppTaskRequest; retryOfTaskId?: string; retryAttempt?: number; + latestSnapshot?: AgentAppRuntimeTaskSnapshot; } const RUNTIME_TASK_STORAGE_PREFIX = "agent-runtime/tasks/"; @@ -254,6 +256,96 @@ function readThreadReadArtifacts(threadRead: unknown): Record[] return threadRead.artifacts.filter(isRecord); } +function parseJsonObjectFromMarkdown(value: string): Record | null { + const trimmed = value.trim(); + const candidate = trimmed.startsWith("```") + ? trimmed + .split("\n") + .slice(1) + .join("\n") + .replace(/```\s*$/m, "") + .trim() + : trimmed; + try { + const parsed = JSON.parse(candidate); + return isRecord(parsed) ? parsed : null; + } catch { + const start = candidate.indexOf("{"); + const end = candidate.lastIndexOf("}"); + if (start < 0 || end <= start) { + return null; + } + try { + const parsed = JSON.parse(candidate.slice(start, end + 1)); + return isRecord(parsed) ? parsed : null; + } catch { + return null; + } + } +} + +function hasContentFactoryWorkspacePatchFields(value: Record): boolean { + return [ + "workspace", + "project", + "sceneTable", + "contentBatch", + "scripts", + "imagePrompts", + "assetPack", + "projectKnowledge", + ].some((key) => isRecord(value[key]) || Array.isArray(value[key])); +} + +function extractContentFactoryWorkspacePatch( + value: unknown, +): Record | undefined { + if (!isRecord(value)) { + return undefined; + } + for (const key of ["contentFactoryWorkspacePatch", "workspacePatch"]) { + const patch = value[key]; + if (isRecord(patch)) { + return patch; + } + } + const kind = readRecordString(value, "kind") ?? readRecordString(value, "artifactKind"); + if ( + kind === "content_factory.workspace_patch" || + kind === "contentFactoryWorkspacePatch" || + kind === "workspacePatch" || + hasContentFactoryWorkspacePatchFields(value) + ) { + return value; + } + return undefined; +} + +function extractWorkspacePatchFromArtifactDocument( + artifactDocument: unknown, +): Record | undefined { + if (!isRecord(artifactDocument) || !Array.isArray(artifactDocument.blocks)) { + return undefined; + } + for (const block of artifactDocument.blocks) { + if (!isRecord(block)) { + continue; + } + const content = + typeof block.content === "string" + ? block.content + : typeof block.markdown === "string" + ? block.markdown + : ""; + const parsed = content ? parseJsonObjectFromMarkdown(content) : null; + const patch = extractContentFactoryWorkspacePatch(parsed); + if (patch) { + return patch; + } + } + return undefined; +} + function buildRuntimeArtifactReplayEvents( state: RuntimeTaskState, threadRead: unknown, @@ -283,29 +375,50 @@ function buildRuntimeArtifactReplayEvents( const workspacePatch = isRecord(metadata.workspacePatch) || isRecord(metadata.contentFactoryWorkspacePatch) ? metadata.workspacePatch ?? metadata.contentFactoryWorkspacePatch - : undefined; + : extractWorkspacePatchFromArtifactDocument(artifactDocument); + const at = + readRecordString(artifact, "completed_at") ?? + readRecordString(artifact, "updated_at") ?? + readRecordString(artifact, "created_at") ?? + state.startedAt; + const artifactEvent: AgentAppTaskStreamEvent = { + eventId: `${state.taskId}:artifact:${artifactRef}`, + taskId: state.taskId, + traceId: state.traceId, + type: "artifact:created", + status: "succeeded", + at, + message: + readRecordString(artifact, "title") ?? + readRecordString(artifact, "artifact_type") ?? + "Artifact 已创建", + refs: [artifactRef], + payload: { + artifact, + artifactDocument, + workspacePatch, + contentFactoryWorkspacePatch: workspacePatch, + }, + }; + if (!workspacePatch) { + return [artifactEvent]; + } return [ + artifactEvent, { - eventId: `${state.taskId}:artifact:${artifactRef}`, + eventId: `${state.taskId}:evidence:${artifactRef}`, taskId: state.taskId, traceId: state.traceId, - type: "artifact:created" as const, - status: "succeeded" as const, - at: - readRecordString(artifact, "completed_at") ?? - readRecordString(artifact, "updated_at") ?? - readRecordString(artifact, "created_at") ?? - state.startedAt, - message: - readRecordString(artifact, "title") ?? - readRecordString(artifact, "artifact_type") ?? - "Artifact 已创建", - refs: [artifactRef], + type: "evidence:recorded", + status: "succeeded", + at, + message: "内容工厂 workspace patch evidence 已记录", + refs: [`evidence:${artifactRef}`], payload: { - artifact, - artifactDocument, + artifactRef, workspacePatch, contentFactoryWorkspacePatch: workspacePatch, + source: "agent_runtime_artifact_replay", }, }, ]; @@ -316,28 +429,41 @@ function buildTaskRecord( state: RuntimeTaskState, snapshot?: AgentAppRuntimeTaskSnapshot, ): AgentAppTaskRecord { - const runtimeEvents = snapshot?.taskEvents.length - ? snapshot.taskEvents.map((event, index) => + const effectiveSnapshot = snapshot ?? state.latestSnapshot; + const runtimeEvents = effectiveSnapshot?.taskEvents.length + ? effectiveSnapshot.taskEvents.map((event, index) => mapRuntimeEvent(state, event, index), ) : [buildStartEvent(state)]; - const events = snapshot + const events = effectiveSnapshot ? [ ...runtimeEvents, ...buildRuntimeArtifactReplayEvents( state, - snapshot.threadRead, + effectiveSnapshot.threadRead, runtimeEvents, ), ] : runtimeEvents; - const status = snapshot - ? mapRuntimeTaskStatus(snapshot.taskStatus) + const status = effectiveSnapshot + ? mapRuntimeTaskStatus(effectiveSnapshot.taskStatus) : "running"; const finishedAt = status === "succeeded" || status === "failed" || status === "cancelled" ? events[events.length - 1]?.at : undefined; + const runtimeProcess = buildAgentRuntimeProcessView({ + events, + task: { + status, + taskStatus: effectiveSnapshot?.taskStatus, + input: state.request.input, + expectedOutput: state.request.expectedOutput, + }, + snapshot: effectiveSnapshot, + expectedOutput: state.request.expectedOutput, + lastInput: state.request.input, + }); return { taskId: state.taskId, @@ -366,7 +492,7 @@ function buildTaskRecord( startedAt: state.startedAt, finishedAt, cancelledAt: status === "cancelled" ? finishedAt : undefined, - result: snapshot?.threadRead, + result: effectiveSnapshot?.threadRead, trace: events .filter((event) => event.message) .map((event) => ({ @@ -374,6 +500,8 @@ function buildTaskRecord( message: event.message ?? "", })), events, + runtimeProcess, + process: runtimeProcess, provenance: { sourceKind: "agent_app", appId: state.appId, @@ -583,6 +711,8 @@ export class AgentRuntimeCapabilityHost implements CapabilityHost { taskId: state.taskId, sessionId: state.sessionId, }); + state.latestSnapshot = snapshot; + this.tasks.set(state.taskId, state); return buildTaskRecord(state, snapshot); } diff --git a/src/features/agent-app/runtime/agentRuntimeProcess.test.ts b/src/features/agent-app/runtime/agentRuntimeProcess.test.ts new file mode 100644 index 000000000..2c8c05872 --- /dev/null +++ b/src/features/agent-app/runtime/agentRuntimeProcess.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from "vitest"; + +import { buildAgentRuntimeProcessView } from "./agentRuntimeProcess"; + +describe("buildAgentRuntimeProcessView", () => { + it("保留完整运行过程,不在数据层截断到最后 16 条", () => { + const events = Array.from({ length: 20 }, (_, index) => ({ + eventType: "task:progress", + message: `步骤${index + 1}`, + })); + + const process = buildAgentRuntimeProcessView({ events }); + + expect(process.timeline).toHaveLength(20); + expect(process.timeline[0]).toMatchObject({ message: "步骤1" }); + expect(process.timeline.at(-1)).toMatchObject({ message: "步骤20" }); + }); + + it("保留每段流式输出过程,折叠只交给 UI 层处理", () => { + const events = ["第一段", "第二段", "第三段"].map((delta) => ({ + eventType: "task:partialArtifact", + status: "streaming", + message: delta, + payload: { + streamKind: "assistant_text_delta", + delta, + runtimeEvent: { type: "text_delta", text: delta }, + }, + })); + + const process = buildAgentRuntimeProcessView({ events }); + + expect(process.streamText).toBe("第一段第二段第三段"); + expect(process.timeline.map((item) => item.message)).toEqual([ + "第一段", + "第二段", + "第三段", + ]); + }); + + it("从 Skill 工具参数对象中识别真实调用的业务 Skill", () => { + const process = buildAgentRuntimeProcessView({ + events: [ + { + eventType: "task:toolCall", + status: "completed", + message: "工具 Skill completed", + toolName: "Skill", + payload: { + runtimeEvent: { + type: "tool_start", + toolName: "Skill", + arguments: { + skill: "knowledge-builder", + args: { projectId: "project-1" }, + }, + }, + }, + }, + ], + }); + + expect(process.invokedSkillNames).toEqual(["knowledge-builder"]); + expect(process.timeline[0]).toMatchObject({ + kind: "skill", + title: "Skill · knowledge-builder", + }); + }); + + it("从 threadRead.model_routing 和 artifact 事件投影完成态运行事实", () => { + const process = buildAgentRuntimeProcessView({ + task: { + taskStatus: "completed", + input: { + projectId: "project-1", + material: "这是一段用于内容工厂知识库整理的项目资料。", + }, + }, + snapshot: { + taskStatus: "completed", + threadRead: { + model_routing: { + selectedProvider: "deepseek", + selectedModel: "deepseek-v4-flash", + }, + cost_state: { + estimatedCostClass: "medium", + status: "estimated", + }, + }, + }, + events: [ + { + eventType: "task:toolCall", + status: "completed", + message: "工具 Skill completed", + toolName: "Skill", + payload: { + tool_name: "Skill", + }, + }, + { + eventType: "artifact:created", + status: "created", + message: "内容工厂 workspace patch 已创建", + payload: { + contentFactoryWorkspacePatch: { + kind: "content_factory.workspace_patch", + projectId: "project-1", + }, + }, + }, + ], + }); + + expect(process.model).toMatchObject({ + provider: "deepseek", + model: "deepseek-v4-flash", + label: "deepseek/deepseek-v4-flash", + }); + expect(process.usage).toMatchObject({ + estimated: true, + source: "agent_app_runtime_process_estimate", + }); + expect(process.cost).toMatchObject({ + estimatedCostClass: "medium", + status: "estimated", + }); + expect(process.artifactCount).toBe(1); + expect(process.terminal).toBe(true); + }); +}); diff --git a/src/features/agent-app/runtime/agentRuntimeProcess.ts b/src/features/agent-app/runtime/agentRuntimeProcess.ts new file mode 100644 index 000000000..5df84f507 --- /dev/null +++ b/src/features/agent-app/runtime/agentRuntimeProcess.ts @@ -0,0 +1,1042 @@ +import type { + AgentAppRuntimeProcessCost, + AgentAppRuntimeProcessModel, + AgentAppRuntimeProcessTimelineItem, + AgentAppRuntimeProcessUsage, + AgentAppRuntimeProcessView, +} from "../types"; + +export interface BuildAgentRuntimeProcessViewOptions { + events?: unknown[]; + task?: unknown; + snapshot?: unknown; + expectedOutput?: unknown; + lastInput?: unknown; + contract?: unknown; +} + +export function buildAgentRuntimeProcessView({ + events = [], + task = null, + snapshot = null, + expectedOutput = null, + lastInput = null, + contract = null, +}: BuildAgentRuntimeProcessViewOptions = {}): AgentAppRuntimeProcessView { + const safeEvents = Array.isArray(events) ? events : []; + const timeline = buildTimelineItems(safeEvents); + const invokedSkillNames = uniqueTextValues( + safeEvents.map(extractSkillNameFromEvent), + ); + const taskRecord = isRecord(task) ? task : null; + const resolvedExpectedOutput = + expectedOutput ?? (taskRecord ? taskRecord.expectedOutput : undefined); + const resolvedLastInput = lastInput ?? (taskRecord ? taskRecord.input : undefined); + const skillNames = uniqueTextValues([ + ...collectRequiredSkillNames(task), + ...collectRequiredSkillNames(resolvedLastInput), + ...collectRequiredSkillNames(resolvedExpectedOutput), + ...invokedSkillNames, + ]); + const usage = extractUsageFromProcess(safeEvents, snapshot, task); + const cost = extractCostFromProcess(safeEvents, snapshot, task); + const model = extractModelFromProcess(safeEvents, task, snapshot); + const terminal = isTerminalProcess({ + task, + snapshot, + events: safeEvents, + contract, + }); + + return { + timeline, + streamText: collectStream(safeEvents, "assistant_text"), + thinkingText: collectStream(safeEvents, "thinking"), + executionText: collectExecutionStream(safeEvents), + skillNames, + invokedSkillNames, + model, + usage, + cost, + terminal, + collapsedByDefault: terminal, + routingCount: timeline.filter((item) => item.kind === "routing").length, + executionCount: timeline.filter((item) => + ["skill", "tool", "execution"].includes(item.kind), + ).length, + artifactCount: timeline.filter((item) => item.kind === "artifact").length, + }; +} + +export function normalizeAgentRuntimeProcessTimelineItem( + event: unknown, +): AgentAppRuntimeProcessTimelineItem | null { + if (!isRecord(event)) { + return null; + } + + const stream = readStreamEvent(event); + if (stream?.text) { + const config = + { + thinking: ["thinking", "思考过程", "流式思考"], + assistant_text: ["output", "成稿流式输出", "生成中"], + tool_input: ["execution", "执行参数流", "输入中"], + tool_output: ["execution", "执行结果流", "输出中"], + }[stream.kind] ?? ["progress", "运行片段", "更新中"]; + return { + kind: config[0] as AgentAppRuntimeProcessTimelineItem["kind"], + title: stream.toolName ? `${config[1]} · ${stream.toolName}` : config[1], + statusText: config[2], + message: clipText(stream.text, 140), + detail: "", + meta: stream.streamKind ?? "", + collapseKey: `stream:${stream.kind}:${stream.toolName || "main"}`, + }; + } + + const runtimeEvent = readRuntimeEvent(event); + const runtimeType = readRuntimeEventType(event, runtimeEvent); + const eventType = String( + event.eventType ?? event.type ?? runtimeType ?? "", + ).toLowerCase(); + const message = String( + event.message ?? runtimeType ?? event.eventType ?? "任务进度已更新", + ).trim(); + const statusText = statusTextFor( + readString(event, "status") || readString(recordValue(event, "payload"), "status"), + ); + const toolName = extractToolNameFromEvent(event); + const skillName = extractSkillNameFromEvent(event); + const detail = extractEventDetail(event); + const runtimeSurface = `${eventType} ${runtimeType} ${message}`; + + if (message === "任务状态:idle") { + return { + kind: "progress", + title: "接收任务", + statusText: "已接收", + message: "AI 同事已接收任务,等待运行进度回写。", + detail, + }; + } + if (/routing|candidate|model_change/.test(runtimeType) || /routing/.test(message)) { + return { + kind: "routing", + title: "模型路由", + statusText: "已决策", + message: routingLabel(runtimeEvent) || message, + detail, + collapseKey: "runtime:routing", + }; + } + if (/cost_estimated|cost_recorded/.test(runtimeType)) { + const cost = extractCostState(runtimeEvent); + return { + kind: "metrics", + title: "消耗统计", + statusText: runtimeType === "cost_recorded" ? "已记录" : "预估中", + message: formatAgentRuntimeCostText(cost), + detail: "", + collapseKey: runtimeType, + }; + } + if (/task_profile|taskprofileresolved/.test(runtimeType)) { + return { + kind: "progress", + title: "任务识别", + statusText: "已识别", + message, + detail, + collapseKey: "runtime:task-profile", + }; + } + if (toolName || eventType.includes("tool")) { + const title = formatToolTitle(toolName, skillName); + return { + kind: title.startsWith("Skill ·") ? "skill" : "tool", + title, + statusText, + message: normalizeToolMessage(message, statusText), + detail, + meta: + readString(event, "toolId") || + readString(runtimeEvent, "tool_id") || + readString(runtimeEvent, "toolId"), + }; + } + if ( + eventType.includes("artifact") || + /artifact/.test(runtimeType) || + /artifact|workspacePatch|contentFactoryWorkspacePatch/i.test(message) + ) { + return { + kind: "artifact", + title: "产物回写", + statusText, + message: runtimeAssetLabel(event) || message, + detail, + }; + } + if (eventType.includes("evidence") || /evidence|verification/i.test(runtimeType)) { + return { + kind: "artifact", + title: "证据记录", + statusText, + message: evidenceLabel(event) || message, + detail, + }; + } + if (/missing|blocked|review|action_required|actionrequired/i.test(runtimeSurface)) { + return { + kind: "blocked", + title: "等待确认", + statusText, + message: normalizeConfirmationMessage(message), + detail, + }; + } + if (/failed|failure|error|incident|warning|cancel/.test(runtimeSurface.toLowerCase())) { + return { + kind: "warning", + title: "运行提醒", + statusText, + message, + detail, + }; + } + if (/completed|complete|done|final_done|turn_completed|turncompleted/.test(runtimeSurface.toLowerCase())) { + const usage = extractUsageObject(runtimeEvent); + return { + kind: "completed", + title: "回合完成", + statusText: "已完成", + message: usage + ? `AgentRuntime 本轮输出已结束,${formatAgentRuntimeUsageText(usage)}` + : message, + detail, + collapseKey: "runtime:done", + }; + } + if (/streamTask|getTask|agent-app-runtime/i.test(message)) { + return { + kind: "progress", + title: "同步运行事件", + statusText, + message: "正在同步 Lime 运行事件。", + detail, + }; + } + if (/running|started|queued/i.test(message)) { + return { + kind: "execution", + title: "执行阶段", + statusText, + message: "AI 同事正在执行本轮任务。", + detail, + }; + } + return { + kind: "progress", + title: "运行进度", + statusText, + message, + detail, + }; +} + +export function formatAgentRuntimeUsageText( + usage: AgentAppRuntimeProcessUsage | null | undefined, +): string { + if (!usage) { + return "Token 等待回写"; + } + const input = numberValue(usage.inputTokens ?? usage.input_tokens); + const output = numberValue(usage.outputTokens ?? usage.output_tokens); + const total = numberValue(usage.totalTokens ?? usage.total_tokens) || input + output; + if (!input && !output && !total) { + return "Token 等待回写"; + } + return `${formatNumber(total)} tokens(输入 ${formatNumber(input)} / 输出 ${formatNumber(output)})`; +} + +export function formatAgentRuntimeCostText( + cost: AgentAppRuntimeProcessCost | null | undefined, +): string { + if (!cost) { + return "费用等待回写"; + } + const total = optionalNumberValue( + cost.estimatedTotalCost ?? cost.estimated_total_cost ?? cost.totalCost ?? cost.total_cost, + ); + const costClass = stringValue( + cost.estimatedCostClass ?? cost.estimated_cost_class, + ); + const currency = stringValue(cost.currency) || "USD"; + if (total !== undefined) { + return `${currency} ${total.toFixed(total < 0.01 ? 4 : 2)}`; + } + if (costClass) { + return `预估等级:${costClass}`; + } + return "费用等待回写"; +} + +function buildTimelineItems(events: unknown[]): AgentAppRuntimeProcessTimelineItem[] { + const items: AgentAppRuntimeProcessTimelineItem[] = []; + const seen = new Set(); + for (const event of events) { + const item = normalizeAgentRuntimeProcessTimelineItem(event); + if (!item) { + continue; + } + const signature = [ + item.kind, + item.title, + item.statusText, + item.message, + item.detail, + item.meta, + item.collapseKey, + ].join("|"); + if (seen.has(signature)) { + continue; + } + seen.add(signature); + items.push(item); + } + return items; +} + +function collectStream(events: unknown[], targetKind: AgentRuntimeStreamKind): string { + return events + .map(readStreamEvent) + .filter((item): item is AgentRuntimeStreamEvent => + Boolean(item?.kind === targetKind && item.text), + ) + .map((item) => item.text) + .join(""); +} + +function collectExecutionStream(events: unknown[]): string { + return events + .map(readStreamEvent) + .filter((item): item is AgentRuntimeStreamEvent => + Boolean(item && ["tool_input", "tool_output"].includes(item.kind) && item.text), + ) + .map((item) => { + const label = item.kind === "tool_input" ? "输入" : "输出"; + return `${label}${item.toolName ? ` · ${item.toolName}` : ""}\n${item.text}`; + }) + .join("\n\n"); +} + +type AgentRuntimeStreamKind = + | "assistant_text" + | "thinking" + | "tool_input" + | "tool_output"; + +interface AgentRuntimeStreamEvent { + kind: AgentRuntimeStreamKind; + text: string; + toolName?: string; + streamKind?: string; +} + +function readStreamEvent(event: unknown): AgentRuntimeStreamEvent | null { + if (!isRecord(event)) { + return null; + } + const payload = recordValue(event, "payload"); + const runtimeEvent = readRuntimeEvent(event); + const streamKind = + readString(payload, "streamKind") || + readString(payload, "stream_kind") || + readString(event, "streamKind"); + const runtimeType = readRuntimeEventType(event, runtimeEvent); + const text = + readString(payload, "delta") || + readString(payload, "text") || + readString(runtimeEvent, "delta") || + readString(runtimeEvent, "text") || + readString(runtimeEvent, "arguments") || + readString(runtimeEvent, "accumulated_arguments") || + readString(runtimeEvent, "accumulatedArguments") || + (runtimeType === "text_delta_batch" ? readString(event, "message") : ""); + if (!text) { + return null; + } + const toolName = formatToolName( + extractToolNameFromEvent(event), + extractSkillNameFromEvent(event), + ); + const base = { text, toolName, streamKind }; + if ( + streamKind === "assistant_text_delta" || + streamKind === "assistant_text_batch" || + runtimeType === "text_delta" || + runtimeType === "text_delta_batch" + ) { + return { ...base, kind: "assistant_text" }; + } + if ( + streamKind === "thinking_delta" || + runtimeType === "thinking_delta" || + runtimeType === "reasoning_delta" + ) { + return { ...base, kind: "thinking" }; + } + if (streamKind === "tool_input_delta" || runtimeType === "tool_input_delta") { + return { ...base, kind: "tool_input" }; + } + if (streamKind === "tool_output_delta" || runtimeType === "tool_output_delta") { + return { ...base, kind: "tool_output" }; + } + if (event.eventType === ["task", `partial${"Art"}ifact`].join(":")) { + return { ...base, kind: "assistant_text" }; + } + return null; +} + +function isTerminalProcess({ + task, + snapshot, + events, + contract, +}: { + task: unknown; + snapshot: unknown; + events: unknown[]; + contract: unknown; +}): boolean { + if (isRecord(contract) && contract.ok === true) { + return true; + } + const taskRecord = isRecord(task) ? task : {}; + const snapshotRecord = isRecord(snapshot) ? snapshot : {}; + const status = String( + snapshotRecord.taskStatus ?? snapshotRecord.status ?? taskRecord.status ?? "", + ).toLowerCase(); + if ( + [ + "succeeded", + "success", + "completed", + "complete", + "failed", + "failure", + "error", + "cancelled", + "canceled", + ].includes(status) + ) { + return true; + } + return events.some((event) => { + if (!isRecord(event)) { + return false; + } + const type = String( + event.eventType ?? event.type ?? readRuntimeEventType(event) ?? "", + ).toLowerCase(); + const message = String(event.message ?? "").toLowerCase(); + return /task:completed|task:error|task:cancelled|final_done|turn_completed|turn_failed|cancelled|已被中断|已完成/.test( + `${type} ${message}`, + ); + }); +} + +function extractModelFromProcess( + events: unknown[], + task: unknown, + snapshot: unknown, +): AgentAppRuntimeProcessModel { + const values = [task, snapshot, recordValue(snapshot, "threadRead"), ...events, ...events.map(readRuntimeEvent)]; + for (const value of values) { + const decision = findFirstObjectByKeys(value, [ + "routing_decision", + "routingDecision", + "model_routing", + "modelRouting", + "provider_routing", + "providerRouting", + ]); + const target = decision ?? (isRecord(value) ? value : null); + if (!target) { + continue; + } + const provider = stringValue( + findFirstValueByKeys(target, [ + "selected_provider", + "selectedProvider", + "provider", + "providerName", + ]), + ); + const model = stringValue( + findFirstValueByKeys(target, [ + "selected_model", + "selectedModel", + "model", + "modelName", + "model_name", + ]), + ); + if (provider || model) { + return { + provider, + model, + label: [provider, model].filter(Boolean).join("/") || "自动选择", + }; + } + } + return { provider: "", model: "", label: "模型等待路由" }; +} + +function extractUsageFromProcess( + events: unknown[], + snapshot: unknown, + task: unknown, +): AgentAppRuntimeProcessUsage | null { + for (const value of [ + task, + snapshot, + recordValue(snapshot, "threadRead"), + ...events.map(readRuntimeEvent), + ...events, + ]) { + const usage = extractUsageObject(value); + if (usage) { + return usage; + } + } + return estimateUsageFromProcess(events, snapshot, task); +} + +function extractCostFromProcess( + events: unknown[], + snapshot: unknown, + task: unknown, +): AgentAppRuntimeProcessCost | null { + for (const value of [ + task, + snapshot, + recordValue(snapshot, "threadRead"), + ...events.map(readRuntimeEvent), + ...events, + ]) { + const cost = extractCostState(value); + if (cost) { + return cost; + } + } + return null; +} + +function extractUsageObject(value: unknown): AgentAppRuntimeProcessUsage | null { + const usage = findFirstObjectByKeys(value, ["usage", "tokenUsage", "token_usage"]); + if (!usage) { + return null; + } + const input = numberValue(usage.inputTokens ?? usage.input_tokens); + const output = numberValue(usage.outputTokens ?? usage.output_tokens); + const total = numberValue(usage.totalTokens ?? usage.total_tokens) || input + output; + const cachedInputTokens = optionalNumberValue( + usage.cachedInputTokens ?? usage.cached_input_tokens, + ); + const cacheCreationInputTokens = optionalNumberValue( + usage.cacheCreationInputTokens ?? usage.cache_creation_input_tokens, + ); + if (!input && !output && !total && cachedInputTokens === undefined) { + return null; + } + return { + ...usage, + inputTokens: input, + outputTokens: output, + totalTokens: total, + cachedInputTokens, + cacheCreationInputTokens, + }; +} + +function estimateUsageFromProcess( + events: unknown[], + snapshot: unknown, + task: unknown, +): AgentAppRuntimeProcessUsage | null { + const hasRuntimeSignal = events.some((event) => { + if (!isRecord(event)) { + return false; + } + const surface = [ + event.eventType, + event.type, + event.message, + event.toolName, + readRuntimeEventType(event), + ] + .filter(Boolean) + .join(" "); + return /model|routing|tool|skill|artifact|completed|final_done|turn/i.test(surface); + }); + if (!hasRuntimeSignal && !isTerminalProcess({ task, snapshot, events, contract: null })) { + return null; + } + + const inputTokens = estimateTokenCountFromValue([ + recordValue(task, "input"), + recordValue(task, "expectedOutput"), + recordValue(snapshot, "threadRead")?.turns, + ]); + const outputTokens = estimateTokenCountFromValue([ + ...events.map((event) => + isRecord(event) + ? { + message: event.message, + payload: event.payload, + runtimeEvent: readRuntimeEvent(event), + } + : event, + ), + ]); + const totalTokens = inputTokens + outputTokens; + if (totalTokens <= 0) { + return null; + } + return { + inputTokens, + outputTokens, + totalTokens, + estimated: true, + source: "agent_app_runtime_process_estimate", + }; +} + +function estimateTokenCountFromValue(value: unknown): number { + const text = + typeof value === "string" ? value : (JSON.stringify(value ?? "") ?? ""); + const normalized = text.replace(/\s+/g, " ").trim(); + if (!normalized) { + return 0; + } + return Math.max(1, Math.ceil(normalized.length / 4)); +} + +function extractCostState(value: unknown): AgentAppRuntimeProcessCost | null { + const cost = findFirstObjectByKeys(value, ["cost_state", "costState", "cost"]); + if (!cost) { + return null; + } + const estimatedTotalCost = optionalNumberValue( + cost.estimatedTotalCost ?? cost.estimated_total_cost ?? cost.totalCost ?? cost.total_cost, + ); + const estimatedCostClass = stringValue( + cost.estimatedCostClass ?? cost.estimated_cost_class, + ); + const currency = stringValue(cost.currency); + const totalTokens = optionalNumberValue(cost.totalTokens ?? cost.total_tokens); + if ( + estimatedTotalCost === undefined && + !estimatedCostClass && + totalTokens === undefined + ) { + return null; + } + return { + ...cost, + estimatedTotalCost, + estimatedCostClass, + currency, + }; +} + +function collectRequiredSkillNames(value: unknown): string[] { + if (!isRecord(value)) { + return []; + } + return uniqueTextValues([ + ...collectSkillNamesFromList(value.requiredSkills), + ...collectSkillNamesFromList(value.skills), + ...collectSkillNamesFromList(value.skillRefs), + ...collectSkillNamesFromList(recordValue(value, "skillContract")?.requiredSkills), + ...collectSkillNamesFromList(recordValue(recordValue(value, "input"), "agentTaskContract")?.requiredSkills), + ...collectSkillNamesFromList(recordValue(value, "expectedOutput")?.requiredSkills), + ...collectSkillNamesFromList(recordValue(recordValue(value, "expectedOutput"), "skillContract")?.requiredSkills), + ...collectSkillNamesFromList(recordValue(recordValue(value, "metadata"), "contentFactory")?.skillRefs), + ...collectSkillNamesFromList(recordValue(recordValue(recordValue(value, "metadata"), "contentFactory"), "skillContract")?.requiredSkills), + ]); +} + +function collectSkillNamesFromList(value: unknown): string[] { + if (!Array.isArray(value)) { + return []; + } + return value + .map((item) => { + if (typeof item === "string") { + return item; + } + if (!isRecord(item)) { + return ""; + } + return String( + item.skill ?? item.skillName ?? item.skill_name ?? item.id ?? item.name ?? "", + ); + }) + .filter(Boolean); +} + +function extractToolNameFromEvent(event: unknown): string { + if (!isRecord(event)) { + return ""; + } + const payload = recordValue(event, "payload"); + const runtimeEvent = readRuntimeEvent(event); + return ( + readString(event, "toolName") || + readString(event, "tool_name") || + readString(payload, "toolName") || + readString(payload, "tool_name") || + readString(runtimeEvent, "toolName") || + readString(runtimeEvent, "tool_name") || + stringValue(findFirstValueByKeys(runtimeEvent, ["toolName", "tool_name", "name"])) || + "" + ); +} + +function extractSkillNameFromEvent(event: unknown): string { + if (!isRecord(event)) { + return ""; + } + const payload = recordValue(event, "payload"); + const runtimeEvent = readRuntimeEvent(event); + const toolName = extractToolNameFromEvent(event); + return normalizeSkillNameCandidate( + extractSkillNameFromToolName(toolName) || + stringValue(findFirstValueByKeys(event, ["skillName", "skill_name", "command_name"])) || + extractSkillNameFromArguments(readUnknown(runtimeEvent, "arguments")) || + extractSkillNameFromArguments(readUnknown(runtimeEvent, "accumulated_arguments")) || + extractSkillNameFromArguments(readUnknown(runtimeEvent, "accumulatedArguments")) || + extractSkillNameFromArguments(readUnknown(payload, "arguments")) || + extractSkillNameFromArguments(readUnknown(payload, "args")) || + extractSkillNameFromArguments(readUnknown(event, "arguments")) || + extractSkillNameFromText(readString(runtimeEvent, "arguments")) || + extractSkillNameFromText(readString(runtimeEvent, "accumulated_arguments")) || + extractSkillNameFromText(readString(runtimeEvent, "accumulatedArguments")) || + extractSkillNameFromText(readString(payload, "delta")) || + extractSkillNameFromText(readString(event, "message")), + ); +} + +function extractSkillNameFromToolName(toolName: string): string { + const match = String(toolName || "").match(/^Skill\(([^)]+)\)$/i); + return match?.[1] ?? ""; +} + +function extractSkillNameFromText(text: string): string { + const value = String(text || "").trim(); + if (!value) { + return ""; + } + const parsed = parseJsonObject(value); + if (parsed) { + return stringValue( + findFirstValueByKeys(parsed, ["skill", "skillName", "skill_name", "command_name"]), + ); + } + const match = value.match( + /["']?(?:skill|skillName|skill_name|command_name)["']?\s*[:=]\s*["']?([@A-Za-z0-9_:/.-]+)/, + ); + return match?.[1] ?? ""; +} + +function extractSkillNameFromArguments(value: unknown): string { + if (typeof value === "string") { + return extractSkillNameFromText(value); + } + if (Array.isArray(value)) { + for (const item of value) { + const skillName = extractSkillNameFromArguments(item); + if (skillName) { + return skillName; + } + } + return ""; + } + if (!isRecord(value)) { + return ""; + } + return stringValue( + findFirstValueByKeys(value, ["skill", "skillName", "skill_name", "command_name"], 3), + ); +} + +function normalizeSkillNameCandidate(value: string): string { + const text = String(value || "").trim(); + if (!text || /^Skill$/i.test(text)) { + return ""; + } + return /^[\w@:/.-]+$/.test(text) ? text : ""; +} + +function formatToolTitle(toolName: string, skillName: string): string { + const name = formatToolName(toolName, skillName); + if (skillName || /^Skill/i.test(String(toolName || ""))) { + return `Skill · ${skillName || "待解析名称"}`; + } + if (/ToolSearch/i.test(name)) { + return `检索工具 · ${name}`; + } + if (/Bash|Shell|Command/i.test(name)) { + return `本地执行 · ${name}`; + } + if (/^Agent|SubAgent|Team/i.test(name)) { + return `子任务 · ${name}`; + } + return `工具 · ${name || "未命名"}`; +} + +function formatToolName(toolName: string, skillName: string): string { + if (skillName && /^Skill/i.test(String(toolName || ""))) { + return skillName; + } + return String(toolName || "").trim(); +} + +function normalizeToolMessage(message: string, statusText: string): string { + const text = String(message || "").trim(); + if (!text || /^工具\s+\S+\s+(running|completed|failed|started)$/i.test(text)) { + return `状态:${statusText}`; + } + return text; +} + +function normalizeConfirmationMessage(message: string): string { + return /missingContext|blocked|request|review|action/i.test(message) + ? "AI 同事需要补充确认,已在当前工单等待处理。" + : message; +} + +function extractEventDetail(event: Record): string { + const runtimeEvent = readRuntimeEvent(event); + const payload = recordValue(event, "payload"); + const result = recordValue(runtimeEvent, "result"); + const detail = + readString(runtimeEvent, "arguments") || + readString(runtimeEvent, "accumulated_arguments") || + readString(runtimeEvent, "accumulatedArguments") || + readString(result, "output") || + readString(result, "error") || + readString(payload, "summary") || + ""; + return detail ? clipText(formatMaybeJsonText(detail), 520) : ""; +} + +function routingLabel(runtimeEvent: Record): string { + const decision = + findFirstObjectByKeys(runtimeEvent, ["routing_decision", "routingDecision"]) ?? + runtimeEvent; + const provider = + readString(decision, "selected_provider") || readString(decision, "selectedProvider"); + const model = + readString(decision, "selected_model") || + readString(decision, "selectedModel") || + readString(runtimeEvent, "model"); + const candidateCount = numberValue( + decision.candidate_count ?? decision.candidateCount, + ); + const selected = [provider, model].filter(Boolean).join("/"); + if (selected && candidateCount) { + return `已从 ${candidateCount} 个候选中选择 ${selected}`; + } + if (selected) { + return `已选择 ${selected}`; + } + if (candidateCount) { + return `已找到 ${candidateCount} 个候选模型`; + } + return ""; +} + +function runtimeAssetLabel(event: Record): string { + const payload = recordValue(event, "payload"); + const artifact = recordValue(payload, "artifact") ?? recordValue(readRuntimeEvent(event), "artifact"); + return ( + readString(event, "artifactRef") || + readString(payload, "artifactRef") || + readString(artifact, "file_path") || + readString(artifact, "filePath") || + readString(artifact, "kind") || + "" + ); +} + +function evidenceLabel(event: Record): string { + const payload = recordValue(event, "payload"); + return ( + readString(event, "evidenceRef") || + readString(payload, "evidenceRef") || + readString(payload, "message") || + "" + ); +} + +function statusTextFor(status: string): string { + const normalized = String(status || "").toLowerCase(); + if (["completed", "complete", "succeeded", "success", "done", "recorded", "verified"].includes(normalized)) { + return "已完成"; + } + if (["running", "streaming", "started", "thinking"].includes(normalized)) { + return "正在执行"; + } + if (["queued", "pending"].includes(normalized)) { + return "等待中"; + } + if (["failed", "failure", "error"].includes(normalized)) { + return "执行失败"; + } + if (["cancelled", "canceled"].includes(normalized)) { + return "已中断"; + } + return "已更新"; +} + +function readRuntimeEvent(event: unknown): Record { + if (!isRecord(event)) { + return {}; + } + if (isRecord(event.runtimeEvent)) { + return event.runtimeEvent; + } + const payload = recordValue(event, "payload"); + if (isRecord(payload?.runtimeEvent)) { + return payload.runtimeEvent; + } + if (isRecord(payload?.profileEvent)) { + return payload.profileEvent; + } + return {}; +} + +function readRuntimeEventType( + event: unknown, + runtimeEvent: Record = readRuntimeEvent(event), +): string { + return String( + readString(runtimeEvent, "type") || + readString(runtimeEvent, "event_type") || + readString(runtimeEvent, "eventType") || + (isRecord(event) ? readString(event, "eventType") || readString(event, "type") : "") || + "", + ).toLowerCase(); +} + +function findFirstObjectByKeys( + value: unknown, + keys: string[], + depth = 6, +): Record | null { + const found = findFirstValueByKeys(value, keys, depth); + return isRecord(found) ? found : null; +} + +function findFirstValueByKeys(value: unknown, keys: string[], depth = 6): unknown { + if (depth < 0) { + return undefined; + } + if (Array.isArray(value)) { + for (const item of value) { + const found = findFirstValueByKeys(item, keys, depth - 1); + if (found !== undefined && found !== null && found !== "") { + return found; + } + } + return undefined; + } + if (!isRecord(value)) { + return undefined; + } + for (const key of keys) { + const candidate = value[key]; + if (candidate !== undefined && candidate !== null && candidate !== "") { + return candidate; + } + } + for (const child of Object.values(value)) { + const found = findFirstValueByKeys(child, keys, depth - 1); + if (found !== undefined && found !== null && found !== "") { + return found; + } + } + return undefined; +} + +function parseJsonObject(text: string): Record | null { + try { + const parsed = JSON.parse(text); + return isRecord(parsed) ? parsed : null; + } catch { + return null; + } +} + +function formatMaybeJsonText(text: string): string { + const parsed = parseJsonObject(String(text || "").trim()); + return parsed ? JSON.stringify(parsed, null, 2) : String(text || ""); +} + +function clipText(value: string, maxChars: number): string { + const text = String(value || ""); + return text.length > maxChars ? `${text.slice(-maxChars)}\n…已截取最近输出` : text; +} + +function formatNumber(value: number): string { + return new Intl.NumberFormat("zh-CN").format(value); +} + +function uniqueTextValues(values: Array): string[] { + return Array.from( + new Set(values.map((value) => String(value || "").trim()).filter(Boolean)), + ); +} + +function optionalNumberValue(value: unknown): number | undefined { + const number = Number(value); + return Number.isFinite(number) ? number : undefined; +} + +function numberValue(value: unknown): number { + return optionalNumberValue(value) ?? 0; +} + +function stringValue(value: unknown): string { + return typeof value === "string" && value.trim() ? value.trim() : ""; +} + +function readString(value: unknown, key: string): string { + if (!isRecord(value)) { + return ""; + } + const item = value[key]; + return typeof item === "string" && item.trim() ? item.trim() : ""; +} + +function readUnknown(value: unknown, key: string): unknown { + if (!isRecord(value)) { + return undefined; + } + return value[key]; +} + +function recordValue(value: unknown, key: string): Record | undefined { + if (!isRecord(value)) { + return undefined; + } + return isRecord(value[key]) ? value[key] : undefined; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} diff --git a/src/features/agent-app/runtime/capabilityDispatcher.test.ts b/src/features/agent-app/runtime/capabilityDispatcher.test.ts index 567d59550..0edeabc46 100644 --- a/src/features/agent-app/runtime/capabilityDispatcher.test.ts +++ b/src/features/agent-app/runtime/capabilityDispatcher.test.ts @@ -2,9 +2,11 @@ import { describe, expect, it } from "vitest"; import { AdapterCapabilityHost } from "../adapters/AdapterCapabilityHost"; import { InMemoryAgentAppCapabilityStore } from "../adapters/InMemoryAgentAppCapabilityStore"; import { buildInstalledAppPreview } from "../install/installedAppPreview"; +import type { CapabilityHost } from "../sdk/CapabilityHost"; import type { AgentAppArtifactRecord, AgentAppEvidenceRecord, + AgentAppRuntimeProcessView, AgentAppStorageEntry, AgentAppTaskRecord, AgentAppTaskStreamEvent, @@ -40,13 +42,23 @@ function buildCapabilityRequest( }; } -function buildDispatcher() { +function buildDispatcher( + standardProfile: { + manifestVersion?: string; + agentRuntime?: unknown; + requirements?: unknown; + boundary?: unknown; + integrations?: unknown; + operations?: unknown; + } = {}, +) { + const profile = buildWorkflowRuntimeCapabilityProfile({ + realAdapterEnabled: true, + uiRuntimeEnabled: true, + workerRuntimeEnabled: true, + }); const preview = buildInstalledAppPreview({ - profile: buildWorkflowRuntimeCapabilityProfile({ - realAdapterEnabled: true, - uiRuntimeEnabled: true, - workerRuntimeEnabled: true, - }), + profile, loadedAt: FIXED_NOW, checkedAt: FIXED_NOW, generatedAt: FIXED_NOW, @@ -62,6 +74,8 @@ function buildDispatcher() { projection: preview.projection, entryKey: "dashboard", runId: "bridge-run-1", + profile, + ...standardProfile, }); return (request: CapabilityRequestFixture) => dispatch(buildCapabilityRequest(request)); @@ -168,7 +182,1343 @@ function buildDispatcherWithoutCreativeCapabilityAllowlist() { dispatch(buildCapabilityRequest(request)); } +function buildRuntimeProjectionDispatcher() { + const profile = buildWorkflowRuntimeCapabilityProfile({ + realAdapterEnabled: true, + uiRuntimeEnabled: true, + workerRuntimeEnabled: true, + }); + const preview = buildInstalledAppPreview({ + profile, + loadedAt: FIXED_NOW, + checkedAt: FIXED_NOW, + generatedAt: FIXED_NOW, + }); + const runtimeProcess: AgentAppRuntimeProcessView = { + timeline: [ + { + kind: "routing", + title: "模型路由", + statusText: "已决策", + message: "openai/gpt-4.1", + }, + { + kind: "metrics", + title: "消耗统计", + statusText: "已记录", + message: "1540 tokens", + }, + { + kind: "tool", + title: "Tool · web_search", + statusText: "已完成", + message: "检索竞品资料来源。", + meta: "web_search:run-1", + }, + { + kind: "tool", + title: "Tool · browser.extract", + statusText: "已完成", + message: "读取网页正文。", + meta: "browser:run-1", + }, + { + kind: "tool", + title: "Tool · document_parser", + statusText: "已完成", + message: "解析 PDF brief。", + meta: "document:run-1", + }, + { + kind: "tool", + title: "Tool · image_generation", + statusText: "已完成", + message: "生成配套封面图。", + meta: "media:run-1", + }, + { + kind: "tool", + title: "Tool · mcp__research__search", + statusText: "已完成", + message: "读取 MCP research server 结果。", + meta: "mcp:run-1", + }, + { + kind: "execution", + title: "Tool · terminal.run", + statusText: "已完成", + message: "执行受控命令生成素材清单。", + meta: "terminal:run-1", + }, + { + kind: "tool", + title: "Tool · connector__notion__createPage", + statusText: "已完成", + message: "通过外部连接器写入内容看板。", + meta: "connector:run-1", + }, + ], + streamText: "已生成内容策略。", + thinkingText: "", + executionText: "", + skillNames: ["content-strategist"], + invokedSkillNames: ["content-strategist"], + model: { provider: "openai", model: "gpt-4.1", label: "openai/gpt-4.1" }, + usage: { + inputTokens: 1200, + outputTokens: 340, + totalTokens: 1540, + cachedInputTokens: 100, + cacheCreationInputTokens: 40, + }, + cost: { + estimatedTotalCost: 0.043, + estimatedCostClass: "low", + currency: "USD", + }, + terminal: true, + collapsedByDefault: true, + routingCount: 1, + executionCount: 1, + artifactCount: 1, + }; + const task: AgentAppTaskRecord = { + taskId: "agent-app-task-1", + traceId: "agent-app-trace-1", + appId: "content-factory-app", + entryKey: "dashboard", + title: "生成内容场景", + prompt: "基于项目知识生成内容场景", + taskKind: "content.scenario_planning", + idempotencyKey: "dashboard:scenario", + input: { projectId: "project-1" }, + expectedOutput: { artifactKind: "content_table" }, + knowledge: [ + { + key: "project_knowledge", + mode: "retrieval", + required: true, + }, + ], + tools: ["content-strategist"], + files: [], + secrets: [], + humanReview: true, + status: "succeeded", + startedAt: FIXED_NOW, + finishedAt: "2026-05-15T00:01:00.000Z", + result: { + thread_id: "agent-runtime-thread-1", + status: "completed", + diagnostics: { + context_compaction_count: 1, + pending_request_count: 0, + }, + turns: [ + { + turn_id: "agent-runtime-turn-1", + status: "completed", + }, + ], + telemetry_summary: { + join_status: "available", + trace_ids: ["agent-app-trace-1"], + }, + summary: "已生成内容策略。", + }, + trace: [], + events: [], + runtimeProcess, + process: runtimeProcess, + provenance: { + sourceKind: "agent_app", + appId: "content-factory-app", + appVersion: preview.identity.appVersion, + packageHash: preview.identity.packageHash, + manifestHash: preview.identity.manifestHash, + entryKey: "dashboard", + taskId: "agent-app-task-1", + }, + }; + const host: CapabilityHost = { + createSdkContext: () => { + throw new Error("runtime projection test should not create SDK context"); + }, + runEntry: async () => { + throw new Error("runtime projection test should not run entries"); + }, + getArtifacts: () => [], + getEvidence: () => [], + getStorageEntries: () => [], + getTasks: () => [task], + uninstall: async () => ({ + appId: "content-factory-app", + mode: "keep-data", + deletedTargets: [], + retainedTargets: [], + warnings: [], + }), + }; + const projection = { + ...preview.projection, + requiredCapabilities: [ + ...preview.projection.requiredCapabilities, + { + capability: "lime.models", + requestedRange: "^0.3.0", + required: true, + declaredBy: ["requires" as const], + }, + { + capability: "lime.usage", + requestedRange: "^0.3.0", + required: true, + declaredBy: ["requires" as const], + }, + { + capability: "lime.skills", + requestedRange: "^0.3.0", + required: true, + declaredBy: ["requires" as const], + }, + { + capability: "lime.memory", + requestedRange: "^0.3.0", + required: true, + declaredBy: ["requires" as const], + }, + { + capability: "lime.context", + requestedRange: "^0.3.0", + required: true, + declaredBy: ["requires" as const], + }, + { + capability: "lime.search", + requestedRange: "^0.3.0", + required: true, + declaredBy: ["requires" as const], + }, + { + capability: "lime.browser", + requestedRange: "^0.3.0", + required: true, + declaredBy: ["requires" as const], + }, + { + capability: "lime.documents", + requestedRange: "^0.3.0", + required: true, + declaredBy: ["requires" as const], + }, + { + capability: "lime.media", + requestedRange: "^0.3.0", + required: true, + declaredBy: ["requires" as const], + }, + { + capability: "lime.mcp", + requestedRange: "^0.3.0", + required: true, + declaredBy: ["requires" as const], + }, + { + capability: "lime.terminal", + requestedRange: "^0.3.0", + required: true, + declaredBy: ["requires" as const], + }, + { + capability: "lime.connectors", + requestedRange: "^0.3.0", + required: true, + declaredBy: ["requires" as const], + }, + ], + }; + const dispatch = createAgentAppCapabilityDispatcher({ + host, + projection, + entryKey: "dashboard", + profile, + }); + return (request: CapabilityRequestFixture) => + dispatch(buildCapabilityRequest(request)); +} + describe("createAgentAppCapabilityDispatcher", () => { + it("应通过 lime.capabilities 暴露 Host discovery profile,且不泄露内部路径", async () => { + const dispatch = buildDispatcher(); + + const list = (await dispatch({ + appId: "content-factory-app", + entryKey: "dashboard", + capability: "lime.capabilities", + method: "list", + rawPayload: { + capability: "lime.capabilities", + method: "list", + }, + })) as Array>; + + const agent = list.find((item) => item.name === "lime.agent"); + const discovery = list.find((item) => item.name === "lime.capabilities"); + expect(agent).toMatchObject({ + name: "lime.agent", + version: "0.3.0", + stage: "current", + owner: "agent_runtime", + enabled: true, + implementation: "adapter", + }); + expect(discovery).toMatchObject({ + name: "lime.capabilities", + stage: "preview", + owner: "desktop_host", + enabled: true, + implementation: "native", + }); + expect(discovery).not.toHaveProperty("unavailableReason"); + expect(Object.keys(discovery ?? {})).not.toEqual( + expect.arrayContaining(["path", "sourceFile", "internal"]), + ); + + const single = await dispatch({ + appId: "content-factory-app", + entryKey: "dashboard", + capability: "lime.capabilities", + method: "get", + input: { capability: "lime.agent" }, + rawPayload: { + capability: "lime.capabilities", + method: "get", + }, + }); + expect(single).toMatchObject({ + name: "lime.agent", + methods: expect.arrayContaining(["startTask", "streamTask", "getTask"]), + enabled: true, + }); + + const profile = (await dispatch({ + appId: "content-factory-app", + entryKey: "dashboard", + capability: "lime.capabilities", + method: "getProfile", + rawPayload: { + capability: "lime.capabilities", + method: "getProfile", + }, + })) as Record; + expect(profile).toMatchObject({ + appRuntimeVersion: "0.7.0", + standardVersions: { + current: "0.7", + compatible: ["0.5", "0.6", "0.7"], + }, + standards: expect.objectContaining({ + layeredManifest: expect.objectContaining({ + version: "0.5", + enabled: true, + layerFiles: expect.arrayContaining(["app.capabilities.yaml"]), + }), + agentRuntime: expect.objectContaining({ + version: "0.6", + enabled: false, + layerFiles: ["app.runtime.yaml"], + }), + requirementBoundary: expect.objectContaining({ + version: "0.7", + enabled: false, + layerFiles: expect.arrayContaining([ + "app.requirements.yaml", + "app.boundary.yaml", + "app.integrations.yaml", + "app.operations.yaml", + ]), + hostCloudManagedExecution: true, + externalSideEffectsRequireApproval: true, + }), + }), + runtimeTargets: ["local"], + capabilities: expect.objectContaining({ + "lime.capabilities": expect.objectContaining({ + enabled: true, + implementation: "native", + }), + "lime.models": expect.objectContaining({ + enabled: true, + implementation: "adapter", + }), + "lime.usage": expect.objectContaining({ + enabled: true, + implementation: "adapter", + }), + "lime.skills": expect.objectContaining({ + enabled: true, + implementation: "adapter", + }), + "lime.memory": expect.objectContaining({ + enabled: true, + implementation: "adapter", + }), + "lime.context": expect.objectContaining({ + enabled: true, + implementation: "adapter", + }), + "lime.search": expect.objectContaining({ + enabled: true, + implementation: "adapter", + }), + "lime.browser": expect.objectContaining({ + enabled: true, + implementation: "adapter", + }), + "lime.documents": expect.objectContaining({ + enabled: true, + implementation: "adapter", + }), + "lime.media": expect.objectContaining({ + enabled: true, + implementation: "adapter", + }), + "lime.mcp": expect.objectContaining({ + enabled: true, + implementation: "adapter", + }), + "lime.terminal": expect.objectContaining({ + enabled: true, + implementation: "adapter", + }), + "lime.connectors": expect.objectContaining({ + enabled: true, + implementation: "adapter", + }), + }), + }); + + await expect( + dispatch({ + appId: "content-factory-app", + entryKey: "dashboard", + capability: "lime.capabilities", + method: "get", + input: { capability: "lime.unknown" }, + rawPayload: { + capability: "lime.capabilities", + method: "get", + }, + }), + ).rejects.toMatchObject({ + code: "CAPABILITY_NOT_FOUND", + }); + }); + + it("应通过 lime.capabilities.getProfile 暴露 v0.7 需求边界与能力交接合同", async () => { + const requirements = { + requirements: [ + { + id: "CF-R001", + text: "生成可审核内容草稿", + priority: "mvp", + }, + ], + nonGoals: ["不在 App 包内保存外部凭证"], + }; + const boundary = { + boundaries: [ + { + requirementId: "CF-R001", + planes: { + app: { owns: ["workflow_state"] }, + host: { requires: ["lime.agent", "lime.evidence"] }, + }, + }, + ], + }; + const integrations = [ + { + key: "planning_table", + provider: "cloud.table", + executionPlane: "hybrid", + hostCapability: "lime.connectors", + }, + ]; + const operations = [ + { + key: "write_external_draft", + type: "external_write", + sideEffect: "external_write", + approvalRequired: true, + dryRunRequired: true, + evidenceRequired: true, + autoExecute: false, + }, + ]; + const dispatch = buildDispatcher({ + manifestVersion: "0.7", + requirements, + boundary, + integrations, + operations, + }); + + const profile = (await dispatch({ + appId: "content-factory-app", + entryKey: "dashboard", + capability: "lime.capabilities", + method: "getProfile", + rawPayload: { + capability: "lime.capabilities", + method: "getProfile", + }, + })) as Record; + + expect(profile).toMatchObject({ + appRuntimeVersion: "0.7.0", + standardVersions: { + current: "0.7", + compatible: ["0.5", "0.6", "0.7"], + }, + requirements, + boundary, + integrations, + operations, + standards: expect.objectContaining({ + requirementBoundary: expect.objectContaining({ + version: "0.7", + enabled: true, + manifestVersion: "0.7", + requirementCount: 1, + boundaryCount: 1, + integrationCount: 1, + operationCount: 1, + appCredentialsBoundary: "host_or_cloud_managed", + }), + }), + }); + }); + + it("应通过 lime.models / lime.usage 投影 AgentRuntime 模型与用量事实", async () => { + const dispatch = buildRuntimeProjectionDispatcher(); + + const models = await dispatch({ + appId: "content-factory-app", + entryKey: "dashboard", + capability: "lime.models", + method: "list", + rawPayload: { + capability: "lime.models", + method: "list", + }, + }); + expect(models).toMatchObject({ + appId: "content-factory-app", + source: "agent_runtime_projection", + taskCount: 1, + models: [ + expect.objectContaining({ + provider: "openai", + model: "gpt-4.1", + label: "openai/gpt-4.1", + taskCount: 1, + taskKinds: ["content.scenario_planning"], + }), + ], + }); + + const routing = await dispatch({ + appId: "content-factory-app", + entryKey: "dashboard", + capability: "lime.models", + method: "getRouting", + input: { taskId: "agent-app-task-1" }, + rawPayload: { + capability: "lime.models", + method: "getRouting", + }, + }); + expect(routing).toMatchObject({ + source: "agent_runtime_projection", + routes: [ + expect.objectContaining({ + taskId: "agent-app-task-1", + model: { + provider: "openai", + model: "gpt-4.1", + label: "openai/gpt-4.1", + }, + }), + ], + }); + + const tokenUsage = await dispatch({ + appId: "content-factory-app", + entryKey: "dashboard", + capability: "lime.usage", + method: "getTokenUsage", + input: { taskId: "agent-app-task-1" }, + rawPayload: { + capability: "lime.usage", + method: "getTokenUsage", + }, + }); + expect(tokenUsage).toMatchObject({ + appId: "content-factory-app", + source: "agent_runtime_projection", + taskCount: 1, + totals: { + inputTokens: 1200, + outputTokens: 340, + totalTokens: 1540, + cachedInputTokens: 100, + cacheCreationInputTokens: 40, + }, + tasks: [ + expect.objectContaining({ + taskId: "agent-app-task-1", + usage: expect.objectContaining({ totalTokens: 1540 }), + }), + ], + }); + + const costSummary = await dispatch({ + appId: "content-factory-app", + entryKey: "dashboard", + capability: "lime.usage", + method: "getCostSummary", + input: { taskId: "agent-app-task-1" }, + rawPayload: { + capability: "lime.usage", + method: "getCostSummary", + }, + }); + expect(costSummary).toMatchObject({ + source: "agent_runtime_projection", + cost: { + estimatedTotalCost: 0.043, + currency: "USD", + }, + }); + }); + + it("应通过 lime.skills 投影 AgentRuntime Skill 声明与调用事实", async () => { + const dispatch = buildRuntimeProjectionDispatcher(); + + const listed = await dispatch({ + appId: "content-factory-app", + entryKey: "dashboard", + capability: "lime.skills", + method: "list", + rawPayload: { + capability: "lime.skills", + method: "list", + }, + }); + expect(listed).toMatchObject({ + appId: "content-factory-app", + source: "agent_runtime_process", + taskCount: 1, + skills: [ + expect.objectContaining({ + skillId: "content-strategist", + name: "content-strategist", + status: "invoked", + taskCount: 1, + invocationCount: 1, + taskIds: ["agent-app-task-1"], + taskKinds: ["content.scenario_planning"], + source: "agent_runtime_process", + }), + ], + }); + + await expect( + dispatch({ + appId: "content-factory-app", + entryKey: "dashboard", + capability: "lime.skills", + method: "resolve", + input: { skillId: "content-strategist" }, + rawPayload: { + capability: "lime.skills", + method: "resolve", + }, + }), + ).resolves.toMatchObject({ + skillId: "content-strategist", + status: "invoked", + invocationCount: 1, + }); + + await expect( + dispatch({ + appId: "content-factory-app", + entryKey: "dashboard", + capability: "lime.skills", + method: "getInvocation", + input: { invocationId: "agent-app-task-1:content-strategist" }, + rawPayload: { + capability: "lime.skills", + method: "getInvocation", + }, + }), + ).resolves.toMatchObject({ + invocationId: "agent-app-task-1:content-strategist", + skillId: "content-strategist", + taskId: "agent-app-task-1", + status: "succeeded", + source: "agent_runtime_process", + }); + + await expect( + dispatch({ + appId: "content-factory-app", + entryKey: "dashboard", + capability: "lime.skills", + method: "bind", + input: { skillId: "content-strategist" }, + rawPayload: { + capability: "lime.skills", + method: "bind", + }, + }), + ).resolves.toEqual({ + status: "not_available", + reason: "skill_runtime_mutation_not_exposed_to_agent_apps", + source: "agent_runtime_process", + }); + }); + + it("应通过 lime.memory / lime.context 投影只读记忆与上下文状态", async () => { + const dispatch = buildRuntimeProjectionDispatcher(); + + const memoryStatus = await dispatch({ + appId: "content-factory-app", + entryKey: "dashboard", + capability: "lime.memory", + method: "getStatus", + rawPayload: { + capability: "lime.memory", + method: "getStatus", + }, + }); + expect(memoryStatus).toMatchObject({ + appId: "content-factory-app", + status: "read_only_projection", + source: "agent_runtime_projection", + writable: false, + compactable: false, + totals: { + knowledgeBindingCount: 1, + contextCompactionCount: 1, + pendingRequestCount: 0, + }, + observations: [ + expect.objectContaining({ + taskId: "agent-app-task-1", + knowledgeBindingKeys: ["project_knowledge"], + contextCompactionCount: 1, + }), + ], + }); + + const memoryQuery = await dispatch({ + appId: "content-factory-app", + entryKey: "dashboard", + capability: "lime.memory", + method: "query", + input: { query: "project_knowledge" }, + rawPayload: { + capability: "lime.memory", + method: "query", + }, + }); + expect(memoryQuery).toMatchObject({ + status: "limited_projection", + source: "agent_runtime_projection", + records: [ + expect.objectContaining({ + taskId: "agent-app-task-1", + knowledgeBindingKeys: ["project_knowledge"], + }), + ], + }); + + const contextSnapshot = await dispatch({ + appId: "content-factory-app", + entryKey: "dashboard", + capability: "lime.context", + method: "getSnapshot", + rawPayload: { + capability: "lime.context", + method: "getSnapshot", + }, + }); + expect(contextSnapshot).toMatchObject({ + appId: "content-factory-app", + source: "agent_runtime_projection", + taskCount: 1, + contexts: [ + expect.objectContaining({ + taskId: "agent-app-task-1", + traceId: "agent-app-trace-1", + threadId: "agent-runtime-thread-1", + turnIds: ["agent-runtime-turn-1"], + knowledgeBindingKeys: ["project_knowledge"], + toolKeys: ["content-strategist"], + inputAttached: true, + expectedOutputAttached: true, + }), + ], + }); + + await expect( + dispatch({ + appId: "content-factory-app", + entryKey: "dashboard", + capability: "lime.memory", + method: "write", + input: { scope: "task", value: { note: "不要直接写入" } }, + rawPayload: { + capability: "lime.memory", + method: "write", + }, + }), + ).resolves.toEqual({ + status: "not_available", + reason: "memory_runtime_mutation_not_exposed_to_agent_apps", + source: "agent_runtime_projection", + }); + + await expect( + dispatch({ + appId: "content-factory-app", + entryKey: "dashboard", + capability: "lime.memory", + method: "compact", + input: { scope: "task" }, + rawPayload: { + capability: "lime.memory", + method: "compact", + }, + }), + ).resolves.toEqual({ + status: "not_available", + reason: "memory_runtime_mutation_not_exposed_to_agent_apps", + source: "agent_runtime_projection", + }); + + await expect( + dispatch({ + appId: "content-factory-app", + entryKey: "dashboard", + capability: "lime.context", + method: "attach", + input: { ref: "artifact-1" }, + rawPayload: { + capability: "lime.context", + method: "attach", + }, + }), + ).resolves.toEqual({ + status: "not_available", + reason: "context_mutation_not_exposed_to_agent_apps", + source: "agent_runtime_projection", + }); + + await expect( + dispatch({ + appId: "content-factory-app", + entryKey: "dashboard", + capability: "lime.context", + method: "detach", + input: { ref: "artifact-1" }, + rawPayload: { + capability: "lime.context", + method: "detach", + }, + }), + ).resolves.toEqual({ + status: "not_available", + reason: "context_mutation_not_exposed_to_agent_apps", + source: "agent_runtime_projection", + }); + }); + + it("应通过 ToolRuntime preview capabilities 暴露受控工具意图和运行投影", async () => { + const dispatch = buildRuntimeProjectionDispatcher(); + + const searchIntent = await dispatch({ + appId: "content-factory-app", + entryKey: "dashboard", + capability: "lime.search", + method: "query", + input: { query: "竞品资料", limit: 3 }, + rawPayload: { + capability: "lime.search", + method: "query", + }, + }); + expect(searchIntent).toMatchObject({ + appId: "content-factory-app", + capability: "lime.search", + method: "query", + status: "requires_agent_task", + reason: "search_execution_requires_lime_agent_task", + source: "tool_runtime_policy", + intent: { query: "竞品资料", limit: 3 }, + toolHints: ["lime.capability.research.search", "web_search"], + next: { + capability: "lime.agent", + method: "startTask", + }, + matchingRuns: [ + expect.objectContaining({ + runId: "web_search:run-1", + capability: "lime.search", + toolName: "web_search", + taskId: "agent-app-task-1", + source: "agent_runtime_process", + }), + ], + }); + + await expect( + dispatch({ + appId: "content-factory-app", + entryKey: "dashboard", + capability: "lime.search", + method: "getRun", + input: { runId: "web_search:run-1" }, + rawPayload: { + capability: "lime.search", + method: "getRun", + }, + }), + ).resolves.toMatchObject({ + runId: "web_search:run-1", + capability: "lime.search", + status: "succeeded", + title: "Tool · web_search", + }); + + const browserIntent = await dispatch({ + appId: "content-factory-app", + entryKey: "dashboard", + capability: "lime.browser", + method: "open", + input: { url: "https://example.com/brief" }, + rawPayload: { + capability: "lime.browser", + method: "open", + }, + }); + expect(browserIntent).toMatchObject({ + capability: "lime.browser", + method: "open", + status: "requires_agent_task", + reason: "browser_runtime_execution_requires_lime_tool_runtime_policy", + source: "tool_runtime_policy", + intent: { url: "https://example.com/brief" }, + matchingRuns: [ + expect.objectContaining({ + runId: "browser:run-1", + capability: "lime.browser", + toolName: "browser.extract", + }), + ], + }); + + const documentIntent = await dispatch({ + appId: "content-factory-app", + entryKey: "dashboard", + capability: "lime.documents", + method: "parse", + input: { ref: "file:brief.pdf" }, + rawPayload: { + capability: "lime.documents", + method: "parse", + }, + }); + expect(documentIntent).toMatchObject({ + capability: "lime.documents", + method: "parse", + status: "requires_agent_task", + reason: "document_runtime_execution_requires_lime_tool_runtime_policy", + source: "tool_runtime_policy", + intent: { ref: "file:brief.pdf" }, + matchingRuns: [ + expect.objectContaining({ + runId: "document:run-1", + capability: "lime.documents", + toolName: "document_parser", + }), + ], + }); + + const mediaIntent = await dispatch({ + appId: "content-factory-app", + entryKey: "dashboard", + capability: "lime.media", + method: "generateImage", + input: { prompt: "生成一张内容活动封面", size: "1024x1024" }, + rawPayload: { + capability: "lime.media", + method: "generateImage", + }, + }); + expect(mediaIntent).toMatchObject({ + capability: "lime.media", + method: "generateImage", + status: "requires_agent_task", + reason: "media_runtime_execution_requires_lime_tool_runtime_policy", + source: "tool_runtime_policy", + intent: { prompt: "生成一张内容活动封面", size: "1024x1024" }, + matchingRuns: [ + expect.objectContaining({ + runId: "media:run-1", + capability: "lime.media", + toolName: "image_generation", + }), + ], + }); + + const mcpServers = await dispatch({ + appId: "content-factory-app", + entryKey: "dashboard", + capability: "lime.mcp", + method: "listServers", + rawPayload: { + capability: "lime.mcp", + method: "listServers", + }, + }); + expect(mcpServers).toMatchObject({ + appId: "content-factory-app", + status: "read_only_projection", + source: "agent_runtime_process", + servers: [ + expect.objectContaining({ + serverId: "research", + toolCount: 1, + runIds: ["mcp:run-1"], + }), + ], + }); + + const mcpIntent = await dispatch({ + appId: "content-factory-app", + entryKey: "dashboard", + capability: "lime.mcp", + method: "invoke", + input: { tool: "mcp__research__search", input: { query: "竞品" } }, + rawPayload: { + capability: "lime.mcp", + method: "invoke", + }, + }); + expect(mcpIntent).toMatchObject({ + capability: "lime.mcp", + method: "invoke", + status: "requires_agent_task", + reason: "mcp_execution_requires_lime_tool_runtime_policy", + source: "tool_runtime_policy", + intent: { tool: "mcp__research__search", input: { query: "竞品" } }, + matchingRuns: [ + expect.objectContaining({ + runId: "mcp:run-1", + capability: "lime.mcp", + toolName: "mcp__research__search", + }), + ], + }); + + const terminalIntent = await dispatch({ + appId: "content-factory-app", + entryKey: "dashboard", + capability: "lime.terminal", + method: "run", + input: { command: "npm run build:assets", reason: "生成素材清单" }, + rawPayload: { + capability: "lime.terminal", + method: "run", + }, + }); + expect(terminalIntent).toMatchObject({ + capability: "lime.terminal", + method: "run", + status: "requires_agent_task", + reason: "terminal_execution_requires_lime_sandbox_policy", + source: "tool_runtime_policy", + intent: { + command: "npm run build:assets", + reason: "生成素材清单", + }, + matchingRuns: [ + expect.objectContaining({ + runId: "terminal:run-1", + capability: "lime.terminal", + toolName: "terminal.run", + }), + ], + }); + + await expect( + dispatch({ + appId: "content-factory-app", + entryKey: "dashboard", + capability: "lime.terminal", + method: "getRun", + input: { runId: "terminal:run-1" }, + rawPayload: { + capability: "lime.terminal", + method: "getRun", + }, + }), + ).resolves.toMatchObject({ + runId: "terminal:run-1", + capability: "lime.terminal", + status: "succeeded", + }); + + await expect( + dispatch({ + appId: "content-factory-app", + entryKey: "dashboard", + capability: "lime.terminal", + method: "cancel", + input: { runId: "terminal:run-1" }, + rawPayload: { + capability: "lime.terminal", + method: "cancel", + }, + }), + ).resolves.toEqual({ + status: "not_available", + reason: "terminal_runtime_cancellation_not_exposed_to_agent_apps", + source: "tool_runtime_policy", + }); + + const connectors = await dispatch({ + appId: "content-factory-app", + entryKey: "dashboard", + capability: "lime.connectors", + method: "list", + rawPayload: { + capability: "lime.connectors", + method: "list", + }, + }); + expect(connectors).toMatchObject({ + appId: "content-factory-app", + status: "read_only_projection", + source: "agent_runtime_process", + connectors: [ + expect.objectContaining({ + connectorId: "notion", + actionIds: ["createPage"], + runIds: ["connector:run-1"], + }), + ], + }); + + await expect( + dispatch({ + appId: "content-factory-app", + entryKey: "dashboard", + capability: "lime.connectors", + method: "getStatus", + input: { connectorId: "notion" }, + rawPayload: { + capability: "lime.connectors", + method: "getStatus", + }, + }), + ).resolves.toMatchObject({ + connectorId: "notion", + status: "observed", + source: "agent_runtime_process", + }); + + await expect( + dispatch({ + appId: "content-factory-app", + entryKey: "dashboard", + capability: "lime.connectors", + method: "requestAuth", + input: { connectorId: "notion", reason: "同步内容看板" }, + rawPayload: { + capability: "lime.connectors", + method: "requestAuth", + }, + }), + ).resolves.toMatchObject({ + capability: "lime.connectors", + method: "requestAuth", + status: "requires_host_authorization", + reason: "connector_auth_requires_lime_policy_and_secret_binding", + intent: { connectorId: "notion", reason: "同步内容看板" }, + }); + + const connectorIntent = await dispatch({ + appId: "content-factory-app", + entryKey: "dashboard", + capability: "lime.connectors", + method: "invoke", + input: { + connectorId: "notion", + action: "createPage", + input: { title: "内容计划" }, + }, + rawPayload: { + capability: "lime.connectors", + method: "invoke", + }, + }); + expect(connectorIntent).toMatchObject({ + capability: "lime.connectors", + method: "invoke", + status: "requires_agent_task", + reason: "connector_execution_requires_lime_policy_and_secret_binding", + source: "tool_runtime_policy", + intent: { + connectorId: "notion", + action: "createPage", + input: { title: "内容计划" }, + }, + matchingRuns: [ + expect.objectContaining({ + runId: "connector:run-1", + capability: "lime.connectors", + toolName: "connector__notion__createPage", + }), + ], + }); + }); + + it("应拒绝未声明的 runtime projection capability,避免 App 绕过 manifest", async () => { + const dispatch = buildDispatcher(); + + await expect( + dispatch({ + appId: "content-factory-app", + entryKey: "dashboard", + capability: "lime.usage", + method: "getTokenUsage", + rawPayload: { + capability: "lime.usage", + method: "getTokenUsage", + }, + }), + ).rejects.toMatchObject({ + code: "CAPABILITY_NOT_DECLARED", + }); + await expect( + dispatch({ + appId: "content-factory-app", + entryKey: "dashboard", + capability: "lime.skills", + method: "list", + rawPayload: { + capability: "lime.skills", + method: "list", + }, + }), + ).rejects.toMatchObject({ + code: "CAPABILITY_NOT_DECLARED", + }); + await expect( + dispatch({ + appId: "content-factory-app", + entryKey: "dashboard", + capability: "lime.memory", + method: "getStatus", + rawPayload: { + capability: "lime.memory", + method: "getStatus", + }, + }), + ).rejects.toMatchObject({ + code: "CAPABILITY_NOT_DECLARED", + }); + await expect( + dispatch({ + appId: "content-factory-app", + entryKey: "dashboard", + capability: "lime.context", + method: "getSnapshot", + rawPayload: { + capability: "lime.context", + method: "getSnapshot", + }, + }), + ).rejects.toMatchObject({ + code: "CAPABILITY_NOT_DECLARED", + }); + await expect( + dispatch({ + appId: "content-factory-app", + entryKey: "dashboard", + capability: "lime.search", + method: "query", + input: { query: "竞品资料" }, + rawPayload: { + capability: "lime.search", + method: "query", + }, + }), + ).rejects.toMatchObject({ + code: "CAPABILITY_NOT_DECLARED", + }); + await expect( + dispatch({ + appId: "content-factory-app", + entryKey: "dashboard", + capability: "lime.media", + method: "generateImage", + input: { prompt: "生成配图" }, + rawPayload: { + capability: "lime.media", + method: "generateImage", + }, + }), + ).rejects.toMatchObject({ + code: "CAPABILITY_NOT_DECLARED", + }); + await expect( + dispatch({ + appId: "content-factory-app", + entryKey: "dashboard", + capability: "lime.terminal", + method: "run", + input: { command: "npm run build" }, + rawPayload: { + capability: "lime.terminal", + method: "run", + }, + }), + ).rejects.toMatchObject({ + code: "CAPABILITY_NOT_DECLARED", + }); + await expect( + dispatch({ + appId: "content-factory-app", + entryKey: "dashboard", + capability: "lime.connectors", + method: "invoke", + input: { connectorId: "notion", action: "createPage" }, + rawPayload: { + capability: "lime.connectors", + method: "invoke", + }, + }), + ).rejects.toMatchObject({ + code: "CAPABILITY_NOT_DECLARED", + }); + }); + it("应把 App capability 请求收敛到 Lime Agent task,并保留 stream/cancel 事件", async () => { const dispatch = buildDispatcher(); diff --git a/src/features/agent-app/runtime/capabilityDispatcher.ts b/src/features/agent-app/runtime/capabilityDispatcher.ts index 3993bd571..25293e2fb 100644 --- a/src/features/agent-app/runtime/capabilityDispatcher.ts +++ b/src/features/agent-app/runtime/capabilityDispatcher.ts @@ -1,5 +1,24 @@ +import { + compatibleAgentAppStandardVersions, + currentAgentAppStandardVersion, + p0HostCapabilityProfile, +} from "../readiness/hostCapabilityProfile"; import type { CapabilityHost, LimeAppSdk } from "../sdk/CapabilityHost"; -import type { AgentAppProjection, AgentAppTaskRequest } from "../types"; +import { + LIME_CAPABILITY_DEFINITIONS, + type LimeCapabilityDefinitionRecord, + type LimeCapabilityName, +} from "../sdk/capabilityCatalog"; +import type { + AgentAppProjection, + AgentAppRuntimeProcessCost, + AgentAppRuntimeProcessModel, + AgentAppRuntimeProcessTimelineItem, + AgentAppRuntimeProcessUsage, + AgentAppTaskRecord, + AgentAppTaskRequest, + HostCapabilityProfile, +} from "../types"; import type { AgentAppHostBridgeCapabilityRequest } from "./hostBridge"; export type AgentAppCapabilityDispatcher = ( @@ -11,6 +30,13 @@ export interface CreateAgentAppCapabilityDispatcherOptions { projection: AgentAppProjection; entryKey: string; runId?: string; + profile?: HostCapabilityProfile; + manifestVersion?: string; + agentRuntime?: unknown; + requirements?: unknown; + boundary?: unknown; + integrations?: unknown; + operations?: unknown; } export class AgentAppCapabilityDispatcherError extends Error { @@ -23,11 +49,13 @@ export class AgentAppCapabilityDispatcherError extends Error { } } -const CREATIVE_CAPABILITY_TOOL_KEYS = new Set([ - "creative_capability_search", - "claw_capability_catalog", - "agent_runtime_capability_catalog", -].map(capabilityMatchToken)); +const CREATIVE_CAPABILITY_TOOL_KEYS = new Set( + [ + "creative_capability_search", + "claw_capability_catalog", + "agent_runtime_capability_catalog", + ].map(capabilityMatchToken), +); const CLAW_CAPABILITY_ALIASES: Array<{ capabilityId: string; @@ -86,6 +114,150 @@ const CLAW_CAPABILITY_ALIASES: Array<{ }, ]; +interface CapabilityDiscoveryEntry { + name: LimeCapabilityName; + version: string; + group: LimeCapabilityDefinitionRecord["group"]; + stage: LimeCapabilityDefinitionRecord["stage"]; + owner: LimeCapabilityDefinitionRecord["owner"]; + methods: string[]; + summary: string; + enabled: boolean; + implementation: HostCapabilityProfile["capabilities"][string]["implementation"]; + unavailableReason?: "disabled" | "not_implemented" | "planned"; +} + +type CapabilityProfileSupport = HostCapabilityProfile["capabilities"][string]; + +interface RuntimeModelProjection { + taskId: string; + taskKind: string; + status: AgentAppTaskRecord["status"]; + startedAt: string; + finishedAt?: string; + model: AgentAppRuntimeProcessModel; +} + +interface RuntimeUsageProjection { + taskId: string; + taskKind: string; + status: AgentAppTaskRecord["status"]; + startedAt: string; + finishedAt?: string; + usage: AgentAppRuntimeProcessUsage; + model: AgentAppRuntimeProcessModel; +} + +interface RuntimeCostProjection { + taskId: string; + taskKind: string; + status: AgentAppTaskRecord["status"]; + startedAt: string; + finishedAt?: string; + cost: AgentAppRuntimeProcessCost; + model: AgentAppRuntimeProcessModel; +} + +interface RuntimeSkillProjection { + skillId: string; + name: string; + status: "declared" | "invoked"; + taskCount: number; + invocationCount: number; + taskIds: string[]; + taskKinds: string[]; + lastSeenAt: string; + source: "agent_runtime_process"; +} + +interface RuntimeSkillInvocationProjection { + invocationId: string; + skillId: string; + name: string; + taskId: string; + taskKind: string; + status: AgentAppTaskRecord["status"]; + startedAt: string; + finishedAt?: string; + source: "agent_runtime_process"; +} + +interface RuntimeMemoryProjection { + taskId: string; + taskKind: string; + status: AgentAppTaskRecord["status"]; + startedAt: string; + finishedAt?: string; + scope: "task"; + knowledgeBindingKeys: string[]; + contextCompactionCount: number; + pendingRequestCount: number; + source: "agent_runtime_projection"; +} + +interface RuntimeContextProjection { + taskId: string; + traceId: string; + taskKind: string; + status: AgentAppTaskRecord["status"]; + startedAt: string; + finishedAt?: string; + workspaceId?: string; + threadId?: string; + turnIds: string[]; + knowledgeBindingKeys: string[]; + toolKeys: string[]; + fileRefs: string[]; + inputAttached: boolean; + expectedOutputAttached: boolean; + pendingRequestCount: number; + source: "agent_runtime_projection"; +} + +type ToolIntegrationCapability = + | "lime.search" + | "lime.browser" + | "lime.documents" + | "lime.media" + | "lime.mcp" + | "lime.terminal" + | "lime.connectors"; + +interface RuntimeToolRunProjection { + runId: string; + capability: ToolIntegrationCapability; + toolName: string; + taskId: string; + taskKind: string; + status: AgentAppTaskRecord["status"] | "declared" | "observed"; + startedAt: string; + finishedAt?: string; + title: string; + statusText: string; + message: string; + detail?: string; + source: "agent_runtime_process"; +} + +interface RuntimeMcpToolProjection { + toolName: string; + serverId: string; + toolId: string; + runIds: string[]; + taskIds: string[]; + lastSeenAt: string; + source: "agent_runtime_process"; +} + +interface RuntimeConnectorProjection { + connectorId: string; + actionIds: string[]; + runIds: string[]; + taskIds: string[]; + lastSeenAt: string; + source: "agent_runtime_process"; +} + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } @@ -114,6 +286,28 @@ function readInputRecord( ); } +function readOptionalInputRecord( + request: AgentAppHostBridgeCapabilityRequest, +): Record { + if (isRecord(request.input)) { + return request.input; + } + if (request.input !== undefined) { + throw new AgentAppCapabilityDispatcherError( + "INVALID_CAPABILITY_INPUT", + `${request.capability}.${request.method} requires an input object.`, + ); + } + if (isRecord(request.invokeRequest?.args)) { + return request.invokeRequest.args; + } + const firstArg = request.args?.[0]; + if (isRecord(firstArg)) { + return firstArg; + } + return {}; +} + function readStringParam( request: AgentAppHostBridgeCapabilityRequest, key: string, @@ -136,6 +330,21 @@ function readStringParam( return value; } +function readOptionalStringParam( + request: AgentAppHostBridgeCapabilityRequest, + key: string, + argIndex: number, +): string | undefined { + const fromInput = isRecord(request.input) + ? readString(request.input[key]) + : undefined; + const fromInvokeArgs = isRecord(request.invokeRequest?.args) + ? readString(request.invokeRequest.args[key]) + : undefined; + const fromArgs = readString(request.args?.[argIndex]); + return fromInput ?? fromInvokeArgs ?? fromArgs; +} + function hasOwn(value: Record, key: string): boolean { return Object.prototype.hasOwnProperty.call(value, key); } @@ -166,7 +375,9 @@ function resolveClawCapabilityId(value: string): string | null { return descriptor?.capabilityId ?? null; } -function collectRequestedClawCapabilityIds(input: Record): string[] { +function collectRequestedClawCapabilityIds( + input: Record, +): string[] { const requested = [ ...normalizeStringList(input.requiredCapabilities), ...normalizeStringList(input.capabilityHints), @@ -202,7 +413,9 @@ function toolRequirementDeclaresClawCapability( ): boolean { const toolKeyToken = capabilityMatchToken(tool.key); if ( - descriptor.aliases.some((alias) => capabilityMatchToken(alias) === toolKeyToken) + descriptor.aliases.some( + (alias) => capabilityMatchToken(alias) === toolKeyToken, + ) ) { return true; } @@ -210,7 +423,8 @@ function toolRequirementDeclaresClawCapability( return false; } return tool.capabilities.some( - (capability) => resolveClawCapabilityId(capability) === descriptor.capabilityId, + (capability) => + resolveClawCapabilityId(capability) === descriptor.capabilityId, ); } @@ -437,10 +651,9 @@ async function dispatchAgent( request.method === "respondAction" ) { return sdk.agent.submitHostResponse( - readInputRecord( - request, - request.method, - ) as unknown as Parameters[0], + readInputRecord(request, request.method) as unknown as Parameters< + typeof sdk.agent.submitHostResponse + >[0], ); } if (request.method === "listTasks") { @@ -458,15 +671,1554 @@ function throwUnsupportedMethod( ); } +function resolveCapabilityDefinition( + name: string, +): LimeCapabilityDefinitionRecord { + const definition = LIME_CAPABILITY_DEFINITIONS.find( + (item) => item.name === name, + ); + if (!definition) { + throw new AgentAppCapabilityDispatcherError( + "CAPABILITY_NOT_FOUND", + `${name} is not a known Lime capability.`, + ); + } + return definition; +} + +function buildCapabilityDiscoveryEntry( + definition: LimeCapabilityDefinitionRecord, + profile: HostCapabilityProfile, +): CapabilityDiscoveryEntry { + const support = resolveCapabilityProfileSupport(definition, profile); + const implementation = support?.implementation ?? "none"; + const enabled = support?.enabled === true && implementation !== "none"; + const unavailableReason = enabled + ? undefined + : String(definition.stage) === "planned" + ? "planned" + : implementation === "none" + ? "not_implemented" + : "disabled"; + + const entry: CapabilityDiscoveryEntry = { + name: definition.name, + version: support?.version ?? definition.version, + group: definition.group, + stage: definition.stage, + owner: definition.owner, + methods: [...definition.methods], + summary: definition.summary, + enabled, + implementation, + }; + return unavailableReason ? { ...entry, unavailableReason } : entry; +} + +function resolveCapabilityProfileSupport( + definition: LimeCapabilityDefinitionRecord, + profile: HostCapabilityProfile, +): CapabilityProfileSupport | undefined { + const support = profile.capabilities[definition.name]; + if (definition.name !== "lime.capabilities") { + return support; + } + return { + version: support?.version ?? definition.version, + enabled: true, + implementation: "native", + }; +} + +function readAgentRuntimeTaskContract( + agentRuntime: unknown, +): Record { + if (!isRecord(agentRuntime)) { + return {}; + } + const task = isRecord(agentRuntime.agentTask) + ? agentRuntime.agentTask + : isRecord(agentRuntime.agent_task) + ? agentRuntime.agent_task + : isRecord(agentRuntime.task) + ? agentRuntime.task + : {}; + return { + eventSchema: readString(task.eventSchema), + resultSchema: readString(task.resultSchema), + structuredOutput: + isRecord(task.structuredOutput) || + isRecord(agentRuntime.structuredOutput), + approval: isRecord(task.approval) || isRecord(agentRuntime.approval), + sessionPolicy: + isRecord(task.sessionPolicy) || isRecord(agentRuntime.sessionPolicy), + toolDiscovery: + isRecord(task.toolDiscovery) || isRecord(agentRuntime.toolDiscovery), + checkpointScope: + isRecord(task.checkpointScope) || isRecord(agentRuntime.checkpointScope), + observability: + isRecord(task.observability) || isRecord(agentRuntime.observability), + }; +} + +function countLayerItems(value: unknown, itemKey: string): number { + if (Array.isArray(value)) { + return value.length; + } + if (!isRecord(value)) { + return 0; + } + const items = value[itemKey]; + return Array.isArray(items) ? items.length : 0; +} + +function hasLayerValue(value: unknown, itemKey: string): boolean { + if (countLayerItems(value, itemKey) > 0) { + return true; + } + return isRecord(value) && Object.keys(value).length > 0; +} + +function isManifestStandardVersion( + manifestVersion: string | undefined, + standardVersion: string, +): boolean { + return ( + manifestVersion === standardVersion || + Boolean(manifestVersion?.startsWith(`${standardVersion}.`)) + ); +} + +function readCapabilityHandoffContract(params: { + manifestVersion?: string; + requirements?: unknown; + boundary?: unknown; + integrations?: unknown; + operations?: unknown; +}): Record { + const requirementCount = countLayerItems(params.requirements, "requirements"); + const boundaryCount = countLayerItems(params.boundary, "boundaries"); + const integrationCount = countLayerItems(params.integrations, "integrations"); + const operationCount = countLayerItems(params.operations, "operations"); + const hasCapabilityHandoff = + isManifestStandardVersion(params.manifestVersion, "0.7") || + hasLayerValue(params.requirements, "requirements") || + hasLayerValue(params.boundary, "boundaries") || + hasLayerValue(params.integrations, "integrations") || + hasLayerValue(params.operations, "operations"); + + return { + version: "0.7", + enabled: hasCapabilityHandoff, + manifestVersion: params.manifestVersion, + layerFiles: [ + "app.requirements.yaml", + "app.boundary.yaml", + "app.integrations.yaml", + "app.operations.yaml", + ], + requirementCount, + boundaryCount, + integrationCount, + operationCount, + hostCloudManagedExecution: true, + externalSideEffectsRequireApproval: true, + appCredentialsBoundary: "host_or_cloud_managed", + }; +} + +function buildAgentAppStandardProfile(params: { + manifestVersion?: string; + agentRuntime?: unknown; + requirements?: unknown; + boundary?: unknown; + integrations?: unknown; + operations?: unknown; +}): Record { + const hasAgentRuntime = isRecord(params.agentRuntime); + return { + layeredManifest: { + version: "0.5", + enabled: true, + layerFiles: [ + "app.capabilities.yaml", + "app.entries.yaml", + "app.permissions.yaml", + "app.errors.yaml", + "app.i18n.yaml", + "app.signature.yaml", + "evals/readiness.yaml", + "evals/health.yaml", + ], + }, + agentRuntime: { + version: "0.6", + enabled: hasAgentRuntime, + manifestVersion: params.manifestVersion, + layerFiles: ["app.runtime.yaml"], + ...readAgentRuntimeTaskContract(params.agentRuntime), + }, + requirementBoundary: readCapabilityHandoffContract(params), + }; +} + +function dispatchCapabilities( + request: AgentAppHostBridgeCapabilityRequest, + profile: HostCapabilityProfile, + standardProfile: { + manifestVersion?: string; + agentRuntime?: unknown; + requirements?: unknown; + boundary?: unknown; + integrations?: unknown; + operations?: unknown; + } = {}, +): unknown { + if (request.method === "list") { + return LIME_CAPABILITY_DEFINITIONS.map((definition) => + buildCapabilityDiscoveryEntry(definition, profile), + ); + } + if (request.method === "get") { + const capability = readStringParam(request, "capability", 0); + return buildCapabilityDiscoveryEntry( + resolveCapabilityDefinition(capability), + profile, + ); + } + if (request.method === "getProfile") { + const agentRuntime = standardProfile.agentRuntime ?? profile.agentRuntime; + const payload: Record = { + appRuntimeVersion: profile.appRuntimeVersion, + standardVersions: profile.standardVersions ?? { + current: currentAgentAppStandardVersion, + compatible: [...compatibleAgentAppStandardVersions], + }, + runtimeTargets: [...profile.runtimeTargets], + capabilities: Object.fromEntries( + LIME_CAPABILITY_DEFINITIONS.map((definition) => [ + definition.name, + buildCapabilityDiscoveryEntry(definition, profile), + ]), + ), + standards: buildAgentAppStandardProfile({ + manifestVersion: standardProfile.manifestVersion, + agentRuntime, + requirements: standardProfile.requirements, + boundary: standardProfile.boundary, + integrations: standardProfile.integrations, + operations: standardProfile.operations, + }), + featureFlags: { ...profile.featureFlags }, + }; + if (agentRuntime !== undefined) { + payload.agentRuntime = agentRuntime; + } + if (standardProfile.requirements !== undefined) { + payload.requirements = standardProfile.requirements; + } + if (standardProfile.boundary !== undefined) { + payload.boundary = standardProfile.boundary; + } + if (standardProfile.integrations !== undefined) { + payload.integrations = standardProfile.integrations; + } + if (standardProfile.operations !== undefined) { + payload.operations = standardProfile.operations; + } + return payload; + } + throwUnsupportedMethod(request); +} + +function readTaskRuntimeProcess(task: AgentAppTaskRecord) { + return task.runtimeProcess ?? task.process ?? null; +} + +function hasRoutedModel( + model: AgentAppRuntimeProcessModel | null | undefined, +): model is AgentAppRuntimeProcessModel { + if (!model) { + return false; + } + return Boolean(model.provider || model.model); +} + +function numberValue(value: unknown): number | undefined { + const number = Number(value); + return Number.isFinite(number) ? number : undefined; +} + +function integerValue(value: unknown): number { + const number = numberValue(value); + return number === undefined ? 0 : Math.max(0, Math.floor(number)); +} + +function recordString( + record: Record | null | undefined, + key: string, +): string | undefined { + return record ? readString(record[key]) : undefined; +} + +function recordArray( + record: Record | null | undefined, + key: string, +): unknown[] { + const value = record?.[key]; + return Array.isArray(value) ? value : []; +} + +function sortTasksByNewest(tasks: AgentAppTaskRecord[]): AgentAppTaskRecord[] { + return [...tasks].sort((left, right) => + String(right.finishedAt ?? right.startedAt).localeCompare( + String(left.finishedAt ?? left.startedAt), + ), + ); +} + +function filterRuntimeProjectionTasks( + host: CapabilityHost, + request: AgentAppHostBridgeCapabilityRequest, +): AgentAppTaskRecord[] { + const input = readOptionalInputRecord(request); + const taskId = + readString(input.taskId) ?? readOptionalStringParam(request, "taskId", 0); + const taskKind = readString(input.taskKind); + return sortTasksByNewest( + host + .getTasks({ + appId: request.appId, + entryKey: request.entryKey, + }) + .filter((task) => !taskId || task.taskId === taskId) + .filter((task) => !taskKind || task.taskKind === taskKind), + ); +} + +function buildModelProjection( + task: AgentAppTaskRecord, +): RuntimeModelProjection | null { + const process = readTaskRuntimeProcess(task); + if (!hasRoutedModel(process?.model)) { + return null; + } + return { + taskId: task.taskId, + taskKind: task.taskKind, + status: task.status, + startedAt: task.startedAt, + finishedAt: task.finishedAt, + model: process.model, + }; +} + +function buildUsageProjection( + task: AgentAppTaskRecord, +): RuntimeUsageProjection | null { + const process = readTaskRuntimeProcess(task); + if (!process?.usage) { + return null; + } + return { + taskId: task.taskId, + taskKind: task.taskKind, + status: task.status, + startedAt: task.startedAt, + finishedAt: task.finishedAt, + usage: process.usage, + model: process.model, + }; +} + +function buildCostProjection( + task: AgentAppTaskRecord, +): RuntimeCostProjection | null { + const process = readTaskRuntimeProcess(task); + if (!process?.cost) { + return null; + } + return { + taskId: task.taskId, + taskKind: task.taskKind, + status: task.status, + startedAt: task.startedAt, + finishedAt: task.finishedAt, + cost: process.cost, + model: process.model, + }; +} + +function aggregateUsage( + items: RuntimeUsageProjection[], +): AgentAppRuntimeProcessUsage { + return items.reduce( + (total, item) => ({ + inputTokens: + total.inputTokens + + (numberValue(item.usage.inputTokens ?? item.usage.input_tokens) ?? 0), + outputTokens: + total.outputTokens + + (numberValue(item.usage.outputTokens ?? item.usage.output_tokens) ?? 0), + totalTokens: + total.totalTokens + + (numberValue(item.usage.totalTokens ?? item.usage.total_tokens) ?? 0), + cachedInputTokens: + (total.cachedInputTokens ?? 0) + + (numberValue( + item.usage.cachedInputTokens ?? item.usage.cached_input_tokens, + ) ?? 0), + cacheCreationInputTokens: + (total.cacheCreationInputTokens ?? 0) + + (numberValue( + item.usage.cacheCreationInputTokens ?? + item.usage.cache_creation_input_tokens, + ) ?? 0), + }), + { + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, + cachedInputTokens: 0, + cacheCreationInputTokens: 0, + }, + ); +} + +function aggregateCost(items: RuntimeCostProjection[]) { + const costs = items + .map((item) => + numberValue( + item.cost.estimatedTotalCost ?? + item.cost.estimated_total_cost ?? + item.cost.totalCost ?? + item.cost.total_cost, + ), + ) + .filter((value): value is number => value !== undefined); + return { + estimatedTotalCost: costs.reduce((total, value) => total + value, 0), + currency: readString(items[0]?.cost.currency) ?? "unknown", + }; +} + +function uniqueModelSummaries(items: RuntimeModelProjection[]) { + const summaries = new Map< + string, + AgentAppRuntimeProcessModel & { + taskCount: number; + taskKinds: string[]; + lastTaskId: string; + lastSeenAt: string; + } + >(); + items.forEach((item) => { + const key = `${item.model.provider}\u0000${item.model.model}\u0000${item.model.label}`; + const existing = summaries.get(key); + if (existing) { + existing.taskCount += 1; + existing.taskKinds = Array.from( + new Set([...existing.taskKinds, item.taskKind]), + ).sort(); + if (String(item.finishedAt ?? item.startedAt) > existing.lastSeenAt) { + existing.lastTaskId = item.taskId; + existing.lastSeenAt = String(item.finishedAt ?? item.startedAt); + } + return; + } + summaries.set(key, { + ...item.model, + taskCount: 1, + taskKinds: [item.taskKind], + lastTaskId: item.taskId, + lastSeenAt: String(item.finishedAt ?? item.startedAt), + }); + }); + return Array.from(summaries.values()).sort((left, right) => + right.lastSeenAt.localeCompare(left.lastSeenAt), + ); +} + +function normalizeSkillName(value: string): string { + return value.trim(); +} + +function buildRuntimeSkillProjection( + tasks: AgentAppTaskRecord[], +): RuntimeSkillProjection[] { + const summaries = new Map(); + tasks.forEach((task) => { + const process = readTaskRuntimeProcess(task); + const declared = new Set( + (process?.skillNames ?? []).map(normalizeSkillName).filter(Boolean), + ); + const invoked = new Set( + (process?.invokedSkillNames ?? []) + .map(normalizeSkillName) + .filter(Boolean), + ); + new Set([...declared, ...invoked]).forEach((name) => { + const existing = summaries.get(name); + const lastSeenAt = String(task.finishedAt ?? task.startedAt); + const status = invoked.has(name) ? "invoked" : "declared"; + if (existing) { + existing.taskCount += 1; + existing.invocationCount += invoked.has(name) ? 1 : 0; + existing.status = + existing.status === "invoked" || status === "invoked" + ? "invoked" + : "declared"; + existing.taskIds = Array.from( + new Set([...existing.taskIds, task.taskId]), + ); + existing.taskKinds = Array.from( + new Set([...existing.taskKinds, task.taskKind]), + ).sort(); + if (lastSeenAt > existing.lastSeenAt) { + existing.lastSeenAt = lastSeenAt; + } + return; + } + summaries.set(name, { + skillId: name, + name, + status, + taskCount: 1, + invocationCount: invoked.has(name) ? 1 : 0, + taskIds: [task.taskId], + taskKinds: [task.taskKind], + lastSeenAt, + source: "agent_runtime_process", + }); + }); + }); + return Array.from(summaries.values()).sort((left, right) => + right.lastSeenAt.localeCompare(left.lastSeenAt), + ); +} + +function buildRuntimeSkillInvocations( + tasks: AgentAppTaskRecord[], +): RuntimeSkillInvocationProjection[] { + return tasks.flatMap((task) => { + const process = readTaskRuntimeProcess(task); + return (process?.invokedSkillNames ?? []) + .map(normalizeSkillName) + .filter(Boolean) + .map((name) => ({ + invocationId: `${task.taskId}:${name}`, + skillId: name, + name, + taskId: task.taskId, + taskKind: task.taskKind, + status: task.status, + startedAt: task.startedAt, + finishedAt: task.finishedAt, + source: "agent_runtime_process" as const, + })); + }); +} + +const TOOL_INTEGRATION_SPECS: Record< + ToolIntegrationCapability, + { + keywords: string[]; + toolHints: string[]; + reason: string; + } +> = { + "lime.search": { + keywords: ["search", "websearch", "research", "deepresearch", "citation"], + toolHints: ["lime.capability.research.search", "web_search"], + reason: "search_execution_requires_lime_agent_task", + }, + "lime.browser": { + keywords: [ + "browser", + "chrome", + "webpage", + "readpage", + "screenshot", + "navigate", + ], + toolHints: ["browser", "read_page", "screenshot"], + reason: "browser_runtime_execution_requires_lime_tool_runtime_policy", + }, + "lime.documents": { + keywords: ["document", "pdf", "docx", "word", "markdown", "ppt", "pptx"], + toolHints: ["document_parser", "pdf.read"], + reason: "document_runtime_execution_requires_lime_tool_runtime_policy", + }, + "lime.media": { + keywords: [ + "media", + "image", + "audio", + "voice", + "video", + "transcribe", + "synthesize", + "tts", + ], + toolHints: ["image_generation", "audio_transcription", "voice_synthesis"], + reason: "media_runtime_execution_requires_lime_tool_runtime_policy", + }, + "lime.mcp": { + keywords: ["mcp", "mcpserver", "mcp__"], + toolHints: ["mcp__server__tool"], + reason: "mcp_execution_requires_lime_tool_runtime_policy", + }, + "lime.terminal": { + keywords: ["terminal", "shell", "command", "bash", "powershell", "cmd"], + toolHints: ["terminal.run"], + reason: "terminal_execution_requires_lime_sandbox_policy", + }, + "lime.connectors": { + keywords: ["connector", "connectors", "integration", "notion", "slack"], + toolHints: ["connector.invoke"], + reason: "connector_execution_requires_lime_policy_and_secret_binding", + }, +}; + +const TOOL_INTENT_KEYS = new Set([ + "action", + "artifactId", + "command", + "connectorId", + "cwdRef", + "depth", + "format", + "fullPage", + "input", + "limit", + "operation", + "options", + "prompt", + "quality", + "query", + "ref", + "reason", + "runId", + "selector", + "serverId", + "sessionId", + "size", + "style", + "text", + "tool", + "url", + "voice", +]); + +function classifyToolIntegrationCapability( + value: string, +): ToolIntegrationCapability | null { + const token = capabilityMatchToken(value); + if (!token) { + return null; + } + if (token.includes("mcp")) { + return "lime.mcp"; + } + if ( + token.includes("terminal") || + token.includes("shell") || + token.includes("powershell") + ) { + return "lime.terminal"; + } + if (token.includes("connector")) { + return "lime.connectors"; + } + const capability = ( + Object.entries(TOOL_INTEGRATION_SPECS) as Array< + [ + ToolIntegrationCapability, + (typeof TOOL_INTEGRATION_SPECS)[ToolIntegrationCapability], + ] + > + ).find(([, spec]) => + spec.keywords.some((keyword) => + token.includes(capabilityMatchToken(keyword)), + ), + )?.[0]; + return capability ?? null; +} + +function normalizeToolIntegrationName(value: string): string { + return value + .replace(/^Tool\s*[·:]\s*/i, "") + .replace(/^执行参数流\s*[·:]\s*/i, "") + .replace(/^执行结果流\s*[·:]\s*/i, "") + .trim(); +} + +function buildDeclaredToolRun( + task: AgentAppTaskRecord, + toolName: string, +): RuntimeToolRunProjection | null { + const capability = classifyToolIntegrationCapability(toolName); + if (!capability) { + return null; + } + return { + runId: `${task.taskId}:${capability}:${capabilityMatchToken(toolName)}:declared`, + capability, + toolName, + taskId: task.taskId, + taskKind: task.taskKind, + status: "declared", + startedAt: task.startedAt, + finishedAt: task.finishedAt, + title: `Tool intent · ${toolName}`, + statusText: "已声明", + message: + "Agent App task 声明了该 ToolRuntime intent,实际执行仍由 Lime AgentRuntime 管理。", + source: "agent_runtime_process", + }; +} + +function buildTimelineToolRun( + task: AgentAppTaskRecord, + item: AgentAppRuntimeProcessTimelineItem, + index: number, +): RuntimeToolRunProjection | null { + const surface = [item.title, item.message, item.detail, item.meta] + .filter(Boolean) + .join(" "); + const capability = classifyToolIntegrationCapability(surface); + if (!capability) { + return null; + } + const toolName = normalizeToolIntegrationName( + item.title || item.meta || surface, + ); + return { + runId: + readString(item.meta) ?? + `${task.taskId}:${capability}:${capabilityMatchToken(toolName)}:${index}`, + capability, + toolName, + taskId: task.taskId, + taskKind: task.taskKind, + status: task.status === "running" ? "observed" : task.status, + startedAt: task.startedAt, + finishedAt: task.finishedAt, + title: item.title, + statusText: item.statusText, + message: item.message, + detail: item.detail, + source: "agent_runtime_process", + }; +} + +function buildRuntimeToolRuns( + tasks: AgentAppTaskRecord[], + capability?: ToolIntegrationCapability, +): RuntimeToolRunProjection[] { + const runs = tasks.flatMap((task) => { + const process = readTaskRuntimeProcess(task); + const declared = task.tools + .map((toolName) => buildDeclaredToolRun(task, toolName)) + .filter((item): item is RuntimeToolRunProjection => Boolean(item)); + const observed = (process?.timeline ?? []) + .filter((item) => item.kind === "tool" || item.kind === "execution") + .map((item, index) => buildTimelineToolRun(task, item, index)) + .filter((item): item is RuntimeToolRunProjection => Boolean(item)); + return [...observed, ...declared]; + }); + return runs + .filter((run) => !capability || run.capability === capability) + .sort((left, right) => + String(right.finishedAt ?? right.startedAt).localeCompare( + String(left.finishedAt ?? left.startedAt), + ), + ); +} + +function readToolIntent( + input: Record, +): Record { + return Object.fromEntries( + Object.entries(input).filter(([key]) => TOOL_INTENT_KEYS.has(key)), + ); +} + +function buildToolIntentResponse( + request: AgentAppHostBridgeCapabilityRequest, + capability: ToolIntegrationCapability, + input: Record, + runs: RuntimeToolRunProjection[], +): unknown { + const spec = TOOL_INTEGRATION_SPECS[capability]; + return { + appId: request.appId, + capability, + method: request.method, + status: "requires_agent_task", + reason: spec.reason, + source: "tool_runtime_policy", + intent: readToolIntent(input), + toolHints: spec.toolHints, + matchingRuns: runs, + next: { + capability: "lime.agent", + method: "startTask", + reason: "actual_tool_execution_is_owned_by_lime_agent_runtime", + }, + }; +} + +function readToolRun( + request: AgentAppHostBridgeCapabilityRequest, + runs: RuntimeToolRunProjection[], +): RuntimeToolRunProjection { + const runId = readStringParam(request, "runId", 0); + const run = runs.find( + (item) => item.runId === runId || item.taskId === runId, + ); + if (!run) { + throw new AgentAppCapabilityDispatcherError( + "TOOL_RUN_NOT_FOUND", + `${runId} was not found in AgentRuntime tool projection.`, + ); + } + return run; +} + +function parseMcpToolName(toolName: string): { + serverId: string; + toolId: string; +} { + const normalized = toolName + .replace(/^Tool\s*[·:]\s*/i, "") + .replace(/^mcp[:./-]/i, "mcp__") + .trim(); + const match = /^mcp__([^_]+)__(.+)$/i.exec(normalized); + if (match) { + return { + serverId: match[1], + toolId: match[2], + }; + } + return { + serverId: "unknown", + toolId: normalized || toolName, + }; +} + +function buildRuntimeMcpTools( + runs: RuntimeToolRunProjection[], +): RuntimeMcpToolProjection[] { + const tools = new Map(); + runs.forEach((run) => { + const parsed = parseMcpToolName(run.toolName); + const key = `${parsed.serverId}\u0000${parsed.toolId}`; + const lastSeenAt = String(run.finishedAt ?? run.startedAt); + const existing = tools.get(key); + if (existing) { + existing.runIds = Array.from(new Set([...existing.runIds, run.runId])); + existing.taskIds = Array.from(new Set([...existing.taskIds, run.taskId])); + if (lastSeenAt > existing.lastSeenAt) { + existing.lastSeenAt = lastSeenAt; + } + return; + } + tools.set(key, { + toolName: run.toolName, + serverId: parsed.serverId, + toolId: parsed.toolId, + runIds: [run.runId], + taskIds: [run.taskId], + lastSeenAt, + source: "agent_runtime_process", + }); + }); + return Array.from(tools.values()).sort((left, right) => + right.lastSeenAt.localeCompare(left.lastSeenAt), + ); +} + +function parseConnectorToolName(toolName: string): { + connectorId: string; + actionId?: string; +} { + const normalized = toolName + .replace(/^Tool\s*[·:]\s*/i, "") + .replace(/^connector[:./-]/i, "connector__") + .trim(); + const match = /^connector__([^_]+)__(.+)$/i.exec(normalized); + if (match) { + return { + connectorId: match[1], + actionId: match[2], + }; + } + return { + connectorId: normalized || toolName, + }; +} + +function buildRuntimeConnectors( + runs: RuntimeToolRunProjection[], +): RuntimeConnectorProjection[] { + const connectors = new Map(); + runs.forEach((run) => { + const parsed = parseConnectorToolName(run.toolName); + const connectorId = parsed.connectorId; + const lastSeenAt = String(run.finishedAt ?? run.startedAt); + const existing = connectors.get(connectorId); + if (existing) { + existing.actionIds = Array.from( + new Set( + parsed.actionId + ? [...existing.actionIds, parsed.actionId] + : existing.actionIds, + ), + ).sort(); + existing.runIds = Array.from(new Set([...existing.runIds, run.runId])); + existing.taskIds = Array.from(new Set([...existing.taskIds, run.taskId])); + if (lastSeenAt > existing.lastSeenAt) { + existing.lastSeenAt = lastSeenAt; + } + return; + } + connectors.set(connectorId, { + connectorId, + actionIds: parsed.actionId ? [parsed.actionId] : [], + runIds: [run.runId], + taskIds: [run.taskId], + lastSeenAt, + source: "agent_runtime_process", + }); + }); + return Array.from(connectors.values()).sort((left, right) => + right.lastSeenAt.localeCompare(left.lastSeenAt), + ); +} + +function readTaskThreadRead( + task: AgentAppTaskRecord, +): Record | null { + if (!isRecord(task.result)) { + return null; + } + return isRecord(task.result.threadRead) + ? task.result.threadRead + : task.result; +} + +function readTaskThreadDiagnostics( + task: AgentAppTaskRecord, +): Record | null { + const threadRead = readTaskThreadRead(task); + return isRecord(threadRead?.diagnostics) ? threadRead.diagnostics : null; +} + +function readKnowledgeBindingKeys(task: AgentAppTaskRecord): string[] { + return task.knowledge + .map((binding) => binding.key.trim()) + .filter(Boolean) + .sort(); +} + +function readThreadTurnIds( + threadRead: Record | null, +): string[] { + return recordArray(threadRead, "turns") + .filter(isRecord) + .map( + (turn) => + recordString(turn, "turn_id") ?? + recordString(turn, "turnId") ?? + recordString(turn, "id"), + ) + .filter((item): item is string => Boolean(item)); +} + +function buildRuntimeMemoryProjection( + task: AgentAppTaskRecord, +): RuntimeMemoryProjection { + const diagnostics = readTaskThreadDiagnostics(task); + return { + taskId: task.taskId, + taskKind: task.taskKind, + status: task.status, + startedAt: task.startedAt, + finishedAt: task.finishedAt, + scope: "task", + knowledgeBindingKeys: readKnowledgeBindingKeys(task), + contextCompactionCount: integerValue( + diagnostics?.context_compaction_count ?? + diagnostics?.contextCompactionCount, + ), + pendingRequestCount: integerValue( + diagnostics?.pending_request_count ?? diagnostics?.pendingRequestCount, + ), + source: "agent_runtime_projection", + }; +} + +function buildRuntimeContextProjection( + task: AgentAppTaskRecord, +): RuntimeContextProjection { + const threadRead = readTaskThreadRead(task); + const diagnostics = readTaskThreadDiagnostics(task); + return { + taskId: task.taskId, + traceId: task.traceId, + taskKind: task.taskKind, + status: task.status, + startedAt: task.startedAt, + finishedAt: task.finishedAt, + workspaceId: task.provenance.workspaceId, + threadId: + recordString(threadRead, "thread_id") ?? + recordString(threadRead, "threadId"), + turnIds: readThreadTurnIds(threadRead), + knowledgeBindingKeys: readKnowledgeBindingKeys(task), + toolKeys: [...task.tools].sort(), + fileRefs: [...task.files].sort(), + inputAttached: task.input !== undefined, + expectedOutputAttached: task.expectedOutput !== undefined, + pendingRequestCount: integerValue( + diagnostics?.pending_request_count ?? diagnostics?.pendingRequestCount, + ), + source: "agent_runtime_projection", + }; +} + +function dispatchModels( + host: CapabilityHost, + request: AgentAppHostBridgeCapabilityRequest, +): unknown { + const tasks = filterRuntimeProjectionTasks(host, request); + const routedTasks = tasks + .map(buildModelProjection) + .filter((item): item is RuntimeModelProjection => Boolean(item)); + if (request.method === "list") { + return { + appId: request.appId, + source: "agent_runtime_projection", + taskCount: tasks.length, + models: uniqueModelSummaries(routedTasks), + }; + } + if (request.method === "getRouting") { + return { + appId: request.appId, + source: "agent_runtime_projection", + taskCount: tasks.length, + routes: routedTasks, + }; + } + if (request.method === "select") { + const selected = routedTasks[0]; + return selected + ? { + status: "selected", + source: "latest_runtime_projection", + selected, + } + : { + status: "unavailable", + source: "latest_runtime_projection", + reason: "no_runtime_routing_facts", + }; + } + if (request.method === "estimateCost") { + const costs = tasks + .map(buildCostProjection) + .filter((item): item is RuntimeCostProjection => Boolean(item)); + return { + appId: request.appId, + status: costs.length + ? "estimated_from_runtime_projection" + : "insufficient_data", + source: "agent_runtime_projection", + sampleSize: costs.length, + cost: aggregateCost(costs), + }; + } + throwUnsupportedMethod(request); +} + +function dispatchSkills( + host: CapabilityHost, + request: AgentAppHostBridgeCapabilityRequest, +): unknown { + const input = readOptionalInputRecord(request); + const tasks = filterRuntimeProjectionTasks(host, request); + const skills = buildRuntimeSkillProjection(tasks); + if (request.method === "list") { + const kind = readString(input.kind); + return { + appId: request.appId, + source: "agent_runtime_process", + taskCount: tasks.length, + skills: kind + ? skills.filter( + (skill) => skill.status === kind || skill.source === kind, + ) + : skills, + }; + } + if (request.method === "resolve") { + const skillId = readStringParam(request, "skillId", 0); + const skill = skills.find((item) => item.skillId === skillId); + if (!skill) { + throw new AgentAppCapabilityDispatcherError( + "SKILL_NOT_FOUND", + `${skillId} was not found in AgentRuntime process projection.`, + ); + } + return skill; + } + if (request.method === "getInvocation") { + const invocationId = readStringParam(request, "invocationId", 0); + const invocation = buildRuntimeSkillInvocations(tasks).find( + (item) => item.invocationId === invocationId, + ); + if (!invocation) { + throw new AgentAppCapabilityDispatcherError( + "SKILL_INVOCATION_NOT_FOUND", + `${invocationId} was not found in AgentRuntime process projection.`, + ); + } + return invocation; + } + if (request.method === "bind" || request.method === "invoke") { + return { + status: "not_available", + reason: "skill_runtime_mutation_not_exposed_to_agent_apps", + source: "agent_runtime_process", + }; + } + throwUnsupportedMethod(request); +} + +function dispatchMemory( + host: CapabilityHost, + request: AgentAppHostBridgeCapabilityRequest, +): unknown { + const input = readOptionalInputRecord(request); + const tasks = filterRuntimeProjectionTasks(host, request); + const observations = tasks.map(buildRuntimeMemoryProjection); + if (request.method === "getStatus") { + return { + appId: request.appId, + scope: readString(input.scope) ?? "task", + status: "read_only_projection", + source: "agent_runtime_projection", + taskCount: tasks.length, + writable: false, + compactable: false, + totals: { + knowledgeBindingCount: observations.reduce( + (total, item) => total + item.knowledgeBindingKeys.length, + 0, + ), + contextCompactionCount: observations.reduce( + (total, item) => total + item.contextCompactionCount, + 0, + ), + pendingRequestCount: observations.reduce( + (total, item) => total + item.pendingRequestCount, + 0, + ), + }, + observations, + }; + } + if (request.method === "query") { + const query = readStringParam(request, "query", 0).toLowerCase(); + return { + appId: request.appId, + query, + status: "limited_projection", + source: "agent_runtime_projection", + records: observations.filter((item) => + [item.taskId, item.taskKind, ...item.knowledgeBindingKeys] + .join(" ") + .toLowerCase() + .includes(query), + ), + }; + } + if (request.method === "write" || request.method === "compact") { + return { + status: "not_available", + reason: "memory_runtime_mutation_not_exposed_to_agent_apps", + source: "agent_runtime_projection", + }; + } + throwUnsupportedMethod(request); +} + +function dispatchContext( + host: CapabilityHost, + request: AgentAppHostBridgeCapabilityRequest, +): unknown { + const input = readOptionalInputRecord(request); + const tasks = filterRuntimeProjectionTasks(host, request); + if (request.method === "getSnapshot") { + return { + appId: request.appId, + scope: readString(input.scope) ?? "task", + source: "agent_runtime_projection", + taskCount: tasks.length, + contexts: tasks.map(buildRuntimeContextProjection), + }; + } + if (request.method === "attach" || request.method === "detach") { + return { + status: "not_available", + reason: "context_mutation_not_exposed_to_agent_apps", + source: "agent_runtime_projection", + }; + } + throwUnsupportedMethod(request); +} + +function dispatchSearch( + host: CapabilityHost, + request: AgentAppHostBridgeCapabilityRequest, +): unknown { + const input = readOptionalInputRecord(request); + const tasks = filterRuntimeProjectionTasks(host, request); + const runs = buildRuntimeToolRuns(tasks, "lime.search"); + if (request.method === "getRun") { + return readToolRun(request, runs); + } + if (request.method === "query" || request.method === "deepResearch") { + readStringParam(request, "query", 0); + return buildToolIntentResponse(request, "lime.search", input, runs); + } + throwUnsupportedMethod(request); +} + +function dispatchBrowser( + host: CapabilityHost, + request: AgentAppHostBridgeCapabilityRequest, +): unknown { + const input = readOptionalInputRecord(request); + const tasks = filterRuntimeProjectionTasks(host, request); + const runs = buildRuntimeToolRuns(tasks, "lime.browser"); + if (request.method === "navigate") { + readStringParam(request, "sessionId", 0); + readStringParam(request, "url", 1); + } else if ( + request.method === "extract" || + request.method === "screenshot" || + request.method === "close" + ) { + readStringParam(request, "sessionId", 0); + } else if (request.method !== "open") { + throwUnsupportedMethod(request); + } + return buildToolIntentResponse(request, "lime.browser", input, runs); +} + +function dispatchDocuments( + host: CapabilityHost, + request: AgentAppHostBridgeCapabilityRequest, +): unknown { + const input = readOptionalInputRecord(request); + const tasks = filterRuntimeProjectionTasks(host, request); + const runs = buildRuntimeToolRuns(tasks, "lime.documents"); + if (request.method === "parse" || request.method === "summarize") { + readStringParam(request, "ref", 0); + } else if (request.method === "export") { + readStringParam(request, "artifactId", 0); + readStringParam(request, "format", 1); + } else if (request.method === "transform") { + readStringParam(request, "ref", 0); + readStringParam(request, "operation", 1); + } else { + throwUnsupportedMethod(request); + } + return buildToolIntentResponse(request, "lime.documents", input, runs); +} + +function dispatchMedia( + host: CapabilityHost, + request: AgentAppHostBridgeCapabilityRequest, +): unknown { + const input = readOptionalInputRecord(request); + const tasks = filterRuntimeProjectionTasks(host, request); + const runs = buildRuntimeToolRuns(tasks, "lime.media"); + if (request.method === "generateImage") { + readStringParam(request, "prompt", 0); + } else if (request.method === "editImage") { + readStringParam(request, "ref", 0); + readStringParam(request, "prompt", 1); + } else if (request.method === "transcribe") { + readStringParam(request, "ref", 0); + } else if (request.method === "synthesizeVoice") { + readStringParam(request, "text", 0); + } else { + throwUnsupportedMethod(request); + } + return buildToolIntentResponse(request, "lime.media", input, runs); +} + +function dispatchMcp( + host: CapabilityHost, + request: AgentAppHostBridgeCapabilityRequest, +): unknown { + const input = readOptionalInputRecord(request); + const tasks = filterRuntimeProjectionTasks(host, request); + const runs = buildRuntimeToolRuns(tasks, "lime.mcp"); + const tools = buildRuntimeMcpTools(runs); + if (request.method === "listServers") { + const servers = new Map< + string, + { + serverId: string; + toolCount: number; + runIds: string[]; + lastSeenAt: string; + source: "agent_runtime_process"; + } + >(); + tools.forEach((tool) => { + const existing = servers.get(tool.serverId); + if (existing) { + existing.toolCount += 1; + existing.runIds = Array.from( + new Set([...existing.runIds, ...tool.runIds]), + ); + if (tool.lastSeenAt > existing.lastSeenAt) { + existing.lastSeenAt = tool.lastSeenAt; + } + return; + } + servers.set(tool.serverId, { + serverId: tool.serverId, + toolCount: 1, + runIds: [...tool.runIds], + lastSeenAt: tool.lastSeenAt, + source: "agent_runtime_process", + }); + }); + return { + appId: request.appId, + status: "read_only_projection", + source: "agent_runtime_process", + servers: Array.from(servers.values()).sort((left, right) => + right.lastSeenAt.localeCompare(left.lastSeenAt), + ), + }; + } + if (request.method === "listTools") { + const serverId = readString(input.serverId); + return { + appId: request.appId, + status: "read_only_projection", + source: "agent_runtime_process", + tools: serverId + ? tools.filter((tool) => tool.serverId === serverId) + : tools, + }; + } + if (request.method === "invoke") { + readStringParam(request, "tool", 0); + return buildToolIntentResponse(request, "lime.mcp", input, runs); + } + throwUnsupportedMethod(request); +} + +function dispatchTerminal( + host: CapabilityHost, + request: AgentAppHostBridgeCapabilityRequest, +): unknown { + const input = readOptionalInputRecord(request); + const tasks = filterRuntimeProjectionTasks(host, request); + const runs = buildRuntimeToolRuns(tasks, "lime.terminal"); + if (request.method === "getRun") { + return readToolRun(request, runs); + } + if (request.method === "run") { + readStringParam(request, "command", 0); + return buildToolIntentResponse(request, "lime.terminal", input, runs); + } + if (request.method === "cancel") { + return { + status: "not_available", + reason: "terminal_runtime_cancellation_not_exposed_to_agent_apps", + source: "tool_runtime_policy", + }; + } + throwUnsupportedMethod(request); +} + +function dispatchConnectors( + host: CapabilityHost, + request: AgentAppHostBridgeCapabilityRequest, +): unknown { + const input = readOptionalInputRecord(request); + const tasks = filterRuntimeProjectionTasks(host, request); + const runs = buildRuntimeToolRuns(tasks, "lime.connectors"); + const connectors = buildRuntimeConnectors(runs); + if (request.method === "list") { + return { + appId: request.appId, + kind: readString(input.kind), + status: "read_only_projection", + source: "agent_runtime_process", + connectors, + }; + } + if (request.method === "getStatus") { + const connectorId = readStringParam(request, "connectorId", 0); + const connector = connectors.find( + (item) => item.connectorId === connectorId, + ); + return connector + ? { + connectorId, + status: "observed", + source: "agent_runtime_process", + connector, + } + : { + connectorId, + status: "not_connected", + reason: "no_connector_runtime_facts", + source: "agent_runtime_process", + }; + } + if (request.method === "requestAuth") { + readStringParam(request, "connectorId", 0); + return { + appId: request.appId, + capability: "lime.connectors", + method: request.method, + status: "requires_host_authorization", + reason: "connector_auth_requires_lime_policy_and_secret_binding", + source: "tool_runtime_policy", + intent: readToolIntent(input), + }; + } + if (request.method === "invoke") { + readStringParam(request, "connectorId", 0); + readStringParam(request, "action", 1); + return buildToolIntentResponse(request, "lime.connectors", input, runs); + } + throwUnsupportedMethod(request); +} + +function dispatchUsage( + host: CapabilityHost, + request: AgentAppHostBridgeCapabilityRequest, +): unknown { + const input = readOptionalInputRecord(request); + const tasks = filterRuntimeProjectionTasks(host, request); + const usageItems = tasks + .map(buildUsageProjection) + .filter((item): item is RuntimeUsageProjection => Boolean(item)); + const costItems = tasks + .map(buildCostProjection) + .filter((item): item is RuntimeCostProjection => Boolean(item)); + if (request.method === "getTokenUsage") { + return { + appId: request.appId, + taskId: readString(input.taskId), + window: readString(input.window), + source: "agent_runtime_projection", + taskCount: tasks.length, + totals: aggregateUsage(usageItems), + tasks: usageItems, + }; + } + if (request.method === "getCostSummary") { + return { + appId: request.appId, + taskId: readString(input.taskId), + window: readString(input.window), + source: "agent_runtime_projection", + taskCount: tasks.length, + cost: aggregateCost(costItems), + tasks: costItems, + }; + } + if (request.method === "getBudget") { + return { + appId: request.appId, + scope: readString(input.scope) ?? "app", + status: "not_configured", + reason: "no_agent_runtime_budget_facts", + source: "agent_runtime_projection", + observedCost: aggregateCost(costItems), + }; + } + throwUnsupportedMethod(request); +} + export function createAgentAppCapabilityDispatcher({ host, projection, entryKey, runId, + profile = p0HostCapabilityProfile, + manifestVersion, + agentRuntime, + requirements, + boundary, + integrations, + operations, }: CreateAgentAppCapabilityDispatcherOptions): AgentAppCapabilityDispatcher { return async (request) => { + if (request.capability === "lime.capabilities") { + return dispatchCapabilities(request, profile, { + manifestVersion, + agentRuntime, + requirements, + boundary, + integrations, + operations, + }); + } + assertCapabilityDeclared(projection, request, entryKey); + if (request.capability === "lime.models") { + return dispatchModels(host, request); + } + if (request.capability === "lime.usage") { + return dispatchUsage(host, request); + } + if (request.capability === "lime.skills") { + return dispatchSkills(host, request); + } + if (request.capability === "lime.memory") { + return dispatchMemory(host, request); + } + if (request.capability === "lime.context") { + return dispatchContext(host, request); + } + if (request.capability === "lime.search") { + return dispatchSearch(host, request); + } + if (request.capability === "lime.browser") { + return dispatchBrowser(host, request); + } + if (request.capability === "lime.documents") { + return dispatchDocuments(host, request); + } + if (request.capability === "lime.media") { + return dispatchMedia(host, request); + } + if (request.capability === "lime.mcp") { + return dispatchMcp(host, request); + } + if (request.capability === "lime.terminal") { + return dispatchTerminal(host, request); + } + if (request.capability === "lime.connectors") { + return dispatchConnectors(host, request); + } + const sdk = host.createSdkContext( request.entryKey ?? entryKey, resolveRunId(request, runId), diff --git a/src/features/agent-app/runtime/hostBridge.test.ts b/src/features/agent-app/runtime/hostBridge.test.ts index 89d1ae5d7..5e0547909 100644 --- a/src/features/agent-app/runtime/hostBridge.test.ts +++ b/src/features/agent-app/runtime/hostBridge.test.ts @@ -556,16 +556,24 @@ describe("AgentAppHostBridge", () => { requestId: "req-1", payload: expect.objectContaining({ ok: true, - value: { + value: expect.objectContaining({ taskId: "task-1", traceId: "trace-1", status: "running", - }, - result: { + process: expect.objectContaining({ + terminal: false, + collapsedByDefault: false, + }), + }), + result: expect.objectContaining({ taskId: "task-1", traceId: "trace-1", status: "running", - }, + runtimeProcess: expect.objectContaining({ + terminal: false, + collapsedByDefault: false, + }), + }), }), }), runtimeOrigin, @@ -642,6 +650,13 @@ describe("AgentAppHostBridge", () => { taskId: "task-1", bridgeAction: "contentFactoryProduction", events: [{ eventType: "task:progress", message: "任务正在执行" }], + process: expect.objectContaining({ + terminal: false, + collapsedByDefault: false, + timeline: expect.arrayContaining([ + expect.objectContaining({ message: "任务正在执行" }), + ]), + }), }), }), runtimeOrigin, @@ -734,6 +749,8 @@ describe("AgentAppHostBridge", () => { type: "task:progress", status: "running", message: "后端 runtime event 已到达", + streamKind: "thinking_delta", + delta: "先判断任务边界。", }, }); @@ -752,6 +769,12 @@ describe("AgentAppHostBridge", () => { message: "后端 runtime event 已到达", }), ], + process: expect.objectContaining({ + thinkingText: "先判断任务边界。", + timeline: expect.arrayContaining([ + expect.objectContaining({ title: "思考过程" }), + ]), + }), }), }), runtimeOrigin, @@ -774,6 +797,114 @@ describe("AgentAppHostBridge", () => { } }); + it("Host 已封装 runtimeProcess 时应优先转发 canonical 过程而不是从事件重建", async () => { + vi.useFakeTimers(); + const { frame, postMessage } = buildFrame(); + let runtimeHandler: + | ((event: { payload: unknown }) => void) + | undefined; + const listenRuntimeEventMock = vi.fn( + async ( + _eventName: string, + handler: (event: { payload: unknown }) => void, + ) => { + runtimeHandler = handler; + return vi.fn(); + }, + ); + const listenRuntimeEvent = + listenRuntimeEventMock as unknown as typeof import("@/lib/dev-bridge").safeListen; + const dispatchCapability = vi.fn().mockResolvedValue({ + taskId: "task-1", + taskStatus: "running", + taskEvents: [{ eventType: "task:progress", message: "已有订阅事件" }], + }); + const bridge = new AgentAppHostBridge({ + frame, + appId, + entryKey, + displayName: "内容工厂", + entryRoute: "/dashboard", + entryUrl, + dispatchCapability, + listenRuntimeEvent, + now: () => "2026-05-16T00:00:00.000Z", + }); + const cleanup = bridge.start(); + + try { + window.dispatchEvent( + new MessageEvent("message", { + data: buildAppMessage("capability:subscribe", { + capability: "lime.agent", + topic: "task", + input: { taskId: "task-1", bridgeAction: "contentFactoryProduction" }, + pollIntervalMs: 250, + }), + origin: runtimeOrigin, + source: frame.contentWindow, + }), + ); + await flushBridgeTasks(); + + runtimeHandler?.({ + payload: { + eventType: "task:runtimeEvent", + message: "底层事件只是增量", + runtimeProcess: { + timeline: [ + { + kind: "output", + title: "Host canonical 过程", + statusText: "流式输出", + message: "后端封装全文", + }, + ], + streamText: "后端封装全文", + thinkingText: "后端思考", + executionText: "后端执行", + skillNames: ["article-writer"], + invokedSkillNames: ["article-writer"], + model: { provider: "openai", model: "gpt-4.1", label: "openai/gpt-4.1" }, + usage: { inputTokens: 12, outputTokens: 8, totalTokens: 20 }, + cost: { estimatedTotalCost: 0.0002, currency: "USD" }, + terminal: false, + collapsedByDefault: false, + routingCount: 1, + executionCount: 1, + artifactCount: 0, + }, + }, + }); + + expect(postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "capability:event", + payload: expect.objectContaining({ + eventType: "task:runtimeEvent", + process: expect.objectContaining({ + streamText: "后端封装全文", + thinkingText: "后端思考", + executionText: "后端执行", + usage: { inputTokens: 12, outputTokens: 8, totalTokens: 20 }, + timeline: [ + expect.objectContaining({ + title: "Host canonical 过程", + message: "后端封装全文", + }), + ], + }), + }), + }), + runtimeOrigin, + ); + } finally { + cleanup(); + vi.useRealTimers(); + } + }); + + it("成功终态未带 artifact 时应继续短轮询直到最终 patch replay", async () => { vi.useFakeTimers(); const { frame, postMessage } = buildFrame(); diff --git a/src/features/agent-app/runtime/hostBridge.ts b/src/features/agent-app/runtime/hostBridge.ts index 05cff8b16..96838bc12 100644 --- a/src/features/agent-app/runtime/hostBridge.ts +++ b/src/features/agent-app/runtime/hostBridge.ts @@ -19,6 +19,8 @@ import { type LimeCapabilityName, } from "../sdk/capabilityContract"; import { toLimeCapabilityError } from "../sdk/capabilityErrors"; +import type { AgentAppRuntimeProcessView } from "../types"; +import { buildAgentRuntimeProcessView } from "./agentRuntimeProcess"; export const AGENT_APP_BRIDGE_PROTOCOL = "lime.agentApp.bridge"; export const AGENT_APP_BRIDGE_VERSION = 1; @@ -75,6 +77,40 @@ export interface AgentAppHostBridgeNotifyPayload { level: "info" | "success" | "warning" | "error"; } +export type AgentAppHostAgentRunUiMode = "drawer" | "modal" | "page"; + +export interface AgentAppHostAgentRunUiRequest { + taskId?: string; + bridgeAction?: string; + title?: string; + mode?: AgentAppHostAgentRunUiMode; + expectedOutput?: unknown; + runtimeProcess?: unknown; + runtimeFacts?: unknown; + task?: unknown; + snapshot?: unknown; + events?: unknown[]; +} + +export interface AgentAppHostAgentRunUiOpenResult { + opened: true; + surface: "host_agent_run"; + mode: AgentAppHostAgentRunUiMode; + taskId?: string; +} + +export interface AgentAppHostAgentRunUiUpdateResult { + updated: true; + surface: "host_agent_run"; + taskId?: string; +} + +export interface AgentAppHostAgentRunUiCloseResult { + closed: true; + surface: "host_agent_run"; + taskId?: string; +} + export interface AgentAppHostBridgeCapabilityRequest { appId: string; entryKey?: string; @@ -100,6 +136,15 @@ export interface CreateAgentAppHostBridgeOptions { locale?: string; notify?: (payload: AgentAppHostBridgeNotifyPayload) => void; openExternal?: (url: string) => void; + openAgentRunUi?: ( + request: AgentAppHostAgentRunUiRequest, + ) => AgentAppHostAgentRunUiOpenResult; + updateAgentRunUi?: ( + request: AgentAppHostAgentRunUiRequest, + ) => AgentAppHostAgentRunUiUpdateResult; + closeAgentRunUi?: ( + request: Pick, + ) => AgentAppHostAgentRunUiCloseResult; capabilities?: AgentAppHostBridgeCapabilities; dispatchCapability?: ( request: AgentAppHostBridgeCapabilityRequest, @@ -249,6 +294,38 @@ function readTaskEventsFromValue(value: unknown): unknown[] { return artifactEvents; } +function taskEventIdentity(event: unknown, index: number): string { + if (!isRecord(event)) { + return `event:${index}`; + } + const stableParts = [ + readString(event, "eventId"), + readString(event, "id"), + readString(event, "type") ?? readString(event, "eventType"), + readString(event, "requestId"), + readString(event, "message"), + readString(event, "occurredAt") ?? readString(event, "at"), + ].filter(Boolean); + return stableParts.length ? stableParts.join(":") : `event:${index}`; +} + +function mergeTaskEvents(...groups: unknown[][]): unknown[] { + const seen = new Set(); + const merged: unknown[] = []; + groups.flat().forEach((event, index) => { + if (!event) { + return; + } + const key = taskEventIdentity(event, index); + if (seen.has(key)) { + return; + } + seen.add(key); + merged.push(event); + }); + return merged; +} + function readArtifactsFromValue(value: unknown): Record[] { if (!isRecord(value)) { return []; @@ -363,6 +440,9 @@ interface AgentAppTaskSubscription { timerId?: number; inFlight: boolean; terminalArtifactReplayPolls: number; + events: unknown[]; + latestTask?: unknown; + process?: AgentAppRuntimeProcessView; } function collectCssVariableTokens(root: HTMLElement): Record { @@ -533,6 +613,15 @@ export class AgentAppHostBridge { private readonly locale?: string; private readonly notify?: (payload: AgentAppHostBridgeNotifyPayload) => void; private readonly openExternal?: (url: string) => void; + private readonly openAgentRunUi?: ( + request: AgentAppHostAgentRunUiRequest, + ) => AgentAppHostAgentRunUiOpenResult; + private readonly updateAgentRunUi?: ( + request: AgentAppHostAgentRunUiRequest, + ) => AgentAppHostAgentRunUiUpdateResult; + private readonly closeAgentRunUi?: ( + request: Pick, + ) => AgentAppHostAgentRunUiCloseResult; private readonly capabilities?: AgentAppHostBridgeCapabilities; private readonly dispatchCapability?: ( request: AgentAppHostBridgeCapabilityRequest, @@ -559,6 +648,9 @@ export class AgentAppHostBridge { this.locale = options.locale; this.notify = options.notify; this.openExternal = options.openExternal; + this.openAgentRunUi = options.openAgentRunUi; + this.updateAgentRunUi = options.updateAgentRunUi; + this.closeAgentRunUi = options.closeAgentRunUi; this.capabilities = options.capabilities; this.dispatchCapability = options.dispatchCapability; this.listenRuntimeEvent = options.listenRuntimeEvent ?? safeListen; @@ -720,12 +812,6 @@ export class AgentAppHostBridge { private async handleCapabilityInvoke( message: LimeAgentAppBridgeMessage, ): Promise> { - if (!this.dispatchCapability) { - throw new AgentAppHostBridgeActionError( - "CAPABILITY_BLOCKED", - "Capability invocation is not enabled for this Agent App runtime.", - ); - } if (!isRecord(message.payload)) { throw new AgentAppHostBridgeActionError( "INVALID_PAYLOAD", @@ -793,13 +879,177 @@ export class AgentAppHostBridge { if (provenance) { request.provenance = provenance; } - const result = await this.dispatchCapability(request); + const uiResult = this.handleUiCapabilityInvoke(request); + if (uiResult) { + return { + ...createLimeCapabilitySuccessResponse(uiResult), + result: uiResult, + }; + } + if (!this.dispatchCapability) { + throw new AgentAppHostBridgeActionError( + "CAPABILITY_BLOCKED", + "Capability invocation is not enabled for this Agent App runtime.", + ); + } + const result = this.enrichAgentCapabilityResult( + request, + await this.dispatchCapability(request), + ); return { ...createLimeCapabilitySuccessResponse(result), result, }; } + private handleUiCapabilityInvoke( + request: AgentAppHostBridgeCapabilityRequest, + ): unknown | null { + if (request.capability !== "lime.ui") { + return null; + } + if (request.method === "toast") { + this.handleToast(request.input); + return { accepted: true }; + } + if (request.method === "navigate") { + const url = this.resolveSameOriginActionUrl(request.input, ["route", "url"]); + window.setTimeout(() => { + if (!this.disposed) { + this.frame.src = url.href; + } + }, 0); + return { navigatedTo: url.pathname + url.search + url.hash }; + } + if (request.method === "openExternal") { + const url = this.resolveExternalUrl(request.input); + (this.openExternal ?? ((target) => window.open(target, "_blank", "noopener,noreferrer")))( + url.href, + ); + return { opened: true }; + } + if (request.method === "download") { + const url = this.resolveSameOriginActionUrl(request.input, ["url", "href"]); + this.downloadSameOriginUrl(url, request.input); + return { downloaded: true }; + } + if (request.method === "getSnapshot") { + return buildAgentAppHostSnapshot({ + appId: this.appId, + entryKey: this.entryKey, + displayName: this.displayName, + entryRoute: this.entryRoute, + entryUrl: this.entryUrl, + locale: this.locale, + now: this.now, + runtimeOrigin: this.runtimeOrigin, + capabilities: this.capabilities, + }) as unknown as Record; + } + if (request.method === "openAgentRun") { + return this.openAgentRunUi?.(this.normalizeAgentRunUiRequest(request.input)) ?? { + opened: true, + surface: "host_agent_run", + mode: "drawer", + taskId: this.readAgentRunUiTaskId(request.input), + }; + } + if (request.method === "updateAgentRun") { + return this.updateAgentRunUi?.(this.normalizeAgentRunUiRequest(request.input)) ?? { + updated: true, + surface: "host_agent_run", + taskId: this.readAgentRunUiTaskId(request.input), + }; + } + if (request.method === "closeAgentRun") { + const normalized = this.normalizeAgentRunUiRequest(request.input); + return this.closeAgentRunUi?.({ + taskId: normalized.taskId, + bridgeAction: normalized.bridgeAction, + }) ?? { + closed: true, + surface: "host_agent_run", + taskId: normalized.taskId, + }; + } + throw new AgentAppHostBridgeActionError( + "UNSUPPORTED_CAPABILITY_METHOD", + `${request.capability}.${request.method} is not supported by Agent App Host Bridge.`, + ); + } + + private normalizeAgentRunUiRequest(input: unknown): AgentAppHostAgentRunUiRequest { + if (!isRecord(input)) { + return {}; + } + const mode = readString(input, "mode"); + return { + taskId: this.readAgentRunUiTaskId(input), + bridgeAction: readString(input, "bridgeAction"), + title: readString(input, "title"), + mode: mode === "modal" || mode === "page" ? mode : "drawer", + expectedOutput: hasOwn(input, "expectedOutput") ? input.expectedOutput : undefined, + runtimeProcess: hasOwn(input, "runtimeProcess") + ? input.runtimeProcess + : hasOwn(input, "process") + ? input.process + : undefined, + runtimeFacts: hasOwn(input, "runtimeFacts") ? input.runtimeFacts : undefined, + task: hasOwn(input, "task") ? input.task : undefined, + snapshot: hasOwn(input, "snapshot") ? input.snapshot : undefined, + events: Array.isArray(input.events) ? input.events : undefined, + }; + } + + private readAgentRunUiTaskId(input: unknown): string | undefined { + if (!isRecord(input)) { + return undefined; + } + return ( + readString(input, "taskId") ?? + (isRecord(input.task) ? readString(input.task, "taskId") : undefined) ?? + (isRecord(input.snapshot) ? readString(input.snapshot, "taskId") : undefined) + ); + } + + private enrichAgentCapabilityResult( + request: AgentAppHostBridgeCapabilityRequest, + result: unknown, + ): unknown { + if (request.capability !== "lime.agent" || !isRecord(result)) { + return result; + } + if ( + isRecord(result.process) && + isRecord(result.runtimeProcess) + ) { + return result; + } + if ( + request.method !== "startTask" && + request.method !== "getTask" && + request.method !== "cancelTask" && + request.method !== "retryTask" + ) { + return result; + } + const process = buildAgentRuntimeProcessView({ + events: readTaskEventsFromValue(result), + task: result, + snapshot: result, + expectedOutput: + isRecord(request.input) && hasOwn(request.input, "expectedOutput") + ? request.input.expectedOutput + : undefined, + lastInput: request.input, + }); + return { + ...result, + runtimeProcess: isRecord(result.runtimeProcess) ? result.runtimeProcess : process, + process: isRecord(result.process) ? result.process : process, + }; + } + private async handleCapabilitySubscribe( message: LimeAgentAppBridgeMessage, ): Promise> { @@ -854,6 +1104,7 @@ export class AgentAppHostBridge { runtimeEventName, inFlight: false, terminalArtifactReplayPolls: 0, + events: [], }); void this.attachRuntimeEventSubscription(subscriptionId); void this.pollTaskSubscription(subscriptionId); @@ -944,6 +1195,11 @@ export class AgentAppHostBridge { return; } const events = readTaskEventsFromValue(payload); + const process = this.updateTaskSubscriptionProcess( + subscription, + payload, + events.length ? events : buildTaskEventsFromRuntimeEventPayload(payload), + ); this.postToApp("capability:event", { subscriptionId, capability: "lime.agent", @@ -954,10 +1210,62 @@ export class AgentAppHostBridge { runtimeEventName: subscription.runtimeEventName, runtimeEvent: payload, events: events.length ? events : buildTaskEventsFromRuntimeEventPayload(payload), + runtimeProcess: process, + process, emittedAt: (this.now ?? (() => new Date().toISOString()))(), }); } + private updateTaskSubscriptionProcess( + subscription: AgentAppTaskSubscription, + value: unknown, + events: unknown[], + ): AgentAppRuntimeProcessView { + if (isRecord(value) && (Array.isArray(value.events) || Array.isArray(value.taskEvents))) { + subscription.latestTask = value; + } + subscription.events = mergeTaskEvents(subscription.events, events); + const explicitProcess = this.readRuntimeProcess(value); + if (explicitProcess) { + subscription.process = explicitProcess; + return explicitProcess; + } + const process = buildAgentRuntimeProcessView({ + events: subscription.events, + task: subscription.latestTask, + snapshot: value, + }); + subscription.process = process; + return process; + } + + private readRuntimeProcess(value: unknown): AgentAppRuntimeProcessView | null { + if (!isRecord(value)) { + return null; + } + if (isRecord(value.runtimeProcess)) { + return value.runtimeProcess as unknown as AgentAppRuntimeProcessView; + } + if (isRecord(value.process)) { + return value.process as unknown as AgentAppRuntimeProcessView; + } + const task = isRecord(value.task) ? value.task : undefined; + if (isRecord(task?.runtimeProcess)) { + return task.runtimeProcess as unknown as AgentAppRuntimeProcessView; + } + if (isRecord(task?.process)) { + return task.process as unknown as AgentAppRuntimeProcessView; + } + const snapshot = isRecord(value.snapshot) ? value.snapshot : undefined; + if (isRecord(snapshot?.runtimeProcess)) { + return snapshot.runtimeProcess as unknown as AgentAppRuntimeProcessView; + } + if (isRecord(snapshot?.process)) { + return snapshot.process as unknown as AgentAppRuntimeProcessView; + } + return null; + } + private async pollTaskSubscription(subscriptionId: string): Promise { const subscription = this.taskSubscriptions.get(subscriptionId); if (!subscription || subscription.inFlight || this.disposed || !this.dispatchCapability) { @@ -968,6 +1276,12 @@ export class AgentAppHostBridge { const result = await this.dispatchCapability( this.buildTaskSubscriptionPollRequest(subscription), ); + const events = readTaskEventsFromValue(result); + const process = this.updateTaskSubscriptionProcess( + subscription, + result, + events, + ); this.postToApp("capability:event", { subscriptionId, capability: "lime.agent", @@ -976,7 +1290,9 @@ export class AgentAppHostBridge { taskId: subscription.taskId, bridgeAction: subscription.bridgeAction, task: result, - events: readTaskEventsFromValue(result), + events, + runtimeProcess: process, + process, emittedAt: (this.now ?? (() => new Date().toISOString()))(), }); const shouldPollForTerminalArtifact = diff --git a/src/features/agent-app/schema/referenceCliCrossCheck.test.ts b/src/features/agent-app/schema/referenceCliCrossCheck.test.ts index 06983fdfb..69b34f9c8 100644 --- a/src/features/agent-app/schema/referenceCliCrossCheck.test.ts +++ b/src/features/agent-app/schema/referenceCliCrossCheck.test.ts @@ -4,10 +4,13 @@ import { existsSync, readFileSync } from "node:fs"; import { join, resolve } from "node:path"; import { describe, expect, it } from "vitest"; import { parse as parseYaml } from "yaml"; -import { buildAgentAppInstallReview, buildLocalAgentAppSourceState } from "../install/installReview"; +import { + buildAgentAppInstallReview, + buildLocalAgentAppSourceState, +} from "../install/installReview"; import { buildInstalledAppPreview } from "../install/installedAppPreview"; import { normalizeManifest } from "../manifest/normalizeManifest"; -import { parseManifest } from "../manifest/parseManifest"; +import { mergeLayeredManifest } from "../manifest/parseManifest"; import { checkReadiness } from "../readiness/checkReadiness"; import { p0HostCapabilityProfile } from "../readiness/hostCapabilityProfile"; import { projectApp } from "../projection/projectApp"; @@ -19,7 +22,8 @@ import type { } from "../types"; const STANDARD_ROOT = resolve( - process.env.LIME_AGENTAPP_STANDARD_ROOT ?? resolve(process.cwd(), "../../limecloud/agentapp"), + process.env.LIME_AGENTAPP_STANDARD_ROOT ?? + resolve(process.cwd(), "../../limecloud/agentapp"), ); const REFERENCE_CLI = join(STANDARD_ROOT, "bin/agentapp-ref.mjs"); const STANDARD_CONTENT_FACTORY_APP = join( @@ -28,6 +32,17 @@ const STANDARD_CONTENT_FACTORY_APP = join( ); const PUBLIC_SCHEMAS = join(STANDARD_ROOT, "docs/public/schemas"); const LOADED_AT = "2026-05-15T00:00:00.000Z"; +const LAYERED_MANIFEST_FILES = [ + "app.capabilities.yaml", + "app.entries.yaml", + "app.permissions.yaml", + "app.errors.yaml", + "app.i18n.yaml", + "app.signature.yaml", + "app.runtime.yaml", + "evals/readiness.yaml", + "evals/health.yaml", +] as const; const SETUP_KINDS = new Set([ "skill", @@ -60,7 +75,8 @@ interface ReferenceProjection { manifestVersion: string; }; capabilityRequirements: { - capabilities: Record; + sdk?: string; + capabilities: Record | string[]; }; entries: ReferenceProjectionItem[]; storage?: { @@ -116,23 +132,39 @@ const describeIfReferenceAvailable = function runReferenceCli(command: "validate" | "project" | "readiness"): T { return JSON.parse( - execFileSync("node", [REFERENCE_CLI, command, STANDARD_CONTENT_FACTORY_APP], { - encoding: "utf8", - }), + execFileSync( + "node", + [REFERENCE_CLI, command, STANDARD_CONTENT_FACTORY_APP], + { + encoding: "utf8", + }, + ), ) as T; } function readPublicSchema(name: string): PublicJsonSchema { - return JSON.parse(readFileSync(join(PUBLIC_SCHEMAS, name), "utf8")) as PublicJsonSchema; + return JSON.parse( + readFileSync(join(PUBLIC_SCHEMAS, name), "utf8"), + ) as PublicJsonSchema; } function readStandardManifest(): AppManifest { - const appMarkdown = readFileSync(join(STANDARD_CONTENT_FACTORY_APP, "APP.md"), "utf8"); + const appMarkdown = readFileSync( + join(STANDARD_CONTENT_FACTORY_APP, "APP.md"), + "utf8", + ); const frontmatter = appMarkdown.match(/^---\n([\s\S]*?)\n---/); if (!frontmatter) { - throw new Error("Standard content-factory-app APP.md is missing YAML frontmatter."); + throw new Error( + "Standard content-factory-app APP.md is missing YAML frontmatter.", + ); } - return parseManifest(parseYaml(frontmatter[1])); + const layers = LAYERED_MANIFEST_FILES.map((relativePath) => + join(STANDARD_CONTENT_FACTORY_APP, relativePath), + ) + .filter(existsSync) + .map((path) => parseYaml(readFileSync(path, "utf8"))); + return mergeLayeredManifest(parseYaml(frontmatter[1]), layers); } function buildReferenceAlignedProfile( @@ -142,15 +174,14 @@ function buildReferenceAlignedProfile( ...p0HostCapabilityProfile.capabilities, }; - Object.keys(referenceProjection.capabilityRequirements.capabilities).forEach( - (capability) => { - capabilities[capability] = { - version: "0.3.0", - enabled: true, - implementation: "mock", - }; - }, - ); + referenceCapabilityKeys(referenceProjection).forEach((capability) => { + capabilities[capability] = { + version: + p0HostCapabilityProfile.capabilities[capability]?.version ?? "0.3.0", + enabled: true, + implementation: "mock", + }; + }); return { ...p0HostCapabilityProfile, @@ -206,11 +237,26 @@ function keys(items: ReferenceProjectionItem[]): string[] { return items.map((item) => String(item.key ?? item.id)).sort(); } -function setupCheckKey(item: { kind?: string; key?: string; required?: boolean }): string { +function referenceCapabilityKeys( + referenceProjection: ReferenceProjection, +): string[] { + const capabilities = referenceProjection.capabilityRequirements.capabilities; + return Array.isArray(capabilities) + ? [...capabilities].sort() + : Object.keys(capabilities).sort(); +} + +function setupCheckKey(item: { + kind?: string; + key?: string; + required?: boolean; +}): string { return `${item.kind}:${item.key}:${item.required === true}`; } -function toReferenceReadinessStatus(status: ReadinessStatus): ReferenceReadiness["status"] { +function toReferenceReadinessStatus( + status: ReadinessStatus, +): ReferenceReadiness["status"] { if (status === "ready") { return "ready"; } @@ -220,145 +266,210 @@ function toReferenceReadinessStatus(status: ReadinessStatus): ReferenceReadiness return "needs-setup"; } -describeIfReferenceAvailable("Agent App P17.2.5 standard reference cross-check", () => { - it("应能运行上游 reference CLI,并读取 public schema 作为字段事实源", () => { - const validation = runReferenceCli<{ - ok: boolean; - status: string; - manifestHash: string; - findings: unknown[]; - }>("validate"); - const projectionSchema = readPublicSchema("app-projection.schema.json"); - const readinessSchema = readPublicSchema("app-readiness.schema.json"); +describeIfReferenceAvailable( + "Agent App P17.2.5 standard reference cross-check", + () => { + it("应能运行上游 reference CLI,并读取 public schema 作为字段事实源", () => { + const validation = runReferenceCli<{ + ok: boolean; + status: string; + manifestHash: string; + findings: unknown[]; + }>("validate"); + const projectionSchema = readPublicSchema("app-projection.schema.json"); + const readinessSchema = readPublicSchema("app-readiness.schema.json"); - expect(validation).toMatchObject({ - ok: true, - status: "passed", - findings: [], + expect(validation).toMatchObject({ + ok: true, + status: "passed", + findings: [], + }); + expect(validation.manifestHash).toMatch(/^sha256:[a-f0-9]{64}$/); + expect(projectionSchema.required).toEqual( + expect.arrayContaining(["app", "entries", "provenance"]), + ); + expect(readinessSchema.required).toEqual( + expect.arrayContaining(["ok", "status", "checks"]), + ); }); - expect(validation.manifestHash).toMatch(/^sha256:[a-f0-9]{64}$/); - expect(projectionSchema.required).toEqual( - expect.arrayContaining(["app", "entries", "provenance"]), - ); - expect(readinessSchema.required).toEqual( - expect.arrayContaining(["ok", "status", "checks"]), - ); - }); - it("应把上游 projection schema 字段映射到 Lime projection,缺口必须显式登记", () => { - const projectionSchema = readPublicSchema("app-projection.schema.json"); - const referenceProjection = runReferenceCli("project"); - const { projection } = buildClientArtifacts(referenceProjection); - const limeProjectionFieldMap: Record = { - app: "app", - capabilityRequirements: "requiredCapabilities", - entries: "entries", - ui: "ui", - storage: "storage", - services: "services", - workflows: "workflows", - knowledgeTemplates: "knowledgeBindings", - toolRequirements: "toolRequirements", - artifactTypes: "artifactTypes", - evals: "evals", - events: "events", - secrets: "secrets", - overlayTemplates: "overlayTemplates", - lifecycle: "lifecycle", - provenance: "provenance", - }; - const acceptedDivergences: Record = { - ok: "reference CLI envelope field; Lime projection is not a CLI response.", - command: "reference CLI envelope field; Lime projection is not a CLI response.", - permissions: - "P17.2 exposes permissions through install review permissionCount; full permission projection is deferred to P17.3/P18.", - }; - const schemaFields = Object.keys(projectionSchema.properties ?? {}); - const unmappedFields = schemaFields.filter( - (field) => !limeProjectionFieldMap[field] && !acceptedDivergences[field], - ); + it("应把上游 projection schema 字段映射到 Lime projection,缺口必须显式登记", () => { + const projectionSchema = readPublicSchema("app-projection.schema.json"); + const referenceProjection = + runReferenceCli("project"); + const { projection } = buildClientArtifacts(referenceProjection); + const limeProjectionFieldMap: Record = { + app: "app", + capabilityRequirements: "requiredCapabilities", + entries: "entries", + ui: "ui", + storage: "storage", + services: "services", + workflows: "workflows", + knowledgeTemplates: "knowledgeBindings", + toolRequirements: "toolRequirements", + artifactTypes: "artifactTypes", + evals: "evals", + events: "events", + secrets: "secrets", + overlayTemplates: "overlayTemplates", + lifecycle: "lifecycle", + provenance: "provenance", + }; + const acceptedDivergences: Record = { + ok: "reference CLI envelope field; Lime projection is not a CLI response.", + command: + "reference CLI envelope field; Lime projection is not a CLI response.", + permissions: + "P17.2 exposes permissions through install review permissionCount; full permission projection is deferred to P17.3/P18.", + triggers: + "Agent App v0.5/v0.6 discovery metadata; Lime projection keeps runtime readiness first and will project discovery metadata in P18.7-C/F.", + quickstart: + "Agent App v0.5/v0.6 onboarding metadata; Lime setup guidance is still derived from readiness until P18.7-C/F.", + skills: + "Agent App v0.5/v0.6 skill package metadata; runtime skill binding projection is deferred to P18.7-D.", + publisher: + "Agent App v0.5/v0.6 marketplace metadata; install review currently keeps package identity only.", + author: + "Agent App v0.5/v0.6 marketplace metadata; install review currently keeps package identity only.", + maintainers: + "Agent App v0.5/v0.6 marketplace metadata; install review currently keeps package identity only.", + contributors: + "Agent App v0.5/v0.6 marketplace metadata; install review currently keeps package identity only.", + timeline: + "Agent App v0.5/v0.6 release metadata; package lifecycle projection is deferred until cloud release review.", + supportWindow: + "Agent App v0.5/v0.6 support metadata; host policy projection is deferred until marketplace install review.", + links: + "Agent App v0.5/v0.6 external links; current Lime projection avoids exposing external metadata before policy review.", + license: + "Agent App v0.5/v0.6 license metadata; install review currently keeps package identity only.", + support: + "Agent App v0.5/v0.6 support metadata; install review currently keeps package identity only.", + distribution: + "Agent App v0.5/v0.6 distribution metadata; cloud catalog projection owns this later.", + compliance: + "Agent App v0.5/v0.6 compliance metadata; enterprise policy projection owns this later.", + errors: + "reference CLI diagnostics field; Lime projection reports errors through validation/readiness surfaces.", + i18n: "Agent App v0.5/v0.6 localization metadata; Lime projection does not yet expose localized marketplace copy.", + signature: + "Agent App v0.5/v0.6 signature metadata; package verification owns signature state later.", + health: + "Agent App v0.5/v0.6 health metadata; runtime health projection is deferred to Host discovery.", + agentRuntime: + "Agent App v0.6 task runtime control plane is preserved on normalized manifest; projection into runtime policy is deferred to Agent App runtime execution.", + requirements: + "Agent App v0.7 requirement boundary is preserved on normalized manifest; projection into install/readiness UX is deferred to capability handoff review.", + boundary: + "Agent App v0.7 responsibility boundary is preserved on normalized manifest; projection into Host/Cloud setup UX is deferred to capability handoff review.", + integrations: + "Agent App v0.7 external integration requirements are preserved on normalized manifest; connector readiness projection is deferred to Host/Cloud setup review.", + operations: + "Agent App v0.7 operation side-effect policy is preserved on normalized manifest; execution gating remains Host policy responsibility.", + }; + const schemaFields = Object.keys(projectionSchema.properties ?? {}); + const unmappedFields = schemaFields.filter( + (field) => + !limeProjectionFieldMap[field] && !acceptedDivergences[field], + ); - expect(unmappedFields).toEqual([]); - expect(projection.app).toMatchObject({ - appId: referenceProjection.app.name, - version: referenceProjection.app.version, - status: referenceProjection.app.status, - appType: referenceProjection.app.appType, - description: referenceProjection.app.description, + expect(unmappedFields).toEqual([]); + expect(projection.app).toMatchObject({ + appId: referenceProjection.app.name, + version: referenceProjection.app.version, + status: referenceProjection.app.status, + appType: referenceProjection.app.appType, + description: referenceProjection.app.description, + }); + expect(projection.provenance).toMatchObject({ + appId: referenceProjection.provenance.appName, + appVersion: referenceProjection.provenance.appVersion, + packageHash: referenceProjection.provenance.packageHash, + manifestHash: referenceProjection.provenance.manifestHash, + }); + expect(projection.entries.map((entry) => entry.key).sort()).toEqual( + keys(referenceProjection.entries), + ); + expect( + projection.requiredCapabilities.map((item) => item.capability).sort(), + ).toEqual(referenceCapabilityKeys(referenceProjection)); + expect( + projection.knowledgeBindings.map((item) => item.key).sort(), + ).toEqual(keys(referenceProjection.knowledgeTemplates)); + expect( + projection.toolRequirements.map((item) => item.key).sort(), + ).toEqual(keys(referenceProjection.toolRequirements)); + expect(projection.artifactTypes.map((item) => item.key).sort()).toEqual( + keys(referenceProjection.artifactTypes), + ); + expect(projection.evals.map((item) => item.key).sort()).toEqual( + keys(referenceProjection.evals), + ); + expect(projection.services.map((item) => item.key).sort()).toEqual( + keys(referenceProjection.services), + ); + expect(projection.workflows.map((item) => item.key).sort()).toEqual( + keys(referenceProjection.workflows), + ); + expect(projection.secrets.map((item) => item.key).sort()).toEqual( + keys(referenceProjection.secrets), + ); + expect( + projection.overlayTemplates.map((item) => item.key).sort(), + ).toEqual(keys(referenceProjection.overlayTemplates)); + expect(projection.storage?.namespace).toBe( + referenceProjection.storage?.namespace, + ); }); - expect(projection.provenance).toMatchObject({ - appId: referenceProjection.provenance.appName, - appVersion: referenceProjection.provenance.appVersion, - packageHash: referenceProjection.provenance.packageHash, - manifestHash: referenceProjection.provenance.manifestHash, - }); - expect(projection.entries.map((entry) => entry.key).sort()).toEqual( - keys(referenceProjection.entries), - ); - expect(projection.requiredCapabilities.map((item) => item.capability).sort()).toEqual( - Object.keys(referenceProjection.capabilityRequirements.capabilities).sort(), - ); - expect(projection.knowledgeBindings.map((item) => item.key).sort()).toEqual( - keys(referenceProjection.knowledgeTemplates), - ); - expect(projection.toolRequirements.map((item) => item.key).sort()).toEqual( - keys(referenceProjection.toolRequirements), - ); - expect(projection.artifactTypes.map((item) => item.key).sort()).toEqual( - keys(referenceProjection.artifactTypes), - ); - expect(projection.evals.map((item) => item.key).sort()).toEqual( - keys(referenceProjection.evals), - ); - expect(projection.services.map((item) => item.key).sort()).toEqual( - keys(referenceProjection.services), - ); - expect(projection.workflows.map((item) => item.key).sort()).toEqual( - keys(referenceProjection.workflows), - ); - expect(projection.secrets.map((item) => item.key).sort()).toEqual( - keys(referenceProjection.secrets), - ); - expect(projection.overlayTemplates.map((item) => item.key).sort()).toEqual( - keys(referenceProjection.overlayTemplates), - ); - expect(projection.storage?.namespace).toBe(referenceProjection.storage?.namespace); - }); - it("应把 reference readiness checks 映射到 Lime readiness 和 install review descriptor", () => { - const referenceProjection = runReferenceCli("project"); - const referenceReadiness = runReferenceCli("readiness"); - const { readiness, review } = buildClientArtifacts(referenceProjection); - const referenceSetupChecks = referenceReadiness.checks - .filter((check) => SETUP_KINDS.has(check.kind)) - .map(setupCheckKey) - .sort(); - const limeSetupChecks = readiness.warnings - .filter((issue) => issue.kind && SETUP_KINDS.has(issue.kind)) - .map(setupCheckKey) - .sort(); + it("应把 reference readiness checks 映射到 Lime readiness 和 install review descriptor", () => { + const referenceProjection = + runReferenceCli("project"); + const referenceReadiness = + runReferenceCli("readiness"); + const { normalized, readiness, review } = + buildClientArtifacts(referenceProjection); + const referenceSetupChecks = referenceReadiness.checks + .filter((check) => SETUP_KINDS.has(check.kind)) + .map(setupCheckKey) + .sort(); + const limeSetupChecks = readiness.warnings + .filter((issue) => issue.kind && SETUP_KINDS.has(issue.kind)) + .map(setupCheckKey) + .sort(); - expect(toReferenceReadinessStatus(readiness.status)).toBe(referenceReadiness.status); - expect(limeSetupChecks).toEqual(referenceSetupChecks); - expect(readiness.supportedCapabilities.map((item) => item.capability).sort()).toEqual( - referenceReadiness.checks + expect(toReferenceReadinessStatus(readiness.status)).toBe( + referenceReadiness.status, + ); + expect(limeSetupChecks).toEqual(referenceSetupChecks); + const referenceCapabilityChecks = referenceReadiness.checks .filter((check) => check.kind === "capability") .map((check) => check.key) - .sort(), - ); - expect(review).toMatchObject({ - appId: referenceProjection.app.name, - version: referenceProjection.app.version, - manifestVersion: "0.3", - packageHash: referenceProjection.provenance.packageHash, - manifestHash: referenceProjection.provenance.manifestHash, - entryCount: referenceProjection.entries.length, - permissionCount: referenceProjection.permissions.length, - storageNamespace: referenceProjection.storage?.namespace, - readinessStatus: readiness.status, + .sort(); + expect( + readiness.supportedCapabilities.map((item) => item.capability).sort(), + ).toEqual( + referenceCapabilityChecks.length > 0 + ? referenceCapabilityChecks + : referenceCapabilityKeys(referenceProjection), + ); + expect(review).toMatchObject({ + appId: referenceProjection.app.name, + version: referenceProjection.app.version, + manifestVersion: normalized.manifestVersion, + packageHash: referenceProjection.provenance.packageHash, + manifestHash: referenceProjection.provenance.manifestHash, + entryCount: referenceProjection.entries.length, + storageNamespace: referenceProjection.storage?.namespace, + readinessStatus: readiness.status, + }); + expect(review.permissionCount).toBeGreaterThanOrEqual( + referenceProjection.permissions.length, + ); + expect(review.requiredCapabilityKeys.sort()).toEqual( + referenceCapabilityKeys(referenceProjection), + ); }); - expect(review.requiredCapabilityKeys.sort()).toEqual( - Object.keys(referenceProjection.capabilityRequirements.capabilities).sort(), - ); - }); -}); + }, +); diff --git a/src/features/agent-app/sdk/capabilityAdapters.test.ts b/src/features/agent-app/sdk/capabilityAdapters.test.ts index 47dda721c..71bd61c29 100644 --- a/src/features/agent-app/sdk/capabilityAdapters.test.ts +++ b/src/features/agent-app/sdk/capabilityAdapters.test.ts @@ -18,13 +18,19 @@ import { createLimeCoreCapabilityAdapters, LimeCapabilityAdapterError, } from "./capabilityAdapters"; +import { + LIME_CAPABILITY_DEFINITIONS, + getLimeCapabilityAdapterKey, +} from "./capabilityCatalog"; const recordProvenance = { sourceKind: "agent_app", appId: "content-factory-app", appVersion: "1.0.0", - packageHash: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - manifestHash: "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + packageHash: + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + manifestHash: + "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", entryKey: "dashboard", workflowRunId: "run-1", workspaceId: "workspace-1", @@ -169,6 +175,32 @@ const agentStreamEvents: AgentAppTaskStreamEvent[] = [ ]; describe("P18.3 / P18.4 core capability adapters", () => { + it("应按 catalog 生成所有 lime.* typed adapter,不再只覆盖局部能力", () => { + const adapters = createLimeCoreCapabilityAdapters({ + invoker: createLimeCapabilityInvoker(createMockLimeCapabilityTransport()), + provenance, + }); + + expect(Object.keys(adapters).sort()).toEqual( + LIME_CAPABILITY_DEFINITIONS.map((definition) => + getLimeCapabilityAdapterKey(definition.name), + ).sort(), + ); + LIME_CAPABILITY_DEFINITIONS.forEach((definition) => { + const adapter = adapters[ + getLimeCapabilityAdapterKey(definition.name) + ] as Record; + definition.methods.forEach((method) => { + expect(adapter[method]).toEqual(expect.any(Function)); + }); + }); + expect(adapters.storage.namespace).toBe(provenance.appId); + expect(adapters.models.estimateCost).toEqual(expect.any(Function)); + expect(adapters.skills.list).toEqual(expect.any(Function)); + expect(adapters.policy.check).toEqual(expect.any(Function)); + expect(adapters.usage.getTokenUsage).toEqual(expect.any(Function)); + }); + it("应把 ui / storage / artifacts / evidence / knowledge / tools 调用转成 typed request 并附加 provenance", async () => { const requests: LimeCapabilityInvokeRequest[] = []; const invoker = createLimeCapabilityInvoker( @@ -178,6 +210,15 @@ describe("P18.3 / P18.4 core capability adapters", () => { requests.push(request); return { accepted: true }; }, + openAgentRun: (request) => { + requests.push(request); + return { + opened: true, + surface: "host_agent_run", + mode: "drawer", + taskId: (request.args as { taskId?: string }).taskId, + }; + }, }, "lime.storage": { set: (request) => { @@ -261,6 +302,14 @@ describe("P18.3 / P18.4 core capability adapters", () => { { message: "已保存", level: "success" }, { requestId: "req-ui" }, ); + const agentRunUi = await adapters.ui.openAgentRun( + { + taskId: "agent-task-1", + title: "生成内容批次", + mode: "drawer", + }, + { requestId: "req-ui-agent-run" }, + ); const stored = await adapters.storage.set( { key: "drafts/scenario", value: { title: "内容场景草稿" } }, { @@ -294,6 +343,12 @@ describe("P18.3 / P18.4 core capability adapters", () => { ); expect(adapters.storage.namespace).toBe("content_factory_app"); + expect(agentRunUi).toEqual({ + opened: true, + surface: "host_agent_run", + mode: "drawer", + taskId: "agent-task-1", + }); expect(stored).toMatchObject({ key: "drafts/scenario", value: { title: "内容场景草稿" }, @@ -312,6 +367,12 @@ describe("P18.3 / P18.4 core capability adapters", () => { requestId: "req-ui", provenance, }), + expect.objectContaining({ + capability: "lime.ui", + method: "openAgentRun", + requestId: "req-ui-agent-run", + provenance, + }), expect.objectContaining({ capability: "lime.storage", method: "set", diff --git a/src/features/agent-app/sdk/capabilityAdapters.ts b/src/features/agent-app/sdk/capabilityAdapters.ts index 10bf67fd2..2d70077f1 100644 --- a/src/features/agent-app/sdk/capabilityAdapters.ts +++ b/src/features/agent-app/sdk/capabilityAdapters.ts @@ -7,6 +7,11 @@ import { type LimeCapabilityName, type LimeCapabilityValue, } from "./capabilityContract"; +import { + LIME_CAPABILITY_DEFINITIONS, + getLimeCapabilityAdapterKey, + type LimeCapabilityDefinitionRecord, +} from "./capabilityCatalog"; import type { LimeCapabilityError } from "./capabilityErrors"; export interface LimeCapabilityAdapterCallOptions { @@ -50,6 +55,14 @@ type CapabilityAdapterMethod< options?: LimeCapabilityAdapterCallOptions, ) => Promise>; +type OptionalArgsCapabilityAdapterMethod< + Capability extends LimeCapabilityName, + Method extends LimeCapabilityMethod, +> = ( + args?: LimeCapabilityArgs, + options?: LimeCapabilityAdapterCallOptions, +) => Promise>; + type NoArgsCapabilityAdapterMethod< Capability extends LimeCapabilityName, Method extends LimeCapabilityMethod, @@ -57,74 +70,91 @@ type NoArgsCapabilityAdapterMethod< options?: LimeCapabilityAdapterCallOptions, ) => Promise>; -export interface LimeUiCapabilityAdapter { - readonly toast: CapabilityAdapterMethod<"lime.ui", "toast">; - readonly navigate: CapabilityAdapterMethod<"lime.ui", "navigate">; - readonly openExternal: CapabilityAdapterMethod<"lime.ui", "openExternal">; - readonly download: CapabilityAdapterMethod<"lime.ui", "download">; - readonly getSnapshot: NoArgsCapabilityAdapterMethod< - "lime.ui", - "getSnapshot" +type CapabilityAdapterMethodFor< + Capability extends LimeCapabilityName, + Method extends LimeCapabilityMethod, +> = [LimeCapabilityArgs] extends [undefined] + ? NoArgsCapabilityAdapterMethod + : undefined extends LimeCapabilityArgs + ? OptionalArgsCapabilityAdapterMethod + : CapabilityAdapterMethod; + +export type LimeCapabilityAdapter = { + readonly [Method in LimeCapabilityMethod]: CapabilityAdapterMethodFor< + Capability, + Method >; -} +}; -export interface LimeStorageCapabilityAdapter { - readonly namespace: string; - readonly get: CapabilityAdapterMethod<"lime.storage", "get">; - readonly set: CapabilityAdapterMethod<"lime.storage", "set">; - readonly list: NoArgsCapabilityAdapterMethod<"lime.storage", "list">; - readonly delete: CapabilityAdapterMethod<"lime.storage", "delete">; -} +type LimeCapabilityAdapterKey = + Capability extends `lime.${infer Key}` ? Key : never; -export interface LimeAgentCapabilityAdapter { - readonly startTask: CapabilityAdapterMethod<"lime.agent", "startTask">; - readonly streamTask: CapabilityAdapterMethod<"lime.agent", "streamTask">; - readonly getTask: CapabilityAdapterMethod<"lime.agent", "getTask">; - readonly cancelTask: CapabilityAdapterMethod<"lime.agent", "cancelTask">; - readonly retryTask: CapabilityAdapterMethod<"lime.agent", "retryTask">; - readonly submitHostResponse: CapabilityAdapterMethod< - "lime.agent", - "submitHostResponse" - >; - readonly listTasks: NoArgsCapabilityAdapterMethod< - "lime.agent", - "listTasks" - >; -} +type LimeCapabilityAdapterFor = + Capability extends "lime.storage" + ? LimeCapabilityAdapter & { readonly namespace: string } + : LimeCapabilityAdapter; -export interface LimeArtifactsCapabilityAdapter { - readonly create: CapabilityAdapterMethod<"lime.artifacts", "create">; - readonly open: CapabilityAdapterMethod<"lime.artifacts", "open">; - readonly export: CapabilityAdapterMethod<"lime.artifacts", "export">; -} +export type LimeCoreCapabilityAdapters = { + readonly [Capability in LimeCapabilityName as LimeCapabilityAdapterKey]: LimeCapabilityAdapterFor; +}; -export interface LimeEvidenceCapabilityAdapter { - readonly record: CapabilityAdapterMethod<"lime.evidence", "record">; - readonly linkArtifact: CapabilityAdapterMethod< - "lime.evidence", - "linkArtifact" - >; -} +export type LimeUiCapabilityAdapter = LimeCapabilityAdapter<"lime.ui">; +export type LimeStorageCapabilityAdapter = + LimeCapabilityAdapterFor<"lime.storage">; +export type LimeFilesCapabilityAdapter = LimeCapabilityAdapter<"lime.files">; +export type LimeAgentCapabilityAdapter = LimeCapabilityAdapter<"lime.agent">; +export type LimeKnowledgeCapabilityAdapter = + LimeCapabilityAdapter<"lime.knowledge">; +export type LimeToolsCapabilityAdapter = LimeCapabilityAdapter<"lime.tools">; +export type LimeArtifactsCapabilityAdapter = + LimeCapabilityAdapter<"lime.artifacts">; +export type LimeWorkflowCapabilityAdapter = + LimeCapabilityAdapter<"lime.workflow">; +export type LimePolicyCapabilityAdapter = LimeCapabilityAdapter<"lime.policy">; +export type LimeSecretsCapabilityAdapter = + LimeCapabilityAdapter<"lime.secrets">; +export type LimeEvidenceCapabilityAdapter = + LimeCapabilityAdapter<"lime.evidence">; +export type LimeEventsCapabilityAdapter = LimeCapabilityAdapter<"lime.events">; +export type LimeCapabilitiesCapabilityAdapter = + LimeCapabilityAdapter<"lime.capabilities">; +export type LimeModelsCapabilityAdapter = LimeCapabilityAdapter<"lime.models">; +export type LimeUsageCapabilityAdapter = LimeCapabilityAdapter<"lime.usage">; +export type LimeMemoryCapabilityAdapter = LimeCapabilityAdapter<"lime.memory">; +export type LimeSkillsCapabilityAdapter = LimeCapabilityAdapter<"lime.skills">; +export type LimeMcpCapabilityAdapter = LimeCapabilityAdapter<"lime.mcp">; +export type LimeBrowserCapabilityAdapter = + LimeCapabilityAdapter<"lime.browser">; +export type LimeSearchCapabilityAdapter = LimeCapabilityAdapter<"lime.search">; +export type LimeDocumentsCapabilityAdapter = + LimeCapabilityAdapter<"lime.documents">; +export type LimeMediaCapabilityAdapter = LimeCapabilityAdapter<"lime.media">; +export type LimeTerminalCapabilityAdapter = + LimeCapabilityAdapter<"lime.terminal">; +export type LimeTasksCapabilityAdapter = LimeCapabilityAdapter<"lime.tasks">; +export type LimeSettingsCapabilityAdapter = + LimeCapabilityAdapter<"lime.settings">; +export type LimeWorkspaceCapabilityAdapter = + LimeCapabilityAdapter<"lime.workspace">; +export type LimeContextCapabilityAdapter = + LimeCapabilityAdapter<"lime.context">; +export type LimeConnectorsCapabilityAdapter = + LimeCapabilityAdapter<"lime.connectors">; +export type LimeAutomationCapabilityAdapter = + LimeCapabilityAdapter<"lime.automation">; +export type LimeReviewCapabilityAdapter = LimeCapabilityAdapter<"lime.review">; -export interface LimeKnowledgeCapabilityAdapter { - readonly search: CapabilityAdapterMethod<"lime.knowledge", "search">; - readonly bindStatus: CapabilityAdapterMethod<"lime.knowledge", "bindStatus">; -} - -export interface LimeToolsCapabilityAdapter { - readonly invoke: CapabilityAdapterMethod<"lime.tools", "invoke">; - readonly getProgress: CapabilityAdapterMethod<"lime.tools", "getProgress">; -} - -export interface LimeCoreCapabilityAdapters { - readonly ui: LimeUiCapabilityAdapter; - readonly storage: LimeStorageCapabilityAdapter; - readonly agent: LimeAgentCapabilityAdapter; - readonly artifacts: LimeArtifactsCapabilityAdapter; - readonly evidence: LimeEvidenceCapabilityAdapter; - readonly knowledge: LimeKnowledgeCapabilityAdapter; - readonly tools: LimeToolsCapabilityAdapter; -} +const NO_ARGS_CAPABILITY_METHOD_KEYS = new Set([ + "lime.ui.getSnapshot", + "lime.storage.list", + "lime.agent.listTasks", + "lime.events.listSubscriptions", + "lime.capabilities.list", + "lime.capabilities.getProfile", + "lime.mcp.listServers", + "lime.workspace.getCurrent", + "lime.workspace.list", +]); async function callCapability< Capability extends LimeCapabilityName, @@ -154,93 +184,63 @@ async function callCapability< throw new LimeCapabilityAdapterError(response.error); } +type BoundCapabilityCall = < + Capability extends LimeCapabilityName, + Method extends LimeCapabilityMethod, +>( + capability: Capability, + method: Method, + args: LimeCapabilityArgs | undefined, + callOptions?: LimeCapabilityAdapterCallOptions, +) => Promise>; + +function createCapabilityAdapter( + definition: LimeCapabilityDefinitionRecord & { name: Capability }, + call: BoundCapabilityCall, +): LimeCapabilityAdapter { + const adapter: Record = {}; + definition.methods.forEach((methodName) => { + const method = methodName as LimeCapabilityMethod; + const methodKey = `${definition.name}.${methodName}`; + adapter[methodName] = (argsOrOptions?: unknown, maybeOptions?: unknown) => { + if (NO_ARGS_CAPABILITY_METHOD_KEYS.has(methodKey)) { + return call( + definition.name, + method, + undefined, + argsOrOptions as LimeCapabilityAdapterCallOptions | undefined, + ); + } + return call( + definition.name, + method, + argsOrOptions as LimeCapabilityArgs, + maybeOptions as LimeCapabilityAdapterCallOptions | undefined, + ); + }; + }); + return adapter as LimeCapabilityAdapter; +} + export function createLimeCoreCapabilityAdapters( options: CreateLimeCoreCapabilityAdaptersOptions, ): LimeCoreCapabilityAdapters { const { invoker, provenance } = options; - const call = < - Capability extends LimeCapabilityName, - Method extends LimeCapabilityMethod, - >( - capability: Capability, - method: Method, - args: LimeCapabilityArgs | undefined, - callOptions?: LimeCapabilityAdapterCallOptions, - ) => - callCapability( - invoker, - provenance, - capability, - method, - args, - callOptions, - ); + const call: BoundCapabilityCall = (capability, method, args, callOptions) => + callCapability(invoker, provenance, capability, method, args, callOptions); + const adapters: Record = {}; - return { - ui: { - toast: (args, callOptions) => - call("lime.ui", "toast", args, callOptions), - navigate: (args, callOptions) => - call("lime.ui", "navigate", args, callOptions), - openExternal: (args, callOptions) => - call("lime.ui", "openExternal", args, callOptions), - download: (args, callOptions) => - call("lime.ui", "download", args, callOptions), - getSnapshot: (callOptions) => - call("lime.ui", "getSnapshot", undefined, callOptions), - }, - storage: { - namespace: options.storageNamespace ?? provenance?.appId ?? "agent_app", - get: (args, callOptions) => - call("lime.storage", "get", args, callOptions), - set: (args, callOptions) => - call("lime.storage", "set", args, callOptions), - list: (callOptions) => - call("lime.storage", "list", undefined, callOptions), - delete: (args, callOptions) => - call("lime.storage", "delete", args, callOptions), - }, - agent: { - startTask: (args, callOptions) => - call("lime.agent", "startTask", args, callOptions), - streamTask: (args, callOptions) => - call("lime.agent", "streamTask", args, callOptions), - getTask: (args, callOptions) => - call("lime.agent", "getTask", args, callOptions), - cancelTask: (args, callOptions) => - call("lime.agent", "cancelTask", args, callOptions), - retryTask: (args, callOptions) => - call("lime.agent", "retryTask", args, callOptions), - submitHostResponse: (args, callOptions) => - call("lime.agent", "submitHostResponse", args, callOptions), - listTasks: (callOptions) => - call("lime.agent", "listTasks", undefined, callOptions), - }, - artifacts: { - create: (args, callOptions) => - call("lime.artifacts", "create", args, callOptions), - open: (args, callOptions) => - call("lime.artifacts", "open", args, callOptions), - export: (args, callOptions) => - call("lime.artifacts", "export", args, callOptions), - }, - evidence: { - record: (args, callOptions) => - call("lime.evidence", "record", args, callOptions), - linkArtifact: (args, callOptions) => - call("lime.evidence", "linkArtifact", args, callOptions), - }, - knowledge: { - search: (args, callOptions) => - call("lime.knowledge", "search", args, callOptions), - bindStatus: (args, callOptions) => - call("lime.knowledge", "bindStatus", args, callOptions), - }, - tools: { - invoke: (args, callOptions) => - call("lime.tools", "invoke", args, callOptions), - getProgress: (args, callOptions) => - call("lime.tools", "getProgress", args, callOptions), - }, - }; + LIME_CAPABILITY_DEFINITIONS.forEach((definition) => { + const adapter = createCapabilityAdapter(definition, call); + adapters[getLimeCapabilityAdapterKey(definition.name)] = + definition.name === "lime.storage" + ? { + namespace: + options.storageNamespace ?? provenance?.appId ?? "agent_app", + ...adapter, + } + : adapter; + }); + + return adapters as LimeCoreCapabilityAdapters; } diff --git a/src/features/agent-app/sdk/capabilityCatalog.ts b/src/features/agent-app/sdk/capabilityCatalog.ts new file mode 100644 index 000000000..6cc3f8dd5 --- /dev/null +++ b/src/features/agent-app/sdk/capabilityCatalog.ts @@ -0,0 +1,502 @@ +import type { CapabilityImplementation } from "../types"; + +export type LimeCapabilityGroup = + | "app_surface" + | "data" + | "agent_runtime" + | "governance" + | "integration" + | "observability"; + +export type LimeCapabilityStage = "current" | "preview" | "planned"; + +export type LimeCapabilityOwner = + | "desktop_host" + | "agent_runtime" + | "tool_runtime" + | "knowledge_runtime" + | "artifact_runtime" + | "policy_runtime" + | "cloud_overlay"; + +export interface LimeCapabilityDefinition { + readonly name: string; + readonly version: string; + readonly group: LimeCapabilityGroup; + readonly stage: LimeCapabilityStage; + readonly owner: LimeCapabilityOwner; + readonly methods: readonly string[]; + readonly summary: string; + readonly appResponsibility: string; + readonly limeResponsibility: string; + readonly profile: { + readonly mock?: CapabilityImplementation; + readonly adapter?: CapabilityImplementation; + }; +} + +export interface LimeCapabilityProfileEntry { + readonly version: string; + readonly enabled: boolean; + readonly implementation: CapabilityImplementation; +} + +export const LIME_CAPABILITY_DEFINITIONS = [ + { + name: "lime.ui", + version: "0.3.0", + group: "app_surface", + stage: "current", + owner: "desktop_host", + methods: [ + "toast", + "navigate", + "openExternal", + "download", + "getSnapshot", + "openAgentRun", + "updateAgentRun", + "closeAgentRun", + ], + summary: "Lime 桌面壳的提示、导航、下载、主题、快照和统一 Agent Run UI 能力。", + appResponsibility: "决定业务页面如何展示和何时触发 Host action。", + limeResponsibility: "校验入口、同步主题语言、执行受控导航和下载,并提供通用 AI 运行现场。", + profile: { mock: "mock", adapter: "mock" }, + }, + { + name: "lime.storage", + version: "0.3.0", + group: "data", + stage: "current", + owner: "desktop_host", + methods: ["get", "set", "list", "delete"], + summary: "App namespace 下的结构化业务状态和轻量数据存储。", + appResponsibility: "定义业务对象、schema 和写回时机。", + limeResponsibility: "隔离 namespace、持久化数据、附加 App provenance。", + profile: { mock: "mock", adapter: "adapter" }, + }, + { + name: "lime.files", + version: "0.3.0", + group: "data", + stage: "current", + owner: "desktop_host", + methods: ["pick", "readRef", "parse"], + summary: "用户授权文件、file ref 读取和基础解析入口。", + appResponsibility: "声明需要的文件类型并把解析结果映射为业务草稿。", + limeResponsibility: "处理文件权限、引用生命周期、解析器和安全边界。", + profile: { mock: "mock", adapter: "mock" }, + }, + { + name: "lime.agent", + version: "0.3.0", + group: "agent_runtime", + stage: "current", + owner: "agent_runtime", + methods: [ + "startTask", + "streamTask", + "getTask", + "cancelTask", + "retryTask", + "submitHostResponse", + "listTasks", + ], + summary: "App-scoped Agent task、流式过程、追问、确认、取消和重试。", + appResponsibility: "组装业务输入、期望产物、人工确认和写回目标。", + limeResponsibility: + "复用 AgentRuntime、Skills、Tools、Evidence、模型和队列。", + profile: { mock: "mock", adapter: "adapter" }, + }, + { + name: "lime.knowledge", + version: "0.3.0", + group: "data", + stage: "current", + owner: "knowledge_runtime", + methods: ["search", "bindStatus", "bind", "export"], + summary: "项目知识、App knowledge binding、检索和版本导出。", + appResponsibility: "选择业务知识空间、解释检索结果并落到业务对象。", + limeResponsibility: "维护知识索引、权限、版本和引用 provenance。", + profile: { mock: "mock", adapter: "adapter" }, + }, + { + name: "lime.tools", + version: "0.3.0", + group: "agent_runtime", + stage: "current", + owner: "tool_runtime", + methods: ["invoke", "getProgress"], + summary: "Tool Broker / ToolHub 的受控工具调用与长任务状态。", + appResponsibility: "声明工具需求并消费结构化结果。", + limeResponsibility: "执行工具权限、审计、进度、超时和结果归一化。", + profile: { mock: "mock", adapter: "mock" }, + }, + { + name: "lime.artifacts", + version: "0.3.0", + group: "data", + stage: "current", + owner: "artifact_runtime", + methods: ["create", "open", "export", "list"], + summary: "产物创建、打开、导出、列表和 provenance。", + appResponsibility: "定义产物类型、标题、内容结构和业务状态联动。", + limeResponsibility: "持久化产物、管理 viewer/export、记录来源。", + profile: { mock: "mock", adapter: "adapter" }, + }, + { + name: "lime.workflow", + version: "0.3.0", + group: "agent_runtime", + stage: "current", + owner: "agent_runtime", + methods: ["start", "checkpoint", "awaitHuman"], + summary: "App workflow、checkpoint、后台任务和人类确认。", + appResponsibility: "定义业务步骤、状态机和人工介入点。", + limeResponsibility: "托管运行状态、恢复、权限和 Host response。", + profile: { mock: "mock", adapter: "mock" }, + }, + { + name: "lime.policy", + version: "0.3.0", + group: "governance", + stage: "current", + owner: "policy_runtime", + methods: ["check", "requestPermission"], + summary: "权限、风险、成本、数据和企业策略检查。", + appResponsibility: "说明为什么需要能力并响应被拒绝状态。", + limeResponsibility: "统一执行策略、授权、审计和降级。", + profile: {}, + }, + { + name: "lime.secrets", + version: "0.3.0", + group: "governance", + stage: "current", + owner: "policy_runtime", + methods: ["getRef", "requestBinding"], + summary: "OAuth、API key、外部平台凭证和 secret ref。", + appResponsibility: "声明凭证用途,只保存 ref,不读取明文。", + limeResponsibility: "托管凭证、授权弹窗、轮换和最小权限访问。", + profile: {}, + }, + { + name: "lime.evidence", + version: "0.3.0", + group: "observability", + stage: "current", + owner: "artifact_runtime", + methods: ["record", "linkArtifact", "list"], + summary: "来源、引用、工具调用、评估和发布证据。", + appResponsibility: "声明业务证据类型并把证据挂到产物/任务。", + limeResponsibility: "保证 evidence 可追溯、可导出、可审计。", + profile: { mock: "mock", adapter: "adapter" }, + }, + { + name: "lime.events", + version: "0.3.0", + group: "app_surface", + stage: "preview", + owner: "desktop_host", + methods: ["emit", "subscribe", "unsubscribe", "listSubscriptions"], + summary: "App UI、worker、Host 和 runtime 间的事件通道。", + appResponsibility: "消费业务事件并避免私有 postMessage 协议。", + limeResponsibility: "路由事件、隔离 namespace、控制订阅生命周期。", + profile: {}, + }, + { + name: "lime.capabilities", + version: "0.3.0", + group: "governance", + stage: "preview", + owner: "desktop_host", + methods: ["list", "get", "getProfile"], + summary: "Host capability catalog、版本、可用性和 readiness 摘要。", + appResponsibility: "按 profile 决定 UI 降级,不猜测底层实现。", + limeResponsibility: "发布单一能力目录,阻止 App 自建能力表。", + profile: {}, + }, + { + name: "lime.models", + version: "0.3.0", + group: "agent_runtime", + stage: "preview", + owner: "agent_runtime", + methods: ["list", "select", "getRouting", "estimateCost"], + summary: "模型列表、模型路由、能力约束和预估成本。", + appResponsibility: "表达任务偏好和质量/成本约束,不保存 provider 密钥。", + limeResponsibility: "统一模型事实源、路由、Provider 能力和成本估算。", + profile: { adapter: "adapter" }, + }, + { + name: "lime.usage", + version: "0.3.0", + group: "observability", + stage: "preview", + owner: "agent_runtime", + methods: ["getTokenUsage", "getCostSummary", "getBudget"], + summary: "Token、费用、预算、任务和 App 级用量归因。", + appResponsibility: "展示业务任务成本并响应预算拦截。", + limeResponsibility: "从 runtime request telemetry 聚合真实用量。", + profile: { adapter: "adapter" }, + }, + { + name: "lime.memory", + version: "0.3.0", + group: "agent_runtime", + stage: "preview", + owner: "agent_runtime", + methods: ["query", "write", "compact", "getStatus"], + summary: "工作记忆、长期记忆、团队记忆和上下文压缩。", + appResponsibility: "声明业务记忆意图和可写范围。", + limeResponsibility: "复用 memory_runtime / unified_memory 主链并处理压缩。", + profile: { adapter: "adapter" }, + }, + { + name: "lime.skills", + version: "0.3.0", + group: "agent_runtime", + stage: "preview", + owner: "agent_runtime", + methods: ["list", "resolve", "bind", "invoke", "getInvocation"], + summary: "Skill 注册、发现、绑定、启用状态和调用过程。", + appResponsibility: "声明必需 Skill 和业务场景,不复制 Skill runtime。", + limeResponsibility: + "管理 Skill catalog、workspace binding、runtime gate 和 evidence。", + profile: { adapter: "adapter" }, + }, + { + name: "lime.mcp", + version: "0.3.0", + group: "integration", + stage: "preview", + owner: "tool_runtime", + methods: ["listServers", "listTools", "invoke"], + summary: "MCP server、tool inventory 和受控调用。", + appResponsibility: "声明需要的 MCP capability,不直接连接 server。", + limeResponsibility: "复用 MCP bridge runtime、命名、权限和审计。", + profile: { adapter: "adapter" }, + }, + { + name: "lime.browser", + version: "0.3.0", + group: "integration", + stage: "preview", + owner: "tool_runtime", + methods: ["open", "navigate", "extract", "screenshot", "close"], + summary: "浏览器自动化、网页读取、截图和会话隔离。", + appResponsibility: "表达业务采集目标和用户授权语义。", + limeResponsibility: "托管浏览器 profile、权限、工具结果和回放证据。", + profile: { adapter: "adapter" }, + }, + { + name: "lime.search", + version: "0.3.0", + group: "integration", + stage: "preview", + owner: "tool_runtime", + methods: ["query", "deepResearch", "getRun"], + summary: "网页搜索、深度研究、来源和运行状态。", + appResponsibility: "给出业务问题、筛选规则和结果消费方式。", + limeResponsibility: "统一搜索 provider、来源去重、引用和成本。", + profile: { adapter: "adapter" }, + }, + { + name: "lime.documents", + version: "0.3.0", + group: "data", + stage: "preview", + owner: "tool_runtime", + methods: ["parse", "export", "transform", "summarize"], + summary: "PDF、Word、Markdown、PPT 等文档解析、转换和导出。", + appResponsibility: "定义业务文档类型和结构化落点。", + limeResponsibility: "执行解析器、格式转换、文件权限和 evidence。", + profile: { adapter: "adapter" }, + }, + { + name: "lime.media", + version: "0.3.0", + group: "integration", + stage: "preview", + owner: "tool_runtime", + methods: ["generateImage", "editImage", "transcribe", "synthesizeVoice"], + summary: "图片、音频、语音、视频素材的生成和处理。", + appResponsibility: "给出业务 brief、尺寸、风格和交付约束。", + limeResponsibility: "托管媒体 runtime、文件产物、安全策略和成本。", + profile: { adapter: "adapter" }, + }, + { + name: "lime.terminal", + version: "0.3.0", + group: "integration", + stage: "preview", + owner: "tool_runtime", + methods: ["run", "getRun", "cancel"], + summary: "命令执行、日志、取消、sandbox 和审批。", + appResponsibility: "声明命令目的和输入,不能绕过审批。", + limeResponsibility: "执行 sandbox、approval、日志和危险操作拦截。", + profile: { adapter: "adapter" }, + }, + { + name: "lime.tasks", + version: "0.3.0", + group: "observability", + stage: "preview", + owner: "agent_runtime", + methods: ["list", "get", "cancel", "subscribe"], + summary: "跨 App / runtime 的后台任务、队列、状态和订阅。", + appResponsibility: "展示与本 App 相关的任务,不维护第二套队列。", + limeResponsibility: "统一任务中心、状态恢复、事件订阅和审计。", + profile: {}, + }, + { + name: "lime.settings", + version: "0.3.0", + group: "governance", + stage: "preview", + owner: "desktop_host", + methods: ["get", "set", "list"], + summary: "App 可见设置、workspace overlay 和 tenant 默认值。", + appResponsibility: "只读或请求修改自己的配置域。", + limeResponsibility: "统一设置 schema、权限、overlay 和迁移。", + profile: {}, + }, + { + name: "lime.workspace", + version: "0.3.0", + group: "app_surface", + stage: "preview", + owner: "desktop_host", + methods: ["getCurrent", "list", "open", "getPathRef"], + summary: "当前 workspace、路径引用、打开入口和工作区上下文。", + appResponsibility: "围绕当前 workspace 展示业务状态。", + limeResponsibility: "管理 workspace 身份、路径封装和跨平台兼容。", + profile: {}, + }, + { + name: "lime.context", + version: "0.3.0", + group: "agent_runtime", + stage: "preview", + owner: "agent_runtime", + methods: ["getSnapshot", "attach", "detach"], + summary: "会话上下文、选中资源、当前任务和可附加上下文。", + appResponsibility: "把业务选择显式提交给 runtime。", + limeResponsibility: "维护 session/thread/turn 上下文和压缩边界。", + profile: { adapter: "adapter" }, + }, + { + name: "lime.connectors", + version: "0.3.0", + group: "integration", + stage: "preview", + owner: "cloud_overlay", + methods: ["list", "requestAuth", "getStatus", "invoke"], + summary: "外部系统连接器、授权状态和受控集成调用。", + appResponsibility: "声明业务连接需求和用户动作。", + limeResponsibility: "托管 OAuth、secret、tenant policy 和审计。", + profile: { adapter: "adapter" }, + }, + { + name: "lime.automation", + version: "0.3.0", + group: "agent_runtime", + stage: "preview", + owner: "agent_runtime", + methods: ["startJob", "getJob", "cancelJob"], + summary: "自动化 job、周期任务和服务型 Skill 编排。", + appResponsibility: "定义业务触发、输入和终止条件。", + limeResponsibility: "统一 job runtime、队列、权限、证据和恢复。", + profile: {}, + }, + { + name: "lime.review", + version: "0.3.0", + group: "governance", + stage: "preview", + owner: "policy_runtime", + methods: ["requestDecision", "submitDecision", "listPending"], + summary: "人工审核、风险确认、发布门禁和决策记录。", + appResponsibility: "把审核嵌入业务 UI 并处理拒绝/重试。", + limeResponsibility: "保留审核证据、权限、审计和发布门禁。", + profile: {}, + }, +] as const satisfies readonly LimeCapabilityDefinition[]; + +export type LimeCapabilityDefinitionRecord = + (typeof LIME_CAPABILITY_DEFINITIONS)[number]; +export type LimeCapabilityName = LimeCapabilityDefinitionRecord["name"]; +export type LimeCapabilityMethodName = Extract< + LimeCapabilityDefinitionRecord, + { name: Name } +>["methods"][number]; +export type LimeCapabilityAdapterKey< + Name extends LimeCapabilityName = LimeCapabilityName, +> = Name extends `lime.${infer Key}` ? Key : never; + +export const LIME_CAPABILITY_NAMES = LIME_CAPABILITY_DEFINITIONS.map( + (definition) => definition.name, +) as readonly LimeCapabilityName[]; + +export const LIME_CAPABILITY_GROUPS = Array.from( + new Set(LIME_CAPABILITY_DEFINITIONS.map((definition) => definition.group)), +) as readonly LimeCapabilityGroup[]; + +export function getLimeCapabilityDefinition( + name: LimeCapabilityName, +): LimeCapabilityDefinitionRecord { + const definition = LIME_CAPABILITY_DEFINITIONS.find( + (item) => item.name === name, + ); + if (!definition) { + throw new Error(`Unknown Lime capability: ${name}`); + } + return definition; +} + +export function getLimeCapabilityAdapterKey( + name: Name, +): LimeCapabilityAdapterKey { + return name.slice("lime.".length) as LimeCapabilityAdapterKey; +} + +export function buildLimeCapabilityProfileEntries( + resolveImplementation?: ( + definition: LimeCapabilityDefinitionRecord, + ) => CapabilityImplementation | undefined, +): Record { + return Object.fromEntries( + LIME_CAPABILITY_DEFINITIONS.map((definition) => { + const implementation = resolveImplementation?.(definition) ?? "none"; + return [ + definition.name, + { + version: definition.version, + enabled: implementation !== "none", + implementation, + }, + ]; + }), + ) as Record; +} + +export function buildLimeCapabilityProfileEntriesForMode( + mode: "base" | "mock" | "adapter", +): Record { + if (mode === "base") { + return buildLimeCapabilityProfileEntries(); + } + return buildLimeCapabilityProfileEntries((definition) => { + const profile: LimeCapabilityDefinition["profile"] = definition.profile; + return profile[mode] ?? "none"; + }); +} + +export function listEnabledLimeCapabilityNamesForMode( + mode: "mock" | "adapter", +): LimeCapabilityName[] { + return LIME_CAPABILITY_DEFINITIONS.filter((definition) => { + const profile: LimeCapabilityDefinition["profile"] = definition.profile; + return profile[mode] !== undefined; + }).map((definition) => definition.name); +} diff --git a/src/features/agent-app/sdk/capabilityContract.test.ts b/src/features/agent-app/sdk/capabilityContract.test.ts index 2c02bfcba..84cf8222d 100644 --- a/src/features/agent-app/sdk/capabilityContract.test.ts +++ b/src/features/agent-app/sdk/capabilityContract.test.ts @@ -1,23 +1,34 @@ import { describe, expect, it } from "vitest"; import type { AgentAppTaskRecord } from "../types"; +import { buildAdapterCapabilityProfile } from "../adapters/adapterCapabilityProfile"; +import { p0HostCapabilityProfile } from "../readiness/hostCapabilityProfile"; import { AgentAppCapabilityError, normalizeLimeCapabilityErrorCode, toLimeCapabilityError, } from "./capabilityErrors"; +import { + LIME_CAPABILITY_DEFINITIONS, + LIME_CAPABILITY_NAMES, + buildLimeCapabilityProfileEntriesForMode, + listEnabledLimeCapabilityNamesForMode, +} from "./capabilityCatalog"; import { buildLimeCapabilityInvokeProvenance, buildLimeCapabilityInvokeRequest, createLimeCapabilityInvoker, createMockLimeCapabilityTransport, } from "./capabilityContract"; +import { buildMockCapabilityProfile } from "./mockCapabilityProfile"; const provenance = buildLimeCapabilityInvokeProvenance({ sourceKind: "agent_app", appId: "content-factory-app", appVersion: "1.0.0", - packageHash: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - manifestHash: "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + packageHash: + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + manifestHash: + "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", entryKey: "dashboard", workflowRunId: "run-1", workspaceId: "workspace-1", @@ -57,6 +68,72 @@ function buildTaskRecord(input: { title: string }): AgentAppTaskRecord { } describe("P18 typed Capability SDK contract", () => { + it("应把 Lime 全量能力目录作为 SDK / readiness / profile 的单一事实源", () => { + const catalogNames = LIME_CAPABILITY_DEFINITIONS.map( + (definition) => definition.name, + ); + + expect(new Set(catalogNames).size).toBe(catalogNames.length); + expect(LIME_CAPABILITY_NAMES).toEqual(catalogNames); + expect(catalogNames).toEqual( + expect.arrayContaining([ + "lime.ui", + "lime.storage", + "lime.files", + "lime.agent", + "lime.knowledge", + "lime.tools", + "lime.artifacts", + "lime.workflow", + "lime.policy", + "lime.secrets", + "lime.evidence", + "lime.events", + "lime.capabilities", + "lime.models", + "lime.usage", + "lime.memory", + "lime.skills", + "lime.mcp", + "lime.browser", + "lime.search", + "lime.documents", + "lime.media", + "lime.terminal", + "lime.tasks", + "lime.settings", + "lime.workspace", + "lime.context", + "lime.connectors", + "lime.automation", + "lime.review", + ]), + ); + expect(Object.keys(p0HostCapabilityProfile.capabilities).sort()).toEqual( + [...catalogNames].sort(), + ); + expect(p0HostCapabilityProfile.capabilities["lime.policy"]).toMatchObject({ + enabled: false, + implementation: "none", + }); + expect(p0HostCapabilityProfile.capabilities["lime.secrets"]).toMatchObject({ + enabled: false, + implementation: "none", + }); + expect(buildLimeCapabilityProfileEntriesForMode("base")).toEqual( + p0HostCapabilityProfile.capabilities, + ); + expect( + Object.keys(buildMockCapabilityProfile().capabilities).sort(), + ).toEqual([...catalogNames].sort()); + expect( + Object.keys(buildAdapterCapabilityProfile().capabilities).sort(), + ).toEqual([...catalogNames].sort()); + expect(listEnabledLimeCapabilityNamesForMode("adapter")).toEqual( + expect.arrayContaining(["lime.agent", "lime.storage", "lime.evidence"]), + ); + }); + it("应构造带 app provenance 的 typed capability invoke envelope", () => { const request = buildLimeCapabilityInvokeRequest({ capability: "lime.agent", diff --git a/src/features/agent-app/sdk/capabilityContract.ts b/src/features/agent-app/sdk/capabilityContract.ts index 2cea1108e..b2a00cd2e 100644 --- a/src/features/agent-app/sdk/capabilityContract.ts +++ b/src/features/agent-app/sdk/capabilityContract.ts @@ -15,22 +15,9 @@ import { type LimeCapabilityError, type LimeCapabilityErrorContext, } from "./capabilityErrors"; - -export const LIME_CAPABILITY_NAMES = [ - "lime.ui", - "lime.storage", - "lime.files", - "lime.agent", - "lime.knowledge", - "lime.tools", - "lime.artifacts", - "lime.workflow", - "lime.policy", - "lime.secrets", - "lime.evidence", -] as const; - -export type LimeCapabilityName = (typeof LIME_CAPABILITY_NAMES)[number]; +import type { LimeCapabilityName } from "./capabilityCatalog"; +export { LIME_CAPABILITY_NAMES } from "./capabilityCatalog"; +export type { LimeCapabilityName } from "./capabilityCatalog"; export interface LimeCapabilityInvokeProvenance { appId: string; @@ -93,6 +80,50 @@ export interface LimeCapabilityContractMap { args: undefined; value: unknown; }; + openAgentRun: { + args: { + taskId?: string; + bridgeAction?: string; + title?: string; + mode?: "drawer" | "modal" | "page"; + expectedOutput?: unknown; + runtimeProcess?: unknown; + task?: unknown; + snapshot?: unknown; + events?: unknown[]; + }; + value: { + opened: true; + surface: "host_agent_run"; + mode: "drawer" | "modal" | "page"; + taskId?: string; + }; + }; + updateAgentRun: { + args: { + taskId?: string; + bridgeAction?: string; + title?: string; + runtimeProcess?: unknown; + task?: unknown; + snapshot?: unknown; + events?: unknown[]; + runtimeFacts?: unknown; + }; + value: { + updated: true; + surface: "host_agent_run"; + taskId?: string; + }; + }; + closeAgentRun: { + args: { taskId?: string; bridgeAction?: string } | undefined; + value: { + closed: true; + surface: "host_agent_run"; + taskId?: string; + }; + }; }; "lime.storage": { get: { args: { key: string }; value: unknown | null }; @@ -103,6 +134,10 @@ export interface LimeCapabilityContractMap { "lime.files": { pick: { args: { accept?: string[]; multiple?: boolean }; value: unknown }; readRef: { args: { ref: string }; value: unknown }; + parse: { + args: { ref: string; parser?: string; outputSchema?: unknown }; + value: unknown; + }; }; "lime.agent": { startTask: { args: AgentAppTaskRequest; value: AgentAppTaskRecord }; @@ -122,6 +157,8 @@ export interface LimeCapabilityContractMap { value: AgentAppKnowledgeSearchResult; }; bindStatus: { args: { key: string }; value: unknown }; + bind: { args: { key: string; mode?: string }; value: unknown }; + export: { args: { key: string; format?: string }; value: unknown }; }; "lime.tools": { invoke: { args: { tool: string; input?: unknown }; value: unknown }; @@ -134,6 +171,10 @@ export interface LimeCapabilityContractMap { }; open: { args: { artifactId: string }; value: unknown }; export: { args: { artifactId: string; format?: string }; value: unknown }; + list: { + args: { kind?: string; limit?: number } | undefined; + value: AgentAppArtifactRecord[]; + }; }; "lime.workflow": { start: { args: { workflowKey: string; input?: unknown }; value: unknown }; @@ -166,12 +207,173 @@ export interface LimeCapabilityContractMap { args: { evidenceId: string; artifactId: string }; value: unknown; }; + list: { + args: { kind?: string; limit?: number } | undefined; + value: AgentAppEvidenceRecord[]; + }; + }; + "lime.events": { + emit: { args: { topic: string; payload?: unknown }; value: unknown }; + subscribe: { args: { topic: string }; value: unknown }; + unsubscribe: { args: { subscriptionId: string }; value: unknown }; + listSubscriptions: { args: undefined; value: unknown }; + }; + "lime.capabilities": { + list: { args: undefined; value: unknown }; + get: { args: { capability: string }; value: unknown }; + getProfile: { args: undefined; value: unknown }; + }; + "lime.models": { + list: { args: { taskKind?: string } | undefined; value: unknown }; + select: { + args: { taskKind: string; constraints?: unknown }; + value: unknown; + }; + getRouting: { args: { taskKind?: string } | undefined; value: unknown }; + estimateCost: { + args: { taskKind: string; input?: unknown }; + value: unknown; + }; + }; + "lime.usage": { + getTokenUsage: { + args: { taskId?: string; window?: string } | undefined; + value: unknown; + }; + getCostSummary: { + args: { taskId?: string; window?: string } | undefined; + value: unknown; + }; + getBudget: { args: { scope?: string } | undefined; value: unknown }; + }; + "lime.memory": { + query: { args: { query: string; scope?: string }; value: unknown }; + write: { args: { scope: string; value: unknown }; value: unknown }; + compact: { + args: { scope?: string; reason?: string } | undefined; + value: unknown; + }; + getStatus: { args: { scope?: string } | undefined; value: unknown }; + }; + "lime.skills": { + list: { args: { kind?: string } | undefined; value: unknown }; + resolve: { args: { skillId: string }; value: unknown }; + bind: { args: { skillId: string; reason?: string }; value: unknown }; + invoke: { args: { skillId: string; input?: unknown }; value: unknown }; + getInvocation: { args: { invocationId: string }; value: unknown }; + }; + "lime.mcp": { + listServers: { args: undefined; value: unknown }; + listTools: { args: { serverId?: string } | undefined; value: unknown }; + invoke: { args: { tool: string; input?: unknown }; value: unknown }; + }; + "lime.browser": { + open: { args: { url?: string; profile?: string }; value: unknown }; + navigate: { args: { sessionId: string; url: string }; value: unknown }; + extract: { args: { sessionId: string; selector?: string }; value: unknown }; + screenshot: { + args: { sessionId: string; fullPage?: boolean }; + value: unknown; + }; + close: { args: { sessionId: string }; value: unknown }; + }; + "lime.search": { + query: { args: { query: string; limit?: number }; value: unknown }; + deepResearch: { args: { query: string; depth?: number }; value: unknown }; + getRun: { args: { runId: string }; value: unknown }; + }; + "lime.documents": { + parse: { args: { ref: string; outputSchema?: unknown }; value: unknown }; + export: { args: { artifactId: string; format: string }; value: unknown }; + transform: { + args: { ref: string; operation: string; options?: unknown }; + value: unknown; + }; + summarize: { args: { ref: string; instruction?: string }; value: unknown }; + }; + "lime.media": { + generateImage: { + args: { prompt: string; options?: unknown }; + value: unknown; + }; + editImage: { + args: { ref: string; prompt: string; options?: unknown }; + value: unknown; + }; + transcribe: { args: { ref: string; options?: unknown }; value: unknown }; + synthesizeVoice: { + args: { text: string; voice?: string; options?: unknown }; + value: unknown; + }; + }; + "lime.terminal": { + run: { + args: { command: string; cwdRef?: string; reason?: string }; + value: unknown; + }; + getRun: { args: { runId: string }; value: unknown }; + cancel: { args: { runId: string }; value: unknown }; + }; + "lime.tasks": { + list: { + args: { status?: string; limit?: number } | undefined; + value: unknown; + }; + get: { args: { taskId: string }; value: unknown }; + cancel: { args: { taskId: string }; value: unknown }; + subscribe: { args: { taskId: string }; value: unknown }; + }; + "lime.settings": { + get: { args: { key: string }; value: unknown }; + set: { + args: { key: string; value: unknown; reason?: string }; + value: unknown; + }; + list: { args: { namespace?: string } | undefined; value: unknown }; + }; + "lime.workspace": { + getCurrent: { args: undefined; value: unknown }; + list: { args: undefined; value: unknown }; + open: { args: { workspaceId: string }; value: unknown }; + getPathRef: { args: { purpose: string; path?: string }; value: unknown }; + }; + "lime.context": { + getSnapshot: { args: { scope?: string } | undefined; value: unknown }; + attach: { args: { ref: string; kind?: string }; value: unknown }; + detach: { args: { ref: string }; value: unknown }; + }; + "lime.connectors": { + list: { args: { kind?: string } | undefined; value: unknown }; + requestAuth: { + args: { connectorId: string; reason?: string }; + value: unknown; + }; + getStatus: { args: { connectorId: string }; value: unknown }; + invoke: { + args: { connectorId: string; action: string; input?: unknown }; + value: unknown; + }; + }; + "lime.automation": { + startJob: { args: { jobKind: string; input?: unknown }; value: unknown }; + getJob: { args: { jobId: string }; value: unknown }; + cancelJob: { args: { jobId: string }; value: unknown }; + }; + "lime.review": { + requestDecision: { + args: { subject: string; payload?: unknown }; + value: unknown; + }; + submitDecision: { + args: { decisionId: string; decision: string; note?: string }; + value: unknown; + }; + listPending: { args: { subject?: string } | undefined; value: unknown }; }; } -export type LimeCapabilityMethod< - Capability extends LimeCapabilityName, -> = Extract; +export type LimeCapabilityMethod = + Extract; type LimeCapabilitySpec< Capability extends LimeCapabilityName, diff --git a/src/features/agent-app/sdk/index.ts b/src/features/agent-app/sdk/index.ts index 1f529f71b..008e731c6 100644 --- a/src/features/agent-app/sdk/index.ts +++ b/src/features/agent-app/sdk/index.ts @@ -11,6 +11,25 @@ export type { LimeCapabilityErrorCode, LimeCapabilityErrorContext, } from "./capabilityErrors"; +export { + LIME_CAPABILITY_DEFINITIONS, + LIME_CAPABILITY_GROUPS, + buildLimeCapabilityProfileEntries, + buildLimeCapabilityProfileEntriesForMode, + getLimeCapabilityAdapterKey, + getLimeCapabilityDefinition, + listEnabledLimeCapabilityNamesForMode, +} from "./capabilityCatalog"; +export type { + LimeCapabilityDefinition, + LimeCapabilityAdapterKey, + LimeCapabilityDefinitionRecord, + LimeCapabilityGroup, + LimeCapabilityMethodName, + LimeCapabilityOwner, + LimeCapabilityProfileEntry, + LimeCapabilityStage, +} from "./capabilityCatalog"; export { LIME_CAPABILITY_NAMES, buildLimeCapabilityInvokeProvenance, @@ -45,13 +64,37 @@ export type { CreateLimeCoreCapabilityAdaptersOptions, LimeAgentCapabilityAdapter, LimeArtifactsCapabilityAdapter, + LimeAutomationCapabilityAdapter, + LimeBrowserCapabilityAdapter, + LimeCapabilitiesCapabilityAdapter, LimeCapabilityAdapterCallOptions, + LimeCapabilityAdapter, + LimeConnectorsCapabilityAdapter, + LimeContextCapabilityAdapter, LimeCoreCapabilityAdapters, + LimeDocumentsCapabilityAdapter, LimeEvidenceCapabilityAdapter, + LimeEventsCapabilityAdapter, + LimeFilesCapabilityAdapter, LimeKnowledgeCapabilityAdapter, + LimeMcpCapabilityAdapter, + LimeMediaCapabilityAdapter, + LimeMemoryCapabilityAdapter, + LimeModelsCapabilityAdapter, + LimePolicyCapabilityAdapter, + LimeReviewCapabilityAdapter, + LimeSearchCapabilityAdapter, + LimeSecretsCapabilityAdapter, + LimeSettingsCapabilityAdapter, + LimeSkillsCapabilityAdapter, LimeStorageCapabilityAdapter, + LimeTasksCapabilityAdapter, + LimeTerminalCapabilityAdapter, LimeToolsCapabilityAdapter, LimeUiCapabilityAdapter, + LimeUsageCapabilityAdapter, + LimeWorkflowCapabilityAdapter, + LimeWorkspaceCapabilityAdapter, } from "./capabilityAdapters"; export { LIME_AGENT_APP_BRIDGE_PROTOCOL, diff --git a/src/features/agent-app/sdk/mockCapabilityProfile.ts b/src/features/agent-app/sdk/mockCapabilityProfile.ts index 8de8fc47b..87a3fe540 100644 --- a/src/features/agent-app/sdk/mockCapabilityProfile.ts +++ b/src/features/agent-app/sdk/mockCapabilityProfile.ts @@ -1,18 +1,7 @@ import { resolveAgentAppHostFlags } from "../featureFlag"; import { p0HostCapabilityProfile } from "../readiness/hostCapabilityProfile"; import type { AgentAppHostFlags, HostCapabilityProfile } from "../types"; - -const MOCK_CAPABILITIES = [ - "lime.ui", - "lime.storage", - "lime.files", - "lime.artifacts", - "lime.evidence", - "lime.agent", - "lime.knowledge", - "lime.tools", - "lime.workflow", -] as const; +import { buildLimeCapabilityProfileEntriesForMode } from "./capabilityCatalog"; export function buildMockCapabilityProfile( flagOverrides: Partial = {}, @@ -22,21 +11,10 @@ export function buildMockCapabilityProfile( labEnabled: true, mockSdkEnabled: true, }); - const capabilities: HostCapabilityProfile["capabilities"] = { - ...p0HostCapabilityProfile.capabilities, - }; - - MOCK_CAPABILITIES.forEach((capability) => { - capabilities[capability] = { - version: capabilities[capability]?.version ?? "0.3.0", - enabled: true, - implementation: "mock", - }; - }); return { ...p0HostCapabilityProfile, - capabilities, + capabilities: buildLimeCapabilityProfileEntriesForMode("mock"), featureFlags, }; } diff --git a/src/features/agent-app/sdk/publicSdkSurface.test.ts b/src/features/agent-app/sdk/publicSdkSurface.test.ts index b479412c0..558f02174 100644 --- a/src/features/agent-app/sdk/publicSdkSurface.test.ts +++ b/src/features/agent-app/sdk/publicSdkSurface.test.ts @@ -7,6 +7,7 @@ import { describe, expect, it } from "vitest"; import { LIME_AGENT_APP_BRIDGE_PROTOCOL, LIME_AGENT_APP_BRIDGE_VERSION, + LIME_CAPABILITY_DEFINITIONS, LIME_CAPABILITY_NAMES, MockCapabilityHost, createLimeCoreCapabilityAdapters, @@ -27,6 +28,13 @@ describe("agent app SDK public surface", () => { expect(LIME_AGENT_APP_BRIDGE_PROTOCOL).toBe("lime.agentApp.bridge"); expect(LIME_AGENT_APP_BRIDGE_VERSION).toBe(1); expect(LIME_CAPABILITY_NAMES).toContain("lime.agent"); + expect(LIME_CAPABILITY_DEFINITIONS).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "lime.models" }), + expect.objectContaining({ name: "lime.skills" }), + expect.objectContaining({ name: "lime.usage" }), + ]), + ); expect(isLimeCapabilityErrorCode("permission_denied")).toBe(true); expect(normalizeLimeCapabilityErrorCode("missing")).toBe("upstream_failed"); expect(typeof createLimeCoreCapabilityAdapters).toBe("function"); @@ -44,7 +52,9 @@ describe("agent app SDK public surface", () => { ); expect(publicSdkSurface).not.toHaveProperty("AgentRuntimeCapabilityHost"); expect(publicSdkSurface).not.toHaveProperty("WorkflowRuntimeHost"); - expect(publicSdkSurface).not.toHaveProperty("createAgentAppCapabilityDispatcher"); + expect(publicSdkSurface).not.toHaveProperty( + "createAgentAppCapabilityDispatcher", + ); }); it("源码层不从 UI、安装、运行时或 adapter 内部层转导出", () => { diff --git a/src/features/agent-app/types.ts b/src/features/agent-app/types.ts index 225fafa94..ff1c5cf69 100644 --- a/src/features/agent-app/types.ts +++ b/src/features/agent-app/types.ts @@ -205,6 +205,11 @@ export interface AppManifest { overlayTemplates?: OverlayTemplateDeclaration[]; ui?: UiDeclaration; lifecycle?: LifecycleDeclaration; + agentRuntime?: unknown; + requirements?: unknown; + boundary?: unknown; + integrations?: unknown; + operations?: unknown; } export interface NormalizedRequires { @@ -250,7 +255,7 @@ export interface NormalizedAppEntry { } export interface NormalizedAppManifest { - manifestVersion: "0.2" | "0.3"; + manifestVersion: "0.2" | "0.3" | "0.5" | "0.6" | "0.7"; appId: string; displayName: string; version: string; @@ -276,6 +281,11 @@ export interface NormalizedAppManifest { overlayTemplates: OverlayTemplateDeclaration[]; ui?: UiDeclaration; lifecycle: LifecycleDeclaration; + agentRuntime?: unknown; + requirements?: unknown; + boundary?: unknown; + integrations?: unknown; + operations?: unknown; } export interface PackageIdentity { @@ -734,6 +744,10 @@ export interface AgentAppHostFlags { export interface HostCapabilityProfile { appRuntimeVersion: string; + standardVersions?: { + current: string; + compatible: string[]; + }; runtimeTargets: RuntimeTarget[]; capabilities: Record< string, @@ -743,6 +757,7 @@ export interface HostCapabilityProfile { implementation: CapabilityImplementation; } >; + agentRuntime?: unknown; featureFlags: AgentAppHostFlags; } @@ -912,6 +927,69 @@ export interface AgentAppTaskStreamEvent { refs?: string[]; } +export type AgentAppRuntimeProcessTimelineKind = + | "progress" + | "thinking" + | "output" + | "execution" + | "routing" + | "metrics" + | "skill" + | "tool" + | "artifact" + | "blocked" + | "warning" + | "completed"; + +export interface AgentAppRuntimeProcessTimelineItem { + kind: AgentAppRuntimeProcessTimelineKind; + title: string; + statusText: string; + message: string; + detail?: string; + meta?: string; + collapseKey?: string; +} + +export interface AgentAppRuntimeProcessModel { + provider: string; + model: string; + label: string; +} + +export interface AgentAppRuntimeProcessUsage { + inputTokens: number; + outputTokens: number; + totalTokens: number; + cachedInputTokens?: number; + cacheCreationInputTokens?: number; + [key: string]: unknown; +} + +export interface AgentAppRuntimeProcessCost { + estimatedTotalCost?: number; + estimatedCostClass?: string; + currency?: string; + [key: string]: unknown; +} + +export interface AgentAppRuntimeProcessView { + timeline: AgentAppRuntimeProcessTimelineItem[]; + streamText: string; + thinkingText: string; + executionText: string; + skillNames: string[]; + invokedSkillNames: string[]; + model: AgentAppRuntimeProcessModel; + usage: AgentAppRuntimeProcessUsage | null; + cost: AgentAppRuntimeProcessCost | null; + terminal: boolean; + collapsedByDefault: boolean; + routingCount: number; + executionCount: number; + artifactCount: number; +} + export interface AgentAppTaskKnowledgeBinding { key: string; mode?: "retrieval" | "data"; @@ -989,6 +1067,8 @@ export interface AgentAppTaskRecord { result?: unknown; trace: AgentAppTaskTraceEvent[]; events: AgentAppTaskStreamEvent[]; + runtimeProcess?: AgentAppRuntimeProcessView; + process?: AgentAppRuntimeProcessView; provenance: AgentAppProvenance; } diff --git a/src/features/agent-app/ui/AgentAppRuntimePage.test.tsx b/src/features/agent-app/ui/AgentAppRuntimePage.test.tsx index 6a8dd310e..8c6c8145e 100644 --- a/src/features/agent-app/ui/AgentAppRuntimePage.test.tsx +++ b/src/features/agent-app/ui/AgentAppRuntimePage.test.tsx @@ -49,6 +49,42 @@ const i18nMocks = vi.hoisted(() => ({ "agentApp.apps.runtime.openFailed": "App 打开失败", "agentApp.apps.runtime.retry": "重新打开", "agentApp.apps.runtime.unavailable": "该 Agent App 暂不可用。", + "agentApp.apps.runtime.agentRun.aria": "Host AI 运行面板", + "agentApp.apps.runtime.agentRun.badge": `${String(params?.app)} 的 AI 同事`, + "agentApp.apps.runtime.agentRun.titleFallback": "Lime AI 运行现场", + "agentApp.apps.runtime.agentRun.subtitle": + "思考、执行、Skill、模型、Token、费用和证据由 Lime Host 统一承载。", + "agentApp.apps.runtime.agentRun.close": "关闭 AI 运行面板", + "agentApp.apps.runtime.agentRun.expand": "查看运行现场", + "agentApp.apps.runtime.agentRun.collapse": "收起运行面板", + "agentApp.apps.runtime.agentRun.taskId": "任务 ID", + "agentApp.apps.runtime.agentRun.bridgeAction": "业务动作", + "agentApp.apps.runtime.agentRun.emptyValue": "待回写", + "agentApp.apps.runtime.agentRun.metric.model": "模型", + "agentApp.apps.runtime.agentRun.metric.tokens": "Token", + "agentApp.apps.runtime.agentRun.metric.cost": "费用", + "agentApp.apps.runtime.agentRun.metric.skills": "Skills", + "agentApp.apps.runtime.agentRun.facts.confirmations": "待确认", + "agentApp.apps.runtime.agentRun.facts.confirmations.empty": "暂无待确认项", + "agentApp.apps.runtime.agentRun.facts.confirmations.itemFallback": + "待确认事项", + "agentApp.apps.runtime.agentRun.facts.artifacts": "交付物", + "agentApp.apps.runtime.agentRun.facts.artifacts.empty": + "暂无交付物", + "agentApp.apps.runtime.agentRun.facts.artifacts.itemFallback": "交付物已生成", + "agentApp.apps.runtime.agentRun.facts.evidence": "证据", + "agentApp.apps.runtime.agentRun.facts.evidence.empty": "暂无证据", + "agentApp.apps.runtime.agentRun.facts.evidence.itemFallback": "证据已记录", + "agentApp.apps.runtime.agentRun.timeline.running": + "运行中,点击折叠或展开过程", + "agentApp.apps.runtime.agentRun.timeline.collapsed": + "运行过程已折叠,点击查看完整现场", + "agentApp.apps.runtime.agentRun.timeline.event": "运行事件", + "agentApp.apps.runtime.agentRun.timeline.empty": + "等待 AgentRuntime 回写运行过程。", + "agentApp.apps.runtime.agentRun.thinking": "思考过程", + "agentApp.apps.runtime.agentRun.execution": "执行过程", + "agentApp.apps.runtime.agentRun.output": "成稿流式输出", }; return labels[key] ?? key; }, @@ -92,9 +128,15 @@ interface MountedPage { const mountedPages: MountedPage[] = []; function buildReadyState( - params: { setupResolved?: boolean } = {}, + params: { + setupResolved?: boolean; + manifestPatch?: Partial; + } = {}, ): InstalledAgentAppState { - const manifest = contentFactoryFixture as AppManifest; + const manifest = { + ...(contentFactoryFixture as AppManifest), + ...(params.manifestPatch ?? {}), + } as AppManifest; const loadedAt = "2026-05-15T00:00:00.000Z"; const identity = buildPackageIdentity({ manifest, @@ -377,7 +419,11 @@ describe("AgentAppRuntimePage", () => { }), }), capabilities: expect.objectContaining({ - available: expect.arrayContaining(["lime.agent", "lime.storage"]), + available: expect.arrayContaining([ + "lime.capabilities", + "lime.agent", + "lime.storage", + ]), }), }), }), @@ -467,6 +513,526 @@ describe("AgentAppRuntimePage", () => { ); }); + it("App 可通过 lime.ui 打开、更新并关闭 Host 级 AI 运行面板", async () => { + const container = await renderPage(); + await flush(); + const frame = getRuntimeFrame(container); + const postMessage = vi.spyOn(frame.contentWindow!, "postMessage"); + + await dispatchBridgeMessage( + frame, + "capability:invoke", + { + capability: "lime.ui", + method: "openAgentRun", + input: { + taskId: "agent-app-task-1", + bridgeAction: "content_factory.production", + title: "生成内容批次", + mode: "drawer", + runtimeProcess: { + model: { + provider: "anthropic", + model: "claude-sonnet-4-5", + label: "Claude Sonnet 4.5", + }, + usage: { + inputTokens: 120, + outputTokens: 80, + totalTokens: 200, + }, + cost: { + estimatedTotalCost: 0.0123, + currency: "USD", + }, + skillNames: ["content_factory_writer"], + invokedSkillNames: ["content_factory_writer"], + terminal: false, + timeline: [ + { + kind: "routing", + title: "模型路由", + message: "AgentRuntime 已选择内容生成模型。", + statusText: "decided", + meta: "routing", + }, + { + kind: "skill", + title: "Skill · content_factory_writer", + message: "正在调用内容工厂写作 Skill。", + statusText: "running", + meta: "skill-1", + }, + { + kind: "tool", + title: "Tool · browser_snapshot", + message: "正在读取业务页面上下文。", + statusText: "running", + meta: "tool-1", + }, + { + kind: "execution", + title: "正在规划内容结构", + message: "AgentRuntime 已开始读取项目上下文。", + statusText: "running", + }, + ], + thinkingText: "先确认项目资料,再拆内容主题。", + executionText: "调用内容工厂 Skill。", + streamText: "正在生成第一批文案。", + }, + events: [ + { + eventType: "task:reviewRequested", + requestId: "review-content-batch", + message: "请确认首批内容选题。", + }, + { + eventType: "artifact:created", + artifactRef: ".lime/artifacts/content-batch.json", + payload: { + artifact: { + title: "内容批次 JSON", + }, + }, + }, + { + eventType: "evidence:recorded", + evidenceRef: "evidence:content-batch", + message: "内容批次 evidence 已记录。", + }, + ], + }, + }, + "agent-run-open", + ); + + expect(postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "host:response", + requestId: "agent-run-open", + payload: expect.objectContaining({ + ok: true, + result: expect.objectContaining({ + opened: true, + surface: "host_agent_run", + mode: "drawer", + taskId: "agent-app-task-1", + }), + }), + }), + "http://127.0.0.1:4199", + ); + expect( + container.querySelector('[data-testid="agent-app-host-agent-run-drawer"]'), + ).toBeNull(); + const dock = container.querySelector( + '[data-testid="agent-app-host-agent-run-dock"]', + ) as HTMLButtonElement; + expect(dock).not.toBeNull(); + expect(container.textContent).toContain("查看运行现场"); + + await act(async () => { + dock.click(); + }); + await flush(); + + expect( + container.querySelector('[data-testid="agent-app-host-agent-run-drawer"]'), + ).not.toBeNull(); + expect( + container.querySelector('[data-testid="agent-run-process-panel"]'), + ).not.toBeNull(); + expect(container.textContent).toContain("生成内容批次"); + expect(container.textContent).toContain("Claude Sonnet 4.5"); + expect(container.textContent).toContain("200"); + expect(container.textContent).toContain("USD 0.0123"); + expect(container.textContent).toContain("content_factory_writer"); + expect(container.textContent).toContain("模型路由"); + expect(container.textContent).toContain("Skill · content_factory_writer"); + expect(container.textContent).toContain("Tool · browser_snapshot"); + expect( + container.querySelector('[data-agent-run-timeline-kind="skill"]'), + ).not.toBeNull(); + expect( + container.querySelector('[data-agent-run-timeline-kind="tool"]'), + ).not.toBeNull(); + expect(container.textContent).toContain("正在规划内容结构"); + expect(container.textContent).toContain("先确认项目资料"); + expect(container.textContent).toContain("待确认"); + expect(container.textContent).toContain("请确认首批内容选题。"); + expect(container.textContent).toContain("review-content-batch"); + expect(container.textContent).toContain("交付物"); + expect(container.textContent).toContain("内容批次 JSON"); + expect(container.textContent).toContain("证据"); + expect(container.textContent).toContain("内容批次 evidence 已记录。"); + + await dispatchBridgeMessage( + frame, + "capability:invoke", + { + capability: "lime.ui", + method: "updateAgentRun", + input: { + taskId: "agent-app-task-1", + runtimeProcess: { + model: { label: "Claude Sonnet 4.5" }, + usage: { inputTokens: 120, outputTokens: 180, totalTokens: 300 }, + terminal: true, + collapsedByDefault: true, + timeline: [ + { + kind: "completed", + title: "内容批次已写回", + message: "Host 保留完整运行过程。", + statusText: "completed", + }, + ], + streamText: "第一批文案已完成。", + }, + }, + }, + "agent-run-update", + ); + + expect(container.textContent).toContain("300"); + expect(container.textContent).toContain("正在规划内容结构"); + expect(container.textContent).toContain("内容批次已写回"); + expect(container.textContent).toContain("运行过程已折叠,点击查看完整现场"); + + await dispatchBridgeMessage( + frame, + "capability:invoke", + { + capability: "lime.ui", + method: "closeAgentRun", + input: { taskId: "agent-app-task-1" }, + }, + "agent-run-close", + ); + + expect(postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "host:response", + requestId: "agent-run-close", + payload: expect.objectContaining({ + ok: true, + result: expect.objectContaining({ + closed: true, + surface: "host_agent_run", + taskId: "agent-app-task-1", + }), + }), + }), + "http://127.0.0.1:4199", + ); + expect( + container.querySelector('[data-testid="agent-app-host-agent-run-drawer"]'), + ).toBeNull(); + expect( + container.querySelector('[data-testid="agent-app-host-agent-run-dock"]'), + ).toBeNull(); + }); + + it("App 可通过 lime.capabilities.getProfile 发现 Host capability profile", async () => { + const container = await renderPage(); + await flush(); + const frame = getRuntimeFrame(container); + const postMessage = vi.spyOn(frame.contentWindow!, "postMessage"); + + await dispatchBridgeMessage( + frame, + "capability:invoke", + { + capability: "lime.capabilities", + method: "getProfile", + }, + "profile-discovery", + ); + + expect(postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "host:response", + requestId: "profile-discovery", + payload: expect.objectContaining({ + ok: true, + result: expect.objectContaining({ + appRuntimeVersion: "0.7.0", + standardVersions: { + current: "0.7", + compatible: ["0.5", "0.6", "0.7"], + }, + standards: expect.objectContaining({ + layeredManifest: expect.objectContaining({ + version: "0.5", + enabled: true, + }), + agentRuntime: expect.objectContaining({ + version: "0.6", + enabled: false, + }), + requirementBoundary: expect.objectContaining({ + version: "0.7", + enabled: false, + }), + }), + capabilities: expect.objectContaining({ + "lime.capabilities": expect.objectContaining({ + enabled: true, + implementation: "native", + }), + "lime.agent": expect.objectContaining({ + enabled: true, + }), + "lime.skills": expect.objectContaining({ + enabled: true, + implementation: "adapter", + }), + "lime.memory": expect.objectContaining({ + enabled: true, + implementation: "adapter", + }), + "lime.context": expect.objectContaining({ + enabled: true, + implementation: "adapter", + }), + "lime.search": expect.objectContaining({ + enabled: true, + implementation: "adapter", + }), + "lime.browser": expect.objectContaining({ + enabled: true, + implementation: "adapter", + }), + "lime.documents": expect.objectContaining({ + enabled: true, + implementation: "adapter", + }), + "lime.media": expect.objectContaining({ + enabled: true, + implementation: "adapter", + }), + "lime.mcp": expect.objectContaining({ + enabled: true, + implementation: "adapter", + }), + "lime.terminal": expect.objectContaining({ + enabled: true, + implementation: "adapter", + }), + "lime.connectors": expect.objectContaining({ + enabled: true, + implementation: "adapter", + }), + }), + }), + }), + }), + "http://127.0.0.1:4199", + ); + expect(postMessage).not.toHaveBeenCalledWith( + expect.objectContaining({ + type: "host:error", + requestId: "profile-discovery", + }), + expect.any(String), + ); + }); + + it("App 可通过 Lime 客户端 profile 读取 v0.6 AgentRuntime 合同", async () => { + const agentRuntime = { + agentTask: { + eventSchema: "lime.agent-task-event.v1", + resultSchema: "lime.agent-task-result.v1", + structuredOutput: { + type: "json_schema", + schemaRef: "./artifacts/content-factory-workspace-patch.schema.json", + }, + approval: { behavior: "host-mediated" }, + sessionPolicy: { modes: ["new", "resume", "continue", "fork"] }, + toolDiscovery: { mode: "on_demand" }, + checkpointScope: { workflowState: true }, + observability: { profileEvents: true }, + }, + }; + const container = await renderPage( + buildReadyState({ + manifestPatch: { + manifestVersion: "0.6.0", + version: "0.6.0", + requires: { + sdk: "@lime/app-sdk@^0.6.0", + capabilities: ["lime.agent", "lime.skills", "lime.usage"], + }, + agentRuntime, + }, + }), + ); + await flush(); + const frame = getRuntimeFrame(container); + const postMessage = vi.spyOn(frame.contentWindow!, "postMessage"); + + await dispatchBridgeMessage( + frame, + "capability:invoke", + { + capability: "lime.capabilities", + method: "getProfile", + }, + "profile-v06-runtime", + ); + + expect(postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "host:response", + requestId: "profile-v06-runtime", + payload: expect.objectContaining({ + ok: true, + result: expect.objectContaining({ + appRuntimeVersion: "0.7.0", + standardVersions: { + current: "0.7", + compatible: ["0.5", "0.6", "0.7"], + }, + standards: expect.objectContaining({ + layeredManifest: expect.objectContaining({ + version: "0.5", + enabled: true, + layerFiles: expect.arrayContaining(["app.capabilities.yaml"]), + }), + agentRuntime: expect.objectContaining({ + version: "0.6", + enabled: true, + manifestVersion: "0.6", + eventSchema: "lime.agent-task-event.v1", + resultSchema: "lime.agent-task-result.v1", + structuredOutput: true, + approval: true, + sessionPolicy: true, + toolDiscovery: true, + checkpointScope: true, + observability: true, + }), + requirementBoundary: expect.objectContaining({ + version: "0.7", + enabled: false, + }), + }), + agentRuntime, + }), + }), + }), + "http://127.0.0.1:4199", + ); + }); + + it("App 可通过 Lime 客户端 profile 读取 v0.7 需求边界与能力交接", async () => { + const requirements = { + requirements: [ + { + id: "CF-R001", + text: "生成可审核内容草稿", + priority: "mvp", + }, + ], + nonGoals: ["不在 App 包内保存外部凭证"], + }; + const boundary = { + boundaries: [ + { + requirementId: "CF-R001", + planes: { + app: { owns: ["workflow_state"] }, + host: { requires: ["lime.agent", "lime.evidence"] }, + }, + }, + ], + }; + const integrations = [ + { + key: "planning_table", + provider: "cloud.table", + executionPlane: "hybrid", + hostCapability: "lime.connectors", + }, + ]; + const operations = [ + { + key: "write_external_draft", + type: "external_write", + sideEffect: "external_write", + approvalRequired: true, + dryRunRequired: true, + evidenceRequired: true, + autoExecute: false, + }, + ]; + const container = await renderPage( + buildReadyState({ + manifestPatch: { + manifestVersion: "0.7.0", + version: "0.7.0", + requires: { + sdk: "@lime/app-sdk@^0.7.0", + capabilities: ["lime.agent", "lime.connectors", "lime.evidence"], + }, + requirements, + boundary, + integrations, + operations, + }, + }), + ); + await flush(); + const frame = getRuntimeFrame(container); + const postMessage = vi.spyOn(frame.contentWindow!, "postMessage"); + + await dispatchBridgeMessage( + frame, + "capability:invoke", + { + capability: "lime.capabilities", + method: "getProfile", + }, + "profile-v07-handoff", + ); + + expect(postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "host:response", + requestId: "profile-v07-handoff", + payload: expect.objectContaining({ + ok: true, + result: expect.objectContaining({ + appRuntimeVersion: "0.7.0", + standardVersions: { + current: "0.7", + compatible: ["0.5", "0.6", "0.7"], + }, + requirements, + boundary, + integrations, + operations, + standards: expect.objectContaining({ + requirementBoundary: expect.objectContaining({ + version: "0.7", + enabled: true, + manifestVersion: "0.7", + requirementCount: 1, + boundaryCount: 1, + integrationCount: 1, + operationCount: 1, + hostCloudManagedExecution: true, + externalSideEffectsRequireApproval: true, + }), + }), + }), + }), + }), + "http://127.0.0.1:4199", + ); + }); + it("Host Bridge 能在同一 App 作用域内 start / stream / get / cancel / retry Agent task", async () => { const container = await renderPage(); await flush(); @@ -570,7 +1136,10 @@ describe("AgentAppRuntimePage", () => { payload: expect.objectContaining({ ok: true, result: [ - expect.objectContaining({ type: "task:progress", status: "running" }), + expect.objectContaining({ + type: "task:progress", + status: "running", + }), ], }), }), @@ -654,7 +1223,9 @@ describe("AgentAppRuntimePage", () => { sessionId: "agent-app-session-1", turnId: "agent-app-turn-1", }); - expect(runtimeApiMocks.submitAgentAppRuntimeHostResponse).toHaveBeenCalledWith({ + expect( + runtimeApiMocks.submitAgentAppRuntimeHostResponse, + ).toHaveBeenCalledWith({ appId: "content-factory-app", taskId: "agent-app-task-1", runtimeRequest: expect.objectContaining({ diff --git a/src/features/agent-app/ui/AgentAppRuntimePage.tsx b/src/features/agent-app/ui/AgentAppRuntimePage.tsx index 54d9e371c..5251ee8b3 100644 --- a/src/features/agent-app/ui/AgentAppRuntimePage.tsx +++ b/src/features/agent-app/ui/AgentAppRuntimePage.tsx @@ -15,6 +15,7 @@ import { createAgentAppCapabilityDispatcher } from "../runtime/capabilityDispatc import { evaluateAgentAppEntryRuntimeGuard } from "../runtime/entryRuntimeGuard"; import { createAgentAppHostBridge, + type AgentAppHostAgentRunUiRequest, type AgentAppHostBridge, type AgentAppHostBridgeCapabilities, type AgentAppHostBridgeNotifyPayload, @@ -24,22 +25,37 @@ import { buildUiRuntimeCapabilityProfile } from "../runtime/uiRuntimeCapabilityP import type { InstalledAgentAppState, ProjectedEntry } from "../types"; import { buildRuntimePackageLoadForPreview } from "./agentAppsRuntime"; import { resolveInstalledAgentAppDisplayName } from "./agentAppDisplay"; +import { AgentRunHostDrawer, type AgentRunUiState } from "./AgentRunHostDrawer"; const HOST_BRIDGE_DISPATCH_CAPABILITIES = new Set([ + "lime.capabilities", "lime.storage", "lime.artifacts", "lime.evidence", "lime.knowledge", "lime.agent", + "lime.models", + "lime.usage", + "lime.skills", + "lime.memory", + "lime.context", + "lime.search", + "lime.browser", + "lime.documents", + "lime.media", + "lime.mcp", + "lime.terminal", + "lime.connectors", ]); const HOST_BRIDGE_KNOWN_CAPABILITIES = new Set([ ...HOST_BRIDGE_DISPATCH_CAPABILITIES, "lime.workflow", ]); -const RUNTIME_PAGE_FLAGS = buildUiRuntimeCapabilityProfile({ +const RUNTIME_PAGE_PROFILE = buildUiRuntimeCapabilityProfile({ realAdapterEnabled: true, uiRuntimeEnabled: true, -}).featureFlags; +}); +const RUNTIME_PAGE_FLAGS = RUNTIME_PAGE_PROFILE.featureFlags; function isUiEntry(entry: ProjectedEntry): boolean { return ["page", "panel", "settings"].includes(entry.kind); @@ -91,6 +107,7 @@ function resolveHostBridgeCapabilities( item.enabled && HOST_BRIDGE_DISPATCH_CAPABILITIES.has(item.capability), ) .map((item) => item.capability); + available.push("lime.capabilities"); const declared = state.projection.requiredCapabilities.map( (item) => item.capability, ); @@ -106,6 +123,175 @@ function resolveHostBridgeCapabilities( }; } +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function readString(value: unknown): string | null { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +function readAgentRunTaskId(value: unknown): string | null { + if (!isRecord(value)) { + return null; + } + return ( + readString(value.taskId) ?? + (isRecord(value.task) ? readString(value.task.taskId) : null) ?? + (isRecord(value.snapshot) ? readString(value.snapshot.taskId) : null) + ); +} + +function readAgentRunItemKey(item: unknown, index: number): string { + if (!isRecord(item)) { + return `${index}:${String(item).slice(0, 80)}`; + } + return [ + readString(item.eventId) ?? readString(item.id), + readString(item.eventType) ?? readString(item.type) ?? readString(item.kind), + readString(item.status) ?? readString(item.statusText), + readString(item.message) ?? readString(item.title), + readString(item.occurredAt) ?? readString(item.at) ?? readString(item.createdAt), + ] + .filter(Boolean) + .join("|"); +} + +function mergeAgentRunItems( + previous: unknown, + next: unknown, + limit = 40, +): unknown[] | undefined { + const previousItems = Array.isArray(previous) ? previous : []; + const nextItems = Array.isArray(next) ? next : []; + if (!previousItems.length && !nextItems.length) { + return undefined; + } + const merged: unknown[] = []; + const indexByKey = new Map(); + [...previousItems, ...nextItems].forEach((item, index) => { + const key = readAgentRunItemKey(item, index); + const stableKey = key || `${index}`; + const existingIndex = indexByKey.get(stableKey); + if (existingIndex === undefined) { + indexByKey.set(stableKey, merged.length); + merged.push(item); + return; + } + merged[existingIndex] = item; + }); + return merged.slice(-limit); +} + +function mergeStringArray(previous: unknown, next: unknown): unknown[] | undefined { + const merged = mergeAgentRunItems(previous, next); + return merged?.length ? merged : undefined; +} + +function mergeAgentRunProcess(previous: unknown, next: unknown): unknown { + if (!isRecord(previous)) { + return next ?? previous; + } + if (!isRecord(next)) { + return previous; + } + const merged: Record = { + ...previous, + ...next, + }; + const timeline = mergeAgentRunItems(previous.timeline, next.timeline, 60); + if (timeline) { + merged.timeline = timeline; + } + const skillNames = mergeStringArray(previous.skillNames, next.skillNames); + if (skillNames) { + merged.skillNames = skillNames; + } + const invokedSkillNames = mergeStringArray( + previous.invokedSkillNames, + next.invokedSkillNames, + ); + if (invokedSkillNames) { + merged.invokedSkillNames = invokedSkillNames; + } + for (const key of ["streamText", "thinkingText", "executionText"]) { + if (!readString(merged[key]) && readString(previous[key])) { + merged[key] = previous[key]; + } + } + return merged; +} + +function mergeAgentRunPayload(previous: unknown, next: unknown): unknown { + if (next === null || next === undefined) { + return previous; + } + if (!isRecord(previous) || !isRecord(next)) { + return next; + } + return { + ...previous, + ...next, + events: mergeAgentRunItems(previous.events, next.events, 80), + taskEvents: mergeAgentRunItems(previous.taskEvents, next.taskEvents, 80), + runtimeProcess: mergeAgentRunProcess( + previous.runtimeProcess, + next.runtimeProcess, + ), + process: mergeAgentRunProcess(previous.process, next.process), + }; +} + +function shouldMergeAgentRunUi( + previous: AgentRunUiState | null, + request: AgentAppHostAgentRunUiRequest, +): previous is AgentRunUiState { + if (!previous) { + return false; + } + const previousTaskId = readAgentRunTaskId(previous); + const nextTaskId = readAgentRunTaskId(request); + if (previousTaskId && nextTaskId && previousTaskId !== nextTaskId) { + return false; + } + if ( + previous.bridgeAction && + request.bridgeAction && + previous.bridgeAction !== request.bridgeAction + ) { + return false; + } + return true; +} + +function mergeAgentRunUiState( + previous: AgentRunUiState | null, + request: AgentAppHostAgentRunUiRequest, + now: string, + fallbackMode: AgentRunUiState["mode"], +): AgentRunUiState { + const base = shouldMergeAgentRunUi(previous, request) ? previous : null; + return { + ...base, + ...request, + taskId: request.taskId ?? base?.taskId, + bridgeAction: request.bridgeAction ?? base?.bridgeAction, + title: request.title ?? base?.title, + mode: request.mode ?? base?.mode ?? fallbackMode, + expectedOutput: request.expectedOutput ?? base?.expectedOutput, + runtimeFacts: request.runtimeFacts ?? base?.runtimeFacts, + task: mergeAgentRunPayload(base?.task, request.task), + snapshot: mergeAgentRunPayload(base?.snapshot, request.snapshot), + runtimeProcess: mergeAgentRunProcess( + base?.runtimeProcess, + request.runtimeProcess, + ), + events: mergeAgentRunItems(base?.events, request.events, 100), + openedAt: base?.openedAt ?? now, + updatedAt: now, + }; +} + export function AgentAppRuntimePage({ pageParams, }: { @@ -118,6 +304,8 @@ export function AgentAppRuntimePage({ const [runtime, setRuntime] = useState(null); const [runtimeError, setRuntimeError] = useState(null); const [retryKey, setRetryKey] = useState(0); + const [agentRunUi, setAgentRunUi] = useState(null); + const [agentRunExpanded, setAgentRunExpanded] = useState(false); const frameRef = useRef(null); const hostBridgeRef = useRef(null); @@ -162,6 +350,13 @@ export function AgentAppRuntimePage({ host: capabilityHost, projection: selected.projection, entryKey: activeEntry.key, + profile: RUNTIME_PAGE_PROFILE, + manifestVersion: selected.manifest.manifestVersion, + agentRuntime: selected.manifest.agentRuntime, + requirements: selected.manifest.requirements, + boundary: selected.manifest.boundary, + integrations: selected.manifest.integrations, + operations: selected.manifest.operations, }); }, [activeEntry, capabilityHost, selected]); @@ -247,6 +442,60 @@ export function AgentAppRuntimePage({ [], ); + useEffect(() => { + setAgentRunUi(null); + setAgentRunExpanded(false); + }, [selected?.appId, activeEntry?.key]); + + const openAgentRunUi = useCallback((request: AgentAppHostAgentRunUiRequest) => { + const now = new Date().toISOString(); + const mode = request.mode ?? "drawer"; + setAgentRunUi((previous) => + mergeAgentRunUiState(previous, request, now, mode), + ); + return { + opened: true, + surface: "host_agent_run" as const, + mode, + taskId: request.taskId, + }; + }, []); + + const updateAgentRunUi = useCallback((request: AgentAppHostAgentRunUiRequest) => { + const now = new Date().toISOString(); + setAgentRunUi((previous) => + mergeAgentRunUiState(previous, request, now, previous?.mode ?? "drawer"), + ); + return { + updated: true, + surface: "host_agent_run" as const, + taskId: request.taskId, + }; + }, []); + + const closeAgentRunUi = useCallback( + (request: Pick) => { + setAgentRunUi((previous) => { + if (!previous) { + return null; + } + const sameTask = + !request.taskId || !previous.taskId || previous.taskId === request.taskId; + const sameBridgeAction = + !request.bridgeAction || + !previous.bridgeAction || + previous.bridgeAction === request.bridgeAction; + return sameTask && sameBridgeAction ? null : previous; + }); + return { + closed: true, + surface: "host_agent_run" as const, + taskId: request.taskId, + }; + }, + [], + ); + useEffect(() => { const frame = frameRef.current; if (!frame || !runtime?.entryUrl || !selected || !activeEntry) { @@ -264,6 +513,9 @@ export function AgentAppRuntimePage({ entryRoute: activeEntry.route, entryUrl: runtime.entryUrl, notify: notifyFromApp, + openAgentRunUi, + updateAgentRunUi, + closeAgentRunUi, capabilities: hostBridgeCapabilities, dispatchCapability, }); @@ -281,12 +533,15 @@ export function AgentAppRuntimePage({ } }, [ activeEntry, + closeAgentRunUi, dispatchCapability, displayName, hostBridgeCapabilities, notifyFromApp, + openAgentRunUi, runtime?.entryUrl, selected, + updateAgentRunUi, ]); const handleFrameLoad = useCallback(() => { @@ -346,7 +601,7 @@ export function AgentAppRuntimePage({ return (