mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-01 15:30:52 +08:00
release: v1.11.0
This commit is contained in:
@@ -50,6 +50,10 @@
|
||||
- **UI 规范**:`docs/aiprompts/design-language.md`
|
||||
- **Tauri 命令边界**:`docs/aiprompts/commands.md`
|
||||
- **命令运行时**:`docs/aiprompts/command-runtime.md`
|
||||
- **任务 / 子代理 taxonomy**:`docs/aiprompts/task-agent-taxonomy.md`
|
||||
- **远程运行时**:`docs/aiprompts/remote-runtime.md`
|
||||
- **记忆 / 压缩主链**:`docs/aiprompts/memory-compaction.md`
|
||||
- **状态 / 历史 / 遥测主链**:`docs/aiprompts/state-history-telemetry.md`
|
||||
- **治理与收口**:`docs/aiprompts/governance.md`
|
||||
- **Harness Engine 治理**:`docs/aiprompts/harness-engine-governance.md`
|
||||
- **Playwright / GUI 续测**:`docs/aiprompts/playwright-e2e.md`
|
||||
|
||||
@@ -178,8 +178,8 @@ npm run tauri:dev
|
||||
npm run tauri build
|
||||
```
|
||||
|
||||
说明:开发脚本统一使用 `CARGO_TARGET_DIR=target`(在 `src-tauri/` 下),避免生成分散的 `target_*` 目录。
|
||||
请务必在仓库根目录执行上述命令;若在 `src-tauri/` 子目录执行,会误生成 `src-tauri/src-tauri/target`。
|
||||
说明:开发脚本默认使用仓库根绝对路径下的 `src-tauri/target` 作为 `CARGO_TARGET_DIR`,避免生成分散的 `target_*` 目录或误写到 `src-tauri/src-tauri/target`;如需并行跑 GUI 续测和 Rust 编译,也可以临时覆盖,例如 `CARGO_TARGET_DIR=/tmp/lime-headless-target npm run tauri:dev:headless`。
|
||||
请务必在仓库根目录执行上述命令;若在 `src-tauri/` 子目录直接运行 Tauri 命令,容易误生成额外的 `target` 目录。
|
||||
|
||||
---
|
||||
|
||||
|
||||
+8
-9
@@ -1,8 +1,8 @@
|
||||
## Lime v1.10.0
|
||||
## Lime v1.11.0
|
||||
|
||||
### ✨ 主要更新
|
||||
|
||||
- 本次 `v1.10.0` 重点把 Harness Engine 的验证事实源进一步收口到同一条主链:`evidence / analysis / review / dashboard / cleanup` 现在共享同一套 verification facts 语义,前端 review 与 evidence 展示也开始复用统一的验证结果区块
|
||||
- 本次 `v1.11.0` 重点把 Harness Engine 的验证事实源进一步收口到同一条主链:`evidence / analysis / review / dashboard / cleanup` 现在共享同一套 verification facts 语义,前端 review 与 evidence 展示也开始复用统一的验证结果区块
|
||||
- Agent 工作台继续围绕 General Workbench、Harness 状态、Tool Search / Tool Call、Inline Process Step、Message List 与 Review Decision 做交互收敛,工作区输入发送与场景运行时同步补齐了一批回归测试
|
||||
- 资源工作台补上图片资源工作台与分类浏览能力,Provider Pool 同步把 Prompt Cache 认知前置到配置 UI,`anthropic-compatible` 渠道与官方兼容 Host 的展示口径进一步统一
|
||||
- 仓库治理继续做减法:独立 `terminal / tools / image-gen / video` 页面面已下线,只保留当前主路径需要的运行时与 API 能力,侧边栏与旧页面残留同步清退
|
||||
@@ -10,23 +10,22 @@
|
||||
|
||||
### 🔗 版本与发布同步
|
||||
|
||||
- 应用、Rust workspace 与 CLI npm wrapper 版本已统一提升到 `1.10.0`
|
||||
- 应用、Rust workspace 与 CLI npm wrapper 版本已统一提升到 `1.11.0`
|
||||
- 应用版本入口已对齐到 `package.json`、`src-tauri/Cargo.toml`、`src-tauri/tauri.conf.json`、`src-tauri/tauri.conf.headless.json`
|
||||
- `package-lock.json`、`src-tauri/Cargo.lock`、`packages/lime-cli-npm/package.json` 与 CLI README 示例已同步到当前版本
|
||||
- 本次发布目标 tag 为 `v1.10.0`
|
||||
- 本次发布目标 tag 为 `v1.11.0`
|
||||
|
||||
### 🧪 已执行校验
|
||||
|
||||
- `npm run verify:app-version`
|
||||
- `npm test -- src/components/settings-v2/system/about/index.test.tsx`
|
||||
- `cargo test --manifest-path "src-tauri/Cargo.toml"`
|
||||
- `cargo clippy --manifest-path "src-tauri/Cargo.toml"`:通过,当前包含 1 条 `clippy::if_same_then_else` 告警,位置在 `src-tauri/crates/core/src/models/provider_pool_model.rs`
|
||||
- `cargo test --manifest-path "src-tauri/Cargo.toml"`:通过,`913` 个单测通过;另有 `2` 个真实联网用例按默认配置保持 `ignored`
|
||||
- `cargo clippy --manifest-path "src-tauri/Cargo.toml"`:通过,当前包含 `2` 条非阻塞告警,分别是 `src-tauri/crates/core/src/models/provider_pool_model.rs` 的 `clippy::if_same_then_else` 与 `src-tauri/src/commands/aster_agent_cmd/tool_runtime/workspace_tools.rs` 的 `dead_code`
|
||||
- `npm run lint`
|
||||
|
||||
### ⏳ 待执行发布动作
|
||||
|
||||
- `cargo fmt --manifest-path "src-tauri/Cargo.toml" --all`
|
||||
- 创建并推送 `v1.10.0` tag
|
||||
- 创建并推送 `v1.11.0` tag
|
||||
- 推送当前分支到 GitHub
|
||||
|
||||
### 📝 发布说明
|
||||
@@ -36,4 +35,4 @@
|
||||
|
||||
---
|
||||
|
||||
**完整变更**: `v1.9.0` -> `v1.10.0`
|
||||
**完整变更**: `v1.10.0` -> `v1.11.0`
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 904 KiB |
@@ -23,6 +23,8 @@
|
||||
- `images/`:文档图片资源
|
||||
- `TECH_SPEC.md`:技术规格文档
|
||||
- `exec-plans/README.md`:执行计划目录说明
|
||||
- `exec-plans/upstream-runtime-alignment-plan.md`:参考运行时主链对齐总计划与排期事实源
|
||||
- `exec-plans/upstream-runtime-alignment-progress.md`:参考运行时主链对齐进度日志
|
||||
- `exec-plans/tech-debt-tracker.md`:技术债持续追踪表
|
||||
- `develop/execution-tracker-technical-plan.md`:统一执行追踪(Execution Tracker)专项技术规划
|
||||
- `develop/execution-tracker-deprecation-plan.md`:统一执行追踪旧路径退场计划(P0 收口)
|
||||
@@ -30,8 +32,27 @@
|
||||
- `develop/execution-tracker-p1-p2-roadmap.md`:统一执行追踪后续路线(P1/P2)
|
||||
- `tech/harness/README.md`:Lime Harness Engineering 总入口
|
||||
- `tech/harness/implementation-blueprint.md`:Lime Harness 分阶段实施蓝图
|
||||
- `aiprompts/query-loop.md`:运行时 Query Loop current 主链与提交边界
|
||||
- `aiprompts/prompt-foundation.md`:基础 Prompt current 主链、system prompt 组装顺序与 current/compat 边界
|
||||
- `aiprompts/task-agent-taxonomy.md`:Task / Agent / Coordinator current taxonomy 与边界归属
|
||||
- `aiprompts/remote-runtime.md`:Remote runtime current 主链、current/compat 分类与远程入口归属
|
||||
- `aiprompts/memory-compaction.md`:Memory / Compaction current 主链、来源链/持久记忆/压缩边界与 current/compat 分类
|
||||
- `aiprompts/state-history-telemetry.md`:State / History / Telemetry current 主链、session/thread/request/evidence/history 边界与 current/compat 分类
|
||||
- `develop/scheduler-task-governance-p1.md`:调度任务治理 P1(连续失败、自动停用、冷却恢复)
|
||||
- `roadmap/lime-skills-standardization-roadmap.md`:Skills 标准化与产品化路线图
|
||||
- `roadmap/limenext/README.md`:LimeNext 平台总纲入口,统一挂接 Workspace、Scene、Runtime、Remote、Governance 与选品系统,并提供业务优先阅读顺序
|
||||
- `roadmap/limenext/sceneapp-capability-model.md`:SceneApp 底层能力模型,定义本地、浏览器、云端、混合场景需要的能力模块范围
|
||||
- `roadmap/limenext/sceneapp-blueprints.md`:用 `x-article-export`、`@配音`、`每日趋势摘要 / 账号增长跟踪` 三条样板把 SceneApp 分类翻译成业务语言
|
||||
- `roadmap/limenext/artifact-evidence-scorecards.md`:把样板场景继续翻译成“交付物、失败证据、经营评分”三层,方便业务与产品判断该继续做还是收缩
|
||||
- `roadmap/limenext/base-setup-decoupling.md`:定义主 App 宿主能力与通用基础设置包的解耦边界,减少“每加一个场景就要升级客户端”的耦合
|
||||
- `roadmap/limenext/base-setup-package-schema.md`:把基础设置包顶层对象、目录投影、profile、兼容性与校验规则写成统一 schema,方便判断“改包还是改宿主”
|
||||
- `roadmap/limenext/base-setup-projection-lifecycle.md`:把基础设置包如何校验、投影、灰度、离线兜底与快速回滚讲成一条发布链
|
||||
- `roadmap/limenext/base-setup-implementation.md`:把 validator、projection compiler、rollout gate 第一版如何挂到现有 `ServiceSkillCatalog` 与 launch/runtime 锚点上写成代码级设计
|
||||
- `roadmap/limenext/composition-blueprint-schema.md`:把组合蓝图最小 schema、步骤类型和 `project pack` 交付合同写清楚
|
||||
- `roadmap/limenext/agent-skills-profile.md`:LimeNext 对齐 Agent Skills 与 Google ADK 的技能包 / 运行时分层说明
|
||||
- `roadmap/limenext/flowcharts.md`:LimeNext 的业务图与技术主链流程图合集
|
||||
- `roadmap/limenext/sequences.md`:LimeNext 的业务时序与技术时序合集
|
||||
- `prd/gongneng/x-article-export/prd.md`:`Browser-grounded SceneApp` 样板功能包,定义 `/x文章转存` 如何把真实网页沉淀成项目内 Markdown bundle
|
||||
- `roadmap/lime-service-skill-cloud-config-prd.md`:服务型技能的端优先执行与云配置同步 PRD
|
||||
- `roadmap/lime-browser-site-capability-prd.md`:站点能力按真实浏览器优先接入的产品需求文档
|
||||
- `ops.md`:运维与发布说明
|
||||
|
||||
@@ -15,6 +15,12 @@
|
||||
### 架构与治理
|
||||
|
||||
- `overview.md` - 项目架构总览与模块分层
|
||||
- `query-loop.md` - 运行时 Query Loop current 主链、提交入口与执行边界
|
||||
- `prompt-foundation.md` - 基础 Prompt current 主链、system prompt 组装顺序与 current/compat 分类
|
||||
- `task-agent-taxonomy.md` - Task / Agent / Coordinator current taxonomy、current/compat 分类与协调边界
|
||||
- `remote-runtime.md` - Remote runtime current 主链、远程入口 current/compat 分类与控制面归属
|
||||
- `memory-compaction.md` - Memory / Compaction current 主链、来源链/持久记忆/压缩边界与 current/compat 分类
|
||||
- `state-history-telemetry.md` - State / History / Telemetry current 主链、session/thread/request/evidence/history 边界与 current/compat 分类
|
||||
- `governance.md` - 新旧并存治理、迁移收口、禁止回流
|
||||
- `harness-engine-governance.md` - Harness Engine 事实源、evidence pack、replay / analysis / review 治理规范
|
||||
- `quality-workflow.md` - 本地校验、GUI smoke、契约检查、CI 门禁
|
||||
@@ -56,6 +62,12 @@
|
||||
## 常见入口建议
|
||||
|
||||
- **改 UI / 页面结构**:先读 `design-language.md`,再看 `quality-workflow.md`
|
||||
- **改 system prompt / subagent prompt / plan prompt / prompt_context / augmentation 顺序**:先读 `prompt-foundation.md`,再回看 `query-loop.md`
|
||||
- **改 turn 提交 / prompt 组包 / queue / compaction / evidence 主链**:先读 `query-loop.md`
|
||||
- **改 subagent / automation / execution tracker / scheduler taxonomy**:先读 `task-agent-taxonomy.md`
|
||||
- **改 channels / browser connector / DevBridge / OpenClaw remote runtime**:先读 `remote-runtime.md`
|
||||
- **改记忆来源链 / working memory / durable memory / Team Memory / compaction**:先读 `memory-compaction.md`
|
||||
- **改 session detail / thread read / requestTelemetry / evidence / history-record**:先读 `state-history-telemetry.md`
|
||||
- **改 Tauri 命令 / Bridge / mock**:先读 `commands.md`,再看 `quality-workflow.md`
|
||||
- **改 `@` / `/` / 轻卡 / viewer / ServiceSkill 场景**:先读 `command-runtime.md`
|
||||
- **改 Claw 技能 / Service Skill / 统一 Skills 标准**:先读 `skill-standard.md`
|
||||
@@ -63,7 +75,7 @@
|
||||
- **做网页登录态访问 / 网页导出 / Markdown 落盘场景**:先读 `web-browser-scene-skill.md`
|
||||
- **改 Workspace / GUI 壳 / 主路径**:先读 `workspace.md`、`quality-workflow.md`、`playwright-e2e.md`
|
||||
- **做迁移 / 收口 / 去兼容层**:先读 `governance.md`
|
||||
- **改 handoff / evidence pack / replay / review / HarnessStatusPanel**:先读 `harness-engine-governance.md`,再回看 `governance.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`
|
||||
|
||||
|
||||
@@ -152,6 +152,35 @@ Lime 的命令体系固定按以下关系理解:
|
||||
|
||||
## 四种产品分型
|
||||
|
||||
先固定一个边界:
|
||||
|
||||
下面这四种分型回答的是:
|
||||
|
||||
**这个能力在产品面属于什么类型。**
|
||||
|
||||
它们不回答:
|
||||
|
||||
**这个 skill / scene 内部是按 `Pipeline`、`Inversion`、`Generator`、`Tool Wrapper` 还是 `Reviewer` 组织的。**
|
||||
|
||||
也就是说,后续新增命令或 SceneApp 时,至少要做三次分类:
|
||||
|
||||
1. **产品分型**
|
||||
- `Agent + Task`
|
||||
- `Agent + ServiceSkill`
|
||||
- `Agent + Workflow`
|
||||
- `Agent + Prompt`
|
||||
2. **SceneApp 运行形态与基础设施画像**
|
||||
- `sceneapp_type`
|
||||
- `infra_profile`
|
||||
3. **编排模式分型**
|
||||
- `Pipeline`
|
||||
- `Inversion`
|
||||
- `Generator`
|
||||
- `Tool Wrapper`
|
||||
- `Reviewer`
|
||||
|
||||
同一个产品分型可以搭配不同的 `SceneApp` 运行形态、基础设施画像与模式组合。
|
||||
|
||||
新增命令前,必须先判断它属于哪一种产品分型:
|
||||
|
||||
### 1. `Agent + Task`
|
||||
@@ -174,6 +203,11 @@ Lime 的命令体系固定按以下关系理解:
|
||||
- 可恢复
|
||||
- 有结构化结果
|
||||
|
||||
常见模式组合:
|
||||
|
||||
- `Pipeline + Generator`
|
||||
- `Pipeline + Tool Wrapper`
|
||||
|
||||
其中图片类能力当前已经有额外运行时纪律:
|
||||
|
||||
- `@配图` / `@修图` / `@重绘` 的 current 主链必须保留原始用户消息进入 Agent
|
||||
@@ -205,6 +239,14 @@ Lime 的命令体系固定按以下关系理解:
|
||||
- 有 slot schema
|
||||
- 有 run / delivery / managed 语义
|
||||
|
||||
常见模式组合:
|
||||
|
||||
- 主模式优先用 `Pipeline`
|
||||
- 缺参或权限门禁叠加 `Inversion`
|
||||
- 结果要稳定落文稿时叠加 `Generator`
|
||||
- 依赖站点、浏览器、服务端能力时叠加 `Tool Wrapper`
|
||||
- 发布前复核或质量守门再按需叠加 `Reviewer`
|
||||
|
||||
当前客户端第一刀收口规则:
|
||||
|
||||
- `/scene-key` 不再直接落回本地 slash skill 预处理
|
||||
@@ -227,6 +269,11 @@ Lime 的命令体系固定按以下关系理解:
|
||||
- 更像打开一个工作区或会话型工作流
|
||||
- 不一定需要独立异步任务协议
|
||||
|
||||
常见模式组合:
|
||||
|
||||
- `Pipeline`
|
||||
- `Pipeline + Inversion`
|
||||
|
||||
### 4. `Agent + Prompt`
|
||||
|
||||
适合:
|
||||
@@ -247,6 +294,12 @@ Lime 的命令体系固定按以下关系理解:
|
||||
- 可以先不独立恢复
|
||||
- 后续可升级为更重的形态
|
||||
|
||||
常见模式组合:
|
||||
|
||||
- `Tool Wrapper`
|
||||
- `Reviewer`
|
||||
- `Inversion + Generator`
|
||||
|
||||
当前 `@搜索` 已按这条主链收口:
|
||||
|
||||
- 前端只补 `harness.research_skill_launch`
|
||||
@@ -367,7 +420,25 @@ Lime 的命令体系固定按以下关系理解:
|
||||
|
||||
如果这一步说不清,禁止直接开始实现。
|
||||
|
||||
### 2. 先判 binding family 和 executor kind
|
||||
### 2. 再补 SceneApp 设计卡
|
||||
|
||||
至少要明确:
|
||||
|
||||
- `sceneapp_type`
|
||||
- `pattern_primary`
|
||||
- `pattern_stack`
|
||||
- `infra_profile`
|
||||
- `execution_entity`
|
||||
- `delivery_contract`
|
||||
|
||||
固定要求:
|
||||
|
||||
- `sceneapp_type` 回答它主要是本地即时、本地 durable、浏览器依赖、云端托管还是混合型
|
||||
- `pattern_primary / pattern_stack` 回答它内部怎么组织逻辑
|
||||
- `infra_profile` 回答它到底用了浏览器、server skill、CLI、schedule、db、markdown 还是 json
|
||||
- 如果这一卡片写不出来,说明这个命令还没被真正设计清楚
|
||||
|
||||
### 3. 再判 binding family 和 executor kind
|
||||
|
||||
至少要明确:
|
||||
|
||||
@@ -376,7 +447,7 @@ Lime 的命令体系固定按以下关系理解:
|
||||
- 如果涉及 `skill`,背后是 CLI、API 还是 hybrid
|
||||
- 底层 truth source 是什么
|
||||
|
||||
### 2.5 先判目录来源与兜底策略
|
||||
### 4. 再判目录来源与兜底策略
|
||||
|
||||
至少要明确:
|
||||
|
||||
@@ -386,10 +457,11 @@ Lime 的命令体系固定按以下关系理解:
|
||||
- 服务端未返回该目录项时,客户端如何回退
|
||||
- 如果这项能力依赖新 render type,Lime 当前是否已经支持
|
||||
|
||||
### 3. 先补方案包
|
||||
### 5. 再补方案包
|
||||
|
||||
方案包至少要回答:
|
||||
|
||||
- `SceneApp` 设计卡怎么填
|
||||
- Agent 如何判断
|
||||
- 如何补参
|
||||
- 目录项由谁下发,客户端如何兜底
|
||||
@@ -400,7 +472,7 @@ Lime 的命令体系固定按以下关系理解:
|
||||
- Rust / Tauri 改哪里
|
||||
- 哪些路径是 current,哪些只是 compat
|
||||
|
||||
### 4. 再进入实现
|
||||
### 6. 再进入实现
|
||||
|
||||
实现时优先遵守:
|
||||
|
||||
@@ -409,7 +481,7 @@ Lime 的命令体系固定按以下关系理解:
|
||||
- viewer 只吃统一 snapshot
|
||||
- 结果卡与 viewer 语义保持一致
|
||||
|
||||
### 5. 实现后回挂
|
||||
### 7. 实现后回挂
|
||||
|
||||
实现完成后,要回写:
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
- `src/components/workspace/**` 与 `src/lib/workspace/**` 是现役 `current` runtime,后续能力只允许继续向这里收敛
|
||||
- 文档导航、实现判断与后续扩展应优先参考 `workspace.md`,不要再把本文件当成现役入口
|
||||
- 基础 Prompt、system prompt 组装顺序、subagent / plan prompt 与 augmentation 主链应优先参考 `prompt-foundation.md`;本文件里的历史工作台叙事不再定义基础 Prompt 事实源
|
||||
- 不要再直接恢复 `src/components/content-creator/**` 旧路径;若需要共享能力,统一经 `src/lib/workspace/*` 网关访问
|
||||
- 已删除的根级旧入口如 `src/hooks/usePosterWorkflow.ts`、`src/hooks/useMultiPlatformExport.ts` 不应回流
|
||||
- 已删除的海报 workflow 孤岛 `src/components/content-creator/workflows/poster/**`、`src/lib/workspace/workbenchPoster.ts` 不应回流
|
||||
@@ -214,7 +215,7 @@ const handleWriteFile = useWorkspaceWriteFileAction(...);
|
||||
|
||||
### Aster 框架限制
|
||||
|
||||
Aster 框架的 `SessionConfig` 不支持 session 级别的 system prompt,因此现役主链采用**消息注入**方案:
|
||||
Aster 框架当前现役主链会把 Lime 组装好的 session prompt 通过 `SessionConfig.system_prompt` 传给 Aster `PromptManager`,再包成最终 provider prompt;这里保留的“首条消息注入”描述只用于解释历史工作台时期的画布联动背景,不再代表仓库当前基础 Prompt 主链:
|
||||
|
||||
- 在第一条用户消息前注入 systemPrompt
|
||||
- 后续消息不再注入(避免重复)
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
# Memory / Compaction 主链
|
||||
|
||||
## 这份文档回答什么
|
||||
|
||||
本文件定义 Lime 当前 `Memory / Compaction` 的唯一主链,主要回答:
|
||||
|
||||
- 哪些路径负责当前回合的记忆来源解析、会话记忆预取、持久记忆召回与压缩续接
|
||||
- `memory_runtime_*`、`unified_memory_*`、`agent_runtime_compact_session`、`project_memory_get` 分别属于哪一层
|
||||
- 哪些页面和稳定读模型只是在消费这条主链,而不是继续定义另一套“真实记忆”
|
||||
- 哪些旧侧链只能作为附属层或退场面,不能再反向定义当前记忆与压缩事实源
|
||||
|
||||
它是 **当前记忆来源、会话记忆、持久记忆与压缩边界的 current 文档**,不是单个页面说明,也不是旧项目资料记忆的聚合注释。
|
||||
|
||||
## 什么时候先读
|
||||
|
||||
遇到以下任一情况时,先读本文件:
|
||||
|
||||
- 调整 `memory_runtime_*`、`memory_get_*`、`memory_toggle_auto`、`memory_update_auto_note`
|
||||
- 调整 `agent_runtime_compact_session`、自动压缩、summary cache 或 overflow compaction
|
||||
- 调整 `memory_source_resolver_service.rs`、`auto_memory_service.rs`、runtime agents template scaffold
|
||||
- 调整 `unified_memory_*`、持久记忆召回、记忆抽取或 unified memory 分析
|
||||
- 调整 `MemoryPage.tsx`、记忆设置页、线程可靠性面板里的记忆预演与压缩展示
|
||||
- 讨论“这条记忆应该进工作记忆、持久记忆、Team Memory 还是项目资料层”的边界归属
|
||||
|
||||
如果一个需求同时碰到“来源链 + 单回合预取”“会话压缩 + 持久记忆”“Memory 页面 + 线程面板记忆预演”中的两项以上,默认属于本主链。
|
||||
|
||||
## 固定主链
|
||||
|
||||
后续 Lime 的 `Memory / Compaction` 只允许向下面这条主链收敛:
|
||||
|
||||
`记忆来源链解析 -> 单回合 memory prefetch -> runtime_turn prompt augmentation -> session compaction -> working/durable memory 沉淀 -> Memory 页面 / 设置页 / 线程面板稳定读模型`
|
||||
|
||||
这条主链的固定判断是:
|
||||
|
||||
1. `memory_runtime_*` 是当前 runtime / 上下文记忆的唯一读模型入口。
|
||||
2. `agent_runtime_compact_session` 是当前手动压缩入口;它属于 Query Loop 下游治理动作,不是另一套聊天系统。
|
||||
3. `unified_memory_*` 是当前跨会话结构化持久记忆的唯一主表面。
|
||||
4. `memory_get_effective_sources`、`memory_get_auto_index`、`memory_toggle_auto`、`memory_update_auto_note` 是来源链与自动记忆 control plane,不是第二条 recall pipeline。
|
||||
5. `project_memory_get` 只保留“项目资料附属层”职责,不能继续抢占 runtime memory / compaction 的主链解释权。
|
||||
|
||||
固定规则只有一句:
|
||||
|
||||
**后续新增记忆或压缩能力时,只允许接到 `memory_runtime_*`、`unified_memory_*` 和 `agent_runtime_compact_session` 这组 current 边界;不允许再造并列记忆真相。**
|
||||
|
||||
## 代码入口地图
|
||||
|
||||
### 1. 来源链与自动记忆 control plane
|
||||
|
||||
- `src/lib/api/memoryRuntime.ts`
|
||||
- `src-tauri/src/commands/memory_management_cmd.rs`
|
||||
- `src-tauri/src/services/memory_source_resolver_service.rs`
|
||||
- `src-tauri/src/services/auto_memory_service.rs`
|
||||
- `src-tauri/src/services/runtime_agents_template_service.rs`
|
||||
|
||||
当前这里负责:
|
||||
|
||||
1. 解析 managed / user / project / local / rules / durable / additional 记忆来源。
|
||||
2. 读取与写入自动记忆入口 `MEMORY.md` 及主题 note。
|
||||
3. 生成 `.lime/AGENTS.md` / `.lime/AGENTS.local.md` 模板与 `.gitignore` 守卫。
|
||||
4. 为 runtime prefetch 和 Memory 页面提供统一来源链读模型。
|
||||
|
||||
固定规则:
|
||||
|
||||
- 来源链解析统一收口到 `resolve_effective_sources(...)`。
|
||||
- 自动记忆目录定位与入口索引统一收口到 `auto_memory_service.rs`。
|
||||
- 页面、Hook 或 runtime 不允许再各自扫描另一套“真实来源链”。
|
||||
|
||||
### 2. 单回合 prefetch、工作记忆与压缩状态
|
||||
|
||||
- `memory_runtime_get_working_memory`
|
||||
- `memory_runtime_get_extraction_status`
|
||||
- `memory_runtime_prefetch_for_turn`
|
||||
- `src/components/memory/memoryLayerMetrics.ts`
|
||||
|
||||
当前这里负责:
|
||||
|
||||
1. 从 runtime memory 目录聚合 `task_plan.md`、`findings.md`、`progress.md`、`error_log.json`。
|
||||
2. 读取最近的 compaction summary cache,形成 `latest_compaction / recent_compactions`。
|
||||
3. 在 `build_turn_memory_prefetch_result(...)` 内统一组装:
|
||||
- `rules_source_paths`
|
||||
- `working_memory_excerpt`
|
||||
- `durable_memories`
|
||||
- `team_memory_entries`
|
||||
- `latest_compaction`
|
||||
- `prompt`
|
||||
4. 输出 Memory 页面、线程可靠性面板和记忆命中预演共用的稳定读模型。
|
||||
|
||||
固定规则:
|
||||
|
||||
- working memory 视图统一由 `collect_working_memory_view(...)` 生成。
|
||||
- durable recall 的回退顺序统一由 `resolve_durable_memory_recall(...)` 决定,不允许 UI 或调用方自己再拼会话优先 / 全局回退逻辑。
|
||||
- Team Memory shadow 只通过 `request_metadata.harness.team_memory_shadow` 进入 prefetch,不单独长第二条 Team recall 边界。
|
||||
|
||||
### 3. Query Loop 集成与压缩执行
|
||||
|
||||
- `src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs`
|
||||
- `src-tauri/src/commands/aster_agent_cmd/command_api/runtime_api.rs`
|
||||
- `src-tauri/crates/aster-rust/crates/aster/src/agents/agent.rs`
|
||||
|
||||
当前这里负责:
|
||||
|
||||
1. `runtime_turn.rs` 在提交前调用 `build_turn_memory_prefetch_result(...)`,把来源链、会话记忆、持久记忆与压缩摘要注入 turn prompt augmentation。
|
||||
2. 回合完成后,`spawn_runtime_memory_capture_task(...)` 会并行沉淀:
|
||||
- working memory:`analyze_memory_candidates(...)`
|
||||
- durable memory:`analyze_unified_memory_candidates(...)`
|
||||
3. `agent_runtime_compact_session` 统一走 `compact_runtime_session_internal(...) -> compact_runtime_session_with_trigger(...) -> Agent::perform_context_compaction(...)` 这条 shared compaction core。
|
||||
4. `compact_runtime_session_with_trigger(...)` 只创建一个最小 control turn 来写时间线与 usage metrics 锚点;真正替换 conversation、更新 summary cache 的核心逻辑仍复用 Aster 的共享 compaction 实现。
|
||||
|
||||
固定规则:
|
||||
|
||||
- 自动沉淀与手动压缩都属于同一条 Query Loop 下游主链。
|
||||
- `agent_runtime_compact_session` 只能视为上下文治理动作,不能被包装成第二套独立记忆系统。
|
||||
- compaction control turn 允许只保留 `thread_id / turn_id` 这组最小 `SessionConfig`;它不参与常规 turn prompt / tool / turn_context snapshot 组包,因此不构成第二事实源。
|
||||
- 如果一个功能要影响“这轮 prompt 最终用了哪些记忆”,先改 `runtime_turn.rs` 与 `memory_management_cmd.rs`,不要在 UI 层旁路拼装。
|
||||
|
||||
### 4. 持久记忆 current surface
|
||||
|
||||
- `src/lib/api/unifiedMemory.ts`
|
||||
- `src-tauri/src/commands/unified_memory_cmd.rs`
|
||||
- `src-tauri/src/commands/memory_search_cmd.rs`
|
||||
|
||||
当前这里负责:
|
||||
|
||||
1. `unified_memory` 的 CRUD、list、stats、analyze。
|
||||
2. 从对话候选中提取跨会话结构化记忆,并写回 `unified_memory` 表。
|
||||
3. 为 durable recall、关键词搜索、语义搜索、混合搜索提供统一持久层。
|
||||
|
||||
固定规则:
|
||||
|
||||
- 跨会话结构化沉淀继续只认 `unified_memory_*`。
|
||||
- `memory_runtime_prefetch_for_turn` 的 durable recall 只能消费 `unified_memory`,不要再造另一套长期记忆表或 JSON 缓存真相。
|
||||
- memory feedback 不属于当前 durable 主链,它不能反向定义哪些记忆是 current。
|
||||
|
||||
### 5. 用户可见稳定读模型
|
||||
|
||||
- `src/components/memory/MemoryPage.tsx`
|
||||
- `src/components/settings-v2/general/memory/index.tsx`
|
||||
- `src/components/agent/chat/components/AgentThreadMemoryPrefetchPreview.tsx`
|
||||
- `src/components/agent/chat/components/AgentThreadReliabilityPanel.tsx`
|
||||
- `src-tauri/src/dev_bridge/dispatcher/memory_runtime.rs`
|
||||
- `src-tauri/src/dev_bridge/dispatcher/memory.rs`
|
||||
- `src/lib/dev-bridge/mockPriorityCommands.ts`
|
||||
- `src/lib/tauri-mock/core.ts`
|
||||
|
||||
当前这里负责:
|
||||
|
||||
1. Memory 页面按 `来源链 / 会话记忆 / 持久记忆 / Team Memory / 会话压缩` 展示统一读模型。
|
||||
2. 设置页复用同一套来源链、自动记忆与命中层状态,而不是再长另一份配置解释。
|
||||
3. 线程可靠性面板与记忆预演卡片继续消费 `memory_runtime_prefetch_for_turn` 的结果。
|
||||
4. DevBridge dispatcher、mock priority 和默认 mock 只为 current 命令提供桥接与兜底,不构成新的事实源。
|
||||
|
||||
固定规则:
|
||||
|
||||
- 页面层只消费 `memory_runtime_*` / `unified_memory_*` 输出,不自己读磁盘或数据库重组另一套真相。
|
||||
- 浏览器模式下的 mock priority 只允许模拟 current 命令结果,不能发明额外字段或第二套状态含义。
|
||||
|
||||
## current / compat / deprecated / dead
|
||||
|
||||
### `current`
|
||||
|
||||
- `docs/aiprompts/memory-compaction.md`
|
||||
- `src/lib/api/memoryRuntime.ts`
|
||||
- `src-tauri/src/commands/memory_management_cmd.rs`
|
||||
- `src-tauri/src/services/memory_source_resolver_service.rs`
|
||||
- `src-tauri/src/services/auto_memory_service.rs`
|
||||
- `src-tauri/src/services/runtime_agents_template_service.rs`
|
||||
- `src-tauri/src/commands/aster_agent_cmd/command_api/runtime_api.rs` 的 `agent_runtime_compact_session`
|
||||
- `src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs`
|
||||
- `src-tauri/crates/aster-rust/crates/aster/src/agents/agent.rs` 的 `compact_session`
|
||||
- `src/lib/api/unifiedMemory.ts`
|
||||
- `src-tauri/src/commands/unified_memory_cmd.rs`
|
||||
- `src-tauri/src/commands/memory_search_cmd.rs`
|
||||
- `src/components/memory/MemoryPage.tsx`
|
||||
- `src/components/settings-v2/general/memory/index.tsx`
|
||||
- `src/components/agent/chat/components/AgentThreadMemoryPrefetchPreview.tsx`
|
||||
- `src/components/agent/chat/components/AgentThreadReliabilityPanel.tsx`
|
||||
- `src-tauri/src/dev_bridge/dispatcher/memory_runtime.rs`
|
||||
- `src-tauri/src/dev_bridge/dispatcher/memory.rs`
|
||||
- `src/lib/dev-bridge/mockPriorityCommands.ts`
|
||||
- `src/lib/tauri-mock/core.ts`
|
||||
|
||||
这些路径共同构成当前记忆 / 压缩主链:
|
||||
|
||||
- 来源链与自动记忆配置看 `memory_management_cmd.rs`
|
||||
- 单回合命中与工作记忆 / 压缩状态看 `memory_runtime_*`
|
||||
- 持久沉淀看 `unified_memory_*`
|
||||
- 手动压缩看 `agent_runtime_compact_session`
|
||||
- GUI 与线程面板只消费同一套稳定读模型
|
||||
|
||||
### `compat`
|
||||
|
||||
- `src/lib/api/memory.ts`
|
||||
- `src-tauri/src/commands/memory_cmd.rs`
|
||||
- `src/lib/workspace/projectPrompt.ts`
|
||||
- `src/components/agent/chat/AgentChatWorkspace.tsx`
|
||||
|
||||
保留原因:
|
||||
|
||||
- 这组路径仍承接角色、世界观、大纲和项目资料 prompt 的附属层能力。
|
||||
- 它们当前还会参与 workspace 上下文和创作资料展示。
|
||||
|
||||
退出条件:
|
||||
|
||||
- 后续若继续保留,必须明确只把它们当“项目资料附属层”,不能再把它们写成 runtime memory / compaction 的唯一事实源。
|
||||
- 新的记忆预取、压缩、自动沉淀、Team Memory 或 durable recall 能力不允许落到这里。
|
||||
|
||||
### `deprecated`
|
||||
|
||||
- `src-tauri/src/commands/memory_feedback_cmd.rs`
|
||||
- `unified_memory_feedback`
|
||||
- `get_memory_feedback_stats`
|
||||
- 任何重新恢复独立 `memory feedback` 前端页或 API 网关的新实现
|
||||
|
||||
保留原因:
|
||||
|
||||
- 仓库里旧的独立记忆反馈前端侧链已经被治理目录册标记为 `dead-candidate`,当前主页面不再暴露这条链。
|
||||
- Rust 侧反馈命令仍在,但已不属于当前记忆 / 压缩主链。
|
||||
|
||||
退出条件:
|
||||
|
||||
- 后续若要继续保留反馈能力,应明确挂回 `MemoryPage` 或 durable memory current surface;否则默认按退场面处理。
|
||||
- 不再允许把反馈链单独扩成新的记忆真相定义者。
|
||||
|
||||
### `dead`
|
||||
|
||||
- `src-tauri/src/commands/memory_search_cmd.rs.bak`
|
||||
|
||||
它只是本地备份残留,不属于任何 current / compat / deprecated 运行面,也不能再被当成实现事实源。
|
||||
|
||||
## 最低验证要求
|
||||
|
||||
如果本轮改动涉及本主链,至少按边界选择最贴近的验证:
|
||||
|
||||
- 纯文档 / 分类回写:`npm run harness:doc-freshness`
|
||||
- 改 `memory_runtime_*`、`memory_get_*`、`unified_memory_*` 或 DevBridge / mock:相关前端测试 + `npm run test:contracts`
|
||||
- 改 `runtime_turn.rs`、自动沉淀或 compaction:相关 Rust 定向测试
|
||||
- 改 Memory 页面、设置页、线程面板:补现有 `*.test.tsx` 稳定断言;必要时再补 `npm run verify:gui-smoke`
|
||||
|
||||
## 这一步如何服务主线
|
||||
|
||||
`M4` 的目标不是一次性重写所有记忆代码,而是先把记忆 / 压缩事实源收成一条 current 主链。
|
||||
|
||||
从现在开始:
|
||||
|
||||
- 解释来源链、自动记忆与入口模板时,回到 `memory_management_cmd.rs`
|
||||
- 解释当前回合命中哪些记忆时,回到 `memory_runtime_prefetch_for_turn`
|
||||
- 解释跨会话结构化沉淀时,回到 `unified_memory_*`
|
||||
- 解释会话压缩与续接边界时,回到 `agent_runtime_compact_session`
|
||||
- 解释角色 / 世界观 / 大纲资料时,视为 `project memory` compat 附属层
|
||||
|
||||
这样后续 `M5 State / History / Telemetry` 才不会继续被“工作记忆、持久记忆、项目资料、压缩摘要”几套语言来回打断。
|
||||
@@ -71,17 +71,18 @@ lime/
|
||||
|------|------|
|
||||
| `src/features/browser-runtime/` | 浏览器协助运行时与调试工作区 |
|
||||
| `src-tauri/src/terminal/` | 内置终端与 PTY 会话 |
|
||||
| `src-tauri/src/services/heartbeat_service/` | 异步调度、周期任务与投递 |
|
||||
| `src-tauri/src/services/automation_service/` + `src-tauri/src/app/scheduler_service.rs` | 自动化任务、后台轮询与兼容调度触发壳 |
|
||||
| `src-tauri/src/plugin/` | 插件系统 |
|
||||
| `src-tauri/src/services/mcp_service.rs` | MCP 服务器与工具管理 |
|
||||
| `src-tauri/src/commands/gateway_channel_cmd.rs` | Telegram / Feishu / Discord Claw 渠道运行时 |
|
||||
| `src-tauri/src/commands/telegram_remote_cmd.rs` | Telegram 远程触发入口 |
|
||||
| `src-tauri/src/commands/gateway_channel_cmd.rs` | 多渠道远程入口、Webhook/Tunnel 与渠道运行时 |
|
||||
| `src-tauri/src/commands/browser_connector_cmd.rs` + `src-tauri/src/commands/webview_cmd.rs` | 浏览器连接器、ChromeBridge 与远程浏览器接入 |
|
||||
| `src-tauri/src/commands/telegram_remote_cmd.rs` + `src-tauri/src/dev_bridge.rs` + `src-tauri/src/services/openclaw_service/*` | 单通道旧入口、开发桥与本地 Gateway 兼容支撑 |
|
||||
|
||||
### 基础设施层
|
||||
|
||||
| 模块 | 说明 |
|
||||
|------|------|
|
||||
| `src-tauri/src/agent/` | Aster Agent 集成、会话、工具注册与流式桥接 |
|
||||
| `src-tauri/src/agent/` | Aster Agent 集成、会话历史、线程读模型、工具注册与流式桥接 |
|
||||
| `providers/` | LLM Provider 认证和 API 实现 |
|
||||
| `services/` | 业务服务层 |
|
||||
| `converter/` | 协议转换与兼容层 |
|
||||
@@ -96,7 +97,7 @@ lime/
|
||||
|
||||
| 模块 | 说明 |
|
||||
|------|------|
|
||||
| `agent/` | Aster Agent 运行时桥接与会话管理 |
|
||||
| `agent/` | Aster Agent 运行时桥接、会话管理与状态读模型装配 |
|
||||
| `skills/` | Skills 标准集成、动态加载与执行回调 |
|
||||
| `providers/` | 多 Provider 认证与请求发送 |
|
||||
| `services/` | 心跳、OpenClaw、浏览器窗口、MCP 等业务服务 |
|
||||
@@ -223,6 +224,8 @@ lime/
|
||||
|
||||
### 产品与工作台
|
||||
- [workspace.md](workspace.md) - Workspace 边界与工作区设计
|
||||
- [memory-compaction.md](memory-compaction.md) - 记忆来源链、会话记忆、持久记忆与会话压缩主链
|
||||
- [state-history-telemetry.md](state-history-telemetry.md) - session/thread/request/evidence/history 的状态读模型主链
|
||||
- [skill-standard.md](skill-standard.md) - 统一技能标准、目录与运行边界
|
||||
- [../../src-tauri/src/skills/README.md](../../src-tauri/src/skills/README.md) - Skills 标准与集成
|
||||
- [terminal.md](terminal.md) - 终端能力
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
# 基础 Prompt 主链
|
||||
|
||||
## 这份文档回答什么
|
||||
|
||||
本文件只定义 Lime 当前真正入模的基础 Prompt 主链,回答四个问题:
|
||||
|
||||
1. 当前 provider 最终看到的 system prompt 是怎么拼出来的
|
||||
2. `project / session / frontend / runtime AGENTS / prompt_context / augmentation` 分别处在什么位置
|
||||
3. 哪些 prompt 文件和 builder 属于 `current`,哪些只是 `compat` / `deprecated`
|
||||
4. `query-loop.md`、功能样板文档、历史工作台说明与基础 Prompt 主链的关系是什么
|
||||
|
||||
一句话事实源声明:
|
||||
|
||||
> 后续所有基础 Prompt、system prompt、subagent prompt、plan prompt、augmentation 顺序与 diagnostics 判断,统一向 `runtime_turn.rs -> prompt_context.rs / prompt services -> TurnInputEnvelope -> aster PromptManager / embedded prompts` 这一条 current 主链收敛。
|
||||
|
||||
## Current 主链总览
|
||||
|
||||
```text
|
||||
runtime_turn.rs
|
||||
-> 选择 base session prompt 来源(project > session > frontend > none)
|
||||
-> merge_system_prompt_with_runtime_agents(...)
|
||||
-> build_full_runtime_system_prompt(...) / build_fast_chat_system_prompt(...)
|
||||
-> apply_service_skill_preload_prompt_stage(...)(仅 FullRuntime)
|
||||
-> TurnInputEnvelope 记录 base/final prompt 与 augmentation stages
|
||||
-> SessionConfig.system_prompt
|
||||
|
||||
aster Agent::prepare_tools_and_prompt(...)
|
||||
-> PromptManager.builder().with_session_prompt(session_prompt)
|
||||
-> Identity(identity.md 或 custom identity)
|
||||
-> Session Context(Lime 组装后的 session prompt)
|
||||
-> Capabilities(capabilities.md + extensions/frontend instructions)
|
||||
-> Additional Instructions / hints / mode guidance
|
||||
-> provider 实际收到的最终 system prompt
|
||||
```
|
||||
|
||||
关键事实:
|
||||
|
||||
- `runtime_turn.rs` 里记录的 `base_system_prompt_len / final_system_prompt_len` 只覆盖 Lime 侧的 `session prompt` 片段,不等于 provider 侧最终收到的完整 system prompt 长度。
|
||||
- provider 最终 prompt 还会再经过 Aster `PromptManager` 包一层 `Identity + Capabilities + hints`。
|
||||
- 因此,排查“Prompt 为什么变长”“Prompt cache 为什么失效”时,不能只看 `prompt_context.rs`,还要一起看 Aster 的 `PromptManager` 和扩展工具面变化。
|
||||
|
||||
## 基础 Prompt 的 current 事实源
|
||||
|
||||
### 1. Base Session Prompt 入口
|
||||
|
||||
- `src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs`
|
||||
- 优先级固定为:
|
||||
1. `project prompt`
|
||||
2. `session prompt`
|
||||
3. `request.system_prompt`(frontend)
|
||||
4. `None`
|
||||
|
||||
对应实现:
|
||||
|
||||
- `AsterAgentState::build_project_system_prompt(...)`
|
||||
- `session_state_snapshot.system_prompt()`
|
||||
- `request.system_prompt`
|
||||
- `TurnInputEnvelopeBuilder::set_base_system_prompt(...)`
|
||||
|
||||
### 2. Lime 侧 augmentation 主链
|
||||
|
||||
- `src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs`
|
||||
- `src-tauri/src/commands/aster_agent_cmd/prompt_context.rs`
|
||||
- `src-tauri/src/services/memory_profile_prompt_service.rs`
|
||||
- `src-tauri/src/services/artifact_prompt_service.rs`
|
||||
- `src-tauri/src/services/web_search_prompt_service.rs`
|
||||
- `src-tauri/crates/agent/src/request_tool_policy.rs`
|
||||
- `src-tauri/crates/agent/src/prompt/runtime_agents.rs`
|
||||
- `src-tauri/crates/agent/src/turn_input_envelope.rs`
|
||||
|
||||
FullRuntime 固定顺序:
|
||||
|
||||
1. `RuntimeAgents`
|
||||
2. `ExplicitLocalPathFocus`
|
||||
3. `Memory`
|
||||
4. `WebSearch`
|
||||
5. `RequestToolPolicy`
|
||||
6. `Artifact`
|
||||
7. `ImageSkillLaunch`
|
||||
8. `CoverSkillLaunch`
|
||||
9. `VideoSkillLaunch`
|
||||
10. `BroadcastSkillLaunch`
|
||||
11. `ResourceSearchSkillLaunch`
|
||||
12. `ResearchSkillLaunch`
|
||||
13. `ReportSkillLaunch`
|
||||
14. `DeepSearchSkillLaunch`
|
||||
15. `SiteSearchSkillLaunch`
|
||||
16. `PdfReadSkillLaunch`
|
||||
17. `PresentationSkillLaunch`
|
||||
18. `FormSkillLaunch`
|
||||
19. `SummarySkillLaunch`
|
||||
20. `TranslationSkillLaunch`
|
||||
21. `AnalysisSkillLaunch`
|
||||
22. `TranscriptionSkillLaunch`
|
||||
23. `UrlParseSkillLaunch`
|
||||
24. `TypesettingSkillLaunch`
|
||||
25. `WebpageSkillLaunch`
|
||||
26. `ServiceSkillLaunch`
|
||||
27. `Elicitation`
|
||||
28. `TeamPreference`
|
||||
29. `AutoContinue`
|
||||
30. `ServiceSkillLaunchPreload`(在主组装后追加,只在 `FullRuntime` 生效)
|
||||
|
||||
FastChat 固定顺序:
|
||||
|
||||
1. `RuntimeAgents`
|
||||
2. `ExplicitLocalPathFocus`
|
||||
3. `RequestToolPolicy`
|
||||
|
||||
这里的“固定顺序”以 `runtime_turn.rs` 和 `TurnPromptAugmentationStageKind` 为准;文档、前端假设或样板说明不得自行重排。
|
||||
|
||||
### 3. Aster 侧最终包装
|
||||
|
||||
- `src-tauri/crates/aster-rust/crates/aster/src/agents/reply_parts.rs`
|
||||
- `src-tauri/crates/aster-rust/crates/aster/src/agents/prompt_manager.rs`
|
||||
- `src-tauri/crates/aster-rust/crates/aster/src/prompt_template.rs`
|
||||
- `src-tauri/crates/aster-rust/crates/aster/src/prompts/*.md`
|
||||
|
||||
当前 provider prompt 的最终结构不是“Lime 直接整段覆盖”,而是:
|
||||
|
||||
1. `identity.md` 或 `AgentIdentity.custom_prompt`
|
||||
2. `Session Context`
|
||||
Lime 在 `runtime_turn.rs` 组好的 session prompt 会放在这里
|
||||
3. `capabilities.md`
|
||||
由 extensions、frontend instructions、tool 数量、mode 等上下文渲染
|
||||
4. 额外指令
|
||||
包括 final output tool prompt、hints、chat mode guidance 等
|
||||
|
||||
### 4. Embedded Prompt 文件的 current 用途
|
||||
|
||||
`src-tauri/crates/aster-rust/crates/aster/src/prompts` 是 Aster 的嵌入式模板目录,但不是所有文件都在 current 主链里。
|
||||
|
||||
当前有明确 runtime 调用点的模板:
|
||||
|
||||
- `identity.md`
|
||||
- `capabilities.md`
|
||||
- `subagent_system.md`
|
||||
- `plan.md`
|
||||
- `recipe.md`
|
||||
- `summarize_oneshot.md`
|
||||
- `permission_judge.md`
|
||||
|
||||
其中:
|
||||
|
||||
- `identity.md + capabilities.md` 是普通主对话最终 prompt 的基础层
|
||||
- `subagent_system.md` 是 subagent current prompt
|
||||
- `plan.md` 是 planning current prompt
|
||||
- `recipe.md` 是 recipe 生成流的 current prompt
|
||||
- `summarize_oneshot.md`、`permission_judge.md` 是特定侧链使用的 current 专用 prompt
|
||||
|
||||
## Current / Compat / Deprecated 边界
|
||||
|
||||
### Current
|
||||
|
||||
以下路径是当前唯一允许继续演进的基础 Prompt 事实源:
|
||||
|
||||
- `src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs`
|
||||
- `src-tauri/src/commands/aster_agent_cmd/prompt_context.rs`
|
||||
- `src-tauri/src/services/memory_profile_prompt_service.rs`
|
||||
- `src-tauri/src/services/artifact_prompt_service.rs`
|
||||
- `src-tauri/src/services/web_search_prompt_service.rs`
|
||||
- `src-tauri/crates/agent/src/request_tool_policy.rs`
|
||||
- `src-tauri/crates/agent/src/prompt/runtime_agents.rs`
|
||||
- `src-tauri/crates/agent/src/turn_input_envelope.rs`
|
||||
- `src-tauri/crates/agent/src/aster_state_support.rs`
|
||||
- `src-tauri/crates/aster-rust/crates/aster/src/agents/prompt_manager.rs`
|
||||
- `src-tauri/crates/aster-rust/crates/aster/src/agents/reply_parts.rs`
|
||||
- `src-tauri/crates/aster-rust/crates/aster/src/prompt_template.rs`
|
||||
- `src-tauri/crates/aster-rust/crates/aster/src/prompts/identity.md`
|
||||
- `src-tauri/crates/aster-rust/crates/aster/src/prompts/capabilities.md`
|
||||
- `src-tauri/crates/aster-rust/crates/aster/src/prompts/subagent_system.md`
|
||||
- `src-tauri/crates/aster-rust/crates/aster/src/prompts/plan.md`
|
||||
- `src-tauri/crates/aster-rust/crates/aster/src/prompts/recipe.md`
|
||||
- `src-tauri/crates/aster-rust/crates/aster/src/prompts/summarize_oneshot.md`
|
||||
- `src-tauri/crates/aster-rust/crates/aster/src/prompts/permission_judge.md`
|
||||
|
||||
### Compat
|
||||
|
||||
以下路径仍保留,但不属于当前基础 Prompt 主链,后续不要继续把新能力长进去:
|
||||
|
||||
- `src-tauri/crates/agent/src/prompt/builder.rs`
|
||||
- `src-tauri/crates/agent/src/prompt/templates.rs`
|
||||
- `src-tauri/crates/aster-rust/crates/aster/src/prompt/builder.rs`
|
||||
- `src-tauri/crates/aster-rust/crates/aster/src/prompt/templates.rs`
|
||||
- `docs/aiprompts/query-loop.md`
|
||||
这是 Query Loop current 文档,但不是基础 Prompt 逐层拼装的唯一事实源
|
||||
- `docs/aiprompts/content-creator.md`
|
||||
这是归档工作台说明,不是基础 Prompt 主入口
|
||||
|
||||
这些 compat 路径可以继续被读取、测试或保留导出,但不能再被当成“当前 prompt 主链定义处”。
|
||||
|
||||
### Deprecated
|
||||
|
||||
以下 embedded prompt 文件当前在仓库内没有明确 runtime 调用点,不应再作为新实现参考:
|
||||
|
||||
- `src-tauri/crates/aster-rust/crates/aster/src/prompts/system.md`
|
||||
- `src-tauri/crates/aster-rust/crates/aster/src/prompts/system_gpt_4.1.md`
|
||||
- `src-tauri/crates/aster-rust/crates/aster/src/prompts/desktop_prompt.md`
|
||||
- `src-tauri/crates/aster-rust/crates/aster/src/prompts/desktop_recipe_instruction.md`
|
||||
|
||||
### 特殊说明
|
||||
|
||||
- `src-tauri/crates/aster-rust/crates/aster/src/prompts/mock.md` 目前只看到 `prompt_template.rs` 内部测试覆盖,不属于基础 Prompt current 主链。
|
||||
- `docs/prd/gongneng/**`、`docs/roadmap/**`、`x-article-export/**` 等功能样板或产品文档,只能消费主链事实,不能反向定义基础 Prompt 顺序。
|
||||
|
||||
## 谁可以定义基础 Prompt,谁只能消费
|
||||
|
||||
可以定义基础 Prompt 的边界:
|
||||
|
||||
- `runtime_turn.rs`
|
||||
- `prompt_context.rs`
|
||||
- 统一 prompt services
|
||||
- `request_tool_policy.rs`
|
||||
- `runtime_agents.rs`
|
||||
- `turn_input_envelope.rs`
|
||||
- Aster `PromptManager` 与有明确调用点的 embedded templates
|
||||
|
||||
只能消费、解释或验证基础 Prompt 的边界:
|
||||
|
||||
- 前端 Workspace / Chat UI
|
||||
- `docs/aiprompts/query-loop.md`
|
||||
- `docs/prd/**` / `docs/roadmap/**`
|
||||
- 功能样板文档,例如 `x-article-export`
|
||||
- 历史工作台归档文档,例如 `content-creator.md`
|
||||
|
||||
如果消费层文档与上述 current 事实源冲突,以 current 代码边界为准,并同步回写文档。
|
||||
|
||||
## 与 Query Loop 的关系
|
||||
|
||||
- `query-loop.md` 负责解释 submit turn、queue、tool runtime、context compaction、evidence 等主循环。
|
||||
- 本文只负责解释“系统提示词是如何形成并进入 provider”的主链。
|
||||
- 两者关系是并列 current 文档,但基础 Prompt 的更细粒度事实源以本文为准。
|
||||
|
||||
遇到以下改动时,先读本文,再回到 `query-loop.md` 看提交链:
|
||||
|
||||
- 改 `system prompt`
|
||||
- 改 `subagent prompt`
|
||||
- 改 `plan prompt`
|
||||
- 改 `prompt_context.rs`
|
||||
- 改 augmentation 顺序或 marker
|
||||
- 改 `TurnInputEnvelope` diagnostics
|
||||
- 排查 token、Prompt Cache、prompt 变长、无声注入等问题
|
||||
|
||||
## 对齐结论
|
||||
|
||||
本轮治理后的统一口径是:
|
||||
|
||||
- Lime 的基础 Prompt 主链不是某一份前端 `systemPrompt`、某一份 PRD,或某个样板包
|
||||
- Lime 的基础 Prompt 主链也不是单独某个 builder 文件
|
||||
- 真正的 current 主链是 “`runtime_turn` 先组 session prompt,再由 Aster `PromptManager` 包装成最终 provider prompt”
|
||||
- 后续所有 Prompt 相关治理,必须围绕这条主链做减法和收口,而不是再新增平级 builder、平级模板或平级文档解释
|
||||
@@ -0,0 +1,266 @@
|
||||
# Query Loop 主链
|
||||
|
||||
## 这份文档回答什么
|
||||
|
||||
本文件定义 Lime 当前运行时 `Query Loop` 的唯一主链,主要回答:
|
||||
|
||||
- 哪个入口才是当前回合提交的唯一开始点
|
||||
- 请求在进入模型前,system prompt、工具策略、记忆、项目上下文、场景 metadata 是怎样被组装的
|
||||
- runtime queue、tool runtime、流式执行、artifact 落盘、记忆沉淀、evidence 导出分别挂在哪一段
|
||||
- 哪些旧路线图或专题文档只能作为下游实现说明,不能再反向充当 Query Loop 事实源
|
||||
|
||||
它是 **当前运行时主循环的工程入口文档**,不是通用架构总览,也不是单次专题路线图。
|
||||
|
||||
## 什么时候先读
|
||||
|
||||
遇到以下任一情况时,先读本文件:
|
||||
|
||||
- 调整 `agent_runtime_submit_turn`
|
||||
- 调整 `agent_runtime_respond_action` 或 elicitation / ask 恢复路径
|
||||
- 调整 turn 级 system prompt / metadata / provider routing / continuation
|
||||
- 调整 runtime queue、流式执行、自动压缩或记忆预取
|
||||
- 调整 `@` 命令、`/scene`、`service_scene_launch`、`*_skill_launch` 这类“提交前补 metadata”能力
|
||||
- 调整 evidence / replay / review 与主回合执行的衔接
|
||||
|
||||
如果一个需求同时碰到“提交入口 + prompt/metadata 组装 + 工具执行 + 证据导出”里的两步以上,默认属于 Query Loop 改动。
|
||||
|
||||
## 固定主链
|
||||
|
||||
当前 Lime 的 Query Loop 统一按下面这条链理解:
|
||||
|
||||
`agent_runtime_submit_turn -> runtime_turn 归一化与组包 -> TurnInputEnvelope -> runtime_queue -> stream_reply_once -> timeline / artifact / memory -> thread_read / evidence / replay / review`
|
||||
|
||||
这条主链意味着:
|
||||
|
||||
1. **统一入口是 `agent_runtime_submit_turn`**
|
||||
其他 `@` 命令、`/scene`、图片/素材/站点搜索等场景,只允许在提交前补 `request_metadata.harness.*`,不能绕开 submit turn 另建第二条执行链。
|
||||
|
||||
2. **`runtime_turn.rs` 是当前组包主边界**
|
||||
Provider 解析、workspace 解析、execution profile、request tool policy、prompt augmentation、sandbox、preload、turn state 都在这里收口。
|
||||
|
||||
3. **`TurnInputEnvelope` 是当前 turn 输入快照**
|
||||
它记录最终 system prompt、history source、provider routing、tool policy、continuation、turn context metadata,不允许下游再各自重组另一份“真实输入”。
|
||||
|
||||
4. **`runtime_queue.rs` 是当前排队执行边界**
|
||||
queue/resume/promote/remove 统一走 runtime queue,不允许每个调用方自己维护另一套忙碌态与排队状态。
|
||||
|
||||
5. **工具面仍统一属于 `tool_runtime.rs`**
|
||||
浏览器、workspace、search、service skill、subagent、social/image 等工具都从这里注册和裁剪,不允许在 Query Loop 外再长出第二套 registry 语义。
|
||||
|
||||
6. **证据链是主链的下游消费,不是旁路真相**
|
||||
`thread_read / evidence / replay / review` 只能复用主回合产生的 runtime facts,不能反向定义 Query Loop 真相。
|
||||
|
||||
## 代码入口地图
|
||||
|
||||
### 1. 提交入口
|
||||
|
||||
- `src-tauri/src/commands/aster_agent_cmd/command_api/runtime_api.rs`
|
||||
- `agent_runtime_submit_turn`
|
||||
- `agent_runtime_interrupt_turn`
|
||||
- `agent_runtime_compact_session`
|
||||
- `agent_runtime_get_session`
|
||||
- `agent_runtime_get_thread_read`
|
||||
|
||||
这里的固定规则:
|
||||
|
||||
- `agent_runtime_submit_turn` 是唯一提交入口
|
||||
- `agent_runtime_respond_action` 这类恢复路径只能复用当前 turn context snapshot 组装,不能再旁路拼第二份 turn context 真相
|
||||
- `get_session / get_thread_read` 负责消费稳定读模型,不负责重新解释提交逻辑
|
||||
- `compact_session` 是主链内的上下文治理动作,不是独立聊天系统
|
||||
|
||||
### 2. turn 归一化与输入组装
|
||||
|
||||
- `src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs`
|
||||
- `src-tauri/crates/agent/src/turn_input_envelope.rs`
|
||||
|
||||
当前 `runtime_turn.rs` 负责的关键步骤:
|
||||
|
||||
1. 确保 agent、session store、runtime support tools 已准备好
|
||||
2. 解析 provider config、workspace、session recent harness/runtime context
|
||||
3. 归一化 `request.metadata`
|
||||
4. 解析 `request_tool_policy` 与 `execution_profile`
|
||||
5. 在 FullRuntime 下补 `*_skill_launch` / `service_scene_launch` metadata
|
||||
6. 组装 prompt augmentation:
|
||||
- runtime agents
|
||||
- memory / turn prefetch
|
||||
- web search
|
||||
- request tool policy
|
||||
- artifact
|
||||
- 各类 skill launch
|
||||
- team preference / auto continue / service skill preload
|
||||
7. 生成 `TurnState`
|
||||
8. 生成 `TurnInputEnvelope` 与 diagnostics snapshot
|
||||
9. 初始化 runtime turn、发出 runtime status
|
||||
10. 进入 tracker + stream 执行
|
||||
|
||||
`TurnInputEnvelope` 当前固定记录:
|
||||
|
||||
- session / workspace / project / thread / turn
|
||||
- system prompt 来源与最终值
|
||||
- prompt augmentation stages
|
||||
- request tool policy snapshot
|
||||
- provider routing snapshot
|
||||
- provider continuation state
|
||||
- effective user message
|
||||
- turn output schema snapshot
|
||||
- approval / sandbox policy
|
||||
- turn context metadata
|
||||
|
||||
后续如果一个改动说不清应该落在 `runtime_turn.rs` 还是 `TurnInputEnvelope`,先判断它属于:
|
||||
|
||||
- **组包逻辑**:落 `runtime_turn.rs`
|
||||
- **turn 输入快照字段**:落 `turn_input_envelope.rs`
|
||||
|
||||
### 3. queue 与恢复
|
||||
|
||||
- `src-tauri/crates/agent/src/runtime_queue.rs`
|
||||
|
||||
当前 queue 主链固定为:
|
||||
|
||||
- `submit_runtime_turn`
|
||||
- `resume_runtime_queue_if_needed`
|
||||
- `clear_runtime_queue`
|
||||
- `list_runtime_queue_snapshots`
|
||||
- `remove_runtime_queued_turn`
|
||||
- `promote_runtime_queued_turn`
|
||||
- `resume_persisted_runtime_queues_on_startup`
|
||||
|
||||
这里的固定规则:
|
||||
|
||||
- queue busy / enqueue / start-now 统一由 runtime queue service 决定
|
||||
- 启动恢复、回合完成后的下一条接力,都复用同一套 queue service
|
||||
- 不允许前端、命令层或业务专题自己重建另一套排队真相
|
||||
|
||||
### 4. 工具面与沙箱
|
||||
|
||||
- `src-tauri/src/commands/aster_agent_cmd/tool_runtime.rs`
|
||||
|
||||
当前这里负责:
|
||||
|
||||
- runtime support tools 注册
|
||||
- fast chat 与 full runtime 的工具面裁剪
|
||||
- workspace sandbox 权限注入
|
||||
- browser / site / service skill / subagent / workspace / search 等工具面注册或下线
|
||||
|
||||
固定规则:
|
||||
|
||||
- Query Loop 不直接 new tool registry
|
||||
- 所有工具面增删都先回到 `tool_runtime.rs`
|
||||
- `request_tool_policy` 只能在这里影响实际工具可见面,不能在别处再复制一套“隐藏工具”逻辑
|
||||
|
||||
### 5. 流式执行与主回合副作用
|
||||
|
||||
- `src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs`
|
||||
|
||||
主回合执行当前固定通过:
|
||||
|
||||
- `ExecutionTracker::with_run_custom(...)`
|
||||
- `stream_reply_once(...)`
|
||||
|
||||
执行中的 current 副作用包括:
|
||||
|
||||
- runtime event -> timeline recorder
|
||||
- artifact document persist / fallback
|
||||
- runtime memory capture
|
||||
- provider continuation state 更新
|
||||
- runtime status / warning / preload 事件投影
|
||||
|
||||
如果一个功能想在“模型回复完成后”做收尾动作,应优先挂到这里,而不是在 UI 或单独 service 旁路触发。
|
||||
|
||||
### 6. 压缩、读模型与证据导出
|
||||
|
||||
当前 Query Loop 的下游消费统一挂在下面几处:
|
||||
|
||||
- `agent_runtime_compact_session`
|
||||
- `agent_runtime_get_session`
|
||||
- `agent_runtime_get_thread_read`
|
||||
- `src-tauri/src/services/runtime_evidence_pack_service.rs`
|
||||
- `src-tauri/src/services/runtime_replay_case_service.rs`
|
||||
- `src-tauri/src/services/runtime_review_decision_service.rs`
|
||||
|
||||
固定规则:
|
||||
|
||||
- 自动压缩与手动压缩都属于同一 runtime 主链
|
||||
- `thread_read` 是稳定读模型,不重新定义提交逻辑
|
||||
- `evidence / replay / review` 必须消费 runtime facts,不允许自己拼第二份 Query Loop 真相
|
||||
|
||||
## 专项场景如何挂回主链
|
||||
|
||||
所有下列能力都只能视为 Query Loop 的“提交前 metadata / prompt 归一化扩展”,不能单独定义新的执行主链:
|
||||
|
||||
- `@配图` / `@封面` / `@视频` / `@播报` / `@素材`
|
||||
- `@搜索` / `@深搜` / `@研报` / `@竞品`
|
||||
- `@站点搜索` / `@读PDF` / `@总结` / `@翻译` / `@分析`
|
||||
- `@转写` / `@链接解析` / `@抓取` / `@网页读取`
|
||||
- `@排版` / `@网页` / `@PPT` / `@表单`
|
||||
- `service_scene_launch`
|
||||
|
||||
它们当前的正确角色是:
|
||||
|
||||
1. 前端发送边界写入结构化 `request_metadata.harness.*`
|
||||
2. `runtime_turn.rs` 在提交前归一化 metadata 与 prompt
|
||||
3. Agent 首刀按系统提示走 skill / tool / service skill 主链
|
||||
|
||||
不允许回流成:
|
||||
|
||||
- 前端直建任务
|
||||
- 前端直调云端 run
|
||||
- viewer 自己推断执行状态
|
||||
- 单个场景自己维护一套独立 queue / runtime / evidence
|
||||
|
||||
## current / compat / deprecated
|
||||
|
||||
### `current`
|
||||
|
||||
- `agent_runtime_submit_turn`
|
||||
- `runtime_turn.rs`
|
||||
- `TurnInputEnvelope`
|
||||
- `runtime_queue.rs`
|
||||
- `tool_runtime.rs`
|
||||
- `agent_runtime_get_session / get_thread_read`
|
||||
- `agent_runtime_compact_session`
|
||||
- `src-tauri/src/commands/aster_agent_cmd/action_runtime.rs::agent_runtime_respond_action`
|
||||
- `runtime_evidence_pack_service.rs`
|
||||
- `runtime_replay_case_service.rs`
|
||||
- `runtime_review_decision_service.rs`
|
||||
|
||||
### `compat`
|
||||
|
||||
- `docs/roadmap/lime-conversation-execution-efficiency-roadmap.md`
|
||||
- `docs/roadmap/lime-aster-codex-alignment-roadmap.md`
|
||||
- `docs/roadmap/lime-aster-codex-state-model-implementation-plan.md`
|
||||
- `src-tauri/src/commands/persona_cmd.rs::generate_persona`
|
||||
- `src-tauri/src/commands/theme_context_cmd.rs::aster_agent_theme_context_search`
|
||||
|
||||
这些文档与专用命令仍可保留各自职责,但不再承担 Query Loop 唯一事实源职责。
|
||||
这两条命令属于专用一次性会话能力:允许显式拼自己的临时 `SessionConfig`,但不能参与 submit turn、runtime queue、turn context snapshot 或 evidence 真相定义。
|
||||
当前命令层允许保留的原始执行面只剩这 3 处:`action_runtime` 属于 current 恢复链,`persona_cmd` 与 `theme_context_cmd` 属于受控 compat 一次性命令。
|
||||
|
||||
### `deprecated`
|
||||
|
||||
- 让任何 `@` 场景、slash scene 或 viewer 自己维护执行状态
|
||||
- 在 UI、专题 service 或证据导出层重新拼装“真实模型输入”
|
||||
- 绕开 `agent_runtime_submit_turn` 直接定义第二条主回合执行链
|
||||
- 在 Tauri 命令层继续新增未分类的原始 `agent.reply(...)` / `stream_reply_with_policy(...)` 调用
|
||||
|
||||
## 最低验证要求
|
||||
|
||||
如果本轮改动涉及 Query Loop,至少按边界选择最贴近的验证:
|
||||
|
||||
- Rust 定向测试:
|
||||
- `runtime_turn.rs`
|
||||
- `turn_input_envelope.rs`
|
||||
- `runtime_queue.rs`
|
||||
- 相关 evidence / replay / review service
|
||||
- `npm run governance:legacy-report`
|
||||
- 文档改动额外跑 `npm run harness:doc-freshness`
|
||||
|
||||
如果还改了命令边界、MCP 注入、GUI 时间线或工具展示,再回看:
|
||||
|
||||
- `docs/aiprompts/commands.md`
|
||||
- `docs/aiprompts/quality-workflow.md`
|
||||
- `docs/aiprompts/harness-engine-governance.md`
|
||||
|
||||
## 一句话
|
||||
|
||||
> Lime 当前 Query Loop 的唯一主链,是 `agent_runtime_submit_turn` 驱动的 turn 组包、runtime queue、tool runtime、流式执行与 evidence 下游消费链;任何新能力都只能往这条链收敛,不能平行再长一套执行真相。
|
||||
@@ -0,0 +1,217 @@
|
||||
# Remote runtime 主链
|
||||
|
||||
## 这份文档回答什么
|
||||
|
||||
本文件定义 Lime 当前 `Remote / SDK / Server Mode` 的唯一 remote runtime 事实源,主要回答:
|
||||
|
||||
- 哪些远程入口才算当前产品主链
|
||||
- `消息渠道`、`浏览器连接器 / ChromeBridge`、`OpenClaw`、`DevBridge`、`telegram_remote` 分别属于哪一层
|
||||
- 哪些路径负责真实 ingress / control plane,哪些只是安装壳、调试桥或兼容入口
|
||||
- 后续新增 remote 能力应该往哪里收敛,而不是继续长平级旁路
|
||||
|
||||
它是 **远程入口与控制面的 current 文档**,不是 OpenClaw 页面说明,也不是单条渠道命令的局部注释。
|
||||
|
||||
## 什么时候先读
|
||||
|
||||
遇到以下任一情况时,先读本文件:
|
||||
|
||||
- 调整 `gateway_channel_*`、`gateway_tunnel_*`、渠道 probe 或多账号渠道运行时
|
||||
- 调整浏览器连接器、ChromeBridge、远程调试入口或 browser backend policy
|
||||
- 调整 `DevBridge` HTTP 桥、浏览器 dev shell 的后端接通逻辑
|
||||
- 调整 `OpenClaw` Gateway、Dashboard、安装与运行态管理
|
||||
- 调整单通道 Telegram 远程触发入口,或评估是否应该继续保留它
|
||||
|
||||
如果一个需求同时碰到“远程触发 + 浏览器接入”“渠道入口 + 本地 Gateway”“调试桥 + 产品运行时”中的两项以上,默认属于本主链。
|
||||
|
||||
## 固定 remote 主链
|
||||
|
||||
后续 Lime 的 remote runtime 只允许向下面这条主链收敛:
|
||||
|
||||
`外部入口(消息渠道 / 浏览器连接器) -> 当前本地 control plane -> agent/browser runtime -> 现有 session/task/evidence 事实源`
|
||||
|
||||
这条主链的固定判断是:
|
||||
|
||||
1. `消息渠道 runtime` 是当前 IM 远程入口主链
|
||||
2. `浏览器连接器 / ChromeBridge` 是当前浏览器侧远程 transport 主链
|
||||
3. `OpenClaw` 只作为本地 Gateway / 安装 / Dashboard 兼容壳,不再定义新的 remote 真相
|
||||
4. `DevBridge` 只作为 debug-only 开发桥,不再冒充产品 remote runtime
|
||||
5. `telegram_remote_cmd` 是旧单通道入口,不再继续扩成长期主链
|
||||
|
||||
固定规则只有一句:
|
||||
|
||||
**后续新增 remote 能力时,只允许接到 `消息渠道 runtime` 或 `浏览器连接器 / ChromeBridge` 这两条 current ingress;不允许再造第三条并列 remote runtime。**
|
||||
|
||||
## 代码入口地图
|
||||
|
||||
### 1. `消息渠道 runtime`
|
||||
|
||||
- `src/lib/api/channelsRuntime.ts`
|
||||
- `src-tauri/src/commands/gateway_channel_cmd.rs`
|
||||
- `lime_gateway::{telegram, feishu, discord, wechat}`
|
||||
|
||||
当前这里负责:
|
||||
|
||||
1. 多渠道 start / stop / status
|
||||
2. 渠道账号 probe、登录、运行时模型绑定
|
||||
3. tunnel / webhook 暴露与同步
|
||||
4. 远程入站请求到本地 agent runtime 的 current 渠道入口
|
||||
|
||||
固定规则:
|
||||
|
||||
- 渠道远程入口统一走 `gateway_channel_*` 与 `gateway_tunnel_*`
|
||||
- 前端当前主入口是 `channelsRuntime.ts`
|
||||
- 不再把单独某个平台 bot runtime 重新拉回产品级总入口
|
||||
|
||||
### 2. `浏览器连接器 / ChromeBridge`
|
||||
|
||||
- `src/lib/webview-api.ts`
|
||||
- `src-tauri/src/commands/browser_connector_cmd.rs`
|
||||
- `src-tauri/src/services/browser_connector_service.rs`
|
||||
- `src-tauri/src/commands/webview_cmd.rs`
|
||||
- `src-tauri/src/commands/browser_runtime_cmd.rs`
|
||||
|
||||
当前这里负责:
|
||||
|
||||
1. 浏览器连接器安装、启停与权限配置
|
||||
2. ChromeBridge 端点、连接状态与 connector session 断开
|
||||
3. 外部 Chrome profile / CDP / managed browser backend 状态
|
||||
4. Browser Assist 与远程浏览器接入的 current transport 事实
|
||||
|
||||
固定规则:
|
||||
|
||||
- 浏览器侧 remote transport 统一收口到 `webview-api.ts`
|
||||
- `browser_connector_cmd.rs` 只负责设置与安装入口
|
||||
- 真正的 session / backend / remote debugging 状态继续由 `webview_cmd.rs`、`browser_runtime_cmd.rs` 暴露
|
||||
|
||||
### 3. `OpenClaw` 本地 Gateway 壳
|
||||
|
||||
- `src/lib/api/openclaw.ts`
|
||||
- `src-tauri/src/commands/openclaw_cmd.rs`
|
||||
- `src-tauri/src/services/openclaw_service/*`
|
||||
- `src/components/openclaw/*`
|
||||
|
||||
当前这里负责:
|
||||
|
||||
1. 本地安装、升级、环境检查
|
||||
2. 本地 Gateway 进程拉起、停止、重启
|
||||
3. Dashboard URL、健康检查、运行环境选择
|
||||
4. 兼容页面与过渡操作壳
|
||||
|
||||
固定规则:
|
||||
|
||||
- `OpenClaw` 当前不是 remote runtime 的唯一事实源
|
||||
- 它管理的是“本地 Gateway 壳与安装体验”,不是远程 session/control plane 真相
|
||||
- 后续如果保留,只允许继续做 compat 支撑,不再扩成第二套产品 remote coordinator
|
||||
|
||||
这一步里“`OpenClaw` 属于 compat”是基于现有仓库实现和产品规划推断:
|
||||
|
||||
- 代码上它主要暴露安装、环境、Gateway、Dashboard 与 runtime candidate 管理
|
||||
- 产品规划里它已被明确定位为“过渡安装入口”,不是长期一级产品导航
|
||||
|
||||
### 4. `DevBridge`
|
||||
|
||||
- `src-tauri/src/dev_bridge.rs`
|
||||
- `src-tauri/src/dev_bridge/*`
|
||||
- `src/lib/dev-bridge/*`
|
||||
|
||||
当前这里负责:
|
||||
|
||||
1. 仅在 `debug_assertions` 下提供 `3030` HTTP 桥
|
||||
2. 让浏览器 dev server 调用现有 Tauri 命令
|
||||
3. 为浏览器开发模式提供事件流和本地后端接通能力
|
||||
|
||||
固定规则:
|
||||
|
||||
- `DevBridge` 是 debug-only 开发桥,不是产品 remote runtime
|
||||
- 它可以桥接 current 命令,但不能反向定义 current remote taxonomy
|
||||
- 后续只允许继续做开发态适配与调试支撑
|
||||
|
||||
### 5. `telegram_remote_cmd`
|
||||
|
||||
- `src-tauri/src/commands/telegram_remote_cmd.rs`
|
||||
|
||||
当前这里负责:
|
||||
|
||||
1. Telegram 单通道轮询
|
||||
2. 将命令映射到 `agent.run / agent.wait / agent.stop / cron.* / sessions.*`
|
||||
|
||||
固定规则:
|
||||
|
||||
- 它当前没有前端主入口
|
||||
- 多渠道 current 主链已经迁到 `gateway_channel_cmd.rs`
|
||||
- 后续只允许迁移、收口或下线,不再继续扩功能
|
||||
|
||||
## current / compat / deprecated / dead
|
||||
|
||||
### `current`
|
||||
|
||||
- `src/lib/api/channelsRuntime.ts`
|
||||
- `src-tauri/src/commands/gateway_channel_cmd.rs`
|
||||
- `gateway_tunnel_*`
|
||||
- `lime_gateway::{telegram, feishu, discord, wechat}`
|
||||
- `src/lib/webview-api.ts`
|
||||
- `src-tauri/src/commands/browser_connector_cmd.rs`
|
||||
- `src-tauri/src/services/browser_connector_service.rs`
|
||||
- `src-tauri/src/commands/webview_cmd.rs`
|
||||
- `src-tauri/src/commands/browser_runtime_cmd.rs`
|
||||
- `docs/aiprompts/remote-runtime.md`
|
||||
|
||||
这些路径共同构成当前 remote 主链:
|
||||
|
||||
- IM 远程入口看 `gateway_channel_*`
|
||||
- 远程浏览器 transport 看 `browser connector / ChromeBridge`
|
||||
- 真实会话、任务与执行结果继续回到既有 agent/browser runtime 真相
|
||||
|
||||
### `compat`
|
||||
|
||||
- `src-tauri/src/dev_bridge.rs`
|
||||
- `src-tauri/src/dev_bridge/*`
|
||||
- `src/lib/dev-bridge/*`
|
||||
- `src/lib/api/openclaw.ts`
|
||||
- `src-tauri/src/commands/openclaw_cmd.rs`
|
||||
- `src-tauri/src/services/openclaw_service/*`
|
||||
- `src/components/openclaw/*`
|
||||
|
||||
保留原因:
|
||||
|
||||
- `DevBridge` 仍是浏览器开发模式的必要桥接层
|
||||
- `OpenClaw` 仍承接本地 Gateway、安装、升级与 Dashboard 过渡体验
|
||||
|
||||
退出条件:
|
||||
|
||||
- `DevBridge` 继续只做开发态桥接,不再承担产品 remote 叙事
|
||||
- `OpenClaw` 若继续保留,也只能作为本地壳与兼容入口,不再扩成第二套 remote control plane
|
||||
|
||||
### `deprecated`
|
||||
|
||||
- `src-tauri/src/commands/telegram_remote_cmd.rs`
|
||||
- 任何继续把单渠道 bot runtime 直接定义为 remote 总入口的新实现
|
||||
- 任何继续把 `OpenClaw` 页面或 `DevBridge` HTTP 桥当成产品 remote runtime 真相的新实现
|
||||
|
||||
### `dead`
|
||||
|
||||
- 当前 `M3` 不新增远程面的强制删除项;本轮先完成 current 事实源收口,不做额外硬删
|
||||
|
||||
## 最低验证要求
|
||||
|
||||
如果本轮改动涉及本主链,至少按边界选择最贴近的验证:
|
||||
|
||||
- 纯文档 / 分类回写:`npm run harness:doc-freshness`
|
||||
- 改渠道命令 / tunnel / webhook:`npm run test:contracts` 与相关渠道定向测试
|
||||
- 改浏览器连接器 / ChromeBridge:浏览器相关定向测试或 `verify:gui-smoke`
|
||||
- 改 `DevBridge`:至少补桥接定向测试
|
||||
- 改 `OpenClaw`:至少补相关页面或命令定向测试
|
||||
|
||||
## 这一步如何服务主线
|
||||
|
||||
`M3` 的目标不是一次性重写所有 remote 功能,而是先把 remote 真相收成一条 current 主链。
|
||||
|
||||
从现在开始:
|
||||
|
||||
- 解释 IM 远程入口时,回到 `gateway_channel_*`
|
||||
- 解释远程浏览器 transport 时,回到 `browser connector / ChromeBridge`
|
||||
- 解释开发态浏览器接通时,回到 `DevBridge` compat
|
||||
- 解释本地 Gateway / Dashboard 壳时,回到 `OpenClaw` compat
|
||||
- 解释旧 Telegram 单通道触发时,视为 `deprecated`
|
||||
|
||||
这样后续 `M4 Memory / Compaction` 和 `M5 State / History / Telemetry` 就不必继续被 remote 入口语言打断。
|
||||
@@ -4,9 +4,9 @@
|
||||
|
||||
本文件定义 Lime 仓库里 `skill` 能力的统一工程标准,主要回答:
|
||||
|
||||
- Lime 自己认可的 skill 标准长什么样
|
||||
- Lime 在 Agent Skills 之上认可的技能包标准与运行时 profile 长什么样
|
||||
- `skill`、`adapter`、`runtime binding` 的边界分别是什么
|
||||
- 为什么外部 `SKILL.md` 仓库只能作为说明层参考,不能直接成为 Lime 的正式标准
|
||||
- 为什么 Agent Skills 应该成为 Lime 的包格式标准,但不能直接等同于 Lime 的运行时协议
|
||||
- 以后新增 Claw 业务技能、站点技能、提示词技能时,应该如何保持一致
|
||||
|
||||
它是 **Lime 技能能力的总标准文档**。
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
## 第一原则
|
||||
|
||||
**Lime 有自己的 skills 标准。来源可以多个,但标准只能有一个。**
|
||||
**Agent Skills 是 Lime 唯一对齐的技能包格式标准;Lime 只在这个标准之上定义自己的运行时与产品 profile。**
|
||||
|
||||
对 Lime 来说,可以同时存在:
|
||||
|
||||
@@ -27,14 +27,15 @@
|
||||
- 仓库内 seeded 技能目录
|
||||
- 外部项目提供的 `SKILL.md` / YAML / adapter 来源
|
||||
|
||||
但 Lime 内部继续演进的标准只能有一套。
|
||||
但“技能包格式标准”只能有一套。
|
||||
|
||||
从现在开始,技能能力的唯一长期事实源应收敛到:
|
||||
从现在开始,技能包格式的唯一长期事实源应收敛到:
|
||||
|
||||
> `Lime Skill Spec`
|
||||
> `Agent Skills Specification`
|
||||
|
||||
外部仓库只能提供:
|
||||
|
||||
- 标准包结构
|
||||
- 说明层模板
|
||||
- 来源层原料
|
||||
- 触发语义参考
|
||||
@@ -46,6 +47,11 @@
|
||||
- Lime 的 UI 表达标准
|
||||
- Lime 的自动化与浏览器行为边界
|
||||
|
||||
换句话说:
|
||||
|
||||
- `Agent Skills` 负责回答“技能包长什么样”
|
||||
- `Lime` 负责回答“技能包进入产品后怎么分发、怎么补参、怎么执行、怎么交付”
|
||||
|
||||
## 什么时候先读
|
||||
|
||||
出现以下任一情况时,先读本文件,再决定是否写代码:
|
||||
@@ -68,7 +74,7 @@
|
||||
- 定义另一套浏览器 runtime
|
||||
- 让外部 `SKILL.md` 直接成为 Lime 运行时协议
|
||||
- 把 adapter 当成 skill 本体
|
||||
- 为了兼容来源而长期维护第二套 skill 协议
|
||||
- 为了兼容来源而长期维护第二套技能包协议
|
||||
- 在第一阶段把所有既有实现一次性重命名重构完
|
||||
|
||||
尤其不要把“支持更多技能”误解成:
|
||||
@@ -99,6 +105,20 @@ Lime 在工程上必须明确接受这一点:
|
||||
|
||||
如果一个 Skill 只有一段说明文字,没有任何额外知识、资产、脚本、验证或渐进披露结构,那么它更接近提示词说明,而不是强 Skill。
|
||||
|
||||
## 第三原则
|
||||
|
||||
**Lime 不直接执行 `SKILL.md`,而是把 Agent Skills 包编译成自己的目录投影与运行时绑定。**
|
||||
|
||||
这也是为什么 Lime 可以兼容 Agent Skills,但不需要把产品层降级成“SkillToolset 产品”:
|
||||
|
||||
`Agent Skill Bundle -> 标准解析 / 校验 -> SkillBundle 摘要层 -> ServiceSkillCatalog / SkillCatalog / SceneCatalog 投影 -> Runtime Binding 执行`
|
||||
|
||||
固定结论:
|
||||
|
||||
1. `SKILL.md` 不是 Lime 的最终产品对象。
|
||||
2. `ServiceSkill` / `Scene` 也不是新的包格式标准。
|
||||
3. `ServiceSkill` / `Scene` 是 Lime 在 Agent Skills 之上的产品投影层。
|
||||
|
||||
## 设计原则补充
|
||||
|
||||
除了分层边界,Lime Skill 在设计上还应遵守下面几条补充原则。
|
||||
@@ -152,9 +172,35 @@ Skill 最有价值的内容,是把模型从默认思路里“推出来”。
|
||||
|
||||
## 标准分层
|
||||
|
||||
Lime 的技能标准必须分成四层:
|
||||
Lime 的技能标准必须分成五层:
|
||||
|
||||
### 1. 说明层
|
||||
### 1. 包标准层
|
||||
|
||||
作用:
|
||||
|
||||
- 定义一个 Skill 包在磁盘或远程仓库里长什么样
|
||||
- 明确前置字段、资源目录、兼容校验与渐进加载边界
|
||||
|
||||
当前固定对齐:
|
||||
|
||||
- `Agent Skills Specification`
|
||||
|
||||
标准字段优先包括:
|
||||
|
||||
- `name`
|
||||
- `description`
|
||||
- `license`
|
||||
- `compatibility`
|
||||
- `metadata`
|
||||
- `allowed-tools`
|
||||
|
||||
固定规则:
|
||||
|
||||
1. 不再新造第二种 Skill 包格式。
|
||||
2. Lime 私有字段统一进入 `metadata.Lime_*`,不新增顶层私有 frontmatter。
|
||||
3. 包标准层只回答“技能包长什么样”,不回答“在 Lime 里怎么执行”。
|
||||
|
||||
### 2. 说明层
|
||||
|
||||
作用:
|
||||
|
||||
@@ -171,27 +217,30 @@ Lime 的技能标准必须分成四层:
|
||||
|
||||
但说明层不是 Lime 的运行时事实源。
|
||||
|
||||
### 2. 输入层
|
||||
### 3. 输入与产品投影层
|
||||
|
||||
作用:
|
||||
|
||||
- 定义技能参数、默认值、校验和补参表单
|
||||
- 把标准 Skill 包投影成 Lime 可消费的输入、展示和产品对象
|
||||
|
||||
当前主承载结构是:
|
||||
|
||||
- `src/lib/api/serviceSkills.ts` 里的 `ServiceSkillItem`
|
||||
- `slotSchema`
|
||||
- `readinessRequirements`
|
||||
- `sceneBinding`
|
||||
- `skillBundle`
|
||||
|
||||
新增技能时,优先补结构化输入字段,不要继续把参数要求散落在 prompt 和按钮文案里。
|
||||
|
||||
固定边界:
|
||||
|
||||
- `slotSchema` / `readinessRequirements` 是技能补参真相
|
||||
- `ServiceSkillItem` 是 Lime 客户端产品投影,不是 Agent Skills 原始包
|
||||
- `a2ui` 只允许作为 GUI 渲染层,把缺失信息映射成表单
|
||||
- 不要把 `a2ui` 结构直接写进 skill catalog、runtime metadata 或协议字段
|
||||
|
||||
### 3. 运行时层
|
||||
### 4. 运行时层
|
||||
|
||||
作用:
|
||||
|
||||
@@ -207,7 +256,7 @@ Lime 的技能标准必须分成四层:
|
||||
|
||||
运行时层回答的是“怎么执行”,不是“对用户如何命名”。
|
||||
|
||||
### 4. 分发层
|
||||
### 5. 分发层
|
||||
|
||||
作用:
|
||||
|
||||
@@ -223,14 +272,19 @@ Lime 的技能标准必须分成四层:
|
||||
|
||||
## 统一对象关系
|
||||
|
||||
Lime 技能能力必须明确区分三个对象:
|
||||
Lime 技能能力必须明确区分四个对象:
|
||||
|
||||
### 1. Skill
|
||||
### 1. Skill Bundle
|
||||
|
||||
作用:
|
||||
|
||||
- 面向用户和产品表达业务入口
|
||||
- 解决“为什么用、何时触发、输出去哪”
|
||||
- 作为标准能力包与内容载体
|
||||
- 解决“这个能力包里有哪些说明、参考资料、模板、脚本和元数据”
|
||||
|
||||
不是谁:
|
||||
|
||||
- 不是最终产品入口
|
||||
- 不直接等于 `ServiceSkill / Scene`
|
||||
|
||||
### 2. Adapter / Tool
|
||||
|
||||
@@ -253,8 +307,15 @@ Lime 技能能力必须明确区分三个对象:
|
||||
- 把产品型 slash scene 组织成可复用的技能流程
|
||||
- 解决“为了达成一个目标,需要按什么步骤驱动 skill / adapter / runtime”
|
||||
|
||||
不是谁:
|
||||
|
||||
- 不是原始 `SKILL.md`
|
||||
- 不等于 `SceneApp`
|
||||
- 更接近 `Scene / ServiceSkill` 背后的流程层与产品投影层
|
||||
|
||||
固定规则:
|
||||
|
||||
- `Skill Bundle` 是包标准层,`ServiceSkill / Scene` 才是产品对象层
|
||||
- `/scene` 的长期真相是 `Scene Skill`,不是前端 if/else,不是单站点特判
|
||||
- `site-adapter` 只能作为 `Scene Skill` 某一步的执行提供者,不能反客为主变成 scene runtime 本体
|
||||
- 缺失信息时,优先由 `Scene Skill` 产出结构化 gate request,再由 GUI 层映射成 `a2ui`
|
||||
@@ -269,6 +330,60 @@ Lime 技能能力必须明确区分三个对象:
|
||||
- 封装站点 / CDP / 浏览器能力用 `Tool Wrapper`
|
||||
- 只有在确实需要产物复核时再叠加 `Reviewer`
|
||||
|
||||
这些模式回答的是:
|
||||
|
||||
**skill / scene skill 内部怎么组织逻辑。**
|
||||
|
||||
它们不回答:
|
||||
|
||||
- 这个场景是本地还是云端
|
||||
- 这个场景是不是 `ServiceSkill`
|
||||
- 这个场景最终走哪条 runtime binding
|
||||
|
||||
### Skill / Scene Skill 内容设计模式
|
||||
|
||||
| 模式 | 回答什么 | 什么时候优先用 | 常用目录 | 常见运行时搭配 |
|
||||
|------|------|------|------|------|
|
||||
| `Tool Wrapper` | 如何把某个库、站点、协议、规范封装成专家上下文 | 封装 framework、site adapter、browser protocol、内部规范 | `references/`、`scripts/` | `browser_assist`、`native_skill`、`agent_turn` |
|
||||
| `Generator` | 如何稳定地产出结构化结果 | 输出模板固定、格式不能漂移 | `assets/`、`references/` | `agent_turn`、`cloud_scene`、Artifact |
|
||||
| `Reviewer` | 如何按 checklist 打分、归类严重性、提出修复建议 | QA、合规、发布前复核、代码审查 | `references/checklist*.md` | `agent_turn`、review/evidence |
|
||||
| `Inversion` | 如何先提问、补参、过 gate,再继续执行 | 需求不完整、项目/权限/账号/审批门禁 | 问题清单、gate template | `slotSchema`、`scene gate`、`a2ui` |
|
||||
| `Pipeline` | 如何强制按顺序执行多步流程,并在 checkpoint 处停住 | 多步任务、外部依赖多、不能跳步 | `references/`、`assets/`、`scripts/` 全部都可能 | `agent_turn`、`browser_assist`、`automation_job`、`cloud_scene` |
|
||||
|
||||
固定规则:
|
||||
|
||||
1. 一个 skill 应只有一个 `主模式`。
|
||||
2. 一个 skill 可以有多个 `辅助模式`。
|
||||
3. `Pipeline` 常常是复杂 `Scene Skill` 的主模式。
|
||||
4. `Tool Wrapper` 更适合做某一步的能力封装,不适合直接冒充产品对象。
|
||||
5. `Reviewer` 只有在“复核本身是产品价值”时才应显式叠加,不要默认所有场景都加。
|
||||
|
||||
推荐组合:
|
||||
|
||||
| 组合 | 适合什么 |
|
||||
|------|------|
|
||||
| `Pipeline + Inversion` | 先补参,再执行严格多步流程 |
|
||||
| `Pipeline + Generator` | 多步流程后输出固定结构 Artifact |
|
||||
| `Pipeline + Tool Wrapper` | 流程中某一步依赖站点、浏览器、协议专家上下文 |
|
||||
| `Pipeline + Reviewer` | 产物生成后还要显式质量检查 |
|
||||
| `Inversion + Generator` | 先采访用户,再生成结构化结果 |
|
||||
| `Tool Wrapper + Reviewer` | 以内部规范或站点规则为准做审查 |
|
||||
|
||||
如果后续需要把这些模式暴露给 Lime 的产品投影层,优先把原始包信息放在:
|
||||
|
||||
- `metadata.Lime_pattern_primary`
|
||||
- `metadata.Lime_pattern_stack`
|
||||
- `metadata.Lime_interaction_mode`
|
||||
- `metadata.Lime_checkpoint_policy`
|
||||
|
||||
不要新增新的顶层 frontmatter 字段。
|
||||
|
||||
补充边界:
|
||||
|
||||
- `patternPrimary / patternStack` 可以来自 Skill Bundle 编译结果
|
||||
- `infra_profile` 不属于原始 Skill Bundle 的强制字段,它属于上层 `SceneApp` 装配声明
|
||||
- 不要把“这个 skill 是 `Pipeline` 型”误写成“这个场景就是云端 / 浏览器 / 本地 durable”
|
||||
|
||||
必须遵守:
|
||||
|
||||
- adapter 不是 skill
|
||||
@@ -276,7 +391,11 @@ Lime 技能能力必须明确区分三个对象:
|
||||
- 一个 skill 只能有一个主执行绑定
|
||||
- 多 adapter 编排不属于普通 site skill,属于后续 scene / orchestration 范畴
|
||||
|
||||
## Lime Skill Spec v1
|
||||
## Lime Runtime Profile v1
|
||||
|
||||
这一节定义的不是新的包格式,而是:
|
||||
|
||||
**一个 Agent Skills 兼容包进入 Lime 之后,最少需要被编译成哪些产品投影与运行时字段。**
|
||||
|
||||
### 1. 技能分类
|
||||
|
||||
@@ -306,6 +425,11 @@ Lime 技能能力必须明确区分三个对象:
|
||||
- `version`
|
||||
- `source`
|
||||
|
||||
说明:
|
||||
|
||||
- 这些字段可以来自远端目录或本地编译结果
|
||||
- 不要求直接写在 `SKILL.md` 顶层 frontmatter
|
||||
|
||||
#### 展示字段
|
||||
|
||||
- `title`
|
||||
@@ -331,6 +455,19 @@ Lime 技能能力必须明确区分三个对象:
|
||||
- `default values`
|
||||
- `validation`
|
||||
|
||||
#### 编排字段
|
||||
|
||||
- `patternPrimary`
|
||||
- `patternStack`
|
||||
- `interactionMode`
|
||||
- `checkpointPolicy`
|
||||
|
||||
说明:
|
||||
|
||||
- 这组字段回答的是“skill 内部怎么组织逻辑”,不是“最终走哪种 runtime binding”。
|
||||
- 如果原始包要携带这些信息,优先通过 `metadata.Lime_*` 命名空间进入编译层。
|
||||
- `ServiceSkill / Scene` 的产品投影如果需要展示“这是一个 `Pipeline` 型场景,还是 `Reviewer` 型场景”,应消费这里,而不是重新猜测 prompt 内容。
|
||||
|
||||
#### 执行字段
|
||||
|
||||
- `defaultExecutorBinding`
|
||||
@@ -342,6 +479,7 @@ Lime 技能能力必须明确区分三个对象:
|
||||
- `siteCapabilityBinding`
|
||||
- `promptTemplateKey`
|
||||
- 未来可扩展的 `toolHubBinding`
|
||||
- 未来可扩展的 `additionalTools`
|
||||
|
||||
#### 产物字段
|
||||
|
||||
@@ -365,6 +503,61 @@ Lime 技能能力必须明确区分三个对象:
|
||||
- 这些字段可以先由服务端模板或说明文档承接
|
||||
- 长期目标是结构化,而不是永久只写在 README / prompt 里
|
||||
|
||||
## Google ADK 与 ClaudeCode 借鉴边界
|
||||
|
||||
Google ADK 对 Lime 最值得借鉴的,不是“再做一个 ADK SkillToolset”,而是它的分层方式。
|
||||
|
||||
可直接借鉴的点:
|
||||
|
||||
1. **轻发现、重加载**
|
||||
- 列表阶段只读 frontmatter
|
||||
- 命中后再加载正文和 `references/assets/scripts`
|
||||
2. **严格 validator**
|
||||
- 目录名与 `name` 一致
|
||||
- 未知顶层字段报错
|
||||
- `allowed-tools` / `allowed_tools` alias 正规化
|
||||
3. **显式 skill 工具面**
|
||||
- `list_skills`
|
||||
- `load_skill`
|
||||
- `load_skill_resource`
|
||||
- `run_skill_script`
|
||||
4. **激活后再开放额外工具**
|
||||
- ADK 用 `metadata.adk_additional_tools`
|
||||
- Lime 可借鉴为 `metadata.Lime_additional_tools`
|
||||
5. **脚本前资源物化**
|
||||
- 在临时工作目录里重建 skill bundle,再执行脚本
|
||||
|
||||
ClaudeCode 对 Lime 最值得借鉴的,不是“再做一个 ClaudeCode skills 系统”,而是它把 skills 当成宿主治理对象来处理。
|
||||
|
||||
可直接借鉴的点:
|
||||
|
||||
1. **多 root 扫描与优先级**
|
||||
- managed / user / project / additional dirs 并行加载
|
||||
- 不同 root 的覆盖顺序明确且可解释
|
||||
2. **严格目录约定与 legacy compat 分离**
|
||||
- `/skills/` 只接受 `skill-name/SKILL.md`
|
||||
- `/commands/` 单独作为兼容层,而不是继续污染主标准
|
||||
3. **按真实文件身份去重**
|
||||
- 用 `realpath` 规避软链接和重复父目录带来的重复加载
|
||||
4. **条件技能激活**
|
||||
- `paths` 命中的 skill 先进入待激活池
|
||||
- 只有用户实际触碰相关文件时才进入动态技能集
|
||||
5. **动态发现嵌套技能目录**
|
||||
- 随文件操作向上发现子目录下的 `.claude/skills`
|
||||
- 深层目录优先于浅层目录
|
||||
6. **内置技能、磁盘技能、MCP 技能分层**
|
||||
- bundled skills 是宿主内建能力
|
||||
- file-based skills 是外部技能包
|
||||
- MCP skills 是远端技能来源
|
||||
|
||||
不应直接照搬的点:
|
||||
|
||||
1. 不把 `SkillToolset` 当成 Lime 最终产品形态。
|
||||
2. 不把已激活 skill 直接等同于 `ServiceSkill` / `Scene`。
|
||||
3. 不把 Skill 包原文直接当成 `service_scene_launch`、`browser_assist`、`automation_job` 等运行时协议。
|
||||
4. 不把 ClaudeCode 的 `.claude/skills`、`/commands/`、plugin-only policy、bare mode 直接当成 Lime 的产品事实源。
|
||||
5. 不把 ClaudeCode 的 bundled skill 注册机制,误当成开放技能包标准。
|
||||
|
||||
## 执行绑定标准
|
||||
|
||||
### 1. `agent_turn`
|
||||
@@ -519,13 +712,14 @@ Agent / Claw 主路径里的 skill 补参与启动,统一承载在当前对话
|
||||
|
||||
## 外部 `SKILL.md` 参考边界
|
||||
|
||||
外部 `SKILL.md` 仓库对 Lime 只有三类帮助:
|
||||
外部 `SKILL.md` 仓库对 Lime 的帮助不只在“说明书结构”,还包括:
|
||||
|
||||
- 触发语义怎么写更清楚
|
||||
- `when to use / setup / examples` 怎么组织更清楚
|
||||
- 说明层如何让人和模型都容易理解
|
||||
1. 标准包结构
|
||||
2. 触发语义与说明组织
|
||||
3. `references/assets/scripts` 的渐进加载组织方式
|
||||
4. 最小 validator 与标准状态表达
|
||||
|
||||
它不能直接成为:
|
||||
但它仍不能直接成为:
|
||||
|
||||
- Lime 的目录协议
|
||||
- Lime 的执行绑定协议
|
||||
@@ -534,7 +728,7 @@ Agent / Claw 主路径里的 skill 补参与启动,统一承载在当前对话
|
||||
|
||||
一句话:
|
||||
|
||||
> 外部 `SKILL.md` 只可借“说明书结构”,不可借“产品标准定义权”。
|
||||
> 外部 `SKILL.md` 可以是 Lime 的技能包标准来源,但不能直接拿走 Lime 的运行时与产品定义权。
|
||||
|
||||
## 新增技能的最低检查单
|
||||
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
# State / History / Telemetry 主链
|
||||
|
||||
## 这份文档回答什么
|
||||
|
||||
本文件定义 Lime 当前 `State / History / Telemetry` 的唯一主链,主要回答:
|
||||
|
||||
- 哪些路径负责持久会话历史、线程稳定读模型、pending request / outcome / incident 投影
|
||||
- `SessionDetail`、`AgentRuntimeThreadReadModel`、`RequestLog`、`agent_runtime_export_*`、`history-record` 分别属于哪一层
|
||||
- 哪些页面、报表和导出只是消费这条主链,而不是继续定义另一套“真实线程状态”
|
||||
- 哪些旧专题计划、原始遥测浏览面或旧 observability 语义只能作为附属层或退场面
|
||||
|
||||
它是 **当前 session / thread / turn / request / evidence / history 边界的 current 文档**,不是某个专题计划,也不是单独的 harness 或 reliability 页面说明。
|
||||
|
||||
## 什么时候先读
|
||||
|
||||
遇到以下任一情况时,先读本文件:
|
||||
|
||||
- 调整 `agent_runtime_get_session`、`agent_runtime_get_thread_read`、`agent_runtime_replay_request`
|
||||
- 调整 `SessionDetail`、`AgentRuntimeSessionDetail`、`AgentRuntimeThreadReadModel`
|
||||
- 调整 `build_pending_requests(...)`、`build_last_outcome(...)`、`build_incidents(...)`
|
||||
- 调整 request correlation headers、`RequestLog`、`requestTelemetry`
|
||||
- 调整 `agent_runtime_export_*`、handoff bundle、evidence pack、replay case、analysis handoff、review decision
|
||||
- 调整 `scripts/harness-eval-history-record.mjs`、cleanup/dashboard、`HarnessStatusPanel.tsx`、`AgentThreadReliabilityPanel.tsx`
|
||||
- 讨论“状态模型”“可靠性”“证据链”“历史窗口”这几个词时,发现大家已经在混用不同层的语言
|
||||
|
||||
如果一个需求同时碰到“会话历史 + 线程状态”“request telemetry + evidence 导出”“history-record + cleanup/dashboard”中的两项以上,默认属于本主链。
|
||||
|
||||
## 固定主链
|
||||
|
||||
后续 Lime 的 `State / History / Telemetry` 只允许向下面这条主链收敛:
|
||||
|
||||
`agent_sessions / agent_messages -> SessionDetail -> AgentRuntimeThreadReadModel -> RequestLog 关联键 -> handoff / evidence / replay / analysis / review -> history-record / trend / cleanup / dashboard -> HarnessStatusPanel / AgentThreadReliabilityPanel`
|
||||
|
||||
这条主链的固定判断是:
|
||||
|
||||
1. `SessionDetail` 是当前会话、回合、时间线 item 的唯一持久读模型入口。
|
||||
2. `AgentRuntimeThreadReadModel` 是当前线程状态、pending request、最近 outcome、active incident 的唯一稳定线程读模型。
|
||||
3. `RequestLog` 只有在带上 `session/thread/turn/pending/queued/subagent` 关联键后,才算当前线程的 request telemetry 事实源。
|
||||
4. `agent_runtime_export_*` 与 `agent_runtime_save_review_decision` 是当前交接、证据、回放、分析、审核的唯一派生导出链。
|
||||
5. `history-record / trend / cleanup / dashboard` 与 GUI 面板都是下游消费层,不允许反向定义 session 或 thread 真相。
|
||||
|
||||
固定规则只有一句:
|
||||
|
||||
**后续新增状态、历史或遥测能力时,只允许接到 `SessionDetail -> AgentRuntimeThreadReadModel -> RequestLog -> export/history` 这组 current 边界;不允许再造并列状态真相。**
|
||||
|
||||
## 代码入口地图
|
||||
|
||||
### 1. 持久会话与历史事实源
|
||||
|
||||
- `src-tauri/crates/agent/src/session_store.rs`
|
||||
- `src-tauri/src/agent/aster_agent.rs` 的 `get_runtime_session_detail(...)`
|
||||
- `agent_sessions / agent_messages`
|
||||
|
||||
当前这里负责:
|
||||
|
||||
1. 会话创建、列表、详情与最近消息预览。
|
||||
2. 把消息、turn、timeline item、todo、子会话信息统一投影成 `SessionDetail`。
|
||||
3. 为 `get_session`、`get_thread_read`、`replay_request`、`export_*` 提供同一份历史事实源。
|
||||
|
||||
固定规则:
|
||||
|
||||
- 持久会话历史统一收口到 `session_store.rs` 与 `SessionDetail`,不要让 UI 或脚本再各自重扫数据库拼第二套历史。
|
||||
- 如果一个需求要解释“这一线程真实发生了什么”,先回到 `SessionDetail.messages / turns / items`,不要从页面状态或导出文件反推。
|
||||
|
||||
### 2. 线程稳定读模型与 reliability projection
|
||||
|
||||
- `src-tauri/src/commands/aster_agent_cmd/command_api/runtime_api.rs`
|
||||
- `agent_runtime_get_session`
|
||||
- `agent_runtime_get_thread_read`
|
||||
- `load_runtime_export_context(...)`
|
||||
- `src-tauri/src/commands/aster_agent_cmd/dto.rs`
|
||||
- `AgentRuntimeSessionDetail`
|
||||
- `AgentRuntimeThreadReadModel`
|
||||
- `build_pending_requests(...)`
|
||||
- `build_last_outcome(...)`
|
||||
- `build_incidents(...)`
|
||||
- `src-tauri/src/services/thread_reliability_projection_service.rs`
|
||||
|
||||
当前这里负责:
|
||||
|
||||
1. 从 `SessionDetail` 派生 pending request、最近 outcome、active incident。
|
||||
2. 把派生结果同步到线程 reliability 投影表,再回读成稳定线程状态。
|
||||
3. 统一组装线程状态、interrupt 状态、queued turn、diagnostics、latest compaction boundary。
|
||||
4. 让 `get_session`、`get_thread_read` 与所有 `export_*` 共享同一份线程读模型加载前奏。
|
||||
|
||||
固定规则:
|
||||
|
||||
- 新的线程健康信号、等待态、重放态,优先落在 `dto.rs` + `thread_reliability_projection_service.rs`,不要先写到面板局部推断逻辑里。
|
||||
- `get_session`、`get_thread_read` 与 `export_*` 必须复用同一套 `SessionDetail + queued_turns + projection` 组合,不允许各写各的 thread loader。
|
||||
- `AgentRuntimeSessionDetail` 是“会话详情 + 稳定线程读模型”的组合返回,不是另一套事实源。
|
||||
|
||||
### 3. 请求关联键与 request telemetry 事实源
|
||||
|
||||
- `src-tauri/crates/server/src/handlers/api.rs` 的 `attach_request_correlation_metadata(...)`
|
||||
- `src-tauri/crates/server/src/lib.rs` 的 `record_request_telemetry(...)`
|
||||
- `src-tauri/crates/infra/src/telemetry/types.rs` 的 `RequestLog`
|
||||
|
||||
当前这里负责:
|
||||
|
||||
1. 从请求头把 `session_id / thread_id / turn_id / pending_request_id / queued_turn_id / subagent_session_id` 注入 `RequestContext.metadata`。
|
||||
2. 在请求完成后记录统一 `RequestLog`,保留 provider、model、status、duration、token、credential 与上述关联键。
|
||||
3. 让 evidence pack 可以按会话与线程真实 join 到 request telemetry 摘要。
|
||||
|
||||
固定规则:
|
||||
|
||||
- 如果当前线程没有匹配的 `RequestLog`,导出空摘要即可;不要再发明 `unlinked` 这一类伪会话级状态。
|
||||
- request telemetry 的 join 条件优先看关联键,而不是 provider/model/时间戳的模糊猜测。
|
||||
- 原始日志浏览、统计页、控制台可以展示 `RequestLog`,但不能反向定义 session/thread 的唯一真相。
|
||||
|
||||
### 4. 证据、交接与审核派生链
|
||||
|
||||
- `src-tauri/src/services/runtime_handoff_artifact_service.rs`
|
||||
- `src-tauri/src/services/runtime_evidence_pack_service.rs`
|
||||
- `src-tauri/src/services/runtime_replay_case_service.rs`
|
||||
- `src-tauri/src/services/runtime_analysis_handoff_service.rs`
|
||||
- `src-tauri/src/services/runtime_review_decision_service.rs`
|
||||
- `src-tauri/src/commands/aster_agent_cmd/command_api/runtime_api.rs` 的:
|
||||
- `agent_runtime_export_handoff_bundle`
|
||||
- `agent_runtime_export_evidence_pack`
|
||||
- `agent_runtime_export_analysis_handoff`
|
||||
- `agent_runtime_export_review_decision_template`
|
||||
- `agent_runtime_save_review_decision`
|
||||
- `agent_runtime_export_replay_case`
|
||||
|
||||
当前这里负责:
|
||||
|
||||
1. 统一复用 `load_runtime_export_context(...)` 拿到 `SessionDetail + AgentRuntimeThreadReadModel + workspace_root`。
|
||||
2. 导出 handoff bundle、evidence pack、replay case、analysis handoff、review decision 模板与保存结果。
|
||||
3. 把 request telemetry、verification outcomes、recent artifacts、timeline snapshot、review actions 作为下游派生链的共同输入。
|
||||
|
||||
固定规则:
|
||||
|
||||
- 派生导出层只能消费 `SessionDetail`、`AgentRuntimeThreadReadModel`、`RequestLog` 和现有工作区产物,不得回头再拼第二套 observability summary。
|
||||
- verification 的适用性、known gaps、current/degraded 角色判断统一由 evidence pack 与 shared verification facts 决定。
|
||||
- replay / analysis / review 不能跳过 evidence/handoff 直接定义自己的线程真相。
|
||||
|
||||
### 5. 历史窗口与下游汇总
|
||||
|
||||
- `scripts/harness-eval-history-record.mjs`
|
||||
- `scripts/lib/harness-verification-facts.mjs`
|
||||
|
||||
当前这里负责:
|
||||
|
||||
1. 记录 summary 历史窗口,并继续派生 trend、cleanup 与 dashboard。
|
||||
2. 统一从 summary / trend / cleanup 提炼 verification outcome 焦点与 recovered baseline。
|
||||
3. 为 nightly report、cleanup recommendation、dashboard 卡片提供共享解释层。
|
||||
|
||||
固定规则:
|
||||
|
||||
- history-record 只记录当前 summary 窗口,不应回头重建运行时 thread 状态。
|
||||
- verification outcome 的聚合与文案焦点统一走 `harness-verification-facts.mjs`,不要在 cleanup、dashboard、review 各自再写一套。
|
||||
- 下游汇总必须区分 `current` 与 `degraded` 样本,不允许继续把两者混成同一种主线回归风险。
|
||||
|
||||
### 6. 用户可见稳定消费层
|
||||
|
||||
- `src/components/agent/chat/components/HarnessStatusPanel.tsx`
|
||||
- `src/components/agent/chat/components/AgentThreadReliabilityPanel.tsx`
|
||||
|
||||
当前这里负责:
|
||||
|
||||
1. 展示 `threadRead`、queued turn、pending request、outcome、incident、handoff / evidence / replay / review 导出动作。
|
||||
2. 复用会话稳定读模型、记忆预演和 verification 摘要,不自行读取另一套原始状态。
|
||||
3. 为操作者提供当前线程可继续执行、可回放、可交接、可审核的统一面板。
|
||||
|
||||
固定规则:
|
||||
|
||||
- 页面层只能消费 `threadRead`、导出结果和 shared verification facts,不自己再扫描 timeline 或 request logs 定义第二套线程状态。
|
||||
- UI 文案如果和 evidence pack / thread read 冲突,以后端稳定读模型为准。
|
||||
|
||||
## current / compat / deprecated / dead
|
||||
|
||||
### `current`
|
||||
|
||||
- `docs/aiprompts/state-history-telemetry.md`
|
||||
- `src-tauri/crates/agent/src/session_store.rs`
|
||||
- `src-tauri/src/agent/aster_agent.rs`
|
||||
- `src-tauri/src/commands/aster_agent_cmd/dto.rs`
|
||||
- `src-tauri/src/commands/aster_agent_cmd/command_api/runtime_api.rs` 的 `get_session / get_thread_read / replay_request / export_*`
|
||||
- `src-tauri/src/services/thread_reliability_projection_service.rs`
|
||||
- `src-tauri/crates/server/src/handlers/api.rs`
|
||||
- `src-tauri/crates/server/src/lib.rs`
|
||||
- `src-tauri/crates/infra/src/telemetry/types.rs`
|
||||
- `src-tauri/src/services/runtime_handoff_artifact_service.rs`
|
||||
- `src-tauri/src/services/runtime_evidence_pack_service.rs`
|
||||
- `src-tauri/src/services/runtime_replay_case_service.rs`
|
||||
- `src-tauri/src/services/runtime_analysis_handoff_service.rs`
|
||||
- `src-tauri/src/services/runtime_review_decision_service.rs`
|
||||
- `scripts/harness-eval-history-record.mjs`
|
||||
- `scripts/lib/harness-verification-facts.mjs`
|
||||
- `src/components/agent/chat/components/HarnessStatusPanel.tsx`
|
||||
- `src/components/agent/chat/components/AgentThreadReliabilityPanel.tsx`
|
||||
|
||||
这些路径共同构成当前状态 / 历史 / 遥测主链:
|
||||
|
||||
- 会话与时间线历史看 `SessionDetail`
|
||||
- 线程稳定状态看 `AgentRuntimeThreadReadModel`
|
||||
- request telemetry 看带关联键的 `RequestLog`
|
||||
- 交接、证据、回放、分析、审核看 `agent_runtime_export_*`
|
||||
- 历史窗口、cleanup 与 dashboard 只作为下游派生层
|
||||
|
||||
### `compat`
|
||||
|
||||
- `docs/roadmap/lime-aster-codex-state-model-implementation-plan.md`
|
||||
- `docs/roadmap/reliability/README.md`
|
||||
- `docs/roadmap/reliability/*`
|
||||
- `src-tauri/src/commands/telemetry_cmd.rs`
|
||||
|
||||
保留原因:
|
||||
|
||||
- 这组路径仍然有用,但它们当前只能作为实现方案、专题排期或原始日志浏览入口。
|
||||
- 它们可以解释“怎么做专项”,不能再解释“什么是当前唯一状态真相”。
|
||||
|
||||
退出条件:
|
||||
|
||||
- 新的状态模型判断、分类和导航回写统一落到本文件,不再把专题计划文档当仓库级总入口。
|
||||
- 原始 request log 浏览面只展示 `RequestLog` 原始事实,并回链到 session / thread / evidence;不再自行扩展会话级状态语义。
|
||||
|
||||
### `deprecated`
|
||||
|
||||
- `scripts/lib/generated-slop-report-core.mjs` 中仍保留的 `requestTelemetry:unlinked` 权重与同类旧 observability 语义
|
||||
- 任何在没有匹配 `RequestLog` 时仍然输出全局 request telemetry 缺口的报表、脚本或面板逻辑
|
||||
- 任何绕过 `AgentRuntimeThreadReadModel`、直接从 timeline / request logs 重新拼 pending request、outcome、incident 的实现
|
||||
|
||||
保留原因:
|
||||
|
||||
- 这类逻辑会把“没有匹配日志”“旧样本兼容状态”“当前真实缺口”混在一起,继续制造第二事实源。
|
||||
|
||||
退出条件:
|
||||
|
||||
- cleanup / dashboard / review 统一回到 `evidence pack + shared verification facts` 的 current/degraded 语义。
|
||||
- `requestTelemetry` 只保留 `exported / known_gap` 等当前可解释状态,不再保留 `unlinked` 旧语义。
|
||||
|
||||
### `dead`
|
||||
|
||||
当前没有新增确认可立即删除的 `dead` 实现文件。
|
||||
|
||||
这轮主问题是并行计划和旧语义残留,而不是零引用代码;后续若 `reliability` 专项文档或旧报表分支完全失去入口,再单独转为 `dead`。
|
||||
|
||||
## 最低验证要求
|
||||
|
||||
如果本轮改动涉及本主链,至少按边界选择最贴近的验证:
|
||||
|
||||
- 纯文档 / 分类回写:`npm run harness:doc-freshness`
|
||||
- 改 `SessionDetail`、`AgentRuntimeThreadReadModel`、projection 或 `export_*`:相关 Rust 定向测试
|
||||
- 改 request correlation headers、`RequestLog` 或 cleanup/dashboard verification 语义:相关 Rust / Vitest 测试;必要时补 `npm run harness:cleanup-report:check`
|
||||
- 改 Tauri 命令边界:额外执行 `npm run test:contracts`
|
||||
- 改 `HarnessStatusPanel`、`AgentThreadReliabilityPanel` 等用户可见面:补现有 `*.test.tsx` 稳定断言;必要时再补 `npm run verify:gui-smoke`
|
||||
|
||||
## 这一步如何服务主线
|
||||
|
||||
`M5` 的目标不是一次性重写所有状态代码,而是先把状态、历史和遥测的事实源收成一条 current 主链。
|
||||
|
||||
从现在开始:
|
||||
|
||||
- 解释会话与历史回放时,回到 `SessionDetail`
|
||||
- 解释线程当前能否继续、在等什么、最近为什么失败时,回到 `AgentRuntimeThreadReadModel`
|
||||
- 解释 request telemetry 时,回到带关联键的 `RequestLog`
|
||||
- 解释 evidence / replay / analysis / review / handoff 时,回到 `agent_runtime_export_*`
|
||||
- 解释历史窗口、cleanup、dashboard 时,回到 `history-record + harness-verification-facts`
|
||||
|
||||
这样后续再做 reliability、history、review 或 operator 视图时,就不会继续在“状态模型”“线程读模型”“证据链”“遥测控制台”之间横跳排期语言。
|
||||
@@ -0,0 +1,231 @@
|
||||
# Task / Agent taxonomy 主链
|
||||
|
||||
## 这份文档回答什么
|
||||
|
||||
本文件定义 Lime 当前 `Task / Agent / Coordinator` 的唯一 taxonomy,主要回答:
|
||||
|
||||
- 哪些对象才算当前一等执行实体
|
||||
- `agent turn`、`subagent turn`、`automation job`、`scheduler tick`、`execution run` 分别是什么关系
|
||||
- `execution tracker / scheduler / subagent / automation` 各自属于哪一层,而不是继续互相抢“主入口”
|
||||
- 哪些旧文档、旧术语、旧路径只能当专项说明或兼容壳,不能再反向定义当前主链
|
||||
|
||||
它是 **长时执行与协作编排的 current 事实源**,不是执行追踪专项计划,也不是单个服务的实现说明。
|
||||
|
||||
## 什么时候先读
|
||||
|
||||
遇到以下任一情况时,先读本文件:
|
||||
|
||||
- 调整 `agent_runtime_spawn_subagent`、`send_input`、`wait_agent`、`resume_agent`、`close_agent`
|
||||
- 调整自动化任务的创建、调度、执行、投递或运行历史
|
||||
- 调整 `ExecutionTracker`、`agent_runs`、执行状态聚合或 run 级读模型
|
||||
- 调整 `SchedulerService`、`scheduled_tasks`、`cron.run` 或任何“后台轮询 / 心跳执行”逻辑
|
||||
- 讨论“这是任务、子代理、自动化还是调度器”的边界归属
|
||||
|
||||
如果一个需求同时碰到“子代理 + 自动化”“调度 + 执行追踪”“会话回合 + 长时后台任务”里的两项以上,默认属于本主链。
|
||||
|
||||
## 固定 taxonomy
|
||||
|
||||
当前 Lime 只承认下面三类一等执行实体:
|
||||
|
||||
1. `agent turn`
|
||||
前台会话回合。统一走 `agent_runtime_submit_turn -> runtime_turn -> Query Loop` 主链。
|
||||
|
||||
2. `subagent turn`
|
||||
父会话派生出的 child session / teammate 回合。它是 `agent turn` 的协作变体,不是另一套执行引擎。
|
||||
|
||||
3. `automation job`
|
||||
可持久化、可延时、可周期触发的后台任务。它是 durable coordinator,统一由自动化服务承接。
|
||||
|
||||
下面两类不是一等执行实体:
|
||||
|
||||
- `scheduler tick`
|
||||
只是“发现到期任务并触发执行”的兼容触发器,不单独代表一个任务分类。
|
||||
|
||||
- `execution run`
|
||||
只是跨入口的执行摘要与生命周期记录,不是 coordinator。本层统一由 `ExecutionTracker` 与 `agent_runs` 承载。
|
||||
|
||||
固定规则只有一句:
|
||||
|
||||
**后续新增长时执行能力时,只允许落成 `agent turn`、`subagent turn` 或 `automation job` 三类之一;不允许再造第四类 runtime taxonomy。**
|
||||
|
||||
## 固定心智模型
|
||||
|
||||
当前主链统一按下面这张图理解:
|
||||
|
||||
`agent turn -> subagent turn / automation job -> ExecutionTracker(agent_runs) -> thread/session/evidence 读模型`
|
||||
|
||||
这条主链意味着:
|
||||
|
||||
1. `agent turn` 是前台交互入口,主事实源仍然是 `query-loop.md`
|
||||
2. `subagent turn` 是 child session 的协作入口,复用当前 agent runtime 与会话事实,不单独发明另一套 task 状态机
|
||||
3. `automation job` 是唯一 durable 后台任务入口,可以触发 agent turn,但不应该自己再发明第二套 run 摘要系统
|
||||
4. `ExecutionTracker` 只负责“这次执行怎么开始、怎么结束、归因到哪里”,不负责调度、分工或 parent/child 编排
|
||||
5. `scheduler tick` 只负责触发 due job,不负责定义产品层 task taxonomy
|
||||
|
||||
## 代码入口地图
|
||||
|
||||
### 1. `agent turn`
|
||||
|
||||
- `docs/aiprompts/query-loop.md`
|
||||
- `src-tauri/src/commands/aster_agent_cmd/command_api/runtime_api.rs`
|
||||
- `src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs`
|
||||
|
||||
固定规则:
|
||||
|
||||
- 前台回合只有一条 Query Loop 主链
|
||||
- 子代理回合如果进入模型执行,仍然复用这条主链
|
||||
- 不允许为长时任务再造第二套“聊天执行入口”
|
||||
|
||||
### 2. `subagent turn`
|
||||
|
||||
- `src-tauri/src/commands/aster_agent_cmd/subagent_runtime.rs`
|
||||
- `src-tauri/src/commands/aster_agent_cmd/command_api/subagent_api.rs`
|
||||
- `src-tauri/src/commands/aster_agent_cmd/tool_runtime/subagent_tools.rs`
|
||||
|
||||
当前这里负责:
|
||||
|
||||
1. spawn child session / teammate
|
||||
2. 写入父子会话与 team membership
|
||||
3. 将 child turn 放入后台执行
|
||||
4. 处理 `send_input / wait / resume / close`
|
||||
5. 发出 runtime stream/status 事件,维持父子状态投影
|
||||
|
||||
固定规则:
|
||||
|
||||
- `subagent turn` 当前不是新的 `RunSource`
|
||||
- 在执行摘要层,它继续复用 `chat` 会话型 run 与 `session_id / parent-child context / evidence` 关联
|
||||
- 需要新增子代理能力时,优先扩展这里,而不是绕去 scheduler 或自动化任务
|
||||
|
||||
### 3. `automation job`
|
||||
|
||||
- `src-tauri/src/services/automation_service/mod.rs`
|
||||
- `src-tauri/src/commands/automation_cmd.rs`
|
||||
- `src/lib/api/automation.ts`
|
||||
|
||||
当前这里负责:
|
||||
|
||||
1. `automation_jobs` 的创建、更新、删除与启停
|
||||
2. 后台轮询和到期任务执行
|
||||
3. `run_job_now` 手动触发
|
||||
4. 输出投递、健康聚合与运行历史
|
||||
5. 与 `ExecutionTracker` 的 `RunSource::Automation` 对接
|
||||
|
||||
固定规则:
|
||||
|
||||
- durable 后台任务统一走自动化服务
|
||||
- 如果一个需求需要“稍后执行 / 周期执行 / 无前台会话也能继续跑”,默认先落 `automation job`
|
||||
- 自动化任务可以触发 agent turn,但不允许自己再维护第二份 run 历史真相
|
||||
|
||||
### 4. `execution run`
|
||||
|
||||
- `src-tauri/src/services/execution_tracker_service.rs`
|
||||
- `src-tauri/crates/core/src/database/dao/agent_run.rs`
|
||||
- `src-tauri/src/commands/execution_run_cmd.rs`
|
||||
|
||||
当前这里负责:
|
||||
|
||||
1. 为 `chat / skill / automation` 记录统一生命周期摘要
|
||||
2. 暴露 `agent_runs` 只读查询
|
||||
3. 统一终态与错误归一化
|
||||
|
||||
固定规则:
|
||||
|
||||
- `ExecutionTracker` 是观测层,不是 coordinator
|
||||
- `RunSource::Chat` 覆盖前台与子代理会话型回合
|
||||
- `RunSource::Skill` 代表独立 skill 执行摘要,不是新的 task taxonomy
|
||||
- `RunSource::Automation` 代表后台任务执行摘要
|
||||
|
||||
### 5. `scheduler tick`
|
||||
|
||||
- `src-tauri/src/app/scheduler_service.rs`
|
||||
- `src-tauri/crates/scheduler/*`
|
||||
- `docs/develop/scheduler-task-governance-p1.md`
|
||||
|
||||
当前这里负责:
|
||||
|
||||
1. 轮询 `scheduled_tasks`
|
||||
2. 发现 due task
|
||||
3. 执行并标记完成 / 失败
|
||||
|
||||
固定规则:
|
||||
|
||||
- 它当前是 compat 触发壳,不再是主 taxonomy
|
||||
- 后续允许做治理减法、冷却恢复、兼容委托
|
||||
- 不允许继续在这里长新的产品级任务语义、统一状态页或第二套编排模型
|
||||
|
||||
## current / compat / deprecated / dead
|
||||
|
||||
### `current`
|
||||
|
||||
- `docs/aiprompts/task-agent-taxonomy.md`
|
||||
- `docs/aiprompts/query-loop.md`
|
||||
- `src-tauri/src/commands/aster_agent_cmd/subagent_runtime.rs`
|
||||
- `src-tauri/src/services/automation_service/*`
|
||||
- `src-tauri/src/services/execution_tracker_service.rs`
|
||||
- `agent_runs`
|
||||
- `automation_jobs`
|
||||
|
||||
这些路径共同构成当前唯一 taxonomy:
|
||||
|
||||
- 前台执行看 `agent turn`
|
||||
- 协作执行看 `subagent turn`
|
||||
- 后台 durable 执行看 `automation job`
|
||||
- 执行摘要看 `ExecutionTracker`
|
||||
|
||||
### `compat`
|
||||
|
||||
- `src-tauri/src/app/scheduler_service.rs`
|
||||
- `src-tauri/crates/scheduler/*`
|
||||
- `scheduled_tasks`
|
||||
- `docs/develop/scheduler-task-governance-p1.md`
|
||||
|
||||
保留原因:
|
||||
|
||||
- 仓库里仍存在到期任务轮询与 `cron.run` 兼容链路
|
||||
- 这些路径目前仍承接“发现任务并执行”的历史职责
|
||||
|
||||
退出条件:
|
||||
|
||||
- 后续如果 scheduler 继续留存,也必须明确只做“触发器 / 兼容壳”
|
||||
- 若有新的后台任务能力,一律先判断能否落到 `automation job`
|
||||
- 不再允许把 scheduler 写成新的 coordinator 事实源
|
||||
|
||||
### `deprecated`
|
||||
|
||||
- `docs/develop/execution-tracker-technical-plan.md`
|
||||
- `docs/develop/execution-tracker-deprecation-plan.md`
|
||||
- `docs/develop/execution-tracker-p1-p2-roadmap.md`
|
||||
- 任何新增的 `heartbeat_executions` 写路径或读取依赖
|
||||
- 任何把 `heartbeat` 当成与 `chat / skill / automation` 并列 run source 的新设计
|
||||
- 任何把 `scheduler tick / cron / 心跳任务` 当成独立 task taxonomy 的新设计
|
||||
|
||||
这些路径仍可作为历史实现说明或退场清单,但不再承担 current taxonomy 定义权。
|
||||
|
||||
### `dead`
|
||||
|
||||
- `automation_jobs.payload.browser_session`
|
||||
|
||||
当前自动化服务已在启动与执行阶段主动停用这类任务;它只能迁移或删除,不能继续创建、更新或恢复为 current 能力。
|
||||
|
||||
## 最低验证要求
|
||||
|
||||
如果本轮改动涉及本主链,至少按边界选择最贴近的验证:
|
||||
|
||||
- 纯文档 / 分类回写:`npm run harness:doc-freshness`
|
||||
- 改 `ExecutionTracker` 或 `agent_runs`:相关定向 Rust 测试
|
||||
- 改子代理 runtime:`subagent_runtime.rs` 或 `runtime_turn` 的定向测试
|
||||
- 改自动化命令 / 服务:自动化服务定向测试,必要时补 `test:contracts`
|
||||
- 改 scheduler / cron 命令:scheduler 或 websocket RPC 定向测试
|
||||
|
||||
## 这一步如何服务主线
|
||||
|
||||
`M2` 的目标不是把所有长时执行代码一次性重写,而是先把 taxonomy 收成唯一事实源。
|
||||
|
||||
从现在开始:
|
||||
|
||||
- 解释前台协作执行时,回到 `agent turn / subagent turn`
|
||||
- 解释后台 durable 执行时,回到 `automation job`
|
||||
- 解释执行摘要时,回到 `ExecutionTracker`
|
||||
- 解释 scheduler 时,默认把它视为 compat 触发壳
|
||||
|
||||
这样后续的 `M3 Remote runtime`、`M4 Memory / Compaction`、`M5 State / History / Telemetry` 才不会继续被长时任务边界反复打断。
|
||||
@@ -23,10 +23,11 @@
|
||||
|
||||
## 设计结论
|
||||
|
||||
### 1. 外部 `web-access` 对 Lime 有帮助,但只应作为参考来源
|
||||
### 1. 外部 `web-access` / Agent Skills 对 Lime 有帮助,但不能直接替代 Lime 运行时
|
||||
|
||||
可以借鉴的部分:
|
||||
|
||||
- Agent Skills 包结构
|
||||
- Skill 作为 bundle 的组织方式
|
||||
- 前置检查、站点经验、工具选择策略
|
||||
- 针对动态页面的预热、滚动、媒体提取思路
|
||||
@@ -37,7 +38,7 @@
|
||||
- 外部自带的 CDP proxy / daemon / HTTP API
|
||||
- 外部工具仓库定义的运行时协议
|
||||
- 外部环境变量约定和执行入口
|
||||
- 把外部 `SKILL.md` 直接当成 Lime 产品事实源
|
||||
- 把外部 `SKILL.md` 或 SkillToolset 直接当成 Lime 产品事实源
|
||||
|
||||
### 2. Lime 的长期真相仍然是 `Scene Skill`
|
||||
|
||||
@@ -179,6 +180,8 @@ skill/
|
||||
- 输出结构放 `templates/`
|
||||
- 产物校验可放 `scripts/`
|
||||
|
||||
这里的 `SKILL.md` 应优先理解为 **Agent Skills 兼容包入口**,不是 Lime 最终运行时协议。
|
||||
|
||||
## 推荐流程主线
|
||||
|
||||
以下流程适用于“导出网页内容并保存为 Markdown”这一整类场景,而不只适用于 X。
|
||||
|
||||
@@ -24,5 +24,9 @@
|
||||
## 关联入口
|
||||
|
||||
- 路线图主线:`docs/roadmap/`
|
||||
- 参考运行时主链总计划:`docs/exec-plans/upstream-runtime-alignment-plan.md`
|
||||
- 参考运行时主链进度日志:`docs/exec-plans/upstream-runtime-alignment-progress.md`
|
||||
- LimeNext 总实施计划:`docs/exec-plans/limenext-plan.md`
|
||||
- LimeNext 推进日志:`docs/exec-plans/limenext-progress.md`
|
||||
- 技术债追踪:`docs/exec-plans/tech-debt-tracker.md`
|
||||
- 模块级实施细节:`docs/aiprompts/README.md`
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# Chrome Web Store 发布计划
|
||||
|
||||
## 目标
|
||||
|
||||
将 `extensions/lime-chrome` 以当前可发布版本提交到 Chrome Web Store,并沉淀可复用的上架材料,避免后续版本更新再次依赖口头说明。
|
||||
|
||||
## 当前状态
|
||||
|
||||
- 已确认扩展目录:`extensions/lime-chrome`
|
||||
- 已确认扩展版本:`0.3.0`
|
||||
- 已生成上传包:`tmp/lime-chrome-0.3.0.zip`
|
||||
- 已发现安装页内预留商店链接:`https://chromewebstore.google.com/detail/lime-chrome/cpidmllglbedhpombjibeoalnafofipo`
|
||||
- 公开链接当前未返回可见上架页,需在开发者后台确认该条目是草稿、私有条目还是无效占位
|
||||
|
||||
## 已完成
|
||||
|
||||
1. 检查扩展结构、`manifest.json`、README 与现有仓库脚本,确认仓库内没有现成的 Chrome 商店发布自动化
|
||||
2. 识别审核敏感权限与行为:
|
||||
- `debugger`
|
||||
- `clipboardRead`
|
||||
- `notifications`
|
||||
- `host_permissions: <all_urls>`
|
||||
3. 生成待上传 zip 产物,并记录哈希便于核对
|
||||
4. 补齐仓库内可复用的发布文案与隐私政策工件
|
||||
5. 生成首批 Chrome 商店截图草稿素材
|
||||
|
||||
## 阻塞项
|
||||
|
||||
1. 真正发布到 Chrome Web Store 需要进入 Google 开发者后台,属于高风险外部发布动作,必须先得到用户明确确认
|
||||
2. 若开发者后台尚未保存截图、分类、数据使用问卷或发布地区信息,仍需在后台继续补齐
|
||||
3. 若当前商店条目与安装页中的扩展 ID 不一致,需要在后台确认是否继续沿用旧条目或新建条目
|
||||
4. 隐私政策需要一个公网可访问 URL;当前仓库内文件已就绪,但尚未推送到公开可访问位置
|
||||
|
||||
## 下一步
|
||||
|
||||
1. 进入 Chrome Web Store 开发者后台,确认 `cpidmllglbedhpombjibeoalnafofipo` 对应条目状态
|
||||
2. 使用 `tmp/lime-chrome-0.3.0.zip` 上传扩展包
|
||||
3. 将 `extensions/lime-chrome/CHROME_WEB_STORE_SUBMISSION.md` 中的文案和权限说明填入后台
|
||||
4. 将 `extensions/lime-chrome/PRIVACY_POLICY.md` 对应的公开 URL 填入隐私政策字段
|
||||
5. 完成数据使用问卷、截图与可见性配置后提交审核或发布
|
||||
|
||||
## 进度日志
|
||||
|
||||
### 2026-04-13
|
||||
|
||||
- 完成本地打包与审核风险盘点
|
||||
- 确认需要先补齐发布文案与隐私政策,再进入发布后台
|
||||
- 生成 3 张 `1280x800` 商店截图草稿,位于 `tmp/chrome-store-assets/`
|
||||
- 由于发布属于外部生产动作,暂未执行任何登录、上传或提交操作
|
||||
@@ -0,0 +1,106 @@
|
||||
# Claude Code Agent Task Runtime 全面对齐计划
|
||||
|
||||
## 摘要
|
||||
|
||||
目标是把 Lime 的 agent 主链从“线程态 + 工具流 + 诊断信号”升级为对标 Claude Code 的“统一任务运行时”:
|
||||
|
||||
- 主会话、子 agent、等待输入、完成结算都统一映射为可见任务。
|
||||
- 简单问题不再错误抬升为常驻 `MAIN TASK` 面板,只在复杂、长链路、可跟踪任务里显示任务视图。
|
||||
- 工具调用按批次聚合,必须产出中间过程结论,而不是只留下工具名。
|
||||
- `token usage` 与 `prompt cache` 在任务完成态和消息态都保持可见。
|
||||
- E2E 与 GUI smoke 后续统一以“任务是否创建、推进、完成”为核心断言。
|
||||
|
||||
## 关键改动
|
||||
|
||||
### 1. Agent Task Runtime 投影
|
||||
|
||||
- 新增统一任务投影模型,先覆盖 `main_session` 与 `subagent`。
|
||||
- 每次主会话回合都可以被投影成任务,但展示层增加“简单直答自动折叠”规则。
|
||||
- 当前回合的任务标题、状态、阶段、等待原因、工具批次摘要、子任务统计统一从现有 `thread_read / turns / items / messages / child_subagent_sessions` 归一投影。
|
||||
|
||||
### 2. 任务展示规则
|
||||
|
||||
- 仅在以下场景显示任务状态:
|
||||
- 有工具批次或过程轨迹
|
||||
- 有子任务
|
||||
- 有排队 / 待补信息 / 等待确认
|
||||
- 回合失败或中断
|
||||
- 简单直接回答成功后默认折叠隐藏,不显示常驻 `MAIN TASK`。
|
||||
- 复杂任务也不再插入聊天主区大卡片,改挂输入区底栏弱提示 pill。
|
||||
- 任务状态 pill 保留:
|
||||
- 主任务标题
|
||||
- 当前状态
|
||||
- 工具批次摘要
|
||||
- 子任务数量
|
||||
- 阻塞 / 排队信号
|
||||
|
||||
### 3. 工具批次与过程结论
|
||||
|
||||
- 复用现有工具批次聚合与工具过程摘要能力。
|
||||
- 任务状态条优先展示批次级结论,例如:
|
||||
- 已探索项目
|
||||
- 已查看关键文件
|
||||
- 已检查页面
|
||||
- 当没有工具批次时,状态 pill 回退到等待原因、失败原因或当前阻塞摘要。
|
||||
|
||||
### 4. 回归与验收
|
||||
|
||||
- 前端单测覆盖:
|
||||
- 简单直答完成后不显示任务状态条
|
||||
- 工具批次会显示输入区顶部弱提示条
|
||||
- 等待输入会显示等待态
|
||||
- 聊天主区仍保留消息级 `token usage` 与 `prompt cache` 摘要
|
||||
- 后续 GUI / E2E 继续对齐:
|
||||
- 打开真实项目后触发分析请求
|
||||
- 断言复杂任务可见
|
||||
- 断言简单直答不出现误报任务卡
|
||||
|
||||
## 当前进度
|
||||
|
||||
- 2026-04-14:创建执行计划文件。
|
||||
- 2026-04-14:前端新增主任务投影工具与任务卡组件。
|
||||
- 2026-04-14:先前尝试把主任务直接插入聊天主区,已确认不符合 Claude Code 的任务层级。
|
||||
- 2026-04-14:主任务展示已回撤出聊天主区,并进一步收敛为输入区底栏弱提示 pill;完成态自动折叠。
|
||||
- 2026-04-14:补充任务投影、输入区与消息列表的定向回归测试。
|
||||
- 2026-04-14:定向 `vitest` 已通过,覆盖“复杂任务完成后不继续占据聊天主区,token usage / Prompt Cache 仍由消息结算区承载”。
|
||||
- 2026-04-14:定向 `eslint` 已通过;`verify:local` 仍被仓库现存无关问题阻塞,当前已知阻塞文件为 `src/components/agent/chat/utils/toolBatchGrouping.ts` 与 `src/components/memory/MemoryPage.tsx`。
|
||||
- 2026-04-14:`verify:gui-smoke` 已启动并推进到 headless Tauri 最终链接阶段,当前阻塞为首次临时 `CARGO_TARGET_DIR` 的 Rust/Tauri 大编译,尚未拿到 `http://127.0.0.1:3030/health` 就绪结果。
|
||||
- 2026-04-14:`verify:gui-smoke` 最终通过,确认默认 workspace、browser runtime、site adapter catalog 与 agent service skill entry 主链未回退。
|
||||
- 2026-04-14:浏览器 E2E 已验证复杂请求运行中只在输入区底栏显示状态 pill;完成后 pill 自动折叠消失,消息尾部继续显示 `token usage` 与缓存统计。
|
||||
- 2026-04-14:后端已落地“连续只读工具批次并发执行”,当前只对 Bash / PowerShell 的保守只读命令集启用并发,写操作与高风险命令继续串行。
|
||||
- 2026-04-14:补齐路径预检,避免在分析本地项目时盲猜不存在路径。
|
||||
- 2026-04-14:定位到 `tauri:dev*` 脚本把 `CARGO_TARGET_DIR` 写死为 `target`,会在 GUI 续测与 Rust 测试并行时放大构建锁竞争;现已改成“默认 target,但允许显式覆盖”。
|
||||
- 2026-04-14:继续收口“任务分工建议”视觉层级,首页空态与普通输入栏统一改为轻量胶囊提示,不再用大横幅抢占输入区主视觉。
|
||||
- 2026-04-14:按最新反馈去掉消息正文里的 `阶段结论` 标题壳;新消息直接显示结论正文,旧历史通过前端统一清洗兼容去标题。
|
||||
- 2026-04-14:继续追查 GUI 续测慢点,确认 `run-tauri-dev.mjs` / `run-tauri-profile.mjs` 会把相对 `CARGO_TARGET_DIR` 传进 `tauri dev`,在当前启动链下误写成 `src-tauri/src-tauri/target`;现已改成仓库根绝对路径下的 `src-tauri/target`,同时同步更新 README 说明。
|
||||
|
||||
## 本轮验证记录
|
||||
|
||||
### 已完成
|
||||
|
||||
- `npx vitest run "src/components/agent/chat/components/Inputbar/index.test.tsx" "src/components/agent/chat/components/MessageList.test.tsx" "src/components/agent/chat/utils/agentTaskRuntime.test.ts"`
|
||||
- `npx eslint "src/components/agent/chat/components/AgentTaskRuntimeCard.tsx" "src/components/agent/chat/components/Inputbar/index.tsx" "src/components/agent/chat/components/Inputbar/components/InputbarComposerSection.tsx" "src/components/agent/chat/components/MessageList.tsx" "src/components/agent/chat/workspace/useWorkspaceInputbarSceneRuntime.tsx" "src/components/agent/chat/utils/agentTaskRuntime.ts"`
|
||||
|
||||
### 进行中
|
||||
|
||||
- `npm run verify:gui-smoke`
|
||||
- 当前表现:前端壳 `http://127.0.0.1:1420/` 可访问,但 `3030` DevBridge 尚未监听,页面仍处于 mock/fallback 状态。
|
||||
- 当前判断:阻塞源是 headless Tauri 首次完整构建与链接,不是任务 pill 回退或聊天主区重新渲染 task 卡片。
|
||||
- `npm run tauri:dev:headless`
|
||||
- 当前表现:真实页面发送按钮 disabled 的直接原因不是输入组件,而是 `aster_agent_init` 因 DevBridge 不可达失败,页面落入 bridge cooldown / mock fallback。
|
||||
- 当前判断:根因之一不是单纯构建锁,而是开发脚本传入的相对 `CARGO_TARGET_DIR` 被 `tauri dev` 解析成 `src-tauri/src-tauri/target`,造成双层 target 与冷编译;脚本已改成绝对路径,正在重新拉起真实 bridge 做二次验证。
|
||||
|
||||
### 已完成补充验收
|
||||
|
||||
- 真实浏览器会话中已验证:
|
||||
- 复杂请求运行中,聊天主区展示的是消息与过程结论,不再插入大 task 面板
|
||||
- 输入区底栏出现紧凑状态 pill,内容包含任务标题、状态与批次摘要
|
||||
- 任务完成后状态 pill 自动折叠隐藏
|
||||
- 消息结算区继续显示 `68.0K tokens` 与缓存统计 `缓存 62.3K(读 62.3K / 写 0)`
|
||||
- 本轮真实交互后控制台未新增 bridge/runtime 错误,仍只剩浏览器模式的 i18n warning
|
||||
|
||||
## 默认假设
|
||||
|
||||
- 本阶段优先修正产品感知与主路径可见性,不先重写底层 `session/thread/turn/item` 存储结构。
|
||||
- 媒体 / 插件任务暂不并入统一任务 runtime。
|
||||
- 真正的后端 `task event` 契约和 `thread_read.task_runs` 字段后续再补,当前先用前端投影收口主体验。
|
||||
@@ -0,0 +1,269 @@
|
||||
# LimeNext 总实施计划
|
||||
|
||||
> 状态:进行中
|
||||
> 更新时间:2026-04-15
|
||||
> 上位总纲:`docs/roadmap/limenext/`
|
||||
> 目标:把 LimeNext 从“上位平台定义”推进到“可持续实施的主线计划”,统一产品对象、运行时骨架、远程入口、长时执行、证据治理与场景选品的推进顺序。
|
||||
|
||||
## 1. 先给结论
|
||||
|
||||
LimeNext 当前不是缺一篇愿景文档,而是缺一条能持续推进的平台主线。
|
||||
|
||||
本计划固定一个判断:
|
||||
|
||||
**后续提到“推进 LimeNext”,默认不是泛泛优化平台,而是沿着 `产品对象 -> 业务装配 -> 运行时骨架 -> 长时执行与远程 -> 证据治理 -> 场景选品` 这条主线推进。**
|
||||
|
||||
## 2. 当前基线
|
||||
|
||||
已经完成:
|
||||
|
||||
1. `docs/roadmap/limenext/` 专题包已建立。
|
||||
2. Lime 的 current 平台事实源已经分别收口到:
|
||||
- `overview.md`
|
||||
- `query-loop.md`
|
||||
- `command-runtime.md`
|
||||
- `task-agent-taxonomy.md`
|
||||
- `remote-runtime.md`
|
||||
- `state-history-telemetry.md`
|
||||
- `skill-standard.md`
|
||||
3. `ribbi/`、`command-runtime/`、`harness-engine/`、`product convergence`、`service skill cloud config` 等子专题已被回挂到 LimeNext 总图。
|
||||
4. `sceneapp-capability-model.md` 已补齐,固定了五类 `SceneApp`、五种 skill / scene 设计模式与九类底层能力模块的判断标准。
|
||||
5. `base-setup-decoupling.md` 已把“基础设置包”和宿主内核边界单独收口。
|
||||
6. 多模态组合型样板已进入 LimeNext 总纲,开始用 `composition blueprint` 统一表达组合蓝图。
|
||||
7. `composition-blueprint-schema.md` 已补出文档级 schema 草案,后续可以继续下沉到 validator 与目录投影。
|
||||
8. `base-setup-package-schema.md` 已把基础设置包补成顶层装配对象,开始明确“改包、改投影、改宿主”的判断线。
|
||||
9. `base-setup-projection-lifecycle.md` 已把装配包发布、灰度、seeded 兜底与回滚链收口成可讨论的事实源。
|
||||
10. `base-setup-implementation.md` 已把 validator / compiler / rollout gate 的第一版代码落点收口到现有 `ServiceSkillCatalog` 与 launch/runtime 锚点。
|
||||
11. `src/lib/base-setup/*` 已新增第一版代码骨架,开始把基础设置包下沉到实际 TypeScript 模块与单测。
|
||||
|
||||
尚未完成:
|
||||
|
||||
1. 把 `ServiceSkill` 在产品面、目录面、运行时面和自动化面真正打通成同一个对象。
|
||||
2. 让所有样板场景都按统一 `SceneApp` 类型与能力模块声明接入。
|
||||
3. 把 `SceneScorecard` 与现有 evidence / tracker / artifact 数据链打通。
|
||||
4. 在 `docs/exec-plans/` 里建立 LimeNext 的持续推进日志与阶段风险管理。
|
||||
5. 把已完成的样板进一步接到 `Artifact + Evidence + Scorecard`。
|
||||
6. 把 `SceneScorecard` 从概念字段推进到可被聚合和查看的产品对象。
|
||||
7. 把“主 App 宿主内核”与“基础设置包”分层固定下来,减少场景扩张对客户端发版的依赖。
|
||||
8. 让基础设置包的 schema 继续下沉到 bootstrap / seeded catalog 可消费的目录投影。
|
||||
9. 把 `composition blueprint` 从文档口径继续收口到可校验、可投影的装配对象。
|
||||
10. 把 `project pack` 的最小交付合同继续对齐 artifact profile 与 viewer 主链。
|
||||
11. 把装配包的 validator / projection compiler / rollout gate 真正落成代码或脚本。
|
||||
12. 决定第一版是“客户端内编译 package”还是“服务端下发预编译 projection,客户端只做 gate + fallback”。
|
||||
13. 决定 `src/lib/base-setup/*` 何时正式接入 `src/lib/api/serviceSkills.ts`,替换当前手写 catalog 来源。
|
||||
|
||||
## 3. 总目标
|
||||
|
||||
LimeNext 的实施总目标固定为:
|
||||
|
||||
`目标输入 -> Scene / ServiceSkill -> Agent / Binding 执行 -> Artifact 交付 -> Evidence / Review 治理 -> Scorecard 评估 -> Keep / Incubate / Retire`
|
||||
|
||||
## 4. 实施阶段
|
||||
|
||||
### P0:总纲固化
|
||||
|
||||
目标:
|
||||
|
||||
- 让 LimeNext 平台定义稳定,不再来回摇摆。
|
||||
- 固定 `SceneApp` 的能力分类,不再只停留在“场景工作台”的口号层。
|
||||
|
||||
已完成:
|
||||
|
||||
- `docs/roadmap/limenext/` 专题包
|
||||
|
||||
退出条件:
|
||||
|
||||
- 后续讨论新专题时,能先声明它属于 LimeNext 哪一层。
|
||||
- 新样板进入实施前,能先写出 `sceneapp_type`、`pattern_primary / pattern_stack`、`infra_profile` 与 `composition_blueprint`。
|
||||
|
||||
### P1:产品对象收口
|
||||
|
||||
目标:
|
||||
|
||||
- 统一 `ServiceSkill / Scene` 的产品对象语义
|
||||
- 把首页卡、技能页、slash scene、推荐方案挂到同一对象
|
||||
|
||||
依赖:
|
||||
|
||||
- `docs/roadmap/ribbi/*`
|
||||
- `docs/roadmap/lime-product-convergence-plan.md`
|
||||
- `docs/roadmap/lime-service-skill-cloud-config-prd.md`
|
||||
|
||||
退出条件:
|
||||
|
||||
- 前台不再把 `skill bundle` 和 `ServiceSkill` 混成一个词
|
||||
- 首页与输入栏围绕同一场景对象表达
|
||||
|
||||
### P1.5:业务装配收口
|
||||
|
||||
目标:
|
||||
|
||||
- 固定基础设置包与 `composition blueprint` 的角色边界
|
||||
- 让多模态组合场景能优先通过装配层下发,而不是频繁改主 App
|
||||
|
||||
退出条件:
|
||||
|
||||
- 团队能明确回答“什么改动只改基础设置包,什么改动必须升级宿主”
|
||||
- 第一条组合型样板能被写成标准设计卡,而不是口头 workflow
|
||||
|
||||
### P2:运行时骨架收口
|
||||
|
||||
目标:
|
||||
|
||||
- 让 `Scene / ServiceSkill` 真正走统一运行时主链
|
||||
|
||||
依赖:
|
||||
|
||||
- `docs/roadmap/gongneng/command-runtime/*`
|
||||
- `docs/aiprompts/query-loop.md`
|
||||
- `docs/aiprompts/command-runtime.md`
|
||||
|
||||
退出条件:
|
||||
|
||||
- 至少一条 `Agent + ServiceSkill` 样板链路与一条 `Agent + Task` 样板链路稳定成立
|
||||
|
||||
当前状态:
|
||||
|
||||
- `@配音 / voice_runtime / cloud-video-dubbing` 已被选定并沉淀为第一条 `Agent + ServiceSkill` current 样板方案包
|
||||
- `x-article-export` 已被沉淀为第二条 `Agent + ServiceSkill` current 样板方案包,并覆盖 `site_adapter / browser_assist` 分支
|
||||
|
||||
### P3:长时执行与远程收口
|
||||
|
||||
目标:
|
||||
|
||||
- 把 `subagent turn / automation job / channels runtime / browser connector` 纳入统一平台解释与实施顺序
|
||||
|
||||
依赖:
|
||||
|
||||
- `docs/aiprompts/task-agent-taxonomy.md`
|
||||
- `docs/aiprompts/remote-runtime.md`
|
||||
|
||||
退出条件:
|
||||
|
||||
- 团队不再把 OpenClaw compat shell 或 scheduler tick 当成长期主线
|
||||
|
||||
### P4:证据与治理闭环
|
||||
|
||||
目标:
|
||||
|
||||
- 让 evidence / replay / review / dashboard 成为平台默认治理面
|
||||
|
||||
依赖:
|
||||
|
||||
- `docs/aiprompts/state-history-telemetry.md`
|
||||
- `docs/roadmap/harness-engine/*`
|
||||
|
||||
退出条件:
|
||||
|
||||
- 失败场景可以稳定回挂到 evidence -> analysis/review -> fix -> regress 主链
|
||||
|
||||
### P5:场景工厂
|
||||
|
||||
目标:
|
||||
|
||||
- 建立 `SceneScorecard`
|
||||
- 固定 launch / keep / incubate / retire 规则
|
||||
|
||||
依赖:
|
||||
|
||||
- `docs/roadmap/limenext/metrics-and-selection.md`
|
||||
|
||||
退出条件:
|
||||
|
||||
- 平台能够系统性淘汰低价值场景,而不是只增不减
|
||||
|
||||
当前补充:
|
||||
|
||||
- `Artifact / Evidence / Scorecard` 的业务样板与图示已经建立
|
||||
- `Local Durable SceneApp` 当前样板已固定为 `每日趋势摘要 / 账号增长跟踪`
|
||||
- `x-article-export` 的当前产品合同已固定为“资料包优先 + 同一路径后处理”
|
||||
- `Base Setup` 解耦规则已单独成文,开始固定“什么该下发,什么必须跟宿主升级”
|
||||
- 下一步不再只是写定义,而是要明确具体聚合对象、入口和周会使用方式
|
||||
|
||||
## 5. 默认样板链路
|
||||
|
||||
后续实施默认至少推进五条样板:
|
||||
|
||||
1. `Agent + ServiceSkill`
|
||||
- 第一条 current 样板:`@配音 / voice_runtime`
|
||||
- 第二条 current 样板:`x-article-export`
|
||||
2. `Agent + Task`
|
||||
- `@配图`
|
||||
3. `Prompt 型`
|
||||
- `@总结` 或 `@分析`
|
||||
4. `automation job`
|
||||
- `每日趋势摘要`
|
||||
- `账号增长跟踪`
|
||||
5. `Multimodal Composition`
|
||||
- `文本 -> 线框图 -> 配乐 -> 剧本 -> 短视频草稿`
|
||||
|
||||
固定补充规则:
|
||||
|
||||
- 每条样板都必须显式声明:
|
||||
- `sceneapp_type`
|
||||
- `pattern_primary`
|
||||
- `pattern_stack`
|
||||
- `infra_profile`
|
||||
- `composition_blueprint`
|
||||
- `execution_entity`
|
||||
- `runtime_binding`
|
||||
- `storage_strategy`
|
||||
- `delivery_contract`
|
||||
|
||||
推荐先从三句最短版开始写:
|
||||
|
||||
- 这是什么类型的 `SceneApp`
|
||||
- 它内部主要按什么模式组织
|
||||
- 它到底调用了哪些基础设施
|
||||
|
||||
## 6. 当前优先顺序
|
||||
|
||||
当前建议优先顺序固定为:
|
||||
|
||||
1. 先按 `SceneApp` 能力模型收口样板声明标准
|
||||
2. 继续收口 `ServiceSkill` 作为产品对象
|
||||
3. 以 `@配音 / voice_runtime` 作为第一条 `Cloud-managed SceneApp` 样板进入实施
|
||||
4. 以 `x-article-export` 作为第一条 `Browser-grounded SceneApp` 样板进入实施
|
||||
5. 以 `每日趋势摘要 / 账号增长跟踪` 作为第一条 `Local Durable SceneApp` 样板进入实施
|
||||
6. 以多模态组合短视频草稿作为第一条 `Hybrid SceneApp` 样板进入实施
|
||||
7. 把五条样板链路接到 `Artifact + Evidence + Scorecard`
|
||||
|
||||
当前不建议优先做:
|
||||
|
||||
1. 扩目录数量
|
||||
2. 新增更多 compat 入口
|
||||
3. 新造一层平行 runtime
|
||||
|
||||
## 7. 风险
|
||||
|
||||
### 风险 1:继续把 Lime 误讲成 `skills runtime`
|
||||
|
||||
处理:
|
||||
|
||||
- 所有执行文档都保持 `Skill / ServiceSkill / Scene / Artifact / Evidence / Scorecard` 分层
|
||||
|
||||
### 风险 2:把设计模式误当成产品对象
|
||||
|
||||
处理:
|
||||
|
||||
- 文档中固定区分 `ServiceSkill / Scene` 与 `pattern_primary / pattern_stack`
|
||||
- 不再出现“这是个 Pipeline 产品”这类混层说法
|
||||
|
||||
### 风险 3:只做总纲,不接实施
|
||||
|
||||
处理:
|
||||
|
||||
- 本计划与进度日志持续更新
|
||||
- 每进入实现阶段,回挂具体子专题与验证命令
|
||||
|
||||
### 风险 4:只做治理减法,不回主线交付
|
||||
|
||||
处理:
|
||||
|
||||
- 连续两轮主要在做治理后,下一轮必须回到样板场景交付
|
||||
|
||||
## 8. 当前下一刀
|
||||
|
||||
当前下一刀建议固定为:
|
||||
|
||||
**把 `composition blueprint` 从文档对象继续收口到基础设置包 schema 与目录投影,再把 `SceneScorecard` 接到真实聚合来源、任务中心 / 场景看板入口与周会节奏;这样 LimeNext 才会从“样板闭环成立”继续推进到“经营面可执行”。**
|
||||
@@ -0,0 +1,236 @@
|
||||
# LimeNext 推进日志
|
||||
|
||||
## 2026-04-15
|
||||
|
||||
### 已完成
|
||||
|
||||
- 新增基础设置包第一版代码骨架:
|
||||
- `src/lib/base-setup/types.ts`
|
||||
- `src/lib/base-setup/validator.ts`
|
||||
- `src/lib/base-setup/compiler.ts`
|
||||
- `src/lib/base-setup/rolloutGate.ts`
|
||||
- `src/lib/base-setup/compat/serviceSkillCatalogProjection.ts`
|
||||
- 固定第一版先把 `Base Setup Package` 编译成 compat `ServiceSkillCatalog`
|
||||
- 固定 runtime 侧暂不新增执行链,继续只吃 current `request_metadata`
|
||||
- 新增基础设置包单测:
|
||||
- `src/lib/base-setup/validator.test.ts`
|
||||
- `src/lib/base-setup/compiler.test.ts`
|
||||
- `src/lib/base-setup/rolloutGate.test.ts`
|
||||
- 证明结构校验、引用校验、catalog projection 与 rollout gate 决策已跑通
|
||||
- 新增基础设置包实现设计专题:
|
||||
- [base-setup-implementation.md](../roadmap/limenext/base-setup-implementation.md)
|
||||
- 固定第一版先以 `Base Setup Package -> compat ServiceSkillCatalog projection` 落地
|
||||
- 固定第一版不重写产品面 API,而是先改 `ServiceSkillCatalog` 背后的编译来源
|
||||
- 固定 validator / compiler / rollout gate 的建议模块拆分与接线顺序
|
||||
- 新增基础设置包投影与发布生命周期专题:
|
||||
- [base-setup-projection-lifecycle.md](../roadmap/limenext/base-setup-projection-lifecycle.md)
|
||||
- 固定装配包从编写、校验、投影编译、灰度发布、bootstrap 拉取、seeded 兜底到回滚的完整链路
|
||||
- 固定最小 validator 分层:`L0 结构 / L1 装配 / L2 宿主边界`
|
||||
- 固定“只要 L2 不通过,就必须升级主 App”的门禁
|
||||
- 新增基础设置包顶层 schema:
|
||||
- [base-setup-package-schema.md](../roadmap/limenext/base-setup-package-schema.md)
|
||||
- 固定 `Base Setup Package` 的 v1 顶层字段、目录投影、profile 结构与最小 validator 口径
|
||||
- 固定“改包、改投影、改宿主”的判断表
|
||||
- 新增组合蓝图 schema 草案:
|
||||
- [composition-blueprint-schema.md](../roadmap/limenext/composition-blueprint-schema.md)
|
||||
- 固定 `composition blueprint` 的 v1 顶层字段、步骤字段、校验规则与 `project_pack` 最小合同
|
||||
- 固定 v1 只支持“有序步骤链”,不先做通用 DAG
|
||||
- 继续收口 LimeNext 总纲,明确平台主张不仅是“场景工作台”,也是“快捷组合多模态能力的 SceneApp factory”
|
||||
- 在以下文档中引入统一口径:
|
||||
- [README.md](../roadmap/limenext/README.md)
|
||||
- [prd.md](../roadmap/limenext/prd.md)
|
||||
- [architecture.md](../roadmap/limenext/architecture.md)
|
||||
- [sceneapp-capability-model.md](../roadmap/limenext/sceneapp-capability-model.md)
|
||||
- [sceneapp-blueprints.md](../roadmap/limenext/sceneapp-blueprints.md)
|
||||
- [flowcharts.md](../roadmap/limenext/flowcharts.md)
|
||||
- [sequences.md](../roadmap/limenext/sequences.md)
|
||||
- [artifact-evidence-scorecards.md](../roadmap/limenext/artifact-evidence-scorecards.md)
|
||||
- [metrics-and-selection.md](../roadmap/limenext/metrics-and-selection.md)
|
||||
- [execution-plan.md](../roadmap/limenext/execution-plan.md)
|
||||
- [base-setup-decoupling.md](../roadmap/limenext/base-setup-decoupling.md)
|
||||
- [agent-skills-profile.md](../roadmap/limenext/agent-skills-profile.md)
|
||||
- 把 `composition blueprint` 固定为基础设置包中的通用装配对象:
|
||||
- `recipe` 只保留为别名
|
||||
- 不新增 runtime taxonomy
|
||||
- 不新增第六种 binding family
|
||||
- 把第一条组合型样板正式收口为:
|
||||
- `文本 -> 线框图 -> 配乐 -> 剧本 -> 短视频草稿`
|
||||
- 归类为 `Hybrid / Multimodal Composition SceneApp`
|
||||
- 交付合同固定为 `project pack`
|
||||
- 把 LimeNext 业务图和业务时序继续改成“非技术同学也能看懂”的版本:
|
||||
- 增加多模态组合业务流图
|
||||
- 增加基础设置包如何下发组合场景的技术图
|
||||
- 增加组合蓝图编排多模态结果链的业务 / 技术时序
|
||||
- 更新执行计划与总实施计划:
|
||||
- [execution-plan.md](../roadmap/limenext/execution-plan.md)
|
||||
- [limenext-plan.md](./limenext-plan.md)
|
||||
- 明确多模态组合样板成为第五条默认样板链路
|
||||
- 明确下一刀是把 `composition blueprint` 继续收口到 schema 与目录投影
|
||||
|
||||
### 当前判断
|
||||
|
||||
- LimeNext 现在不该再被理解成“很多单点场景的目录”,而应被理解成“场景对象 + 组合蓝图 + 结果包”的装配平台
|
||||
- `composition blueprint` 这层已经进入正式文档口径,后续可以继续下沉到 schema、投影和校验规则
|
||||
- `composition blueprint` 现在已经不只是术语,而有了 v1 schema 草案和最小 validator 口径
|
||||
- `Base Setup Package` 现在也不再只是概念层,而有了顶层对象、目录投影和兼容性校验口径
|
||||
- 基础设置包现在不仅回答“长什么样”,也开始回答“怎么发布、怎么灰度、怎么回滚”
|
||||
- 基础设置包这条线现在也开始回答“第一版先改什么代码、先不改什么代码”
|
||||
- 基础设置包这条线现在已经不只是文档设计,而有了第一版代码骨架和最小单测闭环
|
||||
- 当前四类样板已经覆盖:
|
||||
- 浏览器采集导出
|
||||
- 云端托管媒体生产
|
||||
- 本地持续跟踪
|
||||
- 多模态组合结果链
|
||||
- 基础设置包现在已经不只是“目录下发”,还承担多步骤组合链的业务装配语义
|
||||
|
||||
### 风险
|
||||
|
||||
- 如果后续只把 `composition blueprint` 写成单一场景私有 workflow,通用装配层会再次失效
|
||||
- 如果后续让 `catalog projection`、`binding profile`、`artifact profile` 分别在不同文档里各说各话,基础设置包会再次失去顶层约束
|
||||
- 如果后续只写 schema,不把 validator / rollout / seeded fallback 做成真实门禁,团队还是会退回“改了文档等于完成”
|
||||
- 如果后续跳过 compat projection,直接要求所有前台入口原生理解 `Base Setup Package`,改动面会过大,容易再次拖慢主线
|
||||
- 如果后续长期只停在独立模块和单测,不接回 `serviceSkills.ts` 的真实 catalog 主链,这套骨架会再次漂浮
|
||||
- 如果后续给组合型场景单独发明 viewer 或 binding,宿主边界会再次变模糊
|
||||
- 如果后续只看最终视频草稿成败,不看 `project pack` 接受率与阶段返工率,组合场景很容易被误判
|
||||
|
||||
### 下一刀
|
||||
|
||||
- 把基础设置包 schema 继续下沉到 bootstrap / seeded catalog 可消费的目录投影与最小 validator
|
||||
- 把 `base-setup-projection-lifecycle.md` 继续下沉为 validator / projection compiler / rollout gate 的代码级设计
|
||||
- 基于 `base-setup-implementation.md` 决定第一版真实实现路线:客户端编译,还是服务端预编译 + 客户端 gate
|
||||
- 把 `src/lib/base-setup/*` 正式接回 `src/lib/api/serviceSkills.ts` 的 seeded / bootstrap 主链
|
||||
- 把多模态组合样板的 `project pack` 接到真实 artifact / viewer / scorecard 聚合入口
|
||||
- 把 `SceneScorecard` 与周会模板、场景看板和任务中心口径继续打通
|
||||
|
||||
### 验证
|
||||
|
||||
- 已执行:
|
||||
- `npm run harness:doc-freshness`
|
||||
- 结果:`clean`
|
||||
- `npm test -- "src/lib/base-setup/validator.test.ts" "src/lib/base-setup/compiler.test.ts" "src/lib/base-setup/rolloutGate.test.ts"`
|
||||
- 结果:`3 files / 8 tests passed`
|
||||
- `npm run typecheck`
|
||||
- 结果:未通过,当前阻塞来自仓库其他已有类型错误;本轮新增 `src/lib/base-setup/*` 未再报新错
|
||||
|
||||
## 2026-04-14
|
||||
|
||||
### 已完成
|
||||
|
||||
- 建立 LimeNext 上位总纲专题包:
|
||||
- [README.md](../roadmap/limenext/README.md)
|
||||
- [prd.md](../roadmap/limenext/prd.md)
|
||||
- [architecture.md](../roadmap/limenext/architecture.md)
|
||||
- [flowcharts.md](../roadmap/limenext/flowcharts.md)
|
||||
- [sequences.md](../roadmap/limenext/sequences.md)
|
||||
- [code-structure.md](../roadmap/limenext/code-structure.md)
|
||||
- [roadmap.md](../roadmap/limenext/roadmap.md)
|
||||
- [execution-plan.md](../roadmap/limenext/execution-plan.md)
|
||||
- [metrics-and-selection.md](../roadmap/limenext/metrics-and-selection.md)
|
||||
- [migration-map.md](../roadmap/limenext/migration-map.md)
|
||||
- 在 [docs/README.md](../README.md) 增加 LimeNext 总纲入口,确保文档中心可发现
|
||||
- 完成一轮文档新鲜度校验:
|
||||
- `npm run harness:doc-freshness`
|
||||
- 结果:`clean`
|
||||
- 根据反馈把 LimeNext 定义改成更容易传播的三层版本:
|
||||
- 给所有人看的白话版
|
||||
- 给产品团队看的版本
|
||||
- 给工程团队看的版本
|
||||
- 新增本执行计划与进度日志:
|
||||
- [limenext-plan.md](./limenext-plan.md)
|
||||
- [limenext-progress.md](./limenext-progress.md)
|
||||
- 补齐 SceneApp 能力模型总纲:
|
||||
- [sceneapp-capability-model.md](../roadmap/limenext/sceneapp-capability-model.md)
|
||||
- 固定五类 `SceneApp` 分类
|
||||
- 固定五种 `Skill / Scene` 编排模式
|
||||
- 固定九类底层能力模块范围
|
||||
- 明确 `server skill / CLI / browser / cron / db / markdown / json` 的适用边界
|
||||
- 把 skill 设计模式正式写回基础标准与运行时标准:
|
||||
- [skill-standard.md](../aiprompts/skill-standard.md)
|
||||
- [command-runtime.md](../aiprompts/command-runtime.md)
|
||||
- 明确 `Skill Bundle` 不是产品对象
|
||||
- 明确新增样板至少要声明 `sceneapp_type + pattern_primary / pattern_stack + infra_profile`
|
||||
- 在 `SceneApp` 总纲里增加常见组合原型,避免分类只停在抽象概念
|
||||
- 新增 SceneApp 样板蓝图:
|
||||
- [sceneapp-blueprints.md](../roadmap/limenext/sceneapp-blueprints.md)
|
||||
- 用 `x-article-export`、`@配音`、`每日趋势摘要 / 账号增长跟踪` 三条样板补齐业务图、业务时序和经营指标
|
||||
- 更新 LimeNext 总纲图示:
|
||||
- [flowcharts.md](../roadmap/limenext/flowcharts.md)
|
||||
- [sequences.md](../roadmap/limenext/sequences.md)
|
||||
- [architecture.md](../roadmap/limenext/architecture.md)
|
||||
- 补齐“业务图在前、技术图在后”的双层表达
|
||||
- 选定 LimeNext 第一条 `Agent + ServiceSkill` 样板为 `@配音 / voice_runtime / cloud-video-dubbing`
|
||||
- 重写 `docs/prd/gongneng/peiyin/` 六件套,形成完整实施级方案包:
|
||||
- [prd.md](../prd/gongneng/peiyin/prd.md)
|
||||
- [architecture.md](../prd/gongneng/peiyin/architecture.md)
|
||||
- [flowcharts.md](../prd/gongneng/peiyin/flowcharts.md)
|
||||
- [sequences.md](../prd/gongneng/peiyin/sequences.md)
|
||||
- [code-structure.md](../prd/gongneng/peiyin/code-structure.md)
|
||||
- [tasks.md](../prd/gongneng/peiyin/tasks.md)
|
||||
- 新增 `docs/prd/gongneng/x-article-export/` 六件套,形成完整实施级方案包:
|
||||
- [prd.md](../prd/gongneng/x-article-export/prd.md)
|
||||
- [architecture.md](../prd/gongneng/x-article-export/architecture.md)
|
||||
- [flowcharts.md](../prd/gongneng/x-article-export/flowcharts.md)
|
||||
- [sequences.md](../prd/gongneng/x-article-export/sequences.md)
|
||||
- [code-structure.md](../prd/gongneng/x-article-export/code-structure.md)
|
||||
- [tasks.md](../prd/gongneng/x-article-export/tasks.md)
|
||||
- 修正文档入口与引用,确保 `x-article-export` 作为 `Browser-grounded SceneApp` 样板可被发现:
|
||||
- [docs/README.md](../README.md)
|
||||
- [command-runtime/roadmap.md](../roadmap/gongneng/command-runtime/roadmap.md)
|
||||
- [limenext/README.md](../roadmap/limenext/README.md)
|
||||
- 新增 `Artifact / Evidence / Scorecard` 业务样板文档,并把它回挂到 LimeNext 总纲:
|
||||
- [artifact-evidence-scorecards.md](../roadmap/limenext/artifact-evidence-scorecards.md)
|
||||
- [metrics-and-selection.md](../roadmap/limenext/metrics-and-selection.md)
|
||||
- [sceneapp-blueprints.md](../roadmap/limenext/sceneapp-blueprints.md)
|
||||
- [flowcharts.md](../roadmap/limenext/flowcharts.md)
|
||||
- [sequences.md](../roadmap/limenext/sequences.md)
|
||||
- 把第三条 `Local Durable SceneApp` 样板固定到真实仓库锚点:
|
||||
- `daily-trend-briefing`
|
||||
- `account-performance-tracking`
|
||||
- `useWorkspaceServiceSkillEntryActions.ts`
|
||||
- `AutomationJobDialog.tsx`
|
||||
- `automation_service/mod.rs`
|
||||
- 新增通用解耦专题,明确“主 App 宿主内核”与“基础设置包”的边界:
|
||||
- [base-setup-decoupling.md](../roadmap/limenext/base-setup-decoupling.md)
|
||||
- 固定“换装配优先不升级主 App,换内核才升级”
|
||||
- 固定基础设置包不是新 runtime,也不是新产品对象
|
||||
- 把 `x-article-export` 的产品合同从“待澄清”收口为固定口径:
|
||||
- 先交付 `index.md + images/ + meta.json`
|
||||
- 若带 `target_language`,只在同一路径 Markdown 上继续后处理
|
||||
- 若未来要支持“纯原文模式”,必须显式分流入口
|
||||
- 完成第二轮文档新鲜度校验:
|
||||
- `npm run harness:doc-freshness`
|
||||
- 结果:`clean`
|
||||
|
||||
### 当前判断
|
||||
|
||||
- LimeNext 作为上位总纲已经成立
|
||||
- `SceneApp` 已不再只是一个抽象词,而有了统一能力模型
|
||||
- `SceneApp` 现在同时有“运行形态 + 编排模式 + 基础设施画像”三轴分类
|
||||
- `Skill` 设计模式已经不再只是经验说法,而进入了正式工程字段与设计卡
|
||||
- LimeNext 图示已经不再只有技术分层图,开始具备业务、产品、运营可读的总图与时序
|
||||
- LimeNext 现在不只有抽象分类,还有三条业务样板蓝图可以直接拿来对齐讨论
|
||||
- 第一条 `Agent + ServiceSkill` 样板已经不再空缺,当前由 `@配音 / voice_runtime` 承担
|
||||
- 第二条 `Agent + ServiceSkill` 样板也已不再空缺,当前由 `x-article-export` 承担
|
||||
- 第三条 `Local Durable SceneApp` 样板也不再只是概念词,当前由 `每日趋势摘要 / 账号增长跟踪` 承担
|
||||
- `Browser-grounded SceneApp` 已经不再停留在蓝图层,而有了完整实施级方案包
|
||||
- 当前最重要的问题不再是“第二条样板怎么补”,而是“样板如何接到 `Artifact + Evidence + Scorecard`”
|
||||
- `Artifact / Evidence / Scorecard` 三层现在已经不再只是抽象词,而有了业务样板和图示
|
||||
- `x-article-export` 的产品合同也已不再摇摆,当前口径是“资料包优先 + 同一路径后处理”
|
||||
- “尽量不升级主 App 也能使用基础设置” 这条原则也已不再只是口头要求,而被收口成通用分层规则
|
||||
- 当前还需要继续明确:
|
||||
- `SceneScorecard` 后续具体落到哪些真实聚合对象与看板入口
|
||||
|
||||
### 风险
|
||||
|
||||
- 如果后续继续只写总纲与治理,而不落样板链路,LimeNext 很容易再次变成抽象平台叙事
|
||||
- 如果后续只停在 `@配音` 一条云端样板,LimeNext 仍然会被误解成“只适合云端场景”
|
||||
- 如果后续不把 `Local Durable` 的经营口径接进真实看板,团队还是容易把它误讲成“只是定时任务”
|
||||
- 如果后续把“基础设置包”写成某个单场景私有配置,解耦层会再次失效
|
||||
- 如果后续重新把 `Skill`、`ServiceSkill`、`Scene` 混成一个词,总纲会迅速失效
|
||||
|
||||
### 下一刀
|
||||
|
||||
- 把 `SceneScorecard` 聚合对象继续接到任务中心 / 场景看板 / 周会模板
|
||||
- 把三条样板的经营信号进一步映射到真实数据源与产品入口
|
||||
- 继续把结果卡文案从“技术摘要”升级成“业务可理解的交付说明”
|
||||
- 把基础设置包收口成更明确的 schema 与校验规则
|
||||
@@ -12,4 +12,8 @@
|
||||
|
||||
| ID | 日期 | 区域 | 差距 / 债务 | 影响 | 下一小步 | 关联文档 | 状态 |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| TBD-000 | 2026-04-12 | 待补 | 初始化占位,后续将零散技术债迁入本表 | 先让规则有稳定落点,避免口头追踪 | 首次登记真实条目时替换本行 | `docs/exec-plans/README.md` | 待处理 |
|
||||
| CCD-001 | 2026-04-14 | Query Loop | Lime 已有 `submit_turn / runtime_turn / tool_runtime / evidence` 主链,但过去缺少单一 Query Loop 事实源;现已补齐 current 文档入口 | 已从“主链解释分散”降到“代码边界仍待继续收口”,后续推进顺序已稳定 | 继续沿 `docs/aiprompts/query-loop.md` 盘点代码侧重复边界,准备下一刀 helper 收口 | `docs/exec-plans/upstream-runtime-alignment-plan.md` | 已完成 |
|
||||
| CCD-002 | 2026-04-14 | Remote Runtime | Telegram remote、OpenClaw、DevBridge、browser remote/debugging 等入口过去并存且语义混杂;现已补齐 `消息渠道 runtime + 浏览器连接器 / ChromeBridge` 的 current 主链,并明确 `DevBridge / OpenClaw` 为 compat、`telegram_remote_cmd` 为 deprecated | 已从“remote 入口平级并存”降到“只剩 compat / deprecated 退场减法”,后续 remote 主线解释已稳定 | 后续只允许 remote 新能力落到 `gateway_channel_*` 或 `browser connector / ChromeBridge` current ingress;`DevBridge / OpenClaw / telegram_remote_cmd` 默认只做减法和兼容维护 | `docs/exec-plans/upstream-runtime-alignment-plan.md` | 已完成 |
|
||||
| CCD-003 | 2026-04-14 | Task / Agent | `subagent / automation / execution tracker / scheduler / heartbeat` 过去缺少统一 taxonomy;现已补齐 `agent turn / subagent turn / automation job` current 分型,并明确 `ExecutionTracker` 为摘要层、`SchedulerService` 为 compat 触发壳 | 已从“排期语言混乱”降到“只剩 compat / deprecated 退场减法”,后续主线解释已稳定 | 后续只允许新增长时执行需求落到 `agent turn / subagent turn / automation job` 三类之一;`SchedulerService` 与 `heartbeat_executions` 只继续做减法和退场 | `docs/exec-plans/upstream-runtime-alignment-plan.md` | 已完成 |
|
||||
| CCD-004 | 2026-04-14 | Memory / Compaction | `memory_runtime_*`、`unified_memory_*`、`agent_runtime_compact_session` 与来源链 control plane 过去缺少统一主链叙事;现已补齐 current 文档入口,并明确 `project_memory_get` 为 compat 附属层、`memory_feedback_cmd` 为 deprecated | 已从“记忆、压缩、恢复分头演进”降到“后续只需守住 current 边界不回流” | 后续只允许新记忆 / 压缩需求落到 `docs/aiprompts/memory-compaction.md` 定义的 current 主链;发现回流时做定点治理 | `docs/exec-plans/upstream-runtime-alignment-plan.md` | 已完成 |
|
||||
| CCD-005 | 2026-04-14 | State / History / Telemetry | reliability、harness、history、review、replay、evidence 过去分别成文且入口分散;现已补齐 `SessionDetail -> AgentRuntimeThreadReadModel -> RequestLog 关联键 -> export/history` 的 current 状态地图,并明确旧状态模型方案、reliability 计划与原始 request log 浏览面退回附属层 | 已从“状态模型叙事分散”降到“只剩 compat / deprecated 退场减法”,后续主线解释已稳定 | 后续只允许新状态 / 历史 / 遥测能力落到 `docs/aiprompts/state-history-telemetry.md` 定义的 current 主链;优先继续清 `reliability` 计划与 cleanup 旧语义 | `docs/exec-plans/upstream-runtime-alignment-plan.md` | 已完成 |
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
# 参考运行时主链对齐计划
|
||||
|
||||
> 状态:进行中
|
||||
> 更新时间:2026-04-14
|
||||
> 对照基线:本地参考运行时源码镜像(2026-04-14 快照)
|
||||
> 目标:把 Lime 与参考运行时的“运行时主链”对齐工作,收口成一份唯一的排期与差距事实源,避免路线图、治理减法和专题计划继续并行漂移。
|
||||
|
||||
## 1. 先给结论
|
||||
|
||||
Lime 当前不是“能力不够”,而是“主链不够单一”。
|
||||
|
||||
已确认的现状:
|
||||
|
||||
- `Tool Runtime / MCP / evidence / replay / review / subagent` 已具备较强底座
|
||||
- `Query Loop`、`Task / Agent taxonomy`、`Remote runtime` 与 `Memory / Compaction` 已完成 current 主链收口
|
||||
- 现有对齐文档散落在 `roadmap/`、`tech/`、`develop/`、`exec-plans/`,导致排期和治理容易串线
|
||||
|
||||
本计划固定一个判断:
|
||||
|
||||
**后续“对齐上游运行时”默认指向运行时主链对齐,而不是目录、UI 或命名的 1:1 复制。**
|
||||
|
||||
## 2. 对齐口径
|
||||
|
||||
### 2.1 对齐什么
|
||||
|
||||
默认对齐下面六条运行时主链:
|
||||
|
||||
1. `Query Loop`
|
||||
2. `Tool Runtime`
|
||||
3. `Memory / Compaction`
|
||||
4. `Remote / SDK / Server Mode`
|
||||
5. `Task / Agent / Coordinator`
|
||||
6. `State / History / Telemetry`
|
||||
|
||||
### 2.2 不对齐什么
|
||||
|
||||
- 不以上游运行时的目录结构作为目标
|
||||
- 不先追求 UI 行为逐像素一致
|
||||
- 不为了对齐而回退 Lime 已有的 GUI、Artifact、Provider、Workspace 优势
|
||||
- 不把已有专题路线图全部重写成新体系;已有文档保留为下游专项
|
||||
|
||||
## 2.3 当前进度
|
||||
|
||||
- `M0` 统一排期事实源:`done`
|
||||
- `M1` Query Loop 收口:`done`
|
||||
- `M2` Task / Agent taxonomy 收口:`done`
|
||||
- `M3` Remote runtime 收口:`done`
|
||||
- `M4` Memory / Compaction 收口:`done`
|
||||
- `M5` State / History / Telemetry 收口:`done`
|
||||
- 当前阶段:`六条运行时主链 current 入口已补齐,后续转入守 current 边界与 compat / deprecated 退场`
|
||||
- 当前进度日志:`docs/exec-plans/upstream-runtime-alignment-progress.md`
|
||||
|
||||
## 3. 主链差距矩阵
|
||||
|
||||
| 主链 | 参考运行时基线模块 | Lime 当前事实源 | 当前判断 | 主要差距 | 下一刀 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `Query Loop` | `QueryEngine.ts`、`query.ts`、`query/deps.ts`、`services/tools/*`、`services/compact/*` | `src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs`、`runtime_api.rs`、`tool_runtime.rs`、`docs/aiprompts/query-loop.md` | `aligned` | `Query Loop` current 事实源与主循环入口已收口;旧的 `alignment/state-model/conversation-efficiency` 专项文档都已退回 compat 历史档案,Artifact 文档族也已改回 current 入口引用;`TurnInputEnvelope -> SessionConfig` 的 turn context snapshot 分叉、`action_runtime` 辅助恢复链旁路,以及 `compact_session` 控制回合的最小上下文边界都已显式化;剩余散落在 `persona_cmd` / `theme_context_cmd` 的一次性临时会话配置已收口为专用 helper,零入口的旧 `AsterAgentWrapper::send_message` 已删除;Tauri 命令层原始 `agent.reply` / `stream_reply_with_policy` 扫描结果也已固定为 `action_runtime(current) + persona/theme_context(compat)` 三处,并补了源码扫描守卫 | 维持 aligned 主链;下一刀转向继续盘点 `src-tauri/src` 非命令层与 README/示例面是否还残留会误导实现者的原始执行旁路叙事 |
|
||||
| `Tool Runtime` | `src/tools/*`、`services/tools/*`、`services/mcp/*`、`ToolSearchTool` | `src-tauri/src/agent_tools/catalog.rs`、`inventory.rs`、`src-tauri/src/commands/aster_agent_cmd/tool_runtime/*`、`docs/aiprompts/commands.md`、`docs/aiprompts/command-runtime.md`、`docs/prd/tools/architecture.md` | `aligned` | 能力基本齐,但仍需持续防止命名、inventory、mock、MCP 注入回退到第二事实源 | 维持 current 主链,后续只做减法和守卫,不新增并行入口 |
|
||||
| `Memory / Compaction` | `services/compact/*`、`services/SessionMemory/*`、`memdir/*` | `docs/aiprompts/memory-compaction.md`、`src/lib/api/memoryRuntime.ts`、`src-tauri/src/commands/memory_management_cmd.rs`、`src-tauri/src/services/memory_source_resolver_service.rs`、`src-tauri/src/services/auto_memory_service.rs`、`src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs`、`src-tauri/src/commands/aster_agent_cmd/command_api/runtime_api.rs`、`src/lib/api/unifiedMemory.ts`、`src-tauri/src/commands/unified_memory_cmd.rs` | `aligned` | `M4` 已建立统一主链:`来源链解析 -> 单回合 prefetch -> runtime_turn prompt augmentation -> session compaction -> working/durable memory 沉淀 -> GUI 稳定读模型`;`project_memory_get` 退回 compat 附属层,`memory_feedback_cmd` 退回 deprecated | 转入 `M5`,固定 `session / thread / turn / request / evidence / history` 的 current 状态地图 |
|
||||
| `Remote / SDK / Server Mode` | `remote/*`、`server/*`、`entrypoints/sdk/*`、`cli/transports/*` | `docs/aiprompts/remote-runtime.md`、`src/lib/api/channelsRuntime.ts`、`src-tauri/src/commands/gateway_channel_cmd.rs`、`src/lib/webview-api.ts`、`src-tauri/src/commands/browser_connector_cmd.rs`、`src-tauri/src/commands/webview_cmd.rs`、`src-tauri/src/commands/browser_runtime_cmd.rs`、`src-tauri/src/dev_bridge/*`、`src-tauri/src/services/openclaw_service/*`、`src-tauri/src/commands/telegram_remote_cmd.rs` | `aligned` | `M3` 已建立统一 remote 主链:`消息渠道 runtime + 浏览器连接器 / ChromeBridge` 是 current ingress,`DevBridge / OpenClaw` 退回 compat,`telegram_remote_cmd` 退回 deprecated;后续残余减法转入 compat surface 继续处理 | 维持 aligned 主链,后续只做 compat / deprecated 退场减法 |
|
||||
| `Task / Agent / Coordinator` | `tasks/*`、`tools/AgentTool`、`Task*Tool`、`EnterWorktreeTool`、`RemoteTriggerTool`、`coordinator/*` | `docs/aiprompts/task-agent-taxonomy.md`、`docs/aiprompts/query-loop.md`、`src-tauri/src/commands/aster_agent_cmd/subagent_runtime.rs`、`src-tauri/src/services/execution_tracker_service.rs`、`src-tauri/src/services/automation_service/*`、`src-tauri/src/app/scheduler_service.rs` | `aligned` | `M2` 已建立统一 taxonomy:`agent turn / subagent turn / automation job` 是 current 一等执行实体,`ExecutionTracker` 是统一执行摘要层,`SchedulerService` 退回 compat 触发壳;后续残余减法转入 execution tracker / scheduler 专项继续处理 | 维持 aligned 主链,后续只做定点减法与回归修复 |
|
||||
| `State / History / Telemetry` | `state/*`、`bootstrap/*`、`assistant/sessionHistory.ts`、`cli/transports/ccrClient.ts`、`services/analytics/*` | `docs/aiprompts/state-history-telemetry.md`、`src-tauri/crates/agent/src/session_store.rs`、`src-tauri/src/commands/aster_agent_cmd/dto.rs`、`src-tauri/src/commands/aster_agent_cmd/command_api/runtime_api.rs`、`src-tauri/src/services/thread_reliability_projection_service.rs`、`src-tauri/src/services/runtime_handoff_artifact_service.rs`、`src-tauri/src/services/runtime_evidence_pack_service.rs`、`src-tauri/src/services/runtime_replay_case_service.rs`、`src-tauri/src/services/runtime_review_decision_service.rs`、`scripts/harness-eval-history-record.mjs` | `aligned` | `M5` 已建立统一状态地图,并继续完成 compat/deprecated 收口:`reliability / state-model / alignment / conversation-efficiency` 这些重型 compat 文档都已压成历史摘要档案,`telemetry_cmd.rs` 只再保留原始日志面定位 | 维持 current 主链;文档侧重型 compat 路线图已基本收完,下一刀优先回到 current 代码边界继续做减法 |
|
||||
|
||||
## 4. 现有文档归位
|
||||
|
||||
本计划是排期总入口,不替代现有专题方案。现有文档按下列方式归位:
|
||||
|
||||
| 文档群 | 归属主链 | 角色 |
|
||||
| --- | --- | --- |
|
||||
| `docs/roadmap/lime-aster-codex-alignment-roadmap.md`、`docs/roadmap/lime-aster-codex-state-model-implementation-plan.md` | `Query Loop`、`State / History / Telemetry` | Aster/Codex 历史专项档案,不再承担仓库级总排期或 current 实施入口职责 |
|
||||
| `docs/roadmap/reliability/*` | `State / History / Telemetry` | Reliability control plane 专项 |
|
||||
| `docs/tech/harness/*`、`docs/roadmap/harness-engine/*` | `State / History / Telemetry`、`Memory / Compaction` | Evidence / replay / review / observability / cleanup 专项 |
|
||||
| `docs/develop/execution-tracker-technical-plan.md`、`docs/develop/execution-tracker-p1-p2-roadmap.md`、`docs/develop/scheduler-task-governance-p1.md` | `Task / Agent / Coordinator` | 长时执行与治理专项 |
|
||||
| `docs/aiprompts/commands.md`、`docs/aiprompts/command-runtime.md`、`docs/prd/tools/*` | `Tool Runtime` | 当前工具面与协议事实源 |
|
||||
|
||||
如果后续新增专项文档,必须先标明它属于哪条主链;如果说不清主链归属,就不允许单独立项。
|
||||
|
||||
## 5. 排期纪律
|
||||
|
||||
从本计划起,后续排期默认遵守以下规则:
|
||||
|
||||
1. 同一时间只允许 `1` 条主链作为主任务。
|
||||
2. 每轮最多再搭配 `1` 条服务主链的治理任务。
|
||||
3. 所有任务必须声明:
|
||||
- 所属主链
|
||||
- 当前分类:`current / compat / deprecated / dead`
|
||||
- 阶段出口
|
||||
4. 治理减法如果不能直接缩短主链,就登记到 `docs/exec-plans/tech-debt-tracker.md`,不直接插队。
|
||||
5. 专题路线图完成后,必须回写本计划的矩阵状态,避免“专题 done,但总盘子仍 unknown”。
|
||||
|
||||
## 6. 推荐推进顺序
|
||||
|
||||
### `M0` 统一排期事实源
|
||||
|
||||
- 建立本计划,固定六条主链
|
||||
- 把现有对齐文档回挂到单一总入口
|
||||
- 把当前未收口差距登记进技术债追踪
|
||||
|
||||
### `M1` Query Loop 收口
|
||||
|
||||
- 为 Lime 补一份单一 Query Loop 事实图
|
||||
- 明确 `submit_turn -> turn runtime -> tool orchestration -> compaction -> evidence` 的 current 主链
|
||||
- 退出条件:不再需要横跳多份文档才能解释 Lime 主循环
|
||||
|
||||
当前进度(2026-04-14):
|
||||
|
||||
- 已完成第一刀:`docs/aiprompts/query-loop.md` 已成为 Query Loop current 入口
|
||||
- 已完成第二刀实现:`runtime_turn.rs` 已将 `run_start_metadata`、`runtime_status_session_config`、`build_session_config` 的重复拼装收口为稳定 helper,主循环可读性继续提升
|
||||
- 已完成第三刀实现:单次流式执行成功后的 Artifact 自动落盘与记忆沉淀已收口为统一 helper,主成功分支与降级成功分支不再各自维护重复收尾逻辑
|
||||
- 已完成第四刀实现:两处 `stream_reply_once` 的运行时事件记录闭包已收口为统一 helper,主分支与降级分支共享同一条事件记录路径
|
||||
- 已完成第五刀实现:`RunFinishDecision` 组装与 terminal result 收尾已收口为统一 helper,主循环不再内联 success/error 两套终态处理
|
||||
- 已完成第六刀实现:主执行分支与降级到 ReAct 的分支已共用“单次流式尝试” helper,`build_runtime_user_message(...)`、`build_session_config()`、`stream_reply_once(...)` 与成功收尾逻辑不再在主循环中重复展开
|
||||
- 已完成第七刀实现:`CodeOrchestrated` 的扩展启用、失败降级、扩展清理已收口为统一策略 helper,`with_run_custom(...)` 内只保留一次 Query Loop 执行入口
|
||||
- 已完成第八刀实现:runtime turn 初始化、status 投射与 service preload 事件已收口为统一前奏 helper,主循环的前置准备阶段已压成单一语义块
|
||||
- 已完成第九刀实现:`run_start_metadata`、`timeline_recorder`、`runtime_status_session_config` 与流式 `session_config` 构建状态已收口为统一 execution context,主循环不再散落拼装这组前置状态
|
||||
- 已完成第十刀实现:`run_observation`、`run_finish decision` 与 terminal finalize 链路已回收到 execution context,主循环不再手工拼接 tracked execution 与终态收尾
|
||||
- 已完成第十一刀实现:`skill_tool_session_access` 与 `cancel_token` 已收口为统一 session scope,异常路径与正常路径共享同一套会话级清理边界
|
||||
- 已完成第十二刀实现:`runtime_snapshot -> runtime_projection_snapshot -> turn_state -> turn_input_envelope` 已收口为统一 `build_runtime_turn_artifacts(...)` helper,主循环不再内联读取 snapshot、派生 thread/turn 和构建 turn 输入诊断
|
||||
- 已完成第十三刀实现:`service_skill_preload -> prepared execution -> prelude/execute handoff` 已收口为 `prepare_runtime_turn_execution(...)` 与 `RuntimeTurnPreparedExecution`,submit 主路径已压成更明确的 `prepare -> execute` 语义块
|
||||
- 已完成第十四刀实现:`provider_continuation -> workspace sandbox apply -> tracker/session scope bootstrap` 已收口为 `prepare_runtime_turn_submit_bootstrap(...)`,submit 主路径不再内联铺开这组前置副作用和运行期参数拼装
|
||||
- 已完成第十五刀实现:`request.provider_config -> configure_provider/configure_provider_from_pool -> persist_session_provider_routing` 已收口为 `apply_runtime_turn_provider_config(...)`,submit 主路径不再内联铺开 provider apply 分支
|
||||
- 已完成第十六刀实现:`resolved_prompt -> prompt augmentation -> requested/effective strategy persist` 已收口为 `prepare_runtime_turn_prompt_strategy(...)`,submit 主路径不再内联铺开 prompt/strategy 组装与持久化
|
||||
- 已完成第十七刀实现:`tool surface metadata -> MCP warmup -> skill launch metadata normalize -> turn_input_builder seed` 已收口为 `prepare_runtime_turn_request(...)`,submit 主路径不再内联铺开 request prepare 分支
|
||||
- 已完成第十八刀实现:`runtime_chat_mode -> web_search/request_tool_policy -> execution_profile` 已收口为 `prepare_runtime_turn_policy(...)`,submit 主路径不再内联铺开 policy resolve 逻辑
|
||||
- 已完成第十九刀实现:`auto_continue -> workspace repair warning -> session_state_snapshot/working_dir update -> session_recent_runtime_context` 已收口为 `prepare_runtime_turn_session(...)`,submit 主路径的 session 级前置准备已压成单一 helper
|
||||
- 已完成第二十刀实现:`session/policy/request/prompt_strategy -> provider apply/bootstrap` 已收口为 `prepare_runtime_turn_submit_preparation(...)`,submit 主路径已提升为单一 preflight 准备块
|
||||
- 已完成第二十一刀实现:`sync_browser_assist_runtime_hint -> prepare_runtime_turn_execution -> agent guard -> emit_prelude_and_execute` 已收口为 `execute_runtime_turn_submit(...)`,submit 主路径当前已压成 `prepare -> scoped execute` 两段主骨架
|
||||
- 已完成第二十二刀实现:`provider_config resolve -> harness metadata normalize -> workspace resolve/turn id/runtime_config` 已收口为 `prepare_runtime_turn_ingress_context(...)`,入口上下文边界不再散落在主路径中
|
||||
- 已完成第二十三刀实现:`agent init/session_store check -> support tools register` 已收口为 `prepare_runtime_turn_entry(...)`,主路径的运行时入口准备已形成独立阶段
|
||||
- 已完成第二十四刀实现:`model_skill_tool_access derive -> with_runtime_turn_session_scope -> execute_runtime_turn_submit` 已收口为 `execute_runtime_turn_with_session_scope(...)`,主路径不再拆包又重组 `submit_preparation`
|
||||
- 已完成第二十五刀实现:`execute_aster_chat_request(...)` 已把 `entry -> ingress -> submit_preparation -> session_scope_execute` 提升为单一 `execute_runtime_turn_pipeline(...)` 调用,`M1` 外层 orchestration 收口完成
|
||||
- 已补最小 Rust 定向校验:`env CARGO_TARGET_DIR="/Users/coso/Documents/dev/ai/aiclientproxy/lime/.codex-target-runtime-turn-2" cargo test --manifest-path "src-tauri/Cargo.toml" -p lime --lib commands::aster_agent_cmd::runtime_turn::tests:: -- --nocapture` 通过(`37 passed`,仅剩 `workspace_tools.rs` 既有 dead_code warning)
|
||||
- 已补文档新鲜度校验:`npm run harness:doc-freshness` 通过(`clean`)
|
||||
- 已补格式化校验:`cargo fmt --manifest-path "src-tauri/Cargo.toml" --all` 通过
|
||||
- `M1` 退出判断:已满足“不再需要横跳多份文档才能解释 Lime 主循环”的出口条件,后续不再继续细切 `M1`
|
||||
|
||||
### `M2` Task / Agent taxonomy 收口
|
||||
|
||||
- 为 Lime 定义统一的 task / agent / coordinator 分型
|
||||
- 将 `execution tracker / scheduler / subagent / automation` 收回同一语义层
|
||||
- 退出条件:所有长时执行入口都能归到唯一 taxonomy
|
||||
|
||||
当前进度(2026-04-14):
|
||||
|
||||
- 已完成第一刀:`docs/aiprompts/task-agent-taxonomy.md` 已成为 Task / Agent / Coordinator current 入口
|
||||
- 已完成第二刀:`ExecutionTracker / subagent runtime / automation service / scheduler trigger` 的 `current / compat / deprecated / dead` 已有明确归类
|
||||
- 已完成第三刀:`docs/README.md`、`docs/aiprompts/README.md`、`docs/aiprompts/overview.md`、`AGENTS.md` 已同步回挂新入口,仓库导航不再继续把 scheduler/heartbeat 叙事误当 current 主线
|
||||
- `M2` 退出判断:已满足“所有长时执行入口都能归到唯一 taxonomy”的出口条件,后续不再把 execution tracker、scheduler、subagent、automation 分散解释为多条平级主线
|
||||
|
||||
### `M3` Remote runtime 收口
|
||||
|
||||
- 盘点 remote/dev bridge/browser remote/IM remote 入口
|
||||
- 声明唯一 current remote runtime
|
||||
- 退出条件:remote 不再是多个并列产品旁路
|
||||
|
||||
当前进度(2026-04-14):
|
||||
|
||||
- 已完成第一刀:`docs/aiprompts/remote-runtime.md` 已成为 Remote runtime current 入口
|
||||
- 已完成第二刀:`gateway_channel_* + browser connector / ChromeBridge` 已明确为 current remote ingress,`DevBridge / OpenClaw` 已归到 compat,`telegram_remote_cmd` 已归到 deprecated
|
||||
- 已完成第三刀:`docs/README.md`、`docs/aiprompts/README.md`、`docs/aiprompts/overview.md`、`AGENTS.md` 已同步回挂新入口,仓库导航不再继续把 debug 桥或单通道 Telegram 入口误当 remote 主线
|
||||
- `M3` 退出判断:已满足“remote 不再是多个并列产品旁路”的出口条件,后续只在 current ingress 上新增能力,其余 remote 面默认只做减法与兼容维护
|
||||
|
||||
### `M4` Memory / Compaction 收口
|
||||
|
||||
- 固定压缩边界、记忆预取、恢复和用户可见状态的主链
|
||||
- 退出条件:`memory_runtime_*` 与 `compact_session` 不再被当成分散能力点看待
|
||||
|
||||
当前进度(2026-04-14):
|
||||
|
||||
- 已完成第一刀:`docs/aiprompts/memory-compaction.md` 已成为 Memory / Compaction current 入口
|
||||
- 已完成第二刀:`来源链解析 -> 单回合 prefetch -> runtime_turn prompt augmentation -> session compaction -> working/durable memory 沉淀 -> GUI 稳定读模型` 已明确为 current 主链
|
||||
- 已完成第三刀:`project_memory_get` 与角色/世界观/大纲资料链已明确退回 compat 附属层,`memory_feedback_cmd` 已退回 deprecated,`memory_search_cmd.rs.bak` 已标记为 dead
|
||||
- 已完成第四刀:`docs/README.md`、`docs/aiprompts/README.md`、`docs/aiprompts/overview.md`、`AGENTS.md` 已同步回挂新入口,仓库导航不再继续把项目资料聚合或旧 feedback 侧链误当成记忆 / 压缩主线
|
||||
- `M4` 退出判断:已满足“`memory_runtime_*` 与 `compact_session` 不再被当成分散能力点看待”的出口条件,后续只允许在 current 边界继续长记忆 / 压缩能力
|
||||
|
||||
### `M5` State / History / Telemetry 收口
|
||||
|
||||
- 把 reliability、harness、history、review、replay 的事实源统一成一张状态地图
|
||||
- 退出条件:session / thread / turn / request / evidence / history 的读模型叙事收口
|
||||
|
||||
当前进度(2026-04-14):
|
||||
|
||||
- 已完成第一刀:`docs/aiprompts/state-history-telemetry.md` 已成为 State / History / Telemetry current 入口
|
||||
- 已完成第二刀:`agent_sessions / agent_messages -> SessionDetail -> AgentRuntimeThreadReadModel -> RequestLog 关联键 -> handoff/evidence/replay/analysis/review -> history-record/trend/cleanup/dashboard -> HarnessStatusPanel / AgentThreadReliabilityPanel` 已明确为 current 主链
|
||||
- 已完成第三刀:`docs/roadmap/lime-aster-codex-state-model-implementation-plan.md`、`docs/roadmap/reliability/*` 与 `telemetry_cmd.rs` 已明确退回 compat;cleanup 报表里残留的 `requestTelemetry:unlinked` 旧语义已明确为 deprecated
|
||||
- 已完成第四刀:`docs/README.md`、`docs/aiprompts/README.md`、`docs/aiprompts/overview.md`、`AGENTS.md` 已同步回挂新入口,仓库导航不再继续把状态模型专题计划、reliability 计划或原始 request log 控制台误当成 current 主链
|
||||
- 已完成第五刀:`docs/roadmap/reliability/README.md` 已补成 compat 目录入口;cleanup 核心脚本已把旧 `requestTelemetry:unlinked` 样本折叠为 `known_gap`,避免旧历史语义继续充当现役状态类别
|
||||
- 已完成第六刀:`docs/roadmap/reliability/*.md` 全部补上 compat 提示,正文开头先回挂 `state-history-telemetry.md`;`telemetry_cmd.rs` 也已明确只暴露原始 `RequestLog` 与聚合统计,不再和 thread read / evidence 主链抢解释权
|
||||
- 已完成第七刀:`docs/roadmap/reliability/*.md` 顶部重复的上位文档列表已压成统一 `README + current 主链 + PR 对应映射` 导航,专项正文不再继续堆叠第二套入口说明
|
||||
- 已完成第八刀:整组 `docs/roadmap/reliability/*` 已进一步压缩为 compat 历史摘要档案,只保留落地结果、current 映射与延后增强项;重复的目标/问题/范围/实施清单正文已回退到仓库历史
|
||||
- 已完成第九刀:`docs/roadmap/lime-aster-codex-state-model-implementation-plan.md` 已进一步压缩为 compat 历史摘要档案,`alignment-roadmap` 顶部导航也已改回 `query-loop / state-history-telemetry / upstream-runtime-alignment-plan` 这组 current 入口
|
||||
- 已完成第十刀:`docs/roadmap/lime-aster-codex-alignment-roadmap.md` 已进一步压缩为 compat 历史摘要档案,只保留阶段映射、历史判断与 current 回看入口
|
||||
- 已完成第十一刀:`docs/roadmap/lime-conversation-execution-efficiency-roadmap.md` 已进一步压缩为 compat 历史摘要档案,`docs/roadmap/artifacts/*` 对运行时边界的引用也已统一改回 `query-loop / task-agent-taxonomy / state-history-telemetry / upstream-runtime-alignment-plan`
|
||||
- `M5` 退出判断:已满足“session / thread / turn / request / evidence / history 的读模型叙事收口”的出口条件,后续只允许在 current 边界上继续长能力
|
||||
|
||||
## 7. 当前默认判断
|
||||
|
||||
- `Tool Runtime` 继续视为 `current`
|
||||
- `Query Loop`、`Task / Agent / Coordinator`、`Remote / SDK / Server Mode`、`Memory / Compaction`、`State / History / Telemetry` 视为 `aligned`
|
||||
|
||||
当前最值得继续推进的一刀固定为:
|
||||
|
||||
**文档侧重型 compat 路线图已基本压成历史摘要档案;当前已收口 `TurnInputEnvelope -> SessionConfig` 的 turn context snapshot 分叉、`action_runtime` 辅助恢复链的 turn context 旁路,并显式化 `compact_session` 控制回合的最小上下文边界;剩余散落在 `persona_cmd` / `theme_context_cmd` 的一次性临时会话配置也已收口为专用 helper,零入口旧发送壳已删除;Tauri 命令层原始执行面也已固定为 3 处并补了源码扫描守卫。下一刀转向继续盘点 `src-tauri/src` 非命令层与 README/示例面是否还残留会误导实现者的原始执行旁路叙事。**
|
||||
|
||||
这样做的原因是:六条运行时主链的 current 入口已经补齐;cleanup 的旧 `unlinked` 语义、telemetry 原始浏览面定位,以及 `reliability / state-model / alignment / conversation-efficiency` 这些重型 compat 文档都已经被收紧。继续留在文档治理上的边际收益已经明显下降,下一步更值得回到 current 代码边界继续做减法。
|
||||
@@ -0,0 +1,211 @@
|
||||
# 参考运行时主链对齐进度日志
|
||||
|
||||
## 2026-04-15
|
||||
|
||||
### 已完成
|
||||
|
||||
- 在 [tests.rs](../../src-tauri/src/commands/aster_agent_cmd/tests.rs) 新增基础 Prompt 主链源码守卫,固定 `runtime_turn` 的 current 组装顺序:入口段必须保持 `RuntimeAgents -> ExplicitLocalPathFocus -> FullRuntime/FastChat 分流`,`build_full_runtime_system_prompt(...)` 必须保持 `Memory -> ... -> AutoContinue` 的既定 augmentation 顺序,`ServiceSkillLaunchPreload` 只能在 FullRuntime 下作为尾部追加阶段
|
||||
- 同步把 `service_skill_launch_preload` 相关重复测试 fixture 收口为共享 helper,减少同一预执行样例在多条测试里重复内联,避免后续调整站点技能预执行 contract 时只改一半测试
|
||||
- 补最贴边界的定向校验:
|
||||
- `env CARGO_TARGET_DIR="/Users/coso/Documents/dev/ai/aiclientproxy/lime/.codex-target" cargo test --manifest-path "src-tauri/Cargo.toml" runtime_turn_source_keeps --lib -- --nocapture` 通过(`3 passed`)
|
||||
- `env CARGO_TARGET_DIR="/Users/coso/Documents/dev/ai/aiclientproxy/lime/.codex-target" cargo test --manifest-path "src-tauri/Cargo.toml" service_skill_launch_preload --lib -- --nocapture` 通过(`3 passed`)
|
||||
|
||||
## 2026-04-14
|
||||
|
||||
### 已完成
|
||||
|
||||
- 建立基础 Prompt current 文档 [prompt-foundation.md](../aiprompts/prompt-foundation.md),把 `runtime_turn -> prompt_context / prompt services -> TurnInputEnvelope -> aster PromptManager / embedded prompts` 固定为唯一基础 Prompt 事实源;同时明确 `query-loop.md` 负责提交主循环、本文负责 Prompt 主链,功能样板与历史工作台文档不再反向定义 system prompt 顺序
|
||||
- 将 [content-creator.md](../aiprompts/content-creator.md) 明确回挂到 [prompt-foundation.md](../aiprompts/prompt-foundation.md),把“首条消息注入 systemPrompt”的旧工作台叙事降回归档背景说明,不再误导成当前基础 Prompt 入口
|
||||
- 将 [docs/aiprompts/README.md](../aiprompts/README.md) 与 [docs/README.md](../README.md) 同步回挂到 [prompt-foundation.md](../aiprompts/prompt-foundation.md),后续改 `system prompt / subagent prompt / plan prompt / prompt_context / augmentation` 时有单一入口
|
||||
- 补文档新鲜度校验:`npm run harness:doc-freshness` 已通过(`doc freshness: clean`),确认新增的基础 Prompt 文档、入口回挂与路径引用没有漂移
|
||||
- 在 [MessageList.tsx](../../src/components/agent/chat/components/MessageList.tsx) 将运行状态线从输入区底栏迁到最后一条 assistant 消息尾部,统一承载 `处理中 / 已完成`、耗时、工具批次、输入输出 token 与 Prompt Cache 摘要,不再让状态提示霸占输入区主视觉;本轮继续补齐“完成态保留最后一批工具统计”,真实 GUI 已确认可见 `已完成 · 00:20 · 工具 读 2 / 列 2 · 输入 11.4K / 输出 309`
|
||||
- 在 [MessageList.test.tsx](../../src/components/agent/chat/components/MessageList.test.tsx) 补上“复杂任务完成后状态线应跟随最后一条 assistant 消息尾部”的稳定断言,并通过真实 GUI 复测确认运行态 footer 位于消息流内、显著高于输入框,不再回落到底部输入栏
|
||||
- 建立总计划 [upstream-runtime-alignment-plan.md](./upstream-runtime-alignment-plan.md),固定六条主链作为唯一排期事实源
|
||||
- 建立 Query Loop current 文档 [query-loop.md](../aiprompts/query-loop.md),把提交入口、turn 组包、queue、tool runtime、流式执行、压缩与 evidence 消费收口成单一主链
|
||||
- 在 [turn_input_envelope.rs](../../src-tauri/crates/agent/src/turn_input_envelope.rs) 与 [runtime_turn.rs](../../src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs) 收口一条 Query Loop current 旁路:`TurnInputEnvelope` 现会记录最终 turn output schema 与 workspace-derived turn context metadata,`SessionConfig` 改为直接消费 envelope 提前固化的 turn context,不再在执行前再次拼装 `artifact output schema / auto_compact` 这一层真实输入
|
||||
- 在 [action_runtime.rs](../../src-tauri/src/commands/aster_agent_cmd/action_runtime.rs) 继续收口 Query Loop 辅助恢复链:`agent_runtime_respond_action` 的 elicitation / ask 恢复路径现复用 `runtime_turn` 的 turn context snapshot helper,不再只单独注入 `auto_compact`,artifact output schema 与 request metadata 也会走同一条 current turn context 边界
|
||||
- 在 [runtime_turn.rs](../../src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs) 把 `compact_session` 控制回合的最小 `SessionConfig` 边界编码为专用 helper,并补测试守卫:压缩控制回合只保留 `thread_id / turn_id`,真正的 conversation 替换与 summary cache 更新仍复用共享 `perform_context_compaction(...)` core;[memory-compaction.md](../aiprompts/memory-compaction.md) 也已同步修正文档,避免继续误写成 `agent.compact_session()` 旁路
|
||||
- 在 [aster_agent.rs](../../src-tauri/src/agent/aster_agent.rs)、[persona_cmd.rs](../../src-tauri/src/commands/persona_cmd.rs) 与 [theme_context_cmd.rs](../../src-tauri/src/commands/theme_context_cmd.rs) 完成剩余 `SessionConfigBuilder` 生产落点分类:`generate_persona` 与 `aster_agent_theme_context_search` 现在统一复用“专用一次性会话” helper,显式声明它们不参与 Query Loop 的 submit turn / runtime queue / turn context snapshot 真相;零入口的旧 `AsterAgentWrapper::send_message` 聊天壳已删除,避免继续伪装成 current 主链
|
||||
- 在 [tests.rs](../../src-tauri/src/commands/aster_agent_cmd/tests.rs) 补上命令层 raw execution 源码扫描守卫:当前只允许 `action_runtime.rs`、`persona_cmd.rs`、`theme_context_cmd.rs` 三处保留原始 `agent.reply(...)` / `stream_reply_with_policy(...)`,一旦 Tauri 命令层再长出第四条未分类旁路,测试会直接失败
|
||||
- 在 [query-loop.md](../aiprompts/query-loop.md) 与 [src-tauri/src/agent/README.md](../../src-tauri/src/agent/README.md) 回写命令层边界说明:`agent_runtime_respond_action` 明确属于 current 恢复链;`generate_persona` 与 `aster_agent_theme_context_search` 明确属于受控 compat 一次性命令;README 的底层 `agent.reply(...)` 示例也已补充“不要在 Tauri 命令层继续新增 raw 执行旁路”的提醒
|
||||
- 建立 Task / Agent current 文档 [task-agent-taxonomy.md](../aiprompts/task-agent-taxonomy.md),把 `agent turn / subagent turn / automation job / scheduler tick / execution run` 收口成单一 taxonomy
|
||||
- 建立 Remote runtime current 文档 [remote-runtime.md](../aiprompts/remote-runtime.md),把 `消息渠道 runtime / 浏览器连接器 / ChromeBridge / DevBridge / OpenClaw / telegram_remote` 收口成单一 remote taxonomy
|
||||
- 建立 Memory / Compaction current 文档 [memory-compaction.md](../aiprompts/memory-compaction.md),把 `来源链 / working memory / durable memory / Team Memory / 会话压缩 / project memory sidecar` 收口成单一 memory taxonomy
|
||||
- 将 `ExecutionTracker / subagent runtime / automation service / scheduler trigger` 明确分类为 `current / compat / deprecated / dead`,其中 `SchedulerService` 退回 compat 触发壳,`automation_jobs.payload.browser_session` 明确为 dead
|
||||
- 将 `gateway_channel_* + browser connector / ChromeBridge` 明确归到 remote current 主链,把 `DevBridge / OpenClaw` 降回 compat、`telegram_remote_cmd` 降回 deprecated
|
||||
- 将 `memory_runtime_* + unified_memory_* + agent_runtime_compact_session` 明确归到 memory current 主链,把 `project_memory_get` 相关项目资料链降回 compat 附属层、`memory_feedback_cmd` 降回 deprecated、`memory_search_cmd.rs.bak` 标记为 dead
|
||||
- 将新 taxonomy 入口同步回 [docs/README.md](../README.md)、[docs/aiprompts/README.md](../aiprompts/README.md)、[docs/aiprompts/overview.md](../aiprompts/overview.md) 与 [AGENTS.md](../../AGENTS.md),仓库导航不再继续把旧 `heartbeat_service` 叙事当成 current
|
||||
- 将 remote 入口同步回 [docs/README.md](../README.md)、[docs/aiprompts/README.md](../aiprompts/README.md)、[docs/aiprompts/overview.md](../aiprompts/overview.md) 与 [AGENTS.md](../../AGENTS.md),仓库导航不再继续把 debug 桥或单通道 Telegram 入口误当成 remote 主线
|
||||
- 将 memory 入口同步回 [docs/README.md](../README.md)、[docs/aiprompts/README.md](../aiprompts/README.md)、[docs/aiprompts/overview.md](../aiprompts/overview.md) 与 [AGENTS.md](../../AGENTS.md),仓库导航不再继续把项目资料聚合或旧 feedback 侧链误当成记忆 / 压缩主线
|
||||
- 在 [runtime_turn.rs](../../src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs) 完成第二刀代码收口:把 `run_start_metadata`、`runtime_status_session_config`、`build_session_config` 的重复拼装下沉为 helper,继续缩短 Query Loop 主循环
|
||||
- 在 [runtime_turn.rs](../../src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs) 完成第三刀代码收口:把流式执行成功后的 Artifact 自动落盘与记忆沉淀收口为统一 helper,消除主成功分支与降级成功分支的重复收尾逻辑
|
||||
- 在 [runtime_turn.rs](../../src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs) 完成第四刀代码收口:把两处 `stream_reply_once` 的事件记录闭包收口为统一 helper,主分支与降级分支共享同一条事件记录路径
|
||||
- 在 [runtime_turn.rs](../../src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs) 完成第五刀代码收口:把 `RunFinishDecision` 组装与 terminal result 收尾收口为统一 helper,主循环不再内联 success/error 两套终态处理
|
||||
- 在 [runtime_turn.rs](../../src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs) 完成第六刀代码收口:把主执行分支与降级到 ReAct 的分支统一为“单次流式尝试” helper,消除 `build_runtime_user_message(...)`、`build_session_config()`、`stream_reply_once(...)` 与成功收尾逻辑的重复展开
|
||||
- 在 [runtime_turn.rs](../../src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs) 完成第七刀代码收口:把 `CodeOrchestrated` 的扩展启用、失败降级与扩展清理统一为策略 helper,让 `with_run_custom(...)` 内只保留一次 Query Loop 执行入口
|
||||
- 在 [runtime_turn.rs](../../src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs) 完成第八刀代码收口:把 runtime turn 初始化、status 投射与 service preload 事件收口为统一前奏 helper,主循环的前置准备阶段已压成单一语义块
|
||||
- 在 [runtime_turn.rs](../../src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs) 完成第九刀代码收口:把 `run_start_metadata`、`timeline_recorder`、`runtime_status_session_config` 与流式 `session_config` 构建状态统一为 execution context,主循环不再散落拼装这组前置状态
|
||||
- 在 [runtime_turn.rs](../../src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs) 完成第十刀代码收口:把 `run_observation`、`run_finish decision` 与 terminal finalize 链路回收到 execution context,主循环不再手工拼接 tracked execution 与终态收尾
|
||||
- 在 [runtime_turn.rs](../../src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs) 完成第十一刀代码收口:把 `skill_tool_session_access` 与 `cancel_token` 收口为统一 session scope,异常路径与正常路径共享同一套会话级清理边界
|
||||
- 在 [runtime_turn.rs](../../src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs) 完成第十二刀代码收口:把 `runtime_snapshot -> runtime_projection_snapshot -> turn_state -> turn_input_envelope` 统一收口为 `build_runtime_turn_artifacts(...)` helper,主循环不再散落读取 snapshot、派生 thread/turn 和构建 turn 输入诊断
|
||||
- 在 [runtime_turn.rs](../../src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs) 完成第十三刀代码收口:把 `service_skill_preload -> prepared execution -> prelude/execute handoff` 统一收口为 `prepare_runtime_turn_execution(...)` 与 `RuntimeTurnPreparedExecution`,submit 主路径已进一步压成 `prepare -> execute` 两个清晰阶段
|
||||
- 在 [runtime_turn.rs](../../src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs) 完成第十四刀代码收口:把 `provider_continuation -> workspace sandbox apply -> tracker/session scope bootstrap` 统一收口为 `prepare_runtime_turn_submit_bootstrap(...)`,submit 主路径不再内联铺开这组前置副作用和运行期参数拼装
|
||||
- 在 [runtime_turn.rs](../../src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs) 完成第十五刀代码收口:把 `request.provider_config -> configure_provider/configure_provider_from_pool -> persist_session_provider_routing` 统一收口为 `apply_runtime_turn_provider_config(...)`,submit 主路径不再内联铺开 provider apply 分支
|
||||
- 在 [runtime_turn.rs](../../src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs) 完成第十六刀代码收口:把 `resolved_prompt -> prompt augmentation -> requested/effective strategy persist` 统一收口为 `prepare_runtime_turn_prompt_strategy(...)`,submit 主路径不再内联铺开 prompt/strategy 组装与持久化
|
||||
- 在 [runtime_turn.rs](../../src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs) 完成第十七刀代码收口:把 `tool surface metadata -> MCP warmup -> skill launch metadata normalize -> turn_input_builder seed` 统一收口为 `prepare_runtime_turn_request(...)`,submit 主路径不再内联铺开 request prepare 分支
|
||||
- 在 [runtime_turn.rs](../../src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs) 完成第十八刀代码收口:把 `runtime_chat_mode -> web_search/request_tool_policy -> execution_profile` 统一收口为 `prepare_runtime_turn_policy(...)`,submit 主路径不再内联铺开 policy resolve 逻辑
|
||||
- 在 [runtime_turn.rs](../../src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs) 完成第十九刀代码收口:把 `auto_continue -> workspace repair warning -> session_state_snapshot/working_dir update -> session_recent_runtime_context` 统一收口为 `prepare_runtime_turn_session(...)`,submit 主路径的 session 级前置准备已压成单一 helper,并顺手移除新增的 `runtime_chat_mode` 未使用 warning
|
||||
- 在 [runtime_turn.rs](../../src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs) 完成第二十刀代码收口:把 `session/policy/request/prompt_strategy -> provider apply/bootstrap` 统一收口为 `prepare_runtime_turn_submit_preparation(...)`,submit 主路径已从多段 helper 串联提升为单一 preflight 准备块
|
||||
- 在 [runtime_turn.rs](../../src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs) 完成第二十一刀代码收口:把 `sync_browser_assist_runtime_hint -> prepare_runtime_turn_execution -> agent guard -> emit_prelude_and_execute` 统一收口为 `execute_runtime_turn_submit(...)`,submit 主路径当前已压成 `prepare -> scoped execute` 两段主骨架
|
||||
- 在 [runtime_turn.rs](../../src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs) 完成第二十二刀代码收口:把 `provider_config resolve -> harness metadata normalize -> workspace resolve/turn id/runtime_config` 统一收口为 `prepare_runtime_turn_ingress_context(...)`,入口上下文边界不再散落在主路径中
|
||||
- 在 [runtime_turn.rs](../../src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs) 完成第二十三刀代码收口:把 `agent init/session_store check -> support tools register` 统一收口为 `prepare_runtime_turn_entry(...)`,主路径的运行时入口准备已形成独立阶段
|
||||
- 在 [runtime_turn.rs](../../src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs) 完成第二十四刀代码收口:把 `model_skill_tool_access derive -> with_runtime_turn_session_scope -> execute_runtime_turn_submit` 统一收口为 `execute_runtime_turn_with_session_scope(...)`,主路径不再拆包又重组 `submit_preparation`
|
||||
- 在 [runtime_turn.rs](../../src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs) 完成第二十五刀代码收口:把 `execute_aster_chat_request(...)` 的 `entry -> ingress -> submit_preparation -> session_scope_execute` 外层编排统一收口为 `execute_runtime_turn_pipeline(...)`,`M1` 主路径现在只保留日志入口与单次 pipeline 调用
|
||||
- 为解除 `runtime_turn` 定向测试的编译阻塞,在 [task_list_tools.rs](../../src-tauri/crates/aster-rust/crates/aster/src/tools/task_list_tools.rs) 把两处 `TeamSessionState / TeamMembershipState` 的团队状态恢复改为复用现有 `from_session(...)` 包装,避免当前工作区里 trait 静态方法解析差异继续卡住主线校验
|
||||
- 完成最小 Rust 定向校验:`cargo test --manifest-path "src-tauri/Cargo.toml" -p lime --lib commands::aster_agent_cmd::runtime_turn::tests:: -- --nocapture`,结果为 `35 passed`
|
||||
- 在工作区存在其他 Cargo 并行任务时,补充使用独立 `CARGO_TARGET_DIR=.codex-target-runtime-turn` 重跑同一条 `runtime_turn` 定向测试,结果仍为 `35 passed`
|
||||
- 在完成第十六到第十九刀后,补充使用独立 `CARGO_TARGET_DIR=.codex-target-runtime-turn-2` 重跑同一条 `runtime_turn` 定向测试,结果更新为 `37 passed`;当前仅剩 [workspace_tools.rs](../../src-tauri/src/commands/aster_agent_cmd/tool_runtime/workspace_tools.rs) 的既有 `format_output` dead_code warning
|
||||
- 在完成第二十到第二十一刀后,再次使用独立 `CARGO_TARGET_DIR=.codex-target-runtime-turn-2` 重跑同一条 `runtime_turn` 定向测试,结果保持 `37 passed`;第 20-21 刀未引入新的 runtime_turn warning
|
||||
- 在完成第二十二到第二十四刀后,再次使用独立 `CARGO_TARGET_DIR=.codex-target-runtime-turn-2` 重跑同一条 `runtime_turn` 定向测试,结果仍为 `37 passed`;新增的 `session_id` 未使用 warning 已收掉,当前只剩 [workspace_tools.rs](../../src-tauri/src/commands/aster_agent_cmd/tool_runtime/workspace_tools.rs) 的既有 `format_output` dead_code warning
|
||||
- 在完成第二十五刀后,再次使用独立 `CARGO_TARGET_DIR=.codex-target-runtime-turn-2` 重跑同一条 `runtime_turn` 定向测试,结果仍为 `37 passed`;新增的 `unused_mut` warning 已收掉,当前只剩 [workspace_tools.rs](../../src-tauri/src/commands/aster_agent_cmd/tool_runtime/workspace_tools.rs) 的既有 `format_output` dead_code warning
|
||||
- 完成文档新鲜度校验:`npm run harness:doc-freshness`,结果为 `clean`
|
||||
- 将新入口回挂到 `docs/README.md`、`docs/aiprompts/README.md`、`docs/exec-plans/README.md`
|
||||
- 将 Query Loop 差距从“口头判断”改为可追踪状态,并更新技术债与总计划进度
|
||||
- 继续对齐参考运行时 `src/tools` current surface:
|
||||
- [plan_mode_tool.rs](../../src-tauri/crates/aster-rust/crates/aster/src/tools/plan_mode_tool.rs) 的 `ExitPlanMode` 现已在 team teammate 场景通过 `send_input` 回调真实向 team lead 投递 `plan_approval_request`,输出补齐 `awaitingLeaderApproval / requestId`
|
||||
- [task_list_tools.rs](../../src-tauri/crates/aster-rust/crates/aster/src/tools/task_list_tools.rs) 的 `TaskList` 现已对齐参考语义,自动从 `blockedBy` 里过滤已 `completed` 的 blocker,避免后续任务误判仍被阻塞
|
||||
- [mod.rs](../../src-tauri/crates/aster-rust/crates/aster/src/tools/mod.rs) 已把 agent control 的 `send_input` 回调注入 `ExitPlanMode` 注册路径,保持默认注册与 team tools 注册条件不回退
|
||||
- [task_output_tool.rs](../../src-tauri/crates/aster-rust/crates/aster/src/tools/task_output_tool.rs) 已继续向参考 `TaskOutputTool` 收口:`task_type` 改为 `local_bash`,移除 payload 中额外的 `outputFile` 字段,补齐 `block=false -> not_ready` 与 `block=true + timeout -> timeout` 的回归测试,并去掉空输出占位文案
|
||||
- [task_stop_tool.rs](../../src-tauri/crates/aster-rust/crates/aster/src/tools/task_stop_tool.rs) 已改成参考 `TaskStopTool` 的结构化输出:成功返回 `message / task_id / task_type / command` JSON,`task_type` 改为 `local_bash`,`shell_id` 仅作为兼容别名保留,非运行中任务改成与参考一致的错误语义
|
||||
- [agent_control.rs](../../src-tauri/crates/aster-rust/crates/aster/src/tools/agent_control.rs) 的 `SendMessage` 已补 `plan_approval_response` 权限守卫:只有 team lead 可以发送 approve / reject,并补 teammate 被拒绝、lead 可发送的定向测试
|
||||
- [task_list_tools.rs](../../src-tauri/crates/aster-rust/crates/aster/src/tools/task_list_tools.rs) 已把 `TaskCreate / TaskGet / TaskUpdate` 的 Rust 反序列化层与 schema 一起收紧到 `deny_unknown_fields / additionalProperties: false`,避免多余字段在 current surface 下静默穿透
|
||||
- [task_list_tools.rs](../../src-tauri/crates/aster-rust/crates/aster/src/tools/task_list_tools.rs) 已把 team task board 的读写边界收口到 lead session:同一 team 下的 `TaskCreate / TaskList / TaskGet / TaskUpdate` 现在统一从 lead session 的 `task_list.v1` 快照读取并回写,跨不同 tool registry / 不同 teammate session 也能看到同一份共享任务板,不再依赖单个 agent 进程内的内存缓存
|
||||
- [task_list_tools.rs](../../src-tauri/crates/aster-rust/crates/aster/src/tools/task_list_tools.rs) 已把 `TaskUpdate` 的 `verificationNudgeNeeded` 再向参考语义收紧:subagent / teammate 完成最后一个任务时不再误触发“主线程收尾验证提醒”,只保留在非 `SubAgent` session 的完成收尾场景下评估该提示
|
||||
- [hooks/types.rs](../../src-tauri/crates/aster-rust/crates/aster/src/hooks/types.rs)、[hooks/loader.rs](../../src-tauri/crates/aster-rust/crates/aster/src/hooks/loader.rs) 与 [task_list_tools.rs](../../src-tauri/crates/aster-rust/crates/aster/src/tools/task_list_tools.rs) 已把 `TaskCreated / TaskCompleted` 正式接入当前 hooks 体系:hook 载荷补齐 `task_id / task_subject / task_description / teammate_name / team_name`,`TaskCreate` 现在会在持久化后执行 `TaskCreated` hook 并在阻塞时回滚任务,`TaskUpdate(status=completed)` 会在真正完成前执行 `TaskCompleted` hook,并按参考语义返回 `success=false`
|
||||
- [task_list_tools.rs](../../src-tauri/crates/aster-rust/crates/aster/src/tools/task_list_tools.rs) 已把 `TaskUpdate.owner` 变更继续向参考 `TaskUpdateTool` 收口:当 owner 变更为当前 team 中可解析的成员时,会通过现有 `UserMessageManager` 给目标 session 追加一条 agent-only 的 `task_assignment` JSON 消息,替代参考仓库的 file-mailbox 路径,同时保持“同 owner 重复写入不重复通知”的语义
|
||||
- [team_tools.rs](../../src-tauri/crates/aster-rust/crates/aster/src/tools/team_tools.rs) 已把 `TeamCreate / TeamDelete / ListPeers` 的输入反序列化层统一收紧为 `deny_unknown_fields`,保持 team surface 与参考的严格对象语义一致
|
||||
- [team_tools.rs](../../src-tauri/crates/aster-rust/crates/aster/src/tools/team_tools.rs) 已继续向参考 `TeamDeleteTool` / `ListPeers` 收口:`TeamDelete` 改为只拦截“仍可达且仍活跃”的 non-lead 成员,idle teammate 不再误阻塞删除,并在成功清理 team 时同步清掉 reachable idle teammate 的 `TeamMembershipState`;`ListPeers` 现会过滤缺失有效 membership 的 stale member,只暴露当前仍可直接通信的 peer
|
||||
- [agent_control.rs](../../src-tauri/crates/aster-rust/crates/aster/src/tools/agent_control.rs) 的 `SendMessage` 现已继续向参考 `SendMessageTool` 收口:纯字符串消息缺少 `summary` 时会在 dispatch 前直接报 `summary is required when message is a string`,并补齐命名子 agent 路由场景的回归测试;`shutdown_response` 的 target / reject-reason 参数错误文案也已对齐参考英文语义
|
||||
- [team_tools.rs](../../src-tauri/crates/aster-rust/crates/aster/src/tools/team_tools.rs) 的 `TeamCreate` 现已补齐参考默认值语义:未显式传 `agent_type` 时,team lead 成员默认记录为 `team-lead`,避免 `ListPeers` / team 状态里出现空 lead role
|
||||
- [team_tools.rs](../../src-tauri/crates/aster-rust/crates/aster/src/tools/team_tools.rs) 的 `TeamCreate / ListPeers` 输出面已继续向参考收口:对外返回的 `lead_agent_id` 与 `peers[].agent_id` 改为稳定的 `name@team` display id,内部 team session / membership 仍保持当前 runtime 所需的真实 session id 路由,`sendTo` 继续作为消息发送主入口
|
||||
- [team_tools.rs](../../src-tauri/crates/aster-rust/crates/aster/src/tools/team_tools.rs) 的 `TeamCreate` 已继续向参考 `TeamCreateTool` 收口:`team_file_path` 现改为稳定的 `<config>/teams/<sanitize(team_name)>/config.json` 形态,不再返回 `session://...` 伪路径
|
||||
- [team_tools.rs](../../src-tauri/crates/aster-rust/crates/aster/src/tools/team_tools.rs) 的 `TeamCreate` 重名策略已从 `name-2/name-3` 递增后缀改成随机三段式 slug,行为更接近参考仓库的 `generateWordSlug()`
|
||||
- [agent_control.rs](../../src-tauri/crates/aster-rust/crates/aster/src/tools/agent_control.rs) 的 `SendMessage` 已补齐当前 team display id 路由:当 `to` 传入 `ListPeers` 暴露的 `name@team` 标识时,会先规范化成当前 team 成员名再进入既有路由与权限校验,因此 `team-lead@alpha` 这类目标现在可用于普通消息和 `shutdown_response`
|
||||
- [agent_control.rs](../../src-tauri/crates/aster-rust/crates/aster/src/tools/agent_control.rs) 的 `Agent / SendMessage` 已继续把 strict schema 收口到 Rust 反序列化层:`AgentToolInput` 与 `SendMessageToolInput` 现已补上 `deny_unknown_fields`,多余字段会在 dispatch 前直接被拒绝,不再绕过 JSON schema 静默穿透
|
||||
- [remote_trigger_tool.rs](../../src-tauri/crates/aster-rust/crates/aster/src/tools/remote_trigger_tool.rs) 的 `RemoteTrigger` 也已补上 `deny_unknown_fields`,让 `additionalProperties: false` 与实际解析行为保持一致,并补齐未知字段回归测试
|
||||
- [send_user_message_tool.rs](../../src-tauri/crates/aster-rust/crates/aster/src/tools/send_user_message_tool.rs) 已继续向参考 `BriefTool` 的附件校验语义收口:输入反序列化改为 `deny_unknown_fields`,附件路径现支持 `~` 展开,缺失文件、权限拒绝和非 regular file 会分别返回与参考一致的细粒度错误文案,同时保留前端当前依赖的 `"Message delivered to user."` 摘要输出不回退
|
||||
- [workflow_tool.rs](../../src-tauri/crates/aster-rust/crates/aster/src/tools/workflow_tool.rs)、[config_tool.rs](../../src-tauri/crates/aster-rust/crates/aster/src/tools/config_tool.rs)、[powershell_tool.rs](../../src-tauri/crates/aster-rust/crates/aster/src/tools/powershell_tool.rs)、[worktree_tools.rs](../../src-tauri/crates/aster-rust/crates/aster/src/tools/worktree_tools.rs)、[cron_tools.rs](../../src-tauri/crates/aster-rust/crates/aster/src/tools/cron_tools.rs) 与 [sleep_tool.rs](../../src-tauri/crates/aster-rust/crates/aster/src/tools/sleep_tool.rs) 已完成同一批 strict-schema 收口:这些工具凡是 schema 已声明 `additionalProperties: false` 的 current surface,现已统一在 Rust 输入结构上补齐 `deny_unknown_fields`,避免多余字段继续从反序列化层静默穿透
|
||||
- [config_tool.rs](../../src-tauri/crates/aster-rust/crates/aster/src/tools/config_tool.rs) 已继续向参考 `ConfigTool` / `supportedSettings.ts` 收口:工具面从仅支持 `model` 与 `permissions.defaultMode` 扩到当前参考仓库的全部非 feature-gated setting(`theme / editorMode / verbose / preferredNotifChannel / autoCompactEnabled / autoMemoryEnabled / autoDreamEnabled / fileCheckpointingEnabled / showTurnDuration / terminalProgressBarEnabled / taskTrackingEnabled / alwaysThinkingEnabled / language / teammateMode`),同时把布尔值字符串写入、枚举项校验和动态 description 统一收口到受控 setting 表
|
||||
- [config_tool.rs](../../src-tauri/crates/aster-rust/crates/aster/src/tools/config_tool.rs) 的 `permissions.defaultMode` 已补齐当前 Lime runtime 别名语义:除参考当前可映射的 `default / acceptEdits / auto` 外,现也显式支持 `approve / chat` 两个 Lime 运行时模式,并对参考里的 `plan / dontAsk` 返回“当前 runtime 尚未实现”的结构化失败,避免继续把不等价模式静默映射错位
|
||||
- [plan_mode_tool.rs](../../src-tauri/crates/aster-rust/crates/aster/src/tools/plan_mode_tool.rs) 已重新确认 current 边界:`ExitPlanMode` 顶层输入仍刻意保持 `additionalProperties: true`,因此这轮不再把顶层反序列化收紧;只保留 `allowedPrompts` item 级 strict object,不让计划模式工具偏离当前 surface
|
||||
- [ask.rs](../../src-tauri/crates/aster-rust/crates/aster/src/tools/ask.rs) 已把 `AskUserQuestion` 继续向参考 strict object surface 收口:顶层 `questions`、question item、option item 的 schema 全部显式补齐 `additionalProperties: false`,Rust 输入结构同步补上 `deny_unknown_fields`;同时把 `AskOptionInput` 改成手写反序列化,避免 `untagged enum` 吞掉 option 子项的未知字段错误,当前 `extra` 这类脏字段会稳定返回 `unknown field` 失败而不再被泛化成匹配失败
|
||||
- [worktree_tools.rs](../../src-tauri/crates/aster-rust/crates/aster/src/tools/worktree_tools.rs) 已把 `EnterWorktree / ExitWorktree` 的输出面继续向参考收口:`EnterWorktree` 成功消息补齐“退出 session 也会提示处理 worktree”的语义;`ExitWorktree` 的成功输出去掉当前 runtime 实际不会返回的 `noop` 字段,避免继续暴露参考 current surface 外的冗余字段
|
||||
- [tool_search_tool.rs](../../src-tauri/crates/aster-rust/crates/aster/src/tools/tool_search_tool.rs) 已把 `ToolSearch` 的输入面继续向参考收口:Rust 输入结构补上 `deny_unknown_fields`,schema 顶层补齐 `additionalProperties: false`;同时在“无命中结果”时补回参考 `pending_mcp_servers` 输出字段,并保留当前 Lime UI 仍在消费的 `notes` 字段不回退
|
||||
- [extension_manager.rs](../../src-tauri/crates/aster-rust/crates/aster/src/agents/extension_manager.rs) 已补最小 pending 扩展状态:`ExtensionManager` 现在会在 `add_extension(...)` 连接期间维护一个排序后的 `pending_extensions` 集合,供 `ToolSearch` 在扩展尚未连上时暴露 `pending_mcp_servers`,同时对并发重复启用同名扩展做去重,避免重复连接
|
||||
- [toolSearchResultSummary.ts](../../src/components/agent/chat/utils/toolSearchResultSummary.ts) 与 [ToolSearchSummaryPanel.tsx](../../src/components/agent/chat/components/ToolSearchSummaryPanel.tsx) 已完成 `pending_mcp_servers` 的最小前端兼容:summary parser 会识别后端新增字段,面板会在无命中但 MCP 仍在连接时显示“以下 MCP 服务仍在连接中”,避免这次 `ToolSearch` 对齐只停留在后端埋字段
|
||||
- [agent.rs](../../src-tauri/crates/aster-rust/crates/aster/src/agents/agent.rs) 已把两个测试 helper 的 `CallToolRequestParam.name` 显式改成 `Cow::Owned(...)`,用于消除 lib test 编译时的 `'static` 推断歧义,避免它继续阻塞 tools 定向校验
|
||||
|
||||
### 本轮补充校验
|
||||
|
||||
- `npx vitest run "src/components/agent/chat/components/MessageList.test.tsx" "src/components/agent/chat/components/AgentThreadTimeline.test.tsx"`
|
||||
- `cargo fmt --manifest-path "src-tauri/Cargo.toml" --all`
|
||||
- `env CARGO_TARGET_DIR="/Users/coso/Documents/dev/ai/aiclientproxy/lime/.codex-target-runtime-turn" cargo test --manifest-path "src-tauri/Cargo.toml" -p lime --lib commands::aster_agent_cmd::runtime_turn::tests:: -- --nocapture`
|
||||
- `env CARGO_TARGET_DIR="/Users/coso/Documents/dev/ai/aiclientproxy/lime/.codex-target-runtime-turn-2" cargo test --manifest-path "src-tauri/Cargo.toml" -p lime --lib commands::aster_agent_cmd::runtime_turn::tests:: -- --nocapture`
|
||||
- `npm run harness:doc-freshness`
|
||||
- `env CARGO_TARGET_DIR="/Users/coso/Documents/dev/ai/aiclientproxy/lime/.codex-target" cargo test --manifest-path "src-tauri/crates/aster-rust/crates/aster/Cargo.toml" plan_mode_tool::tests:: --lib -- --nocapture`
|
||||
- `env CARGO_TARGET_DIR="/Users/coso/Documents/dev/ai/aiclientproxy/lime/.codex-target" cargo test --manifest-path "src-tauri/crates/aster-rust/crates/aster/Cargo.toml" task_list_tools::tests:: --lib -- --nocapture`
|
||||
- `env CARGO_TARGET_DIR="/Users/coso/Documents/dev/ai/aiclientproxy/lime/.codex-target" cargo test --manifest-path "src-tauri/crates/aster-rust/crates/aster/Cargo.toml" tools::tests::test_register --lib -- --nocapture`
|
||||
- `env CARGO_TARGET_DIR="/Users/coso/Documents/dev/ai/aiclientproxy/lime/.codex-target" cargo test --manifest-path "src-tauri/crates/aster-rust/crates/aster/Cargo.toml" task_output_tool::tests:: --lib -- --nocapture`
|
||||
- `env CARGO_TARGET_DIR="/Users/coso/Documents/dev/ai/aiclientproxy/lime/.codex-target" cargo test --manifest-path "src-tauri/crates/aster-rust/crates/aster/Cargo.toml" task_stop_tool::tests:: --lib -- --nocapture`
|
||||
- `env CARGO_TARGET_DIR="/Users/coso/Documents/dev/ai/aiclientproxy/lime/.codex-target" cargo test --manifest-path "src-tauri/crates/aster-rust/crates/aster/Cargo.toml" agent_control::tests:: --lib -- --nocapture`
|
||||
- `env CARGO_TARGET_DIR="/Users/coso/Documents/dev/ai/aiclientproxy/lime/.codex-target" cargo test --manifest-path "src-tauri/crates/aster-rust/crates/aster/Cargo.toml" remote_trigger_tool::tests:: --lib -- --nocapture`
|
||||
- `env CARGO_TARGET_DIR="/Users/coso/Documents/dev/ai/aiclientproxy/lime/.codex-target" cargo test --manifest-path "src-tauri/crates/aster-rust/crates/aster/Cargo.toml" team_tools::tests:: --lib -- --nocapture`
|
||||
- `env CARGO_TARGET_DIR="/Users/coso/Documents/dev/ai/aiclientproxy/lime/.codex-target" cargo test --manifest-path "src-tauri/crates/aster-rust/crates/aster/Cargo.toml" send_user_message_tool::tests:: --lib -- --nocapture`
|
||||
- `env CARGO_TARGET_DIR="/Users/coso/Documents/dev/ai/aiclientproxy/lime/.codex-target" cargo test --manifest-path "src-tauri/crates/aster-rust/crates/aster/Cargo.toml" config_tool::tests:: --lib -- --nocapture`
|
||||
- `env CARGO_TARGET_DIR="/Users/coso/Documents/dev/ai/aiclientproxy/lime/.codex-target" cargo test --manifest-path "src-tauri/crates/aster-rust/crates/aster/Cargo.toml" ask::tests:: --lib -- --nocapture`
|
||||
- `env CARGO_TARGET_DIR="/Users/coso/Documents/dev/ai/aiclientproxy/lime/.codex-target" cargo test --manifest-path "src-tauri/crates/aster-rust/crates/aster/Cargo.toml" tool_search_tool::tests:: --lib -- --nocapture`
|
||||
- `env CARGO_TARGET_DIR="/Users/coso/Documents/dev/ai/aiclientproxy/lime/.codex-target" cargo test --manifest-path "src-tauri/crates/aster-rust/crates/aster/Cargo.toml" extension_manager::tests:: --lib -- --nocapture`
|
||||
- `npx vitest run "src/components/agent/chat/utils/toolSearchResultSummary.test.ts" "src/components/agent/chat/components/ToolSearchSummaryPanel.test.tsx"`
|
||||
- `env CARGO_TARGET_DIR="/Users/coso/Documents/dev/ai/aiclientproxy/lime/.codex-target" cargo test --manifest-path "src-tauri/crates/aster-rust/crates/aster/Cargo.toml" worktree_tools::tests:: --lib -- --nocapture`
|
||||
- `env CARGO_TARGET_DIR="/Users/coso/Documents/dev/ai/aiclientproxy/lime/.codex-target" cargo test --manifest-path "src-tauri/crates/aster-rust/crates/aster/Cargo.toml" cron_tools::tests:: --lib -- --nocapture`
|
||||
- `env CARGO_TARGET_DIR="/Users/coso/Documents/dev/ai/aiclientproxy/lime/.codex-target" cargo test --manifest-path "src-tauri/crates/aster-rust/crates/aster/Cargo.toml" sleep_tool::tests:: --lib -- --nocapture`
|
||||
- `env CARGO_TARGET_DIR="/Users/coso/Documents/dev/ai/aiclientproxy/lime/.codex-target" cargo test --manifest-path "src-tauri/crates/aster-rust/crates/aster/Cargo.toml" powershell_tool::tests:: --lib -- --nocapture`
|
||||
- `env CARGO_TARGET_DIR="/Users/coso/Documents/dev/ai/aiclientproxy/lime/.codex-target" cargo test --manifest-path "src-tauri/crates/aster-rust/crates/aster/Cargo.toml" workflow_tool::tests:: --lib -- --nocapture`
|
||||
- `env CARGO_TARGET_DIR="/Users/coso/Documents/dev/ai/aiclientproxy/lime/.codex-target" cargo test --manifest-path "src-tauri/crates/aster-rust/crates/aster/Cargo.toml" hooks::tests:: --lib -- --nocapture`
|
||||
- 建立 State / History / Telemetry current 文档 [state-history-telemetry.md](../aiprompts/state-history-telemetry.md),把 `session / thread / turn / request / evidence / history` 收口成单一状态地图
|
||||
- 将 `agent_sessions / agent_messages -> SessionDetail -> AgentRuntimeThreadReadModel -> RequestLog 关联键 -> handoff/evidence/replay/analysis/review -> history-record/trend/cleanup/dashboard -> HarnessStatusPanel / AgentThreadReliabilityPanel` 明确归到 state/history/telemetry current 主链
|
||||
- 将 [lime-aster-codex-state-model-implementation-plan.md](../roadmap/lime-aster-codex-state-model-implementation-plan.md)、`docs/roadmap/reliability/*` 与 [telemetry_cmd.rs](../../src-tauri/src/commands/telemetry_cmd.rs) 明确归到 compat,并把 cleanup 报表里残留的 `requestTelemetry:unlinked` 旧语义标记为 deprecated
|
||||
- 将 state/history/telemetry 入口同步回 [docs/README.md](../README.md)、[docs/aiprompts/README.md](../aiprompts/README.md)、[docs/aiprompts/overview.md](../aiprompts/overview.md) 与 [AGENTS.md](../../AGENTS.md),仓库导航不再继续把旧状态模型方案或 reliability 计划当成 current 主线
|
||||
- 再次执行 `npm run harness:doc-freshness` 并通过(`clean`)
|
||||
- 新增 [docs/roadmap/reliability/README.md](../roadmap/reliability/README.md),把 reliability 目录补成明确的 compat 入口,不再让分阶段计划文件继续承担 current 导航职责
|
||||
- 在 [generated-slop-report-core.mjs](../../scripts/lib/generated-slop-report-core.mjs) 将旧 `requestTelemetry:unlinked` 语义折叠为 `known_gap` 兼容别名,并补定向测试守卫,避免 cleanup/dashboard 继续把旧历史样本当成现役 observability 状态
|
||||
- 在 `docs/roadmap/reliability/*.md` 全部补上 compat 提示,正文开头统一先回挂 [state-history-telemetry.md](../aiprompts/state-history-telemetry.md),避免专项正文继续被误读成 current 主入口
|
||||
- 在 `docs/roadmap/reliability/*.md` 进一步压缩顶部导航:把重复的上位文档长列表统一收口为 `README + current 主链 + PR 对应映射`,减少专项正文重复解释
|
||||
- 将整组 `docs/roadmap/reliability/*` 进一步压缩为 compat 历史摘要档案:只保留落地结果、current 映射与延后增强项,重复的目标/问题/范围/实施清单正文统一回退到仓库历史
|
||||
- 将 [lime-aster-codex-state-model-implementation-plan.md](../roadmap/lime-aster-codex-state-model-implementation-plan.md) 进一步压缩为 compat 历史摘要档案:只保留状态边界判断、current 映射与延后增强项,不再把它当 current 实施入口
|
||||
- 在 [lime-aster-codex-alignment-roadmap.md](../roadmap/lime-aster-codex-alignment-roadmap.md) 收紧顶部导航:当前入口统一回挂到 [query-loop.md](../aiprompts/query-loop.md)、[state-history-telemetry.md](../aiprompts/state-history-telemetry.md) 与 [upstream-runtime-alignment-plan.md](./upstream-runtime-alignment-plan.md)
|
||||
- 将 [lime-aster-codex-alignment-roadmap.md](../roadmap/lime-aster-codex-alignment-roadmap.md) 进一步压缩为 compat 历史摘要档案:只保留阶段映射、历史判断与 current 回看入口,不再继续承载长篇阶段任务与验证流水
|
||||
- 将 [lime-conversation-execution-efficiency-roadmap.md](../roadmap/lime-conversation-execution-efficiency-roadmap.md) 进一步压缩为 compat 历史摘要档案:只保留历史主题、current 映射与延后方向,不再继续承载运行时边界总入口职责
|
||||
- 将 `docs/roadmap/artifacts/*` 中仍把旧执行效率路线图当 current 运行时依据的说明,统一改回 `query-loop / task-agent-taxonomy / state-history-telemetry / upstream-runtime-alignment-plan`
|
||||
- 在 [telemetry_cmd.rs](../../src-tauri/src/commands/telemetry_cmd.rs) 收紧命令注释:这些命令只暴露原始 `RequestLog` 与聚合统计,不负责定义 session/thread current 状态真相
|
||||
- 继续对齐 team / task tool surface:
|
||||
- 在 [task_output_tool.rs](../../src-tauri/crates/aster-rust/crates/aster/src/tools/task_output_tool.rs) 为 `block` 补齐参考仓库 `semanticBoolean` 兼容,允许模型把布尔值误写成字符串 `"true"` / `"false"` 时仍能按当前 `TaskOutput` 主链执行
|
||||
- 在 [tool_search_tool.rs](../../src-tauri/crates/aster-rust/crates/aster/src/tools/tool_search_tool.rs) 为 `TaskCreate / TaskGet / TaskList / TaskUpdate / TaskOutput / TaskStop / TeamCreate / TeamDelete / ListPeers` 补齐参考仓库常见别名与意图词,减少 `kill shell`、`agent output`、`ListPeersTool`、`swarm peers` 这类上游表述在 Lime 中搜不到当前工具的漂移
|
||||
- 在 [catalog.rs](../../src-tauri/src/agent_tools/catalog.rs) 补齐 `KillShell -> TaskStop` 与 `ListPeersTool -> ListPeers` 的 reference alias 规范化,避免参考仓库工具名在 Lime 运行时目录册中掉映射
|
||||
- 在 [agent_control.rs](../../src-tauri/crates/aster-rust/crates/aster/src/tools/agent_control.rs) 与 [team_tools.rs](../../src-tauri/crates/aster-rust/crates/aster/src/tools/team_tools.rs) 显式收口 peer surface 差异:`SendMessage` 现会识别参考仓库 `uds:` / `bridge:` peer address,并返回“当前 Lime runtime 未实现 cross-session peer messaging”的结构化失败,不再误把这类 target 当作普通 `agent_id` 投递;`ListPeers` 描述同步明确当前只枚举 team 内可达 peers
|
||||
- 在 [base.rs](../../src-tauri/crates/aster-rust/crates/aster/src/tools/base.rs)、[registry.rs](../../src-tauri/crates/aster-rust/crates/aster/src/tools/registry.rs)、[task_list_tools.rs](../../src-tauri/crates/aster-rust/crates/aster/src/tools/task_list_tools.rs)、[task_output_tool.rs](../../src-tauri/crates/aster-rust/crates/aster/src/tools/task_output_tool.rs)、[task_stop_tool.rs](../../src-tauri/crates/aster-rust/crates/aster/src/tools/task_stop_tool.rs)、[team_tools.rs](../../src-tauri/crates/aster-rust/crates/aster/src/tools/team_tools.rs) 与 [agent_control.rs](../../src-tauri/crates/aster-rust/crates/aster/src/tools/agent_control.rs) 补齐执行层 alias 兼容:`ToolRegistry` 现在会把 `BashTool / ConfigTool / FileReadTool / FileWriteTool / FileEditTool / EnterPlanModeTool / ExitPlanModeTool / EnterWorktreeTool / ExitWorktreeTool / GlobTool / GrepTool / LSPTool / NotebookEditTool / PowerShellTool / BriefTool / SkillTool / SleepTool / WebFetchTool / WebSearchTool`,以及 `TaskCreateTool / TaskListTool / TaskGetTool / TaskUpdateTool / TaskOutputTool / AgentOutputTool / BashOutputTool / TaskStopTool / KillShell / SendMessageTool / SendInput / TeamCreateTool / TeamDeleteTool / ListPeersTool` 真实解析到 current native tools,不再停留在“目录能搜到、运行时却找不到”的半对齐状态
|
||||
- 继续对齐 Config current surface:
|
||||
- 在 [config_tool.rs](../../src-tauri/crates/aster-rust/crates/aster/src/tools/config_tool.rs) 把 `classifierPermissionsEnabled / voiceEnabled / remoteControlAtStartup / taskCompleteNotifEnabled / inputNeededNotifEnabled / agentPushNotifEnabled` 纳入 `ConfigTool` 已知 setting 集合,不再继续落到 `Unknown setting`
|
||||
- 同时将上述 6 个 setting 明确收口为“已知上游表面,但当前 Lime runtime 未实现”的结构化失败,避免把分散存在的语音 / 通知 / autostart / remote 能力误绑定到错误配置键
|
||||
- 进一步收紧 [config_tool.rs](../../src-tauri/crates/aster-rust/crates/aster/src/tools/config_tool.rs) 的 `voiceEnabled / remoteControlAtStartup` unsupported 文案:前者明确卡在“缺少宿主 callback 同步全局语音快捷键副作用”,后者明确 Lime 当前只有 OS auto-launch,并不等同于上游 remote-control-at-startup 语义
|
||||
- 在 [config_tool.rs](../../src-tauri/crates/aster-rust/crates/aster/src/tools/config_tool.rs) 与 [runtime_turn.rs](../../src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs) 接上 `voiceEnabled` 宿主回调:当前 runtime turn 入口会用 Tauri 宿主的 `get_voice_input_config / save_voice_input_config` 回填 `ConfigTool`,因此 `voiceEnabled` 现在会真实读取/更新 `experimental.voice_input.enabled`,并同步触发全局语音快捷键注册/注销副作用;`remoteControlAtStartup` 仍保持 unsupported
|
||||
- 补执行边界校验:
|
||||
- `npx vitest run "scripts/lib/generated-slop-report-core.test.ts"` 通过(`8 passed`)
|
||||
- `npm run harness:cleanup-report:check` 通过(`ok`)
|
||||
- `npm run harness:doc-freshness` 通过(`clean`,含历史摘要压缩后的再次确认)
|
||||
- `env CARGO_TARGET_DIR="/Users/coso/Documents/dev/ai/aiclientproxy/lime/.codex-target" cargo test --manifest-path "src-tauri/crates/aster-rust/crates/aster/Cargo.toml" task_output_tool::tests:: --lib -- --nocapture` 通过(`11 passed`)
|
||||
- `env CARGO_TARGET_DIR="/Users/coso/Documents/dev/ai/aiclientproxy/lime/.codex-target" cargo test --manifest-path "src-tauri/crates/aster-rust/crates/aster/Cargo.toml" tool_search_tool::tests:: --lib -- --nocapture` 通过(`25 passed`)
|
||||
- `env CARGO_TARGET_DIR="/Users/coso/Documents/dev/ai/aiclientproxy/lime/.codex-target" cargo test --manifest-path "src-tauri/Cargo.toml" test_tool_catalog_entry_normalizes_reference_js_tool_names_to_current_surface --lib -- --nocapture` 通过(`1 passed`)
|
||||
- `env CARGO_TARGET_DIR="/Users/coso/Documents/dev/ai/aiclientproxy/lime/.codex-target" cargo test --manifest-path "src-tauri/crates/aster-rust/crates/aster/Cargo.toml" config_tool::tests:: --lib -- --nocapture` 通过(`14 passed`)
|
||||
- `env CARGO_TARGET_DIR="/Users/coso/Documents/dev/ai/aiclientproxy/lime/.codex-target" cargo test --manifest-path "src-tauri/crates/aster-rust/crates/aster/Cargo.toml" test_registry_resolves_native_aliases_during_lookup_and_execution --lib -- --nocapture` 通过(`1 passed`)
|
||||
- `env CARGO_TARGET_DIR="/Users/coso/Documents/dev/ai/aiclientproxy/lime/.codex-target" cargo test --manifest-path "src-tauri/crates/aster-rust/crates/aster/Cargo.toml" test_registry_unregister_clears_native_aliases --lib -- --nocapture` 通过(`1 passed`)
|
||||
- `env CARGO_TARGET_DIR="/Users/coso/Documents/dev/ai/aiclientproxy/lime/.codex-target" cargo test --manifest-path "src-tauri/crates/aster-rust/crates/aster/Cargo.toml" test_register_default_tools --lib -- --nocapture` 通过(`1 passed`)
|
||||
- `env CARGO_TARGET_DIR="/Users/coso/Documents/dev/ai/aiclientproxy/lime/.codex-target" cargo test --manifest-path "src-tauri/crates/aster-rust/crates/aster/Cargo.toml" test_registers_team_tools_when_spawn_and_send_callbacks_exist --lib -- --nocapture` 通过(`1 passed`)
|
||||
- `env CARGO_TARGET_DIR="/Users/coso/Documents/dev/ai/aiclientproxy/lime/.codex-target" cargo test --manifest-path "src-tauri/Cargo.toml" test_normalize_workspace_tool_permission_behavior_auto_mode_allows_warning --lib -- --nocapture` 通过(`1 passed`,确认主 crate 的 `runtime_turn` 编译链已带起)
|
||||
- `env CARGO_TARGET_DIR="/Users/coso/Documents/dev/ai/aiclientproxy/lime/.codex-target" cargo test --manifest-path "src-tauri/crates/aster-rust/crates/aster/Cargo.toml" agent_control::tests:: --lib -- --nocapture` 已启动重跑,但本轮未等到 `aster` test binary 重编译完成;后续需补跑以确认 `uds:` / `bridge:` 受控失败分支未回退现有 `team` / `named child` 路由
|
||||
|
||||
### 本轮继续补齐
|
||||
|
||||
- 继续推进“应用层优先消费统一 runtime tool surface”:
|
||||
- 在 [inventory.rs](../../src-tauri/src/agent_tools/inventory.rs)、[runtime_api.rs](../../src-tauri/src/commands/aster_agent_cmd/command_api/runtime_api.rs)、[types.ts](../../src/lib/api/agentRuntime/types.ts)、[core.ts](../../src/lib/tauri-mock/core.ts) 与 [HarnessStatusPanel.tsx](../../src/components/agent/chat/components/HarnessStatusPanel.tsx) 已补统一 `runtime_tools` 视图后,本轮继续把前端 usage 从“只在工具库存面板里看四张表”往“主界面直接消费实际 runtime tool surface”推进
|
||||
- [useWorkspaceHarnessInventoryRuntime.ts](../../src/components/agent/chat/workspace/useWorkspaceHarnessInventoryRuntime.ts) 现在会在主界面启用时预取工具库存,不再等 Harness 面板展开后才第一次拉取
|
||||
- 新增 [runtimeToolAvailability.ts](../../src/components/agent/chat/utils/runtimeToolAvailability.ts),把 `runtime_tools` 优先、`registry_tools` 兜底的真实 current surface 收口为 `webSearch / subagent(team) / task` 三组 capability 派生
|
||||
- [useWorkspaceInputbarSceneRuntime.tsx](../../src/components/agent/chat/workspace/useWorkspaceInputbarSceneRuntime.tsx)、[AgentRuntimeStrip.tsx](../../src/components/agent/chat/components/AgentRuntimeStrip.tsx)、[WorkspaceConversationScene.tsx](../../src/components/agent/chat/workspace/WorkspaceConversationScene.tsx)、[EmptyState.tsx](../../src/components/agent/chat/components/EmptyState.tsx) 与 [EmptyStateComposerPanel.tsx](../../src/components/agent/chat/components/EmptyStateComposerPanel.tsx) 已开始直接消费这份派生结果:Runtime strip 会显示实际 runtime tool surface 规模与 team/task gap,首页主输入区也会在用户开启 `联网搜索 / 任务拆分` 偏好但 runtime current tools 尚未接通时给出明确提示,不再把静态偏好误显示成“真实可用能力”
|
||||
- 本轮继续补齐测试与 smoke:
|
||||
- 新增 [runtimeToolAvailability.test.ts](../../src/components/agent/chat/utils/runtimeToolAvailability.test.ts),覆盖 `runtime_tools` current surface 派生与开发态 override
|
||||
- 新增 [AgentRuntimeStrip.test.tsx](../../src/components/agent/chat/components/AgentRuntimeStrip.test.tsx) 与 [EmptyState.test.tsx](../../src/components/agent/chat/components/EmptyState.test.tsx) 的 `runtime tool surface` 页级断言,验证 runtime strip 的 team/task gap 与首页空态告警都能真实透传
|
||||
- 在 [HarnessStatusPanel.tsx](../../src/components/agent/chat/components/HarnessStatusPanel.tsx) 新增 `Runtime 能力摘要`,让工具库存面板也直接消费 `deriveRuntimeToolAvailability(...)`,显式展示 `WebSearch / 子任务核心 tools / Team current tools / Task current tools` 的已接通状态或缺口,不再只显示 raw runtime tool list
|
||||
- 在 [HarnessStatusPanel.test.tsx](../../src/components/agent/chat/components/HarnessStatusPanel.test.tsx) 新增 `runtime tool surface` 断言,覆盖 team/task gap 暴露与 current surface 完整接通两种情况
|
||||
- 新增 [agent-runtime-tool-surface-smoke.mjs](../../scripts/agent-runtime-tool-surface-smoke.mjs),并接入 [verify-gui-smoke.mjs](../../scripts/verify-gui-smoke.mjs) 与 [package.json](../../package.json) 的 `smoke:agent-runtime-tool-surface` current smoke 入口,避免这条主线只停留在局部单测
|
||||
- 本轮已执行校验:
|
||||
- `npm run smoke:agent-runtime-tool-surface` 通过
|
||||
- `npm run verify:gui-smoke` 通过(已串联新的 `smoke:agent-runtime-tool-surface`)
|
||||
- Playwright MCP 真实页面续测通过:首页在开发态 override 下可稳定出现 `runtime tool surface` 告警,且 `toggle-web-search` / `toggle-subagent-mode` 可直接驱动真实空态提示
|
||||
|
||||
### 当前判断
|
||||
|
||||
- `M0` 统一排期事实源:已完成
|
||||
- `M1` Query Loop 收口:已完成第二十五刀实现,并继续收口 `TurnInputEnvelope -> SessionConfig` 的 turn context snapshot 分叉、`action_runtime` 辅助恢复链的 turn context 旁路,以及 `compact_session` 控制回合的最小上下文边界;当前主路径已压成 `execute_aster_chat_request -> execute_runtime_turn_pipeline -> entry/ingress/submit_preparation/session_scope_execute`,turn context 的 output schema / auto_compact / request metadata 也已进一步收紧到共享 snapshot helper
|
||||
- `M2` Task / Agent taxonomy 收口:已完成 current taxonomy 文档、索引回挂与分类判断;当前长时执行入口统一按 `agent turn / subagent turn / automation job` 解释,`ExecutionTracker` 只作为统一执行摘要层,`SchedulerService` 只作为 compat 触发壳
|
||||
- `M3` Remote runtime 收口:已完成 current remote 文档、索引回挂与分类判断;当前远程入口统一按 `消息渠道 runtime + 浏览器连接器 / ChromeBridge` 解释,`DevBridge` 与 `OpenClaw` 只作为 compat 支撑,`telegram_remote_cmd` 只作为 deprecated 单通道入口
|
||||
- `M5` State / History / Telemetry 收口:已完成 current 状态地图、索引回挂与分类判断;当前状态链统一按 `SessionDetail -> AgentRuntimeThreadReadModel -> RequestLog 关联键 -> export/history` 解释,旧状态模型方案、reliability 计划、Aster/Codex 联合路线图、旧执行效率路线图与原始 request log 浏览面只作为 compat 附属层,其中 `docs/roadmap/reliability/*`、[lime-aster-codex-state-model-implementation-plan.md](../roadmap/lime-aster-codex-state-model-implementation-plan.md)、[lime-aster-codex-alignment-roadmap.md](../roadmap/lime-aster-codex-alignment-roadmap.md) 与 [lime-conversation-execution-efficiency-roadmap.md](../roadmap/lime-conversation-execution-efficiency-roadmap.md) 已进一步压成历史摘要档案
|
||||
- `M1` 退出判断:已满足“不再需要横跳多份文档才能解释 Lime 主循环”的出口条件,后续默认不再继续微切 `runtime_turn.rs`
|
||||
- `M2` 退出判断:已满足“所有长时执行入口都能归到唯一 taxonomy”的出口条件,后续不再继续把 execution tracker、scheduler、subagent、automation 当作多条平级主线分别排期
|
||||
- `M3` 退出判断:已满足“remote 不再是多个并列产品旁路”的出口条件,后续只允许在 `gateway_channel_*` 与 `browser connector / ChromeBridge` current ingress 上继续长能力
|
||||
- `M4` 退出判断:已满足“`memory_runtime_*` 与 `compact_session` 不再被当成分散能力点看待”的出口条件;后续只允许在 `memory_runtime_*`、`unified_memory_*` 与 `agent_runtime_compact_session` current 边界继续长记忆 / 压缩能力
|
||||
- `M5` 退出判断:已满足“session / thread / turn / request / evidence / history 的读模型叙事收口”的出口条件;后续只允许在 `SessionDetail`、`AgentRuntimeThreadReadModel`、`RequestLog` 关联键与 `agent_runtime_export_*` current 边界继续长能力
|
||||
|
||||
### 下一刀
|
||||
|
||||
- `docs/roadmap/reliability/*`、[lime-aster-codex-state-model-implementation-plan.md](../roadmap/lime-aster-codex-state-model-implementation-plan.md)、[lime-aster-codex-alignment-roadmap.md](../roadmap/lime-aster-codex-alignment-roadmap.md) 与 [lime-conversation-execution-efficiency-roadmap.md](../roadmap/lime-conversation-execution-efficiency-roadmap.md) 的 compat 历史档案化已完成;当前又收口了 `TurnInputEnvelope -> SessionConfig` 的 turn context 分叉、`action_runtime` 辅助恢复链旁路,并显式化了 `compact_session` 控制回合的最小上下文边界;剩余散落在 `persona_cmd` / `theme_context_cmd` 的一次性临时会话配置也已收回专用 helper,零入口旧发送壳已删除,Tauri 命令层 raw execution 也已经固定为 3 处并补了源码扫描守卫。下一刀转向继续盘点 `src-tauri/src` 非命令层与 README/示例面是否还残留会误导实现者的原始执行旁路叙事
|
||||
- 后续若出现 `runtime_turn` 行为回退,再回到 `M1` current 主路径做定点修复,而不是继续常态化微切
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> 状态:提案
|
||||
> 更新时间:2026-03-24
|
||||
> 运行时边界:凡涉及发送边界、runtime metadata、Team 委派、Op/Event 收口,均以 `docs/roadmap/lime-conversation-execution-efficiency-roadmap.md` 为准;本文只细化 Artifact Workbench 架构
|
||||
> 运行时边界:凡涉及发送边界、runtime metadata、Team 委派、Op/Event 收口,均以 `docs/aiprompts/query-loop.md`、`docs/aiprompts/task-agent-taxonomy.md`、`docs/aiprompts/state-history-telemetry.md` 与 `docs/exec-plans/upstream-runtime-alignment-plan.md` 为准;本文只细化 Artifact Workbench 架构
|
||||
> 依赖文档:
|
||||
> - `docs/roadmap/artifacts/roadmap.md`
|
||||
> - `docs/roadmap/artifacts/artifact-document-v1.md`
|
||||
@@ -254,7 +254,7 @@ flowchart TD
|
||||
|
||||
以下时序图只表达产品层与运行时层的职责分工,不单独定义 Lime 当前仓库的 on-wire 字段名、命令名或 metadata 归一化细节。
|
||||
|
||||
这些当前实施细节统一以 `docs/roadmap/lime-conversation-execution-efficiency-roadmap.md` 为准。
|
||||
这些当前实施细节统一以上述 current 入口为准。
|
||||
|
||||
## 5.1 生成时序图
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> 状态:进行中,block renderer 映射、current-first 协议读取、Markdown / HTML / JSON 桌面导出主链已落地,完整导出态仍未完成
|
||||
> 更新时间:2026-03-31
|
||||
> 运行时边界:turn metadata、prompt 组装入口、runtime output schema 注入链以 `docs/roadmap/lime-conversation-execution-efficiency-roadmap.md` 为准;本文只定义 `ArtifactDocument v1` 的产品层协议与校验映射
|
||||
> 运行时边界:turn metadata、prompt 组装入口、runtime output schema 注入链以 `docs/aiprompts/query-loop.md`、`docs/aiprompts/state-history-telemetry.md` 与 `docs/exec-plans/upstream-runtime-alignment-plan.md` 为准;本文只定义 `ArtifactDocument v1` 的产品层协议与校验映射
|
||||
> 依赖文档:`docs/roadmap/artifacts/roadmap.md`
|
||||
> 架构蓝图:`docs/roadmap/artifacts/architecture-blueprint.md`
|
||||
> 分层边界:`docs/roadmap/artifacts/framework-boundary.md`
|
||||
@@ -711,7 +711,7 @@ validator 失败时,不应直接放弃整份文档。
|
||||
- “turn metadata 如何归一化”
|
||||
- “output schema 在哪里注入”
|
||||
|
||||
这些执行层问题仍以 `docs/roadmap/lime-conversation-execution-efficiency-roadmap.md` 为准。
|
||||
这些执行层问题仍以上述 current 入口为准。
|
||||
|
||||
系统提示词应增加以下硬约束:
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> 状态:提案
|
||||
> 更新时间:2026-03-24
|
||||
> 运行时边界:凡涉及发送边界、runtime metadata、Team 委派、Op/Event 收口、状态同步,均以 `docs/roadmap/lime-conversation-execution-efficiency-roadmap.md` 为准;本文只定义长期框架分层原则与远期边界
|
||||
> 运行时边界:凡涉及发送边界、runtime metadata、Team 委派、Op/Event 收口、状态同步,均以 `docs/aiprompts/query-loop.md`、`docs/aiprompts/task-agent-taxonomy.md`、`docs/aiprompts/state-history-telemetry.md` 与 `docs/exec-plans/upstream-runtime-alignment-plan.md` 为准;本文只定义长期框架分层原则与远期边界
|
||||
> 关联文档:
|
||||
> - `docs/roadmap/artifacts/roadmap.md`
|
||||
> - `docs/roadmap/artifacts/architecture-blueprint.md`
|
||||
@@ -264,7 +264,7 @@ Artifact Workbench 要控制的是:
|
||||
|
||||
- 本节描述的是 **Aster 框架层的目标形状参考**
|
||||
- 不是 Lime 当前仓库的直接实施主计划
|
||||
- 如果与 `docs/roadmap/lime-conversation-execution-efficiency-roadmap.md` 的当前迁移顺序、协议收口方式冲突,以后者为准
|
||||
- 如果与上述 current 文档的当前迁移顺序、协议收口方式冲突,以 current 文档为准
|
||||
|
||||
建议在 `aster-rust` 中,把通用 agent runtime 从 `blueprint` 旁边独立出来,而不是继续把所有能力堆进 `blueprint/`。
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> 状态:进行中,P1 / P2 已落地,P3 已闭环,rewrite typed patch 与 current incremental 合同已落地
|
||||
> 更新时间:2026-03-31
|
||||
> 运行时边界:发送边界、runtime metadata、Team 委派、协议瘦身以 `docs/roadmap/lime-conversation-execution-efficiency-roadmap.md` 为准;本文只定义 Artifact 产品层与 Workbench 主线
|
||||
> 运行时边界:发送边界、prompt 组装、turn metadata、Team 委派与协议瘦身以 `docs/aiprompts/query-loop.md`、`docs/aiprompts/task-agent-taxonomy.md`、`docs/aiprompts/state-history-telemetry.md` 与 `docs/exec-plans/upstream-runtime-alignment-plan.md` 为准;本文只定义 Artifact 产品层与 Workbench 主线
|
||||
> 目标:把 Lime 从“能显示文件/画布的聊天工作台”升级为“交付物优先的 Artifact Workbench”,让回复不再只是普通 Markdown,而是可扫描、可编辑、可版本化、可复用的正式产物
|
||||
>
|
||||
> 配套文档:
|
||||
@@ -845,7 +845,7 @@ src-tauri/crates/aster-rust/crates/aster/src/runtime/
|
||||
|
||||
## 12.4 运行时迁移原则
|
||||
|
||||
本节只表达 Artifact 产品侧对 runtime 的依赖顺序,不替代 `docs/roadmap/lime-conversation-execution-efficiency-roadmap.md` 已锁定的 P1 / P2 / P3 / P4 执行顺序。
|
||||
本节只表达 Artifact 产品侧对 runtime 的依赖顺序,不替代上述 current 文档已经锁定的发送边界、Team 委派、状态模型与统一排期。
|
||||
|
||||
1. 先把 `system prompt + output schema + validator` 的控制链建立起来。
|
||||
2. 再把 Stage 1 / Stage 2 生成链升级为标准 turn。
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> 状态:进行中,核心合同已落地,P3 产品闭环已落地,rewrite typed patch 与 current incremental 合同已落地
|
||||
> 更新时间:2026-03-31
|
||||
> 运行时边界:prompt 组装入口、turn metadata 主合同、runtime output schema 注入链以 `docs/roadmap/lime-conversation-execution-efficiency-roadmap.md` 为准;本文只细化 Artifact 相关合同
|
||||
> 运行时边界:prompt 组装入口、turn metadata 主合同、runtime output schema 注入链以 `docs/aiprompts/query-loop.md`、`docs/aiprompts/state-history-telemetry.md` 与 `docs/exec-plans/upstream-runtime-alignment-plan.md` 为准;本文只细化 Artifact 相关合同
|
||||
> 关联文档:
|
||||
> - `docs/roadmap/artifacts/architecture-blueprint.md`
|
||||
> - `docs/roadmap/artifacts/artifact-document-v1.md`
|
||||
@@ -164,7 +164,7 @@ flowchart TD
|
||||
|
||||
这里的 `ArtifactTurnMetadata` 只表达 Artifact 领域的意图模型,不直接等于 Lime 当前仓库的请求 wire format。
|
||||
|
||||
当前实际发送边界、`harness` 结构、metadata 归一化与兼容收口,统一以 `docs/roadmap/lime-conversation-execution-efficiency-roadmap.md` 为准。
|
||||
当前实际发送边界、`harness` 结构、metadata 归一化与兼容收口,统一以上述 current 入口为准。
|
||||
|
||||
建议长期保留以下 Artifact turn intent:
|
||||
|
||||
|
||||
@@ -108,6 +108,7 @@ npm run bridge:health -- --timeout-ms 120000
|
||||
npm run smoke:workspace-ready
|
||||
npm run smoke:browser-runtime
|
||||
npm run smoke:site-adapters
|
||||
npm run smoke:agent-runtime-tool-surface
|
||||
```
|
||||
|
||||
### 运行 Harness eval 摘要
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
- `npm run smoke:workspace-ready`:当前自包含 smoke,覆盖 DevBridge 就绪与默认 workspace 基础链路
|
||||
- `npm run smoke:browser-runtime`:当前自包含 smoke,覆盖 browser runtime 的启动、状态读取、最小动作与审计关联键
|
||||
- `npm run smoke:site-adapters`:当前自包含 smoke,覆盖站点适配器目录状态、列表、推荐与检索主链
|
||||
- `npm run smoke:agent-runtime-tool-surface`:当前自包含 smoke,覆盖 runtime inventory 到主界面提示、Runtime strip 与 HarnessStatusPanel 的 team/task current surface 透传
|
||||
|
||||
### supplement
|
||||
|
||||
@@ -81,6 +82,7 @@ npm run bridge:health -- --timeout-ms 120000
|
||||
1. 首页可加载,主导航可见
|
||||
2. 社媒内容工作流可进入
|
||||
3. 页面交互后控制台不新增关键 error
|
||||
4. runtime inventory、Runtime strip、空态提示与 HarnessStatusPanel 对 team/task current tools 的展示保持一致
|
||||
|
||||
详细点击路径、控制台检查要求、交接格式,以 `docs/aiprompts/playwright-e2e.md` 为准。
|
||||
|
||||
@@ -94,6 +96,7 @@ npm run bridge:health -- --timeout-ms 120000
|
||||
| Workspace 自包含 smoke | `npm run smoke:workspace-ready` | current | 验证 DevBridge、默认 workspace、路径回查链路 |
|
||||
| Browser Runtime smoke | `npm run smoke:browser-runtime` | current | 验证 browser runtime 最短主链与审计关联键 |
|
||||
| Site Adapter smoke | `npm run smoke:site-adapters` | current | 验证站点适配器目录、推荐与检索最短主链 |
|
||||
| Runtime Tool Surface smoke | `npm run smoke:agent-runtime-tool-surface` | current | 验证 runtime inventory 到 EmptyState / Runtime strip / HarnessStatusPanel 的 current surface 透传 |
|
||||
| 校验跨层命令契约 | `npm run test:contracts` | current | 检查前端命令、Rust 注册、catalog、mock 集合漂移 |
|
||||
| 浏览器续测细则 | `docs/aiprompts/playwright-e2e.md` | current | Playwright MCP 唯一详细事实源 |
|
||||
| 专项 bridge 排障 | `npm run bridge:e2e` | supplement | 适合排障,不是统一门禁 |
|
||||
|
||||
@@ -40,15 +40,15 @@
|
||||
- 旧权限表面治理护栏已经补齐:`src/lib/governance/legacyToolPermissionGuard.test.ts` + `npm run governance:legacy-report`
|
||||
- 跨层命令契约检查基础版已经落地:`npm run test:contracts` 已进入 `scripts/local-ci.mjs`
|
||||
- 命令契约延期例外已经收口:`agent_terminal_command_response`、`agent_term_scrollback_response` 已退出 `runtimeGatewayCommands`,改为 `dead-candidate` 治理监控
|
||||
- 自包含 smoke 最小基线已落地:`npm run smoke:workspace-ready`、`npm run smoke:browser-runtime`、`npm run smoke:site-adapters` 都无需人工准备,且 `npm run verify:gui-smoke` 已默认串联这三条主链 smoke
|
||||
- 自包含 smoke 最小基线已落地:`npm run smoke:workspace-ready`、`npm run smoke:browser-runtime`、`npm run smoke:site-adapters` 都无需人工准备;另外,`npm run smoke:agent-runtime-tool-surface` 已补齐“runtime inventory -> 主界面提示/runtime strip”这条应用层主线 smoke,`npm run verify:gui-smoke` 现已默认串联这四条 current smoke
|
||||
- 测试文档事实源已经收口:`docs/test/README.md`、`docs/test/e2e-tests.md`、`docs/aiprompts/playwright-e2e.md` 已按“索引 / 总览 / 详细事实源”分层
|
||||
|
||||
## 3. 当前仍未解决的问题优先级
|
||||
|
||||
| 优先级 | 事项 | 为什么重要 | 当前证据 | 完成定义 |
|
||||
| ------ | ------------------------- | ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
|
||||
| P1 | Agent eval 仍未完全工程化 | 价值高,且当前最缺的是把证据沉淀成长期回归资产 | 已补 `docs/test/harness-evals.md`、`harness-evals.manifest.json`、`scripts/harness-eval-runner.mjs`、`scripts/harness-eval-trend-report.mjs` 与 nightly 摘要 / trend 骨架,但真实执行与更多高价值样本仍缺 | 形成稳定任务集、可增长 replay 样本、grader、nightly 输出与趋势指标 |
|
||||
| P2 | terminal / server 自包含 smoke 仍可继续扩面 | 最小 GUI smoke 基线已具备,但更细分主链仍缺专项守卫 | 当前 `workspace-ready / browser-runtime / site-adapters` 已覆盖 GUI 最小主链;`smoke:social-workbench` 仍依赖已有 session,terminal / server 还没有各自独立的自包含 smoke 入口 | 如后续需要继续扩面,应补 terminal 或 server 的独立 smoke,而不是继续把现有 3 条 current smoke 算成缺口 |
|
||||
| 优先级 | 事项 | 为什么重要 | 当前证据 | 完成定义 |
|
||||
| ------ | ------------------------------------------- | --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
|
||||
| P1 | Agent eval 仍未完全工程化 | 价值高,且当前最缺的是把证据沉淀成长期回归资产 | 已补 `docs/test/harness-evals.md`、`harness-evals.manifest.json`、`scripts/harness-eval-runner.mjs`、`scripts/harness-eval-trend-report.mjs` 与 nightly 摘要 / trend 骨架,但真实执行与更多高价值样本仍缺 | 形成稳定任务集、可增长 replay 样本、grader、nightly 输出与趋势指标 |
|
||||
| P2 | terminal / server 自包含 smoke 仍可继续扩面 | 最小 GUI smoke 基线已具备,但更细分主链仍缺专项守卫 | 当前 `workspace-ready / browser-runtime / site-adapters` 已覆盖 GUI 最小主链;`smoke:social-workbench` 仍依赖已有 session,terminal / server 还没有各自独立的自包含 smoke 入口 | 如后续需要继续扩面,应补 terminal 或 server 的独立 smoke,而不是继续把现有 3 条 current smoke 算成缺口 |
|
||||
|
||||
## 4. 建议执行顺序
|
||||
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
# Lime Browser Bridge Chrome 商店提交底稿
|
||||
|
||||
本文档用于填写 Chrome Web Store 开发者后台的上架表单、审核备注与数据使用说明。
|
||||
|
||||
## 基础信息
|
||||
|
||||
- 扩展名称:`Lime Browser Bridge`
|
||||
- 当前版本:`0.3.0`
|
||||
- 上传包:`tmp/lime-chrome-0.3.0.zip`
|
||||
- 上传包 SHA-256:`65838b438f96bcd58ebd8464bd92ba2aeb362512fe99ea1f857a631330d4f542`
|
||||
- 建议分类:`Productivity`
|
||||
- 建议语言:`English`
|
||||
|
||||
## 单一句用途
|
||||
|
||||
`Lime Browser Bridge` 的单一用途是把用户当前 Chrome 标签页连接到本地运行的 Lime 桌面端,让 Lime 在用户明确触发浏览器相关任务时读取当前页面上下文,并在当前浏览器中执行对应操作。
|
||||
|
||||
## Short Description
|
||||
|
||||
Connect Lime to your current Chrome tab so Lime can read page context and perform browser actions through your local desktop runtime.
|
||||
|
||||
## Detailed Description
|
||||
|
||||
Lime Browser Bridge is the companion Chrome extension for the Lime desktop app.
|
||||
|
||||
It connects your current Chrome session to a locally running Lime runtime so Lime can:
|
||||
|
||||
- read the active page title, URL, and page content
|
||||
- execute browser actions such as open URL, click, type, scroll, switch tab, and go back
|
||||
- keep browser control attached to the tabs you are already using instead of launching a separate managed browser
|
||||
- reconnect automatically when Lime is running again
|
||||
|
||||
This extension is designed for users who already use Lime on their desktop and want browser-aware AI workflows inside their existing Chrome session.
|
||||
|
||||
The extension does not provide a consumer cloud service by itself. It works only with a user-configured or locally exported Lime connection and only operates on pages the user chooses to open or control.
|
||||
|
||||
## 审核备注
|
||||
|
||||
可直接粘贴给审核团队:
|
||||
|
||||
This extension is a companion bridge for the Lime desktop application. Its single purpose is to connect the user's existing Chrome tabs to a locally running Lime runtime so Lime can inspect the current page and perform browser actions requested by the user.
|
||||
|
||||
The `debugger` permission is required because some actions rely on Chrome DevTools Protocol, including screenshot capture, coordinate-based interactions, raw keyboard input, and page lifecycle inspection. Chrome will show its standard debugger banner during those actions.
|
||||
|
||||
The `<all_urls>` host permission is required because users may ask Lime to work with arbitrary websites in their existing session. The extension does not run on `chrome://` pages and does not inject persistent scripts into every site by default; it injects scripts on demand when a read or action request occurs.
|
||||
|
||||
The extension stores only local configuration and recent bridge status in Chrome storage. It sends page data only to the user-configured Lime runtime endpoint, which is local by default.
|
||||
|
||||
## 权限说明
|
||||
|
||||
### `debugger`
|
||||
|
||||
用于通过 Chrome DevTools Protocol 执行以下能力:
|
||||
|
||||
- 页面结构读取
|
||||
- 截图
|
||||
- 坐标点击与键盘输入
|
||||
- 页面生命周期与标签页调试会话管理
|
||||
|
||||
### `tabs` / `tabGroups` / `windows` / `activeTab`
|
||||
|
||||
用于读取当前标签页、切换标签页、聚合 Lime Agent 打开的标签页,并把用户带回正确的窗口。
|
||||
|
||||
### `scripting`
|
||||
|
||||
用于在用户当前操作的网页上按需注入内容脚本,以抓取页面内容或执行页面内动作。
|
||||
|
||||
### `storage`
|
||||
|
||||
用于保存以下本地状态:
|
||||
|
||||
- `serverUrl`
|
||||
- `bridgeKey`
|
||||
- `profileKey`
|
||||
- 开关状态
|
||||
- 最近一次页面快照摘要
|
||||
- relay 连接状态与端口信息
|
||||
|
||||
### `clipboardRead`
|
||||
|
||||
仅在用户点击弹窗中的粘贴配置操作时使用,用于把 Lime 导出的配置粘贴到扩展设置。
|
||||
|
||||
### `alarms`
|
||||
|
||||
用于维持 MV3 service worker 存活和断线自动重连。
|
||||
|
||||
### `notifications`
|
||||
|
||||
用于在本地 relay 异常时向用户展示浏览器内提示。
|
||||
|
||||
### `<all_urls>`
|
||||
|
||||
用于支持用户在任意常规网页上让 Lime 读取内容或执行浏览器动作。该权限不会用于 `chrome://` 等 Chrome 内部页面。
|
||||
|
||||
## 数据使用填写建议
|
||||
|
||||
以下内容是后台数据使用问卷的建议答案,请以上线前实际条目为准再核对一遍。
|
||||
|
||||
### 收集的数据
|
||||
|
||||
- Website content
|
||||
- Website URLs
|
||||
- Page titles
|
||||
- User-provided connection settings
|
||||
- Optional screenshots captured from the active tab during requested actions
|
||||
|
||||
### 数据用途
|
||||
|
||||
- Extension functionality
|
||||
|
||||
### 是否出售数据
|
||||
|
||||
- No
|
||||
|
||||
### 是否用于广告
|
||||
|
||||
- No
|
||||
|
||||
### 是否用于信用评估或放贷
|
||||
|
||||
- No
|
||||
|
||||
### 是否与数据经纪人共享
|
||||
|
||||
- No
|
||||
|
||||
### 数据传输说明
|
||||
|
||||
- Page data is sent only to the user-configured Lime runtime endpoint.
|
||||
- The default endpoint is local (`ws://127.0.0.1:8999`).
|
||||
- The extension does not include analytics, ad SDKs, or third-party tracking code.
|
||||
|
||||
## 截图建议
|
||||
|
||||
建议准备至少 3 张 `1280x800` 截图:
|
||||
|
||||
1. 安装引导页
|
||||
2. 扩展状态/连接页
|
||||
3. 连接后由 Lime 控制当前浏览器标签页的示意页
|
||||
|
||||
已生成的本地截图草稿:
|
||||
|
||||
- `tmp/chrome-store-assets/install-extension-1280x800.png`
|
||||
- `tmp/chrome-store-assets/compare-methods-1280x800.png`
|
||||
- `tmp/chrome-store-assets/options-connected-clean-1280x800.png`
|
||||
|
||||
## 发布前核对
|
||||
|
||||
- 确认后台条目是否就是 `cpidmllglbedhpombjibeoalnafofipo`
|
||||
- 确认隐私政策 URL 已填写且外网可访问;当前仓库内草稿文件为 `extensions/lime-chrome/PRIVACY_POLICY.md`
|
||||
- 确认截图、分类、语言、可见性、地区都已补齐
|
||||
- 确认审核备注已说明 `debugger` 与 `<all_urls>` 的必要性
|
||||
@@ -0,0 +1,92 @@
|
||||
# Lime Browser Bridge Privacy Policy
|
||||
|
||||
Last updated: 2026-04-13
|
||||
|
||||
## Overview
|
||||
|
||||
Lime Browser Bridge is a companion Chrome extension for the Lime desktop application. Its purpose is to connect your current Chrome tabs to a Lime runtime that you configure, so Lime can read page context and perform browser actions on your behalf.
|
||||
|
||||
## What Data The Extension Processes
|
||||
|
||||
Depending on the actions you trigger in Lime, the extension may process:
|
||||
|
||||
- the title and URL of the active tab
|
||||
- page content extracted from the current webpage
|
||||
- screenshots captured from the active tab
|
||||
- browser tab metadata needed to switch, group, or focus tabs
|
||||
- local connection settings such as server URL, bridge key, profile key, and relay port
|
||||
- recent connection status and the latest page snapshot summary stored locally for status display
|
||||
|
||||
## How The Data Is Used
|
||||
|
||||
The extension uses this data only to provide the browser bridge functionality, including:
|
||||
|
||||
- connecting Chrome to your Lime runtime
|
||||
- reading the current page when you ask Lime to inspect it
|
||||
- performing browser actions you trigger through Lime
|
||||
- restoring connection status and recent bridge state locally in the extension UI
|
||||
|
||||
## Where Data Is Sent
|
||||
|
||||
The extension sends processed page data only to the Lime runtime endpoint that you configure.
|
||||
|
||||
- By default, Lime is typically configured to run locally, for example `ws://127.0.0.1:8999`.
|
||||
- The extension does not include advertising SDKs, analytics trackers, or third-party telemetry services.
|
||||
- The extension does not sell your data.
|
||||
|
||||
Some page integrations may issue page-related network requests required to extract or complete information from the current site context. These requests are part of the page interaction workflow triggered by the user.
|
||||
|
||||
## Local Storage
|
||||
|
||||
The extension stores limited local state in Chrome storage, including:
|
||||
|
||||
- connection settings
|
||||
- relay enablement state
|
||||
- relay port settings
|
||||
- latest page info summary
|
||||
- recent bridge status
|
||||
|
||||
This local storage is used only to keep the extension working across browser restarts and to show current status in the extension UI.
|
||||
|
||||
## Permissions
|
||||
|
||||
The extension requests the following permissions to provide its single purpose:
|
||||
|
||||
- `debugger`: required for DevTools Protocol based actions such as screenshots, coordinate click, raw input, and page inspection
|
||||
- `tabs`, `tabGroups`, `windows`, `activeTab`: required to inspect and control the current Chrome tabs and window context
|
||||
- `scripting`: required to inject scripts on demand into pages the user asks Lime to inspect or control
|
||||
- `storage`: required to save local configuration and status
|
||||
- `clipboardRead`: required only when the user chooses to paste exported connection settings into the extension
|
||||
- `alarms`: required for MV3 keepalive and reconnect behavior
|
||||
- `notifications`: required to show local relay error notifications
|
||||
- `<all_urls>` host permission: required because users may ask Lime to work with arbitrary regular webpages in their current session
|
||||
|
||||
The extension does not operate on restricted Chrome internal pages such as `chrome://`.
|
||||
|
||||
## Data Sharing
|
||||
|
||||
The extension does not sell personal information and does not share data with advertisers or data brokers.
|
||||
|
||||
Data is shared only with:
|
||||
|
||||
- the Lime runtime endpoint configured by the user
|
||||
- websites the user already visits when the requested action requires normal page loading or page-origin requests
|
||||
|
||||
## Your Choices
|
||||
|
||||
You control whether the extension is enabled and which Lime runtime it connects to.
|
||||
|
||||
You can:
|
||||
|
||||
- disable the extension
|
||||
- disconnect the bridge
|
||||
- remove the extension from Chrome
|
||||
- clear local extension storage from Chrome extension settings
|
||||
|
||||
## Contact
|
||||
|
||||
Project repository:
|
||||
|
||||
- `https://github.com/aiclientproxy/lime`
|
||||
|
||||
If you publish this policy via GitHub, use the public repository URL for this file in the Chrome Web Store listing.
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "lime",
|
||||
"version": "1.10.0",
|
||||
"version": "1.11.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "lime",
|
||||
"version": "1.10.0",
|
||||
"version": "1.11.0",
|
||||
"dependencies": {
|
||||
"@babel/standalone": "^7.29.0",
|
||||
"@fabianlars/tauri-plugin-oauth": "^2",
|
||||
|
||||
+5
-4
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "lime",
|
||||
"private": true,
|
||||
"version": "1.10.0",
|
||||
"version": "1.11.0",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
@@ -23,9 +23,9 @@
|
||||
"build": "npm run generate:agent-runtime-clients && npm run generate:extension-site-adapters && npm run verify:app-version && tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"tauri:dev": "npm run generate:agent-runtime-clients && npm run generate:extension-site-adapters && CARGO_TARGET_DIR=target tauri dev",
|
||||
"tauri:dev:headless": "npm run generate:agent-runtime-clients && npm run generate:extension-site-adapters && CARGO_TARGET_DIR=target tauri dev --config src-tauri/tauri.conf.headless.json",
|
||||
"tauri:dev:nowatch": "npm run generate:agent-runtime-clients && npm run generate:extension-site-adapters && CARGO_TARGET_DIR=target tauri dev --no-watch",
|
||||
"tauri:dev": "npm run generate:agent-runtime-clients && npm run generate:extension-site-adapters && node scripts/run-tauri-dev.mjs",
|
||||
"tauri:dev:headless": "npm run generate:agent-runtime-clients && npm run generate:extension-site-adapters && node scripts/run-tauri-dev.mjs --headless",
|
||||
"tauri:dev:nowatch": "npm run generate:agent-runtime-clients && npm run generate:extension-site-adapters && node scripts/run-tauri-dev.mjs --no-watch",
|
||||
"tauri:dev:profile:trace": "node scripts/run-tauri-profile.mjs trace",
|
||||
"tauri:dev:profile:trace:devtools": "node scripts/run-tauri-profile.mjs trace --open-devtools",
|
||||
"tauri:dev:profile:trace:headless": "node scripts/run-tauri-profile.mjs trace --headless",
|
||||
@@ -74,6 +74,7 @@
|
||||
"smoke:workspace-ready": "node scripts/workspace-ready-smoke.mjs",
|
||||
"smoke:browser-runtime": "node scripts/browser-runtime-smoke.mjs",
|
||||
"smoke:site-adapters": "node scripts/site-adapter-catalog-smoke.mjs",
|
||||
"smoke:agent-runtime-tool-surface": "node scripts/agent-runtime-tool-surface-smoke.mjs",
|
||||
"smoke:agent-service-skill-entry": "node scripts/agent-service-skill-entry-smoke.mjs",
|
||||
"smoke:social-workbench": "node scripts/social-workbench-e2e-smoke.mjs",
|
||||
"dev:web-bridge": "node scripts/start-web-bridge-dev.mjs",
|
||||
|
||||
@@ -112,7 +112,7 @@ npm run build:release -- \
|
||||
```bash
|
||||
npm run build:release -- \
|
||||
--target-triple "aarch64-apple-darwin" \
|
||||
--version "1.10.0" \
|
||||
--version "1.11.0" \
|
||||
--out-dir "./dist"
|
||||
```
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@limecloud/lime-cli",
|
||||
"version": "1.10.0",
|
||||
"version": "1.11.0",
|
||||
"description": "Lime 官方任务 CLI",
|
||||
"bin": {
|
||||
"lime": "scripts/run.js"
|
||||
|
||||
@@ -0,0 +1,560 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import process from "node:process";
|
||||
|
||||
const DEFAULTS = {
|
||||
appUrl: "http://127.0.0.1:1420/",
|
||||
healthUrl: "http://127.0.0.1:3030/health",
|
||||
invokeUrl: "http://127.0.0.1:3030/invoke",
|
||||
timeoutMs: 180_000,
|
||||
intervalMs: 1_000,
|
||||
};
|
||||
|
||||
const INVOKE_TIMEOUT_CEILING_MS = 180_000;
|
||||
const INVOKE_RETRY_COUNT = 10;
|
||||
const INVOKE_RETRY_DELAY_MS = 1_000;
|
||||
const POST_HEALTH_SETTLE_MS = 1_500;
|
||||
const POST_CONFIG_SETTLE_MS = 1_000;
|
||||
const POST_LAUNCH_SETTLE_MS = 1_500;
|
||||
const DEFAULT_ACTION_TIMEOUT_MS = 15_000;
|
||||
const ONBOARDING_VERSION = "1.1.0";
|
||||
const PROMPT_TEXT = "请回复一句:smoke harness";
|
||||
const RUNTIME_TOOL_AVAILABILITY_OVERRIDE = {
|
||||
known: true,
|
||||
agentInitialized: true,
|
||||
source: "runtime_tools",
|
||||
availableToolCount: 2,
|
||||
webSearch: false,
|
||||
subagentCore: false,
|
||||
subagentTeamTools: false,
|
||||
subagentRuntime: false,
|
||||
taskRuntime: false,
|
||||
missingSubagentCoreTools: ["Agent", "SendMessage"],
|
||||
missingSubagentTeamTools: ["TeamCreate", "TeamDelete", "ListPeers"],
|
||||
missingTaskTools: ["TaskCreate"],
|
||||
};
|
||||
const REQUIRED_RUNTIME_SUMMARY_FLAGS = [
|
||||
"hasWorkbench",
|
||||
"hasRuntimeSummary",
|
||||
"hasWebSearchGap",
|
||||
"hasSubagentGap",
|
||||
"hasTeamGap",
|
||||
"hasTaskGap",
|
||||
"hasGapBanner",
|
||||
];
|
||||
const FORBIDDEN_PAGE_WARNINGS = [
|
||||
"当前 runtime tool surface 还没有暴露 WebSearch,联网搜索偏好本轮可能不会生效。",
|
||||
"当前 runtime tool surface 缺少 Agent / SendMessage / Team* current tools,任务拆分偏好本轮可能不会完全生效。",
|
||||
];
|
||||
|
||||
function printHelp() {
|
||||
console.log(`
|
||||
Lime Runtime Tool Surface Page Smoke
|
||||
|
||||
用途:
|
||||
通过真实 Lime 页面验证 runtime inventory -> 工作台 Harness -> Runtime 能力摘要的主链,
|
||||
同时确认首页空态不再弹出 runtime tool surface 黄提示。
|
||||
|
||||
用法:
|
||||
node scripts/agent-runtime-tool-surface-page-smoke.mjs [选项]
|
||||
|
||||
选项:
|
||||
--app-url <url> 前端地址,默认 http://127.0.0.1:1420/
|
||||
--health-url <url> DevBridge 健康检查地址,默认 http://127.0.0.1:3030/health
|
||||
--invoke-url <url> DevBridge invoke 地址,默认 http://127.0.0.1:3030/invoke
|
||||
--timeout-ms <ms> 总超时,默认 180000
|
||||
--interval-ms <ms> 轮询间隔,默认 1000
|
||||
-h, --help 显示帮助
|
||||
`);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = { ...DEFAULTS };
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
|
||||
if (arg === "--app-url" && argv[index + 1]) {
|
||||
options.appUrl = String(argv[index + 1]).trim();
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--health-url" && argv[index + 1]) {
|
||||
options.healthUrl = String(argv[index + 1]).trim();
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--invoke-url" && argv[index + 1]) {
|
||||
options.invokeUrl = String(argv[index + 1]).trim();
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--timeout-ms" && argv[index + 1]) {
|
||||
options.timeoutMs = Number(argv[index + 1]);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--interval-ms" && argv[index + 1]) {
|
||||
options.intervalMs = Number(argv[index + 1]);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--help" || arg === "-h") {
|
||||
printHelp();
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
if (!Number.isFinite(options.timeoutMs) || options.timeoutMs < 30_000) {
|
||||
throw new Error("--timeout-ms 必须是 >= 30000 的数字");
|
||||
}
|
||||
if (!Number.isFinite(options.intervalMs) || options.intervalMs < 100) {
|
||||
throw new Error("--interval-ms 必须是 >= 100 的数字");
|
||||
}
|
||||
if (!options.appUrl) {
|
||||
throw new Error("--app-url 不能为空");
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
function deepClone(value) {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function isTransientInvokeError(error) {
|
||||
return (
|
||||
error?.name === "TimeoutError" ||
|
||||
(error instanceof TypeError && error.message === "fetch failed")
|
||||
);
|
||||
}
|
||||
|
||||
async function invoke(options, cmd, args) {
|
||||
const invokeTimeoutMs = Math.min(options.timeoutMs, INVOKE_TIMEOUT_CEILING_MS);
|
||||
const requestInit = {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ cmd, args }),
|
||||
signal: AbortSignal.timeout(invokeTimeoutMs),
|
||||
};
|
||||
|
||||
for (let attempt = 1; attempt <= INVOKE_RETRY_COUNT; attempt += 1) {
|
||||
try {
|
||||
const response = await fetch(options.invokeUrl, requestInit);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
if (payload?.error) {
|
||||
throw new Error(String(payload.error));
|
||||
}
|
||||
|
||||
return payload?.result;
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
if (!isTransientInvokeError(error) || attempt >= INVOKE_RETRY_COUNT) {
|
||||
if (error?.name === "TimeoutError") {
|
||||
throw new Error(
|
||||
`[smoke:agent-runtime-tool-surface-page] ${cmd} 超时,${invokeTimeoutMs}ms 内未收到 DevBridge 响应`,
|
||||
);
|
||||
}
|
||||
throw new Error(
|
||||
`[smoke:agent-runtime-tool-surface-page] ${cmd} 请求失败: ${detail}`,
|
||||
);
|
||||
}
|
||||
console.warn(
|
||||
`[smoke:agent-runtime-tool-surface-page] ${cmd} 第 ${attempt} 次请求失败,${INVOKE_RETRY_DELAY_MS}ms 后重试: ${detail}`,
|
||||
);
|
||||
await sleep(INVOKE_RETRY_DELAY_MS);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`[smoke:agent-runtime-tool-surface-page] ${cmd} 请求失败: unknown error`,
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForHealth(options) {
|
||||
const startedAt = Date.now();
|
||||
let lastError = null;
|
||||
|
||||
while (Date.now() - startedAt < options.timeoutMs) {
|
||||
try {
|
||||
const response = await fetch(options.healthUrl, { method: "GET" });
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
console.log(
|
||||
`[smoke:agent-runtime-tool-surface-page] DevBridge 已就绪 (${Date.now() - startedAt}ms)${
|
||||
payload?.status ? ` status=${payload.status}` : ""
|
||||
}`,
|
||||
);
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
await sleep(options.intervalMs);
|
||||
}
|
||||
}
|
||||
|
||||
const detail =
|
||||
lastError instanceof Error
|
||||
? lastError.message
|
||||
: String(lastError || "unknown error");
|
||||
throw new Error(
|
||||
`[smoke:agent-runtime-tool-surface-page] DevBridge 未就绪,请先启动 npm run tauri:dev:headless。最后错误: ${detail}`,
|
||||
);
|
||||
}
|
||||
|
||||
function buildHarnessBootstrapScript() {
|
||||
return `(() => {
|
||||
localStorage.setItem("lime_onboarding_complete", "true");
|
||||
localStorage.setItem("lime_onboarding_version", ${JSON.stringify(ONBOARDING_VERSION)});
|
||||
localStorage.setItem("lime_user_profile", "developer");
|
||||
localStorage.setItem("lime.chat.harness-panel.visible.v1", "true");
|
||||
localStorage.setItem(
|
||||
"lime:debug:runtime-tool-availability:v1",
|
||||
${JSON.stringify(JSON.stringify(RUNTIME_TOOL_AVAILABILITY_OVERRIDE))}
|
||||
);
|
||||
return true;
|
||||
})()`;
|
||||
}
|
||||
|
||||
function buildFillPromptAndSendScript(prompt) {
|
||||
return `(() => {
|
||||
const textarea = document.querySelector('textarea[placeholder="有什么我可以帮你的?"]');
|
||||
const send = document.querySelector('button[aria-label="发送"]');
|
||||
if (!textarea || !send) {
|
||||
return { ok: false, reason: "missing-input-or-send" };
|
||||
}
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, "value")?.set;
|
||||
if (!setter) {
|
||||
return { ok: false, reason: "missing-native-textarea-setter" };
|
||||
}
|
||||
setter.call(textarea, ${JSON.stringify(prompt)});
|
||||
textarea.dispatchEvent(new InputEvent("input", {
|
||||
bubbles: true,
|
||||
data: ${JSON.stringify(prompt)},
|
||||
inputType: "insertText",
|
||||
}));
|
||||
textarea.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
return {
|
||||
ok: true,
|
||||
value: textarea.value,
|
||||
sendDisabled: Boolean(send.disabled),
|
||||
};
|
||||
})()`;
|
||||
}
|
||||
|
||||
function buildClickSendScript() {
|
||||
return `(() => {
|
||||
const send = document.querySelector('button[aria-label="发送"]');
|
||||
if (!send) {
|
||||
return { ok: false, reason: "missing-send-button" };
|
||||
}
|
||||
send.click();
|
||||
return {
|
||||
ok: true,
|
||||
disabled: Boolean(send.disabled),
|
||||
ariaExpanded: send.getAttribute("aria-expanded"),
|
||||
};
|
||||
})()`;
|
||||
}
|
||||
|
||||
function buildOpenWorkbenchScript() {
|
||||
return `(() => {
|
||||
const target = Array.from(document.querySelectorAll("button")).find(
|
||||
(button) => (button.textContent || "").trim() === "工作台",
|
||||
);
|
||||
if (!target) {
|
||||
return {
|
||||
ok: false,
|
||||
buttons: Array.from(document.querySelectorAll("button")).map((button) => ({
|
||||
text: (button.textContent || "").trim(),
|
||||
aria: button.getAttribute("aria-label"),
|
||||
})),
|
||||
};
|
||||
}
|
||||
target.click();
|
||||
return {
|
||||
ok: true,
|
||||
ariaExpanded: target.getAttribute("aria-expanded"),
|
||||
};
|
||||
})()`;
|
||||
}
|
||||
|
||||
function buildRuntimeSummaryCheckScript() {
|
||||
return `(() => {
|
||||
const text = document.body ? document.body.innerText : "";
|
||||
return {
|
||||
hasWorkbench: text.includes("处理工作台"),
|
||||
hasRuntimeSummary: text.includes("Runtime 能力摘要"),
|
||||
hasWebSearchGap: text.includes("WebSearch 未接通"),
|
||||
hasSubagentGap: text.includes("子任务核心 tools 缺 2 项"),
|
||||
hasTeamGap: text.includes("Team current tools 缺 3 项"),
|
||||
hasTaskGap: text.includes("Task current tools 缺 1 项"),
|
||||
hasGapBanner: text.includes("当前 runtime current surface 仍有缺口"),
|
||||
hasLegacyWebSearchWarning: text.includes(${JSON.stringify(FORBIDDEN_PAGE_WARNINGS[0])}),
|
||||
hasLegacySubagentWarning: text.includes(${JSON.stringify(FORBIDDEN_PAGE_WARNINGS[1])}),
|
||||
};
|
||||
})()`;
|
||||
}
|
||||
|
||||
function extractJavascriptValue(actionResult) {
|
||||
return (
|
||||
actionResult?.data?.result?.result?.value ??
|
||||
actionResult?.data?.result?.value ??
|
||||
actionResult?.data?.result ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
async function runBrowserAction(options, profileKey, action, args = {}) {
|
||||
return invoke(options, "browser_execute_action", {
|
||||
request: {
|
||||
profile_key: profileKey,
|
||||
backend: "cdp_direct",
|
||||
action,
|
||||
args,
|
||||
timeout_ms: DEFAULT_ACTION_TIMEOUT_MS,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function runJavascript(options, profileKey, expression) {
|
||||
const result = await runBrowserAction(options, profileKey, "javascript", {
|
||||
expression,
|
||||
return_by_value: true,
|
||||
});
|
||||
return extractJavascriptValue(result);
|
||||
}
|
||||
|
||||
async function readPageMarkdown(options, profileKey) {
|
||||
const result = await runBrowserAction(options, profileKey, "read_page");
|
||||
return String(result?.data?.markdown || "");
|
||||
}
|
||||
|
||||
async function waitForCheck(options, label, check) {
|
||||
const startedAt = Date.now();
|
||||
let lastValue = null;
|
||||
|
||||
while (Date.now() - startedAt < options.timeoutMs) {
|
||||
lastValue = await check();
|
||||
if (lastValue?.ok) {
|
||||
return lastValue.value;
|
||||
}
|
||||
await sleep(options.intervalMs);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`[smoke:agent-runtime-tool-surface-page] 等待 ${label} 超时,最后结果: ${JSON.stringify(
|
||||
lastValue?.value ?? null,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function ensureHarnessEnabled(options) {
|
||||
const originalConfig = await invoke(options, "get_config");
|
||||
const enabled =
|
||||
originalConfig?.developer?.workspace_harness_enabled === true;
|
||||
if (enabled) {
|
||||
return {
|
||||
originalConfig,
|
||||
changed: false,
|
||||
};
|
||||
}
|
||||
|
||||
const nextConfig = deepClone(originalConfig);
|
||||
nextConfig.developer = {
|
||||
...(nextConfig.developer || {}),
|
||||
workspace_harness_enabled: true,
|
||||
};
|
||||
await invoke(options, "save_config", nextConfig);
|
||||
await sleep(POST_CONFIG_SETTLE_MS);
|
||||
return {
|
||||
originalConfig,
|
||||
changed: true,
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (typeof fetch !== "function") {
|
||||
throw new Error("当前 Node 运行时不支持 fetch,请使用 Node 18+");
|
||||
}
|
||||
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
await waitForHealth(options);
|
||||
await sleep(POST_HEALTH_SETTLE_MS);
|
||||
|
||||
const { originalConfig, changed } = await ensureHarnessEnabled(options);
|
||||
const profileKey = `smoke-agent-runtime-tool-surface-page-${Date.now()}`;
|
||||
let sessionId = null;
|
||||
|
||||
try {
|
||||
const launchResponse = await invoke(options, "launch_browser_session", {
|
||||
request: {
|
||||
profile_key: profileKey,
|
||||
url: options.appUrl,
|
||||
headless: true,
|
||||
open_window: false,
|
||||
stream_mode: "both",
|
||||
},
|
||||
});
|
||||
|
||||
sessionId = launchResponse?.session?.session_id ?? null;
|
||||
assert(
|
||||
typeof sessionId === "string" && sessionId.trim(),
|
||||
"launch_browser_session 未返回 session.session_id",
|
||||
);
|
||||
await sleep(POST_LAUNCH_SETTLE_MS);
|
||||
|
||||
await runJavascript(options, profileKey, buildHarnessBootstrapScript());
|
||||
await runBrowserAction(options, profileKey, "refresh_page");
|
||||
|
||||
await waitForCheck(options, "首页空态加载", async () => {
|
||||
const text = await runJavascript(
|
||||
options,
|
||||
profileKey,
|
||||
'document.body ? document.body.innerText : ""',
|
||||
);
|
||||
return {
|
||||
ok:
|
||||
typeof text === "string" &&
|
||||
(text.includes("青柠一下,灵感即来") ||
|
||||
text.includes("有什么我可以帮你的?")),
|
||||
value: text,
|
||||
};
|
||||
});
|
||||
|
||||
const prepared = await runJavascript(
|
||||
options,
|
||||
profileKey,
|
||||
buildFillPromptAndSendScript(PROMPT_TEXT),
|
||||
);
|
||||
assert(
|
||||
prepared?.ok === true,
|
||||
`准备输入失败: ${JSON.stringify(prepared ?? null)}`,
|
||||
);
|
||||
assert(prepared?.sendDisabled === false, "发送按钮仍处于禁用状态");
|
||||
|
||||
const sendResult = await runJavascript(
|
||||
options,
|
||||
profileKey,
|
||||
buildClickSendScript(),
|
||||
);
|
||||
assert(
|
||||
sendResult?.ok === true,
|
||||
`发送最小请求失败: ${JSON.stringify(sendResult ?? null)}`,
|
||||
);
|
||||
|
||||
await waitForCheck(options, "运行态工作台按钮出现", async () => {
|
||||
const text = await runJavascript(
|
||||
options,
|
||||
profileKey,
|
||||
'document.body ? document.body.innerText : ""',
|
||||
);
|
||||
return {
|
||||
ok: typeof text === "string" && text.includes("工作台"),
|
||||
value: text,
|
||||
};
|
||||
});
|
||||
|
||||
const openWorkbench = await runJavascript(
|
||||
options,
|
||||
profileKey,
|
||||
buildOpenWorkbenchScript(),
|
||||
);
|
||||
assert(
|
||||
openWorkbench?.ok === true,
|
||||
`打开工作台失败: ${JSON.stringify(openWorkbench ?? null)}`,
|
||||
);
|
||||
|
||||
const summaryFlags = await waitForCheck(
|
||||
options,
|
||||
"Runtime 能力摘要出现",
|
||||
async () => {
|
||||
const value = await runJavascript(
|
||||
options,
|
||||
profileKey,
|
||||
buildRuntimeSummaryCheckScript(),
|
||||
);
|
||||
const hasAllRequired = REQUIRED_RUNTIME_SUMMARY_FLAGS.every(
|
||||
(key) => value?.[key] === true,
|
||||
);
|
||||
const hasForbiddenWarning =
|
||||
value?.hasLegacyWebSearchWarning || value?.hasLegacySubagentWarning;
|
||||
return {
|
||||
ok: hasAllRequired && !hasForbiddenWarning,
|
||||
value,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const pageMarkdown = await readPageMarkdown(options, profileKey);
|
||||
for (const warning of FORBIDDEN_PAGE_WARNINGS) {
|
||||
assert(
|
||||
!pageMarkdown.includes(warning),
|
||||
`真实页面仍出现不应存在的页级告警: ${warning}`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[smoke:agent-runtime-tool-surface-page] 通过 session=${sessionId} profile=${profileKey}`,
|
||||
);
|
||||
console.log(
|
||||
`[smoke:agent-runtime-tool-surface-page] summary=${JSON.stringify(summaryFlags)}`,
|
||||
);
|
||||
} finally {
|
||||
if (sessionId) {
|
||||
try {
|
||||
await invoke(options, "close_cdp_session", {
|
||||
request: {
|
||||
session_id: sessionId,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[smoke:agent-runtime-tool-surface-page] 清理浏览器会话失败: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
try {
|
||||
await invoke(options, "save_config", originalConfig);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[smoke:agent-runtime-tool-surface-page] 恢复 developer.workspace_harness_enabled 失败: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const rootDir = path.resolve(__dirname, "..");
|
||||
const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";
|
||||
|
||||
function runVitest(label, args) {
|
||||
console.log(`\n[smoke:agent-runtime-tool-surface] > ${label}`);
|
||||
const result = spawnSync(
|
||||
npmCommand,
|
||||
["exec", "--", "vitest", "run", ...args],
|
||||
{
|
||||
cwd: rootDir,
|
||||
stdio: "inherit",
|
||||
env: process.env,
|
||||
},
|
||||
);
|
||||
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
if (typeof result.status === "number" && result.status !== 0) {
|
||||
const error = new Error(`[smoke:agent-runtime-tool-surface] ${label} 失败`);
|
||||
error.exitCode = result.status;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
runVitest("runtime tool surface 派生与页级提示", [
|
||||
"src/components/agent/chat/utils/runtimeToolAvailability.test.ts",
|
||||
"src/components/agent/chat/components/AgentRuntimeStrip.test.tsx",
|
||||
"src/components/agent/chat/components/EmptyState.test.tsx",
|
||||
"src/components/agent/chat/components/HarnessStatusPanel.test.tsx",
|
||||
"--hookTimeout=60000",
|
||||
"-t",
|
||||
"runtime tool surface",
|
||||
]);
|
||||
|
||||
runVitest("runtime inventory 主链透传", [
|
||||
"src/components/agent/chat/workspace/useWorkspaceHarnessInventoryRuntime.test.tsx",
|
||||
"src/components/agent/chat/components/EmptyStateComposerPanel.test.tsx",
|
||||
"src/components/agent/chat/workspace/useWorkspaceConversationSceneRuntime.test.ts",
|
||||
]);
|
||||
|
||||
console.log("\n[smoke:agent-runtime-tool-surface] 通过");
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -468,18 +468,26 @@ function splitObservabilitySignalName(name) {
|
||||
const separatorIndex = normalized.indexOf(":");
|
||||
return {
|
||||
signal: normalized.slice(0, separatorIndex),
|
||||
status: normalized.slice(separatorIndex + 1),
|
||||
status: normalizeObservabilityStatus(normalized.slice(separatorIndex + 1)),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeObservabilityStatus(status) {
|
||||
const normalized = normalizeString(status);
|
||||
switch (normalized) {
|
||||
case "unlinked":
|
||||
return "known_gap";
|
||||
default:
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
function getObservabilityStatusWeight(status) {
|
||||
switch (status) {
|
||||
case "missing":
|
||||
return 120;
|
||||
case "missing_signal_coverage":
|
||||
return 110;
|
||||
case "unlinked":
|
||||
return 95;
|
||||
case "known_gap":
|
||||
return 85;
|
||||
case "partial":
|
||||
@@ -498,6 +506,9 @@ function buildObservabilityFocusEntries(entries, sampleCount) {
|
||||
const delta = isObject(entry?.delta) ? entry.delta : {};
|
||||
const baseline = isObject(entry?.baseline) ? entry.baseline : {};
|
||||
const parsed = splitObservabilitySignalName(entry?.name);
|
||||
const signal = parsed.signal || normalizeString(entry?.name, "(unknown)");
|
||||
const status = parsed.status || "unknown";
|
||||
const canonicalName = parsed.status ? `${signal}:${parsed.status}` : signal;
|
||||
const positiveDeltaCase = Math.max(0, normalizeNumber(delta.caseCount));
|
||||
const latestCase = normalizeNumber(latest.caseCount);
|
||||
const score =
|
||||
@@ -514,9 +525,9 @@ function buildObservabilityFocusEntries(entries, sampleCount) {
|
||||
}
|
||||
|
||||
return {
|
||||
name: normalizeString(entry?.name, "(unknown)"),
|
||||
signal: parsed.signal || "(unknown)",
|
||||
status: parsed.status || "unknown",
|
||||
name: canonicalName,
|
||||
signal,
|
||||
status,
|
||||
baseline: {
|
||||
caseCount: normalizeNumber(baseline.caseCount),
|
||||
readyCount: normalizeNumber(baseline.readyCount),
|
||||
|
||||
@@ -1456,6 +1456,116 @@ describe("generated-slop-report-core", () => {
|
||||
).toBe("P1");
|
||||
});
|
||||
|
||||
it("应把旧 requestTelemetry:unlinked 样本折叠成 known_gap", () => {
|
||||
const report = buildGeneratedSlopReport({
|
||||
repoRoot: "/tmp/lime",
|
||||
trendReport: {
|
||||
sampleCount: 2,
|
||||
delta: {
|
||||
invalidCount: 0,
|
||||
pendingRequestCaseCount: 0,
|
||||
needsHumanReviewCount: 0,
|
||||
reviewDecisionRecordedCount: 0,
|
||||
observabilityGapCaseCount: 1,
|
||||
currentObservabilityGapCaseCount: 1,
|
||||
degradedObservabilityGapCaseCount: 0,
|
||||
readyRate: 0,
|
||||
},
|
||||
signals: ["request telemetry 仍有旧样本待清理。"],
|
||||
baseline: {
|
||||
totals: {
|
||||
needsHumanReviewCount: 0,
|
||||
reviewDecisionRecordedCount: 0,
|
||||
observabilityGapCaseCount: 0,
|
||||
currentObservabilityGapCaseCount: 0,
|
||||
degradedObservabilityGapCaseCount: 0,
|
||||
},
|
||||
},
|
||||
latest: {
|
||||
totals: {
|
||||
needsHumanReviewCount: 0,
|
||||
reviewDecisionRecordedCount: 0,
|
||||
observabilityGapCaseCount: 1,
|
||||
currentObservabilityGapCaseCount: 1,
|
||||
degradedObservabilityGapCaseCount: 0,
|
||||
},
|
||||
},
|
||||
classificationDeltas: {
|
||||
failureModes: [],
|
||||
suiteTags: [],
|
||||
reviewDecisionStatuses: [],
|
||||
reviewRiskLevels: [],
|
||||
observabilitySignals: [
|
||||
{
|
||||
name: "requestTelemetry:unlinked",
|
||||
baseline: {
|
||||
caseCount: 0,
|
||||
readyCount: 0,
|
||||
invalidCount: 0,
|
||||
pendingRequestCaseCount: 0,
|
||||
needsHumanReviewCount: 0,
|
||||
},
|
||||
latest: {
|
||||
caseCount: 1,
|
||||
readyCount: 1,
|
||||
invalidCount: 0,
|
||||
pendingRequestCaseCount: 0,
|
||||
needsHumanReviewCount: 0,
|
||||
},
|
||||
delta: {
|
||||
caseCount: 1,
|
||||
readyCount: 1,
|
||||
invalidCount: 0,
|
||||
pendingRequestCaseCount: 0,
|
||||
needsHumanReviewCount: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
observabilityVerificationOutcomes: [],
|
||||
currentObservabilityVerificationOutcomes: [],
|
||||
degradedObservabilityVerificationOutcomes: [],
|
||||
currentRecoveredObservabilityVerificationOutcomes: [],
|
||||
},
|
||||
},
|
||||
docFreshnessReport: {
|
||||
summary: {
|
||||
monitoredDocumentCount: 0,
|
||||
existingDocumentCount: 0,
|
||||
issueCount: 0,
|
||||
missingDocumentCount: 0,
|
||||
missingRequiredReferenceCount: 0,
|
||||
brokenMarkdownLinkCount: 0,
|
||||
brokenCodePathReferenceCount: 0,
|
||||
deletedSurfaceReferenceCount: 0,
|
||||
},
|
||||
issues: [],
|
||||
},
|
||||
governanceReport: {
|
||||
summary: {
|
||||
compatibilityCount: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(report.focus.observabilitySignals).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: "requestTelemetry:known_gap",
|
||||
signal: "requestTelemetry",
|
||||
status: "known_gap",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(report.recommendations).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: "observability-evidence-follow-up",
|
||||
priority: "P1",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("应拒绝 recommendation 回流旧 verification outcome 字段", () => {
|
||||
expect(() =>
|
||||
assertGeneratedSlopReportContract({
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const rootDir = path.resolve(__dirname, "..");
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const headless = args.includes("--headless");
|
||||
const noWatch = args.includes("--no-watch");
|
||||
const passthroughArgs = args.filter(
|
||||
(arg) => arg !== "--headless" && arg !== "--no-watch",
|
||||
);
|
||||
const defaultCargoTargetDir = path.join(rootDir, "src-tauri", "target");
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
CARGO_TARGET_DIR: process.env.CARGO_TARGET_DIR || defaultCargoTargetDir,
|
||||
};
|
||||
|
||||
const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";
|
||||
const tauriArgs = ["exec", "--", "tauri", "dev"];
|
||||
|
||||
if (headless) {
|
||||
tauriArgs.push("--config", "src-tauri/tauri.conf.headless.json");
|
||||
}
|
||||
|
||||
if (noWatch) {
|
||||
tauriArgs.push("--no-watch");
|
||||
}
|
||||
|
||||
tauriArgs.push(...passthroughArgs);
|
||||
|
||||
const child = spawn(npmCommand, tauriArgs, {
|
||||
cwd: rootDir,
|
||||
env,
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
if (signal) {
|
||||
process.kill(process.pid, signal);
|
||||
return;
|
||||
}
|
||||
process.exit(code ?? 0);
|
||||
});
|
||||
@@ -30,6 +30,7 @@ const supportedModes = new Map([
|
||||
["console", ["console"]],
|
||||
["trace-console", ["trace", "console"]],
|
||||
]);
|
||||
const defaultCargoTargetDir = path.join(rootDir, "src-tauri", "target");
|
||||
|
||||
const profileModes = supportedModes.get(mode);
|
||||
|
||||
@@ -40,7 +41,7 @@ if (!profileModes) {
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
CARGO_TARGET_DIR: process.env.CARGO_TARGET_DIR || "target",
|
||||
CARGO_TARGET_DIR: process.env.CARGO_TARGET_DIR || defaultCargoTargetDir,
|
||||
LIME_PROFILE: profileModes.join(","),
|
||||
RUST_BACKTRACE: process.env.RUST_BACKTRACE || "1",
|
||||
RUST_LOG: process.env.RUST_LOG || "info",
|
||||
|
||||
@@ -16,7 +16,7 @@ const LIME_DISABLE_SINGLE_INSTANCE = "LIME_DISABLE_SINGLE_INSTANCE";
|
||||
const LIME_WEB_BRIDGE_REUSE_EXISTING_ONLY =
|
||||
"LIME_WEB_BRIDGE_REUSE_EXISTING_ONLY";
|
||||
const LIME_WEB_BRIDGE_URL = "LIME_WEB_BRIDGE_URL";
|
||||
const ROOT_MARKERS = ['<title>Lime</title>', '<div id="root"></div>'];
|
||||
const ROOT_MARKERS = ["<title>Lime</title>", '<div id="root"></div>'];
|
||||
const SHARED_TAURI_TARGET_DIR = path.join(rootDir, "src-tauri", "target");
|
||||
const ISOLATED_GUI_SMOKE_TARGET_DIR = path.join(
|
||||
os.tmpdir(),
|
||||
@@ -580,7 +580,14 @@ function listListeningCommandsForPort(port) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const pids = [...new Set(pidOutput.split("\n").map((item) => item.trim()).filter(Boolean))];
|
||||
const pids = [
|
||||
...new Set(
|
||||
pidOutput
|
||||
.split("\n")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean),
|
||||
),
|
||||
];
|
||||
return pids
|
||||
.map((pid) => runQuietCommand("ps", ["-p", pid, "-o", "command="]))
|
||||
.filter(Boolean);
|
||||
@@ -618,18 +625,18 @@ function startHeadlessTauri(options, startupMode) {
|
||||
cwd: rootDir,
|
||||
stdio: "inherit",
|
||||
env: {
|
||||
...process.env,
|
||||
CARGO_TARGET_DIR: options.cargoTargetDir,
|
||||
[LIME_SKIP_STARTUP_WINDOW_REVEAL]: "1",
|
||||
[LIME_DISABLE_SINGLE_INSTANCE]: "1",
|
||||
[LIME_WEB_BRIDGE_URL]: options.appUrl,
|
||||
...(startupMode.reuseExistingAppShell
|
||||
? {
|
||||
[LIME_WEB_BRIDGE_REUSE_EXISTING_ONLY]: "1",
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
detached: process.platform !== "win32",
|
||||
...process.env,
|
||||
CARGO_TARGET_DIR: options.cargoTargetDir,
|
||||
[LIME_SKIP_STARTUP_WINDOW_REVEAL]: "1",
|
||||
[LIME_DISABLE_SINGLE_INSTANCE]: "1",
|
||||
[LIME_WEB_BRIDGE_URL]: options.appUrl,
|
||||
...(startupMode.reuseExistingAppShell
|
||||
? {
|
||||
[LIME_WEB_BRIDGE_REUSE_EXISTING_ONLY]: "1",
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
detached: process.platform !== "win32",
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -908,7 +915,8 @@ async function resolveStartupMode(options) {
|
||||
const guiSmokeProcesses = inspectGuiSmokeTauriProcesses();
|
||||
const existingAppShell = await probeAppShell(options.appUrl, 1_500);
|
||||
if (existingAppShell.reachable && !existingAppShell.isLimeDevShell) {
|
||||
const statusLabel = `${existingAppShell.status || "unknown"} ${existingAppShell.statusText || ""}`.trim();
|
||||
const statusLabel =
|
||||
`${existingAppShell.status || "unknown"} ${existingAppShell.statusText || ""}`.trim();
|
||||
throw new Error(
|
||||
`[verify:gui-smoke] ${options.appUrl} 已被其他服务占用,且返回内容不是 Lime 前端壳(${statusLabel})。请先关闭占用进程后重试。`,
|
||||
);
|
||||
@@ -923,7 +931,9 @@ async function resolveStartupMode(options) {
|
||||
);
|
||||
|
||||
if (guiSmokeProcesses.active.length > 0) {
|
||||
const pidList = guiSmokeProcesses.active.map((item) => item.pid).join(", ");
|
||||
const pidList = guiSmokeProcesses.active
|
||||
.map((item) => item.pid)
|
||||
.join(", ");
|
||||
console.log(
|
||||
`[verify:gui-smoke] 检测到已有 GUI smoke headless 进程正在启动(PID: ${pidList});本次将直接复用现有链路并等待 DevBridge。`,
|
||||
);
|
||||
@@ -1001,9 +1011,7 @@ async function main() {
|
||||
const startupMode = await resolveStartupMode(options);
|
||||
const startedByScript = startupMode.shouldStart;
|
||||
|
||||
console.log(
|
||||
`[verify:gui-smoke] Cargo target: ${options.cargoTargetDir}`,
|
||||
);
|
||||
console.log(`[verify:gui-smoke] Cargo target: ${options.cargoTargetDir}`);
|
||||
|
||||
const handleSignal = async (signal) => {
|
||||
try {
|
||||
@@ -1087,6 +1095,13 @@ async function main() {
|
||||
options.timeoutMs + 30_000,
|
||||
);
|
||||
|
||||
runCommand(
|
||||
npmCommand,
|
||||
["run", "smoke:agent-runtime-tool-surface"],
|
||||
"smoke:agent-runtime-tool-surface",
|
||||
options.timeoutMs + 30_000,
|
||||
);
|
||||
|
||||
console.log("\n[verify:gui-smoke] 通过");
|
||||
} finally {
|
||||
if (startedByScript) {
|
||||
|
||||
Generated
+19
-19
@@ -5101,7 +5101,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime"
|
||||
version = "1.10.0"
|
||||
version = "1.11.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
@@ -5206,7 +5206,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-agent"
|
||||
version = "1.10.0"
|
||||
version = "1.11.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"aster-core",
|
||||
@@ -5235,7 +5235,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-browser-runtime"
|
||||
version = "1.10.0"
|
||||
version = "1.11.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"futures",
|
||||
@@ -5252,7 +5252,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-cli"
|
||||
version = "1.10.0"
|
||||
version = "1.11.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"lime-core",
|
||||
@@ -5264,7 +5264,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-config"
|
||||
version = "1.10.0"
|
||||
version = "1.11.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"lime-core",
|
||||
@@ -5280,7 +5280,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-core"
|
||||
version = "1.10.0"
|
||||
version = "1.11.0"
|
||||
dependencies = [
|
||||
"aster-models",
|
||||
"async-trait",
|
||||
@@ -5320,7 +5320,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-credential"
|
||||
version = "1.10.0"
|
||||
version = "1.11.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"base64 0.22.1",
|
||||
@@ -5355,7 +5355,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-gateway"
|
||||
version = "1.10.0"
|
||||
version = "1.11.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"axum 0.7.9",
|
||||
@@ -5385,7 +5385,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-infra"
|
||||
version = "1.10.0"
|
||||
version = "1.11.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dashmap 5.5.3",
|
||||
@@ -5405,7 +5405,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-mcp"
|
||||
version = "1.10.0"
|
||||
version = "1.11.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dirs 5.0.1",
|
||||
@@ -5421,7 +5421,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-media-runtime"
|
||||
version = "1.10.0"
|
||||
version = "1.11.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -5452,7 +5452,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-processor"
|
||||
version = "1.10.0"
|
||||
version = "1.11.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"lime-core",
|
||||
@@ -5471,7 +5471,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-providers"
|
||||
version = "1.10.0"
|
||||
version = "1.11.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
@@ -5526,7 +5526,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-server"
|
||||
version = "1.10.0"
|
||||
version = "1.11.0"
|
||||
dependencies = [
|
||||
"aster-core",
|
||||
"async-stream",
|
||||
@@ -5571,7 +5571,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-server-utils"
|
||||
version = "1.10.0"
|
||||
version = "1.11.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"futures",
|
||||
@@ -5586,7 +5586,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-services"
|
||||
version = "1.10.0"
|
||||
version = "1.11.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"aster-core",
|
||||
@@ -5628,7 +5628,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-skills"
|
||||
version = "1.10.0"
|
||||
version = "1.11.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dirs 5.0.1",
|
||||
@@ -5646,7 +5646,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-terminal"
|
||||
version = "1.10.0"
|
||||
version = "1.11.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
@@ -5673,7 +5673,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-websocket"
|
||||
version = "1.10.0"
|
||||
version = "1.11.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
|
||||
@@ -4,7 +4,7 @@ exclude = ["crates/aster", "crates/aster-models", "crates/aster-rust"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "1.10.0"
|
||||
version = "1.11.0"
|
||||
edition = "2021"
|
||||
authors = ["coso"]
|
||||
repository = "https://github.com/aiclientproxy/lime"
|
||||
@@ -189,7 +189,7 @@ version = "2.4"
|
||||
|
||||
[package]
|
||||
name = "lime"
|
||||
version = "1.10.0"
|
||||
version = "1.11.0"
|
||||
description = "AI API Proxy Desktop App"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -30,11 +30,11 @@ use crate::tool_io_offload::{maybe_offload_tool_arguments, maybe_offload_tool_re
|
||||
const JSON_RECURSION_LIMIT: usize = 50;
|
||||
const JSON_TRAVERSAL_NODE_LIMIT: usize = 4_096;
|
||||
const TOOL_RESULT_MAX_TEXT_PARTS: usize = 256;
|
||||
const TOOL_RESULT_MAX_OUTPUT_CHARS: usize = 16_000;
|
||||
const TOOL_RESULT_MAX_OUTPUT_CHARS: usize = 4_000;
|
||||
const TOOL_RESULT_MAX_IMAGES: usize = 12;
|
||||
const TOOL_RESULT_TRUNCATED_NOTICE: &str = "\n\n[event_converter] 工具输出已截断";
|
||||
const TOOL_RESULT_DIAG_WARN_JSON_BYTES: usize = 64 * 1024;
|
||||
const TOOL_RESULT_DIAG_WARN_OUTPUT_CHARS: usize = 8_000;
|
||||
const TOOL_RESULT_DIAG_WARN_OUTPUT_CHARS: usize = 4_000;
|
||||
const TOOL_RESULT_DIAG_WARN_IMAGE_COUNT: usize = 4;
|
||||
const ASK_USER_QUESTIONS_SCHEMA_KEY: &str = "x-lime-ask-user-questions";
|
||||
|
||||
|
||||
@@ -158,9 +158,9 @@ pub use team_runtime_governor::{
|
||||
};
|
||||
pub use tools::{BrowserAction, BrowserTool, BrowserToolError, BrowserToolResult};
|
||||
pub use turn_input_envelope::{
|
||||
TurnDiagnosticsSnapshot, TurnInputEnvelope, TurnInputEnvelopeBuilder, TurnMessageHistorySource,
|
||||
TurnPromptAugmentationStage, TurnPromptAugmentationStageKind, TurnProviderRoutingSnapshot,
|
||||
TurnRequestToolPolicySnapshot, TurnSystemPromptSource,
|
||||
TurnDiagnosticsSnapshot, TurnExecutionProfile, TurnInputEnvelope, TurnInputEnvelopeBuilder,
|
||||
TurnMessageHistorySource, TurnPromptAugmentationStage, TurnPromptAugmentationStageKind,
|
||||
TurnProviderRoutingSnapshot, TurnRequestToolPolicySnapshot, TurnSystemPromptSource,
|
||||
};
|
||||
pub use turn_state::TurnState;
|
||||
pub use write_artifact_events::{build_write_tool_artifact_events, WriteArtifactEventEmitter};
|
||||
|
||||
@@ -51,10 +51,13 @@ pub const TOOL_GUIDELINES: &str = r#"# 工具使用策略
|
||||
## 使用原则
|
||||
|
||||
1. **优先使用专用工具**:文件操作使用 Read/Write/Edit,不要用 Bash 的 cat/echo
|
||||
2. **并行调用**:如果多个工具调用之间没有依赖关系,应该并行调用
|
||||
3. **先读后改**:修改文件前必须先读取文件内容
|
||||
4. **最小权限**:只执行必要的操作,避免不必要的文件修改
|
||||
5. **独立子问题再委派**:只有当任务需要隔离上下文、并行探索或分离执行时,才使用 team runtime 工具;优先 `Agent`,不要恢复旧工具名或额外平行入口"#;
|
||||
2. **先交代再调用**:第一次工具调用前,先用 1 句话说明这一批准备确认什么,不要无声进入长链工具调用
|
||||
3. **并行调用**:如果多个工具调用之间没有依赖关系,应该在同一条回复里一次性发起多个工具调用,让运行时并行执行;本地仓库分析时,独立的 Read / Glob / Grep / Bash(只读) 侦查优先收敛成 2 到 4 个一批
|
||||
4. **批后先给过程结论**:每完成一批工具调用,如果还要继续,先直接用 1 到 2 句话说明已经确认了什么、还缺什么、为什么继续;不要额外输出“阶段结论”标题,再决定下一批;不要连续多轮只丢工具而不给过程结论
|
||||
5. **先读后改**:修改文件前必须先读取文件内容
|
||||
6. **最小权限**:只执行必要的操作,避免不必要的文件修改
|
||||
7. **独立子问题再委派**:只有当任务需要隔离上下文、并行探索或分离执行时,才使用 team runtime 工具;优先 `Agent`,不要恢复旧工具名或额外平行入口
|
||||
8. **不要猜文件路径**:当你不确定某个文件是否真的存在、是否就在仓库根目录时,先用 `Glob` / `Grep` / `Read` / `Bash(ls)` 确认父目录,再去读文件;如果某次读取因路径不存在失败,先修正路径,再继续下一批工具"#;
|
||||
|
||||
/// 代码编写指南
|
||||
pub const CODING_GUIDELINES: &str = r#"# 代码编写指南
|
||||
|
||||
@@ -271,6 +271,7 @@ pub enum AgentEvent {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct AgentImageAttachment {
|
||||
pub data: String,
|
||||
#[serde(alias = "mediaType")]
|
||||
pub media_type: String,
|
||||
}
|
||||
|
||||
@@ -412,6 +413,17 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_image_attachment_deserializes_media_type_alias() {
|
||||
let attachment: AgentImageAttachment = serde_json::from_value(serde_json::json!({
|
||||
"data": "aGVsbG8=",
|
||||
"mediaType": "image/png"
|
||||
}))
|
||||
.expect("deserialize image attachment");
|
||||
|
||||
assert_eq!(attachment.media_type, "image/png");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_event_runtime_status_serializes_with_protocol_tag() {
|
||||
let value = serde_json::to_value(AgentEvent::RuntimeStatus {
|
||||
|
||||
@@ -25,6 +25,8 @@ pub const REQUEST_TOOL_POLICY_MARKER: &str = "【请求级工具策略】";
|
||||
pub const WEB_SEARCH_PREFETCH_CONTEXT_MARKER: &str = "【联网预检索上下文】";
|
||||
pub const WEB_SEARCH_SYNTHESIS_MARKER: &str = "【预检索后输出要求】";
|
||||
|
||||
const EMPTY_REPLY_DIRECT_ANSWER_RETRY_PROMPT: &str = "请继续。你上一条回复没有输出任何内容。不要重复调用工具,直接基于当前上下文给出最终答复;如果当前确实无法继续,请明确说明原因。";
|
||||
const INCOMPLETE_TOOL_BATCH_CONTINUE_PROMPT: &str = "请继续。你上一条回复还是中间过程结论,不是最终答复。若仍缺关键证据,请立刻继续下一批必要工具调用;证据足够后直接给出完整结论。不要停在“还需要继续查看/读取/确认”的中间态,也不要重复上一批已经完成的工具。";
|
||||
const DEFAULT_REQUIRED_TOOLS: &[&str] = &["WebSearch"];
|
||||
const DEFAULT_ALLOWED_TOOLS: &[&str] = &["WebSearch", "WebFetch"];
|
||||
const WEB_SEARCH_REQUIRED_TOOLS_ENV_KEYS: &[&str] = &[
|
||||
@@ -372,6 +374,14 @@ pub struct StreamReplyExecution {
|
||||
pub attempts_summary: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum ReplyRetryMode {
|
||||
None,
|
||||
WebSearchSynthesis,
|
||||
DirectAnswer,
|
||||
IntermediateConclusion,
|
||||
}
|
||||
|
||||
fn build_empty_final_reply_fallback(
|
||||
diagnostics: &StreamEventDiagnostics,
|
||||
emitted_any: bool,
|
||||
@@ -380,11 +390,7 @@ fn build_empty_final_reply_fallback(
|
||||
return None;
|
||||
}
|
||||
|
||||
if diagnostics.tool_start_count > 0 || diagnostics.tool_end_count > 0 {
|
||||
return Some("本轮执行已完成,详细过程与产物已保留在当前对话中。".to_string());
|
||||
}
|
||||
|
||||
Some("本轮执行已结束,过程记录已保留在当前对话中。".to_string())
|
||||
build_output_preserved_reply_fallback(diagnostics)
|
||||
}
|
||||
|
||||
fn read_lookup_string<'a, F>(keys: &[&str], mut lookup: F) -> Option<String>
|
||||
@@ -455,7 +461,7 @@ fn should_downgrade_provider_tail_failure(
|
||||
.trim()
|
||||
.to_ascii_lowercase()
|
||||
.starts_with("agent provider execution failed:")
|
||||
&& (diagnostics.saved_site_content_count > 0 || diagnostics.persisted_artifact_count > 0)
|
||||
&& build_output_preserved_reply_fallback(diagnostics).is_some()
|
||||
}
|
||||
|
||||
fn build_output_preserved_reply_fallback(diagnostics: &StreamEventDiagnostics) -> Option<String> {
|
||||
@@ -623,20 +629,26 @@ pub fn resolve_request_tool_policy_with_mode(
|
||||
_ => RequestToolPolicyMode::Disabled,
|
||||
};
|
||||
let effective_web_search = search_mode.enables_web_search();
|
||||
let required_tools =
|
||||
parse_tool_list_env(WEB_SEARCH_REQUIRED_TOOLS_ENV_KEYS, DEFAULT_REQUIRED_TOOLS);
|
||||
let mut allowed_tools =
|
||||
parse_tool_list_env(WEB_SEARCH_ALLOWED_TOOLS_ENV_KEYS, DEFAULT_ALLOWED_TOOLS);
|
||||
let disallowed_tools = parse_tool_list_env(WEB_SEARCH_DISALLOWED_TOOLS_ENV_KEYS, &[]);
|
||||
let (required_tools, allowed_tools) = if effective_web_search {
|
||||
let required_tools =
|
||||
parse_tool_list_env(WEB_SEARCH_REQUIRED_TOOLS_ENV_KEYS, DEFAULT_REQUIRED_TOOLS);
|
||||
let mut allowed_tools =
|
||||
parse_tool_list_env(WEB_SEARCH_ALLOWED_TOOLS_ENV_KEYS, DEFAULT_ALLOWED_TOOLS);
|
||||
|
||||
for required in &required_tools {
|
||||
if !allowed_tools
|
||||
.iter()
|
||||
.any(|candidate| is_same_tool(candidate, required))
|
||||
{
|
||||
allowed_tools.push(required.clone());
|
||||
for required in &required_tools {
|
||||
if !allowed_tools
|
||||
.iter()
|
||||
.any(|candidate| is_same_tool(candidate, required))
|
||||
{
|
||||
allowed_tools.push(required.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(required_tools, allowed_tools)
|
||||
} else {
|
||||
(Vec::new(), Vec::new())
|
||||
};
|
||||
|
||||
RequestToolPolicy {
|
||||
search_mode,
|
||||
@@ -1210,6 +1222,36 @@ fn merge_system_prompt_with_web_search_synthesis_instruction(
|
||||
}
|
||||
}
|
||||
|
||||
fn build_empty_reply_retry_runtime_status() -> AgentRuntimeStatus {
|
||||
AgentRuntimeStatus {
|
||||
phase: "retrying".to_string(),
|
||||
title: "正在重试生成答复".to_string(),
|
||||
detail: "模型上一轮没有输出任何内容,正在基于当前上下文补发最终答复,不重复执行工具。"
|
||||
.to_string(),
|
||||
checkpoints: vec![
|
||||
"首轮流式回复未产出正文".to_string(),
|
||||
"当前轮次未检测到真实工具产物".to_string(),
|
||||
"正在直接补发最终答复".to_string(),
|
||||
],
|
||||
metadata: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_incomplete_tool_batch_continue_runtime_status() -> AgentRuntimeStatus {
|
||||
AgentRuntimeStatus {
|
||||
phase: "continuing".to_string(),
|
||||
title: "正在补齐剩余证据".to_string(),
|
||||
detail: "检测到上一轮只给出了中间过程结论,正在继续推进下一批必要工具或整理最终结论。"
|
||||
.to_string(),
|
||||
checkpoints: vec![
|
||||
"已完成上一批工具调用".to_string(),
|
||||
"当前答复仍停留在中间过程结论".to_string(),
|
||||
"继续推进直到形成完整答复".to_string(),
|
||||
],
|
||||
metadata: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_web_search_synthesis_runtime_status(coverage_summary: Option<&str>) -> AgentRuntimeStatus {
|
||||
let mut checkpoints = vec![
|
||||
"已完成 WebSearch 预检索".to_string(),
|
||||
@@ -1282,18 +1324,146 @@ async fn emit_runtime_status_with_projection<F>(
|
||||
on_event(&event);
|
||||
}
|
||||
|
||||
fn should_retry_after_empty_reply(
|
||||
preflight_execution: &PreflightToolExecution,
|
||||
current_text_output: &str,
|
||||
tracker: &WebSearchExecutionTracker,
|
||||
) -> bool {
|
||||
if !current_text_output.trim().is_empty() {
|
||||
fn looks_like_incomplete_tool_batch_summary(text: &str) -> bool {
|
||||
let normalized = text.trim();
|
||||
if normalized.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
preflight_execution.system_prompt_appendix.is_some()
|
||||
let normalized = normalized.replace("\r\n", "\n");
|
||||
let paragraphs = normalized
|
||||
.split("\n\n")
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
let detection_window = if paragraphs.is_empty() {
|
||||
normalized
|
||||
} else {
|
||||
let start = paragraphs.len().saturating_sub(2);
|
||||
paragraphs[start..].join("\n\n")
|
||||
};
|
||||
let detection_window = {
|
||||
let char_count = detection_window.chars().count();
|
||||
if char_count <= 320 {
|
||||
detection_window
|
||||
} else {
|
||||
detection_window
|
||||
.chars()
|
||||
.skip(char_count - 320)
|
||||
.collect::<String>()
|
||||
}
|
||||
};
|
||||
let normalized = detection_window.trim();
|
||||
if normalized.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let strong_markers = [
|
||||
"还需要",
|
||||
"现在需要",
|
||||
"下一步需要",
|
||||
"接下来需要",
|
||||
"仍需",
|
||||
"还缺",
|
||||
"仍缺",
|
||||
"继续读取",
|
||||
"继续查看",
|
||||
"继续检查",
|
||||
"继续对比",
|
||||
"继续确认",
|
||||
];
|
||||
if strong_markers
|
||||
.iter()
|
||||
.any(|marker| normalized.contains(marker))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
normalized.contains("才能")
|
||||
&& [
|
||||
"读取",
|
||||
"查看",
|
||||
"检查",
|
||||
"对比",
|
||||
"确认",
|
||||
"补齐",
|
||||
"补一个证据点",
|
||||
]
|
||||
.iter()
|
||||
.any(|marker| normalized.contains(marker))
|
||||
}
|
||||
|
||||
fn resolve_reply_retry_mode(
|
||||
preflight_execution: &PreflightToolExecution,
|
||||
current_text_output: &str,
|
||||
tracker: &WebSearchExecutionTracker,
|
||||
diagnostics: &StreamEventDiagnostics,
|
||||
event_errors: &[String],
|
||||
) -> ReplyRetryMode {
|
||||
if !event_errors.is_empty() {
|
||||
return ReplyRetryMode::None;
|
||||
}
|
||||
|
||||
let trimmed_text_output = current_text_output.trim();
|
||||
if !trimmed_text_output.is_empty()
|
||||
&& diagnostics.tool_start_count > 0
|
||||
&& diagnostics.tool_end_count > 0
|
||||
&& looks_like_incomplete_tool_batch_summary(trimmed_text_output)
|
||||
{
|
||||
return ReplyRetryMode::IntermediateConclusion;
|
||||
}
|
||||
|
||||
if !trimmed_text_output.is_empty() {
|
||||
return ReplyRetryMode::None;
|
||||
}
|
||||
|
||||
if preflight_execution.system_prompt_appendix.is_some()
|
||||
|| preflight_execution.expanded_news_search
|
||||
|| !tracker.ordered_tool_ids.is_empty()
|
||||
{
|
||||
return ReplyRetryMode::WebSearchSynthesis;
|
||||
}
|
||||
|
||||
if diagnostics.tool_start_count == 0
|
||||
&& diagnostics.tool_end_count == 0
|
||||
&& diagnostics.saved_site_content_count == 0
|
||||
&& diagnostics.persisted_artifact_count == 0
|
||||
{
|
||||
return ReplyRetryMode::DirectAnswer;
|
||||
}
|
||||
|
||||
ReplyRetryMode::None
|
||||
}
|
||||
|
||||
fn build_empty_final_reply_attempts_summary(
|
||||
diagnostics: &StreamEventDiagnostics,
|
||||
tracker: &WebSearchExecutionTracker,
|
||||
) -> String {
|
||||
if !tracker.ordered_tool_ids.is_empty() {
|
||||
return tracker.format_attempts();
|
||||
}
|
||||
|
||||
if diagnostics.tool_start_count > 0 || diagnostics.tool_end_count > 0 {
|
||||
return format!(
|
||||
"已执行非联网工具(tool_start={}, tool_end={})",
|
||||
diagnostics.tool_start_count, diagnostics.tool_end_count
|
||||
);
|
||||
}
|
||||
|
||||
"无工具调用".to_string()
|
||||
}
|
||||
|
||||
fn build_empty_final_reply_error_message(
|
||||
diagnostics: &StreamEventDiagnostics,
|
||||
tracker: &WebSearchExecutionTracker,
|
||||
) -> String {
|
||||
let attempts_summary = build_empty_final_reply_attempts_summary(diagnostics, tracker);
|
||||
|
||||
if diagnostics.tool_start_count == 0 && diagnostics.tool_end_count == 0 {
|
||||
format!("模型未输出最终答复,且未执行任何工具。\n尝试记录: {attempts_summary}")
|
||||
} else {
|
||||
format!("已完成当前回合的工具执行,但模型未输出最终答复。\n尝试记录: {attempts_summary}")
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -1690,9 +1860,10 @@ where
|
||||
let fallback_text = text_chunks.join("").trim().to_string();
|
||||
return Ok(StreamReplyExecution {
|
||||
text_output: if fallback_text.is_empty() {
|
||||
build_output_preserved_reply_fallback(&diagnostics).unwrap_or_else(|| {
|
||||
"本轮执行已完成,详细过程与产物已保留在当前对话中。".to_string()
|
||||
})
|
||||
match build_output_preserved_reply_fallback(&diagnostics) {
|
||||
Some(output) => output,
|
||||
None => return Err(error),
|
||||
}
|
||||
} else {
|
||||
fallback_text
|
||||
},
|
||||
@@ -1705,63 +1876,178 @@ where
|
||||
}
|
||||
|
||||
let current_text_output = text_chunks.join("");
|
||||
if should_retry_after_empty_reply(
|
||||
match resolve_reply_retry_mode(
|
||||
&preflight_execution,
|
||||
¤t_text_output,
|
||||
&web_search_tracker,
|
||||
&diagnostics,
|
||||
&event_errors,
|
||||
) {
|
||||
tracing::warn!(
|
||||
"[AsterAgent][WebSearchPrefetch] empty final text after preflight, retrying synthesis: session={}, attempts={}",
|
||||
session_config.id,
|
||||
web_search_tracker.format_attempts()
|
||||
);
|
||||
emit_runtime_status_with_projection(
|
||||
agent,
|
||||
&session_config,
|
||||
build_web_search_synthesis_runtime_status(
|
||||
preflight_execution.coverage_summary.as_deref(),
|
||||
),
|
||||
&mut on_event,
|
||||
)
|
||||
.await;
|
||||
session_config.system_prompt = merge_system_prompt_with_web_search_synthesis_instruction(
|
||||
session_config.system_prompt.take(),
|
||||
);
|
||||
let retry_attempt = stream_agent_reply_once(
|
||||
agent,
|
||||
Message::user().with_text(WEB_SEARCH_EMPTY_REPLY_RETRY_PROMPT),
|
||||
duplicate_session_config(&session_config),
|
||||
cancel_token,
|
||||
request_tool_policy,
|
||||
&mut web_search_tracker,
|
||||
&mut write_artifact_emitter,
|
||||
&mut emitted_any,
|
||||
&mut text_chunks,
|
||||
&mut event_errors,
|
||||
&mut diagnostics,
|
||||
&mut on_event,
|
||||
)
|
||||
.await;
|
||||
if let Err(error) = retry_attempt {
|
||||
if should_downgrade_provider_tail_failure(&error.message, &diagnostics, emitted_any) {
|
||||
tracing::warn!(
|
||||
"[AsterAgent][ReplyPolicy] provider tail failure downgraded after retry with persisted output: tools={}, artifacts={}, saved_site={}",
|
||||
diagnostics.tool_end_count,
|
||||
diagnostics.persisted_artifact_count,
|
||||
diagnostics.saved_site_content_count
|
||||
ReplyRetryMode::WebSearchSynthesis => {
|
||||
tracing::warn!(
|
||||
"[AsterAgent][WebSearchPrefetch] empty final text after preflight, retrying synthesis: session={}, attempts={}",
|
||||
session_config.id,
|
||||
web_search_tracker.format_attempts()
|
||||
);
|
||||
emit_runtime_status_with_projection(
|
||||
agent,
|
||||
&session_config,
|
||||
build_web_search_synthesis_runtime_status(
|
||||
preflight_execution.coverage_summary.as_deref(),
|
||||
),
|
||||
&mut on_event,
|
||||
)
|
||||
.await;
|
||||
session_config.system_prompt =
|
||||
merge_system_prompt_with_web_search_synthesis_instruction(
|
||||
session_config.system_prompt.take(),
|
||||
);
|
||||
return Ok(StreamReplyExecution {
|
||||
text_output: build_output_preserved_reply_fallback(&diagnostics)
|
||||
.unwrap_or_else(|| {
|
||||
"本轮执行已完成,详细过程与产物已保留在当前对话中。".to_string()
|
||||
}),
|
||||
event_errors,
|
||||
emitted_any,
|
||||
attempts_summary: web_search_tracker.format_attempts(),
|
||||
});
|
||||
let retry_attempt = stream_agent_reply_once(
|
||||
agent,
|
||||
Message::user()
|
||||
.with_text(WEB_SEARCH_EMPTY_REPLY_RETRY_PROMPT)
|
||||
.agent_only(),
|
||||
duplicate_session_config(&session_config),
|
||||
cancel_token,
|
||||
request_tool_policy,
|
||||
&mut web_search_tracker,
|
||||
&mut write_artifact_emitter,
|
||||
&mut emitted_any,
|
||||
&mut text_chunks,
|
||||
&mut event_errors,
|
||||
&mut diagnostics,
|
||||
&mut on_event,
|
||||
)
|
||||
.await;
|
||||
if let Err(error) = retry_attempt {
|
||||
if should_downgrade_provider_tail_failure(&error.message, &diagnostics, emitted_any)
|
||||
{
|
||||
tracing::warn!(
|
||||
"[AsterAgent][ReplyPolicy] provider tail failure downgraded after retry with persisted output: tools={}, artifacts={}, saved_site={}",
|
||||
diagnostics.tool_end_count,
|
||||
diagnostics.persisted_artifact_count,
|
||||
diagnostics.saved_site_content_count
|
||||
);
|
||||
let Some(fallback_text) = build_output_preserved_reply_fallback(&diagnostics)
|
||||
else {
|
||||
return Err(error);
|
||||
};
|
||||
return Ok(StreamReplyExecution {
|
||||
text_output: fallback_text,
|
||||
event_errors,
|
||||
emitted_any,
|
||||
attempts_summary: web_search_tracker.format_attempts(),
|
||||
});
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
ReplyRetryMode::DirectAnswer => {
|
||||
tracing::warn!(
|
||||
"[AsterAgent][ReplyPolicy] empty final text without tool activity, retrying direct answer: session={}",
|
||||
session_config.id
|
||||
);
|
||||
emit_runtime_status_with_projection(
|
||||
agent,
|
||||
&session_config,
|
||||
build_empty_reply_retry_runtime_status(),
|
||||
&mut on_event,
|
||||
)
|
||||
.await;
|
||||
let retry_attempt = stream_agent_reply_once(
|
||||
agent,
|
||||
Message::user()
|
||||
.with_text(EMPTY_REPLY_DIRECT_ANSWER_RETRY_PROMPT)
|
||||
.agent_only(),
|
||||
duplicate_session_config(&session_config),
|
||||
cancel_token,
|
||||
request_tool_policy,
|
||||
&mut web_search_tracker,
|
||||
&mut write_artifact_emitter,
|
||||
&mut emitted_any,
|
||||
&mut text_chunks,
|
||||
&mut event_errors,
|
||||
&mut diagnostics,
|
||||
&mut on_event,
|
||||
)
|
||||
.await;
|
||||
if let Err(error) = retry_attempt {
|
||||
if should_downgrade_provider_tail_failure(&error.message, &diagnostics, emitted_any)
|
||||
{
|
||||
tracing::warn!(
|
||||
"[AsterAgent][ReplyPolicy] provider tail failure downgraded after empty-reply retry with persisted output: tools={}, artifacts={}, saved_site={}",
|
||||
diagnostics.tool_end_count,
|
||||
diagnostics.persisted_artifact_count,
|
||||
diagnostics.saved_site_content_count
|
||||
);
|
||||
let Some(fallback_text) = build_output_preserved_reply_fallback(&diagnostics)
|
||||
else {
|
||||
return Err(error);
|
||||
};
|
||||
return Ok(StreamReplyExecution {
|
||||
text_output: fallback_text,
|
||||
event_errors,
|
||||
emitted_any,
|
||||
attempts_summary: web_search_tracker.format_attempts(),
|
||||
});
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
ReplyRetryMode::IntermediateConclusion => {
|
||||
tracing::warn!(
|
||||
"[AsterAgent][ReplyPolicy] tool batch ended with intermediate conclusion, retrying continuation: session={}, tools={}",
|
||||
session_config.id,
|
||||
diagnostics.tool_end_count
|
||||
);
|
||||
emit_runtime_status_with_projection(
|
||||
agent,
|
||||
&session_config,
|
||||
build_incomplete_tool_batch_continue_runtime_status(),
|
||||
&mut on_event,
|
||||
)
|
||||
.await;
|
||||
let retry_attempt = stream_agent_reply_once(
|
||||
agent,
|
||||
Message::user()
|
||||
.with_text(INCOMPLETE_TOOL_BATCH_CONTINUE_PROMPT)
|
||||
.agent_only(),
|
||||
duplicate_session_config(&session_config),
|
||||
cancel_token,
|
||||
request_tool_policy,
|
||||
&mut web_search_tracker,
|
||||
&mut write_artifact_emitter,
|
||||
&mut emitted_any,
|
||||
&mut text_chunks,
|
||||
&mut event_errors,
|
||||
&mut diagnostics,
|
||||
&mut on_event,
|
||||
)
|
||||
.await;
|
||||
if let Err(error) = retry_attempt {
|
||||
if should_downgrade_provider_tail_failure(&error.message, &diagnostics, emitted_any)
|
||||
{
|
||||
tracing::warn!(
|
||||
"[AsterAgent][ReplyPolicy] provider tail failure downgraded after intermediate-conclusion retry with persisted output: tools={}, artifacts={}, saved_site={}",
|
||||
diagnostics.tool_end_count,
|
||||
diagnostics.persisted_artifact_count,
|
||||
diagnostics.saved_site_content_count
|
||||
);
|
||||
let Some(fallback_text) = build_output_preserved_reply_fallback(&diagnostics)
|
||||
else {
|
||||
return Err(error);
|
||||
};
|
||||
return Ok(StreamReplyExecution {
|
||||
text_output: fallback_text,
|
||||
event_errors,
|
||||
emitted_any,
|
||||
attempts_summary: web_search_tracker.format_attempts(),
|
||||
});
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
ReplyRetryMode::None => {}
|
||||
}
|
||||
|
||||
if let Err(validation_error) =
|
||||
@@ -1809,10 +2095,7 @@ where
|
||||
});
|
||||
}
|
||||
return Err(ReplyAttemptError {
|
||||
message: format!(
|
||||
"已完成当前回合的工具执行,但模型未输出最终答复。\n尝试记录: {}",
|
||||
web_search_tracker.format_attempts()
|
||||
),
|
||||
message: build_empty_final_reply_error_message(&diagnostics, &web_search_tracker),
|
||||
emitted_any,
|
||||
});
|
||||
}
|
||||
@@ -1855,12 +2138,13 @@ fn derive_preflight_query(message_text: &str) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use aster::providers::base::{Provider, ProviderMetadata, ProviderUsage};
|
||||
use aster::providers::base::{Provider, ProviderMetadata, ProviderUsage, Usage};
|
||||
use aster::providers::errors::ProviderError;
|
||||
use aster::session::{SessionManager, SessionType, TurnContextOverride};
|
||||
use async_trait::async_trait;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
struct ContextLengthExceededProvider;
|
||||
@@ -1895,6 +2179,48 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
struct EmptyReplyThenTextProvider {
|
||||
attempts: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for EmptyReplyThenTextProvider {
|
||||
fn metadata() -> ProviderMetadata
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
ProviderMetadata::empty()
|
||||
}
|
||||
|
||||
fn get_name(&self) -> &str {
|
||||
"empty-reply-then-text-provider"
|
||||
}
|
||||
|
||||
async fn complete_with_model(
|
||||
&self,
|
||||
_model_config: &aster::model::ModelConfig,
|
||||
_system: &str,
|
||||
_messages: &[Message],
|
||||
_tools: &[rmcp::model::Tool],
|
||||
) -> Result<(Message, ProviderUsage), ProviderError> {
|
||||
let attempt = self.attempts.fetch_add(1, Ordering::SeqCst);
|
||||
let message = if attempt == 0 {
|
||||
Message::assistant()
|
||||
} else {
|
||||
Message::assistant().with_text("这是补发的最终答复。")
|
||||
};
|
||||
|
||||
Ok((
|
||||
message,
|
||||
ProviderUsage::new("gpt-5.3-codex".to_string(), Usage::default()),
|
||||
))
|
||||
}
|
||||
|
||||
fn get_model_config(&self) -> aster::model::ModelConfig {
|
||||
aster::model::ModelConfig::new("gpt-5.3-codex").expect("test model config")
|
||||
}
|
||||
}
|
||||
|
||||
fn build_auto_compaction_disabled_turn_context() -> TurnContextOverride {
|
||||
let mut metadata = HashMap::new();
|
||||
metadata.insert(
|
||||
@@ -1914,12 +2240,71 @@ mod tests {
|
||||
let policy = resolve_request_tool_policy(Some(false), true);
|
||||
assert!(!policy.effective_web_search);
|
||||
assert_eq!(policy.search_mode, RequestToolPolicyMode::Disabled);
|
||||
assert!(policy.required_tools.is_empty());
|
||||
assert!(policy.allowed_tools.is_empty());
|
||||
|
||||
let policy = resolve_request_tool_policy(Some(true), false);
|
||||
assert!(policy.effective_web_search);
|
||||
assert_eq!(policy.search_mode, RequestToolPolicyMode::Allowed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_incomplete_tool_batch_summary_text() {
|
||||
assert!(looks_like_incomplete_tool_batch_summary(
|
||||
"已确认 claudecode/src/tasks 下有 7 种 Task 类型。现在需要读取核心类型定义、调度框架和几个关键子 Task 的入口,才能和 Lime 的 task 系统做准确对比。"
|
||||
));
|
||||
assert!(looks_like_incomplete_tool_batch_summary(
|
||||
"当前已经定位主入口,但还需要继续查看 task 调度和状态映射。"
|
||||
));
|
||||
assert!(looks_like_incomplete_tool_batch_summary(
|
||||
"已确认主入口,但还需要继续查看 task 调度和状态映射。\n\n如果你希望我继续,我可以马上深入这两个模块。"
|
||||
));
|
||||
assert!(!looks_like_incomplete_tool_batch_summary(
|
||||
"我已经完成对比。Claude Code 的任务面板更轻量,Lime 当前主要差异集中在任务展示位置、批次工具摘要和继续策略。"
|
||||
));
|
||||
assert!(!looks_like_incomplete_tool_batch_summary(
|
||||
"已获得完整文件树,这是一个很大的 Claude Code CLI 项目。接下来需要看核心入口文件和关键模块来理解架构,才能对比 Lime 的优化点。\n\n## 一、Claude Code 项目概览\n这是 Claude Code CLI 的源码,主循环、工具注册、Task 系统与 compact 都已经识别清楚。\n\n## 二、Lime 当前还能继续对标优化的点\n优先补自动 compact、任务 runtime 和权限边界,然后再做长链路体验优化。"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_retry_mode_for_incomplete_tool_batch_summary() {
|
||||
let diagnostics = StreamEventDiagnostics {
|
||||
tool_start_count: 2,
|
||||
tool_end_count: 2,
|
||||
..StreamEventDiagnostics::default()
|
||||
};
|
||||
|
||||
let mode = resolve_reply_retry_mode(
|
||||
&PreflightToolExecution::none(),
|
||||
"已确认 claudecode/src/tasks 下有 7 种 Task 类型。现在需要读取核心类型定义,才能和 Lime 的 task 系统做准确对比。",
|
||||
&WebSearchExecutionTracker::default(),
|
||||
&diagnostics,
|
||||
&[],
|
||||
);
|
||||
|
||||
assert_eq!(mode, ReplyRetryMode::IntermediateConclusion);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_retry_when_final_answer_follows_intermediate_process_summary() {
|
||||
let diagnostics = StreamEventDiagnostics {
|
||||
tool_start_count: 4,
|
||||
tool_end_count: 4,
|
||||
..StreamEventDiagnostics::default()
|
||||
};
|
||||
|
||||
let mode = resolve_reply_retry_mode(
|
||||
&PreflightToolExecution::none(),
|
||||
"已获得完整文件树,这是一个非常大的 Claude Code CLI 项目。接下来需要看核心入口文件和关键模块来理解架构,才能对比 Lime 的优化点。\n\n## 一、Claude Code 项目概览\n这是 Anthropic 官方的 Claude Code CLI 源码,主循环、工具体系、Task 系统和 compact 模块都已经识别清楚。\n\n## 二、Lime 当前还能继续对标优化的点\n优先补自动 compact、权限规则引擎和统一任务 runtime,再继续补子代理隔离与长链路体验。",
|
||||
&WebSearchExecutionTracker::default(),
|
||||
&diagnostics,
|
||||
&[],
|
||||
);
|
||||
|
||||
assert_eq!(mode, ReplyRetryMode::None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_effective_web_search_with_mode_default() {
|
||||
let policy = resolve_request_tool_policy(None, true);
|
||||
@@ -1940,6 +2325,24 @@ mod tests {
|
||||
);
|
||||
assert!(policy.effective_web_search);
|
||||
assert!(policy.requires_web_search());
|
||||
assert!(policy.matches_any_required_tool("WebSearch"));
|
||||
assert!(policy.matches_any_allowed_tool("WebFetch"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_mode_should_not_expose_web_search_tool_surface() {
|
||||
let policy = resolve_request_tool_policy_with_mode(
|
||||
None,
|
||||
Some(RequestToolPolicyMode::Disabled),
|
||||
true,
|
||||
);
|
||||
|
||||
assert_eq!(policy.search_mode, RequestToolPolicyMode::Disabled);
|
||||
assert!(!policy.effective_web_search);
|
||||
assert!(policy.required_tools.is_empty());
|
||||
assert!(policy.allowed_tools.is_empty());
|
||||
assert!(!policy.matches_any_required_tool("WebSearch"));
|
||||
assert!(!policy.matches_any_allowed_tool("WebFetch"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2027,17 +2430,14 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_final_reply_with_tool_events_should_use_fallback_text() {
|
||||
fn empty_final_reply_with_only_tool_events_should_not_fallback() {
|
||||
let diagnostics = StreamEventDiagnostics {
|
||||
tool_start_count: 1,
|
||||
tool_end_count: 1,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
build_empty_final_reply_fallback(&diagnostics, true).as_deref(),
|
||||
Some("本轮执行已完成,详细过程与产物已保留在当前对话中。")
|
||||
);
|
||||
assert_eq!(build_empty_final_reply_fallback(&diagnostics, true), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2047,6 +2447,48 @@ mod tests {
|
||||
assert_eq!(build_empty_final_reply_fallback(&diagnostics, false), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_final_reply_with_saved_site_output_should_use_preserved_output_fallback() {
|
||||
let diagnostics = StreamEventDiagnostics {
|
||||
saved_site_content_count: 1,
|
||||
last_saved_markdown_path: Some("exports/x-article-export/article/index.md".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
build_empty_final_reply_fallback(&diagnostics, true).as_deref(),
|
||||
Some(
|
||||
"本轮站点内容已成功保存到项目文件中(Markdown:exports/x-article-export/article/index.md)。由于模型通道暂时不可用,未能补充最终总结;详细过程与产物已保留在当前对话中。"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_final_reply_without_tool_activity_should_report_precise_error() {
|
||||
let diagnostics = StreamEventDiagnostics::default();
|
||||
let tracker = WebSearchExecutionTracker::default();
|
||||
|
||||
assert_eq!(
|
||||
build_empty_final_reply_error_message(&diagnostics, &tracker),
|
||||
"模型未输出最终答复,且未执行任何工具。\n尝试记录: 无工具调用"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_final_reply_with_non_web_tools_should_not_claim_no_tool_calls() {
|
||||
let diagnostics = StreamEventDiagnostics {
|
||||
tool_start_count: 1,
|
||||
tool_end_count: 1,
|
||||
..Default::default()
|
||||
};
|
||||
let tracker = WebSearchExecutionTracker::default();
|
||||
|
||||
assert_eq!(
|
||||
build_empty_final_reply_error_message(&diagnostics, &tracker),
|
||||
"已完成当前回合的工具执行,但模型未输出最终答复。\n尝试记录: 已执行非联网工具(tool_start=1, tool_end=1)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_tail_failure_with_saved_site_content_should_downgrade() {
|
||||
let diagnostics = StreamEventDiagnostics {
|
||||
@@ -2311,4 +2753,63 @@ mod tests {
|
||||
"禁用自动压缩后,不应再投影 compaction completed"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stream_message_reply_with_policy_should_retry_empty_reply_without_tool_activity() {
|
||||
let session = SessionManager::create_session(
|
||||
PathBuf::default(),
|
||||
"lime-empty-reply-retry".to_string(),
|
||||
SessionType::Hidden,
|
||||
)
|
||||
.await
|
||||
.expect("应创建测试 session");
|
||||
let agent = Agent::new();
|
||||
let attempts = Arc::new(AtomicUsize::new(0));
|
||||
agent
|
||||
.update_provider(
|
||||
Arc::new(EmptyReplyThenTextProvider {
|
||||
attempts: attempts.clone(),
|
||||
}),
|
||||
&session.id,
|
||||
)
|
||||
.await
|
||||
.expect("应配置测试 provider");
|
||||
|
||||
let session_config = aster::agents::SessionConfig {
|
||||
id: session.id.clone(),
|
||||
thread_id: None,
|
||||
turn_id: Some("turn-empty-reply-retry".to_string()),
|
||||
schedule_id: None,
|
||||
max_turns: None,
|
||||
retry_config: None,
|
||||
system_prompt: None,
|
||||
include_context_trace: None,
|
||||
turn_context: None,
|
||||
};
|
||||
let policy = resolve_request_tool_policy(Some(false), false);
|
||||
let mut runtime_events = Vec::new();
|
||||
|
||||
let reply = stream_message_reply_with_policy(
|
||||
&agent,
|
||||
Message::user().with_text("帮我总结一下这个项目"),
|
||||
None,
|
||||
session_config,
|
||||
None,
|
||||
&policy,
|
||||
|event| runtime_events.push(event.clone()),
|
||||
)
|
||||
.await
|
||||
.expect("空答复后应自动重试并成功");
|
||||
|
||||
assert_eq!(reply.text_output, "这是补发的最终答复。");
|
||||
assert_eq!(attempts.load(Ordering::SeqCst), 2);
|
||||
assert!(
|
||||
runtime_events.iter().any(|event| matches!(
|
||||
event,
|
||||
RuntimeAgentEvent::RuntimeStatus { status }
|
||||
if status.title == "正在重试生成答复"
|
||||
)),
|
||||
"应向前端投影空答复重试状态"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ use futures::future::BoxFuture;
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
|
||||
const RUNTIME_TURN_THREAD_STACK_SIZE: usize = 8 * 1024 * 1024;
|
||||
|
||||
pub type RuntimeQueueExecutor<C> =
|
||||
Arc<dyn Fn(C, Value) -> BoxFuture<'static, Result<(), String>> + Send + Sync>;
|
||||
|
||||
@@ -35,22 +37,53 @@ fn spawn_runtime_turn_task<C>(
|
||||
) where
|
||||
C: Clone + Send + Sync + 'static,
|
||||
{
|
||||
tokio::spawn(async move {
|
||||
let result = executor(context.clone(), payload).await;
|
||||
if let Err(error) = continue_runtime_queue_after_turn(
|
||||
session_id,
|
||||
context.clone(),
|
||||
executor.clone(),
|
||||
emitter.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("[AsterAgent][Queue] 调度下一条排队 turn 失败: {}", error);
|
||||
}
|
||||
if let Err(error) = result {
|
||||
tracing::warn!("[AsterAgent][Queue] 队列任务执行失败: {}", error);
|
||||
}
|
||||
});
|
||||
let thread_name = format!("lime-runtime-turn-{}", session_id);
|
||||
let spawn_result = std::thread::Builder::new()
|
||||
.name(thread_name)
|
||||
.stack_size(RUNTIME_TURN_THREAD_STACK_SIZE)
|
||||
.spawn(move || {
|
||||
let runtime = match tokio::runtime::Builder::new_multi_thread()
|
||||
.worker_threads(1)
|
||||
.thread_name("lime-runtime-turn-worker")
|
||||
.thread_stack_size(RUNTIME_TURN_THREAD_STACK_SIZE)
|
||||
.enable_io()
|
||||
.enable_time()
|
||||
.build()
|
||||
{
|
||||
Ok(runtime) => runtime,
|
||||
Err(error) => {
|
||||
tracing::error!(
|
||||
"[AsterAgent][Queue] 创建 runtime turn 专用运行时失败: {}",
|
||||
error
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
runtime.block_on(async move {
|
||||
let result = executor(context.clone(), payload).await;
|
||||
if let Err(error) = continue_runtime_queue_after_turn(
|
||||
session_id,
|
||||
context.clone(),
|
||||
executor.clone(),
|
||||
emitter.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("[AsterAgent][Queue] 调度下一条排队 turn 失败: {}", error);
|
||||
}
|
||||
if let Err(error) = result {
|
||||
tracing::warn!("[AsterAgent][Queue] 队列任务执行失败: {}", error);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if let Err(error) = spawn_result {
|
||||
tracing::error!(
|
||||
"[AsterAgent][Queue] 启动 runtime turn 专用线程失败: {}",
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async fn continue_runtime_queue_after_turn<C>(
|
||||
|
||||
@@ -927,17 +927,51 @@ pub fn list_title_preview_messages_sync(
|
||||
limit: usize,
|
||||
) -> Result<Vec<SessionTitlePreviewMessage>, String> {
|
||||
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
|
||||
let messages = agent_session_repository::list_title_preview_messages(&conn, session_id, limit)?;
|
||||
if limit == 0 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
Ok(messages
|
||||
.into_iter()
|
||||
.map(
|
||||
|msg: SessionRecordPreviewMessage| SessionTitlePreviewMessage {
|
||||
role: msg.role,
|
||||
content: msg.content,
|
||||
},
|
||||
)
|
||||
.collect())
|
||||
match LimeSessionStore::load_conversation_from_conn(&conn, session_id) {
|
||||
Ok(conversation) => Ok(conversation
|
||||
.messages()
|
||||
.iter()
|
||||
.filter(|message| message.is_user_visible())
|
||||
.filter_map(|message| {
|
||||
let content = message.as_concat_text().trim().to_string();
|
||||
if content.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let role = if message.role == rmcp::model::Role::Assistant {
|
||||
"assistant".to_string()
|
||||
} else {
|
||||
"user".to_string()
|
||||
};
|
||||
|
||||
Some(SessionTitlePreviewMessage { role, content })
|
||||
})
|
||||
.take(limit)
|
||||
.collect()),
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
"[SessionStore] 标题预览读取可见性对话失败,已回退仓储消息: session_id={}, error={}",
|
||||
session_id,
|
||||
error
|
||||
);
|
||||
let messages =
|
||||
agent_session_repository::list_title_preview_messages(&conn, session_id, limit)?;
|
||||
|
||||
Ok(messages
|
||||
.into_iter()
|
||||
.map(
|
||||
|msg: SessionRecordPreviewMessage| SessionTitlePreviewMessage {
|
||||
role: msg.role,
|
||||
content: msg.content,
|
||||
},
|
||||
)
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取会话详情
|
||||
@@ -957,7 +991,21 @@ pub fn get_session_sync(db: &DbConnection, session_id: &str) -> Result<SessionDe
|
||||
let working_dir = session.working_dir.clone();
|
||||
let todo_items = load_session_todo_items_from_conn(&conn, session_id);
|
||||
|
||||
let tauri_messages = convert_agent_messages(&session.messages, Some(session.model.as_str()));
|
||||
let tauri_messages = match LimeSessionStore::load_conversation_from_conn(&conn, session_id) {
|
||||
Ok(conversation) => convert_user_visible_agent_messages(
|
||||
&session.messages,
|
||||
conversation.messages(),
|
||||
Some(session.model.as_str()),
|
||||
),
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
"[SessionStore] 读取可见性对话失败,已回退旧消息转换: session_id={}, error={}",
|
||||
session_id,
|
||||
error
|
||||
);
|
||||
convert_agent_messages(&session.messages, Some(session.model.as_str()))
|
||||
}
|
||||
};
|
||||
|
||||
tracing::debug!(
|
||||
"[SessionStore] 会话消息转换完成: session_id={}, messages_count={}",
|
||||
@@ -1288,6 +1336,29 @@ fn convert_agent_messages(
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn convert_user_visible_agent_messages(
|
||||
messages: &[AgentMessage],
|
||||
persisted_messages: &[aster::conversation::message::Message],
|
||||
model_name: Option<&str>,
|
||||
) -> Vec<RuntimeAgentMessage> {
|
||||
if messages.len() != persisted_messages.len() {
|
||||
tracing::warn!(
|
||||
"[SessionStore] user_visible 过滤失败,消息条数不一致: core={}, aster={}",
|
||||
messages.len(),
|
||||
persisted_messages.len()
|
||||
);
|
||||
return convert_agent_messages(messages, model_name);
|
||||
}
|
||||
|
||||
let filtered = messages
|
||||
.iter()
|
||||
.zip(persisted_messages.iter())
|
||||
.filter_map(|(message, persisted)| persisted.is_user_visible().then_some(message.clone()))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
convert_agent_messages(&filtered, model_name)
|
||||
}
|
||||
|
||||
fn convert_agent_message(
|
||||
message: &AgentMessage,
|
||||
eviction_plan: &crate::tool_io_offload::HistoryToolIoEvictionPlan,
|
||||
@@ -1938,6 +2009,46 @@ mod tests {
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_user_visible_agent_messages_should_skip_agent_only_history() {
|
||||
let messages = vec![
|
||||
AgentMessage {
|
||||
role: "user".to_string(),
|
||||
content: MessageContent::Text("用户消息".to_string()),
|
||||
timestamp: "2026-02-19T13:00:00Z".to_string(),
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
reasoning_content: None,
|
||||
usage: None,
|
||||
},
|
||||
AgentMessage {
|
||||
role: "user".to_string(),
|
||||
content: MessageContent::Text("内部续跑提示".to_string()),
|
||||
timestamp: "2026-02-19T13:00:01Z".to_string(),
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
reasoning_content: None,
|
||||
usage: None,
|
||||
},
|
||||
];
|
||||
let persisted_messages = vec![
|
||||
aster::conversation::message::Message::user().with_text("用户消息"),
|
||||
aster::conversation::message::Message::user()
|
||||
.with_text("内部续跑提示")
|
||||
.agent_only(),
|
||||
];
|
||||
|
||||
let converted =
|
||||
convert_user_visible_agent_messages(&messages, &persisted_messages, Some("gpt-4.1"));
|
||||
|
||||
assert_eq!(converted.len(), 1);
|
||||
assert_eq!(converted[0].role, "user");
|
||||
assert!(matches!(
|
||||
converted[0].content.as_slice(),
|
||||
[RuntimeAgentMessageContent::Text { text }] if text == "用户消息"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_runtime_usage_fallback_should_fill_latest_assistant_message() {
|
||||
let mut messages = vec![
|
||||
|
||||
@@ -27,6 +27,7 @@ pub struct StepResult {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SkillInputImage {
|
||||
pub data: String,
|
||||
#[serde(alias = "mediaType")]
|
||||
pub media_type: String,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use aster::session::TurnContextOverride;
|
||||
use aster::session::{TurnContextOverride, TurnOutputSchemaSource};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
@@ -58,10 +58,19 @@ pub enum TurnSystemPromptSource {
|
||||
Project,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TurnExecutionProfile {
|
||||
FastChat,
|
||||
#[default]
|
||||
FullRuntime,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TurnPromptAugmentationStageKind {
|
||||
RuntimeAgents,
|
||||
ExplicitLocalPathFocus,
|
||||
Memory,
|
||||
WebSearch,
|
||||
RequestToolPolicy,
|
||||
@@ -146,6 +155,7 @@ pub struct TurnDiagnosticsSnapshot {
|
||||
pub project_id: Option<String>,
|
||||
pub thread_id: Option<String>,
|
||||
pub turn_id: Option<String>,
|
||||
pub execution_profile: TurnExecutionProfile,
|
||||
pub has_persisted_session: bool,
|
||||
pub system_prompt_source: TurnSystemPromptSource,
|
||||
pub base_system_prompt_len: Option<usize>,
|
||||
@@ -161,6 +171,8 @@ pub struct TurnDiagnosticsSnapshot {
|
||||
pub working_dir_set: bool,
|
||||
pub effective_user_message_len: usize,
|
||||
pub include_context_trace: bool,
|
||||
pub has_turn_output_schema: bool,
|
||||
pub turn_output_schema_source: Option<TurnOutputSchemaSource>,
|
||||
pub has_turn_context_metadata: bool,
|
||||
pub turn_context_metadata_keys: Vec<String>,
|
||||
}
|
||||
@@ -172,6 +184,7 @@ pub struct TurnInputEnvelope {
|
||||
project_id: Option<String>,
|
||||
thread_id: Option<String>,
|
||||
turn_id: Option<String>,
|
||||
execution_profile: TurnExecutionProfile,
|
||||
has_persisted_session: bool,
|
||||
system_prompt_source: TurnSystemPromptSource,
|
||||
base_system_prompt: Option<String>,
|
||||
@@ -187,6 +200,8 @@ pub struct TurnInputEnvelope {
|
||||
working_dir: Option<String>,
|
||||
effective_user_message: String,
|
||||
include_context_trace: bool,
|
||||
turn_output_schema: Option<Value>,
|
||||
turn_output_schema_source: Option<TurnOutputSchemaSource>,
|
||||
approval_policy: Option<String>,
|
||||
sandbox_policy: Option<String>,
|
||||
turn_context_metadata: Option<Map<String, Value>>,
|
||||
@@ -220,13 +235,19 @@ impl TurnInputEnvelope {
|
||||
|
||||
pub fn turn_context_override(&self) -> Option<TurnContextOverride> {
|
||||
let metadata = self.merged_turn_context_metadata();
|
||||
if metadata.is_none() && self.approval_policy.is_none() && self.sandbox_policy.is_none() {
|
||||
if metadata.is_none()
|
||||
&& self.approval_policy.is_none()
|
||||
&& self.sandbox_policy.is_none()
|
||||
&& self.turn_output_schema.is_none()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(TurnContextOverride {
|
||||
approval_policy: self.approval_policy.clone(),
|
||||
sandbox_policy: self.sandbox_policy.clone(),
|
||||
output_schema: self.turn_output_schema.clone(),
|
||||
output_schema_source: self.turn_output_schema_source,
|
||||
metadata: metadata.unwrap_or_default().into_iter().collect(),
|
||||
..TurnContextOverride::default()
|
||||
})
|
||||
@@ -246,6 +267,7 @@ impl TurnInputEnvelope {
|
||||
project_id: self.project_id.clone(),
|
||||
thread_id: self.thread_id.clone(),
|
||||
turn_id: self.turn_id.clone(),
|
||||
execution_profile: self.execution_profile,
|
||||
has_persisted_session: self.has_persisted_session,
|
||||
system_prompt_source: self.system_prompt_source,
|
||||
base_system_prompt_len: prompt_len(&self.base_system_prompt),
|
||||
@@ -261,6 +283,8 @@ impl TurnInputEnvelope {
|
||||
working_dir_set: self.working_dir.is_some(),
|
||||
effective_user_message_len: self.effective_user_message.chars().count(),
|
||||
include_context_trace: self.include_context_trace,
|
||||
has_turn_output_schema: self.turn_output_schema.is_some(),
|
||||
turn_output_schema_source: self.turn_output_schema_source,
|
||||
has_turn_context_metadata: self.merged_turn_context_metadata().is_some(),
|
||||
turn_context_metadata_keys,
|
||||
}
|
||||
@@ -280,6 +304,7 @@ impl TurnInputEnvelopeBuilder {
|
||||
project_id: None,
|
||||
thread_id: None,
|
||||
turn_id: None,
|
||||
execution_profile: TurnExecutionProfile::default(),
|
||||
has_persisted_session: false,
|
||||
system_prompt_source: TurnSystemPromptSource::None,
|
||||
base_system_prompt: None,
|
||||
@@ -295,6 +320,8 @@ impl TurnInputEnvelopeBuilder {
|
||||
working_dir: None,
|
||||
effective_user_message: String::new(),
|
||||
include_context_trace: false,
|
||||
turn_output_schema: None,
|
||||
turn_output_schema_source: None,
|
||||
approval_policy: None,
|
||||
sandbox_policy: None,
|
||||
turn_context_metadata: None,
|
||||
@@ -322,6 +349,11 @@ impl TurnInputEnvelopeBuilder {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn set_execution_profile(&mut self, execution_profile: TurnExecutionProfile) -> &mut Self {
|
||||
self.envelope.execution_profile = execution_profile;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn set_base_system_prompt(
|
||||
&mut self,
|
||||
source: TurnSystemPromptSource,
|
||||
@@ -376,6 +408,20 @@ impl TurnInputEnvelopeBuilder {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn set_turn_output_schema(
|
||||
&mut self,
|
||||
output_schema: Option<Value>,
|
||||
output_schema_source: Option<TurnOutputSchemaSource>,
|
||||
) -> &mut Self {
|
||||
self.envelope.turn_output_schema = output_schema;
|
||||
self.envelope.turn_output_schema_source = if self.envelope.turn_output_schema.is_some() {
|
||||
output_schema_source
|
||||
} else {
|
||||
None
|
||||
};
|
||||
self
|
||||
}
|
||||
|
||||
pub fn set_approval_policy(&mut self, approval_policy: Option<String>) -> &mut Self {
|
||||
self.envelope.approval_policy = normalize_optional_string(approval_policy);
|
||||
self
|
||||
@@ -439,12 +485,13 @@ impl TurnInputEnvelopeBuilder {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
TurnInputEnvelopeBuilder, TurnPromptAugmentationStageKind, TurnProviderRoutingSnapshot,
|
||||
TurnRequestToolPolicySnapshot, TurnSystemPromptSource,
|
||||
TurnExecutionProfile, TurnInputEnvelopeBuilder, TurnPromptAugmentationStageKind,
|
||||
TurnProviderRoutingSnapshot, TurnRequestToolPolicySnapshot, TurnSystemPromptSource,
|
||||
};
|
||||
use crate::provider_continuation_state::{
|
||||
ProviderContinuationCapability, ProviderContinuationState,
|
||||
};
|
||||
use aster::session::TurnOutputSchemaSource;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
@@ -453,6 +500,7 @@ mod tests {
|
||||
builder
|
||||
.set_project_id(Some("project-1".to_string()))
|
||||
.set_has_persisted_session(true)
|
||||
.set_execution_profile(TurnExecutionProfile::FastChat)
|
||||
.set_base_system_prompt(TurnSystemPromptSource::Session, Some("base".to_string()))
|
||||
.apply_prompt_stage(
|
||||
TurnPromptAugmentationStageKind::RuntimeAgents,
|
||||
@@ -500,6 +548,10 @@ mod tests {
|
||||
diagnostics.system_prompt_source,
|
||||
TurnSystemPromptSource::Session
|
||||
);
|
||||
assert_eq!(
|
||||
diagnostics.execution_profile,
|
||||
TurnExecutionProfile::FastChat
|
||||
);
|
||||
assert_eq!(diagnostics.base_system_prompt_len, Some(4));
|
||||
assert_eq!(diagnostics.final_system_prompt_len, Some(12));
|
||||
assert!(diagnostics.working_dir_set);
|
||||
@@ -519,6 +571,8 @@ mod tests {
|
||||
diagnostics.provider_continuation_capability,
|
||||
ProviderContinuationCapability::PreviousResponseId
|
||||
);
|
||||
assert!(!diagnostics.has_turn_output_schema);
|
||||
assert_eq!(diagnostics.turn_output_schema_source, None);
|
||||
assert_eq!(diagnostics.prompt_augmentation_stages.len(), 2);
|
||||
assert!(diagnostics.prompt_augmentation_stages[0].changed);
|
||||
assert!(!diagnostics.prompt_augmentation_stages[1].changed);
|
||||
@@ -564,11 +618,58 @@ mod tests {
|
||||
diagnostics.system_prompt_source,
|
||||
TurnSystemPromptSource::None
|
||||
);
|
||||
assert_eq!(
|
||||
diagnostics.execution_profile,
|
||||
TurnExecutionProfile::FullRuntime
|
||||
);
|
||||
assert_eq!(diagnostics.base_system_prompt_len, None);
|
||||
assert_eq!(diagnostics.final_system_prompt_len, Some(12));
|
||||
assert_eq!(envelope.system_prompt(), Some("runtime-only"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_turn_input_envelope_exposes_turn_output_schema() {
|
||||
let mut builder = TurnInputEnvelopeBuilder::new("session-schema", "workspace-schema");
|
||||
builder
|
||||
.set_turn_output_schema(
|
||||
Some(json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
})),
|
||||
Some(TurnOutputSchemaSource::Turn),
|
||||
)
|
||||
.set_effective_user_message("生成结构化输出");
|
||||
|
||||
let envelope = builder.build();
|
||||
let diagnostics = envelope.diagnostics_snapshot();
|
||||
let turn_context = envelope.turn_context_override().expect("turn context");
|
||||
|
||||
assert!(diagnostics.has_turn_output_schema);
|
||||
assert_eq!(
|
||||
diagnostics.turn_output_schema_source,
|
||||
Some(TurnOutputSchemaSource::Turn)
|
||||
);
|
||||
assert_eq!(
|
||||
turn_context.output_schema,
|
||||
Some(json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}))
|
||||
);
|
||||
assert_eq!(
|
||||
turn_context.output_schema_source,
|
||||
Some(TurnOutputSchemaSource::Turn)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_turn_input_envelope_exposes_provider_continuation_without_user_metadata() {
|
||||
let mut builder = TurnInputEnvelopeBuilder::new("session-3", "workspace-3");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::turn_input_envelope::TurnRequestToolPolicySnapshot;
|
||||
use crate::turn_input_envelope::{TurnExecutionProfile, TurnRequestToolPolicySnapshot};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct TurnState {
|
||||
@@ -8,6 +8,7 @@ pub struct TurnState {
|
||||
pub workspace_id: String,
|
||||
pub thread_id: String,
|
||||
pub turn_id: String,
|
||||
pub execution_profile: TurnExecutionProfile,
|
||||
pub requested_execution_strategy: String,
|
||||
pub effective_execution_strategy: String,
|
||||
pub request_tool_policy: TurnRequestToolPolicySnapshot,
|
||||
@@ -22,6 +23,7 @@ impl TurnState {
|
||||
workspace_id: impl Into<String>,
|
||||
thread_id: impl Into<String>,
|
||||
turn_id: impl Into<String>,
|
||||
execution_profile: TurnExecutionProfile,
|
||||
requested_execution_strategy: impl Into<String>,
|
||||
effective_execution_strategy: impl Into<String>,
|
||||
request_tool_policy: TurnRequestToolPolicySnapshot,
|
||||
@@ -33,6 +35,7 @@ impl TurnState {
|
||||
workspace_id: workspace_id.into(),
|
||||
thread_id: thread_id.into(),
|
||||
turn_id: turn_id.into(),
|
||||
execution_profile,
|
||||
requested_execution_strategy: requested_execution_strategy.into(),
|
||||
effective_execution_strategy: effective_execution_strategy.into(),
|
||||
request_tool_policy,
|
||||
@@ -45,7 +48,7 @@ impl TurnState {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::TurnState;
|
||||
use crate::turn_input_envelope::TurnRequestToolPolicySnapshot;
|
||||
use crate::turn_input_envelope::{TurnExecutionProfile, TurnRequestToolPolicySnapshot};
|
||||
|
||||
#[test]
|
||||
fn test_turn_state_is_serializable_and_complete() {
|
||||
@@ -54,6 +57,7 @@ mod tests {
|
||||
"workspace-1",
|
||||
"thread-1",
|
||||
"turn-1",
|
||||
TurnExecutionProfile::FastChat,
|
||||
"auto",
|
||||
"react",
|
||||
TurnRequestToolPolicySnapshot {
|
||||
@@ -70,6 +74,7 @@ mod tests {
|
||||
let value = serde_json::to_value(&turn_state).expect("serialize turn state");
|
||||
assert_eq!(value["thread_id"], serde_json::json!("thread-1"));
|
||||
assert_eq!(value["turn_id"], serde_json::json!("turn-1"));
|
||||
assert_eq!(value["execution_profile"], serde_json::json!("fast_chat"));
|
||||
assert_eq!(
|
||||
value["effective_execution_strategy"],
|
||||
serde_json::json!("react")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -95,6 +95,7 @@ impl Extension {
|
||||
pub struct ExtensionManager {
|
||||
extensions: Mutex<HashMap<String, Extension>>,
|
||||
loaded_deferred_tools: Mutex<HashSet<String>>,
|
||||
pending_extensions: Mutex<HashSet<String>>,
|
||||
context: Mutex<PlatformExtensionContext>,
|
||||
provider: SharedProvider,
|
||||
}
|
||||
@@ -447,6 +448,7 @@ impl ExtensionManager {
|
||||
Self {
|
||||
extensions: Mutex::new(HashMap::new()),
|
||||
loaded_deferred_tools: Mutex::new(HashSet::new()),
|
||||
pending_extensions: Mutex::new(HashSet::new()),
|
||||
context: Mutex::new(PlatformExtensionContext {
|
||||
session_id: None,
|
||||
extension_manager: None,
|
||||
@@ -479,125 +481,144 @@ impl ExtensionManager {
|
||||
pub async fn add_extension(&self, config: ExtensionConfig) -> ExtensionResult<()> {
|
||||
let config_name = config.key().to_string();
|
||||
let sanitized_name = normalize(config_name.clone());
|
||||
let pending_name = pending_extension_display_name(&config);
|
||||
|
||||
if self.extensions.lock().await.contains_key(&sanitized_name) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut temp_dir = None;
|
||||
|
||||
let client: Box<dyn McpClientTrait> = match &config {
|
||||
ExtensionConfig::Sse { .. } => {
|
||||
return Err(ExtensionError::ConfigError(
|
||||
"SSE is unsupported, migrate to streamable_http".to_string(),
|
||||
));
|
||||
{
|
||||
let mut pending_extensions = self.pending_extensions.lock().await;
|
||||
if pending_extensions.contains(&pending_name) {
|
||||
return Ok(());
|
||||
}
|
||||
ExtensionConfig::StreamableHttp {
|
||||
uri,
|
||||
timeout,
|
||||
headers,
|
||||
name,
|
||||
envs,
|
||||
env_keys,
|
||||
..
|
||||
} => {
|
||||
let all_envs = merge_environments(envs, env_keys, &sanitized_name).await?;
|
||||
create_streamable_http_client(
|
||||
pending_extensions.insert(pending_name.clone());
|
||||
}
|
||||
|
||||
let result = async {
|
||||
let mut temp_dir = None;
|
||||
|
||||
let client: Box<dyn McpClientTrait> = match &config {
|
||||
ExtensionConfig::Sse { .. } => {
|
||||
return Err(ExtensionError::ConfigError(
|
||||
"SSE is unsupported, migrate to streamable_http".to_string(),
|
||||
));
|
||||
}
|
||||
ExtensionConfig::StreamableHttp {
|
||||
uri,
|
||||
*timeout,
|
||||
timeout,
|
||||
headers,
|
||||
name,
|
||||
&all_envs,
|
||||
self.provider.clone(),
|
||||
)
|
||||
.await?
|
||||
}
|
||||
ExtensionConfig::Stdio {
|
||||
cmd,
|
||||
args,
|
||||
envs,
|
||||
env_keys,
|
||||
timeout,
|
||||
..
|
||||
} => {
|
||||
let all_envs = merge_environments(envs, env_keys, &sanitized_name).await?;
|
||||
create_stdio_client(cmd, args, all_envs, timeout, self.provider.clone()).await?
|
||||
}
|
||||
ExtensionConfig::Builtin { name, timeout, .. } => {
|
||||
let cmd = std::env::current_exe()
|
||||
.and_then(|path| {
|
||||
path.to_str().map(|s| s.to_string()).ok_or_else(|| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"Invalid UTF-8 in executable path",
|
||||
)
|
||||
envs,
|
||||
env_keys,
|
||||
..
|
||||
} => {
|
||||
let all_envs = merge_environments(envs, env_keys, &sanitized_name).await?;
|
||||
create_streamable_http_client(
|
||||
uri,
|
||||
*timeout,
|
||||
headers,
|
||||
name,
|
||||
&all_envs,
|
||||
self.provider.clone(),
|
||||
)
|
||||
.await?
|
||||
}
|
||||
ExtensionConfig::Stdio {
|
||||
cmd,
|
||||
args,
|
||||
envs,
|
||||
env_keys,
|
||||
timeout,
|
||||
..
|
||||
} => {
|
||||
let all_envs = merge_environments(envs, env_keys, &sanitized_name).await?;
|
||||
create_stdio_client(cmd, args, all_envs, timeout, self.provider.clone())
|
||||
.await?
|
||||
}
|
||||
ExtensionConfig::Builtin { name, timeout, .. } => {
|
||||
let cmd = std::env::current_exe()
|
||||
.and_then(|path| {
|
||||
path.to_str().map(|s| s.to_string()).ok_or_else(|| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"Invalid UTF-8 in executable path",
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
.map_err(|e| {
|
||||
ExtensionError::ConfigError(format!(
|
||||
"Failed to resolve executable path: {}",
|
||||
e
|
||||
))
|
||||
})?;
|
||||
let command = Command::new(cmd).configure(|command| {
|
||||
command.arg("mcp").arg(name);
|
||||
});
|
||||
Box::new(child_process_client(command, timeout, self.provider.clone()).await?)
|
||||
}
|
||||
ExtensionConfig::Platform { name, .. } => {
|
||||
let normalized_key = normalize(name.clone());
|
||||
let def = PLATFORM_EXTENSIONS
|
||||
.get(normalized_key.as_str())
|
||||
.ok_or_else(|| {
|
||||
ExtensionError::ConfigError(format!("Unknown platform extension: {}", name))
|
||||
})?;
|
||||
let context = self.get_context().await;
|
||||
(def.client_factory)(context)
|
||||
}
|
||||
ExtensionConfig::InlinePython {
|
||||
name,
|
||||
code,
|
||||
timeout,
|
||||
dependencies,
|
||||
..
|
||||
} => {
|
||||
let dir = tempdir()?;
|
||||
let file_path = dir.path().join(format!("{}.py", name));
|
||||
temp_dir = Some(dir);
|
||||
std::fs::write(&file_path, code)?;
|
||||
|
||||
let command = Command::new("uvx").configure(|command| {
|
||||
command.arg("--with").arg("mcp");
|
||||
dependencies.iter().flatten().for_each(|dep| {
|
||||
command.arg("--with").arg(dep);
|
||||
.map_err(|e| {
|
||||
ExtensionError::ConfigError(format!(
|
||||
"Failed to resolve executable path: {}",
|
||||
e
|
||||
))
|
||||
})?;
|
||||
let command = Command::new(cmd).configure(|command| {
|
||||
command.arg("mcp").arg(name);
|
||||
});
|
||||
command.arg("python").arg(file_path.to_str().unwrap());
|
||||
});
|
||||
Box::new(child_process_client(command, timeout, self.provider.clone()).await?)
|
||||
}
|
||||
ExtensionConfig::Platform { name, .. } => {
|
||||
let normalized_key = normalize(name.clone());
|
||||
let def = PLATFORM_EXTENSIONS
|
||||
.get(normalized_key.as_str())
|
||||
.ok_or_else(|| {
|
||||
ExtensionError::ConfigError(format!(
|
||||
"Unknown platform extension: {}",
|
||||
name
|
||||
))
|
||||
})?;
|
||||
let context = self.get_context().await;
|
||||
(def.client_factory)(context)
|
||||
}
|
||||
ExtensionConfig::InlinePython {
|
||||
name,
|
||||
code,
|
||||
timeout,
|
||||
dependencies,
|
||||
..
|
||||
} => {
|
||||
let dir = tempdir()?;
|
||||
let file_path = dir.path().join(format!("{}.py", name));
|
||||
temp_dir = Some(dir);
|
||||
std::fs::write(&file_path, code)?;
|
||||
|
||||
Box::new(child_process_client(command, timeout, self.provider.clone()).await?)
|
||||
}
|
||||
ExtensionConfig::Frontend { .. } => {
|
||||
return Err(ExtensionError::ConfigError(
|
||||
"Invalid extension type: Frontend extensions cannot be added as server extensions".to_string()
|
||||
));
|
||||
}
|
||||
};
|
||||
let command = Command::new("uvx").configure(|command| {
|
||||
command.arg("--with").arg("mcp");
|
||||
dependencies.iter().flatten().for_each(|dep| {
|
||||
command.arg("--with").arg(dep);
|
||||
});
|
||||
command.arg("python").arg(file_path.to_str().unwrap());
|
||||
});
|
||||
|
||||
let server_info = client.get_info().cloned();
|
||||
Box::new(child_process_client(command, timeout, self.provider.clone()).await?)
|
||||
}
|
||||
ExtensionConfig::Frontend { .. } => {
|
||||
return Err(ExtensionError::ConfigError(
|
||||
"Invalid extension type: Frontend extensions cannot be added as server extensions".to_string()
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// Only generate name from server info when config has no name (e.g., CLI --with-*-extension args)
|
||||
let mut extensions = self.extensions.lock().await;
|
||||
let final_name = if sanitized_name.is_empty() {
|
||||
generate_extension_name(server_info.as_ref(), |n| extensions.contains_key(n))
|
||||
} else {
|
||||
sanitized_name
|
||||
};
|
||||
extensions.insert(
|
||||
final_name,
|
||||
Extension::new(config, Arc::new(Mutex::new(client)), server_info, temp_dir),
|
||||
);
|
||||
let server_info = client.get_info().cloned();
|
||||
|
||||
Ok(())
|
||||
// Only generate name from server info when config has no name (e.g., CLI --with-*-extension args)
|
||||
let mut extensions = self.extensions.lock().await;
|
||||
let final_name = if sanitized_name.is_empty() {
|
||||
generate_extension_name(server_info.as_ref(), |n| extensions.contains_key(n))
|
||||
} else {
|
||||
sanitized_name
|
||||
};
|
||||
extensions.insert(
|
||||
final_name,
|
||||
Extension::new(config, Arc::new(Mutex::new(client)), server_info, temp_dir),
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
|
||||
self.pending_extensions.lock().await.remove(&pending_name);
|
||||
result
|
||||
}
|
||||
|
||||
pub async fn add_client(
|
||||
@@ -653,6 +674,18 @@ impl ExtensionManager {
|
||||
Ok(self.extensions.lock().await.keys().cloned().collect())
|
||||
}
|
||||
|
||||
pub async fn list_pending_extensions(&self) -> Vec<String> {
|
||||
let mut pending = self
|
||||
.pending_extensions
|
||||
.lock()
|
||||
.await
|
||||
.iter()
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
pending.sort();
|
||||
pending
|
||||
}
|
||||
|
||||
pub async fn is_extension_enabled(&self, name: &str) -> bool {
|
||||
self.extensions.lock().await.contains_key(name)
|
||||
}
|
||||
@@ -1560,6 +1593,22 @@ impl ExtensionManager {
|
||||
}
|
||||
}
|
||||
|
||||
fn pending_extension_display_name(config: &ExtensionConfig) -> String {
|
||||
let name = config.name();
|
||||
let trimmed = name.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return trimmed.to_string();
|
||||
}
|
||||
|
||||
let key = config.key();
|
||||
let trimmed_key = key.trim();
|
||||
if !trimmed_key.is_empty() {
|
||||
return trimmed_key.to_string();
|
||||
}
|
||||
|
||||
"unnamed".to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -2224,6 +2273,21 @@ mod tests {
|
||||
assert!(names.iter().any(|name| name == "test_extension__tool"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_list_pending_extensions_returns_sorted_names() {
|
||||
let extension_manager = ExtensionManager::new_without_provider();
|
||||
{
|
||||
let mut pending = extension_manager.pending_extensions.lock().await;
|
||||
pending.insert("zeta".to_string());
|
||||
pending.insert("alpha".to_string());
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
extension_manager.list_pending_extensions().await,
|
||||
vec!["alpha".to_string(), "zeta".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_search_tools_shows_deferred_status() {
|
||||
let extension_manager = ExtensionManager::new_without_provider();
|
||||
|
||||
@@ -16,6 +16,7 @@ use crate::providers::toolshim::{
|
||||
augment_message_with_tool_calls, convert_tool_messages_to_text,
|
||||
modify_system_prompt_for_tool_json, OllamaInterpreter,
|
||||
};
|
||||
use crate::session_context::current_turn_context;
|
||||
|
||||
use crate::agents::code_execution_extension::EXTENSION_NAME as CODE_EXECUTION_EXTENSION;
|
||||
use crate::agents::subagent_tool::AGENT_TOOL_NAME;
|
||||
@@ -24,6 +25,14 @@ use crate::session::SessionType;
|
||||
use crate::session::{SessionManager, SessionStore, TokenStatsUpdate};
|
||||
use rmcp::model::Tool;
|
||||
|
||||
const LIME_RUNTIME_METADATA_KEY: &str = "lime_runtime";
|
||||
const LIME_RUNTIME_TOOL_SURFACE_KEY: &str = "tool_surface";
|
||||
const TURN_TOOL_SURFACE_DIRECT_ANSWER: &str = "direct_answer";
|
||||
const TURN_TOOL_SURFACE_LOCAL_WORKSPACE: &str = "local_workspace";
|
||||
const LOCAL_WORKSPACE_TOOL_NAMES: &[&str] = &["Bash", "Read", "Write", "Edit", "Glob", "Grep"];
|
||||
const DIRECT_ANSWER_TURN_GUIDANCE: &str = "【当前回合执行约束】本回合应优先直接回答。除非信息明显不足或用户明确要求,否则不要调用工具,也不要把简单回复扩展成多阶段流程。";
|
||||
const LOCAL_WORKSPACE_TURN_GUIDANCE: &str = "【当前回合执行约束】本回合只允许使用本地工作区工具。先用最少的侦查动作定位关键文件,优先小范围目录/文件列表与精确搜索;通常先控制在 3 到 6 次工具调用内拿到关键证据,只有前一步明确暴露新线索时再继续深入。若需要连续侦查,请把相互独立的读取/搜索收敛成一批,并在同一条回复里一起发起 2 到 4 个彼此独立的只读工具调用,让运行时并行执行;先完成这一批,再直接输出 1 到 2 句用户可见的结论正文,说明已经确认了什么、还缺什么、为什么还要继续,不要额外输出“阶段结论”标题,再决定是否继续下一批。如果用户消息里已经点名绝对路径、仓库根或具体文件,就把这些显式路径当作本回合唯一优先入口;第一批只围绕这些路径展开,不要先扫描当前默认工作区或无关目录。读取文件时聚焦与问题直接相关的入口、注册表、配置和代码片段,避免重复枚举大目录、避免一次性展开超长目录或整文件全文,也不要把大段原文直接抄回最终回答,改用结论加文件路径。";
|
||||
|
||||
fn coerce_value(s: &str, schema: &Value) -> Value {
|
||||
let type_str = schema.get("type");
|
||||
|
||||
@@ -74,6 +83,52 @@ fn try_coerce_boolean(s: &str) -> Value {
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_turn_tool_surface_mode() -> Option<String> {
|
||||
current_turn_context()?
|
||||
.metadata
|
||||
.get(LIME_RUNTIME_METADATA_KEY)
|
||||
.and_then(|value| value.get(LIME_RUNTIME_TOOL_SURFACE_KEY))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
fn is_local_workspace_tool(tool_name: &str) -> bool {
|
||||
LOCAL_WORKSPACE_TOOL_NAMES
|
||||
.iter()
|
||||
.any(|candidate| candidate.eq_ignore_ascii_case(tool_name))
|
||||
}
|
||||
|
||||
fn filter_tools_for_turn_surface(
|
||||
mut tools: Vec<Tool>,
|
||||
tool_surface_mode: Option<&str>,
|
||||
) -> Vec<Tool> {
|
||||
match tool_surface_mode {
|
||||
Some(TURN_TOOL_SURFACE_DIRECT_ANSWER) => Vec::new(),
|
||||
Some(TURN_TOOL_SURFACE_LOCAL_WORKSPACE) => {
|
||||
tools.retain(|tool| is_local_workspace_tool(&tool.name));
|
||||
tools
|
||||
}
|
||||
_ => tools,
|
||||
}
|
||||
}
|
||||
|
||||
fn should_strip_extension_prompt_context(tool_surface_mode: Option<&str>) -> bool {
|
||||
matches!(
|
||||
tool_surface_mode,
|
||||
Some(TURN_TOOL_SURFACE_DIRECT_ANSWER | TURN_TOOL_SURFACE_LOCAL_WORKSPACE)
|
||||
)
|
||||
}
|
||||
|
||||
fn turn_surface_prompt_guidance(tool_surface_mode: Option<&str>) -> Option<&'static str> {
|
||||
match tool_surface_mode {
|
||||
Some(TURN_TOOL_SURFACE_DIRECT_ANSWER) => Some(DIRECT_ANSWER_TURN_GUIDANCE),
|
||||
Some(TURN_TOOL_SURFACE_LOCAL_WORKSPACE) => Some(LOCAL_WORKSPACE_TURN_GUIDANCE),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn coerce_tool_arguments(
|
||||
arguments: Option<serde_json::Map<String, Value>>,
|
||||
tool_schema: &Value,
|
||||
@@ -164,15 +219,22 @@ impl Agent {
|
||||
tools.retain(|tool| tool.name.starts_with(&code_exec_prefix));
|
||||
}
|
||||
|
||||
let turn_tool_surface_mode = resolve_turn_tool_surface_mode();
|
||||
tools = filter_tools_for_turn_surface(tools, turn_tool_surface_mode.as_deref());
|
||||
let subagents_enabled = tools.iter().any(|tool| tool.name == AGENT_TOOL_NAME);
|
||||
|
||||
// Stable tool ordering is important for multi session prompt caching.
|
||||
tools.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
|
||||
// Prepare system prompt
|
||||
let extensions_info = self.extension_manager.get_extensions_info().await;
|
||||
let (extension_count, tool_count) =
|
||||
let mut extensions_info = self.extension_manager.get_extensions_info().await;
|
||||
let (mut extension_count, mut tool_count) =
|
||||
self.extension_manager.get_extension_and_tool_counts().await;
|
||||
if should_strip_extension_prompt_context(turn_tool_surface_mode.as_deref()) {
|
||||
extensions_info.clear();
|
||||
extension_count = 0;
|
||||
tool_count = tools.len();
|
||||
}
|
||||
|
||||
let final_output_instruction = self
|
||||
.final_output_tool
|
||||
@@ -193,6 +255,10 @@ impl Agent {
|
||||
.with_enable_subagents(subagents_enabled)
|
||||
.with_session_prompt(session_prompt.map(|s| s.to_string()))
|
||||
.build();
|
||||
if let Some(guidance) = turn_surface_prompt_guidance(turn_tool_surface_mode.as_deref()) {
|
||||
system_prompt.push_str("\n\n");
|
||||
system_prompt.push_str(guidance);
|
||||
}
|
||||
|
||||
// Handle toolshim if enabled
|
||||
let mut toolshim_tools = vec![];
|
||||
@@ -277,7 +343,8 @@ impl Agent {
|
||||
};
|
||||
|
||||
Ok(Box::pin(try_stream! {
|
||||
while let Some(Ok((mut message, usage))) = stream.next().await {
|
||||
while let Some(next) = stream.next().await {
|
||||
let (mut message, usage) = next?;
|
||||
// Store the model information in the global store
|
||||
if let Some(usage) = usage.as_ref() {
|
||||
crate::providers::base::set_current_model(&usage.model);
|
||||
@@ -494,10 +561,11 @@ mod tests {
|
||||
use crate::providers::errors::ProviderError;
|
||||
use crate::scheduler::{ScheduledJob, SchedulerError};
|
||||
use crate::scheduler_trait::SchedulerTrait;
|
||||
use crate::session::Session;
|
||||
use crate::session::{Session, TurnContextOverride};
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use rmcp::object;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -717,6 +785,109 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_turn_context_with_tool_surface(mode: &str) -> TurnContextOverride {
|
||||
let mut runtime_metadata = serde_json::Map::new();
|
||||
runtime_metadata.insert(
|
||||
LIME_RUNTIME_TOOL_SURFACE_KEY.to_string(),
|
||||
Value::String(mode.to_string()),
|
||||
);
|
||||
|
||||
let mut metadata = HashMap::new();
|
||||
metadata.insert(
|
||||
LIME_RUNTIME_METADATA_KEY.to_string(),
|
||||
Value::Object(runtime_metadata),
|
||||
);
|
||||
|
||||
TurnContextOverride {
|
||||
metadata,
|
||||
..TurnContextOverride::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn prepare_tools_and_prompt_hides_all_tools_for_direct_answer_turn_surface(
|
||||
) -> anyhow::Result<()> {
|
||||
let agent = crate::agents::Agent::new();
|
||||
|
||||
let session = SessionManager::create_session(
|
||||
std::path::PathBuf::default(),
|
||||
"test-direct-answer-tool-surface".to_string(),
|
||||
SessionType::Hidden,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let model_config = ModelConfig::new("test-model").unwrap();
|
||||
let provider = std::sync::Arc::new(MockProvider {
|
||||
model_config,
|
||||
observed_models: None,
|
||||
});
|
||||
agent.update_provider(provider, &session.id).await?;
|
||||
|
||||
let working_dir = std::env::current_dir()?;
|
||||
let (tools, _toolshim_tools, _system_prompt) = crate::session_context::with_turn_context(
|
||||
Some(build_turn_context_with_tool_surface(
|
||||
TURN_TOOL_SURFACE_DIRECT_ANSWER,
|
||||
)),
|
||||
async {
|
||||
agent
|
||||
.prepare_tools_and_prompt(
|
||||
&working_dir,
|
||||
None,
|
||||
&ModelConfig::new("test-model").unwrap(),
|
||||
)
|
||||
.await
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert!(tools.is_empty());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn prepare_tools_and_prompt_keeps_only_local_workspace_tools_for_local_workspace_turn_surface(
|
||||
) -> anyhow::Result<()> {
|
||||
let agent = crate::agents::Agent::new();
|
||||
agent
|
||||
.set_scheduler(std::sync::Arc::new(MockScheduler))
|
||||
.await;
|
||||
|
||||
let session = SessionManager::create_session(
|
||||
std::path::PathBuf::default(),
|
||||
"test-local-workspace-tool-surface".to_string(),
|
||||
SessionType::Hidden,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let model_config = ModelConfig::new("test-model").unwrap();
|
||||
let provider = std::sync::Arc::new(MockProvider {
|
||||
model_config,
|
||||
observed_models: None,
|
||||
});
|
||||
agent.update_provider(provider, &session.id).await?;
|
||||
|
||||
let working_dir = std::env::current_dir()?;
|
||||
let (tools, _toolshim_tools, _system_prompt) = crate::session_context::with_turn_context(
|
||||
Some(build_turn_context_with_tool_surface(
|
||||
TURN_TOOL_SURFACE_LOCAL_WORKSPACE,
|
||||
)),
|
||||
async {
|
||||
agent
|
||||
.prepare_tools_and_prompt(
|
||||
&working_dir,
|
||||
None,
|
||||
&ModelConfig::new("test-model").unwrap(),
|
||||
)
|
||||
.await
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert!(!tools.is_empty());
|
||||
assert!(tools.iter().all(|tool| is_local_workspace_tool(&tool.name)));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stream_response_from_provider_uses_explicit_model_config() -> anyhow::Result<()> {
|
||||
let observed_models = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
@@ -750,6 +921,91 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct MockStreamingErrorProvider {
|
||||
model_config: ModelConfig,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for MockStreamingErrorProvider {
|
||||
fn metadata() -> crate::providers::base::ProviderMetadata {
|
||||
crate::providers::base::ProviderMetadata::empty()
|
||||
}
|
||||
|
||||
fn get_name(&self) -> &str {
|
||||
"mock-streaming-error"
|
||||
}
|
||||
|
||||
fn get_model_config(&self) -> ModelConfig {
|
||||
self.model_config.clone()
|
||||
}
|
||||
|
||||
fn supports_streaming(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
async fn complete_with_model(
|
||||
&self,
|
||||
_model_config: &ModelConfig,
|
||||
_system: &str,
|
||||
_messages: &[Message],
|
||||
_tools: &[Tool],
|
||||
) -> anyhow::Result<(Message, ProviderUsage), ProviderError> {
|
||||
unreachable!("streaming path should be used in this test");
|
||||
}
|
||||
|
||||
async fn stream(
|
||||
&self,
|
||||
_system: &str,
|
||||
_messages: &[Message],
|
||||
_tools: &[Tool],
|
||||
) -> anyhow::Result<MessageStream, ProviderError> {
|
||||
let usage = ProviderUsage::new(self.model_config.model_name.clone(), Usage::default());
|
||||
Ok(Box::pin(futures::stream::iter(vec![
|
||||
Ok((Some(Message::assistant().with_text("partial")), Some(usage))),
|
||||
Err(ProviderError::RequestFailed("stream exploded".to_string())),
|
||||
])))
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stream_response_from_provider_propagates_stream_errors() -> anyhow::Result<()> {
|
||||
let provider = std::sync::Arc::new(MockStreamingErrorProvider {
|
||||
model_config: ModelConfig::new("test-model").unwrap(),
|
||||
});
|
||||
let messages = vec![Message::user().with_text("hello")];
|
||||
|
||||
let mut stream = Agent::stream_response_from_provider(
|
||||
provider,
|
||||
&ModelConfig::new("test-model").unwrap(),
|
||||
"",
|
||||
&messages,
|
||||
&[],
|
||||
&[],
|
||||
)
|
||||
.await?;
|
||||
|
||||
let first = stream
|
||||
.next()
|
||||
.await
|
||||
.expect("first stream item should exist")?;
|
||||
assert_eq!(
|
||||
first.0.expect("message should exist").as_concat_text(),
|
||||
"partial"
|
||||
);
|
||||
|
||||
let error = stream
|
||||
.next()
|
||||
.await
|
||||
.expect("second stream item should exist")
|
||||
.expect_err("stream error should be propagated");
|
||||
assert_eq!(
|
||||
error,
|
||||
ProviderError::RequestFailed("stream exploded".to_string())
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_response_tool_requests_keeps_thinking_and_original_request_order() {
|
||||
let response = Message::assistant()
|
||||
|
||||
@@ -27,7 +27,9 @@ struct HooksConfigLegacy {
|
||||
fn is_valid_hook_event(event: &str) -> bool {
|
||||
matches!(
|
||||
event,
|
||||
"PreToolUse"
|
||||
"TaskCreated"
|
||||
| "TaskCompleted"
|
||||
| "PreToolUse"
|
||||
| "PostToolUse"
|
||||
| "PostToolUseFailure"
|
||||
| "Notification"
|
||||
@@ -52,6 +54,8 @@ fn is_valid_hook_event(event: &str) -> bool {
|
||||
/// 解析事件名称
|
||||
fn parse_event(event: &str) -> Option<HookEvent> {
|
||||
match event {
|
||||
"TaskCreated" => Some(HookEvent::TaskCreated),
|
||||
"TaskCompleted" => Some(HookEvent::TaskCompleted),
|
||||
"PreToolUse" => Some(HookEvent::PreToolUse),
|
||||
"PostToolUse" => Some(HookEvent::PostToolUse),
|
||||
"PostToolUseFailure" => Some(HookEvent::PostToolUseFailure),
|
||||
|
||||
@@ -4,6 +4,8 @@ use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_hook_event_display() {
|
||||
assert_eq!(HookEvent::TaskCreated.to_string(), "TaskCreated");
|
||||
assert_eq!(HookEvent::TaskCompleted.to_string(), "TaskCompleted");
|
||||
assert_eq!(HookEvent::PreToolUse.to_string(), "PreToolUse");
|
||||
assert_eq!(HookEvent::PostToolUse.to_string(), "PostToolUse");
|
||||
assert_eq!(HookEvent::SessionStart.to_string(), "SessionStart");
|
||||
@@ -133,6 +135,10 @@ fn test_hook_config_serialization() {
|
||||
fn test_hook_input_serialization() {
|
||||
let input = HookInput {
|
||||
event: Some(HookEvent::PreToolUse),
|
||||
task_id: Some("1".to_string()),
|
||||
task_subject: Some("整理任务板".to_string()),
|
||||
teammate_name: Some("researcher".to_string()),
|
||||
team_name: Some("alpha".to_string()),
|
||||
tool_name: Some("Bash".to_string()),
|
||||
tool_input: Some(serde_json::json!({"command": "ls"})),
|
||||
session_id: Some("session-123".to_string()),
|
||||
@@ -143,6 +149,7 @@ fn test_hook_input_serialization() {
|
||||
let parsed: HookInput = serde_json::from_str(&json).unwrap();
|
||||
|
||||
assert_eq!(parsed.event, Some(HookEvent::PreToolUse));
|
||||
assert_eq!(parsed.task_id, Some("1".to_string()));
|
||||
assert_eq!(parsed.tool_name, Some("Bash".to_string()));
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,10 @@ use std::collections::HashMap;
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum HookEvent {
|
||||
/// 任务创建
|
||||
TaskCreated,
|
||||
/// 任务完成
|
||||
TaskCompleted,
|
||||
/// 工具执行前
|
||||
PreToolUse,
|
||||
/// 工具执行后
|
||||
@@ -52,6 +56,8 @@ pub enum HookEvent {
|
||||
impl std::fmt::Display for HookEvent {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
HookEvent::TaskCreated => write!(f, "TaskCreated"),
|
||||
HookEvent::TaskCompleted => write!(f, "TaskCompleted"),
|
||||
HookEvent::PreToolUse => write!(f, "PreToolUse"),
|
||||
HookEvent::PostToolUse => write!(f, "PostToolUse"),
|
||||
HookEvent::PostToolUseFailure => write!(f, "PostToolUseFailure"),
|
||||
@@ -296,6 +302,21 @@ pub enum CompactTrigger {
|
||||
pub struct HookInput {
|
||||
/// 事件类型
|
||||
pub event: Option<HookEvent>,
|
||||
/// 任务 ID
|
||||
#[serde(default)]
|
||||
pub task_id: Option<String>,
|
||||
/// 任务标题
|
||||
#[serde(default)]
|
||||
pub task_subject: Option<String>,
|
||||
/// 任务描述
|
||||
#[serde(default)]
|
||||
pub task_description: Option<String>,
|
||||
/// 触发任务事件的 teammate 名称
|
||||
#[serde(default)]
|
||||
pub teammate_name: Option<String>,
|
||||
/// 关联 team 名称
|
||||
#[serde(default)]
|
||||
pub team_name: Option<String>,
|
||||
/// 工具名称
|
||||
#[serde(default)]
|
||||
pub tool_name: Option<String>,
|
||||
|
||||
@@ -15,6 +15,10 @@ pub const TOOL_GUIDELINES: &str = r#"# Tool usage policy
|
||||
- When doing file search or codebase exploration, prefer Glob, Grep, and Read before falling back to bash.
|
||||
- Use specialized tools instead of bash commands when possible, as this provides a better user experience.
|
||||
- NEVER use bash echo or other command-line tools to communicate thoughts, explanations, or instructions to the user.
|
||||
- Before the first tool batch, briefly tell the user what you are about to check instead of going silent.
|
||||
- If the user explicitly names absolute local paths, repo roots, or concrete files, treat them as the primary exploration roots for this turn. Keep the first batch inside those explicit paths instead of scanning the default working directory or unrelated folders.
|
||||
- When independent read-only tool calls have no dependency on each other, emit them together in one response so the runtime can execute them in parallel. For local codebase exploration, prefer batches of 2 to 4 independent Read / Glob / Grep / read-only Bash calls instead of long single-step chains.
|
||||
- After each tool batch, if you still need another batch, first give a short process conclusion that explains what you confirmed, what is still missing, and why you need to continue. Do not emit an extra `阶段结论` title.
|
||||
- Use TaskCreate, TaskList, TaskGet, and TaskUpdate to track progress on multi-step work.
|
||||
- Use ToolSearch only for deferred extension/MCP tools, and use exact names such as `select:Read,Edit,Grep` or `select:mcp__playwright__browser_click` when you need to load or confirm a tool. Do not use ToolSearch for already-visible native tools like Read, Write, Edit, Glob, Grep, Bash, WebFetch, or WebSearch.
|
||||
- Do not search for native tools via aliases like `read_file`, `write_file`, `edit_file`, or `system`; call the actual tool names directly.
|
||||
|
||||
@@ -13,6 +13,14 @@ Use ToolSearch to discover deferred extension tools, and use exact names such as
|
||||
`select:mcp__playwright__browser_click` when you need to load a specific deferred tool into the active tool surface.
|
||||
Do not keep retrying ToolSearch with synonyms like `read_file`, `write_file`, `edit_file`, or `system`. If ToolSearch
|
||||
returns no matches, call already-visible native tools directly or report that the deferred capability is unavailable.
|
||||
Before your first tool batch, briefly tell the user what you are about to verify instead of going silent.
|
||||
If the user explicitly names absolute local paths, repo roots, or concrete files, treat them as the primary exploration roots for this turn. Keep the first batch inside those explicit paths instead of scanning the default working directory or unrelated folders.
|
||||
You can call multiple tools in a single response. When several read-only or otherwise independent tool calls do not
|
||||
depend on each other, emit them together in the same response so the runtime can execute them in parallel. For local
|
||||
repository exploration, prefer batches of 2 to 4 independent Read / Glob / Grep / read-only Bash calls instead of a
|
||||
long one-tool-at-a-time chain. Only keep tool calls sequential when later calls depend on earlier results.
|
||||
After each tool batch, if you still need another batch, first give a short process conclusion that explains what you
|
||||
confirmed, what is still missing, and why you need to continue. Do not emit an extra `阶段结论` title.
|
||||
If Extension Manager is not available, you can only work with currently enabled extensions and cannot dynamically load
|
||||
new ones.
|
||||
|
||||
|
||||
@@ -21,6 +21,9 @@ Use ToolSearch to discover deferred extension tools, and use exact names such as
|
||||
`select:mcp__playwright__browser_click` when you need to load a specific deferred tool into the active tool surface.
|
||||
Do not keep retrying ToolSearch with synonyms like `read_file`, `write_file`, `edit_file`, or `system`. If ToolSearch
|
||||
returns no matches, call already-visible native tools directly or report that the deferred capability is unavailable.
|
||||
You can call multiple tools in a single response. When several read-only or otherwise independent tool calls do not
|
||||
depend on each other, emit them together in the same response so the runtime can execute them in parallel. Only keep
|
||||
tool calls sequential when later calls depend on earlier results.
|
||||
If Extension Manager is not available, you can only work with currently enabled extensions and cannot dynamically load
|
||||
new ones.
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ You are capable of dynamically plugging into new extensions and learning how to
|
||||
If the Extension Manager extension is enabled, you can use the search_available_extensions tool to discover additional extensions that can help with your task. To enable or disable extensions, use the manage_extensions tool with the extension_name. You should only enable extensions found from the search_available_extensions tool.
|
||||
Use ToolSearch to discover deferred extension tools, and use exact names such as `select:Read,Edit,Grep` or `select:mcp__playwright__browser_click` when you need to load a specific deferred tool into the active tool surface.
|
||||
Do not keep retrying ToolSearch with synonyms like `read_file`, `write_file`, `edit_file`, or `system`. If ToolSearch returns no matches, call already-visible native tools directly or report that the deferred capability is unavailable.
|
||||
You can call multiple tools in a single response. When several read-only or otherwise independent tool calls do not depend on each other, emit them together in the same response so the runtime can execute them in parallel. Only keep tool calls sequential when later calls depend on earlier results.
|
||||
If Extension Manager is not available, you can only work with currently enabled extensions and cannot dynamically load new ones.
|
||||
|
||||
{% if (extensions is defined) and extensions %}
|
||||
|
||||
@@ -122,6 +122,17 @@ impl AnthropicProvider {
|
||||
Ok(request.api_post(payload).await?)
|
||||
}
|
||||
|
||||
async fn post_stream(&self, payload: &Value) -> Result<reqwest::Response, ProviderError> {
|
||||
let mut request = self.api_client.request("v1/messages");
|
||||
|
||||
for (key, value) in self.get_conditional_headers() {
|
||||
request = request.header(key, value)?;
|
||||
}
|
||||
|
||||
let response = Box::pin(async move { request.response_post(payload).await }).await?;
|
||||
Box::pin(async move { handle_status_openai_compat(response).await }).await
|
||||
}
|
||||
|
||||
fn anthropic_api_call_result(response: ApiResponse) -> Result<Value, ProviderError> {
|
||||
match response.status {
|
||||
StatusCode::OK => response.payload.ok_or_else(|| {
|
||||
@@ -259,19 +270,17 @@ impl Provider for AnthropicProvider {
|
||||
.unwrap()
|
||||
.insert("stream".to_string(), Value::Bool(true));
|
||||
|
||||
let mut request = self.api_client.request("v1/messages");
|
||||
let mut log = RequestLog::start(&self.model, &payload)?;
|
||||
|
||||
for (key, value) in self.get_conditional_headers() {
|
||||
request = request.header(key, value)?;
|
||||
}
|
||||
|
||||
let resp = request.response_post(&payload).await.inspect_err(|e| {
|
||||
let _ = log.error(e);
|
||||
})?;
|
||||
let response = handle_status_openai_compat(resp).await.inspect_err(|e| {
|
||||
let _ = log.error(e);
|
||||
})?;
|
||||
let response = self
|
||||
.with_retry(|| {
|
||||
let payload = payload.clone();
|
||||
async move { self.post_stream(&payload).await }
|
||||
})
|
||||
.await
|
||||
.inspect_err(|e| {
|
||||
let _ = log.error(e);
|
||||
})?;
|
||||
|
||||
let stream = response.bytes_stream().map_err(io::Error::other);
|
||||
|
||||
|
||||
@@ -358,7 +358,9 @@ impl<'a> ApiRequestBuilder<'a> {
|
||||
);
|
||||
|
||||
let request = self.send_request(|url, client| client.post(url)).await?;
|
||||
Ok(request.json(payload).send().await?)
|
||||
Box::pin(async move { request.json(payload).send().await })
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn api_get(self) -> Result<ApiResponse> {
|
||||
|
||||
@@ -3,7 +3,7 @@ use crate::model::ModelConfig;
|
||||
use crate::providers::base::Usage;
|
||||
use crate::providers::errors::ProviderError;
|
||||
use crate::providers::formats::tool_input_examples;
|
||||
use crate::providers::utils::{convert_image, ImageFormat};
|
||||
use crate::providers::utils::{convert_image, parse_tool_arguments_json_object, ImageFormat};
|
||||
use anyhow::{anyhow, Result};
|
||||
use rmcp::model::{object, CallToolRequestParam, ErrorCode, ErrorData, JsonObject, Role, Tool};
|
||||
use rmcp::object as json_object;
|
||||
@@ -507,7 +507,6 @@ where
|
||||
let mut current_tool_id: Option<String> = None;
|
||||
let mut final_usage: Option<crate::providers::base::ProviderUsage> = None;
|
||||
let mut message_id: Option<String> = None;
|
||||
|
||||
while let Some(line_result) = stream.next().await {
|
||||
let line = line_result?;
|
||||
|
||||
@@ -607,7 +606,7 @@ where
|
||||
let parsed_args = if args.is_empty() {
|
||||
json!({})
|
||||
} else {
|
||||
match serde_json::from_str::<Value>(&args) {
|
||||
match parse_tool_arguments_json_object(&args) {
|
||||
Ok(parsed) => parsed,
|
||||
Err(_) => {
|
||||
// If parsing fails, create an error tool request
|
||||
|
||||
@@ -3,8 +3,8 @@ use crate::model::ModelConfig;
|
||||
use crate::providers::formats::google as gemini_schema;
|
||||
use crate::providers::formats::tool_description_with_examples;
|
||||
use crate::providers::utils::{
|
||||
convert_image, detect_image_path, is_valid_function_name, load_image_file, safely_parse_json,
|
||||
sanitize_function_name, ImageFormat,
|
||||
convert_image, detect_image_path, is_valid_function_name, load_image_file,
|
||||
parse_tool_arguments_json_object, sanitize_function_name, ImageFormat,
|
||||
};
|
||||
use anyhow::{anyhow, Error};
|
||||
use rmcp::model::{
|
||||
@@ -337,7 +337,7 @@ pub fn response_to_message(response: &Value) -> anyhow::Result<Message> {
|
||||
};
|
||||
content.push(MessageContent::tool_request(id, Err(error)));
|
||||
} else {
|
||||
match safely_parse_json(&arguments_str) {
|
||||
match parse_tool_arguments_json_object(&arguments_str) {
|
||||
Ok(params) => {
|
||||
content.push(MessageContent::tool_request(
|
||||
id,
|
||||
|
||||
@@ -3,8 +3,8 @@ use crate::model::ModelConfig;
|
||||
use crate::providers::base::{ProviderUsage, Usage};
|
||||
use crate::providers::formats::tool_description_with_examples;
|
||||
use crate::providers::utils::{
|
||||
convert_image, detect_image_path, is_valid_function_name, load_image_file, safely_parse_json,
|
||||
sanitize_function_name, ImageFormat,
|
||||
convert_image, detect_image_path, is_valid_function_name, load_image_file,
|
||||
parse_tool_arguments_json_object, safely_parse_json, sanitize_function_name, ImageFormat,
|
||||
};
|
||||
use anyhow::{anyhow, Error};
|
||||
use async_stream::try_stream;
|
||||
@@ -582,7 +582,7 @@ where
|
||||
let parsed = if arguments.is_empty() {
|
||||
Ok(json!({}))
|
||||
} else {
|
||||
serde_json::from_str::<Value>(arguments)
|
||||
parse_tool_arguments_json_object(arguments)
|
||||
};
|
||||
|
||||
let content = match parsed {
|
||||
|
||||
+81
-31
@@ -2,13 +2,15 @@ use crate::conversation::message::{Message, MessageContent};
|
||||
use crate::model::ModelConfig;
|
||||
use crate::providers::base::{ProviderUsage, Usage};
|
||||
use crate::providers::formats::tool_description_with_examples;
|
||||
use crate::providers::utils::parse_tool_arguments_json_object;
|
||||
use anyhow::{anyhow, Error};
|
||||
use async_stream::try_stream;
|
||||
use chrono;
|
||||
use futures::Stream;
|
||||
use rmcp::model::{object, CallToolRequestParam, RawContent, Role, Tool};
|
||||
use rmcp::model::{object, CallToolRequestParam, ErrorCode, ErrorData, RawContent, Role, Tool};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::borrow::Cow;
|
||||
use std::ops::Deref;
|
||||
|
||||
fn convert_image_to_input_image(mime_type: &str, data: &str) -> Value {
|
||||
@@ -522,18 +524,34 @@ pub fn responses_api_to_message(response: &ResponsesApiResponse) -> anyhow::Resu
|
||||
} => {
|
||||
tracing::debug!("Received FunctionCall with id: {}, name: {}", id, name);
|
||||
let parsed_args = if arguments.is_empty() {
|
||||
json!({})
|
||||
Ok(json!({}))
|
||||
} else {
|
||||
serde_json::from_str(arguments).unwrap_or_else(|_| json!({}))
|
||||
parse_tool_arguments_json_object(arguments)
|
||||
};
|
||||
|
||||
content.push(MessageContent::tool_request(
|
||||
id.clone(),
|
||||
Ok(CallToolRequestParam {
|
||||
name: name.clone().into(),
|
||||
arguments: Some(object(parsed_args)),
|
||||
}),
|
||||
));
|
||||
match parsed_args {
|
||||
Ok(parsed_args) => {
|
||||
content.push(MessageContent::tool_request(
|
||||
id.clone(),
|
||||
Ok(CallToolRequestParam {
|
||||
name: name.clone().into(),
|
||||
arguments: Some(object(parsed_args)),
|
||||
}),
|
||||
));
|
||||
}
|
||||
Err(error) => {
|
||||
content.push(MessageContent::tool_request(
|
||||
id.clone(),
|
||||
Err(ErrorData {
|
||||
code: ErrorCode::INVALID_PARAMS,
|
||||
message: Cow::from(format!(
|
||||
"Could not interpret tool use parameters for id {}: {}. Raw arguments: '{}'",
|
||||
id, error, arguments
|
||||
)),
|
||||
data: None,
|
||||
}),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -587,18 +605,34 @@ fn process_streaming_output_items(
|
||||
arguments,
|
||||
} => {
|
||||
let parsed_args = if arguments.is_empty() {
|
||||
json!({})
|
||||
Ok(json!({}))
|
||||
} else {
|
||||
serde_json::from_str(&arguments).unwrap_or_else(|_| json!({}))
|
||||
parse_tool_arguments_json_object(&arguments)
|
||||
};
|
||||
|
||||
content.push(MessageContent::tool_request(
|
||||
id,
|
||||
Ok(CallToolRequestParam {
|
||||
name: name.into(),
|
||||
arguments: Some(object(parsed_args)),
|
||||
}),
|
||||
));
|
||||
match parsed_args {
|
||||
Ok(parsed_args) => {
|
||||
content.push(MessageContent::tool_request(
|
||||
id,
|
||||
Ok(CallToolRequestParam {
|
||||
name: name.into(),
|
||||
arguments: Some(object(parsed_args)),
|
||||
}),
|
||||
));
|
||||
}
|
||||
Err(error) => {
|
||||
content.push(MessageContent::tool_request(
|
||||
id.clone(),
|
||||
Err(ErrorData {
|
||||
code: ErrorCode::INVALID_PARAMS,
|
||||
message: Cow::from(format!(
|
||||
"Could not interpret tool use parameters for id {}: {}. Raw arguments: '{}'",
|
||||
id, error, arguments
|
||||
)),
|
||||
data: None,
|
||||
}),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -610,18 +644,34 @@ fn process_streaming_output_items(
|
||||
..
|
||||
} => {
|
||||
let parsed_args = if arguments.is_empty() {
|
||||
json!({})
|
||||
Ok(json!({}))
|
||||
} else {
|
||||
serde_json::from_str(&arguments).unwrap_or_else(|_| json!({}))
|
||||
parse_tool_arguments_json_object(&arguments)
|
||||
};
|
||||
|
||||
content.push(MessageContent::tool_request(
|
||||
call_id,
|
||||
Ok(CallToolRequestParam {
|
||||
name: name.into(),
|
||||
arguments: Some(object(parsed_args)),
|
||||
}),
|
||||
));
|
||||
match parsed_args {
|
||||
Ok(parsed_args) => {
|
||||
content.push(MessageContent::tool_request(
|
||||
call_id,
|
||||
Ok(CallToolRequestParam {
|
||||
name: name.into(),
|
||||
arguments: Some(object(parsed_args)),
|
||||
}),
|
||||
));
|
||||
}
|
||||
Err(error) => {
|
||||
content.push(MessageContent::tool_request(
|
||||
call_id.clone(),
|
||||
Err(ErrorData {
|
||||
code: ErrorCode::INVALID_PARAMS,
|
||||
message: Cow::from(format!(
|
||||
"Could not interpret tool use parameters for id {}: {}. Raw arguments: '{}'",
|
||||
call_id, error, arguments
|
||||
)),
|
||||
data: None,
|
||||
}),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,12 @@ use crate::model::ModelConfig;
|
||||
use crate::providers::base::Usage;
|
||||
use crate::providers::errors::ProviderError;
|
||||
use crate::providers::formats::tool_description_with_examples;
|
||||
use crate::providers::utils::parse_tool_arguments_json_object;
|
||||
use anyhow::{anyhow, Result};
|
||||
use rmcp::model::{object, CallToolRequestParam, Role, Tool};
|
||||
use rmcp::model::{object, CallToolRequestParam, ErrorCode, ErrorData, Role, Tool};
|
||||
use rmcp::object;
|
||||
use serde_json::{json, Value};
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashSet;
|
||||
|
||||
/// Convert internal Message format to Snowflake's API message specification
|
||||
@@ -183,13 +185,28 @@ pub fn parse_streaming_response(sse_data: &str) -> Result<Message> {
|
||||
// Add tool use if complete
|
||||
if let Some((id, name)) = tool_use_id.zip(tool_name) {
|
||||
if !tool_input.is_empty() {
|
||||
let input_value = serde_json::from_str::<Value>(&tool_input)
|
||||
.unwrap_or_else(|_| Value::String(tool_input.clone()));
|
||||
let tool_call = CallToolRequestParam {
|
||||
name: name.into(),
|
||||
arguments: Some(object(input_value)),
|
||||
};
|
||||
message = message.with_tool_request(&id, Ok(tool_call));
|
||||
match parse_tool_arguments_json_object(&tool_input) {
|
||||
Ok(input_value) => {
|
||||
let tool_call = CallToolRequestParam {
|
||||
name: name.into(),
|
||||
arguments: Some(object(input_value)),
|
||||
};
|
||||
message = message.with_tool_request(&id, Ok(tool_call));
|
||||
}
|
||||
Err(error) => {
|
||||
message = message.with_tool_request(
|
||||
&id,
|
||||
Err(ErrorData {
|
||||
code: ErrorCode::INVALID_PARAMS,
|
||||
message: Cow::from(format!(
|
||||
"Could not interpret tool use parameters for id {}: {}. Raw arguments: '{}'",
|
||||
id, error, tool_input
|
||||
)),
|
||||
data: None,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Tool with no input - use empty object
|
||||
let tool_call = CallToolRequestParam {
|
||||
|
||||
@@ -13,6 +13,8 @@ use reqwest::{Response, StatusCode};
|
||||
use rmcp::model::{AnnotateAble, ImageContent, RawImageContent};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::any::{type_name, Any};
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt::Display;
|
||||
use std::fs::File;
|
||||
use std::io;
|
||||
@@ -478,11 +480,324 @@ pub struct RequestLog {
|
||||
}
|
||||
|
||||
pub const LOGS_TO_KEEP: usize = 10;
|
||||
const REQUEST_LOG_KEY_SAMPLE_LIMIT: usize = 12;
|
||||
const REQUEST_LOG_TOOL_NAME_SAMPLE_LIMIT: usize = 8;
|
||||
const REQUEST_LOG_TEXT_PREVIEW_CHARS: usize = 160;
|
||||
|
||||
#[derive(Default)]
|
||||
struct RequestLogContentStats {
|
||||
block_count: usize,
|
||||
text_chars: usize,
|
||||
cache_control_blocks: usize,
|
||||
type_counts: BTreeMap<String, usize>,
|
||||
first_text_preview: Option<String>,
|
||||
}
|
||||
|
||||
impl RequestLogContentStats {
|
||||
fn ingest_block(&mut self, block: &Value) {
|
||||
self.block_count += 1;
|
||||
*self
|
||||
.type_counts
|
||||
.entry(request_log_value_type_hint(block))
|
||||
.or_default() += 1;
|
||||
|
||||
if block.get("cache_control").is_some() {
|
||||
self.cache_control_blocks += 1;
|
||||
}
|
||||
|
||||
if let Some(text) = request_log_text_candidate(block) {
|
||||
self.ingest_text_preview(text);
|
||||
}
|
||||
}
|
||||
|
||||
fn ingest_text_preview(&mut self, text: &str) {
|
||||
let chars = text.chars().count();
|
||||
self.text_chars += chars;
|
||||
if self.first_text_preview.is_none() && !text.trim().is_empty() {
|
||||
self.first_text_preview = Some(request_log_truncate_text(text));
|
||||
}
|
||||
}
|
||||
|
||||
fn into_json(self) -> Value {
|
||||
let mut summary = serde_json::Map::new();
|
||||
summary.insert("blocks".to_string(), json!(self.block_count));
|
||||
summary.insert("type_counts".to_string(), json!(self.type_counts));
|
||||
|
||||
if self.text_chars > 0 {
|
||||
summary.insert("text_chars".to_string(), json!(self.text_chars));
|
||||
}
|
||||
if self.cache_control_blocks > 0 {
|
||||
summary.insert(
|
||||
"cache_control_blocks".to_string(),
|
||||
json!(self.cache_control_blocks),
|
||||
);
|
||||
}
|
||||
if let Some(preview) = self.first_text_preview {
|
||||
summary.insert("text_preview".to_string(), json!(preview));
|
||||
}
|
||||
|
||||
Value::Object(summary)
|
||||
}
|
||||
}
|
||||
|
||||
fn request_log_truncate_text(text: &str) -> String {
|
||||
let mut preview = text
|
||||
.chars()
|
||||
.take(REQUEST_LOG_TEXT_PREVIEW_CHARS)
|
||||
.collect::<String>();
|
||||
if text.chars().count() > REQUEST_LOG_TEXT_PREVIEW_CHARS {
|
||||
preview.push_str("...");
|
||||
}
|
||||
preview
|
||||
}
|
||||
|
||||
fn request_log_scalar_summary(value: &Value) -> Value {
|
||||
match value {
|
||||
Value::Null => json!({ "type": "null" }),
|
||||
Value::Bool(boolean) => json!({ "type": "bool", "value": boolean }),
|
||||
Value::Number(number) => json!({ "type": "number", "value": number }),
|
||||
Value::String(text) => json!({
|
||||
"type": "string",
|
||||
"chars": text.chars().count(),
|
||||
"preview": request_log_truncate_text(text),
|
||||
}),
|
||||
Value::Array(items) => json!({
|
||||
"type": "array",
|
||||
"len": items.len(),
|
||||
}),
|
||||
Value::Object(object) => json!({
|
||||
"type": "object",
|
||||
"keys": object
|
||||
.keys()
|
||||
.take(REQUEST_LOG_KEY_SAMPLE_LIMIT)
|
||||
.cloned()
|
||||
.collect::<Vec<_>>(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn request_log_value_type_hint(value: &Value) -> String {
|
||||
value
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| match value {
|
||||
Value::Null => "null".to_string(),
|
||||
Value::Bool(_) => "bool".to_string(),
|
||||
Value::Number(_) => "number".to_string(),
|
||||
Value::String(_) => "string".to_string(),
|
||||
Value::Array(_) => "array".to_string(),
|
||||
Value::Object(_) => "object".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn request_log_text_candidate(value: &Value) -> Option<&str> {
|
||||
["text", "input_text", "content"]
|
||||
.iter()
|
||||
.find_map(|key| value.get(*key).and_then(Value::as_str))
|
||||
}
|
||||
|
||||
fn summarize_request_log_messages(messages: &[Value]) -> Value {
|
||||
let roles = messages
|
||||
.iter()
|
||||
.filter_map(|message| message.get("role").and_then(Value::as_str))
|
||||
.map(str::to_string)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let mut stats = RequestLogContentStats::default();
|
||||
let mut last_user_preview = None;
|
||||
|
||||
for message in messages {
|
||||
let role = message.get("role").and_then(Value::as_str);
|
||||
match message.get("content") {
|
||||
Some(Value::Array(blocks)) => {
|
||||
for block in blocks {
|
||||
stats.ingest_block(block);
|
||||
}
|
||||
if role == Some("user") {
|
||||
last_user_preview = blocks
|
||||
.iter()
|
||||
.find_map(request_log_text_candidate)
|
||||
.map(request_log_truncate_text);
|
||||
}
|
||||
}
|
||||
Some(Value::String(text)) => {
|
||||
stats.ingest_text_preview(text);
|
||||
if role == Some("user") {
|
||||
last_user_preview = Some(request_log_truncate_text(text));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let mut summary = serde_json::Map::new();
|
||||
summary.insert("count".to_string(), json!(messages.len()));
|
||||
if !roles.is_empty() {
|
||||
summary.insert("roles".to_string(), json!(roles));
|
||||
}
|
||||
summary.insert("content".to_string(), stats.into_json());
|
||||
if let Some(preview) = last_user_preview {
|
||||
summary.insert("last_user_preview".to_string(), json!(preview));
|
||||
}
|
||||
Value::Object(summary)
|
||||
}
|
||||
|
||||
fn summarize_request_log_tools(tools: &[Value]) -> Value {
|
||||
let names = tools
|
||||
.iter()
|
||||
.filter_map(|tool| tool.get("name").and_then(Value::as_str))
|
||||
.take(REQUEST_LOG_TOOL_NAME_SAMPLE_LIMIT)
|
||||
.map(str::to_string)
|
||||
.collect::<Vec<_>>();
|
||||
let cache_control_tools = tools
|
||||
.iter()
|
||||
.filter(|tool| tool.get("cache_control").is_some())
|
||||
.count();
|
||||
|
||||
let mut summary = serde_json::Map::new();
|
||||
summary.insert("count".to_string(), json!(tools.len()));
|
||||
if !names.is_empty() {
|
||||
summary.insert("names".to_string(), json!(names));
|
||||
}
|
||||
if cache_control_tools > 0 {
|
||||
summary.insert(
|
||||
"cache_control_tools".to_string(),
|
||||
json!(cache_control_tools),
|
||||
);
|
||||
}
|
||||
Value::Object(summary)
|
||||
}
|
||||
|
||||
fn summarize_request_log_system(system: &Value) -> Value {
|
||||
match system {
|
||||
Value::Array(blocks) => {
|
||||
let mut stats = RequestLogContentStats::default();
|
||||
for block in blocks {
|
||||
stats.ingest_block(block);
|
||||
}
|
||||
let mut summary = serde_json::Map::new();
|
||||
summary.insert("format".to_string(), json!("blocks"));
|
||||
summary.insert("content".to_string(), stats.into_json());
|
||||
Value::Object(summary)
|
||||
}
|
||||
Value::String(text) => json!({
|
||||
"format": "string",
|
||||
"chars": text.chars().count(),
|
||||
"preview": request_log_truncate_text(text),
|
||||
}),
|
||||
_ => request_log_scalar_summary(system),
|
||||
}
|
||||
}
|
||||
|
||||
fn summarize_request_log_input_items(items: &[Value]) -> Value {
|
||||
let roles = items
|
||||
.iter()
|
||||
.filter_map(|item| item.get("role").and_then(Value::as_str))
|
||||
.map(str::to_string)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let mut stats = RequestLogContentStats::default();
|
||||
for item in items {
|
||||
match item.get("content") {
|
||||
Some(Value::Array(content_items)) => {
|
||||
for content_item in content_items {
|
||||
stats.ingest_block(content_item);
|
||||
}
|
||||
}
|
||||
Some(Value::String(text)) => stats.ingest_text_preview(text),
|
||||
_ => stats.ingest_block(item),
|
||||
}
|
||||
}
|
||||
|
||||
let mut summary = serde_json::Map::new();
|
||||
summary.insert("count".to_string(), json!(items.len()));
|
||||
if !roles.is_empty() {
|
||||
summary.insert("roles".to_string(), json!(roles));
|
||||
}
|
||||
summary.insert("content".to_string(), stats.into_json());
|
||||
Value::Object(summary)
|
||||
}
|
||||
|
||||
fn summarize_request_log_json_payload(payload: &Value, payload_type: &'static str) -> Value {
|
||||
let mut summary = serde_json::Map::new();
|
||||
summary.insert("logging_mode".to_string(), json!("summary"));
|
||||
summary.insert("payload_type".to_string(), json!(payload_type));
|
||||
|
||||
match payload {
|
||||
Value::Object(object) => {
|
||||
summary.insert("kind".to_string(), json!("object"));
|
||||
summary.insert(
|
||||
"keys".to_string(),
|
||||
json!(object
|
||||
.keys()
|
||||
.take(REQUEST_LOG_KEY_SAMPLE_LIMIT)
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()),
|
||||
);
|
||||
|
||||
for key in ["model", "stream", "max_tokens", "temperature", "seed"] {
|
||||
if let Some(value) = object.get(key) {
|
||||
summary.insert(key.to_string(), request_log_scalar_summary(value));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(messages) = object.get("messages").and_then(Value::as_array) {
|
||||
summary.insert(
|
||||
"messages".to_string(),
|
||||
summarize_request_log_messages(messages),
|
||||
);
|
||||
}
|
||||
if let Some(system) = object.get("system") {
|
||||
summary.insert("system".to_string(), summarize_request_log_system(system));
|
||||
}
|
||||
if let Some(tools) = object.get("tools").and_then(Value::as_array) {
|
||||
summary.insert("tools".to_string(), summarize_request_log_tools(tools));
|
||||
}
|
||||
if let Some(input) = object.get("input").and_then(Value::as_array) {
|
||||
summary.insert(
|
||||
"input".to_string(),
|
||||
summarize_request_log_input_items(input),
|
||||
);
|
||||
}
|
||||
}
|
||||
Value::Array(items) => {
|
||||
summary.insert("kind".to_string(), json!("array"));
|
||||
summary.insert(
|
||||
"items".to_string(),
|
||||
summarize_request_log_input_items(items),
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
summary.insert(
|
||||
"kind".to_string(),
|
||||
json!(request_log_value_type_hint(payload)),
|
||||
);
|
||||
summary.insert("value".to_string(), request_log_scalar_summary(payload));
|
||||
}
|
||||
}
|
||||
|
||||
Value::Object(summary)
|
||||
}
|
||||
|
||||
fn summarize_request_log_input<Payload>(payload: &Payload) -> Value
|
||||
where
|
||||
Payload: Any,
|
||||
{
|
||||
if let Some(value) = (payload as &dyn Any).downcast_ref::<Value>() {
|
||||
return summarize_request_log_json_payload(value, type_name::<Payload>());
|
||||
}
|
||||
|
||||
json!({
|
||||
"logging_mode": "summary",
|
||||
"payload_type": type_name::<Payload>(),
|
||||
})
|
||||
}
|
||||
|
||||
impl RequestLog {
|
||||
pub fn start<Payload>(model_config: &ModelConfig, payload: &Payload) -> Result<Self>
|
||||
where
|
||||
Payload: Serialize,
|
||||
Payload: Any,
|
||||
{
|
||||
let logs_dir = Paths::in_state_dir("logs");
|
||||
|
||||
@@ -500,7 +815,7 @@ impl RequestLog {
|
||||
|
||||
let data = serde_json::json!({
|
||||
"model_config": model_config,
|
||||
"input": payload,
|
||||
"input": summarize_request_log_input(payload),
|
||||
});
|
||||
writeln!(writer, "{}", serde_json::to_string(&data)?)?;
|
||||
|
||||
@@ -583,6 +898,154 @@ pub fn safely_parse_json(s: &str) -> Result<serde_json::Value, serde_json::Error
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_wrapping_code_fence(s: &str) -> &str {
|
||||
let trimmed = s.trim();
|
||||
let Some(stripped_prefix) = trimmed.strip_prefix("```") else {
|
||||
return trimmed;
|
||||
};
|
||||
let Some(first_newline) = stripped_prefix.find('\n') else {
|
||||
return trimmed;
|
||||
};
|
||||
let fenced_body = &stripped_prefix[first_newline + 1..];
|
||||
fenced_body
|
||||
.trim_end()
|
||||
.strip_suffix("```")
|
||||
.map(str::trim)
|
||||
.unwrap_or(trimmed)
|
||||
}
|
||||
|
||||
fn tool_protocol_tag_regex() -> &'static Regex {
|
||||
static TOOL_PROTOCOL_TAG_RE: OnceLock<Regex> = OnceLock::new();
|
||||
TOOL_PROTOCOL_TAG_RE.get_or_init(|| {
|
||||
Regex::new(
|
||||
r#"(?is)</?(?:tool_call|tool_use|tool_result|function_call|function_calls)\b[^>]*>"#,
|
||||
)
|
||||
.expect("tool protocol tag regex should compile")
|
||||
})
|
||||
}
|
||||
|
||||
fn strip_tool_protocol_markup(s: &str) -> String {
|
||||
tool_protocol_tag_regex().replace_all(s, " ").into_owned()
|
||||
}
|
||||
|
||||
fn push_unique_candidate(candidates: &mut Vec<String>, candidate: &str) {
|
||||
let trimmed = candidate.trim();
|
||||
if trimmed.is_empty() {
|
||||
return;
|
||||
}
|
||||
if !candidates.iter().any(|existing| existing == trimmed) {
|
||||
candidates.push(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
fn find_outer_json_span(s: &str) -> Option<(usize, usize)> {
|
||||
let mut start: Option<usize> = None;
|
||||
let mut stack: Vec<char> = Vec::new();
|
||||
let mut in_string = false;
|
||||
let mut escaping = false;
|
||||
|
||||
for (index, ch) in s.char_indices() {
|
||||
if start.is_none() {
|
||||
match ch {
|
||||
'{' => {
|
||||
start = Some(index);
|
||||
stack.push('}');
|
||||
}
|
||||
'[' => {
|
||||
start = Some(index);
|
||||
stack.push(']');
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if in_string {
|
||||
if escaping {
|
||||
escaping = false;
|
||||
continue;
|
||||
}
|
||||
match ch {
|
||||
'\\' => escaping = true,
|
||||
'"' => in_string = false,
|
||||
_ => {}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
match ch {
|
||||
'"' => in_string = true,
|
||||
'{' => stack.push('}'),
|
||||
'[' => stack.push(']'),
|
||||
'}' | ']' => {
|
||||
if stack.last().copied() != Some(ch) {
|
||||
return None;
|
||||
}
|
||||
stack.pop();
|
||||
if stack.is_empty() {
|
||||
return start.map(|start_index| (start_index, index + ch.len_utf8()));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn build_tool_argument_candidates(raw: &str) -> Vec<String> {
|
||||
let mut candidates = Vec::new();
|
||||
push_unique_candidate(&mut candidates, raw);
|
||||
|
||||
let without_fence = strip_wrapping_code_fence(raw);
|
||||
push_unique_candidate(&mut candidates, without_fence);
|
||||
|
||||
let without_markup = strip_tool_protocol_markup(without_fence);
|
||||
push_unique_candidate(&mut candidates, &without_markup);
|
||||
|
||||
let seed_candidates = candidates.clone();
|
||||
for candidate in seed_candidates {
|
||||
if let Some((start, end)) = find_outer_json_span(&candidate) {
|
||||
push_unique_candidate(&mut candidates, &candidate[start..end]);
|
||||
}
|
||||
}
|
||||
|
||||
candidates
|
||||
}
|
||||
|
||||
fn parse_tool_arguments_candidate(candidate: &str, depth: usize) -> Option<serde_json::Value> {
|
||||
let parsed = safely_parse_json(candidate).ok()?;
|
||||
match parsed {
|
||||
Value::Object(_) => Some(parsed),
|
||||
Value::String(inner) if depth == 0 => {
|
||||
for nested_candidate in build_tool_argument_candidates(&inner) {
|
||||
if let Some(value) = parse_tool_arguments_candidate(&nested_candidate, depth + 1) {
|
||||
return Some(value);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_tool_arguments_json_object(raw: &str) -> anyhow::Result<serde_json::Value> {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Ok(json!({}));
|
||||
}
|
||||
|
||||
for candidate in build_tool_argument_candidates(trimmed) {
|
||||
if let Some(parsed) = parse_tool_arguments_candidate(&candidate, 0) {
|
||||
return Ok(parsed);
|
||||
}
|
||||
}
|
||||
|
||||
Err(anyhow!(
|
||||
"Could not interpret tool use parameters as a JSON object"
|
||||
))
|
||||
}
|
||||
|
||||
/// Helper to escape control characters in a string that is supposed to be a JSON document.
|
||||
/// This function iterates through the input string `s` and replaces any literal
|
||||
/// control characters (U+0000 to U+001F) with their JSON-escaped equivalents
|
||||
@@ -889,6 +1352,34 @@ mod tests {
|
||||
assert_eq!(result["key"], "value with\nnewline");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_tool_arguments_json_object_with_tool_markup_suffix() {
|
||||
let raw = r#"{"command":"ls /Users/coso/Documents/dev/js/claudecode"} </tool_call>"#;
|
||||
let parsed = parse_tool_arguments_json_object(raw).unwrap();
|
||||
assert_eq!(
|
||||
parsed["command"],
|
||||
"ls /Users/coso/Documents/dev/js/claudecode"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_tool_arguments_json_object_with_wrapping_tool_call_tag() {
|
||||
let raw =
|
||||
r#"<tool_call>{"command":"ls /Users/coso/Documents/dev/js/claudecode"}</tool_call>"#;
|
||||
let parsed = parse_tool_arguments_json_object(raw).unwrap();
|
||||
assert_eq!(
|
||||
parsed["command"],
|
||||
"ls /Users/coso/Documents/dev/js/claudecode"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_tool_arguments_json_object_with_stringified_object() {
|
||||
let raw = r#""{\"command\":\"ls /tmp\"}""#;
|
||||
let parsed = parse_tool_arguments_json_object(raw).unwrap();
|
||||
assert_eq!(parsed["command"], "ls /tmp");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_json_escape_control_chars_in_string() {
|
||||
// Test basic control character escaping
|
||||
@@ -957,4 +1448,68 @@ mod tests {
|
||||
Some(Duration::from_secs(42))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_summarize_request_log_json_payload_compacts_anthropic_request() {
|
||||
let payload = json!({
|
||||
"model": "glm-5.1",
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "对比 Claude Code 与 Lime 的 task 调度差异",
|
||||
"cache_control": { "type": "ephemeral" }
|
||||
},
|
||||
{
|
||||
"type": "image",
|
||||
"source": { "type": "base64" }
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"system": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "你是一个代码助手",
|
||||
"cache_control": { "type": "ephemeral" }
|
||||
}
|
||||
],
|
||||
"tools": [
|
||||
{ "name": "Read", "input_schema": { "type": "object" } },
|
||||
{ "name": "Glob", "input_schema": { "type": "object" }, "cache_control": { "type": "ephemeral" } }
|
||||
],
|
||||
"stream": true,
|
||||
"max_tokens": 64000
|
||||
});
|
||||
|
||||
let summary = summarize_request_log_json_payload(&payload, "serde_json::Value");
|
||||
|
||||
assert_eq!(summary["logging_mode"], "summary");
|
||||
assert_eq!(summary["messages"]["count"], 1);
|
||||
assert_eq!(summary["messages"]["content"]["type_counts"]["text"], 1);
|
||||
assert_eq!(summary["messages"]["content"]["type_counts"]["image"], 1);
|
||||
assert_eq!(summary["system"]["content"]["cache_control_blocks"], 1);
|
||||
assert_eq!(summary["tools"]["count"], 2);
|
||||
assert_eq!(summary["tools"]["cache_control_tools"], 1);
|
||||
assert_eq!(summary["model"]["preview"], "glm-5.1");
|
||||
assert!(summary.get("value").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_summarize_request_log_input_for_non_json_payload_keeps_type_only() {
|
||||
#[derive(Serialize)]
|
||||
struct ExamplePayload {
|
||||
value: &'static str,
|
||||
}
|
||||
|
||||
let summary = summarize_request_log_input(&ExamplePayload { value: "hello" });
|
||||
assert_eq!(summary["logging_mode"], "summary");
|
||||
assert!(summary["payload_type"]
|
||||
.as_str()
|
||||
.expect("payload_type should be a string")
|
||||
.contains("ExamplePayload"));
|
||||
assert!(summary.get("value").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,12 +8,13 @@ use super::api_client::{ApiClient, AuthMethod};
|
||||
use super::base::{ConfigKey, Provider, ProviderMetadata, ProviderUsage, Usage};
|
||||
use super::errors::ProviderError;
|
||||
use super::retry::ProviderRetry;
|
||||
use super::utils::map_http_error_to_provider_error;
|
||||
use super::utils::{map_http_error_to_provider_error, parse_tool_arguments_json_object};
|
||||
use crate::conversation::message::{Message, MessageContent};
|
||||
|
||||
use crate::mcp_utils::ToolResult;
|
||||
use crate::model::ModelConfig;
|
||||
use rmcp::model::{object, CallToolRequestParam, Role, Tool};
|
||||
use rmcp::model::{object, CallToolRequestParam, ErrorCode, ErrorData, Role, Tool};
|
||||
use std::borrow::Cow;
|
||||
|
||||
// ---------- Capability Flags ----------
|
||||
#[derive(Debug)]
|
||||
@@ -464,22 +465,46 @@ impl Provider for VeniceProvider {
|
||||
let function = tool_call["function"].clone();
|
||||
let name = function["name"].as_str().unwrap_or("unknown").to_string();
|
||||
|
||||
// Parse arguments string to Value if it's a string
|
||||
let arguments = if let Some(args_str) = function["arguments"].as_str() {
|
||||
serde_json::from_str::<Value>(args_str)
|
||||
.unwrap_or(function["arguments"].clone())
|
||||
let tool_request = if let Some(args_str) = function["arguments"].as_str() {
|
||||
match parse_tool_arguments_json_object(args_str) {
|
||||
Ok(arguments) => MessageContent::tool_request(
|
||||
id,
|
||||
ToolResult::Ok(CallToolRequestParam {
|
||||
name: name.into(),
|
||||
arguments: Some(object(arguments)),
|
||||
}),
|
||||
),
|
||||
Err(error) => MessageContent::tool_request(
|
||||
id,
|
||||
ToolResult::Err(ErrorData {
|
||||
code: ErrorCode::INVALID_PARAMS,
|
||||
message: Cow::from(format!(
|
||||
"Could not interpret tool use parameters: {}. Raw arguments: '{}'",
|
||||
error, args_str
|
||||
)),
|
||||
data: None,
|
||||
}),
|
||||
),
|
||||
}
|
||||
} else if function["arguments"].is_object() {
|
||||
MessageContent::tool_request(
|
||||
id,
|
||||
ToolResult::Ok(CallToolRequestParam {
|
||||
name: name.into(),
|
||||
arguments: Some(object(function["arguments"].clone())),
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
function["arguments"].clone()
|
||||
MessageContent::tool_request(
|
||||
id,
|
||||
ToolResult::Err(ErrorData {
|
||||
code: ErrorCode::INVALID_PARAMS,
|
||||
message: Cow::from("Could not interpret tool use parameters"),
|
||||
data: None,
|
||||
}),
|
||||
)
|
||||
};
|
||||
|
||||
let tool_call = CallToolRequestParam {
|
||||
name: name.into(),
|
||||
arguments: Some(object(arguments)),
|
||||
};
|
||||
|
||||
// Create a ToolRequest MessageContent
|
||||
let tool_request = MessageContent::tool_request(id, ToolResult::Ok(tool_call));
|
||||
|
||||
content.push(tool_request);
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,7 @@
|
||||
//! Requirements: 6.1, 6.2, 6.3, 6.4, 6.5
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::future::Future;
|
||||
@@ -310,15 +310,32 @@ impl Default for AskTool {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
#[derive(Debug, Clone)]
|
||||
enum AskOptionInput {
|
||||
String(String),
|
||||
Object(AskOptionObject),
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for AskOptionInput {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let value = Value::deserialize(deserializer)?;
|
||||
match value {
|
||||
Value::String(value) => Ok(Self::String(value)),
|
||||
Value::Object(_) => serde_json::from_value::<AskOptionObject>(value)
|
||||
.map(Self::Object)
|
||||
.map_err(serde::de::Error::custom),
|
||||
_ => Err(serde::de::Error::custom(
|
||||
"ask option must be a string or object",
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||||
struct AskOptionObject {
|
||||
value: Option<String>,
|
||||
label: Option<String>,
|
||||
@@ -355,7 +372,7 @@ impl TryFrom<AskOptionInput> for AskOption {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||||
struct AskQuestionInput {
|
||||
question: String,
|
||||
header: Option<String>,
|
||||
@@ -387,7 +404,7 @@ impl TryFrom<AskQuestionInput> for AskQuestion {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||||
struct AskToolInput {
|
||||
questions: Option<Vec<AskQuestionInput>>,
|
||||
}
|
||||
@@ -781,6 +798,7 @@ impl Tool for AskTool {
|
||||
fn input_schema(&self) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"questions": {
|
||||
"type": "array",
|
||||
@@ -789,6 +807,7 @@ impl Tool for AskTool {
|
||||
"maxItems": 4,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"question": {
|
||||
"type": "string",
|
||||
@@ -805,6 +824,7 @@ impl Tool for AskTool {
|
||||
"maxItems": 4,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"label": {
|
||||
"type": "string",
|
||||
@@ -1206,8 +1226,18 @@ mod tests {
|
||||
let schema = tool.input_schema();
|
||||
|
||||
assert_eq!(schema["type"], "object");
|
||||
assert_eq!(schema["additionalProperties"], serde_json::json!(false));
|
||||
assert!(schema["properties"]["questions"].is_object());
|
||||
assert_eq!(schema["required"], serde_json::json!(["questions"]));
|
||||
assert_eq!(
|
||||
schema["properties"]["questions"]["items"]["additionalProperties"],
|
||||
serde_json::json!(false)
|
||||
);
|
||||
assert_eq!(
|
||||
schema["properties"]["questions"]["items"]["properties"]["options"]["items"]
|
||||
["additionalProperties"],
|
||||
serde_json::json!(false)
|
||||
);
|
||||
assert!(!schema["properties"]
|
||||
.as_object()
|
||||
.unwrap()
|
||||
@@ -1343,6 +1373,89 @@ mod tests {
|
||||
assert!(matches!(result.unwrap_err(), ToolError::InvalidParams(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_ask_tool_rejects_unknown_top_level_field() {
|
||||
let callback = mock_callback(Some(serde_json::json!("test")));
|
||||
let tool = AskTool::new().with_callback(callback);
|
||||
let context = ToolContext::new(PathBuf::from("/tmp"));
|
||||
|
||||
let params = serde_json::json!({
|
||||
"questions": [
|
||||
{
|
||||
"question": "Continue?",
|
||||
"header": "Approval",
|
||||
"options": [
|
||||
{ "label": "Yes", "description": "Proceed" },
|
||||
{ "label": "No", "description": "Stop" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"extra": true
|
||||
});
|
||||
|
||||
let result = tool.execute(params, &context).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("unknown field `extra`"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_ask_tool_rejects_unknown_question_field() {
|
||||
let callback = mock_callback(Some(serde_json::json!("test")));
|
||||
let tool = AskTool::new().with_callback(callback);
|
||||
let context = ToolContext::new(PathBuf::from("/tmp"));
|
||||
|
||||
let params = serde_json::json!({
|
||||
"questions": [
|
||||
{
|
||||
"question": "Continue?",
|
||||
"header": "Approval",
|
||||
"options": [
|
||||
{ "label": "Yes", "description": "Proceed" },
|
||||
{ "label": "No", "description": "Stop" }
|
||||
],
|
||||
"extra": "field"
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
let result = tool.execute(params, &context).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("unknown field `extra`"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_ask_tool_rejects_unknown_option_field() {
|
||||
let callback = mock_callback(Some(serde_json::json!("test")));
|
||||
let tool = AskTool::new().with_callback(callback);
|
||||
let context = ToolContext::new(PathBuf::from("/tmp"));
|
||||
|
||||
let params = serde_json::json!({
|
||||
"questions": [
|
||||
{
|
||||
"question": "Continue?",
|
||||
"header": "Approval",
|
||||
"options": [
|
||||
{ "label": "Yes", "description": "Proceed", "extra": 1 },
|
||||
{ "label": "No", "description": "Stop" }
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
let result = tool.execute(params, &context).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("unknown field `extra`"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_ask_tool_check_permissions() {
|
||||
let tool = AskTool::new();
|
||||
|
||||
@@ -138,6 +138,14 @@ pub trait Tool: Send + Sync {
|
||||
/// - Providing parameter information to the LLM
|
||||
fn input_schema(&self) -> serde_json::Value;
|
||||
|
||||
/// Returns compatibility aliases for runtime lookup.
|
||||
///
|
||||
/// 这些别名不会额外暴露到模型可见的 tool definition 列表,
|
||||
/// 只用于兼容上游或历史 transcript 中的旧工具名。
|
||||
fn aliases(&self) -> &'static [&'static str] {
|
||||
&[]
|
||||
}
|
||||
|
||||
/// Execute the tool with the given parameters and context
|
||||
///
|
||||
/// This is the main entry point for tool execution.
|
||||
|
||||
@@ -10,19 +10,26 @@
|
||||
//!
|
||||
//! Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 3.7, 3.8, 3.9
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::Stdio;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use once_cell::sync::Lazy;
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::process::Command;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use super::base::{PermissionCheckResult, Tool};
|
||||
use super::command_semantics::interpret_bash_command_result;
|
||||
use super::context::{ToolContext, ToolOptions, ToolResult};
|
||||
use super::error::ToolError;
|
||||
use super::path_guard::{
|
||||
evaluate_path_mutations, resolve_static_path_candidate, summarize_paths, summarize_raw_paths,
|
||||
PathGuardFinding, PathMutationCandidate, PathMutationKind,
|
||||
};
|
||||
use super::task::TaskManager;
|
||||
|
||||
/// Maximum output length before truncation (128KB)
|
||||
@@ -34,6 +41,24 @@ pub const DEFAULT_TIMEOUT_SECS: u64 = 300;
|
||||
/// Maximum timeout allowed (30 minutes)
|
||||
pub const MAX_TIMEOUT_SECS: u64 = 1800;
|
||||
|
||||
static SHELL_ENV_ASSIGN_RE: Lazy<Regex> =
|
||||
Lazy::new(|| Regex::new(r"^[A-Za-z_]\w*=").expect("valid env assign regex"));
|
||||
static BASH_WRITE_REDIRECTION_RE: Lazy<Regex> = Lazy::new(|| {
|
||||
Regex::new(
|
||||
r#"(?x)
|
||||
(?:^|[\s;(])
|
||||
(?:\d+|&)?(?:>>?|>\|)
|
||||
\s*
|
||||
(?P<target>'[^']*'|"[^"]*"|[^\s;&|()]+)
|
||||
"#,
|
||||
)
|
||||
.expect("valid bash write redirection regex")
|
||||
});
|
||||
static BASH_SED_IN_PLACE_RE: Lazy<Regex> = Lazy::new(|| {
|
||||
Regex::new(r"(?i)\bsed\b[^\n;|&]*(?:\s--in-place(?:=\S+)?|\s-[A-Za-z]*i[A-Za-z]*)")
|
||||
.expect("valid sed in-place regex")
|
||||
});
|
||||
|
||||
/// Safety check result for command validation
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SafetyCheckResult {
|
||||
@@ -268,6 +293,10 @@ impl BashTool {
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(reason) = self.detect_high_risk_command_reason(command_trimmed) {
|
||||
return SafetyCheckResult::unsafe_with_reason(reason);
|
||||
}
|
||||
|
||||
// Check against warning patterns
|
||||
let mut warnings = Vec::new();
|
||||
for pattern in &self.warning_patterns {
|
||||
@@ -276,6 +305,10 @@ impl BashTool {
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(warning) = self.detect_mutating_command_warning(command_trimmed) {
|
||||
warnings.push(warning);
|
||||
}
|
||||
|
||||
if !warnings.is_empty() {
|
||||
return SafetyCheckResult::safe_with_warning(warnings.join("; "));
|
||||
}
|
||||
@@ -329,6 +362,89 @@ impl BashTool {
|
||||
false
|
||||
}
|
||||
|
||||
fn detect_high_risk_command_reason(&self, command: &str) -> Option<String> {
|
||||
for segment in split_shell_segments(command) {
|
||||
let words = extract_bash_command_words(segment);
|
||||
if words.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if words[0] != "git" {
|
||||
continue;
|
||||
}
|
||||
|
||||
let subcommand = words.get(1).map(String::as_str).unwrap_or("");
|
||||
match subcommand {
|
||||
"reset" if words.iter().any(|word| word == "--hard") => {
|
||||
return Some(
|
||||
"Blocked: `git reset --hard` is a destructive repository operation."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
"clean" if is_forced_git_clean(&words) => {
|
||||
return Some(
|
||||
"Blocked: forced `git clean` may permanently remove untracked files."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
"push" if words.iter().any(|word| word == "--force" || word == "-f") => {
|
||||
return Some(
|
||||
"Blocked: force-pushing git history requires explicit manual confirmation."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn detect_mutating_command_warning(&self, command: &str) -> Option<String> {
|
||||
if has_bash_write_redirection(command) {
|
||||
return Some("Command writes to files via shell redirection".to_string());
|
||||
}
|
||||
|
||||
for segment in split_shell_segments(command) {
|
||||
let words = extract_bash_command_words(segment);
|
||||
if words.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let command_name = words[0].as_str();
|
||||
if command_name == "sed" && segment_has_sed_in_place(segment) {
|
||||
return Some("Command performs in-place edits via `sed -i`".to_string());
|
||||
}
|
||||
|
||||
if command_name == "tee" && tee_writes_to_file(&words) {
|
||||
return Some("Command writes to files via `tee`".to_string());
|
||||
}
|
||||
|
||||
if command_name == "dd"
|
||||
&& words
|
||||
.iter()
|
||||
.any(|word| word.to_ascii_lowercase().starts_with("of="))
|
||||
{
|
||||
return Some("Command writes to files via `dd of=...`".to_string());
|
||||
}
|
||||
|
||||
if is_mutating_shell_command(command_name) {
|
||||
return Some(format!("Command may modify files via `{command_name}`"));
|
||||
}
|
||||
|
||||
if command_name == "git" {
|
||||
let subcommand = words.get(1).map(String::as_str).unwrap_or("");
|
||||
if is_mutating_git_subcommand(subcommand) {
|
||||
return Some(format!(
|
||||
"Command modifies repository state via `git {subcommand}`"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Check if a command is in the dangerous commands list
|
||||
pub fn is_dangerous_command(&self, command: &str) -> bool {
|
||||
!self.check_command_safety(command).safe
|
||||
@@ -340,6 +456,523 @@ impl BashTool {
|
||||
}
|
||||
}
|
||||
|
||||
fn split_shell_segments(command: &str) -> Vec<&str> {
|
||||
let mut segments = Vec::new();
|
||||
let mut start = 0usize;
|
||||
let mut in_single = false;
|
||||
let mut in_double = false;
|
||||
let mut chars = command.char_indices().peekable();
|
||||
|
||||
while let Some((index, ch)) = chars.next() {
|
||||
match ch {
|
||||
'\'' if !in_double => in_single = !in_single,
|
||||
'"' if !in_single => in_double = !in_double,
|
||||
';' | '\n' if !in_single && !in_double => {
|
||||
let segment = command[start..index].trim();
|
||||
if !segment.is_empty() {
|
||||
segments.push(segment);
|
||||
}
|
||||
start = index + ch.len_utf8();
|
||||
}
|
||||
'&' if !in_single && !in_double => {
|
||||
if let Some((next_index, next_char)) = chars.peek().copied() {
|
||||
if next_char == '&' {
|
||||
let segment = command[start..index].trim();
|
||||
if !segment.is_empty() {
|
||||
segments.push(segment);
|
||||
}
|
||||
let _ = chars.next();
|
||||
start = next_index + next_char.len_utf8();
|
||||
}
|
||||
}
|
||||
}
|
||||
'|' if !in_single && !in_double => {
|
||||
let segment = command[start..index].trim();
|
||||
if !segment.is_empty() {
|
||||
segments.push(segment);
|
||||
}
|
||||
if let Some((next_index, next_char)) = chars.peek().copied() {
|
||||
if next_char == '|' {
|
||||
let _ = chars.next();
|
||||
start = next_index + next_char.len_utf8();
|
||||
} else {
|
||||
start = index + ch.len_utf8();
|
||||
}
|
||||
} else {
|
||||
start = index + ch.len_utf8();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let rest = command[start..].trim();
|
||||
if !rest.is_empty() {
|
||||
segments.push(rest);
|
||||
}
|
||||
|
||||
segments
|
||||
}
|
||||
|
||||
fn normalize_shell_word(word: &str) -> String {
|
||||
word.trim_matches(|ch| matches!(ch, '"' | '\'' | '`' | '(' | ')' | ','))
|
||||
.to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn skip_shell_command_prefix(raw_words: &[String]) -> usize {
|
||||
let mut index = 0usize;
|
||||
while index < raw_words.len() {
|
||||
let normalized = normalize_shell_word(&raw_words[index]);
|
||||
if SHELL_ENV_ASSIGN_RE.is_match(&normalized) || is_shell_wrapper_command(&normalized) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
index
|
||||
}
|
||||
|
||||
fn extract_bash_command_words(segment: &str) -> Vec<String> {
|
||||
let raw_words = segment
|
||||
.split_whitespace()
|
||||
.map(normalize_shell_word)
|
||||
.filter(|word| !word.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
let start_index = skip_shell_command_prefix(&raw_words);
|
||||
|
||||
raw_words.into_iter().skip(start_index).collect()
|
||||
}
|
||||
|
||||
fn is_shell_wrapper_command(word: &str) -> bool {
|
||||
matches!(
|
||||
word,
|
||||
"sudo" | "env" | "command" | "builtin" | "nohup" | "nice" | "stdbuf" | "timeout" | "time"
|
||||
)
|
||||
}
|
||||
|
||||
fn has_bash_write_redirection(command: &str) -> bool {
|
||||
BASH_WRITE_REDIRECTION_RE
|
||||
.captures_iter(command)
|
||||
.any(|captures| {
|
||||
let Some(target) = captures.name("target") else {
|
||||
return false;
|
||||
};
|
||||
!is_safe_shell_sink(target.as_str())
|
||||
})
|
||||
}
|
||||
|
||||
fn is_safe_shell_sink(target: &str) -> bool {
|
||||
let normalized = normalize_shell_word(target);
|
||||
matches!(
|
||||
normalized.as_str(),
|
||||
"&1" | "&2" | "/dev/null" | "/dev/stdout" | "/dev/stderr" | "/dev/tty" | "nul"
|
||||
)
|
||||
}
|
||||
|
||||
fn segment_has_sed_in_place(segment: &str) -> bool {
|
||||
BASH_SED_IN_PLACE_RE.is_match(segment)
|
||||
}
|
||||
|
||||
fn tee_writes_to_file(words: &[String]) -> bool {
|
||||
words
|
||||
.iter()
|
||||
.skip(1)
|
||||
.filter(|word| !word.starts_with('-'))
|
||||
.any(|word| !is_safe_shell_sink(word))
|
||||
}
|
||||
|
||||
fn is_mutating_shell_command(command_name: &str) -> bool {
|
||||
matches!(
|
||||
command_name,
|
||||
"rm" | "rmdir"
|
||||
| "mv"
|
||||
| "cp"
|
||||
| "install"
|
||||
| "mkdir"
|
||||
| "touch"
|
||||
| "chmod"
|
||||
| "chown"
|
||||
| "chgrp"
|
||||
| "ln"
|
||||
| "unlink"
|
||||
| "truncate"
|
||||
)
|
||||
}
|
||||
|
||||
fn is_mutating_git_subcommand(subcommand: &str) -> bool {
|
||||
matches!(
|
||||
subcommand,
|
||||
"add"
|
||||
| "am"
|
||||
| "apply"
|
||||
| "branch"
|
||||
| "checkout"
|
||||
| "cherry-pick"
|
||||
| "clean"
|
||||
| "commit"
|
||||
| "merge"
|
||||
| "mv"
|
||||
| "pull"
|
||||
| "push"
|
||||
| "rebase"
|
||||
| "reset"
|
||||
| "restore"
|
||||
| "revert"
|
||||
| "rm"
|
||||
| "stash"
|
||||
| "switch"
|
||||
| "tag"
|
||||
)
|
||||
}
|
||||
|
||||
fn is_forced_git_clean(words: &[String]) -> bool {
|
||||
let has_force = words
|
||||
.iter()
|
||||
.any(|word| word.starts_with('-') && word.contains('f'));
|
||||
let has_scope = words.iter().any(|word| {
|
||||
word.starts_with('-') && (word.contains('d') || word.contains('x') || word.contains('X'))
|
||||
});
|
||||
has_force && has_scope
|
||||
}
|
||||
|
||||
fn validate_bash_command_paths(command: &str, cwd: &Path) -> Option<PermissionCheckResult> {
|
||||
let candidates = collect_bash_path_candidates(command);
|
||||
match evaluate_path_mutations(&candidates, cwd)? {
|
||||
PathGuardFinding::ProtectedPaths(paths) => Some(PermissionCheckResult::deny(format!(
|
||||
"Blocked: command targets protected path(s): {}",
|
||||
summarize_paths(&paths)
|
||||
))),
|
||||
PathGuardFinding::OutsideWorkspace(paths) => Some(PermissionCheckResult::ask(format!(
|
||||
"Command modifies path(s) outside the current working directory: {}. Do you want to proceed?",
|
||||
summarize_paths(&paths)
|
||||
))),
|
||||
PathGuardFinding::DynamicPaths(paths) => Some(PermissionCheckResult::ask(format!(
|
||||
"Command uses path expression(s) that cannot be validated safely: {}. Do you want to proceed?",
|
||||
summarize_raw_paths(&paths)
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_bash_path_candidates(command: &str) -> Vec<PathMutationCandidate> {
|
||||
let mut candidates = BASH_WRITE_REDIRECTION_RE
|
||||
.captures_iter(command)
|
||||
.filter_map(|captures| captures.name("target"))
|
||||
.map(|target| PathMutationCandidate::new(target.as_str(), PathMutationKind::Write))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
for segment in split_shell_segments(command) {
|
||||
let raw_words = tokenize_shell_words(segment);
|
||||
if raw_words.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let normalized_words = normalize_command_words(&raw_words);
|
||||
let command_name = normalized_words[0].as_str();
|
||||
|
||||
match command_name {
|
||||
"rm" | "rmdir" => {
|
||||
for target in extract_rm_targets(&raw_words) {
|
||||
candidates.push(PathMutationCandidate::new(target, PathMutationKind::Remove));
|
||||
}
|
||||
}
|
||||
"tee" => {
|
||||
for target in extract_tee_targets(&raw_words) {
|
||||
candidates.push(PathMutationCandidate::new(target, PathMutationKind::Write));
|
||||
}
|
||||
}
|
||||
"dd" => {
|
||||
for target in extract_dd_output_targets(&raw_words) {
|
||||
candidates.push(PathMutationCandidate::new(target, PathMutationKind::Write));
|
||||
}
|
||||
}
|
||||
"sed" if segment_has_sed_in_place(segment) => {
|
||||
for target in extract_sed_in_place_targets(&raw_words) {
|
||||
candidates.push(PathMutationCandidate::new(target, PathMutationKind::Write));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
candidates
|
||||
}
|
||||
|
||||
fn extract_bash_read_targets(raw_words: &[String], command_name: &str) -> Vec<String> {
|
||||
let start_index = skip_shell_command_prefix(raw_words);
|
||||
if raw_words.len() <= start_index + 1 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut positional_targets = Vec::new();
|
||||
let mut after_double_dash = false;
|
||||
|
||||
for word in raw_words.iter().skip(start_index + 1) {
|
||||
if !after_double_dash && word == "--" {
|
||||
after_double_dash = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
let normalized = normalize_shell_word(word);
|
||||
if !after_double_dash && normalized.starts_with('-') {
|
||||
continue;
|
||||
}
|
||||
|
||||
positional_targets.push(word.clone());
|
||||
}
|
||||
|
||||
match command_name {
|
||||
"cat" | "bat" | "head" | "tail" | "wc" | "ls" | "dir" | "tree" => {
|
||||
positional_targets.into_iter().rev().take(1).collect()
|
||||
}
|
||||
"rg" | "grep" | "findstr" if positional_targets.len() >= 2 => {
|
||||
positional_targets.into_iter().rev().take(1).collect()
|
||||
}
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_bash_read_path_candidates(command: &str) -> Vec<String> {
|
||||
let mut candidates = Vec::new();
|
||||
|
||||
for segment in split_shell_segments(command) {
|
||||
let raw_words = tokenize_shell_words(segment);
|
||||
if raw_words.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let normalized_words = normalize_command_words(&raw_words);
|
||||
if normalized_words.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let command_name = normalized_words[0].as_str();
|
||||
candidates.extend(extract_bash_read_targets(&raw_words, command_name));
|
||||
}
|
||||
|
||||
candidates
|
||||
}
|
||||
|
||||
fn is_known_read_only_bash_command(command_name: &str, words: &[String]) -> bool {
|
||||
match command_name {
|
||||
"cat" | "bat" | "head" | "tail" | "wc" | "ls" | "dir" | "tree" | "rg" | "grep"
|
||||
| "findstr" | "find" | "pwd" | "realpath" | "readlink" | "stat" | "file" | "du"
|
||||
| "which" | "cut" | "sort" | "uniq" | "tr" | "awk" | "jq" | "basename" | "dirname"
|
||||
| "test" | "[" => true,
|
||||
"sed" => true,
|
||||
"git" => matches!(
|
||||
words.get(1).map(String::as_str).unwrap_or(""),
|
||||
"status" | "diff" | "show" | "log" | "rev-parse" | "ls-files" | "grep" | "blame"
|
||||
),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_bash_command_concurrency_safe(command: &str) -> bool {
|
||||
let trimmed = command.trim();
|
||||
if trimmed.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
if has_bash_write_redirection(trimmed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut saw_segment = false;
|
||||
for segment in split_shell_segments(trimmed) {
|
||||
let words = extract_bash_command_words(segment);
|
||||
if words.is_empty() {
|
||||
continue;
|
||||
}
|
||||
saw_segment = true;
|
||||
|
||||
let command_name = words[0].as_str();
|
||||
if command_name == "sed" && segment_has_sed_in_place(segment) {
|
||||
return false;
|
||||
}
|
||||
if command_name == "tee" && tee_writes_to_file(&words) {
|
||||
return false;
|
||||
}
|
||||
if command_name == "dd"
|
||||
&& words
|
||||
.iter()
|
||||
.any(|word| word.to_ascii_lowercase().starts_with("of="))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if is_mutating_shell_command(command_name) {
|
||||
return false;
|
||||
}
|
||||
if command_name == "git"
|
||||
&& is_mutating_git_subcommand(words.get(1).map(String::as_str).unwrap_or(""))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if !is_known_read_only_bash_command(command_name, &words) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
saw_segment
|
||||
}
|
||||
|
||||
fn build_missing_read_target_result(paths: &[std::path::PathBuf]) -> ToolResult {
|
||||
let path_values = paths
|
||||
.iter()
|
||||
.map(|path| path.display().to_string())
|
||||
.collect::<Vec<_>>();
|
||||
let message = if path_values.len() == 1 {
|
||||
format!(
|
||||
"路径不存在:{}。请先确认父目录,或先列目录再继续读取。",
|
||||
path_values[0]
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"以下路径不存在:{}。请先确认父目录,或先列目录再继续读取。",
|
||||
path_values.join(", ")
|
||||
)
|
||||
};
|
||||
|
||||
ToolResult::error(message)
|
||||
.with_metadata("preflight_check", serde_json::json!("missing_read_target"))
|
||||
.with_metadata("missing_paths", serde_json::json!(path_values))
|
||||
}
|
||||
|
||||
pub fn preflight_bash_read_targets(command: &str, cwd: &Path) -> Option<ToolResult> {
|
||||
let mut missing_paths = Vec::new();
|
||||
|
||||
for raw_path in collect_bash_read_path_candidates(command) {
|
||||
let Some(resolved_path) = resolve_static_path_candidate(&raw_path, cwd) else {
|
||||
continue;
|
||||
};
|
||||
if resolved_path.exists() || missing_paths.contains(&resolved_path) {
|
||||
continue;
|
||||
}
|
||||
missing_paths.push(resolved_path);
|
||||
}
|
||||
|
||||
(!missing_paths.is_empty()).then(|| build_missing_read_target_result(&missing_paths))
|
||||
}
|
||||
|
||||
fn tokenize_shell_words(segment: &str) -> Vec<String> {
|
||||
let mut words = Vec::new();
|
||||
let mut current = String::new();
|
||||
let mut in_single = false;
|
||||
let mut in_double = false;
|
||||
let mut escaped = false;
|
||||
|
||||
for ch in segment.chars() {
|
||||
if escaped {
|
||||
current.push(ch);
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
match ch {
|
||||
'\\' if !in_single => {
|
||||
escaped = true;
|
||||
}
|
||||
'\'' if !in_double => {
|
||||
in_single = !in_single;
|
||||
}
|
||||
'"' if !in_single => {
|
||||
in_double = !in_double;
|
||||
}
|
||||
ch if ch.is_whitespace() && !in_single && !in_double => {
|
||||
if !current.is_empty() {
|
||||
words.push(std::mem::take(&mut current));
|
||||
}
|
||||
}
|
||||
_ => current.push(ch),
|
||||
}
|
||||
}
|
||||
|
||||
if !current.is_empty() {
|
||||
words.push(current);
|
||||
}
|
||||
|
||||
words
|
||||
}
|
||||
|
||||
fn normalize_command_words(raw_words: &[String]) -> Vec<String> {
|
||||
let words = raw_words
|
||||
.iter()
|
||||
.map(|word| normalize_shell_word(word))
|
||||
.filter(|word| !word.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
let start_index = skip_shell_command_prefix(raw_words);
|
||||
words.into_iter().skip(start_index).collect()
|
||||
}
|
||||
|
||||
fn extract_rm_targets(raw_words: &[String]) -> Vec<String> {
|
||||
if raw_words.len() <= 1 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut targets = Vec::new();
|
||||
let mut after_double_dash = false;
|
||||
|
||||
for word in raw_words.iter().skip(1) {
|
||||
if after_double_dash {
|
||||
targets.push(word.clone());
|
||||
continue;
|
||||
}
|
||||
if word == "--" {
|
||||
after_double_dash = true;
|
||||
continue;
|
||||
}
|
||||
if word.starts_with('-') {
|
||||
continue;
|
||||
}
|
||||
targets.push(word.clone());
|
||||
}
|
||||
|
||||
targets
|
||||
}
|
||||
|
||||
fn extract_tee_targets(raw_words: &[String]) -> Vec<String> {
|
||||
raw_words
|
||||
.iter()
|
||||
.skip(1)
|
||||
.filter(|word| word.as_str() != "--")
|
||||
.filter(|word| !word.starts_with('-'))
|
||||
.filter(|word| !is_safe_shell_sink(word))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn extract_dd_output_targets(raw_words: &[String]) -> Vec<String> {
|
||||
raw_words
|
||||
.iter()
|
||||
.skip(1)
|
||||
.filter_map(|word| word.strip_prefix("of=").map(ToOwned::to_owned))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn extract_sed_in_place_targets(raw_words: &[String]) -> Vec<String> {
|
||||
if raw_words.len() <= 1 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut non_flag_words = Vec::new();
|
||||
let mut after_double_dash = false;
|
||||
|
||||
for word in raw_words.iter().skip(1) {
|
||||
if after_double_dash {
|
||||
non_flag_words.push(word.clone());
|
||||
continue;
|
||||
}
|
||||
if word == "--" {
|
||||
after_double_dash = true;
|
||||
continue;
|
||||
}
|
||||
if word.starts_with('-') {
|
||||
continue;
|
||||
}
|
||||
non_flag_words.push(word.clone());
|
||||
}
|
||||
|
||||
non_flag_words.into_iter().skip(1).collect()
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Foreground Execution Implementation (Requirements: 3.1, 3.5)
|
||||
// =============================================================================
|
||||
@@ -406,20 +1039,32 @@ impl BashTool {
|
||||
stderr.len()
|
||||
);
|
||||
|
||||
let interpretation =
|
||||
interpret_bash_command_result(command, exit_code, &stdout, &stderr);
|
||||
|
||||
// Combine and truncate output
|
||||
let combined_output = self.format_output(&stdout, &stderr, exit_code);
|
||||
let combined_output = self.format_output_with_message(
|
||||
&stdout,
|
||||
&stderr,
|
||||
exit_code,
|
||||
interpretation.message.as_deref(),
|
||||
);
|
||||
let truncated_output = self.truncate_output(&combined_output);
|
||||
|
||||
if output.status.success() {
|
||||
Ok(ToolResult::success(truncated_output)
|
||||
.with_metadata("exit_code", serde_json::json!(exit_code))
|
||||
.with_metadata("stdout_length", serde_json::json!(stdout.len()))
|
||||
.with_metadata("stderr_length", serde_json::json!(stderr.len())))
|
||||
} else {
|
||||
if interpretation.is_error {
|
||||
Ok(ToolResult::error(truncated_output)
|
||||
.with_metadata("exit_code", serde_json::json!(exit_code))
|
||||
.with_metadata("stdout_length", serde_json::json!(stdout.len()))
|
||||
.with_metadata("stderr_length", serde_json::json!(stderr.len())))
|
||||
} else {
|
||||
let mut result = ToolResult::success(truncated_output)
|
||||
.with_metadata("exit_code", serde_json::json!(exit_code))
|
||||
.with_metadata("stdout_length", serde_json::json!(stdout.len()))
|
||||
.with_metadata("stderr_length", serde_json::json!(stderr.len()));
|
||||
if exit_code != 0 {
|
||||
result = result.with_metadata("reported_success", serde_json::json!(true));
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
@@ -471,6 +1116,16 @@ impl BashTool {
|
||||
|
||||
/// Format command output combining stdout and stderr
|
||||
fn format_output(&self, stdout: &str, stderr: &str, exit_code: i32) -> String {
|
||||
self.format_output_with_message(stdout, stderr, exit_code, None)
|
||||
}
|
||||
|
||||
fn format_output_with_message(
|
||||
&self,
|
||||
stdout: &str,
|
||||
stderr: &str,
|
||||
exit_code: i32,
|
||||
fallback_message: Option<&str>,
|
||||
) -> String {
|
||||
let mut output = String::new();
|
||||
|
||||
if !stdout.is_empty() {
|
||||
@@ -487,8 +1142,12 @@ impl BashTool {
|
||||
output.push_str(stderr);
|
||||
}
|
||||
|
||||
if exit_code != 0 && output.is_empty() {
|
||||
output = format!("Command exited with code {}", exit_code);
|
||||
if output.is_empty() {
|
||||
if let Some(message) = fallback_message {
|
||||
output = message.to_string();
|
||||
} else if exit_code != 0 {
|
||||
output = format!("Command exited with code {}", exit_code);
|
||||
}
|
||||
}
|
||||
|
||||
output
|
||||
@@ -556,6 +1215,18 @@ impl Tool for BashTool {
|
||||
Use 'background: true' parameter for long-running commands."
|
||||
}
|
||||
|
||||
fn dynamic_description(&self) -> Option<String> {
|
||||
Some(
|
||||
[
|
||||
self.description().to_string(),
|
||||
String::new(),
|
||||
"IMPORTANT: Prefer Read / Glob / Grep for file inspection before reaching for shell commands.".to_string(),
|
||||
"Do not guess file paths. If you are not sure whether a target exists, list or search the parent directory first.".to_string(),
|
||||
]
|
||||
.join("\n"),
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns the JSON Schema for input parameters
|
||||
fn input_schema(&self) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
@@ -609,6 +1280,14 @@ impl Tool for BashTool {
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
if !background {
|
||||
if let Some(preflight_result) =
|
||||
preflight_bash_read_targets(command, &context.working_directory)
|
||||
{
|
||||
return Ok(preflight_result);
|
||||
}
|
||||
}
|
||||
|
||||
// Execute based on mode
|
||||
if background {
|
||||
self.execute_background(command, context).await
|
||||
@@ -625,7 +1304,7 @@ impl Tool for BashTool {
|
||||
async fn check_permissions(
|
||||
&self,
|
||||
params: &serde_json::Value,
|
||||
_context: &ToolContext,
|
||||
context: &ToolContext,
|
||||
) -> PermissionCheckResult {
|
||||
// Extract command for safety check
|
||||
let command = match params.get("command").and_then(|v| v.as_str()) {
|
||||
@@ -636,6 +1315,11 @@ impl Tool for BashTool {
|
||||
// Perform safety check
|
||||
let safety_result = self.check_command_safety(command);
|
||||
|
||||
if let Some(path_result) = validate_bash_command_paths(command, &context.working_directory)
|
||||
{
|
||||
return path_result;
|
||||
}
|
||||
|
||||
if !safety_result.safe {
|
||||
let reason = safety_result
|
||||
.reason
|
||||
@@ -845,6 +1529,13 @@ mod tests {
|
||||
assert!(tool.description().contains("shell"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_definition_mentions_path_guidance() {
|
||||
let tool = BashTool::new();
|
||||
let definition = tool.get_definition();
|
||||
assert!(definition.description.contains("Do not guess file paths"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_input_schema() {
|
||||
let tool = BashTool::new();
|
||||
@@ -898,6 +1589,76 @@ mod tests {
|
||||
assert!(result.requires_confirmation());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_check_permissions_write_redirection_requires_confirmation() {
|
||||
let tool = BashTool::new();
|
||||
let context = create_test_context();
|
||||
let params = serde_json::json!({"command": "echo hello > note.txt"});
|
||||
|
||||
let result = tool.check_permissions(¶ms, &context).await;
|
||||
assert!(result.requires_confirmation());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_check_permissions_write_outside_workspace_mentions_path_scope() {
|
||||
let tool = BashTool::new();
|
||||
let context = ToolContext::new(PathBuf::from("/tmp/project"));
|
||||
let params = serde_json::json!({"command": "echo hello > ../note.txt"});
|
||||
|
||||
let result = tool.check_permissions(¶ms, &context).await;
|
||||
assert!(result.requires_confirmation());
|
||||
assert!(result
|
||||
.message
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.contains("outside the current working directory"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_check_permissions_sed_in_place_requires_confirmation() {
|
||||
let tool = BashTool::new();
|
||||
let context = create_test_context();
|
||||
let params = serde_json::json!({"command": "sed -i 's/a/b/' file.txt"});
|
||||
|
||||
let result = tool.check_permissions(¶ms, &context).await;
|
||||
assert!(result.requires_confirmation());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_check_permissions_git_reset_hard_is_denied() {
|
||||
let tool = BashTool::new();
|
||||
let context = create_test_context();
|
||||
let params = serde_json::json!({"command": "git reset --hard HEAD~1"});
|
||||
|
||||
let result = tool.check_permissions(¶ms, &context).await;
|
||||
assert!(result.is_denied());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_check_permissions_relative_root_removal_is_denied() {
|
||||
let tool = BashTool::new();
|
||||
let context = ToolContext::new(PathBuf::from("/tmp/project"));
|
||||
let params = serde_json::json!({"command": "rm -rf ../../"});
|
||||
|
||||
let result = tool.check_permissions(¶ms, &context).await;
|
||||
assert!(result.is_denied());
|
||||
assert!(result
|
||||
.message
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.contains("protected path"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_check_permissions_dev_null_redirection_stays_allowed() {
|
||||
let tool = BashTool::new();
|
||||
let context = create_test_context();
|
||||
let params = serde_json::json!({"command": "grep foo file.txt >/dev/null"});
|
||||
|
||||
let result = tool.check_permissions(¶ms, &context).await;
|
||||
assert!(result.is_allowed());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_check_permissions_missing_command() {
|
||||
let tool = BashTool::new();
|
||||
@@ -997,6 +1758,40 @@ mod tests {
|
||||
let _ = task_manager.kill_all().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_execute_preflights_missing_head_target() {
|
||||
use tempfile::TempDir;
|
||||
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let missing_path = temp_dir.path().join("missing.txt");
|
||||
let tool = BashTool::new();
|
||||
let context = ToolContext::new(temp_dir.path().to_path_buf());
|
||||
let params = serde_json::json!({
|
||||
"command": format!("head -20 {}", missing_path.display())
|
||||
});
|
||||
|
||||
let result = tool.execute(params, &context).await.unwrap();
|
||||
assert!(result.is_error());
|
||||
assert!(result.message().unwrap_or_default().contains("路径不存在"));
|
||||
assert_eq!(
|
||||
result.metadata.get("preflight_check"),
|
||||
Some(&serde_json::json!("missing_read_target"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_bash_command_concurrency_safe_for_read_only_pipeline() {
|
||||
assert!(is_bash_command_concurrency_safe(
|
||||
"rg \"Agent\" src | head -n 5"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_bash_command_concurrency_safe_rejects_mutation() {
|
||||
assert!(!is_bash_command_concurrency_safe("mkdir tmp-output"));
|
||||
assert!(!is_bash_command_concurrency_safe("git checkout main"));
|
||||
}
|
||||
|
||||
// Builder Tests
|
||||
|
||||
#[test]
|
||||
@@ -1063,6 +1858,13 @@ mod tests {
|
||||
assert!(result.contains("exited with code 1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_output_empty_with_semantic_message() {
|
||||
let tool = BashTool::new();
|
||||
let result = tool.format_output_with_message("", "", 1, Some("No matches found"));
|
||||
assert_eq!(result, "No matches found");
|
||||
}
|
||||
|
||||
// Safety Check Result Tests
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
//! Command-specific exit code semantics for shell tools.
|
||||
//!
|
||||
//! Some commands use non-zero exit codes to report state rather than failure.
|
||||
//! We keep the real exit code for observability, but avoid misclassifying
|
||||
//! expected outcomes such as "no matches found" as execution errors.
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CommandInterpretation {
|
||||
pub is_error: bool,
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
impl CommandInterpretation {
|
||||
fn success(message: Option<&str>) -> Self {
|
||||
Self {
|
||||
is_error: false,
|
||||
message: message.map(str::to_owned),
|
||||
}
|
||||
}
|
||||
|
||||
fn error() -> Self {
|
||||
Self {
|
||||
is_error: true,
|
||||
message: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn interpret_bash_command_result(
|
||||
command: &str,
|
||||
exit_code: i32,
|
||||
_stdout: &str,
|
||||
_stderr: &str,
|
||||
) -> CommandInterpretation {
|
||||
let base_command = heuristically_extract_bash_base_command(command);
|
||||
match base_command.as_str() {
|
||||
"grep" | "rg" => {
|
||||
if exit_code >= 2 {
|
||||
CommandInterpretation::error()
|
||||
} else if exit_code == 1 {
|
||||
CommandInterpretation::success(Some("No matches found"))
|
||||
} else {
|
||||
CommandInterpretation::success(None)
|
||||
}
|
||||
}
|
||||
"find" => {
|
||||
if exit_code >= 2 {
|
||||
CommandInterpretation::error()
|
||||
} else if exit_code == 1 {
|
||||
CommandInterpretation::success(Some("Some directories were inaccessible"))
|
||||
} else {
|
||||
CommandInterpretation::success(None)
|
||||
}
|
||||
}
|
||||
"diff" => {
|
||||
if exit_code >= 2 {
|
||||
CommandInterpretation::error()
|
||||
} else if exit_code == 1 {
|
||||
CommandInterpretation::success(Some("Files differ"))
|
||||
} else {
|
||||
CommandInterpretation::success(None)
|
||||
}
|
||||
}
|
||||
"test" | "[" => {
|
||||
if exit_code >= 2 {
|
||||
CommandInterpretation::error()
|
||||
} else if exit_code == 1 {
|
||||
CommandInterpretation::success(Some("Condition is false"))
|
||||
} else {
|
||||
CommandInterpretation::success(None)
|
||||
}
|
||||
}
|
||||
_ => default_interpretation(exit_code),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn interpret_powershell_command_result(
|
||||
command: &str,
|
||||
exit_code: i32,
|
||||
_stdout: &str,
|
||||
_stderr: &str,
|
||||
) -> CommandInterpretation {
|
||||
let base_command = heuristically_extract_powershell_base_command(command);
|
||||
match base_command.as_str() {
|
||||
"grep" | "rg" | "findstr" => {
|
||||
if exit_code >= 2 {
|
||||
CommandInterpretation::error()
|
||||
} else if exit_code == 1 {
|
||||
CommandInterpretation::success(Some("No matches found"))
|
||||
} else {
|
||||
CommandInterpretation::success(None)
|
||||
}
|
||||
}
|
||||
"robocopy" => {
|
||||
if exit_code >= 8 {
|
||||
CommandInterpretation::error()
|
||||
} else if exit_code == 0 {
|
||||
CommandInterpretation::success(Some("No files copied (already in sync)"))
|
||||
} else if exit_code & 1 == 1 {
|
||||
CommandInterpretation::success(Some("Files copied successfully"))
|
||||
} else {
|
||||
CommandInterpretation::success(Some("Robocopy completed (no errors)"))
|
||||
}
|
||||
}
|
||||
_ => default_interpretation(exit_code),
|
||||
}
|
||||
}
|
||||
|
||||
fn default_interpretation(exit_code: i32) -> CommandInterpretation {
|
||||
if exit_code == 0 {
|
||||
CommandInterpretation::success(None)
|
||||
} else {
|
||||
CommandInterpretation::error()
|
||||
}
|
||||
}
|
||||
|
||||
fn heuristically_extract_bash_base_command(command: &str) -> String {
|
||||
let last_segment = command
|
||||
.split(['\n', '\r', ';'])
|
||||
.flat_map(|segment| segment.split("&&"))
|
||||
.flat_map(|segment| segment.split("||"))
|
||||
.flat_map(|segment| segment.split('|'))
|
||||
.filter_map(|segment| {
|
||||
let trimmed = segment.trim();
|
||||
(!trimmed.is_empty()).then_some(trimmed)
|
||||
})
|
||||
.last()
|
||||
.unwrap_or(command.trim());
|
||||
|
||||
extract_base_command(last_segment)
|
||||
}
|
||||
|
||||
fn heuristically_extract_powershell_base_command(command: &str) -> String {
|
||||
let last_segment = command
|
||||
.split([';', '|', '\n', '\r'])
|
||||
.filter_map(|segment| {
|
||||
let trimmed = segment.trim();
|
||||
(!trimmed.is_empty()).then_some(trimmed)
|
||||
})
|
||||
.last()
|
||||
.unwrap_or(command.trim());
|
||||
|
||||
extract_base_command(
|
||||
last_segment
|
||||
.trim_start_matches('&')
|
||||
.trim_start_matches('.')
|
||||
.trim(),
|
||||
)
|
||||
}
|
||||
|
||||
fn extract_base_command(segment: &str) -> String {
|
||||
let first_token = segment.split_whitespace().next().unwrap_or_default();
|
||||
let unquoted = first_token.trim_matches(|ch| ch == '"' || ch == '\'');
|
||||
let basename = unquoted
|
||||
.rsplit(['/', '\\'])
|
||||
.next()
|
||||
.unwrap_or(unquoted)
|
||||
.trim();
|
||||
basename
|
||||
.to_ascii_lowercase()
|
||||
.trim_end_matches(".exe")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
interpret_bash_command_result, interpret_powershell_command_result, CommandInterpretation,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn bash_rg_no_match_is_not_error() {
|
||||
assert_eq!(
|
||||
interpret_bash_command_result("cat README.md | rg todo", 1, "", ""),
|
||||
CommandInterpretation {
|
||||
is_error: false,
|
||||
message: Some("No matches found".to_string()),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bash_diff_exit_one_reports_difference() {
|
||||
assert_eq!(
|
||||
interpret_bash_command_result("diff old.txt new.txt", 1, "", ""),
|
||||
CommandInterpretation {
|
||||
is_error: false,
|
||||
message: Some("Files differ".to_string()),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bash_test_false_is_not_error() {
|
||||
assert_eq!(
|
||||
interpret_bash_command_result("[ -f missing.txt ]", 1, "", ""),
|
||||
CommandInterpretation {
|
||||
is_error: false,
|
||||
message: Some("Condition is false".to_string()),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bash_default_non_zero_still_errors() {
|
||||
assert_eq!(
|
||||
interpret_bash_command_result("exit 1", 1, "", ""),
|
||||
CommandInterpretation {
|
||||
is_error: true,
|
||||
message: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn powershell_grep_exe_no_match_is_not_error() {
|
||||
assert_eq!(
|
||||
interpret_powershell_command_result(
|
||||
"& \"C:\\Tools\\grep.exe\" foo file.txt",
|
||||
1,
|
||||
"",
|
||||
"",
|
||||
),
|
||||
CommandInterpretation {
|
||||
is_error: false,
|
||||
message: Some("No matches found".to_string()),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn powershell_robocopy_success_range_is_not_error() {
|
||||
assert_eq!(
|
||||
interpret_powershell_command_result("robocopy src dst /E", 3, "", ""),
|
||||
CommandInterpretation {
|
||||
is_error: false,
|
||||
message: Some("Files copied successfully".to_string()),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn powershell_robocopy_already_synced_has_message() {
|
||||
assert_eq!(
|
||||
interpret_powershell_command_result("robocopy src dst /E", 0, "", ""),
|
||||
CommandInterpretation {
|
||||
is_error: false,
|
||||
message: Some("No files copied (already in sync)".to_string()),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn powershell_default_non_zero_still_errors() {
|
||||
assert_eq!(
|
||||
interpret_powershell_command_result("Write-Error 'boom'; exit 1", 1, "", ""),
|
||||
CommandInterpretation {
|
||||
is_error: true,
|
||||
message: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -29,7 +29,7 @@ const CRON_DELETE_TOOL_NAME: &str = "CronDelete";
|
||||
const MAX_CRON_JOBS: usize = 50;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||||
struct CronCreateInput {
|
||||
cron: String,
|
||||
prompt: String,
|
||||
@@ -66,7 +66,7 @@ struct CronListOutput {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||||
struct CronDeleteInput {
|
||||
id: String,
|
||||
}
|
||||
|
||||
@@ -28,12 +28,14 @@ mod agent_control;
|
||||
mod analyze_image;
|
||||
pub mod ask;
|
||||
pub mod bash;
|
||||
pub mod command_semantics;
|
||||
pub mod config_tool;
|
||||
pub mod cron_tools;
|
||||
pub mod file;
|
||||
pub mod lsp;
|
||||
pub mod mcp_resource_tools;
|
||||
pub mod notebook_edit_tool;
|
||||
pub mod path_guard;
|
||||
pub mod plan_mode_tool;
|
||||
pub mod powershell_tool;
|
||||
pub mod remote_trigger_tool;
|
||||
@@ -80,7 +82,13 @@ pub use task::{
|
||||
};
|
||||
|
||||
// Tool implementations
|
||||
pub use bash::{BashTool, SafetyCheckResult, SandboxConfig, MAX_OUTPUT_LENGTH};
|
||||
pub use bash::{
|
||||
is_bash_command_concurrency_safe, preflight_bash_read_targets, BashTool, SafetyCheckResult,
|
||||
SandboxConfig, MAX_OUTPUT_LENGTH,
|
||||
};
|
||||
pub use command_semantics::{
|
||||
interpret_bash_command_result, interpret_powershell_command_result, CommandInterpretation,
|
||||
};
|
||||
pub use config_tool::ConfigTool;
|
||||
pub use cron_tools::{CronCreateTool, CronDeleteTool, CronListTool};
|
||||
pub use sleep_tool::SleepTool;
|
||||
@@ -120,7 +128,9 @@ pub use crate::skills::SkillTool;
|
||||
// Task tools
|
||||
pub use notebook_edit_tool::{NotebookCell, NotebookContent, NotebookEditInput, NotebookEditTool};
|
||||
pub use plan_mode_tool::{EnterPlanModeTool, ExitPlanModeTool, PlanModeState, SavedPlan};
|
||||
pub use powershell_tool::PowerShellTool;
|
||||
pub use powershell_tool::{
|
||||
is_powershell_command_concurrency_safe, preflight_powershell_read_targets, PowerShellTool,
|
||||
};
|
||||
pub use remote_trigger_tool::{RemoteTriggerTool, REMOTE_TRIGGER_GATE_ENV};
|
||||
pub use send_user_message_tool::{SendUserMessageTool, SEND_USER_MESSAGE_TOOL_NAME};
|
||||
pub use task_list_tools::{
|
||||
@@ -456,7 +466,15 @@ pub fn register_all_tools(
|
||||
|
||||
// Register Plan Mode tools
|
||||
registry.register(Box::new(EnterPlanModeTool::new()));
|
||||
registry.register(Box::new(ExitPlanModeTool::new()));
|
||||
let mut exit_plan_mode_tool = ExitPlanModeTool::new();
|
||||
if let Some(send_input_callback) = config
|
||||
.agent_control_tools
|
||||
.as_ref()
|
||||
.and_then(|agent_control_tools| agent_control_tools.send_input.clone())
|
||||
{
|
||||
exit_plan_mode_tool = exit_plan_mode_tool.with_send_input_callback(send_input_callback);
|
||||
}
|
||||
registry.register(Box::new(exit_plan_mode_tool));
|
||||
|
||||
if let Some(agent_control_tools) = config.agent_control_tools.as_ref() {
|
||||
register_agent_control_tools(registry, agent_control_tools);
|
||||
@@ -590,26 +608,47 @@ mod tests {
|
||||
|
||||
// Verify core tools are registered
|
||||
assert!(registry.contains("Bash"));
|
||||
assert!(registry.contains("BashTool"));
|
||||
assert!(registry.contains("Read"));
|
||||
assert!(registry.contains("FileReadTool"));
|
||||
assert!(registry.contains("Write"));
|
||||
assert!(registry.contains("FileWriteTool"));
|
||||
assert!(registry.contains("Edit"));
|
||||
assert!(registry.contains("FileEditTool"));
|
||||
assert!(registry.contains("Glob"));
|
||||
assert!(registry.contains("GlobTool"));
|
||||
assert!(registry.contains("Grep"));
|
||||
assert!(registry.contains("GrepTool"));
|
||||
assert_eq!(
|
||||
registry.contains("Config"),
|
||||
should_register_current_surface_tool("Config", tool_gates)
|
||||
);
|
||||
assert_eq!(
|
||||
registry.contains("ConfigTool"),
|
||||
should_register_current_surface_tool("Config", tool_gates)
|
||||
);
|
||||
assert!(registry.contains("SendUserMessage"));
|
||||
assert!(registry.contains("BriefTool"));
|
||||
assert_eq!(
|
||||
registry.contains("Sleep"),
|
||||
should_register_current_surface_tool("Sleep", tool_gates)
|
||||
);
|
||||
assert_eq!(
|
||||
registry.contains("SleepTool"),
|
||||
should_register_current_surface_tool("Sleep", tool_gates)
|
||||
);
|
||||
assert_eq!(
|
||||
registry.contains("PowerShell"),
|
||||
should_register_current_surface_tool("PowerShell", tool_gates)
|
||||
&& PowerShellTool::is_runtime_available()
|
||||
);
|
||||
assert_eq!(
|
||||
registry.contains("PowerShellTool"),
|
||||
should_register_current_surface_tool("PowerShell", tool_gates)
|
||||
&& PowerShellTool::is_runtime_available()
|
||||
);
|
||||
assert!(registry.contains("Skill"));
|
||||
assert!(registry.contains("SkillTool"));
|
||||
assert_eq!(
|
||||
registry.contains("Workflow"),
|
||||
should_register_current_surface_tool("Workflow", tool_gates)
|
||||
@@ -620,17 +659,33 @@ mod tests {
|
||||
assert!(registry.contains("TaskUpdate"));
|
||||
assert!(registry.contains("TaskOutput"));
|
||||
assert!(registry.contains("TaskStop"));
|
||||
assert!(registry.contains("TaskCreateTool"));
|
||||
assert!(registry.contains("TaskListTool"));
|
||||
assert!(registry.contains("TaskGetTool"));
|
||||
assert!(registry.contains("TaskUpdateTool"));
|
||||
assert!(registry.contains("TaskOutputTool"));
|
||||
assert!(registry.contains("AgentOutputTool"));
|
||||
assert!(registry.contains("BashOutputTool"));
|
||||
assert!(registry.contains("TaskStopTool"));
|
||||
assert!(registry.contains("KillShell"));
|
||||
assert!(registry.contains("NotebookEdit"));
|
||||
assert!(registry.contains("NotebookEditTool"));
|
||||
assert!(!registry.contains("CronCreate"));
|
||||
assert!(!registry.contains("CronList"));
|
||||
assert!(!registry.contains("CronDelete"));
|
||||
assert!(!registry.contains("RemoteTrigger"));
|
||||
assert!(registry.contains("EnterWorktree"));
|
||||
assert!(registry.contains("EnterWorktreeTool"));
|
||||
assert!(registry.contains("ExitWorktree"));
|
||||
assert!(registry.contains("ExitWorktreeTool"));
|
||||
assert!(registry.contains("EnterPlanMode"));
|
||||
assert!(registry.contains("EnterPlanModeTool"));
|
||||
assert!(registry.contains("ExitPlanMode"));
|
||||
assert!(registry.contains("ExitPlanModeTool"));
|
||||
assert!(registry.contains("WebFetch"));
|
||||
assert!(registry.contains("WebFetchTool"));
|
||||
assert!(registry.contains("WebSearch"));
|
||||
assert!(registry.contains("WebSearchTool"));
|
||||
assert!(!registry.contains("ToolSearch"));
|
||||
assert!(!registry.contains("spawn_agent"));
|
||||
assert!(!registry.contains("Agent"));
|
||||
@@ -690,28 +745,51 @@ mod tests {
|
||||
|
||||
// Verify all tools are registered
|
||||
assert!(registry.contains("Bash"));
|
||||
assert!(registry.contains("BashTool"));
|
||||
assert!(registry.contains("Read"));
|
||||
assert!(registry.contains("FileReadTool"));
|
||||
assert!(registry.contains("Write"));
|
||||
assert!(registry.contains("FileWriteTool"));
|
||||
assert!(registry.contains("Edit"));
|
||||
assert!(registry.contains("FileEditTool"));
|
||||
assert!(registry.contains("Glob"));
|
||||
assert!(registry.contains("GlobTool"));
|
||||
assert!(registry.contains("Grep"));
|
||||
assert!(registry.contains("GrepTool"));
|
||||
assert_eq!(
|
||||
registry.contains("Config"),
|
||||
should_register_current_surface_tool("Config", tool_gates)
|
||||
);
|
||||
assert_eq!(
|
||||
registry.contains("ConfigTool"),
|
||||
should_register_current_surface_tool("Config", tool_gates)
|
||||
);
|
||||
assert_eq!(
|
||||
registry.contains("Sleep"),
|
||||
should_register_current_surface_tool("Sleep", tool_gates)
|
||||
);
|
||||
assert_eq!(
|
||||
registry.contains("SleepTool"),
|
||||
should_register_current_surface_tool("Sleep", tool_gates)
|
||||
);
|
||||
assert!(registry.contains("SendUserMessage"));
|
||||
assert!(registry.contains("BriefTool"));
|
||||
assert_eq!(
|
||||
registry.contains("PowerShell"),
|
||||
should_register_current_surface_tool("PowerShell", tool_gates)
|
||||
&& PowerShellTool::is_runtime_available()
|
||||
);
|
||||
assert_eq!(
|
||||
registry.contains("PowerShellTool"),
|
||||
should_register_current_surface_tool("PowerShell", tool_gates)
|
||||
&& PowerShellTool::is_runtime_available()
|
||||
);
|
||||
assert!(registry.contains("AskUserQuestion"));
|
||||
assert!(registry.contains("AskUserQuestionTool"));
|
||||
assert!(registry.contains("LSP"));
|
||||
assert!(registry.contains("LSPTool"));
|
||||
assert!(registry.contains("Skill"));
|
||||
assert!(registry.contains("SkillTool"));
|
||||
assert_eq!(
|
||||
registry.contains("Workflow"),
|
||||
should_register_current_surface_tool("Workflow", tool_gates)
|
||||
@@ -723,18 +801,26 @@ mod tests {
|
||||
assert!(registry.contains("TaskOutput"));
|
||||
assert!(registry.contains("TaskStop"));
|
||||
assert!(registry.contains("NotebookEdit"));
|
||||
assert!(registry.contains("NotebookEditTool"));
|
||||
assert!(!registry.contains("CronCreate"));
|
||||
assert!(!registry.contains("CronList"));
|
||||
assert!(!registry.contains("CronDelete"));
|
||||
assert!(!registry.contains("RemoteTrigger"));
|
||||
assert!(registry.contains("EnterWorktree"));
|
||||
assert!(registry.contains("EnterWorktreeTool"));
|
||||
assert!(registry.contains("ExitWorktree"));
|
||||
assert!(registry.contains("ExitWorktreeTool"));
|
||||
assert!(registry.contains("EnterPlanMode"));
|
||||
assert!(registry.contains("EnterPlanModeTool"));
|
||||
assert!(registry.contains("ExitPlanMode"));
|
||||
assert!(registry.contains("ExitPlanModeTool"));
|
||||
assert!(registry.contains("WebFetch"));
|
||||
assert!(registry.contains("WebFetchTool"));
|
||||
assert!(registry.contains("WebSearch"));
|
||||
assert!(registry.contains("WebSearchTool"));
|
||||
assert!(!registry.contains("spawn_agent"));
|
||||
assert!(registry.contains("Agent"));
|
||||
assert!(registry.contains("AgentTool"));
|
||||
assert!(!registry.contains("SendMessage"));
|
||||
assert!(!registry.contains("TeamCreate"));
|
||||
assert!(!registry.contains("TeamDelete"));
|
||||
@@ -903,5 +989,11 @@ mod tests {
|
||||
assert!(registry.contains("TeamCreate"));
|
||||
assert!(registry.contains("TeamDelete"));
|
||||
assert!(registry.contains("ListPeers"));
|
||||
assert!(registry.contains("SendMessageTool"));
|
||||
assert!(registry.contains("SendInput"));
|
||||
assert!(registry.contains("SendInputTool"));
|
||||
assert!(registry.contains("TeamCreateTool"));
|
||||
assert!(registry.contains("TeamDeleteTool"));
|
||||
assert!(registry.contains("ListPeersTool"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PathMutationKind {
|
||||
Write,
|
||||
Remove,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PathMutationCandidate {
|
||||
pub raw_path: String,
|
||||
pub kind: PathMutationKind,
|
||||
}
|
||||
|
||||
impl PathMutationCandidate {
|
||||
pub fn new(raw_path: impl Into<String>, kind: PathMutationKind) -> Self {
|
||||
Self {
|
||||
raw_path: raw_path.into(),
|
||||
kind,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PathGuardFinding {
|
||||
ProtectedPaths(Vec<PathBuf>),
|
||||
OutsideWorkspace(Vec<PathBuf>),
|
||||
DynamicPaths(Vec<String>),
|
||||
}
|
||||
|
||||
pub fn evaluate_path_mutations(
|
||||
candidates: &[PathMutationCandidate],
|
||||
cwd: &Path,
|
||||
) -> Option<PathGuardFinding> {
|
||||
let normalized_cwd = normalize_path_lexically(cwd);
|
||||
let protected_git_root = normalized_cwd.join(".git");
|
||||
let home_dir = dirs::home_dir().map(|path| normalize_path_lexically(&path));
|
||||
|
||||
let mut protected_paths = Vec::new();
|
||||
let mut outside_workspace_paths = Vec::new();
|
||||
let mut dynamic_paths = Vec::new();
|
||||
|
||||
for candidate in candidates {
|
||||
let raw = candidate.raw_path.trim();
|
||||
if raw.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if is_safe_sink_path(raw) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if path_looks_dynamic(raw) {
|
||||
dynamic_paths.push(raw.to_string());
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(resolved_path) = resolve_candidate_path(raw, &normalized_cwd, home_dir.as_deref())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if is_protected_path(
|
||||
&resolved_path,
|
||||
&normalized_cwd,
|
||||
&protected_git_root,
|
||||
home_dir.as_deref(),
|
||||
) {
|
||||
if !protected_paths.contains(&resolved_path) {
|
||||
protected_paths.push(resolved_path);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if !path_within(&resolved_path, &normalized_cwd)
|
||||
&& !outside_workspace_paths.contains(&resolved_path)
|
||||
{
|
||||
outside_workspace_paths.push(resolved_path);
|
||||
}
|
||||
}
|
||||
|
||||
if !protected_paths.is_empty() {
|
||||
return Some(PathGuardFinding::ProtectedPaths(protected_paths));
|
||||
}
|
||||
if !outside_workspace_paths.is_empty() {
|
||||
return Some(PathGuardFinding::OutsideWorkspace(outside_workspace_paths));
|
||||
}
|
||||
if !dynamic_paths.is_empty() {
|
||||
return Some(PathGuardFinding::DynamicPaths(dynamic_paths));
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub fn summarize_paths(paths: &[PathBuf]) -> String {
|
||||
paths
|
||||
.iter()
|
||||
.take(3)
|
||||
.map(|path| path.display().to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
}
|
||||
|
||||
pub fn summarize_raw_paths(paths: &[String]) -> String {
|
||||
paths.iter().take(3).cloned().collect::<Vec<_>>().join(", ")
|
||||
}
|
||||
|
||||
pub fn resolve_static_path_candidate(raw: &str, cwd: &Path) -> Option<PathBuf> {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() || is_safe_sink_path(trimmed) || path_looks_dynamic(trimmed) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let normalized_cwd = normalize_path_lexically(cwd);
|
||||
let home_dir = dirs::home_dir().map(|path| normalize_path_lexically(&path));
|
||||
resolve_candidate_path(trimmed, &normalized_cwd, home_dir.as_deref())
|
||||
}
|
||||
|
||||
fn path_looks_dynamic(raw: &str) -> bool {
|
||||
let trimmed = raw.trim();
|
||||
trimmed.contains('$')
|
||||
|| trimmed.contains('*')
|
||||
|| trimmed.contains('?')
|
||||
|| trimmed.contains('[')
|
||||
|| trimmed.contains(']')
|
||||
|| trimmed.contains('{')
|
||||
|| trimmed.contains('}')
|
||||
|| trimmed.contains('`')
|
||||
|| trimmed.contains("$(")
|
||||
|| trimmed.contains("${")
|
||||
|| trimmed.contains("%")
|
||||
}
|
||||
|
||||
fn is_safe_sink_path(raw: &str) -> bool {
|
||||
let normalized = raw
|
||||
.trim()
|
||||
.trim_matches(|ch| matches!(ch, '"' | '\'' | '`'))
|
||||
.to_ascii_lowercase();
|
||||
matches!(
|
||||
normalized.as_str(),
|
||||
"/dev/null" | "/dev/stdout" | "/dev/stderr" | "/dev/tty" | "nul" | "$null"
|
||||
)
|
||||
}
|
||||
|
||||
fn resolve_candidate_path(raw: &str, cwd: &Path, home_dir: Option<&Path>) -> Option<PathBuf> {
|
||||
let cleaned = raw
|
||||
.trim()
|
||||
.trim_matches(|ch| matches!(ch, '"' | '\'' | '`'))
|
||||
.trim_end_matches(|ch: char| matches!(ch, ',' | ';' | ')' | '('));
|
||||
|
||||
if cleaned.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let expanded = if cleaned == "~" || cleaned.starts_with("~/") || cleaned.starts_with("~\\") {
|
||||
let home = home_dir?;
|
||||
let suffix = cleaned.trim_start_matches('~');
|
||||
home.join(suffix.trim_start_matches(['/', '\\']))
|
||||
} else {
|
||||
PathBuf::from(cleaned)
|
||||
};
|
||||
|
||||
let resolved = if expanded.is_absolute() {
|
||||
expanded
|
||||
} else {
|
||||
cwd.join(expanded)
|
||||
};
|
||||
|
||||
Some(normalize_path_lexically(&resolved))
|
||||
}
|
||||
|
||||
fn normalize_path_lexically(path: &Path) -> PathBuf {
|
||||
let mut normalized = PathBuf::new();
|
||||
let is_absolute = path.is_absolute();
|
||||
|
||||
for component in path.components() {
|
||||
match component {
|
||||
Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
|
||||
Component::RootDir => normalized.push(std::path::MAIN_SEPARATOR.to_string()),
|
||||
Component::CurDir => {}
|
||||
Component::ParentDir => {
|
||||
if !normalized.pop() && !is_absolute {
|
||||
normalized.push("..");
|
||||
}
|
||||
}
|
||||
Component::Normal(part) => normalized.push(part),
|
||||
}
|
||||
}
|
||||
|
||||
normalized
|
||||
}
|
||||
|
||||
fn path_within(path: &Path, cwd: &Path) -> bool {
|
||||
path == cwd || path.starts_with(cwd)
|
||||
}
|
||||
|
||||
fn is_protected_path(
|
||||
path: &Path,
|
||||
_cwd: &Path,
|
||||
protected_git_root: &Path,
|
||||
home_dir: Option<&Path>,
|
||||
) -> bool {
|
||||
if path == Path::new("/") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if let Some(home) = home_dir {
|
||||
if path == home {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if path == protected_git_root || path.starts_with(protected_git_root) {
|
||||
return true;
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
for protected_prefix in [
|
||||
"/System",
|
||||
"/Library",
|
||||
"/Applications",
|
||||
"/bin",
|
||||
"/sbin",
|
||||
"/usr",
|
||||
"/etc",
|
||||
"/private/etc",
|
||||
"/dev",
|
||||
"/proc",
|
||||
"/sys",
|
||||
"/var/db",
|
||||
] {
|
||||
let protected_path = Path::new(protected_prefix);
|
||||
if path == protected_path || path.starts_with(protected_path) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
for protected_prefix in [
|
||||
r"C:\Windows",
|
||||
r"C:\Program Files",
|
||||
r"C:\Program Files (x86)",
|
||||
] {
|
||||
let protected_path = Path::new(protected_prefix);
|
||||
if path == protected_path || path.starts_with(protected_path) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
evaluate_path_mutations, resolve_static_path_candidate, PathGuardFinding,
|
||||
PathMutationCandidate, PathMutationKind,
|
||||
};
|
||||
use std::path::Path;
|
||||
|
||||
#[test]
|
||||
fn outside_workspace_write_is_reported() {
|
||||
let finding = evaluate_path_mutations(
|
||||
&[PathMutationCandidate::new(
|
||||
"../notes.txt",
|
||||
PathMutationKind::Write,
|
||||
)],
|
||||
Path::new("/tmp/project"),
|
||||
);
|
||||
assert!(matches!(
|
||||
finding,
|
||||
Some(PathGuardFinding::OutsideWorkspace(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protected_git_path_is_reported() {
|
||||
let finding = evaluate_path_mutations(
|
||||
&[PathMutationCandidate::new(
|
||||
".git/config",
|
||||
PathMutationKind::Write,
|
||||
)],
|
||||
Path::new("/tmp/project"),
|
||||
);
|
||||
assert!(matches!(finding, Some(PathGuardFinding::ProtectedPaths(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_path_is_reported() {
|
||||
let finding = evaluate_path_mutations(
|
||||
&[PathMutationCandidate::new(
|
||||
"$TARGET/out.txt",
|
||||
PathMutationKind::Write,
|
||||
)],
|
||||
Path::new("/tmp/project"),
|
||||
);
|
||||
assert_eq!(
|
||||
finding,
|
||||
Some(PathGuardFinding::DynamicPaths(vec![
|
||||
"$TARGET/out.txt".to_string()
|
||||
]))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_static_path_candidate_normalizes_relative_path() {
|
||||
let resolved =
|
||||
resolve_static_path_candidate("../notes.txt", Path::new("/tmp/project")).unwrap();
|
||||
assert_eq!(resolved, Path::new("/tmp/notes.txt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_static_path_candidate_skips_dynamic_expression() {
|
||||
let resolved = resolve_static_path_candidate("$TARGET/out.txt", Path::new("/tmp/project"));
|
||||
assert!(resolved.is_none());
|
||||
}
|
||||
}
|
||||
@@ -11,12 +11,14 @@
|
||||
// - 计划持久化存储
|
||||
// - 用户权限确认机制
|
||||
|
||||
use crate::session::{resolve_team_context, SessionManager, SessionType};
|
||||
use crate::tools::{
|
||||
base::{PermissionCheckResult, Tool},
|
||||
context::{ToolContext, ToolOptions, ToolResult},
|
||||
error::ToolError,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::fs;
|
||||
@@ -25,6 +27,8 @@ use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::agent_control::{SendInputCallback, SendInputRequest};
|
||||
|
||||
// =============================================================================
|
||||
// 计划模式状态管理
|
||||
// =============================================================================
|
||||
@@ -143,6 +147,18 @@ pub(crate) fn current_plan_mode_active() -> bool {
|
||||
GLOBAL_STATE.is_plan_mode_active()
|
||||
}
|
||||
|
||||
async fn session_is_subagent_context(context: &ToolContext) -> bool {
|
||||
let session_id = context.session_id.trim();
|
||||
if session_id.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
SessionManager::get_session(session_id, false)
|
||||
.await
|
||||
.map(|session| matches!(session.session_type, SessionType::SubAgent))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 计划持久化管理
|
||||
// =============================================================================
|
||||
@@ -397,7 +413,8 @@ User: "What files handle routing?"
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": []
|
||||
"required": [],
|
||||
"additionalProperties": false
|
||||
})
|
||||
}
|
||||
|
||||
@@ -410,8 +427,13 @@ User: "What files handle routing?"
|
||||
async fn check_permissions(
|
||||
&self,
|
||||
_params: &Value,
|
||||
_context: &ToolContext,
|
||||
context: &ToolContext,
|
||||
) -> PermissionCheckResult {
|
||||
if session_is_subagent_context(context).await {
|
||||
return PermissionCheckResult::deny(
|
||||
"EnterPlanMode tool cannot be used in agent contexts",
|
||||
);
|
||||
}
|
||||
PermissionCheckResult::ask("Enter plan mode?")
|
||||
}
|
||||
|
||||
@@ -420,6 +442,12 @@ User: "What files handle routing?"
|
||||
_params: Value,
|
||||
context: &ToolContext,
|
||||
) -> Result<ToolResult, ToolError> {
|
||||
if session_is_subagent_context(context).await {
|
||||
return Err(ToolError::execution_failed(
|
||||
"EnterPlanMode tool cannot be used in agent contexts",
|
||||
));
|
||||
}
|
||||
|
||||
// 检查是否已经在计划模式中
|
||||
if GLOBAL_STATE.is_plan_mode_active() {
|
||||
return Ok(ToolResult::error(
|
||||
@@ -539,17 +567,55 @@ Focus on understanding the problem before proposing solutions."#,
|
||||
|
||||
/// 退出计划模式工具输入
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ExitPlanModeInput {}
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExitPlanModeInput {
|
||||
#[serde(default)]
|
||||
pub allowed_prompts: Option<Vec<AllowedPrompt>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AllowedPrompt {
|
||||
pub tool: String,
|
||||
pub prompt: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ExitPlanModeOutput {
|
||||
plan: Option<String>,
|
||||
is_agent: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
file_path: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
has_task_tool: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
plan_was_edited: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
awaiting_leader_approval: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
request_id: Option<String>,
|
||||
}
|
||||
|
||||
/// 退出计划模式工具
|
||||
///
|
||||
/// 对齐当前工具面的 ExitPlanModeTool 语义
|
||||
/// 用于完成计划并等待用户批准
|
||||
pub struct ExitPlanModeTool;
|
||||
#[derive(Clone)]
|
||||
pub struct ExitPlanModeTool {
|
||||
send_input_callback: Option<SendInputCallback>,
|
||||
}
|
||||
|
||||
impl ExitPlanModeTool {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
Self {
|
||||
send_input_callback: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_send_input_callback(mut self, callback: SendInputCallback) -> Self {
|
||||
self.send_input_callback = Some(callback);
|
||||
self
|
||||
}
|
||||
|
||||
/// 解析计划内容为 SavedPlan 结构
|
||||
@@ -751,6 +817,16 @@ impl Default for ExitPlanModeTool {
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_plan_approval_request_id() -> String {
|
||||
let short = Uuid::new_v4()
|
||||
.simple()
|
||||
.to_string()
|
||||
.chars()
|
||||
.take(12)
|
||||
.collect::<String>();
|
||||
format!("plan_approval-{short}")
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for ExitPlanModeTool {
|
||||
fn name(&self) -> &str {
|
||||
@@ -787,8 +863,23 @@ Before using this tool, ensure your plan is clear and unambiguous. If there are
|
||||
fn input_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": []
|
||||
"properties": {
|
||||
"allowedPrompts": {
|
||||
"type": "array",
|
||||
"description": "计划阶段推导出的语义权限提示,当前 runtime 会保留到 metadata 供后续实现阶段参考。",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"tool": { "type": "string" },
|
||||
"prompt": { "type": "string" }
|
||||
},
|
||||
"required": ["tool", "prompt"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [],
|
||||
"additionalProperties": true
|
||||
})
|
||||
}
|
||||
|
||||
@@ -801,16 +892,34 @@ Before using this tool, ensure your plan is clear and unambiguous. If there are
|
||||
async fn check_permissions(
|
||||
&self,
|
||||
_params: &Value,
|
||||
_context: &ToolContext,
|
||||
context: &ToolContext,
|
||||
) -> PermissionCheckResult {
|
||||
if !GLOBAL_STATE.is_plan_mode_active() {
|
||||
return PermissionCheckResult::deny(
|
||||
"You are not in plan mode. This tool is only for exiting plan mode after writing a plan.",
|
||||
);
|
||||
}
|
||||
|
||||
if session_is_subagent_context(context).await {
|
||||
return PermissionCheckResult::allow();
|
||||
}
|
||||
|
||||
let teammate_requires_lead_approval = resolve_team_context(&context.session_id)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_some_and(|team_context| !team_context.is_lead);
|
||||
if teammate_requires_lead_approval {
|
||||
return PermissionCheckResult::allow();
|
||||
}
|
||||
|
||||
PermissionCheckResult::ask("Exit plan mode?")
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
_params: Value,
|
||||
_context: &ToolContext,
|
||||
) -> Result<ToolResult, ToolError> {
|
||||
async fn execute(&self, params: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
|
||||
let input: ExitPlanModeInput = serde_json::from_value(params).map_err(|error| {
|
||||
ToolError::invalid_params(format!("ExitPlanMode 参数无效: {error}"))
|
||||
})?;
|
||||
// 检查是否在计划模式中
|
||||
if !GLOBAL_STATE.is_plan_mode_active() {
|
||||
return Ok(ToolResult::error(
|
||||
@@ -847,53 +956,123 @@ Before using this tool, ensure your plan is clear and unambiguous. If there are
|
||||
}
|
||||
}
|
||||
|
||||
let is_agent = session_is_subagent_context(context).await;
|
||||
let team_context = resolve_team_context(&context.session_id)
|
||||
.await
|
||||
.map_err(|error| ToolError::execution_failed(format!("读取 team 状态失败: {error}")))?;
|
||||
|
||||
let mut awaiting_leader_approval = None;
|
||||
let mut request_id = None;
|
||||
let mut approval_delivery = None;
|
||||
let mut approval_request = None;
|
||||
|
||||
if let Some(team_context) = team_context
|
||||
.as_ref()
|
||||
.filter(|team_context| !team_context.is_lead)
|
||||
{
|
||||
let file_path = plan_file.clone().ok_or_else(|| {
|
||||
ToolError::execution_failed(
|
||||
"No plan file found for ExitPlanMode approval request. Please write your plan before requesting leader approval.",
|
||||
)
|
||||
})?;
|
||||
if plan_content.trim().is_empty() {
|
||||
return Err(ToolError::execution_failed(format!(
|
||||
"No plan file found at {file_path}. Please write your plan before requesting leader approval."
|
||||
)));
|
||||
}
|
||||
|
||||
let callback = self.send_input_callback.clone().ok_or_else(|| {
|
||||
ToolError::execution_failed(
|
||||
"当前 runtime 未配置计划审批消息路由,无法把计划提交给 team lead",
|
||||
)
|
||||
})?;
|
||||
let generated_request_id = generate_plan_approval_request_id();
|
||||
let payload = json!({
|
||||
"type": "plan_approval_request",
|
||||
"from": team_context.current_member_name.clone(),
|
||||
"timestamp": Utc::now().to_rfc3339(),
|
||||
"planFilePath": file_path,
|
||||
"planContent": plan_content.clone(),
|
||||
"requestId": generated_request_id,
|
||||
});
|
||||
let request_message = serde_json::to_string(&payload).map_err(|error| {
|
||||
ToolError::execution_failed(format!("序列化计划审批请求失败: {error}"))
|
||||
})?;
|
||||
let response = (callback)(SendInputRequest {
|
||||
id: team_context.lead_session_id.clone(),
|
||||
message: request_message,
|
||||
interrupt: false,
|
||||
})
|
||||
.await
|
||||
.map_err(|error| {
|
||||
ToolError::execution_failed(format!("发送计划审批请求失败: {error}"))
|
||||
})?;
|
||||
|
||||
awaiting_leader_approval = Some(true);
|
||||
request_id = Some(generated_request_id.clone());
|
||||
approval_request = Some(payload);
|
||||
approval_delivery = Some(json!({
|
||||
"target": team_context.lead_session_id.clone(),
|
||||
"submissionId": response.submission_id,
|
||||
"extra": response.extra,
|
||||
}));
|
||||
}
|
||||
|
||||
// 更新全局状态:退出计划模式
|
||||
GLOBAL_STATE.set_plan_mode(false, None, None);
|
||||
|
||||
let output = if let Some(ref plan_file_path) = plan_file {
|
||||
format!(
|
||||
r#"Exited plan mode.
|
||||
|
||||
Your plan has been saved to:
|
||||
- Working file: {}{}
|
||||
{}
|
||||
|
||||
Awaiting user approval to proceed with implementation.
|
||||
|
||||
## Approved Plan:
|
||||
{}"#,
|
||||
plan_file_path,
|
||||
saved_plan_path
|
||||
.as_ref()
|
||||
.map(|p| format!("\n- Persistent storage: {}", p))
|
||||
.unwrap_or_default(),
|
||||
plan_id
|
||||
.as_ref()
|
||||
.map(|id| format!("\nPlan ID: {}", id))
|
||||
.unwrap_or_default(),
|
||||
plan_content
|
||||
)
|
||||
} else {
|
||||
"Exited plan mode. Awaiting user approval to proceed with implementation.".to_string()
|
||||
let output = ExitPlanModeOutput {
|
||||
plan: if plan_content.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(plan_content.clone())
|
||||
},
|
||||
is_agent,
|
||||
file_path: plan_file.clone(),
|
||||
has_task_tool: None,
|
||||
plan_was_edited: None,
|
||||
awaiting_leader_approval,
|
||||
request_id,
|
||||
};
|
||||
|
||||
Ok(ToolResult::success(output)
|
||||
let mut result =
|
||||
ToolResult::success(serde_json::to_string_pretty(&output).map_err(|error| {
|
||||
ToolError::execution_failed(format!("序列化 ExitPlanMode 结果失败: {error}"))
|
||||
})?)
|
||||
.with_metadata("plan_id", json!(plan_id))
|
||||
.with_metadata("plan_file", json!(plan_file))
|
||||
.with_metadata("saved_plan_path", json!(saved_plan_path))
|
||||
.with_metadata("mode", json!("normal")))
|
||||
.with_metadata("allowed_prompts", json!(input.allowed_prompts))
|
||||
.with_metadata("mode", json!("normal"));
|
||||
|
||||
if let Some(approval_request) = approval_request {
|
||||
result = result.with_metadata("plan_approval_request", approval_request);
|
||||
}
|
||||
if let Some(approval_delivery) = approval_delivery {
|
||||
result = result.with_metadata("plan_approval_delivery", approval_delivery);
|
||||
}
|
||||
if let Some(request_id) = output.request_id.clone() {
|
||||
result = result.with_metadata("pending_request_id", json!(request_id));
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::tools::{context::ToolContext, PermissionBehavior};
|
||||
use serde_json::json;
|
||||
use crate::session::{
|
||||
save_team_membership, save_team_state, SessionManager, SessionType, TeamMember,
|
||||
TeamMembershipState, TeamSessionState,
|
||||
};
|
||||
use crate::tools::{context::ToolContext, PermissionBehavior, SendInputResponse};
|
||||
use serde_json::{json, Value};
|
||||
use serial_test::serial;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tempfile::TempDir;
|
||||
use tokio;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn create_test_context() -> ToolContext {
|
||||
ToolContext {
|
||||
@@ -906,6 +1085,69 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
async fn create_session_context(session_type: SessionType) -> anyhow::Result<ToolContext> {
|
||||
let working_directory = std::env::current_dir().unwrap();
|
||||
let session = SessionManager::create_session(
|
||||
working_directory.clone(),
|
||||
format!("plan-mode-test-{}", Uuid::new_v4()),
|
||||
session_type,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(ToolContext::new(working_directory).with_session_id(session.id))
|
||||
}
|
||||
|
||||
async fn create_teammate_context(teammate_name: &str) -> anyhow::Result<(String, ToolContext)> {
|
||||
let working_directory = std::env::current_dir().unwrap();
|
||||
let lead = SessionManager::create_session(
|
||||
working_directory.clone(),
|
||||
format!("plan-mode-lead-{}", Uuid::new_v4()),
|
||||
SessionType::Hidden,
|
||||
)
|
||||
.await?;
|
||||
let teammate = SessionManager::create_session(
|
||||
working_directory.clone(),
|
||||
format!("plan-mode-teammate-{}", Uuid::new_v4()),
|
||||
SessionType::SubAgent,
|
||||
)
|
||||
.await?;
|
||||
let team_name = format!("team-{}", Uuid::new_v4().simple());
|
||||
|
||||
save_team_state(
|
||||
&lead.id,
|
||||
Some(TeamSessionState {
|
||||
team_name: team_name.clone(),
|
||||
description: Some("测试 team".to_string()),
|
||||
lead_session_id: lead.id.clone(),
|
||||
members: vec![
|
||||
TeamMember::lead(lead.id.clone(), Some("lead".to_string())),
|
||||
TeamMember::teammate(
|
||||
teammate.id.clone(),
|
||||
teammate_name.to_string(),
|
||||
Some("worker".to_string()),
|
||||
),
|
||||
],
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
save_team_membership(
|
||||
&teammate.id,
|
||||
Some(TeamMembershipState {
|
||||
team_name,
|
||||
lead_session_id: lead.id.clone(),
|
||||
agent_id: teammate.id.clone(),
|
||||
name: teammate_name.to_string(),
|
||||
agent_type: Some("worker".to_string()),
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok((
|
||||
lead.id,
|
||||
ToolContext::new(working_directory).with_session_id(teammate.id),
|
||||
))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_enter_plan_mode_tool_creation() {
|
||||
let tool = EnterPlanModeTool::new();
|
||||
@@ -1004,15 +1246,71 @@ mod tests {
|
||||
assert_eq!(result.message, Some("Enter plan mode?".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_enter_plan_mode_permissions_reject_subagent_context() -> anyhow::Result<()> {
|
||||
let tool = EnterPlanModeTool::new();
|
||||
let context = create_session_context(SessionType::SubAgent).await?;
|
||||
let input = json!({});
|
||||
|
||||
let result = tool.check_permissions(&input, &context).await;
|
||||
assert!(matches!(result.behavior, PermissionBehavior::Deny));
|
||||
assert_eq!(
|
||||
result.message,
|
||||
Some("EnterPlanMode tool cannot be used in agent contexts".to_string())
|
||||
);
|
||||
|
||||
let _ = SessionManager::delete_session(&context.session_id).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_exit_plan_mode_permissions() {
|
||||
let tool = ExitPlanModeTool::new();
|
||||
let context = create_test_context();
|
||||
let input = json!({});
|
||||
|
||||
GLOBAL_STATE.set_plan_mode(true, None, None);
|
||||
let result = tool.check_permissions(&input, &context).await;
|
||||
assert!(matches!(result.behavior, PermissionBehavior::Ask));
|
||||
assert_eq!(result.message, Some("Exit plan mode?".to_string()));
|
||||
GLOBAL_STATE.set_plan_mode(false, None, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_exit_plan_mode_permissions_reject_when_not_in_plan_mode() {
|
||||
let tool = ExitPlanModeTool::new();
|
||||
let context = create_test_context();
|
||||
let input = json!({});
|
||||
|
||||
GLOBAL_STATE.set_plan_mode(false, None, None);
|
||||
|
||||
let result = tool.check_permissions(&input, &context).await;
|
||||
assert!(matches!(result.behavior, PermissionBehavior::Deny));
|
||||
assert_eq!(
|
||||
result.message,
|
||||
Some(
|
||||
"You are not in plan mode. This tool is only for exiting plan mode after writing a plan."
|
||||
.to_string()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_exit_plan_mode_permissions_allow_subagent_context() -> anyhow::Result<()> {
|
||||
let tool = ExitPlanModeTool::new();
|
||||
let context = create_session_context(SessionType::SubAgent).await?;
|
||||
let input = json!({});
|
||||
|
||||
GLOBAL_STATE.set_plan_mode(true, None, None);
|
||||
|
||||
let result = tool.check_permissions(&input, &context).await;
|
||||
assert!(matches!(result.behavior, PermissionBehavior::Allow));
|
||||
|
||||
GLOBAL_STATE.set_plan_mode(false, None, None);
|
||||
let _ = SessionManager::delete_session(&context.session_id).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1042,6 +1340,26 @@ mod tests {
|
||||
GLOBAL_STATE.set_plan_mode(false, None, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_enter_plan_mode_execution_rejects_subagent_context() -> anyhow::Result<()> {
|
||||
let tool = EnterPlanModeTool::new();
|
||||
let context = create_session_context(SessionType::SubAgent).await?;
|
||||
|
||||
GLOBAL_STATE.set_plan_mode(false, None, None);
|
||||
|
||||
let error = tool
|
||||
.execute(json!({}), &context)
|
||||
.await
|
||||
.expect_err("subagent context should be rejected");
|
||||
assert!(error
|
||||
.to_string()
|
||||
.contains("EnterPlanMode tool cannot be used in agent contexts"));
|
||||
|
||||
let _ = SessionManager::delete_session(&context.session_id).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_exit_plan_mode_execution_not_in_plan_mode() {
|
||||
@@ -1075,12 +1393,115 @@ mod tests {
|
||||
let result = tool.execute(input, &context).await.unwrap();
|
||||
assert!(result.success);
|
||||
assert!(result.output.is_some());
|
||||
assert!(result.output.as_ref().unwrap().contains("Exited plan mode"));
|
||||
let output: serde_json::Value =
|
||||
serde_json::from_str(result.output.as_ref().unwrap()).expect("valid exit plan output");
|
||||
assert_eq!(output["isAgent"], json!(false));
|
||||
|
||||
// 验证状态已更新
|
||||
assert!(!GLOBAL_STATE.is_plan_mode_active());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_exit_plan_mode_marks_subagent_context_as_agent() -> anyhow::Result<()> {
|
||||
let tool = ExitPlanModeTool::new();
|
||||
let context = create_session_context(SessionType::SubAgent).await?;
|
||||
|
||||
GLOBAL_STATE.set_plan_mode(
|
||||
true,
|
||||
Some("test-subagent.md".to_string()),
|
||||
Some("test-subagent-id".to_string()),
|
||||
);
|
||||
|
||||
let result = tool.execute(json!({}), &context).await?;
|
||||
let output: serde_json::Value =
|
||||
serde_json::from_str(result.output.as_ref().unwrap()).expect("valid exit plan output");
|
||||
assert_eq!(output["isAgent"], json!(true));
|
||||
|
||||
let _ = SessionManager::delete_session(&context.session_id).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_exit_plan_mode_teammate_submits_plan_approval_request() -> anyhow::Result<()> {
|
||||
let temp_dir = TempDir::new()?;
|
||||
let plan_path = temp_dir.path().join("PLAN.md");
|
||||
let plan_content = r#"# 审批计划
|
||||
|
||||
## Summary
|
||||
|
||||
等待 team lead 审批后再进入实现阶段。
|
||||
"#;
|
||||
fs::write(&plan_path, plan_content)?;
|
||||
|
||||
let (lead_session_id, context) = create_teammate_context("researcher").await?;
|
||||
let captured_request = Arc::new(Mutex::new(None::<SendInputRequest>));
|
||||
let captured_request_handle = Arc::clone(&captured_request);
|
||||
let tool = ExitPlanModeTool::new().with_send_input_callback(Arc::new(move |request| {
|
||||
let captured_request = Arc::clone(&captured_request_handle);
|
||||
Box::pin(async move {
|
||||
*captured_request.lock().unwrap() = Some(request.clone());
|
||||
Ok(SendInputResponse {
|
||||
submission_id: "submission-plan-approval-1".to_string(),
|
||||
extra: BTreeMap::new(),
|
||||
})
|
||||
})
|
||||
}));
|
||||
|
||||
GLOBAL_STATE.set_plan_mode(
|
||||
true,
|
||||
Some(plan_path.to_string_lossy().to_string()),
|
||||
Some("plan-approval-id".to_string()),
|
||||
);
|
||||
|
||||
let result = tool.execute(json!({}), &context).await?;
|
||||
let output: Value =
|
||||
serde_json::from_str(result.output.as_deref().unwrap()).expect("valid output json");
|
||||
let request_id = output["requestId"]
|
||||
.as_str()
|
||||
.expect("requestId should be present")
|
||||
.to_string();
|
||||
|
||||
assert_eq!(output["awaitingLeaderApproval"], json!(true));
|
||||
assert_eq!(output["isAgent"], json!(true));
|
||||
assert_eq!(
|
||||
output["filePath"],
|
||||
json!(plan_path.to_string_lossy().to_string())
|
||||
);
|
||||
assert!(request_id.starts_with("plan_approval-"));
|
||||
assert!(!GLOBAL_STATE.is_plan_mode_active());
|
||||
|
||||
let sent_request = captured_request
|
||||
.lock()
|
||||
.unwrap()
|
||||
.clone()
|
||||
.expect("approval request should be sent");
|
||||
assert_eq!(sent_request.id, lead_session_id);
|
||||
assert!(!sent_request.interrupt);
|
||||
|
||||
let payload: Value =
|
||||
serde_json::from_str(&sent_request.message).expect("valid approval request json");
|
||||
assert_eq!(payload["type"], json!("plan_approval_request"));
|
||||
assert_eq!(payload["from"], json!("researcher"));
|
||||
assert_eq!(
|
||||
payload["planFilePath"],
|
||||
json!(plan_path.to_string_lossy().to_string())
|
||||
);
|
||||
assert_eq!(payload["planContent"], json!(plan_content));
|
||||
assert_eq!(payload["requestId"], json!(request_id));
|
||||
|
||||
assert_eq!(
|
||||
result.metadata["plan_approval_delivery"]["submissionId"],
|
||||
json!("submission-plan-approval-1")
|
||||
);
|
||||
assert_eq!(result.metadata["pending_request_id"], json!(request_id));
|
||||
|
||||
let _ = SessionManager::delete_session(&context.session_id).await;
|
||||
let _ = SessionManager::delete_session(&lead_session_id).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_plan_content_parsing() {
|
||||
let tool = ExitPlanModeTool::new();
|
||||
|
||||
@@ -4,10 +4,16 @@
|
||||
//! - PowerShell
|
||||
|
||||
use super::base::{PermissionCheckResult, Tool};
|
||||
use super::command_semantics::interpret_powershell_command_result;
|
||||
use super::context::{ToolContext, ToolOptions, ToolResult};
|
||||
use super::error::ToolError;
|
||||
use super::path_guard::{
|
||||
evaluate_path_mutations, resolve_static_path_candidate, summarize_paths, summarize_raw_paths,
|
||||
PathGuardFinding, PathMutationCandidate, PathMutationKind,
|
||||
};
|
||||
use super::task::{TaskManager, TaskShell};
|
||||
use async_trait::async_trait;
|
||||
use once_cell::sync::Lazy;
|
||||
use regex::Regex;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
@@ -24,7 +30,32 @@ const DEFAULT_TIMEOUT_MS: u64 = 300_000;
|
||||
const MAX_TIMEOUT_MS: u64 = 1_800_000;
|
||||
const MAX_OUTPUT_LENGTH: usize = 128 * 1024;
|
||||
|
||||
static POWERSHELL_WRITE_REDIRECTION_RE: Lazy<Regex> = Lazy::new(|| {
|
||||
Regex::new(
|
||||
r#"(?x)
|
||||
(?:^|[\s;(])
|
||||
(?:\d+)?(?:>>?|>\|)
|
||||
\s*
|
||||
(?P<target>'[^']*'|"[^"]*"|[^\s;&|()]+)
|
||||
"#,
|
||||
)
|
||||
.expect("valid powershell write redirection regex")
|
||||
});
|
||||
static POWERSHELL_SYMLINK_RE: Lazy<Regex> = Lazy::new(|| {
|
||||
Regex::new(
|
||||
r#"(?ix)
|
||||
\b(?:new-item|ni)\b
|
||||
[^\n;|&]*
|
||||
(?:-itemtype|-type|-it(?:emtype)?|-ty(?:pe)?)
|
||||
\s*(?::|=|\s)\s*
|
||||
['"]?(symboliclink|junction|hardlink)
|
||||
"#,
|
||||
)
|
||||
.expect("valid powershell symlink regex")
|
||||
});
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct PowerShellToolInput {
|
||||
command: String,
|
||||
#[serde(default)]
|
||||
@@ -157,6 +188,16 @@ impl PowerShellTool {
|
||||
}
|
||||
|
||||
fn format_output(&self, stdout: &str, stderr: &str, exit_code: i32) -> String {
|
||||
self.format_output_with_message(stdout, stderr, exit_code, None)
|
||||
}
|
||||
|
||||
fn format_output_with_message(
|
||||
&self,
|
||||
stdout: &str,
|
||||
stderr: &str,
|
||||
exit_code: i32,
|
||||
fallback_message: Option<&str>,
|
||||
) -> String {
|
||||
let mut output = String::new();
|
||||
|
||||
if !stdout.is_empty() {
|
||||
@@ -173,8 +214,12 @@ impl PowerShellTool {
|
||||
output.push_str(stderr);
|
||||
}
|
||||
|
||||
if exit_code != 0 && output.is_empty() {
|
||||
output = format!("Command exited with code {}", exit_code);
|
||||
if output.is_empty() {
|
||||
if let Some(message) = fallback_message {
|
||||
output = message.to_string();
|
||||
} else if exit_code != 0 {
|
||||
output = format!("Command exited with code {}", exit_code);
|
||||
}
|
||||
}
|
||||
|
||||
output
|
||||
@@ -186,6 +231,10 @@ impl PowerShellTool {
|
||||
return SafetyCheckResult::deny("Command cannot be empty");
|
||||
}
|
||||
|
||||
if let Some(reason) = detect_high_risk_powershell_reason(command_trimmed) {
|
||||
return SafetyCheckResult::deny(reason);
|
||||
}
|
||||
|
||||
for pattern in &self.dangerous_patterns {
|
||||
if pattern.is_match(command_trimmed) {
|
||||
return SafetyCheckResult::deny(format!(
|
||||
@@ -204,6 +253,10 @@ impl PowerShellTool {
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(warning) = detect_mutating_powershell_warning(command_trimmed) {
|
||||
return SafetyCheckResult::warn(warning);
|
||||
}
|
||||
|
||||
SafetyCheckResult::safe()
|
||||
}
|
||||
|
||||
@@ -247,19 +300,29 @@ impl PowerShellTool {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
|
||||
let exit_code = output.status.code().unwrap_or(-1);
|
||||
let formatted =
|
||||
self.truncate_output(&self.format_output(&stdout, &stderr, exit_code));
|
||||
let interpretation =
|
||||
interpret_powershell_command_result(command, exit_code, &stdout, &stderr);
|
||||
let formatted = self.truncate_output(&self.format_output_with_message(
|
||||
&stdout,
|
||||
&stderr,
|
||||
exit_code,
|
||||
interpretation.message.as_deref(),
|
||||
));
|
||||
|
||||
if output.status.success() {
|
||||
Ok(ToolResult::success(formatted)
|
||||
.with_metadata("exit_code", json!(exit_code))
|
||||
.with_metadata("stdout_length", json!(stdout.len()))
|
||||
.with_metadata("stderr_length", json!(stderr.len())))
|
||||
} else {
|
||||
if interpretation.is_error {
|
||||
Ok(ToolResult::error(formatted)
|
||||
.with_metadata("exit_code", json!(exit_code))
|
||||
.with_metadata("stdout_length", json!(stdout.len()))
|
||||
.with_metadata("stderr_length", json!(stderr.len())))
|
||||
} else {
|
||||
let mut result = ToolResult::success(formatted)
|
||||
.with_metadata("exit_code", json!(exit_code))
|
||||
.with_metadata("stdout_length", json!(stdout.len()))
|
||||
.with_metadata("stderr_length", json!(stderr.len()));
|
||||
if exit_code != 0 {
|
||||
result = result.with_metadata("reported_success", json!(true));
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
Ok(Err(error)) => Err(ToolError::execution_failed(format!(
|
||||
@@ -351,6 +414,633 @@ fn default_warning_patterns() -> Vec<Regex> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn split_powershell_segments(command: &str) -> Vec<&str> {
|
||||
let mut segments = Vec::new();
|
||||
let mut start = 0usize;
|
||||
let mut in_single = false;
|
||||
let mut in_double = false;
|
||||
let mut chars = command.char_indices().peekable();
|
||||
|
||||
while let Some((index, ch)) = chars.next() {
|
||||
match ch {
|
||||
'\'' if !in_double => in_single = !in_single,
|
||||
'"' if !in_single => in_double = !in_double,
|
||||
';' | '\n' if !in_single && !in_double => {
|
||||
let segment = command[start..index].trim();
|
||||
if !segment.is_empty() {
|
||||
segments.push(segment);
|
||||
}
|
||||
start = index + ch.len_utf8();
|
||||
}
|
||||
'&' if !in_single && !in_double => {
|
||||
if let Some((next_index, next_char)) = chars.peek().copied() {
|
||||
if next_char == '&' {
|
||||
let segment = command[start..index].trim();
|
||||
if !segment.is_empty() {
|
||||
segments.push(segment);
|
||||
}
|
||||
let _ = chars.next();
|
||||
start = next_index + next_char.len_utf8();
|
||||
}
|
||||
}
|
||||
}
|
||||
'|' if !in_single && !in_double => {
|
||||
let segment = command[start..index].trim();
|
||||
if !segment.is_empty() {
|
||||
segments.push(segment);
|
||||
}
|
||||
if let Some((next_index, next_char)) = chars.peek().copied() {
|
||||
if next_char == '|' {
|
||||
let _ = chars.next();
|
||||
start = next_index + next_char.len_utf8();
|
||||
} else {
|
||||
start = index + ch.len_utf8();
|
||||
}
|
||||
} else {
|
||||
start = index + ch.len_utf8();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let rest = command[start..].trim();
|
||||
if !rest.is_empty() {
|
||||
segments.push(rest);
|
||||
}
|
||||
|
||||
segments
|
||||
}
|
||||
|
||||
fn normalize_powershell_word(word: &str) -> String {
|
||||
word.trim_matches(|ch| matches!(ch, '"' | '\'' | '`' | '(' | ')' | ','))
|
||||
.to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn resolve_powershell_alias(word: &str) -> &str {
|
||||
match word {
|
||||
"rm" | "del" | "erase" | "ri" => "remove-item",
|
||||
"mv" | "move" | "mi" => "move-item",
|
||||
"cp" | "copy" | "cpi" => "copy-item",
|
||||
"ren" | "rni" => "rename-item",
|
||||
"ni" | "mkdir" | "md" => "new-item",
|
||||
"sc" => "set-content",
|
||||
"ac" => "add-content",
|
||||
"tee" => "tee-object",
|
||||
"iwr" => "invoke-webrequest",
|
||||
"irm" => "invoke-restmethod",
|
||||
"cat" | "gc" | "type" => "get-content",
|
||||
"ls" | "dir" | "gci" => "get-childitem",
|
||||
"sls" => "select-string",
|
||||
"sl" | "cd" | "chdir" => "set-location",
|
||||
_ => word,
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_powershell_command_words(segment: &str) -> Vec<String> {
|
||||
let raw_words = segment
|
||||
.split_whitespace()
|
||||
.map(normalize_powershell_word)
|
||||
.filter(|word| !word.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let mut index = 0usize;
|
||||
while index < raw_words.len() {
|
||||
let word = raw_words[index].as_str();
|
||||
if matches!(word, "&" | "." | "powershell" | "pwsh") {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
raw_words
|
||||
.into_iter()
|
||||
.skip(index)
|
||||
.map(|word| resolve_powershell_alias(&word).to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn is_safe_powershell_sink(target: &str) -> bool {
|
||||
let normalized = normalize_powershell_word(target);
|
||||
matches!(
|
||||
normalized.as_str(),
|
||||
"$null" | "nul" | "null:" | "[system.io.stream]::null" | "&1" | "&2"
|
||||
)
|
||||
}
|
||||
|
||||
fn has_powershell_write_redirection(command: &str) -> bool {
|
||||
POWERSHELL_WRITE_REDIRECTION_RE
|
||||
.captures_iter(command)
|
||||
.any(|captures| {
|
||||
let Some(target) = captures.name("target") else {
|
||||
return false;
|
||||
};
|
||||
!is_safe_powershell_sink(target.as_str())
|
||||
})
|
||||
}
|
||||
|
||||
fn is_mutating_powershell_cmdlet(name: &str) -> bool {
|
||||
matches!(
|
||||
name,
|
||||
"set-content"
|
||||
| "add-content"
|
||||
| "clear-content"
|
||||
| "remove-item"
|
||||
| "copy-item"
|
||||
| "move-item"
|
||||
| "rename-item"
|
||||
| "new-item"
|
||||
| "out-file"
|
||||
| "tee-object"
|
||||
| "export-csv"
|
||||
| "export-clixml"
|
||||
| "expand-archive"
|
||||
)
|
||||
}
|
||||
|
||||
fn detect_high_risk_powershell_reason(command: &str) -> Option<String> {
|
||||
if POWERSHELL_SYMLINK_RE.is_match(command) {
|
||||
return Some(
|
||||
"Blocked: creating symbolic links or junctions is not allowed in PowerShell tool."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
for segment in split_powershell_segments(command) {
|
||||
let words = extract_powershell_command_words(segment);
|
||||
if words.is_empty() || words[0] != "git" {
|
||||
continue;
|
||||
}
|
||||
|
||||
let subcommand = words.get(1).map(String::as_str).unwrap_or("");
|
||||
match subcommand {
|
||||
"reset" if words.iter().any(|word| word == "--hard") => {
|
||||
return Some(
|
||||
"Blocked: `git reset --hard` is a destructive repository operation."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
"clean" if is_forced_git_clean_words(&words) => {
|
||||
return Some(
|
||||
"Blocked: forced `git clean` may permanently remove untracked files."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
"push" if words.iter().any(|word| word == "--force" || word == "-f") => {
|
||||
return Some(
|
||||
"Blocked: force-pushing git history requires explicit manual confirmation."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn detect_mutating_powershell_warning(command: &str) -> Option<String> {
|
||||
if has_powershell_write_redirection(command) {
|
||||
return Some("Command writes to files via PowerShell redirection".to_string());
|
||||
}
|
||||
|
||||
for segment in split_powershell_segments(command) {
|
||||
let words = extract_powershell_command_words(segment);
|
||||
if words.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let command_name = words[0].as_str();
|
||||
if matches!(command_name, "invoke-webrequest" | "invoke-restmethod")
|
||||
&& words
|
||||
.iter()
|
||||
.any(|word| word == "-outfile" || word == "-literalpath" || word == "-path")
|
||||
{
|
||||
return Some(format!(
|
||||
"Command may persist downloaded content via `{command_name}`"
|
||||
));
|
||||
}
|
||||
|
||||
if is_mutating_powershell_cmdlet(command_name) {
|
||||
return Some(format!(
|
||||
"Command may modify files or project state via `{command_name}`"
|
||||
));
|
||||
}
|
||||
|
||||
if command_name == "git" {
|
||||
let subcommand = words.get(1).map(String::as_str).unwrap_or("");
|
||||
if matches!(
|
||||
subcommand,
|
||||
"add"
|
||||
| "am"
|
||||
| "apply"
|
||||
| "branch"
|
||||
| "checkout"
|
||||
| "cherry-pick"
|
||||
| "clean"
|
||||
| "commit"
|
||||
| "merge"
|
||||
| "mv"
|
||||
| "pull"
|
||||
| "push"
|
||||
| "rebase"
|
||||
| "reset"
|
||||
| "restore"
|
||||
| "revert"
|
||||
| "rm"
|
||||
| "stash"
|
||||
| "switch"
|
||||
| "tag"
|
||||
) {
|
||||
return Some(format!(
|
||||
"Command modifies repository state via `git {subcommand}`"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn is_forced_git_clean_words(words: &[String]) -> bool {
|
||||
let has_force = words
|
||||
.iter()
|
||||
.any(|word| word.starts_with('-') && word.contains('f'));
|
||||
let has_scope = words.iter().any(|word| {
|
||||
word.starts_with('-') && (word.contains('d') || word.contains('x') || word.contains('X'))
|
||||
});
|
||||
has_force && has_scope
|
||||
}
|
||||
|
||||
fn validate_powershell_command_paths(command: &str, cwd: &Path) -> Option<PermissionCheckResult> {
|
||||
let candidates = collect_powershell_path_candidates(command);
|
||||
match evaluate_path_mutations(&candidates, cwd)? {
|
||||
PathGuardFinding::ProtectedPaths(paths) => Some(PermissionCheckResult::deny(format!(
|
||||
"Blocked: PowerShell command targets protected path(s): {}",
|
||||
summarize_paths(&paths)
|
||||
))),
|
||||
PathGuardFinding::OutsideWorkspace(paths) => Some(PermissionCheckResult::ask(format!(
|
||||
"PowerShell command modifies path(s) outside the current working directory: {}. Do you want to proceed?",
|
||||
summarize_paths(&paths)
|
||||
))),
|
||||
PathGuardFinding::DynamicPaths(paths) => Some(PermissionCheckResult::ask(format!(
|
||||
"PowerShell command uses path expression(s) that cannot be validated safely: {}. Do you want to proceed?",
|
||||
summarize_raw_paths(&paths)
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_powershell_path_candidates(command: &str) -> Vec<PathMutationCandidate> {
|
||||
let mut candidates = POWERSHELL_WRITE_REDIRECTION_RE
|
||||
.captures_iter(command)
|
||||
.filter_map(|captures| captures.name("target"))
|
||||
.map(|target| PathMutationCandidate::new(target.as_str(), PathMutationKind::Write))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
for segment in split_powershell_segments(command) {
|
||||
let raw_words = tokenize_powershell_words(segment);
|
||||
if raw_words.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let normalized_words = normalize_powershell_words(&raw_words);
|
||||
let command_name = normalized_words[0].as_str();
|
||||
|
||||
match command_name {
|
||||
"set-content" | "add-content" | "clear-content" | "remove-item" | "copy-item"
|
||||
| "move-item" | "rename-item" | "new-item" | "out-file" | "tee-object"
|
||||
| "export-csv" | "export-clixml" | "invoke-webrequest" | "invoke-restmethod" => {
|
||||
let kind = if command_name == "remove-item" {
|
||||
PathMutationKind::Remove
|
||||
} else {
|
||||
PathMutationKind::Write
|
||||
};
|
||||
for path in extract_powershell_write_targets(&raw_words, command_name) {
|
||||
candidates.push(PathMutationCandidate::new(path, kind));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
candidates
|
||||
}
|
||||
|
||||
fn tokenize_powershell_words(segment: &str) -> Vec<String> {
|
||||
let mut words = Vec::new();
|
||||
let mut current = String::new();
|
||||
let mut in_single = false;
|
||||
let mut in_double = false;
|
||||
let mut escaped = false;
|
||||
|
||||
for ch in segment.chars() {
|
||||
if escaped {
|
||||
current.push(ch);
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
match ch {
|
||||
'`' if !in_single => {
|
||||
escaped = true;
|
||||
}
|
||||
'\'' if !in_double => {
|
||||
in_single = !in_single;
|
||||
}
|
||||
'"' if !in_single => {
|
||||
in_double = !in_double;
|
||||
}
|
||||
ch if ch.is_whitespace() && !in_single && !in_double => {
|
||||
if !current.is_empty() {
|
||||
words.push(std::mem::take(&mut current));
|
||||
}
|
||||
}
|
||||
_ => current.push(ch),
|
||||
}
|
||||
}
|
||||
|
||||
if !current.is_empty() {
|
||||
words.push(current);
|
||||
}
|
||||
|
||||
words
|
||||
}
|
||||
|
||||
fn normalize_powershell_words(raw_words: &[String]) -> Vec<String> {
|
||||
let mut normalized = raw_words
|
||||
.iter()
|
||||
.map(|word| resolve_powershell_alias(&normalize_powershell_word(word)).to_string())
|
||||
.filter(|word| !word.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
while let Some(word) = normalized.first() {
|
||||
if matches!(word.as_str(), "&" | "." | "powershell" | "pwsh") {
|
||||
normalized.remove(0);
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
normalized
|
||||
}
|
||||
|
||||
fn extract_powershell_write_targets(raw_words: &[String], command_name: &str) -> Vec<String> {
|
||||
if raw_words.len() <= 1 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut named_targets = Vec::new();
|
||||
let mut positional_targets = Vec::new();
|
||||
let mut index = 1usize;
|
||||
let mut after_double_dash = false;
|
||||
let path_params = [
|
||||
"-path",
|
||||
"-literalpath",
|
||||
"-destination",
|
||||
"-filepath",
|
||||
"-outfile",
|
||||
"-pspath",
|
||||
"-lp",
|
||||
];
|
||||
|
||||
while index < raw_words.len() {
|
||||
let word = &raw_words[index];
|
||||
let normalized = normalize_powershell_word(word);
|
||||
|
||||
if !after_double_dash && word == "--" {
|
||||
after_double_dash = true;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if !after_double_dash && normalized.starts_with('-') {
|
||||
if let Some((param, value)) = normalized.split_once(':') {
|
||||
if path_params.contains(¶m) && !value.is_empty() {
|
||||
named_targets.push(value.to_string());
|
||||
}
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if path_params.contains(&normalized.as_str()) {
|
||||
if let Some(next) = raw_words.get(index + 1) {
|
||||
named_targets.push(next.clone());
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
positional_targets.push(word.clone());
|
||||
index += 1;
|
||||
}
|
||||
|
||||
if !named_targets.is_empty() {
|
||||
return named_targets;
|
||||
}
|
||||
|
||||
match command_name {
|
||||
"set-content" | "add-content" | "clear-content" | "remove-item" | "rename-item"
|
||||
| "new-item" | "out-file" | "tee-object" | "export-csv" | "export-clixml" => {
|
||||
positional_targets.into_iter().take(1).collect()
|
||||
}
|
||||
"copy-item" | "move-item" => positional_targets.into_iter().skip(1).take(1).collect(),
|
||||
"invoke-webrequest" | "invoke-restmethod" => Vec::new(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_powershell_read_targets(raw_words: &[String], command_name: &str) -> Vec<String> {
|
||||
if raw_words.len() <= 1 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut named_targets = Vec::new();
|
||||
let mut positional_targets = Vec::new();
|
||||
let mut index = 1usize;
|
||||
let mut after_double_dash = false;
|
||||
let path_params = ["-path", "-literalpath", "-lp"];
|
||||
|
||||
while index < raw_words.len() {
|
||||
let word = &raw_words[index];
|
||||
let normalized = normalize_powershell_word(word);
|
||||
|
||||
if !after_double_dash && word == "--" {
|
||||
after_double_dash = true;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if !after_double_dash && normalized.starts_with('-') {
|
||||
if let Some((param, value)) = normalized.split_once(':') {
|
||||
if path_params.contains(¶m) && !value.is_empty() {
|
||||
named_targets.push(value.to_string());
|
||||
}
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if path_params.contains(&normalized.as_str()) {
|
||||
if let Some(next) = raw_words.get(index + 1) {
|
||||
named_targets.push(next.clone());
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
positional_targets.push(word.clone());
|
||||
index += 1;
|
||||
}
|
||||
|
||||
if !named_targets.is_empty() {
|
||||
return named_targets;
|
||||
}
|
||||
|
||||
match command_name {
|
||||
"get-content" | "get-childitem" => positional_targets.into_iter().rev().take(1).collect(),
|
||||
"select-string" if positional_targets.len() >= 2 => {
|
||||
positional_targets.into_iter().rev().take(1).collect()
|
||||
}
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_powershell_read_path_candidates(command: &str) -> Vec<String> {
|
||||
let mut candidates = Vec::new();
|
||||
|
||||
for segment in split_powershell_segments(command) {
|
||||
let raw_words = tokenize_powershell_words(segment);
|
||||
if raw_words.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let normalized_words = normalize_powershell_words(&raw_words);
|
||||
if normalized_words.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let command_name = normalized_words[0].as_str();
|
||||
candidates.extend(extract_powershell_read_targets(&raw_words, command_name));
|
||||
}
|
||||
|
||||
candidates
|
||||
}
|
||||
|
||||
fn is_known_read_only_powershell_command(command_name: &str, words: &[String]) -> bool {
|
||||
match command_name {
|
||||
"get-content" | "get-childitem" | "select-string" | "get-item" | "resolve-path"
|
||||
| "split-path" | "test-path" | "measure-object" | "select-object" | "sort-object"
|
||||
| "where-object" | "format-table" | "format-list" => true,
|
||||
"git" => matches!(
|
||||
words.get(1).map(String::as_str).unwrap_or(""),
|
||||
"status" | "diff" | "show" | "log" | "rev-parse" | "ls-files" | "grep" | "blame"
|
||||
),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_powershell_command_concurrency_safe(command: &str) -> bool {
|
||||
let trimmed = command.trim();
|
||||
if trimmed.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
if has_powershell_write_redirection(trimmed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut saw_segment = false;
|
||||
for segment in split_powershell_segments(trimmed) {
|
||||
let words = extract_powershell_command_words(segment);
|
||||
if words.is_empty() {
|
||||
continue;
|
||||
}
|
||||
saw_segment = true;
|
||||
|
||||
let command_name = words[0].as_str();
|
||||
if is_mutating_powershell_cmdlet(command_name) {
|
||||
return false;
|
||||
}
|
||||
if command_name == "git"
|
||||
&& matches!(
|
||||
words.get(1).map(String::as_str).unwrap_or(""),
|
||||
"add"
|
||||
| "am"
|
||||
| "apply"
|
||||
| "branch"
|
||||
| "checkout"
|
||||
| "cherry-pick"
|
||||
| "clean"
|
||||
| "commit"
|
||||
| "merge"
|
||||
| "mv"
|
||||
| "pull"
|
||||
| "push"
|
||||
| "rebase"
|
||||
| "reset"
|
||||
| "restore"
|
||||
| "revert"
|
||||
| "rm"
|
||||
| "stash"
|
||||
| "switch"
|
||||
| "tag"
|
||||
)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if !is_known_read_only_powershell_command(command_name, &words) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
saw_segment
|
||||
}
|
||||
|
||||
fn build_missing_read_target_result(paths: &[PathBuf]) -> ToolResult {
|
||||
let path_values = paths
|
||||
.iter()
|
||||
.map(|path| path.display().to_string())
|
||||
.collect::<Vec<_>>();
|
||||
let message = if path_values.len() == 1 {
|
||||
format!(
|
||||
"路径不存在:{}。请先确认父目录,或先列目录再继续读取。",
|
||||
path_values[0]
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"以下路径不存在:{}。请先确认父目录,或先列目录再继续读取。",
|
||||
path_values.join(", ")
|
||||
)
|
||||
};
|
||||
|
||||
ToolResult::error(message)
|
||||
.with_metadata("preflight_check", json!("missing_read_target"))
|
||||
.with_metadata("missing_paths", json!(path_values))
|
||||
}
|
||||
|
||||
pub fn preflight_powershell_read_targets(command: &str, cwd: &Path) -> Option<ToolResult> {
|
||||
let mut missing_paths = Vec::new();
|
||||
|
||||
for raw_path in collect_powershell_read_path_candidates(command) {
|
||||
let Some(resolved_path) = resolve_static_path_candidate(&raw_path, cwd) else {
|
||||
continue;
|
||||
};
|
||||
if resolved_path.exists() || missing_paths.contains(&resolved_path) {
|
||||
continue;
|
||||
}
|
||||
missing_paths.push(resolved_path);
|
||||
}
|
||||
|
||||
(!missing_paths.is_empty()).then(|| build_missing_read_target_result(&missing_paths))
|
||||
}
|
||||
|
||||
fn detect_blocked_sleep_pattern(command: &str) -> Option<String> {
|
||||
let trimmed = command.trim();
|
||||
let first = trimmed.split([';', '|', '&', '\r', '\n']).next()?.trim();
|
||||
@@ -380,6 +1070,7 @@ fn dynamic_description() -> String {
|
||||
POWERSHELL_TOOL_DESCRIPTION.to_string(),
|
||||
String::new(),
|
||||
"IMPORTANT: This tool is for terminal operations via PowerShell. Do not use it for file read/write/search operations when specialized tools already exist.".to_string(),
|
||||
"Do not guess file paths. If you are not sure whether a target exists, list or search the parent directory first.".to_string(),
|
||||
String::new(),
|
||||
"Parameters:".to_string(),
|
||||
"- `command`: required PowerShell command string.".to_string(),
|
||||
@@ -446,7 +1137,7 @@ impl Tool for PowerShellTool {
|
||||
async fn check_permissions(
|
||||
&self,
|
||||
params: &Value,
|
||||
_context: &ToolContext,
|
||||
context: &ToolContext,
|
||||
) -> PermissionCheckResult {
|
||||
let input: PowerShellToolInput = match serde_json::from_value(params.clone()) {
|
||||
Ok(input) => input,
|
||||
@@ -470,6 +1161,12 @@ impl Tool for PowerShellTool {
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(path_result) =
|
||||
validate_powershell_command_paths(&input.command, &context.working_directory)
|
||||
{
|
||||
return path_result;
|
||||
}
|
||||
|
||||
if let Some(warning) = safety_result.warning {
|
||||
return PermissionCheckResult::ask(format!(
|
||||
"PowerShell command may be dangerous: {}. Do you want to proceed?",
|
||||
@@ -498,6 +1195,14 @@ impl Tool for PowerShellTool {
|
||||
}
|
||||
}
|
||||
|
||||
if !input.run_in_background.unwrap_or(false) {
|
||||
if let Some(preflight_result) =
|
||||
preflight_powershell_read_targets(&input.command, &context.working_directory)
|
||||
{
|
||||
return Ok(preflight_result);
|
||||
}
|
||||
}
|
||||
|
||||
let timeout_ms = input.timeout.unwrap_or(DEFAULT_TIMEOUT_MS);
|
||||
if input.run_in_background.unwrap_or(false) {
|
||||
self.execute_background(&input.command, input.description.as_deref(), context)
|
||||
@@ -520,6 +1225,7 @@ mod tests {
|
||||
|
||||
assert_eq!(definition.name, POWERSHELL_TOOL_NAME);
|
||||
assert!(definition.description.contains("run_in_background"));
|
||||
assert!(definition.description.contains("Do not guess file paths"));
|
||||
assert_eq!(
|
||||
definition
|
||||
.input_schema
|
||||
@@ -579,6 +1285,153 @@ mod tests {
|
||||
assert!(result.is_denied());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_powershell_tool_check_permissions_warns_set_content() {
|
||||
let tool = PowerShellTool::with_executable_path(Arc::new(TaskManager::new()), None);
|
||||
|
||||
let result = tool
|
||||
.check_permissions(
|
||||
&json!({
|
||||
"command": "Set-Content notes.txt 'hello'"
|
||||
}),
|
||||
&ToolContext::default(),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.requires_confirmation());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_powershell_tool_check_permissions_outside_workspace_mentions_path_scope() {
|
||||
let tool = PowerShellTool::with_executable_path(Arc::new(TaskManager::new()), None);
|
||||
|
||||
let result = tool
|
||||
.check_permissions(
|
||||
&json!({
|
||||
"command": "Set-Content ../notes.txt 'hello'"
|
||||
}),
|
||||
&ToolContext::new(PathBuf::from("/tmp/project")),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.requires_confirmation());
|
||||
assert!(result
|
||||
.message
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.contains("outside the current working directory"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_powershell_tool_check_permissions_denies_git_reset_hard() {
|
||||
let tool = PowerShellTool::with_executable_path(Arc::new(TaskManager::new()), None);
|
||||
|
||||
let result = tool
|
||||
.check_permissions(
|
||||
&json!({
|
||||
"command": "git reset --hard HEAD~1"
|
||||
}),
|
||||
&ToolContext::default(),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_denied());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_powershell_tool_check_permissions_denies_relative_root_removal() {
|
||||
let tool = PowerShellTool::with_executable_path(Arc::new(TaskManager::new()), None);
|
||||
|
||||
let result = tool
|
||||
.check_permissions(
|
||||
&json!({
|
||||
"command": "Remove-Item ../../ -Recurse -Force"
|
||||
}),
|
||||
&ToolContext::new(PathBuf::from("/tmp/project")),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_denied());
|
||||
assert!(result
|
||||
.message
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.contains("protected path"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_powershell_tool_check_permissions_denies_symlink_creation() {
|
||||
let tool = PowerShellTool::with_executable_path(Arc::new(TaskManager::new()), None);
|
||||
|
||||
let result = tool
|
||||
.check_permissions(
|
||||
&json!({
|
||||
"command": "New-Item -ItemType SymbolicLink -Path link -Target target"
|
||||
}),
|
||||
&ToolContext::default(),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_denied());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_powershell_tool_check_permissions_allows_null_redirection() {
|
||||
let tool = PowerShellTool::with_executable_path(Arc::new(TaskManager::new()), None);
|
||||
|
||||
let result = tool
|
||||
.check_permissions(
|
||||
&json!({
|
||||
"command": "Get-Content notes.txt > $null"
|
||||
}),
|
||||
&ToolContext::default(),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_allowed());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_powershell_tool_execute_preflights_missing_read_target() {
|
||||
use tempfile::tempdir;
|
||||
|
||||
let temp_dir = tempdir().unwrap();
|
||||
let missing_path = temp_dir.path().join("missing.txt");
|
||||
let tool = PowerShellTool::with_executable_path(Arc::new(TaskManager::new()), None);
|
||||
|
||||
let result = tool
|
||||
.execute(
|
||||
json!({
|
||||
"command": format!("Get-Content {}", missing_path.display())
|
||||
}),
|
||||
&ToolContext::new(temp_dir.path().to_path_buf()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(result.is_error());
|
||||
assert!(result.message().unwrap_or_default().contains("路径不存在"));
|
||||
assert_eq!(
|
||||
result.metadata.get("preflight_check"),
|
||||
Some(&json!("missing_read_target"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_powershell_command_concurrency_safe_for_read_only_pipeline() {
|
||||
assert!(is_powershell_command_concurrency_safe(
|
||||
"Get-ChildItem src | Select-Object -First 5"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_powershell_command_concurrency_safe_rejects_mutation() {
|
||||
assert!(!is_powershell_command_concurrency_safe(
|
||||
"Set-Content notes.txt 'hello'"
|
||||
));
|
||||
assert!(!is_powershell_command_concurrency_safe("git checkout main"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_powershell_tool_options() {
|
||||
let tool = PowerShellTool::with_executable_path(Arc::new(TaskManager::new()), None);
|
||||
@@ -591,4 +1444,11 @@ mod tests {
|
||||
);
|
||||
assert!(!options.enable_dynamic_timeout);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_output_uses_semantic_message_when_empty() {
|
||||
let tool = PowerShellTool::with_executable_path(Arc::new(TaskManager::new()), None);
|
||||
let result = tool.format_output_with_message("", "", 1, Some("No matches found"));
|
||||
assert_eq!(result, "No matches found");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,6 +109,8 @@ impl Tool for McpToolWrapper {
|
||||
pub struct ToolRegistry {
|
||||
/// Native tools (high priority)
|
||||
native_tools: HashMap<String, Box<dyn Tool>>,
|
||||
/// Compatibility aliases that resolve to canonical native tool names
|
||||
native_aliases: HashMap<String, String>,
|
||||
/// MCP tools (low priority)
|
||||
mcp_tools: HashMap<String, McpToolWrapper>,
|
||||
/// Permission manager for checking tool permissions
|
||||
@@ -128,6 +130,7 @@ impl ToolRegistry {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
native_tools: HashMap::new(),
|
||||
native_aliases: HashMap::new(),
|
||||
mcp_tools: HashMap::new(),
|
||||
permission_manager: None,
|
||||
audit_logger: None,
|
||||
@@ -141,6 +144,7 @@ impl ToolRegistry {
|
||||
) -> Self {
|
||||
Self {
|
||||
native_tools: HashMap::new(),
|
||||
native_aliases: HashMap::new(),
|
||||
mcp_tools: HashMap::new(),
|
||||
permission_manager: Some(permission_manager),
|
||||
audit_logger: Some(audit_logger),
|
||||
@@ -173,12 +177,63 @@ impl ToolRegistry {
|
||||
// =============================================================================
|
||||
|
||||
impl ToolRegistry {
|
||||
fn default_native_aliases(name: &str) -> &'static [&'static str] {
|
||||
match name {
|
||||
"Agent" => &["AgentTool"],
|
||||
"AskUserQuestion" => &["AskUserQuestionTool"],
|
||||
"Bash" => &["BashTool"],
|
||||
"Config" => &["ConfigTool"],
|
||||
"Edit" => &["FileEditTool"],
|
||||
"Read" => &["FileReadTool"],
|
||||
"Write" => &["FileWriteTool"],
|
||||
"EnterPlanMode" => &["EnterPlanModeTool"],
|
||||
"ExitPlanMode" => &["ExitPlanModeTool"],
|
||||
"EnterWorktree" => &["EnterWorktreeTool"],
|
||||
"ExitWorktree" => &["ExitWorktreeTool"],
|
||||
"Glob" => &["GlobTool"],
|
||||
"Grep" => &["GrepTool"],
|
||||
"LSP" => &["LSPTool"],
|
||||
"NotebookEdit" => &["NotebookEditTool"],
|
||||
"PowerShell" => &["PowerShellTool"],
|
||||
"RemoteTrigger" => &["RemoteTriggerTool"],
|
||||
"SendUserMessage" => &["BriefTool"],
|
||||
"Skill" => &["SkillTool"],
|
||||
"Sleep" => &["SleepTool"],
|
||||
"ToolSearch" => &["ToolSearchTool"],
|
||||
"WebFetch" => &["WebFetchTool"],
|
||||
"WebSearch" => &["WebSearchTool"],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
fn find_native_key(&self, name: &str) -> Option<&String> {
|
||||
self.native_tools
|
||||
.keys()
|
||||
.find(|registered| registered.eq_ignore_ascii_case(name))
|
||||
}
|
||||
|
||||
fn find_native_alias_key(&self, name: &str) -> Option<&String> {
|
||||
self.native_aliases
|
||||
.keys()
|
||||
.find(|registered| registered.eq_ignore_ascii_case(name))
|
||||
}
|
||||
|
||||
fn resolve_native_key(&self, name: &str) -> Option<&String> {
|
||||
if let Some(registered) = self.find_native_key(name) {
|
||||
return Some(registered);
|
||||
}
|
||||
|
||||
let canonical_name = self
|
||||
.find_native_alias_key(name)
|
||||
.and_then(|alias| self.native_aliases.get(alias))?;
|
||||
self.find_native_key(canonical_name)
|
||||
}
|
||||
|
||||
fn remove_native_aliases_for(&mut self, canonical_name: &str) {
|
||||
self.native_aliases
|
||||
.retain(|_, target| !target.eq_ignore_ascii_case(canonical_name));
|
||||
}
|
||||
|
||||
fn find_mcp_key(&self, name: &str) -> Option<&String> {
|
||||
self.mcp_tools
|
||||
.keys()
|
||||
@@ -196,10 +251,36 @@ impl ToolRegistry {
|
||||
/// Requirements: 2.1
|
||||
pub fn register(&mut self, tool: Box<dyn Tool>) {
|
||||
let name = tool.name().to_string();
|
||||
let aliases = tool
|
||||
.aliases()
|
||||
.iter()
|
||||
.copied()
|
||||
.chain(Self::default_native_aliases(&name).iter().copied())
|
||||
.collect::<Vec<_>>();
|
||||
if let Some(existing_name) = self.find_native_key(&name).cloned() {
|
||||
self.remove_native_aliases_for(&existing_name);
|
||||
self.native_tools.remove(&existing_name);
|
||||
}
|
||||
self.native_tools.insert(name, tool);
|
||||
if let Some(existing_alias_name) = self.find_native_alias_key(&name).cloned() {
|
||||
self.native_aliases.remove(&existing_alias_name);
|
||||
}
|
||||
|
||||
self.native_tools.insert(name.clone(), tool);
|
||||
self.remove_native_aliases_for(&name);
|
||||
|
||||
for alias in aliases {
|
||||
let alias = alias.trim();
|
||||
if alias.is_empty() || alias.eq_ignore_ascii_case(&name) {
|
||||
continue;
|
||||
}
|
||||
if self.find_native_key(alias).is_some() {
|
||||
continue;
|
||||
}
|
||||
if let Some(existing_alias_name) = self.find_native_alias_key(alias).cloned() {
|
||||
self.native_aliases.remove(&existing_alias_name);
|
||||
}
|
||||
self.native_aliases.insert(alias.to_string(), name.clone());
|
||||
}
|
||||
}
|
||||
|
||||
/// Register an MCP tool
|
||||
@@ -228,6 +309,7 @@ impl ToolRegistry {
|
||||
/// The unregistered tool if it existed
|
||||
pub fn unregister(&mut self, name: &str) -> Option<Box<dyn Tool>> {
|
||||
let key = self.find_native_key(name).cloned()?;
|
||||
self.remove_native_aliases_for(&key);
|
||||
self.native_tools.remove(&key)
|
||||
}
|
||||
|
||||
@@ -251,12 +333,12 @@ impl ToolRegistry {
|
||||
/// # Returns
|
||||
/// `true` if the tool is registered
|
||||
pub fn contains(&self, name: &str) -> bool {
|
||||
self.find_native_key(name).is_some() || self.find_mcp_key(name).is_some()
|
||||
self.resolve_native_key(name).is_some() || self.find_mcp_key(name).is_some()
|
||||
}
|
||||
|
||||
/// Check if a native tool is registered
|
||||
pub fn contains_native(&self, name: &str) -> bool {
|
||||
self.find_native_key(name).is_some()
|
||||
self.resolve_native_key(name).is_some()
|
||||
}
|
||||
|
||||
/// Check if an MCP tool is registered
|
||||
@@ -303,7 +385,7 @@ impl ToolRegistry {
|
||||
pub fn get(&self, name: &str) -> Option<&dyn Tool> {
|
||||
// Native tools have priority over MCP tools
|
||||
if let Some(tool) = self
|
||||
.find_native_key(name)
|
||||
.resolve_native_key(name)
|
||||
.and_then(|registered| self.native_tools.get(registered))
|
||||
{
|
||||
return Some(tool.as_ref());
|
||||
@@ -384,7 +466,7 @@ impl ToolRegistry {
|
||||
|
||||
/// Check if a tool is a native tool
|
||||
pub fn is_native(&self, name: &str) -> bool {
|
||||
self.find_native_key(name).is_some()
|
||||
self.resolve_native_key(name).is_some()
|
||||
}
|
||||
|
||||
/// Check if a tool is an MCP tool (and not shadowed by a native tool)
|
||||
@@ -625,6 +707,7 @@ mod tests {
|
||||
name: String,
|
||||
should_fail: bool,
|
||||
permission_behavior: PermissionBehavior,
|
||||
aliases: &'static [&'static str],
|
||||
}
|
||||
|
||||
impl TestTool {
|
||||
@@ -633,6 +716,7 @@ mod tests {
|
||||
name: name.to_string(),
|
||||
should_fail: false,
|
||||
permission_behavior: PermissionBehavior::Allow,
|
||||
aliases: &[],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -641,6 +725,7 @@ mod tests {
|
||||
name: name.to_string(),
|
||||
should_fail: true,
|
||||
permission_behavior: PermissionBehavior::Allow,
|
||||
aliases: &[],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -649,6 +734,16 @@ mod tests {
|
||||
name: name.to_string(),
|
||||
should_fail: false,
|
||||
permission_behavior: behavior,
|
||||
aliases: &[],
|
||||
}
|
||||
}
|
||||
|
||||
fn with_aliases(name: &str, aliases: &'static [&'static str]) -> Self {
|
||||
Self {
|
||||
name: name.to_string(),
|
||||
should_fail: false,
|
||||
permission_behavior: PermissionBehavior::Allow,
|
||||
aliases,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -673,6 +768,10 @@ mod tests {
|
||||
})
|
||||
}
|
||||
|
||||
fn aliases(&self) -> &'static [&'static str] {
|
||||
self.aliases
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
params: serde_json::Value,
|
||||
@@ -739,6 +838,50 @@ mod tests {
|
||||
assert!(registry.get("bash").is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_registry_resolves_native_aliases_during_lookup_and_execution() {
|
||||
let mut registry = ToolRegistry::new();
|
||||
registry.register(Box::new(TestTool::with_aliases(
|
||||
"TaskStop",
|
||||
&["TaskStopTool", "KillShell"],
|
||||
)));
|
||||
|
||||
assert!(registry.contains("TaskStopTool"));
|
||||
assert!(registry.contains_native("killshell"));
|
||||
assert!(registry.is_native("KillShell"));
|
||||
assert!(registry.get("TaskStopTool").is_some());
|
||||
|
||||
let definitions = registry.get_definitions();
|
||||
assert_eq!(definitions.len(), 1);
|
||||
assert_eq!(definitions[0].name, "TaskStop");
|
||||
|
||||
let context = create_test_context();
|
||||
let result = registry
|
||||
.execute(
|
||||
"KillShell",
|
||||
serde_json::json!({ "input": "hello" }),
|
||||
&context,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("alias should resolve to native tool");
|
||||
assert_eq!(result.output.as_deref(), Some("Processed: hello"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_registry_unregister_clears_native_aliases() {
|
||||
let mut registry = ToolRegistry::new();
|
||||
registry.register(Box::new(TestTool::with_aliases(
|
||||
"TaskOutput",
|
||||
&["TaskOutputTool"],
|
||||
)));
|
||||
|
||||
assert!(registry.contains("TaskOutputTool"));
|
||||
let removed = registry.unregister("TaskOutput");
|
||||
assert!(removed.is_some());
|
||||
assert!(!registry.contains("TaskOutputTool"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_registry_register_mcp_tool() {
|
||||
let mut registry = ToolRegistry::new();
|
||||
|
||||
@@ -29,6 +29,7 @@ enum RemoteTriggerAction {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct RemoteTriggerInput {
|
||||
action: RemoteTriggerAction,
|
||||
#[serde(default)]
|
||||
@@ -277,6 +278,25 @@ mod tests {
|
||||
assert!(error.to_string().contains("requires body"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remote_trigger_rejects_unknown_fields() {
|
||||
let result = RemoteTriggerTool::new()
|
||||
.execute(
|
||||
json!({
|
||||
"action": "list",
|
||||
"unexpected": true
|
||||
}),
|
||||
&ToolContext::new(std::path::PathBuf::from(".")),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(ToolError::InvalidParams(message))
|
||||
if message.contains("unknown field `unexpected`, expected one of `action`, `trigger_id`, `body`")
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn remote_trigger_execute_calls_runtime_api_and_serializes_response() {
|
||||
|
||||
@@ -9,6 +9,7 @@ use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::io::ErrorKind;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub const SEND_USER_MESSAGE_TOOL_NAME: &str = "SendUserMessage";
|
||||
@@ -21,6 +22,7 @@ enum SendUserMessageStatus {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct SendUserMessageInput {
|
||||
message: String,
|
||||
#[serde(default)]
|
||||
@@ -152,24 +154,13 @@ fn resolve_attachments(
|
||||
attachments
|
||||
.iter()
|
||||
.map(|raw_path| {
|
||||
let trimmed = raw_path.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(ToolError::invalid_params(
|
||||
"attachments 里不能包含空路径".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let resolved_path = resolve_attachment_path(working_directory, trimmed);
|
||||
let resolved_path = resolve_attachment_path(working_directory, raw_path);
|
||||
let metadata = std::fs::metadata(&resolved_path).map_err(|error| {
|
||||
ToolError::execution_failed(format!(
|
||||
"读取附件失败 ({}): {error}",
|
||||
resolved_path.display()
|
||||
))
|
||||
map_attachment_metadata_error(raw_path, working_directory, error)
|
||||
})?;
|
||||
if !metadata.is_file() {
|
||||
return Err(ToolError::invalid_params(format!(
|
||||
"附件必须是文件: {}",
|
||||
resolved_path.display()
|
||||
"Attachment \"{raw_path}\" is not a regular file."
|
||||
)));
|
||||
}
|
||||
|
||||
@@ -184,7 +175,22 @@ fn resolve_attachments(
|
||||
}
|
||||
|
||||
fn resolve_attachment_path(working_directory: &Path, raw_path: &str) -> PathBuf {
|
||||
let path = PathBuf::from(raw_path);
|
||||
let trimmed = raw_path.trim();
|
||||
if trimmed == "~" {
|
||||
if let Some(home_dir) = dirs::home_dir() {
|
||||
return home_dir;
|
||||
}
|
||||
}
|
||||
if let Some(home_relative) = trimmed
|
||||
.strip_prefix("~/")
|
||||
.or_else(|| trimmed.strip_prefix("~\\"))
|
||||
{
|
||||
if let Some(home_dir) = dirs::home_dir() {
|
||||
return home_dir.join(home_relative);
|
||||
}
|
||||
}
|
||||
|
||||
let path = PathBuf::from(trimmed);
|
||||
if path.is_absolute() {
|
||||
path
|
||||
} else {
|
||||
@@ -192,6 +198,25 @@ fn resolve_attachment_path(working_directory: &Path, raw_path: &str) -> PathBuf
|
||||
}
|
||||
}
|
||||
|
||||
fn map_attachment_metadata_error(
|
||||
raw_path: &str,
|
||||
working_directory: &Path,
|
||||
error: std::io::Error,
|
||||
) -> ToolError {
|
||||
match error.kind() {
|
||||
ErrorKind::NotFound => ToolError::invalid_params(format!(
|
||||
"Attachment \"{raw_path}\" does not exist. Current working directory: {}.",
|
||||
working_directory.display()
|
||||
)),
|
||||
ErrorKind::PermissionDenied => ToolError::invalid_params(format!(
|
||||
"Attachment \"{raw_path}\" is not accessible (permission denied)."
|
||||
)),
|
||||
_ => ToolError::execution_failed(format!(
|
||||
"Failed to read attachment \"{raw_path}\": {error}"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_user_visible_message(message: &str, attachments: &[UserAttachment]) -> String {
|
||||
if attachments.is_empty() {
|
||||
return message.to_string();
|
||||
@@ -227,6 +252,7 @@ fn is_image_path(path: &Path) -> bool {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[tokio::test]
|
||||
@@ -290,6 +316,68 @@ mod tests {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let error = resolve_attachments(temp_dir.path(), &["missing.txt".to_string()])
|
||||
.expect_err("missing attachment should fail");
|
||||
assert!(error.to_string().contains("读取附件失败"));
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
format!(
|
||||
"Invalid parameters: Attachment \"missing.txt\" does not exist. Current working directory: {}.",
|
||||
temp_dir.path().display()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_attachments_rejects_non_regular_files() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
fs::create_dir(temp_dir.path().join("nested")).unwrap();
|
||||
|
||||
let error = resolve_attachments(temp_dir.path(), &["nested".to_string()])
|
||||
.expect_err("directory attachment should fail");
|
||||
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
"Invalid parameters: Attachment \"nested\" is not a regular file."
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_user_message_rejects_unknown_fields() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let error = SendUserMessageTool::new()
|
||||
.execute(
|
||||
json!({
|
||||
"message": "处理完成",
|
||||
"status": "normal",
|
||||
"unexpected": true
|
||||
}),
|
||||
&ToolContext::new(temp_dir.path().to_path_buf()),
|
||||
)
|
||||
.await
|
||||
.expect_err("unknown fields should be rejected");
|
||||
|
||||
assert!(error.to_string().contains(
|
||||
"unknown field `unexpected`, expected one of `message`, `attachments`, `status`"
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn resolve_attachments_rejects_permission_denied_paths() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let locked_dir = temp_dir.path().join("locked");
|
||||
fs::create_dir(&locked_dir).unwrap();
|
||||
fs::write(locked_dir.join("secret.txt"), "secret").unwrap();
|
||||
|
||||
let original_permissions = fs::metadata(&locked_dir).unwrap().permissions();
|
||||
fs::set_permissions(&locked_dir, std::fs::Permissions::from_mode(0o000)).unwrap();
|
||||
let result = resolve_attachments(temp_dir.path(), &["locked/secret.txt".to_string()]);
|
||||
fs::set_permissions(&locked_dir, original_permissions).unwrap();
|
||||
|
||||
let error = result.expect_err("permission denied attachment should fail");
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
"Invalid parameters: Attachment \"locked/secret.txt\" is not accessible (permission denied)."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ const SLEEP_TOOL_DESCRIPTION: &str = "Wait for a specified duration";
|
||||
const SLEEP_TOOL_BASE_TIMEOUT_SECS: u64 = 24 * 60 * 60;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||||
struct SleepToolInput {
|
||||
duration_ms: u64,
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,10 +7,12 @@ use super::context::{ToolContext, ToolResult};
|
||||
use super::error::ToolError;
|
||||
use super::task::TaskManager;
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::{de, Deserialize, Deserializer, Serialize};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
const TASK_OUTPUT_TOOL_ALIASES: &[&str] = &["TaskOutputTool", "AgentOutputTool", "BashOutputTool"];
|
||||
|
||||
/// TaskOutputTool 输入参数
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -18,11 +20,36 @@ pub struct TaskOutputInput {
|
||||
/// 任务 ID
|
||||
pub task_id: String,
|
||||
/// 是否阻塞等待任务完成
|
||||
#[serde(default, deserialize_with = "deserialize_optional_semantic_bool")]
|
||||
pub block: Option<bool>,
|
||||
/// 等待超时时间(毫秒)
|
||||
pub timeout: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum SemanticBoolInput {
|
||||
Bool(bool),
|
||||
String(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct TaskOutputPayload {
|
||||
task_id: String,
|
||||
task_type: String,
|
||||
status: String,
|
||||
description: String,
|
||||
output: String,
|
||||
#[serde(rename = "exitCode", skip_serializing_if = "Option::is_none")]
|
||||
exit_code: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct TaskOutputResponse {
|
||||
retrieval_status: String,
|
||||
task: Option<TaskOutputPayload>,
|
||||
}
|
||||
|
||||
/// TaskOutputTool - 查询任务输出和状态
|
||||
///
|
||||
/// 对齐当前工具面的 TaskOutput 能力
|
||||
@@ -51,12 +78,41 @@ impl Default for TaskOutputTool {
|
||||
}
|
||||
}
|
||||
|
||||
fn pretty_json<T: Serialize>(value: &T) -> Result<String, ToolError> {
|
||||
serde_json::to_string_pretty(value).map_err(|error| {
|
||||
ToolError::execution_failed(format!("序列化 TaskOutput 结果失败: {error}"))
|
||||
})
|
||||
}
|
||||
|
||||
fn deserialize_optional_semantic_bool<'de, D>(deserializer: D) -> Result<Option<bool>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let value = Option::<SemanticBoolInput>::deserialize(deserializer)?;
|
||||
match value {
|
||||
None => Ok(None),
|
||||
Some(SemanticBoolInput::Bool(value)) => Ok(Some(value)),
|
||||
Some(SemanticBoolInput::String(raw)) => match raw.trim().to_ascii_lowercase().as_str() {
|
||||
"true" => Ok(Some(true)),
|
||||
"false" => Ok(Some(false)),
|
||||
_ => Err(de::Error::invalid_value(
|
||||
de::Unexpected::Str(&raw),
|
||||
&"a boolean or the string \"true\"/\"false\"",
|
||||
)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for TaskOutputTool {
|
||||
fn name(&self) -> &str {
|
||||
"TaskOutput"
|
||||
}
|
||||
|
||||
fn aliases(&self) -> &'static [&'static str] {
|
||||
TASK_OUTPUT_TOOL_ALIASES
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
r#"获取后台任务的输出和状态
|
||||
|
||||
@@ -83,7 +139,9 @@ TaskOutput 用于按 `task_id` 查询后台执行状态与日志。
|
||||
},
|
||||
"timeout": {
|
||||
"type": "number",
|
||||
"description": "等待超时时间(毫秒,默认 30000)"
|
||||
"minimum": 0,
|
||||
"maximum": 600000,
|
||||
"description": "等待超时时间(毫秒,默认 30000,最大 600000)"
|
||||
}
|
||||
},
|
||||
"required": ["task_id"],
|
||||
@@ -101,6 +159,9 @@ TaskOutput 用于按 `task_id` 查询后台执行状态与日志。
|
||||
|
||||
let block = input.block.unwrap_or(true);
|
||||
let timeout_ms = input.timeout.unwrap_or(30000);
|
||||
if timeout_ms > 600000 {
|
||||
return Err(ToolError::invalid_params("timeout 不能超过 600000 毫秒"));
|
||||
}
|
||||
|
||||
// 检查任务是否存在
|
||||
if !self.task_manager.task_exists(&input.task_id).await {
|
||||
@@ -147,78 +208,26 @@ TaskOutput 用于按 `task_id` 查询后台执行状态与日志。
|
||||
};
|
||||
let output_file = state.output_file.display().to_string();
|
||||
|
||||
// 构建输出信息
|
||||
let mut output = Vec::new();
|
||||
output.push(
|
||||
"兼容提示: 新链路优先使用 read 工具读取任务输出文件,TaskOutput 仅作为旧 task_id 查询兜底。"
|
||||
.to_string(),
|
||||
);
|
||||
output.push(format!("=== 任务 {} ===", input.task_id));
|
||||
output.push(format!("命令: {}", state.command));
|
||||
output.push(format!("状态: {}", state.status));
|
||||
output.push(format!("开始时间: {}", format_instant(state.start_time)));
|
||||
|
||||
let duration = state.duration();
|
||||
if let Some(end_time) = state.end_time {
|
||||
output.push(format!("结束时间: {}", format_instant(end_time)));
|
||||
output.push(format!("执行时间: {:.2}秒", duration.as_secs_f64()));
|
||||
} else {
|
||||
output.push(format!("运行时间: {:.2}秒", duration.as_secs_f64()));
|
||||
}
|
||||
let task_output = match self.task_manager.get_output(&input.task_id, None).await {
|
||||
Ok(task_output) => task_output,
|
||||
Err(error) => format!("输出获取失败: {error}"),
|
||||
};
|
||||
let response = TaskOutputResponse {
|
||||
retrieval_status: retrieval_status.to_string(),
|
||||
task: Some(TaskOutputPayload {
|
||||
task_id: input.task_id.clone(),
|
||||
task_type: "local_bash".to_string(),
|
||||
status: state.status.to_string(),
|
||||
description: state.command.clone(),
|
||||
output: task_output,
|
||||
exit_code: state.exit_code,
|
||||
}),
|
||||
};
|
||||
|
||||
if let Some(exit_code) = state.exit_code {
|
||||
output.push(format!("退出码: {}", exit_code));
|
||||
}
|
||||
|
||||
output.push(format!("工作目录: {}", state.working_directory.display()));
|
||||
output.push(format!("输出文件: {}", output_file));
|
||||
output.push(format!("会话 ID: {}", state.session_id));
|
||||
|
||||
// 获取任务输出
|
||||
match self.task_manager.get_output(&input.task_id, None).await {
|
||||
Ok(task_output) => {
|
||||
output.push("\n=== 任务输出 ===".to_string());
|
||||
if task_output.trim().is_empty() {
|
||||
output.push("(暂无输出)".to_string());
|
||||
} else {
|
||||
output.push(task_output);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
output.push("\n=== 输出获取失败 ===".to_string());
|
||||
output.push(format!("错误: {}", e));
|
||||
}
|
||||
}
|
||||
|
||||
// 根据任务状态添加状态说明
|
||||
match state.status {
|
||||
super::task::TaskStatus::Running => {
|
||||
output.push("\n=== 状态说明 ===".to_string());
|
||||
output.push(
|
||||
"任务仍在运行中。优先直接读取输出文件;如需继续等待,可使用 block=true。"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
super::task::TaskStatus::Completed => {
|
||||
output.push("\n=== 状态说明 ===".to_string());
|
||||
output.push("任务已成功完成。".to_string());
|
||||
}
|
||||
super::task::TaskStatus::Failed => {
|
||||
output.push("\n=== 状态说明 ===".to_string());
|
||||
output.push("任务执行失败。请检查命令和输出错误信息。".to_string());
|
||||
}
|
||||
super::task::TaskStatus::TimedOut => {
|
||||
output.push("\n=== 状态说明 ===".to_string());
|
||||
output.push("任务因超时被终止。".to_string());
|
||||
}
|
||||
super::task::TaskStatus::Killed => {
|
||||
output.push("\n=== 状态说明 ===".to_string());
|
||||
output.push("任务被用户终止。".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ToolResult::success(output.join("\n"))
|
||||
Ok(ToolResult::success(pretty_json(&response)?)
|
||||
.with_metadata("task_id", serde_json::json!(input.task_id))
|
||||
.with_metadata("task_type", serde_json::json!("local_bash"))
|
||||
.with_metadata("status", serde_json::json!(state.status.to_string()))
|
||||
.with_metadata("duration", serde_json::json!(duration.as_secs_f64()))
|
||||
.with_metadata("exit_code", serde_json::json!(state.exit_code))
|
||||
@@ -236,13 +245,6 @@ TaskOutput 用于按 `task_id` 查询后台执行状态与日志。
|
||||
}
|
||||
}
|
||||
|
||||
/// 格式化 Instant 为可读字符串
|
||||
/// 注意:Instant 不能直接转换为绝对时间,这里只显示相对时间
|
||||
fn format_instant(instant: std::time::Instant) -> String {
|
||||
let elapsed = instant.elapsed();
|
||||
format!("{:.2}秒前", elapsed.as_secs_f64())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -317,7 +319,9 @@ mod tests {
|
||||
|
||||
let tool_result = result.unwrap();
|
||||
assert!(tool_result.success);
|
||||
assert!(tool_result.output.as_ref().unwrap().contains(&task_id));
|
||||
let output = tool_result.output.as_ref().unwrap();
|
||||
assert!(output.contains(&task_id));
|
||||
assert!(output.contains("\"task_type\": \"local_bash\""));
|
||||
assert!(tool_result.metadata.contains_key("status"));
|
||||
assert_eq!(
|
||||
tool_result.metadata.get("retrieval_status"),
|
||||
@@ -326,6 +330,79 @@ mod tests {
|
||||
assert!(tool_result.metadata.contains_key("output_file"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_task_output_tool_non_blocking_running_task_returns_not_ready() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let task_manager = Arc::new(
|
||||
TaskManager::new()
|
||||
.with_output_directory(temp_dir.path().to_path_buf())
|
||||
.with_max_concurrent(5),
|
||||
);
|
||||
let tool = TaskOutputTool::with_manager(task_manager.clone());
|
||||
let context = create_test_context();
|
||||
|
||||
let task_id = task_manager.start("sleep 2", &context).await.unwrap();
|
||||
|
||||
let result = tool
|
||||
.execute(
|
||||
serde_json::json!({
|
||||
"task_id": task_id,
|
||||
"block": false
|
||||
}),
|
||||
&context,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(result.success);
|
||||
assert_eq!(
|
||||
result.metadata["retrieval_status"],
|
||||
serde_json::json!("not_ready")
|
||||
);
|
||||
assert_eq!(
|
||||
result.metadata["task_type"],
|
||||
serde_json::json!("local_bash")
|
||||
);
|
||||
let output = result.output.as_ref().unwrap();
|
||||
assert!(output.contains("\"retrieval_status\": \"not_ready\""));
|
||||
assert!(output.contains("\"task_type\": \"local_bash\""));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_task_output_tool_blocking_timeout_returns_timeout() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let task_manager = Arc::new(
|
||||
TaskManager::new()
|
||||
.with_output_directory(temp_dir.path().to_path_buf())
|
||||
.with_max_concurrent(5),
|
||||
);
|
||||
let tool = TaskOutputTool::with_manager(task_manager.clone());
|
||||
let context = create_test_context();
|
||||
|
||||
let task_id = task_manager.start("sleep 2", &context).await.unwrap();
|
||||
|
||||
let result = tool
|
||||
.execute(
|
||||
serde_json::json!({
|
||||
"task_id": task_id,
|
||||
"block": true,
|
||||
"timeout": 100
|
||||
}),
|
||||
&context,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(result.success);
|
||||
assert_eq!(
|
||||
result.metadata["retrieval_status"],
|
||||
serde_json::json!("timeout")
|
||||
);
|
||||
let output = result.output.as_ref().unwrap();
|
||||
assert!(output.contains("\"retrieval_status\": \"timeout\""));
|
||||
assert!(output.contains("\"task_type\": \"local_bash\""));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_task_output_tool_with_block() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
@@ -355,9 +432,9 @@ mod tests {
|
||||
|
||||
let tool_result = result.unwrap();
|
||||
assert!(tool_result.success);
|
||||
// 应该包含任务输出
|
||||
let output = tool_result.output.as_ref().unwrap();
|
||||
assert!(output.contains("blocking test") || output.contains("已完成"));
|
||||
assert!(output.contains("blocking test"));
|
||||
assert!(output.contains("\"retrieval_status\": \"success\""));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -373,6 +450,36 @@ mod tests {
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_task_output_tool_accepts_string_block_flag() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let task_manager = Arc::new(
|
||||
TaskManager::new()
|
||||
.with_output_directory(temp_dir.path().to_path_buf())
|
||||
.with_max_concurrent(5),
|
||||
);
|
||||
let tool = TaskOutputTool::with_manager(task_manager.clone());
|
||||
let context = create_test_context();
|
||||
|
||||
let task_id = task_manager.start("sleep 1", &context).await.unwrap();
|
||||
|
||||
let result = tool
|
||||
.execute(
|
||||
serde_json::json!({
|
||||
"task_id": task_id,
|
||||
"block": "false"
|
||||
}),
|
||||
&context,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
result.metadata["retrieval_status"],
|
||||
serde_json::json!("not_ready")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_task_output_tool_rejects_legacy_extra_fields() {
|
||||
let tool = TaskOutputTool::new();
|
||||
|
||||
@@ -14,12 +14,24 @@ use super::context::{ToolContext, ToolOptions, ToolResult};
|
||||
use super::error::ToolError;
|
||||
use super::task::TaskManager;
|
||||
|
||||
const TASK_STOP_TOOL_ALIASES: &[&str] = &["TaskStopTool", "KillShell"];
|
||||
|
||||
/// TaskStop 工具输入参数
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TaskStopInput {
|
||||
/// 要终止的后台任务 ID
|
||||
#[serde(alias = "shell_id")]
|
||||
pub task_id: String,
|
||||
pub task_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct TaskStopPayload {
|
||||
message: String,
|
||||
task_id: String,
|
||||
task_type: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
command: Option<String>,
|
||||
}
|
||||
|
||||
/// 停止后台任务的新版工具
|
||||
@@ -46,12 +58,21 @@ impl TaskStopTool {
|
||||
}
|
||||
}
|
||||
|
||||
fn pretty_json<T: Serialize>(value: &T) -> Result<String, ToolError> {
|
||||
serde_json::to_string_pretty(value)
|
||||
.map_err(|error| ToolError::execution_failed(format!("序列化 TaskStop 结果失败: {error}")))
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for TaskStopTool {
|
||||
fn name(&self) -> &str {
|
||||
"TaskStop"
|
||||
}
|
||||
|
||||
fn aliases(&self) -> &'static [&'static str] {
|
||||
TASK_STOP_TOOL_ALIASES
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"停止正在运行的后台任务。优先使用 task_id;shell_id 仅作为旧参数名兼容。"
|
||||
}
|
||||
@@ -69,7 +90,7 @@ impl Tool for TaskStopTool {
|
||||
"description": "旧参数名兼容别名,请改用 task_id"
|
||||
}
|
||||
},
|
||||
"required": ["task_id"]
|
||||
"additionalProperties": false
|
||||
})
|
||||
}
|
||||
|
||||
@@ -78,31 +99,50 @@ impl Tool for TaskStopTool {
|
||||
params: serde_json::Value,
|
||||
_context: &ToolContext,
|
||||
) -> Result<ToolResult, ToolError> {
|
||||
let task_id = params
|
||||
.get("task_id")
|
||||
.or_else(|| params.get("shell_id"))
|
||||
.and_then(|value| value.as_str())
|
||||
let input: TaskStopInput = serde_json::from_value(params)
|
||||
.map_err(|error| ToolError::invalid_params(format!("TaskStop 参数无效: {error}")))?;
|
||||
let task_id = input
|
||||
.task_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| ToolError::invalid_params("Missing required parameter: task_id"))?;
|
||||
|
||||
let existing_state = self.task_manager.get_status(task_id).await;
|
||||
let existing_command = existing_state.as_ref().map(|state| state.command.clone());
|
||||
let Some(existing_state) = self.task_manager.get_status(task_id).await else {
|
||||
return Ok(
|
||||
ToolResult::error(format!("No task found with ID: {}", task_id))
|
||||
.with_metadata("task_id", serde_json::json!(task_id)),
|
||||
);
|
||||
};
|
||||
|
||||
if !existing_state.status.is_running() {
|
||||
return Ok(ToolResult::error(format!(
|
||||
"Task {} is not running (status: {})",
|
||||
task_id, existing_state.status
|
||||
))
|
||||
.with_metadata("task_id", serde_json::json!(task_id))
|
||||
.with_metadata("task_type", serde_json::json!("local_bash"))
|
||||
.with_metadata(
|
||||
"status",
|
||||
serde_json::json!(existing_state.status.to_string()),
|
||||
)
|
||||
.with_metadata("command", serde_json::json!(existing_state.command)));
|
||||
}
|
||||
|
||||
let command = existing_state.command.clone();
|
||||
match self.task_manager.kill(task_id).await {
|
||||
Ok(()) => {
|
||||
let command = existing_command.unwrap_or_else(|| "unknown".to_string());
|
||||
Ok(ToolResult::success(format!(
|
||||
"Successfully stopped task: {} ({})",
|
||||
task_id, command
|
||||
))
|
||||
.with_metadata("task_id", serde_json::json!(task_id))
|
||||
.with_metadata("task_type", serde_json::json!("background_task"))
|
||||
.with_metadata("command", serde_json::json!(command)))
|
||||
let output = TaskStopPayload {
|
||||
message: format!("Successfully stopped task: {} ({})", task_id, command),
|
||||
task_id: task_id.to_string(),
|
||||
task_type: "local_bash".to_string(),
|
||||
command: Some(command.clone()),
|
||||
};
|
||||
Ok(ToolResult::success(pretty_json(&output)?)
|
||||
.with_metadata("task_id", serde_json::json!(task_id))
|
||||
.with_metadata("task_type", serde_json::json!("local_bash"))
|
||||
.with_metadata("command", serde_json::json!(command)))
|
||||
}
|
||||
Err(ToolError::NotFound(_)) => Ok(ToolResult::error(format!(
|
||||
"No task found with ID: {}",
|
||||
task_id
|
||||
))
|
||||
.with_metadata("task_id", serde_json::json!(task_id))),
|
||||
Err(error) => Ok(ToolResult::error(format!(
|
||||
"Failed to stop task {}: {}",
|
||||
task_id, error
|
||||
@@ -165,10 +205,8 @@ mod tests {
|
||||
assert_eq!(schema["type"], "object");
|
||||
assert!(schema["properties"]["task_id"].is_object());
|
||||
assert!(schema["properties"]["shell_id"].is_object());
|
||||
assert!(schema["required"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.contains(&serde_json::json!("task_id")));
|
||||
assert_eq!(schema["additionalProperties"], serde_json::json!(false));
|
||||
assert!(schema["required"].is_null());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -230,9 +268,66 @@ mod tests {
|
||||
.expect("execute should succeed");
|
||||
|
||||
assert!(result.success);
|
||||
let output = result.output.as_ref().unwrap();
|
||||
assert!(output.contains("\"task_type\": \"local_bash\""));
|
||||
assert!(output.contains("\"message\": \"Successfully stopped task:"));
|
||||
assert_eq!(
|
||||
result.metadata["task_type"],
|
||||
serde_json::json!("background_task")
|
||||
serde_json::json!("local_bash")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_execute_accepts_shell_id_alias() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let task_manager =
|
||||
Arc::new(TaskManager::new().with_output_directory(temp_dir.path().to_path_buf()));
|
||||
let tool = TaskStopTool::with_task_manager(task_manager.clone());
|
||||
let context = create_test_context();
|
||||
|
||||
let task_id = task_manager
|
||||
.start("sleep 5", &context)
|
||||
.await
|
||||
.expect("task should start");
|
||||
|
||||
let result = tool
|
||||
.execute(serde_json::json!({ "shell_id": task_id }), &context)
|
||||
.await
|
||||
.expect("execute should succeed");
|
||||
|
||||
assert!(result.success);
|
||||
assert_eq!(
|
||||
result.metadata["task_type"],
|
||||
serde_json::json!("local_bash")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_execute_rejects_completed_task() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let task_manager =
|
||||
Arc::new(TaskManager::new().with_output_directory(temp_dir.path().to_path_buf()));
|
||||
let tool = TaskStopTool::with_task_manager(task_manager.clone());
|
||||
let context = create_test_context();
|
||||
|
||||
let task_id = task_manager
|
||||
.start("echo done", &context)
|
||||
.await
|
||||
.expect("task should start");
|
||||
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
|
||||
|
||||
let result = tool
|
||||
.execute(serde_json::json!({ "task_id": task_id }), &context)
|
||||
.await
|
||||
.expect("execute should return a structured error result");
|
||||
|
||||
assert!(!result.success);
|
||||
let expected_error = format!("Task {} is not running (status: completed)", task_id);
|
||||
assert_eq!(result.error.as_deref(), Some(expected_error.as_str()));
|
||||
assert_eq!(
|
||||
result.metadata["task_type"],
|
||||
serde_json::json!("local_bash")
|
||||
);
|
||||
assert_eq!(result.metadata["status"], serde_json::json!("completed"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::session::{
|
||||
resolve_team_context, save_team_state, ExtensionState, SessionManager, TeamMembershipState,
|
||||
TeamSessionState,
|
||||
require_shared_session_runtime_queue_service, resolve_team_context, save_team_membership,
|
||||
save_team_state, ExtensionState, SessionManager, SessionRuntimeQueueService, SessionType,
|
||||
TeamMember, TeamMembershipState, TeamSessionState, TEAM_LEAD_NAME,
|
||||
};
|
||||
use crate::tools::{
|
||||
base::Tool,
|
||||
@@ -10,13 +11,20 @@ use crate::tools::{
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::config::paths::Paths;
|
||||
|
||||
const TEAM_CREATE_TOOL_NAME: &str = "TeamCreate";
|
||||
const TEAM_DELETE_TOOL_NAME: &str = "TeamDelete";
|
||||
const LIST_PEERS_TOOL_NAME: &str = "ListPeers";
|
||||
const TEAM_CREATE_TOOL_ALIASES: &[&str] = &["TeamCreateTool"];
|
||||
const TEAM_DELETE_TOOL_ALIASES: &[&str] = &["TeamDeleteTool"];
|
||||
const LIST_PEERS_TOOL_ALIASES: &[&str] = &["ListPeersTool"];
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct TeamCreateInput {
|
||||
#[serde(alias = "team_name")]
|
||||
team_name: String,
|
||||
@@ -27,48 +35,113 @@ struct TeamCreateInput {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct TeamCreateOutput {
|
||||
#[serde(rename = "team_name")]
|
||||
team_name: String,
|
||||
#[serde(rename = "team_file_path")]
|
||||
team_file_path: String,
|
||||
#[serde(rename = "lead_agent_id")]
|
||||
lead_agent_id: String,
|
||||
task_list_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct TeamDeleteInput {}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct TeamDeleteOutput {
|
||||
success: bool,
|
||||
message: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(rename = "team_name", skip_serializing_if = "Option::is_none")]
|
||||
team_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct ListPeersInput {}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct PeerDescriptor {
|
||||
name: String,
|
||||
#[serde(rename = "agent_id")]
|
||||
agent_id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(rename = "agent_type", skip_serializing_if = "Option::is_none")]
|
||||
agent_type: Option<String>,
|
||||
#[serde(rename = "is_lead")]
|
||||
is_lead: bool,
|
||||
#[serde(rename = "send_to")]
|
||||
send_to: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ListPeersOutput {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(rename = "team_name", skip_serializing_if = "Option::is_none")]
|
||||
team_name: Option<String>,
|
||||
peers: Vec<PeerDescriptor>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct ResolvedTeamMemberState {
|
||||
member: TeamMember,
|
||||
is_active: bool,
|
||||
}
|
||||
|
||||
fn format_team_agent_id(name: &str, team_name: &str) -> String {
|
||||
format!("{name}@{team_name}")
|
||||
}
|
||||
|
||||
fn sanitize_team_name(name: &str) -> String {
|
||||
name.chars()
|
||||
.map(|character| {
|
||||
if character.is_ascii_alphanumeric() {
|
||||
character.to_ascii_lowercase()
|
||||
} else {
|
||||
'-'
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn team_config_file_path(team_name: &str) -> String {
|
||||
Paths::in_config_dir(&format!(
|
||||
"teams/{}/config.json",
|
||||
sanitize_team_name(team_name)
|
||||
))
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn generate_team_name_slug() -> String {
|
||||
const ADJECTIVES: &[&str] = &[
|
||||
"amber", "brisk", "clear", "cosmic", "eager", "gentle", "lively", "mellow", "nimble",
|
||||
"solar", "steady", "vivid",
|
||||
];
|
||||
const VERBS: &[&str] = &[
|
||||
"building", "charting", "crafting", "drifting", "guiding", "mapping", "racing", "shaping",
|
||||
"sparking", "spinning", "tracking", "weaving",
|
||||
];
|
||||
const NOUNS: &[&str] = &[
|
||||
"anchor",
|
||||
"atlas",
|
||||
"beacon",
|
||||
"bridge",
|
||||
"comet",
|
||||
"harbor",
|
||||
"lighthouse",
|
||||
"meadow",
|
||||
"orbit",
|
||||
"signal",
|
||||
"summit",
|
||||
"voyager",
|
||||
];
|
||||
|
||||
let seed = Uuid::new_v4().into_bytes();
|
||||
let adjective = ADJECTIVES[usize::from(seed[0]) % ADJECTIVES.len()];
|
||||
let verb = VERBS[usize::from(seed[1]) % VERBS.len()];
|
||||
let noun = NOUNS[usize::from(seed[2]) % NOUNS.len()];
|
||||
format!("{adjective}-{verb}-{noun}")
|
||||
}
|
||||
|
||||
pub struct TeamCreateTool;
|
||||
|
||||
impl TeamCreateTool {
|
||||
@@ -117,6 +190,10 @@ impl Tool for TeamCreateTool {
|
||||
TEAM_CREATE_TOOL_NAME
|
||||
}
|
||||
|
||||
fn aliases(&self) -> &'static [&'static str] {
|
||||
TEAM_CREATE_TOOL_ALIASES
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"创建一个共享任务板和多代理协作上下文。只保留当前 team surface:创建后,同一 team 下的子代理会共享 task list,并可通过 SendMessage 用名字互相通信。"
|
||||
}
|
||||
@@ -150,13 +227,15 @@ impl Tool for TeamCreateTool {
|
||||
"当前 session 已经属于一个 team;请先退出或删除现有 team",
|
||||
));
|
||||
}
|
||||
ensure_team_name_available(&team_name, &session.id).await?;
|
||||
let team_name = resolve_available_team_name(&team_name, &session.id).await?;
|
||||
|
||||
let lead_agent_type =
|
||||
normalize_optional_text(input.agent_type).or_else(|| Some(TEAM_LEAD_NAME.to_string()));
|
||||
let team_state = TeamSessionState::new(
|
||||
team_name.clone(),
|
||||
session.id.clone(),
|
||||
normalize_optional_text(input.description),
|
||||
normalize_optional_text(input.agent_type),
|
||||
lead_agent_type,
|
||||
);
|
||||
team_state
|
||||
.to_extension_data(&mut session.extension_data)
|
||||
@@ -169,15 +248,14 @@ impl Tool for TeamCreateTool {
|
||||
|
||||
let output = TeamCreateOutput {
|
||||
team_name: team_name.clone(),
|
||||
team_file_path: format!("session://{}/team", session.id),
|
||||
lead_agent_id: session.id.clone(),
|
||||
task_list_id: team_name.clone(),
|
||||
team_file_path: team_config_file_path(&team_name),
|
||||
lead_agent_id: format_team_agent_id(TEAM_LEAD_NAME, &team_name),
|
||||
};
|
||||
|
||||
Ok(ToolResult::success(pretty_json(&output)?)
|
||||
.with_metadata("teamName", json!(output.team_name))
|
||||
.with_metadata("leadAgentId", json!(output.lead_agent_id))
|
||||
.with_metadata("taskListId", json!(output.task_list_id)))
|
||||
.with_metadata("taskListId", json!(team_name)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,8 +265,12 @@ impl Tool for TeamDeleteTool {
|
||||
TEAM_DELETE_TOOL_NAME
|
||||
}
|
||||
|
||||
fn aliases(&self) -> &'static [&'static str] {
|
||||
TEAM_DELETE_TOOL_ALIASES
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"删除当前 team 协作上下文;仅 team lead 可执行。若仍有已注册成员,工具会拒绝删除,要求先逐个关闭这些成员。"
|
||||
"删除当前 team 协作上下文;仅 team lead 可执行。若仍有活跃成员,工具会拒绝删除,要求先逐个关闭这些成员。"
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> Value {
|
||||
@@ -222,7 +304,12 @@ impl Tool for TeamDeleteTool {
|
||||
));
|
||||
}
|
||||
|
||||
let active_members = team_context.team_state.non_lead_members();
|
||||
let reachable_members = resolve_reachable_team_members(&team_context.team_state).await?;
|
||||
let active_members = reachable_members
|
||||
.iter()
|
||||
.filter(|member| !member.member.is_lead && member.is_active)
|
||||
.map(|member| &member.member)
|
||||
.collect::<Vec<_>>();
|
||||
if !active_members.is_empty() {
|
||||
let member_names = active_members
|
||||
.iter()
|
||||
@@ -232,7 +319,7 @@ impl Tool for TeamDeleteTool {
|
||||
let output = TeamDeleteOutput {
|
||||
success: false,
|
||||
message: format!(
|
||||
"Cannot cleanup team with {} active member(s): {}. 请先通过 SendMessage 逐个通知这些成员结束当前工作后再重试。",
|
||||
"Cannot cleanup team with {} active member(s): {}. Use requestShutdown to gracefully terminate teammates first.",
|
||||
active_members.len(),
|
||||
member_names
|
||||
),
|
||||
@@ -250,12 +337,29 @@ impl Tool for TeamDeleteTool {
|
||||
));
|
||||
}
|
||||
|
||||
for member in reachable_members
|
||||
.iter()
|
||||
.filter(|member| !member.member.is_lead && !member.is_active)
|
||||
{
|
||||
save_team_membership(&member.member.agent_id, None)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
ToolError::execution_failed(format!(
|
||||
"清理 team 成员 {} 的 membership 失败: {error}",
|
||||
member.member.name
|
||||
))
|
||||
})?;
|
||||
}
|
||||
|
||||
save_team_state(&team_context.lead_session_id, None)
|
||||
.await
|
||||
.map_err(|error| ToolError::execution_failed(format!("删除 team 状态失败: {error}")))?;
|
||||
let output = TeamDeleteOutput {
|
||||
success: true,
|
||||
message: format!("Cleaned up team \"{}\"", team_context.team_state.team_name),
|
||||
message: format!(
|
||||
"Cleaned up directories and worktrees for team \"{}\"",
|
||||
team_context.team_state.team_name
|
||||
),
|
||||
team_name: Some(team_context.team_state.team_name.clone()),
|
||||
};
|
||||
Ok(ToolResult::success(pretty_json(&output)?)
|
||||
@@ -270,8 +374,12 @@ impl Tool for ListPeersTool {
|
||||
LIST_PEERS_TOOL_NAME
|
||||
}
|
||||
|
||||
fn aliases(&self) -> &'static [&'static str] {
|
||||
LIST_PEERS_TOOL_ALIASES
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"列出当前 team 中可通过 SendMessage 直接通信的 peers。当前 runtime 返回 team 成员名字与 agent id,不暴露旧 peer surface。"
|
||||
"列出当前 team 中可通过 SendMessage 直接通信的 peers。当前 Lime runtime 只返回 team 内可达成员的名字与 `name@team` display id,不枚举上游 `uds:` / `bridge:` 这类跨会话 peer surface。"
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> Value {
|
||||
@@ -289,19 +397,24 @@ impl Tool for ListPeersTool {
|
||||
.await
|
||||
.map_err(|error| ToolError::execution_failed(format!("读取 team 状态失败: {error}")))?
|
||||
{
|
||||
let team_name = team_context.team_state.team_name.clone();
|
||||
let peer_team_name = team_name.clone();
|
||||
let reachable_members =
|
||||
resolve_reachable_team_members(&team_context.team_state).await?;
|
||||
ListPeersOutput {
|
||||
team_name: Some(team_context.team_state.team_name),
|
||||
peers: team_context
|
||||
.team_state
|
||||
.members
|
||||
team_name: Some(team_name),
|
||||
peers: reachable_members
|
||||
.into_iter()
|
||||
.filter(|member| member.agent_id != team_context.current_agent_id)
|
||||
.map(|member| PeerDescriptor {
|
||||
send_to: member.name.clone(),
|
||||
name: member.name,
|
||||
agent_id: member.agent_id,
|
||||
agent_type: member.agent_type,
|
||||
is_lead: member.is_lead,
|
||||
.filter(|member| member.member.agent_id != team_context.current_agent_id)
|
||||
.map(|member| {
|
||||
let name = member.member.name;
|
||||
PeerDescriptor {
|
||||
send_to: name.clone(),
|
||||
agent_id: format_team_agent_id(&name, &peer_team_name),
|
||||
name,
|
||||
agent_type: member.member.agent_type,
|
||||
is_lead: member.member.is_lead,
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
@@ -312,36 +425,62 @@ impl Tool for ListPeersTool {
|
||||
}
|
||||
};
|
||||
|
||||
let peer_metadata = peers
|
||||
.peers
|
||||
.iter()
|
||||
.map(|peer| {
|
||||
json!({
|
||||
"name": peer.name,
|
||||
"agentId": peer.agent_id,
|
||||
"agentType": peer.agent_type,
|
||||
"isLead": peer.is_lead,
|
||||
"sendTo": peer.send_to,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Ok(ToolResult::success(pretty_json(&peers)?)
|
||||
.with_metadata("teamName", json!(peers.team_name))
|
||||
.with_metadata("peers", json!(peers.peers)))
|
||||
.with_metadata("peers", json!(peer_metadata)))
|
||||
}
|
||||
}
|
||||
|
||||
async fn ensure_team_name_available(
|
||||
async fn resolve_available_team_name(
|
||||
team_name: &str,
|
||||
current_session_id: &str,
|
||||
) -> Result<(), ToolError> {
|
||||
let sessions = SessionManager::list_sessions()
|
||||
.await
|
||||
.map_err(|error| ToolError::execution_failed(format!("列出 sessions 失败: {error}")))?;
|
||||
let conflict = sessions.into_iter().any(|session| {
|
||||
if session.id == current_session_id {
|
||||
return false;
|
||||
}
|
||||
) -> Result<String, ToolError> {
|
||||
let sessions = SessionManager::list_sessions_by_types(&[
|
||||
SessionType::User,
|
||||
SessionType::Scheduled,
|
||||
SessionType::SubAgent,
|
||||
SessionType::Hidden,
|
||||
SessionType::Terminal,
|
||||
])
|
||||
.await
|
||||
.map_err(|error| ToolError::execution_failed(format!("列出 sessions 失败: {error}")))?;
|
||||
let existing_names = sessions
|
||||
.into_iter()
|
||||
.filter(|session| session.id != current_session_id)
|
||||
.filter_map(|session| {
|
||||
TeamSessionState::from_extension_data(&session.extension_data)
|
||||
.map(|state| state.team_name)
|
||||
})
|
||||
.collect::<std::collections::HashSet<_>>();
|
||||
|
||||
TeamSessionState::from_extension_data(&session.extension_data)
|
||||
.map(|state| state.team_name == team_name)
|
||||
.unwrap_or(false)
|
||||
});
|
||||
|
||||
if conflict {
|
||||
return Err(ToolError::execution_failed(format!(
|
||||
"team_name \"{team_name}\" 已存在,请换一个名字"
|
||||
)));
|
||||
if !existing_names.contains(team_name) {
|
||||
return Ok(team_name.to_string());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
for _ in 0..1000 {
|
||||
let candidate = generate_team_name_slug();
|
||||
if !existing_names.contains(&candidate) {
|
||||
return Ok(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
Err(ToolError::execution_failed(format!(
|
||||
"team_name \"{team_name}\" 已存在,且未能生成可用别名"
|
||||
)))
|
||||
}
|
||||
|
||||
fn normalize_required_text(value: &str, field_name: &str) -> Result<String, ToolError> {
|
||||
@@ -371,16 +510,116 @@ fn pretty_json<T: Serialize>(value: &T) -> Result<String, ToolError> {
|
||||
.map_err(|error| ToolError::execution_failed(format!("序列化结果失败: {error}")))
|
||||
}
|
||||
|
||||
async fn resolve_reachable_team_members(
|
||||
team_state: &TeamSessionState,
|
||||
) -> Result<Vec<ResolvedTeamMemberState>, ToolError> {
|
||||
let runtime_queue_service = require_shared_session_runtime_queue_service().ok();
|
||||
let mut resolved_members = Vec::new();
|
||||
|
||||
for member in &team_state.members {
|
||||
if let Some(resolved) =
|
||||
resolve_team_member_state(member, team_state, runtime_queue_service.as_deref()).await?
|
||||
{
|
||||
resolved_members.push(resolved);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(resolved_members)
|
||||
}
|
||||
|
||||
async fn resolve_team_member_state(
|
||||
member: &TeamMember,
|
||||
team_state: &TeamSessionState,
|
||||
runtime_queue_service: Option<&SessionRuntimeQueueService>,
|
||||
) -> Result<Option<ResolvedTeamMemberState>, ToolError> {
|
||||
let session = match SessionManager::get_session(&member.agent_id, false).await {
|
||||
Ok(session) => session,
|
||||
Err(_) => return Ok(None),
|
||||
};
|
||||
|
||||
if member.is_lead {
|
||||
let Some(lead_state) = TeamSessionState::from_extension_data(&session.extension_data)
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
if session.id != team_state.lead_session_id
|
||||
|| member.agent_id != team_state.lead_session_id
|
||||
|| lead_state.team_name != team_state.team_name
|
||||
|| lead_state.lead_session_id != team_state.lead_session_id
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
return Ok(Some(ResolvedTeamMemberState {
|
||||
member: member.clone(),
|
||||
is_active: false,
|
||||
}));
|
||||
}
|
||||
|
||||
let Some(membership) = TeamMembershipState::from_extension_data(&session.extension_data) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if membership.team_name != team_state.team_name
|
||||
|| membership.lead_session_id != team_state.lead_session_id
|
||||
|| membership.agent_id != member.agent_id
|
||||
|| membership.name != member.name
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let is_active = match runtime_queue_service {
|
||||
Some(runtime_queue_service) => {
|
||||
runtime_queue_service.has_active_turn(&member.agent_id)
|
||||
|| !runtime_queue_service
|
||||
.list_queued_turns(&member.agent_id)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
ToolError::execution_failed(format!(
|
||||
"读取 team 成员 {} 的运行队列失败: {error}",
|
||||
member.name
|
||||
))
|
||||
})?
|
||||
.is_empty()
|
||||
}
|
||||
None => true,
|
||||
};
|
||||
|
||||
Ok(Some(ResolvedTeamMemberState {
|
||||
member: member.clone(),
|
||||
is_active,
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::session::{save_team_membership, SessionType, TeamMember, TEAM_LEAD_NAME};
|
||||
use crate::session::{
|
||||
initialize_shared_thread_runtime_store, require_shared_session_runtime_queue_service,
|
||||
save_team_membership, InMemoryThreadRuntimeStore, QueuedTurnRuntime, SessionType,
|
||||
TeamMember, TEAM_LEAD_NAME,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tempfile::tempdir;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn queued_turn(session_id: &str, queued_turn_id: &str) -> QueuedTurnRuntime {
|
||||
QueuedTurnRuntime {
|
||||
queued_turn_id: queued_turn_id.to_string(),
|
||||
session_id: session_id.to_string(),
|
||||
message_preview: format!("preview-{queued_turn_id}"),
|
||||
message_text: format!("message-{queued_turn_id}"),
|
||||
created_at: 1,
|
||||
image_count: 0,
|
||||
payload: json!({ "queuedTurnId": queued_turn_id }),
|
||||
metadata: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn team_create_persists_team_state() -> anyhow::Result<()> {
|
||||
let temp_dir = tempdir()?;
|
||||
let team_name = format!("alpha-{}", Uuid::new_v4().simple());
|
||||
let session = SessionManager::create_session(
|
||||
temp_dir.path().to_path_buf(),
|
||||
format!("team-create-{}", Uuid::new_v4()),
|
||||
@@ -392,7 +631,7 @@ mod tests {
|
||||
let result = TeamCreateTool::new()
|
||||
.execute(
|
||||
json!({
|
||||
"team_name": "alpha",
|
||||
"team_name": team_name,
|
||||
"description": "协作测试",
|
||||
"agent_type": "leader"
|
||||
}),
|
||||
@@ -401,23 +640,117 @@ mod tests {
|
||||
.await?;
|
||||
|
||||
assert!(result.success);
|
||||
let output: Value = serde_json::from_str(result.output.as_deref().unwrap())?;
|
||||
assert_eq!(
|
||||
output["lead_agent_id"],
|
||||
json!(format!("team-lead@{team_name}"))
|
||||
);
|
||||
let team_file_path = output["team_file_path"]
|
||||
.as_str()
|
||||
.expect("team_file_path should be a string")
|
||||
.replace('\\', "/");
|
||||
assert!(team_file_path.ends_with(&format!(
|
||||
"/teams/{}/config.json",
|
||||
sanitize_team_name(&team_name)
|
||||
)));
|
||||
let updated = SessionManager::get_session(&session.id, false).await?;
|
||||
let team_state = TeamSessionState::from_extension_data(&updated.extension_data)
|
||||
.expect("team state should exist");
|
||||
assert_eq!(team_state.team_name, "alpha");
|
||||
assert_eq!(team_state.team_name, team_name);
|
||||
assert_eq!(team_state.members[0].name, TEAM_LEAD_NAME);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn team_create_generates_slug_when_team_name_conflicts() -> anyhow::Result<()> {
|
||||
let temp_dir = tempdir()?;
|
||||
let team_name = format!("alpha-{}", Uuid::new_v4().simple());
|
||||
let first = SessionManager::create_session(
|
||||
temp_dir.path().to_path_buf(),
|
||||
format!("team-create-collision-first-{}", Uuid::new_v4()),
|
||||
SessionType::Hidden,
|
||||
)
|
||||
.await?;
|
||||
let second = SessionManager::create_session(
|
||||
temp_dir.path().to_path_buf(),
|
||||
format!("team-create-collision-second-{}", Uuid::new_v4()),
|
||||
SessionType::Hidden,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let first_context =
|
||||
ToolContext::new(temp_dir.path().to_path_buf()).with_session_id(&first.id);
|
||||
let second_context =
|
||||
ToolContext::new(temp_dir.path().to_path_buf()).with_session_id(&second.id);
|
||||
|
||||
TeamCreateTool::new()
|
||||
.execute(json!({ "team_name": team_name }), &first_context)
|
||||
.await?;
|
||||
|
||||
let result = TeamCreateTool::new()
|
||||
.execute(json!({ "team_name": team_name }), &second_context)
|
||||
.await?;
|
||||
|
||||
let output: Value = serde_json::from_str(result.output.as_deref().unwrap())?;
|
||||
let generated_name = output["team_name"]
|
||||
.as_str()
|
||||
.expect("team_name should be a string");
|
||||
assert_ne!(generated_name, team_name);
|
||||
assert!(!generated_name.starts_with(&format!("{team_name}-")));
|
||||
assert_eq!(
|
||||
output["lead_agent_id"],
|
||||
json!(format!("team-lead@{generated_name}"))
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn team_create_defaults_lead_agent_type_to_team_lead() -> anyhow::Result<()> {
|
||||
let temp_dir = tempdir()?;
|
||||
let team_name = format!("alpha-default-{}", Uuid::new_v4().simple());
|
||||
let session = SessionManager::create_session(
|
||||
temp_dir.path().to_path_buf(),
|
||||
format!("team-create-default-type-{}", Uuid::new_v4()),
|
||||
SessionType::Hidden,
|
||||
)
|
||||
.await?;
|
||||
let context = ToolContext::new(temp_dir.path().to_path_buf()).with_session_id(&session.id);
|
||||
|
||||
TeamCreateTool::new()
|
||||
.execute(
|
||||
json!({
|
||||
"team_name": team_name
|
||||
}),
|
||||
&context,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let updated = SessionManager::get_session(&session.id, false).await?;
|
||||
let team_state = TeamSessionState::from_extension_data(&updated.extension_data)
|
||||
.expect("team state should exist");
|
||||
assert_eq!(
|
||||
team_state.members[0].agent_type.as_deref(),
|
||||
Some(TEAM_LEAD_NAME)
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn team_delete_refuses_when_members_remain() -> anyhow::Result<()> {
|
||||
let temp_dir = tempdir()?;
|
||||
initialize_shared_thread_runtime_store(Arc::new(InMemoryThreadRuntimeStore::default()));
|
||||
let lead = SessionManager::create_session(
|
||||
temp_dir.path().to_path_buf(),
|
||||
format!("team-delete-{}", Uuid::new_v4()),
|
||||
SessionType::Hidden,
|
||||
)
|
||||
.await?;
|
||||
let child = SessionManager::create_session(
|
||||
temp_dir.path().to_path_buf(),
|
||||
format!("team-delete-child-{}", Uuid::new_v4()),
|
||||
SessionType::SubAgent,
|
||||
)
|
||||
.await?;
|
||||
|
||||
save_team_state(
|
||||
&lead.id,
|
||||
@@ -427,17 +760,101 @@ mod tests {
|
||||
lead_session_id: lead.id.clone(),
|
||||
members: vec![
|
||||
TeamMember::lead(lead.id.clone(), Some("leader".to_string())),
|
||||
TeamMember::teammate("child-1", "researcher", Some("explorer".to_string())),
|
||||
TeamMember::teammate(
|
||||
child.id.clone(),
|
||||
"researcher",
|
||||
Some("explorer".to_string()),
|
||||
),
|
||||
],
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
save_team_membership(
|
||||
&child.id,
|
||||
Some(TeamMembershipState {
|
||||
team_name: "alpha".to_string(),
|
||||
lead_session_id: lead.id.clone(),
|
||||
agent_id: child.id.clone(),
|
||||
name: "researcher".to_string(),
|
||||
agent_type: Some("explorer".to_string()),
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
require_shared_session_runtime_queue_service()?
|
||||
.submit_turn(
|
||||
queued_turn(&child.id, &format!("queued-{}", Uuid::new_v4())),
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let context = ToolContext::new(temp_dir.path().to_path_buf()).with_session_id(&lead.id);
|
||||
let result = TeamDeleteTool::new().execute(json!({}), &context).await?;
|
||||
|
||||
assert!(result.success);
|
||||
assert_eq!(result.metadata["success"], json!(false));
|
||||
assert_eq!(result.metadata["activeMembers"], json!(["researcher"]));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn team_delete_succeeds_when_members_are_idle_and_clears_membership() -> anyhow::Result<()>
|
||||
{
|
||||
let temp_dir = tempdir()?;
|
||||
initialize_shared_thread_runtime_store(Arc::new(InMemoryThreadRuntimeStore::default()));
|
||||
let lead = SessionManager::create_session(
|
||||
temp_dir.path().to_path_buf(),
|
||||
format!("team-delete-idle-{}", Uuid::new_v4()),
|
||||
SessionType::Hidden,
|
||||
)
|
||||
.await?;
|
||||
let child = SessionManager::create_session(
|
||||
temp_dir.path().to_path_buf(),
|
||||
format!("team-delete-idle-child-{}", Uuid::new_v4()),
|
||||
SessionType::SubAgent,
|
||||
)
|
||||
.await?;
|
||||
|
||||
save_team_state(
|
||||
&lead.id,
|
||||
Some(TeamSessionState {
|
||||
team_name: "alpha".to_string(),
|
||||
description: None,
|
||||
lead_session_id: lead.id.clone(),
|
||||
members: vec![
|
||||
TeamMember::lead(lead.id.clone(), Some("leader".to_string())),
|
||||
TeamMember::teammate(
|
||||
child.id.clone(),
|
||||
"researcher",
|
||||
Some("explorer".to_string()),
|
||||
),
|
||||
],
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
save_team_membership(
|
||||
&child.id,
|
||||
Some(TeamMembershipState {
|
||||
team_name: "alpha".to_string(),
|
||||
lead_session_id: lead.id.clone(),
|
||||
agent_id: child.id.clone(),
|
||||
name: "researcher".to_string(),
|
||||
agent_type: Some("explorer".to_string()),
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let context = ToolContext::new(temp_dir.path().to_path_buf()).with_session_id(&lead.id);
|
||||
let result = TeamDeleteTool::new().execute(json!({}), &context).await?;
|
||||
|
||||
assert!(result.success);
|
||||
assert_eq!(result.metadata["success"], json!(false));
|
||||
assert_eq!(result.metadata["success"], json!(true));
|
||||
|
||||
let updated_lead = SessionManager::get_session(&lead.id, false).await?;
|
||||
assert!(TeamSessionState::from_extension_data(&updated_lead.extension_data).is_none());
|
||||
|
||||
let updated_child = SessionManager::get_session(&child.id, false).await?;
|
||||
assert!(TeamMembershipState::from_extension_data(&updated_child.extension_data).is_none());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -495,6 +912,52 @@ mod tests {
|
||||
.expect("peers metadata should be an array");
|
||||
assert_eq!(peers.len(), 1);
|
||||
assert_eq!(peers[0]["name"], json!(TEAM_LEAD_NAME));
|
||||
assert_eq!(peers[0]["agentId"], json!("team-lead@alpha"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_peers_skips_stale_members_without_membership() -> anyhow::Result<()> {
|
||||
let temp_dir = tempdir()?;
|
||||
let lead = SessionManager::create_session(
|
||||
temp_dir.path().to_path_buf(),
|
||||
format!("team-list-stale-lead-{}", Uuid::new_v4()),
|
||||
SessionType::Hidden,
|
||||
)
|
||||
.await?;
|
||||
let child = SessionManager::create_session(
|
||||
temp_dir.path().to_path_buf(),
|
||||
format!("team-list-stale-child-{}", Uuid::new_v4()),
|
||||
SessionType::SubAgent,
|
||||
)
|
||||
.await?;
|
||||
|
||||
save_team_state(
|
||||
&lead.id,
|
||||
Some(TeamSessionState {
|
||||
team_name: "alpha".to_string(),
|
||||
description: None,
|
||||
lead_session_id: lead.id.clone(),
|
||||
members: vec![
|
||||
TeamMember::lead(lead.id.clone(), Some("leader".to_string())),
|
||||
TeamMember::teammate(
|
||||
child.id.clone(),
|
||||
"researcher",
|
||||
Some("explorer".to_string()),
|
||||
),
|
||||
],
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let context = ToolContext::new(temp_dir.path().to_path_buf()).with_session_id(&lead.id);
|
||||
let result = ListPeersTool::new().execute(json!({}), &context).await?;
|
||||
|
||||
assert!(result.success);
|
||||
let peers = result.metadata["peers"]
|
||||
.as_array()
|
||||
.expect("peers metadata should be an array");
|
||||
assert!(peers.is_empty());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ use super::registry::ToolRegistry;
|
||||
use async_trait::async_trait;
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::{Arc, Weak};
|
||||
|
||||
@@ -38,6 +38,7 @@ const BUILTIN_VISIBLE_NATIVE_TOOLS: &[(&str, &str)] = &[
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct ToolSearchInput {
|
||||
query: String,
|
||||
#[serde(default = "default_max_results", alias = "maxResults")]
|
||||
@@ -49,6 +50,8 @@ struct ToolSearchOutput {
|
||||
matches: Vec<String>,
|
||||
query: String,
|
||||
total_deferred_tools: usize,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pending_mcp_servers: Option<Vec<String>>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
notes: Vec<String>,
|
||||
}
|
||||
@@ -269,6 +272,30 @@ fn native_tool_search_aliases(name: &str) -> &'static [&'static str] {
|
||||
"run command",
|
||||
"command execution",
|
||||
],
|
||||
"taskcreate" | "taskcreatetool" => &["create task", "new task", "task board", "task list"],
|
||||
"taskget" | "taskgettool" => &["get task", "task details", "read task"],
|
||||
"tasklist" | "tasklisttool" => &["list tasks", "task list", "todo list"],
|
||||
"taskupdate" | "taskupdatetool" => {
|
||||
&["update task", "complete task", "mark task", "task status"]
|
||||
}
|
||||
"taskoutput" | "taskoutputtool" => &[
|
||||
"agent output",
|
||||
"bash output",
|
||||
"task output",
|
||||
"task logs",
|
||||
"read task output",
|
||||
],
|
||||
"taskstop" | "taskstoptool" => {
|
||||
&["kill shell", "stop task", "cancel task", "terminate task"]
|
||||
}
|
||||
"teamcreate" | "teamcreatetool" => &["create team", "create swarm", "swarm team"],
|
||||
"teamdelete" | "teamdeletetool" => &["delete team", "cleanup team", "disband swarm"],
|
||||
"listpeers" | "listpeerstool" => &[
|
||||
"list peers",
|
||||
"peer discovery",
|
||||
"swarm peers",
|
||||
"message peers",
|
||||
],
|
||||
"webfetch" | "webfetchtool" => &["fetch url", "fetch page", "read url", "web reader"],
|
||||
"websearch" | "websearchtool" => &["search web", "internet search", "web search"],
|
||||
"structuredoutput" | "syntheticoutputtool" => &[
|
||||
@@ -377,7 +404,15 @@ fn tool_search_exact_match(name: &str, query: &str) -> bool {
|
||||
}
|
||||
|
||||
let query_key = tool_search_lookup_key(&query_lower);
|
||||
if !query_key.is_empty() && tool_search_lookup_key(name) == query_key {
|
||||
let name_key = tool_search_lookup_key(name);
|
||||
if !query_key.is_empty() && name_key == query_key {
|
||||
return true;
|
||||
}
|
||||
|
||||
if query_key
|
||||
.strip_suffix("tool")
|
||||
.is_some_and(|stripped| !stripped.is_empty() && stripped == name_key)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -424,10 +459,18 @@ fn select_match_rank(name: &str, query: &str) -> Option<i32> {
|
||||
}
|
||||
|
||||
let query_key = tool_search_lookup_key(&query_lower);
|
||||
if !query_key.is_empty() && tool_search_lookup_key(name) == query_key {
|
||||
let name_key = tool_search_lookup_key(name);
|
||||
if !query_key.is_empty() && name_key == query_key {
|
||||
return Some(450);
|
||||
}
|
||||
|
||||
if query_key
|
||||
.strip_suffix("tool")
|
||||
.is_some_and(|stripped| !stripped.is_empty() && stripped == name_key)
|
||||
{
|
||||
return Some(430);
|
||||
}
|
||||
|
||||
let parsed = parse_tool_name(name);
|
||||
if let Some(inner_name) = parsed.inner_name.as_deref() {
|
||||
if inner_name == query_lower {
|
||||
@@ -650,10 +693,27 @@ fn build_tool_search_result(
|
||||
.with_metadata("matches", json!(&output.matches))
|
||||
.with_metadata("query", json!(&output.query))
|
||||
.with_metadata("total_deferred_tools", json!(output.total_deferred_tools))
|
||||
.with_metadata("pending_mcp_servers", json!(&output.pending_mcp_servers))
|
||||
.with_metadata("notes", json!(&output.notes))
|
||||
.with_metadata(TOOL_SURFACE_UPDATED_KEY, json!(tool_surface_updated)))
|
||||
}
|
||||
|
||||
async fn pending_mcp_servers_for_empty_result(
|
||||
extension_manager: &ExtensionManager,
|
||||
matches: &[String],
|
||||
) -> Option<Vec<String>> {
|
||||
if !matches.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let pending = extension_manager.list_pending_extensions().await;
|
||||
if pending.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(pending)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for ToolSearchTool {
|
||||
fn name(&self) -> &str {
|
||||
@@ -677,13 +737,14 @@ impl Tool for ToolSearchTool {
|
||||
"description": "Maximum number of results to return for keyword search (default: 5)"
|
||||
}
|
||||
},
|
||||
"required": ["query"]
|
||||
"required": ["query"],
|
||||
"additionalProperties": false
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
params: serde_json::Value,
|
||||
params: Value,
|
||||
_context: &ToolContext,
|
||||
) -> Result<ToolResult, ToolError> {
|
||||
let input: ToolSearchInput = serde_json::from_value(params)
|
||||
@@ -732,8 +793,11 @@ impl Tool for ToolSearchTool {
|
||||
total_deferred_tools = state_after.deferred_tools.len();
|
||||
}
|
||||
|
||||
let pending_mcp_servers =
|
||||
pending_mcp_servers_for_empty_result(extension_manager.as_ref(), &matches).await;
|
||||
return build_tool_search_result(
|
||||
&ToolSearchOutput {
|
||||
pending_mcp_servers,
|
||||
notes: build_tool_search_notes(query, &matches),
|
||||
matches,
|
||||
query: query.to_string(),
|
||||
@@ -748,9 +812,12 @@ impl Tool for ToolSearchTool {
|
||||
.into_iter()
|
||||
.take(max_results)
|
||||
.collect::<Vec<_>>();
|
||||
let pending_mcp_servers =
|
||||
pending_mcp_servers_for_empty_result(extension_manager.as_ref(), &matches).await;
|
||||
|
||||
build_tool_search_result(
|
||||
&ToolSearchOutput {
|
||||
pending_mcp_servers,
|
||||
notes: build_tool_search_notes(query, &matches),
|
||||
matches,
|
||||
query: query.to_string(),
|
||||
@@ -845,6 +912,26 @@ mod tests {
|
||||
assert_eq!(matches, vec!["Bash".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_selected_tools_matches_reference_alias_for_list_peers() {
|
||||
let deferred = Vec::new();
|
||||
let all = vec![searchable("ListPeers", "list available peers")];
|
||||
|
||||
let matches = resolve_selected_tools(&["ListPeersTool".to_string()], &deferred, &all);
|
||||
|
||||
assert_eq!(matches, vec!["ListPeers".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_selected_tools_matches_reference_alias_for_task_stop() {
|
||||
let deferred = Vec::new();
|
||||
let all = vec![searchable("TaskStop", "stop a background task")];
|
||||
|
||||
let matches = resolve_selected_tools(&["kill shell".to_string()], &deferred, &all);
|
||||
|
||||
assert_eq!(matches, vec!["TaskStop".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_selected_tools_normalizes_server_prefix_variants() {
|
||||
let deferred = vec![searchable(
|
||||
@@ -991,6 +1078,7 @@ mod tests {
|
||||
matches: vec!["alpha__tool".to_string()],
|
||||
query: "select:alpha__tool".to_string(),
|
||||
total_deferred_tools: 3,
|
||||
pending_mcp_servers: None,
|
||||
notes: Vec::new(),
|
||||
};
|
||||
|
||||
@@ -1002,6 +1090,39 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_tool_search_result_preserves_pending_mcp_servers() {
|
||||
let output = ToolSearchOutput {
|
||||
matches: Vec::new(),
|
||||
query: "browser".to_string(),
|
||||
total_deferred_tools: 2,
|
||||
pending_mcp_servers: Some(vec!["playwright".to_string(), "slack".to_string()]),
|
||||
notes: vec!["未命中任何 deferred 工具".to_string()],
|
||||
};
|
||||
|
||||
let result = build_tool_search_result(&output, false).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
result.metadata.get("pending_mcp_servers"),
|
||||
Some(&json!(["playwright", "slack"]))
|
||||
);
|
||||
assert!(result
|
||||
.output
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.contains("\"pending_mcp_servers\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_search_input_schema_is_strict_object() {
|
||||
let tool = ToolSearchTool::new(Weak::new());
|
||||
let schema = tool.input_schema();
|
||||
|
||||
assert_eq!(schema["type"], "object");
|
||||
assert_eq!(schema["additionalProperties"], json!(false));
|
||||
assert_eq!(schema["required"], json!(["query"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_tool_search_notes_warns_against_retry_loops() {
|
||||
let notes = build_tool_search_notes("select:unknown_tool", &[]);
|
||||
@@ -1048,4 +1169,32 @@ mod tests {
|
||||
|
||||
assert_eq!(matches, vec!["StructuredOutput".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_score_query_match_can_resolve_task_output_alias() {
|
||||
let matches = score_query_match(
|
||||
"agent output",
|
||||
&[],
|
||||
&[searchable(
|
||||
"TaskOutput",
|
||||
"read output from a background task",
|
||||
)],
|
||||
);
|
||||
|
||||
assert_eq!(matches, vec!["TaskOutput".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_score_query_match_can_resolve_list_peers_alias() {
|
||||
let matches = score_query_match(
|
||||
"message peers",
|
||||
&[],
|
||||
&[searchable(
|
||||
"ListPeers",
|
||||
"list peers available for messaging",
|
||||
)],
|
||||
);
|
||||
|
||||
assert_eq!(matches, vec!["ListPeers".to_string()]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ use super::context::{ToolContext, ToolResult};
|
||||
use super::error::ToolError;
|
||||
use async_trait::async_trait;
|
||||
use lru::LruCache;
|
||||
use reqwest::Client;
|
||||
use reqwest::{redirect::Policy, Client};
|
||||
use scraper::{Html, Selector};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
@@ -30,6 +30,7 @@ const MAX_RESPONSE_SIZE: usize = 10 * 1024 * 1024;
|
||||
const DEFAULT_WEB_FETCH_MAX_CHARS: usize = 100_000;
|
||||
const DEFAULT_DYNAMIC_FILTER_MAX_CHARS: usize = 20_000;
|
||||
const DEFAULT_DYNAMIC_FILTER_MAX_CHUNKS: usize = 8;
|
||||
const MAX_WEB_FETCH_REDIRECTS: usize = 10;
|
||||
|
||||
/// WebFetch 缓存 TTL (15分钟)
|
||||
const WEB_FETCH_CACHE_TTL: Duration = Duration::from_secs(15 * 60);
|
||||
@@ -37,6 +38,97 @@ const WEB_FETCH_CACHE_TTL: Duration = Duration::from_secs(15 * 60);
|
||||
/// WebSearch 缓存 TTL (1小时)
|
||||
const WEB_SEARCH_CACHE_TTL: Duration = Duration::from_secs(60 * 60);
|
||||
|
||||
const WEB_FETCH_PREAPPROVED_HOSTS: &[&str] = &[
|
||||
"platform.claude.com",
|
||||
"code.claude.com",
|
||||
"modelcontextprotocol.io",
|
||||
"github.com/anthropics",
|
||||
"agentskills.io",
|
||||
"docs.python.org",
|
||||
"en.cppreference.com",
|
||||
"docs.oracle.com",
|
||||
"learn.microsoft.com",
|
||||
"developer.mozilla.org",
|
||||
"go.dev",
|
||||
"pkg.go.dev",
|
||||
"www.php.net",
|
||||
"docs.swift.org",
|
||||
"kotlinlang.org",
|
||||
"ruby-doc.org",
|
||||
"doc.rust-lang.org",
|
||||
"www.typescriptlang.org",
|
||||
"react.dev",
|
||||
"angular.io",
|
||||
"vuejs.org",
|
||||
"nextjs.org",
|
||||
"expressjs.com",
|
||||
"nodejs.org",
|
||||
"bun.sh",
|
||||
"jquery.com",
|
||||
"getbootstrap.com",
|
||||
"tailwindcss.com",
|
||||
"d3js.org",
|
||||
"threejs.org",
|
||||
"redux.js.org",
|
||||
"webpack.js.org",
|
||||
"jestjs.io",
|
||||
"reactrouter.com",
|
||||
"docs.djangoproject.com",
|
||||
"flask.palletsprojects.com",
|
||||
"fastapi.tiangolo.com",
|
||||
"pandas.pydata.org",
|
||||
"numpy.org",
|
||||
"www.tensorflow.org",
|
||||
"pytorch.org",
|
||||
"scikit-learn.org",
|
||||
"matplotlib.org",
|
||||
"requests.readthedocs.io",
|
||||
"jupyter.org",
|
||||
"laravel.com",
|
||||
"symfony.com",
|
||||
"wordpress.org",
|
||||
"docs.spring.io",
|
||||
"hibernate.org",
|
||||
"tomcat.apache.org",
|
||||
"gradle.org",
|
||||
"maven.apache.org",
|
||||
"asp.net",
|
||||
"dotnet.microsoft.com",
|
||||
"nuget.org",
|
||||
"blazor.net",
|
||||
"reactnative.dev",
|
||||
"docs.flutter.dev",
|
||||
"developer.apple.com",
|
||||
"developer.android.com",
|
||||
"keras.io",
|
||||
"spark.apache.org",
|
||||
"huggingface.co",
|
||||
"www.kaggle.com",
|
||||
"www.mongodb.com",
|
||||
"redis.io",
|
||||
"www.postgresql.org",
|
||||
"dev.mysql.com",
|
||||
"www.sqlite.org",
|
||||
"graphql.org",
|
||||
"prisma.io",
|
||||
"docs.aws.amazon.com",
|
||||
"cloud.google.com",
|
||||
"kubernetes.io",
|
||||
"www.docker.com",
|
||||
"www.terraform.io",
|
||||
"www.ansible.com",
|
||||
"vercel.com/docs",
|
||||
"docs.netlify.com",
|
||||
"devcenter.heroku.com",
|
||||
"cypress.io",
|
||||
"selenium.dev",
|
||||
"docs.unity.com",
|
||||
"docs.unrealengine.com",
|
||||
"git-scm.com",
|
||||
"nginx.org",
|
||||
"httpd.apache.org",
|
||||
];
|
||||
|
||||
/// 缓存内容结构
|
||||
#[derive(Debug, Clone)]
|
||||
struct CachedContent {
|
||||
@@ -542,6 +634,96 @@ pub struct WebSearchInput {
|
||||
pub blocked_domains: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct WebFetchOutput {
|
||||
bytes: usize,
|
||||
code: u16,
|
||||
code_text: String,
|
||||
result: String,
|
||||
duration_ms: u64,
|
||||
url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct WebSearchHit {
|
||||
title: String,
|
||||
url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct WebSearchResultBlock {
|
||||
tool_use_id: String,
|
||||
content: Vec<WebSearchHit>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum WebSearchOutputEntry {
|
||||
Result(WebSearchResultBlock),
|
||||
Text(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct WebSearchOutput {
|
||||
query: String,
|
||||
results: Vec<WebSearchOutputEntry>,
|
||||
duration_seconds: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum WebFetchResponse {
|
||||
Content {
|
||||
content: String,
|
||||
content_type: String,
|
||||
status_code: u16,
|
||||
},
|
||||
Redirect {
|
||||
original_url: String,
|
||||
redirect_url: String,
|
||||
status_code: u16,
|
||||
},
|
||||
}
|
||||
|
||||
fn is_preapproved_web_fetch_host(hostname: &str, pathname: &str) -> bool {
|
||||
for entry in WEB_FETCH_PREAPPROVED_HOSTS {
|
||||
if let Some((host, path_prefix)) = entry.split_once('/') {
|
||||
if hostname == host
|
||||
&& (pathname == format!("/{path_prefix}")
|
||||
|| pathname.starts_with(&format!("/{path_prefix}/")))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
} else if hostname == *entry {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
fn strip_www_prefix(hostname: &str) -> &str {
|
||||
hostname.strip_prefix("www.").unwrap_or(hostname)
|
||||
}
|
||||
|
||||
fn is_permitted_web_fetch_redirect(original_url: &Url, redirect_url: &Url) -> bool {
|
||||
if redirect_url.scheme() != original_url.scheme() {
|
||||
return false;
|
||||
}
|
||||
|
||||
if redirect_url.port_or_known_default() != original_url.port_or_known_default() {
|
||||
return false;
|
||||
}
|
||||
|
||||
if !redirect_url.username().is_empty() || redirect_url.password().is_some() {
|
||||
return false;
|
||||
}
|
||||
|
||||
strip_www_prefix(redirect_url.host_str().unwrap_or_default())
|
||||
== strip_www_prefix(original_url.host_str().unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Web 工具的共享缓存
|
||||
pub struct WebCache {
|
||||
fetch_cache: Arc<Mutex<LruCache<String, CachedContent>>>,
|
||||
@@ -652,6 +834,7 @@ impl WebFetchTool {
|
||||
pub fn new() -> Self {
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.redirect(Policy::none())
|
||||
.user_agent("Mozilla/5.0 (compatible; AsterAgent/1.0)")
|
||||
.build()
|
||||
.unwrap_or_else(|_| Client::new());
|
||||
@@ -666,6 +849,7 @@ impl WebFetchTool {
|
||||
pub fn with_cache(cache: Arc<WebCache>) -> Self {
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.redirect(Policy::none())
|
||||
.user_agent("Mozilla/5.0 (compatible; AsterAgent/1.0)")
|
||||
.build()
|
||||
.unwrap_or_else(|_| Client::new());
|
||||
@@ -712,6 +896,29 @@ impl WebFetchTool {
|
||||
false
|
||||
}
|
||||
|
||||
fn http_status_text(status_code: u16) -> &'static str {
|
||||
match status_code {
|
||||
200 => "OK",
|
||||
201 => "Created",
|
||||
202 => "Accepted",
|
||||
204 => "No Content",
|
||||
301 => "Moved Permanently",
|
||||
302 => "Found",
|
||||
307 => "Temporary Redirect",
|
||||
308 => "Permanent Redirect",
|
||||
400 => "Bad Request",
|
||||
401 => "Unauthorized",
|
||||
403 => "Forbidden",
|
||||
404 => "Not Found",
|
||||
429 => "Too Many Requests",
|
||||
500 => "Internal Server Error",
|
||||
502 => "Bad Gateway",
|
||||
503 => "Service Unavailable",
|
||||
504 => "Gateway Timeout",
|
||||
_ => "Unknown",
|
||||
}
|
||||
}
|
||||
|
||||
/// HTML 转 Markdown
|
||||
fn html_to_markdown(&self, html: &str) -> String {
|
||||
let _document = Html::parse_document(html);
|
||||
@@ -879,69 +1086,104 @@ impl WebFetchTool {
|
||||
}
|
||||
|
||||
/// 实际的 URL 抓取逻辑
|
||||
async fn fetch_url(&self, url: &str) -> Result<(String, String, u16), String> {
|
||||
let parsed_url = Url::parse(url).map_err(|e| format!("无效的 URL: {}", e))?;
|
||||
async fn fetch_url(&self, url: &str) -> Result<WebFetchResponse, String> {
|
||||
let mut current_url = url.to_string();
|
||||
|
||||
// 域名安全检查
|
||||
self.check_domain_safety(&parsed_url)?;
|
||||
for _ in 0..=MAX_WEB_FETCH_REDIRECTS {
|
||||
let parsed_url = Url::parse(¤t_url).map_err(|e| format!("无效的 URL: {}", e))?;
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.get(url)
|
||||
.header("User-Agent", "Mozilla/5.0 (compatible; AsterAgent/1.0)")
|
||||
.header(
|
||||
"Accept",
|
||||
"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("请求失败: {}", e))?;
|
||||
// 域名安全检查
|
||||
self.check_domain_safety(&parsed_url)?;
|
||||
|
||||
let status_code = response.status().as_u16();
|
||||
let content_type = response
|
||||
.headers()
|
||||
.get("content-type")
|
||||
.and_then(|ct| ct.to_str().ok())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let response = self
|
||||
.client
|
||||
.get(current_url.clone())
|
||||
.header("User-Agent", "Mozilla/5.0 (compatible; AsterAgent/1.0)")
|
||||
.header(
|
||||
"Accept",
|
||||
"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("请求失败: {}", e))?;
|
||||
|
||||
// 检查响应体大小
|
||||
if let Some(content_length) = response.content_length() {
|
||||
if content_length > MAX_RESPONSE_SIZE as u64 {
|
||||
let status_code = response.status().as_u16();
|
||||
|
||||
if matches!(status_code, 301 | 302 | 307 | 308) {
|
||||
let location = response
|
||||
.headers()
|
||||
.get(reqwest::header::LOCATION)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.ok_or_else(|| "重定向响应缺少 Location 头".to_string())?;
|
||||
let redirect_url = parsed_url
|
||||
.join(location)
|
||||
.map_err(|e| format!("解析重定向 URL 失败: {}", e))?;
|
||||
|
||||
if is_permitted_web_fetch_redirect(&parsed_url, &redirect_url) {
|
||||
current_url = redirect_url.to_string();
|
||||
continue;
|
||||
}
|
||||
|
||||
return Ok(WebFetchResponse::Redirect {
|
||||
original_url: current_url,
|
||||
redirect_url: redirect_url.to_string(),
|
||||
status_code,
|
||||
});
|
||||
}
|
||||
|
||||
let content_type = response
|
||||
.headers()
|
||||
.get("content-type")
|
||||
.and_then(|ct| ct.to_str().ok())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
// 检查响应体大小
|
||||
if let Some(content_length) = response.content_length() {
|
||||
if content_length > MAX_RESPONSE_SIZE as u64 {
|
||||
return Err(format!(
|
||||
"响应体大小 ({} 字节) 超过最大限制 ({} 字节)",
|
||||
content_length, MAX_RESPONSE_SIZE
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let body = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| format!("读取响应体失败: {}", e))?;
|
||||
|
||||
// 检查处理后内容的大小
|
||||
if body.len() > MAX_RESPONSE_SIZE {
|
||||
return Err(format!(
|
||||
"响应体大小 ({} 字节) 超过最大限制 ({} 字节)",
|
||||
content_length, MAX_RESPONSE_SIZE
|
||||
"内容大小 ({} 字节) 超过最大限制 ({} 字节)",
|
||||
body.len(),
|
||||
MAX_RESPONSE_SIZE
|
||||
));
|
||||
}
|
||||
|
||||
let processed_content = if content_type.contains("text/html") {
|
||||
self.html_to_markdown(&body)
|
||||
} else if content_type.contains("application/json") {
|
||||
match serde_json::from_str::<serde_json::Value>(&body) {
|
||||
Ok(json) => serde_json::to_string_pretty(&json).unwrap_or(body),
|
||||
Err(_) => body,
|
||||
}
|
||||
} else {
|
||||
body
|
||||
};
|
||||
|
||||
return Ok(WebFetchResponse::Content {
|
||||
content: processed_content,
|
||||
content_type,
|
||||
status_code,
|
||||
});
|
||||
}
|
||||
|
||||
let body = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| format!("读取响应体失败: {}", e))?;
|
||||
|
||||
// 检查处理后内容的大小
|
||||
if body.len() > MAX_RESPONSE_SIZE {
|
||||
return Err(format!(
|
||||
"内容大小 ({} 字节) 超过最大限制 ({} 字节)",
|
||||
body.len(),
|
||||
MAX_RESPONSE_SIZE
|
||||
));
|
||||
}
|
||||
|
||||
let processed_content = if content_type.contains("text/html") {
|
||||
self.html_to_markdown(&body)
|
||||
} else if content_type.contains("application/json") {
|
||||
// 格式化 JSON
|
||||
match serde_json::from_str::<serde_json::Value>(&body) {
|
||||
Ok(json) => serde_json::to_string_pretty(&json).unwrap_or(body),
|
||||
Err(_) => body,
|
||||
}
|
||||
} else {
|
||||
body
|
||||
};
|
||||
|
||||
Ok((processed_content, content_type, status_code))
|
||||
Err(format!(
|
||||
"重定向次数过多(超过 {} 次)",
|
||||
MAX_WEB_FETCH_REDIRECTS
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -996,10 +1238,27 @@ impl Tool for WebFetchTool {
|
||||
|
||||
async fn check_permissions(
|
||||
&self,
|
||||
_params: &serde_json::Value,
|
||||
params: &serde_json::Value,
|
||||
_context: &ToolContext,
|
||||
) -> PermissionCheckResult {
|
||||
PermissionCheckResult::allow()
|
||||
let parsed_url = serde_json::from_value::<WebFetchInput>(params.clone())
|
||||
.ok()
|
||||
.and_then(|input| Url::parse(&input.url).ok());
|
||||
|
||||
if let Some(url) = parsed_url.as_ref() {
|
||||
if let Some(hostname) = url.host_str() {
|
||||
if is_preapproved_web_fetch_host(hostname, url.path()) {
|
||||
return PermissionCheckResult::allow();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match parsed_url.and_then(|url| url.host_str().map(|host| host.to_string())) {
|
||||
Some(hostname) => PermissionCheckResult::ask(format!(
|
||||
"WebFetch 将访问远程站点 {hostname},请确认后继续。"
|
||||
)),
|
||||
None => PermissionCheckResult::ask("WebFetch 将访问远程 URL,请确认后继续。"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
@@ -1007,15 +1266,15 @@ impl Tool for WebFetchTool {
|
||||
params: serde_json::Value,
|
||||
_context: &ToolContext,
|
||||
) -> Result<ToolResult, ToolError> {
|
||||
let started_at = std::time::Instant::now();
|
||||
let input: WebFetchInput = serde_json::from_value(params)
|
||||
.map_err(|e| ToolError::execution_failed(format!("输入参数解析失败: {}", e)))?;
|
||||
|
||||
let mut url = input.url.clone();
|
||||
let prompt = input.prompt.clone();
|
||||
|
||||
// URL 验证和规范化
|
||||
let parsed_url = Url::parse(&url)
|
||||
.map_err(|e| ToolError::execution_failed(format!("无效的 URL: {}", e)))?;
|
||||
.map_err(|e| ToolError::invalid_params(format!("无效的 URL: {}", e)))?;
|
||||
|
||||
// HTTP 到 HTTPS 自动升级
|
||||
if parsed_url.scheme() == "http" {
|
||||
@@ -1029,17 +1288,41 @@ impl Tool for WebFetchTool {
|
||||
// 检查缓存
|
||||
if let Some(cached) = self.cache.get_cached_content(&url) {
|
||||
let (content, filtered) = self.prepare_response_content(&cached.content, &input);
|
||||
let filtered_suffix = if filtered { " (动态过滤)" } else { "" };
|
||||
let mut result = content;
|
||||
if filtered {
|
||||
result = format!("{result}\n\n[dynamic_filter_applied]");
|
||||
}
|
||||
let output = WebFetchOutput {
|
||||
bytes: cached.content.len(),
|
||||
code: cached.status_code,
|
||||
code_text: Self::http_status_text(cached.status_code).to_string(),
|
||||
result,
|
||||
duration_ms: started_at
|
||||
.elapsed()
|
||||
.as_millis()
|
||||
.try_into()
|
||||
.unwrap_or(u64::MAX),
|
||||
url: url.clone(),
|
||||
};
|
||||
|
||||
return Ok(ToolResult::success(format!(
|
||||
"URL: {}\n提示词: {}\n\n--- 内容{} (缓存) ---\n{}",
|
||||
url, prompt, filtered_suffix, content
|
||||
)));
|
||||
return Ok(
|
||||
ToolResult::success(serde_json::to_string_pretty(&output).map_err(|error| {
|
||||
ToolError::execution_failed(format!("序列化 WebFetch 缓存结果失败: {error}"))
|
||||
})?)
|
||||
.with_metadata("url", serde_json::json!(url))
|
||||
.with_metadata("code", serde_json::json!(cached.status_code))
|
||||
.with_metadata("bytes", serde_json::json!(output.bytes))
|
||||
.with_metadata("durationMs", serde_json::json!(output.duration_ms)),
|
||||
);
|
||||
}
|
||||
|
||||
// 获取内容
|
||||
match self.fetch_url(&url).await {
|
||||
Ok((content, content_type, status_code)) => {
|
||||
Ok(WebFetchResponse::Content {
|
||||
content,
|
||||
content_type,
|
||||
status_code,
|
||||
}) => {
|
||||
if status_code >= 400 {
|
||||
return Err(ToolError::execution_failed(format!(
|
||||
"HTTP 错误: {} {}",
|
||||
@@ -1054,7 +1337,10 @@ impl Tool for WebFetchTool {
|
||||
}
|
||||
|
||||
let (display_content, filtered) = self.prepare_response_content(&content, &input);
|
||||
let filtered_suffix = if filtered { " (动态过滤)" } else { "" };
|
||||
let mut result = display_content;
|
||||
if filtered {
|
||||
result = format!("{result}\n\n[dynamic_filter_applied]");
|
||||
}
|
||||
|
||||
// 缓存结果
|
||||
self.cache.cache_content(
|
||||
@@ -1067,10 +1353,82 @@ impl Tool for WebFetchTool {
|
||||
},
|
||||
);
|
||||
|
||||
Ok(ToolResult::success(format!(
|
||||
"URL: {}\n提示词: {}\n\n--- 内容{} ---\n{}",
|
||||
url, prompt, filtered_suffix, display_content
|
||||
)))
|
||||
let output = WebFetchOutput {
|
||||
bytes: content.len(),
|
||||
code: status_code,
|
||||
code_text: Self::http_status_text(status_code).to_string(),
|
||||
result,
|
||||
duration_ms: started_at
|
||||
.elapsed()
|
||||
.as_millis()
|
||||
.try_into()
|
||||
.unwrap_or(u64::MAX),
|
||||
url: url.clone(),
|
||||
};
|
||||
|
||||
Ok(
|
||||
ToolResult::success(serde_json::to_string_pretty(&output).map_err(
|
||||
|error| {
|
||||
ToolError::execution_failed(format!(
|
||||
"序列化 WebFetch 结果失败: {error}"
|
||||
))
|
||||
},
|
||||
)?)
|
||||
.with_metadata("url", serde_json::json!(url))
|
||||
.with_metadata("code", serde_json::json!(status_code))
|
||||
.with_metadata("bytes", serde_json::json!(output.bytes))
|
||||
.with_metadata("durationMs", serde_json::json!(output.duration_ms)),
|
||||
)
|
||||
}
|
||||
Ok(WebFetchResponse::Redirect {
|
||||
original_url,
|
||||
redirect_url,
|
||||
status_code,
|
||||
}) => {
|
||||
let status_text = Self::http_status_text(status_code).to_string();
|
||||
let message = format!(
|
||||
"REDIRECT DETECTED: The URL redirects to a different host.\n\nOriginal URL: {}\nRedirect URL: {}\nStatus: {} {}\n\nTo complete your request, call WebFetch again with:\n- url: \"{}\"\n- prompt: \"{}\"",
|
||||
original_url,
|
||||
redirect_url,
|
||||
status_code,
|
||||
status_text,
|
||||
redirect_url,
|
||||
input.prompt
|
||||
);
|
||||
let output = WebFetchOutput {
|
||||
bytes: message.len(),
|
||||
code: status_code,
|
||||
code_text: status_text,
|
||||
result: message,
|
||||
duration_ms: started_at
|
||||
.elapsed()
|
||||
.as_millis()
|
||||
.try_into()
|
||||
.unwrap_or(u64::MAX),
|
||||
url: url.clone(),
|
||||
};
|
||||
|
||||
Ok(
|
||||
ToolResult::success(serde_json::to_string_pretty(&output).map_err(
|
||||
|error| {
|
||||
ToolError::execution_failed(format!(
|
||||
"序列化 WebFetch 重定向结果失败: {error}"
|
||||
))
|
||||
},
|
||||
)?)
|
||||
.with_metadata("url", serde_json::json!(url))
|
||||
.with_metadata("code", serde_json::json!(status_code))
|
||||
.with_metadata("bytes", serde_json::json!(output.bytes))
|
||||
.with_metadata("durationMs", serde_json::json!(output.duration_ms))
|
||||
.with_metadata(
|
||||
"redirect",
|
||||
serde_json::json!({
|
||||
"originalUrl": original_url,
|
||||
"redirectUrl": redirect_url,
|
||||
"statusCode": status_code,
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
Err(e) => Err(ToolError::execution_failed(format!("获取失败: {}", e))),
|
||||
}
|
||||
@@ -1822,7 +2180,7 @@ impl Tool for WebSearchTool {
|
||||
_params: &serde_json::Value,
|
||||
_context: &ToolContext,
|
||||
) -> PermissionCheckResult {
|
||||
PermissionCheckResult::allow()
|
||||
PermissionCheckResult::ask("WebSearch 将联网搜索最新信息,请确认后继续。")
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
@@ -1830,10 +2188,27 @@ impl Tool for WebSearchTool {
|
||||
params: serde_json::Value,
|
||||
_context: &ToolContext,
|
||||
) -> Result<ToolResult, ToolError> {
|
||||
let started_at = std::time::Instant::now();
|
||||
let input: WebSearchInput = serde_json::from_value(params)
|
||||
.map_err(|e| ToolError::execution_failed(format!("输入参数解析失败: {}", e)))?;
|
||||
|
||||
let query = &input.query;
|
||||
if query.trim().len() < 2 {
|
||||
return Err(ToolError::invalid_params("query 至少需要 2 个非空白字符"));
|
||||
}
|
||||
if input
|
||||
.allowed_domains
|
||||
.as_ref()
|
||||
.is_some_and(|domains| !domains.is_empty())
|
||||
&& input
|
||||
.blocked_domains
|
||||
.as_ref()
|
||||
.is_some_and(|domains| !domains.is_empty())
|
||||
{
|
||||
return Err(ToolError::invalid_params(
|
||||
"不能同时传 allowed_domains 和 blocked_domains",
|
||||
));
|
||||
}
|
||||
let (allowed_domains, blocked_domains) =
|
||||
self.sanitize_domain_filters(query, input.allowed_domains, input.blocked_domains);
|
||||
|
||||
@@ -1856,15 +2231,47 @@ impl Tool for WebSearchTool {
|
||||
cache_age
|
||||
);
|
||||
|
||||
return Ok(ToolResult::success(output).with_metadata(
|
||||
"web_search",
|
||||
serde_json::json!({
|
||||
"cache_hit": true,
|
||||
"cache_query": cached.query,
|
||||
"allowed_domains": cached.allowed_domains,
|
||||
"blocked_domains": cached.blocked_domains,
|
||||
}),
|
||||
));
|
||||
let structured = WebSearchOutput {
|
||||
query: query.clone(),
|
||||
results: vec![
|
||||
WebSearchOutputEntry::Result(WebSearchResultBlock {
|
||||
tool_use_id: "cached_web_search".to_string(),
|
||||
content: cached
|
||||
.results
|
||||
.iter()
|
||||
.map(|item| WebSearchHit {
|
||||
title: item.title.clone(),
|
||||
url: item.url.clone(),
|
||||
})
|
||||
.collect(),
|
||||
}),
|
||||
WebSearchOutputEntry::Text(output),
|
||||
],
|
||||
duration_seconds: started_at.elapsed().as_secs_f64(),
|
||||
};
|
||||
|
||||
return Ok(
|
||||
ToolResult::success(serde_json::to_string_pretty(&structured).map_err(
|
||||
|error| {
|
||||
ToolError::execution_failed(format!(
|
||||
"序列化 WebSearch 缓存结果失败: {error}"
|
||||
))
|
||||
},
|
||||
)?)
|
||||
.with_metadata(
|
||||
"durationSeconds",
|
||||
serde_json::json!(structured.duration_seconds),
|
||||
)
|
||||
.with_metadata(
|
||||
"web_search",
|
||||
serde_json::json!({
|
||||
"cache_hit": true,
|
||||
"cache_query": cached.query,
|
||||
"allowed_domains": cached.allowed_domains,
|
||||
"blocked_domains": cached.blocked_domains,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 执行搜索
|
||||
@@ -1908,9 +2315,38 @@ impl Tool for WebSearchTool {
|
||||
|
||||
// 如果有真实结果,格式化并返回
|
||||
if !filtered_results.is_empty() {
|
||||
let structured = WebSearchOutput {
|
||||
query: query.clone(),
|
||||
results: vec![
|
||||
WebSearchOutputEntry::Result(WebSearchResultBlock {
|
||||
tool_use_id: "web_search".to_string(),
|
||||
content: filtered_results
|
||||
.iter()
|
||||
.map(|item| WebSearchHit {
|
||||
title: item.title.clone(),
|
||||
url: item.url.clone(),
|
||||
})
|
||||
.collect(),
|
||||
}),
|
||||
WebSearchOutputEntry::Text(
|
||||
self.format_search_results(&filtered_results, query),
|
||||
),
|
||||
],
|
||||
duration_seconds: started_at.elapsed().as_secs_f64(),
|
||||
};
|
||||
Ok(
|
||||
ToolResult::success(self.format_search_results(&filtered_results, query))
|
||||
.with_metadata("web_search", web_search_metadata),
|
||||
ToolResult::success(serde_json::to_string_pretty(&structured).map_err(
|
||||
|error| {
|
||||
ToolError::execution_failed(format!(
|
||||
"序列化 WebSearch 结果失败: {error}"
|
||||
))
|
||||
},
|
||||
)?)
|
||||
.with_metadata(
|
||||
"durationSeconds",
|
||||
serde_json::json!(structured.duration_seconds),
|
||||
)
|
||||
.with_metadata("web_search", web_search_metadata),
|
||||
)
|
||||
} else if !raw_results.is_empty() {
|
||||
// 如果搜索返回了结果但被过滤器全部过滤掉了
|
||||
@@ -1923,11 +2359,28 @@ impl Tool for WebSearchTool {
|
||||
.map(|d: &Vec<String>| d.join(", "))
|
||||
.unwrap_or_else(|| "无".to_string());
|
||||
|
||||
Ok(ToolResult::success(format!(
|
||||
"网络搜索: \"{}\"\n\n应用域名过滤器后未找到结果。\n\n应用的过滤器:\n- 允许的域名: {}\n- 阻止的域名: {}\n\n尝试调整您的域名过滤器或搜索查询。",
|
||||
query, allowed_str, blocked_str
|
||||
))
|
||||
.with_metadata("web_search", web_search_metadata))
|
||||
let structured = WebSearchOutput {
|
||||
query: query.clone(),
|
||||
results: vec![WebSearchOutputEntry::Text(format!(
|
||||
"应用域名过滤器后未找到结果。允许的域名: {};阻止的域名: {}。",
|
||||
allowed_str, blocked_str
|
||||
))],
|
||||
duration_seconds: started_at.elapsed().as_secs_f64(),
|
||||
};
|
||||
Ok(
|
||||
ToolResult::success(serde_json::to_string_pretty(&structured).map_err(
|
||||
|error| {
|
||||
ToolError::execution_failed(format!(
|
||||
"序列化 WebSearch 过滤结果失败: {error}"
|
||||
))
|
||||
},
|
||||
)?)
|
||||
.with_metadata(
|
||||
"durationSeconds",
|
||||
serde_json::json!(structured.duration_seconds),
|
||||
)
|
||||
.with_metadata("web_search", web_search_metadata),
|
||||
)
|
||||
} else {
|
||||
// 如果搜索 API 没有返回结果
|
||||
let configured_chain = search_execution
|
||||
@@ -1936,11 +2389,28 @@ impl Tool for WebSearchTool {
|
||||
.map(|provider| provider.as_env_value())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" -> ");
|
||||
Ok(ToolResult::success(format!(
|
||||
"网络搜索: \"{}\"\n\n未找到结果。这可能是由于:\n1. 搜索查询过于具体或不常见\n2. 上游搜索引擎返回空结果\n3. 网络或 API 问题\n\n建议:\n- 尝试不同的搜索查询\n- 检查搜索提供商配置与 API Key\n- 如果需要提高覆盖率,可启用 tavily 或 multi_search_engine\n\n当前搜索提供商链路: {}",
|
||||
query, configured_chain
|
||||
))
|
||||
.with_metadata("web_search", web_search_metadata))
|
||||
let structured = WebSearchOutput {
|
||||
query: query.clone(),
|
||||
results: vec![WebSearchOutputEntry::Text(format!(
|
||||
"未找到结果。当前搜索提供商链路: {}",
|
||||
configured_chain
|
||||
))],
|
||||
duration_seconds: started_at.elapsed().as_secs_f64(),
|
||||
};
|
||||
Ok(
|
||||
ToolResult::success(serde_json::to_string_pretty(&structured).map_err(
|
||||
|error| {
|
||||
ToolError::execution_failed(format!(
|
||||
"序列化 WebSearch 空结果失败: {error}"
|
||||
))
|
||||
},
|
||||
)?)
|
||||
.with_metadata(
|
||||
"durationSeconds",
|
||||
serde_json::json!(structured.duration_seconds),
|
||||
)
|
||||
.with_metadata("web_search", web_search_metadata),
|
||||
)
|
||||
}
|
||||
}
|
||||
Err(e) => Err(ToolError::execution_failed(format!("搜索失败: {}", e))),
|
||||
@@ -1971,6 +2441,7 @@ pub fn clear_web_caches(cache: &WebCache) {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::tools::base::PermissionBehavior;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[tokio::test]
|
||||
@@ -2129,6 +2600,111 @@ mod tests {
|
||||
assert!(result.contains("Paragraph B with random text."));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_web_fetch_permissions_require_confirmation() {
|
||||
let tool = WebFetchTool::new();
|
||||
let result = tool
|
||||
.check_permissions(
|
||||
&serde_json::json!({
|
||||
"url": "https://example.com/docs",
|
||||
"prompt": "总结内容"
|
||||
}),
|
||||
&ToolContext::default(),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(result.behavior, PermissionBehavior::Ask);
|
||||
assert_eq!(
|
||||
result.message,
|
||||
Some("WebFetch 将访问远程站点 example.com,请确认后继续。".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_web_fetch_permissions_allow_preapproved_host() {
|
||||
let tool = WebFetchTool::new();
|
||||
let result = tool
|
||||
.check_permissions(
|
||||
&serde_json::json!({
|
||||
"url": "https://react.dev/reference/react/useEffect",
|
||||
"prompt": "总结内容"
|
||||
}),
|
||||
&ToolContext::default(),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(result.behavior, PermissionBehavior::Allow);
|
||||
assert!(result.message.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_web_fetch_preapproved_path_prefix_matches_exact_scope() {
|
||||
assert!(is_preapproved_web_fetch_host(
|
||||
"github.com",
|
||||
"/anthropics/claude-code"
|
||||
));
|
||||
assert!(!is_preapproved_web_fetch_host(
|
||||
"github.com",
|
||||
"/anthropics-evil/claude-code"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_web_fetch_permitted_redirect_allows_same_host_or_www_changes() {
|
||||
let original = Url::parse("https://example.com/docs").unwrap();
|
||||
let same_host = Url::parse("https://example.com/docs/getting-started").unwrap();
|
||||
let add_www = Url::parse("https://www.example.com/docs").unwrap();
|
||||
let remove_www = Url::parse("https://example.com/docs").unwrap();
|
||||
let original_www = Url::parse("https://www.example.com/docs").unwrap();
|
||||
|
||||
assert!(is_permitted_web_fetch_redirect(&original, &same_host));
|
||||
assert!(is_permitted_web_fetch_redirect(&original, &add_www));
|
||||
assert!(is_permitted_web_fetch_redirect(&original_www, &remove_www));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_web_fetch_permitted_redirect_rejects_cross_host() {
|
||||
let original = Url::parse("https://example.com/docs").unwrap();
|
||||
let redirect = Url::parse("https://evil.example.net/phish").unwrap();
|
||||
|
||||
assert!(!is_permitted_web_fetch_redirect(&original, &redirect));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_web_search_permissions_require_confirmation() {
|
||||
let tool = WebSearchTool::new();
|
||||
let result = tool
|
||||
.check_permissions(
|
||||
&serde_json::json!({
|
||||
"query": "latest ai news"
|
||||
}),
|
||||
&ToolContext::default(),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(result.behavior, PermissionBehavior::Ask);
|
||||
assert_eq!(
|
||||
result.message,
|
||||
Some("WebSearch 将联网搜索最新信息,请确认后继续。".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_web_search_rejects_short_query() {
|
||||
let tool = WebSearchTool::new();
|
||||
let error = tool
|
||||
.execute(
|
||||
serde_json::json!({
|
||||
"query": " "
|
||||
}),
|
||||
&ToolContext::default(),
|
||||
)
|
||||
.await
|
||||
.expect_err("short query should be rejected");
|
||||
|
||||
assert!(error.to_string().contains("query 至少需要 2 个非空白字符"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_search_runtime_config_priority_resolution() {
|
||||
let mut env = HashMap::new();
|
||||
|
||||
@@ -21,7 +21,7 @@ use crate::tools::error::ToolError;
|
||||
const WORKFLOW_TOOL_NAME: &str = "Workflow";
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||||
struct WorkflowInput {
|
||||
workflow: String,
|
||||
#[serde(default)]
|
||||
|
||||
@@ -20,6 +20,7 @@ const VALID_WORKTREE_SEGMENT_CHARS: &str =
|
||||
const MAX_WORKTREE_SLUG_LENGTH: usize = 64;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct EnterWorktreeInput {
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
@@ -42,6 +43,7 @@ enum ExitWorktreeAction {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct ExitWorktreeInput {
|
||||
action: ExitWorktreeAction,
|
||||
#[serde(default, alias = "discardChanges")]
|
||||
@@ -62,8 +64,6 @@ struct ExitWorktreeOutput {
|
||||
discarded_files: Option<usize>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
discarded_commits: Option<usize>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
noop: Option<bool>,
|
||||
message: String,
|
||||
}
|
||||
|
||||
@@ -188,7 +188,7 @@ impl Tool for EnterWorktreeTool {
|
||||
worktree_path: worktree_path.display().to_string(),
|
||||
worktree_branch: Some(worktree_branch.clone()),
|
||||
message: format!(
|
||||
"Created worktree at {} on branch {}. The session is now working in the worktree. Use ExitWorktree to leave mid-session.",
|
||||
"Created worktree at {} on branch {}. The session is now working in the worktree. Use ExitWorktree to leave mid-session, or exit the session to be prompted.",
|
||||
worktree_path.display(),
|
||||
worktree_branch
|
||||
),
|
||||
@@ -275,21 +275,9 @@ impl Tool for ExitWorktreeTool {
|
||||
.map_err(|error| ToolError::execution_failed(format!("读取 session 失败: {error}")))?;
|
||||
|
||||
let Some(state) = WorktreeSessionState::from_extension_data(&session.extension_data) else {
|
||||
let output = ExitWorktreeOutput {
|
||||
action: input.action,
|
||||
original_cwd: None,
|
||||
worktree_path: None,
|
||||
worktree_branch: None,
|
||||
discarded_files: None,
|
||||
discarded_commits: None,
|
||||
noop: Some(true),
|
||||
message: "No-op: there is no active EnterWorktree session to exit. No filesystem changes were made.".to_string(),
|
||||
};
|
||||
|
||||
return Ok(ToolResult::success(pretty_json(&output)?)
|
||||
.with_metadata("action", json!(output.action))
|
||||
.with_metadata("noop", json!(true))
|
||||
.with_metadata("message", json!(output.message)));
|
||||
return Err(ToolError::execution_failed(
|
||||
"No-op: there is no active EnterWorktree session to exit. This tool only operates on worktrees created by EnterWorktree in the current session. It will not touch worktrees created manually or in a previous session. No filesystem changes were made.",
|
||||
));
|
||||
};
|
||||
|
||||
let change_summary = count_worktree_changes(
|
||||
@@ -370,7 +358,6 @@ impl Tool for ExitWorktreeTool {
|
||||
worktree_branch: worktree_branch.clone(),
|
||||
discarded_files: None,
|
||||
discarded_commits: None,
|
||||
noop: None,
|
||||
message: format!(
|
||||
"Exited worktree. Your work is preserved at {}{}. Session is now back in {}.",
|
||||
worktree_path,
|
||||
@@ -418,7 +405,6 @@ impl Tool for ExitWorktreeTool {
|
||||
worktree_branch: worktree_branch.clone(),
|
||||
discarded_files: Some(summary.changed_files),
|
||||
discarded_commits: Some(summary.commits),
|
||||
noop: None,
|
||||
message: format!(
|
||||
"Exited and removed worktree at {}.{} Session is now back in {}.",
|
||||
worktree_path, discard_note, original_cwd
|
||||
@@ -902,16 +888,13 @@ mod tests {
|
||||
let context =
|
||||
ToolContext::new(repo.path().to_path_buf()).with_session_id(session.id.clone());
|
||||
|
||||
let result = ExitWorktreeTool::new()
|
||||
let error = ExitWorktreeTool::new()
|
||||
.execute(json!({ "action": "keep" }), &context)
|
||||
.await?;
|
||||
|
||||
assert!(result.success);
|
||||
assert!(result
|
||||
.output
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.contains("No-op"));
|
||||
.await
|
||||
.expect_err("missing EnterWorktree session should error");
|
||||
assert!(error
|
||||
.to_string()
|
||||
.contains("no active EnterWorktree session"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user