From d5902c93802e3a4d8485e7186a0371f31f8b3121 Mon Sep 17 00:00:00 2001 From: coso Date: Wed, 15 Apr 2026 02:09:45 +0800 Subject: [PATCH] release: v1.11.0 --- AGENTS.md | 4 + README.md | 4 +- RELEASE_NOTES.md | 17 +- .../message-footer-runtime-status-line.png | Bin 0 -> 925696 bytes docs/README.md | 21 + docs/aiprompts/README.md | 14 +- docs/aiprompts/command-runtime.md | 82 +- docs/aiprompts/content-creator.md | 3 +- docs/aiprompts/memory-compaction.md | 251 + docs/aiprompts/overview.md | 13 +- docs/aiprompts/prompt-foundation.md | 251 + docs/aiprompts/query-loop.md | 266 + docs/aiprompts/remote-runtime.md | 217 + docs/aiprompts/skill-standard.md | 242 +- docs/aiprompts/state-history-telemetry.md | 261 + docs/aiprompts/task-agent-taxonomy.md | 231 + docs/aiprompts/web-browser-scene-skill.md | 7 +- docs/exec-plans/README.md | 4 + .../chrome-web-store-publish-plan.md | 49 + ...-code-agent-task-runtime-alignment-plan.md | 106 + docs/exec-plans/limenext-plan.md | 269 + docs/exec-plans/limenext-progress.md | 236 + docs/exec-plans/tech-debt-tracker.md | 6 +- .../upstream-runtime-alignment-plan.md | 205 + .../upstream-runtime-alignment-progress.md | 211 + .../artifacts/architecture-blueprint.md | 4 +- .../roadmap/artifacts/artifact-document-v1.md | 4 +- docs/roadmap/artifacts/framework-boundary.md | 4 +- docs/roadmap/artifacts/roadmap.md | 4 +- .../system-prompt-and-schema-contract.md | 4 +- docs/test/README.md | 1 + docs/test/e2e-tests.md | 3 + docs/test/testing-strategy-2026.md | 10 +- .../CHROME_WEB_STORE_SUBMISSION.md | 152 + extensions/lime-chrome/PRIVACY_POLICY.md | 92 + package-lock.json | 4 +- package.json | 9 +- packages/lime-cli-npm/README.md | 2 +- packages/lime-cli-npm/package.json | 2 +- .../agent-runtime-tool-surface-page-smoke.mjs | 560 ++ scripts/agent-runtime-tool-surface-smoke.mjs | 56 + scripts/lib/generated-slop-report-core.mjs | 23 +- .../lib/generated-slop-report-core.test.ts | 110 + scripts/run-tauri-dev.mjs | 47 + scripts/run-tauri-profile.mjs | 3 +- scripts/verify-gui-smoke.mjs | 53 +- src-tauri/Cargo.lock | 38 +- src-tauri/Cargo.toml | 4 +- src-tauri/crates/agent/src/event_converter.rs | 4 +- src-tauri/crates/agent/src/lib.rs | 6 +- .../crates/agent/src/prompt/templates.rs | 11 +- src-tauri/crates/agent/src/protocol.rs | 12 + .../crates/agent/src/request_tool_policy.rs | 675 ++- src-tauri/crates/agent/src/runtime_queue.rs | 65 +- src-tauri/crates/agent/src/session_store.rs | 133 +- src-tauri/crates/agent/src/skill_execution.rs | 1 + .../crates/agent/src/turn_input_envelope.rs | 109 +- src-tauri/crates/agent/src/turn_state.rs | 9 +- .../crates/aster/src/agents/agent.rs | 1217 ++++- .../aster/src/agents/extension_manager.rs | 272 +- .../crates/aster/src/agents/reply_parts.rs | 264 +- .../crates/aster/src/hooks/loader.rs | 6 +- .../crates/aster/src/hooks/tests.rs | 7 + .../crates/aster/src/hooks/types.rs | 21 + .../crates/aster/src/prompt/templates.rs | 4 + .../crates/aster/src/prompts/capabilities.md | 8 + .../crates/aster/src/prompts/system.md | 3 + .../aster/src/prompts/system_gpt_4.1.md | 1 + .../crates/aster/src/providers/anthropic.rs | 31 +- .../crates/aster/src/providers/api_client.rs | 4 +- .../aster/src/providers/formats/anthropic.rs | 5 +- .../aster/src/providers/formats/databricks.rs | 6 +- .../aster/src/providers/formats/openai.rs | 6 +- .../src/providers/formats/openai_responses.rs | 112 +- .../aster/src/providers/formats/snowflake.rs | 33 +- .../crates/aster/src/providers/utils.rs | 559 +- .../crates/aster/src/providers/venice.rs | 55 +- .../crates/aster/src/tools/agent_control.rs | 1086 +++- .../aster-rust/crates/aster/src/tools/ask.rs | 125 +- .../aster-rust/crates/aster/src/tools/base.rs | 8 + .../aster-rust/crates/aster/src/tools/bash.rs | 822 ++- .../aster/src/tools/command_semantics.rs | 263 + .../crates/aster/src/tools/config_tool.rs | 854 ++- .../crates/aster/src/tools/cron_tools.rs | 4 +- .../aster-rust/crates/aster/src/tools/mod.rs | 98 +- .../crates/aster/src/tools/path_guard.rs | 321 ++ .../crates/aster/src/tools/plan_mode_tool.rs | 511 +- .../crates/aster/src/tools/powershell_tool.rs | 882 ++- .../crates/aster/src/tools/registry.rs | 153 +- .../aster/src/tools/remote_trigger_tool.rs | 20 + .../aster/src/tools/send_user_message_tool.rs | 120 +- .../crates/aster/src/tools/sleep_tool.rs | 2 +- .../crates/aster/src/tools/task_list_tools.rs | 1142 +++- .../aster/src/tools/task_output_tool.rs | 269 +- .../crates/aster/src/tools/task_stop_tool.rs | 147 +- .../crates/aster/src/tools/team_tools.rs | 577 +- .../aster/src/tools/tool_search_tool.rs | 159 +- .../aster-rust/crates/aster/src/tools/web.rs | 760 ++- .../crates/aster/src/tools/workflow_tool.rs | 2 +- .../crates/aster/src/tools/worktree_tools.rs | 41 +- src-tauri/crates/core/src/app_paths.rs | 14 + .../crates/core/src/database/dao/agent.rs | 19 +- .../services/src/aster_session_store.rs | 97 +- src-tauri/src/agent/README.md | 25 +- src-tauri/src/agent/aster_agent.rs | 165 +- src-tauri/src/agent/mod.rs | 1 + src-tauri/src/agent_tools/catalog.rs | 5 +- src-tauri/src/agent_tools/inventory.rs | 259 + .../aster_agent_cmd/action_runtime.rs | 11 +- .../command_api/runtime_api.rs | 20 +- src-tauri/src/commands/aster_agent_cmd/mod.rs | 5 +- .../commands/aster_agent_cmd/runtime_turn.rs | 4820 ++++++++++++----- .../src/commands/aster_agent_cmd/tests.rs | 542 +- .../commands/aster_agent_cmd/tool_runtime.rs | 109 + .../tool_runtime/browser_tools.rs | 1 + .../tool_runtime/search_bridge.rs | 57 +- .../tool_runtime/workspace_tools.rs | 77 +- src-tauri/src/commands/persona_cmd.rs | 9 +- src-tauri/src/commands/telemetry_cmd.rs | 17 +- src-tauri/src/commands/theme_context_cmd.rs | 12 +- src-tauri/src/commands/workspace_cmd.rs | 6 +- src-tauri/src/dev_bridge.rs | 7 +- .../dispatcher/workspace/queries.rs | 8 +- .../dispatcher/workspace/readiness.rs | 3 +- .../artifact_request_metadata_service.rs | 68 +- src-tauri/src/tests/mod.rs | 1 + src-tauri/src/tests/runtime_test_support.rs | 16 + src-tauri/src/workspace_support.rs | 80 +- src-tauri/tauri.conf.headless.json | 2 +- src-tauri/tauri.conf.json | 2 +- src/components/AppPageContent.test.tsx | 33 + src/components/AppPageContent.tsx | 10 +- .../agent/chat/AgentChatWorkspace.tsx | 63 +- .../components/AgentRuntimeStrip.test.tsx | 134 + .../chat/components/AgentRuntimeStrip.tsx | 67 +- .../chat/components/AgentTaskRuntimeCard.tsx | 182 + .../AgentThreadMemoryPrefetchPreview.test.tsx | 90 + .../AgentThreadMemoryPrefetchPreview.tsx | 117 +- .../AgentThreadReliabilityPanel.test.tsx | 60 +- .../AgentThreadReliabilityPanel.tsx | 147 +- .../agent/chat/components/EmptyState.test.tsx | 23 + .../EmptyStateComposerPanel.test.tsx | 36 +- .../components/EmptyStateComposerPanel.tsx | 1 + .../components/HarnessStatusPanel.test.tsx | 331 ++ .../chat/components/HarnessStatusPanel.tsx | 311 +- .../components/InlineToolProcessStep.test.tsx | 90 +- .../chat/components/InlineToolProcessStep.tsx | 61 +- .../Inputbar/components/InputbarCore.test.tsx | 49 +- .../Inputbar/components/InputbarCore.tsx | 56 +- .../components/InputbarRuntimeStatusLine.tsx | 290 + .../Inputbar/components/InputbarTools.tsx | 2 + .../chat/components/Inputbar/index.test.tsx | 26 +- .../chat/components/MessageList.test.tsx | 542 +- .../agent/chat/components/MessageList.tsx | 383 +- .../components/StreamingRenderer.test.tsx | 87 +- .../chat/components/StreamingRenderer.tsx | 256 +- .../chat/components/TeamSuggestionBar.tsx | 112 +- .../components/TokenUsageDisplay.test.tsx | 23 + .../chat/components/TokenUsageDisplay.tsx | 3 + .../chat/components/ToolCallDisplay.test.tsx | 51 +- .../agent/chat/components/ToolCallDisplay.tsx | 29 +- .../ToolSearchSummaryPanel.test.tsx | 18 +- .../components/ToolSearchSummaryPanel.tsx | 10 + .../agent/chat/hooks/agentChatHistory.test.ts | 68 +- .../agent/chat/hooks/agentChatHistory.ts | 64 +- .../chat/hooks/agentChatToolResult.test.ts | 29 + .../agent/chat/hooks/agentChatToolResult.ts | 3 + .../chat/hooks/agentProjectStorage.test.tsx | 13 + .../agent/chat/hooks/agentProjectStorage.ts | 26 +- .../chat/hooks/agentSessionState.test.ts | 25 +- .../agent/chat/hooks/agentSessionState.ts | 5 +- .../hooks/agentStreamRuntimeHandler.test.ts | 90 +- .../chat/hooks/agentStreamRuntimeHandler.ts | 78 +- .../hooks/agentStreamTurnEventBinding.test.ts | 207 +- .../chat/hooks/agentStreamTurnEventBinding.ts | 121 +- .../agent/chat/hooks/useAgentContext.ts | 13 +- .../agent/chat/hooks/useAgentSession.ts | 59 +- .../chat/hooks/useAsterAgentChat.test.tsx | 117 +- .../useWorkspaceProjectSelection.test.tsx | 15 + .../hooks/useWorkspaceProjectSelection.ts | 23 +- src/components/agent/chat/index.test.tsx | 37 +- .../agent/chat/utils/agentTaskRuntime.test.ts | 240 + .../agent/chat/utils/agentTaskRuntime.ts | 450 ++ .../chat/utils/agentThreadGrouping.test.ts | 65 + .../agent/chat/utils/agentThreadGrouping.ts | 31 +- .../chat/utils/inputbarRuntimeStatusLine.ts | 319 ++ .../utils/internalImagePlaceholder.test.ts | 20 + .../chat/utils/internalImagePlaceholder.ts | 34 +- .../agent/chat/utils/protocolResidue.test.ts | 17 + .../agent/chat/utils/protocolResidue.ts | 77 +- .../utils/runtimeToolAvailability.test.ts | 83 + .../chat/utils/runtimeToolAvailability.ts | 236 + .../chat/utils/toolBatchGrouping.test.ts | 90 + .../agent/chat/utils/toolBatchGrouping.ts | 422 ++ .../agent/chat/utils/toolDisplayInfo.test.ts | 85 +- .../agent/chat/utils/toolDisplayInfo.ts | 425 +- .../chat/utils/toolProcessSummary.test.ts | 276 + .../agent/chat/utils/toolProcessSummary.ts | 1030 ++++ .../utils/toolSearchResultSummary.test.ts | 25 +- .../chat/utils/toolSearchResultSummary.ts | 90 +- .../chat/utils/topicProjectResolution.test.ts | 1 + .../chat/utils/topicProjectResolution.ts | 11 +- .../chat/utils/turnSummaryPresentation.ts | 17 + .../workspace/WorkspaceConversationScene.tsx | 52 +- .../workspace/WorkspaceHarnessDialogs.tsx | 5 + .../agent/chat/workspace/chatSurfaceProps.ts | 4 +- .../useWorkspaceConversationSceneRuntime.tsx | 9 +- ...eWorkspaceHarnessInventoryRuntime.test.tsx | 39 + .../useWorkspaceHarnessInventoryRuntime.ts | 6 +- .../useWorkspaceInputbarSceneRuntime.tsx | 63 +- src/components/memory/MemoryPage.test.tsx | 62 +- src/components/memory/MemoryPage.tsx | 1323 ++++- .../memory/memoryLayerMetrics.test.ts | 14 +- src/components/memory/memoryLayerMetrics.ts | 30 +- .../settings-v2/general/memory/index.test.tsx | 20 +- .../settings-v2/general/memory/index.tsx | 28 +- src/hooks/useAppShellLayout.test.ts | 14 + src/hooks/useAppShellLayout.ts | 1 - .../agentRuntime/commandManifest.generated.ts | 28 +- src/lib/api/agentRuntime/types.ts | 29 + .../compat/serviceSkillCatalogProjection.ts | 234 + src/lib/base-setup/compiler.test.ts | 128 + src/lib/base-setup/compiler.ts | 39 + src/lib/base-setup/rolloutGate.test.ts | 114 + src/lib/base-setup/rolloutGate.ts | 99 + src/lib/base-setup/types.ts | 234 + src/lib/base-setup/validator.test.ts | 162 + src/lib/base-setup/validator.ts | 403 ++ src/lib/dev-bridge/http-client.test.ts | 143 + src/lib/dev-bridge/http-client.ts | 33 +- .../dev-bridge/mockPriorityCommands.test.ts | 20 + src/lib/dev-bridge/mockPriorityCommands.ts | 25 + .../governance/agentRuntimeCommandSchema.json | 28 +- src/lib/tauri-mock/core.test.ts | 160 +- src/lib/tauri-mock/core.ts | 1079 ++-- 235 files changed, 31782 insertions(+), 4198 deletions(-) create mode 100644 artifacts/message-footer-runtime-status-line.png create mode 100644 docs/aiprompts/memory-compaction.md create mode 100644 docs/aiprompts/prompt-foundation.md create mode 100644 docs/aiprompts/query-loop.md create mode 100644 docs/aiprompts/remote-runtime.md create mode 100644 docs/aiprompts/state-history-telemetry.md create mode 100644 docs/aiprompts/task-agent-taxonomy.md create mode 100644 docs/exec-plans/chrome-web-store-publish-plan.md create mode 100644 docs/exec-plans/claude-code-agent-task-runtime-alignment-plan.md create mode 100644 docs/exec-plans/limenext-plan.md create mode 100644 docs/exec-plans/limenext-progress.md create mode 100644 docs/exec-plans/upstream-runtime-alignment-plan.md create mode 100644 docs/exec-plans/upstream-runtime-alignment-progress.md create mode 100644 extensions/lime-chrome/CHROME_WEB_STORE_SUBMISSION.md create mode 100644 extensions/lime-chrome/PRIVACY_POLICY.md create mode 100644 scripts/agent-runtime-tool-surface-page-smoke.mjs create mode 100644 scripts/agent-runtime-tool-surface-smoke.mjs create mode 100644 scripts/run-tauri-dev.mjs create mode 100644 src-tauri/crates/aster-rust/crates/aster/src/tools/command_semantics.rs create mode 100644 src-tauri/crates/aster-rust/crates/aster/src/tools/path_guard.rs create mode 100644 src-tauri/src/tests/runtime_test_support.rs create mode 100644 src/components/agent/chat/components/AgentRuntimeStrip.test.tsx create mode 100644 src/components/agent/chat/components/AgentTaskRuntimeCard.tsx create mode 100644 src/components/agent/chat/components/AgentThreadMemoryPrefetchPreview.test.tsx create mode 100644 src/components/agent/chat/components/Inputbar/components/InputbarRuntimeStatusLine.tsx create mode 100644 src/components/agent/chat/hooks/agentChatToolResult.test.ts create mode 100644 src/components/agent/chat/utils/agentTaskRuntime.test.ts create mode 100644 src/components/agent/chat/utils/agentTaskRuntime.ts create mode 100644 src/components/agent/chat/utils/inputbarRuntimeStatusLine.ts create mode 100644 src/components/agent/chat/utils/runtimeToolAvailability.test.ts create mode 100644 src/components/agent/chat/utils/runtimeToolAvailability.ts create mode 100644 src/components/agent/chat/utils/toolBatchGrouping.test.ts create mode 100644 src/components/agent/chat/utils/toolBatchGrouping.ts create mode 100644 src/components/agent/chat/utils/toolProcessSummary.test.ts create mode 100644 src/components/agent/chat/utils/toolProcessSummary.ts create mode 100644 src/lib/base-setup/compat/serviceSkillCatalogProjection.ts create mode 100644 src/lib/base-setup/compiler.test.ts create mode 100644 src/lib/base-setup/compiler.ts create mode 100644 src/lib/base-setup/rolloutGate.test.ts create mode 100644 src/lib/base-setup/rolloutGate.ts create mode 100644 src/lib/base-setup/types.ts create mode 100644 src/lib/base-setup/validator.test.ts create mode 100644 src/lib/base-setup/validator.ts diff --git a/AGENTS.md b/AGENTS.md index 92a2658ec..eabdf8d80 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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` diff --git a/README.md b/README.md index a10d83c34..3c50818e8 100644 --- a/README.md +++ b/README.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` 目录。 --- diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 5ed2fb812..a517685a2 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -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` diff --git a/artifacts/message-footer-runtime-status-line.png b/artifacts/message-footer-runtime-status-line.png new file mode 100644 index 0000000000000000000000000000000000000000..1647b0d00f49c48add8bbc7f9fcca6af9af3552b GIT binary patch literal 925696 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 iiM(=(IT> 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�Ch6lf48|$q2**;>$|V?K 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�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

1h^}A9BV>)(oraWhnj|T2f!NG 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">NXR7ZbK~TPHdpWX){0mR2~mgt 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

0(7c#2 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%0DNppx2IiW6gR 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

cVWL|K04 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

  • Mp6B@D!0#rM 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 单回合 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` 才不会继续被“工作记忆、持久记忆、项目资料、压缩摘要”几套语言来回打断。 diff --git a/docs/aiprompts/overview.md b/docs/aiprompts/overview.md index 06d7f7e30..e14b397d7 100644 --- a/docs/aiprompts/overview.md +++ b/docs/aiprompts/overview.md @@ -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) - 终端能力 diff --git a/docs/aiprompts/prompt-foundation.md b/docs/aiprompts/prompt-foundation.md new file mode 100644 index 000000000..9e53aaadd --- /dev/null +++ b/docs/aiprompts/prompt-foundation.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、平级模板或平级文档解释 diff --git a/docs/aiprompts/query-loop.md b/docs/aiprompts/query-loop.md new file mode 100644 index 000000000..d2813c427 --- /dev/null +++ b/docs/aiprompts/query-loop.md @@ -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 下游消费链;任何新能力都只能往这条链收敛,不能平行再长一套执行真相。 diff --git a/docs/aiprompts/remote-runtime.md b/docs/aiprompts/remote-runtime.md new file mode 100644 index 000000000..182dcf217 --- /dev/null +++ b/docs/aiprompts/remote-runtime.md @@ -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 入口语言打断。 diff --git a/docs/aiprompts/skill-standard.md b/docs/aiprompts/skill-standard.md index 63de94ef7..c6b79cde6 100644 --- a/docs/aiprompts/skill-standard.md +++ b/docs/aiprompts/skill-standard.md @@ -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 的运行时与产品定义权。 ## 新增技能的最低检查单 diff --git a/docs/aiprompts/state-history-telemetry.md b/docs/aiprompts/state-history-telemetry.md new file mode 100644 index 000000000..03ef37895 --- /dev/null +++ b/docs/aiprompts/state-history-telemetry.md @@ -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 视图时,就不会继续在“状态模型”“线程读模型”“证据链”“遥测控制台”之间横跳排期语言。 diff --git a/docs/aiprompts/task-agent-taxonomy.md b/docs/aiprompts/task-agent-taxonomy.md new file mode 100644 index 000000000..0be1dbd2c --- /dev/null +++ b/docs/aiprompts/task-agent-taxonomy.md @@ -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` 才不会继续被长时任务边界反复打断。 diff --git a/docs/aiprompts/web-browser-scene-skill.md b/docs/aiprompts/web-browser-scene-skill.md index 9ad83497a..43b2582e7 100644 --- a/docs/aiprompts/web-browser-scene-skill.md +++ b/docs/aiprompts/web-browser-scene-skill.md @@ -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。 diff --git a/docs/exec-plans/README.md b/docs/exec-plans/README.md index 46f9e080a..1aac1960a 100644 --- a/docs/exec-plans/README.md +++ b/docs/exec-plans/README.md @@ -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` diff --git a/docs/exec-plans/chrome-web-store-publish-plan.md b/docs/exec-plans/chrome-web-store-publish-plan.md new file mode 100644 index 000000000..0f3a3e120 --- /dev/null +++ b/docs/exec-plans/chrome-web-store-publish-plan.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: ` +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/` +- 由于发布属于外部生产动作,暂未执行任何登录、上传或提交操作 diff --git a/docs/exec-plans/claude-code-agent-task-runtime-alignment-plan.md b/docs/exec-plans/claude-code-agent-task-runtime-alignment-plan.md new file mode 100644 index 000000000..02d925bba --- /dev/null +++ b/docs/exec-plans/claude-code-agent-task-runtime-alignment-plan.md @@ -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` 字段后续再补,当前先用前端投影收口主体验。 diff --git a/docs/exec-plans/limenext-plan.md b/docs/exec-plans/limenext-plan.md new file mode 100644 index 000000000..1020214da --- /dev/null +++ b/docs/exec-plans/limenext-plan.md @@ -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 才会从“样板闭环成立”继续推进到“经营面可执行”。** diff --git a/docs/exec-plans/limenext-progress.md b/docs/exec-plans/limenext-progress.md new file mode 100644 index 000000000..ef7ec402b --- /dev/null +++ b/docs/exec-plans/limenext-progress.md @@ -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 与校验规则 diff --git a/docs/exec-plans/tech-debt-tracker.md b/docs/exec-plans/tech-debt-tracker.md index 401975df5..76c9b99ad 100644 --- a/docs/exec-plans/tech-debt-tracker.md +++ b/docs/exec-plans/tech-debt-tracker.md @@ -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` | 已完成 | diff --git a/docs/exec-plans/upstream-runtime-alignment-plan.md b/docs/exec-plans/upstream-runtime-alignment-plan.md new file mode 100644 index 000000000..7cbe17f8b --- /dev/null +++ b/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 代码边界继续做减法。 diff --git a/docs/exec-plans/upstream-runtime-alignment-progress.md b/docs/exec-plans/upstream-runtime-alignment-progress.md new file mode 100644 index 000000000..d7eb72a1f --- /dev/null +++ b/docs/exec-plans/upstream-runtime-alignment-progress.md @@ -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` 现改为稳定的 `/teams//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 主路径做定点修复,而不是继续常态化微切 diff --git a/docs/roadmap/artifacts/architecture-blueprint.md b/docs/roadmap/artifacts/architecture-blueprint.md index d85c8a3db..77c9a325c 100644 --- a/docs/roadmap/artifacts/architecture-blueprint.md +++ b/docs/roadmap/artifacts/architecture-blueprint.md @@ -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 生成时序图 diff --git a/docs/roadmap/artifacts/artifact-document-v1.md b/docs/roadmap/artifacts/artifact-document-v1.md index 1975b4095..d4c6a0f4c 100644 --- a/docs/roadmap/artifacts/artifact-document-v1.md +++ b/docs/roadmap/artifacts/artifact-document-v1.md @@ -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 入口为准。 系统提示词应增加以下硬约束: diff --git a/docs/roadmap/artifacts/framework-boundary.md b/docs/roadmap/artifacts/framework-boundary.md index 41ce899a5..b6c0861e0 100644 --- a/docs/roadmap/artifacts/framework-boundary.md +++ b/docs/roadmap/artifacts/framework-boundary.md @@ -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/`。 diff --git a/docs/roadmap/artifacts/roadmap.md b/docs/roadmap/artifacts/roadmap.md index a33a29e37..154d44176 100644 --- a/docs/roadmap/artifacts/roadmap.md +++ b/docs/roadmap/artifacts/roadmap.md @@ -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。 diff --git a/docs/roadmap/artifacts/system-prompt-and-schema-contract.md b/docs/roadmap/artifacts/system-prompt-and-schema-contract.md index 85abb9039..41647f960 100644 --- a/docs/roadmap/artifacts/system-prompt-and-schema-contract.md +++ b/docs/roadmap/artifacts/system-prompt-and-schema-contract.md @@ -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: diff --git a/docs/test/README.md b/docs/test/README.md index 05c457330..3556cf707 100644 --- a/docs/test/README.md +++ b/docs/test/README.md @@ -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 摘要 diff --git a/docs/test/e2e-tests.md b/docs/test/e2e-tests.md index 463058a21..869d84764 100644 --- a/docs/test/e2e-tests.md +++ b/docs/test/e2e-tests.md @@ -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 | 适合排障,不是统一门禁 | diff --git a/docs/test/testing-strategy-2026.md b/docs/test/testing-strategy-2026.md index b19b2c75d..c2244fea7 100644 --- a/docs/test/testing-strategy-2026.md +++ b/docs/test/testing-strategy-2026.md @@ -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. 建议执行顺序 diff --git a/extensions/lime-chrome/CHROME_WEB_STORE_SUBMISSION.md b/extensions/lime-chrome/CHROME_WEB_STORE_SUBMISSION.md new file mode 100644 index 000000000..839eb9d44 --- /dev/null +++ b/extensions/lime-chrome/CHROME_WEB_STORE_SUBMISSION.md @@ -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 `` 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 异常时向用户展示浏览器内提示。 + +### `` + +用于支持用户在任意常规网页上让 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` 与 `` 的必要性 diff --git a/extensions/lime-chrome/PRIVACY_POLICY.md b/extensions/lime-chrome/PRIVACY_POLICY.md new file mode 100644 index 000000000..f0722dde4 --- /dev/null +++ b/extensions/lime-chrome/PRIVACY_POLICY.md @@ -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 +- `` 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. diff --git a/package-lock.json b/package-lock.json index b988cad10..a01584d63 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 70cad9338..236530bff 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/lime-cli-npm/README.md b/packages/lime-cli-npm/README.md index 0866ae848..55794e62f 100644 --- a/packages/lime-cli-npm/README.md +++ b/packages/lime-cli-npm/README.md @@ -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" ``` diff --git a/packages/lime-cli-npm/package.json b/packages/lime-cli-npm/package.json index 4f7d751fe..a6a9785b6 100644 --- a/packages/lime-cli-npm/package.json +++ b/packages/lime-cli-npm/package.json @@ -1,6 +1,6 @@ { "name": "@limecloud/lime-cli", - "version": "1.10.0", + "version": "1.11.0", "description": "Lime 官方任务 CLI", "bin": { "lime": "scripts/run.js" diff --git a/scripts/agent-runtime-tool-surface-page-smoke.mjs b/scripts/agent-runtime-tool-surface-page-smoke.mjs new file mode 100644 index 000000000..65249a233 --- /dev/null +++ b/scripts/agent-runtime-tool-surface-page-smoke.mjs @@ -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 前端地址,默认 http://127.0.0.1:1420/ + --health-url DevBridge 健康检查地址,默认 http://127.0.0.1:3030/health + --invoke-url DevBridge invoke 地址,默认 http://127.0.0.1:3030/invoke + --timeout-ms 总超时,默认 180000 + --interval-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); +}); diff --git a/scripts/agent-runtime-tool-surface-smoke.mjs b/scripts/agent-runtime-tool-surface-smoke.mjs new file mode 100644 index 000000000..3002bcf1b --- /dev/null +++ b/scripts/agent-runtime-tool-surface-smoke.mjs @@ -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(); diff --git a/scripts/lib/generated-slop-report-core.mjs b/scripts/lib/generated-slop-report-core.mjs index 4a7c74f47..3f3d2a7b6 100644 --- a/scripts/lib/generated-slop-report-core.mjs +++ b/scripts/lib/generated-slop-report-core.mjs @@ -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), diff --git a/scripts/lib/generated-slop-report-core.test.ts b/scripts/lib/generated-slop-report-core.test.ts index acfb74b4f..25bd1bd4f 100644 --- a/scripts/lib/generated-slop-report-core.test.ts +++ b/scripts/lib/generated-slop-report-core.test.ts @@ -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({ diff --git a/scripts/run-tauri-dev.mjs b/scripts/run-tauri-dev.mjs new file mode 100644 index 000000000..4ccef64e5 --- /dev/null +++ b/scripts/run-tauri-dev.mjs @@ -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); +}); diff --git a/scripts/run-tauri-profile.mjs b/scripts/run-tauri-profile.mjs index 22456a154..3874d5e72 100644 --- a/scripts/run-tauri-profile.mjs +++ b/scripts/run-tauri-profile.mjs @@ -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", diff --git a/scripts/verify-gui-smoke.mjs b/scripts/verify-gui-smoke.mjs index ed3ab574f..501a6babc 100644 --- a/scripts/verify-gui-smoke.mjs +++ b/scripts/verify-gui-smoke.mjs @@ -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 = ['Lime', '
    ']; +const ROOT_MARKERS = ["Lime", '
    ']; 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) { diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index e952b160d..0656599b9 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -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", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 60c76d3ad..d4618cc26 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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" diff --git a/src-tauri/crates/agent/src/event_converter.rs b/src-tauri/crates/agent/src/event_converter.rs index d583b8e7f..4c9a45e9e 100644 --- a/src-tauri/crates/agent/src/event_converter.rs +++ b/src-tauri/crates/agent/src/event_converter.rs @@ -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"; diff --git a/src-tauri/crates/agent/src/lib.rs b/src-tauri/crates/agent/src/lib.rs index 0c7d56489..57c859f75 100644 --- a/src-tauri/crates/agent/src/lib.rs +++ b/src-tauri/crates/agent/src/lib.rs @@ -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}; diff --git a/src-tauri/crates/agent/src/prompt/templates.rs b/src-tauri/crates/agent/src/prompt/templates.rs index df29fb582..51e8b0ed1 100644 --- a/src-tauri/crates/agent/src/prompt/templates.rs +++ b/src-tauri/crates/agent/src/prompt/templates.rs @@ -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#"# 代码编写指南 diff --git a/src-tauri/crates/agent/src/protocol.rs b/src-tauri/crates/agent/src/protocol.rs index e88f71adb..9e0c5f0ee 100644 --- a/src-tauri/crates/agent/src/protocol.rs +++ b/src-tauri/crates/agent/src/protocol.rs @@ -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 { diff --git a/src-tauri/crates/agent/src/request_tool_policy.rs b/src-tauri/crates/agent/src/request_tool_policy.rs index ebeaa9317..f3f281fce 100644 --- a/src-tauri/crates/agent/src/request_tool_policy.rs +++ b/src-tauri/crates/agent/src/request_tool_policy.rs @@ -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 @@ -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 { @@ -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( 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::>(); + 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::() + } + }; + 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, + } + + #[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 == "正在重试生成答复" + )), + "应向前端投影空答复重试状态" + ); + } } diff --git a/src-tauri/crates/agent/src/runtime_queue.rs b/src-tauri/crates/agent/src/runtime_queue.rs index dd0fee6fd..169a30478 100644 --- a/src-tauri/crates/agent/src/runtime_queue.rs +++ b/src-tauri/crates/agent/src/runtime_queue.rs @@ -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 = Arc BoxFuture<'static, Result<(), String>> + Send + Sync>; @@ -35,22 +37,53 @@ fn spawn_runtime_turn_task( ) 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( diff --git a/src-tauri/crates/agent/src/session_store.rs b/src-tauri/crates/agent/src/session_store.rs index e24b7e592..56a1a0e57 100644 --- a/src-tauri/crates/agent/src/session_store.rs +++ b/src-tauri/crates/agent/src/session_store.rs @@ -927,17 +927,51 @@ pub fn list_title_preview_messages_sync( limit: usize, ) -> Result, 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 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 { + 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::>(); + + 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![ diff --git a/src-tauri/crates/agent/src/skill_execution.rs b/src-tauri/crates/agent/src/skill_execution.rs index df88acb01..87fbb8690 100644 --- a/src-tauri/crates/agent/src/skill_execution.rs +++ b/src-tauri/crates/agent/src/skill_execution.rs @@ -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, } diff --git a/src-tauri/crates/agent/src/turn_input_envelope.rs b/src-tauri/crates/agent/src/turn_input_envelope.rs index 78dade693..a4c359be8 100644 --- a/src-tauri/crates/agent/src/turn_input_envelope.rs +++ b/src-tauri/crates/agent/src/turn_input_envelope.rs @@ -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, pub thread_id: Option, pub turn_id: Option, + pub execution_profile: TurnExecutionProfile, pub has_persisted_session: bool, pub system_prompt_source: TurnSystemPromptSource, pub base_system_prompt_len: Option, @@ -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, pub has_turn_context_metadata: bool, pub turn_context_metadata_keys: Vec, } @@ -172,6 +184,7 @@ pub struct TurnInputEnvelope { project_id: Option, thread_id: Option, turn_id: Option, + execution_profile: TurnExecutionProfile, has_persisted_session: bool, system_prompt_source: TurnSystemPromptSource, base_system_prompt: Option, @@ -187,6 +200,8 @@ pub struct TurnInputEnvelope { working_dir: Option, effective_user_message: String, include_context_trace: bool, + turn_output_schema: Option, + turn_output_schema_source: Option, approval_policy: Option, sandbox_policy: Option, turn_context_metadata: Option>, @@ -220,13 +235,19 @@ impl TurnInputEnvelope { pub fn turn_context_override(&self) -> Option { 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, + output_schema_source: Option, + ) -> &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) -> &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"); diff --git a/src-tauri/crates/agent/src/turn_state.rs b/src-tauri/crates/agent/src/turn_state.rs index 91e8fd2c0..5e5dd0eb6 100644 --- a/src-tauri/crates/agent/src/turn_state.rs +++ b/src-tauri/crates/agent/src/turn_state.rs @@ -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, thread_id: impl Into, turn_id: impl Into, + execution_profile: TurnExecutionProfile, requested_execution_strategy: impl Into, effective_execution_strategy: impl Into, 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") diff --git a/src-tauri/crates/aster-rust/crates/aster/src/agents/agent.rs b/src-tauri/crates/aster-rust/crates/aster/src/agents/agent.rs index 39509f751..8cc59b558 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/agents/agent.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/agents/agent.rs @@ -7,7 +7,7 @@ use std::time::Duration; use anyhow::{anyhow, Context, Result}; use chrono::{DateTime, Utc}; use futures::stream::BoxStream; -use futures::{stream, FutureExt, Stream, StreamExt, TryStreamExt}; +use futures::{future::join_all, stream, FutureExt, Stream, StreamExt, TryStreamExt}; use uuid::Uuid; use super::final_output_tool::FinalOutputTool; @@ -54,13 +54,16 @@ use crate::session::{ TurnOutputSchemaRuntime, TurnOutputSchemaSource, TurnOutputSchemaStrategy, TurnRuntime, TurnStatus, }; +use crate::session_context::current_turn_context; use crate::tool_inspection::ToolInspectionManager; use crate::tool_monitor::RepetitionInspector; use crate::tools::{ - current_surface_tool_gates, register_all_tools, should_register_current_surface_tool, - AgentControlToolConfig, AskTool, CronCreateTool, CronDeleteTool, CronListTool, - CurrentSurfaceToolGates, SharedFileReadHistory, SpawnAgentRequest, SpawnAgentResponse, - ToolRegistrationConfig, ToolRegistry, DEFAULT_ASK_TIMEOUT_SECS, + current_surface_tool_gates, is_bash_command_concurrency_safe, + is_powershell_command_concurrency_safe, register_all_tools, + should_register_current_surface_tool, AgentControlToolConfig, AskTool, CronCreateTool, + CronDeleteTool, CronListTool, CurrentSurfaceToolGates, SharedFileReadHistory, + SpawnAgentRequest, SpawnAgentResponse, ToolRegistrationConfig, ToolRegistry, + DEFAULT_ASK_TIMEOUT_SECS, }; use crate::user_message_manager::UserMessageManager; use crate::utils::is_token_cancelled; @@ -112,6 +115,15 @@ const SUBAGENT_TEAMMATE_ALLOWED_TOOL_NAMES: [&str; 5] = [ ]; const AUTO_COMPACTION_DISABLED_CONTEXT_LIMIT_TEXT: &str = "Automatic compaction is disabled for this turn. The conversation reached the context limit. Compact the session manually or start a new session before retrying."; +const LIME_RUNTIME_METADATA_KEY: &str = "lime_runtime"; +const LIME_RUNTIME_TOOL_SURFACE_KEY: &str = "tool_surface"; +const TURN_TOOL_SURFACE_LOCAL_WORKSPACE: &str = "local_workspace"; +const LOCAL_WORKSPACE_TOOL_BATCH_PHASE: &str = "local_workspace_tool_batch"; +const LOCAL_WORKSPACE_STAGE_SUMMARY_REMINDER: &str = + "你刚完成一批本地工作区工具调用。下一步先直接输出 1 到 2 句用户可见的结论正文:说明已经确认了什么、还缺什么、为什么还要继续;不要额外输出“阶段结论”标题。只有仍缺关键证据时,才继续下一批;若继续,请把 2 到 4 个彼此独立的只读工具调用放在同一条回复里一起发起,不要退回单步长链侦查,也不要只说“继续检查”却不给过程结论。若用户已点名绝对路径、仓库根或具体文件,下一批也必须继续优先围绕这些显式路径,不要无故回扫默认工作目录。"; +const LOCAL_WORKSPACE_EMPTY_FOLLOWUP_CONTINUE_PROMPT: &str = + "请继续。你上一轮已经完成本地工作区侦查,但没有给出新的用户可见结论。现在必须继续:若仍缺关键证据,就立刻发起下一批必要的只读工具;证据已足够时,直接给出完整对比结论。不要停在空白,也不要重复已经完成的工具。"; +const ASSISTANT_PHASE_SUMMARY_FALLBACK_TITLE: &str = "已整理当前发现"; const PROPOSED_PLAN_OPEN: &str = ""; const PROPOSED_PLAN_CLOSE: &str = ""; const FILE_ARTIFACT_METADATA_KEYS: [&str; 9] = [ @@ -205,6 +217,547 @@ fn extract_proposed_plan_block(text: &str) -> Option { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ToolBatchOperationKind { + List, + Search, + Read, + Command, + Other, +} + +#[derive(Debug, Default)] +struct LocalWorkspaceToolBatchAccumulator { + total_requests: usize, + list_count: usize, + search_count: usize, + read_count: usize, + command_count: usize, + latest_hint: Option, + latest_list_hint: Option, + latest_search_hint: Option, + latest_read_hint: Option, + latest_command_hint: Option, +} + +impl LocalWorkspaceToolBatchAccumulator { + fn record_request(&mut self, request: &ToolRequest) { + let Ok(tool_call) = &request.tool_call else { + return; + }; + + self.total_requests += 1; + + let operation_kind = resolve_tool_batch_operation_kind( + tool_call.name.as_ref(), + tool_call.arguments.as_ref(), + ); + let hint = resolve_tool_batch_latest_hint(request); + if let Some(latest_hint) = hint.clone() { + self.latest_hint = Some(latest_hint); + } + + match operation_kind { + ToolBatchOperationKind::List => { + self.list_count += 1; + if let Some(latest_hint) = hint { + self.latest_list_hint = Some(latest_hint); + } + } + ToolBatchOperationKind::Search => { + self.search_count += 1; + if let Some(latest_hint) = hint { + self.latest_search_hint = Some(latest_hint); + } + } + ToolBatchOperationKind::Read => { + self.read_count += 1; + if let Some(latest_hint) = hint { + self.latest_read_hint = Some(latest_hint); + } + } + ToolBatchOperationKind::Command => { + self.command_count += 1; + if let Some(latest_hint) = hint { + self.latest_command_hint = Some(latest_hint); + } + } + ToolBatchOperationKind::Other => {} + } + } +} + +fn normalize_tool_batch_name_key(tool_name: &str) -> String { + let mut normalized = String::with_capacity(tool_name.len()); + let mut previous_was_separator = true; + + for ch in tool_name.chars() { + if ch.is_ascii_uppercase() { + if !normalized.is_empty() && !previous_was_separator { + normalized.push('_'); + } + normalized.push(ch.to_ascii_lowercase()); + previous_was_separator = false; + } else if ch.is_ascii_alphanumeric() { + normalized.push(ch.to_ascii_lowercase()); + previous_was_separator = false; + } else if !previous_was_separator && !normalized.is_empty() { + normalized.push('_'); + previous_was_separator = true; + } + } + + normalized.trim_matches('_').to_string() +} + +fn shorten_tool_batch_hint(value: &str) -> Option { + let collapsed = value.split_whitespace().collect::>().join(" "); + let trimmed = collapsed.trim(); + if trimmed.is_empty() { + return None; + } + + const MAX_HINT_CHARS: usize = 72; + let shortened = trimmed.chars().take(MAX_HINT_CHARS).collect::(); + if trimmed.chars().count() > MAX_HINT_CHARS { + Some(format!("{shortened}...")) + } else { + Some(shortened) + } +} + +fn read_tool_batch_argument( + arguments: Option<&rmcp::model::JsonObject>, + keys: &[&str], +) -> Option { + let arguments = arguments?; + + for key in keys { + let Some(value) = arguments.get(*key) else { + continue; + }; + match value { + Value::String(text) => { + if let Some(value) = shorten_tool_batch_hint(text) { + return Some(value); + } + } + Value::Number(number) => return Some(number.to_string()), + Value::Bool(boolean) => return Some(boolean.to_string()), + Value::Array(items) => { + let joined = items + .iter() + .filter_map(|item| item.as_str()) + .map(str::trim) + .filter(|item| !item.is_empty()) + .take(3) + .collect::>() + .join(", "); + if let Some(value) = shorten_tool_batch_hint(&joined) { + return Some(value); + } + } + _ => {} + } + } + + None +} + +fn resolve_tool_batch_path( + tool_name: &str, + arguments: Option<&rmcp::model::JsonObject>, +) -> Option { + fn read_raw_path_argument( + arguments: Option<&rmcp::model::JsonObject>, + keys: &[&str], + ) -> Option { + let arguments = arguments?; + for key in keys { + let Some(value) = arguments.get(*key) else { + continue; + }; + if let Some(text) = value.as_str() { + let trimmed = text.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } + } + } + None + } + + let normalized_name = normalize_tool_batch_name_key(tool_name); + let candidate = if normalized_name.contains("read") { + read_raw_path_argument( + arguments, + &["file_path", "filePath", "path", "absolute_path", "cwd"], + ) + } else if normalized_name.contains("glob") + || normalized_name.contains("list") + || normalized_name.contains("tree") + { + read_raw_path_argument(arguments, &["path", "root", "cwd", "workdir"]) + } else if normalized_name.contains("grep") || normalized_name.contains("search") { + read_raw_path_argument(arguments, &["path", "root", "cwd", "workdir"]) + } else if normalized_name.contains("bash") + || normalized_name.contains("shell") + || normalized_name.contains("power") + { + read_raw_path_argument(arguments, &["cwd", "workdir", "path"]) + } else { + read_raw_path_argument( + arguments, + &[ + "file_path", + "filePath", + "path", + "absolute_path", + "root", + "cwd", + "workdir", + ], + ) + }?; + + Some(candidate) +} + +fn file_name_from_tool_batch_path(path: &str) -> Option { + let trimmed = path.trim().trim_matches('"').trim_matches('\''); + if trimmed.is_empty() { + return None; + } + + std::path::Path::new(trimmed) + .file_name() + .and_then(|value| value.to_str()) + .and_then(shorten_tool_batch_hint) + .or_else(|| shorten_tool_batch_hint(trimmed)) +} + +fn resolve_shell_like_operation_kind(command: &str) -> ToolBatchOperationKind { + let trimmed = command.trim(); + if trimmed.is_empty() { + return ToolBatchOperationKind::Command; + } + + let normalized = trimmed.to_ascii_lowercase(); + let first_token = normalized + .lines() + .find(|line| !line.trim().is_empty()) + .and_then(|line| line.split_whitespace().next()) + .unwrap_or_default() + .trim_matches('"') + .trim_matches('\''); + + if matches!(first_token, "ls" | "dir" | "tree" | "find" | "fd") { + return ToolBatchOperationKind::List; + } + if matches!(first_token, "rg" | "grep" | "findstr") || normalized.contains(" rg ") { + return ToolBatchOperationKind::Search; + } + if matches!( + first_token, + "cat" | "sed" | "head" | "tail" | "awk" | "wc" | "type" | "bat" + ) { + return ToolBatchOperationKind::Read; + } + + ToolBatchOperationKind::Command +} + +fn resolve_tool_batch_operation_kind( + tool_name: &str, + arguments: Option<&rmcp::model::JsonObject>, +) -> ToolBatchOperationKind { + let normalized_name = normalize_tool_batch_name_key(tool_name); + + if normalized_name.contains("read") { + ToolBatchOperationKind::Read + } else if normalized_name.contains("glob") + || normalized_name.contains("list") + || normalized_name.contains("tree") + { + ToolBatchOperationKind::List + } else if normalized_name.contains("grep") || normalized_name.contains("search") { + ToolBatchOperationKind::Search + } else if normalized_name.contains("bash") + || normalized_name.contains("shell") + || normalized_name.contains("power") + { + let command = + read_tool_batch_argument(arguments, &["command", "script"]).unwrap_or_default(); + resolve_shell_like_operation_kind(&command) + } else { + ToolBatchOperationKind::Other + } +} + +fn resolve_tool_batch_latest_hint(request: &ToolRequest) -> Option { + let tool_call = request.tool_call.as_ref().ok()?; + let arguments = tool_call.arguments.as_ref(); + let operation_kind = resolve_tool_batch_operation_kind(tool_call.name.as_ref(), arguments); + let path_hint = resolve_tool_batch_path(tool_call.name.as_ref(), arguments) + .and_then(|path| file_name_from_tool_batch_path(&path).or(Some(path))); + let pattern_hint = + read_tool_batch_argument(arguments, &["pattern", "query", "q", "regex", "search"]); + + match operation_kind { + ToolBatchOperationKind::Read | ToolBatchOperationKind::List => path_hint, + ToolBatchOperationKind::Search => match (path_hint, pattern_hint) { + (Some(path), Some(pattern)) => shorten_tool_batch_hint(&format!("{path} / {pattern}")), + (Some(path), None) => Some(path), + (None, Some(pattern)) => Some(pattern), + (None, None) => shorten_tool_batch_hint(tool_call.name.as_ref()), + }, + ToolBatchOperationKind::Command => { + read_tool_batch_argument(arguments, &["command", "script"]) + .and_then(|command| shorten_tool_batch_hint(&command)) + .or(path_hint) + } + ToolBatchOperationKind::Other => { + path_hint.or_else(|| shorten_tool_batch_hint(tool_call.name.as_ref())) + } + } +} + +fn build_local_workspace_tool_batch_runtime_status( + frontend_requests: &[ToolRequest], + remaining_requests: &[ToolRequest], +) -> Option<(String, String, String, Vec)> { + let mut accumulator = LocalWorkspaceToolBatchAccumulator::default(); + for request in frontend_requests.iter().chain(remaining_requests.iter()) { + accumulator.record_request(request); + } + + if accumulator.total_requests == 0 { + return None; + } + + let mut activities = Vec::new(); + if accumulator.list_count > 0 { + activities.push("目录扫描"); + } + if accumulator.search_count > 0 { + activities.push("关键检索"); + } + if accumulator.read_count > 0 { + activities.push("文件读取"); + } + if accumulator.command_count > 0 { + activities.push("命令探查"); + } + if activities.is_empty() { + activities.push("本地工具分析"); + } + + let activity_text = match activities.as_slice() { + [] => "本地工具分析".to_string(), + [single] => (*single).to_string(), + [left, right] => format!("{left}和{right}"), + _ => { + let head = activities[..activities.len() - 1].join("、"); + let tail = activities.last().copied().unwrap_or("本地工具分析"); + format!("{head}和{tail}") + } + }; + + let detail = match accumulator.latest_hint.as_deref() { + Some(latest_hint) => format!( + "已完成这一批本地仓库的{activity_text},当前线索集中在“{latest_hint}”,正在整理这一批结果并判断是否还需要继续取证。" + ), + None => format!( + "已完成这一批本地仓库的{activity_text},正在整理这一批结果并判断是否还需要继续取证。" + ), + }; + + let mut checkpoints = Vec::new(); + if accumulator.list_count > 0 { + checkpoints.push(match accumulator.latest_list_hint.as_deref() { + Some(latest_hint) => format!("目录扫描:{latest_hint}"), + None => format!("目录扫描 {} 次", accumulator.list_count), + }); + } + if accumulator.search_count > 0 { + checkpoints.push(match accumulator.latest_search_hint.as_deref() { + Some(latest_hint) => format!("关键检索:{latest_hint}"), + None => format!("关键检索 {} 次", accumulator.search_count), + }); + } + if accumulator.read_count > 0 { + checkpoints.push(match accumulator.latest_read_hint.as_deref() { + Some(latest_hint) => format!("文件读取:{latest_hint}"), + None => format!("文件读取 {} 次", accumulator.read_count), + }); + } + if accumulator.command_count > 0 { + checkpoints.push(match accumulator.latest_command_hint.as_deref() { + Some(latest_hint) => format!("命令探查:{latest_hint}"), + None => format!("命令探查 {} 次", accumulator.command_count), + }); + } + if checkpoints.is_empty() { + checkpoints.push(format!( + "本批共发起 {} 个工具调用", + accumulator.total_requests + )); + } + + Some(( + LOCAL_WORKSPACE_TOOL_BATCH_PHASE.to_string(), + "已完成一批本地分析".to_string(), + detail, + checkpoints, + )) +} + +#[derive(Debug, Clone)] +struct ToolExecutionBatch { + is_concurrency_safe: bool, + requests: Vec, +} + +fn is_concurrency_safe_tool_request(request: &ToolRequest) -> bool { + let Ok(tool_call) = &request.tool_call else { + return false; + }; + + match tool_call.name.as_ref() { + "Read" + | "Glob" + | "Grep" + | "LSP" + | "WebFetch" + | "WebSearch" + | "ListMcpResourcesTool" + | "ReadMcpResourceTool" => true, + "Bash" => tool_call + .arguments + .as_ref() + .and_then(|arguments| arguments.get("command")) + .and_then(Value::as_str) + .is_some_and(is_bash_command_concurrency_safe), + "PowerShell" => tool_call + .arguments + .as_ref() + .and_then(|arguments| arguments.get("command")) + .and_then(Value::as_str) + .is_some_and(is_powershell_command_concurrency_safe), + _ => false, + } +} + +fn partition_tool_requests_for_execution(requests: &[ToolRequest]) -> Vec { + let mut batches = Vec::new(); + + for request in requests { + let is_concurrency_safe = is_concurrency_safe_tool_request(request); + if is_concurrency_safe + && batches + .last() + .is_some_and(|batch: &ToolExecutionBatch| batch.is_concurrency_safe) + { + if let Some(last_batch) = batches.last_mut() { + last_batch.requests.push(request.clone()); + } + continue; + } + + batches.push(ToolExecutionBatch { + is_concurrency_safe, + requests: vec![request.clone()], + }); + } + + batches +} + +fn extract_assistant_phase_summary_detail(text: &str) -> Option { + let marker = ["阶段结论:", "阶段结论:"] + .into_iter() + .filter_map(|candidate| text.rfind(candidate).map(|index| (index, candidate))) + .max_by_key(|(index, _)| *index); + if let Some(marker) = marker { + let summary_tail = text.get(marker.0 + marker.1.len()..)?.trim_start(); + if summary_tail.is_empty() { + return None; + } + + let detail = summary_tail + .split("\n\n") + .next() + .map(str::trim) + .filter(|value| !value.is_empty())?; + return Some(detail.to_string()); + } + + let mut offset = 0usize; + let mut last_heading_end = None; + for line in text.split('\n') { + if is_assistant_phase_summary_heading_line(line.trim()) { + last_heading_end = Some(offset + line.len()); + } + offset += line.len() + 1; + } + + let summary_tail = text.get(last_heading_end?..)?.trim_start(); + if summary_tail.is_empty() { + return None; + } + + let detail = summary_tail + .split("\n\n") + .next() + .map(str::trim) + .filter(|value| !value.is_empty())?; + Some(detail.to_string()) +} + +fn is_assistant_phase_summary_heading_line(line: &str) -> bool { + let trimmed = line.trim(); + if trimmed == "阶段结论" { + return true; + } + + let without_hashes = trimmed.trim_start_matches('#').trim(); + without_hashes == "阶段结论" && without_hashes != trimmed +} + +fn strip_assistant_phase_summary_title(text: &str) -> String { + let mut sanitized_lines = Vec::new(); + let mut lines = text.lines().peekable(); + + while let Some(line) = lines.next() { + let trimmed = line.trim(); + if is_assistant_phase_summary_heading_line(trimmed) { + while lines.peek().is_some_and(|next| next.trim().is_empty()) { + lines.next(); + } + continue; + } + + if let Some(detail) = trimmed + .strip_prefix("阶段结论:") + .or_else(|| trimmed.strip_prefix("阶段结论:")) + { + let normalized_detail = detail.trim_start(); + if normalized_detail.is_empty() { + continue; + } + + let indent_width = line.len() - line.trim_start().len(); + let indent = &line[..indent_width]; + sanitized_lines.push(format!("{indent}{normalized_detail}")); + continue; + } + + sanitized_lines.push(line.to_string()); + } + + sanitized_lines.join("\n").trim().to_string() +} + fn build_reasoning_summary_sections(text: &str) -> Option> { let sections = text .split("\n\n") @@ -492,6 +1045,81 @@ fn tool_surface_updated_from_call_tool_result(result: &CallToolResult) -> bool { .unwrap_or(false) } +fn resolve_current_turn_tool_surface_mode() -> Option { + 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_stage_summary_reminder(message: &Message) -> bool { + is_agent_only_runtime_prompt(message, LOCAL_WORKSPACE_STAGE_SUMMARY_REMINDER) +} + +fn is_local_workspace_empty_followup_continue_prompt(message: &Message) -> bool { + is_agent_only_runtime_prompt(message, LOCAL_WORKSPACE_EMPTY_FOLLOWUP_CONTINUE_PROMPT) +} + +fn is_agent_only_runtime_prompt(message: &Message, expected_text: &str) -> bool { + message.metadata.agent_visible + && !message.metadata.user_visible + && message.role == Role::User + && message + .content + .iter() + .filter_map(MessageContent::as_text) + .any(|text| text.trim() == expected_text) +} + +fn already_has_local_workspace_stage_summary_reminder( + conversation: &Conversation, + pending_messages: &Conversation, +) -> bool { + pending_messages + .messages() + .last() + .or_else(|| conversation.messages().last()) + .is_some_and(is_local_workspace_stage_summary_reminder) +} + +fn already_has_local_workspace_empty_followup_continue_prompt( + conversation: &Conversation, + pending_messages: &Conversation, +) -> bool { + pending_messages + .messages() + .last() + .or_else(|| conversation.messages().last()) + .is_some_and(is_local_workspace_empty_followup_continue_prompt) +} + +fn should_queue_local_workspace_stage_summary_prompt( + tool_surface_mode: Option<&str>, + tool_request_count: usize, + already_has_pending_prompt: bool, +) -> bool { + matches!(tool_surface_mode, Some(TURN_TOOL_SURFACE_LOCAL_WORKSPACE)) + && tool_request_count > 0 + && !already_has_pending_prompt +} + +fn should_retry_local_workspace_empty_followup( + tool_surface_mode: Option<&str>, + conversation: &Conversation, + pending_messages: &Conversation, +) -> bool { + matches!(tool_surface_mode, Some(TURN_TOOL_SURFACE_LOCAL_WORKSPACE)) + && already_has_local_workspace_stage_summary_reminder(conversation, pending_messages) + && !already_has_local_workspace_empty_followup_continue_prompt( + conversation, + pending_messages, + ) +} + fn normalize_agent_optional_text(value: Option) -> Option { let trimmed = value?.trim().to_string(); if trimmed.is_empty() { @@ -931,7 +1559,8 @@ impl TurnItemRuntimeProjector { } let item_id = self.message_item_id(message, "assistant"); - let next_text = self.append_agent_message_text(&item_id, &text_content.text); + let raw_next_text = self.append_agent_message_text(&item_id, &text_content.text); + let next_text = strip_assistant_phase_summary_title(&raw_next_text); let mut events = vec![self.upsert_in_progress( item_id, ItemRuntimePayload::AgentMessage { @@ -939,13 +1568,53 @@ impl TurnItemRuntimeProjector { }, )]; - if let Some(plan_text) = extract_proposed_plan_block(&next_text) { + if let Some(plan_text) = extract_proposed_plan_block(&raw_next_text) { events.push(self.upsert_in_progress( format!("plan:{}", self.turn_id), ItemRuntimePayload::Plan { text: plan_text }, )); } + if let Some(detail) = extract_assistant_phase_summary_detail(&raw_next_text) { + let status_item_id = Agent::runtime_status_item_id(&self.turn_id); + let summary_title = self + .items + .get(&status_item_id) + .and_then(|item| match &item.payload { + ItemRuntimePayload::RuntimeStatus { phase, title, .. } + if phase == LOCAL_WORKSPACE_TOOL_BATCH_PHASE + && !title.trim().is_empty() => + { + Some(title.clone()) + } + _ => None, + }) + .unwrap_or_else(|| ASSISTANT_PHASE_SUMMARY_FALLBACK_TITLE.to_string()); + let should_emit_runtime_status = !matches!( + self.items.get(&status_item_id).map(|item| &item.payload), + Some(ItemRuntimePayload::RuntimeStatus { + phase, + detail: existing_detail, + checkpoints, + .. + }) if phase == LOCAL_WORKSPACE_TOOL_BATCH_PHASE + && existing_detail == &detail + && checkpoints.is_empty() + ); + + if should_emit_runtime_status { + events.push(self.upsert_in_progress( + status_item_id, + ItemRuntimePayload::RuntimeStatus { + phase: LOCAL_WORKSPACE_TOOL_BATCH_PHASE.to_string(), + title: summary_title, + detail, + checkpoints: Vec::new(), + }, + )); + } + } + events } @@ -2267,32 +2936,70 @@ impl Agent { ) -> Result> { let mut tool_futures: Vec<(String, ToolStream)> = Vec::new(); - // Handle pre-approved and read-only tools - for request in &permission_check_result.approved { - if let Ok(tool_call) = request.tool_call.clone() { - let (req_id, tool_result) = self - .dispatch_tool_call( - tool_call, - request.id.clone(), - cancel_token.clone(), - session, - ) - .await; + for batch in partition_tool_requests_for_execution(&permission_check_result.approved) { + if batch.is_concurrency_safe { + let batch_cancel_token = cancel_token.clone(); + let batch_results = join_all(batch.requests.into_iter().filter_map(|request| { + let cancel_token = batch_cancel_token.clone(); + request.tool_call.clone().ok().map(|tool_call| async move { + self.dispatch_tool_call( + tool_call, + request.id.clone(), + cancel_token, + session, + ) + .await + }) + })) + .await; - tool_futures.push(( - req_id, - match tool_result { - Ok(result) => tool_stream( - result - .notification_stream - .unwrap_or_else(|| Box::new(stream::empty())), - result.result, - ), - Err(e) => { - tool_stream(Box::new(stream::empty()), futures::future::ready(Err(e))) - } - }, - )); + for (req_id, tool_result) in batch_results { + tool_futures.push(( + req_id, + match tool_result { + Ok(result) => tool_stream( + result + .notification_stream + .unwrap_or_else(|| Box::new(stream::empty())), + result.result, + ), + Err(e) => tool_stream( + Box::new(stream::empty()), + futures::future::ready(Err(e)), + ), + }, + )); + } + continue; + } + + for request in batch.requests { + if let Ok(tool_call) = request.tool_call.clone() { + let (req_id, tool_result) = self + .dispatch_tool_call( + tool_call, + request.id.clone(), + cancel_token.clone(), + session, + ) + .await; + + tool_futures.push(( + req_id, + match tool_result { + Ok(result) => tool_stream( + result + .notification_stream + .unwrap_or_else(|| Box::new(stream::empty())), + result.result, + ), + Err(e) => tool_stream( + Box::new(stream::empty()), + futures::future::ready(Err(e)), + ), + }, + )); + } } } @@ -3357,6 +4064,7 @@ impl Agent { let mut turns_taken = 0u32; let max_turns = session_config.max_turns.unwrap_or(DEFAULT_MAX_TURNS); let mut overflow_handler = OverflowHandler::new(2); + let turn_tool_surface_mode = resolve_current_turn_tool_surface_mode(); if emit_context_trace && !context_trace.is_empty() { yield AgentEvent::ContextTrace { steps: context_trace }; @@ -3637,6 +4345,43 @@ impl Agent { } } + if turn_tool_surface_mode.as_deref() + == Some(TURN_TOOL_SURFACE_LOCAL_WORKSPACE) + { + if let Some((phase, title, detail, checkpoints)) = + build_local_workspace_tool_batch_runtime_status( + &frontend_requests, + &remaining_requests, + ) + { + let runtime_status_event = self + .upsert_runtime_status_item( + &session_config, + phase, + title, + detail, + checkpoints, + ) + .await?; + yield runtime_status_event; + } + } + + if should_queue_local_workspace_stage_summary_prompt( + turn_tool_surface_mode.as_deref(), + num_tool_requests, + already_has_local_workspace_stage_summary_reminder( + &conversation, + &messages_to_add, + ), + ) { + messages_to_add.push( + Message::user() + .with_text(LOCAL_WORKSPACE_STAGE_SUMMARY_REMINDER) + .agent_only(), + ); + } + no_tools_called = false; } } @@ -3778,6 +4523,16 @@ impl Agent { } } else if did_recovery_compact_this_iteration { // Avoid setting exit_chat; continue from last user message in the conversation + } else if should_retry_local_workspace_empty_followup( + turn_tool_surface_mode.as_deref(), + &conversation, + &messages_to_add, + ) { + let message = Message::user() + .with_text(LOCAL_WORKSPACE_EMPTY_FOLLOWUP_CONTINUE_PROMPT) + .agent_only(); + messages_to_add.push(message.clone()); + yield AgentEvent::Message(message); } else { match self.handle_retry_logic(&mut conversation, &session_config, &initial_messages).await { Ok(should_retry) => { @@ -4476,6 +5231,119 @@ mod tests { assert_eq!(build_reasoning_summary_sections(" "), None); } + #[test] + fn test_build_local_workspace_tool_batch_runtime_status() { + let frontend_requests = vec![ToolRequest { + id: "tool-1".to_string(), + tool_call: Ok(CallToolRequestParam { + name: "Glob".into(), + arguments: Some( + serde_json::json!({ + "path": "/Users/coso/Documents/dev/js/claudecode/src" + }) + .as_object() + .cloned() + .expect("glob arguments should be an object"), + ), + }), + metadata: None, + tool_meta: None, + }]; + let remaining_requests = vec![ + ToolRequest { + id: "tool-2".to_string(), + tool_call: Ok(CallToolRequestParam { + name: "Grep".into(), + arguments: Some( + serde_json::json!({ + "pattern": "scripts", + "path": "/Users/coso/Documents/dev/js/claudecode/package.json" + }) + .as_object() + .cloned() + .expect("grep arguments should be an object"), + ), + }), + metadata: None, + tool_meta: None, + }, + ToolRequest { + id: "tool-3".to_string(), + tool_call: Ok(CallToolRequestParam { + name: "Read".into(), + arguments: Some( + serde_json::json!({ + "file_path": "/Users/coso/Documents/dev/js/claudecode/package.json" + }) + .as_object() + .cloned() + .expect("read arguments should be an object"), + ), + }), + metadata: None, + tool_meta: None, + }, + ]; + + let runtime_status = build_local_workspace_tool_batch_runtime_status( + &frontend_requests, + &remaining_requests, + ) + .expect("should build local workspace runtime status"); + + assert_eq!(runtime_status.0, LOCAL_WORKSPACE_TOOL_BATCH_PHASE); + assert_eq!(runtime_status.1, "已完成一批本地分析"); + assert!( + runtime_status.2.contains("目录扫描、关键检索和文件读取"), + "detail should summarize batched operations" + ); + assert!( + runtime_status.2.contains("package.json"), + "detail should retain latest hint" + ); + assert_eq!( + runtime_status.3, + vec![ + "目录扫描:src".to_string(), + "关键检索:package.json / scripts".to_string(), + "文件读取:package.json".to_string(), + ] + ); + } + + #[test] + fn test_extract_assistant_phase_summary_detail() { + let text = "先说明\n阶段结论:第一次结论\n\n继续\n阶段结论:已确认主入口和长链路瓶颈,下一步只需补一个证据点。\n\n再继续"; + assert_eq!( + extract_assistant_phase_summary_detail(text).as_deref(), + Some("已确认主入口和长链路瓶颈,下一步只需补一个证据点。") + ); + let markdown_text = + "先说明\n## 阶段结论\n\n已确认主入口和长链路瓶颈,下一步只需补一个证据点。\n\n再继续"; + assert_eq!( + extract_assistant_phase_summary_detail(markdown_text).as_deref(), + Some("已确认主入口和长链路瓶颈,下一步只需补一个证据点。") + ); + assert_eq!( + extract_assistant_phase_summary_detail("阶段结论: "), + None + ); + } + + #[test] + fn test_strip_assistant_phase_summary_title() { + assert_eq!( + strip_assistant_phase_summary_title( + "先说明\n## 阶段结论\n\n已确认主入口和长链路瓶颈。" + ), + "先说明\n已确认主入口和长链路瓶颈。" + ); + assert_eq!( + strip_assistant_phase_summary_title("先说明\n阶段结论:已确认主入口和长链路瓶颈。"), + "先说明\n已确认主入口和长链路瓶颈。" + ); + } + #[test] fn test_project_message_emits_plan_runtime_item() { let turn = TurnRuntime::new( @@ -4501,6 +5369,68 @@ mod tests { ); } + #[test] + fn test_project_message_emits_phase_summary_runtime_item() { + let turn = TurnRuntime::new( + "turn-phase-summary", + "session-1", + "thread-1", + Some("分析本地项目".to_string()), + None, + ); + let mut projector = TurnItemRuntimeProjector::new(&turn); + projector.upsert_in_progress( + Agent::runtime_status_item_id(&turn.id), + ItemRuntimePayload::RuntimeStatus { + phase: LOCAL_WORKSPACE_TOOL_BATCH_PHASE.to_string(), + title: "已完成一批本地分析".to_string(), + detail: "启发式摘要".to_string(), + checkpoints: vec!["目录扫描:src".to_string()], + }, + ); + + let message = Message::assistant() + .with_id("assistant-msg-phase") + .with_text("好的。\n阶段结论:已确认主入口和长链路瓶颈,下一步只需补一个证据点。"); + + let events = projector.project_agent_event(&AgentEvent::Message(message)); + + assert!( + events.iter().any(|event| matches!( + event, + AgentEvent::ItemUpdated { item } + if item.id == "assistant:assistant-msg-phase" + && matches!( + &item.payload, + ItemRuntimePayload::AgentMessage { text } + if text == "好的。\n已确认主入口和长链路瓶颈,下一步只需补一个证据点。" + ) + )), + "assistant 消息正文不应再保留“阶段结论”标题" + ); + + assert!( + events.iter().any(|event| matches!( + event, + AgentEvent::ItemUpdated { item } + if item.id == Agent::runtime_status_item_id(&turn.id) + && matches!( + &item.payload, + ItemRuntimePayload::RuntimeStatus { + phase, + title, + detail, + checkpoints, + } if phase == LOCAL_WORKSPACE_TOOL_BATCH_PHASE + && title == "已完成一批本地分析" + && detail == "已确认主入口和长链路瓶颈,下一步只需补一个证据点。" + && checkpoints.is_empty() + ) + )), + "assistant 阶段结论应覆盖同一条 turn_summary" + ); + } + #[test] fn test_project_message_emits_reasoning_summary_runtime_item() { let turn = TurnRuntime::new( @@ -6255,4 +7185,227 @@ mod tests { Ok(()) } + + #[test] + fn test_partition_tool_requests_for_execution_groups_consecutive_safe_requests() { + fn request(id: &str, name: &str, arguments: Value) -> ToolRequest { + ToolRequest { + id: id.to_string(), + tool_call: Ok(CallToolRequestParam { + name: std::borrow::Cow::Owned(name.to_string()), + arguments: Some( + arguments + .as_object() + .cloned() + .expect("tool arguments should be an object"), + ), + }), + metadata: None, + tool_meta: None, + } + } + + let batches = partition_tool_requests_for_execution(&[ + request( + "req-read", + "Read", + serde_json::json!({ "path": "README.md" }), + ), + request( + "req-grep", + "Grep", + serde_json::json!({ "pattern": "Agent" }), + ), + request( + "req-write", + "Bash", + serde_json::json!({ "command": "mkdir scratch" }), + ), + request( + "req-bash", + "Bash", + serde_json::json!({ "command": "rg Agent src | head -n 5" }), + ), + ]); + + assert_eq!(batches.len(), 3); + assert!(batches[0].is_concurrency_safe); + assert_eq!(batches[0].requests.len(), 2); + assert!(!batches[1].is_concurrency_safe); + assert_eq!(batches[1].requests.len(), 1); + assert!(batches[2].is_concurrency_safe); + assert_eq!(batches[2].requests.len(), 1); + } + + #[tokio::test] + async fn test_handle_approved_and_denied_tools_runs_read_only_batch_in_parallel() -> Result<()> + { + struct SlowSuccessTool { + name: &'static str, + delay: Duration, + } + + #[async_trait::async_trait] + impl crate::tools::Tool for SlowSuccessTool { + fn name(&self) -> &str { + self.name + } + + fn description(&self) -> &str { + "slow success test tool" + } + + fn input_schema(&self) -> Value { + serde_json::json!({ + "type": "object", + "additionalProperties": true + }) + } + + async fn execute( + &self, + _params: Value, + _context: &crate::tools::ToolContext, + ) -> std::result::Result + { + tokio::time::sleep(self.delay).await; + Ok(crate::tools::ToolResult::success(format!( + "{} completed", + self.name + ))) + } + } + + fn request(id: &str, name: &str, arguments: Value) -> ToolRequest { + ToolRequest { + id: id.to_string(), + tool_call: Ok(CallToolRequestParam { + name: std::borrow::Cow::Owned(name.to_string()), + arguments: Some( + arguments + .as_object() + .cloned() + .expect("tool arguments should be an object"), + ), + }), + metadata: None, + tool_meta: None, + } + } + + let agent = Agent::new(); + { + let mut registry = agent.tool_registry().write().await; + let _ = registry.unregister("Read"); + let _ = registry.unregister("Glob"); + registry.register(Box::new(SlowSuccessTool { + name: "Read", + delay: Duration::from_millis(250), + })); + registry.register(Box::new(SlowSuccessTool { + name: "Glob", + delay: Duration::from_millis(250), + })); + } + + let temp_dir = tempfile::tempdir()?; + let session = SessionManager::create_session( + temp_dir.path().to_path_buf(), + "concurrent-read-only-batch".to_string(), + SessionType::User, + ) + .await?; + + let permission_check_result = PermissionCheckResult { + approved: vec![ + request( + "req-read", + "Read", + serde_json::json!({ "path": "README.md" }), + ), + request( + "req-glob", + "Glob", + serde_json::json!({ "pattern": "**/*.rs" }), + ), + ], + needs_approval: vec![], + denied: vec![], + }; + + let started_at = std::time::Instant::now(); + let tool_futures = agent + .handle_approved_and_denied_tools( + &permission_check_result, + &HashMap::new(), + None, + &session, + ) + .await?; + let elapsed = started_at.elapsed(); + + assert_eq!(tool_futures.len(), 2); + assert!( + elapsed < Duration::from_millis(420), + "read-only batch should execute concurrently, elapsed={elapsed:?}" + ); + + Ok(()) + } + + #[test] + fn test_should_queue_local_workspace_stage_summary_prompt() { + assert!(should_queue_local_workspace_stage_summary_prompt( + Some(TURN_TOOL_SURFACE_LOCAL_WORKSPACE), + 3, + false, + )); + assert!(should_queue_local_workspace_stage_summary_prompt( + Some(TURN_TOOL_SURFACE_LOCAL_WORKSPACE), + 3, + false, + )); + assert!(!should_queue_local_workspace_stage_summary_prompt( + Some("direct_answer"), + 3, + false, + )); + assert!(!should_queue_local_workspace_stage_summary_prompt( + Some(TURN_TOOL_SURFACE_LOCAL_WORKSPACE), + 3, + true, + )); + } + + #[test] + fn test_already_has_local_workspace_stage_summary_reminder() { + let conversation = Conversation::new_unvalidated(vec![Message::user() + .with_text(LOCAL_WORKSPACE_STAGE_SUMMARY_REMINDER) + .agent_only()]); + assert!(already_has_local_workspace_stage_summary_reminder( + &conversation, + &Conversation::default(), + )); + } + + #[test] + fn test_should_retry_local_workspace_empty_followup() { + let conversation = Conversation::new_unvalidated(vec![Message::user() + .with_text(LOCAL_WORKSPACE_STAGE_SUMMARY_REMINDER) + .agent_only()]); + assert!(should_retry_local_workspace_empty_followup( + Some(TURN_TOOL_SURFACE_LOCAL_WORKSPACE), + &conversation, + &Conversation::default(), + )); + + let already_retried = Conversation::new_unvalidated(vec![Message::user() + .with_text(LOCAL_WORKSPACE_EMPTY_FOLLOWUP_CONTINUE_PROMPT) + .agent_only()]); + assert!(!should_retry_local_workspace_empty_followup( + Some(TURN_TOOL_SURFACE_LOCAL_WORKSPACE), + &already_retried, + &Conversation::default(), + )); + } } diff --git a/src-tauri/crates/aster-rust/crates/aster/src/agents/extension_manager.rs b/src-tauri/crates/aster-rust/crates/aster/src/agents/extension_manager.rs index 3983dd33f..aaf0020d4 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/agents/extension_manager.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/agents/extension_manager.rs @@ -95,6 +95,7 @@ impl Extension { pub struct ExtensionManager { extensions: Mutex>, loaded_deferred_tools: Mutex>, + pending_extensions: Mutex>, context: Mutex, 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 = 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 = 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 { + let mut pending = self + .pending_extensions + .lock() + .await + .iter() + .cloned() + .collect::>(); + 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(); diff --git a/src-tauri/crates/aster-rust/crates/aster/src/agents/reply_parts.rs b/src-tauri/crates/aster-rust/crates/aster/src/agents/reply_parts.rs index 47a471187..882cdc6f3 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/agents/reply_parts.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/agents/reply_parts.rs @@ -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 { + 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_surface_mode: Option<&str>, +) -> Vec { + 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>, 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 { + 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() diff --git a/src-tauri/crates/aster-rust/crates/aster/src/hooks/loader.rs b/src-tauri/crates/aster-rust/crates/aster/src/hooks/loader.rs index 41722da99..a40f456f5 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/hooks/loader.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/hooks/loader.rs @@ -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 { match event { + "TaskCreated" => Some(HookEvent::TaskCreated), + "TaskCompleted" => Some(HookEvent::TaskCompleted), "PreToolUse" => Some(HookEvent::PreToolUse), "PostToolUse" => Some(HookEvent::PostToolUse), "PostToolUseFailure" => Some(HookEvent::PostToolUseFailure), diff --git a/src-tauri/crates/aster-rust/crates/aster/src/hooks/tests.rs b/src-tauri/crates/aster-rust/crates/aster/src/hooks/tests.rs index 927c97a86..daf65a3e2 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/hooks/tests.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/hooks/tests.rs @@ -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())); } diff --git a/src-tauri/crates/aster-rust/crates/aster/src/hooks/types.rs b/src-tauri/crates/aster-rust/crates/aster/src/hooks/types.rs index cd7040029..e4720ff1a 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/hooks/types.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/hooks/types.rs @@ -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, + /// 任务 ID + #[serde(default)] + pub task_id: Option, + /// 任务标题 + #[serde(default)] + pub task_subject: Option, + /// 任务描述 + #[serde(default)] + pub task_description: Option, + /// 触发任务事件的 teammate 名称 + #[serde(default)] + pub teammate_name: Option, + /// 关联 team 名称 + #[serde(default)] + pub team_name: Option, /// 工具名称 #[serde(default)] pub tool_name: Option, diff --git a/src-tauri/crates/aster-rust/crates/aster/src/prompt/templates.rs b/src-tauri/crates/aster-rust/crates/aster/src/prompt/templates.rs index 959d8f908..05b3b76c8 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/prompt/templates.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/prompt/templates.rs @@ -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. diff --git a/src-tauri/crates/aster-rust/crates/aster/src/prompts/capabilities.md b/src-tauri/crates/aster-rust/crates/aster/src/prompts/capabilities.md index 5fe0f53c4..55eb3c5c2 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/prompts/capabilities.md +++ b/src-tauri/crates/aster-rust/crates/aster/src/prompts/capabilities.md @@ -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. diff --git a/src-tauri/crates/aster-rust/crates/aster/src/prompts/system.md b/src-tauri/crates/aster-rust/crates/aster/src/prompts/system.md index ed5232b87..a0e7530b5 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/prompts/system.md +++ b/src-tauri/crates/aster-rust/crates/aster/src/prompts/system.md @@ -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. diff --git a/src-tauri/crates/aster-rust/crates/aster/src/prompts/system_gpt_4.1.md b/src-tauri/crates/aster-rust/crates/aster/src/prompts/system_gpt_4.1.md index 375b64460..93f1b41a2 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/prompts/system_gpt_4.1.md +++ b/src-tauri/crates/aster-rust/crates/aster/src/prompts/system_gpt_4.1.md @@ -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 %} diff --git a/src-tauri/crates/aster-rust/crates/aster/src/providers/anthropic.rs b/src-tauri/crates/aster-rust/crates/aster/src/providers/anthropic.rs index 6466de4ea..8472cbd16 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/providers/anthropic.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/providers/anthropic.rs @@ -122,6 +122,17 @@ impl AnthropicProvider { Ok(request.api_post(payload).await?) } + async fn post_stream(&self, payload: &Value) -> Result { + 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 { 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); diff --git a/src-tauri/crates/aster-rust/crates/aster/src/providers/api_client.rs b/src-tauri/crates/aster-rust/crates/aster/src/providers/api_client.rs index b2969f22f..7c6a36fd1 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/providers/api_client.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/providers/api_client.rs @@ -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 { diff --git a/src-tauri/crates/aster-rust/crates/aster/src/providers/formats/anthropic.rs b/src-tauri/crates/aster-rust/crates/aster/src/providers/formats/anthropic.rs index 468274bf2..180cc5b25 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/providers/formats/anthropic.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/providers/formats/anthropic.rs @@ -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 = None; let mut final_usage: Option = None; let mut message_id: Option = 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::(&args) { + match parse_tool_arguments_json_object(&args) { Ok(parsed) => parsed, Err(_) => { // If parsing fails, create an error tool request diff --git a/src-tauri/crates/aster-rust/crates/aster/src/providers/formats/databricks.rs b/src-tauri/crates/aster-rust/crates/aster/src/providers/formats/databricks.rs index 0770d944a..f83216e63 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/providers/formats/databricks.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/providers/formats/databricks.rs @@ -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 { }; 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, diff --git a/src-tauri/crates/aster-rust/crates/aster/src/providers/formats/openai.rs b/src-tauri/crates/aster-rust/crates/aster/src/providers/formats/openai.rs index ead39d1c1..a1d5dbb24 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/providers/formats/openai.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/providers/formats/openai.rs @@ -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::(arguments) + parse_tool_arguments_json_object(arguments) }; let content = match parsed { diff --git a/src-tauri/crates/aster-rust/crates/aster/src/providers/formats/openai_responses.rs b/src-tauri/crates/aster-rust/crates/aster/src/providers/formats/openai_responses.rs index 89b9ac174..782e0cc40 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/providers/formats/openai_responses.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/providers/formats/openai_responses.rs @@ -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, + }), + )); + } + } } } } diff --git a/src-tauri/crates/aster-rust/crates/aster/src/providers/formats/snowflake.rs b/src-tauri/crates/aster-rust/crates/aster/src/providers/formats/snowflake.rs index d52a82160..7f0e981a1 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/providers/formats/snowflake.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/providers/formats/snowflake.rs @@ -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 { // 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::(&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 { diff --git a/src-tauri/crates/aster-rust/crates/aster/src/providers/utils.rs b/src-tauri/crates/aster-rust/crates/aster/src/providers/utils.rs index 403d60266..c1cece8bc 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/providers/utils.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/providers/utils.rs @@ -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, + first_text_preview: Option, +} + +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::(); + 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::>(), + }), + } +} + +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::>(); + + 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::>(); + 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::>(); + + 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::>()), + ); + + 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) -> Value +where + Payload: Any, +{ + if let Some(value) = (payload as &dyn Any).downcast_ref::() { + return summarize_request_log_json_payload(value, type_name::()); + } + + json!({ + "logging_mode": "summary", + "payload_type": type_name::(), + }) +} impl RequestLog { pub fn start(model_config: &ModelConfig, payload: &Payload) -> Result 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 &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 = OnceLock::new(); + TOOL_PROTOCOL_TAG_RE.get_or_init(|| { + Regex::new( + r#"(?is)]*>"#, + ) + .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, 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 = None; + let mut stack: Vec = 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 { + 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 { + 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 { + 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"} "#; + 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#"{"command":"ls /Users/coso/Documents/dev/js/claudecode"}"#; + 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()); + } } diff --git a/src-tauri/crates/aster-rust/crates/aster/src/providers/venice.rs b/src-tauri/crates/aster-rust/crates/aster/src/providers/venice.rs index d32692485..0846f0cae 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/providers/venice.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/providers/venice.rs @@ -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::(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); } diff --git a/src-tauri/crates/aster-rust/crates/aster/src/tools/agent_control.rs b/src-tauri/crates/aster-rust/crates/aster/src/tools/agent_control.rs index 368b00e3b..c1d9ff77a 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/tools/agent_control.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/tools/agent_control.rs @@ -25,6 +25,8 @@ pub type SpawnAgentCallback = pub type SendInputCallback = Arc CallbackFuture + Send + Sync>; +const SEND_MESSAGE_TOOL_ALIASES: &[&str] = &["SendMessageTool", "SendInput", "SendInputTool"]; + #[derive(Clone, Default)] pub struct AgentControlToolConfig { pub spawn_agent: Option, @@ -115,7 +117,7 @@ pub struct SpawnAgentResponse { } #[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] +#[serde(deny_unknown_fields, rename_all = "camelCase")] struct AgentToolInput { description: String, prompt: String, @@ -151,7 +153,7 @@ pub struct SendInputRequest { } #[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] +#[serde(deny_unknown_fields, rename_all = "camelCase")] struct SendMessageToolInput { to: String, #[serde(default)] @@ -159,6 +161,78 @@ struct SendMessageToolInput { message: Value, } +#[derive(Debug, Clone, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +enum StructuredMessage { + ShutdownRequest { + #[serde(default)] + reason: Option, + }, + ShutdownResponse { + request_id: String, + approve: bool, + #[serde(default)] + reason: Option, + }, + PlanApprovalResponse { + request_id: String, + approve: bool, + #[serde(default)] + feedback: Option, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct MessageRouting { + pub sender: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub sender_color: Option, + pub target: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub target_color: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub summary: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub content: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct MessageOutput { + pub success: bool, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub routing: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct BroadcastOutput { + pub success: bool, + pub message: String, + pub recipients: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub routing: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct RequestOutput { + pub success: bool, + pub message: String, + #[serde(rename = "request_id")] + pub request_id: String, + pub target: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ResponseOutput { + pub success: bool, + pub message: String, + #[serde(rename = "request_id", skip_serializing_if = "Option::is_none")] + pub request_id: Option, +} + +pub type SendMessageToolOutput = Value; + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct SendInputResponse { pub submission_id: String, @@ -166,6 +240,12 @@ pub struct SendInputResponse { pub extra: BTreeMap, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PeerAddressScheme { + Uds, + Bridge, +} + fn normalize_required_text(value: &str, field_name: &str) -> Result { let trimmed = value.trim(); if trimmed.is_empty() { @@ -175,6 +255,11 @@ fn normalize_required_text(value: &str, field_name: &str) -> Result(value: &T) -> Result { + serde_json::to_string_pretty(value) + .map_err(|error| ToolError::execution_failed(format!("序列化结果失败: {error}"))) +} + #[derive(Clone)] pub struct SpawnAgentTool { callback: SpawnAgentCallback, @@ -300,16 +385,20 @@ impl Tool for SendInputTool { "SendMessage" } + fn aliases(&self) -> &'static [&'static str] { + SEND_MESSAGE_TOOL_ALIASES + } + fn description(&self) -> &str { - "Send a message to another agent. 优先复用已有 agent 的上下文继续推进任务,而不是重复创建新 agent;当前 runtime 支持直接发送给 agent id,也支持在活跃 team 内按名字或 `*` 广播路由,但不保留旧跨会话 peer surface。" + "Send a message to another agent. 优先复用已有 agent 的上下文继续推进任务,而不是重复创建新 agent;当前 Lime runtime 支持直接发送给 agent id、命名子 session,以及活跃 team 内按名字或 `*` 广播路由。上游 `uds:` / `bridge:` 跨会话 peer address 目前只做显式识别,不会误当作普通 agent id 投递。" } fn input_schema(&self) -> Value { serde_json::json!({ "type": "object", "properties": { - "to": { "type": "string", "description": "目标 agent 标识。可传 agent id;若当前 session 属于活跃 team,也可传 teammate 名称或 `*` 广播给所有其他 team 成员。" }, - "summary": { "type": "string", "description": "可选的 5-10 词预览摘要;当前 runtime 仅保留到 metadata,不参与路由。" }, + "to": { "type": "string", "description": "目标 agent 标识。可传 agent id、命名子 session 名称;若当前 session 属于活跃 team,也可传 teammate 名称、ListPeers 返回的 `agent_id`,或 `*` 广播给所有其他 team 成员。上游 `uds:` / `bridge:` peer address 当前会返回未实现失败。" }, + "summary": { "type": "string", "description": "纯字符串消息必填的 5-10 词预览摘要;当前 runtime 仅保留到 metadata,不参与路由。" }, "message": { "description": "发送给目标 agent 的消息内容。字符串会直接发送;结构化 JSON 会被序列化为字符串后发送。", "oneOf": [ @@ -331,23 +420,96 @@ impl Tool for SendInputTool { let input: SendMessageToolInput = serde_json::from_value(params) .map_err(|error| ToolError::invalid_params(format!("SendMessage 参数无效: {error}")))?; let target = normalize_required_text(&input.to, "to")?; - let message = match input.message { - Value::String(text) => normalize_required_text(&text, "message")?, - other => serde_json::to_string(&other) - .map_err(|error| { - ToolError::invalid_params(format!("SendMessage 无法序列化结构化消息: {error}")) - })? - .trim() - .to_string(), + let summary = normalize_optional_text(input.summary.clone()); + if let Some(scheme) = parse_peer_address(&target) { + return send_message_unsupported_peer_result(&target, summary, scheme); + } + let canonical_target = normalize_send_target(&context.session_id, &target).await?; + if input.message.is_string() && input.summary.as_deref().unwrap_or("").trim().is_empty() { + return Err(ToolError::invalid_params( + "summary is required when message is a string", + )); + } + let structured_message = + serde_json::from_value::(input.message.clone()).ok(); + if target == "*" && structured_message.is_some() { + return Err(ToolError::invalid_params( + "structured messages cannot be broadcast (to: \"*\")", + )); + } + + if let Some(StructuredMessage::ShutdownResponse { + request_id: _, + approve, + reason, + }) = structured_message.as_ref() + { + if canonical_target != "team-lead" { + return Err(ToolError::invalid_params( + "shutdown_response must be sent to \"team-lead\"", + )); + } + if !approve && reason.as_deref().unwrap_or("").trim().is_empty() { + return Err(ToolError::invalid_params( + "reason is required when rejecting a shutdown request", + )); + } + } + + if let Some(StructuredMessage::PlanApprovalResponse { approve, .. }) = + structured_message.as_ref() + { + let team_context = + resolve_team_context(&context.session_id) + .await + .map_err(|error| { + ToolError::execution_failed(format!("读取 team 状态失败: {error}")) + })?; + if !team_context + .as_ref() + .is_some_and(|team_context| team_context.is_lead) + { + return Err(ToolError::invalid_params(if *approve { + "Only the team lead can approve plans. Teammates cannot approve their own or other plans." + } else { + "Only the team lead can reject plans. Teammates cannot reject their own or other plans." + })); + } + } + + let message = match structured_message.as_ref() { + Some(StructuredMessage::ShutdownRequest { reason }) => serde_json::to_string(&json!({ + "type": "shutdown_request", + "request_id": generate_request_id("shutdown", &canonical_target), + "reason": reason.clone(), + })) + .map_err(|error| { + ToolError::invalid_params(format!( + "SendMessage 无法序列化 shutdown_request: {error}" + )) + })?, + Some(_) => serde_json::to_string(&input.message).map_err(|error| { + ToolError::invalid_params(format!("SendMessage 无法序列化结构化消息: {error}")) + })?, + None => match input.message { + Value::String(text) => normalize_required_text(&text, "message")?, + other => serde_json::to_string(&other) + .map_err(|error| { + ToolError::invalid_params(format!( + "SendMessage 无法序列化结构化消息: {error}" + )) + })? + .trim() + .to_string(), + }, }; if message.is_empty() { return Err(ToolError::invalid_params("message 不能为空")); } - let resolved_targets = resolve_send_targets(&context.session_id, &target).await?; - let summary = normalize_optional_text(input.summary); + let resolved_targets = resolve_send_targets(&context.session_id, &canonical_target).await?; let mut deliveries = Vec::with_capacity(resolved_targets.len()); - for resolved_target in resolved_targets { + for resolved_target in &resolved_targets { let request = SendInputRequest { id: resolved_target.agent_id.clone(), message: message.clone(), @@ -364,22 +526,150 @@ impl Tool for SendInputTool { })); } + let sender = resolve_sender_name(&context.session_id).await; + let routing = if target == "*" { + Some(MessageRouting { + sender, + sender_color: None, + target: "@team".to_string(), + target_color: None, + summary: summary.clone(), + content: Some(message.clone()), + }) + } else { + resolved_targets + .first() + .map(|resolved_target| MessageRouting { + sender, + sender_color: None, + target: resolved_target.routing_target.clone(), + target_color: None, + summary: summary.clone(), + content: Some(message.clone()), + }) + }; + + let structured_output = if let Some(structured_message) = structured_message.as_ref() { + match structured_message { + StructuredMessage::ShutdownRequest { .. } => { + let request_id = serde_json::from_str::(&message) + .ok() + .and_then(|value| value.get("request_id").cloned()) + .and_then(|value| value.as_str().map(ToString::to_string)) + .ok_or_else(|| { + ToolError::execution_failed("shutdown_request 缺少 request_id") + })?; + serde_json::to_value(RequestOutput { + success: true, + message: format!( + "Shutdown request sent to {target}. Request ID: {request_id}" + ), + request_id, + target: target.clone(), + }) + .map_err(|error| { + ToolError::execution_failed(format!("序列化 SendMessage 结果失败: {error}")) + })? + } + StructuredMessage::ShutdownResponse { + request_id, + approve, + reason, + } => serde_json::to_value(ResponseOutput { + success: true, + message: if *approve { + format!("Shutdown approved. Request ID: {request_id}") + } else { + format!( + "Shutdown rejected. Reason: \"{}\". Continuing to work.", + reason.clone().unwrap_or_default() + ) + }, + request_id: Some(request_id.clone()), + }) + .map_err(|error| { + ToolError::execution_failed(format!("序列化 SendMessage 结果失败: {error}")) + })?, + StructuredMessage::PlanApprovalResponse { + request_id, + approve, + feedback, + } => serde_json::to_value(ResponseOutput { + success: true, + message: if *approve { + format!("Plan approved for {target}. Request ID: {request_id}") + } else { + format!( + "Plan rejected for {target} with feedback: \"{}\"", + feedback + .clone() + .unwrap_or_else(|| "Plan needs revision".to_string()) + ) + }, + request_id: Some(request_id.clone()), + }) + .map_err(|error| { + ToolError::execution_failed(format!("序列化 SendMessage 结果失败: {error}")) + })?, + } + } else if target == "*" { + let recipients = deliveries + .iter() + .filter_map(|delivery| delivery.get("target").and_then(Value::as_str)) + .map(ToString::to_string) + .collect::>(); + if recipients.is_empty() { + serde_json::to_value(BroadcastOutput { + success: true, + message: "No teammates to broadcast to (you are the only team member)" + .to_string(), + recipients, + routing, + }) + .map_err(|error| { + ToolError::execution_failed(format!("序列化 SendMessage 结果失败: {error}")) + })? + } else { + serde_json::to_value(BroadcastOutput { + success: true, + message: format!( + "Message broadcast to {} teammate(s): {}", + recipients.len(), + recipients.join(", ") + ), + recipients, + routing, + }) + .map_err(|error| { + ToolError::execution_failed(format!("序列化 SendMessage 结果失败: {error}")) + })? + } + } else { + let label = deliveries[0]["target"] + .as_str() + .unwrap_or(&target) + .to_string(); + serde_json::to_value(MessageOutput { + success: true, + message: format!("Message sent to {label}"), + routing, + }) + .map_err(|error| { + ToolError::execution_failed(format!("序列化 SendMessage 结果失败: {error}")) + })? + }; + let mut metadata = serde_json::Map::new(); - metadata.insert("success".to_string(), Value::Bool(true)); - metadata.insert("deliveries".to_string(), Value::Array(deliveries.clone())); - metadata.insert("target".to_string(), Value::String(target.clone())); + if let Value::Object(map) = structured_output.clone() { + metadata.extend(map); + } if let Some(summary) = summary { metadata.insert("summary".to_string(), Value::String(summary)); } + metadata.insert("deliveries".to_string(), Value::Array(deliveries)); + metadata.insert("target".to_string(), Value::String(target)); - let success_message = if deliveries.len() == 1 { - let label = deliveries[0]["target"].as_str().unwrap_or(&target); - format!("Message sent to {label}") - } else { - format!("Message sent to {} teammates", deliveries.len()) - }; - - Ok(ToolResult::success(success_message) + Ok(ToolResult::success(pretty_json(&structured_output)?) .with_metadata("send_message", Value::Object(metadata))) } } @@ -388,6 +678,102 @@ impl Tool for SendInputTool { struct ResolvedSendTarget { display_name: String, agent_id: String, + routing_target: String, +} + +fn parse_peer_address(target: &str) -> Option { + if target.starts_with("uds:") { + return Some(PeerAddressScheme::Uds); + } + if target.starts_with("bridge:") { + return Some(PeerAddressScheme::Bridge); + } + + None +} + +fn send_message_unsupported_peer_result( + target: &str, + summary: Option, + scheme: PeerAddressScheme, +) -> Result { + let message = match scheme { + PeerAddressScheme::Uds => "Known upstream peer address surface (`uds:`), but the current Lime runtime does not expose cross-session local peer messaging through SendMessage yet.", + PeerAddressScheme::Bridge => "Known upstream peer address surface (`bridge:`), but the current Lime runtime does not expose cross-session remote peer messaging through SendMessage yet.", + }; + let output = serde_json::to_value(MessageOutput { + success: false, + message: message.to_string(), + routing: None, + }) + .map_err(|error| { + ToolError::execution_failed(format!("序列化 SendMessage 结果失败: {error}")) + })?; + + let mut metadata = serde_json::Map::new(); + if let Value::Object(map) = output.clone() { + metadata.extend(map); + } + if let Some(summary) = summary { + metadata.insert("summary".to_string(), Value::String(summary)); + } + metadata.insert("deliveries".to_string(), Value::Array(Vec::new())); + metadata.insert("target".to_string(), Value::String(target.to_string())); + metadata.insert( + "unsupportedTargetScheme".to_string(), + Value::String( + match scheme { + PeerAddressScheme::Uds => "uds", + PeerAddressScheme::Bridge => "bridge", + } + .to_string(), + ), + ); + + Ok(ToolResult::success(pretty_json(&output)?) + .with_metadata("send_message", Value::Object(metadata))) +} + +fn split_team_display_id(target: &str) -> Option<(&str, &str)> { + let (name, team_name) = target.split_once('@')?; + let name = name.trim(); + let team_name = team_name.trim(); + if name.is_empty() || team_name.is_empty() { + None + } else { + Some((name, team_name)) + } +} + +async fn normalize_send_target(session_id: &str, target: &str) -> Result { + let Some((name, team_name)) = split_team_display_id(target) else { + return Ok(target.to_string()); + }; + + let Some(team_context) = resolve_team_context(session_id) + .await + .map_err(|error| ToolError::execution_failed(format!("读取 team 状态失败: {error}")))? + else { + return Err(ToolError::invalid_params( + "to 必须是裸 teammate 名称、agent id,或当前活跃 team 的 `name@team` 标识", + )); + }; + + if team_context.team_state.team_name != team_name { + return Err(ToolError::invalid_params(format!( + "目标 team 不匹配:当前 team 为 {},但收到 {}", + team_context.team_state.team_name, team_name + ))); + } + + if team_context.team_state.find_member_by_name(name).is_none() { + return Err(ToolError::invalid_params(format!( + "team {} 中不存在名为 {} 的成员", + team_name, name + ))); + } + + Ok(name.to_string()) } async fn resolve_send_targets( @@ -408,16 +794,16 @@ async fn resolve_send_targets( .members .into_iter() .filter(|member| member.agent_id != team_context.current_agent_id) - .map(|member| ResolvedSendTarget { - display_name: member.name, - agent_id: member.agent_id, + .map(|member| { + let display_name = member.name.clone(); + let routing_target = format!("@{}", display_name); + ResolvedSendTarget { + display_name, + agent_id: member.agent_id, + routing_target, + } }) .collect::>(); - if recipients.is_empty() { - return Err(ToolError::execution_failed( - "当前 team 没有其他成员可接收消息", - )); - } return Ok(recipients); } @@ -435,6 +821,7 @@ async fn resolve_send_targets( return Ok(vec![ResolvedSendTarget { display_name: member.name.clone(), agent_id: member.agent_id.clone(), + routing_target: format!("@{}", member.name), }]); } } @@ -448,15 +835,40 @@ async fn resolve_send_targets( return Ok(vec![ResolvedSendTarget { display_name: target.to_string(), agent_id: child_session.id, + routing_target: target.to_string(), }]); } Ok(vec![ResolvedSendTarget { display_name: target.to_string(), agent_id: target.to_string(), + routing_target: target.to_string(), }]) } +async fn resolve_sender_name(session_id: &str) -> String { + resolve_team_context(session_id) + .await + .ok() + .flatten() + .map(|team_context| team_context.current_member_name) + .filter(|name| !name.trim().is_empty()) + .unwrap_or_else(|| { + if session_id.trim().is_empty() { + "current-session".to_string() + } else { + session_id.to_string() + } + }) +} + +fn generate_request_id(prefix: &str, target: &str) -> String { + format!( + "{prefix}-{}@{target}", + chrono::Utc::now().timestamp_millis() + ) +} + fn normalize_optional_text(value: Option) -> Option { let trimmed = value?.trim().to_string(); if trimmed.is_empty() { @@ -469,11 +881,15 @@ fn normalize_optional_text(value: Option) -> Option { #[cfg(test)] mod tests { use super::*; - use crate::session::{SessionManager, SessionType, SubagentSessionMetadata}; + use crate::session::{ + save_team_membership, save_team_state, SessionManager, SessionType, + SubagentSessionMetadata, TeamMember, TeamMembershipState, TeamSessionState, + }; use crate::tools::Tool; use std::path::PathBuf; use std::time::Duration; use tempfile::tempdir; + use uuid::Uuid; fn create_context(session_id: &str) -> ToolContext { ToolContext::new(PathBuf::from("/tmp")).with_session_id(session_id) @@ -549,6 +965,32 @@ mod tests { assert_eq!(result.metadata["agent"]["description"], "排查失败"); } + #[tokio::test] + async fn test_agent_tool_rejects_unknown_fields() { + let tool = SpawnAgentTool::new(Arc::new(|_request| { + Box::pin(async move { + panic!("unknown fields should be rejected before dispatch"); + }) + })); + + let result = tool + .execute( + serde_json::json!({ + "description": "排查失败", + "prompt": "请检查测试失败原因", + "unexpected": true + }), + &create_test_context(), + ) + .await; + + assert!(matches!( + result, + Err(ToolError::InvalidParams(message)) + if message.contains("unknown field `unexpected`") + )); + } + #[tokio::test] async fn test_send_message_tool_accepts_current_surface() { let tool = SendInputTool::new(Arc::new(|request| { @@ -575,7 +1017,14 @@ mod tests { .await .unwrap(); - assert_eq!(result.output.as_deref(), Some("Message sent to agent-7")); + let output: Value = serde_json::from_str(result.output.as_deref().unwrap()).unwrap(); + assert_eq!(output["success"], json!(true)); + assert_eq!(output["message"], json!("Message sent to agent-7")); + assert_eq!(output["routing"]["target"], json!("agent-7")); + assert_eq!( + output["routing"]["content"], + json!("{\"kind\":\"follow_up\"}") + ); assert_eq!(result.metadata["send_message"]["target"], "agent-7"); assert_eq!(result.metadata["send_message"]["summary"], "继续处理"); assert!(result.metadata["send_message"].get("interrupt").is_none()); @@ -646,6 +1095,7 @@ mod tests { .execute( serde_json::json!({ "to": "verifier", + "summary": "继续验证 verifier", "message": "继续验证" }), &create_context(&parent.id), @@ -653,10 +1103,570 @@ mod tests { .await .unwrap(); - assert_eq!(result.output.as_deref(), Some("Message sent to verifier")); + let output: Value = serde_json::from_str(result.output.as_deref().unwrap()).unwrap(); + assert_eq!(output["message"], json!("Message sent to verifier")); + assert_eq!(output["routing"]["target"], json!("verifier")); assert_eq!( result.metadata["send_message"]["deliveries"][0]["agentId"], newer_child.id ); } + + #[tokio::test] + #[serial_test::serial] + async fn test_send_message_accepts_team_display_agent_id() { + let temp_dir = tempdir().unwrap(); + let lead = SessionManager::create_session( + temp_dir.path().to_path_buf(), + format!("team-display-lead-{}", Uuid::new_v4()), + SessionType::Hidden, + ) + .await + .unwrap(); + let teammate = SessionManager::create_session( + temp_dir.path().to_path_buf(), + format!("team-display-child-{}", Uuid::new_v4()), + SessionType::SubAgent, + ) + .await + .unwrap(); + + let team_state = 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( + teammate.id.clone(), + "researcher", + Some("explorer".to_string()), + ), + ], + }; + save_team_state(&lead.id, Some(team_state)).await.unwrap(); + save_team_membership( + &teammate.id, + Some(TeamMembershipState { + team_name: "alpha".to_string(), + lead_session_id: lead.id.clone(), + agent_id: teammate.id.clone(), + name: "researcher".to_string(), + agent_type: Some("explorer".to_string()), + }), + ) + .await + .unwrap(); + + let expected_target_id = lead.id.clone(); + let tool = SendInputTool::new(Arc::new(move |request| { + let expected_target_id = expected_target_id.clone(); + Box::pin(async move { + assert_eq!(request.id, expected_target_id); + assert_eq!(request.message, "继续同步结果"); + Ok(SendInputResponse { + submission_id: "submission-display-id".to_string(), + extra: BTreeMap::new(), + }) + }) + })); + + let result = tool + .execute( + serde_json::json!({ + "to": "team-lead@alpha", + "summary": "同步给 lead", + "message": "继续同步结果" + }), + &create_context(&teammate.id), + ) + .await + .unwrap(); + + let output: Value = serde_json::from_str(result.output.as_deref().unwrap()).unwrap(); + assert_eq!(output["success"], json!(true)); + assert_eq!(output["message"], json!("Message sent to team-lead")); + assert_eq!(output["routing"]["target"], json!("@team-lead")); + assert_eq!(result.metadata["send_message"]["target"], "team-lead@alpha"); + } + + #[tokio::test] + async fn test_send_message_returns_shutdown_request_output_shape() { + let tool = SendInputTool::new(Arc::new(|request| { + Box::pin(async move { + assert_eq!(request.id, "researcher"); + let payload: Value = serde_json::from_str(&request.message).unwrap(); + assert_eq!(payload["type"], json!("shutdown_request")); + assert!(payload["request_id"] + .as_str() + .unwrap() + .starts_with("shutdown-")); + assert!(payload["request_id"] + .as_str() + .unwrap() + .ends_with("@researcher")); + Ok(SendInputResponse { + submission_id: "submission-3".to_string(), + extra: BTreeMap::new(), + }) + }) + })); + + let result = tool + .execute( + serde_json::json!({ + "to": "researcher", + "message": { + "type": "shutdown_request", + "reason": "team done" + } + }), + &create_test_context(), + ) + .await + .unwrap(); + + let output: Value = serde_json::from_str(result.output.as_deref().unwrap()).unwrap(); + assert_eq!(output["success"], json!(true)); + assert_eq!(output["target"], json!("researcher")); + assert!(output["request_id"] + .as_str() + .unwrap() + .starts_with("shutdown-")); + assert!(output["request_id"] + .as_str() + .unwrap() + .ends_with("@researcher")); + } + + #[tokio::test] + async fn test_send_message_requires_summary_for_plain_text_without_dispatch() { + let tool = SendInputTool::new(Arc::new(|_request| { + Box::pin(async move { + panic!("plain text message without summary should be rejected before dispatch"); + }) + })); + + let result = tool + .execute( + serde_json::json!({ + "to": "researcher", + "message": "继续处理" + }), + &create_test_context(), + ) + .await; + + assert!(matches!( + result, + Err(ToolError::InvalidParams(message)) + if message == "summary is required when message is a string" + )); + } + + #[tokio::test] + async fn test_send_message_rejects_unknown_fields() { + let tool = SendInputTool::new(Arc::new(|_request| { + Box::pin(async move { + panic!("unknown fields should be rejected before dispatch"); + }) + })); + + let result = tool + .execute( + serde_json::json!({ + "to": "researcher", + "summary": "继续处理", + "message": "继续处理", + "unexpected": true + }), + &create_test_context(), + ) + .await; + + assert!(matches!( + result, + Err(ToolError::InvalidParams(message)) + if message.contains("unknown field `unexpected`, expected one of `to`, `summary`, `message`") + )); + } + + #[tokio::test] + async fn test_send_message_returns_structured_failure_for_uds_peer_target() { + let tool = SendInputTool::new(Arc::new(|_request| { + Box::pin(async move { + panic!("uds peer target should not dispatch to send_input callback"); + }) + })); + + let result = tool + .execute( + serde_json::json!({ + "to": "uds:/tmp/peer.sock", + "message": "继续验证" + }), + &create_test_context(), + ) + .await + .unwrap(); + + assert!(result.success); + let output: Value = serde_json::from_str(result.output.as_deref().unwrap()).unwrap(); + assert_eq!(output["success"], json!(false)); + assert!(output["message"] + .as_str() + .unwrap() + .contains("does not expose cross-session local peer messaging")); + assert_eq!( + result.metadata["send_message"]["unsupportedTargetScheme"], + json!("uds") + ); + assert_eq!( + result.metadata["send_message"]["target"], + "uds:/tmp/peer.sock" + ); + } + + #[tokio::test] + async fn test_send_message_returns_structured_failure_for_bridge_peer_target() { + let tool = SendInputTool::new(Arc::new(|_request| { + Box::pin(async move { + panic!("bridge peer target should not dispatch to send_input callback"); + }) + })); + + let result = tool + .execute( + serde_json::json!({ + "to": "bridge:session_123", + "summary": "同步远端会话", + "message": "继续验证" + }), + &create_test_context(), + ) + .await + .unwrap(); + + assert!(result.success); + let output: Value = serde_json::from_str(result.output.as_deref().unwrap()).unwrap(); + assert_eq!(output["success"], json!(false)); + assert!(output["message"] + .as_str() + .unwrap() + .contains("does not expose cross-session remote peer messaging")); + assert_eq!( + result.metadata["send_message"]["unsupportedTargetScheme"], + json!("bridge") + ); + assert_eq!(result.metadata["send_message"]["summary"], "同步远端会话"); + assert_eq!( + result.metadata["send_message"]["target"], + "bridge:session_123" + ); + } + + #[tokio::test] + async fn test_send_message_rejects_structured_broadcast() { + let tool = SendInputTool::new(Arc::new(|_request| { + Box::pin(async move { + Ok(SendInputResponse { + submission_id: "submission-unused".to_string(), + extra: BTreeMap::new(), + }) + }) + })); + + let result = tool + .execute( + serde_json::json!({ + "to": "*", + "message": { + "type": "plan_approval_response", + "request_id": "req-1", + "approve": true + } + }), + &create_test_context(), + ) + .await; + + assert!(matches!( + result, + Err(ToolError::InvalidParams(message)) + if message == "structured messages cannot be broadcast (to: \"*\")" + )); + } + + #[tokio::test] + async fn test_send_message_rejects_shutdown_response_without_reason() { + let tool = SendInputTool::new(Arc::new(|_request| { + Box::pin(async move { + Ok(SendInputResponse { + submission_id: "submission-unused".to_string(), + extra: BTreeMap::new(), + }) + }) + })); + + let result = tool + .execute( + serde_json::json!({ + "to": "team-lead", + "message": { + "type": "shutdown_response", + "request_id": "req-1", + "approve": false + } + }), + &create_test_context(), + ) + .await; + + assert!(matches!( + result, + Err(ToolError::InvalidParams(message)) + if message == "reason is required when rejecting a shutdown request" + )); + } + + #[tokio::test] + #[serial_test::serial] + async fn test_send_message_accepts_shutdown_response_for_team_lead_display_id() { + let temp_dir = tempdir().unwrap(); + let lead = SessionManager::create_session( + temp_dir.path().to_path_buf(), + format!("team-display-response-lead-{}", Uuid::new_v4()), + SessionType::Hidden, + ) + .await + .unwrap(); + let teammate = SessionManager::create_session( + temp_dir.path().to_path_buf(), + format!("team-display-response-child-{}", Uuid::new_v4()), + SessionType::SubAgent, + ) + .await + .unwrap(); + + let team_state = 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( + teammate.id.clone(), + "researcher", + Some("explorer".to_string()), + ), + ], + }; + save_team_state(&lead.id, Some(team_state)).await.unwrap(); + save_team_membership( + &teammate.id, + Some(TeamMembershipState { + team_name: "alpha".to_string(), + lead_session_id: lead.id.clone(), + agent_id: teammate.id.clone(), + name: "researcher".to_string(), + agent_type: Some("explorer".to_string()), + }), + ) + .await + .unwrap(); + + let expected_target_id = lead.id.clone(); + let tool = SendInputTool::new(Arc::new(move |request| { + let expected_target_id = expected_target_id.clone(); + Box::pin(async move { + assert_eq!(request.id, expected_target_id); + let payload: Value = serde_json::from_str(&request.message).unwrap(); + assert_eq!(payload["type"], json!("shutdown_response")); + assert_eq!(payload["approve"], json!(true)); + assert_eq!(payload["request_id"], json!("req-1")); + Ok(SendInputResponse { + submission_id: "submission-display-response".to_string(), + extra: BTreeMap::new(), + }) + }) + })); + + let result = tool + .execute( + serde_json::json!({ + "to": "team-lead@alpha", + "message": { + "type": "shutdown_response", + "request_id": "req-1", + "approve": true + } + }), + &create_context(&teammate.id), + ) + .await + .unwrap(); + + let output: Value = serde_json::from_str(result.output.as_deref().unwrap()).unwrap(); + assert_eq!(output["success"], json!(true)); + assert_eq!(output["request_id"], json!("req-1")); + } + + #[tokio::test] + #[serial_test::serial] + async fn test_send_message_rejects_plan_approval_response_from_teammate() { + let temp_dir = tempdir().unwrap(); + let lead = SessionManager::create_session( + temp_dir.path().to_path_buf(), + format!("team-lead-{}", Uuid::new_v4()), + SessionType::Hidden, + ) + .await + .unwrap(); + let teammate = SessionManager::create_session( + temp_dir.path().to_path_buf(), + format!("team-child-{}", Uuid::new_v4()), + SessionType::SubAgent, + ) + .await + .unwrap(); + + let team_state = 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( + teammate.id.clone(), + "researcher", + Some("explorer".to_string()), + ), + ], + }; + save_team_state(&lead.id, Some(team_state)).await.unwrap(); + save_team_membership( + &teammate.id, + Some(TeamMembershipState { + team_name: "alpha".to_string(), + lead_session_id: lead.id.clone(), + agent_id: teammate.id.clone(), + name: "researcher".to_string(), + agent_type: Some("explorer".to_string()), + }), + ) + .await + .unwrap(); + + let tool = SendInputTool::new(Arc::new(|_request| { + Box::pin(async move { + panic!("plan_approval_response from teammate should be rejected before dispatch"); + }) + })); + + let result = tool + .execute( + serde_json::json!({ + "to": "team-lead", + "message": { + "type": "plan_approval_response", + "request_id": "req-approve", + "approve": true + } + }), + &create_context(&teammate.id), + ) + .await; + + assert!(matches!( + result, + Err(ToolError::InvalidParams(message)) + if message == "Only the team lead can approve plans. Teammates cannot approve their own or other plans." + )); + } + + #[tokio::test] + #[serial_test::serial] + async fn test_send_message_allows_plan_approval_response_from_team_lead() { + let temp_dir = tempdir().unwrap(); + let lead = SessionManager::create_session( + temp_dir.path().to_path_buf(), + format!("team-lead-{}", Uuid::new_v4()), + SessionType::Hidden, + ) + .await + .unwrap(); + let teammate = SessionManager::create_session( + temp_dir.path().to_path_buf(), + format!("team-child-{}", Uuid::new_v4()), + SessionType::SubAgent, + ) + .await + .unwrap(); + + let team_state = 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( + teammate.id.clone(), + "researcher", + Some("explorer".to_string()), + ), + ], + }; + save_team_state(&lead.id, Some(team_state)).await.unwrap(); + save_team_membership( + &teammate.id, + Some(TeamMembershipState { + team_name: "alpha".to_string(), + lead_session_id: lead.id.clone(), + agent_id: teammate.id.clone(), + name: "researcher".to_string(), + agent_type: Some("explorer".to_string()), + }), + ) + .await + .unwrap(); + + let expected_target_id = teammate.id.clone(); + let tool = SendInputTool::new(Arc::new(move |request| { + let expected_target_id = expected_target_id.clone(); + Box::pin(async move { + assert_eq!(request.id, expected_target_id); + let payload: Value = serde_json::from_str(&request.message).unwrap(); + assert_eq!(payload["type"], json!("plan_approval_response")); + assert_eq!(payload["request_id"], json!("req-approve")); + assert_eq!(payload["approve"], json!(true)); + Ok(SendInputResponse { + submission_id: "submission-plan-approve".to_string(), + extra: BTreeMap::new(), + }) + }) + })); + + let result = tool + .execute( + serde_json::json!({ + "to": "researcher", + "message": { + "type": "plan_approval_response", + "request_id": "req-approve", + "approve": true + } + }), + &create_context(&lead.id), + ) + .await + .unwrap(); + + let output: Value = serde_json::from_str(result.output.as_deref().unwrap()).unwrap(); + assert_eq!(output["success"], json!(true)); + assert_eq!( + output["message"], + json!("Plan approved for researcher. Request ID: req-approve") + ); + assert_eq!(result.metadata["send_message"]["target"], "researcher"); + } } diff --git a/src-tauri/crates/aster-rust/crates/aster/src/tools/ask.rs b/src-tauri/crates/aster-rust/crates/aster/src/tools/ask.rs index 5c00d9e82..c0e11e351 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/tools/ask.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/tools/ask.rs @@ -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(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = Value::deserialize(deserializer)?; + match value { + Value::String(value) => Ok(Self::String(value)), + Value::Object(_) => serde_json::from_value::(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, label: Option, @@ -355,7 +372,7 @@ impl TryFrom for AskOption { } #[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] +#[serde(deny_unknown_fields, rename_all = "camelCase")] struct AskQuestionInput { question: String, header: Option, @@ -387,7 +404,7 @@ impl TryFrom for AskQuestion { } #[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] +#[serde(deny_unknown_fields, rename_all = "camelCase")] struct AskToolInput { questions: Option>, } @@ -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(); diff --git a/src-tauri/crates/aster-rust/crates/aster/src/tools/base.rs b/src-tauri/crates/aster-rust/crates/aster/src/tools/base.rs index 724cfc424..92a00c34e 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/tools/base.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/tools/base.rs @@ -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. diff --git a/src-tauri/crates/aster-rust/crates/aster/src/tools/bash.rs b/src-tauri/crates/aster-rust/crates/aster/src/tools/bash.rs index ce181f418..b52bd7c47 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/tools/bash.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/tools/bash.rs @@ -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 = + Lazy::new(|| Regex::new(r"^[A-Za-z_]\w*=").expect("valid env assign regex")); +static BASH_WRITE_REDIRECTION_RE: Lazy = Lazy::new(|| { + Regex::new( + r#"(?x) + (?:^|[\s;(]) + (?:\d+|&)?(?:>>?|>\|) + \s* + (?P'[^']*'|"[^"]*"|[^\s;&|()]+) + "#, + ) + .expect("valid bash write redirection regex") +}); +static BASH_SED_IN_PLACE_RE: Lazy = 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 { + 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 { + 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 { + let raw_words = segment + .split_whitespace() + .map(normalize_shell_word) + .filter(|word| !word.is_empty()) + .collect::>(); + 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 { + 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 { + 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::>(); + + 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 { + 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 { + 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::>(); + 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 { + 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 { + 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 { + let words = raw_words + .iter() + .map(|word| normalize_shell_word(word)) + .filter(|word| !word.is_empty()) + .collect::>(); + let start_index = skip_shell_command_prefix(raw_words); + words.into_iter().skip(start_index).collect() +} + +fn extract_rm_targets(raw_words: &[String]) -> Vec { + 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 { + 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 { + 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 { + 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 { + 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] diff --git a/src-tauri/crates/aster-rust/crates/aster/src/tools/command_semantics.rs b/src-tauri/crates/aster-rust/crates/aster/src/tools/command_semantics.rs new file mode 100644 index 000000000..398cf22ac --- /dev/null +++ b/src-tauri/crates/aster-rust/crates/aster/src/tools/command_semantics.rs @@ -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, +} + +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, + } + ); + } +} diff --git a/src-tauri/crates/aster-rust/crates/aster/src/tools/config_tool.rs b/src-tauri/crates/aster-rust/crates/aster/src/tools/config_tool.rs index 395766daa..af65f4d60 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/tools/config_tool.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/tools/config_tool.rs @@ -9,6 +9,8 @@ use super::error::ToolError; use async_trait::async_trait; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; +use std::future::Future; +use std::pin::Pin; use std::sync::Arc; use crate::config::{AsterMode, Config, ConfigError}; @@ -16,15 +18,67 @@ use crate::model::ModelConfig; const CONFIG_TOOL_NAME: &str = "Config"; const CONFIG_TOOL_DESCRIPTION: &str = "Get or set supported runtime configuration settings."; + +const THEME_SETTING_KEY: &str = "theme"; +const EDITOR_MODE_SETTING_KEY: &str = "editorMode"; +const VERBOSE_SETTING_KEY: &str = "verbose"; +const PREFERRED_NOTIF_CHANNEL_SETTING_KEY: &str = "preferredNotifChannel"; +const AUTO_COMPACT_ENABLED_SETTING_KEY: &str = "autoCompactEnabled"; +const AUTO_MEMORY_ENABLED_SETTING_KEY: &str = "autoMemoryEnabled"; +const AUTO_DREAM_ENABLED_SETTING_KEY: &str = "autoDreamEnabled"; +const FILE_CHECKPOINTING_ENABLED_SETTING_KEY: &str = "fileCheckpointingEnabled"; +const SHOW_TURN_DURATION_SETTING_KEY: &str = "showTurnDuration"; +const TERMINAL_PROGRESS_BAR_ENABLED_SETTING_KEY: &str = "terminalProgressBarEnabled"; +const TASK_TRACKING_ENABLED_SETTING_KEY: &str = "taskTrackingEnabled"; const MODEL_SETTING_KEY: &str = "model"; +const ALWAYS_THINKING_ENABLED_SETTING_KEY: &str = "alwaysThinkingEnabled"; const PERMISSION_MODE_SETTING_KEY: &str = "permissions.defaultMode"; +const LANGUAGE_SETTING_KEY: &str = "language"; +const TEAMMATE_MODE_SETTING_KEY: &str = "teammateMode"; +const CLASSIFIER_PERMISSIONS_ENABLED_SETTING_KEY: &str = "classifierPermissionsEnabled"; +const VOICE_ENABLED_SETTING_KEY: &str = "voiceEnabled"; +const REMOTE_CONTROL_AT_STARTUP_SETTING_KEY: &str = "remoteControlAtStartup"; +const TASK_COMPLETE_NOTIF_ENABLED_SETTING_KEY: &str = "taskCompleteNotifEnabled"; +const INPUT_NEEDED_NOTIF_ENABLED_SETTING_KEY: &str = "inputNeededNotifEnabled"; +const AGENT_PUSH_NOTIF_ENABLED_SETTING_KEY: &str = "agentPushNotifEnabled"; + const MODEL_DEFAULT_VALUE: &str = "default"; +const THEME_DEFAULT_VALUE: &str = "auto"; +const TEAMMATE_MODE_DEFAULT_VALUE: &str = "auto"; const PERMISSION_MODE_DEFAULT_VALUE: &str = "default"; const PERMISSION_MODE_ACCEPT_EDITS_VALUE: &str = "acceptEdits"; const PERMISSION_MODE_AUTO_VALUE: &str = "auto"; +const PERMISSION_MODE_APPROVE_VALUE: &str = "approve"; +const PERMISSION_MODE_CHAT_VALUE: &str = "chat"; +const PERMISSION_MODE_PLAN_VALUE: &str = "plan"; +const PERMISSION_MODE_DONT_ASK_VALUE: &str = "dontAsk"; + +const EDITOR_MODE_OPTIONS: &[&str] = &["normal", "vim"]; +const NOTIFICATION_CHANNEL_OPTIONS: &[&str] = &[ + "auto", + "iterm2", + "iterm2_with_bell", + "terminal_bell", + "kitty", + "ghostty", + "notifications_disabled", +]; +const TEAMMATE_MODE_OPTIONS: &[&str] = &["auto", "tmux", "in-process"]; +const PERMISSION_MODE_SUPPORTED_VALUES: &[&str] = &[ + PERMISSION_MODE_DEFAULT_VALUE, + PERMISSION_MODE_ACCEPT_EDITS_VALUE, + PERMISSION_MODE_AUTO_VALUE, + PERMISSION_MODE_APPROVE_VALUE, + PERMISSION_MODE_CHAT_VALUE, +]; + +type BoolSettingFuture = Pin> + Send>>; + +pub type VoiceEnabledReadCallback = Arc BoolSettingFuture + Send + Sync>; +pub type VoiceEnabledWriteCallback = Arc BoolSettingFuture + Send + Sync>; #[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] +#[serde(deny_unknown_fields, rename_all = "camelCase")] struct ConfigToolInput { setting: String, #[serde(default)] @@ -92,33 +146,205 @@ impl ConfigToolOutput { } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum SupportedSetting { - Model, - PermissionsDefaultMode, +enum SettingValueType { + Boolean, + String, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SupportedSetting { + Theme, + EditorMode, + Verbose, + PreferredNotifChannel, + AutoCompactEnabled, + AutoMemoryEnabled, + AutoDreamEnabled, + FileCheckpointingEnabled, + ShowTurnDuration, + TerminalProgressBarEnabled, + TaskTrackingEnabled, + Model, + AlwaysThinkingEnabled, + PermissionsDefaultMode, + Language, + TeammateMode, + ClassifierPermissionsEnabled, + VoiceEnabled, + RemoteControlAtStartup, + TaskCompleteNotifEnabled, + InputNeededNotifEnabled, + AgentPushNotifEnabled, +} + +const SUPPORTED_SETTINGS: &[SupportedSetting] = &[ + SupportedSetting::Theme, + SupportedSetting::EditorMode, + SupportedSetting::Verbose, + SupportedSetting::PreferredNotifChannel, + SupportedSetting::AutoCompactEnabled, + SupportedSetting::AutoMemoryEnabled, + SupportedSetting::AutoDreamEnabled, + SupportedSetting::FileCheckpointingEnabled, + SupportedSetting::ShowTurnDuration, + SupportedSetting::TerminalProgressBarEnabled, + SupportedSetting::TaskTrackingEnabled, + SupportedSetting::Model, + SupportedSetting::AlwaysThinkingEnabled, + SupportedSetting::PermissionsDefaultMode, + SupportedSetting::Language, + SupportedSetting::TeammateMode, + SupportedSetting::ClassifierPermissionsEnabled, + SupportedSetting::VoiceEnabled, + SupportedSetting::RemoteControlAtStartup, + SupportedSetting::TaskCompleteNotifEnabled, + SupportedSetting::InputNeededNotifEnabled, + SupportedSetting::AgentPushNotifEnabled, +]; + impl SupportedSetting { fn parse(raw: &str) -> Option { - match raw.trim() { - MODEL_SETTING_KEY => Some(Self::Model), - PERMISSION_MODE_SETTING_KEY => Some(Self::PermissionsDefaultMode), - _ => None, - } + SUPPORTED_SETTINGS + .iter() + .copied() + .find(|setting| setting.key() == raw.trim()) } fn key(self) -> &'static str { match self { + Self::Theme => THEME_SETTING_KEY, + Self::EditorMode => EDITOR_MODE_SETTING_KEY, + Self::Verbose => VERBOSE_SETTING_KEY, + Self::PreferredNotifChannel => PREFERRED_NOTIF_CHANNEL_SETTING_KEY, + Self::AutoCompactEnabled => AUTO_COMPACT_ENABLED_SETTING_KEY, + Self::AutoMemoryEnabled => AUTO_MEMORY_ENABLED_SETTING_KEY, + Self::AutoDreamEnabled => AUTO_DREAM_ENABLED_SETTING_KEY, + Self::FileCheckpointingEnabled => FILE_CHECKPOINTING_ENABLED_SETTING_KEY, + Self::ShowTurnDuration => SHOW_TURN_DURATION_SETTING_KEY, + Self::TerminalProgressBarEnabled => TERMINAL_PROGRESS_BAR_ENABLED_SETTING_KEY, + Self::TaskTrackingEnabled => TASK_TRACKING_ENABLED_SETTING_KEY, Self::Model => MODEL_SETTING_KEY, + Self::AlwaysThinkingEnabled => ALWAYS_THINKING_ENABLED_SETTING_KEY, Self::PermissionsDefaultMode => PERMISSION_MODE_SETTING_KEY, + Self::Language => LANGUAGE_SETTING_KEY, + Self::TeammateMode => TEAMMATE_MODE_SETTING_KEY, + Self::ClassifierPermissionsEnabled => CLASSIFIER_PERMISSIONS_ENABLED_SETTING_KEY, + Self::VoiceEnabled => VOICE_ENABLED_SETTING_KEY, + Self::RemoteControlAtStartup => REMOTE_CONTROL_AT_STARTUP_SETTING_KEY, + Self::TaskCompleteNotifEnabled => TASK_COMPLETE_NOTIF_ENABLED_SETTING_KEY, + Self::InputNeededNotifEnabled => INPUT_NEEDED_NOTIF_ENABLED_SETTING_KEY, + Self::AgentPushNotifEnabled => AGENT_PUSH_NOTIF_ENABLED_SETTING_KEY, } } fn description(self) -> &'static str { match self { - Self::Model => "Override the default runtime model. Use \"default\" to clear the override.", - Self::PermissionsDefaultMode => { - "Default permission mode for tool execution. Supported values in the current runtime: \"default\", \"acceptEdits\", \"auto\"." + Self::Theme => "Color theme for the UI.", + Self::EditorMode => "Key binding mode.", + Self::Verbose => "Show detailed debug output.", + Self::PreferredNotifChannel => "Preferred notification channel.", + Self::AutoCompactEnabled => "Auto-compact when context is full.", + Self::AutoMemoryEnabled => "Enable auto-memory.", + Self::AutoDreamEnabled => "Enable background memory consolidation.", + Self::FileCheckpointingEnabled => "Enable file checkpointing for code rewind.", + Self::ShowTurnDuration => { + "Show turn duration message after responses (for example: \"Cooked for 1m 6s\")." } + Self::TerminalProgressBarEnabled => { + "Show OSC 9;4 progress indicator in supported terminals." + } + Self::TaskTrackingEnabled => "Enable task tracking.", + Self::Model => { + "Override the default runtime model. Use \"default\" to clear the override." + } + Self::AlwaysThinkingEnabled => "Enable extended thinking.", + Self::PermissionsDefaultMode => { + "Default permission mode for tool execution. Current Lime runtime supports \"default\", \"acceptEdits\", \"auto\", \"approve\", and \"chat\". Upstream aliases \"plan\" and \"dontAsk\" are not implemented yet." + } + Self::Language => { + "Preferred language for assistant responses and related features." + } + Self::TeammateMode => { + "How to spawn teammates: \"auto\", \"tmux\", or \"in-process\"." + } + Self::ClassifierPermissionsEnabled => { + "Enable AI-based classification for Bash permission rules." + } + Self::VoiceEnabled => "Enable voice dictation (hold-to-talk).", + Self::RemoteControlAtStartup => { + "Enable Remote Control for all sessions at startup." + } + Self::TaskCompleteNotifEnabled => { + "Push to your mobile device when idle after the agent finishes." + } + Self::InputNeededNotifEnabled => { + "Push to your mobile device when a permission prompt or question is waiting." + } + Self::AgentPushNotifEnabled => { + "Allow the agent to proactively push to your mobile device." + } + } + } + + fn value_type(self) -> SettingValueType { + match self { + Self::Verbose + | Self::AutoCompactEnabled + | Self::AutoMemoryEnabled + | Self::AutoDreamEnabled + | Self::FileCheckpointingEnabled + | Self::ShowTurnDuration + | Self::TerminalProgressBarEnabled + | Self::TaskTrackingEnabled + | Self::AlwaysThinkingEnabled + | Self::ClassifierPermissionsEnabled + | Self::VoiceEnabled + | Self::RemoteControlAtStartup + | Self::TaskCompleteNotifEnabled + | Self::InputNeededNotifEnabled + | Self::AgentPushNotifEnabled => SettingValueType::Boolean, + _ => SettingValueType::String, + } + } + + fn unsupported_message(self) -> Option<&'static str> { + match self { + Self::ClassifierPermissionsEnabled => Some( + "Known upstream setting, but the current Lime runtime does not expose transcript-classifier permission toggles through Config yet.", + ), + Self::VoiceEnabled => Some( + "Known upstream setting, but the current Lime runtime needs a host-backed callback to keep persisted voice config and global shortcut registration in sync.", + ), + Self::RemoteControlAtStartup => Some( + "Known upstream setting, but Lime only exposes OS auto-launch today; that is not the same as upstream remote-control-at-startup semantics.", + ), + Self::TaskCompleteNotifEnabled => Some( + "Known upstream setting, but the current Lime runtime does not expose task-complete mobile notification toggles through Config yet.", + ), + Self::InputNeededNotifEnabled => Some( + "Known upstream setting, but the current Lime runtime does not expose input-needed mobile notification toggles through Config yet.", + ), + Self::AgentPushNotifEnabled => Some( + "Known upstream setting, but the current Lime runtime does not expose agent-push mobile notification toggles through Config yet.", + ), + _ => None, + } + } + + fn options(self) -> &'static [&'static str] { + match self { + Self::EditorMode => EDITOR_MODE_OPTIONS, + Self::PreferredNotifChannel => NOTIFICATION_CHANNEL_OPTIONS, + Self::TeammateMode => TEAMMATE_MODE_OPTIONS, + _ => &[], + } + } + + fn default_read_value(self) -> Option { + match self { + Self::Theme => Some(Value::String(THEME_DEFAULT_VALUE.to_string())), + Self::TeammateMode => Some(Value::String(TEAMMATE_MODE_DEFAULT_VALUE.to_string())), + _ => None, } } @@ -134,13 +360,24 @@ impl SupportedSetting { Ok(AsterMode::SmartApprove) => Ok(Value::String( PERMISSION_MODE_ACCEPT_EDITS_VALUE.to_string(), )), - Ok(AsterMode::Approve) => Ok(Value::String("approve".to_string())), - Ok(AsterMode::Chat) => Ok(Value::String("chat".to_string())), + Ok(AsterMode::Approve) => { + Ok(Value::String(PERMISSION_MODE_APPROVE_VALUE.to_string())) + } + Ok(AsterMode::Chat) => Ok(Value::String(PERMISSION_MODE_CHAT_VALUE.to_string())), Err(ConfigError::NotFound(_)) => { Ok(Value::String(PERMISSION_MODE_DEFAULT_VALUE.to_string())) } Err(error) => Err(map_config_error("读取", self.key(), error)), }, + _ => self.read_generic_value(config), + } + } + + fn read_generic_value(self, config: &Config) -> Result { + match config.get_param::(self.key()) { + Ok(value) => Ok(value), + Err(ConfigError::NotFound(_)) => Ok(self.default_read_value().unwrap_or(Value::Null)), + Err(error) => Err(map_config_error("读取", self.key(), error)), } } @@ -148,9 +385,53 @@ impl SupportedSetting { match self { Self::Model => self.write_model_value(config, value), Self::PermissionsDefaultMode => self.write_permission_mode_value(config, value), + _ => self.write_generic_value(config, value), } } + fn write_generic_value( + self, + config: &Config, + value: Value, + ) -> Result { + match self.value_type() { + SettingValueType::Boolean => { + let enabled = expect_boolean_value(self.key(), value)?; + config.set_param(self.key(), enabled).map_err(|error| { + SettingWriteError::system(map_config_error("更新", self.key(), error)) + })?; + Ok(Value::Bool(enabled)) + } + SettingValueType::String => { + let raw = expect_string_value(self.key(), value)?; + let resolved = match self.canonical_string_option(&raw) { + Some(option) => option.to_string(), + None if self.options().is_empty() => raw, + None => { + return Err(SettingWriteError::validation(format!( + "Invalid value \"{raw}\". Options: {}", + self.options().join(", ") + ))) + } + }; + + config + .set_param(self.key(), resolved.as_str()) + .map_err(|error| { + SettingWriteError::system(map_config_error("更新", self.key(), error)) + })?; + Ok(Value::String(resolved)) + } + } + } + + fn canonical_string_option(self, raw: &str) -> Option<&'static str> { + self.options() + .iter() + .copied() + .find(|candidate| candidate.eq_ignore_ascii_case(raw.trim())) + } + fn write_model_value(self, config: &Config, value: Value) -> Result { let model = expect_string_value(self.key(), value)?; if is_default_token(&model) { @@ -199,12 +480,35 @@ impl SupportedSetting { })?; Value::String(PERMISSION_MODE_AUTO_VALUE.to_string()) } + PERMISSION_MODE_APPROVE_VALUE => { + config.set_aster_mode(AsterMode::Approve).map_err(|error| { + SettingWriteError::system(map_config_error("更新", self.key(), error)) + })?; + Value::String(PERMISSION_MODE_APPROVE_VALUE.to_string()) + } + PERMISSION_MODE_CHAT_VALUE => { + config.set_aster_mode(AsterMode::Chat).map_err(|error| { + SettingWriteError::system(map_config_error("更新", self.key(), error)) + })?; + Value::String(PERMISSION_MODE_CHAT_VALUE.to_string()) + } + "plan" | "dontask" => { + return Err(SettingWriteError::validation(format!( + "Unsupported value for {}: \"{}\". Upstream values \"{}\" and \"{}\" are not implemented in the current Lime runtime. Supported values: {}.", + self.key(), + raw_mode, + PERMISSION_MODE_PLAN_VALUE, + PERMISSION_MODE_DONT_ASK_VALUE, + PERMISSION_MODE_SUPPORTED_VALUES.join(", ") + ))) + } _ => { return Err(SettingWriteError::validation(format!( - "Unsupported value for {}: \"{}\". Supported values: default, acceptEdits, auto.", - self.key(), - raw_mode - ))) + "Unsupported value for {}: \"{}\". Supported values: {}.", + self.key(), + raw_mode, + PERMISSION_MODE_SUPPORTED_VALUES.join(", ") + ))) } }; @@ -228,19 +532,40 @@ impl SettingWriteError { } } +#[derive(Clone, Default)] +struct ConfigToolHostCallbacks { + voice_enabled_read: Option, + voice_enabled_write: Option, +} + pub struct ConfigTool { config: Option>, + host_callbacks: ConfigToolHostCallbacks, } impl ConfigTool { pub fn new() -> Self { - Self { config: None } + Self { + config: None, + host_callbacks: ConfigToolHostCallbacks::default(), + } + } + + pub fn with_voice_enabled_callbacks( + mut self, + read_callback: VoiceEnabledReadCallback, + write_callback: VoiceEnabledWriteCallback, + ) -> Self { + self.host_callbacks.voice_enabled_read = Some(read_callback); + self.host_callbacks.voice_enabled_write = Some(write_callback); + self } #[cfg(test)] fn with_config(config: Arc) -> Self { Self { config: Some(config), + host_callbacks: ConfigToolHostCallbacks::default(), } } @@ -250,6 +575,82 @@ impl ConfigTool { None => Config::global(), } } + + fn supports_host_backed_setting(&self, setting: SupportedSetting) -> bool { + match setting { + SupportedSetting::VoiceEnabled => { + self.host_callbacks.voice_enabled_read.is_some() + && self.host_callbacks.voice_enabled_write.is_some() + } + _ => false, + } + } + + fn unsupported_message(&self, setting: SupportedSetting) -> Option<&'static str> { + if self.supports_host_backed_setting(setting) { + None + } else { + setting.unsupported_message() + } + } + + async fn execute_host_backed_setting( + &self, + setting: SupportedSetting, + next_value: Option, + ) -> Result, ToolError> { + match setting { + SupportedSetting::VoiceEnabled => self.execute_voice_enabled(next_value).await, + _ => Ok(None), + } + } + + async fn execute_voice_enabled( + &self, + next_value: Option, + ) -> Result, ToolError> { + let Some(read_callback) = self.host_callbacks.voice_enabled_read.as_ref() else { + return Ok(None); + }; + let Some(write_callback) = self.host_callbacks.voice_enabled_write.as_ref() else { + return Ok(None); + }; + + let setting_key = VOICE_ENABLED_SETTING_KEY; + let current_value = match read_callback().await { + Ok(enabled) => Value::Bool(enabled), + Err(message) => { + let operation = if next_value.is_some() { "set" } else { "get" }; + return Ok(Some(ConfigToolOutput::failure( + operation, + setting_key, + message, + ))); + } + }; + + if let Some(value) = next_value { + let enabled = match expect_boolean_value(setting_key, value) { + Ok(enabled) => enabled, + Err(SettingWriteError::Validation(message)) => { + return Ok(Some(ConfigToolOutput::failure("set", setting_key, message))); + } + Err(SettingWriteError::System(error)) => return Err(error), + }; + + return Ok(Some(match write_callback(enabled).await { + Ok(resolved) => { + ConfigToolOutput::set_success(setting_key, current_value, Value::Bool(resolved)) + } + Err(message) => ConfigToolOutput::failure("set", setting_key, message), + })); + } + + Ok(Some(ConfigToolOutput::get_success( + setting_key, + current_value, + ))) + } } impl Default for ConfigTool { @@ -290,6 +691,24 @@ fn expect_string_value(setting: &str, value: Value) -> Result Result { + match value { + Value::Bool(boolean) => Ok(boolean), + Value::String(text) => match text.trim().to_ascii_lowercase().as_str() { + "true" => Ok(true), + "false" => Ok(false), + _ => Err(SettingWriteError::validation(format!( + "{} requires true or false.", + setting + ))), + }, + _ => Err(SettingWriteError::validation(format!( + "{} requires true or false.", + setting + ))), + } +} + fn pretty_json(value: &T) -> Result { serde_json::to_string_pretty(value) .map_err(|error| ToolError::execution_failed(format!("序列化 Config 结果失败: {error}"))) @@ -321,8 +740,41 @@ fn tool_result_from_output(output: ConfigToolOutput) -> Result String { - [ +fn supported_settings_line(tool: &ConfigTool, setting: SupportedSetting) -> String { + let kind = match setting.value_type() { + SettingValueType::Boolean => "boolean", + SettingValueType::String => "string", + }; + let options = if setting.options().is_empty() { + String::new() + } else { + format!(" Options: {}.", setting.options().join(", ")) + }; + let availability = tool + .unsupported_message(setting) + .map(|message| format!(" Current Lime runtime: {}.", message)) + .unwrap_or_default(); + + format!( + "- {} ({}) - {}{}{}", + setting.key(), + kind, + setting.description(), + options, + availability + ) +} + +fn supported_setting_keys() -> String { + SUPPORTED_SETTINGS + .iter() + .map(|setting| setting.key()) + .collect::>() + .join(", ") +} + +fn dynamic_description(tool: &ConfigTool) -> String { + let mut lines = vec![ CONFIG_TOOL_DESCRIPTION.to_string(), String::new(), "Usage:".to_string(), @@ -330,22 +782,23 @@ fn dynamic_description() -> String { "- Provide `value` to update the setting.".to_string(), String::new(), "Supported settings:".to_string(), - format!( - "- {} - {}", - MODEL_SETTING_KEY, - SupportedSetting::Model.description() - ), - format!( - "- {}: \"{}\", \"{}\", \"{}\" - {}", - PERMISSION_MODE_SETTING_KEY, - PERMISSION_MODE_DEFAULT_VALUE, - PERMISSION_MODE_ACCEPT_EDITS_VALUE, - PERMISSION_MODE_AUTO_VALUE, - SupportedSetting::PermissionsDefaultMode.description() - ), + ]; + + lines.extend( + SUPPORTED_SETTINGS + .iter() + .copied() + .map(|setting| supported_settings_line(tool, setting)), + ); + + lines.extend([ String::new(), "Examples:".to_string(), - format!("- {{ \"setting\": \"{}\" }}", MODEL_SETTING_KEY), + format!("- {{ \"setting\": \"{}\" }}", THEME_SETTING_KEY), + format!( + "- {{ \"setting\": \"{}\", \"value\": true }}", + VERBOSE_SETTING_KEY + ), format!( "- {{ \"setting\": \"{}\", \"value\": \"gpt-5.4\" }}", MODEL_SETTING_KEY @@ -354,8 +807,9 @@ fn dynamic_description() -> String { "- {{ \"setting\": \"{}\", \"value\": \"acceptEdits\" }}", PERMISSION_MODE_SETTING_KEY ), - ] - .join("\n") + ]); + + lines.join("\n") } #[async_trait] @@ -369,7 +823,7 @@ impl Tool for ConfigTool { } fn dynamic_description(&self) -> Option { - Some(dynamic_description()) + Some(dynamic_description(self)) } fn input_schema(&self) -> Value { @@ -379,7 +833,7 @@ impl Tool for ConfigTool { "properties": { "setting": { "type": "string", - "description": "The setting key (for example: \"model\" or \"permissions.defaultMode\")" + "description": "The setting key (for example: \"theme\", \"model\", or \"permissions.defaultMode\")" }, "value": { "description": "The new value. Omit this field to read the current value.", @@ -431,14 +885,28 @@ impl Tool for ConfigTool { if input.value.is_some() { "set" } else { "get" }, setting_name, format!( - "Unknown setting: \"{}\". Supported settings: {}, {}.", + "Unknown setting: \"{}\". Supported settings: {}.", input.setting.trim(), - MODEL_SETTING_KEY, - PERMISSION_MODE_SETTING_KEY + supported_setting_keys() ), )); }; + if let Some(output) = self + .execute_host_backed_setting(setting, input.value.clone()) + .await? + { + return tool_result_from_output(output); + } + + if let Some(message) = self.unsupported_message(setting) { + return tool_result_from_output(ConfigToolOutput::failure( + if input.value.is_some() { "set" } else { "get" }, + setting.key(), + message, + )); + } + let config = self.config(); if let Some(next_value) = input.value { @@ -493,6 +961,8 @@ mod tests { assert_eq!(definition.name, CONFIG_TOOL_NAME); assert!(definition.description.contains("Supported settings")); + assert!(definition.description.contains(THEME_SETTING_KEY)); + assert!(definition.description.contains(TEAMMATE_MODE_SETTING_KEY)); assert_eq!( definition .input_schema @@ -526,6 +996,29 @@ mod tests { ); } + #[tokio::test] + async fn test_config_tool_get_theme_defaults_to_auto() { + let _guard = lock_env([("THEME", None::<&str>)]); + let tool = create_test_tool(); + + let result = tool + .execute( + json!({ "setting": THEME_SETTING_KEY }), + &ToolContext::default(), + ) + .await + .expect("tool should succeed"); + let output = parse_output(result); + + assert_eq!( + output, + ConfigToolOutput::get_success( + THEME_SETTING_KEY, + Value::String(THEME_DEFAULT_VALUE.to_string()) + ) + ); + } + #[tokio::test] async fn test_config_tool_set_model_round_trip() { let _guard = lock_env([("ASTER_MODEL", None::<&str>), ("ASTER_MODE", None::<&str>)]); @@ -560,6 +1053,129 @@ mod tests { ); } + #[tokio::test] + async fn test_config_tool_set_boolean_setting_round_trip() { + let _guard = lock_env([("VERBOSE", None::<&str>)]); + let tool = create_test_tool(); + let context = ToolContext::default(); + + let set_result = tool + .execute( + json!({ "setting": VERBOSE_SETTING_KEY, "value": true }), + &context, + ) + .await + .expect("set verbose should succeed"); + let set_output = parse_output(set_result); + assert!(set_output.success); + assert_eq!(set_output.operation.as_deref(), Some("set")); + assert_eq!(set_output.setting.as_deref(), Some(VERBOSE_SETTING_KEY)); + assert_eq!(set_output.value, Some(Value::Bool(true))); + assert_eq!(set_output.previous_value, None); + assert_eq!(set_output.new_value, Some(Value::Bool(true))); + + let get_result = tool + .execute(json!({ "setting": VERBOSE_SETTING_KEY }), &context) + .await + .expect("get verbose should succeed"); + let get_output = parse_output(get_result); + assert_eq!( + get_output, + ConfigToolOutput::get_success(VERBOSE_SETTING_KEY, Value::Bool(true)) + ); + } + + #[tokio::test] + async fn test_config_tool_boolean_setting_accepts_string_booleans() { + let _guard = lock_env([("TASKTRACKINGENABLED", None::<&str>)]); + let tool = create_test_tool(); + + let result = tool + .execute( + json!({ + "setting": TASK_TRACKING_ENABLED_SETTING_KEY, + "value": "false" + }), + &ToolContext::default(), + ) + .await + .expect("string boolean should be accepted"); + let output = parse_output(result); + + assert!(output.success); + assert_eq!(output.operation.as_deref(), Some("set")); + assert_eq!( + output.setting.as_deref(), + Some(TASK_TRACKING_ENABLED_SETTING_KEY) + ); + assert_eq!(output.value, Some(Value::Bool(false))); + assert_eq!(output.previous_value, None); + assert_eq!(output.new_value, Some(Value::Bool(false))); + } + + #[tokio::test] + async fn test_config_tool_set_teammate_mode_round_trip() { + let _guard = lock_env([("TEAMMATEMODE", None::<&str>)]); + let tool = create_test_tool(); + let context = ToolContext::default(); + + let set_result = tool + .execute( + json!({ "setting": TEAMMATE_MODE_SETTING_KEY, "value": "tmux" }), + &context, + ) + .await + .expect("set teammate mode should succeed"); + let set_output = parse_output(set_result); + assert_eq!( + set_output, + ConfigToolOutput::set_success( + TEAMMATE_MODE_SETTING_KEY, + Value::String(TEAMMATE_MODE_DEFAULT_VALUE.to_string()), + Value::String("tmux".to_string()) + ) + ); + + let get_result = tool + .execute(json!({ "setting": TEAMMATE_MODE_SETTING_KEY }), &context) + .await + .expect("get teammate mode should succeed"); + let get_output = parse_output(get_result); + assert_eq!( + get_output, + ConfigToolOutput::get_success( + TEAMMATE_MODE_SETTING_KEY, + Value::String("tmux".to_string()) + ) + ); + } + + #[tokio::test] + async fn test_config_tool_rejects_invalid_option_value() { + let _guard = lock_env([("TEAMMATEMODE", None::<&str>)]); + let tool = create_test_tool(); + + let result = tool + .execute( + json!({ + "setting": TEAMMATE_MODE_SETTING_KEY, + "value": "fork" + }), + &ToolContext::default(), + ) + .await + .expect("tool should return structured failure"); + let output = parse_output(result); + + assert!(!output.success); + assert_eq!(output.operation, Some("set".to_string())); + assert_eq!(output.setting.as_deref(), Some(TEAMMATE_MODE_SETTING_KEY)); + assert_eq!( + output.error.as_deref(), + Some("Invalid value \"fork\". Options: auto, tmux, in-process") + ); + } + #[tokio::test] async fn test_config_tool_set_permission_mode_round_trip() { let _guard = lock_env([("ASTER_MODEL", None::<&str>), ("ASTER_MODE", None::<&str>)]); @@ -601,15 +1217,55 @@ mod tests { } #[tokio::test] - async fn test_config_tool_rejects_unsupported_permission_value() { - let _guard = lock_env([("ASTER_MODEL", None::<&str>), ("ASTER_MODE", None::<&str>)]); + async fn test_config_tool_supports_runtime_permission_aliases() { + let _guard = lock_env([("ASTER_MODE", None::<&str>)]); + let tool = create_test_tool(); + let context = ToolContext::default(); + + let set_result = tool + .execute( + json!({ + "setting": PERMISSION_MODE_SETTING_KEY, + "value": PERMISSION_MODE_APPROVE_VALUE + }), + &context, + ) + .await + .expect("set approve mode should succeed"); + let set_output = parse_output(set_result); + assert_eq!( + set_output, + ConfigToolOutput::set_success( + PERMISSION_MODE_SETTING_KEY, + Value::String(PERMISSION_MODE_DEFAULT_VALUE.to_string()), + Value::String(PERMISSION_MODE_APPROVE_VALUE.to_string()) + ) + ); + + let get_result = tool + .execute(json!({ "setting": PERMISSION_MODE_SETTING_KEY }), &context) + .await + .expect("get approve mode should succeed"); + let get_output = parse_output(get_result); + assert_eq!( + get_output, + ConfigToolOutput::get_success( + PERMISSION_MODE_SETTING_KEY, + Value::String(PERMISSION_MODE_APPROVE_VALUE.to_string()) + ) + ); + } + + #[tokio::test] + async fn test_config_tool_rejects_unimplemented_upstream_permission_modes() { + let _guard = lock_env([("ASTER_MODE", None::<&str>)]); let tool = create_test_tool(); let result = tool .execute( json!({ "setting": PERMISSION_MODE_SETTING_KEY, - "value": "plan" + "value": PERMISSION_MODE_PLAN_VALUE }), &ToolContext::default(), ) @@ -624,7 +1280,113 @@ mod tests { .error .as_deref() .expect("error message") - .contains("Unsupported value")); + .contains("not implemented in the current Lime runtime")); + } + + #[tokio::test] + async fn test_config_tool_reports_known_but_unimplemented_setting_on_get() { + let tool = create_test_tool(); + + let result = tool + .execute( + json!({ "setting": VOICE_ENABLED_SETTING_KEY }), + &ToolContext::default(), + ) + .await + .expect("tool should return structured failure"); + let output = parse_output(result); + + assert!(!output.success); + assert_eq!(output.operation.as_deref(), Some("get")); + assert_eq!(output.setting.as_deref(), Some(VOICE_ENABLED_SETTING_KEY)); + assert!(output + .error + .as_deref() + .expect("error message") + .contains("Known upstream setting")); + } + + #[tokio::test] + async fn test_config_tool_supports_host_backed_voice_enabled_setting() { + let voice_enabled = Arc::new(tokio::sync::Mutex::new(false)); + let read_state = voice_enabled.clone(); + let write_state = voice_enabled.clone(); + let tool = ConfigTool::new().with_voice_enabled_callbacks( + Arc::new(move || { + let read_state = read_state.clone(); + Box::pin(async move { Ok(*read_state.lock().await) }) + }), + Arc::new(move |enabled| { + let write_state = write_state.clone(); + Box::pin(async move { + *write_state.lock().await = enabled; + Ok(enabled) + }) + }), + ); + let context = ToolContext::default(); + + let definition = tool.get_definition(); + assert!(definition.description.contains(VOICE_ENABLED_SETTING_KEY)); + assert!(!definition + .description + .contains("needs a host-backed callback")); + + let get_result = tool + .execute(json!({ "setting": VOICE_ENABLED_SETTING_KEY }), &context) + .await + .expect("host-backed get should succeed"); + let get_output = parse_output(get_result); + assert_eq!( + get_output, + ConfigToolOutput::get_success(VOICE_ENABLED_SETTING_KEY, Value::Bool(false)) + ); + + let set_result = tool + .execute( + json!({ "setting": VOICE_ENABLED_SETTING_KEY, "value": "true" }), + &context, + ) + .await + .expect("host-backed set should succeed"); + let set_output = parse_output(set_result); + assert_eq!( + set_output, + ConfigToolOutput::set_success( + VOICE_ENABLED_SETTING_KEY, + Value::Bool(false), + Value::Bool(true) + ) + ); + } + + #[tokio::test] + async fn test_config_tool_reports_known_but_unimplemented_setting_on_set() { + let tool = create_test_tool(); + + let result = tool + .execute( + json!({ + "setting": TASK_COMPLETE_NOTIF_ENABLED_SETTING_KEY, + "value": true + }), + &ToolContext::default(), + ) + .await + .expect("tool should return structured failure"); + let output = parse_output(result); + + assert!(!output.success); + assert_eq!(output.operation.as_deref(), Some("set")); + assert_eq!( + output.setting.as_deref(), + Some(TASK_COMPLETE_NOTIF_ENABLED_SETTING_KEY) + ); + assert!(output + .error + .as_deref() + .expect("error message") + .contains("Known upstream setting")); } #[tokio::test] diff --git a/src-tauri/crates/aster-rust/crates/aster/src/tools/cron_tools.rs b/src-tauri/crates/aster-rust/crates/aster/src/tools/cron_tools.rs index 87d65f7f7..07af51949 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/tools/cron_tools.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/tools/cron_tools.rs @@ -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, } diff --git a/src-tauri/crates/aster-rust/crates/aster/src/tools/mod.rs b/src-tauri/crates/aster-rust/crates/aster/src/tools/mod.rs index 665fc5aeb..1f1674cf4 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/tools/mod.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/tools/mod.rs @@ -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")); } } diff --git a/src-tauri/crates/aster-rust/crates/aster/src/tools/path_guard.rs b/src-tauri/crates/aster-rust/crates/aster/src/tools/path_guard.rs new file mode 100644 index 000000000..a5dc1390f --- /dev/null +++ b/src-tauri/crates/aster-rust/crates/aster/src/tools/path_guard.rs @@ -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, kind: PathMutationKind) -> Self { + Self { + raw_path: raw_path.into(), + kind, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PathGuardFinding { + ProtectedPaths(Vec), + OutsideWorkspace(Vec), + DynamicPaths(Vec), +} + +pub fn evaluate_path_mutations( + candidates: &[PathMutationCandidate], + cwd: &Path, +) -> Option { + 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::>() + .join(", ") +} + +pub fn summarize_raw_paths(paths: &[String]) -> String { + paths.iter().take(3).cloned().collect::>().join(", ") +} + +pub fn resolve_static_path_candidate(raw: &str, cwd: &Path) -> Option { + 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 { + 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()); + } +} diff --git a/src-tauri/crates/aster-rust/crates/aster/src/tools/plan_mode_tool.rs b/src-tauri/crates/aster-rust/crates/aster/src/tools/plan_mode_tool.rs index d49170e1c..600cadac0 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/tools/plan_mode_tool.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/tools/plan_mode_tool.rs @@ -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 { + 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>, +} + +#[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, + is_agent: bool, + #[serde(skip_serializing_if = "Option::is_none")] + file_path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + has_task_tool: Option, + #[serde(skip_serializing_if = "Option::is_none")] + plan_was_edited: Option, + #[serde(skip_serializing_if = "Option::is_none")] + awaiting_leader_approval: Option, + #[serde(skip_serializing_if = "Option::is_none")] + request_id: Option, +} /// 退出计划模式工具 /// /// 对齐当前工具面的 ExitPlanModeTool 语义 /// 用于完成计划并等待用户批准 -pub struct ExitPlanModeTool; +#[derive(Clone)] +pub struct ExitPlanModeTool { + send_input_callback: Option, +} 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::(); + 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 { + async fn execute(&self, params: Value, context: &ToolContext) -> Result { + 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 { + 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::)); + 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(); diff --git a/src-tauri/crates/aster-rust/crates/aster/src/tools/powershell_tool.rs b/src-tauri/crates/aster-rust/crates/aster/src/tools/powershell_tool.rs index 2ab8a8280..0a6af90d3 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/tools/powershell_tool.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/tools/powershell_tool.rs @@ -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 = Lazy::new(|| { + Regex::new( + r#"(?x) + (?:^|[\s;(]) + (?:\d+)?(?:>>?|>\|) + \s* + (?P'[^']*'|"[^"]*"|[^\s;&|()]+) + "#, + ) + .expect("valid powershell write redirection regex") +}); +static POWERSHELL_SYMLINK_RE: Lazy = 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 { .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 { + let raw_words = segment + .split_whitespace() + .map(normalize_powershell_word) + .filter(|word| !word.is_empty()) + .collect::>(); + + 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 { + 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 { + 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 { + 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 { + 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::>(); + + 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 { + 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 { + let mut normalized = raw_words + .iter() + .map(|word| resolve_powershell_alias(&normalize_powershell_word(word)).to_string()) + .filter(|word| !word.is_empty()) + .collect::>(); + + 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 { + 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 { + 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 { + 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::>(); + 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 { + 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 { 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"); + } } diff --git a/src-tauri/crates/aster-rust/crates/aster/src/tools/registry.rs b/src-tauri/crates/aster-rust/crates/aster/src/tools/registry.rs index d934225c7..f78269583 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/tools/registry.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/tools/registry.rs @@ -109,6 +109,8 @@ impl Tool for McpToolWrapper { pub struct ToolRegistry { /// Native tools (high priority) native_tools: HashMap>, + /// Compatibility aliases that resolve to canonical native tool names + native_aliases: HashMap, /// MCP tools (low priority) mcp_tools: HashMap, /// 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) { let name = tool.name().to_string(); + let aliases = tool + .aliases() + .iter() + .copied() + .chain(Self::default_native_aliases(&name).iter().copied()) + .collect::>(); 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> { 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(); diff --git a/src-tauri/crates/aster-rust/crates/aster/src/tools/remote_trigger_tool.rs b/src-tauri/crates/aster-rust/crates/aster/src/tools/remote_trigger_tool.rs index 90f1cd32d..f38c4f0fe 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/tools/remote_trigger_tool.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/tools/remote_trigger_tool.rs @@ -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() { diff --git a/src-tauri/crates/aster-rust/crates/aster/src/tools/send_user_message_tool.rs b/src-tauri/crates/aster-rust/crates/aster/src/tools/send_user_message_tool.rs index a2de286c2..99405e12f 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/tools/send_user_message_tool.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/tools/send_user_message_tool.rs @@ -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)." + ); } } diff --git a/src-tauri/crates/aster-rust/crates/aster/src/tools/sleep_tool.rs b/src-tauri/crates/aster-rust/crates/aster/src/tools/sleep_tool.rs index 1dd599b9c..bae1626dd 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/tools/sleep_tool.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/tools/sleep_tool.rs @@ -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, } diff --git a/src-tauri/crates/aster-rust/crates/aster/src/tools/task_list_tools.rs b/src-tauri/crates/aster-rust/crates/aster/src/tools/task_list_tools.rs index bf4fed337..5e74f9a91 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/tools/task_list_tools.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/tools/task_list_tools.rs @@ -7,7 +7,8 @@ //! - `TaskUpdate` use async_trait::async_trait; -use serde::Deserialize; +use chrono::Utc; +use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::collections::{BTreeMap, HashMap, HashSet}; use std::sync::{Arc, RwLock}; @@ -15,11 +16,22 @@ use std::sync::{Arc, RwLock}; use super::base::{PermissionCheckResult, Tool}; use super::context::{ToolContext, ToolResult}; use super::error::ToolError; +use crate::conversation::message::{ActionRequiredScope, Message}; +use crate::hooks::{is_blocked, run_hooks, HookEvent, HookInput}; use crate::session::extension_data::{ persist_task_board_state, resolve_task_board_state, TaskBoardItem, TaskBoardItemStatus, TaskBoardState, }; -use crate::session::{resolve_team_task_list_id, SessionManager}; +use crate::session::{ + resolve_team_context, resolve_team_task_list_id, SessionManager, SessionType, + TeamMembershipState, TeamSessionState, +}; +use crate::user_message_manager::UserMessageManager; + +const TASK_CREATE_TOOL_ALIASES: &[&str] = &["TaskCreateTool"]; +const TASK_LIST_TOOL_ALIASES: &[&str] = &["TaskListTool"]; +const TASK_GET_TOOL_ALIASES: &[&str] = &["TaskGetTool"]; +const TASK_UPDATE_TOOL_ALIASES: &[&str] = &["TaskUpdateTool"]; #[derive(Debug, Default)] pub struct TaskListStorage { @@ -43,8 +55,15 @@ impl TaskListStorage { } } +#[derive(Debug, Clone)] +struct ResolvedTaskBoardBinding { + task_list_id: String, + owner_session_id: Option, +} + #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] +#[serde(deny_unknown_fields)] pub struct TaskCreateInput { pub subject: String, pub description: String, @@ -60,6 +79,7 @@ pub struct TaskListInput {} #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] +#[serde(deny_unknown_fields)] pub struct TaskGetInput { #[serde(alias = "task_id")] pub task_id: String, @@ -87,6 +107,7 @@ impl TaskUpdateStatus { #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] +#[serde(deny_unknown_fields)] pub struct TaskUpdateInput { #[serde(alias = "task_id")] pub task_id: String, @@ -108,27 +129,47 @@ pub struct TaskUpdateInput { pub metadata: Option>, } -async fn resolve_task_list_id(context: &ToolContext) -> String { +async fn resolve_task_board_binding(context: &ToolContext) -> ResolvedTaskBoardBinding { if let Some(task_list_id) = context .environment .get("TASK_LIST_ID") .cloned() .filter(|value| !value.trim().is_empty()) { - return task_list_id; + return ResolvedTaskBoardBinding { + task_list_id, + owner_session_id: (!context.session_id.trim().is_empty()) + .then(|| context.session_id.clone()), + }; } if !context.session_id.trim().is_empty() { + if let Ok(Some(team_context)) = resolve_team_context(&context.session_id).await { + return ResolvedTaskBoardBinding { + task_list_id: team_context.team_state.team_name, + owner_session_id: Some(team_context.lead_session_id), + }; + } + if let Ok(session) = SessionManager::get_session(&context.session_id, false).await { if let Some(task_list_id) = resolve_team_task_list_id(&session.extension_data) { - return task_list_id; + return ResolvedTaskBoardBinding { + task_list_id, + owner_session_id: Some(context.session_id.clone()), + }; } } - return context.session_id.clone(); + return ResolvedTaskBoardBinding { + task_list_id: context.session_id.clone(), + owner_session_id: Some(context.session_id.clone()), + }; } - "main".to_string() + ResolvedTaskBoardBinding { + task_list_id: "main".to_string(), + owner_session_id: None, + } } fn ensure_non_empty(value: &str, field_name: &str) -> Result<(), ToolError> { @@ -245,32 +286,64 @@ fn build_task_metadata(task: &TaskBoardItem) -> Value { }) } +fn build_task_list_output_tasks( + tasks: &[&TaskBoardItem], + resolved_task_ids: &HashSet, +) -> Vec { + tasks + .iter() + .map(|task| { + json!({ + "id": task.id.clone(), + "subject": task.subject.clone(), + "status": task_status_label(&task.status), + "owner": task.owner.clone(), + "blockedBy": task + .blocked_by + .iter() + .filter(|task_id| !resolved_task_ids.contains(*task_id)) + .cloned() + .collect::>(), + }) + }) + .collect() +} + +fn pretty_json(value: &T) -> Result { + serde_json::to_string_pretty(value) + .map_err(|error| ToolError::execution_failed(format!("序列化任务结果失败: {error}"))) +} + async fn load_task_board_state( storage: &TaskListStorage, - task_list_id: &str, - context: &ToolContext, + binding: &ResolvedTaskBoardBinding, ) -> TaskBoardState { - if let Ok(session) = SessionManager::get_session(&context.session_id, false).await { - if let Some(state) = resolve_task_board_state(&session.extension_data) { - storage.set(task_list_id, state.clone()); - return state; + if let Some(owner_session_id) = binding.owner_session_id.as_deref() { + if let Ok(session) = SessionManager::get_session(owner_session_id, false).await { + if let Some(state) = resolve_task_board_state(&session.extension_data) { + storage.set(&binding.task_list_id, state.clone()); + return state; + } } } - storage.get(task_list_id).unwrap_or_default() + storage.get(&binding.task_list_id).unwrap_or_default() } async fn persist_task_board( storage: &TaskListStorage, - task_list_id: &str, - context: &ToolContext, + binding: &ResolvedTaskBoardBinding, state: TaskBoardState, ) { - storage.set(task_list_id, state.clone()); + storage.set(&binding.task_list_id, state.clone()); - if let Ok(mut session) = SessionManager::get_session(&context.session_id, false).await { + let Some(owner_session_id) = binding.owner_session_id.as_deref() else { + return; + }; + + if let Ok(mut session) = SessionManager::get_session(owner_session_id, false).await { if persist_task_board_state(&mut session.extension_data, state).is_ok() { - let _ = SessionManager::update_session(&context.session_id) + let _ = SessionManager::update_session(owner_session_id) .extension_data(session.extension_data) .apply() .await; @@ -278,6 +351,123 @@ async fn persist_task_board( } } +fn current_session_id(context: &ToolContext) -> Option { + (!context.session_id.trim().is_empty()).then(|| context.session_id.clone()) +} + +async fn blocking_task_hook_feedback( + event: HookEvent, + task: &TaskBoardItem, + context: &ToolContext, +) -> Option { + let team_context = resolve_team_context(&context.session_id) + .await + .ok() + .flatten(); + let results = run_hooks(HookInput { + event: Some(event), + task_id: Some(task.id.clone()), + task_subject: Some(task.subject.clone()), + task_description: Some(task.description.clone()), + teammate_name: team_context + .as_ref() + .map(|team_context| team_context.current_member_name.clone()), + team_name: team_context + .as_ref() + .map(|team_context| team_context.team_state.team_name.clone()), + session_id: current_session_id(context), + ..Default::default() + }) + .await; + let (blocked, message) = is_blocked(&results); + if !blocked { + return None; + } + + let fallback = "Blocked by hook".to_string(); + let detail = message.unwrap_or(fallback); + Some(match event { + HookEvent::TaskCreated => format!("TaskCreated hook feedback:\n{detail}"), + HookEvent::TaskCompleted => format!("TaskCompleted hook feedback:\n{detail}"), + _ => detail, + }) +} + +async fn resolve_assignment_target_session_id( + team_state: &TeamSessionState, + owner_name: &str, +) -> Option { + let member = team_state.find_member_by_name(owner_name)?.clone(); + let session = SessionManager::get_session(&member.agent_id, false) + .await + .ok()?; + + if member.is_lead { + let lead_state = TeamSessionState::from_session(&session)?; + 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 + { + Some(member.agent_id) + } else { + None + } + } else { + let membership = TeamMembershipState::from_session(&session)?; + 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 + { + Some(member.agent_id) + } else { + None + } + } +} + +async fn enqueue_task_assignment_message( + context: &ToolContext, + existing_task: &TaskBoardItem, + owner_name: &str, +) { + let Some(team_context) = resolve_team_context(&context.session_id) + .await + .ok() + .flatten() + else { + return; + }; + let Some(target_session_id) = + resolve_assignment_target_session_id(&team_context.team_state, owner_name).await + else { + return; + }; + + let Ok(payload) = serde_json::to_string(&json!({ + "type": "task_assignment", + "taskId": existing_task.id.clone(), + "subject": existing_task.subject.clone(), + "description": existing_task.description.clone(), + "assignedBy": team_context.current_member_name, + "timestamp": Utc::now().to_rfc3339(), + })) else { + return; + }; + + UserMessageManager::global() + .enqueue_scoped( + ActionRequiredScope { + session_id: Some(target_session_id.clone()), + thread_id: Some(target_session_id), + turn_id: None, + }, + Message::user().with_text(payload).agent_only(), + ) + .await; +} + fn find_task_index(state: &TaskBoardState, task_id: &str) -> Option { state.items.iter().position(|item| item.id == task_id) } @@ -355,6 +545,10 @@ impl Tool for TaskCreateTool { "TaskCreate" } + fn aliases(&self) -> &'static [&'static str] { + TASK_CREATE_TOOL_ALIASES + } + fn description(&self) -> &str { "创建新版结构化任务。用于在当前会话中建立可跟踪的任务板条目。" } @@ -380,7 +574,8 @@ impl Tool for TaskCreateTool { "description": "附加元数据(可选)" } }, - "required": ["subject", "description"] + "required": ["subject", "description"], + "additionalProperties": false }) } @@ -395,8 +590,8 @@ impl Tool for TaskCreateTool { ensure_non_empty(&input.subject, "subject")?; ensure_non_empty(&input.description, "description")?; - let task_list_id = resolve_task_list_id(context).await; - let mut state = load_task_board_state(&self.storage, &task_list_id, context).await; + let binding = resolve_task_board_binding(context).await; + let mut state = load_task_board_state(&self.storage, &binding).await; let task_id = state.allocate_id(); let task = TaskBoardItem { @@ -411,23 +606,34 @@ impl Tool for TaskCreateTool { metadata: input.metadata.filter(|value| !value.is_empty()), }; state.items.push(task.clone()); - persist_task_board(&self.storage, &task_list_id, context, state.clone()).await; + persist_task_board(&self.storage, &binding, state.clone()).await; + if let Some(blocking_message) = + blocking_task_hook_feedback(HookEvent::TaskCreated, &task, context).await + { + state.items.retain(|item| item.id != task.id); + persist_task_board(&self.storage, &binding, state).await; + return Err(ToolError::execution_failed(blocking_message)); + } - Ok(ToolResult::success(format!( - "Task #{} created successfully: {}", - task.id, task.subject - )) - .with_metadata("task", build_task_metadata(&task)) - .with_metadata("task_list_id", json!(task_list_id)) - .with_metadata("task_list", json!(build_harness_snapshot(&state))) - .with_metadata( - "tasks", - json!(state - .items - .iter() - .map(build_task_metadata) - .collect::>()), - )) + let output = json!({ + "task": { + "id": task.id.clone(), + "subject": task.subject.clone(), + } + }); + + Ok(ToolResult::success(pretty_json(&output)?) + .with_metadata("task", build_task_metadata(&task)) + .with_metadata("task_list_id", json!(binding.task_list_id)) + .with_metadata("task_list", json!(build_harness_snapshot(&state))) + .with_metadata( + "tasks", + json!(state + .items + .iter() + .map(build_task_metadata) + .collect::>()), + )) } async fn check_permissions( @@ -468,6 +674,10 @@ impl Tool for TaskListTool { "TaskList" } + fn aliases(&self) -> &'static [&'static str] { + TASK_LIST_TOOL_ALIASES + } + fn description(&self) -> &str { "列出当前任务板中的全部任务摘要。" } @@ -488,89 +698,51 @@ impl Tool for TaskListTool { let _: TaskListInput = serde_json::from_value(params) .map_err(|error| ToolError::invalid_params(format!("参数解析失败: {error}")))?; - let task_list_id = resolve_task_list_id(context).await; - let state = load_task_board_state(&self.storage, &task_list_id, context).await; + let binding = resolve_task_board_binding(context).await; + let state = load_task_board_state(&self.storage, &binding).await; let visible_tasks = state .items .iter() .filter(|task| !is_internal_task(task)) .collect::>(); + let resolved_task_ids = visible_tasks + .iter() + .filter(|task| task.status == TaskBoardItemStatus::Completed) + .map(|task| task.id.clone()) + .collect::>(); + let task_output = build_task_list_output_tasks(&visible_tasks, &resolved_task_ids); if visible_tasks.is_empty() { - return Ok(ToolResult::success("No tasks found") - .with_metadata("task_list_id", json!(task_list_id)) + return Ok(ToolResult::success(pretty_json(&json!({ "tasks": [] }))?) + .with_metadata("task_list_id", json!(binding.task_list_id)) .with_metadata("task_list", json!([])) .with_metadata("tasks", json!([]))); } - let resolved_task_ids = state - .items - .iter() - .filter(|task| !is_internal_task(task)) - .filter(|task| task.status == TaskBoardItemStatus::Completed) - .map(|task| task.id.clone()) - .collect::>(); - - let lines = visible_tasks - .iter() - .map(|task| { - let owner = task - .owner - .as_ref() - .map(|value| format!(" ({value})")) - .unwrap_or_default(); - let blocked_by = task - .blocked_by - .iter() - .filter(|id| !resolved_task_ids.contains(*id)) - .cloned() - .collect::>(); - let blocked_suffix = if blocked_by.is_empty() { - String::new() - } else { - format!( - " [blocked by {}]", - blocked_by - .iter() - .map(|id| format!("#{id}")) - .collect::>() - .join(", ") - ) - }; - - format!( - "#{} [{}] {}{}{}", - task.id, - task_status_label(&task.status), - task.subject, - owner, - blocked_suffix - ) - }) - .collect::>(); - - Ok(ToolResult::success(lines.join("\n")) - .with_metadata("task_list_id", json!(task_list_id)) - .with_metadata( - "task_list", - json!(visible_tasks - .iter() - .map(|task| { - json!({ - "id": task.id.clone(), - "content": task.subject.clone(), - "status": task_status_label(&task.status), - }) + Ok(ToolResult::success(pretty_json(&json!({ + "tasks": task_output, + }))?) + .with_metadata("task_list_id", json!(binding.task_list_id)) + .with_metadata( + "task_list", + json!(visible_tasks + .iter() + .map(|task| { + json!({ + "id": task.id.clone(), + "content": task.subject.clone(), + "status": task_status_label(&task.status), }) - .collect::>()), - ) - .with_metadata( - "tasks", - json!(visible_tasks - .iter() - .map(|task| build_task_metadata(task)) - .collect::>()), - )) + }) + .collect::>()), + ) + .with_metadata( + "tasks", + json!(visible_tasks + .iter() + .map(|task| build_task_metadata(task)) + .collect::>()), + )) } async fn check_permissions( @@ -611,6 +783,10 @@ impl Tool for TaskGetTool { "TaskGet" } + fn aliases(&self) -> &'static [&'static str] { + TASK_GET_TOOL_ALIASES + } + fn description(&self) -> &str { "获取单个任务的完整详情。" } @@ -624,7 +800,8 @@ impl Tool for TaskGetTool { "description": "任务 ID" } }, - "required": ["taskId"] + "required": ["taskId"], + "additionalProperties": false }) } @@ -637,8 +814,8 @@ impl Tool for TaskGetTool { .map_err(|error| ToolError::invalid_params(format!("参数解析失败: {error}")))?; ensure_non_empty(&input.task_id, "taskId")?; - let task_list_id = resolve_task_list_id(context).await; - let state = load_task_board_state(&self.storage, &task_list_id, context).await; + let binding = resolve_task_board_binding(context).await; + let state = load_task_board_state(&self.storage, &binding).await; let task = state .items .iter() @@ -646,14 +823,25 @@ impl Tool for TaskGetTool { .cloned(); match task { - Some(task) => Ok(ToolResult::success(render_task_summary(&task)) - .with_metadata("task", build_task_metadata(&task)) - .with_metadata("task_list_id", json!(task_list_id)) - .with_metadata("task_list", json!(build_harness_snapshot(&state)))), - None => Ok(ToolResult::success("Task not found") - .with_metadata("task", Value::Null) - .with_metadata("task_list_id", json!(task_list_id)) - .with_metadata("task_list", json!(build_harness_snapshot(&state)))), + Some(task) => Ok(ToolResult::success(pretty_json(&json!({ + "task": { + "id": task.id.clone(), + "subject": task.subject.clone(), + "description": task.description.clone(), + "status": task_status_label(&task.status), + "blocks": task.blocks.clone(), + "blockedBy": task.blocked_by.clone(), + } + }))?) + .with_metadata("task", build_task_metadata(&task)) + .with_metadata("task_list_id", json!(binding.task_list_id)) + .with_metadata("task_list", json!(build_harness_snapshot(&state)))), + None => Ok( + ToolResult::success(pretty_json(&json!({ "task": Value::Null }))?) + .with_metadata("task", Value::Null) + .with_metadata("task_list_id", json!(binding.task_list_id)) + .with_metadata("task_list", json!(build_harness_snapshot(&state))), + ), } } @@ -695,6 +883,10 @@ impl Tool for TaskUpdateTool { "TaskUpdate" } + fn aliases(&self) -> &'static [&'static str] { + TASK_UPDATE_TOOL_ALIASES + } + fn description(&self) -> &str { "更新任务板中的任务状态、描述、依赖或所有者。" } @@ -742,7 +934,8 @@ impl Tool for TaskUpdateTool { "description": "合并写入的元数据,值为 null 时表示删除键" } }, - "required": ["taskId"] + "required": ["taskId"], + "additionalProperties": false }) } @@ -755,25 +948,51 @@ impl Tool for TaskUpdateTool { .map_err(|error| ToolError::invalid_params(format!("参数解析失败: {error}")))?; ensure_non_empty(&input.task_id, "taskId")?; - let task_list_id = resolve_task_list_id(context).await; - let mut state = load_task_board_state(&self.storage, &task_list_id, context).await; + let binding = resolve_task_board_binding(context).await; + let state = load_task_board_state(&self.storage, &binding).await; let Some(task_index) = find_task_index(&state, &input.task_id) else { - return Ok(ToolResult::success("Task not found") - .with_metadata("success", json!(false)) - .with_metadata("task_id", json!(input.task_id)) - .with_metadata("task_list_id", json!(task_list_id)) - .with_metadata("task_list", json!(build_harness_snapshot(&state)))); + return Ok(ToolResult::success(pretty_json(&json!({ + "success": false, + "taskId": input.task_id, + "updatedFields": [], + "error": "Task not found", + }))?) + .with_metadata("success", json!(false)) + .with_metadata("task_id", json!(input.task_id)) + .with_metadata("task_list_id", json!(binding.task_list_id)) + .with_metadata("task_list", json!(build_harness_snapshot(&state)))); + }; + let previous_task = state.items[task_index].clone(); + let previous_status = previous_task.status.clone(); + let team_context = resolve_team_context(&context.session_id) + .await + .ok() + .flatten(); + let auto_owner = if input.status == Some(TaskUpdateStatus::InProgress) + && input.owner.is_none() + && previous_task.owner.is_none() + { + team_context + .as_ref() + .map(|team_context| team_context.current_member_name.clone()) + } else { + None }; - let previous_status = state.items[task_index].status.clone(); if input.status == Some(TaskUpdateStatus::Deleted) { - state.items.remove(task_index); - remove_task_from_dependencies(&mut state, &input.task_id); - persist_task_board(&self.storage, &task_list_id, context, state.clone()).await; - return Ok(ToolResult::success(format!( - "Task #{} deleted successfully", - input.task_id - )) + let mut next_state = state.clone(); + next_state.items.remove(task_index); + remove_task_from_dependencies(&mut next_state, &input.task_id); + persist_task_board(&self.storage, &binding, next_state.clone()).await; + return Ok(ToolResult::success(pretty_json(&json!({ + "success": true, + "taskId": input.task_id, + "updatedFields": ["deleted"], + "statusChange": { + "from": task_status_label(&previous_status), + "to": "deleted", + } + }))?) .with_metadata("success", json!(true)) .with_metadata( "status_change", @@ -783,24 +1002,45 @@ impl Tool for TaskUpdateTool { }), ) .with_metadata("task_id", json!(input.task_id)) - .with_metadata("task_list_id", json!(task_list_id)) - .with_metadata("task_list", json!(build_harness_snapshot(&state)))); + .with_metadata("task_list_id", json!(binding.task_list_id)) + .with_metadata("task_list", json!(build_harness_snapshot(&next_state)))); } + if input.status == Some(TaskUpdateStatus::Completed) + && previous_status != TaskBoardItemStatus::Completed + { + if let Some(blocking_message) = + blocking_task_hook_feedback(HookEvent::TaskCompleted, &previous_task, context).await + { + return Ok(ToolResult::success(pretty_json(&json!({ + "success": false, + "taskId": input.task_id, + "updatedFields": [], + "error": blocking_message, + }))?) + .with_metadata("success", json!(false)) + .with_metadata("task_id", json!(input.task_id)) + .with_metadata("task_list_id", json!(binding.task_list_id)) + .with_metadata("task_list", json!(build_harness_snapshot(&state)))); + } + } + + let mut next_state = state.clone(); let mut updated_fields = Vec::new(); + let mut assigned_owner = None; if let Some(subject) = input.subject { ensure_non_empty(&subject, "subject")?; - if state.items[task_index].subject != subject { - state.items[task_index].subject = subject; + if next_state.items[task_index].subject != subject { + next_state.items[task_index].subject = subject; updated_fields.push("subject"); } } if let Some(description) = input.description { ensure_non_empty(&description, "description")?; - if state.items[task_index].description != description { - state.items[task_index].description = description; + if next_state.items[task_index].description != description { + next_state.items[task_index].description = description; updated_fields.push("description"); } } @@ -811,86 +1051,146 @@ impl Tool for TaskUpdateTool { } else { Some(active_form) }; - if state.items[task_index].active_form != normalized { - state.items[task_index].active_form = normalized; + if next_state.items[task_index].active_form != normalized { + next_state.items[task_index].active_form = normalized; updated_fields.push("activeForm"); } } + if let Some(owner) = auto_owner { + if next_state.items[task_index].owner.is_none() { + next_state.items[task_index].owner = Some(owner.clone()); + assigned_owner = Some(owner); + updated_fields.push("owner"); + } + } + if let Some(owner) = input.owner { let normalized = if owner.trim().is_empty() { None } else { Some(owner) }; - if state.items[task_index].owner != normalized { - state.items[task_index].owner = normalized; + if next_state.items[task_index].owner != normalized { + next_state.items[task_index].owner = normalized.clone(); + assigned_owner = normalized; updated_fields.push("owner"); } } if let Some(metadata_patch) = input.metadata { - let updated_metadata = - normalize_metadata_patch(state.items[task_index].metadata.clone(), metadata_patch); - if state.items[task_index].metadata != updated_metadata { - state.items[task_index].metadata = updated_metadata; + let updated_metadata = normalize_metadata_patch( + next_state.items[task_index].metadata.clone(), + metadata_patch, + ); + if next_state.items[task_index].metadata != updated_metadata { + next_state.items[task_index].metadata = updated_metadata; updated_fields.push("metadata"); } } if let Some(status) = input.status { if let Some(next_status) = status.into_task_status() { - if state.items[task_index].status != next_status { - state.items[task_index].status = next_status; + if next_state.items[task_index].status != next_status { + next_state.items[task_index].status = next_status; updated_fields.push("status"); } } } if let Some(add_blocks) = input.add_blocks { + let mut added_blocks = false; for blocked_id in dedupe_ids(add_blocks) { - apply_dependency(&mut state, &input.task_id, &blocked_id)?; + let already_present = next_state.items[task_index].blocks.contains(&blocked_id); + apply_dependency(&mut next_state, &input.task_id, &blocked_id)?; + added_blocks |= !already_present; + } + if added_blocks { + updated_fields.push("blocks"); } - updated_fields.push("addBlocks"); } if let Some(add_blocked_by) = input.add_blocked_by { + let mut added_blockers = false; for blocker_id in dedupe_ids(add_blocked_by) { - apply_dependency(&mut state, &blocker_id, &input.task_id)?; + let already_present = next_state.items[task_index] + .blocked_by + .contains(&blocker_id); + apply_dependency(&mut next_state, &blocker_id, &input.task_id)?; + added_blockers |= !already_present; + } + if added_blockers { + updated_fields.push("blockedBy"); } - updated_fields.push("addBlockedBy"); } - let task = state.items[task_index].clone(); - persist_task_board(&self.storage, &task_list_id, context, state.clone()).await; + let task = next_state.items[task_index].clone(); + persist_task_board(&self.storage, &binding, next_state.clone()).await; + if let Some(owner_name) = assigned_owner.as_deref() { + enqueue_task_assignment_message(context, &previous_task, owner_name).await; + } + let status_change = (previous_status != task.status).then(|| { + json!({ + "from": task_status_label(&previous_status), + "to": task_status_label(&task.status) + }) + }); + let should_emit_verification_nudge = + SessionManager::get_session(&context.session_id, false) + .await + .map(|session| session.session_type != SessionType::SubAgent) + .unwrap_or(true); + let verification_nudge_needed = should_emit_verification_nudge + && previous_status != TaskBoardItemStatus::Completed + && task.status == TaskBoardItemStatus::Completed + && { + let visible_tasks = next_state + .items + .iter() + .filter(|item| !is_internal_task(item)) + .collect::>(); + visible_tasks.len() >= 3 + && visible_tasks + .iter() + .all(|item| item.status == TaskBoardItemStatus::Completed) + && !visible_tasks + .iter() + .any(|item| item.subject.to_ascii_lowercase().contains("verif")) + }; - let mut result = ToolResult::success(format!( - "Task #{} updated successfully: {}", - task.id, task.subject - )) - .with_metadata("success", json!(true)) - .with_metadata("task_id", json!(task.id)) - .with_metadata("updated_fields", json!(updated_fields)) - .with_metadata("task", build_task_metadata(&task)) - .with_metadata("task_list_id", json!(task_list_id)) - .with_metadata("task_list", json!(build_harness_snapshot(&state))) - .with_metadata( - "tasks", - json!(state - .items - .iter() - .map(build_task_metadata) - .collect::>()), - ); + let mut output = json!({ + "success": true, + "taskId": task.id.clone(), + "updatedFields": updated_fields, + }); + if let Some(status_change) = status_change.clone() { + output["statusChange"] = status_change; + } + if verification_nudge_needed { + output["verificationNudgeNeeded"] = json!(true); + } - if previous_status != task.status { - result = result.with_metadata( - "status_change", - json!({ - "from": task_status_label(&previous_status), - "to": task_status_label(&task.status) - }), + let mut result = ToolResult::success(pretty_json(&output)?) + .with_metadata("success", json!(true)) + .with_metadata("task_id", json!(task.id)) + .with_metadata("updated_fields", json!(output["updatedFields"].clone())) + .with_metadata("task", build_task_metadata(&task)) + .with_metadata("task_list_id", json!(binding.task_list_id)) + .with_metadata("task_list", json!(build_harness_snapshot(&next_state))) + .with_metadata( + "tasks", + json!(next_state + .items + .iter() + .map(build_task_metadata) + .collect::>()), ); + + if let Some(status_change) = status_change { + result = result.with_metadata("status_change", status_change); + } + if verification_nudge_needed { + result = result.with_metadata("verification_nudge_needed", json!(true)); } Ok(result) @@ -908,13 +1208,37 @@ impl Tool for TaskUpdateTool { #[cfg(test)] mod tests { use super::*; + use crate::hooks::{clear_hooks, register_hook, CommandHookConfig, HookConfig}; + use crate::session::{ + save_team_membership, save_team_state, SessionManager, SessionType, TeamMember, + TeamMembershipState, TeamSessionState, + }; + use serial_test::serial; use std::path::PathBuf; + use tempfile::tempdir; + use uuid::Uuid; + + struct HookRegistryGuard; + + impl HookRegistryGuard { + fn new() -> Self { + clear_hooks(); + Self + } + } + + impl Drop for HookRegistryGuard { + fn drop(&mut self) { + clear_hooks(); + } + } fn create_test_context() -> ToolContext { ToolContext::new(PathBuf::from("/tmp")).with_session_id("task-board-session") } #[tokio::test] + #[serial(task_list_tools)] async fn test_task_create_and_list_tools() { let storage = Arc::new(TaskListStorage::new()); let create_tool = TaskCreateTool::with_storage(storage.clone()); @@ -946,6 +1270,7 @@ mod tests { } #[tokio::test] + #[serial(task_list_tools)] async fn test_task_get_tool_returns_task_details() { let storage = Arc::new(TaskListStorage::new()); let create_tool = TaskCreateTool::with_storage(storage.clone()); @@ -974,6 +1299,7 @@ mod tests { } #[tokio::test] + #[serial(task_list_tools)] async fn test_task_update_tool_updates_status_and_dependencies() { let storage = Arc::new(TaskListStorage::new()); let create_tool = TaskCreateTool::with_storage(storage.clone()); @@ -1023,12 +1349,82 @@ mod tests { .execute(json!({}), &context) .await .expect("list should succeed"); - let output = list_result.output.unwrap_or_default(); - assert!(output.contains("#1 [in_progress] 任务一")); - assert!(output.contains("#2 [pending] 任务二 [blocked by #1]")); + let output: serde_json::Value = serde_json::from_str( + list_result + .output + .as_deref() + .expect("expected TaskList output json"), + ) + .expect("valid task list json"); + assert_eq!(output["tasks"][0]["id"], json!("1")); + assert_eq!(output["tasks"][0]["status"], json!("in_progress")); + assert_eq!(output["tasks"][1]["id"], json!("2")); + assert_eq!(output["tasks"][1]["blockedBy"], json!(["1"])); } #[tokio::test] + #[serial(task_list_tools)] + async fn test_task_list_filters_completed_blockers_from_blocked_by() { + let storage = Arc::new(TaskListStorage::new()); + let create_tool = TaskCreateTool::with_storage(storage.clone()); + let update_tool = TaskUpdateTool::with_storage(storage.clone()); + let list_tool = TaskListTool::with_storage(storage); + let context = create_test_context(); + + create_tool + .execute( + json!({ + "subject": "先完成 blocker", + "description": "完成后不应继续阻塞后续任务" + }), + &context, + ) + .await + .expect("task 1 create should succeed"); + create_tool + .execute( + json!({ + "subject": "后续任务", + "description": "被 blocker 解除后应显示为未阻塞" + }), + &context, + ) + .await + .expect("task 2 create should succeed"); + + update_tool + .execute( + json!({ + "taskId": "1", + "addBlocks": ["2"] + }), + &context, + ) + .await + .expect("dependency update should succeed"); + update_tool + .execute( + json!({ + "taskId": "1", + "status": "completed" + }), + &context, + ) + .await + .expect("completion update should succeed"); + + let list_result = list_tool + .execute(json!({}), &context) + .await + .expect("list should succeed"); + let output: Value = + serde_json::from_str(&list_result.output.unwrap_or_default()).expect("valid json"); + assert_eq!(output["tasks"][0]["status"], json!("completed")); + assert_eq!(output["tasks"][1]["blockedBy"], json!([])); + } + + #[tokio::test] + #[serial(task_list_tools)] async fn test_task_update_tool_deletes_task() { let storage = Arc::new(TaskListStorage::new()); let create_tool = TaskCreateTool::with_storage(storage.clone()); @@ -1062,10 +1458,13 @@ mod tests { .execute(json!({}), &context) .await .expect("list should succeed"); - assert_eq!(list_result.output.unwrap_or_default(), "No tasks found"); + let output: Value = + serde_json::from_str(&list_result.output.unwrap_or_default()).expect("valid json"); + assert_eq!(output, json!({ "tasks": [] })); } #[tokio::test] + #[serial(task_list_tools)] async fn test_task_list_tool_rejects_unknown_fields() { let storage = Arc::new(TaskListStorage::new()); let list_tool = TaskListTool::with_storage(storage); @@ -1078,6 +1477,7 @@ mod tests { } #[tokio::test] + #[serial(task_list_tools)] async fn test_task_list_tool_hides_internal_tasks() { let storage = Arc::new(TaskListStorage::new()); let create_tool = TaskCreateTool::with_storage(storage.clone()); @@ -1123,4 +1523,372 @@ mod tests { ); assert_eq!(list_result.metadata["tasks"].as_array().unwrap().len(), 1); } + + #[tokio::test] + #[serial(task_list_tools)] + async fn test_team_sessions_share_task_board_via_lead_session() { + let temp_dir = tempdir().expect("tempdir should succeed"); + let lead = SessionManager::create_session( + temp_dir.path().to_path_buf(), + format!("task-team-lead-{}", Uuid::new_v4()), + SessionType::Hidden, + ) + .await + .expect("lead session should be created"); + let child = SessionManager::create_session( + temp_dir.path().to_path_buf(), + format!("task-team-child-{}", Uuid::new_v4()), + SessionType::SubAgent, + ) + .await + .expect("child session should be created"); + + let mut team_state = TeamSessionState::new("delivery-team", lead.id.clone(), None, None); + team_state.add_or_update_member(TeamMember::teammate( + child.id.clone(), + "researcher".to_string(), + None, + )); + save_team_state(&lead.id, Some(team_state)) + .await + .expect("team state should be saved"); + save_team_membership( + &child.id, + Some(TeamMembershipState { + team_name: "delivery-team".to_string(), + lead_session_id: lead.id.clone(), + agent_id: child.id.clone(), + name: "researcher".to_string(), + agent_type: None, + }), + ) + .await + .expect("membership should be saved"); + + let lead_storage = Arc::new(TaskListStorage::new()); + let child_storage = Arc::new(TaskListStorage::new()); + let lead_create_tool = TaskCreateTool::with_storage(lead_storage.clone()); + let lead_list_tool = TaskListTool::with_storage(lead_storage); + let child_list_tool = TaskListTool::with_storage(child_storage.clone()); + let child_update_tool = TaskUpdateTool::with_storage(child_storage); + + let lead_context = + ToolContext::new(temp_dir.path().to_path_buf()).with_session_id(&lead.id); + let child_context = + ToolContext::new(temp_dir.path().to_path_buf()).with_session_id(&child.id); + + let create_result = lead_create_tool + .execute( + json!({ + "subject": "共享任务", + "description": "team 成员都应该读到同一份任务板" + }), + &lead_context, + ) + .await + .expect("team lead create should succeed"); + assert_eq!( + create_result.metadata["task_list_id"], + json!("delivery-team") + ); + + let child_list_result = child_list_tool + .execute(json!({}), &child_context) + .await + .expect("child list should see shared board"); + let child_tasks: Value = serde_json::from_str( + child_list_result + .output + .as_deref() + .expect("child list output should exist"), + ) + .expect("child task list output should be valid json"); + assert_eq!(child_tasks["tasks"][0]["subject"], json!("共享任务")); + + let child_update_result = child_update_tool + .execute( + json!({ + "taskId": "1", + "status": "in_progress" + }), + &child_context, + ) + .await + .expect("child update should succeed"); + assert_eq!( + child_update_result.metadata["task"]["owner"], + json!("researcher") + ); + + let lead_list_result = lead_list_tool + .execute(json!({}), &lead_context) + .await + .expect("lead list should reflect child update"); + let lead_tasks: Value = serde_json::from_str( + lead_list_result + .output + .as_deref() + .expect("lead list output should exist"), + ) + .expect("lead task list output should be valid json"); + assert_eq!(lead_tasks["tasks"][0]["status"], json!("in_progress")); + } + + #[tokio::test] + #[serial(task_list_tools)] + async fn test_task_update_skips_verification_nudge_for_subagents() { + let temp_dir = tempdir().expect("tempdir should succeed"); + let session = SessionManager::create_session( + temp_dir.path().to_path_buf(), + format!("task-subagent-{}", Uuid::new_v4()), + SessionType::SubAgent, + ) + .await + .expect("subagent session should be created"); + let context = ToolContext::new(temp_dir.path().to_path_buf()).with_session_id(&session.id); + + let storage = Arc::new(TaskListStorage::new()); + let create_tool = TaskCreateTool::with_storage(storage.clone()); + let update_tool = TaskUpdateTool::with_storage(storage); + + for index in 1..=3 { + create_tool + .execute( + json!({ + "subject": format!("任务 {index}"), + "description": format!("子代理任务 {index}") + }), + &context, + ) + .await + .expect("create should succeed"); + } + + update_tool + .execute(json!({ "taskId": "1", "status": "completed" }), &context) + .await + .expect("task 1 completion should succeed"); + update_tool + .execute(json!({ "taskId": "2", "status": "completed" }), &context) + .await + .expect("task 2 completion should succeed"); + let final_result = update_tool + .execute(json!({ "taskId": "3", "status": "completed" }), &context) + .await + .expect("task 3 completion should succeed"); + + assert!(final_result + .metadata + .get("verification_nudge_needed") + .is_none()); + } + + #[tokio::test] + #[serial(task_list_tools)] + async fn test_task_create_rolls_back_when_task_created_hook_blocks() { + let _hook_guard = HookRegistryGuard::new(); + register_hook( + HookEvent::TaskCreated, + HookConfig::Command(CommandHookConfig { + command: "printf '{\"blocked\":true,\"message\":\"请先补任务说明\"}'; exit 1" + .to_string(), + args: vec![], + env: HashMap::new(), + timeout: 30_000, + blocking: true, + matcher: None, + }), + ); + + let storage = Arc::new(TaskListStorage::new()); + let create_tool = TaskCreateTool::with_storage(storage.clone()); + let list_tool = TaskListTool::with_storage(storage); + let context = create_test_context(); + + let create_result = create_tool + .execute( + json!({ + "subject": "受阻任务", + "description": "TaskCreated hook 会阻止这个任务" + }), + &context, + ) + .await; + + assert!(matches!( + create_result, + Err(ToolError::ExecutionFailed(message)) + if message.contains("TaskCreated hook feedback:\n请先补任务说明") + )); + + let list_result = list_tool + .execute(json!({}), &context) + .await + .expect("list should succeed"); + let output: Value = + serde_json::from_str(&list_result.output.unwrap_or_default()).expect("valid json"); + assert_eq!(output, json!({ "tasks": [] })); + } + + #[tokio::test] + #[serial(task_list_tools)] + async fn test_task_update_returns_success_false_when_task_completed_hook_blocks() { + let _hook_guard = HookRegistryGuard::new(); + register_hook( + HookEvent::TaskCompleted, + HookConfig::Command(CommandHookConfig { + command: "printf '{\"blocked\":true,\"message\":\"需要先跑验证\"}'; exit 1" + .to_string(), + args: vec![], + env: HashMap::new(), + timeout: 30_000, + blocking: true, + matcher: None, + }), + ); + + let storage = Arc::new(TaskListStorage::new()); + let create_tool = TaskCreateTool::with_storage(storage.clone()); + let update_tool = TaskUpdateTool::with_storage(storage.clone()); + let get_tool = TaskGetTool::with_storage(storage); + let context = create_test_context(); + + create_tool + .execute( + json!({ + "subject": "待完成任务", + "description": "TaskCompleted hook 会阻止完成" + }), + &context, + ) + .await + .expect("create should succeed"); + + let update_result = update_tool + .execute(json!({ "taskId": "1", "status": "completed" }), &context) + .await + .expect("update should return structured failure"); + let output: Value = + serde_json::from_str(&update_result.output.unwrap_or_default()).expect("valid json"); + assert_eq!(output["success"], json!(false)); + assert_eq!(output["updatedFields"], json!([])); + assert_eq!( + output["error"], + json!("TaskCompleted hook feedback:\n需要先跑验证") + ); + + let get_result = get_tool + .execute(json!({ "taskId": "1" }), &context) + .await + .expect("get should succeed"); + let task_output: Value = + serde_json::from_str(&get_result.output.unwrap_or_default()).expect("valid json"); + assert_eq!(task_output["task"]["status"], json!("pending")); + } + + #[tokio::test] + #[serial(task_list_tools)] + async fn test_task_update_enqueues_assignment_message_for_owner_change() { + let temp_dir = tempdir().expect("tempdir should succeed"); + let lead = SessionManager::create_session( + temp_dir.path().to_path_buf(), + format!("task-assignment-lead-{}", Uuid::new_v4()), + SessionType::Hidden, + ) + .await + .expect("lead session should be created"); + let child = SessionManager::create_session( + temp_dir.path().to_path_buf(), + format!("task-assignment-child-{}", Uuid::new_v4()), + SessionType::SubAgent, + ) + .await + .expect("child session should be created"); + + let mut team_state = TeamSessionState::new("dispatch-team", lead.id.clone(), None, None); + team_state.add_or_update_member(TeamMember::teammate( + child.id.clone(), + "researcher".to_string(), + None, + )); + save_team_state(&lead.id, Some(team_state)) + .await + .expect("team state should be saved"); + save_team_membership( + &child.id, + Some(TeamMembershipState { + team_name: "dispatch-team".to_string(), + lead_session_id: lead.id.clone(), + agent_id: child.id.clone(), + name: "researcher".to_string(), + agent_type: None, + }), + ) + .await + .expect("membership should be saved"); + + let storage = Arc::new(TaskListStorage::new()); + let create_tool = TaskCreateTool::with_storage(storage.clone()); + let update_tool = TaskUpdateTool::with_storage(storage); + let lead_context = + ToolContext::new(temp_dir.path().to_path_buf()).with_session_id(&lead.id); + + create_tool + .execute( + json!({ + "subject": "指派任务", + "description": "把任务交给 researcher" + }), + &lead_context, + ) + .await + .expect("create should succeed"); + + update_tool + .execute( + json!({ + "taskId": "1", + "owner": "researcher" + }), + &lead_context, + ) + .await + .expect("assignment should succeed"); + + let scope = ActionRequiredScope { + session_id: Some(child.id.clone()), + thread_id: Some(child.id.clone()), + turn_id: None, + }; + let queued_messages = UserMessageManager::global() + .drain_messages_for_scope(&scope) + .await; + assert_eq!(queued_messages.len(), 1); + assert!(queued_messages[0].metadata.agent_visible); + assert!(!queued_messages[0].metadata.user_visible); + + let payload: Value = + serde_json::from_str(&queued_messages[0].as_concat_text()).expect("valid json"); + assert_eq!(payload["type"], json!("task_assignment")); + assert_eq!(payload["taskId"], json!("1")); + assert_eq!(payload["subject"], json!("指派任务")); + assert_eq!(payload["description"], json!("把任务交给 researcher")); + assert_eq!(payload["assignedBy"], json!("team-lead")); + + update_tool + .execute( + json!({ + "taskId": "1", + "owner": "researcher" + }), + &lead_context, + ) + .await + .expect("same owner update should still succeed"); + + let drained_again = UserMessageManager::global() + .drain_messages_for_scope(&scope) + .await; + assert!(drained_again.is_empty()); + } } diff --git a/src-tauri/crates/aster-rust/crates/aster/src/tools/task_output_tool.rs b/src-tauri/crates/aster-rust/crates/aster/src/tools/task_output_tool.rs index e6f622906..292349656 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/tools/task_output_tool.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/tools/task_output_tool.rs @@ -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, /// 等待超时时间(毫秒) pub timeout: Option, } +#[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, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct TaskOutputResponse { + retrieval_status: String, + task: Option, +} + /// TaskOutputTool - 查询任务输出和状态 /// /// 对齐当前工具面的 TaskOutput 能力 @@ -51,12 +78,41 @@ impl Default for TaskOutputTool { } } +fn pretty_json(value: &T) -> Result { + 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, D::Error> +where + D: Deserializer<'de>, +{ + let value = Option::::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(); diff --git a/src-tauri/crates/aster-rust/crates/aster/src/tools/task_stop_tool.rs b/src-tauri/crates/aster-rust/crates/aster/src/tools/task_stop_tool.rs index 82292de30..56688ed78 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/tools/task_stop_tool.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/tools/task_stop_tool.rs @@ -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, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct TaskStopPayload { + message: String, + task_id: String, + task_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + command: Option, } /// 停止后台任务的新版工具 @@ -46,12 +58,21 @@ impl TaskStopTool { } } +fn pretty_json(value: &T) -> Result { + 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 { - 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")); + } } diff --git a/src-tauri/crates/aster-rust/crates/aster/src/tools/team_tools.rs b/src-tauri/crates/aster-rust/crates/aster/src/tools/team_tools.rs index 6ab4c6ae8..d420a2cdd 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/tools/team_tools.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/tools/team_tools.rs @@ -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, } #[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, + #[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, peers: Vec, } +#[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::>(); 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::>(); + 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 { + 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::>(); - 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 { @@ -371,16 +510,116 @@ fn pretty_json(value: &T) -> Result { .map_err(|error| ToolError::execution_failed(format!("序列化结果失败: {error}"))) } +async fn resolve_reachable_team_members( + team_state: &TeamSessionState, +) -> Result, 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, 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(()) } } diff --git a/src-tauri/crates/aster-rust/crates/aster/src/tools/tool_search_tool.rs b/src-tauri/crates/aster-rust/crates/aster/src/tools/tool_search_tool.rs index d036cc74b..0efb9f0ac 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/tools/tool_search_tool.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/tools/tool_search_tool.rs @@ -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, query: String, total_deferred_tools: usize, + #[serde(skip_serializing_if = "Option::is_none")] + pending_mcp_servers: Option>, #[serde(default, skip_serializing_if = "Vec::is_empty")] notes: Vec, } @@ -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 { } 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> { + 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 { 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::>(); + 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()]); + } } diff --git a/src-tauri/crates/aster-rust/crates/aster/src/tools/web.rs b/src-tauri/crates/aster-rust/crates/aster/src/tools/web.rs index 19e782c70..964cc8e80 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/tools/web.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/tools/web.rs @@ -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>, } +#[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, +} + +#[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, + 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>>, @@ -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) -> 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 { + 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::(&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::(&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::(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 { + 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 { + 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| 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::>() .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(); diff --git a/src-tauri/crates/aster-rust/crates/aster/src/tools/workflow_tool.rs b/src-tauri/crates/aster-rust/crates/aster/src/tools/workflow_tool.rs index c9b604213..eda03f468 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/tools/workflow_tool.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/tools/workflow_tool.rs @@ -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)] diff --git a/src-tauri/crates/aster-rust/crates/aster/src/tools/worktree_tools.rs b/src-tauri/crates/aster-rust/crates/aster/src/tools/worktree_tools.rs index 3588d8573..505137179 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/tools/worktree_tools.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/tools/worktree_tools.rs @@ -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, @@ -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, #[serde(skip_serializing_if = "Option::is_none")] discarded_commits: Option, - #[serde(skip_serializing_if = "Option::is_none")] - noop: Option, 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(()) } diff --git a/src-tauri/crates/core/src/app_paths.rs b/src-tauri/crates/core/src/app_paths.rs index bd846fa84..ba777e3c7 100644 --- a/src-tauri/crates/core/src/app_paths.rs +++ b/src-tauri/crates/core/src/app_paths.rs @@ -7,6 +7,7 @@ const APP_DATA_DIR_NAME: &str = "lime"; const LEGACY_APP_DATA_DIR_NAME: &str = "proxycast"; const LEGACY_HOME_DIR_NAME: &str = ".proxycast"; const COMPAT_HOME_DIR_NAME: &str = ".lime"; +const ASTER_RUNTIME_OVERRIDE_ENV: &str = "LIME_ASTER_ROOT"; const DATABASE_FILE_NAME: &str = "lime.db"; const LEGACY_DATABASE_FILE_NAME: &str = "proxycast.db"; const MIGRATION_MARKER_FILE: &str = ".migration_completed"; @@ -83,6 +84,11 @@ pub fn resolve_skills_dir() -> Result { } pub fn resolve_aster_dir() -> Result { + if let Some(root) = resolve_aster_dir_override() { + fs::create_dir_all(&root) + .map_err(|error| format!("无法创建 Aster 运行时目录 {}: {error}", root.display()))?; + return Ok(root); + } resolve_runtime_subdir("aster") } @@ -210,6 +216,14 @@ fn fallback_runtime_subdir(subdir: &str) -> PathBuf { fallback_app_data_dir().join(subdir) } +fn resolve_aster_dir_override() -> Option { + std::env::var(ASTER_RUNTIME_OVERRIDE_ENV) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .map(PathBuf::from) +} + fn fallback_user_memory_path() -> PathBuf { dirs::home_dir() .map(|home| home.join(COMPAT_HOME_DIR_NAME)) diff --git a/src-tauri/crates/core/src/database/dao/agent.rs b/src-tauri/crates/core/src/database/dao/agent.rs index 08f502803..eb0e331c6 100644 --- a/src-tauri/crates/core/src/database/dao/agent.rs +++ b/src-tauri/crates/core/src/database/dao/agent.rs @@ -25,13 +25,14 @@ fn parse_message_content(content_json: &str) -> MessageContent { // 尝试按 JSON 值解析并提取可展示内容 if let Ok(value) = serde_json::from_str::(content_json) { - let parts = parse_content_parts_from_json(&value); + let content_value = value.get("content").unwrap_or(&value); + let parts = parse_content_parts_from_json(content_value); if !parts.is_empty() { return MessageContent::Parts(parts); } // 兼容历史 toolResponse 协议,将工具输出提取为文本用于后续 tool_response 恢复。 - if let Some(tool_output) = extract_tool_response_text(&value) { + if let Some(tool_output) = extract_tool_response_text(content_value) { return MessageContent::Text(tool_output); } @@ -1319,6 +1320,20 @@ mod tests { assert_eq!(parsed.as_text(), "-32603: Tool not found"); } + #[test] + fn parse_message_content_should_extract_text_from_persisted_visibility_envelope() { + let envelope = serde_json::json!({ + "content": [ + { "type": "text", "text": "阶段结论" } + ], + "userVisible": false, + "agentVisible": true + }); + + let parsed = parse_message_content(&envelope.to_string()); + assert_eq!(parsed.as_text(), "阶段结论"); + } + #[test] fn parse_message_content_should_stop_on_excessive_depth() { let mut nested = serde_json::json!({ "text": "不会到达" }); diff --git a/src-tauri/crates/services/src/aster_session_store.rs b/src-tauri/crates/services/src/aster_session_store.rs index 7b53dc120..a619c4e24 100644 --- a/src-tauri/crates/services/src/aster_session_store.rs +++ b/src-tauri/crates/services/src/aster_session_store.rs @@ -6,7 +6,7 @@ //! 这是应用层接管框架层存储的关键桥接模块。 use anyhow::{anyhow, Result}; -use aster::conversation::message::{Message, MessageContent}; +use aster::conversation::message::{Message, MessageContent, MessageMetadata}; use aster::conversation::Conversation; use aster::model::ModelConfig; use aster::recipe::Recipe; @@ -34,6 +34,44 @@ pub struct LimeSessionStore { metadata_cache: StdMutex>, } +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct PersistedConversationMessageRecord { + content: Vec, + #[serde(default = "persisted_visibility_default_true")] + user_visible: bool, + #[serde(default = "persisted_visibility_default_true")] + agent_visible: bool, +} + +fn persisted_visibility_default_true() -> bool { + true +} + +fn serialize_persisted_message_content(message: &Message) -> Result { + serde_json::to_string(&PersistedConversationMessageRecord { + content: message.content.clone(), + user_visible: message.metadata.user_visible, + agent_visible: message.metadata.agent_visible, + }) + .map_err(|e| anyhow!("序列化消息内容失败: {e}")) +} + +fn deserialize_persisted_message_content( + content_json: &str, +) -> Option { + if let Ok(record) = serde_json::from_str::(content_json) { + return Some(record); + } + + let content: Vec = serde_json::from_str(content_json).ok()?; + Some(PersistedConversationMessageRecord { + content, + user_visible: true, + agent_visible: true, + }) +} + impl LimeSessionStore { /// 创建新的 SessionStore 实例 pub fn new(db: DbConnection) -> Self { @@ -379,7 +417,7 @@ impl SessionStore for LimeSessionStore { let working_dir = Self::parse_session_working_dir(&conn, db_working_dir); let conversation = if include_messages { - Some(self.load_conversation(&conn, &id)?) + Some(Self::load_conversation_from_conn(&conn, &id)?) } else { None }; @@ -431,8 +469,7 @@ impl SessionStore for LimeSessionStore { Self::ensure_session_row(&conn, session_id)?; let role = Self::message_role_to_string(message); - let content_json = serde_json::to_string(&message.content) - .map_err(|e| anyhow!("序列化消息内容失败: {e}"))?; + let content_json = serialize_persisted_message_content(message)?; let timestamp = Utc::now().to_rfc3339(); // 从 content 中提取 tool_calls(ToolRequest 类型) @@ -500,7 +537,7 @@ impl SessionStore for LimeSessionStore { for message in conversation.messages() { let role = Self::message_role_to_string(message); - let content_json = serde_json::to_string(&message.content)?; + let content_json = serialize_persisted_message_content(message)?; let timestamp = Utc::now().to_rfc3339(); let tool_requests: Vec<_> = message @@ -1154,8 +1191,7 @@ impl SessionStore for LimeSessionStore { impl LimeSessionStore { /// 加载会话的对话历史 - fn load_conversation( - &self, + pub fn load_conversation_from_conn( conn: &rusqlite::Connection, session_id: &str, ) -> Result { @@ -1176,8 +1212,7 @@ impl LimeSessionStore { })? .filter_map(|r| r.ok()) .filter_map(|(role, content_json)| { - // 尝试解析消息内容 - let content: Vec = serde_json::from_str(&content_json).ok()?; + let persisted = deserialize_persisted_message_content(&content_json)?; // 根据角色创建消息 let mut message = if role == "assistant" { @@ -1187,10 +1222,15 @@ impl LimeSessionStore { }; // 添加所有内容 - for c in content { + for c in persisted.content { message = message.with_content(c); } + message = message.with_metadata(MessageMetadata { + user_visible: persisted.user_visible, + agent_visible: persisted.agent_visible, + }); + Some(message) }) .collect(); @@ -1536,6 +1576,43 @@ mod tests { assert_eq!(refreshed.message_count, 1); } + #[tokio::test] + async fn persisted_conversation_should_roundtrip_agent_only_visibility() { + let store = setup_test_store(); + let session = store + .create_session( + PathBuf::from("."), + "可见性回写测试".to_string(), + SessionType::User, + ) + .await + .expect("创建会话失败"); + + store + .replace_conversation( + &session.id, + &Conversation::new_unvalidated(vec![ + Message::user().with_text("用户可见"), + Message::user().with_text("仅供智能体续跑").agent_only(), + ]), + ) + .await + .expect("替换对话失败"); + + let loaded = store + .get_session(&session.id, true) + .await + .expect("读取会话失败"); + let conversation = loaded.conversation.expect("应包含对话"); + let messages = conversation.messages(); + + assert_eq!(messages.len(), 2); + assert!(messages[0].is_user_visible()); + assert!(!messages[1].is_user_visible()); + assert!(messages[1].is_agent_visible()); + assert_eq!(messages[1].as_concat_text(), "仅供智能体续跑"); + } + #[tokio::test] async fn metadata_cache_should_refresh_after_provider_update() { let store = setup_test_store(); diff --git a/src-tauri/src/agent/README.md b/src-tauri/src/agent/README.md index e4bb743df..890a30b49 100644 --- a/src-tauri/src/agent/README.md +++ b/src-tauri/src/agent/README.md @@ -21,7 +21,7 @@ AI Agent 集成模块,基于 aster-rust 框架实现。 | `mod.rs` | 模块入口,导出公共类型 | | `types.rs` | Agent 相关类型定义 | | `aster_state.rs` | Aster Agent 状态管理(主状态桥接;会话配置/项目上下文/身份配置/Skills 加载辅助逻辑委托 `crates/agent/src/aster_state_support.rs`) | -| `aster_agent.rs` | Aster Agent 包装器(流式桥接,会话存储逻辑委托 `crates/agent/src/session_store.rs`) | +| `aster_agent.rs` | Aster Agent 包装器(会话存储桥接与专用一次性会话 helper) | | `event_converter.rs` | Aster 事件到 Tauri 事件转换 | | `credential_bridge.rs` | 重导出层(纯逻辑已迁移到 `crates/agent/src/credential_bridge.rs`) | | `mcp_bridge.rs` | MCP 服务桥接 | @@ -54,6 +54,18 @@ AsterAgentState::reload_lime_skills(); ## 使用方式 +### current / compat 边界 + +- `current`:产品级主链、Tauri 命令、GUI 提交入口统一走 `agent_runtime_submit_turn` / `agent_runtime_respond_action` +- `compat`:`src/agent/*` 只保留底层状态桥接、会话存储桥接、专用一次性会话 helper 与调试说明 +- `deprecated`:不要再新增任何“直接 `agent.reply(...)` 就能完成业务提交”的 README 示例、服务入口或新命令 + +如果你在实现产品逻辑、自动化任务、工作区提交或工具确认恢复: + +- 先看 `docs/aiprompts/query-loop.md` +- 再看 `docs/aiprompts/prompt-foundation.md` +- 不要从本页示例反推新的 Tauri 主路径 + ### 从凭证池配置(推荐) ```rust @@ -85,12 +97,21 @@ state.configure_provider(config, &session_id, &db).await?; ### 发送消息 +仅限底层 crate 调试或受控 `compat` 一次性命令示例。Tauri 命令、自动化任务、GUI 提交与工具确认恢复都必须优先走 `agent_runtime_submit_turn` / `agent_runtime_respond_action`,不要继续新增原始 `agent.reply(...)` 产品旁路。 + ```rust let user_message = Message::user().with_text("Hello"); -let session_config = SessionConfigBuilder::new(&session_id).build(); +let session_config = build_auxiliary_session_config(&session_id, None, false); let stream = agent.reply(user_message, session_config, Some(cancel_token)).await?; ``` +上面这个 helper 只适用于: + +- 底层 crate 调试 +- 已明确归类为 `compat` 的专用一次性命令 + +它不携带 Query Loop 的 `thread / turn / turn context snapshot` 真相,不能当成新的 current 提交入口。 + ## Tauri 命令 | 命令 | 说明 | diff --git a/src-tauri/src/agent/aster_agent.rs b/src-tauri/src/agent/aster_agent.rs index 1d9f08d72..a27b85dbc 100644 --- a/src-tauri/src/agent/aster_agent.rs +++ b/src-tauri/src/agent/aster_agent.rs @@ -1,151 +1,38 @@ //! Aster Agent 包装器 //! //! 提供简化的接口来使用 Aster Agent。 -//! 处理消息发送、事件流转换,并桥接会话存储服务。 +//! 处理会话存储桥接,并为非 Query Loop 主链的专用一次性命令提供最小会话配置 helper。 use crate::agent::aster_state::{AsterAgentState, SessionConfigBuilder}; -use crate::config::GlobalConfigManagerState; use crate::database::DbConnection; -use crate::services::memory_profile_prompt_service::{ - merge_system_prompt_with_memory_context, MemoryPromptContext, -}; -use aster::conversation::message::Message; -use futures::StreamExt; -use lime_agent::{ - get_persisted_session_metadata_sync, merge_system_prompt_with_runtime_agents, - project_runtime_event, AgentEvent as RuntimeAgentEvent, WriteArtifactEventEmitter, -}; -use std::path::Path; -use tauri::{AppHandle, Emitter, Manager}; pub use lime_agent::{ PersistedSessionMetadata, SessionDetail, SessionInfo, SessionTitlePreviewMessage, }; +/// 为专用一次性命令构建最小 `SessionConfig`。 +/// +/// 这类调用不属于 Query Loop current 主链,不携带 submit turn 的 thread / turn / +/// turn context snapshot,只允许显式声明自己的 system prompt 与 trace 开关。 +pub(crate) fn build_auxiliary_session_config( + session_id: &str, + system_prompt: Option, + include_context_trace: bool, +) -> aster::agents::SessionConfig { + let mut session_config_builder = + SessionConfigBuilder::new(session_id).include_context_trace(include_context_trace); + if let Some(prompt) = system_prompt { + session_config_builder = session_config_builder.system_prompt(prompt); + } + session_config_builder.build() +} + /// Aster Agent 包装器 /// /// 提供与 Tauri 集成的简化接口 pub struct AsterAgentWrapper; impl AsterAgentWrapper { - /// 发送消息并获取流式响应 - /// - /// # Arguments - /// * `state` - Aster Agent 状态 - /// * `db` - 数据库连接 - /// * `app` - Tauri AppHandle,用于发送事件 - /// * `message` - 用户消息文本 - /// * `session_id` - 会话 ID - /// * `event_name` - 前端监听的事件名称 - /// - /// # Returns - /// 成功时返回 Ok(()),失败时返回错误信息 - pub async fn send_message( - state: &AsterAgentState, - db: &DbConnection, - app: &AppHandle, - message: String, - session_id: String, - event_name: String, - ) -> Result<(), String> { - if !state.is_initialized().await { - state.init_agent_with_db(db).await?; - } - - let cancel_token = state.create_cancel_token(&session_id).await; - - let user_message = Message::user().with_text(&message); - let mut session_config_builder = - SessionConfigBuilder::new(&session_id).include_context_trace(true); - let persisted_session_metadata = get_persisted_session_metadata_sync(db, &session_id) - .ok() - .flatten(); - let persisted_prompt = persisted_session_metadata - .as_ref() - .and_then(|session| session.system_prompt.clone()); - let working_dir = persisted_session_metadata - .as_ref() - .and_then(|session| session.working_dir.as_deref()) - .filter(|path| !path.trim().is_empty()) - .map(Path::new); - - let merged_prompt = - if let Some(config_manager) = app.try_state::() { - let runtime_config = config_manager.config(); - merge_system_prompt_with_memory_context( - merge_system_prompt_with_runtime_agents(persisted_prompt, working_dir), - &runtime_config, - MemoryPromptContext { - working_dir, - active_relative_path: None, - }, - ) - } else { - merge_system_prompt_with_runtime_agents(persisted_prompt, working_dir) - }; - - if let Some(prompt) = merged_prompt { - session_config_builder = session_config_builder.system_prompt(prompt); - } - let session_config = session_config_builder.build(); - - let agent_arc = state.get_agent_arc(); - let guard = agent_arc.read().await; - let agent = guard.as_ref().ok_or("Agent not initialized")?; - - let stream_result = agent - .reply(user_message, session_config, Some(cancel_token.clone())) - .await; - let mut write_artifact_emitter = WriteArtifactEventEmitter::new(session_id.clone()); - - match stream_result { - Ok(mut stream) => { - while let Some(event_result) = stream.next().await { - match event_result { - Ok(agent_event) => { - let runtime_events = project_runtime_event(agent_event); - for mut runtime_event in runtime_events { - let extra_events = - write_artifact_emitter.process_event(&mut runtime_event); - for extra_event in &extra_events { - if let Err(error) = app.emit(&event_name, extra_event) { - tracing::error!( - "[AsterAgentWrapper] 发送补充事件失败: {}", - error - ); - } - } - if let Err(error) = app.emit(&event_name, &runtime_event) { - tracing::error!("[AsterAgentWrapper] 发送事件失败: {}", error); - } - } - } - Err(error) => { - let error_event = RuntimeAgentEvent::Error { - message: format!("Stream error: {error}"), - }; - let _ = app.emit(&event_name, &error_event); - } - } - } - - let done_event = RuntimeAgentEvent::FinalDone { usage: None }; - let _ = app.emit(&event_name, &done_event); - } - Err(error) => { - let error_event = RuntimeAgentEvent::Error { - message: format!("Agent error: {error}"), - }; - let _ = app.emit(&event_name, &error_event); - return Err(format!("Agent error: {error}")); - } - } - - state.remove_cancel_token(&session_id).await; - - Ok(()) - } - /// 停止当前会话 pub async fn stop_session(state: &AsterAgentState, session_id: &str) -> bool { state.cancel_session(session_id).await @@ -264,4 +151,20 @@ mod tests { let config = SessionConfigBuilder::new("test-session").build(); assert_eq!(config.id, "test-session"); } + + #[test] + fn test_build_auxiliary_session_config_keeps_explicit_prompt_and_trace() { + let config = build_auxiliary_session_config( + "aux-session", + Some("你是一次性助手".to_string()), + false, + ); + + assert_eq!(config.id, "aux-session"); + assert_eq!(config.system_prompt.as_deref(), Some("你是一次性助手")); + assert_eq!(config.include_context_trace, Some(false)); + assert!(config.thread_id.is_none()); + assert!(config.turn_id.is_none()); + assert!(config.turn_context.is_none()); + } } diff --git a/src-tauri/src/agent/mod.rs b/src-tauri/src/agent/mod.rs index 613dea843..bfefe453f 100644 --- a/src-tauri/src/agent/mod.rs +++ b/src-tauri/src/agent/mod.rs @@ -17,6 +17,7 @@ pub use lime_agent::prompt; pub use lime_core::agent::types; pub use lime_core::agent::types::*; +pub(crate) use aster_agent::build_auxiliary_session_config; pub use aster_agent::{AsterAgentWrapper, SessionDetail, SessionInfo}; pub use aster_state::AsterAgentState; pub use credential_bridge::{ diff --git a/src-tauri/src/agent_tools/catalog.rs b/src-tauri/src/agent_tools/catalog.rs index 057e6f183..c5b7be251 100644 --- a/src-tauri/src/agent_tools/catalog.rs +++ b/src-tauri/src/agent_tools/catalog.rs @@ -695,10 +695,11 @@ fn normalize_tool_catalog_alias(tool_name: &str) -> &str { "taskgettool" => "TaskGet", "tasklisttool" => "TaskList", "taskoutputtool" | "agentoutputtool" | "bashoutputtool" => "TaskOutput", - "taskstoptool" => "TaskStop", + "taskstoptool" | "killshell" => "TaskStop", "taskupdatetool" => "TaskUpdate", "teamcreatetool" => "TeamCreate", "teamdeletetool" => "TeamDelete", + "listpeerstool" => "ListPeers", "toolsearchtool" => "ToolSearch", "webfetchtool" => "WebFetch", "websearchtool" => "WebSearch", @@ -1002,9 +1003,11 @@ mod tests { ("AgentOutputTool", "TaskOutput"), ("BashOutputTool", "TaskOutput"), ("TaskStopTool", "TaskStop"), + ("KillShell", "TaskStop"), ("TaskUpdateTool", "TaskUpdate"), ("TeamCreateTool", "TeamCreate"), ("TeamDeleteTool", "TeamDelete"), + ("ListPeersTool", "ListPeers"), ("ToolSearchTool", "ToolSearch"), ("WebFetchTool", "WebFetch"), ("WebSearchTool", "WebSearch"), diff --git a/src-tauri/src/agent_tools/inventory.rs b/src-tauri/src/agent_tools/inventory.rs index 33d824a23..4476b3187 100644 --- a/src-tauri/src/agent_tools/inventory.rs +++ b/src-tauri/src/agent_tools/inventory.rs @@ -102,6 +102,8 @@ pub struct ToolInventoryCounts { pub catalog_compat_total: usize, pub catalog_deprecated_total: usize, pub default_allowed_total: usize, + pub runtime_total: usize, + pub runtime_visible_total: usize, pub registry_total: usize, pub registry_visible_total: usize, pub registry_catalog_unmapped_total: usize, @@ -156,6 +158,38 @@ pub struct RuntimeRegistryToolInventoryEntry { pub visible_in_context: bool, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum RuntimeToolSourceKind { + RegistryNative, + CurrentSurface, + RuntimeExtension, + Mcp, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct RuntimeToolInventoryEntry { + pub name: String, + pub description: String, + pub source_kind: RuntimeToolSourceKind, + #[serde(skip_serializing_if = "Option::is_none")] + pub source_label: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub status: Option, + pub catalog_entry_name: Option, + pub catalog_source: Option, + pub catalog_lifecycle: Option, + pub catalog_permission_plane: Option, + pub catalog_workspace_default_allow: Option, + pub deferred_loading: bool, + pub always_visible: bool, + pub allowed_callers: Vec, + pub tags: Vec, + pub input_examples_count: usize, + pub caller_allowed: bool, + pub visible_in_context: bool, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct RuntimeExtensionSurfaceInventoryEntry { pub extension_name: String, @@ -220,6 +254,7 @@ pub struct AgentToolInventorySnapshot { pub counts: ToolInventoryCounts, pub catalog_tools: Vec, pub registry_tools: Vec, + pub runtime_tools: Vec, pub extension_surfaces: Vec, pub extension_tools: Vec, pub mcp_tools: Vec, @@ -236,6 +271,7 @@ pub struct AgentToolInventoryBuildInput { pub mcp_server_names: Vec, pub mcp_tools: Vec, pub registry_definitions: Vec, + pub current_surface_tool_names: Vec, pub extension_configs: Vec, pub visible_extension_tools: Vec, pub searchable_extension_tools: Vec, @@ -252,6 +288,7 @@ pub fn build_tool_inventory(input: AgentToolInventoryBuildInput) -> AgentToolInv mcp_server_names, mcp_tools, registry_definitions, + current_surface_tool_names, extension_configs, visible_extension_tools, searchable_extension_tools, @@ -328,6 +365,15 @@ pub fn build_tool_inventory(input: AgentToolInventoryBuildInput) -> AgentToolInv &mcp_extension_lookup, ); let mcp_tools = build_mcp_inventory(&mcp_tools, &caller); + let current_surface_tool_names = current_surface_tool_names + .into_iter() + .collect::>(); + let runtime_tools = build_runtime_tool_inventory( + ®istry_tools, + ¤t_surface_tool_names, + &extension_tools, + &mcp_tools, + ); let counts = ToolInventoryCounts { catalog_total: catalog_tools.len(), @@ -344,6 +390,11 @@ pub fn build_tool_inventory(input: AgentToolInventoryBuildInput) -> AgentToolInv .filter(|entry| entry.lifecycle == ToolLifecycle::Deprecated) .count(), default_allowed_total: default_allowed_tools.len(), + runtime_total: runtime_tools.len(), + runtime_visible_total: runtime_tools + .iter() + .filter(|entry| entry.visible_in_context) + .count(), registry_total: registry_tools.len(), registry_visible_total: registry_tools .iter() @@ -387,6 +438,7 @@ pub fn build_tool_inventory(input: AgentToolInventoryBuildInput) -> AgentToolInv counts, catalog_tools, registry_tools, + runtime_tools, extension_surfaces, extension_tools, mcp_tools, @@ -575,6 +627,130 @@ fn build_mcp_inventory(tools: &[McpToolDefinition], caller: &str) -> Vec u8 { + match kind { + RuntimeToolSourceKind::CurrentSurface => 0, + RuntimeToolSourceKind::RegistryNative => 1, + RuntimeToolSourceKind::RuntimeExtension => 2, + RuntimeToolSourceKind::Mcp => 3, + } +} + +fn insert_runtime_tool_entry( + entries: &mut Vec, + entry: RuntimeToolInventoryEntry, +) { + if entries + .iter() + .any(|existing| existing.name.eq_ignore_ascii_case(&entry.name)) + { + return; + } + + entries.push(entry); +} + +fn build_runtime_tool_inventory( + registry_tools: &[RuntimeRegistryToolInventoryEntry], + current_surface_tool_names: &HashSet, + extension_tools: &[RuntimeExtensionToolInventoryEntry], + mcp_tools: &[McpToolInventoryEntry], +) -> Vec { + let mut result = Vec::new(); + + for entry in registry_tools { + insert_runtime_tool_entry( + &mut result, + RuntimeToolInventoryEntry { + name: entry.name.clone(), + description: entry.description.clone(), + source_kind: if current_surface_tool_names.contains(&entry.name) { + RuntimeToolSourceKind::CurrentSurface + } else { + RuntimeToolSourceKind::RegistryNative + }, + source_label: None, + status: None, + catalog_entry_name: entry.catalog_entry_name.clone(), + catalog_source: entry.catalog_source, + catalog_lifecycle: entry.catalog_lifecycle, + catalog_permission_plane: entry.catalog_permission_plane, + catalog_workspace_default_allow: entry.catalog_workspace_default_allow, + deferred_loading: entry.deferred_loading, + always_visible: entry.always_visible, + allowed_callers: entry.allowed_callers.clone(), + tags: entry.tags.clone(), + input_examples_count: entry.input_examples_count, + caller_allowed: entry.caller_allowed, + visible_in_context: entry.visible_in_context, + }, + ); + } + + for entry in extension_tools { + let catalog_entry = tool_catalog_entry(&entry.name); + insert_runtime_tool_entry( + &mut result, + RuntimeToolInventoryEntry { + name: entry.name.clone(), + description: entry.description.clone(), + source_kind: RuntimeToolSourceKind::RuntimeExtension, + source_label: entry.extension_name.clone(), + status: Some(entry.status.clone()), + catalog_entry_name: catalog_entry.map(|item| item.name.to_string()), + catalog_source: catalog_entry.map(|item| item.source), + catalog_lifecycle: catalog_entry.map(|item| item.lifecycle), + catalog_permission_plane: catalog_entry.map(|item| item.permission_plane), + catalog_workspace_default_allow: catalog_entry + .map(|item| item.workspace_default_allow), + deferred_loading: entry.deferred_loading, + always_visible: false, + allowed_callers: entry.allowed_caller.clone().into_iter().collect::>(), + tags: Vec::new(), + input_examples_count: 0, + caller_allowed: entry.caller_allowed, + visible_in_context: entry.visible_in_context, + }, + ); + } + + for entry in mcp_tools { + let catalog_entry = tool_catalog_entry(&entry.name); + insert_runtime_tool_entry( + &mut result, + RuntimeToolInventoryEntry { + name: entry.name.clone(), + description: entry.description.clone(), + source_kind: RuntimeToolSourceKind::Mcp, + source_label: Some(entry.server_name.clone()), + status: None, + catalog_entry_name: catalog_entry.map(|item| item.name.to_string()), + catalog_source: catalog_entry.map(|item| item.source), + catalog_lifecycle: catalog_entry.map(|item| item.lifecycle), + catalog_permission_plane: catalog_entry.map(|item| item.permission_plane), + catalog_workspace_default_allow: catalog_entry + .map(|item| item.workspace_default_allow), + deferred_loading: entry.deferred_loading, + always_visible: entry.always_visible, + allowed_callers: entry.allowed_callers.clone(), + tags: entry.tags.clone(), + input_examples_count: entry.input_examples_count, + caller_allowed: entry.caller_allowed, + visible_in_context: entry.visible_in_context, + }, + ); + } + + result.sort_by(|left, right| { + runtime_tool_source_rank(left.source_kind) + .cmp(&runtime_tool_source_rank(right.source_kind)) + .then_with(|| right.visible_in_context.cmp(&left.visible_in_context)) + .then_with(|| left.source_label.cmp(&right.source_label)) + .then_with(|| left.name.cmp(&right.name)) + }); + result +} + fn extension_available_tools(config: &ExtensionConfig) -> Vec { match config { ExtensionConfig::Sse { .. } => Vec::new(), @@ -822,6 +998,7 @@ mod tests { }), ), ], + current_surface_tool_names: Vec::new(), extension_configs: vec![builtin_extension( "mcp__docs", vec!["search_docs", "read_docs"], @@ -938,6 +1115,81 @@ mod tests { assert_eq!(caller, "assistant"); } + #[test] + fn test_build_tool_inventory_runtime_tools_dedup_and_marks_current_surface() { + let inventory = build_tool_inventory(AgentToolInventoryBuildInput { + surface: WorkspaceToolSurface::core(), + caller: "assistant".to_string(), + agent_initialized: true, + warnings: Vec::new(), + persisted_execution_policy: None, + request_metadata: None, + mcp_server_names: vec!["docs".to_string()], + mcp_tools: vec![mcp_tool( + "docs", + "mcp__docs__search_docs", + true, + false, + vec!["assistant"], + )], + registry_definitions: vec![ + definition("Agent", "delegate work", json!({ "type": "object" })), + definition("Bash", "workspace bash", json!({ "type": "object" })), + ], + current_surface_tool_names: vec!["Agent".to_string()], + extension_configs: vec![builtin_extension( + "mcp__docs", + vec!["search_docs"], + true, + vec!["search_docs"], + Some("assistant"), + )], + visible_extension_tools: vec![seed("mcp__docs__search_docs", "search docs")], + searchable_extension_tools: vec![seed("mcp__docs__search_docs", "search docs")], + }); + + assert_eq!(inventory.counts.runtime_total, 3); + assert_eq!(inventory.counts.runtime_visible_total, 3); + + let agent_tool = inventory + .runtime_tools + .iter() + .find(|entry| entry.name == "Agent") + .expect("Agent runtime tool should exist"); + assert_eq!( + agent_tool.source_kind, + RuntimeToolSourceKind::CurrentSurface + ); + assert!(agent_tool.visible_in_context); + + let bash_tool = inventory + .runtime_tools + .iter() + .find(|entry| entry.name == "Bash") + .expect("Bash runtime tool should exist"); + assert_eq!(bash_tool.source_kind, RuntimeToolSourceKind::RegistryNative); + + let docs_tool = inventory + .runtime_tools + .iter() + .find(|entry| entry.name == "mcp__docs__search_docs") + .expect("docs runtime tool should exist"); + assert_eq!( + docs_tool.source_kind, + RuntimeToolSourceKind::RuntimeExtension + ); + assert_eq!(docs_tool.source_label.as_deref(), Some("mcp__docs")); + assert_eq!(docs_tool.status.as_deref(), Some("visible")); + assert_eq!( + inventory + .runtime_tools + .iter() + .filter(|entry| entry.name == "mcp__docs__search_docs") + .count(), + 1 + ); + } + #[test] fn test_build_tool_inventory_uninitialized_agent_keeps_sorted_servers_and_mcp_visibility() { let inventory = build_tool_inventory(AgentToolInventoryBuildInput { @@ -953,6 +1205,7 @@ mod tests { mcp_tool("alpha", "read_alpha", false, false, vec![]), ], registry_definitions: Vec::new(), + current_surface_tool_names: Vec::new(), extension_configs: Vec::new(), visible_extension_tools: Vec::new(), searchable_extension_tools: Vec::new(), @@ -992,6 +1245,7 @@ mod tests { mcp_server_names: Vec::new(), mcp_tools: Vec::new(), registry_definitions: Vec::new(), + current_surface_tool_names: Vec::new(), extension_configs: Vec::new(), visible_extension_tools: Vec::new(), searchable_extension_tools: Vec::new(), @@ -1078,6 +1332,7 @@ mod tests { json!({ "type": "object" }), ), ], + current_surface_tool_names: Vec::new(), extension_configs: Vec::new(), visible_extension_tools: Vec::new(), searchable_extension_tools: Vec::new(), @@ -1139,6 +1394,7 @@ mod tests { "x-lime": { "allowed_callers": ["assistant"] } }), )], + current_surface_tool_names: Vec::new(), extension_configs: Vec::new(), visible_extension_tools: Vec::new(), searchable_extension_tools: Vec::new(), @@ -1217,6 +1473,7 @@ mod tests { mcp_server_names: vec!["docs".to_string()], mcp_tools: Vec::new(), registry_definitions: Vec::new(), + current_surface_tool_names: Vec::new(), extension_configs: vec![ builtin_extension( "mcp__docs", @@ -1314,6 +1571,7 @@ mod tests { mcp_server_names: Vec::new(), mcp_tools: Vec::new(), registry_definitions: Vec::new(), + current_surface_tool_names: Vec::new(), extension_configs: vec![ builtin_extension("mcp__docs", vec!["search"], true, vec![], Some("assistant")), builtin_extension( @@ -1414,6 +1672,7 @@ mod tests { }), ), ], + current_surface_tool_names: Vec::new(), extension_configs: Vec::new(), visible_extension_tools: Vec::new(), searchable_extension_tools: Vec::new(), diff --git a/src-tauri/src/commands/aster_agent_cmd/action_runtime.rs b/src-tauri/src/commands/aster_agent_cmd/action_runtime.rs index c74ff7339..d0c203cc0 100644 --- a/src-tauri/src/commands/aster_agent_cmd/action_runtime.rs +++ b/src-tauri/src/commands/aster_agent_cmd/action_runtime.rs @@ -171,12 +171,11 @@ pub(crate) fn build_runtime_action_session_config( if let Some(prompt) = merge_system_prompt_with_elicitation_context(None, request_metadata) { session_config_builder = session_config_builder.system_prompt(prompt); } - if let Some(turn_context) = - super::runtime_turn::merge_turn_context_with_workspace_auto_compaction( - None, - workspace_settings, - ) - { + let turn_context = super::runtime_turn::build_runtime_turn_context_snapshot( + request_metadata, + workspace_settings, + ); + if turn_context.output_schema.is_some() || !turn_context.metadata.is_empty() { session_config_builder = session_config_builder.turn_context(turn_context); } session_config_builder.build() diff --git a/src-tauri/src/commands/aster_agent_cmd/command_api/runtime_api.rs b/src-tauri/src/commands/aster_agent_cmd/command_api/runtime_api.rs index 58867f57c..ec9c3cb95 100644 --- a/src-tauri/src/commands/aster_agent_cmd/command_api/runtime_api.rs +++ b/src-tauri/src/commands/aster_agent_cmd/command_api/runtime_api.rs @@ -575,6 +575,7 @@ pub async fn agent_runtime_get_tool_inventory( mcp_server_names, mcp_tools, registry_definitions: Vec::new(), + current_surface_tool_names: Vec::new(), extension_configs: Vec::new(), visible_extension_tools: Vec::new(), searchable_extension_tools: Vec::new(), @@ -583,9 +584,25 @@ pub async fn agent_runtime_get_tool_inventory( let registry_arc = agent.tool_registry().clone(); let registry = registry_arc.read().await; - let registry_definitions = registry.get_definitions(); + let mut registry_definitions = registry.get_definitions(); drop(registry); + let existing_runtime_tool_names = registry_definitions + .iter() + .map(|definition| definition.name.clone()) + .collect::>(); + let mut current_surface_tool_names = Vec::new(); + for definition in crate::commands::aster_agent_cmd::tool_runtime::list_current_surface_tool_definitions_from_agent(agent) + .await + { + if existing_runtime_tool_names.contains(&definition.name) { + continue; + } + + current_surface_tool_names.push(definition.name.clone()); + registry_definitions.push(definition); + } + let extension_configs = agent.get_extension_configs().await; let extension_manager = agent.extension_manager.clone(); let visible_extension_tools = match extension_manager.get_prefixed_tools(None).await { @@ -626,6 +643,7 @@ pub async fn agent_runtime_get_tool_inventory( mcp_server_names, mcp_tools, registry_definitions, + current_surface_tool_names, extension_configs, visible_extension_tools, searchable_extension_tools, diff --git a/src-tauri/src/commands/aster_agent_cmd/mod.rs b/src-tauri/src/commands/aster_agent_cmd/mod.rs index 5addc109f..a2245550b 100644 --- a/src-tauri/src/commands/aster_agent_cmd/mod.rs +++ b/src-tauri/src/commands/aster_agent_cmd/mod.rs @@ -108,8 +108,9 @@ use lime_agent::{ RuntimeProjectionSnapshot, SessionStateSnapshot, SubagentControlState, SubagentCustomizationState, SubagentRuntimeStatus, SubagentRuntimeStatusKind, SubagentSkillPromptBlock, SubagentSkillSummary, TeamRuntimeGovernorSnapshot, - TurnInputEnvelopeBuilder, TurnPromptAugmentationStageKind, TurnProviderRoutingSnapshot, - TurnRequestToolPolicySnapshot, TurnState, TurnSystemPromptSource, DURABLE_MEMORY_VIRTUAL_ROOT, + TurnExecutionProfile, TurnInputEnvelopeBuilder, TurnPromptAugmentationStageKind, + TurnProviderRoutingSnapshot, TurnRequestToolPolicySnapshot, TurnState, TurnSystemPromptSource, + DURABLE_MEMORY_VIRTUAL_ROOT, }; use lime_services::api_key_provider_service::ApiKeyProviderService; use lime_services::mcp_service::McpService; diff --git a/src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs b/src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs index 65501ae2b..d40dcd3fe 100644 --- a/src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs +++ b/src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs @@ -1,8 +1,11 @@ use super::service_skill_launch::build_service_skill_preload_tool_projection; use super::*; use aster::session::TurnContextOverride; +use aster::tools::ConfigTool; use lime_agent::AgentEvent as RuntimeAgentEvent; use lime_core::workspace::WorkspaceSettings; +use regex::Regex; +use std::sync::{Arc, OnceLock}; use tauri::Manager; const ARTIFACT_DOCUMENT_REPAIRED_WARNING_CODE: &str = "artifact_document_repaired"; @@ -12,8 +15,12 @@ const AUTO_CONTEXT_COMPACTION_EVENT_PREFIX: &str = "agent_context_compaction_aut const AUTO_CONTEXT_COMPACTION_FAILED_WARNING_CODE: &str = "context_compaction_auto_failed"; const CONTEXT_COMPACTION_NOT_NEEDED_WARNING_CODE: &str = "context_compaction_not_needed"; const TURN_MEMORY_PREFETCH_PROMPT_MARKER: &str = "【运行时记忆召回】"; +const TURN_LOCAL_PATH_FOCUS_PROMPT_MARKER: &str = "【本回合本地路径焦点】"; const LIME_RUNTIME_METADATA_KEY: &str = "lime_runtime"; const LIME_RUNTIME_AUTO_COMPACT_KEY: &str = "auto_compact"; +const LIME_RUNTIME_TOOL_SURFACE_KEY: &str = "tool_surface"; +const FAST_CHAT_TOOL_SURFACE_DIRECT_ANSWER: &str = "direct_answer"; +const FAST_CHAT_TOOL_SURFACE_LOCAL_WORKSPACE: &str = "local_workspace"; const AUTO_RUNTIME_MEMORY_MIN_USER_CHARS: usize = 12; const AUTO_RUNTIME_MEMORY_MIN_ASSISTANT_CHARS: usize = 48; const AUTO_RUNTIME_MEMORY_MIN_TOTAL_CHARS: usize = 160; @@ -28,6 +35,47 @@ fn emit_runtime_events(app: &AppHandle, event_name: &str, events: Vec Result<(), String> { + let agent_arc = state.get_agent_arc(); + let guard = agent_arc.read().await; + let agent = guard + .as_ref() + .ok_or_else(|| "Agent not initialized".to_string())?; + let registry_arc = agent.tool_registry().clone(); + drop(guard); + + let mut registry = registry_arc.write().await; + if !registry.contains_native("Config") { + return Ok(()); + } + + let read_app = app.clone(); + let write_app = app.clone(); + registry.register(Box::new(ConfigTool::new().with_voice_enabled_callbacks( + Arc::new(move || { + let _read_app = read_app.clone(); + Box::pin(async move { + let config = crate::voice::commands::get_voice_input_config().await?; + Ok(config.enabled) + }) + }), + Arc::new(move |enabled| { + let write_app = write_app.clone(); + Box::pin(async move { + let mut config = crate::voice::commands::get_voice_input_config().await?; + config.enabled = enabled; + crate::voice::commands::save_voice_input_config(write_app, config.clone()).await?; + Ok(config.enabled) + }) + }), + ))); + + Ok(()) +} + fn merge_runtime_memory_prefetch_prompt( base_prompt: Option, prefetch_prompt: Option<&str>, @@ -53,6 +101,156 @@ fn merge_runtime_memory_prefetch_prompt( } } +fn quoted_absolute_path_regex() -> &'static Regex { + static REGEX: OnceLock = OnceLock::new(); + REGEX.get_or_init(|| { + Regex::new(r#"["'“”‘’](?P(?:/|[A-Za-z]:\\)[^"'“”‘’\r\n]+)["'“”‘’]"#) + .expect("quoted absolute path regex should compile") + }) +} + +fn unix_absolute_path_regex() -> &'static Regex { + static REGEX: OnceLock = OnceLock::new(); + REGEX.get_or_init(|| { + Regex::new(r#"(?P/[^\s"'“”‘’,;:(){}\[\]<>]+)"#) + .expect("unix absolute path regex should compile") + }) +} + +fn windows_absolute_path_regex() -> &'static Regex { + static REGEX: OnceLock = OnceLock::new(); + REGEX.get_or_init(|| { + Regex::new(r#"(?P[A-Za-z]:\\[^\s"'“”‘’,;:(){}\[\]<>]+)"#) + .expect("windows absolute path regex should compile") + }) +} + +fn normalize_explicit_local_path_candidate(candidate: &str) -> Option { + let trimmed = candidate + .trim() + .trim_end_matches(|ch: char| { + matches!( + ch, + '.' | ',' + | ';' + | ':' + | '。' + | ',' + | ';' + | ':' + | ')' + | ')' + | ']' + | '】' + | '}' + | '>' + ) + }) + .trim(); + + if trimmed.is_empty() { + return None; + } + + let path = Path::new(trimmed); + if !path.is_absolute() || !path.exists() { + return None; + } + + Some( + path.canonicalize() + .unwrap_or_else(|_| path.to_path_buf()) + .to_string_lossy() + .to_string(), + ) +} + +fn push_unique_focus_path(paths: &mut Vec, candidate: &str) { + let Some(normalized) = normalize_explicit_local_path_candidate(candidate) else { + return; + }; + + if paths.iter().any(|existing| existing == &normalized) { + return; + } + + paths.push(normalized); +} + +fn extract_explicit_local_focus_paths_from_message(message: &str) -> Vec { + let mut paths = Vec::new(); + + for captures in quoted_absolute_path_regex().captures_iter(message) { + if let Some(path) = captures.name("path") { + push_unique_focus_path(&mut paths, path.as_str()); + } + } + + for captures in unix_absolute_path_regex().captures_iter(message) { + if let Some(path) = captures.name("path") { + push_unique_focus_path(&mut paths, path.as_str()); + } + } + + for captures in windows_absolute_path_regex().captures_iter(message) { + if let Some(path) = captures.name("path") { + push_unique_focus_path(&mut paths, path.as_str()); + } + } + + paths +} + +fn merge_system_prompt_with_explicit_local_path_focus( + base_prompt: Option, + user_message: &str, + workspace_root: &str, +) -> Option { + let focus_paths = extract_explicit_local_focus_paths_from_message(user_message); + if focus_paths.is_empty() { + return base_prompt; + } + + let workspace_root = workspace_root.trim(); + let should_warn_about_workspace = + !workspace_root.is_empty() && focus_paths.iter().all(|path| path != workspace_root); + + let mut lines = vec![ + TURN_LOCAL_PATH_FOCUS_PROMPT_MARKER.to_string(), + "本回合用户已经明确给出本地路径;这些路径是当前侦查与读取的第一优先级。".to_string(), + ]; + for path in focus_paths.iter().take(3) { + lines.push(format!("- 优先路径: {path}")); + } + lines.push( + "- 第一批只围绕这些显式路径做 2 到 4 个只读工具调用,优先精确搜索和读取关键文件。" + .to_string(), + ); + if should_warn_about_workspace { + lines.push(format!( + "- 不要先扫描当前默认工作目录 {workspace_root} 或其它无关目录,除非这些显式路径证据不足,或用户明确要求比较当前工作区。" + )); + } + lines.push( + "- 如果这些显式路径不存在、无法读取,或必须回退到其它路径,先在用户可见结论正文里说明原因,再继续下一批。" + .to_string(), + ); + let focus_prompt = lines.join("\n"); + + match base_prompt { + Some(base) => { + if base.contains(TURN_LOCAL_PATH_FOCUS_PROMPT_MARKER) { + Some(base) + } else if base.trim().is_empty() { + Some(focus_prompt) + } else { + Some(format!("{base}\n\n{focus_prompt}")) + } + } + None => Some(focus_prompt), + } +} + fn normalize_runtime_memory_capture_text(input: &str) -> String { input.split_whitespace().collect::>().join(" ") } @@ -250,6 +448,73 @@ fn resolve_provider_config_apply_mode( ProviderConfigApplyMode::CredentialPool } +async fn apply_runtime_turn_provider_config( + state: &AsterAgentState, + db: &DbConnection, + session_id: &str, + provider_config: Option<&ConfigureProviderRequest>, +) -> Result<(), String> { + let Some(provider_config) = provider_config else { + return Ok(()); + }; + + tracing::info!( + "[AsterAgent] 收到 provider_config: provider_id={:?}, provider_name={}, model_name={}, has_api_key={}, base_url={:?}", + provider_config.provider_id, + provider_config.provider_name, + provider_config.model_name, + provider_config.api_key.is_some(), + provider_config.base_url + ); + let apply_mode = resolve_provider_config_apply_mode(provider_config); + let config = ProviderConfig { + provider_name: provider_config.provider_name.clone(), + provider_selector: provider_config + .provider_id + .clone() + .or_else(|| Some(provider_config.provider_name.clone())), + model_name: provider_config.model_name.clone(), + api_key: provider_config.api_key.clone(), + base_url: provider_config.base_url.clone(), + credential_uuid: None, + force_responses_api: false, + credential_path: None, + toolshim: matches!( + provider_config.tool_call_strategy, + Some(RuntimeToolCallStrategy::ToolShim) + ), + toolshim_model: provider_config.toolshim_model.clone(), + }; + let provider_selector = provider_config + .provider_id + .as_deref() + .unwrap_or(&provider_config.provider_name); + tracing::info!( + "[AsterAgent] provider_config 应用策略: provider_selector={}, mode={:?}, tool_call_strategy={:?}, toolshim_model={:?}", + provider_selector, + apply_mode, + provider_config.tool_call_strategy, + provider_config.toolshim_model + ); + + match apply_mode { + ProviderConfigApplyMode::Direct => { + state.configure_provider(config, session_id, db).await?; + } + ProviderConfigApplyMode::CredentialPool => { + state + .configure_provider_from_pool( + db, + provider_selector, + &provider_config.model_name, + session_id, + ) + .await?; + } + } + persist_session_provider_routing(session_id, provider_selector).await +} + fn emit_runtime_side_event( app: &AppHandle, event_name: &str, @@ -379,6 +644,1726 @@ pub(crate) fn merge_turn_context_with_workspace_auto_compaction( Some(turn_context) } +fn build_runtime_turn_context_override( + turn_context: Option, + request_metadata: Option<&serde_json::Value>, + workspace_settings: &WorkspaceSettings, +) -> Option { + merge_turn_context_with_workspace_auto_compaction( + merge_turn_context_with_artifact_output_schema(turn_context, request_metadata), + workspace_settings, + ) +} + +pub(crate) fn build_runtime_turn_context_snapshot( + request_metadata: Option<&serde_json::Value>, + workspace_settings: &WorkspaceSettings, +) -> TurnContextOverride { + let seed_turn_context = + request_metadata + .and_then(serde_json::Value::as_object) + .map(|metadata| TurnContextOverride { + metadata: metadata.clone().into_iter().collect(), + ..TurnContextOverride::default() + }); + + build_runtime_turn_context_override(seed_turn_context, request_metadata, workspace_settings) + .unwrap_or_default() +} + +fn build_runtime_turn_context_metadata_value( + turn_context: &TurnContextOverride, +) -> Option { + if turn_context.metadata.is_empty() { + None + } else { + Some(serde_json::Value::Object( + turn_context.metadata.clone().into_iter().collect(), + )) + } +} + +fn build_runtime_session_config( + session_id: &str, + thread_id: &str, + turn_id: &str, + system_prompt: Option<&str>, + include_context_trace: Option, + turn_context: Option, +) -> aster::agents::types::SessionConfig { + let mut session_config_builder = SessionConfigBuilder::new(session_id) + .thread_id(thread_id.to_string()) + .turn_id(turn_id.to_string()); + if let Some(system_prompt) = system_prompt { + session_config_builder = session_config_builder.system_prompt(system_prompt.to_string()); + } + if let Some(include_context_trace) = include_context_trace { + session_config_builder = + session_config_builder.include_context_trace(include_context_trace); + } + if let Some(turn_context) = turn_context { + session_config_builder = session_config_builder.turn_context(turn_context); + } + session_config_builder.build() +} + +fn insert_serialized_run_metadata( + metadata: &mut serde_json::Map, + key: &str, + value: &T, +) { + if let Ok(serialized) = serde_json::to_value(value) { + metadata.insert(key.to_string(), serialized); + } +} + +fn build_runtime_run_start_metadata( + request: &AsterChatRequest, + workspace_id: &str, + effective_strategy: AsterExecutionStrategy, + request_tool_policy: &RequestToolPolicy, + auto_continue_enabled: bool, + auto_continue_metadata: Option<&AutoContinuePayload>, + session_recent_preferences: Option<&lime_agent::SessionExecutionRuntimePreferences>, + session_state_snapshot: &SessionStateSnapshot, + runtime_projection_snapshot: &RuntimeProjectionSnapshot, + turn_state: &TurnState, + turn_input_diagnostics: &lime_agent::TurnDiagnosticsSnapshot, + service_skill_preload: Option<&ServiceSkillLaunchPreloadExecution>, +) -> serde_json::Map { + let mut metadata = build_chat_run_metadata_base( + request, + workspace_id, + effective_strategy, + request_tool_policy, + auto_continue_enabled, + auto_continue_metadata, + session_recent_preferences, + ); + insert_serialized_run_metadata(&mut metadata, "session_state", session_state_snapshot); + insert_serialized_run_metadata( + &mut metadata, + "runtime_projection", + runtime_projection_snapshot, + ); + insert_serialized_run_metadata(&mut metadata, "turn_state", turn_state); + insert_serialized_run_metadata(&mut metadata, "turn_input", turn_input_diagnostics); + + if let Some(preload) = service_skill_preload { + metadata.insert( + "service_skill_launch_preload".to_string(), + serde_json::json!({ + "executed": true, + "adapter_name": preload.request.adapter_name, + "ok": preload.result.ok, + "error_code": preload.result.error_code, + "saved_content_id": preload + .result + .saved_content + .as_ref() + .map(|content| content.content_id.clone()), + }), + ); + } + + metadata +} + +#[derive(Clone)] +struct RuntimeTurnStreamSessionConfigState { + session_id: String, + thread_id: String, + turn_id: String, + system_prompt: Option, + include_context_trace: bool, + turn_context_override: Option, +} + +impl RuntimeTurnStreamSessionConfigState { + fn build(&self) -> aster::agents::types::SessionConfig { + build_runtime_session_config( + &self.session_id, + &self.thread_id, + &self.turn_id, + self.system_prompt.as_deref(), + Some(self.include_context_trace), + self.turn_context_override.clone(), + ) + } +} + +struct RuntimeTurnExecutionContext { + run_start_metadata: serde_json::Map, + run_observation: Arc>, + timeline_recorder: Arc>, + runtime_status_session_config: aster::agents::types::SessionConfig, + stream_session_config_state: RuntimeTurnStreamSessionConfigState, +} + +impl RuntimeTurnExecutionContext { + fn build_run_finish_decision(&self, result: &Result<(), String>) -> RunFinishDecision { + build_runtime_run_finish_decision(result, &self.run_start_metadata, &self.run_observation) + } + + async fn execute_and_finalize( + &self, + tracker: &ExecutionTracker, + agent: &Agent, + app: &AppHandle, + db: &DbConnection, + request: &AsterChatRequest, + runtime_memory_config: &lime_core::config::MemoryConfig, + session_id: &str, + workspace_root: &str, + workspace_id: &str, + thread_id: &str, + turn_id: &str, + execution_profile: TurnExecutionProfile, + request_metadata: Option<&serde_json::Value>, + provider_continuation_capability: ProviderContinuationCapability, + cancel_token: CancellationToken, + request_tool_policy: &RequestToolPolicy, + effective_strategy: AsterExecutionStrategy, + ) -> Result<(), String> { + let run_start_metadata = self.run_start_metadata.clone(); + let run_observation = self.run_observation.clone(); + let timeline_recorder = self.timeline_recorder.clone(); + let runtime_status_session_config = self.runtime_status_session_config.clone(); + let stream_session_config_state = self.stream_session_config_state.clone(); + + let final_result = tracker + .with_run_custom( + RunSource::Chat, + Some("agent_runtime_submit_turn".to_string()), + Some(session_id.to_string()), + Some(serde_json::Value::Object(run_start_metadata)), + async move { + execute_runtime_stream_with_strategy( + agent, + app, + db, + request, + &timeline_recorder, + &run_observation, + runtime_memory_config, + session_id, + workspace_root, + workspace_id, + thread_id, + turn_id, + execution_profile, + request_metadata, + provider_continuation_capability, + cancel_token, + request_tool_policy, + effective_strategy, + || stream_session_config_state.build(), + ) + .await + }, + move |result| self.build_run_finish_decision(result), + ) + .await; + + finalize_runtime_turn_result( + agent, + app, + db, + &request.event_name, + &self.timeline_recorder, + workspace_root, + &runtime_status_session_config, + session_id, + final_result, + ) + .await + } +} + +struct RuntimeTurnBuildArtifacts { + runtime_projection_snapshot: RuntimeProjectionSnapshot, + turn_state: TurnState, + turn_input_envelope: lime_agent::TurnInputEnvelope, + turn_input_diagnostics: lime_agent::TurnDiagnosticsSnapshot, +} + +struct RuntimeTurnPreparedExecution { + service_skill_preload: Option, + runtime_turn_artifacts: RuntimeTurnBuildArtifacts, + runtime_turn_execution_context: RuntimeTurnExecutionContext, +} + +struct RuntimeTurnSubmitBootstrap { + request_metadata: Option, + runtime_memory_config: lime_core::config::MemoryConfig, + provider_continuation_capability: ProviderContinuationCapability, + tracker: ExecutionTracker, + model_skill_tool_enabled: bool, +} + +struct RuntimeTurnPromptStrategy { + system_prompt: Option, + requested_strategy: AsterExecutionStrategy, + effective_strategy: AsterExecutionStrategy, + system_prompt_source: TurnSystemPromptSource, +} + +struct RuntimeTurnSessionPreparation { + auto_continue_config: Option, + auto_continue_enabled: bool, + session_state_snapshot: SessionStateSnapshot, + session_recent_preferences: Option, + session_recent_team_selection: Option, +} + +struct RuntimeTurnIngressContext { + owned_session_id: String, + workspace_id: String, + workspace_root: String, + workspace_settings: WorkspaceSettings, + resolved_turn_id: String, + runtime_config: lime_core::config::Config, + session_recent_harness_context: SessionRecentHarnessContext, + workspace_repaired: bool, + workspace_warning: Option, +} + +struct RuntimeTurnSubmitPreparation { + auto_continue_config: Option, + auto_continue_enabled: bool, + session_state_snapshot: SessionStateSnapshot, + session_recent_preferences: Option, + runtime_chat_mode: RuntimeChatMode, + include_context_trace: bool, + turn_input_builder: TurnInputEnvelopeBuilder, + request_tool_policy: RequestToolPolicy, + execution_profile: TurnExecutionProfile, + requested_strategy: AsterExecutionStrategy, + effective_strategy: AsterExecutionStrategy, + system_prompt: Option, + system_prompt_source: TurnSystemPromptSource, + submit_bootstrap: RuntimeTurnSubmitBootstrap, +} + +struct RuntimeTurnRequestPreparation { + runtime_chat_mode: RuntimeChatMode, + include_context_trace: bool, + turn_input_builder: TurnInputEnvelopeBuilder, +} + +struct RuntimeTurnPolicyPreparation { + request_tool_policy: RequestToolPolicy, + execution_profile: TurnExecutionProfile, +} + +impl RuntimeTurnPreparedExecution { + fn thread_id(&self) -> &str { + self.runtime_turn_artifacts.turn_state.thread_id.as_str() + } + + fn turn_id(&self) -> &str { + self.runtime_turn_artifacts.turn_state.turn_id.as_str() + } + + async fn emit_prelude( + &self, + agent: &Agent, + app: &AppHandle, + request: &AsterChatRequest, + workspace_root: &str, + effective_strategy: AsterExecutionStrategy, + request_tool_policy: &RequestToolPolicy, + model_name: Option<&str>, + session_recent_preferences: Option<&lime_agent::SessionExecutionRuntimePreferences>, + ) -> Result<(), String> { + prepare_runtime_turn_prelude( + agent, + app, + request, + &self.runtime_turn_execution_context.timeline_recorder, + workspace_root, + &self + .runtime_turn_execution_context + .runtime_status_session_config, + effective_strategy, + request_tool_policy, + model_name, + session_recent_preferences, + self.service_skill_preload.as_ref(), + ) + .await + } + + #[allow(clippy::too_many_arguments)] + async fn emit_prelude_and_execute( + &self, + agent: &Agent, + tracker: &ExecutionTracker, + app: &AppHandle, + db: &DbConnection, + request: &AsterChatRequest, + runtime_memory_config: &lime_core::config::MemoryConfig, + session_id: &str, + workspace_root: &str, + workspace_id: &str, + execution_profile: TurnExecutionProfile, + request_metadata: Option<&serde_json::Value>, + provider_continuation_capability: ProviderContinuationCapability, + cancel_token: CancellationToken, + effective_strategy: AsterExecutionStrategy, + request_tool_policy: &RequestToolPolicy, + model_name: Option<&str>, + session_recent_preferences: Option<&lime_agent::SessionExecutionRuntimePreferences>, + ) -> Result<(), String> { + self.emit_prelude( + agent, + app, + request, + workspace_root, + effective_strategy, + request_tool_policy, + model_name, + session_recent_preferences, + ) + .await?; + + self.runtime_turn_execution_context + .execute_and_finalize( + tracker, + agent, + app, + db, + request, + runtime_memory_config, + session_id, + workspace_root, + workspace_id, + self.thread_id(), + self.turn_id(), + execution_profile, + request_metadata, + provider_continuation_capability, + cancel_token, + request_tool_policy, + effective_strategy, + ) + .await + } +} + +impl RuntimeTurnSubmitPreparation { + fn model_skill_tool_enabled(&self) -> bool { + self.submit_bootstrap.model_skill_tool_enabled + } +} + +#[allow(clippy::too_many_arguments)] +async fn prepare_runtime_turn_submit_bootstrap( + app: &AppHandle, + state: &AsterAgentState, + db: &DbConnection, + api_key_provider_service: &ApiKeyProviderServiceState, + logs: &LogState, + config_manager: &GlobalConfigManagerState, + mcp_manager: &McpManagerState, + automation_state: &AutomationServiceState, + request: &AsterChatRequest, + session_id: &str, + workspace_root: &str, + workspace_settings: &WorkspaceSettings, + runtime_config: &lime_core::config::Config, + runtime_chat_mode: RuntimeChatMode, + execution_profile: TurnExecutionProfile, + requested_strategy: AsterExecutionStrategy, + request_tool_policy: &RequestToolPolicy, + turn_input_builder: &mut TurnInputEnvelopeBuilder, +) -> Result { + if !state.is_provider_configured().await { + return Err("Provider 未配置,请先调用 aster_agent_configure_provider".to_string()); + } + + maybe_auto_compact_runtime_session_before_turn( + app, + state, + db, + session_id, + &request.event_name, + workspace_settings, + ) + .await?; + + let effective_provider_config = state.get_provider_config().await; + let provider_routing_snapshot = + effective_provider_config + .as_ref() + .map(|config| TurnProviderRoutingSnapshot { + provider_name: config.provider_name.clone(), + provider_selector: config.provider_selector.clone(), + model_name: config.model_name.clone(), + credential_uuid: config.credential_uuid.clone(), + configured_from_request: request.provider_config.is_some(), + used_inline_api_key: request + .provider_config + .as_ref() + .and_then(|config| config.api_key.as_ref()) + .is_some(), + }); + turn_input_builder.set_provider_routing(provider_routing_snapshot.clone()); + + let provider_continuation_capability = effective_provider_config + .as_ref() + .map(|config| config.provider_continuation_capability()) + .unwrap_or(ProviderContinuationCapability::HistoryReplayOnly); + let configured_provider_continuation_state = effective_provider_config + .as_ref() + .map(|config| config.provider_continuation_state()) + .unwrap_or_else(ProviderContinuationState::history_replay_only); + let restored_provider_continuation_state = load_previous_provider_continuation_state( + db, + session_id, + provider_routing_snapshot.as_ref(), + provider_continuation_capability, + ); + let provider_continuation_state = if matches!( + restored_provider_continuation_state, + ProviderContinuationState::HistoryReplayOnly + ) { + configured_provider_continuation_state + } else { + tracing::info!( + "[AsterAgent] 恢复上一条 terminal run 的 provider continuation: session_id={}, kind={}", + session_id, + restored_provider_continuation_state.kind() + ); + restored_provider_continuation_state + }; + turn_input_builder + .set_provider_continuation_capability(provider_continuation_capability) + .set_provider_continuation(provider_continuation_state); + + let request_metadata = request.metadata.clone(); + let sandbox_outcome = apply_workspace_sandbox_permissions( + state, + config_manager, + db, + api_key_provider_service, + logs, + mcp_manager, + automation_state, + app, + session_id, + request_metadata.as_ref(), + workspace_root, + runtime_chat_mode, + execution_profile, + request_tool_policy, + requested_strategy, + ) + .await + .map_err(|error| format!("注入 workspace 安全策略失败: {error}"))?; + + match sandbox_outcome { + WorkspaceSandboxApplyOutcome::Applied { sandbox_type } => { + tracing::info!( + "[AsterAgent] 已启用 workspace 本地 sandbox: root={}, type={}", + workspace_root, + sandbox_type + ); + } + WorkspaceSandboxApplyOutcome::DisabledByConfig => { + tracing::info!( + "[AsterAgent] workspace 本地 sandbox 已关闭,继续使用普通执行模式: root={}", + workspace_root + ); + } + WorkspaceSandboxApplyOutcome::UnavailableFallback { + warning_message, + notify_user, + } => { + tracing::warn!( + "[AsterAgent] workspace 本地 sandbox 不可用,已降级为普通执行: root={}, warning={}", + workspace_root, + warning_message + ); + if notify_user { + let warning_event = RuntimeAgentEvent::Warning { + code: Some(WORKSPACE_SANDBOX_FALLBACK_WARNING_CODE.to_string()), + message: warning_message, + }; + if let Err(error) = app.emit(&request.event_name, &warning_event) { + tracing::error!("[AsterAgent] 发送 sandbox 降级提醒失败: {}", error); + } + } + } + } + + Ok(RuntimeTurnSubmitBootstrap { + model_skill_tool_enabled: matches!(execution_profile, TurnExecutionProfile::FullRuntime) + && should_enable_model_skill_tool(request_metadata.as_ref()), + request_metadata, + runtime_memory_config: runtime_config.memory.clone(), + provider_continuation_capability, + tracker: ExecutionTracker::new(db.clone()), + }) +} + +async fn prepare_runtime_turn_session( + app: &AppHandle, + logs: &LogState, + db: &DbConnection, + request: &AsterChatRequest, + session_id: &str, + workspace_root: &str, + workspace_repaired: bool, + workspace_warning: Option, +) -> Result { + let auto_continue_config = request + .auto_continue + .clone() + .map(AutoContinuePayload::normalized); + let auto_continue_enabled = auto_continue_config + .as_ref() + .map(|config| config.enabled) + .unwrap_or(false); + if let Some(config) = auto_continue_config + .as_ref() + .filter(|config| config.enabled) + { + tracing::info!( + "[AsterAgent] 自动续写策略已启用: source={:?}, fast_mode={}, continuation_length={}, sensitivity={}", + config.source, + config.fast_mode_enabled, + config.continuation_length, + config.sensitivity + ); + } + + if workspace_repaired { + let warning_message = workspace_warning.unwrap_or_else(|| { + format!( + "检测到工作区目录缺失,已自动创建并继续执行: {}", + workspace_root + ) + }); + logs.write() + .await + .add("warn", &format!("[AsterAgent] {}", warning_message)); + let warning_event = RuntimeAgentEvent::Warning { + code: Some(WORKSPACE_PATH_AUTO_CREATED_WARNING_CODE.to_string()), + message: warning_message, + }; + if let Err(error) = app.emit(&request.event_name, &warning_event) { + tracing::error!("[AsterAgent] 发送工作区自动恢复提醒失败: {}", error); + } + } + + let mut session_state_snapshot = SessionStateSnapshot::from_persisted_metadata( + session_id, + AsterAgentWrapper::get_persisted_session_metadata_sync(db, session_id)?, + ); + + if session_state_snapshot.needs_working_dir_update(workspace_root) { + tracing::info!( + "[AsterAgent] workspace 变更,自动更新 session working_dir: {} -> {}", + session_state_snapshot.working_dir().unwrap_or_default(), + workspace_root + ); + AsterAgentWrapper::update_session_working_dir_sync(db, session_id, workspace_root)?; + session_state_snapshot = + session_state_snapshot.with_working_dir(Some(workspace_root.to_string())); + } + + let SessionRecentRuntimeContext { + preferences: session_recent_preferences, + team_selection: session_recent_team_selection, + } = resolve_session_recent_runtime_context(session_id).await?; + + Ok(RuntimeTurnSessionPreparation { + auto_continue_config, + auto_continue_enabled, + session_state_snapshot, + session_recent_preferences, + session_recent_team_selection, + }) +} + +fn prepare_runtime_turn_policy( + request: &AsterChatRequest, + session_id: &str, + session_recent_preferences: Option<&lime_agent::SessionExecutionRuntimePreferences>, + auto_continue_enabled: bool, +) -> RuntimeTurnPolicyPreparation { + let runtime_chat_mode = resolve_runtime_chat_mode(request.metadata.as_ref()); + let mode_default_web_search = default_web_search_enabled_for_chat_mode(runtime_chat_mode); + let resolved_request_web_search = resolve_request_web_search_preference_from_sources( + request.web_search, + request.metadata.as_ref(), + session_recent_preferences, + ); + let (request_web_search, request_search_mode) = + apply_browser_requirement_to_request_tool_policy( + request.metadata.as_ref(), + resolved_request_web_search, + request.search_mode, + ); + + let request_tool_policy = resolve_request_tool_policy_with_mode( + request_web_search, + request_search_mode, + mode_default_web_search, + ); + tracing::info!( + "[AsterAgent][WebSearchGuard] session={}, chat_mode={:?}, request_web_search={:?}, request_search_mode={:?}, effective_request_web_search={:?}, effective_request_search_mode={:?}, mode_default_web_search={}, effective_web_search={}, search_mode={}", + session_id, + runtime_chat_mode, + request.web_search, + request.search_mode, + request_web_search, + request_search_mode, + mode_default_web_search, + request_tool_policy.effective_web_search, + request_tool_policy.search_mode.as_str() + ); + + let execution_profile = resolve_turn_execution_profile( + request, + runtime_chat_mode, + &request_tool_policy, + auto_continue_enabled, + ); + + RuntimeTurnPolicyPreparation { + request_tool_policy, + execution_profile, + } +} + +async fn prepare_runtime_turn_request( + state: &AsterAgentState, + db: &DbConnection, + mcp_manager: &McpManagerState, + request: &mut AsterChatRequest, + session_id: &str, + workspace_id: &str, + workspace_root: &str, + resolved_turn_id: &str, + runtime_config: &lime_core::config::Config, + workspace_settings: &WorkspaceSettings, + request_tool_policy: &RequestToolPolicy, + execution_profile: TurnExecutionProfile, + session_state_snapshot: &SessionStateSnapshot, + session_recent_harness_context: &SessionRecentHarnessContext, +) -> RuntimeTurnRequestPreparation { + request.metadata = merge_runtime_turn_tool_surface_metadata( + request.metadata.take(), + resolve_fast_chat_tool_surface_mode(request, execution_profile, request_tool_policy), + ); + let runtime_chat_mode = resolve_runtime_chat_mode(request.metadata.as_ref()); + + if matches!(execution_profile, TurnExecutionProfile::FullRuntime) { + let (_start_ok, start_fail) = ensure_lime_mcp_servers_running(db, mcp_manager).await; + let (_mcp_ok, mcp_fail) = inject_mcp_extensions(state, mcp_manager).await; + + if start_fail > 0 { + tracing::warn!( + "[AsterAgent] 部分 MCP server 自动启动失败 ({} 失败),后续可用工具可能不完整", + start_fail + ); + } + if mcp_fail > 0 { + tracing::warn!( + "[AsterAgent] 部分 MCP extension 注入失败 ({} 失败),Agent 可能无法使用某些 MCP 工具", + mcp_fail + ); + } + } else { + tracing::info!( + "[AsterAgent] FastChat 跳过 MCP runtime 预热: session={}, search_mode={}, chat_mode={:?}", + session_id, + request_tool_policy.search_mode.as_str(), + runtime_chat_mode + ); + } + + if matches!(execution_profile, TurnExecutionProfile::FullRuntime) { + request.metadata = prepare_image_skill_launch_request_metadata( + Path::new(workspace_root), + session_id, + resolved_turn_id, + request.metadata.as_ref(), + request.images.as_deref(), + ); + request.metadata = + prepare_broadcast_skill_launch_request_metadata(request.metadata.as_ref()); + request.metadata = + prepare_resource_search_skill_launch_request_metadata(request.metadata.as_ref()); + request.metadata = + prepare_research_skill_launch_request_metadata(request.metadata.as_ref()); + request.metadata = prepare_report_skill_launch_request_metadata(request.metadata.as_ref()); + request.metadata = + prepare_deep_search_skill_launch_request_metadata(request.metadata.as_ref()); + request.metadata = + prepare_site_search_skill_launch_request_metadata(request.metadata.as_ref()); + request.metadata = + prepare_pdf_read_skill_launch_request_metadata(request.metadata.as_ref()); + request.metadata = + prepare_presentation_skill_launch_request_metadata(request.metadata.as_ref()); + request.metadata = prepare_form_skill_launch_request_metadata(request.metadata.as_ref()); + request.metadata = prepare_summary_skill_launch_request_metadata(request.metadata.as_ref()); + request.metadata = + prepare_translation_skill_launch_request_metadata(request.metadata.as_ref()); + request.metadata = + prepare_analysis_skill_launch_request_metadata(request.metadata.as_ref()); + request.metadata = + prepare_typesetting_skill_launch_request_metadata(request.metadata.as_ref()); + request.metadata = prepare_webpage_skill_launch_request_metadata(request.metadata.as_ref()); + request.metadata = prepare_service_scene_launch_request_metadata(request.metadata.as_ref()); + normalize_runtime_turn_request_metadata( + request, + session_recent_harness_context.theme.as_deref(), + session_recent_harness_context.session_mode.as_deref(), + session_recent_harness_context.gate_key.as_deref(), + session_recent_harness_context.run_title.as_deref(), + session_recent_harness_context.content_id.as_deref(), + true, + ); + } + + let runtime_chat_mode = resolve_runtime_chat_mode(request.metadata.as_ref()); + // `context_trace` 主要服务诊断面板;真实 GUI 回放里这条事件链会在主回复流中触发栈溢出。 + // 默认先关闭,只有显式打开调试开关时才继续发射,优先保证主聊天链稳定可交付。 + let include_context_trace = + runtime_config.memory.enabled && std::env::var("LIME_ENABLE_CONTEXT_TRACE").is_ok(); + tracing::info!( + "[AsterAgent] session_state_snapshot={}", + serde_json::to_string(&session_state_snapshot).unwrap_or_else(|_| "{}".to_string()) + ); + let turn_context_snapshot = + build_runtime_turn_context_snapshot(request.metadata.as_ref(), workspace_settings); + let turn_context_metadata = build_runtime_turn_context_metadata_value(&turn_context_snapshot); + + let mut turn_input_builder = TurnInputEnvelopeBuilder::new(session_id, workspace_id); + turn_input_builder + .set_project_id(request.project_id.clone()) + .set_execution_profile(execution_profile) + .set_has_persisted_session(session_state_snapshot.has_persisted_session()) + .set_request_tool_policy(Some(TurnRequestToolPolicySnapshot::from( + request_tool_policy, + ))) + .set_working_dir(Some(workspace_root.to_string())) + .set_effective_user_message(request.message.clone()) + .set_include_context_trace(include_context_trace) + .set_approval_policy(request.approval_policy.clone()) + .set_sandbox_policy(request.sandbox_policy.clone()) + .set_turn_output_schema( + turn_context_snapshot.output_schema.clone(), + turn_context_snapshot.output_schema_source, + ) + .set_turn_context_metadata_from_value(turn_context_metadata.as_ref()); + + RuntimeTurnRequestPreparation { + runtime_chat_mode, + include_context_trace, + turn_input_builder, + } +} + +#[allow(clippy::too_many_arguments)] +fn prepare_runtime_turn_prompt_strategy( + db: &DbConnection, + request: &AsterChatRequest, + session_id: &str, + workspace_root: &str, + execution_profile: TurnExecutionProfile, + runtime_config: &lime_core::config::Config, + request_tool_policy: &RequestToolPolicy, + session_state_snapshot: &SessionStateSnapshot, + session_recent_team_selection: Option<&lime_agent::SessionExecutionRuntimeRecentTeamSelection>, + session_recent_preferences: Option<&lime_agent::SessionExecutionRuntimePreferences>, + auto_continue_config: Option<&AutoContinuePayload>, + turn_input_builder: &mut TurnInputEnvelopeBuilder, +) -> RuntimeTurnPromptStrategy { + let persisted_strategy = + AsterExecutionStrategy::from_db_value(session_state_snapshot.execution_strategy()); + let session_prompt = if let Some(prompt) = session_state_snapshot.system_prompt() { + tracing::debug!( + "[AsterAgent] 找到 session,system_prompt: {:?}", + Some(prompt.len()) + ); + Some(prompt.to_string()) + } else { + if !session_state_snapshot.has_persisted_session() { + tracing::debug!("[AsterAgent] Lime 数据库中未找到 session: {}", session_id); + } + None + }; + + let project_prompt = if let Some(ref project_id) = request.project_id { + match AsterAgentState::build_project_system_prompt(db, project_id) { + Ok(prompt) => { + tracing::info!( + "[AsterAgent] 已加载项目上下文: project_id={}, prompt_len={}", + project_id, + prompt.len() + ); + Some(prompt) + } + Err(error) => { + tracing::warn!( + "[AsterAgent] 加载项目上下文失败: {}, 继续使用 session prompt", + error + ); + None + } + } + } else { + None + }; + + let (resolved_prompt, system_prompt_source) = if let Some(project_prompt) = project_prompt { + (Some(project_prompt), TurnSystemPromptSource::Project) + } else if let Some(session_prompt) = session_prompt { + (Some(session_prompt), TurnSystemPromptSource::Session) + } else if let Some(ref frontend_prompt) = request.system_prompt { + if !frontend_prompt.trim().is_empty() { + tracing::info!( + "[AsterAgent] 使用前端传入的 system_prompt, len={}", + frontend_prompt.len() + ); + ( + Some(frontend_prompt.clone()), + TurnSystemPromptSource::Frontend, + ) + } else { + (None, TurnSystemPromptSource::None) + } + } else { + (None, TurnSystemPromptSource::None) + }; + turn_input_builder.set_base_system_prompt(system_prompt_source, resolved_prompt.clone()); + + let prompt_with_runtime_agents = + merge_system_prompt_with_runtime_agents(resolved_prompt, Some(Path::new(workspace_root))); + turn_input_builder.apply_prompt_stage( + TurnPromptAugmentationStageKind::RuntimeAgents, + prompt_with_runtime_agents.clone(), + ); + let prompt_with_local_path_focus = apply_turn_prompt_stage( + turn_input_builder, + TurnPromptAugmentationStageKind::ExplicitLocalPathFocus, + prompt_with_runtime_agents, + |prompt| { + merge_system_prompt_with_explicit_local_path_focus( + prompt, + request.message.as_str(), + workspace_root, + ) + }, + ); + + let system_prompt = if matches!(execution_profile, TurnExecutionProfile::FullRuntime) { + build_full_runtime_system_prompt( + turn_input_builder, + prompt_with_local_path_focus, + runtime_config, + db, + session_id, + workspace_root, + request, + request_tool_policy, + session_recent_team_selection, + session_recent_preferences, + auto_continue_config, + ) + } else { + build_fast_chat_system_prompt( + turn_input_builder, + prompt_with_local_path_focus, + request_tool_policy, + ) + }; + + let requested_strategy = request.execution_strategy.unwrap_or(persisted_strategy); + let effective_strategy = requested_strategy.effective_for_message(&request.message); + turn_input_builder + .set_requested_execution_strategy(Some(requested_strategy.as_db_value().to_string())) + .set_effective_execution_strategy(Some(effective_strategy.as_db_value().to_string())); + + if let Some(explicit_strategy) = request.execution_strategy { + if session_state_snapshot.has_persisted_session() { + if let Err(error) = AsterAgentWrapper::update_session_execution_strategy_sync( + db, + session_id, + explicit_strategy.as_db_value(), + ) { + tracing::warn!( + "[AsterAgent] 更新会话执行策略失败: session={}, strategy={}, error={}", + session_id, + explicit_strategy.as_db_value(), + error + ); + } + } + } + + tracing::info!( + "[AsterAgent] 执行策略: requested={:?}, effective={:?}", + requested_strategy, + effective_strategy + ); + + RuntimeTurnPromptStrategy { + system_prompt, + requested_strategy, + effective_strategy, + system_prompt_source, + } +} + +#[allow(clippy::too_many_arguments)] +async fn prepare_runtime_turn_submit_preparation( + app: &AppHandle, + state: &AsterAgentState, + db: &DbConnection, + api_key_provider_service: &ApiKeyProviderServiceState, + logs: &LogState, + config_manager: &GlobalConfigManagerState, + mcp_manager: &McpManagerState, + automation_state: &AutomationServiceState, + request: &mut AsterChatRequest, + session_id: &str, + workspace_id: &str, + workspace_root: &str, + resolved_turn_id: &str, + workspace_settings: &WorkspaceSettings, + runtime_config: &lime_core::config::Config, + workspace_repaired: bool, + workspace_warning: Option, + session_recent_harness_context: &SessionRecentHarnessContext, +) -> Result { + let RuntimeTurnSessionPreparation { + auto_continue_config, + auto_continue_enabled, + session_state_snapshot, + session_recent_preferences, + session_recent_team_selection, + } = prepare_runtime_turn_session( + app, + logs, + db, + request, + session_id, + workspace_root, + workspace_repaired, + workspace_warning, + ) + .await?; + + let RuntimeTurnPolicyPreparation { + request_tool_policy, + execution_profile, + } = prepare_runtime_turn_policy( + request, + session_id, + session_recent_preferences.as_ref(), + auto_continue_enabled, + ); + + let RuntimeTurnRequestPreparation { + runtime_chat_mode, + include_context_trace, + mut turn_input_builder, + } = prepare_runtime_turn_request( + state, + db, + mcp_manager, + request, + session_id, + workspace_id, + workspace_root, + resolved_turn_id, + runtime_config, + workspace_settings, + &request_tool_policy, + execution_profile, + &session_state_snapshot, + session_recent_harness_context, + ) + .await; + + let RuntimeTurnPromptStrategy { + system_prompt, + requested_strategy, + effective_strategy, + system_prompt_source, + } = prepare_runtime_turn_prompt_strategy( + db, + request, + session_id, + workspace_root, + execution_profile, + runtime_config, + &request_tool_policy, + &session_state_snapshot, + session_recent_team_selection.as_ref(), + session_recent_preferences.as_ref(), + auto_continue_config.as_ref(), + &mut turn_input_builder, + ); + + apply_runtime_turn_provider_config(state, db, session_id, request.provider_config.as_ref()) + .await?; + + let submit_bootstrap = prepare_runtime_turn_submit_bootstrap( + app, + state, + db, + api_key_provider_service, + logs, + config_manager, + mcp_manager, + automation_state, + request, + session_id, + workspace_root, + workspace_settings, + runtime_config, + runtime_chat_mode, + execution_profile, + requested_strategy, + &request_tool_policy, + &mut turn_input_builder, + ) + .await?; + + Ok(RuntimeTurnSubmitPreparation { + auto_continue_config, + auto_continue_enabled, + session_state_snapshot, + session_recent_preferences, + runtime_chat_mode, + include_context_trace, + turn_input_builder, + request_tool_policy, + execution_profile, + requested_strategy, + effective_strategy, + system_prompt, + system_prompt_source, + submit_bootstrap, + }) +} + +#[allow(clippy::too_many_arguments)] +async fn build_runtime_turn_artifacts( + agent_arc: &Arc>>, + session_id: &str, + workspace_id: &str, + resolved_turn_id: &str, + execution_profile: TurnExecutionProfile, + requested_strategy: AsterExecutionStrategy, + effective_strategy: AsterExecutionStrategy, + request_tool_policy: &RequestToolPolicy, + include_context_trace: bool, + runtime_chat_mode: RuntimeChatMode, + system_prompt_source: TurnSystemPromptSource, + mut turn_input_builder: TurnInputEnvelopeBuilder, +) -> Result { + let runtime_snapshot = { + let guard = agent_arc.read().await; + let agent = guard.as_ref().ok_or("Agent not initialized")?; + match agent.runtime_snapshot(session_id).await { + Ok(snapshot) => Some(snapshot), + Err(error) => { + tracing::warn!( + "[AsterAgent] 提交 turn 前读取 runtime snapshot 失败: session_id={}, error={}", + session_id, + error + ); + None + } + } + }; + let runtime_projection_snapshot = + RuntimeProjectionSnapshot::from_snapshot(session_id, runtime_snapshot.as_ref()); + if matches!(execution_profile, TurnExecutionProfile::FullRuntime) { + tracing::info!( + "[AsterAgent] runtime_projection_snapshot={}", + serde_json::to_string(&runtime_projection_snapshot) + .unwrap_or_else(|_| "{}".to_string()) + ); + } + let resolved_thread_id = runtime_projection_snapshot + .primary_thread_id() + .map(str::to_string) + .unwrap_or_else(|| session_id.to_string()); + let turn_state = TurnState::new( + session_id, + workspace_id, + resolved_thread_id, + resolved_turn_id, + execution_profile, + requested_strategy.as_db_value(), + effective_strategy.as_db_value(), + TurnRequestToolPolicySnapshot::from(request_tool_policy), + include_context_trace, + runtime_chat_mode_label(runtime_chat_mode), + ); + turn_input_builder + .set_thread_id(turn_state.thread_id.clone()) + .set_turn_id(turn_state.turn_id.clone()); + let turn_input_envelope = turn_input_builder.build(); + let turn_input_diagnostics = turn_input_envelope.diagnostics_snapshot(); + if matches!(execution_profile, TurnExecutionProfile::FullRuntime) { + tracing::info!( + "[AsterAgent] turn_state={}", + serde_json::to_string(&turn_state).unwrap_or_else(|_| "{}".to_string()) + ); + tracing::info!( + "[AsterAgent] turn_input_envelope={}", + serde_json::to_string(&turn_input_diagnostics).unwrap_or_else(|_| "{}".to_string()) + ); + } else { + tracing::info!( + "[AsterAgent] fast_turn_summary={}", + serde_json::json!({ + "session_id": session_id, + "workspace_id": workspace_id, + "thread_id": turn_state.thread_id.clone(), + "turn_id": turn_state.turn_id.clone(), + "execution_profile": execution_profile, + "requested_execution_strategy": requested_strategy.as_db_value(), + "effective_execution_strategy": effective_strategy.as_db_value(), + "system_prompt_source": system_prompt_source, + "final_system_prompt_len": turn_input_diagnostics.final_system_prompt_len, + "has_turn_context_metadata": turn_input_diagnostics.has_turn_context_metadata, + }) + ); + } + + Ok(RuntimeTurnBuildArtifacts { + runtime_projection_snapshot, + turn_state, + turn_input_envelope, + turn_input_diagnostics, + }) +} + +fn build_runtime_turn_execution_context( + db: &DbConnection, + request: &AsterChatRequest, + session_id: &str, + workspace_id: &str, + effective_strategy: AsterExecutionStrategy, + request_tool_policy: &RequestToolPolicy, + auto_continue_enabled: bool, + auto_continue_metadata: Option<&AutoContinuePayload>, + session_recent_preferences: Option<&lime_agent::SessionExecutionRuntimePreferences>, + session_state_snapshot: &SessionStateSnapshot, + runtime_projection_snapshot: &RuntimeProjectionSnapshot, + turn_state: &TurnState, + turn_input_system_prompt: Option, + turn_input_include_context_trace: bool, + turn_input_turn_context_override: Option, + turn_input_diagnostics: &lime_agent::TurnDiagnosticsSnapshot, + service_skill_preload: Option<&ServiceSkillLaunchPreloadExecution>, +) -> Result { + let run_start_metadata = build_runtime_run_start_metadata( + request, + workspace_id, + effective_strategy, + request_tool_policy, + auto_continue_enabled, + auto_continue_metadata, + session_recent_preferences, + session_state_snapshot, + runtime_projection_snapshot, + turn_state, + turn_input_diagnostics, + service_skill_preload, + ); + let timeline_recorder = Arc::new(Mutex::new(AgentTimelineRecorder::create( + db.clone(), + turn_state.thread_id.clone(), + turn_state.turn_id.clone(), + request.message.clone(), + )?)); + let runtime_status_session_config = build_runtime_session_config( + session_id, + &turn_state.thread_id, + &turn_state.turn_id, + None, + None, + turn_input_turn_context_override.clone(), + ); + + Ok(RuntimeTurnExecutionContext { + run_start_metadata, + run_observation: Arc::new(Mutex::new(ChatRunObservation::default())), + timeline_recorder, + runtime_status_session_config, + stream_session_config_state: RuntimeTurnStreamSessionConfigState { + session_id: session_id.to_string(), + thread_id: turn_state.thread_id.clone(), + turn_id: turn_state.turn_id.clone(), + system_prompt: turn_input_system_prompt, + include_context_trace: turn_input_include_context_trace, + turn_context_override: turn_input_turn_context_override, + }, + }) +} + +fn apply_service_skill_preload_prompt_stage( + turn_input_builder: &mut TurnInputEnvelopeBuilder, + execution_profile: TurnExecutionProfile, + system_prompt: Option, + service_skill_preload: Option<&ServiceSkillLaunchPreloadExecution>, +) { + if !matches!(execution_profile, TurnExecutionProfile::FullRuntime) { + return; + } + + let system_prompt = + merge_system_prompt_with_service_skill_launch_preload(system_prompt, service_skill_preload); + turn_input_builder.apply_prompt_stage( + TurnPromptAugmentationStageKind::ServiceSkillLaunchPreload, + system_prompt, + ); +} + +#[allow(clippy::too_many_arguments)] +async fn prepare_runtime_turn_execution( + agent_arc: &Arc>>, + db: &DbConnection, + request: &AsterChatRequest, + session_id: &str, + workspace_id: &str, + resolved_turn_id: &str, + execution_profile: TurnExecutionProfile, + requested_strategy: AsterExecutionStrategy, + effective_strategy: AsterExecutionStrategy, + request_tool_policy: &RequestToolPolicy, + include_context_trace: bool, + runtime_chat_mode: RuntimeChatMode, + system_prompt_source: TurnSystemPromptSource, + system_prompt: Option, + mut turn_input_builder: TurnInputEnvelopeBuilder, + auto_continue_enabled: bool, + auto_continue_metadata: Option<&AutoContinuePayload>, + session_recent_preferences: Option<&lime_agent::SessionExecutionRuntimePreferences>, + session_state_snapshot: &SessionStateSnapshot, + request_metadata: Option<&serde_json::Value>, +) -> Result { + let service_skill_preload = if matches!(execution_profile, TurnExecutionProfile::FullRuntime) { + preload_service_skill_launch_execution(db, request_metadata) + .await + .map_err(|error| format!("站点技能预执行失败: {error}"))? + } else { + None + }; + apply_service_skill_preload_prompt_stage( + &mut turn_input_builder, + execution_profile, + system_prompt, + service_skill_preload.as_ref(), + ); + + let runtime_turn_artifacts = build_runtime_turn_artifacts( + agent_arc, + session_id, + workspace_id, + resolved_turn_id, + execution_profile, + requested_strategy, + effective_strategy, + request_tool_policy, + include_context_trace, + runtime_chat_mode, + system_prompt_source, + turn_input_builder, + ) + .await?; + + let runtime_turn_execution_context = build_runtime_turn_execution_context( + db, + request, + session_id, + workspace_id, + effective_strategy, + request_tool_policy, + auto_continue_enabled, + auto_continue_metadata, + session_recent_preferences, + session_state_snapshot, + &runtime_turn_artifacts.runtime_projection_snapshot, + &runtime_turn_artifacts.turn_state, + runtime_turn_artifacts + .turn_input_envelope + .system_prompt() + .map(str::to_string), + runtime_turn_artifacts + .turn_input_envelope + .include_context_trace(), + runtime_turn_artifacts + .turn_input_envelope + .turn_context_override(), + &runtime_turn_artifacts.turn_input_diagnostics, + service_skill_preload.as_ref(), + )?; + + Ok(RuntimeTurnPreparedExecution { + service_skill_preload, + runtime_turn_artifacts, + runtime_turn_execution_context, + }) +} + +#[allow(clippy::too_many_arguments)] +async fn execute_runtime_turn_submit( + app: &AppHandle, + state: &AsterAgentState, + db: &DbConnection, + request: &AsterChatRequest, + session_id: &str, + workspace_id: &str, + workspace_root: &str, + resolved_turn_id: &str, + submit_preparation: RuntimeTurnSubmitPreparation, + cancel_token: CancellationToken, +) -> Result<(), String> { + let RuntimeTurnSubmitPreparation { + auto_continue_config, + auto_continue_enabled, + session_state_snapshot, + session_recent_preferences, + runtime_chat_mode, + include_context_trace, + turn_input_builder, + request_tool_policy, + execution_profile, + requested_strategy, + effective_strategy, + system_prompt, + system_prompt_source, + submit_bootstrap, + } = submit_preparation; + + sync_browser_assist_runtime_hint(session_id, submit_bootstrap.request_metadata.as_ref()).await; + + let agent_arc = state.get_agent_arc(); + let provider_model_name = request + .provider_config + .as_ref() + .map(|config| config.model_name.as_str()); + let auto_continue_metadata = auto_continue_config.clone(); + let runtime_turn_prepared_execution = prepare_runtime_turn_execution( + &agent_arc, + db, + request, + session_id, + workspace_id, + resolved_turn_id, + execution_profile, + requested_strategy, + effective_strategy, + &request_tool_policy, + include_context_trace, + runtime_chat_mode, + system_prompt_source, + system_prompt, + turn_input_builder, + auto_continue_enabled, + auto_continue_metadata.as_ref(), + session_recent_preferences.as_ref(), + &session_state_snapshot, + submit_bootstrap.request_metadata.as_ref(), + ) + .await?; + + let guard = agent_arc.read().await; + let agent = guard.as_ref().ok_or("Agent not initialized")?; + runtime_turn_prepared_execution + .emit_prelude_and_execute( + agent, + &submit_bootstrap.tracker, + app, + db, + request, + &submit_bootstrap.runtime_memory_config, + session_id, + workspace_root, + workspace_id, + execution_profile, + submit_bootstrap.request_metadata.as_ref(), + submit_bootstrap.provider_continuation_capability, + cancel_token, + effective_strategy, + &request_tool_policy, + provider_model_name, + session_recent_preferences.as_ref(), + ) + .await +} + +#[allow(clippy::too_many_arguments)] +async fn execute_runtime_turn_with_session_scope( + app: &AppHandle, + state: &AsterAgentState, + db: &DbConnection, + request: &AsterChatRequest, + session_id: &str, + workspace_id: &str, + workspace_root: &str, + resolved_turn_id: &str, + submit_preparation: RuntimeTurnSubmitPreparation, +) -> Result<(), String> { + let model_skill_tool_enabled = submit_preparation.model_skill_tool_enabled(); + with_runtime_turn_session_scope( + state, + session_id, + model_skill_tool_enabled, + move |cancel_token| async move { + execute_runtime_turn_submit( + app, + state, + db, + request, + session_id, + workspace_id, + workspace_root, + resolved_turn_id, + submit_preparation, + cancel_token, + ) + .await + }, + ) + .await +} + +#[allow(clippy::too_many_arguments)] +async fn execute_runtime_turn_pipeline( + app: &AppHandle, + state: &AsterAgentState, + db: &DbConnection, + api_key_provider_service: &ApiKeyProviderServiceState, + logs: &LogState, + config_manager: &GlobalConfigManagerState, + mcp_manager: &McpManagerState, + automation_state: &AutomationServiceState, + mut request: AsterChatRequest, +) -> Result<(), String> { + prepare_runtime_turn_entry(app, state, db, mcp_manager).await?; + let RuntimeTurnIngressContext { + owned_session_id, + workspace_id, + workspace_root, + workspace_settings, + resolved_turn_id, + runtime_config, + session_recent_harness_context, + workspace_repaired, + workspace_warning, + } = prepare_runtime_turn_ingress_context( + app, + db, + api_key_provider_service, + logs, + config_manager, + &mut request, + ) + .await?; + + let session_id = owned_session_id.as_str(); + let submit_preparation = prepare_runtime_turn_submit_preparation( + app, + state, + db, + api_key_provider_service, + logs, + config_manager, + mcp_manager, + automation_state, + &mut request, + session_id, + workspace_id.as_str(), + workspace_root.as_str(), + resolved_turn_id.as_str(), + &workspace_settings, + &runtime_config, + workspace_repaired, + workspace_warning, + &session_recent_harness_context, + ) + .await?; + + execute_runtime_turn_with_session_scope( + app, + state, + db, + &request, + session_id, + workspace_id.as_str(), + workspace_root.as_str(), + resolved_turn_id.as_str(), + submit_preparation, + ) + .await +} + +async fn prepare_runtime_turn_entry( + app: &AppHandle, + state: &AsterAgentState, + db: &DbConnection, + mcp_manager: &McpManagerState, +) -> Result<(), String> { + let is_init = state.is_initialized().await; + tracing::warn!("[AsterAgent] Agent 初始化状态: {}", is_init); + if !is_init { + tracing::warn!("[AsterAgent] Agent 未初始化,开始初始化..."); + state.init_agent_with_db(db).await?; + tracing::warn!("[AsterAgent] Agent 初始化完成"); + } else { + tracing::warn!("[AsterAgent] Agent 已初始化,检查 session_store..."); + let agent_arc = state.get_agent_arc(); + let guard = agent_arc.read().await; + if let Some(agent) = guard.as_ref() { + let has_store = agent.session_store().is_some(); + tracing::warn!("[AsterAgent] session_store 存在: {}", has_store); + } + } + + ensure_host_backed_config_tool_registered(app, state).await?; + ensure_runtime_support_tools_registered(state, mcp_manager).await +} + +async fn prepare_runtime_turn_ingress_context( + app: &AppHandle, + db: &DbConnection, + api_key_provider_service: &ApiKeyProviderServiceState, + logs: &LogState, + config_manager: &GlobalConfigManagerState, + request: &mut AsterChatRequest, +) -> Result { + let should_resolve_session_recent_harness_context = extract_harness_string( + request.metadata.as_ref(), + &["theme", "harness_theme", "harnessTheme"], + ) + .is_none() + || extract_harness_string(request.metadata.as_ref(), &["session_mode", "sessionMode"]) + .is_none() + || extract_harness_string(request.metadata.as_ref(), &["gate_key", "gateKey"]).is_none() + || extract_harness_string( + request.metadata.as_ref(), + &["run_title", "runTitle", "title"], + ) + .is_none() + || extract_harness_string(request.metadata.as_ref(), &["content_id", "contentId"]) + .is_none(); + let provider_config_future = + resolve_runtime_request_provider_config(app, db, api_key_provider_service, request); + let session_recent_harness_context_future = async { + if should_resolve_session_recent_harness_context { + resolve_session_recent_harness_context(&request.session_id).await + } else { + Ok(SessionRecentHarnessContext::default()) + } + }; + let (resolved_provider_config, session_recent_harness_context) = tokio::try_join!( + provider_config_future, + session_recent_harness_context_future + )?; + + if let Some(resolved_provider_config) = resolved_provider_config { + request.provider_config = Some(resolved_provider_config); + } + if let Some(provider_config) = request.provider_config.as_mut() { + ensure_provider_runtime_ready(provider_config).await?; + let runtime_tool_call_decision = + enrich_provider_config_with_runtime_tool_strategy(provider_config).await; + tracing::info!( + "[AsterAgent] provider_config 运行时工具策略: provider_id={:?}, provider_name={}, model_name={}, strategy={:?}, toolshim_model={:?}, tools={}, function_calling={}, reasoning={}", + provider_config.provider_id, + provider_config.provider_name, + provider_config.model_name, + runtime_tool_call_decision.strategy, + runtime_tool_call_decision.toolshim_model, + runtime_tool_call_decision.capabilities.tools, + runtime_tool_call_decision.capabilities.function_calling, + runtime_tool_call_decision.capabilities.reasoning + ); + } + + normalize_runtime_turn_request_metadata( + request, + session_recent_harness_context.theme.as_deref(), + session_recent_harness_context.session_mode.as_deref(), + session_recent_harness_context.gate_key.as_deref(), + session_recent_harness_context.run_title.as_deref(), + session_recent_harness_context.content_id.as_deref(), + false, + ); + backfill_runtime_access_policies(request); + + let owned_session_id = request.session_id.clone(); + + let workspace_id = match resolve_runtime_turn_workspace_id(db, request) { + Ok(workspace_id) => workspace_id, + Err(message) => { + logs.write() + .await + .add("error", &format!("[AsterAgent] {}", message)); + return Err(message); + } + }; + + let manager = WorkspaceManager::new(db.clone()); + let workspace = match manager.get(&workspace_id) { + Ok(Some(workspace)) => workspace, + Ok(None) => { + let message = format!("Workspace 不存在: {workspace_id}"); + logs.write() + .await + .add("error", &format!("[AsterAgent] {}", message)); + return Err(message); + } + Err(error) => { + let message = format!("读取 workspace 失败: {error}"); + logs.write() + .await + .add("error", &format!("[AsterAgent] {}", message)); + return Err(message); + } + }; + let ensured = match ensure_workspace_ready_with_auto_relocate(&manager, &workspace) { + Ok(result) => result, + Err(message) => { + logs.write() + .await + .add("error", &format!("[AsterAgent] {}", message)); + return Err(message); + } + }; + + let runtime_config = config_manager.config(); + apply_web_search_runtime_env(&runtime_config); + + Ok(RuntimeTurnIngressContext { + owned_session_id, + workspace_id, + workspace_root: ensured.root_path.to_string_lossy().to_string(), + workspace_settings: workspace.settings.clone(), + resolved_turn_id: request + .turn_id + .clone() + .unwrap_or_else(|| Uuid::new_v4().to_string()), + runtime_config, + session_recent_harness_context, + workspace_repaired: ensured.repaired, + workspace_warning: ensured.warning, + }) +} + fn normalize_runtime_turn_request_metadata( request: &mut AsterChatRequest, session_recent_theme: Option<&str>, @@ -386,18 +2371,560 @@ fn normalize_runtime_turn_request_metadata( session_recent_gate_key: Option<&str>, session_recent_run_title: Option<&str>, session_recent_content_id: Option<&str>, + enable_artifact_defaults: bool, ) { request.metadata = crate::services::artifact_request_metadata_service:: - normalize_request_metadata_with_artifact_defaults( + normalize_request_metadata_with_artifact_options( request.metadata.take(), session_recent_theme, session_recent_session_mode, session_recent_gate_key, session_recent_run_title, session_recent_content_id, + crate::services::artifact_request_metadata_service:: + ArtifactRequestMetadataNormalizationOptions { + enable_artifact_defaults, + }, ); } +fn apply_turn_prompt_stage( + turn_input_builder: &mut TurnInputEnvelopeBuilder, + stage: TurnPromptAugmentationStageKind, + prompt: Option, + apply: F, +) -> Option +where + F: FnOnce(Option) -> Option, +{ + let prompt = apply(prompt); + turn_input_builder.apply_prompt_stage(stage, prompt.clone()); + prompt +} + +fn apply_turn_metadata_prompt_stage( + turn_input_builder: &mut TurnInputEnvelopeBuilder, + stage: TurnPromptAugmentationStageKind, + prompt: Option, + request_metadata: Option<&serde_json::Value>, + apply: F, +) -> Option +where + F: FnOnce(Option, Option<&serde_json::Value>) -> Option, +{ + apply_turn_prompt_stage(turn_input_builder, stage, prompt, |prompt| { + apply(prompt, request_metadata) + }) +} + +fn merge_system_prompt_with_turn_memory_prefetch( + prompt: Option, + runtime_config: &lime_core::config::Config, + db: &DbConnection, + session_id: &str, + workspace_root: &str, + request: &AsterChatRequest, +) -> Option { + if !runtime_config.memory.enabled { + return prompt; + } + + let prefetch_request = crate::commands::memory_management_cmd::TurnMemoryPrefetchRequest { + session_id: session_id.to_string(), + working_dir: Some(workspace_root.to_string()), + user_message: request.message.clone(), + request_metadata: request.metadata.clone(), + max_durable_entries: None, + max_working_chars: None, + }; + + match db.lock() { + Ok(conn) => { + match crate::commands::memory_management_cmd::build_turn_memory_prefetch_result( + runtime_config, + &conn, + Path::new(workspace_root), + &prefetch_request, + ) { + Ok(prefetch) => { + merge_runtime_memory_prefetch_prompt(prompt, prefetch.prompt.as_deref()) + } + Err(error) => { + tracing::warn!( + "[AsterAgent] 单回合记忆预取失败,已降级继续: session_id={}, error={}", + session_id, + error + ); + prompt + } + } + } + Err(error) => { + tracing::warn!( + "[AsterAgent] 记忆预取无法获取数据库锁,已降级继续: session_id={}, error={}", + session_id, + error + ); + prompt + } + } +} + +fn build_full_runtime_system_prompt( + turn_input_builder: &mut TurnInputEnvelopeBuilder, + prompt_with_local_path_focus: Option, + runtime_config: &lime_core::config::Config, + db: &DbConnection, + session_id: &str, + workspace_root: &str, + request: &AsterChatRequest, + request_tool_policy: &RequestToolPolicy, + session_recent_team_selection: Option<&lime_agent::SessionExecutionRuntimeRecentTeamSelection>, + session_recent_preferences: Option<&lime_agent::SessionExecutionRuntimePreferences>, + auto_continue_config: Option<&AutoContinuePayload>, +) -> Option { + let request_metadata = request.metadata.as_ref(); + let mut prompt = apply_turn_prompt_stage( + turn_input_builder, + TurnPromptAugmentationStageKind::Memory, + prompt_with_local_path_focus, + |prompt| { + let prompt = merge_system_prompt_with_memory_context( + prompt, + runtime_config, + MemoryPromptContext::with_working_dir(Path::new(workspace_root)), + ); + merge_system_prompt_with_turn_memory_prefetch( + prompt, + runtime_config, + db, + session_id, + workspace_root, + request, + ) + }, + ); + + prompt = apply_turn_prompt_stage( + turn_input_builder, + TurnPromptAugmentationStageKind::WebSearch, + prompt, + |prompt| merge_system_prompt_with_web_search(prompt, runtime_config), + ); + prompt = apply_turn_prompt_stage( + turn_input_builder, + TurnPromptAugmentationStageKind::RequestToolPolicy, + prompt, + |prompt| merge_system_prompt_with_request_tool_policy(prompt, request_tool_policy), + ); + prompt = apply_turn_metadata_prompt_stage( + turn_input_builder, + TurnPromptAugmentationStageKind::Artifact, + prompt, + request_metadata, + merge_system_prompt_with_artifact_context, + ); + prompt = apply_turn_metadata_prompt_stage( + turn_input_builder, + TurnPromptAugmentationStageKind::ImageSkillLaunch, + prompt, + request_metadata, + merge_system_prompt_with_image_skill_launch, + ); + prompt = apply_turn_metadata_prompt_stage( + turn_input_builder, + TurnPromptAugmentationStageKind::CoverSkillLaunch, + prompt, + request_metadata, + merge_system_prompt_with_cover_skill_launch, + ); + prompt = apply_turn_metadata_prompt_stage( + turn_input_builder, + TurnPromptAugmentationStageKind::VideoSkillLaunch, + prompt, + request_metadata, + merge_system_prompt_with_video_skill_launch, + ); + prompt = apply_turn_metadata_prompt_stage( + turn_input_builder, + TurnPromptAugmentationStageKind::BroadcastSkillLaunch, + prompt, + request_metadata, + merge_system_prompt_with_broadcast_skill_launch, + ); + prompt = apply_turn_metadata_prompt_stage( + turn_input_builder, + TurnPromptAugmentationStageKind::ResourceSearchSkillLaunch, + prompt, + request_metadata, + merge_system_prompt_with_resource_search_skill_launch, + ); + prompt = apply_turn_metadata_prompt_stage( + turn_input_builder, + TurnPromptAugmentationStageKind::ResearchSkillLaunch, + prompt, + request_metadata, + merge_system_prompt_with_research_skill_launch, + ); + prompt = apply_turn_metadata_prompt_stage( + turn_input_builder, + TurnPromptAugmentationStageKind::ReportSkillLaunch, + prompt, + request_metadata, + merge_system_prompt_with_report_skill_launch, + ); + prompt = apply_turn_metadata_prompt_stage( + turn_input_builder, + TurnPromptAugmentationStageKind::DeepSearchSkillLaunch, + prompt, + request_metadata, + merge_system_prompt_with_deep_search_skill_launch, + ); + prompt = apply_turn_metadata_prompt_stage( + turn_input_builder, + TurnPromptAugmentationStageKind::SiteSearchSkillLaunch, + prompt, + request_metadata, + merge_system_prompt_with_site_search_skill_launch, + ); + prompt = apply_turn_metadata_prompt_stage( + turn_input_builder, + TurnPromptAugmentationStageKind::PdfReadSkillLaunch, + prompt, + request_metadata, + merge_system_prompt_with_pdf_read_skill_launch, + ); + prompt = apply_turn_metadata_prompt_stage( + turn_input_builder, + TurnPromptAugmentationStageKind::PresentationSkillLaunch, + prompt, + request_metadata, + merge_system_prompt_with_presentation_skill_launch, + ); + prompt = apply_turn_metadata_prompt_stage( + turn_input_builder, + TurnPromptAugmentationStageKind::FormSkillLaunch, + prompt, + request_metadata, + merge_system_prompt_with_form_skill_launch, + ); + prompt = apply_turn_metadata_prompt_stage( + turn_input_builder, + TurnPromptAugmentationStageKind::SummarySkillLaunch, + prompt, + request_metadata, + merge_system_prompt_with_summary_skill_launch, + ); + prompt = apply_turn_metadata_prompt_stage( + turn_input_builder, + TurnPromptAugmentationStageKind::TranslationSkillLaunch, + prompt, + request_metadata, + merge_system_prompt_with_translation_skill_launch, + ); + prompt = apply_turn_metadata_prompt_stage( + turn_input_builder, + TurnPromptAugmentationStageKind::AnalysisSkillLaunch, + prompt, + request_metadata, + merge_system_prompt_with_analysis_skill_launch, + ); + prompt = apply_turn_metadata_prompt_stage( + turn_input_builder, + TurnPromptAugmentationStageKind::TranscriptionSkillLaunch, + prompt, + request_metadata, + merge_system_prompt_with_transcription_skill_launch, + ); + prompt = apply_turn_metadata_prompt_stage( + turn_input_builder, + TurnPromptAugmentationStageKind::UrlParseSkillLaunch, + prompt, + request_metadata, + merge_system_prompt_with_url_parse_skill_launch, + ); + prompt = apply_turn_metadata_prompt_stage( + turn_input_builder, + TurnPromptAugmentationStageKind::TypesettingSkillLaunch, + prompt, + request_metadata, + merge_system_prompt_with_typesetting_skill_launch, + ); + prompt = apply_turn_metadata_prompt_stage( + turn_input_builder, + TurnPromptAugmentationStageKind::WebpageSkillLaunch, + prompt, + request_metadata, + merge_system_prompt_with_webpage_skill_launch, + ); + prompt = apply_turn_metadata_prompt_stage( + turn_input_builder, + TurnPromptAugmentationStageKind::ServiceSkillLaunch, + prompt, + request_metadata, + merge_system_prompt_with_service_skill_launch, + ); + prompt = apply_turn_metadata_prompt_stage( + turn_input_builder, + TurnPromptAugmentationStageKind::Elicitation, + prompt, + request_metadata, + merge_system_prompt_with_elicitation_context, + ); + + let subagent_mode_enabled = resolve_recent_preference_from_sources( + request_metadata, + &["subagent_mode_enabled", "subagentModeEnabled"], + session_recent_preferences.map(|preferences| preferences.subagent), + ) + .unwrap_or(false); + prompt = apply_turn_prompt_stage( + turn_input_builder, + TurnPromptAugmentationStageKind::TeamPreference, + prompt, + |prompt| { + merge_system_prompt_with_team_preference( + prompt, + request_metadata, + session_recent_team_selection, + subagent_mode_enabled, + ) + }, + ); + + apply_turn_prompt_stage( + turn_input_builder, + TurnPromptAugmentationStageKind::AutoContinue, + prompt, + |prompt| merge_system_prompt_with_auto_continue(prompt, auto_continue_config), + ) +} + +fn build_fast_chat_system_prompt( + turn_input_builder: &mut TurnInputEnvelopeBuilder, + prompt_with_local_path_focus: Option, + request_tool_policy: &RequestToolPolicy, +) -> Option { + apply_turn_prompt_stage( + turn_input_builder, + TurnPromptAugmentationStageKind::RequestToolPolicy, + prompt_with_local_path_focus, + |prompt| merge_system_prompt_with_request_tool_policy(prompt, request_tool_policy), + ) +} + +fn has_root_object_key(request_metadata: Option<&serde_json::Value>, key: &str) -> bool { + request_metadata + .and_then(serde_json::Value::as_object) + .and_then(|object| object.get(key)) + .and_then(serde_json::Value::as_object) + .is_some() +} + +fn request_metadata_contains_full_runtime_context( + request_metadata: Option<&serde_json::Value>, +) -> bool { + const FULL_RUNTIME_HARNESS_OBJECT_KEYS: [(&str, &str); 18] = [ + ("image_skill_launch", "imageSkillLaunch"), + ("service_skill_launch", "serviceSkillLaunch"), + ("service_scene_launch", "serviceSceneLaunch"), + ("cover_skill_launch", "coverSkillLaunch"), + ("video_skill_launch", "videoSkillLaunch"), + ("broadcast_skill_launch", "broadcastSkillLaunch"), + ("resource_search_skill_launch", "resourceSearchSkillLaunch"), + ("research_skill_launch", "researchSkillLaunch"), + ("report_skill_launch", "reportSkillLaunch"), + ("deep_search_skill_launch", "deepSearchSkillLaunch"), + ("site_search_skill_launch", "siteSearchSkillLaunch"), + ("pdf_read_skill_launch", "pdfReadSkillLaunch"), + ("presentation_skill_launch", "presentationSkillLaunch"), + ("form_skill_launch", "formSkillLaunch"), + ("summary_skill_launch", "summarySkillLaunch"), + ("translation_skill_launch", "translationSkillLaunch"), + ("analysis_skill_launch", "analysisSkillLaunch"), + ("team_memory_shadow", "teamMemoryShadow"), + ]; + const FULL_RUNTIME_HARNESS_OBJECT_KEYS_EXTRA: [(&str, &str); 4] = [ + ("transcription_skill_launch", "transcriptionSkillLaunch"), + ("url_parse_skill_launch", "urlParseSkillLaunch"), + ("typesetting_skill_launch", "typesettingSkillLaunch"), + ("webpage_skill_launch", "webpageSkillLaunch"), + ]; + + if has_root_object_key(request_metadata, "artifact") + || has_root_object_key(request_metadata, "elicitation_context") + { + return true; + } + + if FULL_RUNTIME_HARNESS_OBJECT_KEYS + .iter() + .chain(FULL_RUNTIME_HARNESS_OBJECT_KEYS_EXTRA.iter()) + .any(|(snake_case, camel_case)| { + extract_harness_nested_object(request_metadata, &[*snake_case, *camel_case]).is_some() + }) + { + return true; + } + + extract_harness_string( + request_metadata, + &[ + "content_id", + "contentId", + "turn_purpose", + "turnPurpose", + "purpose", + ], + ) + .is_some() + || extract_harness_string( + request_metadata, + &[ + "preferred_team_preset_id", + "preferredTeamPresetId", + "selected_team_id", + "selectedTeamId", + ], + ) + .is_some() + || extract_harness_string( + request_metadata, + &["browser_requirement", "browserRequirement"], + ) + .is_some() + || extract_harness_bool(request_metadata, &["task_mode_enabled", "taskModeEnabled"]) + .unwrap_or(false) + || extract_harness_bool( + request_metadata, + &["subagent_mode_enabled", "subagentModeEnabled"], + ) + .unwrap_or(false) +} + +fn message_suggests_local_workspace_tooling(message: &str) -> bool { + let trimmed = message.trim(); + if trimmed.contains('/') + || trimmed.contains('\\') + || trimmed.contains(".rs") + || trimmed.contains(".ts") + || trimmed.contains(".tsx") + || trimmed.contains(".js") + || trimmed.contains(".json") + || trimmed.contains("AGENTS.md") + || trimmed.contains("Cargo.toml") + || trimmed.contains("package.json") + { + return true; + } + + let normalized = trimmed.to_ascii_lowercase(); + [ + "项目", + "代码", + "仓库", + "目录", + "文件", + "路径", + "读一下", + "读取", + "分析代码", + "修复", + "报错", + "repo", + "repository", + "project", + "codebase", + "source tree", + "read ", + "grep", + "cargo", + "npm", + "bug", + "fix", + "path", + "workspace", + ] + .iter() + .any(|keyword| normalized.contains(keyword)) +} + +fn resolve_fast_chat_tool_surface_mode( + request: &AsterChatRequest, + execution_profile: TurnExecutionProfile, + request_tool_policy: &RequestToolPolicy, +) -> Option<&'static str> { + if !matches!(execution_profile, TurnExecutionProfile::FastChat) + || request_tool_policy.allows_web_search() + { + return None; + } + + if message_suggests_local_workspace_tooling(&request.message) { + Some(FAST_CHAT_TOOL_SURFACE_LOCAL_WORKSPACE) + } else { + Some(FAST_CHAT_TOOL_SURFACE_DIRECT_ANSWER) + } +} + +fn merge_runtime_turn_tool_surface_metadata( + request_metadata: Option, + tool_surface_mode: Option<&str>, +) -> Option { + let Some(tool_surface_mode) = tool_surface_mode + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + return request_metadata; + }; + + let mut root = match request_metadata { + Some(serde_json::Value::Object(object)) => object, + Some(_) | None => serde_json::Map::new(), + }; + let runtime_entry = root + .entry(LIME_RUNTIME_METADATA_KEY.to_string()) + .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new())); + let runtime_object = runtime_entry + .as_object_mut() + .expect("lime_runtime metadata should be an object"); + runtime_object.insert( + LIME_RUNTIME_TOOL_SURFACE_KEY.to_string(), + serde_json::Value::String(tool_surface_mode.to_string()), + ); + + Some(serde_json::Value::Object(root)) +} + +fn resolve_turn_execution_profile( + request: &AsterChatRequest, + runtime_chat_mode: RuntimeChatMode, + request_tool_policy: &RequestToolPolicy, + auto_continue_enabled: bool, +) -> TurnExecutionProfile { + let has_images = request + .images + .as_ref() + .is_some_and(|images| !images.is_empty()); + + if has_images + || request.project_id.is_some() + || auto_continue_enabled + || request_tool_policy.effective_web_search + || !matches!(runtime_chat_mode, RuntimeChatMode::General) + || request_metadata_contains_full_runtime_context(request.metadata.as_ref()) + || extract_harness_bool( + request.metadata.as_ref(), + &["allow_model_skills", "allowModelSkills"], + ) + .unwrap_or(false) + { + TurnExecutionProfile::FullRuntime + } else { + TurnExecutionProfile::FastChat + } +} + pub(crate) fn resolve_workspace_id_from_sources( request_workspace_id: Option, session_workspace_id: Option, @@ -481,6 +3008,60 @@ fn should_skip_artifact_document_autopersist( !observation.artifact_paths.is_empty() } +fn request_metadata_has_explicit_artifact_intent( + request_metadata: Option<&serde_json::Value>, +) -> bool { + let Some(root) = request_metadata.and_then(serde_json::Value::as_object) else { + return false; + }; + + if root + .get("artifact") + .and_then(serde_json::Value::as_object) + .is_some_and(|artifact| !artifact.is_empty()) + { + return true; + } + + [ + "artifact_mode", + "artifactMode", + "artifact_stage", + "artifactStage", + "artifact_kind", + "artifactKind", + "artifact_request_id", + "artifactRequestId", + ] + .iter() + .any(|key| { + root.get(*key) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .is_some_and(|value| !value.is_empty()) + }) +} + +fn should_skip_default_fast_chat_artifact_autopersist( + execution_profile: TurnExecutionProfile, + request_metadata: Option<&serde_json::Value>, +) -> bool { + if !matches!(execution_profile, TurnExecutionProfile::FastChat) { + return false; + } + + if request_metadata_has_explicit_artifact_intent(request_metadata) + || extract_harness_string(request_metadata, &["content_id", "contentId"]).is_some() + { + return false; + } + + !matches!( + extract_harness_string(request_metadata, &["session_mode", "sessionMode"]).as_deref(), + Some("general_workbench") + ) +} + fn maybe_persist_artifact_document_after_stream( app: &AppHandle, db: &DbConnection, @@ -491,9 +3072,13 @@ fn maybe_persist_artifact_document_after_stream( workspace_id: &str, thread_id: &str, turn_id: &str, + execution_profile: TurnExecutionProfile, request_metadata: Option<&serde_json::Value>, final_text_output: &str, ) { + if should_skip_default_fast_chat_artifact_autopersist(execution_profile, request_metadata) { + return; + } if !crate::services::artifact_document_service::should_attempt_artifact_document_autopersist( request_metadata, ) { @@ -603,1042 +3188,268 @@ fn maybe_persist_artifact_document_after_stream( } } -async fn execute_aster_chat_request( +fn finalize_runtime_stream_success( app: &AppHandle, - state: &AsterAgentState, db: &DbConnection, - api_key_provider_service: &ApiKeyProviderServiceState, - logs: &LogState, - config_manager: &GlobalConfigManagerState, - mcp_manager: &McpManagerState, - automation_state: &AutomationServiceState, - mut request: AsterChatRequest, -) -> Result<(), String> { - tracing::info!( - "[AsterAgent] 发送流式消息: session={}, event={}", - request.session_id, - request.event_name - ); - - // 确保 Agent 已初始化(使用带数据库的版本,注入 SessionStore) - let is_init = state.is_initialized().await; - tracing::warn!("[AsterAgent] Agent 初始化状态: {}", is_init); - if !is_init { - tracing::warn!("[AsterAgent] Agent 未初始化,开始初始化..."); - state.init_agent_with_db(db).await?; - tracing::warn!("[AsterAgent] Agent 初始化完成"); - } else { - tracing::warn!("[AsterAgent] Agent 已初始化,检查 session_store..."); - // 检查 session_store 是否存在 - let agent_arc = state.get_agent_arc(); - let guard = agent_arc.read().await; - if let Some(agent) = guard.as_ref() { - let has_store = agent.session_store().is_some(); - tracing::warn!("[AsterAgent] session_store 存在: {}", has_store); - } - } - ensure_runtime_support_tools_registered(state, mcp_manager).await?; - let request_session_id = request.session_id.clone(); - let mcp_runtime_prepare_future = async { - let (_start_ok, start_fail) = ensure_lime_mcp_servers_running(db, mcp_manager).await; - let (_mcp_ok, mcp_fail) = inject_mcp_extensions(state, mcp_manager).await; - (start_fail, mcp_fail) - }; - let session_recent_runtime_context_future = - resolve_session_recent_runtime_context(&request_session_id); - - let should_resolve_session_recent_harness_context = extract_harness_string( - request.metadata.as_ref(), - &["theme", "harness_theme", "harnessTheme"], - ) - .is_none() - || extract_harness_string(request.metadata.as_ref(), &["session_mode", "sessionMode"]) - .is_none() - || extract_harness_string(request.metadata.as_ref(), &["gate_key", "gateKey"]).is_none() - || extract_harness_string( - request.metadata.as_ref(), - &["run_title", "runTitle", "title"], - ) - .is_none() - || extract_harness_string(request.metadata.as_ref(), &["content_id", "contentId"]) - .is_none(); - let provider_config_future = - resolve_runtime_request_provider_config(app, db, api_key_provider_service, &request); - let session_recent_harness_context_future = async { - if should_resolve_session_recent_harness_context { - resolve_session_recent_harness_context(&request.session_id).await - } else { - Ok(SessionRecentHarnessContext::default()) - } - }; - let (resolved_provider_config, session_recent_harness_context) = tokio::try_join!( - provider_config_future, - session_recent_harness_context_future - )?; - if let Some(resolved_provider_config) = resolved_provider_config { - request.provider_config = Some(resolved_provider_config); - } - if let Some(provider_config) = request.provider_config.as_mut() { - ensure_provider_runtime_ready(provider_config).await?; - let runtime_tool_call_decision = - enrich_provider_config_with_runtime_tool_strategy(provider_config).await; - tracing::info!( - "[AsterAgent] provider_config 运行时工具策略: provider_id={:?}, provider_name={}, model_name={}, strategy={:?}, toolshim_model={:?}, tools={}, function_calling={}, reasoning={}", - provider_config.provider_id, - provider_config.provider_name, - provider_config.model_name, - runtime_tool_call_decision.strategy, - runtime_tool_call_decision.toolshim_model, - runtime_tool_call_decision.capabilities.tools, - runtime_tool_call_decision.capabilities.function_calling, - runtime_tool_call_decision.capabilities.reasoning - ); - } - normalize_runtime_turn_request_metadata( - &mut request, - session_recent_harness_context.theme.as_deref(), - session_recent_harness_context.session_mode.as_deref(), - session_recent_harness_context.gate_key.as_deref(), - session_recent_harness_context.run_title.as_deref(), - session_recent_harness_context.content_id.as_deref(), - ); - backfill_runtime_access_policies(&mut request); - - // 直接使用前端传递的 session_id - // LimeSessionStore 会在 add_message 时自动创建不存在的 session - // 同时 get_session 也会自动创建不存在的 session - let session_id = &request.session_id; - - let workspace_id = match resolve_runtime_turn_workspace_id(db, &request) { - Ok(workspace_id) => workspace_id, - Err(message) => { - logs.write() - .await - .add("error", &format!("[AsterAgent] {}", message)); - return Err(message); - } - }; - - let manager = WorkspaceManager::new(db.clone()); - let workspace = match manager.get(&workspace_id) { - Ok(Some(workspace)) => workspace, - Ok(None) => { - let message = format!("Workspace 不存在: {workspace_id}"); - logs.write() - .await - .add("error", &format!("[AsterAgent] {}", message)); - return Err(message); - } - Err(error) => { - let message = format!("读取 workspace 失败: {error}"); - logs.write() - .await - .add("error", &format!("[AsterAgent] {}", message)); - return Err(message); - } - }; - let ensured = match ensure_workspace_ready_with_auto_relocate(&manager, &workspace) { - Ok(result) => result, - Err(message) => { - logs.write() - .await - .add("error", &format!("[AsterAgent] {}", message)); - return Err(message); - } - }; - let workspace_root = ensured.root_path.to_string_lossy().to_string(); - let resolved_turn_id = request - .turn_id - .clone() - .unwrap_or_else(|| Uuid::new_v4().to_string()); - request.metadata = prepare_image_skill_launch_request_metadata( - Path::new(&workspace_root), - session_id, - &resolved_turn_id, - request.metadata.as_ref(), - request.images.as_deref(), - ); - request.metadata = prepare_broadcast_skill_launch_request_metadata(request.metadata.as_ref()); - request.metadata = - prepare_resource_search_skill_launch_request_metadata(request.metadata.as_ref()); - request.metadata = prepare_research_skill_launch_request_metadata(request.metadata.as_ref()); - request.metadata = prepare_report_skill_launch_request_metadata(request.metadata.as_ref()); - request.metadata = prepare_deep_search_skill_launch_request_metadata(request.metadata.as_ref()); - request.metadata = prepare_site_search_skill_launch_request_metadata(request.metadata.as_ref()); - request.metadata = prepare_pdf_read_skill_launch_request_metadata(request.metadata.as_ref()); - request.metadata = - prepare_presentation_skill_launch_request_metadata(request.metadata.as_ref()); - request.metadata = prepare_form_skill_launch_request_metadata(request.metadata.as_ref()); - request.metadata = prepare_summary_skill_launch_request_metadata(request.metadata.as_ref()); - request.metadata = prepare_translation_skill_launch_request_metadata(request.metadata.as_ref()); - request.metadata = prepare_analysis_skill_launch_request_metadata(request.metadata.as_ref()); - request.metadata = prepare_typesetting_skill_launch_request_metadata(request.metadata.as_ref()); - request.metadata = prepare_webpage_skill_launch_request_metadata(request.metadata.as_ref()); - request.metadata = prepare_service_scene_launch_request_metadata(request.metadata.as_ref()); - let runtime_config = config_manager.config(); - apply_web_search_runtime_env(&runtime_config); - let auto_continue_config = request - .auto_continue - .clone() - .map(AutoContinuePayload::normalized); - let auto_continue_enabled = auto_continue_config - .as_ref() - .map(|config| config.enabled) - .unwrap_or(false); - if let Some(config) = auto_continue_config - .as_ref() - .filter(|config| config.enabled) - { - tracing::info!( - "[AsterAgent] 自动续写策略已启用: source={:?}, fast_mode={}, continuation_length={}, sensitivity={}", - config.source, - config.fast_mode_enabled, - config.continuation_length, - config.sensitivity - ); - } - - if ensured.repaired { - let warning_message = ensured.warning.unwrap_or_else(|| { - format!( - "检测到工作区目录缺失,已自动创建并继续执行: {}", - workspace_root - ) - }); - logs.write() - .await - .add("warn", &format!("[AsterAgent] {}", warning_message)); - let warning_event = RuntimeAgentEvent::Warning { - code: Some(WORKSPACE_PATH_AUTO_CREATED_WARNING_CODE.to_string()), - message: warning_message, - }; - if let Err(error) = app.emit(&request.event_name, &warning_event) { - tracing::error!("[AsterAgent] 发送工作区自动恢复提醒失败: {}", error); - } - } - - let mut session_state_snapshot = SessionStateSnapshot::from_persisted_metadata( - session_id, - AsterAgentWrapper::get_persisted_session_metadata_sync(db, session_id)?, - ); - - if session_state_snapshot.needs_working_dir_update(&workspace_root) { - tracing::info!( - "[AsterAgent] workspace 变更,自动更新 session working_dir: {} -> {}", - session_state_snapshot.working_dir().unwrap_or_default(), - workspace_root - ); - AsterAgentWrapper::update_session_working_dir_sync(db, session_id, &workspace_root)?; - session_state_snapshot = - session_state_snapshot.with_working_dir(Some(workspace_root.clone())); - } - - let ((start_fail, mcp_fail), session_recent_runtime_context) = tokio::join!( - mcp_runtime_prepare_future, - session_recent_runtime_context_future - ); - - if start_fail > 0 { - tracing::warn!( - "[AsterAgent] 部分 MCP server 自动启动失败 ({} 失败),后续可用工具可能不完整", - start_fail - ); - } - if mcp_fail > 0 { - tracing::warn!( - "[AsterAgent] 部分 MCP extension 注入失败 ({} 失败),Agent 可能无法使用某些 MCP 工具", - mcp_fail - ); - } - - let SessionRecentRuntimeContext { - preferences: session_recent_preferences, - team_selection: session_recent_team_selection, - } = session_recent_runtime_context?; - let runtime_chat_mode = resolve_runtime_chat_mode(request.metadata.as_ref()); - let mode_default_web_search = default_web_search_enabled_for_chat_mode(runtime_chat_mode); - let resolved_request_web_search = resolve_request_web_search_preference_from_sources( - request.web_search, - request.metadata.as_ref(), - session_recent_preferences.as_ref(), - ); - let (request_web_search, request_search_mode) = - apply_browser_requirement_to_request_tool_policy( - request.metadata.as_ref(), - resolved_request_web_search, - request.search_mode, - ); - - // 构建请求级工具策略: - // - web_search=true 默认只表示“允许搜索” - // - 仅显式 search_mode=required 时才强制预搜索 - let request_tool_policy = resolve_request_tool_policy_with_mode( - request_web_search, - request_search_mode, - mode_default_web_search, - ); - tracing::info!( - "[AsterAgent][WebSearchGuard] session={}, chat_mode={:?}, request_web_search={:?}, request_search_mode={:?}, effective_request_web_search={:?}, effective_request_search_mode={:?}, mode_default_web_search={}, effective_web_search={}, search_mode={}", - session_id, - runtime_chat_mode, - request.web_search, - request.search_mode, - request_web_search, - request_search_mode, - mode_default_web_search, - request_tool_policy.effective_web_search, - request_tool_policy.search_mode.as_str() - ); - - let include_context_trace = runtime_config.memory.enabled; - let has_persisted_session = session_state_snapshot.has_persisted_session(); - tracing::info!( - "[AsterAgent] session_state_snapshot={}", - serde_json::to_string(&session_state_snapshot).unwrap_or_else(|_| "{}".to_string()) - ); - let mut turn_input_builder = TurnInputEnvelopeBuilder::new(session_id, workspace_id.as_str()); - turn_input_builder - .set_project_id(request.project_id.clone()) - .set_has_persisted_session(has_persisted_session) - .set_request_tool_policy(Some(TurnRequestToolPolicySnapshot::from( - &request_tool_policy, - ))) - .set_working_dir(Some(workspace_root.clone())) - .set_effective_user_message(request.message.clone()) - .set_include_context_trace(include_context_trace) - .set_approval_policy(request.approval_policy.clone()) - .set_sandbox_policy(request.sandbox_policy.clone()) - .set_turn_context_metadata_from_value(request.metadata.as_ref()); - - // 构建 system_prompt:优先使用项目上下文,其次使用 session 的 system_prompt - // 同时读取会话已持久化的 execution_strategy - let persisted_strategy = - AsterExecutionStrategy::from_db_value(session_state_snapshot.execution_strategy()); - let session_prompt = if let Some(prompt) = session_state_snapshot.system_prompt() { - tracing::debug!( - "[AsterAgent] 找到 session,system_prompt: {:?}", - Some(prompt.len()) - ); - Some(prompt.to_string()) - } else { - if !session_state_snapshot.has_persisted_session() { - tracing::debug!("[AsterAgent] Lime 数据库中未找到 session: {}", session_id); - } - None - }; - - let project_prompt = if let Some(ref project_id) = request.project_id { - match AsterAgentState::build_project_system_prompt(db, project_id) { - Ok(prompt) => { - tracing::info!( - "[AsterAgent] 已加载项目上下文: project_id={}, prompt_len={}", - project_id, - prompt.len() - ); - Some(prompt) - } - Err(e) => { - tracing::warn!( - "[AsterAgent] 加载项目上下文失败: {}, 继续使用 session prompt", - e - ); - None - } - } - } else { - None - }; - - let (resolved_prompt, system_prompt_source) = if let Some(project_prompt) = project_prompt { - (Some(project_prompt), TurnSystemPromptSource::Project) - } else if let Some(session_prompt) = session_prompt { - (Some(session_prompt), TurnSystemPromptSource::Session) - } else if let Some(ref frontend_prompt) = request.system_prompt { - if !frontend_prompt.trim().is_empty() { - tracing::info!( - "[AsterAgent] 使用前端传入的 system_prompt, len={}", - frontend_prompt.len() - ); - ( - Some(frontend_prompt.clone()), - TurnSystemPromptSource::Frontend, - ) - } else { - (None, TurnSystemPromptSource::None) - } - } else { - (None, TurnSystemPromptSource::None) - }; - turn_input_builder.set_base_system_prompt(system_prompt_source, resolved_prompt.clone()); - - let prompt_with_runtime_agents = - merge_system_prompt_with_runtime_agents(resolved_prompt, Some(Path::new(&workspace_root))); - turn_input_builder.apply_prompt_stage( - TurnPromptAugmentationStageKind::RuntimeAgents, - prompt_with_runtime_agents.clone(), - ); - - let prompt_with_memory = merge_system_prompt_with_memory_context( - prompt_with_runtime_agents, - &runtime_config, - MemoryPromptContext::with_working_dir(Path::new(&workspace_root)), - ); - let prompt_with_memory = if runtime_config.memory.enabled { - let prefetch_request = crate::commands::memory_management_cmd::TurnMemoryPrefetchRequest { - session_id: session_id.to_string(), - working_dir: Some(workspace_root.clone()), - user_message: request.message.clone(), - request_metadata: request.metadata.clone(), - max_durable_entries: None, - max_working_chars: None, - }; - - match db.lock() { - Ok(conn) => { - match crate::commands::memory_management_cmd::build_turn_memory_prefetch_result( - &runtime_config, - &conn, - Path::new(&workspace_root), - &prefetch_request, - ) { - Ok(prefetch) => merge_runtime_memory_prefetch_prompt( - prompt_with_memory, - prefetch.prompt.as_deref(), - ), - Err(error) => { - tracing::warn!( - "[AsterAgent] 单回合记忆预取失败,已降级继续: session_id={}, error={}", - session_id, - error - ); - prompt_with_memory - } - } - } - Err(error) => { - tracing::warn!( - "[AsterAgent] 记忆预取无法获取数据库锁,已降级继续: session_id={}, error={}", - session_id, - error - ); - prompt_with_memory - } - } - } else { - prompt_with_memory - }; - turn_input_builder.apply_prompt_stage( - TurnPromptAugmentationStageKind::Memory, - prompt_with_memory.clone(), - ); - - let prompt_with_web_search = - merge_system_prompt_with_web_search(prompt_with_memory, &runtime_config); - turn_input_builder.apply_prompt_stage( - TurnPromptAugmentationStageKind::WebSearch, - prompt_with_web_search.clone(), - ); - - let prompt_with_request_policy = - merge_system_prompt_with_request_tool_policy(prompt_with_web_search, &request_tool_policy); - turn_input_builder.apply_prompt_stage( - TurnPromptAugmentationStageKind::RequestToolPolicy, - prompt_with_request_policy.clone(), - ); - - let prompt_with_artifact = merge_system_prompt_with_artifact_context( - prompt_with_request_policy, - request.metadata.as_ref(), - ); - turn_input_builder.apply_prompt_stage( - TurnPromptAugmentationStageKind::Artifact, - prompt_with_artifact.clone(), - ); - - let prompt_with_image_skill_launch = merge_system_prompt_with_image_skill_launch( - prompt_with_artifact, - request.metadata.as_ref(), - ); - turn_input_builder.apply_prompt_stage( - TurnPromptAugmentationStageKind::ImageSkillLaunch, - prompt_with_image_skill_launch.clone(), - ); - - let prompt_with_cover_skill_launch = merge_system_prompt_with_cover_skill_launch( - prompt_with_image_skill_launch, - request.metadata.as_ref(), - ); - turn_input_builder.apply_prompt_stage( - TurnPromptAugmentationStageKind::CoverSkillLaunch, - prompt_with_cover_skill_launch.clone(), - ); - - let prompt_with_video_skill_launch = merge_system_prompt_with_video_skill_launch( - prompt_with_cover_skill_launch, - request.metadata.as_ref(), - ); - turn_input_builder.apply_prompt_stage( - TurnPromptAugmentationStageKind::VideoSkillLaunch, - prompt_with_video_skill_launch.clone(), - ); - - let prompt_with_broadcast_skill_launch = merge_system_prompt_with_broadcast_skill_launch( - prompt_with_video_skill_launch, - request.metadata.as_ref(), - ); - turn_input_builder.apply_prompt_stage( - TurnPromptAugmentationStageKind::BroadcastSkillLaunch, - prompt_with_broadcast_skill_launch.clone(), - ); - - let prompt_with_resource_search_skill_launch = - merge_system_prompt_with_resource_search_skill_launch( - prompt_with_broadcast_skill_launch, - request.metadata.as_ref(), - ); - turn_input_builder.apply_prompt_stage( - TurnPromptAugmentationStageKind::ResourceSearchSkillLaunch, - prompt_with_resource_search_skill_launch.clone(), - ); - - let prompt_with_research_skill_launch = merge_system_prompt_with_research_skill_launch( - prompt_with_resource_search_skill_launch, - request.metadata.as_ref(), - ); - turn_input_builder.apply_prompt_stage( - TurnPromptAugmentationStageKind::ResearchSkillLaunch, - prompt_with_research_skill_launch.clone(), - ); - - let prompt_with_report_skill_launch = merge_system_prompt_with_report_skill_launch( - prompt_with_research_skill_launch, - request.metadata.as_ref(), - ); - turn_input_builder.apply_prompt_stage( - TurnPromptAugmentationStageKind::ReportSkillLaunch, - prompt_with_report_skill_launch.clone(), - ); - - let prompt_with_deep_search_skill_launch = merge_system_prompt_with_deep_search_skill_launch( - prompt_with_report_skill_launch, - request.metadata.as_ref(), - ); - turn_input_builder.apply_prompt_stage( - TurnPromptAugmentationStageKind::DeepSearchSkillLaunch, - prompt_with_deep_search_skill_launch.clone(), - ); - - let prompt_with_site_search_skill_launch = merge_system_prompt_with_site_search_skill_launch( - prompt_with_deep_search_skill_launch, - request.metadata.as_ref(), - ); - turn_input_builder.apply_prompt_stage( - TurnPromptAugmentationStageKind::SiteSearchSkillLaunch, - prompt_with_site_search_skill_launch.clone(), - ); - - let prompt_with_pdf_read_skill_launch = merge_system_prompt_with_pdf_read_skill_launch( - prompt_with_site_search_skill_launch, - request.metadata.as_ref(), - ); - turn_input_builder.apply_prompt_stage( - TurnPromptAugmentationStageKind::PdfReadSkillLaunch, - prompt_with_pdf_read_skill_launch.clone(), - ); - - let prompt_with_presentation_skill_launch = merge_system_prompt_with_presentation_skill_launch( - prompt_with_pdf_read_skill_launch, - request.metadata.as_ref(), - ); - turn_input_builder.apply_prompt_stage( - TurnPromptAugmentationStageKind::PresentationSkillLaunch, - prompt_with_presentation_skill_launch.clone(), - ); - - let prompt_with_form_skill_launch = merge_system_prompt_with_form_skill_launch( - prompt_with_presentation_skill_launch, - request.metadata.as_ref(), - ); - turn_input_builder.apply_prompt_stage( - TurnPromptAugmentationStageKind::FormSkillLaunch, - prompt_with_form_skill_launch.clone(), - ); - - let prompt_with_summary_skill_launch = merge_system_prompt_with_summary_skill_launch( - prompt_with_form_skill_launch, - request.metadata.as_ref(), - ); - turn_input_builder.apply_prompt_stage( - TurnPromptAugmentationStageKind::SummarySkillLaunch, - prompt_with_summary_skill_launch.clone(), - ); - - let prompt_with_translation_skill_launch = merge_system_prompt_with_translation_skill_launch( - prompt_with_summary_skill_launch, - request.metadata.as_ref(), - ); - turn_input_builder.apply_prompt_stage( - TurnPromptAugmentationStageKind::TranslationSkillLaunch, - prompt_with_translation_skill_launch.clone(), - ); - - let prompt_with_analysis_skill_launch = merge_system_prompt_with_analysis_skill_launch( - prompt_with_translation_skill_launch, - request.metadata.as_ref(), - ); - turn_input_builder.apply_prompt_stage( - TurnPromptAugmentationStageKind::AnalysisSkillLaunch, - prompt_with_analysis_skill_launch.clone(), - ); - - let prompt_with_transcription_skill_launch = - merge_system_prompt_with_transcription_skill_launch( - prompt_with_analysis_skill_launch, - request.metadata.as_ref(), - ); - turn_input_builder.apply_prompt_stage( - TurnPromptAugmentationStageKind::TranscriptionSkillLaunch, - prompt_with_transcription_skill_launch.clone(), - ); - - let prompt_with_url_parse_skill_launch = merge_system_prompt_with_url_parse_skill_launch( - prompt_with_transcription_skill_launch, - request.metadata.as_ref(), - ); - turn_input_builder.apply_prompt_stage( - TurnPromptAugmentationStageKind::UrlParseSkillLaunch, - prompt_with_url_parse_skill_launch.clone(), - ); - - let prompt_with_typesetting_skill_launch = merge_system_prompt_with_typesetting_skill_launch( - prompt_with_url_parse_skill_launch, - request.metadata.as_ref(), - ); - turn_input_builder.apply_prompt_stage( - TurnPromptAugmentationStageKind::TypesettingSkillLaunch, - prompt_with_typesetting_skill_launch.clone(), - ); - - let prompt_with_webpage_skill_launch = merge_system_prompt_with_webpage_skill_launch( - prompt_with_typesetting_skill_launch, - request.metadata.as_ref(), - ); - turn_input_builder.apply_prompt_stage( - TurnPromptAugmentationStageKind::WebpageSkillLaunch, - prompt_with_webpage_skill_launch.clone(), - ); - - let prompt_with_service_skill_launch = merge_system_prompt_with_service_skill_launch( - prompt_with_webpage_skill_launch, - request.metadata.as_ref(), - ); - turn_input_builder.apply_prompt_stage( - TurnPromptAugmentationStageKind::ServiceSkillLaunch, - prompt_with_service_skill_launch.clone(), - ); - - let prompt_with_elicitation = merge_system_prompt_with_elicitation_context( - prompt_with_service_skill_launch, - request.metadata.as_ref(), - ); - turn_input_builder.apply_prompt_stage( - TurnPromptAugmentationStageKind::Elicitation, - prompt_with_elicitation.clone(), - ); - - let prompt_with_team_preference = merge_system_prompt_with_team_preference( - prompt_with_elicitation, - request.metadata.as_ref(), - session_recent_team_selection.as_ref(), - resolve_recent_preference_from_sources( - request.metadata.as_ref(), - &["subagent_mode_enabled", "subagentModeEnabled"], - session_recent_preferences - .as_ref() - .map(|preferences| preferences.subagent), - ) - .unwrap_or(false), - ); - turn_input_builder.apply_prompt_stage( - TurnPromptAugmentationStageKind::TeamPreference, - prompt_with_team_preference.clone(), - ); - - let system_prompt = merge_system_prompt_with_auto_continue( - prompt_with_team_preference, - auto_continue_config.as_ref(), - ); - turn_input_builder.apply_prompt_stage( - TurnPromptAugmentationStageKind::AutoContinue, - system_prompt.clone(), - ); - - let requested_strategy = request.execution_strategy.unwrap_or(persisted_strategy); - let effective_strategy = requested_strategy.effective_for_message(&request.message); - turn_input_builder - .set_requested_execution_strategy(Some(requested_strategy.as_db_value().to_string())) - .set_effective_execution_strategy(Some(effective_strategy.as_db_value().to_string())); - - if let Some(explicit_strategy) = request.execution_strategy { - if has_persisted_session { - if let Err(error) = AsterAgentWrapper::update_session_execution_strategy_sync( - db, - session_id, - explicit_strategy.as_db_value(), - ) { - tracing::warn!( - "[AsterAgent] 更新会话执行策略失败: session={}, strategy={}, error={}", - session_id, - explicit_strategy.as_db_value(), - error - ); - } - } - } - - tracing::info!( - "[AsterAgent] 执行策略: requested={:?}, effective={:?}", - requested_strategy, - effective_strategy - ); - - // 如果提供了 Provider 配置,则配置 Provider - if let Some(provider_config) = &request.provider_config { - tracing::info!( - "[AsterAgent] 收到 provider_config: provider_id={:?}, provider_name={}, model_name={}, has_api_key={}, base_url={:?}", - provider_config.provider_id, - provider_config.provider_name, - provider_config.model_name, - provider_config.api_key.is_some(), - provider_config.base_url - ); - let apply_mode = resolve_provider_config_apply_mode(provider_config); - let config = ProviderConfig { - provider_name: provider_config.provider_name.clone(), - provider_selector: provider_config - .provider_id - .clone() - .or_else(|| Some(provider_config.provider_name.clone())), - model_name: provider_config.model_name.clone(), - api_key: provider_config.api_key.clone(), - base_url: provider_config.base_url.clone(), - credential_uuid: None, - force_responses_api: false, - credential_path: None, - toolshim: matches!( - provider_config.tool_call_strategy, - Some(RuntimeToolCallStrategy::ToolShim) - ), - toolshim_model: provider_config.toolshim_model.clone(), - }; - let provider_selector = provider_config - .provider_id - .as_deref() - .unwrap_or(&provider_config.provider_name); - tracing::info!( - "[AsterAgent] provider_config 应用策略: provider_selector={}, mode={:?}, tool_call_strategy={:?}, toolshim_model={:?}", - provider_selector, - apply_mode, - provider_config.tool_call_strategy, - provider_config.toolshim_model - ); - match apply_mode { - ProviderConfigApplyMode::Direct => { - state.configure_provider(config, session_id, db).await?; - } - ProviderConfigApplyMode::CredentialPool => { - state - .configure_provider_from_pool( - db, - provider_selector, - &provider_config.model_name, - session_id, - ) - .await?; - } - } - persist_session_provider_routing(session_id, provider_selector).await?; - } - - // 检查 Provider 是否已配置 - if !state.is_provider_configured().await { - return Err("Provider 未配置,请先调用 aster_agent_configure_provider".to_string()); - } - maybe_auto_compact_runtime_session_before_turn( + event_name: &str, + timeline_recorder: &Arc>, + run_observation: &Arc>, + runtime_memory_config: &lime_core::config::MemoryConfig, + session_id: &str, + user_message: &str, + workspace_root: &str, + workspace_id: &str, + thread_id: &str, + turn_id: &str, + execution_profile: TurnExecutionProfile, + request_metadata: Option<&serde_json::Value>, + execution: &lime_agent::request_tool_policy::StreamReplyExecution, +) { + maybe_persist_artifact_document_after_stream( app, - state, db, + event_name, + timeline_recorder, + run_observation, + workspace_root, + workspace_id, + thread_id, + turn_id, + execution_profile, + request_metadata, + execution.text_output.as_str(), + ); + spawn_runtime_memory_capture_task( + app, + db, + runtime_memory_config.clone(), session_id, + user_message, + execution.text_output.as_str(), + ); +} + +async fn execute_runtime_stream_attempt( + agent: &Agent, + app: &AppHandle, + db: &DbConnection, + request: &AsterChatRequest, + timeline_recorder: &Arc>, + run_observation: &Arc>, + runtime_memory_config: &lime_core::config::MemoryConfig, + session_id: &str, + workspace_root: &str, + workspace_id: &str, + thread_id: &str, + turn_id: &str, + execution_profile: TurnExecutionProfile, + request_metadata: Option<&serde_json::Value>, + provider_continuation_capability: ProviderContinuationCapability, + session_config: aster::agents::types::SessionConfig, + cancel_token: CancellationToken, + request_tool_policy: &RequestToolPolicy, +) -> Result<(), ReplyAttemptError> { + let execution = stream_reply_once( + agent, + app, &request.event_name, - &workspace.settings, + build_runtime_user_message(&request.message, request.images.as_deref()), + Some(Path::new(workspace_root)), + session_config, + cancel_token, + request_tool_policy, + { + let run_observation = run_observation.clone(); + let app = app.clone(); + let event_name = request.event_name.clone(); + let timeline_recorder = timeline_recorder.clone(); + move |event| { + record_runtime_stream_event( + &run_observation, + &app, + &event_name, + &timeline_recorder, + workspace_root, + request_metadata, + provider_continuation_capability, + event, + ); + } + }, ) .await?; - let effective_provider_config = state.get_provider_config().await; - let provider_routing_snapshot = - effective_provider_config - .as_ref() - .map(|config| TurnProviderRoutingSnapshot { - provider_name: config.provider_name.clone(), - provider_selector: config.provider_selector.clone(), - model_name: config.model_name.clone(), - credential_uuid: config.credential_uuid.clone(), - configured_from_request: request.provider_config.is_some(), - used_inline_api_key: request - .provider_config - .as_ref() - .and_then(|config| config.api_key.as_ref()) - .is_some(), - }); - turn_input_builder.set_provider_routing(provider_routing_snapshot.clone()); - let provider_continuation_capability = effective_provider_config - .as_ref() - .map(|config| config.provider_continuation_capability()) - .unwrap_or(ProviderContinuationCapability::HistoryReplayOnly); - let runtime_memory_config = runtime_config.memory.clone(); - let configured_provider_continuation_state = effective_provider_config - .as_ref() - .map(|config| config.provider_continuation_state()) - .unwrap_or_else(ProviderContinuationState::history_replay_only); - let restored_provider_continuation_state = load_previous_provider_continuation_state( - db, - session_id, - provider_routing_snapshot.as_ref(), - provider_continuation_capability, - ); - let provider_continuation_state = if matches!( - restored_provider_continuation_state, - ProviderContinuationState::HistoryReplayOnly - ) { - configured_provider_continuation_state - } else { - tracing::info!( - "[AsterAgent] 恢复上一条 terminal run 的 provider continuation: session_id={}, kind={}", - session_id, - restored_provider_continuation_state.kind() - ); - restored_provider_continuation_state - }; - turn_input_builder - .set_provider_continuation_capability(provider_continuation_capability) - .set_provider_continuation(provider_continuation_state); - let sandbox_outcome = apply_workspace_sandbox_permissions( - state, - config_manager, - db, - api_key_provider_service, - logs, - mcp_manager, - automation_state, + finalize_runtime_stream_success( app, + db, + &request.event_name, + timeline_recorder, + run_observation, + runtime_memory_config, session_id, - request.metadata.as_ref(), - &workspace_root, - runtime_chat_mode, - requested_strategy, + &request.message, + workspace_root, + workspace_id, + thread_id, + turn_id, + execution_profile, + request_metadata, + &execution, + ); + + Ok(()) +} + +async fn remove_code_execution_extension_if_added( + agent: &Agent, + added_code_execution: &mut bool, + warning_message: &str, +) { + if !*added_code_execution { + return; + } + + if let Err(error) = agent.remove_extension(CODE_EXECUTION_EXTENSION_NAME).await { + tracing::warn!("[AsterAgent] {}: {}", warning_message, error); + } + *added_code_execution = false; +} + +async fn execute_runtime_stream_with_strategy( + agent: &Agent, + app: &AppHandle, + db: &DbConnection, + request: &AsterChatRequest, + timeline_recorder: &Arc>, + run_observation: &Arc>, + runtime_memory_config: &lime_core::config::MemoryConfig, + session_id: &str, + workspace_root: &str, + workspace_id: &str, + thread_id: &str, + turn_id: &str, + execution_profile: TurnExecutionProfile, + request_metadata: Option<&serde_json::Value>, + provider_continuation_capability: ProviderContinuationCapability, + cancel_token: CancellationToken, + request_tool_policy: &RequestToolPolicy, + effective_strategy: AsterExecutionStrategy, + build_session_config: F, +) -> Result<(), String> +where + F: Fn() -> aster::agents::types::SessionConfig, +{ + let mut added_code_execution = false; + if effective_strategy == AsterExecutionStrategy::CodeOrchestrated { + added_code_execution = ensure_code_execution_extension_enabled(agent).await?; + } + + let primary_result = execute_runtime_stream_attempt( + agent, + app, + db, + request, + timeline_recorder, + run_observation, + runtime_memory_config, + session_id, + workspace_root, + workspace_id, + thread_id, + turn_id, + execution_profile, + request_metadata, + provider_continuation_capability, + build_session_config(), + cancel_token.clone(), + request_tool_policy, ) - .await - .map_err(|e| format!("注入 workspace 安全策略失败: {e}"))?; + .await; - match sandbox_outcome { - WorkspaceSandboxApplyOutcome::Applied { sandbox_type } => { - tracing::info!( - "[AsterAgent] 已启用 workspace 本地 sandbox: root={}, type={}", - workspace_root, - sandbox_type - ); - } - WorkspaceSandboxApplyOutcome::DisabledByConfig => { - tracing::info!( - "[AsterAgent] workspace 本地 sandbox 已关闭,继续使用普通执行模式: root={}", - workspace_root - ); - } - WorkspaceSandboxApplyOutcome::UnavailableFallback { - warning_message, - notify_user, - } => { - tracing::warn!( - "[AsterAgent] workspace 本地 sandbox 不可用,已降级为普通执行: root={}, warning={}", - workspace_root, - warning_message - ); - if notify_user { - let warning_event = RuntimeAgentEvent::Warning { - code: Some(WORKSPACE_SANDBOX_FALLBACK_WARNING_CODE.to_string()), - message: warning_message, - }; - if let Err(e) = app.emit(&request.event_name, &warning_event) { - tracing::error!("[AsterAgent] 发送 sandbox 降级提醒失败: {}", e); - } - } - } - } - - let tracker = ExecutionTracker::new(db.clone()); - let cancel_token = state.create_cancel_token(session_id).await; - let auto_continue_metadata = auto_continue_config.clone(); - let request_metadata = request.metadata.clone(); - sync_browser_assist_runtime_hint(session_id, request_metadata.as_ref()).await; - let service_skill_preload = - preload_service_skill_launch_execution(db, request_metadata.as_ref()) - .await - .map_err(|error| format!("站点技能预执行失败: {error}"))?; - let system_prompt = merge_system_prompt_with_service_skill_launch_preload( - system_prompt, - service_skill_preload.as_ref(), - ); - turn_input_builder.apply_prompt_stage( - TurnPromptAugmentationStageKind::ServiceSkillLaunchPreload, - system_prompt.clone(), - ); - let model_skill_tool_enabled = should_enable_model_skill_tool(request_metadata.as_ref()); - let run_observation = Arc::new(Mutex::new(ChatRunObservation::default())); - let run_observation_for_finalize = run_observation.clone(); - - let agent_arc = state.get_agent_arc(); - let runtime_snapshot = { - let guard = agent_arc.read().await; - let agent = guard.as_ref().ok_or("Agent not initialized")?; - match agent.runtime_snapshot(session_id).await { - Ok(snapshot) => Some(snapshot), - Err(error) => { - tracing::warn!( - "[AsterAgent] 提交 turn 前读取 runtime snapshot 失败: session_id={}, error={}", - session_id, - error - ); - None - } - } - }; - let runtime_projection_snapshot = - RuntimeProjectionSnapshot::from_snapshot(session_id, runtime_snapshot.as_ref()); - tracing::info!( - "[AsterAgent] runtime_projection_snapshot={}", - serde_json::to_string(&runtime_projection_snapshot).unwrap_or_else(|_| "{}".to_string()) - ); - let resolved_thread_id = runtime_projection_snapshot - .primary_thread_id() - .map(str::to_string) - .unwrap_or_else(|| session_id.to_string()); - let turn_state = TurnState::new( - session_id, - workspace_id.as_str(), - resolved_thread_id.clone(), - resolved_turn_id.clone(), - requested_strategy.as_db_value(), - effective_strategy.as_db_value(), - TurnRequestToolPolicySnapshot::from(&request_tool_policy), - include_context_trace, - runtime_chat_mode_label(runtime_chat_mode), - ); - tracing::info!( - "[AsterAgent] turn_state={}", - serde_json::to_string(&turn_state).unwrap_or_else(|_| "{}".to_string()) - ); - turn_input_builder - .set_thread_id(turn_state.thread_id.clone()) - .set_turn_id(turn_state.turn_id.clone()); - let turn_input_envelope = turn_input_builder.build(); - let turn_input_diagnostics = turn_input_envelope.diagnostics_snapshot(); - tracing::info!( - "[AsterAgent] turn_input_envelope={}", - serde_json::to_string(&turn_input_diagnostics).unwrap_or_else(|_| "{}".to_string()) - ); - - let mut run_start_metadata = build_chat_run_metadata_base( - &request, - workspace_id.as_str(), - effective_strategy, - &request_tool_policy, - auto_continue_enabled, - auto_continue_metadata.as_ref(), - session_recent_preferences.as_ref(), - ); - if let Ok(session_state_value) = serde_json::to_value(&session_state_snapshot) { - run_start_metadata.insert("session_state".to_string(), session_state_value); - } - if let Ok(runtime_projection_value) = serde_json::to_value(&runtime_projection_snapshot) { - run_start_metadata.insert("runtime_projection".to_string(), runtime_projection_value); - } - if let Ok(turn_state_value) = serde_json::to_value(&turn_state) { - run_start_metadata.insert("turn_state".to_string(), turn_state_value); - } - if let Ok(turn_input_value) = serde_json::to_value(&turn_input_diagnostics) { - run_start_metadata.insert("turn_input".to_string(), turn_input_value); - } - if let Some(preload) = service_skill_preload.as_ref() { - run_start_metadata.insert( - "service_skill_launch_preload".to_string(), - serde_json::json!({ - "executed": true, - "adapter_name": preload.request.adapter_name, - "ok": preload.result.ok, - "error_code": preload.result.error_code, - "saved_content_id": preload - .result - .saved_content - .as_ref() - .map(|content| content.content_id.clone()), - }), - ); - } - let run_start_metadata_for_finalize = run_start_metadata.clone(); - let timeline_recorder = Arc::new(Mutex::new(AgentTimelineRecorder::create( - db.clone(), - turn_state.thread_id.clone(), - turn_state.turn_id.clone(), - request.message.clone(), - )?)); - let workspace_settings = workspace.settings.clone(); - let runtime_status_session_config = { - let mut session_config_builder = SessionConfigBuilder::new(session_id) - .thread_id(turn_state.thread_id.clone()) - .turn_id(turn_state.turn_id.clone()); - let turn_context = merge_turn_context_with_workspace_auto_compaction( - merge_turn_context_with_artifact_output_schema( - turn_input_envelope.turn_context_override(), - request_metadata.as_ref(), - ), - &workspace_settings, - ); - if let Some(turn_context) = turn_context { - session_config_builder = session_config_builder.turn_context(turn_context); - } - session_config_builder.build() - }; - - { - let guard = agent_arc.read().await; - let agent = guard.as_ref().ok_or("Agent not initialized")?; - if let Err(error) = agent - .ensure_runtime_turn_initialized( - &runtime_status_session_config, - Some(request.message.clone()), - ) - .await + let run_result = match primary_result { + Ok(()) => Ok(()), + Err(primary_error) + if effective_strategy == AsterExecutionStrategy::CodeOrchestrated + && should_fallback_to_react_from_code_orchestrated(&primary_error) => { tracing::warn!( - "[AsterAgent] 初始化 runtime turn 失败,后续降级继续: {}", - error + "[AsterAgent] 编排模式执行失败,自动降级到 ReAct: {}", + primary_error.message ); + remove_code_execution_extension_if_added( + agent, + &mut added_code_execution, + "降级前移除 code_execution 扩展失败", + ) + .await; + execute_runtime_stream_attempt( + agent, + app, + db, + request, + timeline_recorder, + run_observation, + runtime_memory_config, + session_id, + workspace_root, + workspace_id, + thread_id, + turn_id, + execution_profile, + request_metadata, + provider_continuation_capability, + build_session_config(), + cancel_token, + request_tool_policy, + ) + .await + .map_err(|fallback_err| fallback_err.message) } + Err(primary_error) => Err(primary_error.message), + }; + + remove_code_execution_extension_if_added( + agent, + &mut added_code_execution, + "移除 code_execution 扩展失败,后续会话可能继续保留编排模式", + ) + .await; + + run_result +} + +async fn prepare_runtime_turn_prelude( + agent: &Agent, + app: &AppHandle, + request: &AsterChatRequest, + timeline_recorder: &Arc>, + workspace_root: &str, + runtime_status_session_config: &aster::agents::types::SessionConfig, + effective_strategy: AsterExecutionStrategy, + request_tool_policy: &RequestToolPolicy, + model_name: Option<&str>, + session_recent_preferences: Option<&lime_agent::SessionExecutionRuntimePreferences>, + service_skill_preload: Option<&ServiceSkillLaunchPreloadExecution>, +) -> Result<(), String> { + if let Err(error) = agent + .ensure_runtime_turn_initialized( + runtime_status_session_config, + Some(request.message.clone()), + ) + .await + { + tracing::warn!( + "[AsterAgent] 初始化 runtime turn 失败,后续降级继续: {}", + error + ); } - // 获取 Agent Arc 并保持 guard 在整个流处理期间存活 - let guard = agent_arc.read().await; - let agent = guard.as_ref().ok_or("Agent not initialized")?; - let (initial_runtime_status, decided_runtime_status) = build_turn_runtime_statuses( - &request, + request, effective_strategy, - &request_tool_policy, - request - .provider_config - .as_ref() - .map(|config| config.model_name.as_str()), - session_recent_preferences.as_ref(), + request_tool_policy, + model_name, + session_recent_preferences, ) .await?; for status in [initial_runtime_status, decided_runtime_status] { @@ -1646,313 +3457,154 @@ async fn execute_aster_chat_request( agent, app, &request.event_name, - &timeline_recorder, - workspace_root.as_str(), - &runtime_status_session_config, + timeline_recorder, + workspace_root, + runtime_status_session_config, status, ) .await; } - if let Some(preload) = service_skill_preload.as_ref() { + + if let Some(preload) = service_skill_preload { emit_service_skill_preload_runtime_events( app, &request.event_name, - &timeline_recorder, - workspace_root.as_str(), + timeline_recorder, + workspace_root, preload, ); } - let resolved_thread_id_for_session = turn_state.thread_id.clone(); - let resolved_turn_id_for_session = turn_state.turn_id.clone(); - let turn_input_envelope_for_session = turn_input_envelope.clone(); - let request_metadata_for_session = request_metadata.clone(); - let build_session_config = || { - let mut session_config_builder = SessionConfigBuilder::new(session_id) - .thread_id(resolved_thread_id_for_session.clone()) - .turn_id(resolved_turn_id_for_session.clone()); - if let Some(prompt) = turn_input_envelope_for_session.system_prompt() { - session_config_builder = session_config_builder.system_prompt(prompt.to_string()); - } - let turn_context = merge_turn_context_with_workspace_auto_compaction( - merge_turn_context_with_artifact_output_schema( - turn_input_envelope_for_session.turn_context_override(), - request_metadata_for_session.as_ref(), - ), - &workspace_settings, - ); - if let Some(turn_context) = turn_context { - session_config_builder = session_config_builder.turn_context(turn_context); - } - session_config_builder = session_config_builder - .include_context_trace(turn_input_envelope_for_session.include_context_trace()); - session_config_builder.build() - }; + Ok(()) +} - lime_agent::tools::set_skill_tool_session_access(session_id, model_skill_tool_enabled); - let final_result = tracker - .with_run_custom( - RunSource::Chat, - Some("agent_runtime_submit_turn".to_string()), - Some(session_id.to_string()), - Some(serde_json::Value::Object(run_start_metadata.clone())), - async { - let mut added_code_execution = false; - if effective_strategy == AsterExecutionStrategy::CodeOrchestrated { - added_code_execution = ensure_code_execution_extension_enabled(agent).await?; - } +async fn with_runtime_turn_session_scope( + state: &AsterAgentState, + session_id: &str, + skill_tool_access_enabled: bool, + run: F, +) -> Result<(), String> +where + F: FnOnce(CancellationToken) -> Fut, + Fut: std::future::Future>, +{ + let cancel_token = state.create_cancel_token(session_id).await; + lime_agent::tools::set_skill_tool_session_access(session_id, skill_tool_access_enabled); - let primary_result = stream_reply_once( - agent, - app, - &request.event_name, - build_runtime_user_message(&request.message, request.images.as_deref()), - Some(Path::new(&workspace_root)), - build_session_config(), - cancel_token.clone(), - &request_tool_policy, - { - let run_observation = run_observation.clone(); - let app = app.clone(); - let event_name = request.event_name.clone(); - let timeline_recorder = timeline_recorder.clone(); - let workspace_root = workspace_root.clone(); - let request_metadata = request_metadata.clone(); - let provider_continuation_capability = provider_continuation_capability; - move |event| { - let mut observation = match run_observation.lock() { - Ok(guard) => guard, - Err(error) => { - tracing::warn!( - "[AsterAgent] run observation lock poisoned,继续复用内部状态" - ); - error.into_inner() - } - }; - observation.record_event( - event, - workspace_root.as_str(), - request_metadata.as_ref(), - provider_continuation_capability, - ); - let mut recorder = match timeline_recorder.lock() { - Ok(guard) => guard, - Err(error) => error.into_inner(), - }; - if let Err(error) = recorder.record_runtime_event( - &app, - &event_name, - event, - workspace_root.as_str(), - ) { - tracing::warn!( - "[AsterAgent] 记录时间线事件失败(已降级继续): {}", - error - ); - } - } - }, - ) - .await; + let result = run(cancel_token).await; - let run_result: Result<(), String> = match primary_result { - Ok(execution) => { - maybe_persist_artifact_document_after_stream( - &app, - db, - &request.event_name, - &timeline_recorder, - &run_observation, - workspace_root.as_str(), - workspace_id.as_str(), - turn_state.thread_id.as_str(), - turn_state.turn_id.as_str(), - request_metadata.as_ref(), - execution.text_output.as_str(), - ); - spawn_runtime_memory_capture_task( - &app, - db, - runtime_memory_config.clone(), - session_id, - &request.message, - execution.text_output.as_str(), - ); - Ok(()) - } - Err(primary_error) - if effective_strategy == AsterExecutionStrategy::CodeOrchestrated - && should_fallback_to_react_from_code_orchestrated(&primary_error) => - { - tracing::warn!( - "[AsterAgent] 编排模式执行失败,自动降级到 ReAct: {}", - primary_error.message - ); - if added_code_execution { - if let Err(e) = - agent.remove_extension(CODE_EXECUTION_EXTENSION_NAME).await - { - tracing::warn!( - "[AsterAgent] 降级前移除 code_execution 扩展失败: {}", - e - ); - } - added_code_execution = false; - } - stream_reply_once( - agent, - &app, - &request.event_name, - build_runtime_user_message( - &request.message, - request.images.as_deref(), - ), - Some(Path::new(&workspace_root)), - build_session_config(), - cancel_token.clone(), - &request_tool_policy, - { - let run_observation = run_observation.clone(); - let app = app.clone(); - let event_name = request.event_name.clone(); - let timeline_recorder = timeline_recorder.clone(); - let workspace_root = workspace_root.clone(); - let request_metadata = request_metadata.clone(); - let provider_continuation_capability = - provider_continuation_capability; - move |event| { - let mut observation = match run_observation.lock() { - Ok(guard) => guard, - Err(error) => { - tracing::warn!( - "[AsterAgent] run observation lock poisoned,继续复用内部状态" - ); - error.into_inner() - } - }; - observation.record_event( - event, - workspace_root.as_str(), - request_metadata.as_ref(), - provider_continuation_capability, - ); - let mut recorder = match timeline_recorder.lock() { - Ok(guard) => guard, - Err(error) => error.into_inner(), - }; - if let Err(error) = recorder.record_runtime_event( - &app, - &event_name, - event, - workspace_root.as_str(), - ) { - tracing::warn!( - "[AsterAgent] 记录时间线事件失败(已降级继续): {}", - error - ); - } - } - }, - ) - .await - .map(|execution| { - maybe_persist_artifact_document_after_stream( - &app, - db, - &request.event_name, - &timeline_recorder, - &run_observation, - workspace_root.as_str(), - workspace_id.as_str(), - turn_state.thread_id.as_str(), - turn_state.turn_id.as_str(), - request_metadata.as_ref(), - execution.text_output.as_str(), - ); - spawn_runtime_memory_capture_task( - &app, - db, - runtime_memory_config.clone(), - session_id, - &request.message, - execution.text_output.as_str(), - ); - }) - .map_err(|fallback_err| fallback_err.message) - } - Err(primary_error) => Err(primary_error.message), - }; - - if added_code_execution { - if let Err(e) = agent.remove_extension(CODE_EXECUTION_EXTENSION_NAME).await { - tracing::warn!( - "[AsterAgent] 移除 code_execution 扩展失败,后续会话可能继续保留编排模式: {}", - e - ); - } - } - - run_result - }, - move |result| { - let observation = match run_observation_for_finalize.lock() { - Ok(guard) => guard.clone(), - Err(error) => { - tracing::warn!( - "[AsterAgent] finalize run metadata 时 observation lock 已 poisoned" - ); - error.into_inner().clone() - } - }; - let metadata = - build_chat_run_finish_metadata(&run_start_metadata_for_finalize, &observation); - - match result { - Ok(_) => RunFinishDecision { - status: lime_core::database::dao::agent_run::AgentRunStatus::Success, - error_code: None, - error_message: None, - metadata: Some(metadata), - }, - Err(err) => RunFinishDecision { - status: lime_core::database::dao::agent_run::AgentRunStatus::Error, - error_code: Some("chat_stream_failed".to_string()), - error_message: Some(err.clone()), - metadata: Some(metadata), - }, - } - }, - ) - .await; lime_agent::tools::clear_skill_tool_session_access(session_id); + state.remove_cancel_token(session_id).await; + result +} - match final_result { +fn record_runtime_stream_event( + run_observation: &Arc>, + app: &AppHandle, + event_name: &str, + timeline_recorder: &Arc>, + workspace_root: &str, + request_metadata: Option<&serde_json::Value>, + provider_continuation_capability: ProviderContinuationCapability, + event: &RuntimeAgentEvent, +) { + let mut observation = match run_observation.lock() { + Ok(guard) => guard, + Err(error) => { + tracing::warn!("[AsterAgent] run observation lock poisoned,继续复用内部状态"); + error.into_inner() + } + }; + observation.record_event( + event, + workspace_root, + request_metadata, + provider_continuation_capability, + ); + let mut recorder = match timeline_recorder.lock() { + Ok(guard) => guard, + Err(error) => error.into_inner(), + }; + if let Err(error) = recorder.record_runtime_event(app, event_name, event, workspace_root) { + tracing::warn!("[AsterAgent] 记录时间线事件失败(已降级继续): {}", error); + } +} + +fn build_runtime_run_finish_decision( + result: &Result<(), String>, + run_start_metadata: &serde_json::Map, + run_observation: &Arc>, +) -> RunFinishDecision { + let observation = match run_observation.lock() { + Ok(guard) => guard.clone(), + Err(error) => { + tracing::warn!("[AsterAgent] finalize run metadata 时 observation lock 已 poisoned"); + error.into_inner().clone() + } + }; + let metadata = build_chat_run_finish_metadata(run_start_metadata, &observation); + + match result { + Ok(_) => RunFinishDecision { + status: lime_core::database::dao::agent_run::AgentRunStatus::Success, + error_code: None, + error_message: None, + metadata: Some(metadata), + }, + Err(error) => RunFinishDecision { + status: lime_core::database::dao::agent_run::AgentRunStatus::Error, + error_code: Some("chat_stream_failed".to_string()), + error_message: Some(error.clone()), + metadata: Some(metadata), + }, + } +} + +async fn finalize_runtime_turn_result( + agent: &Agent, + app: &AppHandle, + db: &DbConnection, + event_name: &str, + timeline_recorder: &Arc>, + workspace_root: &str, + runtime_status_session_config: &aster::agents::types::SessionConfig, + session_id: &str, + result: Result<(), String>, +) -> Result<(), String> { + complete_runtime_status_projection( + agent, + app, + event_name, + timeline_recorder, + workspace_root, + runtime_status_session_config, + ) + .await; + + let terminal_events = { + let mut recorder = match timeline_recorder.lock() { + Ok(guard) => guard, + Err(error) => error.into_inner(), + }; + match result.as_ref() { + Ok(()) => recorder.complete_turn_success(), + Err(error) => recorder.fail_turn(error), + } + }; + if let Err(error) = &terminal_events { + let message = match result.as_ref() { + Ok(()) => "完成 turn 时间线失败", + Err(_) => "记录失败 turn 时间线失败", + }; + tracing::warn!("[AsterAgent] {}(已降级继续): {}", message, error); + } + if let Ok(events) = terminal_events { + emit_runtime_events(app, event_name, events); + } + + match result { Ok(()) => { - complete_runtime_status_projection( - agent, - app, - &request.event_name, - &timeline_recorder, - workspace_root.as_str(), - &runtime_status_session_config, - ) - .await; - let terminal_events = { - let mut recorder = match timeline_recorder.lock() { - Ok(guard) => guard, - Err(error) => error.into_inner(), - }; - recorder.complete_turn_success() - }; - { - if let Err(error) = &terminal_events { - tracing::warn!("[AsterAgent] 完成 turn 时间线失败(已降级继续): {}", error); - } - } - if let Ok(events) = terminal_events { - emit_runtime_events(app, &request.event_name, events); - } - let done_event = resolve_runtime_final_done_event(session_id).await; + let done_event = resolve_runtime_final_done_event(session_id, Some(db)).await; if let RuntimeAgentEvent::FinalDone { usage: Some(ref usage), } = done_event @@ -1964,53 +3616,54 @@ async fn execute_aster_chat_request( ); } } - if let Err(e) = app.emit(&request.event_name, &done_event) { - tracing::error!("[AsterAgent] 发送完成事件失败: {}", e); + if let Err(error) = app.emit(event_name, &done_event) { + tracing::error!("[AsterAgent] 发送完成事件失败: {}", error); } emit_subagent_status_changed_events(app, session_id).await; + Ok(()) } - Err(e) => { - complete_runtime_status_projection( - agent, - app, - &request.event_name, - &timeline_recorder, - workspace_root.as_str(), - &runtime_status_session_config, - ) - .await; - let terminal_events = { - let mut recorder = match timeline_recorder.lock() { - Ok(guard) => guard, - Err(error) => error.into_inner(), - }; - recorder.fail_turn(&e) + Err(error) => { + let error_event = RuntimeAgentEvent::Error { + message: error.clone(), }; - { - if let Err(timeline_error) = &terminal_events { - tracing::warn!( - "[AsterAgent] 记录失败 turn 时间线失败(已降级继续): {}", - timeline_error - ); - } - } - if let Ok(events) = terminal_events { - emit_runtime_events(app, &request.event_name, events); - } - let error_event = RuntimeAgentEvent::Error { message: e.clone() }; - if let Err(emit_err) = app.emit(&request.event_name, &error_event) { - tracing::error!("[AsterAgent] 发送错误事件失败: {}", emit_err); + if let Err(emit_error) = app.emit(event_name, &error_event) { + tracing::error!("[AsterAgent] 发送错误事件失败: {}", emit_error); } emit_subagent_status_changed_events(app, session_id).await; - state.remove_cancel_token(session_id).await; - return Err(e); + Err(error) } } +} - // 清理取消令牌 - state.remove_cancel_token(session_id).await; +async fn execute_aster_chat_request( + app: &AppHandle, + state: &AsterAgentState, + db: &DbConnection, + api_key_provider_service: &ApiKeyProviderServiceState, + logs: &LogState, + config_manager: &GlobalConfigManagerState, + mcp_manager: &McpManagerState, + automation_state: &AutomationServiceState, + request: AsterChatRequest, +) -> Result<(), String> { + tracing::info!( + "[AsterAgent] 发送流式消息: session={}, event={}", + request.session_id, + request.event_name + ); - Ok(()) + execute_runtime_turn_pipeline( + app, + state, + db, + api_key_provider_service, + logs, + config_manager, + mcp_manager, + automation_state, + request, + ) + .await } fn build_queued_turn_preview(message: &str) -> String { @@ -2059,16 +3712,78 @@ fn resolve_runtime_message_usage_from_session( } } -async fn resolve_runtime_message_usage(session_id: &str) -> Option { - let session = read_session(session_id, false, "读取会话 token 统计失败") - .await - .ok()?; - resolve_runtime_message_usage_from_session(&session) +fn resolve_runtime_message_usage_from_persisted_values( + input_tokens: Option, + output_tokens: Option, + cached_input_tokens: Option, + cache_creation_input_tokens: Option, +) -> Option { + match (input_tokens, output_tokens) { + (Some(input_tokens), Some(output_tokens)) if input_tokens >= 0 && output_tokens >= 0 => { + Some(lime_agent::AgentTokenUsage { + input_tokens: input_tokens as u32, + output_tokens: output_tokens as u32, + cached_input_tokens: cached_input_tokens + .filter(|value| *value >= 0) + .map(|value| value as u32), + cache_creation_input_tokens: cache_creation_input_tokens + .filter(|value| *value >= 0) + .map(|value| value as u32), + }) + } + _ => None, + } } -async fn resolve_runtime_final_done_event(session_id: &str) -> RuntimeAgentEvent { +fn resolve_runtime_message_usage_from_persisted_session( + db: &DbConnection, + session_id: &str, +) -> Option { + let conn = db.lock().ok()?; + let usage_row = conn + .query_row( + "SELECT input_tokens, output_tokens, cached_input_tokens, cache_creation_input_tokens + FROM agent_sessions + WHERE id = ?1", + [session_id], + |row| { + Ok(( + row.get::<_, Option>(0)?, + row.get::<_, Option>(1)?, + row.get::<_, Option>(2)?, + row.get::<_, Option>(3)?, + )) + }, + ) + .ok()?; + + resolve_runtime_message_usage_from_persisted_values( + usage_row.0, + usage_row.1, + usage_row.2, + usage_row.3, + ) +} + +async fn resolve_runtime_message_usage( + session_id: &str, + db: Option<&DbConnection>, +) -> Option { + if let Ok(session) = read_session(session_id, false, "读取会话 token 统计失败").await { + if let Some(usage) = resolve_runtime_message_usage_from_session(&session) { + return Some(usage); + } + } + + db.and_then(|value| resolve_runtime_message_usage_from_persisted_session(value, session_id)) +} + +async fn resolve_runtime_final_done_event( + session_id: &str, + db: Option<&DbConnection>, +) -> RuntimeAgentEvent { RuntimeAgentEvent::FinalDone { - usage: resolve_runtime_message_usage(session_id).await, + usage: resolve_runtime_message_usage(session_id, db).await, } } @@ -2209,6 +3924,19 @@ fn emit_context_compaction_skip(app: &AppHandle, event_name: &str, message: &str } } +fn build_runtime_compaction_session_config( + session_id: &str, + thread_id: &str, + turn_id: &str, +) -> aster::agents::types::SessionConfig { + // 压缩控制回合只需要稳定 thread/turn 锚点来写时间线和 session metrics; + // 它不会走常规 turn prompt / tool / turn_context 组包链,避免再造第二份输入真相。 + SessionConfigBuilder::new(session_id) + .thread_id(thread_id.to_string()) + .turn_id(turn_id.to_string()) + .build() +} + async fn should_auto_compact_runtime_session( provider: &dyn aster::providers::base::Provider, session: &aster::session::Session, @@ -2335,10 +4063,11 @@ async fn compact_runtime_session_with_trigger( resolved_turn_id.clone(), "压缩上下文", )?)); - let session_config = SessionConfigBuilder::new(&session_id) - .thread_id(resolved_thread_id) - .turn_id(resolved_turn_id) - .build(); + let session_config = build_runtime_compaction_session_config( + &session_id, + &resolved_thread_id, + &resolved_turn_id, + ); let final_result: Result<(), String> = { let guard = agent_arc.read().await; @@ -2447,7 +4176,7 @@ async fn compact_runtime_session_with_trigger( if let Ok(events) = terminal_events { emit_runtime_events(app, &event_name, events); } - let done_event = resolve_runtime_final_done_event(&session_id).await; + let done_event = resolve_runtime_final_done_event(&session_id, None).await; if let Err(error) = app.emit(&event_name, &done_event) { tracing::error!("[AsterAgent] 发送压缩完成事件失败: {}", error); } @@ -2920,6 +4649,7 @@ pub(crate) fn build_runtime_queue_executor() -> RuntimeQueueExecutor { #[cfg(test)] mod tests { use super::*; + use crate::tests::runtime_test_support::shared_aster_runtime_test_root; use aster::conversation::message::Message; use aster::model::ModelConfig; use aster::providers::base::{Provider, ProviderMetadata, ProviderUsage, Usage}; @@ -2929,6 +4659,7 @@ mod tests { SessionType, }; use async_trait::async_trait; + use chrono::Utc; use lime_core::database::schema::create_tables; use lime_services::aster_session_store::LimeSessionStore; use rmcp::model::Tool; @@ -2948,8 +4679,7 @@ mod tests { let conn = Connection::open_in_memory().expect("创建内存数据库失败"); create_tables(&conn).expect("初始化表结构失败"); - let runtime_root = - std::env::temp_dir().join(format!("lime-runtime-turn-tests-{}", Uuid::new_v4())); + let runtime_root = shared_aster_runtime_test_root(); fs::create_dir_all(&runtime_root).expect("创建 runtime 测试目录失败"); let session_store = Arc::new(LimeSessionStore::new(Arc::new(Mutex::new(conn)))); @@ -2960,6 +4690,32 @@ mod tests { .await; } + fn build_runtime_turn_test_request(message: &str, metadata: Option) -> AsterChatRequest { + AsterChatRequest { + message: message.to_string(), + session_id: "session-test".to_string(), + event_name: "agent_stream".to_string(), + images: None, + provider_config: None, + provider_preference: None, + model_preference: None, + thinking_enabled: None, + approval_policy: None, + sandbox_policy: None, + project_id: None, + workspace_id: "workspace-test".to_string(), + web_search: None, + search_mode: None, + execution_strategy: None, + auto_continue: None, + system_prompt: None, + metadata, + turn_id: None, + queue_if_busy: None, + queued_turn_id: None, + } + } + #[derive(Clone)] struct AutoCompactThresholdTestProvider { context_limit: Option, @@ -3028,6 +4784,199 @@ mod tests { } } + #[test] + fn resolve_turn_execution_profile_should_use_fast_chat_for_plain_general_message() { + let request = build_runtime_turn_test_request( + "你好", + Some(json!({ + "harness": { + "theme": "general", + "chat_mode": "general", + "session_mode": "general_workbench" + } + })), + ); + let policy = lime_agent::resolve_request_tool_policy(Some(false), false); + + assert_eq!( + resolve_turn_execution_profile(&request, RuntimeChatMode::General, &policy, false,), + TurnExecutionProfile::FastChat + ); + } + + #[test] + fn resolve_turn_execution_profile_should_keep_fast_chat_for_default_browser_assist_hint() { + let request = build_runtime_turn_test_request( + "你好", + Some(json!({ + "harness": { + "theme": "general", + "chat_mode": "general", + "browser_assist": { + "enabled": true, + "profile_key": "general_browser_assist", + "auto_launch": true, + "stream_mode": "both" + } + } + })), + ); + let policy = lime_agent::resolve_request_tool_policy(Some(false), false); + + assert_eq!( + resolve_turn_execution_profile(&request, RuntimeChatMode::General, &policy, false,), + TurnExecutionProfile::FastChat + ); + } + + #[test] + fn resolve_turn_execution_profile_should_use_full_runtime_for_service_skill_launch() { + let request = build_runtime_turn_test_request( + "请帮我抓取站点内容", + Some(json!({ + "harness": { + "theme": "general", + "chat_mode": "general", + "service_skill_launch": { + "adapter_name": "github/search" + } + } + })), + ); + let policy = lime_agent::resolve_request_tool_policy(Some(false), false); + + assert_eq!( + resolve_turn_execution_profile(&request, RuntimeChatMode::General, &policy, false,), + TurnExecutionProfile::FullRuntime + ); + } + + #[test] + fn resolve_turn_execution_profile_should_use_full_runtime_for_image_skill_launch_without_model_skill_flag( + ) { + let request = build_runtime_turn_test_request( + "@配图 生成 一张春日咖啡馆插画", + Some(json!({ + "harness": { + "theme": "general", + "chat_mode": "general", + "image_skill_launch": { + "image_task": { + "mode": "generate", + "prompt": "一张春日咖啡馆插画" + } + } + } + })), + ); + let policy = lime_agent::resolve_request_tool_policy(Some(false), false); + + assert_eq!( + resolve_turn_execution_profile(&request, RuntimeChatMode::General, &policy, false,), + TurnExecutionProfile::FullRuntime + ); + } + + #[test] + fn resolve_turn_execution_profile_should_use_full_runtime_for_explicit_web_search() { + let mut request = build_runtime_turn_test_request("帮我搜今天的新闻", None); + request.web_search = Some(true); + let policy = lime_agent::resolve_request_tool_policy(Some(true), false); + + assert_eq!( + resolve_turn_execution_profile(&request, RuntimeChatMode::General, &policy, false,), + TurnExecutionProfile::FullRuntime + ); + } + + #[test] + fn resolve_fast_chat_tool_surface_mode_should_use_direct_answer_for_greeting() { + let request = build_runtime_turn_test_request("你好", None); + let policy = lime_agent::resolve_request_tool_policy(Some(false), false); + + assert_eq!( + resolve_fast_chat_tool_surface_mode(&request, TurnExecutionProfile::FastChat, &policy,), + Some(FAST_CHAT_TOOL_SURFACE_DIRECT_ANSWER) + ); + } + + #[test] + fn resolve_fast_chat_tool_surface_mode_should_use_local_workspace_for_repo_analysis() { + let request = build_runtime_turn_test_request( + "请读取并分析项目 /Users/coso/Documents/dev/js/claudecode", + None, + ); + let policy = lime_agent::resolve_request_tool_policy(Some(false), false); + + assert_eq!( + resolve_fast_chat_tool_surface_mode(&request, TurnExecutionProfile::FastChat, &policy,), + Some(FAST_CHAT_TOOL_SURFACE_LOCAL_WORKSPACE) + ); + } + + #[test] + fn extract_explicit_local_focus_paths_from_message_should_keep_existing_absolute_paths() { + let temp_dir = tempfile::TempDir::new().expect("create temp dir"); + let quoted_dir = temp_dir.path().join("quoted repo"); + let plain_dir = temp_dir.path().join("plain-repo"); + std::fs::create_dir_all("ed_dir).expect("create quoted dir"); + std::fs::create_dir_all(&plain_dir).expect("create plain dir"); + + let message = format!( + "先看 \"{}\",再对比 {}。", + quoted_dir.display(), + plain_dir.display() + ); + + let paths = extract_explicit_local_focus_paths_from_message(&message); + let quoted_expected = quoted_dir + .canonicalize() + .expect("canonicalize quoted dir") + .to_string_lossy() + .to_string(); + let plain_expected = plain_dir + .canonicalize() + .expect("canonicalize plain dir") + .to_string_lossy() + .to_string(); + + assert!(paths.contains("ed_expected)); + assert!(paths.contains(&plain_expected)); + } + + #[test] + fn merge_system_prompt_with_explicit_local_path_focus_should_append_focus_guidance() { + let temp_dir = tempfile::TempDir::new().expect("create temp dir"); + let repo_dir = temp_dir.path().join("claudecode"); + std::fs::create_dir_all(&repo_dir).expect("create repo dir"); + let user_message = format!("请只分析 {}", repo_dir.display()); + + let merged = merge_system_prompt_with_explicit_local_path_focus( + Some("基础系统提示".to_string()), + &user_message, + "/tmp/lime/workspaces/default", + ) + .expect("merged prompt"); + + assert!(merged.contains(TURN_LOCAL_PATH_FOCUS_PROMPT_MARKER)); + assert!(merged.contains(&repo_dir.to_string_lossy().to_string())); + assert!(merged.contains("不要先扫描当前默认工作目录 /tmp/lime/workspaces/default")); + } + + #[test] + fn merge_runtime_turn_tool_surface_metadata_should_inject_runtime_hint() { + let metadata = merge_runtime_turn_tool_surface_metadata(None, Some("direct_answer")) + .expect("should inject metadata"); + + assert_eq!( + metadata + .get(LIME_RUNTIME_METADATA_KEY) + .and_then(|value| value.get(LIME_RUNTIME_TOOL_SURFACE_KEY)) + .and_then(serde_json::Value::as_str), + Some("direct_answer") + ); + } + #[test] fn normalize_runtime_turn_request_metadata_should_enable_artifact_prompt_before_turn_build() { let mut request = AsterChatRequest { @@ -3067,7 +5016,7 @@ mod tests { .expect("raw prompt"); assert!(!raw_prompt.contains("【Artifact 交付策略】")); - normalize_runtime_turn_request_metadata(&mut request, None, None, None, None, None); + normalize_runtime_turn_request_metadata(&mut request, None, None, None, None, None, true); let normalized_metadata = request.metadata.as_ref().expect("normalized metadata"); assert_eq!( @@ -3289,6 +5238,7 @@ mod tests { None, None, Some("content-from-session"), + true, ); let normalized_metadata = request.metadata.as_ref().expect("normalized metadata"); @@ -3356,6 +5306,7 @@ mod tests { None, None, Some("content-from-session"), + true, ); let normalized_metadata = request.metadata.as_ref().expect("normalized metadata"); @@ -3419,6 +5370,7 @@ mod tests { Some("write_mode"), Some("社媒初稿"), Some("content-social-1"), + true, ); let normalized_metadata = request.metadata.as_ref().expect("normalized metadata"); @@ -3668,7 +5620,7 @@ mod tests { .await .expect("写入 usage 失败"); - let event = resolve_runtime_final_done_event(&session.id).await; + let event = resolve_runtime_final_done_event(&session.id, None).await; match event { RuntimeAgentEvent::FinalDone { usage } => { assert_eq!( @@ -3701,7 +5653,7 @@ mod tests { .await .expect("创建测试会话失败"); - let event = resolve_runtime_final_done_event(&session.id).await; + let event = resolve_runtime_final_done_event(&session.id, None).await; match event { RuntimeAgentEvent::FinalDone { usage } => { assert!(usage.is_none(), "未写入 usage 时应返回 None"); @@ -3714,6 +5666,58 @@ mod tests { .expect("清理测试会话失败"); } + #[tokio::test] + async fn resolve_runtime_final_done_event_should_fall_back_to_persisted_session_usage() { + ensure_runtime_turn_test_session_manager().await; + + let conn = Connection::open_in_memory().expect("创建持久化回退数据库失败"); + create_tables(&conn).expect("初始化持久化回退表结构失败"); + let now = Utc::now().to_rfc3339(); + let session_id = format!("persisted-usage-{}", Uuid::new_v4()); + conn.execute( + "INSERT INTO agent_sessions ( + id, model, system_prompt, title, created_at, updated_at, working_dir, + execution_strategy, session_type, user_set_name, extension_data_json, + input_tokens, output_tokens, cached_input_tokens, cache_creation_input_tokens + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)", + rusqlite::params![ + session_id, + "glm-4.5", + Option::::None, + "persisted usage", + now, + now, + ".", + "react", + "user", + false, + "{}", + 321i64, + 123i64, + 222i64, + 18i64 + ], + ) + .expect("写入持久化 usage 会话失败"); + let db = Arc::new(Mutex::new(conn)); + + let event = resolve_runtime_final_done_event(&session_id, Some(&db)).await; + match event { + RuntimeAgentEvent::FinalDone { usage } => { + assert_eq!( + usage.map(|value| ( + value.input_tokens, + value.output_tokens, + value.cached_input_tokens, + value.cache_creation_input_tokens, + )), + Some((321, 123, Some(222), Some(18))) + ); + } + other => panic!("收到意外事件: {:?}", other), + } + } + #[tokio::test] async fn should_auto_compact_runtime_session_when_workspace_pref_enabled_and_context_threshold_exceeded( ) { @@ -3794,6 +5798,64 @@ mod tests { .is_none()); } + #[test] + fn build_runtime_turn_context_snapshot_should_capture_final_turn_context_inputs() { + let metadata = json!({ + "artifact": { + "artifact_mode": "draft", + "artifact_stage": "stage2", + "artifact_kind": "analysis" + }, + "harness": { + "theme": "analysis" + } + }); + let workspace_settings = WorkspaceSettings { + auto_compact: false, + ..WorkspaceSettings::default() + }; + + let snapshot = build_runtime_turn_context_snapshot(Some(&metadata), &workspace_settings); + let snapshot_metadata = + build_runtime_turn_context_metadata_value(&snapshot).expect("snapshot metadata"); + + assert_eq!( + snapshot.output_schema_source, + Some(aster::session::TurnOutputSchemaSource::Turn) + ); + assert!(snapshot.output_schema.is_some()); + assert_eq!( + snapshot_metadata + .get(LIME_RUNTIME_METADATA_KEY) + .and_then(|value| value.get(LIME_RUNTIME_AUTO_COMPACT_KEY)) + .and_then(Value::as_bool), + Some(false) + ); + assert_eq!( + snapshot_metadata + .get("harness") + .and_then(|value| value.get("theme")) + .and_then(Value::as_str), + Some("analysis") + ); + } + + #[test] + fn build_runtime_compaction_session_config_should_keep_minimal_control_turn_context() { + let session_config = build_runtime_compaction_session_config( + "session-compact", + "thread-compact", + "turn-compact", + ); + + assert_eq!(session_config.id, "session-compact"); + assert_eq!(session_config.thread_id.as_deref(), Some("thread-compact")); + assert_eq!(session_config.turn_id.as_deref(), Some("turn-compact")); + assert_eq!(session_config.system_prompt, None); + assert_eq!(session_config.include_context_trace, None); + assert!(session_config.turn_context.is_none()); + } + #[test] fn should_skip_artifact_document_autopersist_when_output_is_empty() { let observation = Arc::new(Mutex::new(ChatRunObservation::default())); @@ -3827,4 +5889,42 @@ mod tests { "内容" )); } + + #[test] + fn should_skip_default_fast_chat_artifact_autopersist_for_plain_chat() { + assert!(should_skip_default_fast_chat_artifact_autopersist( + TurnExecutionProfile::FastChat, + Some(&json!({ + "harness": { + "theme": "general", + "session_mode": "default" + } + })), + )); + } + + #[test] + fn should_not_skip_default_fast_chat_artifact_autopersist_for_workbench_content() { + assert!(!should_skip_default_fast_chat_artifact_autopersist( + TurnExecutionProfile::FastChat, + Some(&json!({ + "harness": { + "theme": "general", + "session_mode": "general_workbench", + "content_id": "content-1" + } + })), + )); + } + + #[test] + fn should_not_skip_default_fast_chat_artifact_autopersist_for_explicit_artifact_request() { + assert!(!should_skip_default_fast_chat_artifact_autopersist( + TurnExecutionProfile::FastChat, + Some(&json!({ + "artifact_mode": "draft", + "artifact_stage": "stage2" + })), + )); + } } diff --git a/src-tauri/src/commands/aster_agent_cmd/tests.rs b/src-tauri/src/commands/aster_agent_cmd/tests.rs index 01833f29a..d3504a879 100644 --- a/src-tauri/src/commands/aster_agent_cmd/tests.rs +++ b/src-tauri/src/commands/aster_agent_cmd/tests.rs @@ -6,9 +6,14 @@ mod tests { }; use crate::commands::aster_agent_cmd::dto::AgentRuntimeActionScope; use crate::commands::aster_agent_cmd::service_skill_launch::build_service_skill_preload_tool_projection; + use crate::commands::aster_agent_cmd::tool_runtime::{ + append_fast_chat_request_tool_policy_session_permissions, + prune_fast_chat_request_tool_policy_tools_from_registry, + }; use crate::services::site_capability_service::{ RunSiteAdapterRequest, SavedSiteAdapterContent, SiteAdapterDefinition, SiteAdapterRunResult, }; + use crate::tests::runtime_test_support::shared_aster_runtime_test_root; use async_trait::async_trait; use base64::{engine::general_purpose::STANDARD, Engine as _}; use lime_agent::request_tool_policy::resolve_request_tool_policy; @@ -16,8 +21,9 @@ mod tests { use regex::Regex; use std::collections::HashSet; use std::ffi::OsString; + use std::fs; use std::path::{Path, PathBuf}; - use std::sync::{Mutex, OnceLock}; + use std::sync::{Arc, Mutex, OnceLock}; use tempfile::TempDir; struct DummyTool { @@ -64,6 +70,36 @@ mod tests { LOCK.get_or_init(|| Mutex::new(())) } + fn collect_rust_files(root: &Path, files: &mut Vec) { + let entries = fs::read_dir(root).expect("应能读取目录"); + for entry in entries { + let path = entry.expect("应能读取目录项").path(); + if path.is_dir() { + collect_rust_files(&path, files); + continue; + } + if path.extension().and_then(|ext| ext.to_str()) == Some("rs") { + files.push(path); + } + } + } + + fn collect_markdown_files(root: &Path, files: &mut Vec) { + let entries = fs::read_dir(root).expect("应能读取目录"); + for entry in entries { + let path = entry.expect("应能读取目录项").path(); + if path.is_dir() { + collect_markdown_files(&path, files); + continue; + } + let extension = path.extension().and_then(|ext| ext.to_str()); + let file_name = path.file_name().and_then(|name| name.to_str()).unwrap_or(""); + if extension == Some("md") || file_name.starts_with("README") { + files.push(path); + } + } + } + struct DurableMemoryEnvGuard { previous: Option, } @@ -687,6 +723,127 @@ mod tests { assert_eq!(deny_rule.conditions, allow_rule.conditions); } + #[test] + fn test_append_fast_chat_request_tool_policy_session_permissions_blocks_web_tools_when_disabled( + ) { + let policy = resolve_request_tool_policy(Some(false), false); + let mut permissions = Vec::new(); + + append_fast_chat_request_tool_policy_session_permissions( + &mut permissions, + "session-fast-chat-1", + TurnExecutionProfile::FastChat, + &policy, + ); + + let web_search_rule = permissions + .iter() + .find(|permission| permission.tool == "WebSearch") + .expect("should add fast chat web search deny rule"); + assert!(!web_search_rule.allowed); + assert_eq!(web_search_rule.priority, 1236); + assert_eq!(web_search_rule.conditions.len(), 1); + assert_eq!( + web_search_rule.conditions[0].field.as_deref(), + Some("session_id") + ); + assert_eq!( + web_search_rule.conditions[0].value, + serde_json::json!("session-fast-chat-1") + ); + assert!(permissions + .iter() + .any(|permission| permission.tool == "WebFetch" && !permission.allowed)); + } + + #[test] + fn test_append_fast_chat_request_tool_policy_session_permissions_only_applies_to_disabled_fast_chat( + ) { + let disabled_policy = resolve_request_tool_policy(Some(false), false); + let allowed_policy = resolve_request_tool_policy(Some(true), false); + let mut permissions = Vec::new(); + + append_fast_chat_request_tool_policy_session_permissions( + &mut permissions, + "session-fast-chat-2", + TurnExecutionProfile::FullRuntime, + &disabled_policy, + ); + append_fast_chat_request_tool_policy_session_permissions( + &mut permissions, + "session-fast-chat-2", + TurnExecutionProfile::FastChat, + &allowed_policy, + ); + + assert!(permissions.is_empty()); + } + + #[test] + fn test_prune_fast_chat_request_tool_policy_tools_from_registry_hides_web_tools_when_disabled() + { + let policy = resolve_request_tool_policy(Some(false), false); + let mut registry = aster::tools::ToolRegistry::new(); + registry.register(Box::new(DummyTool::new( + "WebSearch", + "Web search", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + "WebFetch", + "Web fetch", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + "Read", + "Read file", + serde_json::json!({"type": "object"}), + ))); + + prune_fast_chat_request_tool_policy_tools_from_registry( + &mut registry, + TurnExecutionProfile::FastChat, + &policy, + ); + + assert!(!registry.contains("WebSearch")); + assert!(!registry.contains("WebFetch")); + assert!(registry.contains("Read")); + } + + #[test] + fn test_prune_fast_chat_request_tool_policy_tools_from_registry_keeps_web_tools_for_non_fast_chat_or_enabled_search( + ) { + let disabled_policy = resolve_request_tool_policy(Some(false), false); + let allowed_policy = resolve_request_tool_policy(Some(true), false); + + let mut full_runtime_registry = aster::tools::ToolRegistry::new(); + full_runtime_registry.register(Box::new(DummyTool::new( + "WebSearch", + "Web search", + serde_json::json!({"type": "object"}), + ))); + prune_fast_chat_request_tool_policy_tools_from_registry( + &mut full_runtime_registry, + TurnExecutionProfile::FullRuntime, + &disabled_policy, + ); + assert!(full_runtime_registry.contains("WebSearch")); + + let mut allowed_registry = aster::tools::ToolRegistry::new(); + allowed_registry.register(Box::new(DummyTool::new( + "WebSearch", + "Web search", + serde_json::json!({"type": "object"}), + ))); + prune_fast_chat_request_tool_policy_tools_from_registry( + &mut allowed_registry, + TurnExecutionProfile::FastChat, + &allowed_policy, + ); + assert!(allowed_registry.contains("WebSearch")); + } + #[test] fn test_build_service_skill_launch_run_request_requires_attached_session() { let metadata = serde_json::json!({ @@ -2906,6 +3063,40 @@ mod tests { assert!(session_config.turn_context.is_none()); } + #[test] + fn test_build_runtime_action_session_config_reuses_turn_context_snapshot() { + let metadata = serde_json::json!({ + "artifact": { + "artifact_mode": "draft", + "artifact_stage": "stage2", + "artifact_kind": "analysis" + }, + "harness": { + "theme": "analysis" + } + }); + let session_config = build_runtime_action_session_config( + "session-1", + Some(&metadata), + &lime_core::workspace::WorkspaceSettings::default(), + ); + let turn_context = session_config.turn_context.as_ref().expect("turn context"); + + assert_eq!( + turn_context + .metadata + .get("harness") + .and_then(|value| value.get("theme")) + .and_then(serde_json::Value::as_str), + Some("analysis") + ); + assert!(turn_context.output_schema.is_some()); + assert_eq!( + turn_context.output_schema_source, + Some(aster::session::TurnOutputSchemaSource::Turn) + ); + } + #[test] fn test_build_runtime_action_scope_ignores_blank_values() { let request = AgentRuntimeRespondActionRequest { @@ -5341,9 +5532,8 @@ mod tests { assert!(merged.contains("请先连接并停留在 github.com。")); } - #[test] - fn test_merge_system_prompt_with_service_skill_launch_preload_appends_result_context() { - let execution = ServiceSkillLaunchPreloadExecution { + fn sample_service_skill_launch_preload_execution() -> ServiceSkillLaunchPreloadExecution { + ServiceSkillLaunchPreloadExecution { request: RunSiteAdapterRequest { adapter_name: "github/search".to_string(), args: serde_json::json!({ @@ -5399,7 +5589,106 @@ mod tests { save_skipped_by: None, save_error_message: None, }, - }; + } + } + + #[test] + fn test_runtime_turn_source_keeps_prompt_strategy_entry_order_contract() { + let source = runtime_turn_source(); + let strategy_slice = source_slice( + &source, + "turn_input_builder.set_base_system_prompt(system_prompt_source, resolved_prompt.clone());", + "let requested_strategy = request.execution_strategy.unwrap_or(persisted_strategy);", + ); + + assert_markers_in_order( + strategy_slice, + &[ + "TurnPromptAugmentationStageKind::RuntimeAgents", + "TurnPromptAugmentationStageKind::ExplicitLocalPathFocus", + "build_full_runtime_system_prompt(", + "build_fast_chat_system_prompt(", + ], + ); + } + + #[test] + fn test_runtime_turn_source_keeps_full_runtime_prompt_stage_order_contract() { + let source = runtime_turn_source(); + let full_runtime_slice = source_slice( + &source, + "fn build_full_runtime_system_prompt(", + "fn build_fast_chat_system_prompt(", + ); + + assert_markers_in_order( + full_runtime_slice, + &[ + "TurnPromptAugmentationStageKind::Memory", + "TurnPromptAugmentationStageKind::WebSearch", + "TurnPromptAugmentationStageKind::RequestToolPolicy", + "TurnPromptAugmentationStageKind::Artifact", + "TurnPromptAugmentationStageKind::ImageSkillLaunch", + "TurnPromptAugmentationStageKind::CoverSkillLaunch", + "TurnPromptAugmentationStageKind::VideoSkillLaunch", + "TurnPromptAugmentationStageKind::BroadcastSkillLaunch", + "TurnPromptAugmentationStageKind::ResourceSearchSkillLaunch", + "TurnPromptAugmentationStageKind::ResearchSkillLaunch", + "TurnPromptAugmentationStageKind::ReportSkillLaunch", + "TurnPromptAugmentationStageKind::DeepSearchSkillLaunch", + "TurnPromptAugmentationStageKind::SiteSearchSkillLaunch", + "TurnPromptAugmentationStageKind::PdfReadSkillLaunch", + "TurnPromptAugmentationStageKind::PresentationSkillLaunch", + "TurnPromptAugmentationStageKind::FormSkillLaunch", + "TurnPromptAugmentationStageKind::SummarySkillLaunch", + "TurnPromptAugmentationStageKind::TranslationSkillLaunch", + "TurnPromptAugmentationStageKind::AnalysisSkillLaunch", + "TurnPromptAugmentationStageKind::TranscriptionSkillLaunch", + "TurnPromptAugmentationStageKind::UrlParseSkillLaunch", + "TurnPromptAugmentationStageKind::TypesettingSkillLaunch", + "TurnPromptAugmentationStageKind::WebpageSkillLaunch", + "TurnPromptAugmentationStageKind::ServiceSkillLaunch", + "TurnPromptAugmentationStageKind::Elicitation", + "TurnPromptAugmentationStageKind::TeamPreference", + "TurnPromptAugmentationStageKind::AutoContinue", + ], + ); + } + + #[test] + fn test_runtime_turn_source_keeps_service_skill_preload_as_full_runtime_tail_stage() { + let source = runtime_turn_source(); + let preload_stage_slice = source_slice( + &source, + "fn apply_service_skill_preload_prompt_stage(", + "#[allow(clippy::too_many_arguments)]", + ); + let prepare_slice = source_slice( + &source, + "let service_skill_preload = if matches!(execution_profile, TurnExecutionProfile::FullRuntime) {", + "let runtime_turn_artifacts = build_runtime_turn_artifacts(", + ); + + assert_markers_in_order( + preload_stage_slice, + &[ + "if !matches!(execution_profile, TurnExecutionProfile::FullRuntime) {", + "merge_system_prompt_with_service_skill_launch_preload", + "TurnPromptAugmentationStageKind::ServiceSkillLaunchPreload", + ], + ); + assert_markers_in_order( + prepare_slice, + &[ + "preload_service_skill_launch_execution", + "apply_service_skill_preload_prompt_stage(", + ], + ); + } + + #[test] + fn test_merge_system_prompt_with_service_skill_launch_preload_appends_result_context() { + let execution = sample_service_skill_launch_preload_execution(); let merged = merge_system_prompt_with_service_skill_launch_preload( Some("你是助手".to_string()), @@ -5416,6 +5705,35 @@ mod tests { assert!(merged.contains("\"require_attached_session\":true")); } + fn runtime_turn_source() -> String { + fs::read_to_string( + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("src/commands/aster_agent_cmd/runtime_turn.rs"), + ) + .expect("应能读取 runtime_turn.rs") + } + + fn source_slice<'a>(source: &'a str, start_marker: &str, end_marker: &str) -> &'a str { + let start = source + .find(start_marker) + .unwrap_or_else(|| panic!("未找到起始标记: {start_marker}")); + let end = source[start..] + .find(end_marker) + .map(|offset| start + offset) + .unwrap_or_else(|| panic!("未找到结束标记: {end_marker}")); + &source[start..end] + } + + fn assert_markers_in_order(source: &str, markers: &[&str]) { + let mut cursor = 0usize; + for marker in markers { + let offset = source[cursor..] + .find(marker) + .unwrap_or_else(|| panic!("未按顺序找到标记: {marker}")); + cursor += offset + marker.len(); + } + } + #[test] fn test_merge_system_prompt_with_service_skill_launch_preload_adds_markdown_bundle_translation_contract( ) { @@ -5777,6 +6095,121 @@ mod tests { assert!(regex.is_match("python3 <<'EOF'\nprint('hello')\nEOF")); } + #[test] + fn test_command_layer_raw_execution_surfaces_are_explicitly_bounded() { + let commands_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/commands"); + let crate_root = Path::new(env!("CARGO_MANIFEST_DIR")); + let expected: HashSet = [ + "src/commands/aster_agent_cmd/action_runtime.rs", + "src/commands/persona_cmd.rs", + "src/commands/theme_context_cmd.rs", + ] + .into_iter() + .map(ToString::to_string) + .collect(); + + let mut rust_files = Vec::new(); + collect_rust_files(&commands_root, &mut rust_files); + + let actual: HashSet = rust_files + .into_iter() + .filter_map(|path| { + let content = fs::read_to_string(&path).expect("应能读取源码"); + let relative_path = path + .strip_prefix(crate_root) + .expect("commands 文件应位于 crate 根下") + .to_string_lossy() + .replace('\\', "/"); + if relative_path.ends_with("/tests.rs") { + return None; + } + if content.contains(".reply(") || content.contains("stream_reply_with_policy(") { + Some( + relative_path, + ) + } else { + None + } + }) + .collect(); + + assert_eq!( + actual, expected, + "Tauri 命令层新增了未分类的原始执行旁路,请先完成 current/compat/deprecated 归类" + ); + } + + #[test] + fn test_non_command_rust_raw_execution_surfaces_remain_empty() { + let src_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let crate_root = Path::new(env!("CARGO_MANIFEST_DIR")); + + let mut rust_files = Vec::new(); + collect_rust_files(&src_root, &mut rust_files); + + let actual: HashSet = rust_files + .into_iter() + .filter_map(|path| { + let content = fs::read_to_string(&path).expect("应能读取源码"); + let relative_path = path + .strip_prefix(crate_root) + .expect("src 文件应位于 crate 根下") + .to_string_lossy() + .replace('\\', "/"); + if relative_path.starts_with("src/commands/") || relative_path.ends_with("/tests.rs") + { + return None; + } + if content.contains(".reply(") || content.contains("stream_reply_with_policy(") { + Some(relative_path) + } else { + None + } + }) + .collect(); + + assert!( + actual.is_empty(), + "非命令层 Rust 源码新增了原始执行旁路,请先完成 current/compat/deprecated 归类: {actual:?}" + ); + } + + #[test] + fn test_non_command_readme_raw_execution_examples_are_explicitly_bounded() { + let src_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let crate_root = Path::new(env!("CARGO_MANIFEST_DIR")); + let expected: HashSet = ["src/agent/README.md"] + .into_iter() + .map(ToString::to_string) + .collect(); + + let mut markdown_files = Vec::new(); + collect_markdown_files(&src_root, &mut markdown_files); + + let actual: HashSet = markdown_files + .into_iter() + .filter_map(|path| { + let content = fs::read_to_string(&path).expect("应能读取文档"); + let relative_path = path + .strip_prefix(crate_root) + .expect("markdown 文件应位于 crate 根下") + .to_string_lossy() + .replace('\\', "/"); + if content.contains("agent.reply(") || content.contains("stream_reply_with_policy(") + { + Some(relative_path) + } else { + None + } + }) + .collect(); + + assert_eq!( + actual, expected, + "README/示例面新增了未分类的原始执行示例,请先完成 current/compat/deprecated 归类" + ); + } + #[test] fn test_workspace_default_allowed_tool_names_include_subagent_controls() { let tool_names = crate::agent_tools::catalog::workspace_default_allowed_tool_names( @@ -6659,15 +7092,106 @@ mod tests { &mut guard, registry.clone(), None, + None, ); let tool = guard .get("ToolSearch") .expect("bridge ToolSearch should replace legacy implementation"); assert!(tool.description().contains("统一搜索当前会话工具面")); - assert!(tool - .input_schema()["properties"] - .get("caller") - .is_some()); + assert!(tool.input_schema()["properties"].get("caller").is_some()); + } + + #[test] + fn test_list_current_surface_tool_definitions_includes_agent_tool() { + let db: DbConnection = Arc::new(Mutex::new( + rusqlite::Connection::open_in_memory().expect("open in-memory db"), + )); + { + let conn = db.lock().expect("db lock"); + lime_core::database::schema::create_tables(&conn).expect("create schema"); + } + std::env::set_var( + "LIME_ASTER_ROOT", + shared_aster_runtime_test_root() + .to_string_lossy() + .to_string(), + ); + lime_agent::initialize_aster_runtime(db.clone()).expect("runtime dirs should initialize"); + + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("build tokio runtime"); + runtime.block_on(async { + let state = AsterAgentState::new(); + state + .init_agent_with_db(&db) + .await + .expect("agent state should initialize"); + + let definitions = + super::tool_runtime::list_current_surface_tool_definitions(&state).await; + + assert!(definitions + .iter() + .any(|definition| definition.name == "Agent")); + }); + } + + #[test] + fn test_tool_search_bridge_includes_current_surface_agent_tool() { + let db: DbConnection = Arc::new(Mutex::new( + rusqlite::Connection::open_in_memory().expect("open in-memory db"), + )); + { + let conn = db.lock().expect("db lock"); + lime_core::database::schema::create_tables(&conn).expect("create schema"); + } + std::env::set_var( + "LIME_ASTER_ROOT", + shared_aster_runtime_test_root() + .to_string_lossy() + .to_string(), + ); + lime_agent::initialize_aster_runtime(db.clone()).expect("runtime dirs should initialize"); + + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("build tokio runtime"); + runtime.block_on(async { + let state = AsterAgentState::new(); + state + .init_agent_with_db(&db) + .await + .expect("agent state should initialize"); + + let agent_arc = state.get_agent_arc(); + let guard = agent_arc.read().await; + let agent = guard.as_ref().expect("agent should exist"); + let registry = agent.tool_registry().clone(); + drop(guard); + + let tool = ToolSearchBridgeTool::new(registry, None).with_state(state.clone()); + let result = tool + .execute( + serde_json::json!({ + "query": "agent", + "caller": "assistant", + "include_deferred": true, + "include_schema": false + }), + &ToolContext::new(PathBuf::from(".")), + ) + .await + .expect("ToolSearch should succeed"); + let output = result.output.expect("ToolSearch output"); + let payload: serde_json::Value = + serde_json::from_str(&output).expect("parse ToolSearch output"); + let tools = payload["tools"].as_array().expect("tools should be array"); + + assert!(tools.iter().any(|tool| tool["name"] == "Agent")); + }); } } diff --git a/src-tauri/src/commands/aster_agent_cmd/tool_runtime.rs b/src-tauri/src/commands/aster_agent_cmd/tool_runtime.rs index f450c1f70..57a68a70e 100644 --- a/src-tauri/src/commands/aster_agent_cmd/tool_runtime.rs +++ b/src-tauri/src/commands/aster_agent_cmd/tool_runtime.rs @@ -72,12 +72,107 @@ async fn resolve_agent_registry( Ok((registry_arc, Some(extension_manager))) } +pub(crate) async fn list_current_surface_tool_definitions_from_agent( + agent: &aster::agents::Agent, +) -> Vec { + agent + .list_tools(None) + .await + .into_iter() + .filter(|tool| matches!(tool.name.as_ref(), "Agent" | "StructuredOutput")) + .map(|tool| { + aster::tools::ToolDefinition::new( + tool.name, + tool.description.unwrap_or_default(), + serde_json::Value::Object(tool.input_schema.as_ref().clone()), + ) + }) + .collect() +} + +pub(crate) async fn list_current_surface_tool_definitions( + state: &AsterAgentState, +) -> Vec { + let agent_arc = state.get_agent_arc(); + let guard = agent_arc.read().await; + let Some(agent) = guard.as_ref() else { + return Vec::new(); + }; + + list_current_surface_tool_definitions_from_agent(agent).await +} + fn unregister_named_tools(registry: &mut aster::tools::ToolRegistry, tool_names: &[&str]) { for tool_name in tool_names { registry.unregister(tool_name); } } +const FAST_CHAT_DISABLED_WEB_TOOL_PATTERNS: &[&str] = + &["WebSearch", "web_search", "WebFetch", "web_fetch"]; + +fn build_tool_runtime_session_scoped_permission_conditions( + session_id: &str, +) -> Vec { + let session_id = session_id.trim(); + if session_id.is_empty() { + return Vec::new(); + } + + vec![PermissionCondition { + condition_type: ConditionType::Session, + field: Some("session_id".to_string()), + operator: ConditionOperator::Equals, + value: serde_json::json!(session_id), + validator: None, + description: Some("仅对当前聊天会话生效".to_string()), + }] +} + +fn should_hide_fast_chat_web_tools( + execution_profile: TurnExecutionProfile, + request_tool_policy: &RequestToolPolicy, +) -> bool { + matches!(execution_profile, TurnExecutionProfile::FastChat) + && !request_tool_policy.allows_web_search() +} + +pub(crate) fn append_fast_chat_request_tool_policy_session_permissions( + permissions: &mut Vec, + session_id: &str, + execution_profile: TurnExecutionProfile, + request_tool_policy: &RequestToolPolicy, +) { + if !should_hide_fast_chat_web_tools(execution_profile, request_tool_policy) { + return; + } + + let conditions = build_tool_runtime_session_scoped_permission_conditions(session_id); + for tool_name in FAST_CHAT_DISABLED_WEB_TOOL_PATTERNS { + permissions.push(ToolPermission { + tool: (*tool_name).to_string(), + allowed: false, + priority: 1236, + conditions: conditions.clone(), + parameter_restrictions: Vec::new(), + scope: PermissionScope::Session, + reason: Some("FastChat 未开启联网搜索,隐藏通用网页检索工具".to_string()), + expires_at: None, + metadata: HashMap::new(), + }); + } +} + +pub(crate) fn prune_fast_chat_request_tool_policy_tools_from_registry( + registry: &mut aster::tools::ToolRegistry, + execution_profile: TurnExecutionProfile, + request_tool_policy: &RequestToolPolicy, +) { + if should_hide_fast_chat_web_tools(execution_profile, request_tool_policy) { + unregister_named_tools(registry, FAST_CHAT_DISABLED_WEB_TOOL_PATTERNS); + } +} + fn sync_workspace_mode_native_tool_surface( registry: &mut aster::tools::ToolRegistry, surface: WorkspaceToolSurface, @@ -128,6 +223,8 @@ pub(crate) async fn apply_workspace_sandbox_permissions( request_metadata: Option<&serde_json::Value>, workspace_root: &str, runtime_chat_mode: RuntimeChatMode, + execution_profile: TurnExecutionProfile, + request_tool_policy: &RequestToolPolicy, execution_strategy: AsterExecutionStrategy, ) -> Result { let workspace_root = workspace_root.trim(); @@ -201,6 +298,12 @@ pub(crate) async fn apply_workspace_sandbox_permissions( } append_browser_assist_session_permissions(&mut permissions, session_id, request_metadata); + append_fast_chat_request_tool_policy_session_permissions( + &mut permissions, + session_id, + execution_profile, + request_tool_policy, + ); append_image_skill_launch_session_permissions(&mut permissions, session_id, request_metadata); append_cover_skill_launch_session_permissions(&mut permissions, session_id, request_metadata); append_video_skill_launch_session_permissions(&mut permissions, session_id, request_metadata); @@ -308,6 +411,11 @@ pub(crate) async fn apply_workspace_sandbox_permissions( config_manager.0.clone(), ); workspace_tools::wrap_registry_native_tools_for_workspace_runtime(&mut registry); + prune_fast_chat_request_tool_policy_tools_from_registry( + &mut registry, + execution_profile, + request_tool_policy, + ); prune_image_skill_launch_detour_tools_from_registry(&mut registry, request_metadata); prune_cover_skill_launch_detour_tools_from_registry(&mut registry, request_metadata); prune_video_skill_launch_detour_tools_from_registry(&mut registry, request_metadata); @@ -345,5 +453,6 @@ pub(crate) async fn ensure_runtime_support_tools_registered( #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ImageInput { pub data: String, + #[serde(alias = "mediaType")] pub media_type: String, } diff --git a/src-tauri/src/commands/aster_agent_cmd/tool_runtime/browser_tools.rs b/src-tauri/src/commands/aster_agent_cmd/tool_runtime/browser_tools.rs index 5d3045279..5c3707bee 100644 --- a/src-tauri/src/commands/aster_agent_cmd/tool_runtime/browser_tools.rs +++ b/src-tauri/src/commands/aster_agent_cmd/tool_runtime/browser_tools.rs @@ -560,6 +560,7 @@ pub(crate) async fn ensure_browser_mcp_tools_registered( &mut registry, registry_arc.clone(), extension_manager, + Some(state.clone()), ); Ok(()) } diff --git a/src-tauri/src/commands/aster_agent_cmd/tool_runtime/search_bridge.rs b/src-tauri/src/commands/aster_agent_cmd/tool_runtime/search_bridge.rs index 07259332d..da7456d78 100644 --- a/src-tauri/src/commands/aster_agent_cmd/tool_runtime/search_bridge.rs +++ b/src-tauri/src/commands/aster_agent_cmd/tool_runtime/search_bridge.rs @@ -3,6 +3,7 @@ use super::*; pub(crate) struct ToolSearchBridgeTool { registry: Arc>, extension_manager: Option>, + state: Option, } impl ToolSearchBridgeTool { @@ -13,6 +14,19 @@ impl ToolSearchBridgeTool { Self { registry, extension_manager, + state: None, + } + } + + pub(crate) fn with_state(mut self, state: AsterAgentState) -> Self { + self.state = Some(state); + self + } + + fn with_state_if_some(self, state: Option) -> Self { + match state { + Some(state) => self.with_state(state), + None => self, } } @@ -135,6 +149,29 @@ impl ToolSearchBridgeTool { "未命中任何工具。优先直接调用当前可见的原生工具,或补充更明确的产品域关键词;不要继续用 ToolSearch 反复改写同义词。".to_string(), ] } + + async fn collect_runtime_definitions(&self) -> Vec { + let mut definitions = { + let registry = self.registry.read().await; + registry.get_definitions() + }; + + let Some(state) = self.state.as_ref() else { + return definitions; + }; + + let existing_names = definitions + .iter() + .map(|definition| definition.name.clone()) + .collect::>(); + let current_surface_definitions = super::list_current_surface_tool_definitions(state).await; + definitions.extend( + current_surface_definitions + .into_iter() + .filter(|definition| !existing_names.contains(&definition.name)), + ); + definitions + } } #[async_trait] @@ -204,8 +241,7 @@ impl Tool for ToolSearchBridgeTool { .unwrap_or(10); let select_requested = Self::parse_select_query(&raw_query); - let registry = self.registry.read().await; - let definitions = registry.get_definitions(); + let definitions = self.collect_runtime_definitions().await; let mut scored = definitions .into_iter() @@ -268,8 +304,6 @@ impl Tool for ToolSearchBridgeTool { }) .collect::>(); - drop(registry); - if let Some(extension_manager) = self.extension_manager.as_ref() { let visible_extension_tools = extension_manager .get_prefixed_tools(None) @@ -377,13 +411,13 @@ pub(crate) fn register_tool_search_tool_to_registry( registry: &mut aster::tools::ToolRegistry, registry_arc: Arc>, extension_manager: Option>, + state: Option, ) { // Lime runtime 里的 ToolSearch 事实源是 bridge 实现。 // 这里始终重新注册,确保旧 aster ToolSearch 不会抢占当前 surface。 - registry.register(Box::new(ToolSearchBridgeTool::new( - registry_arc, - extension_manager, - ))); + registry.register(Box::new( + ToolSearchBridgeTool::new(registry_arc, extension_manager).with_state_if_some(state), + )); } pub(crate) async fn ensure_tool_search_tool_registered( @@ -391,6 +425,11 @@ pub(crate) async fn ensure_tool_search_tool_registered( ) -> Result<(), String> { let (registry_arc, extension_manager) = resolve_agent_registry(state).await?; let mut registry = registry_arc.write().await; - register_tool_search_tool_to_registry(&mut registry, registry_arc.clone(), extension_manager); + register_tool_search_tool_to_registry( + &mut registry, + registry_arc.clone(), + extension_manager, + Some(state.clone()), + ); Ok(()) } diff --git a/src-tauri/src/commands/aster_agent_cmd/tool_runtime/workspace_tools.rs b/src-tauri/src/commands/aster_agent_cmd/tool_runtime/workspace_tools.rs index 18521ed62..0753a43a2 100644 --- a/src-tauri/src/commands/aster_agent_cmd/tool_runtime/workspace_tools.rs +++ b/src-tauri/src/commands/aster_agent_cmd/tool_runtime/workspace_tools.rs @@ -145,6 +145,15 @@ impl WorkspaceSandboxedBashTool { } fn format_output(stdout: &str, stderr: &str, exit_code: i32) -> String { + Self::format_output_with_message(stdout, stderr, exit_code, None) + } + + fn format_output_with_message( + stdout: &str, + stderr: &str, + exit_code: i32, + fallback_message: Option<&str>, + ) -> String { let mut output = String::new(); if !stdout.is_empty() { @@ -161,8 +170,12 @@ impl WorkspaceSandboxedBashTool { 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}"); + } } if output.len() <= MAX_OUTPUT_LENGTH { @@ -612,6 +625,17 @@ impl Tool for WorkspaceSandboxedBashTool { .and_then(|v| v.as_str()) .ok_or_else(|| ToolError::invalid_params("Missing required parameter: command"))?; + if let Some(preflight_result) = if cfg!(target_os = "windows") { + aster::tools::powershell_tool::preflight_powershell_read_targets( + command, + &context.working_directory, + ) + } else { + aster::tools::bash::preflight_bash_read_targets(command, &context.working_directory) + } { + return Ok(preflight_result); + } + let background = normalized_params .get("background") .and_then(|v| v.as_bool()) @@ -639,15 +663,36 @@ impl Tool for WorkspaceSandboxedBashTool { .map_err(|_| ToolError::timeout(Duration::from_secs(timeout_secs)))? .map_err(|e| ToolError::execution_failed(format!("sandbox 执行失败: {e}")))?; + let interpretation = if cfg!(target_os = "windows") { + aster::tools::interpret_powershell_command_result( + command, + execution.exit_code, + &execution.stdout, + &execution.stderr, + ) + } else { + aster::tools::interpret_bash_command_result( + command, + execution.exit_code, + &execution.stdout, + &execution.stderr, + ) + }; + let output = append_workspace_bash_summary( - Self::format_output(&execution.stdout, &execution.stderr, execution.exit_code), + Self::format_output_with_message( + &execution.stdout, + &execution.stderr, + execution.exit_code, + interpretation.message.as_deref(), + ), execution.exit_code, execution.stdout.len(), execution.stderr.len(), execution.sandboxed, &format!("{:?}", execution.sandbox_type), ); - if execution.exit_code == 0 { + if interpretation.is_error { let result = ToolResult::success(output) .with_metadata("exit_code", serde_json::json!(execution.exit_code)) .with_metadata("stdout_length", serde_json::json!(execution.stdout.len())) @@ -656,7 +701,8 @@ impl Tool for WorkspaceSandboxedBashTool { .with_metadata( "sandbox_type", serde_json::json!(format!("{:?}", execution.sandbox_type)), - ); + ) + .with_metadata("reported_success", serde_json::json!(false)); Ok(media_cli_bridge::enrich_tool_result_from_media_cli_output( result, &execution.stdout, @@ -671,8 +717,12 @@ impl Tool for WorkspaceSandboxedBashTool { .with_metadata( "sandbox_type", serde_json::json!(format!("{:?}", execution.sandbox_type)), - ) - .with_metadata("reported_success", serde_json::json!(false)); + ); + let result = if execution.exit_code != 0 { + result.with_metadata("reported_success", serde_json::json!(true)) + } else { + result + }; Ok(media_cli_bridge::enrich_tool_result_from_media_cli_output( result, &execution.stdout, @@ -742,6 +792,19 @@ impl Tool for WorkspaceBashTool { let mut normalized_params = normalize_shell_command_params(¶ms); if let Some(object) = normalized_params.as_object_mut() { if let Some(command) = object.get("command").and_then(|value| value.as_str()) { + if let Some(preflight_result) = if cfg!(target_os = "windows") { + aster::tools::powershell_tool::preflight_powershell_read_targets( + command, + &context.working_directory, + ) + } else { + aster::tools::bash::preflight_bash_read_targets( + command, + &context.working_directory, + ) + } { + return Ok(preflight_result); + } object.insert( "command".to_string(), serde_json::Value::String( diff --git a/src-tauri/src/commands/persona_cmd.rs b/src-tauri/src/commands/persona_cmd.rs index bb02456ea..2723fe9fd 100644 --- a/src-tauri/src/commands/persona_cmd.rs +++ b/src-tauri/src/commands/persona_cmd.rs @@ -18,6 +18,7 @@ use serde::{Deserialize, Serialize}; use tauri::State; +use crate::agent::build_auxiliary_session_config; use crate::commands::aster_agent_cmd::ensure_browser_mcp_tools_registered; use crate::database::DbConnection; use crate::models::project_model::{CreatePersonaRequest, Persona, PersonaTemplate, PersonaUpdate}; @@ -355,9 +356,6 @@ pub async fn generate_persona( let cancel_token = agent_state.create_cancel_token(&session_id).await; let user_message = Message::user().with_text(&user_prompt); - let mut session_config_builder = - crate::agent::aster_state::SessionConfigBuilder::new(&session_id) - .include_context_trace(true); let base_runtime_prompt = merge_system_prompt_with_runtime_agents(None, None); let merged_prompt = if let Some(memory_prompt) = build_memory_prompt(&config_manager.config(), MemoryPromptContext::default()) @@ -369,10 +367,7 @@ pub async fn generate_persona( } else { base_runtime_prompt }; - if let Some(prompt) = merged_prompt { - session_config_builder = session_config_builder.system_prompt(prompt); - } - let session_config = session_config_builder.build(); + let session_config = build_auxiliary_session_config(&session_id, merged_prompt, true); // 获取 Agent 引用 let agent_arc = agent_state.get_agent_arc(); diff --git a/src-tauri/src/commands/telemetry_cmd.rs b/src-tauri/src/commands/telemetry_cmd.rs index fad60335b..241dc9305 100644 --- a/src-tauri/src/commands/telemetry_cmd.rs +++ b/src-tauri/src/commands/telemetry_cmd.rs @@ -1,6 +1,8 @@ //! 遥测命令模块 //! -//! 提供请求日志、统计数据和 Token 追踪的 Tauri 命令 +//! 提供请求日志、统计数据和 Token 追踪的 Tauri 命令。 +//! 这些命令只暴露原始 `RequestLog` 与聚合统计,不负责定义 session / thread 的 current 状态真相; +//! 当前线程稳定状态应统一走 `agent_runtime_get_thread_read` 与 `agent_runtime_export_*` 主链。 use crate::telemetry::{ ModelStats, ModelTokenStats, ProviderStats, ProviderTokenStats, RequestLog, RequestLogger, @@ -72,7 +74,10 @@ impl Default for TelemetryState { // ========== 请求日志命令 ========== -/// 获取请求日志列表 +/// 获取请求日志列表。 +/// +/// 这里只返回原始 `RequestLog` 浏览结果,便于排查 provider / model / status / token 等底层事实; +/// 不应把它当成 session / thread current 状态的唯一读取入口。 #[tauri::command] pub async fn get_request_logs( state: tauri::State<'_, TelemetryState>, @@ -118,7 +123,9 @@ pub async fn get_request_logs( Ok(logs) } -/// 获取单个请求日志详情 +/// 获取单个请求日志详情。 +/// +/// 该命令只返回单条原始 request 记录,不承担 thread read、incident 或 evidence 事实源职责。 #[tauri::command] pub async fn get_request_log_detail( state: tauri::State<'_, TelemetryState>, @@ -127,7 +134,9 @@ pub async fn get_request_log_detail( Ok(state.logger.get_by_id(&id)) } -/// 清空请求日志 +/// 清空请求日志。 +/// +/// 这只影响原始 request log 浏览面,不应被解释为会话、线程或 evidence 历史被删除。 #[tauri::command] pub async fn clear_request_logs(state: tauri::State<'_, TelemetryState>) -> Result<(), String> { state.logger.clear(); diff --git a/src-tauri/src/commands/theme_context_cmd.rs b/src-tauri/src/commands/theme_context_cmd.rs index b4af8ca95..69c299648 100644 --- a/src-tauri/src/commands/theme_context_cmd.rs +++ b/src-tauri/src/commands/theme_context_cmd.rs @@ -3,7 +3,7 @@ //! 为左侧栏“搜索上下文”提供真正的后端检索能力, //! 统一复用 Aster Agent + WebSearch 策略,并返回结构化结果。 -use crate::agent::{AsterAgentState, AsterAgentWrapper}; +use crate::agent::{build_auxiliary_session_config, AsterAgentState, AsterAgentWrapper}; use crate::config::GlobalConfigManagerState; use crate::database::DbConnection; use crate::services::memory_profile_prompt_service::{ @@ -15,7 +15,7 @@ use crate::services::workspace_health_service::ensure_workspace_ready_with_auto_ use crate::workspace::WorkspaceManager; use lime_agent::{ merge_system_prompt_with_runtime_agents, resolve_request_tool_policy_with_mode, - stream_reply_with_policy, RequestToolPolicyMode, SessionConfigBuilder, + stream_reply_with_policy, RequestToolPolicyMode, }; use serde::{Deserialize, Serialize}; use std::path::Path; @@ -414,17 +414,11 @@ pub async fn aster_agent_theme_context_search( .as_ref() .ok_or_else(|| "Agent not initialized".to_string())?; - let mut session_config_builder = SessionConfigBuilder::new(&session_id); - session_config_builder = session_config_builder.include_context_trace(false); - if let Some(prompt) = system_prompt { - session_config_builder = session_config_builder.system_prompt(prompt); - } - stream_reply_with_policy( agent, &build_context_search_prompt(&query, request.mode), Some(Path::new(&workspace_root)), - session_config_builder.build(), + build_auxiliary_session_config(&session_id, system_prompt, false), Some(cancel_token.clone()), &request_tool_policy, |_| {}, diff --git a/src-tauri/src/commands/workspace_cmd.rs b/src-tauri/src/commands/workspace_cmd.rs index a51030bc9..b82517232 100644 --- a/src-tauri/src/commands/workspace_cmd.rs +++ b/src-tauri/src/commands/workspace_cmd.rs @@ -20,7 +20,7 @@ use crate::workspace::{ Workspace, WorkspaceManager, WorkspaceSettings, WorkspaceType, WorkspaceUpdate, }; use crate::workspace_support::{ - get_or_create_default_project as load_or_create_default_project, + get_current_default_project, get_or_create_default_project as load_or_create_default_project, get_workspace_projects_root_dir, sanitize_project_dir_name, }; use lime_core::database::lock_db; @@ -246,7 +246,7 @@ pub async fn workspace_get_default( db: State<'_, DbConnection>, ) -> Result, String> { let manager = WorkspaceManager::new(db.inner().clone()); - let workspace = manager.get_default()?; + let workspace = get_current_default_project(&manager)?; Ok(workspace.map(|ws| ws.into())) } @@ -294,7 +294,7 @@ pub async fn workspace_ensure_default_ready( db: State<'_, DbConnection>, ) -> Result, String> { let manager = WorkspaceManager::new(db.inner().clone()); - let Some(workspace) = manager.get_default()? else { + let Some(workspace) = get_current_default_project(&manager)? else { return Ok(None); }; let ensured = ensure_workspace_ready_with_auto_relocate(&manager, &workspace)?; diff --git a/src-tauri/src/dev_bridge.rs b/src-tauri/src/dev_bridge.rs index 1e66052d4..bbb32cf30 100644 --- a/src-tauri/src/dev_bridge.rs +++ b/src-tauri/src/dev_bridge.rs @@ -178,9 +178,10 @@ impl DevBridgeServer { // 直接运行服务器(不使用 graceful_shutdown) // 服务器将持续运行直到应用退出 - tokio::spawn(async move { - if let Err(error) = axum::serve(listener, app).await { - tracing::error!("[DevBridge] 运行失败: {}", error); + tauri::async_runtime::spawn(async move { + match axum::serve(listener, app).await { + Ok(()) => tracing::warn!("[DevBridge] 服务循环已退出"), + Err(error) => tracing::error!("[DevBridge] 运行失败: {}", error), } }); diff --git a/src-tauri/src/dev_bridge/dispatcher/workspace/queries.rs b/src-tauri/src/dev_bridge/dispatcher/workspace/queries.rs index a2be0676a..2783bd433 100644 --- a/src-tauri/src/dev_bridge/dispatcher/workspace/queries.rs +++ b/src-tauri/src/dev_bridge/dispatcher/workspace/queries.rs @@ -2,7 +2,9 @@ use super::{ args_or_default, get_string_arg, workspace_manager, DynError, PathBuf, WorkspaceListItem, }; use crate::dev_bridge::DevBridgeState; -use crate::workspace_support::{get_workspace_projects_root_dir, sanitize_project_dir_name}; +use crate::workspace_support::{ + get_current_default_project, get_workspace_projects_root_dir, sanitize_project_dir_name, +}; use serde_json::Value as JsonValue; pub(super) fn try_handle( @@ -28,7 +30,9 @@ pub(super) fn try_handle( } "workspace_get_default" => { let manager = workspace_manager(state)?; - serde_json::to_value(manager.get_default()?.map(WorkspaceListItem::from))? + serde_json::to_value( + get_current_default_project(&manager)?.map(WorkspaceListItem::from), + )? } "workspace_get_by_path" => { let args = args_or_default(args); diff --git a/src-tauri/src/dev_bridge/dispatcher/workspace/readiness.rs b/src-tauri/src/dev_bridge/dispatcher/workspace/readiness.rs index 7f82c40e5..9ba631d49 100644 --- a/src-tauri/src/dev_bridge/dispatcher/workspace/readiness.rs +++ b/src-tauri/src/dev_bridge/dispatcher/workspace/readiness.rs @@ -3,6 +3,7 @@ use super::{ get_string_arg, workspace_manager, DynError, }; use crate::dev_bridge::DevBridgeState; +use crate::workspace_support::get_current_default_project; use serde_json::Value as JsonValue; pub(super) fn try_handle( @@ -26,7 +27,7 @@ pub(super) fn try_handle( } "workspace_ensure_default_ready" => { let manager = workspace_manager(state)?; - let Some(workspace) = manager.get_default()? else { + let Some(workspace) = get_current_default_project(&manager)? else { return Ok(Some(serde_json::json!(null))); }; let result = build_ensure_result( diff --git a/src-tauri/src/services/artifact_request_metadata_service.rs b/src-tauri/src/services/artifact_request_metadata_service.rs index 1a7f155ff..1f163e142 100644 --- a/src-tauri/src/services/artifact_request_metadata_service.rs +++ b/src-tauri/src/services/artifact_request_metadata_service.rs @@ -221,6 +221,19 @@ fn backfill_harness_string_if_missing( request_metadata } +#[derive(Debug, Clone, Copy)] +pub struct ArtifactRequestMetadataNormalizationOptions { + pub enable_artifact_defaults: bool, +} + +impl Default for ArtifactRequestMetadataNormalizationOptions { + fn default() -> Self { + Self { + enable_artifact_defaults: true, + } + } +} + pub fn normalize_request_metadata_with_artifact_defaults( request_metadata: Option, theme_fallback: Option<&str>, @@ -228,6 +241,26 @@ pub fn normalize_request_metadata_with_artifact_defaults( gate_key_fallback: Option<&str>, run_title_fallback: Option<&str>, content_id_fallback: Option<&str>, +) -> Option { + normalize_request_metadata_with_artifact_options( + request_metadata, + theme_fallback, + session_mode_fallback, + gate_key_fallback, + run_title_fallback, + content_id_fallback, + ArtifactRequestMetadataNormalizationOptions::default(), + ) +} + +pub fn normalize_request_metadata_with_artifact_options( + request_metadata: Option, + theme_fallback: Option<&str>, + session_mode_fallback: Option<&str>, + gate_key_fallback: Option<&str>, + run_title_fallback: Option<&str>, + content_id_fallback: Option<&str>, + options: ArtifactRequestMetadataNormalizationOptions, ) -> Option { let request_metadata = request_metadata?; let request_metadata = normalize_harness_session_mode_field(request_metadata); @@ -268,7 +301,8 @@ pub fn normalize_request_metadata_with_artifact_defaults( .as_deref() .and_then(infer_artifact_kind) .map(str::to_string); - let should_enable_draft = should_enable_artifact_draft(request_metadata_ref); + let should_enable_draft = + options.enable_artifact_defaults && should_enable_artifact_draft(request_metadata_ref); let content_id = extract_harness_string(request_metadata_ref, &["content_id", "contentId"]); let mut artifact = extract_existing_artifact_object(request_metadata_ref).unwrap_or_default(); @@ -423,6 +457,38 @@ mod tests { assert!(normalized.get("artifact").is_none()); } + #[test] + fn should_allow_backfill_without_infering_artifact_defaults() { + let metadata = json!({ + "harness": { + "theme": "general", + "session_mode": "general_workbench", + "content_id": "content-1" + } + }); + + let normalized = normalize_request_metadata_with_artifact_options( + Some(metadata), + None, + None, + None, + None, + None, + ArtifactRequestMetadataNormalizationOptions { + enable_artifact_defaults: false, + }, + ) + .expect("normalized metadata"); + + assert_eq!( + normalized + .pointer("/harness/content_id") + .and_then(Value::as_str), + Some("content-1") + ); + assert!(normalized.get("artifact").is_none()); + } + #[test] fn should_preserve_explicit_artifact_and_fill_missing_source_policy() { let metadata = json!({ diff --git a/src-tauri/src/tests/mod.rs b/src-tauri/src/tests/mod.rs index ed02fac65..1a9d4497f 100644 --- a/src-tauri/src/tests/mod.rs +++ b/src-tauri/src/tests/mod.rs @@ -1,2 +1,3 @@ mod credential_tests; mod processor_tests; +pub(crate) mod runtime_test_support; diff --git a/src-tauri/src/tests/runtime_test_support.rs b/src-tauri/src/tests/runtime_test_support.rs new file mode 100644 index 000000000..3a7d028a4 --- /dev/null +++ b/src-tauri/src/tests/runtime_test_support.rs @@ -0,0 +1,16 @@ +use std::fs; +use std::path::PathBuf; +use std::sync::OnceLock; +use uuid::Uuid; + +pub(crate) fn shared_aster_runtime_test_root() -> PathBuf { + static ROOT: OnceLock = OnceLock::new(); + + ROOT.get_or_init(|| { + let root = + std::env::temp_dir().join(format!("lime-aster-runtime-tests-{}", Uuid::new_v4())); + fs::create_dir_all(&root).expect("创建共享 Aster runtime 测试目录失败"); + root + }) + .clone() +} diff --git a/src-tauri/src/workspace_support.rs b/src-tauri/src/workspace_support.rs index a4401cdbd..7604ccfda 100644 --- a/src-tauri/src/workspace_support.rs +++ b/src-tauri/src/workspace_support.rs @@ -2,6 +2,8 @@ use crate::workspace::{Workspace, WorkspaceManager, WorkspaceType}; use lime_core::app_paths; use std::path::PathBuf; +const LEGACY_DEFAULT_WORKSPACE_ID: &str = "workspace-default"; + pub(crate) fn get_workspace_projects_root_dir() -> Result { app_paths::resolve_projects_dir() } @@ -29,20 +31,53 @@ pub(crate) fn sanitize_project_dir_name(name: &str) -> String { } } -pub(crate) fn get_or_create_default_project( - manager: &WorkspaceManager, -) -> Result { - if let Some(workspace) = manager.get_default()? { - return Ok(workspace); +fn is_legacy_default_workspace(workspace: &Workspace) -> bool { + workspace + .id + .trim() + .eq_ignore_ascii_case(LEGACY_DEFAULT_WORKSPACE_ID) +} + +fn create_current_default_project(manager: &WorkspaceManager) -> Result { + let default_project_path = resolve_default_project_path()?; + + if let Some(existing_workspace) = manager.get_by_path(&default_project_path)? { + if !is_legacy_default_workspace(&existing_workspace) { + manager.set_default(&existing_workspace.id)?; + return manager + .get(&existing_workspace.id)? + .ok_or_else(|| "加载默认项目失败".to_string()); + } + + tracing::warn!( + "[Workspace] 当前默认项目路径仍被 legacy workspace 占用: id={}, path={}", + existing_workspace.id, + default_project_path.to_string_lossy() + ); } - let default_project_path = resolve_default_project_path()?; - std::fs::create_dir_all(&default_project_path) - .map_err(|e| format!("创建默认项目目录失败: {e}"))?; + let creation_path = if manager.get_by_path(&default_project_path)?.is_some() { + let file_name = default_project_path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("default"); + default_project_path.with_file_name(format!("{file_name}-current")) + } else { + default_project_path + }; + + std::fs::create_dir_all(&creation_path).map_err(|e| format!("创建默认项目目录失败: {e}"))?; + + if let Some(existing_workspace) = manager.get_by_path(&creation_path)? { + manager.set_default(&existing_workspace.id)?; + return manager + .get(&existing_workspace.id)? + .ok_or_else(|| "加载默认项目失败".to_string()); + } let workspace = manager.create_with_type( "默认项目".to_string(), - default_project_path, + creation_path, WorkspaceType::Persistent, )?; manager.set_default(&workspace.id)?; @@ -52,6 +87,33 @@ pub(crate) fn get_or_create_default_project( .ok_or_else(|| "创建默认项目失败".to_string()) } +pub(crate) fn get_current_default_project( + manager: &WorkspaceManager, +) -> Result, String> { + match manager.get_default()? { + Some(workspace) if !is_legacy_default_workspace(&workspace) => Ok(Some(workspace)), + Some(workspace) => { + tracing::warn!( + "[Workspace] 检测到 legacy 默认项目,自动迁移到 current 路径: id={}, path={}", + workspace.id, + workspace.root_path.to_string_lossy() + ); + Ok(Some(create_current_default_project(manager)?)) + } + None => Ok(None), + } +} + +pub(crate) fn get_or_create_default_project( + manager: &WorkspaceManager, +) -> Result { + if let Some(workspace) = get_current_default_project(manager)? { + return Ok(workspace); + } + + create_current_default_project(manager) +} + #[cfg(test)] mod tests { use super::sanitize_project_dir_name; diff --git a/src-tauri/tauri.conf.headless.json b/src-tauri/tauri.conf.headless.json index 52b0a6d8c..2a5694cec 100644 --- a/src-tauri/tauri.conf.headless.json +++ b/src-tauri/tauri.conf.headless.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Lime", - "version": "1.10.0", + "version": "1.11.0", "identifier": "com.lime.app", "build": { "beforeDevCommand": "npm run dev:web-bridge", diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 5490089dc..2e29add6b 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Lime", - "version": "1.10.0", + "version": "1.11.0", "identifier": "com.lime.app", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/components/AppPageContent.test.tsx b/src/components/AppPageContent.test.tsx index 8585e35f7..85519a380 100644 --- a/src/components/AppPageContent.test.tsx +++ b/src/components/AppPageContent.test.tsx @@ -17,6 +17,12 @@ const latestSkillsWorkspaceProps = vi.hoisted( value: null as Record | null, }) as { value: Record | null }, ); +const latestMemoryPageProps = vi.hoisted( + () => + ({ + value: null as Record | null, + }) as { value: Record | null }, +); vi.mock("./agent/chat", () => ({ AgentChatPage: (props: Record) => { @@ -40,6 +46,13 @@ vi.mock("./skills", () => ({ }, })); +vi.mock("./memory", () => ({ + MemoryPage: (props: Record) => { + latestMemoryPageProps.value = props; + return
    ; + }, +})); + interface MountedContent { container: HTMLDivElement; root: Root; @@ -82,6 +95,7 @@ describe("AppPageContent", () => { vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); latestAgentChatProps.value = null; latestSkillsWorkspaceProps.value = null; + latestMemoryPageProps.value = null; }); afterEach(() => { @@ -247,4 +261,23 @@ describe("AppPageContent", () => { }, }); }); + + it("memory 页面应把记忆页挂进可滚动容器", async () => { + const container = renderContent("memory", { + section: "home", + }); + await flushEffects(); + + const memoryPage = container.querySelector('[data-testid="memory-page"]'); + + expect(memoryPage).not.toBeNull(); + expect(latestMemoryPageProps.value).toMatchObject({ + pageParams: { + section: "home", + }, + }); + expect(memoryPage?.parentElement?.className).toContain("overflow-auto"); + expect(memoryPage?.parentElement?.className).toContain("min-h-0"); + expect(memoryPage?.parentElement?.className).toContain("flex-1"); + }); }); diff --git a/src/components/AppPageContent.tsx b/src/components/AppPageContent.tsx index 9a9bdde46..4ab18dfe6 100644 --- a/src/components/AppPageContent.tsx +++ b/src/components/AppPageContent.tsx @@ -199,10 +199,12 @@ export function AppPageContent({ if (currentPage === "memory") { return (
    - +
    + +
    ); } diff --git a/src/components/agent/chat/AgentChatWorkspace.tsx b/src/components/agent/chat/AgentChatWorkspace.tsx index dcebf41c3..ce02d0463 100644 --- a/src/components/agent/chat/AgentChatWorkspace.tsx +++ b/src/components/agent/chat/AgentChatWorkspace.tsx @@ -99,6 +99,7 @@ import { } from "@/lib/workspace/workbenchContract"; import { isDefaultProjectIdAlias, + isLegacyDefaultProjectId, normalizeProjectId, } from "./utils/topicProjectResolution"; import { isHiddenInternalArtifactPath } from "./utils/internalArtifactVisibility"; @@ -536,7 +537,10 @@ export function AgentChatWorkspace({ ]); useEffect(() => { - if (!isDefaultProjectIdAlias(externalProjectId) || projectId) { + const shouldResolveLegacyDefaultProject = + isDefaultProjectIdAlias(externalProjectId) || + isLegacyDefaultProjectId(externalProjectId); + if (!shouldResolveLegacyDefaultProject || projectId) { return; } @@ -548,6 +552,56 @@ export function AgentChatWorkspace({ void (async () => { try { + const rememberedProjectId = getRememberedProjectId(); + if (rememberedProjectId) { + const rememberedProject = await getProject(rememberedProjectId); + if (rememberedProject && !rememberedProject.isArchived) { + let resolvedRootPath = rememberedProject.rootPath; + + try { + const ensuredWorkspace = await ensureWorkspaceReady( + rememberedProject.id, + ); + resolvedRootPath = ensuredWorkspace.rootPath || resolvedRootPath; + } catch (error) { + logAgentDebug( + "AgentChatPage", + "resolveDefaultProjectAlias.ensureRememberedWorkspaceReadyError", + { + error, + projectId: rememberedProject.id, + }, + { level: "warn" }, + ); + } + + if (cancelled) { + return; + } + + applyProjectSelection(rememberedProject.id); + setProject((current) => + current?.id === rememberedProject.id && + current.rootPath === resolvedRootPath + ? current + : { + ...rememberedProject, + rootPath: resolvedRootPath, + }, + ); + logAgentDebug( + "AgentChatPage", + "resolveDefaultProjectAlias.fromRememberedProject", + { + durationMs: Date.now() - startedAt, + projectId: rememberedProject.id, + rootPath: resolvedRootPath, + }, + ); + return; + } + } + const defaultProject = await getOrCreateDefaultProject(); let resolvedRootPath = defaultProject.rootPath; @@ -609,7 +663,12 @@ export function AgentChatWorkspace({ return () => { cancelled = true; }; - }, [applyProjectSelection, externalProjectId, projectId]); + }, [ + applyProjectSelection, + externalProjectId, + getRememberedProjectId, + projectId, + ]); // 画布状态(支持多种画布类型) const [canvasState, setCanvasState] = useState( diff --git a/src/components/agent/chat/components/AgentRuntimeStrip.test.tsx b/src/components/agent/chat/components/AgentRuntimeStrip.test.tsx new file mode 100644 index 000000000..ff117b41d --- /dev/null +++ b/src/components/agent/chat/components/AgentRuntimeStrip.test.tsx @@ -0,0 +1,134 @@ +import { act, type ComponentProps } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { AgentRuntimeStrip } from "./AgentRuntimeStrip"; +import type { HarnessSessionState } from "../utils/harnessState"; + +const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = []; + +function createHarnessState( + overrides: Partial = {}, +): HarnessSessionState { + return { + runtimeStatus: null, + pendingApprovals: [], + latestContextTrace: [], + plan: { + phase: "idle", + items: [], + }, + activity: { + planning: 0, + filesystem: 0, + execution: 0, + web: 0, + skills: 0, + delegation: 0, + }, + delegatedTasks: [], + outputSignals: [], + activeFileWrites: [], + recentFileEvents: [], + hasSignals: false, + ...overrides, + }; +} + +function renderStrip( + props: Partial> = {}, +) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render( + , + ); + }); + + mountedRoots.push({ root, container }); + return container; +} + +beforeEach(() => { + ( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + } + ).IS_REACT_ACT_ENVIRONMENT = true; +}); + +afterEach(() => { + while (mountedRoots.length > 0) { + const mounted = mountedRoots.pop(); + if (!mounted) { + break; + } + act(() => { + mounted.root.unmount(); + }); + mounted.container.remove(); + } +}); + +describe("AgentRuntimeStrip", () => { + it("runtime tool surface 缺口应显示在运行时条上", () => { + const container = renderStrip(); + + expect( + container.querySelector('[data-testid="agent-runtime-strip"]'), + ).toBeTruthy(); + expect( + container.querySelector( + '[data-testid="agent-runtime-strip-status-runtime_surface"]', + )?.textContent, + ).toContain("Runtime 工具面 4 项"); + expect( + container.querySelector( + '[data-testid="agent-runtime-strip-status-task_runtime_gap"]', + )?.textContent, + ).toContain("任务工具缺 6"); + expect( + container.querySelector( + '[data-testid="agent-runtime-strip-status-subagent_runtime_gap"]', + )?.textContent, + ).toContain("任务拆分缺 5 个 current tools"); + expect( + container.querySelector( + '[data-testid="agent-runtime-strip-status-web_search_gap"]', + )?.textContent, + ).toContain("Runtime 未接通 WebSearch"); + }); +}); diff --git a/src/components/agent/chat/components/AgentRuntimeStrip.tsx b/src/components/agent/chat/components/AgentRuntimeStrip.tsx index 5e90f9caa..8383b6fd8 100644 --- a/src/components/agent/chat/components/AgentRuntimeStrip.tsx +++ b/src/components/agent/chat/components/AgentRuntimeStrip.tsx @@ -9,10 +9,12 @@ import type { import type { ChatToolPreferences } from "../utils/chatToolPreferences"; import type { HarnessSessionState } from "../utils/harnessState"; import { getOutputSchemaRuntimeLabel } from "../utils/sessionExecutionRuntime"; +import type { RuntimeToolAvailability } from "../utils/runtimeToolAvailability"; interface AgentRuntimeStripProps { activeTheme?: string; toolPreferences: ChatToolPreferences; + runtimeToolAvailability?: RuntimeToolAvailability | null; harnessState: HarnessSessionState; childSubagentSessions?: AsterSubagentSessionInfo[]; variant?: "standalone" | "embedded"; @@ -44,6 +46,7 @@ interface StatusItem { export const AgentRuntimeStrip: React.FC = ({ activeTheme, toolPreferences, + runtimeToolAvailability = null, harnessState, childSubagentSessions = [], variant = "standalone", @@ -67,15 +70,23 @@ export const AgentRuntimeStrip: React.FC = ({ { key: "web_search", label: "联网搜索", - enabled: toolPreferences.webSearch, + enabled: + toolPreferences.webSearch && + runtimeToolAvailability?.webSearch !== false, }, { key: "subagent", label: "任务拆分", - enabled: toolPreferences.subagent, + enabled: + toolPreferences.subagent && + runtimeToolAvailability?.subagentRuntime !== false, }, ], - [toolPreferences], + [ + runtimeToolAvailability?.subagentRuntime, + runtimeToolAvailability?.webSearch, + toolPreferences, + ], ); const statusItems = useMemo(() => { @@ -113,6 +124,46 @@ export const AgentRuntimeStrip: React.FC = ({ }); } + if (runtimeToolAvailability?.known) { + nextItems.push({ + key: "runtime_surface", + label: `Runtime 工具面 ${runtimeToolAvailability.availableToolCount} 项`, + tone: "outline", + }); + + if (!runtimeToolAvailability.taskRuntime) { + nextItems.push({ + key: "task_runtime_gap", + label: `任务工具缺 ${runtimeToolAvailability.missingTaskTools.length}`, + tone: "outline", + }); + } + + if (toolPreferences.webSearch && !runtimeToolAvailability.webSearch) { + nextItems.push({ + key: "web_search_gap", + label: "联网搜索偏好已开,但 Runtime 未接通 WebSearch", + tone: "secondary", + }); + } + + if ( + toolPreferences.subagent && + !runtimeToolAvailability.subagentRuntime + ) { + nextItems.push({ + key: "subagent_runtime_gap", + label: `任务拆分缺 ${ + [ + ...runtimeToolAvailability.missingSubagentCoreTools, + ...runtimeToolAvailability.missingSubagentTeamTools, + ].length + } 个 current tools`, + tone: "secondary", + }); + } + } + if (harnessState.plan.phase === "planning") { nextItems.push({ key: "planning", @@ -186,11 +237,15 @@ export const AgentRuntimeStrip: React.FC = ({ harnessState, isExecutionRuntimeActive, isSending, + runtimeToolAvailability, runtimeStatusTitle, + toolPreferences.subagent, + toolPreferences.webSearch, ]); return (
    = ({ ) : null}
    {statusItems.map((item) => ( - + {item.label} ))} diff --git a/src/components/agent/chat/components/AgentTaskRuntimeCard.tsx b/src/components/agent/chat/components/AgentTaskRuntimeCard.tsx new file mode 100644 index 000000000..7328086ee --- /dev/null +++ b/src/components/agent/chat/components/AgentTaskRuntimeCard.tsx @@ -0,0 +1,182 @@ +import React, { useMemo } from "react"; +import { AlertCircle, Clock3, GitBranch, Loader2, Wrench } from "lucide-react"; +import { cn } from "@/lib/utils"; +import type { TokenUsagePromptCacheNotice } from "./TokenUsageDisplay"; +import type { AgentTaskRuntimeCardModel } from "../utils/agentTaskRuntime"; + +interface AgentTaskRuntimeCardProps { + task: AgentTaskRuntimeCardModel; + promptCacheNotice?: TokenUsagePromptCacheNotice | null; +} + +function formatCompact(value: number): string { + return Math.max(0, value).toLocaleString(); +} + +function resolvePromptCacheSummary( + task: AgentTaskRuntimeCardModel, + promptCacheNotice?: TokenUsagePromptCacheNotice | null, +): string | null { + if (!task.usage) { + return null; + } + + const cachedRead = Math.max(0, task.usage.cached_input_tokens ?? 0); + const cacheWrite = Math.max(0, task.usage.cache_creation_input_tokens ?? 0); + + if (cachedRead + cacheWrite > 0) { + return `Prompt Cache ${formatCompact(cachedRead)}/${formatCompact(cacheWrite)}`; + } + + if (promptCacheNotice?.label) { + return `Prompt Cache ${promptCacheNotice.label}`; + } + + return null; +} + +function resolveFrameTone(status: AgentTaskRuntimeCardModel["status"]): string { + switch (status) { + case "failed": + case "aborted": + return "border-rose-200/80 bg-rose-50/75"; + case "waiting_input": + return "border-amber-200/80 bg-amber-50/75"; + case "queued": + return "border-slate-200/80 bg-slate-50"; + case "completed": + return "border-emerald-200/80 bg-emerald-50/75"; + case "running": + default: + return "border-sky-200/80 bg-sky-50/70"; + } +} + +function resolveStatusTone( + status: AgentTaskRuntimeCardModel["status"], +): string { + switch (status) { + case "failed": + case "aborted": + return "border-rose-200 bg-rose-100/80 text-rose-700"; + case "waiting_input": + return "border-amber-200 bg-amber-100/80 text-amber-700"; + case "queued": + return "border-slate-200 bg-white/80 text-slate-600"; + case "completed": + return "border-emerald-200 bg-emerald-100/80 text-emerald-700"; + case "running": + default: + return "border-sky-200 bg-sky-100/80 text-sky-700"; + } +} + +function resolveLeadingIcon(status: AgentTaskRuntimeCardModel["status"]) { + if (status === "running") { + return ; + } + if (status === "failed" || status === "aborted") { + return ; + } + return ; +} + +export const AgentTaskRuntimeCard: React.FC = ({ + task, + promptCacheNotice = null, +}) => { + const promptCacheSummary = useMemo( + () => resolvePromptCacheSummary(task, promptCacheNotice), + [promptCacheNotice, task], + ); + const usageSummary = useMemo(() => { + if (!task.usage) { + return null; + } + return `${formatCompact(task.usage.input_tokens + task.usage.output_tokens)} tokens`; + }, [task.usage]); + const shouldShowAccounting = + Boolean(task.usage) && + task.status !== "running" && + task.status !== "queued" && + task.status !== "waiting_input"; + + return ( +
    + + {resolveLeadingIcon(task.status)} + + + {task.title} + + + {task.statusLabel} + + {task.detail ? ( + + {task.detail} + + ) : null} + {task.batchDescriptor ? ( + + {task.batchDescriptor.countLabel} + + ) : null} + {task.pendingRequestCount > 0 ? ( + + 待补 {task.pendingRequestCount} + + ) : null} + {task.queuedTurnCount > 0 ? ( + + + {task.queuedTurnCount} + + ) : null} + {task.subtaskStats?.total ? ( + + + {task.subtaskStats.active}/{task.subtaskStats.total} + + ) : null} + {shouldShowAccounting && usageSummary ? ( + + {usageSummary} + + ) : null} + {shouldShowAccounting && promptCacheSummary ? ( + + {promptCacheSummary} + + ) : null} +
    + ); +}; + +export default AgentTaskRuntimeCard; diff --git a/src/components/agent/chat/components/AgentThreadMemoryPrefetchPreview.test.tsx b/src/components/agent/chat/components/AgentThreadMemoryPrefetchPreview.test.tsx new file mode 100644 index 000000000..16326753b --- /dev/null +++ b/src/components/agent/chat/components/AgentThreadMemoryPrefetchPreview.test.tsx @@ -0,0 +1,90 @@ +import React from "react"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { AgentThreadMemoryPrefetchPreview } from "./AgentThreadMemoryPrefetchPreview"; + +interface MountedHarness { + container: HTMLDivElement; + root: Root; +} + +const mountedRoots: MountedHarness[] = []; + +function renderPreview( + props: React.ComponentProps, +) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render(); + }); + + mountedRoots.push({ container, root }); + return container; +} + +beforeEach(() => { + ( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + } + ).IS_REACT_ACT_ENVIRONMENT = true; +}); + +afterEach(() => { + while (mountedRoots.length > 0) { + const mounted = mountedRoots.pop(); + if (!mounted) break; + act(() => { + mounted.root.unmount(); + }); + mounted.container.remove(); + } +}); + +describe("AgentThreadMemoryPrefetchPreview", () => { + it("ready 状态应使用 emerald 主色并展示记忆命中预演", () => { + const container = renderPreview({ + status: "ready", + error: null, + result: { + session_id: "session-preview-1", + rules_source_paths: ["/workspace/AGENTS.md"], + working_memory_excerpt: "补上本轮风险摘要。", + durable_memories: [], + team_memory_entries: [], + latest_compaction: null, + prompt: null, + }, + }); + + const panel = container.querySelector( + '[data-testid="agent-thread-reliability-memory-prefetch"]', + ); + expect(panel).not.toBeNull(); + expect(panel?.className).toContain("border-emerald-200"); + expect(panel?.textContent).toContain("记忆命中预演"); + expect(panel?.textContent).toContain("规则 1"); + expect(panel?.textContent).toContain("会话 已命中"); + }); + + it("error 状态应保留 amber 提醒色", () => { + const container = renderPreview({ + status: "error", + error: "记忆预取暂不可用", + result: null, + }); + + const panel = container.querySelector( + '[data-testid="agent-thread-reliability-memory-prefetch"]', + ); + expect(panel).not.toBeNull(); + expect(panel?.className).toContain("border-amber-200"); + expect(panel?.textContent).toContain("暂不可用"); + expect(panel?.textContent).toContain("记忆预取暂不可用"); + }); +}); diff --git a/src/components/agent/chat/components/AgentThreadMemoryPrefetchPreview.tsx b/src/components/agent/chat/components/AgentThreadMemoryPrefetchPreview.tsx index 0cbdeb720..146650c69 100644 --- a/src/components/agent/chat/components/AgentThreadMemoryPrefetchPreview.tsx +++ b/src/components/agent/chat/components/AgentThreadMemoryPrefetchPreview.tsx @@ -15,6 +15,13 @@ interface AgentThreadMemoryPrefetchPreviewProps { className?: string; } +const EMERALD_PANEL_CLASS_NAME = "border-emerald-200 bg-emerald-50/60"; +const EMERALD_TITLE_CLASS_NAME = "text-emerald-900"; +const EMERALD_OUTLINE_BADGE_CLASS_NAME = + "border-emerald-200 bg-white text-emerald-700"; +const SLATE_PANEL_CLASS_NAME = "border-slate-200/80 bg-white"; +const SLATE_TITLE_CLASS_NAME = "text-slate-700"; + const DURABLE_CATEGORY_LABELS: Record = { identity: "风格", context: "参考", @@ -88,11 +95,15 @@ function DetailPanel(props: { children?: ReactNode; }) { return ( -
    -
    {props.title}
    +
    +
    + {props.title} +
    {props.children || ( -
    {props.emptyText}
    +
    + {props.emptyText} +
    )}
    @@ -110,7 +121,9 @@ export function AgentThreadMemoryPrefetchPreview({
    本回合记忆预取 @@ -131,29 +144,31 @@ export function AgentThreadMemoryPrefetchPreview({ className={cn( status === "error" ? "border-amber-300 bg-white text-amber-700" - : "border-sky-300 bg-white text-sky-700", + : EMERALD_OUTLINE_BADGE_CLASS_NAME, )} > {status === "loading" ? "加载中" : status === "ready" - ? "五层记忆预演" + ? "记忆命中预演" : status === "error" ? "暂不可用" : "待预取"}
    - {actions ?
    {actions}
    : null} + {actions ? ( +
    {actions}
    + ) : null}
    {status === "loading" - ? "正在按最新回合 prompt 预演规则 / 工作 / 持久 / 任务影子 / 压缩五层记忆命中。" + ? "正在按最新回合 prompt 预演来源链 / 会话记忆 / 持久记忆 / Team Memory / 会话压缩的命中情况。" : status === "error" ? error : "下面展示的是当前这轮真实会用到的记忆命中预演,不会改写会话,只帮助判断续接质量。"} @@ -162,23 +177,47 @@ export function AgentThreadMemoryPrefetchPreview({ {result ? ( <>
    - - {formatMemoryLayerStatusLabel("规则", result.rules_source_paths.length)} - - + {formatMemoryLayerStatusLabel( - "工作", + "规则", + result.rules_source_paths.length, + )} + + + {formatMemoryLayerStatusLabel( + "会话", null, Boolean(result.working_memory_excerpt), )} - - {formatMemoryLayerStatusLabel("持久", result.durable_memories.length)} + + {formatMemoryLayerStatusLabel( + "持久", + result.durable_memories.length, + )} - - {formatMemoryLayerStatusLabel("任务影子", result.team_memory_entries.length)} + + {formatMemoryLayerStatusLabel( + "Team Memory", + result.team_memory_entries.length, + )} - + {formatMemoryLayerStatusLabel( "压缩", null, @@ -208,7 +247,10 @@ export function AgentThreadMemoryPrefetchPreview({ ) : null} - + {result.working_memory_excerpt ? (
    {truncateText(result.working_memory_excerpt, 320)} @@ -216,7 +258,10 @@ export function AgentThreadMemoryPrefetchPreview({ ) : null} - + {result.durable_memories.length > 0 ? (
    {result.durable_memories.slice(0, 3).map((entry) => ( @@ -250,14 +295,18 @@ export function AgentThreadMemoryPrefetchPreview({ ))} {result.durable_memories.length > 3 ? (
    - 另有 {result.durable_memories.length - 3} 条长期记忆未展开。 + 另有 {result.durable_memories.length - 3}{" "} + 条持久记忆未展开。
    ) : null}
    ) : null}
    - + {result.team_memory_entries.length > 0 ? (
    {result.team_memory_entries.slice(0, 3).map((entry) => ( @@ -285,14 +334,18 @@ export function AgentThreadMemoryPrefetchPreview({ ))} {result.team_memory_entries.length > 3 ? (
    - 另有 {result.team_memory_entries.length - 3} 条任务影子未展开。 + 另有 {result.team_memory_entries.length - 3} 条 Team + Memory 未展开。
    ) : null}
    ) : null}
    - + {result.latest_compaction ? (
    @@ -300,16 +353,22 @@ export function AgentThreadMemoryPrefetchPreview({ 触发原因:{result.latest_compaction.trigger} ) : null} {typeof result.latest_compaction.turn_count === "number" ? ( - 覆盖回合:{result.latest_compaction.turn_count} + + 覆盖回合:{result.latest_compaction.turn_count} + ) : null} {formatDateTime(result.latest_compaction.created_at) ? ( - 生成于 {formatDateTime(result.latest_compaction.created_at)} + 生成于{" "} + {formatDateTime(result.latest_compaction.created_at)} ) : null}
    - {truncateText(result.latest_compaction.summary_preview, 260)} + {truncateText( + result.latest_compaction.summary_preview, + 260, + )}
    ) : null} diff --git a/src/components/agent/chat/components/AgentThreadReliabilityPanel.test.tsx b/src/components/agent/chat/components/AgentThreadReliabilityPanel.test.tsx index 8fb95d593..ff3ea683c 100644 --- a/src/components/agent/chat/components/AgentThreadReliabilityPanel.test.tsx +++ b/src/components/agent/chat/components/AgentThreadReliabilityPanel.test.tsx @@ -524,10 +524,13 @@ describe("AgentThreadReliabilityPanel", () => { expect(onReplayPendingRequest).toHaveBeenCalledWith("req-replay-1"); }); - it("应展示本回合五层记忆预取,帮助判断五层续接是否命中", async () => { + it("应展示本回合记忆命中预取,帮助判断当前续接是否命中", async () => { mockPrefetchContextMemoryForTurn.mockResolvedValue({ session_id: "session-memory-1", - rules_source_paths: ["/workspace/AGENTS.md", "/workspace/.memory/rules.md"], + rules_source_paths: [ + "/workspace/AGENTS.md", + "/workspace/.memory/rules.md", + ], working_memory_excerpt: "【task_plan.md】继续整理风险点与来源摘要。", durable_memories: [ { @@ -608,24 +611,29 @@ describe("AgentThreadReliabilityPanel", () => { '[data-testid="agent-thread-reliability-memory-prefetch"]', ), ).not.toBeNull(); + expect( + container.querySelector( + '[data-testid="agent-thread-reliability-memory-prefetch"]', + )?.className, + ).toContain("border-emerald-200"); expect(container.textContent).toContain("本回合记忆预取"); - expect(container.textContent).toContain("五层记忆预演"); + expect(container.textContent).toContain("记忆命中预演"); expect(container.textContent).toContain("规则 2"); - expect(container.textContent).toContain("工作 已命中"); + expect(container.textContent).toContain("会话 已命中"); expect(container.textContent).toContain("持久 1"); - expect(container.textContent).toContain("任务影子 1"); + expect(container.textContent).toContain("Team Memory 1"); expect(container.textContent).toContain("压缩 已命中"); - expect(container.textContent).toContain("工作记忆摘录"); + expect(container.textContent).toContain("会话记忆摘录"); expect(container.textContent).toContain("继续整理风险点与来源摘要"); expect(container.textContent).toContain("规则来源"); expect(container.textContent).toContain("/workspace/AGENTS.md"); expect(container.textContent).toContain("持久记忆命中"); expect(container.textContent).toContain("研究简报输出偏好"); expect(container.textContent).toContain("优先给风险与证据链"); - expect(container.textContent).toContain("任务影子明细"); + expect(container.textContent).toContain("Team Memory 明细"); expect(container.textContent).toContain("team.selection"); expect(container.textContent).toContain("研究协作队"); - expect(container.textContent).toContain("压缩续接摘要"); + expect(container.textContent).toContain("会话压缩摘要"); expect(container.textContent).toContain("保留研究目标与来源摘要"); expect(container.textContent).toContain("运行时记忆片段"); expect(container.textContent).toContain("【运行时记忆召回】"); @@ -651,7 +659,10 @@ describe("AgentThreadReliabilityPanel", () => { mockPrefetchContextMemoryForTurn.mockResolvedValue({ session_id: "session-memory-compare-1", - rules_source_paths: ["/workspace/AGENTS.md", "/workspace/.memory/rules.md"], + rules_source_paths: [ + "/workspace/AGENTS.md", + "/workspace/.memory/rules.md", + ], working_memory_excerpt: "【task_plan.md】补上新版风险结论。", durable_memories: [ { @@ -694,9 +705,16 @@ describe("AgentThreadReliabilityPanel", () => { }); expect(container.textContent).toContain("相对最近基线"); + expect( + container.querySelector( + '[data-testid="agent-thread-reliability-memory-prefetch-baseline"]', + )?.className, + ).toContain("border-emerald-200"); expect(container.textContent).toContain("基线输入:继续输出旧版简报"); expect(container.textContent).toContain("补强"); - expect(container.textContent).toContain("补强层:规则层、工作层、持久层。 摘要内容也有更新。"); + expect(container.textContent).toContain( + "补强层:规则层、工作层、持久层。 摘要内容也有更新。", + ); expect(container.textContent).toContain("具体变化"); expect(container.textContent).toContain("规则 +1"); expect(container.textContent).toContain("工作 新命中"); @@ -773,8 +791,12 @@ describe("AgentThreadReliabilityPanel", () => { expect(container.textContent).toContain("最近压缩边界"); expect(container.textContent).toContain("token_budget"); expect(container.textContent).toContain("覆盖 8 回合"); - expect(container.textContent).toContain("保留研究目标、已确认来源和待输出风险点"); - expect(container.textContent).toContain("压缩备注 压缩后保留研究目标与来源摘要"); + expect(container.textContent).toContain( + "保留研究目标、已确认来源和待输出风险点", + ); + expect(container.textContent).toContain( + "压缩备注 压缩后保留研究目标与来源摘要", + ); }); it("应支持复制给 AI 的可靠性诊断包", async () => { @@ -1032,19 +1054,21 @@ describe("AgentThreadReliabilityPanel", () => { expect.stringContaining("规则层:1 个来源"), ); expect(navigator.clipboard.writeText).toHaveBeenCalledWith( - expect.stringContaining("任务影子层:1 条"), + expect.stringContaining("Team Memory 层:1 条"), ); expect(navigator.clipboard.writeText).toHaveBeenCalledWith( expect.stringContaining("规则来源:/workspace/AGENTS.md"), ); expect(navigator.clipboard.writeText).toHaveBeenCalledWith( - expect.stringContaining("持久记忆详情:研究输出格式偏好|先给结论,再列风险"), + expect.stringContaining( + "持久记忆详情:研究输出格式偏好|先给结论,再列风险", + ), ); expect(navigator.clipboard.writeText).toHaveBeenCalledWith( - expect.stringContaining("任务影子详情:team.selection|研究协作队"), + expect.stringContaining("Team Memory 详情:team.selection|研究协作队"), ); expect(navigator.clipboard.writeText).toHaveBeenCalledWith( - expect.stringContaining("压缩命中摘要:保留研究目标与最近来源摘要。"), + expect.stringContaining("会话压缩摘要:保留研究目标与最近来源摘要。"), ); expect(navigator.clipboard.writeText).toHaveBeenCalledWith( expect.stringContaining("运行时记忆片段:【运行时记忆召回】"), @@ -1053,7 +1077,9 @@ describe("AgentThreadReliabilityPanel", () => { expect.stringContaining("覆盖回合数:6"), ); expect(navigator.clipboard.writeText).toHaveBeenCalledWith( - expect.stringContaining("边界摘要:保留研究目标、最近来源摘要和待输出风险点"), + expect.stringContaining( + "边界摘要:保留研究目标、最近来源摘要和待输出风险点", + ), ); expect(navigator.clipboard.writeText).toHaveBeenCalledWith( expect.stringContaining("### 后端诊断聚合"), diff --git a/src/components/agent/chat/components/AgentThreadReliabilityPanel.tsx b/src/components/agent/chat/components/AgentThreadReliabilityPanel.tsx index a383be26f..c5ca279ff 100644 --- a/src/components/agent/chat/components/AgentThreadReliabilityPanel.tsx +++ b/src/components/agent/chat/components/AgentThreadReliabilityPanel.tsx @@ -137,7 +137,9 @@ function parseDiagnosticDate(value?: string | number | null): Date | null { return null; } -function formatDiagnosticDateTime(value?: string | number | null): string | null { +function formatDiagnosticDateTime( + value?: string | number | null, +): string | null { const date = parseDiagnosticDate(value); if (!date) { return null; @@ -355,10 +357,10 @@ function buildMemoryPrefetchDiagnosticLines( const prefetch = memoryPrefetchState.result; const sections = [ `- 规则层:${prefetch.rules_source_paths.length} 个来源`, - `- 工作层:${prefetch.working_memory_excerpt ? "已命中" : "未命中"}`, + `- 会话层:${prefetch.working_memory_excerpt ? "已命中" : "未命中"}`, `- 持久层:${prefetch.durable_memories.length} 条`, - `- 任务影子层:${prefetch.team_memory_entries.length} 条`, - `- 压缩层:${prefetch.latest_compaction ? "已命中" : "未命中"}`, + `- Team Memory 层:${prefetch.team_memory_entries.length} 条`, + `- 会话压缩层:${prefetch.latest_compaction ? "已命中" : "未命中"}`, ]; if (prefetch.rules_source_paths.length > 0) { @@ -371,7 +373,7 @@ function buildMemoryPrefetchDiagnosticLines( } if (prefetch.working_memory_excerpt) { sections.push( - `- 工作记忆摘录:${truncateDiagnosticText(prefetch.working_memory_excerpt, 220)}`, + `- 会话记忆摘录:${truncateDiagnosticText(prefetch.working_memory_excerpt, 220)}`, ); } if (prefetch.durable_memories.length > 0) { @@ -393,13 +395,13 @@ function buildMemoryPrefetchDiagnosticLines( } if (prefetch.team_memory_entries.length > 0) { sections.push( - `- 任务影子键:${prefetch.team_memory_entries + `- Team Memory 键:${prefetch.team_memory_entries .slice(0, 3) .map((entry) => entry.key) .join("|")}`, ); sections.push( - `- 任务影子详情:${prefetch.team_memory_entries + `- Team Memory 详情:${prefetch.team_memory_entries .slice(0, 3) .map( (entry) => @@ -410,10 +412,10 @@ function buildMemoryPrefetchDiagnosticLines( } if (prefetch.latest_compaction) { sections.push( - `- 压缩命中摘要:${truncateDiagnosticText(prefetch.latest_compaction.summary_preview, 180)}`, + `- 会话压缩摘要:${truncateDiagnosticText(prefetch.latest_compaction.summary_preview, 180)}`, ); sections.push( - `- 压缩命中元数据:触发=${prefetch.latest_compaction.trigger || "未知"}|覆盖回合=${prefetch.latest_compaction.turn_count ?? "未知"}`, + `- 会话压缩元数据:触发=${prefetch.latest_compaction.trigger || "未知"}|覆盖回合=${prefetch.latest_compaction.turn_count ?? "未知"}`, ); } if (prefetch.prompt) { @@ -426,7 +428,9 @@ function buildMemoryPrefetchDiagnosticLines( } if (memoryPrefetchState?.status === "loading") { - return ["- 正在按当前回合最新 prompt 预演五层记忆命中"]; + return [ + "- 正在按当前回合最新 prompt 预演来源链 / 会话记忆 / 持久记忆 / Team Memory / 会话压缩命中", + ]; } if (memoryPrefetchState?.error) { @@ -451,9 +455,9 @@ function resolveMemoryPrefetchPreviewChangeLabel( case "working": return `工作摘录 ${change.previous || "无"} -> ${change.current || "无"}`; case "durable": - return `长期记忆 ${change.previous || "无"} -> ${change.current || "无"}`; + return `持久记忆 ${change.previous || "无"} -> ${change.current || "无"}`; case "team": - return `任务影子 ${change.previous || "无"} -> ${change.current || "无"}`; + return `Team Memory ${change.previous || "无"} -> ${change.current || "无"}`; case "compaction": return `压缩摘要 ${change.previous || "无"} -> ${change.current || "无"}`; case "user_message": @@ -472,7 +476,7 @@ function resolveMemoryPrefetchAssessmentBadgeClassName( case "weaker": return "border-amber-200 bg-white text-amber-700"; case "mixed": - return "border-sky-200 bg-white text-sky-700"; + return "border-slate-200 bg-white text-slate-700"; case "same": default: return "border-slate-200 bg-white text-slate-700"; @@ -492,8 +496,10 @@ function resolveMemoryPrefetchComparison( }; } - const normalizedCurrentWorkingDir = - currentWorkingDir.trim().replace(/\\/g, "/").replace(/\/+$/u, ""); + const normalizedCurrentWorkingDir = currentWorkingDir + .trim() + .replace(/\\/g, "/") + .replace(/\/+$/u, ""); const candidates = entries.slice(1); const baselineEntry = candidates.find((entry) => entry.sessionId === currentEntry.sessionId) || @@ -696,9 +702,7 @@ function buildReliabilityDiagnosticText(params: { sections.push(`- 生成时间:${boundary.created_at || "未知"}`); sections.push(`- 覆盖回合数:${boundary.turn_count ?? "未知"}`); sections.push(`- 触发原因:${boundary.trigger || "未知"}`); - sections.push( - `- 边界摘要:${boundary.summary_preview || "无摘要预览"}`, - ); + sections.push(`- 边界摘要:${boundary.summary_preview || "无摘要预览"}`); sections.push(`- 压缩备注:${boundary.detail || "无"}`); } else { sections.push("- 无"); @@ -708,7 +712,10 @@ function buildReliabilityDiagnosticText(params: { sections.push(...buildMemoryPrefetchDiagnosticLines(memoryPrefetchState)); sections.push("", "### 相对最近基线的记忆变化"); - if (memoryPrefetchComparison?.baselineEntry && memoryPrefetchComparison.diff) { + if ( + memoryPrefetchComparison?.baselineEntry && + memoryPrefetchComparison.diff + ) { sections.push( `- 基线来源:${resolveMemoryPrefetchHistorySourceLabel(memoryPrefetchComparison.baselineEntry.source)}`, ); @@ -716,14 +723,17 @@ function buildReliabilityDiagnosticText(params: { `- 基线输入:${memoryPrefetchComparison.baselineEntry.userMessage || "无"}`, ); sections.push( - `- 基线摘要:${truncateDiagnosticText( - memoryPrefetchComparison.baselineEntry.preview.durableTitle || - memoryPrefetchComparison.baselineEntry.preview.workingExcerpt || - memoryPrefetchComparison.baselineEntry.preview.compactionSummary || - memoryPrefetchComparison.baselineEntry.preview.firstRuleSourcePath || - memoryPrefetchComparison.baselineEntry.preview.teamKey, - 180, - ) || "无"}`, + `- 基线摘要:${ + truncateDiagnosticText( + memoryPrefetchComparison.baselineEntry.preview.durableTitle || + memoryPrefetchComparison.baselineEntry.preview.workingExcerpt || + memoryPrefetchComparison.baselineEntry.preview.compactionSummary || + memoryPrefetchComparison.baselineEntry.preview + .firstRuleSourcePath || + memoryPrefetchComparison.baselineEntry.preview.teamKey, + 180, + ) || "无" + }`, ); if (memoryPrefetchComparison.assessment) { sections.push( @@ -735,7 +745,7 @@ function buildReliabilityDiagnosticText(params: { } if (memoryPrefetchComparison.diff.changed) { sections.push( - `- 层变化:规则 ${memoryPrefetchComparison.diff.layerChanges.rulesDelta >= 0 ? "+" : ""}${memoryPrefetchComparison.diff.layerChanges.rulesDelta}|工作 ${memoryPrefetchComparison.diff.layerChanges.workingChanged}|持久 ${memoryPrefetchComparison.diff.layerChanges.durableDelta >= 0 ? "+" : ""}${memoryPrefetchComparison.diff.layerChanges.durableDelta}|任务影子 ${memoryPrefetchComparison.diff.layerChanges.teamDelta >= 0 ? "+" : ""}${memoryPrefetchComparison.diff.layerChanges.teamDelta}|压缩 ${memoryPrefetchComparison.diff.layerChanges.compactionChanged}`, + `- 层变化:规则 ${memoryPrefetchComparison.diff.layerChanges.rulesDelta >= 0 ? "+" : ""}${memoryPrefetchComparison.diff.layerChanges.rulesDelta}|会话 ${memoryPrefetchComparison.diff.layerChanges.workingChanged}|持久 ${memoryPrefetchComparison.diff.layerChanges.durableDelta >= 0 ? "+" : ""}${memoryPrefetchComparison.diff.layerChanges.durableDelta}|Team Memory ${memoryPrefetchComparison.diff.layerChanges.teamDelta >= 0 ? "+" : ""}${memoryPrefetchComparison.diff.layerChanges.teamDelta}|压缩 ${memoryPrefetchComparison.diff.layerChanges.compactionChanged}`, ); if (memoryPrefetchComparison.diff.previewChanges.length > 0) { sections.push( @@ -966,7 +976,8 @@ export const AgentThreadReliabilityPanel: React.FC< const summary = isInterrupting ? "正在请求停止当前执行,等待运行时确认最新线程状态。" : view.summary; - const latestCompactionBoundary = threadRead?.latest_compaction_boundary || null; + const latestCompactionBoundary = + threadRead?.latest_compaction_boundary || null; const latestCompactionCreatedLabel = formatDiagnosticDateTime( latestCompactionBoundary?.created_at, ); @@ -978,7 +989,8 @@ export const AgentThreadReliabilityPanel: React.FC< latestCompactionBoundary?.summary_preview || "已生成压缩摘要预览"; const latestTurnPrompt = useMemo(() => { const activeTurn = - turns.find((turn) => turn.id === currentTurnId) || turns[turns.length - 1]; + turns.find((turn) => turn.id === currentTurnId) || + turns[turns.length - 1]; return activeTurn?.prompt_text?.trim() || ""; }, [currentTurnId, turns]); const teamMemoryShadowMetadata = useMemo( @@ -997,7 +1009,8 @@ export const AgentThreadReliabilityPanel: React.FC< ].join("|"); }, [teamMemoryShadowMetadata]); const diagnosticSessionId = diagnosticRuntimeContext?.sessionId?.trim() || ""; - const diagnosticWorkingDir = diagnosticRuntimeContext?.workingDir?.trim() || ""; + const diagnosticWorkingDir = + diagnosticRuntimeContext?.workingDir?.trim() || ""; useEffect(() => { if (!diagnosticSessionId) { @@ -1069,9 +1082,7 @@ export const AgentThreadReliabilityPanel: React.FC< status: "error", result: null, error: - error instanceof Error - ? error.message - : "记忆预取失败,请稍后重试", + error instanceof Error ? error.message : "记忆预取失败,请稍后重试", }); setMemoryPrefetchComparison({ baselineEntry: null, @@ -1295,8 +1306,8 @@ export const AgentThreadReliabilityPanel: React.FC<
    当前入口只覆盖当前 thread 的运行信号。正式交给外部模型分析时, - 请优先使用工作台“交接制品 → 外部分析交接”的 - `analysis-brief.md / analysis-context.json` 主链;这里的“快速复制给 + 请优先使用工作台“交接制品 → 外部分析交接”的 `analysis-brief.md / + analysis-context.json` 主链;这里的“快速复制给 AI”只适合临时排障,“复制原始 JSON(debug)”适合程序化分析、 存档或二次处理。
    @@ -1584,7 +1595,7 @@ export const AgentThreadReliabilityPanel: React.FC< variant="outline" size="sm" onClick={onOpenMemoryWorkbench} - className="h-8 rounded-full border-sky-300 bg-white text-sky-700 hover:bg-sky-50" + className="h-8 rounded-full border-emerald-300 bg-white text-emerald-700 hover:bg-emerald-50" > 在记忆工作台查看 @@ -1596,27 +1607,28 @@ export const AgentThreadReliabilityPanel: React.FC< {memoryPrefetchState.status === "ready" && memoryPrefetchComparison.baselineEntry && memoryPrefetchComparison.diff ? ( -
    +
    -
    - 相对最近基线 -
    +
    相对最近基线
    {resolveMemoryPrefetchHistorySourceLabel( memoryPrefetchComparison.baselineEntry.source, )} - + {formatDiagnosticDateTime( memoryPrefetchComparison.baselineEntry.capturedAt, ) || "未知时间"}
    {memoryPrefetchComparison.baselineEntry.userMessage ? ( -
    +
    基线输入:{memoryPrefetchComparison.baselineEntry.userMessage}
    ) : null} @@ -1633,7 +1645,7 @@ export const AgentThreadReliabilityPanel: React.FC< memoryPrefetchComparison.assessment.status, )} - + {describeRuntimeMemoryPrefetchHistoryDiffAssessment( memoryPrefetchComparison.assessment, )} @@ -1643,65 +1655,76 @@ export const AgentThreadReliabilityPanel: React.FC< ) : null} {memoryPrefetchComparison.diff.changed ? ( <> -
    +
    具体变化
    {memoryPrefetchComparison.diff.layerChanges.rulesDelta !== 0 ? ( 规则{" "} - {memoryPrefetchComparison.diff.layerChanges.rulesDelta > 0 ? "+" : ""} + {memoryPrefetchComparison.diff.layerChanges.rulesDelta > 0 + ? "+" + : ""} {memoryPrefetchComparison.diff.layerChanges.rulesDelta} ) : null} - {memoryPrefetchComparison.diff.layerChanges.workingChanged !== "same" ? ( + {memoryPrefetchComparison.diff.layerChanges.workingChanged !== + "same" ? ( 工作 - {memoryPrefetchComparison.diff.layerChanges.workingChanged === "added" + {memoryPrefetchComparison.diff.layerChanges + .workingChanged === "added" ? " 新命中" : " 取消命中"} ) : null} - {memoryPrefetchComparison.diff.layerChanges.durableDelta !== 0 ? ( + {memoryPrefetchComparison.diff.layerChanges.durableDelta !== + 0 ? ( 持久{" "} - {memoryPrefetchComparison.diff.layerChanges.durableDelta > 0 ? "+" : ""} + {memoryPrefetchComparison.diff.layerChanges.durableDelta > 0 + ? "+" + : ""} {memoryPrefetchComparison.diff.layerChanges.durableDelta} ) : null} {memoryPrefetchComparison.diff.layerChanges.teamDelta !== 0 ? ( - 任务影子{" "} - {memoryPrefetchComparison.diff.layerChanges.teamDelta > 0 ? "+" : ""} + Team Memory{" "} + {memoryPrefetchComparison.diff.layerChanges.teamDelta > 0 + ? "+" + : ""} {memoryPrefetchComparison.diff.layerChanges.teamDelta} ) : null} - {memoryPrefetchComparison.diff.layerChanges.compactionChanged !== "same" ? ( + {memoryPrefetchComparison.diff.layerChanges + .compactionChanged !== "same" ? ( 压缩 - {memoryPrefetchComparison.diff.layerChanges.compactionChanged === "added" + {memoryPrefetchComparison.diff.layerChanges + .compactionChanged === "added" ? " 新命中" : " 取消命中"} ) : null}
    {memoryPrefetchComparison.diff.previewChanges.length > 0 ? ( -
    +
    {memoryPrefetchComparison.diff.previewChanges .slice(0, 3) .map((change, index) => ( @@ -1713,7 +1736,7 @@ export const AgentThreadReliabilityPanel: React.FC< ) : null} ) : ( -
    +
    当前回合与最近基线相比没有明显变化。
    )} diff --git a/src/components/agent/chat/components/EmptyState.test.tsx b/src/components/agent/chat/components/EmptyState.test.tsx index 7aa887543..f8911a46f 100644 --- a/src/components/agent/chat/components/EmptyState.test.tsx +++ b/src/components/agent/chat/components/EmptyState.test.tsx @@ -367,6 +367,29 @@ describe("EmptyState", () => { ).toBeNull(); }); + it("runtime tool surface 告警不应再透传到首页空态输入区", async () => { + const container = renderEmptyState({ + activeTheme: "general", + webSearchEnabled: true, + subagentEnabled: true, + }); + + await act(async () => { + await Promise.resolve(); + }); + + expect( + container.querySelector('[data-testid="empty-state-runtime-tool-warning"]'), + ).toBeNull(); + expect(container.textContent).not.toContain("当前 runtime tool surface"); + expect(container.textContent).not.toContain( + "联网搜索偏好本轮可能不会生效", + ); + expect(container.textContent).not.toContain( + "任务拆分偏好本轮可能不会完全生效", + ); + }); + it("有最近记录时应展示继续上次做法入口,并允许直接继续模板与方法", async () => { recordEntryRecommendedSolutionUsage("social-post-starter"); const onSelectServiceSkill = vi.fn<(skill: ServiceSkillHomeItem) => void>(); diff --git a/src/components/agent/chat/components/EmptyStateComposerPanel.test.tsx b/src/components/agent/chat/components/EmptyStateComposerPanel.test.tsx index 952b71570..70a1007ca 100644 --- a/src/components/agent/chat/components/EmptyStateComposerPanel.test.tsx +++ b/src/components/agent/chat/components/EmptyStateComposerPanel.test.tsx @@ -249,6 +249,25 @@ function expandAdvancedControls(container: HTMLDivElement) { } describe("EmptyStateComposerPanel", () => { + it("已开启的偏好若缺少 runtime current tools,也不应再显示页级告警", () => { + const container = renderPanel({ + isGeneralTheme: true, + webSearchEnabled: true, + subagentEnabled: true, + }); + + expect( + container.querySelector('[data-testid="empty-state-runtime-tool-warning"]'), + ).toBeNull(); + expect(container.textContent).not.toContain("当前 runtime tool surface"); + expect(container.textContent).not.toContain( + "联网搜索偏好本轮可能不会生效", + ); + expect(container.textContent).not.toContain( + "任务拆分偏好本轮可能不会完全生效", + ); + }); + it("首页空态输入区默认隐藏技能入口,展开高级设置后与 @ 面板共用同一技能入口", () => { const container = renderPanel({ isGeneralTheme: true, @@ -387,8 +406,11 @@ describe("EmptyStateComposerPanel", () => { onSubagentEnabledChange, }); - expect(container.textContent).toContain("当前任务更适合分工推进"); - expect(container.textContent).toContain("建议角色:分析"); + const suggestionBar = container.querySelector( + '[data-testid="team-suggestion-bar"]', + ); + expect(suggestionBar).toBeTruthy(); + expect(suggestionBar?.textContent).toContain("分工建议"); const enableTeamButton = Array.from( container.querySelectorAll("button"), @@ -423,7 +445,9 @@ describe("EmptyStateComposerPanel", () => { continueButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); - expect(container.textContent).not.toContain("当前任务更适合分工推进"); + expect( + container.querySelector('[data-testid="team-suggestion-bar"]'), + ).toBeNull(); }); it("开启 Team mode 后应显示 TeamSelector", () => { @@ -616,7 +640,11 @@ describe("EmptyStateComposerPanel", () => { ).toBeNull(); expect(enableButton).toBeTruthy(); expect(enableButton?.textContent).toContain("启用任务分工"); - expect(container.textContent).toContain("当前任务更适合分工推进"); + const suggestionBar = container.querySelector( + '[data-testid="team-suggestion-bar"]', + ); + expect(suggestionBar).toBeTruthy(); + expect(suggestionBar?.textContent).toContain("分工建议"); }); it("折叠态应保留当前模型轻提示,展开高级设置后再允许修改", () => { diff --git a/src/components/agent/chat/components/EmptyStateComposerPanel.tsx b/src/components/agent/chat/components/EmptyStateComposerPanel.tsx index 9c574023d..ada907fed 100644 --- a/src/components/agent/chat/components/EmptyStateComposerPanel.tsx +++ b/src/components/agent/chat/components/EmptyStateComposerPanel.tsx @@ -199,6 +199,7 @@ export function EmptyStateComposerPanel({ {shouldShowTeamSuggestion ? ( entry.name !== "Agent"), + ], + }; +} + beforeEach(() => { ( globalThis as typeof globalThis & { @@ -2163,9 +2452,51 @@ describe("HarnessStatusPanel", () => { expect(document.body.textContent).toContain("运行时覆盖"); expect(document.body.textContent).toContain("持久化覆盖"); expect(document.body.textContent).toContain("默认策略"); + expect(document.body.textContent).toContain("实际 Runtime 工具面"); expect(document.body.textContent).toContain("Catalog 工具"); }); + it("runtime tool surface 应在工具库存中展示 team/task current gaps", () => { + renderPanel({ + toolInventory: createToolInventory(), + }); + + const summary = document.body.querySelector( + '[data-testid="harness-runtime-tool-capability-summary"]', + ) as HTMLElement | null; + + expect(summary?.textContent).toContain("Runtime 能力摘要"); + expect(summary?.textContent).toContain("来源 runtime_tools"); + expect(summary?.textContent).toContain("WebSearch 未接通"); + expect(summary?.textContent).toContain("子任务核心 tools 缺 1 项"); + expect(summary?.textContent).toContain("Team current tools 缺 3 项"); + expect(summary?.textContent).toContain("Task current tools 缺 6 项"); + expect(summary?.textContent).toContain("SendMessage"); + expect(summary?.textContent).toContain("TeamCreate / TeamDelete / ListPeers"); + expect(summary?.textContent).toContain( + "TaskCreate / TaskGet / TaskList / TaskUpdate / TaskOutput / TaskStop", + ); + }); + + it("runtime tool surface 应在工具库存中展示已接通的 team/task current surface", () => { + renderPanel({ + toolInventory: createAlignedRuntimeToolInventory(), + }); + + const summary = document.body.querySelector( + '[data-testid="harness-runtime-tool-capability-summary"]', + ) as HTMLElement | null; + + expect(summary?.textContent).toContain("来源 runtime_tools"); + expect(summary?.textContent).toContain("WebSearch 已接通"); + expect(summary?.textContent).toContain("子任务核心 tools 已接通"); + expect(summary?.textContent).toContain("Team current tools 已接通"); + expect(summary?.textContent).toContain("Task current tools 已接通"); + expect(summary?.textContent).toContain( + "当前 runtime current surface 已覆盖 WebSearch、子任务、Team 与 Task 主链。", + ); + }); + it("工具库存应支持按来源筛选 catalog 条目", () => { renderPanel({ toolInventory: createToolInventory(), diff --git a/src/components/agent/chat/components/HarnessStatusPanel.tsx b/src/components/agent/chat/components/HarnessStatusPanel.tsx index 1a9d153e5..2b1c11375 100644 --- a/src/components/agent/chat/components/HarnessStatusPanel.tsx +++ b/src/components/agent/chat/components/HarnessStatusPanel.tsx @@ -45,6 +45,7 @@ import type { AgentRuntimeToolInventory, AgentRuntimeToolInventoryCatalogEntry, AgentRuntimeToolInventoryRegistryEntry, + AgentRuntimeToolInventoryRuntimeEntry, AgentRuntimeThreadReadModel, AgentToolExecutionPolicySource, AsterSubagentSessionInfo, @@ -103,6 +104,7 @@ import { normalizeToolNameKey, resolveToolDisplayLabel, } from "../utils/toolDisplayInfo"; +import { deriveRuntimeToolAvailability } from "../utils/runtimeToolAvailability"; import { buildWorkflowSummaryText, getWorkflowStatusLabel, @@ -1050,6 +1052,33 @@ function formatExtensionSourceKindLabel(value: string): string { } } +function formatRuntimeToolSourceKindLabel(value: string): string { + switch (value) { + case "registry_native": + return "Registry"; + case "current_surface": + return "当前工具面"; + case "runtime_extension": + return "Extension"; + case "mcp": + return "MCP"; + default: + return value; + } +} + +function formatRuntimeToolAvailabilitySourceLabel(value: string): string { + switch (value) { + case "runtime_tools": + return "runtime_tools"; + case "registry_tools": + return "registry_tools"; + case "none": + default: + return "未就绪"; + } +} + function collectCatalogExecutionSources( entry: AgentRuntimeToolInventoryCatalogEntry, ): AgentToolExecutionPolicySource[] { @@ -1070,6 +1099,17 @@ function collectRegistryExecutionSources( ].filter((value): value is AgentToolExecutionPolicySource => Boolean(value)); } +function sortRuntimeToolsByVisibility( + tools: AgentRuntimeToolInventoryRuntimeEntry[], +): AgentRuntimeToolInventoryRuntimeEntry[] { + return [...tools].sort((left, right) => { + if (left.visible_in_context !== right.visible_in_context) { + return left.visible_in_context ? -1 : 1; + } + return left.name.localeCompare(right.name); + }); +} + function matchesCatalogToolInventoryFilter( entry: AgentRuntimeToolInventoryCatalogEntry, filter: ToolInventoryFilterValue, @@ -1945,10 +1985,65 @@ export function HarnessStatusPanel({ const toolInventoryWarnings = toolInventory?.warnings || []; const toolInventoryCatalogTools = toolInventory?.catalog_tools || []; const toolInventoryRegistryTools = toolInventory?.registry_tools || []; + const toolInventoryRuntimeTools = useMemo( + () => sortRuntimeToolsByVisibility(toolInventory?.runtime_tools || []), + [toolInventory?.runtime_tools], + ); + const runtimeToolAvailability = useMemo( + () => deriveRuntimeToolAvailability(toolInventory), + [toolInventory], + ); const toolInventoryExtensionSurfaces = toolInventory?.extension_surfaces || []; const toolInventoryExtensionTools = toolInventory?.extension_tools || []; const toolInventoryMcpTools = toolInventory?.mcp_tools || []; + const runtimeToolTotal = + toolInventory?.counts.runtime_total ?? toolInventoryRuntimeTools.length; + const runtimeToolVisibleTotal = + toolInventory?.counts.runtime_visible_total ?? + toolInventoryRuntimeTools.filter((entry) => entry.visible_in_context) + .length; + const runtimeToolCapabilityGaps = useMemo(() => { + if (!toolInventory || !runtimeToolAvailability.known) { + return []; + } + + const gaps: Array<{ key: string; title: string; missing: string[] }> = []; + + if (!runtimeToolAvailability.webSearch) { + gaps.push({ + key: "web_search", + title: "WebSearch", + missing: ["WebSearch"], + }); + } + + if (!runtimeToolAvailability.subagentCore) { + gaps.push({ + key: "subagent_core", + title: "子任务核心 tools", + missing: runtimeToolAvailability.missingSubagentCoreTools, + }); + } + + if (!runtimeToolAvailability.subagentTeamTools) { + gaps.push({ + key: "subagent_team", + title: "Team current tools", + missing: runtimeToolAvailability.missingSubagentTeamTools, + }); + } + + if (!runtimeToolAvailability.taskRuntime) { + gaps.push({ + key: "task_runtime", + title: "Task current tools", + missing: runtimeToolAvailability.missingTaskTools, + }); + } + + return gaps; + }, [runtimeToolAvailability, toolInventory]); const realTeamSummary = useMemo( () => summarizeChildSubagentSessions(childSubagentSessions), [childSubagentSessions], @@ -2267,12 +2362,12 @@ export function HarnessStatusPanel({ value: toolInventoryLoading ? "读取中" : toolInventory - ? `${toolInventory.counts.registry_visible_total}` + ? `${runtimeToolVisibleTotal}` : "异常", hint: toolInventoryError ? toolInventoryError : toolInventory - ? `catalog ${toolInventory.counts.catalog_total} · MCP 可见 ${toolInventory.counts.mcp_tool_visible_total}` + ? `runtime ${runtimeToolVisibleTotal}/${runtimeToolTotal} · registry ${toolInventory.counts.registry_visible_total}` : "等待拉取运行时库存", icon: Wrench, }); @@ -2351,6 +2446,8 @@ export function HarnessStatusPanel({ toolInventory, toolInventoryError, toolInventoryLoading, + runtimeToolTotal, + runtimeToolVisibleTotal, threadReliabilityView.shouldRender, threadReliabilityView.statusLabel, threadReliabilityView.summary, @@ -4702,7 +4799,7 @@ export function HarnessStatusPanel({ toolInventoryLoading ? "读取中" : toolInventory - ? `catalog ${toolInventory.counts.catalog_total} / registry ${toolInventory.counts.registry_visible_total}` + ? `runtime ${runtimeToolVisibleTotal}/${runtimeToolTotal}` : toolInventoryError ? "读取失败" : "待同步" @@ -4772,7 +4869,12 @@ export function HarnessStatusPanel({ {toolInventory ? ( <> -
    +
    + ) : null} + {toolInventory ? ( +
    +
    +
    + Runtime 能力摘要 +
    + + {runtimeToolAvailability.known + ? `来源 ${formatRuntimeToolAvailabilitySourceLabel( + runtimeToolAvailability.source, + )}` + : "Runtime 工具面未就绪"} + +
    +
    + + {runtimeToolAvailability.webSearch + ? "WebSearch 已接通" + : "WebSearch 未接通"} + + + {runtimeToolAvailability.subagentCore + ? "子任务核心 tools 已接通" + : `子任务核心 tools 缺 ${runtimeToolAvailability.missingSubagentCoreTools.length} 项`} + + + {runtimeToolAvailability.subagentTeamTools + ? "Team current tools 已接通" + : `Team current tools 缺 ${runtimeToolAvailability.missingSubagentTeamTools.length} 项`} + + + {runtimeToolAvailability.taskRuntime + ? "Task current tools 已接通" + : `Task current tools 缺 ${runtimeToolAvailability.missingTaskTools.length} 项`} + +
    + {runtimeToolAvailability.known ? ( + runtimeToolCapabilityGaps.length > 0 ? ( +
    +
    + 当前 runtime current surface 仍有缺口 +
    +
    + {runtimeToolCapabilityGaps.map((gap) => ( +
    + + {gap.title} + + + {gap.missing.join(" / ")} +
    + ))} +
    +
    + ) : ( +
    + 当前 runtime current surface 已覆盖 + WebSearch、子任务、Team 与 Task 主链。 +
    + ) + ) : ( +
    + 当前 inventory 尚未提供可用 runtime tool + surface,暂时只能回看 registry/raw inventory。 +
    + )} +
    + ) : null} + +
    +
    +
    + 实际 Runtime 工具面 +
    + + {runtimeToolVisibleTotal} / {runtimeToolTotal} + +
    + {toolInventoryRuntimeTools.length > 0 ? ( + toolInventoryRuntimeTools.map((entry) => ( +
    +
    + + {entry.name} + + + {formatRuntimeToolSourceKindLabel( + entry.source_kind, + )} + + {entry.source_label ? ( + + {entry.source_label} + + ) : null} + {entry.status ? ( + + {entry.status} + + ) : null} + {entry.visible_in_context ? ( + + 上下文可见 + + ) : null} + {entry.deferred_loading ? ( + Deferred + ) : null} + {!entry.caller_allowed ? ( + + Caller 拒绝 + + ) : null} + {entry.catalog_entry_name ? ( + + 映射 {entry.catalog_entry_name} + + ) : null} +
    +
    + {entry.description} +
    +
    + {entry.allowed_callers.length > 0 ? ( + + callers: + {entry.allowed_callers.join(", ")} + + ) : ( + + callers:全部 + + )} + {entry.always_visible ? ( + + Always Visible + + ) : null} + + input_examples: + {entry.input_examples_count} + + {entry.tags.map((tag) => ( + + {tag} + + ))} +
    +
    + )) + ) : ( +
    + 当前尚未构建统一 runtime 工具面。 +
    + )} +
    +
    diff --git a/src/components/agent/chat/components/InlineToolProcessStep.test.tsx b/src/components/agent/chat/components/InlineToolProcessStep.test.tsx index 715dec595..362096547 100644 --- a/src/components/agent/chat/components/InlineToolProcessStep.test.tsx +++ b/src/components/agent/chat/components/InlineToolProcessStep.test.tsx @@ -70,6 +70,19 @@ afterEach(() => { }); describe("InlineToolProcessStep", () => { + it("运行中的读取工具应展示前置意图摘要", () => { + const { container } = renderTool({ + id: "tool-read-running-1", + name: "Read", + arguments: JSON.stringify({ file_path: "src/app.tsx" }), + status: "running", + startTime: new Date("2026-04-13T09:58:00.000Z"), + }); + + expect(container.textContent).toContain("先查看 app.tsx"); + expect(container.textContent).not.toContain("执行完成"); + }); + it("ToolSearch 在流式阶段应保持结构化预览,不自动展开原始 JSON", () => { const { container } = renderTool( { @@ -92,7 +105,9 @@ describe("InlineToolProcessStep", () => { { isMessageStreaming: true }, ); - expect(container.textContent).toContain("找到工具 2 个"); + expect(container.textContent).toContain("已确认可用工具 2 个"); + expect(container.textContent).toContain("查看文件"); + expect(container.textContent).toContain("保存文件"); expect(container.textContent).not.toContain("查询:"); expect(container.textContent).not.toContain("select:Read,Write"); expect( @@ -102,6 +117,78 @@ describe("InlineToolProcessStep", () => { expect(container.textContent).not.toContain('"tools"'); }); + it("超长工具结果在流式阶段应默认收起原始详情", () => { + const { container } = renderTool( + { + id: "tool-read-large-streaming-1", + name: "Read", + arguments: JSON.stringify({ file_path: "src/main.ts" }), + status: "completed", + result: { + success: true, + output: "A".repeat(1600), + }, + startTime: new Date("2026-04-13T10:05:00.000Z"), + endTime: new Date("2026-04-13T10:05:01.000Z"), + }, + { isMessageStreaming: true }, + ); + + expect(container.textContent).toContain("已查看 main.ts"); + expect(container.querySelector('[data-testid="markdown-renderer"]')).toBeNull(); + }); + + it("工具详情遇到伪标签输出时应先转义再渲染", () => { + const { container } = renderTool({ + id: "tool-bash-ink-tags-1", + name: "Bash", + arguments: JSON.stringify({ command: "echo demo" }), + status: "completed", + result: { + success: true, + output: "正在整理\n", + }, + startTime: new Date("2026-04-13T10:07:00.000Z"), + endTime: new Date("2026-04-13T10:07:01.000Z"), + }); + + act(() => { + const toggle = container.querySelector( + 'button[title="展开过程详情"]', + ) as HTMLButtonElement | null; + toggle?.click(); + }); + + expect(container.textContent).toContain("<text>正在整理</text>"); + expect(container.textContent).toContain("<spinner />"); + }); + + it("工具详情遇到 TypeScript 尖括号语法时也应转义再渲染", () => { + const { container } = renderTool({ + id: "tool-read-typescript-tags-1", + name: "Read", + arguments: JSON.stringify({ file_path: "src/schema.ts" }), + status: "completed", + result: { + success: true, + output: + "type OutputSchema = keyof T\ncontentBlockParam: ContentBlockParam", + }, + startTime: new Date("2026-04-13T10:08:00.000Z"), + endTime: new Date("2026-04-13T10:08:01.000Z"), + }); + + act(() => { + const toggle = container.querySelector( + 'button[title="展开过程详情"]', + ) as HTMLButtonElement | null; + toggle?.click(); + }); + + expect(container.textContent).toContain("<T>"); + expect(container.textContent).toContain("<typeof schema>"); + }); + it("ToolSearch 展开后应展示结构化工具摘要,而不是原始 JSON", () => { const { container } = renderTool({ id: "tool-search-1", @@ -144,6 +231,7 @@ describe("InlineToolProcessStep", () => { expect( container.querySelector('[data-testid="inline-tool-process-tool-search-result"]'), ).not.toBeNull(); + expect(container.textContent).toContain("已确认可用工具 2 个"); expect(container.textContent).toContain("找到工具:2 个"); expect(container.textContent).toContain("查看文件"); expect(container.textContent).toContain("保存文件"); diff --git a/src/components/agent/chat/components/InlineToolProcessStep.tsx b/src/components/agent/chat/components/InlineToolProcessStep.tsx index edbe9d27d..7e6bb7cd9 100644 --- a/src/components/agent/chat/components/InlineToolProcessStep.tsx +++ b/src/components/agent/chat/components/InlineToolProcessStep.tsx @@ -34,6 +34,7 @@ import { normalizeToolSearchResultSummary, resolveUserFacingToolSearchItemLabel, } from "../utils/toolSearchResultSummary"; +import { resolveToolProcessNarrative } from "../utils/toolProcessSummary"; interface InlineToolProcessStepProps { toolCall: ToolCallState; @@ -79,6 +80,11 @@ function summarizeResultText(value: string): string | null { return `${singleLine.slice(0, 180).trim()}...`; } +const LARGE_RESULT_AUTO_COLLAPSE_CHARS = 1200; +function sanitizeToolResultDetailMarkdown(value: string): string { + return value.replace(//g, ">"); +} + function summarizeToolSearchPreview(value: ReturnType< typeof normalizeToolSearchResultSummary >): string | null { @@ -107,6 +113,23 @@ function summarizeSearchResultPreview(resultCount: number): string | null { return `找到 ${resultCount} 条搜索结果`; } +function normalizeSummaryLine( + value: string | null, + headline: string, +): string | null { + const normalized = value?.trim(); + if (!normalized) { + return null; + } + + const normalizedHeadline = headline.trim(); + if (normalized === normalizedHeadline) { + return null; + } + + return normalized; +} + function buildSiteNoticeLines(toolCall: ToolCallState): string[] { const summary = normalizeSiteToolResultSummary(toolCall.result?.metadata); if (!summary) { @@ -193,6 +216,10 @@ export const InlineToolProcessStep: React.FC = ({ const rawText = toolCall.result?.error || toolCall.result?.output || ""; return extractLimeToolMetadataBlock(rawText).text.trim(); }, [toolCall.result?.error, toolCall.result?.output]); + const resultDetailMarkdown = useMemo( + () => sanitizeToolResultDetailMarkdown(resultText), + [resultText], + ); const resultPreview = useMemo( () => summarizeResultText(resultText), [resultText], @@ -225,6 +252,10 @@ export const InlineToolProcessStep: React.FC = ({ } return resultPreview; }, [resultPreview, searchResultItems.length, toolSearchSummary]); + const processNarrative = useMemo( + () => resolveToolProcessNarrative(toolCall), + [toolCall], + ); const savedSiteContentTarget = useMemo( () => resolveSiteSavedContentTargetFromMetadata(toolCall.result?.metadata), [toolCall.result?.metadata], @@ -245,6 +276,25 @@ export const InlineToolProcessStep: React.FC = ({ const isPreload = metadata?.execution_origin === "preload" || metadata?.preload === true; const hasOpenableFile = Boolean(filePath && onFileClick); + const processSummary = useMemo(() => { + const preferredSummary = + toolCall.status === "running" + ? processNarrative.preSummary + : processNarrative.postSource === "generic" && structuredResultPreview + ? structuredResultPreview + : processNarrative.postSummary || + structuredResultPreview || + processNarrative.preSummary; + + return normalizeSummaryLine(preferredSummary, headline); + }, [ + headline, + processNarrative.postSource, + processNarrative.postSummary, + processNarrative.preSummary, + structuredResultPreview, + toolCall.status, + ]); const hasDetails = Boolean(resultText) || resultImages.length > 0 || @@ -271,10 +321,11 @@ export const InlineToolProcessStep: React.FC = ({ } if (isMessageStreaming && !toolSearchSummary) { - setExpanded(true); + setExpanded(resultText.length <= LARGE_RESULT_AUTO_COLLAPSE_CHARS); } }, [ isMessageStreaming, + resultText.length, siteNoticeLines.length, toolCall.status, toolSearchSummary, @@ -341,7 +392,11 @@ export const InlineToolProcessStep: React.FC = ({ {badge} ))}
    - {!expanded && structuredResultPreview ? ( + {processSummary ? ( +
    + {processSummary} +
    + ) : !expanded && structuredResultPreview ? (
    {structuredResultPreview}
    @@ -440,7 +495,7 @@ export const InlineToolProcessStep: React.FC = ({ {!toolSearchSummary && searchResultItems.length === 0 && resultText ? (
    - +
    ) : null} diff --git a/src/components/agent/chat/components/Inputbar/components/InputbarCore.test.tsx b/src/components/agent/chat/components/Inputbar/components/InputbarCore.test.tsx index 33385987b..7df7d284b 100644 --- a/src/components/agent/chat/components/Inputbar/components/InputbarCore.test.tsx +++ b/src/components/agent/chat/components/Inputbar/components/InputbarCore.test.tsx @@ -125,7 +125,7 @@ describe("InputbarCore", () => { expect(mockGetVoiceInputConfig).not.toHaveBeenCalled(); }); - it("主题工作台未聚焦时应使用单行紧凑态,点击展开,移出后收起", async () => { + it("主题工作台空输入时应保持单行紧凑态,聚焦后也不应放大", async () => { const container = await renderInputbarCore(); const textarea = container.querySelector( "textarea", @@ -145,44 +145,27 @@ describe("InputbarCore", () => { textarea?.focus(); }); - expect(textarea?.className).not.toContain("floating-collapsed"); - expect( - container.querySelector('button[aria-label="添加图片"]'), - ).toBeTruthy(); - expect( - container.querySelector('[data-testid="inputbar-tools"]'), - ).toBeNull(); - - act(() => { - inputBar?.dispatchEvent( - new MouseEvent("mouseout", { - bubbles: true, - relatedTarget: document.body, - }), - ); - }); - - expect(textarea?.className).not.toContain("floating-collapsed"); - expect( - container.querySelector('[data-testid="inputbar-tools"]'), - ).toBeNull(); - - act(() => { - textarea?.blur(); - inputBar?.dispatchEvent( - new MouseEvent("mouseout", { - bubbles: true, - relatedTarget: document.body, - }), - ); - }); - expect(textarea?.className).toContain("floating-collapsed"); expect( container.querySelector('[data-testid="inputbar-tools"]'), ).toBeNull(); }); + it("主题工作台有输入内容时应展开为常规编辑态", async () => { + const container = await renderInputbarCore({ + text: "继续补充当前分析", + }); + const textarea = container.querySelector( + "textarea", + ) as HTMLTextAreaElement | null; + + expect(textarea).toBeTruthy(); + expect(textarea?.className).not.toContain("floating-collapsed"); + expect( + container.querySelector('button[aria-label="添加图片"]'), + ).toBeTruthy(); + }); + it("点击展开按钮应切换输入框展开态", async () => { const container = await renderInputbarCore({ visualVariant: "default", diff --git a/src/components/agent/chat/components/Inputbar/components/InputbarCore.tsx b/src/components/agent/chat/components/Inputbar/components/InputbarCore.tsx index 8a85f6e20..baef678c6 100644 --- a/src/components/agent/chat/components/Inputbar/components/InputbarCore.tsx +++ b/src/components/agent/chat/components/Inputbar/components/InputbarCore.tsx @@ -106,7 +106,6 @@ export const InputbarCore: React.FC = ({ onRemoveQueuedTurn, showMetaTools = true, }) => { - const [isComposerExpanded, setIsComposerExpanded] = useState(false); const [isTextareaExpanded, setIsTextareaExpanded] = useState(false); const inputBarContainerRef = useRef(null); const fallbackTextareaRef = useRef(null); @@ -126,14 +125,14 @@ export const InputbarCore: React.FC = ({ textareaRef: resolvedTextareaRef, disabled, }); + const hasInlineComposerContent = + text.trim().length > 0 || pendingImages.length > 0 || queuedTurns.length > 0; const shouldCollapseFloatingTools = isFloatingVariant && toolMode === "attach-only" && - !isComposerExpanded && - pendingImages.length === 0 && - queuedTurns.length === 0; + !hasInlineComposerContent; const shouldUseCompactFloatingComposer = - shouldCollapseFloatingTools && !topExtra; + shouldCollapseFloatingTools && !topExtra && !isTextareaExpanded; const containerClassName = [ isFullscreen ? "flex-1 flex flex-col" : "", isFloatingVariant ? "floating-composer" : "", @@ -184,44 +183,6 @@ export const InputbarCore: React.FC = ({ ? "开始语音输入" : "语音输入未启用"; - const handleExpandComposer = useCallback(() => { - if (!isFloatingVariant || toolMode !== "attach-only") { - return; - } - setIsComposerExpanded(true); - }, [isFloatingVariant, toolMode]); - - const handleCollapseComposer = useCallback(() => { - if ( - !isFloatingVariant || - toolMode !== "attach-only" || - pendingImages.length > 0 - ) { - return; - } - const activeElement = document.activeElement; - if ( - activeElement && - inputBarContainerRef.current?.contains(activeElement) - ) { - return; - } - setIsComposerExpanded(false); - }, [isFloatingVariant, pendingImages.length, toolMode]); - - const handleBlurCapture = useCallback(() => { - if (!isFloatingVariant || toolMode !== "attach-only") { - return; - } - window.requestAnimationFrame(() => { - const nextActiveElement = document.activeElement; - if (inputBarContainerRef.current?.contains(nextActiveElement)) { - return; - } - setIsComposerExpanded(false); - }); - }, [isFloatingVariant, toolMode]); - const handleRemoveImageMouseDown = useCallback( (event: React.MouseEvent) => { event.preventDefault(); @@ -241,10 +202,7 @@ export const InputbarCore: React.FC = ({ const handleToggleTextareaExpanded = useCallback(() => { setIsTextareaExpanded((previous) => !previous); - if (isFloatingVariant) { - setIsComposerExpanded(true); - } - }, [isFloatingVariant]); + }, []); return ( = ({ const handleContainerMouseDownCapture = ( event: React.MouseEvent, ) => { - handleExpandComposer(); if (!isFloatingVariant || toolMode !== "attach-only") { return; } @@ -292,10 +249,7 @@ export const InputbarCore: React.FC = ({ ref={inputBarContainerRef} data-testid="inputbar-core-container" className={inputBarClassName} - onFocusCapture={handleExpandComposer} onMouseDownCapture={handleContainerMouseDownCapture} - onMouseLeave={handleCollapseComposer} - onBlurCapture={handleBlurCapture} > {!isFullscreen && showDragHandle && } diff --git a/src/components/agent/chat/components/Inputbar/components/InputbarRuntimeStatusLine.tsx b/src/components/agent/chat/components/Inputbar/components/InputbarRuntimeStatusLine.tsx new file mode 100644 index 000000000..8e9b0a970 --- /dev/null +++ b/src/components/agent/chat/components/Inputbar/components/InputbarRuntimeStatusLine.tsx @@ -0,0 +1,290 @@ +import React, { useEffect, useMemo, useState } from "react"; +import { cn } from "@/lib/utils"; +import { + resolveConfiguredProviderPromptCacheSupportNotice, + useConfiguredProviders, +} from "@/hooks/useConfiguredProviders"; +import type { AgentTokenUsage as TokenUsage } from "@/lib/api/agentProtocol"; +import type { InputbarRuntimeStatusLineModel } from "../../../utils/inputbarRuntimeStatusLine"; + +interface InputbarRuntimeStatusLineProps { + runtime: InputbarRuntimeStatusLineModel | null; + providerType?: string | null; + canStop?: boolean; +} + +function formatCompactCount(value: number): string { + const normalized = Math.max(0, value); + if (normalized >= 1_000_000_000) { + return `${(normalized / 1_000_000_000).toFixed(1)}B`; + } + if (normalized >= 1_000_000) { + return `${(normalized / 1_000_000).toFixed(1)}M`; + } + if (normalized >= 1_000) { + return `${(normalized / 1_000).toFixed(1)}K`; + } + return normalized.toLocaleString(); +} + +function resolveTimestamp(value?: string | number | null): number | null { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + if (typeof value === "string" && value.trim()) { + const parsed = Date.parse(value); + return Number.isNaN(parsed) ? null : parsed; + } + return null; +} + +function formatElapsed(ms: number): string { + const totalSeconds = Math.max(0, Math.floor(ms / 1000)); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + if (hours > 0) { + return `${hours}:${String(minutes).padStart(2, "0")}:${String( + seconds, + ).padStart(2, "0")}`; + } + + return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart( + 2, + "0", + )}`; +} + +function resolveUsageSummary(usage?: TokenUsage): string | null { + if (!usage) { + return null; + } + + return `输入 ${formatCompactCount(usage.input_tokens)} / 输出 ${formatCompactCount( + usage.output_tokens, + )}`; +} + +function resolvePromptCacheTotal(usage?: TokenUsage): number { + return ( + Math.max(0, usage?.cached_input_tokens ?? 0) + + Math.max(0, usage?.cache_creation_input_tokens ?? 0) + ); +} + +function resolvePromptCacheSummary( + usage?: TokenUsage, + fallbackLabel?: string | null, +): string | null { + if (usage) { + const cachedRead = Math.max(0, usage.cached_input_tokens ?? 0); + const cacheWrite = Math.max(0, usage.cache_creation_input_tokens ?? 0); + if (cachedRead > 0 && cacheWrite > 0) { + return `Prompt Cache 读 ${formatCompactCount(cachedRead)} / 写 ${formatCompactCount(cacheWrite)}`; + } + if (cachedRead > 0) { + return `Prompt Cache ${formatCompactCount(cachedRead)}`; + } + if (cacheWrite > 0) { + return `Prompt Cache 写 ${formatCompactCount(cacheWrite)}`; + } + } + + if (fallbackLabel) { + return `Prompt Cache ${fallbackLabel}`; + } + + return null; +} + +function resolveStatusMeta( + status: InputbarRuntimeStatusLineModel["status"], +): { + label: string; + toneClassName: string; + dotClassName: string; + pulse: boolean; +} { + switch (status) { + case "queued": + return { + label: "排队中", + toneClassName: "text-slate-700", + dotClassName: "bg-slate-400", + pulse: false, + }; + case "waiting_input": + return { + label: "等待补充", + toneClassName: "text-amber-700", + dotClassName: "bg-amber-500", + pulse: false, + }; + case "completed": + return { + label: "已完成", + toneClassName: "text-emerald-700", + dotClassName: "bg-emerald-500", + pulse: false, + }; + case "failed": + return { + label: "失败", + toneClassName: "text-rose-700", + dotClassName: "bg-rose-500", + pulse: false, + }; + case "aborted": + return { + label: "已中断", + toneClassName: "text-slate-600", + dotClassName: "bg-slate-400", + pulse: false, + }; + case "running": + default: + return { + label: "处理中", + toneClassName: "text-emerald-700", + dotClassName: "bg-emerald-500", + pulse: true, + }; + } +} + +export const InputbarRuntimeStatusLine: React.FC< + InputbarRuntimeStatusLineProps +> = ({ runtime, providerType, canStop = false }) => { + const [now, setNow] = useState(() => Date.now()); + const startedAtMs = useMemo( + () => resolveTimestamp(runtime?.startedAt), + [runtime?.startedAt], + ); + const completedAtMs = useMemo( + () => resolveTimestamp(runtime?.completedAt), + [runtime?.completedAt], + ); + const shouldInspectPromptCacheNotice = Boolean( + providerType?.trim() && + runtime?.usage && + resolvePromptCacheTotal(runtime.usage) <= 0, + ); + const { providers } = useConfiguredProviders({ + autoLoad: shouldInspectPromptCacheNotice, + }); + const promptCacheNotice = useMemo( + () => + shouldInspectPromptCacheNotice + ? resolveConfiguredProviderPromptCacheSupportNotice( + providers, + providerType, + ) + : null, + [providerType, providers, shouldInspectPromptCacheNotice], + ); + + useEffect(() => { + if (!runtime || !startedAtMs || completedAtMs) { + return undefined; + } + + const timer = window.setInterval(() => { + setNow(Date.now()); + }, 1000); + + return () => { + window.clearInterval(timer); + }; + }, [completedAtMs, runtime, startedAtMs]); + + useEffect(() => { + if (runtime) { + setNow(Date.now()); + } + }, [runtime]); + + if (!runtime) { + return null; + } + + const statusMeta = resolveStatusMeta(runtime.status); + const elapsedText = + startedAtMs && (completedAtMs || now) >= startedAtMs + ? formatElapsed((completedAtMs || now) - startedAtMs) + : null; + const usageSummary = resolveUsageSummary(runtime.usage); + const promptCacheSummary = resolvePromptCacheSummary( + runtime.usage, + promptCacheNotice?.label, + ); + const detailText = + runtime.status === "waiting_input" || + runtime.status === "failed" || + runtime.status === "aborted" + ? runtime.detail?.trim() || null + : null; + const segments = [ + elapsedText, + runtime.batchDescriptor ? `工具 ${runtime.batchDescriptor.countLabel}` : null, + runtime.pendingRequestCount > 0 ? `待补 ${runtime.pendingRequestCount}` : null, + runtime.queuedTurnCount > 0 ? `排队 ${runtime.queuedTurnCount}` : null, + runtime.subtaskStats?.total + ? `任务 ${runtime.subtaskStats.active}/${runtime.subtaskStats.total}` + : null, + usageSummary, + promptCacheSummary, + canStop && (runtime.status === "running" || runtime.status === "queued") + ? "可随时停止" + : null, + ].filter(Boolean) as string[]; + + return ( +
    + + + {statusMeta.label} + + + {segments.map((segment) => ( + + + · + + {segment} + + ))} + + {detailText ? ( + <> + + · + + + {detailText} + + + ) : null} +
    + ); +}; + +export default InputbarRuntimeStatusLine; diff --git a/src/components/agent/chat/components/Inputbar/components/InputbarTools.tsx b/src/components/agent/chat/components/Inputbar/components/InputbarTools.tsx index 0620c0500..450de9930 100644 --- a/src/components/agent/chat/components/Inputbar/components/InputbarTools.tsx +++ b/src/components/agent/chat/components/Inputbar/components/InputbarTools.tsx @@ -39,6 +39,7 @@ export const InputbarTools: React.FC = ({ className={activeTools["web_search"] ? "active" : ""} aria-pressed={activeTools["web_search"]} title={`联网搜索${activeTools["web_search"] ? "已开启" : "已关闭"}`} + data-testid="toggle-web-search" > 搜索 @@ -52,6 +53,7 @@ export const InputbarTools: React.FC = ({ className={activeTools["subagent_mode"] ? "active" : ""} aria-pressed={activeTools["subagent_mode"]} title={`任务拆分偏好${activeTools["subagent_mode"] ? "已开启" : "已关闭"}`} + data-testid="toggle-subagent-mode" > 任务拆分 diff --git a/src/components/agent/chat/components/Inputbar/index.test.tsx b/src/components/agent/chat/components/Inputbar/index.test.tsx index a107d20fc..3aa8b00c8 100644 --- a/src/components/agent/chat/components/Inputbar/index.test.tsx +++ b/src/components/agent/chat/components/Inputbar/index.test.tsx @@ -378,6 +378,20 @@ describe("Inputbar", () => { container.querySelector('[data-testid="inputbar-secondary-controls"]'), ).toBeNull(); }); + + it("主线程任务状态不应再在输入区单独渲染 task strip", async () => { + const { container } = renderInputbar({ + input: "分析 claudecode 项目结构并继续执行", + activeTheme: "general", + }); + + await act(async () => { + await Promise.resolve(); + }); + + expect(container.querySelector('[data-testid="agent-task-strip"]')).toBeNull(); + }); + it("工作区输入区默认隐藏技能入口,展开高级设置后与 @ 面板共用同一技能数据源", async () => { const { container } = renderInputbar({ activeTheme: "general", @@ -563,7 +577,11 @@ describe("Inputbar", () => { await Promise.resolve(); }); - expect(container.textContent).toContain("当前任务更适合分工推进"); + const suggestionBar = container.querySelector( + '[data-testid="team-suggestion-bar"]', + ); + expect(suggestionBar).toBeTruthy(); + expect(suggestionBar?.textContent).toContain("分工建议"); const enableTeamButton = Array.from( container.querySelectorAll("button"), @@ -729,7 +747,11 @@ describe("Inputbar", () => { container.querySelector('[data-testid="toggle-subagent-mode"]'), ).toBeNull(); expect(recommendationButton).toBeTruthy(); - expect(container.textContent).toContain("当前任务更适合分工推进"); + const suggestionBar = container.querySelector( + '[data-testid="team-suggestion-bar"]', + ); + expect(suggestionBar).toBeTruthy(); + expect(suggestionBar?.textContent).toContain("分工建议"); }); it("内容主题默认发送时不应再注入旧 skill 前缀", async () => { diff --git a/src/components/agent/chat/components/MessageList.test.tsx b/src/components/agent/chat/components/MessageList.test.tsx index d84a8c997..2a7643df5 100644 --- a/src/components/agent/chat/components/MessageList.test.tsx +++ b/src/components/agent/chat/components/MessageList.test.tsx @@ -28,12 +28,14 @@ const mockFindConfiguredProviderBySelection = vi.fn( const mockTokenUsageDisplay = vi.fn( ({ promptCacheNotice, + inline, }: { promptCacheNotice?: { label?: string; } | null; + inline?: boolean; }) => ( -
    +
    {promptCacheNotice?.label || "token-usage-display"}
    ), @@ -88,6 +90,9 @@ vi.mock("./MarkdownRenderer", () => ({ const mockStreamingRenderer = vi.fn( ({ content, + contentParts, + thinkingContent, + toolCalls, onOpenSavedSiteContent, suppressProcessFlow, showRuntimeStatusInline, @@ -96,6 +101,9 @@ const mockStreamingRenderer = vi.fn( onQuoteContent, }: { content: string; + contentParts?: unknown[]; + thinkingContent?: string; + toolCalls?: unknown[]; renderA2UIInline?: boolean; suppressedActionRequestId?: string | null; suppressProcessFlow?: boolean; @@ -111,6 +119,9 @@ const mockStreamingRenderer = vi.fn( }) => (
    ({ promptCacheNotice?: { label?: string; } | null; + inline?: boolean; }) => mockTokenUsageDisplay(props), })); @@ -315,6 +327,31 @@ describe("MessageList", () => { ); }); + it("assistant 消息带 contextTrace 时不应在聊天主线渲染上下文轨迹块", () => { + const now = new Date(); + const messages: Message[] = [ + { + id: "msg-assistant-context-trace", + role: "assistant", + content: "我已经处理完成。", + timestamp: now, + contextTrace: [ + { + stage: "memory_injection", + detail: "query_len=8,injected=2", + }, + ], + }, + ]; + + const container = render(messages); + + expect(container.textContent).toContain("我已经处理完成。"); + expect(container.textContent).not.toContain("上下文轨迹"); + expect(container.textContent).not.toContain("memory_injection"); + expect(container.textContent).not.toContain("query_len=8,injected=2"); + }); + it("anthropic-compatible 自定义 Provider 无缓存命中时应透传自动缓存提示", () => { const now = new Date(); const messages: Message[] = [ @@ -418,6 +455,273 @@ describe("MessageList", () => { ); }); + it("复杂任务完成后应把运行状态、耗时与 token 结算收口到最后一条 assistant 消息尾部", () => { + const now = new Date(); + const messages: Message[] = [ + { + id: "msg-user-task-card", + role: "user", + content: "分析 claudecode 项目为什么没有 task 视图", + timestamp: now, + }, + { + id: "msg-assistant-task-card", + role: "assistant", + content: "已经定位到主聊天区没有任务投影层。", + timestamp: now, + usage: { + input_tokens: 1_800, + output_tokens: 640, + cached_input_tokens: 0, + }, + }, + ]; + + mockUseConfiguredProviders.mockImplementation(() => ({ + providers: [ + { + key: "custom-provider-id", + label: "GLM Anthropic", + registryId: "custom-provider-id", + type: "anthropic-compatible", + providerId: "custom-provider-id", + }, + ], + loading: false, + })); + mockFindConfiguredProviderBySelection.mockImplementation( + ( + providers: MockConfiguredProvider[], + selection?: string | null, + ): MockConfiguredProvider | null => + Array.isArray(providers) + ? (providers.find((provider) => provider.key === selection) ?? null) + : null, + ); + + const container = render(messages, { + providerType: "custom-provider-id", + turns: [ + { + id: "turn-task-card", + thread_id: "thread-task-card", + prompt_text: "分析 claudecode 项目为什么没有 task 视图", + status: "completed", + started_at: "2026-04-14T10:00:00Z", + completed_at: "2026-04-14T10:00:06Z", + created_at: "2026-04-14T10:00:00Z", + updated_at: "2026-04-14T10:00:06Z", + }, + ], + currentTurnId: "turn-task-card", + threadRead: { + thread_id: "thread-task-card", + status: "completed", + }, + threadItems: [ + { + id: "tool-read-task-card", + type: "tool_call", + thread_id: "thread-task-card", + turn_id: "turn-task-card", + sequence: 1, + status: "completed", + started_at: "2026-04-14T10:00:01Z", + completed_at: "2026-04-14T10:00:02Z", + updated_at: "2026-04-14T10:00:02Z", + tool_name: "Read", + arguments: { file_path: "/repo/src/main.tsx" }, + }, + { + id: "tool-list-task-card", + type: "command_execution", + thread_id: "thread-task-card", + turn_id: "turn-task-card", + sequence: 2, + status: "completed", + started_at: "2026-04-14T10:00:02Z", + completed_at: "2026-04-14T10:00:03Z", + updated_at: "2026-04-14T10:00:03Z", + command: "ls /repo/src", + cwd: "/repo", + }, + ], + childSubagentSessions: [ + { + id: "sub-task-card-1", + name: "子任务 1", + created_at: now.getTime(), + updated_at: now.getTime(), + session_type: "subagent", + runtime_status: "completed", + }, + ], + }); + + expect(container.querySelector('[data-testid="agent-task-strip"]')).toBeNull(); + expect( + container.querySelector('[data-testid="assistant-message-meta-footer"]'), + ).not.toBeNull(); + expect( + container.querySelector('[data-testid="inputbar-runtime-status-line"]'), + ).not.toBeNull(); + expect(container.textContent).toContain("已完成"); + expect(container.textContent).toContain("00:06"); + expect(container.textContent).toContain("工具 读 1 / 列 1"); + expect(container.textContent).toContain("任务 0/1"); + expect(container.textContent).toContain("输入 1.8K / 输出 640"); + expect(container.textContent).toContain("Prompt Cache 未声明自动缓存"); + expect( + container.querySelector('[data-testid="token-usage-display"]'), + ).toBeNull(); + }); + + it("流式运行态不应再在消息底部重复渲染阶段 pill", () => { + const now = new Date(); + const messages: Message[] = [ + { + id: "msg-assistant-runtime-footer", + role: "assistant", + content: "我先查看项目结构。", + timestamp: now, + isThinking: true, + runtimeStatus: { + phase: "context", + title: "正在整理相关信息", + detail: "已开始聚焦当前仓库。", + checkpoints: ["首批只读工具待执行"], + }, + }, + ]; + + const container = render(messages); + + expect( + container.querySelector('[data-testid="message-runtime-status-pill"]'), + ).toBeNull(); + expect( + container.querySelector('[data-testid="assistant-message-meta-footer"]'), + ).toBeNull(); + expect(container.textContent).not.toContain("正在整理相关信息"); + }); + + it("assistant 消息结算区应以内联模式承载 token usage", () => { + const now = new Date(); + const messages: Message[] = [ + { + id: "msg-assistant-inline-usage", + role: "assistant", + content: "本轮已完成。", + timestamp: now, + usage: { + input_tokens: 1_200, + output_tokens: 300, + cached_input_tokens: 0, + }, + }, + ]; + + render(messages); + + expect(mockTokenUsageDisplay).toHaveBeenCalledWith( + expect.objectContaining({ + inline: true, + }), + ); + }); + + it("第二轮开始后,上一轮 assistant 的工具调用块不应被从正文投影中剥离", () => { + const firstTurnTime = new Date("2026-04-15T09:00:00.000Z"); + const secondTurnTime = new Date("2026-04-15T09:00:10.000Z"); + const completedToolCall = { + id: "tool-read-1", + name: "Read", + arguments: '{"file_path":"/repo/src/index.ts"}', + status: "completed" as const, + startTime: new Date("2026-04-15T09:00:01.000Z"), + endTime: new Date("2026-04-15T09:00:02.000Z"), + result: { + success: true, + output: "export const answer = 42;", + }, + }; + const messages: Message[] = [ + { + id: "msg-user-first-turn", + role: "user", + content: "先分析项目结构", + timestamp: firstTurnTime, + }, + { + id: "msg-assistant-first-turn", + role: "assistant", + content: "已经整理完第一轮分析。", + timestamp: new Date("2026-04-15T09:00:03.000Z"), + toolCalls: [completedToolCall], + contentParts: [ + { + type: "tool_use", + toolCall: completedToolCall, + }, + { + type: "text", + text: "已经整理完第一轮分析。", + }, + ], + }, + { + id: "msg-user-second-turn", + role: "user", + content: "继续追问第二轮", + timestamp: secondTurnTime, + }, + { + id: "msg-assistant-second-turn", + role: "assistant", + content: "", + timestamp: new Date("2026-04-15T09:00:11.000Z"), + isThinking: true, + contentParts: [ + { + type: "thinking", + text: "准备继续查看模块边界。", + }, + ], + runtimeStatus: { + phase: "preparing", + title: "准备继续分析", + detail: "正在建立第二轮上下文。", + checkpoints: ["等待下一步工具调用"], + }, + }, + ]; + + render(messages); + + const firstAssistantCall = mockStreamingRenderer.mock.calls.find( + ([props]) => props.content === "已经整理完第一轮分析。", + )?.[0]; + const secondAssistantCall = mockStreamingRenderer.mock.calls.find( + ([props]) => props.content === "", + )?.[0]; + + expect(firstAssistantCall?.contentParts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "tool_use", + }), + ]), + ); + expect(firstAssistantCall?.thinkingContent).toBeUndefined(); + expect(secondAssistantCall?.contentParts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "thinking", + }), + ]), + ); + }); + it("图片任务消息卡应在聊天区渲染预览并支持展开图片画布", () => { const now = new Date(); const messages: Message[] = [ @@ -1134,7 +1438,7 @@ describe("MessageList", () => { ); }); - it("存在主执行轨迹时不应再默认压掉正文过程流", () => { + it("已完成 assistant 消息应只向正文传递可见正文片段", () => { const now = new Date(); const messages: Message[] = [ { @@ -1142,6 +1446,40 @@ describe("MessageList", () => { role: "assistant", content: "最终说明", timestamp: now, + thinkingContent: "这段思考应只留在执行轨迹中。", + contentParts: [ + { + type: "thinking", + text: "这段思考应只留在执行轨迹中。", + }, + { + type: "tool_use", + toolCall: { + id: "tool-process-suppressed-1", + name: "functions.exec_command", + arguments: JSON.stringify({ cmd: "rg -n process src" }), + status: "completed", + result: { success: true, output: "ok" }, + startTime: now, + endTime: now, + }, + }, + { + type: "text", + text: "最终说明", + }, + ], + toolCalls: [ + { + id: "tool-process-suppressed-1", + name: "functions.exec_command", + arguments: JSON.stringify({ cmd: "rg -n process src" }), + status: "completed", + result: { success: true, output: "ok" }, + startTime: now, + endTime: now, + }, + ], }, ]; @@ -1178,9 +1516,60 @@ describe("MessageList", () => { expect(mockStreamingRenderer).not.toHaveBeenCalledWith( expect.objectContaining({ suppressProcessFlow: true }), ); + expect(mockStreamingRenderer).toHaveBeenCalledWith( + expect.objectContaining({ + thinkingContent: undefined, + toolCalls: undefined, + contentParts: [{ type: "text", text: "最终说明" }], + }), + ); }); - it("正文已承载工具调用时,不应再把执行轨迹提前到消息顶部", () => { + it("流式 assistant 消息仍应向正文传递当前过程状态", () => { + const now = new Date(); + const messages: Message[] = [ + { + id: "msg-assistant-streaming-process", + role: "assistant", + content: "", + timestamp: now, + isThinking: true, + thinkingContent: "先读取当前实现。", + contentParts: [ + { + type: "thinking", + text: "先读取当前实现。", + }, + ], + toolCalls: [ + { + id: "tool-streaming-process-1", + name: "Read", + arguments: JSON.stringify({ file_path: "src/app.tsx" }), + status: "running", + startTime: now, + }, + ], + }, + ]; + + render(messages); + + expect(mockStreamingRenderer).toHaveBeenCalledWith( + expect.objectContaining({ + thinkingContent: "先读取当前实现。", + toolCalls: [ + expect.objectContaining({ + id: "tool-streaming-process-1", + status: "running", + }), + ], + contentParts: [{ type: "thinking", text: "先读取当前实现。" }], + }), + ); + }); + + it("已完成工具调用应回到消息顶部执行轨迹展示,不再占用正文主视觉", () => { const now = new Date(); const messages: Message[] = [ { @@ -1240,15 +1629,102 @@ describe("MessageList", () => { ], }); - expect( - container.querySelector('[data-testid="agent-thread-timeline:leading"]'), - ).toBeNull(); expect( container.querySelector('[data-testid="agent-thread-timeline:trailing"]'), ).toBeNull(); + expect( + container.querySelector('[data-testid="agent-thread-timeline:leading"]'), + ).not.toBeNull(); expect(mockStreamingRenderer).toHaveBeenCalledWith( expect.objectContaining({ - renderProposedPlanBlocks: true, + renderProposedPlanBlocks: false, + toolCalls: undefined, + contentParts: [{ type: "text", text: "已经定位到问题根因。" }], + }), + ); + }); + + it("当前回合仍在运行时,即使 assistant 非 streaming 占位也应继续透传工具调用", () => { + const now = new Date(); + const messages: Message[] = [ + { + id: "msg-assistant-active-turn", + role: "assistant", + content: "正在分析依赖关系。", + timestamp: now, + runtimeStatus: { + phase: "reasoning", + title: "处理中", + detail: "正在读取多个 crate 的依赖。", + }, + contentParts: [ + { + type: "tool_use", + toolCall: { + id: "tool-active-turn-1", + name: "functions.exec_command", + arguments: JSON.stringify({ + cmd: "sed -n '1,120p' Cargo.toml", + }), + status: "running", + startTime: now, + }, + }, + { + type: "text", + text: "正在分析依赖关系。", + }, + ], + toolCalls: [ + { + id: "tool-active-turn-1", + name: "functions.exec_command", + arguments: JSON.stringify({ + cmd: "sed -n '1,120p' Cargo.toml", + }), + status: "running", + startTime: now, + }, + ], + }, + ]; + + render(messages, { + currentTurnId: "turn-active-turn", + turns: [ + { + id: "turn-active-turn", + thread_id: "thread-active-turn", + prompt_text: "继续分析", + status: "running", + started_at: "2026-04-15T10:00:00Z", + created_at: "2026-04-15T10:00:00Z", + updated_at: "2026-04-15T10:00:03Z", + }, + ], + threadRead: { + thread_id: "thread-active-turn", + status: "running", + }, + }); + + expect(mockStreamingRenderer).toHaveBeenCalledWith( + expect.objectContaining({ + toolCalls: [ + expect.objectContaining({ + id: "tool-active-turn-1", + status: "running", + }), + ], + contentParts: [ + expect.objectContaining({ + type: "tool_use", + }), + { + type: "text", + text: "正在分析依赖关系。", + }, + ], }), ); }); @@ -1331,7 +1807,7 @@ describe("MessageList", () => { ).not.toBeNull(); }); - it("正文已承载过程流时,未被正文承载的计划信息仍应保留在消息前序", () => { + it("完成态 process 不再占正文时,计划信息应回到消息前序执行轨迹", () => { const now = new Date(); const messages: Message[] = [ { @@ -1390,7 +1866,9 @@ describe("MessageList", () => { ).toBeNull(); expect(mockStreamingRenderer).toHaveBeenCalledWith( expect.objectContaining({ - renderProposedPlanBlocks: true, + renderProposedPlanBlocks: false, + thinkingContent: undefined, + contentParts: [{ type: "text", text: "已经整理完执行思路。" }], }), ); }); @@ -1728,6 +2206,52 @@ describe("MessageList", () => { ); }); + it("本地工具批次的阶段结论不应再进入主消息流时间线", () => { + const now = new Date(); + const messages: Message[] = [ + { + id: "msg-assistant-local-batch", + role: "assistant", + content: "", + timestamp: now, + }, + ]; + + const container = render(messages, { + currentTurnId: "turn-local-batch", + turns: [ + { + id: "turn-local-batch", + thread_id: "thread-1", + prompt_text: "分析本地仓库", + status: "running", + started_at: "2026-04-14T10:00:00Z", + created_at: "2026-04-14T10:00:00Z", + updated_at: "2026-04-14T10:00:10Z", + }, + ], + threadItems: [ + { + id: "summary-local-batch-1", + thread_id: "thread-1", + turn_id: "turn-local-batch", + sequence: 1, + status: "in_progress", + started_at: "2026-04-14T10:00:00Z", + updated_at: "2026-04-14T10:00:10Z", + type: "turn_summary", + text: "已完成一批本地分析\n已完成这一批本地仓库的文件读取,正在整理这一批结果并判断是否还需要继续取证。", + }, + ], + }); + + expect( + container.querySelector('[data-testid="agent-thread-timeline:trailing"]'), + ).toBeNull(); + expect(container.textContent).not.toContain("已完成一批本地分析"); + expect(container.textContent).not.toContain("正在整理这一批结果"); + }); + it("已完成且已有真实过程项的 turn_summary 不应再单独占用消息头部或尾部", () => { const now = new Date(); const messages: Message[] = [ diff --git a/src/components/agent/chat/components/MessageList.tsx b/src/components/agent/chat/components/MessageList.tsx index 0c401a857..2916d31d7 100644 --- a/src/components/agent/chat/components/MessageList.tsx +++ b/src/components/agent/chat/components/MessageList.tsx @@ -1,4 +1,5 @@ import React, { + useCallback, useState, useRef, useEffect, @@ -6,6 +7,7 @@ import React, { useLayoutEffect, } from "react"; import { + AlertTriangle, Copy, Quote, Check, @@ -14,6 +16,7 @@ import { ExternalLink, Sparkles, BookmarkPlus, + Square, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { toast } from "sonner"; @@ -36,6 +39,7 @@ import { TokenUsageDisplay } from "./TokenUsageDisplay"; import { AgentThreadTimeline } from "./AgentThreadTimeline"; import { ImageWorkbenchMessagePreview } from "./ImageWorkbenchMessagePreview"; import { TaskMessagePreview } from "./TaskMessagePreview"; +import { InputbarRuntimeStatusLine } from "./Inputbar/components/InputbarRuntimeStatusLine"; import { formatArtifactWritePhaseLabel, resolveArtifactPreviewText, @@ -47,10 +51,12 @@ import { sanitizeMessageTextForDisplay, } from "../utils/internalImagePlaceholder"; import { isHiddenConversationArtifactPath } from "../utils/internalArtifactVisibility"; +import { buildInputbarRuntimeStatusLineModel } from "../utils/inputbarRuntimeStatusLine"; import { isInternalRoutingTurnSummaryText } from "../utils/turnSummaryPresentation"; import { Message, type ActionRequired, + type AgentRuntimeStatus, type AgentThreadItem, type AgentThreadTurn, type MessagePreviewTarget, @@ -61,10 +67,13 @@ import { import type { A2UIFormData } from "@/lib/workspace/a2ui"; import type { ConfirmResponse } from "../types"; import type { + AsterSubagentSessionInfo, AgentRuntimeThreadReadModel, QueuedTurnSnapshot, } from "@/lib/api/agentRuntime"; -import { buildMessageTurnTimeline } from "../utils/threadTimelineView"; +import { + buildMessageTurnTimeline, +} from "../utils/threadTimelineView"; import { buildMessageTurnGroups } from "../utils/messageTurnGrouping"; import { resolveLatestProjectFileSavedSiteContentTargetFromMessage } from "../utils/latestSavedSiteContentTarget"; import { @@ -84,6 +93,7 @@ interface MessageListProps { pendingActions?: ActionRequired[]; submittedActionsInFlight?: ActionRequired[]; queuedTurns?: QueuedTurnSnapshot[]; + childSubagentSessions?: AsterSubagentSessionInfo[]; isSending?: boolean; assistantLabel?: string; onDeleteMessage?: (id: string) => void; @@ -173,6 +183,57 @@ function resolvePromptCacheActivity(usage?: { ); } +function normalizeRuntimeStatusMetaText(value?: string | null): string { + return (value || "").trim().replace(/\s+/g, " "); +} + +const MessageRuntimeStatusPill: React.FC<{ + status: AgentRuntimeStatus; +}> = ({ status }) => { + const failed = status.phase === "failed"; + const cancelled = status.phase === "cancelled"; + const ToneIcon = failed ? AlertTriangle : cancelled ? Square : Loader2; + const titleText = normalizeRuntimeStatusMetaText(status.title); + const detailText = normalizeRuntimeStatusMetaText(status.detail); + const checkpointsText = (status.checkpoints || []) + .map((item) => normalizeRuntimeStatusMetaText(item)) + .filter(Boolean) + .slice(0, 2) + .join(" · "); + const tooltip = [titleText, detailText, checkpointsText] + .filter((item, index, array) => Boolean(item) && array.indexOf(item) === index) + .join("\n"); + + return ( +
    + + {titleText || "处理中"} +
    + ); +}; + +function shouldRenderRuntimeStatusPill( + status?: AgentRuntimeStatus | null, +): boolean { + return status?.phase === "failed" || status?.phase === "cancelled"; +} + function isDeferredTimelineItem(item: AgentThreadItem): boolean { return item.type === "file_artifact" || item.type === "turn_summary"; } @@ -238,6 +299,10 @@ function shouldRenderConversationTimelineItem( return true; } + if (isInternalRoutingTurnSummaryText(item.text)) { + return false; + } + if (item.status === "in_progress" && options?.hasInlineRuntimeStatus) { return false; } @@ -246,10 +311,6 @@ function shouldRenderConversationTimelineItem( return true; } - if (isInternalRoutingTurnSummaryText(item.text)) { - return false; - } - return !timelineItems.some( (entry) => entry.id !== item.id && entry.type !== "turn_summary", ); @@ -330,10 +391,12 @@ function resolveInlineProcessCoverage(params: { actionRequests?: Message["actionRequests"]; }): InlineProcessCoverage { const contentParts = params.contentParts || []; + const toolNameCounts = new Map(); + const actionRequestCounts = new Map(); + let thinking = false; + if (contentParts.length > 0) { - const toolNameCounts = new Map(); - const actionRequestCounts = new Map(); - const thinking = contentParts.some( + thinking = contentParts.some( (part) => part.type === "thinking" && part.text.trim().length > 0, ); contentParts.forEach((part) => { @@ -351,29 +414,21 @@ function resolveInlineProcessCoverage(params: { ); } }); - return { - hasInlineProcessEntries: - thinking || toolNameCounts.size > 0 || actionRequestCounts.size > 0, - thinking, - toolNameCounts, - actionRequestCounts, - }; + } else { + thinking = Boolean(params.thinkingContent?.trim()); + (params.toolCalls || []).forEach((toolCall) => { + incrementInlineCoverageCount( + toolNameCounts, + normalizeInlineCoverageKey(toolCall.name), + ); + }); } - const toolNameCounts = new Map(); - const actionRequestCounts = new Map(); - const thinking = Boolean(params.thinkingContent?.trim()); - (params.toolCalls || []).forEach((toolCall) => { - incrementInlineCoverageCount( - toolNameCounts, - normalizeInlineCoverageKey(toolCall.name), - ); - }); (params.actionRequests || []).forEach((actionRequest) => { - incrementInlineCoverageCount( - actionRequestCounts, - normalizeInlineCoverageKey(actionRequest.requestId), - ); + const actionKey = normalizeInlineCoverageKey(actionRequest.requestId); + if (actionKey && !actionRequestCounts.has(actionKey)) { + incrementInlineCoverageCount(actionRequestCounts, actionKey); + } }); return { @@ -385,6 +440,31 @@ function resolveInlineProcessCoverage(params: { }; } +function filterConversationDisplayContentParts( + parts: Message["contentParts"] | undefined, + options: { + includeProcessFlow: boolean; + preserveToolUseParts: boolean; + }, +): Message["contentParts"] | undefined { + if (!parts || parts.length === 0 || options.includeProcessFlow) { + return parts; + } + + const filtered = parts.filter((part) => { + if (part.type === "thinking") { + return false; + } + + if (part.type === "tool_use") { + return options.preserveToolUseParts; + } + + return true; + }); + return filtered.length > 0 ? filtered : undefined; +} + const MessageListInner: React.FC = ({ messages, emptyStateVariant = "default", @@ -392,6 +472,10 @@ const MessageListInner: React.FC = ({ threadItems = [], currentTurnId = null, threadRead = null, + pendingActions = [], + queuedTurns = [], + childSubagentSessions = [], + isSending = false, assistantLabel = "Lime", onQuoteMessage, onA2UISubmit, @@ -413,6 +497,7 @@ const MessageListInner: React.FC = ({ onCodeBlockClick, promoteActionRequestsToA2UI = false, isRestoringSession = false, + onInterruptCurrentTurn, compactLeadingSpacing = false, focusedTimelineItemId = null, timelineFocusRequestKey = 0, @@ -475,6 +560,34 @@ const MessageListInner: React.FC = ({ .find((message) => message.role === "assistant")?.id ?? null, [visibleMessages], ); + const tailRuntimeStatusLine = useMemo(() => { + if (!lastAssistantMessageId) { + return null; + } + + return buildInputbarRuntimeStatusLineModel({ + messages: visibleMessages, + turns, + threadItems, + currentTurnId, + threadRead, + pendingActions, + queuedTurns, + childSubagentSessions, + isSending, + }); + }, [ + childSubagentSessions, + currentTurnId, + isSending, + lastAssistantMessageId, + pendingActions, + queuedTurns, + threadItems, + threadRead, + turns, + visibleMessages, + ]); const currentTurnTimeline = useMemo(() => { if (!currentTurnId || !lastAssistantMessageId) { return null; @@ -495,6 +608,76 @@ const MessageListInner: React.FC = ({ () => buildMessageTurnGroups(visibleMessages), [visibleMessages], ); + const renderGroups = useMemo( + () => + messageGroups.map((group) => { + const lastAssistantId = + group.assistantMessages[group.assistantMessages.length - 1]?.id ?? null; + const mappedTimeline = + group.assistantMessages + .map((message) => timelineByMessageId.get(message.id)) + .find(Boolean) ?? null; + const isActiveGroup = + Boolean(lastAssistantId) && lastAssistantId === lastAssistantMessageId; + const timeline = + isActiveGroup && currentTurnTimeline + ? currentTurnTimeline + : mappedTimeline; + + return { + ...group, + lastAssistantId, + timeline, + isActiveGroup, + }; + }), + [ + currentTurnTimeline, + lastAssistantMessageId, + messageGroups, + timelineByMessageId, + ], + ); + const shouldKeepInlineProcessForActiveAssistant = useCallback( + (message: Message, isConversationTailAssistant: boolean): boolean => { + if (message.role !== "assistant") { + return false; + } + + if (message.isThinking) { + return true; + } + + if (!isConversationTailAssistant) { + return false; + } + + const hasRunningToolCall = + (message.toolCalls || []).some((toolCall) => toolCall.status === "running") || + (message.contentParts || []).some( + (part) => + part.type === "tool_use" && part.toolCall.status === "running", + ); + const hasPendingActionRequest = + (message.actionRequests || []).some( + (request) => request.status !== "submitted", + ) || + (message.contentParts || []).some( + (part) => + part.type === "action_required" && + part.actionRequired.status !== "submitted", + ); + const hasActiveRuntimeStatus = + Boolean(message.runtimeStatus) && + message.runtimeStatus?.phase !== "failed" && + message.runtimeStatus?.phase !== "cancelled"; + + return ( + hasRunningToolCall || hasPendingActionRequest || hasActiveRuntimeStatus + ); + }, + [], + ); // 检测用户是否在手动滚动 useEffect(() => { @@ -559,7 +742,10 @@ const MessageListInner: React.FC = ({ } }; - const renderMessageItem = (msg: Message) => { + const renderMessageItem = ( + msg: Message, + group: (typeof renderGroups)[number], + ) => { const hasImages = Array.isArray(msg.images) && msg.images.length > 0; const displayContent = sanitizeMessageTextForDisplay(msg.content || "", { role: msg.role, @@ -572,21 +758,50 @@ const MessageListInner: React.FC = ({ hasImages, }, ); - const inlineProcessCoverage = resolveInlineProcessCoverage({ - contentParts: displayContentParts, - thinkingContent: msg.thinkingContent, - toolCalls: msg.toolCalls, - actionRequests: msg.actionRequests, - }); - const mappedTimeline = timelineByMessageId.get(msg.id); + const isConversationTailAssistant = + msg.role === "assistant" && msg.id === group.lastAssistantId; const timeline = msg.role !== "assistant" ? null - : msg.id === lastAssistantMessageId - ? currentTurnTimeline || mappedTimeline || null - : mappedTimeline?.turn.id === currentTurnTimeline?.turn.id - ? null - : mappedTimeline || null; + : isConversationTailAssistant + ? group.timeline + : null; + const hasProcessTimelineItems = Boolean( + timeline?.items.some( + (item) => + item.type === "reasoning" || + item.type === "tool_call" || + item.type === "command_execution" || + item.type === "web_search", + ), + ); + const includeInlineProcessFlow = + msg.role === "assistant" && + shouldKeepInlineProcessForActiveAssistant( + msg, + isConversationTailAssistant, + ); + const conversationContentParts = + msg.role === "assistant" + ? filterConversationDisplayContentParts(displayContentParts, { + includeProcessFlow: includeInlineProcessFlow, + preserveToolUseParts: !hasProcessTimelineItems, + }) + : displayContentParts; + const conversationThinkingContent = + msg.role === "assistant" && includeInlineProcessFlow + ? msg.thinkingContent + : undefined; + const conversationToolCalls = + msg.role === "assistant" && includeInlineProcessFlow + ? msg.toolCalls + : undefined; + const inlineProcessCoverage = resolveInlineProcessCoverage({ + contentParts: conversationContentParts, + thinkingContent: conversationThinkingContent, + toolCalls: conversationToolCalls, + actionRequests: msg.actionRequests, + }); const timelineConversationItems = timeline ? timeline.items.filter((item) => shouldRenderConversationTimelineItem(item, timeline.items, { @@ -692,6 +907,19 @@ const MessageListInner: React.FC = ({ onOpenSavedSiteContent && !hasTrailingArtifactTimelineItems, ); + const shouldRenderTailRuntimeStatusLine = + msg.role === "assistant" && + msg.id === lastAssistantMessageId && + isConversationTailAssistant && + Boolean(tailRuntimeStatusLine); + const shouldRenderUsageFooter = + isConversationTailAssistant && + !shouldRenderTailRuntimeStatusLine && + !msg.isThinking && + Boolean(msg.usage); + const shouldRenderStatusPill = + !shouldRenderTailRuntimeStatusLine && + shouldRenderRuntimeStatusPill(msg.runtimeStatus); const messageCanvasShortcutTitle = messageSavedSiteContentTarget ? resolveSiteSavedContentTargetDisplayName( messageSavedSiteContentTarget, @@ -735,11 +963,11 @@ const MessageListInner: React.FC = ({ = ({ /> ) : null} - {msg.role === "assistant" && !msg.isThinking && msg.usage && ( - - )} - {msg.role === "assistant" && - !msg.isThinking && - msg.contextTrace && - msg.contextTrace.length > 0 && ( -
    - - 上下文轨迹 ({msg.contextTrace.length}) - -
    - {msg.contextTrace.map((step, index) => ( -
    - - {step.stage} - - : - - {step.detail} - -
    - ))} -
    -
    - )} + (shouldRenderTailRuntimeStatusLine || + shouldRenderStatusPill || + shouldRenderUsageFooter) ? ( +
    + {shouldRenderTailRuntimeStatusLine ? ( + + ) : null} + {shouldRenderStatusPill && msg.runtimeStatus ? ( + + ) : null} + {shouldRenderUsageFooter ? ( + + ) : null} +
    + ) : null} {showMessageActions ? ( @@ -1157,7 +1382,7 @@ const MessageListInner: React.FC = ({
    ))} - {messageGroups.map((group, groupIndex) => { + {renderGroups.map((group, groupIndex) => { return (
    = ({ className="py-2" >
    - {group.messages.map((msg) => renderMessageItem(msg))} + {group.messages.map((msg) => renderMessageItem(msg, group))}
    ); diff --git a/src/components/agent/chat/components/StreamingRenderer.test.tsx b/src/components/agent/chat/components/StreamingRenderer.test.tsx index 43c6da32d..a5dc02ae1 100644 --- a/src/components/agent/chat/components/StreamingRenderer.test.tsx +++ b/src/components/agent/chat/components/StreamingRenderer.test.tsx @@ -289,6 +289,44 @@ describe("StreamingRenderer", () => { expect(parseAIResponseMock).toHaveBeenCalledTimes(1); }); + it("连续探索工具应默认折叠成批次摘要", () => { + const { container } = renderHarness({ + content: "", + toolCalls: [ + { + id: "tool-search-1", + name: "Grep", + arguments: JSON.stringify({ + pattern: "tool_use_summary", + path: "/workspace/src", + }), + status: "completed", + result: { success: true, output: "found" }, + startTime: new Date("2026-04-01T10:00:00.000Z"), + endTime: new Date("2026-04-01T10:00:01.000Z"), + }, + { + id: "tool-read-1", + name: "Read", + arguments: JSON.stringify({ + file_path: "/workspace/src/query.ts", + }), + status: "completed", + result: { success: true, output: "file contents" }, + startTime: new Date("2026-04-01T10:00:02.000Z"), + endTime: new Date("2026-04-01T10:00:03.000Z"), + }, + ], + }); + + expect(container.textContent).toContain("已探索项目"); + expect(container.textContent).toContain("查看了 1 个文件,搜索 1 次"); + expect(container.textContent).toContain("最新线索:query.ts"); + expect( + container.querySelector('[data-testid="inline-tool-process-step"]'), + ).toBeNull(); + }); + it("普通工具列表应透传已保存站点内容打开回调", () => { const onOpenSavedSiteContent = vi.fn(); const { container } = renderHarness({ @@ -401,7 +439,7 @@ describe("StreamingRenderer", () => { ], }); - expect(container.textContent).toContain("1 个工具调用,1 条过程消息"); + expect(container.textContent).toContain("先检查滚动触发逻辑 · 1 个工具调用"); expect( container.querySelector('[data-testid="streaming-process-group"]'), ).toBeTruthy(); @@ -447,7 +485,9 @@ describe("StreamingRenderer", () => { isStreaming: false, }); - expect(container.textContent).toContain("1 个工具调用,2 条过程消息"); + expect(container.textContent).toContain( + "先检查 auto-scroll 触发条件 · 1 个工具调用", + ); expect( container.querySelector('[data-testid="streaming-process-group"]'), ).toBeTruthy(); @@ -621,7 +661,7 @@ describe("StreamingRenderer", () => { expect(container.textContent).toContain("然后开始执行"); }); - it("等待首个事件时应渲染 agent 运行状态卡", () => { + it("等待首个事件时不应再把 agent 运行状态插入正文顶部", () => { const { container } = renderHarness({ content: "", isStreaming: true, @@ -634,12 +674,13 @@ describe("StreamingRenderer", () => { showRuntimeStatusInline: true, }); - expect(container.textContent).toContain("正在准备处理"); - expect(container.textContent).toContain("正在理解请求并准备当前阶段。"); - expect(container.textContent).toContain("等待首个事件"); + expect( + container.querySelector('[data-testid="agent-runtime-status"]'), + ).toBeNull(); + expect(container.textContent).not.toContain("正在准备处理"); }); - it("高风险服务进入稳妥顺序处理时,应显示稳妥处理提示", () => { + it("高风险服务进入稳妥顺序处理时,正文顶部不应再出现运行态卡", () => { const { container } = renderHarness({ content: "", isStreaming: true, @@ -658,11 +699,12 @@ describe("StreamingRenderer", () => { showRuntimeStatusInline: true, }); - expect(container.textContent).toContain("当前服务较忙,稍后开始处理"); - expect(container.textContent).toContain("稳妥处理"); + expect( + container.querySelector('[data-testid="agent-runtime-status"]'), + ).toBeNull(); }); - it("正文已经开始输出后,仍应继续显示轻量运行状态", () => { + it("正文已经开始输出后,不应再在正文区域重复渲染运行态", () => { const { container } = renderHarness({ content: "我来帮你先打开 GitHub 搜索页。", isStreaming: true, @@ -677,15 +719,10 @@ describe("StreamingRenderer", () => { expect( container.querySelector('[data-testid="agent-runtime-status"]'), - ).toBeTruthy(); - expect(container.textContent).toContain("正在搜索 GitHub"); - expect(container.textContent).toContain( - "已经打开搜索页,准备补充筛选条件。", - ); - expect(container.textContent).toContain("浏览器已就绪"); + ).toBeNull(); }); - it("交错内容模式下也应继续显示轻量运行状态", () => { + it("交错内容模式下也不应在正文区域渲染运行态卡", () => { const { container } = renderHarness({ content: "", isStreaming: true, @@ -717,12 +754,10 @@ describe("StreamingRenderer", () => { expect( container.querySelector('[data-testid="agent-runtime-status"]'), - ).toBeTruthy(); - expect(container.textContent).toContain("正在整理搜索结果"); - expect(container.textContent).toContain("页面内容已获取"); + ).toBeNull(); }); - it("已取消的运行状态应显示中性文案,而不是失败态文案", () => { + it("已取消的运行状态也不应在正文顶部额外渲染状态卡", () => { const { container } = renderHarness({ content: "", isStreaming: true, @@ -735,13 +770,9 @@ describe("StreamingRenderer", () => { showRuntimeStatusInline: true, }); - const statusBlock = container.querySelector( - '[data-testid="agent-runtime-status"]', - ); - expect(statusBlock).toBeTruthy(); - expect(statusBlock?.textContent).toContain("已取消"); - expect(statusBlock?.textContent).toContain("图片任务已取消"); - expect(statusBlock?.textContent).not.toContain("需要处理"); + expect( + container.querySelector('[data-testid="agent-runtime-status"]'), + ).toBeNull(); }); it("思考内容进入流式阶段后应自动展开", () => { diff --git a/src/components/agent/chat/components/StreamingRenderer.tsx b/src/components/agent/chat/components/StreamingRenderer.tsx index ba2367de2..657357cad 100644 --- a/src/components/agent/chat/components/StreamingRenderer.tsx +++ b/src/components/agent/chat/components/StreamingRenderer.tsx @@ -8,12 +8,9 @@ import React, { memo, useMemo, useState, useEffect, useRef } from "react"; import { cn } from "@/lib/utils"; import { - AlertTriangle, ChevronDown, ExternalLink, FileText, - Loader2, - Square, } from "lucide-react"; import { useDebouncedValue } from "@/lib/artifact/hooks/useDebouncedValue"; import { MarkdownRenderer } from "./MarkdownRenderer"; @@ -47,6 +44,11 @@ import { sanitizeContentPartsForDisplay, sanitizeMessageTextForDisplay, } from "../utils/internalImagePlaceholder"; +import { + summarizeStreamingToolBatch, + type ToolBatchSummaryDescriptor, +} from "../utils/toolBatchGrouping"; +import { resolveToolProcessNarrative } from "../utils/toolProcessSummary"; const STRUCTURED_CONTENT_HINT_RE = / => + entry.kind === "tool", + ); + const batchDescriptor = + toolEntries.length === entries.length + ? summarizeStreamingToolBatch(toolEntries.map((entry) => entry.toolCall)) + : null; + if (batchDescriptor) { + return { + summaryText: batchDescriptor.title, + descriptor: batchDescriptor, + }; + } + const toolCount = entries.filter((entry) => entry.kind === "tool").length; const messageCount = entries.length - toolCount; - const summaryParts = [`${toolCount} 个工具调用`]; - if (messageCount > 0) { - summaryParts.push(`${messageCount} 条过程消息`); + const primarySummary = (() => { + for (const entry of entries) { + if (entry.kind === "thinking") { + const preview = resolveThinkingDisplayParts(entry.text, false).preview; + if (preview) { + return preview; + } + continue; + } + + if (entry.kind === "tool") { + const narrative = resolveToolProcessNarrative(entry.toolCall); + if (narrative.preSummary || narrative.summary) { + return narrative.preSummary || narrative.summary; + } + continue; + } + + const prompt = entry.actionRequired.prompt?.trim(); + if (prompt) { + return prompt.length <= 72 ? prompt : `${prompt.slice(0, 71).trimEnd()}…`; + } + } + + return null; + })(); + + if (!primarySummary) { + const summaryParts = [`${toolCount} 个工具调用`]; + if (messageCount > 0) { + summaryParts.push(`${messageCount} 条过程消息`); + } + return { + summaryText: summaryParts.join(","), + descriptor: null, + }; } - return summaryParts.join(","); + + if (toolCount === 0) { + return { + summaryText: primarySummary, + descriptor: null, + }; + } + + const countLabel = `${toolCount} 个工具调用`; + return { + summaryText: + entries.length > 1 ? `${primarySummary} · ${countLabel}` : primarySummary, + descriptor: null, + }; } const GroupedProcessShell: React.FC<{ @@ -728,7 +794,7 @@ const StreamingProcessGroup: React.FC<{ ) => React.ReactNode; }> = ({ entries, defaultExpanded = true, renderEntry }) => { const [expanded, setExpanded] = React.useState(defaultExpanded); - const summaryText = useMemo( + const { summaryText, descriptor } = useMemo( () => buildStreamingProcessSummary(entries), [entries], ); @@ -754,7 +820,19 @@ const StreamingProcessGroup: React.FC<{ )} /> - {summaryText} + {summaryText} + {descriptor?.supportingLines?.length ? ( + + {descriptor.supportingLines.slice(0, 2).map((line) => ( + + {line} + + ))} + + ) : null} {expanded ? ( @@ -828,139 +906,6 @@ interface StreamingRendererProps { onQuoteContent?: (content: string) => void; } -const RUNTIME_PHASE_LABELS: Record = { - preparing: "准备中", - routing: "处理中", - context: "整理信息中", - failed: "需要处理", - cancelled: "已取消", -}; - -function normalizeRuntimeStatusLine(value?: string | null): string { - return (value || "").trim().replace(/\s+/g, " "); -} - -function buildRuntimeStatusSupportingLines( - status: AgentRuntimeStatus, -): string[] { - const lines: string[] = []; - const normalizedTitle = normalizeRuntimeStatusLine(status.title); - const normalizedDetail = normalizeRuntimeStatusLine(status.detail); - - if (normalizedDetail && normalizedDetail !== normalizedTitle) { - lines.push(normalizedDetail); - } - - const checkpointText = (status.checkpoints || []) - .map((item) => normalizeRuntimeStatusLine(item)) - .filter(Boolean) - .filter((item, index, array) => array.indexOf(item) === index) - .filter((item) => item !== normalizedTitle && item !== normalizedDetail) - .slice(0, 2) - .join(" · "); - - if (checkpointText) { - lines.push(checkpointText); - } - - return lines; -} - -const AgentRuntimeStatusBlock: React.FC<{ status: AgentRuntimeStatus }> = ({ - status, -}) => { - const failed = status.phase === "failed"; - const cancelled = status.phase === "cancelled"; - const sequentialProtection = - !failed && - !cancelled && - status.metadata?.concurrency_scope === "provider_global" && - status.metadata?.retryable_overload; - const supportingLines = buildRuntimeStatusSupportingLines(status); - const ToneIcon = failed ? AlertTriangle : cancelled ? Square : Loader2; - - return ( -
    -
    -
    - -
    -
    -
    -
    - {status.title} -
    -
    - {RUNTIME_PHASE_LABELS[status.phase]} -
    - {sequentialProtection ? ( -
    - 稳妥处理 -
    - ) : null} -
    - {supportingLines.length > 0 ? ( -
    - {supportingLines.map((line) => ( -
    - {line} -
    - ))} -
    - ) : null} -
    -
    -
    - ); -}; - /** * 流式消息渲染组件 * @@ -992,8 +937,6 @@ export const StreamingRenderer: React.FC = memo( collapseCodeBlocks, shouldCollapseCodeBlock, onCodeBlockClick, - runtimeStatus, - showRuntimeStatusInline = false, promoteActionRequestsToA2UI = false, renderProposedPlanBlocks = true, suppressedActionRequestId = null, @@ -1307,11 +1250,21 @@ export const StreamingRenderer: React.FC = memo( (entry) => entry.kind === "tool", ).length; if (toolCount > 0 && entries.length > 1) { + const batchDescriptor = + entries.every((entry) => entry.kind === "tool") + ? summarizeStreamingToolBatch( + entries.map( + (entry) => + (entry as Extract) + .toolCall, + ), + ) + : null; return ( ); @@ -1512,8 +1465,6 @@ export const StreamingRenderer: React.FC = memo( if (useInterleavedMode) { const nodes: React.ReactNode[] = []; let processBuffer: StreamingProcessEntry[] = []; - const shouldShowRuntimeStatus = - showRuntimeStatusInline && Boolean(runtimeStatus) && isStreaming; const flushProcessBuffer = (keySuffix: string) => { if (processBuffer.length === 0) { @@ -1584,9 +1535,6 @@ export const StreamingRenderer: React.FC = memo( return (
    - {shouldShowRuntimeStatus && runtimeStatus ? ( - - ) : null} {nodes} {/* 如果没有内容但正在流式输出,显示光标 */} @@ -1626,14 +1574,9 @@ export const StreamingRenderer: React.FC = memo( actionRequired: request, }); } - const shouldShowRuntimeStatus = - showRuntimeStatusInline && Boolean(runtimeStatus) && isStreaming; return (
    - {shouldShowRuntimeStatus && runtimeStatus ? ( - - ) : null} {fallbackProcessEntries.length > 0 ? renderProcessRun(fallbackProcessEntries, "fallback-process") : null} @@ -1649,7 +1592,6 @@ export const StreamingRenderer: React.FC = memo( {!hasVisibleContent && isStreaming && showCursor && - !runtimeStatus && !hasRunningTools && (
    diff --git a/src/components/agent/chat/components/TeamSuggestionBar.tsx b/src/components/agent/chat/components/TeamSuggestionBar.tsx index e7a311bc1..c96c5b1c3 100644 --- a/src/components/agent/chat/components/TeamSuggestionBar.tsx +++ b/src/components/agent/chat/components/TeamSuggestionBar.tsx @@ -26,54 +26,110 @@ export function TeamSuggestionBar({ onContinueSingleAgent, compact = false, }: TeamSuggestionBarProps) { + const summaryText = + reasons[0] || "这次输入包含多阶段目标,适合按分析、执行、验证拆分推进。"; const containerClassName = compact - ? "mb-2 rounded-[18px] border border-slate-200/85 bg-gradient-to-r from-slate-50 via-white to-sky-50/70 px-3.5 py-3 shadow-sm shadow-slate-950/5" - : "mx-4 mb-3 rounded-[20px] border border-slate-200/85 bg-gradient-to-r from-slate-50 via-white to-sky-50/70 px-4 py-3 shadow-sm shadow-slate-950/5"; + ? "mb-1.5 flex max-w-full flex-wrap items-center gap-2 text-xs text-slate-500" + : "mx-4 mb-3 rounded-[18px] border border-slate-200/85 bg-slate-50/92 px-3.5 py-3 shadow-sm shadow-slate-950/5"; + + if (compact) { + return ( +
    + + + 分工建议 + + + {summaryText} + +
    + + +
    +
    + ); + } return (
    -
    -
    +
    +
    - + 任务分工建议 - {!compact ? ( + + 复杂任务可切到任务分工 + + {!compact && ( 适配度 {Math.round(score * 100)}% - ) : null} + )}
    -
    - 当前任务更适合分工推进 +
    + {compact ? summaryText : reasons.slice(0, 2).join(" ")}
    -
    - {reasons.slice(0, 2).join(" ")} -
    - {suggestedPresetLabel ? ( -
    + {suggestedPresetLabel && !compact ? ( +
    推荐预设:{suggestedPresetLabel}
    ) : null} -
    - {suggestedRoles.slice(0, 3).map((role) => ( - - 建议角色:{TEAM_ROLE_LABELS[role]} - - ))} -
    + {!compact && suggestedRoles.length > 0 ? ( +
    + {suggestedRoles.slice(0, 3).map((role) => ( + + 建议角色:{TEAM_ROLE_LABELS[role]} + + ))} +
    + ) : null}
    -
    +
    diff --git a/src/components/agent/chat/components/TokenUsageDisplay.test.tsx b/src/components/agent/chat/components/TokenUsageDisplay.test.tsx index 2fb0643da..5336548df 100644 --- a/src/components/agent/chat/components/TokenUsageDisplay.test.tsx +++ b/src/components/agent/chat/components/TokenUsageDisplay.test.tsx @@ -202,4 +202,27 @@ describe("TokenUsageDisplay", () => { "未声明支持自动 Prompt Cache", ); }); + + it("inline 模式不应阻断 token 文案展示", () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + mountedRoots.push({ container, root }); + + act(() => { + root.render( + , + ); + }); + + expect(container.textContent).toContain("31.0K tokens"); + expect(container.textContent).toContain("缓存 8.0K"); + }); }); diff --git a/src/components/agent/chat/components/TokenUsageDisplay.tsx b/src/components/agent/chat/components/TokenUsageDisplay.tsx index ef5574ddb..07bdf5b0d 100644 --- a/src/components/agent/chat/components/TokenUsageDisplay.tsx +++ b/src/components/agent/chat/components/TokenUsageDisplay.tsx @@ -56,6 +56,7 @@ interface TokenUsageDisplayProps { usage: TokenUsage; className?: string; promptCacheNotice?: TokenUsagePromptCacheNotice | null; + inline?: boolean; } function formatCompactTokenCount(value: number): string { @@ -110,6 +111,7 @@ export const TokenUsageDisplay: React.FC = ({ usage, className, promptCacheNotice, + inline = false, }) => { const total = usage.input_tokens + usage.output_tokens; const totalPromptCacheTokens = @@ -122,6 +124,7 @@ export const TokenUsageDisplay: React.FC = ({ return ( diff --git a/src/components/agent/chat/components/ToolCallDisplay.test.tsx b/src/components/agent/chat/components/ToolCallDisplay.test.tsx index f7175cccf..04be21609 100644 --- a/src/components/agent/chat/components/ToolCallDisplay.test.tsx +++ b/src/components/agent/chat/components/ToolCallDisplay.test.tsx @@ -345,7 +345,40 @@ describe("ToolCallDisplay", () => { expect(container.textContent).not.toContain("输出已截断"); expect(container.textContent).not.toContain("输出文件:"); expect(container.textContent).not.toContain("退出码 2"); - expect(container.textContent).not.toContain("exports/reports/final-result.md"); + expect(container.textContent).not.toContain( + "exports/reports/final-result.md", + ); + }); + + it("语义成功的非零退出码不应继续显示命令错误提示", () => { + const { container } = renderTool({ + id: "tool-exec-render-3", + name: "bash", + arguments: JSON.stringify({ command: "rg missing src" }), + status: "completed", + result: { + success: true, + output: "No matches found", + metadata: { + exit_code: 1, + stdout_length: 0, + stderr_length: 0, + reported_success: true, + }, + }, + startTime: new Date("2026-04-14T10:00:00.000Z"), + endTime: new Date("2026-04-14T10:00:01.000Z"), + }); + + act(() => { + const toggle = container.querySelector( + 'button[title="查看结果"]', + ) as HTMLButtonElement | null; + toggle?.click(); + }); + + expect(container.textContent).toContain("No matches found"); + expect(container.textContent).not.toContain("命令返回错误"); }); it("正式工具卡不应额外展示原始工具名", () => { @@ -417,14 +450,14 @@ describe("ToolCallDisplay", () => { expect(container.textContent).toContain( "结果已自动保存到当前项目:GitHub MCP 搜索结果", ); - expect(container.textContent).toContain( - "已导出 Markdown 文稿", + expect(container.textContent).toContain("已导出 Markdown 文稿"); + expect(container.textContent).toContain("附带图片 7 张"); + expect(container.textContent).not.toContain( + "exports/x-article-export/github-mcp/index.md", ); - expect(container.textContent).toContain( - "附带图片 7 张", + expect(container.textContent).not.toContain( + "exports/x-article-export/github-mcp/images", ); - expect(container.textContent).not.toContain("exports/x-article-export/github-mcp/index.md"); - expect(container.textContent).not.toContain("exports/x-article-export/github-mcp/images"); expect(container.textContent).not.toContain("项目目录:"); expect(container.textContent).not.toContain("脚本来源:"); }); @@ -684,9 +717,7 @@ describe("ToolCallDisplay", () => { toggle?.click(); }); - expect(container.textContent).toContain( - "执行失败,未保存到当前项目", - ); + expect(container.textContent).toContain("执行失败,未保存到当前项目"); expect(container.textContent).toContain("自动保存失败:数据库写入失败"); }); diff --git a/src/components/agent/chat/components/ToolCallDisplay.tsx b/src/components/agent/chat/components/ToolCallDisplay.tsx index 777cb9208..bee5ac067 100644 --- a/src/components/agent/chat/components/ToolCallDisplay.tsx +++ b/src/components/agent/chat/components/ToolCallDisplay.tsx @@ -27,15 +27,16 @@ import { isUnifiedWebSearchToolName, resolveSearchResultPreviewItemsFromText, } from "../utils/searchResultPreview"; -import { extractLimeToolMetadataBlock } from "../hooks/agentChatToolResult"; +import { + extractLimeToolMetadataBlock, + isToolResultSuccessful, +} from "../hooks/agentChatToolResult"; import { normalizeSiteToolResultSummary, resolveSiteProjectTargetLabel, resolveSiteSavedContentTargetFromMetadata, } from "../utils/siteToolResultSummary"; -import { - normalizeToolSearchResultSummary, -} from "../utils/toolSearchResultSummary"; +import { normalizeToolSearchResultSummary } from "../utils/toolSearchResultSummary"; import type { ToolCallArgumentValue } from "../utils/toolDisplayInfo"; import { buildGroupedChildLine as buildGroupedChildLineFromInfo, @@ -164,7 +165,9 @@ const buildRenderedToolResultContent = (params: { return content; }; -function resolveUserFacingPathName(path: string | null | undefined): string | null { +function resolveUserFacingPathName( + path: string | null | undefined, +): string | null { const trimmed = path?.trim(); if (!trimmed) { return null; @@ -567,6 +570,13 @@ export const ToolCallDisplay: React.FC = ({ const normalized = extractLimeToolMetadataBlock(rawText).text; return normalized || "(无输出)"; }, [toolCall.result?.error, toolCall.result?.output]); + const isResultFailure = useMemo(() => { + if (!toolCall.result) return isFailed; + return !isToolResultSuccessful({ + success: toolCall.result.success, + metadata: resultMetadata, + }); + }, [isFailed, resultMetadata, toolCall.result]); const resultMetaItems = useMemo(() => { if (!resultMetadata) return []; @@ -577,15 +587,12 @@ export const ToolCallDisplay: React.FC = ({ ) { items.push("内容较长,已省略部分文本"); } - if ( - typeof resultMetadata.exit_code === "number" && - (isFailed || resultMetadata.exit_code !== 0) - ) { + if (typeof resultMetadata.exit_code === "number" && isResultFailure) { items.push("命令返回错误"); } return items; - }, [isFailed, resultMetadata]); + }, [isResultFailure, resultMetadata]); const siteResultNotices = useMemo(() => { if (!siteResultSummary) return [] as ToolResultNotice[]; @@ -1003,7 +1010,7 @@ export const ToolCallDisplay: React.FC = ({
    diff --git a/src/components/agent/chat/components/ToolSearchSummaryPanel.test.tsx b/src/components/agent/chat/components/ToolSearchSummaryPanel.test.tsx index c36746ef0..0cb32fa9e 100644 --- a/src/components/agent/chat/components/ToolSearchSummaryPanel.test.tsx +++ b/src/components/agent/chat/components/ToolSearchSummaryPanel.test.tsx @@ -79,7 +79,9 @@ describe("ToolSearchSummaryPanel", () => { expect(container.textContent).toContain("搜索网页"); expect(container.textContent).toContain("没有找到更多匹配工具"); expect(container.textContent).not.toContain("Read"); - expect(container.textContent).not.toContain("mcp__playwright__browser_click"); + expect(container.textContent).not.toContain( + "mcp__playwright__browser_click", + ); expect(container.textContent).not.toContain("WebSearch"); expect(container.textContent).not.toContain("来源:"); expect(container.textContent).not.toContain("状态:"); @@ -102,4 +104,18 @@ describe("ToolSearchSummaryPanel", () => { expect(container.textContent).not.toContain("查询:"); expect(container.textContent).not.toContain("select:Read,Write"); }); + + it("应展示仍在连接中的 MCP 服务", () => { + const { container } = renderPanel({ + query: "slack send", + count: 0, + notes: [], + tools: [], + pendingMcpServers: ["slack", "playwright"], + }); + + expect(container.textContent).toContain( + "以下 MCP 服务仍在连接中:slack、playwright", + ); + }); }); diff --git a/src/components/agent/chat/components/ToolSearchSummaryPanel.tsx b/src/components/agent/chat/components/ToolSearchSummaryPanel.tsx index 18679475c..c42fee08d 100644 --- a/src/components/agent/chat/components/ToolSearchSummaryPanel.tsx +++ b/src/components/agent/chat/components/ToolSearchSummaryPanel.tsx @@ -42,6 +42,10 @@ export function ToolSearchSummaryPanel({ summary, testId, }: ToolSearchSummaryPanelProps) { + const pendingServersText = + summary.pendingMcpServers && summary.pendingMcpServers.length > 0 + ? `以下 MCP 服务仍在连接中:${summary.pendingMcpServers.join("、")}` + : null; const userFacingNotes = summary.notes .map((note) => resolveUserFacingToolSearchNote(note)) .filter((note): note is string => Boolean(note)); @@ -55,6 +59,12 @@ export function ToolSearchSummaryPanel({ ) : null}
    + {pendingServersText ? ( +
    +
    {pendingServersText}
    +
    + ) : null} + {userFacingNotes.length > 0 ? (
    {userFacingNotes.map((note, index) => ( diff --git a/src/components/agent/chat/hooks/agentChatHistory.test.ts b/src/components/agent/chat/hooks/agentChatHistory.test.ts index 946340f53..9198b6613 100644 --- a/src/components/agent/chat/hooks/agentChatHistory.test.ts +++ b/src/components/agent/chat/hooks/agentChatHistory.test.ts @@ -189,6 +189,58 @@ describe("agentChatHistory", () => { }); }); + it("合并相邻 assistant 历史消息时也应保留最后一条 usage", () => { + const detail: AsterSessionDetail = { + id: "session-adjacent-usage", + created_at: 1, + updated_at: 2, + messages: [ + { + role: "user", + timestamp: 1710000300, + content: [{ type: "text", text: "帮我分析这个仓库" } as never], + }, + { + role: "assistant", + timestamp: 1710000301, + content: [ + { type: "output_text", text: "我先做一次轻量侦查。" } as never, + ], + }, + { + role: "assistant", + timestamp: 1710000302, + content: [ + { type: "output_text", text: "## 阶段结论\n\n已经找到关键线索。" } as never, + ], + usage: { + input_tokens: 38483, + output_tokens: 2406, + cached_input_tokens: 36976, + cache_creation_input_tokens: 0, + }, + }, + ], + }; + + const messages = hydrateSessionDetailMessages( + detail, + "session-adjacent-usage", + ); + + expect(messages).toHaveLength(2); + expect(messages[1]?.role).toBe("assistant"); + expect(messages[1]?.content).toContain("我先做一次轻量侦查。"); + expect(messages[1]?.content).toContain("已经找到关键线索。"); + expect(messages[1]?.content).not.toContain("阶段结论"); + expect(messages[1]?.usage).toEqual({ + input_tokens: 38483, + output_tokens: 2406, + cached_input_tokens: 36976, + cache_creation_input_tokens: 0, + }); + }); + it("应从历史 tool_response 恢复图片任务预览,并保留同一任务的连续 assistant 轨迹", () => { const detail: AsterSessionDetail = { id: "session-history-image-task-preview", @@ -552,15 +604,13 @@ describe("agentChatHistory", () => { hydratedMessages, ); - expect(mergedMessages[1]?.thinkingContent).toBe( - "先打开页面,再抓取正文和图片。", - ); - expect( - mergedMessages[1]?.contentParts?.some( - (part) => - part.type === "tool_use" && part.toolCall.id === "tool-site-1", - ), - ).toBe(true); + expect(mergedMessages[1]?.thinkingContent).toBeUndefined(); + expect(mergedMessages[1]?.contentParts).toEqual([ + { + type: "text", + text: "内容已保存到项目目录。", + }, + ]); expect(mergedMessages[1]?.toolCalls?.[0]).toMatchObject({ id: "tool-site-1", status: "completed", diff --git a/src/components/agent/chat/hooks/agentChatHistory.ts b/src/components/agent/chat/hooks/agentChatHistory.ts index e0a0bed98..03580b745 100644 --- a/src/components/agent/chat/hooks/agentChatHistory.ts +++ b/src/components/agent/chat/hooks/agentChatHistory.ts @@ -279,6 +279,53 @@ function mergeHydratedContentParts( return remote; } +function hasRenderableAssistantTextContent(message: Message): boolean { + if (message.role !== "assistant") { + return false; + } + + if (message.content.trim().length > 0) { + return true; + } + + return (message.contentParts || []).some( + (part) => part.type === "text" && part.text.trim().length > 0, + ); +} + +export function normalizeHistoricalTopicSnapshotMessage( + message: Message, +): Message { + if ( + message.role !== "assistant" || + message.isThinking || + !hasRenderableAssistantTextContent(message) + ) { + return message; + } + + const visibleContentParts = (message.contentParts || []).filter( + (part) => part.type === "text" || part.type === "action_required", + ); + const contentText = message.content.trim(); + const contentParts = + visibleContentParts.length > 0 + ? visibleContentParts + : contentText + ? [{ type: "text", text: contentText } satisfies ContentPart] + : undefined; + + return { + ...message, + thinkingContent: undefined, + contentParts, + }; +} + +export const normalizeHistoricalTopicSnapshotMessages = ( + messages: Message[], +): Message[] => messages.map(normalizeHistoricalTopicSnapshotMessage); + function normalizePreviewSignatureValue(value: unknown): string { if (typeof value === "string") { return normalizeSignatureText(value); @@ -486,6 +533,7 @@ export const mergeAdjacentAssistantMessages = ( toolCalls: toolCalls.length > 0 ? toolCalls : undefined, contextTrace: contextTrace.length > 0 ? contextTrace : undefined, artifacts: artifacts.length > 0 ? artifacts : undefined, + usage: current.usage ?? previous.usage, imageWorkbenchPreview, taskPreview, timestamp: current.timestamp, @@ -686,10 +734,14 @@ export const mergeHydratedMessagesWithLocalState = ( return message; } - const contentParts = mergeHydratedContentParts( - localAssistantMessage?.contentParts, - message.contentParts, - ); + const shouldRetainLocalProcessState = + !hasRenderableAssistantTextContent(message); + const contentParts = shouldRetainLocalProcessState + ? mergeHydratedContentParts( + localAssistantMessage?.contentParts, + message.contentParts, + ) + : message.contentParts; const toolCalls = mergeByKey( localAssistantMessage?.toolCalls, message.toolCalls, @@ -719,7 +771,9 @@ export const mergeHydratedMessagesWithLocalState = ( })(); const thinkingContent = message.thinkingContent ?? - localAssistantMessage?.thinkingContent ?? + (shouldRetainLocalProcessState + ? localAssistantMessage?.thinkingContent + : undefined) ?? extractThinkingContentFromParts(contentParts); return { diff --git a/src/components/agent/chat/hooks/agentChatToolResult.test.ts b/src/components/agent/chat/hooks/agentChatToolResult.test.ts new file mode 100644 index 000000000..bef491554 --- /dev/null +++ b/src/components/agent/chat/hooks/agentChatToolResult.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; + +import { isToolResultSuccessful } from "./agentChatToolResult"; + +describe("agentChatToolResult", () => { + it("reported_success 为 true 时应覆盖非零 exit_code", () => { + expect( + isToolResultSuccessful({ + success: true, + metadata: { + exit_code: 1, + reported_success: true, + }, + }), + ).toBe(true); + }); + + it("reported_success 为 false 时应优先判定失败", () => { + expect( + isToolResultSuccessful({ + success: true, + metadata: { + exit_code: 0, + reported_success: false, + }, + }), + ).toBe(false); + }); +}); diff --git a/src/components/agent/chat/hooks/agentChatToolResult.ts b/src/components/agent/chat/hooks/agentChatToolResult.ts index 010c2822d..ca2fa6684 100644 --- a/src/components/agent/chat/hooks/agentChatToolResult.ts +++ b/src/components/agent/chat/hooks/agentChatToolResult.ts @@ -345,6 +345,9 @@ export const isToolResultSuccessful = ( if (metadata?.reported_success === false) { return false; } + if (metadata?.reported_success === true) { + return true; + } if ( typeof metadata?.exit_code === "number" && Number.isFinite(metadata.exit_code) && diff --git a/src/components/agent/chat/hooks/agentProjectStorage.test.tsx b/src/components/agent/chat/hooks/agentProjectStorage.test.tsx index f73875e9a..acbed2a0f 100644 --- a/src/components/agent/chat/hooks/agentProjectStorage.test.tsx +++ b/src/components/agent/chat/hooks/agentProjectStorage.test.tsx @@ -6,6 +6,7 @@ import { getSessionWorkspaceStorageKey, loadPersistedProjectId, loadPersistedSessionWorkspaceId, + loadStoredSessionWorkspaceIdRaw, savePersistedProjectId, savePersistedSessionWorkspaceId, usePersistedProjectId, @@ -87,6 +88,18 @@ describe("agentProjectStorage", () => { expect(loadPersistedSessionWorkspaceId("session-a")).toBe("project-a"); }); + it("legacy 会话工作区影子缓存应保留原始值供治理判断,但 current 读取应归一为空", () => { + localStorage.setItem( + "agent_session_workspace_session-legacy", + JSON.stringify("workspace-default"), + ); + + expect(loadStoredSessionWorkspaceIdRaw("session-legacy")).toBe( + "workspace-default", + ); + expect(loadPersistedSessionWorkspaceId("session-legacy")).toBeNull(); + }); + it("hook 应优先使用 externalProjectId,否则回退最近项目", () => { savePersistedProjectId(LAST_PROJECT_ID_KEY, "project-local"); diff --git a/src/components/agent/chat/hooks/agentProjectStorage.ts b/src/components/agent/chat/hooks/agentProjectStorage.ts index f6272e595..4b56e328b 100644 --- a/src/components/agent/chat/hooks/agentProjectStorage.ts +++ b/src/components/agent/chat/hooks/agentProjectStorage.ts @@ -16,6 +16,14 @@ export function getSessionWorkspaceStorageKey( } export function loadPersistedProjectId(key: string): string | null { + try { + return normalizeProjectId(loadStoredProjectIdRaw(key)); + } catch { + return null; + } +} + +export function loadStoredProjectIdRaw(key: string): string | null { try { const stored = localStorage.getItem(key); if (!stored) { @@ -24,9 +32,12 @@ export function loadPersistedProjectId(key: string): string | null { try { const parsed = JSON.parse(stored); - return normalizeProjectId(typeof parsed === "string" ? parsed : stored); + const normalized = + typeof parsed === "string" ? parsed.trim() : String(stored).trim(); + return normalized || null; } catch { - return normalizeProjectId(stored); + const normalized = stored.trim(); + return normalized || null; } } catch { return null; @@ -57,6 +68,17 @@ export function loadPersistedSessionWorkspaceId( return loadPersistedProjectId(key); } +export function loadStoredSessionWorkspaceIdRaw( + sessionId: string, +): string | null { + const key = getSessionWorkspaceStorageKey(sessionId); + if (!key) { + return null; + } + + return loadStoredProjectIdRaw(key); +} + export function savePersistedSessionWorkspaceId( sessionId: string, projectId: string, diff --git a/src/components/agent/chat/hooks/agentSessionState.test.ts b/src/components/agent/chat/hooks/agentSessionState.test.ts index 6a298ae51..4cc41f351 100644 --- a/src/components/agent/chat/hooks/agentSessionState.test.ts +++ b/src/components/agent/chat/hooks/agentSessionState.test.ts @@ -164,7 +164,7 @@ describe("agentSessionState", () => { ]); }); - it("同会话 hydrate 时远端缺失过程字段也应保留本地 assistant 执行过程", () => { + it("同会话 hydrate 时远端已有最终正文应收敛旧 assistant 执行过程到元数据", () => { const now = new Date("2026-04-08T10:00:00.000Z"); const currentMessages = [ createMessage({ @@ -250,15 +250,13 @@ describe("agentSessionState", () => { topics: [], }); - expect(result.snapshot.messages[1]?.thinkingContent).toBe( - "先抓正文,再下载图片。", - ); - expect( - result.snapshot.messages[1]?.contentParts?.some( - (part) => - part.type === "tool_use" && part.toolCall.id === "tool-site-1", - ), - ).toBe(true); + expect(result.snapshot.messages[1]?.thinkingContent).toBeUndefined(); + expect(result.snapshot.messages[1]?.contentParts).toEqual([ + { + type: "text", + text: "内容已保存到项目目录。", + }, + ]); expect(result.snapshot.messages[1]?.toolCalls?.[0]).toMatchObject({ id: "tool-site-1", status: "completed", @@ -517,9 +515,16 @@ describe("agentSessionState", () => { expect(result.snapshot.messages).toHaveLength(2); expect(result.snapshot.messages[1]?.content).toBe("内容已保存到项目目录。"); + expect(result.snapshot.messages[1]?.thinkingContent).toBeUndefined(); expect(result.snapshot.messages[1]?.toolCalls?.[0]?.id).toBe( "tool-site-target", ); + expect(result.snapshot.messages[1]?.contentParts).toEqual([ + { + type: "text", + text: "内容已保存到项目目录。", + }, + ]); expect(result.snapshot.messages[1]?.content).not.toContain( "这是另一个会话", ); diff --git a/src/components/agent/chat/hooks/agentSessionState.ts b/src/components/agent/chat/hooks/agentSessionState.ts index 12107b640..60ae01356 100644 --- a/src/components/agent/chat/hooks/agentSessionState.ts +++ b/src/components/agent/chat/hooks/agentSessionState.ts @@ -15,6 +15,7 @@ import { normalizeLegacyThreadItems } from "@/lib/api/agentTextNormalization"; import { hydrateSessionDetailMessages, mergeHydratedMessagesWithLocalState, + normalizeHistoricalTopicSnapshotMessages, } from "./agentChatHistory"; import { filterConversationThreadItems, @@ -135,7 +136,9 @@ export function buildHydratedAgentSessionSnapshot( const effectiveCurrentSessionId = localSnapshotOverride?.sessionId ?? currentSessionId; const effectiveCurrentMessages = - localSnapshotOverride?.messages ?? currentMessages; + localSnapshotOverride + ? normalizeHistoricalTopicSnapshotMessages(localSnapshotOverride.messages) + : currentMessages; const effectiveCurrentThreadTurns = localSnapshotOverride?.threadTurns ?? currentThreadTurns; const effectiveCurrentThreadItems = diff --git a/src/components/agent/chat/hooks/agentStreamRuntimeHandler.test.ts b/src/components/agent/chat/hooks/agentStreamRuntimeHandler.test.ts index e9b2b8706..acc383e65 100644 --- a/src/components/agent/chat/hooks/agentStreamRuntimeHandler.test.ts +++ b/src/components/agent/chat/hooks/agentStreamRuntimeHandler.test.ts @@ -177,7 +177,7 @@ describe("agentStreamRuntimeHandler", () => { expect(messages[0]?.content).toBe("已保存到项目目录。"); }); - it("收到空 final_done 时应保留温和兜底正文而不是错误文案", () => { + it("收到空 final_done 且没有真实产物信号时应落成失败态", () => { let messages: Message[] = [ { id: "assistant-3", @@ -242,9 +242,12 @@ describe("agentStreamRuntimeHandler", () => { setIsSending: vi.fn() as never, }); - expect(messages[0]?.content).toBe( - "本轮执行已完成,详细过程与产物已保留在当前对话中。", - ); + expect(messages[0]?.content).toBe("执行失败:模型未输出最终答复,请重试"); + expect(messages[0]?.runtimeStatus).toMatchObject({ + phase: "failed", + title: "当前处理失败", + }); + expect(mockToast.error).toHaveBeenCalledWith("模型未输出最终答复,请重试"); }); it("站点导出在 tool_end 已登记结果时,空 final_done 不应误报缺少最终答复", () => { @@ -377,7 +380,84 @@ describe("agentStreamRuntimeHandler", () => { "本轮执行已完成,详细过程与产物已保留在当前对话中。", ); expect(mockToast.error).not.toHaveBeenCalledWith( - "已完成工具执行,但模型未输出最终答复,请重试", + "模型未输出最终答复,请重试", + ); + }); + + it("命中空最终答复错误但已有真实产物信号时仍应软完成", () => { + let messages: Message[] = [ + { + id: "assistant-site-export-error", + role: "assistant", + content: "", + timestamp: new Date("2026-04-07T10:00:00.000Z"), + isThinking: true, + }, + ]; + + const setMessages = vi.fn( + (value: Message[] | ((prev: Message[]) => Message[])) => { + messages = typeof value === "function" ? value(messages) : value; + }, + ); + + handleTurnStreamEvent({ + data: { + type: "error", + message: + "已完成当前回合的工具执行,但模型未输出最终答复。\n尝试记录: site_run_adapter#tool-site-export-2:success", + } as AgentEvent, + requestState: { + accumulatedContent: "", + hasMeaningfulCompletionSignal: true, + queuedTurnId: null, + requestLogId: null, + requestStartedAt: 0, + requestFinished: false, + }, + callbacks: { + activateStream: () => {}, + isStreamActivated: () => true, + clearOptimisticItem: () => {}, + clearOptimisticTurn: () => {}, + disposeListener: () => {}, + removeQueuedDraftMessages: () => {}, + clearActiveStreamIfMatch: () => true, + upsertQueuedTurn: () => {}, + removeQueuedTurnState: () => {}, + playToolcallSound: () => {}, + playTypewriterSound: () => {}, + appendThinkingToParts: (parts) => parts, + }, + eventName: "agent-runtime-test", + pendingTurnKey: "pending-turn", + pendingItemKey: "pending-item", + assistantMsgId: "assistant-site-export-error", + activeSessionId: "session-1", + resolvedWorkspaceId: "workspace-1", + effectiveExecutionStrategy: "react", + content: "", + runtime: {} as never, + warnedKeysRef: { current: new Set() }, + actionLoggedKeys: new Set(), + toolLogIdByToolId: new Map(), + toolStartedAtByToolId: new Map(), + toolNameByToolId: new Map(), + setMessages: setMessages as never, + setPendingActions: vi.fn() as never, + setThreadItems: vi.fn() as never, + setThreadTurns: vi.fn() as never, + setCurrentTurnId: vi.fn() as never, + setExecutionRuntime: vi.fn() as never, + setIsSending: vi.fn() as never, + }); + + expect(messages[0]?.content).toBe( + "本轮执行已完成,详细过程与产物已保留在当前对话中。", + ); + expect(messages[0]?.runtimeStatus).toBeUndefined(); + expect(mockToast.error).not.toHaveBeenCalledWith( + "模型未输出最终答复,请重试", ); }); diff --git a/src/components/agent/chat/hooks/agentStreamRuntimeHandler.ts b/src/components/agent/chat/hooks/agentStreamRuntimeHandler.ts index 9f0735d29..bb77f61e2 100644 --- a/src/components/agent/chat/hooks/agentStreamRuntimeHandler.ts +++ b/src/components/agent/chat/hooks/agentStreamRuntimeHandler.ts @@ -47,9 +47,7 @@ import { stripAssistantProtocolResidue, } from "../utils/protocolResidue"; import { normalizeIncomingToolResult } from "./agentChatToolResult"; -import { - hasMeaningfulSiteToolResultSignal, -} from "../utils/siteToolResultSummary"; +import { hasMeaningfulSiteToolResultSignal } from "../utils/siteToolResultSummary"; import { buildImageTaskPreviewFromToolResult, buildTaskPreviewFromToolResult, @@ -75,6 +73,7 @@ interface StreamRequestState { } const EMPTY_FINAL_REPLY_ERROR_HINT = "模型未输出最终答复"; +const EMPTY_FINAL_REPLY_ERROR_MESSAGE = "模型未输出最终答复,请重试"; const EMPTY_FINAL_REPLY_FALLBACK_CONTENT = "本轮执行已完成,详细过程与产物已保留在当前对话中。"; const QUEUED_DRAFT_CLEANUP_GRACE_MS = 1800; @@ -186,8 +185,8 @@ function hasMeaningfulCompletionSignalFromToolResult(params: { return Boolean( buildImageTaskPreviewFromToolResult(previewParams) || - buildTaskPreviewFromToolResult(previewParams) || - buildToolResultArtifactFromToolResult(previewParams), + buildTaskPreviewFromToolResult(previewParams) || + buildToolResultArtifactFromToolResult(previewParams), ); } @@ -315,6 +314,42 @@ export function handleTurnStreamEvent({ ); }; + const finalizeMissingFinalReplyFailure = ( + errorMessage: string, + usage?: Message["usage"], + ) => { + markFailedTimelineState(errorMessage); + removeQueuedTurnState( + requestState.queuedTurnId ? [requestState.queuedTurnId] : [], + ); + finishRequestLog(requestState, { + eventType: "chat_request_error", + status: "error", + error: errorMessage, + }); + observer?.onError?.(errorMessage); + const failedRuntimeStatus = buildFailedAgentRuntimeStatus(errorMessage); + toast.error(EMPTY_FINAL_REPLY_ERROR_MESSAGE); + setMessages((prev) => + prev.map((msg) => + msg.id === assistantMsgId + ? { + ...updateMessageArtifactsStatus(msg, "error"), + isThinking: false, + content: buildFailedAgentMessageContent( + errorMessage, + requestState.accumulatedContent || msg.content, + ), + runtimeStatus: failedRuntimeStatus, + usage: usage ?? msg.usage, + } + : msg, + ), + ); + clearActiveStreamIfMatch(eventName); + disposeListener(); + }; + const markQueuedDraftState = (queuedMessageText?: string | null) => { clearActiveStreamIfMatch(eventName); clearOptimisticItem(); @@ -626,14 +661,6 @@ export function handleTurnStreamEvent({ clearQueuedDraftCleanupTimer(); clearOptimisticItem(); clearOptimisticTurn(); - removeQueuedTurnState( - requestState.queuedTurnId ? [requestState.queuedTurnId] : [], - ); - finishRequestLog(requestState, { - eventType: "chat_request_complete", - status: "success", - description: `请求完成,工具调用 ${toolLogIdByToolId.size} 次`, - }); const rawFinalContent = requestState.accumulatedContent.trim(); const cleanedFinalContent = stripAssistantProtocolResidue( requestState.accumulatedContent, @@ -642,9 +669,23 @@ export function handleTurnStreamEvent({ !cleanedFinalContent && (containsAssistantProtocolResidue(requestState.accumulatedContent) || !rawFinalContent); + if (missingFinalReply && !requestState.hasMeaningfulCompletionSignal) { + finalizeMissingFinalReplyFailure( + EMPTY_FINAL_REPLY_ERROR_MESSAGE, + data.usage, + ); + break; + } + + removeQueuedTurnState( + requestState.queuedTurnId ? [requestState.queuedTurnId] : [], + ); + finishRequestLog(requestState, { + eventType: "chat_request_complete", + status: "success", + description: `请求完成,工具调用 ${toolLogIdByToolId.size} 次`, + }); const finalContent = resolveGracefulCompletionContent(); - const shouldWarnMissingFinalReply = - missingFinalReply && !requestState.hasMeaningfulCompletionSignal; observer?.onComplete?.(finalContent); setMessages((prev) => prev.map((msg) => { @@ -661,9 +702,6 @@ export function handleTurnStreamEvent({ }; }), ); - if (shouldWarnMissingFinalReply) { - toast.error("已完成工具执行,但模型未输出最终答复,请重试"); - } clearActiveStreamIfMatch(eventName); disposeListener(); break; @@ -674,6 +712,10 @@ export function handleTurnStreamEvent({ if (data.message.includes(EMPTY_FINAL_REPLY_ERROR_HINT)) { clearOptimisticItem(); clearOptimisticTurn(); + if (!requestState.hasMeaningfulCompletionSignal) { + finalizeMissingFinalReplyFailure(data.message); + break; + } removeQueuedTurnState( requestState.queuedTurnId ? [requestState.queuedTurnId] : [], ); diff --git a/src/components/agent/chat/hooks/agentStreamTurnEventBinding.test.ts b/src/components/agent/chat/hooks/agentStreamTurnEventBinding.test.ts index 1b41c2015..feb05c413 100644 --- a/src/components/agent/chat/hooks/agentStreamTurnEventBinding.test.ts +++ b/src/components/agent/chat/hooks/agentStreamTurnEventBinding.test.ts @@ -17,6 +17,7 @@ function noopDispatch() { describe("agentStreamTurnEventBinding", () => { afterEach(() => { + vi.useRealTimers(); activityLogger.clear(); }); @@ -83,7 +84,7 @@ describe("agentStreamTurnEventBinding", () => { setIsSending: noopDispatch(), }); - expect(result).toBe(unlisten); + expect(typeof result).toBe("function"); expect(runtime.listenToTurnEvents).toHaveBeenCalledWith( "event-1", expect.any(Function), @@ -103,5 +104,209 @@ describe("agentStreamTurnEventBinding", () => { }), ]), ); + + result(); + expect(unlisten).toHaveBeenCalledTimes(1); + }); + + it("首个运行时事件超时未到达时,应把助手消息收口为失败态", async () => { + vi.useFakeTimers(); + + let messages: Message[] = [ + { + id: "assistant-timeout", + role: "assistant", + content: "", + timestamp: new Date("2026-04-14T10:00:00.000Z"), + isThinking: true, + }, + ]; + const setMessages = vi.fn( + (value: Message[] | ((prev: Message[]) => Message[])) => { + messages = typeof value === "function" ? value(messages) : value; + }, + ); + const clearActiveStreamIfMatch = vi.fn(() => true); + const disposeListener = vi.fn(); + const unlisten = vi.fn(); + const runtime = { + listenToTurnEvents: vi.fn(async () => unlisten), + } as unknown as AgentRuntimeAdapter; + const requestState: StreamRequestState = { + accumulatedContent: "", + requestLogId: null, + requestStartedAt: 0, + requestFinished: false, + queuedTurnId: null, + }; + + await registerAgentStreamTurnEventBinding({ + runtime, + eventName: "event-timeout", + requestState, + skipUserMessage: false, + effectiveProviderType: "anthropic", + effectiveModel: "glm-5.1", + effectiveExecutionStrategy: "react", + content: "帮我分析当前仓库", + expectingQueue: false, + activeSessionId: "session-timeout", + resolvedWorkspaceId: "workspace-timeout", + assistantMsgId: "assistant-timeout", + pendingTurnKey: "pending-turn-timeout", + pendingItemKey: "pending-item-timeout", + effectiveWaitingRuntimeStatus: { + phase: "preparing", + title: "处理中", + detail: "正在准备执行上下文", + }, + warnedKeysRef: { current: new Set() }, + actionLoggedKeys: new Set(), + toolLogIdByToolId: new Map(), + toolStartedAtByToolId: new Map(), + toolNameByToolId: new Map(), + callbacks: { + activateStream: () => {}, + isStreamActivated: () => true, + clearOptimisticItem: () => {}, + clearOptimisticTurn: () => {}, + disposeListener, + removeQueuedDraftMessages: () => {}, + clearActiveStreamIfMatch, + upsertQueuedTurn: (_queuedTurn: QueuedTurnSnapshot) => {}, + removeQueuedTurnState: () => {}, + }, + sounds: { + playToolcallSound: () => {}, + playTypewriterSound: () => {}, + }, + appendThinkingToParts: (parts) => parts, + setMessages: setMessages as never, + setPendingActions: noopDispatch(), + setThreadItems: noopDispatch(), + setThreadTurns: noopDispatch(), + setCurrentTurnId: noopDispatch(), + setExecutionRuntime: noopDispatch(), + setIsSending: noopDispatch(), + }); + + await vi.advanceTimersByTimeAsync(12_100); + + expect(messages[0]?.content).toContain("执行失败:执行已中断"); + expect(messages[0]?.runtimeStatus).toMatchObject({ + phase: "failed", + title: "当前处理失败", + }); + expect(clearActiveStreamIfMatch).toHaveBeenCalledWith("event-timeout"); + expect(disposeListener).toHaveBeenCalled(); + }); + + it("首包后长时间没有新事件时,应把助手消息收口为失败态", async () => { + vi.useFakeTimers(); + + let messages: Message[] = [ + { + id: "assistant-inactivity", + role: "assistant", + content: "", + timestamp: new Date("2026-04-14T10:00:00.000Z"), + isThinking: true, + }, + ]; + let streamActivated = false; + let streamHandler: ((event: { payload: unknown }) => void) | null = null; + const setMessages = vi.fn( + (value: Message[] | ((prev: Message[]) => Message[])) => { + messages = typeof value === "function" ? value(messages) : value; + }, + ); + const clearActiveStreamIfMatch = vi.fn(() => true); + const disposeListener = vi.fn(); + const runtime = { + listenToTurnEvents: vi.fn(async (_eventName, handler) => { + streamHandler = handler; + return vi.fn(); + }), + } as unknown as AgentRuntimeAdapter; + const requestState: StreamRequestState = { + accumulatedContent: "", + requestLogId: null, + requestStartedAt: 0, + requestFinished: false, + queuedTurnId: null, + }; + + await registerAgentStreamTurnEventBinding({ + runtime, + eventName: "event-inactivity", + requestState, + skipUserMessage: false, + effectiveProviderType: "anthropic", + effectiveModel: "glm-5.1", + effectiveExecutionStrategy: "react", + content: "继续分析当前仓库", + expectingQueue: false, + activeSessionId: "session-inactivity", + resolvedWorkspaceId: "workspace-inactivity", + assistantMsgId: "assistant-inactivity", + pendingTurnKey: "pending-turn-inactivity", + pendingItemKey: "pending-item-inactivity", + effectiveWaitingRuntimeStatus: { + phase: "preparing", + title: "处理中", + detail: "正在准备执行上下文", + }, + warnedKeysRef: { current: new Set() }, + actionLoggedKeys: new Set(), + toolLogIdByToolId: new Map(), + toolStartedAtByToolId: new Map(), + toolNameByToolId: new Map(), + callbacks: { + activateStream: () => { + streamActivated = true; + }, + isStreamActivated: () => streamActivated, + clearOptimisticItem: () => {}, + clearOptimisticTurn: () => {}, + disposeListener, + removeQueuedDraftMessages: () => {}, + clearActiveStreamIfMatch, + upsertQueuedTurn: (_queuedTurn: QueuedTurnSnapshot) => {}, + removeQueuedTurnState: () => {}, + }, + sounds: { + playToolcallSound: () => {}, + playTypewriterSound: () => {}, + }, + appendThinkingToParts: (parts) => parts, + setMessages: setMessages as never, + setPendingActions: noopDispatch(), + setThreadItems: noopDispatch(), + setThreadTurns: noopDispatch(), + setCurrentTurnId: noopDispatch(), + setExecutionRuntime: noopDispatch(), + setIsSending: noopDispatch(), + }); + + streamHandler?.({ + payload: { + type: "runtime_status", + status: { + phase: "reasoning", + title: "分析中", + detail: "正在整理仓库结构", + }, + }, + }); + + await vi.advanceTimersByTimeAsync(45_100); + + expect(messages[0]?.content).toContain("执行失败:执行已中断"); + expect(messages[0]?.runtimeStatus).toMatchObject({ + phase: "failed", + title: "当前处理失败", + }); + expect(clearActiveStreamIfMatch).toHaveBeenCalledWith("event-inactivity"); + expect(disposeListener).toHaveBeenCalled(); }); }); diff --git a/src/components/agent/chat/hooks/agentStreamTurnEventBinding.ts b/src/components/agent/chat/hooks/agentStreamTurnEventBinding.ts index 0bf47ebb7..c5afc04af 100644 --- a/src/components/agent/chat/hooks/agentStreamTurnEventBinding.ts +++ b/src/components/agent/chat/hooks/agentStreamTurnEventBinding.ts @@ -1,6 +1,7 @@ import type { Dispatch, MutableRefObject, SetStateAction } from "react"; import { parseAgentEvent, + type AgentEvent, type AgentThreadItem, type AgentThreadTurn, } from "@/lib/api/agentProtocol"; @@ -18,6 +19,12 @@ import type { AgentRuntimeAdapter } from "./agentRuntimeAdapter"; import type { StreamRequestState } from "./agentStreamSubmissionLifecycle"; type MessageParts = NonNullable; +const STREAM_FIRST_EVENT_TIMEOUT_MS = 12_000; +const STREAM_FIRST_EVENT_TIMEOUT_MESSAGE = + "执行已中断:运行时未返回任何进度事件,请重试。"; +const STREAM_INACTIVITY_TIMEOUT_MS = 45_000; +const STREAM_INACTIVITY_TIMEOUT_MESSAGE = + "执行已中断:运行时长时间没有返回新进度,请重试。"; interface StreamObserver { onTextDelta?: (delta: string, accumulated: string) => void; @@ -148,13 +155,116 @@ export async function registerAgentStreamTurnEventBinding( }, }); - return runtime.listenToTurnEvents( + let firstEventReceived = false; + let inactivityWatchdogId: ReturnType | null = null; + const clearInactivityWatchdog = () => { + if (inactivityWatchdogId) { + clearTimeout(inactivityWatchdogId); + inactivityWatchdogId = null; + } + }; + let firstEventWatchdogId: ReturnType | null = + globalThis.setTimeout(() => { + firstEventWatchdogId = null; + if (firstEventReceived || requestState.requestFinished) { + return; + } + firstEventReceived = true; + dispatchSyntheticError(STREAM_FIRST_EVENT_TIMEOUT_MESSAGE); + }, STREAM_FIRST_EVENT_TIMEOUT_MS); + + const clearFirstEventWatchdog = () => { + if (firstEventWatchdogId) { + clearTimeout(firstEventWatchdogId); + firstEventWatchdogId = null; + } + }; + const disposeListenerWithWatchdogs = () => { + clearFirstEventWatchdog(); + clearInactivityWatchdog(); + callbacks.disposeListener(); + }; + const dispatchSyntheticError = (message: string) => { + handleTurnStreamEvent({ + data: { + type: "error", + message, + } as AgentEvent, + requestState, + callbacks: { + activateStream: () => + callbacks.activateStream( + activeSessionId, + effectiveWaitingRuntimeStatus, + ), + isStreamActivated: callbacks.isStreamActivated, + clearOptimisticItem: callbacks.clearOptimisticItem, + clearOptimisticTurn: callbacks.clearOptimisticTurn, + disposeListener: disposeListenerWithWatchdogs, + removeQueuedDraftMessages: callbacks.removeQueuedDraftMessages, + clearActiveStreamIfMatch: callbacks.clearActiveStreamIfMatch, + upsertQueuedTurn: callbacks.upsertQueuedTurn, + removeQueuedTurnState: callbacks.removeQueuedTurnState, + playToolcallSound: sounds.playToolcallSound, + playTypewriterSound: sounds.playTypewriterSound, + appendThinkingToParts, + }, + observer, + eventName, + pendingTurnKey, + pendingItemKey, + assistantMsgId, + activeSessionId, + resolvedWorkspaceId, + effectiveExecutionStrategy, + content, + runtime, + webSearch, + warnedKeysRef, + actionLoggedKeys, + toolLogIdByToolId, + toolStartedAtByToolId, + toolNameByToolId, + onWriteFile, + setMessages, + setPendingActions, + setThreadItems, + setThreadTurns, + setCurrentTurnId, + setExecutionRuntime, + setIsSending, + }); + }; + const scheduleInactivityWatchdog = () => { + clearInactivityWatchdog(); + if ( + !firstEventReceived || + requestState.requestFinished || + !callbacks.isStreamActivated() + ) { + return; + } + + inactivityWatchdogId = globalThis.setTimeout(() => { + inactivityWatchdogId = null; + if (requestState.requestFinished || !callbacks.isStreamActivated()) { + return; + } + dispatchSyntheticError(STREAM_INACTIVITY_TIMEOUT_MESSAGE); + }, STREAM_INACTIVITY_TIMEOUT_MS); + }; + + const unlisten = await runtime.listenToTurnEvents( eventName, (event: { payload: unknown }) => { const data = parseAgentEvent(event.payload); if (!data) { return; } + if (!firstEventReceived) { + firstEventReceived = true; + clearFirstEventWatchdog(); + } handleTurnStreamEvent({ data, @@ -168,7 +278,7 @@ export async function registerAgentStreamTurnEventBinding( isStreamActivated: callbacks.isStreamActivated, clearOptimisticItem: callbacks.clearOptimisticItem, clearOptimisticTurn: callbacks.clearOptimisticTurn, - disposeListener: callbacks.disposeListener, + disposeListener: disposeListenerWithWatchdogs, removeQueuedDraftMessages: callbacks.removeQueuedDraftMessages, clearActiveStreamIfMatch: callbacks.clearActiveStreamIfMatch, upsertQueuedTurn: callbacks.upsertQueuedTurn, @@ -202,6 +312,13 @@ export async function registerAgentStreamTurnEventBinding( setExecutionRuntime, setIsSending, }); + scheduleInactivityWatchdog(); }, ); + + return () => { + clearFirstEventWatchdog(); + clearInactivityWatchdog(); + unlisten(); + }; } diff --git a/src/components/agent/chat/hooks/useAgentContext.ts b/src/components/agent/chat/hooks/useAgentContext.ts index a8c1b0bc3..9dd8e82cb 100644 --- a/src/components/agent/chat/hooks/useAgentContext.ts +++ b/src/components/agent/chat/hooks/useAgentContext.ts @@ -15,7 +15,7 @@ import type { WorkspacePathMissingState, } from "./agentChatShared"; import { - loadPersistedSessionWorkspaceId, + loadStoredSessionWorkspaceIdRaw, savePersistedSessionWorkspaceId, } from "./agentProjectStorage"; import { @@ -32,7 +32,10 @@ import { savePersisted, type AgentAccessMode, } from "./agentChatStorage"; -import { normalizeProjectId } from "../utils/topicProjectResolution"; +import { + isLegacyDefaultProjectId, + normalizeProjectId, +} from "../utils/topicProjectResolution"; import { normalizeExecutionStrategy } from "./agentChatCoreUtils"; import { useWechatRuntimeModelSync } from "./useWechatRuntimeModelSync"; @@ -321,12 +324,16 @@ export function useAgentContext(options: UseAgentContextOptions) { return runtimeWorkspaceId === resolvedWorkspaceId; } - const mappedWorkspaceId = loadPersistedSessionWorkspaceId(session.id); + const mappedWorkspaceId = loadStoredSessionWorkspaceIdRaw(session.id); if (!mappedWorkspaceId) { return true; } + if (isLegacyDefaultProjectId(mappedWorkspaceId)) { + return false; + } + return mappedWorkspaceId === resolvedWorkspaceId; }); }, diff --git a/src/components/agent/chat/hooks/useAgentSession.ts b/src/components/agent/chat/hooks/useAgentSession.ts index 5ed5da73d..fc84eed0b 100644 --- a/src/components/agent/chat/hooks/useAgentSession.ts +++ b/src/components/agent/chat/hooks/useAgentSession.ts @@ -26,7 +26,7 @@ import { type Topic, } from "./agentChatShared"; import { - loadPersistedSessionWorkspaceId, + loadStoredSessionWorkspaceIdRaw, savePersistedSessionWorkspaceId, } from "./agentProjectStorage"; import { normalizeHistoryMessages } from "./agentChatHistory"; @@ -51,7 +51,10 @@ import { createSessionAccessModeFromExecutionRuntime, createSessionModelPreferenceFromExecutionRuntime, } from "../utils/sessionExecutionRuntime"; -import { normalizeProjectId } from "../utils/topicProjectResolution"; +import { + isLegacyDefaultProjectId, + normalizeProjectId, +} from "../utils/topicProjectResolution"; import { buildHydratedAgentSessionSnapshot, createEmptyAgentSessionSnapshot, @@ -148,17 +151,52 @@ export function useAgentSession(options: UseAgentSessionOptions) { () => getAgentSessionScopedKeys(workspaceId), [workspaceId], ); + const sanitizeRestoreCandidateSessionId = useCallback( + (candidateSessionId: string | null | undefined): string | null => { + const normalizedCandidate = candidateSessionId?.trim(); + if (!normalizedCandidate) { + return null; + } + + const resolvedWorkspaceId = normalizeProjectId(workspaceId); + const mappedWorkspaceId = + loadStoredSessionWorkspaceIdRaw(normalizedCandidate); + if (!mappedWorkspaceId) { + return normalizedCandidate; + } + + if ( + isLegacyDefaultProjectId(mappedWorkspaceId) || + (resolvedWorkspaceId && mappedWorkspaceId !== resolvedWorkspaceId) + ) { + logAgentDebug("useAgentSession", "restoreCandidate.rejected", { + candidateSessionId: normalizedCandidate, + mappedWorkspaceId, + workspaceId: resolvedWorkspaceId, + }); + return null; + } + + return normalizedCandidate; + }, + [workspaceId], + ); const loadScopedSessionRestoreCandidate = useCallback(() => { if (disableSessionRestore || !workspaceId?.trim()) { return null; } - return ( + return sanitizeRestoreCandidateSessionId( loadTransient(scopedKeys.currentSessionKey, null) ?? - loadPersisted(scopedKeys.persistedSessionKey, null) + loadPersisted(scopedKeys.persistedSessionKey, null), ); - }, [disableSessionRestore, scopedKeys, workspaceId]); + }, [ + disableSessionRestore, + sanitizeRestoreCandidateSessionId, + scopedKeys, + workspaceId, + ]); const [sessionId, setSessionId] = useState(null); const [messages, setMessages] = useState(() => @@ -293,6 +331,7 @@ export function useAgentSession(options: UseAgentSessionOptions) { if ( existingWorkspaceId && existingWorkspaceId !== "__invalid__" && + !isLegacyDefaultProjectId(existingWorkspaceId) && existingWorkspaceId !== resolvedWorkspaceId ) { console.warn("[AsterChat] 检测到会话与工作区映射冲突,跳过覆盖", { @@ -835,10 +874,16 @@ export function useAgentSession(options: UseAgentSessionOptions) { ); const runtimeWorkspaceId = normalizeProjectId(detail.workspace_id); const selectedTopic = topics.find((topic) => topic.id === topicId); - const shadowWorkspaceId = loadPersistedSessionWorkspaceId(topicId); + const shadowWorkspaceId = loadStoredSessionWorkspaceIdRaw(topicId); const resolvedWorkspaceId = normalizeProjectId(workspaceId); const knownWorkspaceId = - runtimeWorkspaceId || selectedTopic?.workspaceId || shadowWorkspaceId; + runtimeWorkspaceId || + (isLegacyDefaultProjectId(selectedTopic?.workspaceId) + ? null + : selectedTopic?.workspaceId) || + (isLegacyDefaultProjectId(shadowWorkspaceId) + ? null + : shadowWorkspaceId); if ( resolvedWorkspaceId && knownWorkspaceId && diff --git a/src/components/agent/chat/hooks/useAsterAgentChat.test.tsx b/src/components/agent/chat/hooks/useAsterAgentChat.test.tsx index 987353025..1e2ac9e51 100644 --- a/src/components/agent/chat/hooks/useAsterAgentChat.test.tsx +++ b/src/components/agent/chat/hooks/useAsterAgentChat.test.tsx @@ -518,9 +518,7 @@ describe("useAsterAgentChat 首页新会话", () => { await flushEffects(); expect(mockGetDefaultProvider).toHaveBeenCalledTimes(1); - expect( - mockResolveClawWorkspaceProviderSelection, - ).toHaveBeenCalledWith({ + expect(mockResolveClawWorkspaceProviderSelection).toHaveBeenCalledWith({ currentProviderType: "deepseek", currentModel: null, theme: "general", @@ -947,7 +945,7 @@ describe("useAsterAgentChat 任务快照", () => { toolCall: { id: "tool-site-tail-1", name: "site_run_adapter", - arguments: "{\"url\":\"https://x.com/example/article/2\"}", + arguments: '{"url":"https://x.com/example/article/2"}', status: "completed", startTime: "2026-04-08T10:00:01.000Z", endTime: "2026-04-08T10:00:02.000Z", @@ -966,7 +964,7 @@ describe("useAsterAgentChat 任务快照", () => { { id: "tool-site-tail-1", name: "site_run_adapter", - arguments: "{\"url\":\"https://x.com/example/article/2\"}", + arguments: '{"url":"https://x.com/example/article/2"}', status: "completed", startTime: "2026-04-08T10:00:01.000Z", endTime: "2026-04-08T10:00:02.000Z", @@ -1015,8 +1013,7 @@ describe("useAsterAgentChat 任务快照", () => { expect( value.messages[1]?.contentParts?.some( (part) => - part.type === "tool_use" && - part.toolCall.id === "tool-site-tail-1", + part.type === "tool_use" && part.toolCall.id === "tool-site-tail-1", ), ).toBe(true); } finally { @@ -2826,10 +2823,13 @@ describe("useAsterAgentChat runtime routing", () => { .find((msg) => msg.role === "assistant"); expect(assistantMessage?.content).toContain( - "本轮执行已完成,详细过程与产物已保留在当前对话中。", + "执行失败:模型未输出最终答复,请重试", ); + expect(assistantMessage?.runtimeStatus).toMatchObject({ + phase: "failed", + }); expect(mockToast.error).toHaveBeenCalledWith( - "已完成工具执行,但模型未输出最终答复,请重试", + "模型未输出最终答复,请重试", ); } finally { harness.unmount(); @@ -2848,7 +2848,14 @@ describe("useAsterAgentChat runtime routing", () => { await act(async () => { await harness .getValue() - .sendMessage("请导出这篇文章并保存到项目", [], false, false, false, "react"); + .sendMessage( + "请导出这篇文章并保存到项目", + [], + false, + false, + false, + "react", + ); }); act(() => { @@ -2891,14 +2898,14 @@ describe("useAsterAgentChat runtime routing", () => { "本轮执行已完成,详细过程与产物已保留在当前对话中。", ); expect(mockToast.error).not.toHaveBeenCalledWith( - "已完成工具执行,但模型未输出最终答复,请重试", + "模型未输出最终答复,请重试", ); } finally { harness.unmount(); } }); - it("final_done 无正文且刷新详情只返回用户消息时,也应保留本地 assistant 过程", async () => { + it("final_done 无正文且没有真实产物信号时,也应保留本地 assistant 过程并标记失败", async () => { const workspaceId = "ws-empty-final-refresh-user-only"; const sessionId = "session-empty-final-refresh-user-only"; seedSession(workspaceId, sessionId); @@ -2955,8 +2962,11 @@ describe("useAsterAgentChat runtime routing", () => { expect(mockGetAgentRuntimeSession).toHaveBeenCalledWith(sessionId); expect(assistantMessage).toBeTruthy(); expect(assistantMessage?.content).toContain( - "本轮执行已完成,详细过程与产物已保留在当前对话中。", + "执行失败:模型未输出最终答复,请重试", ); + expect(assistantMessage?.runtimeStatus).toMatchObject({ + phase: "failed", + }); expect( assistantMessage?.contentParts?.some( (part) => @@ -2964,12 +2974,15 @@ describe("useAsterAgentChat runtime routing", () => { part.toolCall.id === "tool-site-refresh-1", ), ).toBe(true); + expect(mockToast.error).toHaveBeenCalledWith( + "模型未输出最终答复,请重试", + ); } finally { harness.unmount(); } }); - it("stream error 命中空最终答复收尾时应降级为完成态而不是整轮失败", async () => { + it("stream error 命中空最终答复且没有真实产物信号时应落成失败态", async () => { const workspaceId = "ws-thread-empty-final-reply-soft-complete"; seedSession(workspaceId, "session-thread-empty-final-reply-soft-complete"); const harness = mountHook(workspaceId); @@ -2981,7 +2994,14 @@ describe("useAsterAgentChat runtime routing", () => { await act(async () => { await harness .getValue() - .sendMessage("请继续导出并保存内容", [], false, false, false, "react"); + .sendMessage( + "请继续导出并保存内容", + [], + false, + false, + false, + "react", + ); }); act(() => { @@ -3027,7 +3047,7 @@ describe("useAsterAgentChat runtime routing", () => { stream.emit({ type: "error", message: - "已完成当前回合的工具执行,但模型未输出最终答复。\n尝试记录: 无工具调用", + "已完成当前回合的工具执行,但模型未输出最终答复。\n尝试记录: 已执行非联网工具(tool_start=1, tool_end=1)", }); }); @@ -3036,17 +3056,19 @@ describe("useAsterAgentChat runtime routing", () => { .find((msg) => msg.role === "assistant"); expect(assistantMessage?.content).toContain( - "本轮执行已完成,详细过程与产物已保留在当前对话中。", + "执行失败:已完成当前回合的工具执行,但模型未输出最终答复。", ); - expect(assistantMessage?.runtimeStatus).toBeUndefined(); + expect(assistantMessage?.runtimeStatus).toMatchObject({ + phase: "failed", + }); expect(harness.getValue().turns).toEqual([ expect.objectContaining({ id: "turn-empty-final-reply-soft-complete-1", status: "completed", }), ]); - expect(mockToast.error).not.toHaveBeenCalledWith( - expect.stringContaining("模型未输出最终答复"), + expect(mockToast.error).toHaveBeenCalledWith( + "模型未输出最终答复,请重试", ); } finally { harness.unmount(); @@ -5536,6 +5558,37 @@ describe("useAsterAgentChat 偏好持久化", () => { } }); + it("legacy workspace-default 映射不应再触发旧会话恢复", async () => { + const workspaceId = "ws-current"; + const sessionId = "session-legacy-workspace-default"; + seedSession(workspaceId, sessionId); + localStorage.setItem( + `agent_session_workspace_${sessionId}`, + JSON.stringify("workspace-default"), + ); + + const harness = mountHook(workspaceId); + + try { + await flushEffects(); + await flushEffects(); + + expect(harness.getValue().sessionId).toBeNull(); + let ensuredSessionId: string | null = null; + await act(async () => { + ensuredSessionId = await harness.getValue().ensureSession(); + }); + expect(ensuredSessionId).toBe("created-session"); + expect(mockCreateAgentRuntimeSession).toHaveBeenCalledWith( + workspaceId, + undefined, + "react", + ); + } finally { + harness.unmount(); + } + }); + it("恢复候选会话时应先由 runtime 确认工作区归属", async () => { const consoleWarnSpy = vi .spyOn(console, "warn") @@ -5853,7 +5906,7 @@ describe("useAsterAgentChat 偏好持久化", () => { toolCall: { id: "tool-topic-a-1", name: "site_run_adapter", - arguments: "{\"url\":\"https://x.com/example/article/a\"}", + arguments: '{"url":"https://x.com/example/article/a"}', status: "completed", startTime: "2026-04-09T09:00:01.000Z", endTime: "2026-04-09T09:00:02.000Z", @@ -5872,7 +5925,7 @@ describe("useAsterAgentChat 偏好持久化", () => { { id: "tool-topic-a-1", name: "site_run_adapter", - arguments: "{\"url\":\"https://x.com/example/article/a\"}", + arguments: '{"url":"https://x.com/example/article/a"}', status: "completed", startTime: "2026-04-09T09:00:01.000Z", endTime: "2026-04-09T09:00:02.000Z", @@ -5965,16 +6018,14 @@ describe("useAsterAgentChat 偏好持久化", () => { const value = harness.getValue(); expect(value.messages).toHaveLength(2); expect(value.messages[1]?.content).toBe("内容已保存到项目目录。"); - expect(value.messages[1]?.thinkingContent).toBe( - "先恢复 topic-a 自己的本地执行轨迹。", - ); + expect(value.messages[1]?.thinkingContent).toBeUndefined(); expect(value.messages[1]?.toolCalls?.[0]?.id).toBe("tool-topic-a-1"); - expect( - value.messages[1]?.contentParts?.some( - (part) => - part.type === "tool_use" && part.toolCall.id === "tool-topic-a-1", - ), - ).toBe(true); + expect(value.messages[1]?.contentParts).toEqual([ + { + type: "text", + text: "内容已保存到项目目录。", + }, + ]); expect( value.messages.some((message) => message.content === "这是另一个话题"), ).toBe(false); @@ -6854,9 +6905,7 @@ describe("useAsterAgentChat 兼容接口", () => { await flushEffects(); expect(mockGetDefaultProvider).not.toHaveBeenCalled(); - expect( - mockResolveClawWorkspaceProviderSelection, - ).toHaveBeenCalledWith({ + expect(mockResolveClawWorkspaceProviderSelection).toHaveBeenCalledWith({ currentProviderType: "anthropic", currentModel: "glm-5.1", theme: "general", diff --git a/src/components/agent/chat/hooks/useWorkspaceProjectSelection.test.tsx b/src/components/agent/chat/hooks/useWorkspaceProjectSelection.test.tsx index 144b47a6c..5d0c46bef 100644 --- a/src/components/agent/chat/hooks/useWorkspaceProjectSelection.test.tsx +++ b/src/components/agent/chat/hooks/useWorkspaceProjectSelection.test.tsx @@ -118,6 +118,21 @@ describe("useWorkspaceProjectSelection", () => { } }); + it("不应再把 legacy workspace-default 写回当前项目选择", () => { + const harness = mountHook(); + + try { + act(() => { + harness.getValue().applyProjectSelection("workspace-default"); + }); + + expect(harness.getValue().projectId).toBeUndefined(); + expect(harness.getValue().getRememberedProjectId()).toBeNull(); + } finally { + harness.unmount(); + } + }); + it("应在项目切换完成后恢复待执行的任务切换", () => { const harness = mountHook(); diff --git a/src/components/agent/chat/hooks/useWorkspaceProjectSelection.ts b/src/components/agent/chat/hooks/useWorkspaceProjectSelection.ts index 3d7bcd124..c646903bc 100644 --- a/src/components/agent/chat/hooks/useWorkspaceProjectSelection.ts +++ b/src/components/agent/chat/hooks/useWorkspaceProjectSelection.ts @@ -1,5 +1,8 @@ import { useCallback, useRef, useState } from "react"; -import { normalizeProjectId } from "../utils/topicProjectResolution"; +import { + isLegacyDefaultProjectId, + normalizeProjectId, +} from "../utils/topicProjectResolution"; import { LAST_PROJECT_ID_KEY, loadPersistedProjectId, @@ -26,9 +29,19 @@ export function useWorkspaceProjectSelection( newChatAt, storageKey = LAST_PROJECT_ID_KEY, } = options; - const normalizedExternalProjectId = normalizeProjectId(externalProjectId); + const normalizedExternalProjectId = isLegacyDefaultProjectId( + externalProjectId, + ) + ? null + : normalizeProjectId(externalProjectId); + const loadRememberedProjectId = useCallback(() => { + const rememberedProjectId = loadPersistedProjectId(storageKey); + return isLegacyDefaultProjectId(rememberedProjectId) + ? null + : rememberedProjectId; + }, [storageKey]); const [internalProjectId, setInternalProjectId] = useState( - null, + () => normalizedExternalProjectId ?? loadRememberedProjectId(), ); const handledNewChatRequestRef = useRef(null); const pendingTopicSwitchRef = useRef(null); @@ -153,8 +166,8 @@ export function useWorkspaceProjectSelection( ); const getRememberedProjectId = useCallback( - () => loadPersistedProjectId(storageKey), - [storageKey], + () => loadRememberedProjectId(), + [loadRememberedProjectId], ); return { diff --git a/src/components/agent/chat/index.test.tsx b/src/components/agent/chat/index.test.tsx index 542ba8d43..55ddc6af9 100644 --- a/src/components/agent/chat/index.test.tsx +++ b/src/components/agent/chat/index.test.tsx @@ -1014,7 +1014,7 @@ function mockBrowserAssistCompletedSession() { beforeAll(async () => { await preloadAgentChatWorkspaceModule; -}, 30_000); +}, 90_000); beforeEach(() => { ( @@ -1349,9 +1349,12 @@ describe("AgentChatPage 停止 Team 协作", () => { renderPage(); await flushEffects(); - const latestInputbarProps = mockInputbar.mock.calls.at(-1)?.[0] as - | MockInputbarSendProps - | undefined; + const latestInputbarProps = [...mockInputbar.mock.calls] + .map((call) => call[0] as MockInputbarSendProps | undefined) + .reverse() + .find((props) => typeof props?.onStop === "function"); + + expect(latestInputbarProps?.onStop).toBeTypeOf("function"); await latestInputbarProps?.onStop?.(); await flushEffects(); @@ -1472,6 +1475,32 @@ describe("AgentChatPage 话题切换项目恢复", () => { ); }); + it("legacy workspace-default 入口应优先恢复最近项目,而不是继续走默认工作区", async () => { + localStorage.setItem( + "agent_last_project_id", + JSON.stringify("project-remembered"), + ); + mockEnsureWorkspaceReady.mockResolvedValue({ + workspaceId: "project-remembered", + rootPath: "/tmp/project-remembered", + existed: true, + created: false, + repaired: false, + relocated: false, + previousRootPath: null, + warning: null, + }); + + renderPage({ projectId: "workspace-default" }); + await flushEffects(); + + expect(mockGetOrCreateDefaultProject).not.toHaveBeenCalled(); + expect(mockEnsureWorkspaceReady).toHaveBeenCalledWith("project-remembered"); + expect(observedWorkspaceIds[observedWorkspaceIds.length - 1]).toBe( + "project-remembered", + ); + }); + it("存在 newChatAt 时手动选项目不应被重置", async () => { const container = renderPage({ newChatAt: 1234567890 }); await flushEffects(); diff --git a/src/components/agent/chat/utils/agentTaskRuntime.test.ts b/src/components/agent/chat/utils/agentTaskRuntime.test.ts new file mode 100644 index 000000000..58ac5d5e4 --- /dev/null +++ b/src/components/agent/chat/utils/agentTaskRuntime.test.ts @@ -0,0 +1,240 @@ +import { describe, expect, it } from "vitest"; +import type { Message } from "../types"; +import { buildAgentTaskRuntimeCardModel } from "./agentTaskRuntime"; + +describe("agentTaskRuntime", () => { + it("简单直接回答完成后不应继续显示主任务卡", () => { + const model = buildAgentTaskRuntimeCardModel({ + messages: [ + { + id: "msg-user-hello", + role: "user", + content: "你好", + timestamp: new Date("2026-04-14T10:00:00.000Z"), + }, + { + id: "msg-assistant-hello", + role: "assistant", + content: "你好!我是 Lime 助手。", + timestamp: new Date("2026-04-14T10:00:02.000Z"), + usage: { + input_tokens: 2048, + output_tokens: 256, + }, + }, + ], + turns: [ + { + id: "turn-hello", + thread_id: "thread-hello", + prompt_text: "你好", + status: "completed", + started_at: "2026-04-14T10:00:00Z", + completed_at: "2026-04-14T10:00:02Z", + created_at: "2026-04-14T10:00:00Z", + updated_at: "2026-04-14T10:00:02Z", + }, + ], + threadRead: { + thread_id: "thread-hello", + status: "completed", + }, + }); + + expect(model).toBeNull(); + }); + + it("应在主会话执行工具批次时投影为可见任务卡", () => { + const now = new Date("2026-04-14T10:00:00.000Z"); + const messages: Message[] = [ + { + id: "msg-user-1", + role: "user", + content: "分析 claudecode 项目结构", + timestamp: now, + }, + { + id: "msg-assistant-1", + role: "assistant", + content: "", + timestamp: now, + toolCalls: [ + { + id: "tool-1", + name: "Read", + arguments: JSON.stringify({ file_path: "/repo/src/main.ts" }), + status: "completed", + startTime: now, + endTime: now, + }, + { + id: "tool-2", + name: "Grep", + arguments: JSON.stringify({ pattern: "task", path: "/repo/src" }), + status: "running", + startTime: now, + }, + ], + }, + ]; + + const model = buildAgentTaskRuntimeCardModel({ + messages, + turns: [ + { + id: "turn-1", + thread_id: "thread-1", + prompt_text: "分析 claudecode 项目结构", + status: "running", + started_at: "2026-04-14T10:00:00Z", + created_at: "2026-04-14T10:00:00Z", + updated_at: "2026-04-14T10:00:03Z", + }, + ], + currentTurnId: "turn-1", + threadRead: { + thread_id: "thread-1", + status: "running", + }, + childSubagentSessions: [ + { + id: "sub-1", + name: "代码浏览", + created_at: now.getTime(), + updated_at: now.getTime(), + session_type: "subagent", + runtime_status: "running", + }, + { + id: "sub-2", + name: "回归检查", + created_at: now.getTime(), + updated_at: now.getTime(), + session_type: "subagent", + runtime_status: "completed", + }, + ], + isSending: true, + }); + + expect(model).not.toBeNull(); + expect(model?.status).toBe("running"); + expect(model?.phase).toBe("tool_batch"); + expect(model?.batchDescriptor?.title).toBe("已探索项目"); + expect(model?.supportingLines).toContain("子任务 1/2 进行中"); + }); + + it("等待用户确认时应投影为 waiting_input", () => { + const model = buildAgentTaskRuntimeCardModel({ + messages: [ + { + id: "msg-user-1", + role: "user", + content: "继续执行危险命令", + timestamp: new Date("2026-04-14T10:00:00.000Z"), + }, + ], + turns: [ + { + id: "turn-1", + thread_id: "thread-1", + prompt_text: "继续执行危险命令", + status: "running", + started_at: "2026-04-14T10:00:00Z", + created_at: "2026-04-14T10:00:00Z", + updated_at: "2026-04-14T10:00:05Z", + }, + ], + currentTurnId: "turn-1", + threadRead: { + thread_id: "thread-1", + status: "waiting_request", + pending_requests: [ + { + id: "req-1", + thread_id: "thread-1", + request_type: "tool_confirmation", + status: "pending", + title: "请确认是否继续执行 rm -rf", + }, + ], + diagnostics: { + warning_count: 0, + context_compaction_count: 0, + failed_tool_call_count: 0, + failed_command_count: 0, + pending_request_count: 1, + primary_blocking_summary: "等待你确认是否继续执行该命令", + }, + }, + pendingActions: [ + { + requestId: "req-1", + actionType: "tool_confirmation", + prompt: "请确认是否继续执行 rm -rf", + }, + ], + }); + + expect(model?.status).toBe("waiting_input"); + expect(model?.phase).toBe("waiting_input"); + expect(model?.detail).toContain("等待你确认"); + }); + + it("复杂任务完成后应自动折叠,回到消息级 usage 与结算展示", () => { + const model = buildAgentTaskRuntimeCardModel({ + messages: [ + { + id: "msg-user-1", + role: "user", + content: "总结这次修复", + timestamp: new Date("2026-04-14T10:00:00.000Z"), + }, + { + id: "msg-assistant-1", + role: "assistant", + content: "已经定位到主问题,并完成前端任务视图补齐。", + timestamp: new Date("2026-04-14T10:00:08.000Z"), + usage: { + input_tokens: 1200, + output_tokens: 320, + cached_input_tokens: 512, + cache_creation_input_tokens: 128, + }, + }, + ], + turns: [ + { + id: "turn-1", + thread_id: "thread-1", + prompt_text: "总结这次修复", + status: "completed", + started_at: "2026-04-14T10:00:00Z", + completed_at: "2026-04-14T10:00:08Z", + created_at: "2026-04-14T10:00:00Z", + updated_at: "2026-04-14T10:00:08Z", + }, + ], + threadItems: [ + { + id: "item-summary-1", + type: "turn_summary", + thread_id: "thread-1", + turn_id: "turn-1", + sequence: 4, + status: "completed", + started_at: "2026-04-14T10:00:08Z", + completed_at: "2026-04-14T10:00:08Z", + updated_at: "2026-04-14T10:00:08Z", + text: "已补齐主任务卡,并恢复 token 与 Prompt Cache 可见性。", + }, + ], + threadRead: { + thread_id: "thread-1", + status: "completed", + }, + }); + + expect(model).toBeNull(); + }); +}); diff --git a/src/components/agent/chat/utils/agentTaskRuntime.ts b/src/components/agent/chat/utils/agentTaskRuntime.ts new file mode 100644 index 000000000..866ec66e9 --- /dev/null +++ b/src/components/agent/chat/utils/agentTaskRuntime.ts @@ -0,0 +1,450 @@ +import type { AgentTokenUsage } from "@/lib/api/agentProtocol"; +import type { + AsterSubagentSessionInfo, + AgentRuntimeThreadReadModel, + QueuedTurnSnapshot, +} from "@/lib/api/agentRuntime"; +import type { ActionRequired, AgentThreadItem, AgentThreadTurn, Message } from "../types"; +import { + summarizeStreamingToolBatch, + summarizeThreadProcessBatch, + type ToolBatchSummaryDescriptor, +} from "./toolBatchGrouping"; +import { resolveAgentThreadToolProcessPreview } from "./toolProcessSummary"; +import { isInternalRoutingTurnSummaryText } from "./turnSummaryPresentation"; + +export type AgentTaskRuntimeStatus = + | "queued" + | "running" + | "waiting_input" + | "completed" + | "failed" + | "aborted"; + +export type AgentTaskRuntimePhase = + | "preparing" + | "reasoning" + | "tool_batch" + | "waiting_input" + | "completed" + | "failed" + | "aborted"; + +export interface AgentTaskRuntimeSubtaskStats { + total: number; + active: number; + queued: number; + completed: number; + failed: number; +} + +export interface AgentTaskRuntimeCardModel { + taskId: string; + title: string; + summary: string; + status: AgentTaskRuntimeStatus; + statusLabel: string; + phase: AgentTaskRuntimePhase; + phaseLabel: string; + detail: string | null; + supportingLines: string[]; + batchDescriptor: ToolBatchSummaryDescriptor | null; + queuedTurnCount: number; + pendingRequestCount: number; + subtaskStats: AgentTaskRuntimeSubtaskStats | null; + usage?: AgentTokenUsage; +} + +interface BuildAgentTaskRuntimeCardModelParams { + messages: Message[]; + turns?: AgentThreadTurn[]; + threadItems?: AgentThreadItem[]; + currentTurnId?: string | null; + threadRead?: AgentRuntimeThreadReadModel | null; + pendingActions?: ActionRequired[]; + queuedTurns?: QueuedTurnSnapshot[]; + childSubagentSessions?: AsterSubagentSessionInfo[]; + isSending?: boolean; +} + +function shorten(value: string | null | undefined, maxLength = 120): string { + const normalized = (value || "").trim().replace(/\s+/g, " "); + if (!normalized) { + return ""; + } + if (normalized.length <= maxLength) { + return normalized; + } + return `${normalized.slice(0, maxLength - 1).trimEnd()}…`; +} + +function firstMeaningfulLine(value: string | null | undefined): string | null { + const normalized = (value || "") + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean); + return normalized || null; +} + +function isProcessItem(item: AgentThreadItem): boolean { + return ( + item.type === "tool_call" || + item.type === "command_execution" || + item.type === "web_search" + ); +} + +function resolveLatestTurn( + turns: AgentThreadTurn[], + currentTurnId?: string | null, +): AgentThreadTurn | null { + if (currentTurnId) { + const matched = turns.find((turn) => turn.id === currentTurnId); + if (matched) { + return matched; + } + } + return turns[turns.length - 1] || null; +} + +function resolveLatestAssistantMessage(messages: Message[]): Message | null { + return [...messages].reverse().find((message) => message.role === "assistant") || null; +} + +function resolveLatestUserMessage(messages: Message[]): Message | null { + return ( + [...messages] + .reverse() + .find( + (message) => + message.role === "user" && + (message.content.trim().length > 0 || + (Array.isArray(message.images) && message.images.length > 0)), + ) || null + ); +} + +function resolveLatestUsage(messages: Message[]): AgentTokenUsage | undefined { + return [...messages] + .reverse() + .find( + (message) => + message.role === "assistant" && + !message.isThinking && + message.usage, + )?.usage; +} + +function resolveTaskStatus(params: { + latestTurn: AgentThreadTurn | null; + threadRead?: AgentRuntimeThreadReadModel | null; + pendingActions: ActionRequired[]; + queuedTurnCount: number; + isSending: boolean; +}): AgentTaskRuntimeStatus | null { + const { latestTurn, threadRead, pendingActions, queuedTurnCount, isSending } = + params; + if (pendingActions.length > 0 || threadRead?.status === "waiting_request") { + return "waiting_input"; + } + + if (threadRead?.status === "queued" || queuedTurnCount > 0) { + return latestTurn ? "queued" : queuedTurnCount > 0 ? "queued" : null; + } + + if ( + isSending || + threadRead?.status === "running" || + threadRead?.status === "interrupting" + ) { + return "running"; + } + + switch (latestTurn?.status) { + case "completed": + return "completed"; + case "failed": + return "failed"; + case "aborted": + return "aborted"; + case "running": + return "running"; + default: + return null; + } +} + +function resolveTaskPhase(params: { + status: AgentTaskRuntimeStatus; + latestAssistant: Message | null; + batchDescriptor: ToolBatchSummaryDescriptor | null; +}): AgentTaskRuntimePhase { + const { status, latestAssistant, batchDescriptor } = params; + if (status === "waiting_input") { + return "waiting_input"; + } + if (status === "queued") { + return "preparing"; + } + if (status === "completed") { + return "completed"; + } + if (status === "failed") { + return "failed"; + } + if (status === "aborted") { + return "aborted"; + } + + if (batchDescriptor) { + return "tool_batch"; + } + + if (latestAssistant?.runtimeStatus?.phase === "preparing") { + return "preparing"; + } + + return "reasoning"; +} + +function resolveStatusLabel(status: AgentTaskRuntimeStatus): string { + switch (status) { + case "queued": + return "排队中"; + case "running": + return "进行中"; + case "waiting_input": + return "等待输入"; + case "completed": + return "已完成"; + case "failed": + return "失败"; + case "aborted": + return "已中断"; + } +} + +function resolvePhaseLabel(phase: AgentTaskRuntimePhase): string { + switch (phase) { + case "preparing": + return "准备中"; + case "reasoning": + return "分析中"; + case "tool_batch": + return "工具批次处理中"; + case "waiting_input": + return "等待确认或补充"; + case "completed": + return "任务完成"; + case "failed": + return "任务失败"; + case "aborted": + return "任务已中断"; + } +} + +function resolveBlockingDetail( + threadRead: AgentRuntimeThreadReadModel | null | undefined, + pendingActions: ActionRequired[], +): string | null { + const pendingRequestTitle = + threadRead?.diagnostics?.primary_blocking_summary || + threadRead?.pending_requests?.[0]?.title || + pendingActions[0]?.prompt || + pendingActions[0]?.questions?.[0]?.question; + + return shorten(pendingRequestTitle, 96) || null; +} + +function resolveCompletedSummary( + latestAssistant: Message | null, + latestTurnItems: AgentThreadItem[], +): string | null { + const latestTurnSummary = [...latestTurnItems] + .reverse() + .find( + (item) => + item.type === "turn_summary" && + item.status === "completed" && + !isInternalRoutingTurnSummaryText(item.text), + ); + if (latestTurnSummary?.type === "turn_summary") { + return shorten(firstMeaningfulLine(latestTurnSummary.text), 96) || null; + } + + return shorten(firstMeaningfulLine(latestAssistant?.content), 96) || null; +} + +function resolveSubtaskStats( + childSubagentSessions: AsterSubagentSessionInfo[], +): AgentTaskRuntimeSubtaskStats | null { + if (childSubagentSessions.length === 0) { + return null; + } + + return childSubagentSessions.reduce( + (stats, session) => { + const status = session.runtime_status || "idle"; + stats.total += 1; + if (status === "running") { + stats.active += 1; + } else if (status === "queued") { + stats.active += 1; + stats.queued += 1; + } else if (status === "completed" || status === "closed") { + stats.completed += 1; + } else if (status === "failed" || status === "aborted") { + stats.failed += 1; + } + return stats; + }, + { total: 0, active: 0, queued: 0, completed: 0, failed: 0 }, + ); +} + +export function buildAgentTaskRuntimeCardModel({ + messages, + turns = [], + threadItems = [], + currentTurnId = null, + threadRead = null, + pendingActions = [], + queuedTurns = [], + childSubagentSessions = [], + isSending = false, +}: BuildAgentTaskRuntimeCardModelParams): AgentTaskRuntimeCardModel | null { + const latestTurn = resolveLatestTurn(turns, currentTurnId); + const latestAssistant = resolveLatestAssistantMessage(messages); + const latestUser = resolveLatestUserMessage(messages); + const latestTurnItems = latestTurn + ? threadItems.filter((item) => item.turn_id === latestTurn.id) + : []; + const latestProcessItems = latestTurnItems.filter(isProcessItem); + const visibleToolCalls = + latestAssistant?.toolCalls?.filter((toolCall) => toolCall.status !== "failed") || + []; + const streamingBatchDescriptor = summarizeStreamingToolBatch(visibleToolCalls); + const persistedBatchDescriptor = summarizeThreadProcessBatch(latestProcessItems); + const batchDescriptor = streamingBatchDescriptor || persistedBatchDescriptor; + const status = resolveTaskStatus({ + latestTurn, + threadRead, + pendingActions, + queuedTurnCount: queuedTurns.length, + isSending, + }); + + if ( + !status && + !latestTurn && + !latestUser && + childSubagentSessions.length === 0 && + queuedTurns.length === 0 + ) { + return null; + } + + const phase = resolveTaskPhase({ + status: status || "running", + latestAssistant, + batchDescriptor, + }); + const titleSource = + latestTurn?.prompt_text || + latestUser?.content || + (childSubagentSessions.length > 0 ? "正在协调子任务" : "当前任务"); + const title = shorten(firstMeaningfulLine(titleSource), 120) || "当前任务"; + const subtaskStats = resolveSubtaskStats(childSubagentSessions); + const latestPreviewItem = [...latestProcessItems] + .reverse() + .find((item) => Boolean(resolveAgentThreadToolProcessPreview(item))); + const latestPreview = + (latestPreviewItem && resolveAgentThreadToolProcessPreview(latestPreviewItem)) || + shorten(firstMeaningfulLine(latestAssistant?.runtimeStatus?.detail), 96) || + null; + + let detail: string | null = null; + const supportingLines: string[] = []; + + if (status === "waiting_input") { + detail = resolveBlockingDetail(threadRead, pendingActions); + } else if (status === "queued") { + detail = + queuedTurns.length > 0 + ? `还有 ${queuedTurns.length} 条消息在排队,当前任务会在前一轮结束后继续。` + : "正在准备本轮执行。"; + } else if (phase === "tool_batch" && batchDescriptor) { + detail = batchDescriptor.title; + supportingLines.push(...batchDescriptor.supportingLines); + } else if (status === "completed" || status === "failed" || status === "aborted") { + detail = resolveCompletedSummary(latestAssistant, latestTurnItems); + if (batchDescriptor) { + supportingLines.push(...batchDescriptor.supportingLines); + } + } else { + detail = latestPreview || "正在处理当前请求。"; + } + + if ( + subtaskStats && + subtaskStats.total > 0 && + !supportingLines.some((line) => line.includes("子任务")) + ) { + if (subtaskStats.active > 0) { + supportingLines.push( + `子任务 ${subtaskStats.active}/${subtaskStats.total} 进行中`, + ); + } else { + supportingLines.push(`子任务 ${subtaskStats.total} 个已收拢到当前任务`); + } + } + + if ( + threadRead?.diagnostics?.primary_blocking_summary && + status !== "waiting_input" + ) { + supportingLines.push( + shorten(threadRead.diagnostics.primary_blocking_summary, 96), + ); + } + + const dedupedSupportingLines = supportingLines.filter( + (line, index, array) => Boolean(line) && array.indexOf(line) === index, + ); + const hasProcessTrail = + Boolean(batchDescriptor) || latestProcessItems.length > 0; + const hasSubtasks = Boolean(subtaskStats?.total); + const hasBlockingOrQueue = + pendingActions.length > 0 || + (threadRead?.pending_requests?.length || 0) > 0 || + queuedTurns.length > 0; + const shouldDisplay = + status === "waiting_input" || + status === "queued" || + status === "failed" || + status === "aborted" || + ((status === "running" || !status) && + (hasProcessTrail || hasSubtasks || hasBlockingOrQueue)); + + if (!shouldDisplay) { + return null; + } + + return { + taskId: latestTurn?.id || currentTurnId || "main-session-task", + title, + summary: title, + status: status || "running", + statusLabel: resolveStatusLabel(status || "running"), + phase, + phaseLabel: resolvePhaseLabel(phase), + detail, + supportingLines: dedupedSupportingLines, + batchDescriptor, + queuedTurnCount: queuedTurns.length, + pendingRequestCount: + pendingActions.length || threadRead?.pending_requests?.length || 0, + subtaskStats, + usage: resolveLatestUsage(messages), + }; +} diff --git a/src/components/agent/chat/utils/agentThreadGrouping.test.ts b/src/components/agent/chat/utils/agentThreadGrouping.test.ts index a307972ca..04e27416e 100644 --- a/src/components/agent/chat/utils/agentThreadGrouping.test.ts +++ b/src/components/agent/chat/utils/agentThreadGrouping.test.ts @@ -226,6 +226,71 @@ describe("agentThreadGrouping", () => { ]); }); + it("ToolSearch 结果预览应优先展示过程结论,而不是退回通用动词模板", () => { + const items: AgentThreadItem[] = [ + { + ...createBaseItem("tool-search-1", 1), + type: "tool_call", + tool_name: "ToolSearch", + arguments: { + query: "select:Read,Write", + }, + output: JSON.stringify({ + query: "select:Read,Write", + count: 2, + tools: [{ name: "Read" }, { name: "Write" }], + }), + }, + ]; + + const model = buildAgentThreadDisplayModel(items); + + expect(model.groups[0]?.previewLines).toEqual([ + "已确认可用工具 2 个 · 查看文件 · 保存文件", + ]); + }); + + it("连续探索类工具应折叠成项目探索摘要", () => { + const items: AgentThreadItem[] = [ + { + ...createBaseItem("grep-1", 1), + type: "tool_call", + tool_name: "Grep", + arguments: { + pattern: "tool_use_summary", + path: "/workspace/src", + }, + }, + { + ...createBaseItem("read-1", 2), + type: "tool_call", + tool_name: "Read", + arguments: { + file_path: "/workspace/src/query.ts", + }, + }, + { + ...createBaseItem("read-2", 3), + type: "tool_call", + tool_name: "Read", + arguments: { + file_path: "/workspace/src/components/messages/CollapsedReadSearchContent.tsx", + }, + }, + ]; + + const model = buildAgentThreadDisplayModel(items); + + expect(model.orderedBlocks).toHaveLength(1); + expect(model.orderedBlocks[0]?.title).toBe("已探索项目"); + expect(model.orderedBlocks[0]?.previewLines).toEqual([ + "查看了 2 个文件,搜索 1 次", + "最新线索:CollapsedReadSearchContent.tsx", + ]); + expect(model.orderedBlocks[0]?.countLabel).toBe("读 2 / 搜 1"); + expect(model.orderedBlocks[0]?.rawDetailLabel).toBe("展开查看探索明细"); + }); + it("交互与任务结果预览应使用更直白的用户文案", () => { const items: AgentThreadItem[] = [ { diff --git a/src/components/agent/chat/utils/agentThreadGrouping.ts b/src/components/agent/chat/utils/agentThreadGrouping.ts index 1abab1616..ba4a4c00c 100644 --- a/src/components/agent/chat/utils/agentThreadGrouping.ts +++ b/src/components/agent/chat/utils/agentThreadGrouping.ts @@ -10,6 +10,8 @@ import { resolveUserFacingToolDisplayLabel, } from "./toolDisplayInfo"; import { isInternalRoutingTurnSummaryText } from "./turnSummaryPresentation"; +import { summarizeThreadProcessBatch } from "./toolBatchGrouping"; +import { resolveAgentThreadToolProcessPreview } from "./toolProcessSummary"; export type AgentThreadGroupKind = | "process" @@ -774,6 +776,16 @@ function summarizeGroupPreviewLine( ): string | null { switch (kind) { case "process": + if ( + item.type === "tool_call" || + item.type === "command_execution" || + item.type === "web_search" + ) { + const processPreview = resolveAgentThreadToolProcessPreview(item); + if (processPreview) { + return processPreview; + } + } if ( item.type === "plan" || item.type === "reasoning" || @@ -920,25 +932,34 @@ export function buildAgentThreadDisplayModel( } const status = mergeStatuses(current.items.map((entry) => entry.status)); + const processBatchSummary = + current.kind === "process" + ? summarizeThreadProcessBatch(current.items) + : null; const startedAt = current.items[0]?.started_at || current.items[0]?.updated_at || ""; const completedAt = current.items[current.items.length - 1]?.completed_at; const block: AgentThreadOrderedBlock = { id: current.items.map((entry) => entry.id).join(":"), kind: current.kind, - title: resolveBlockTitle(current.kind), + title: processBatchSummary?.title || resolveBlockTitle(current.kind), status, items: current.items, - previewLines: buildPreviewLines(current.kind, current.items), - countLabel: resolveCountLabel(current.kind, current.items.length), + previewLines: + processBatchSummary?.supportingLines || + buildPreviewLines(current.kind, current.items), + countLabel: + processBatchSummary?.countLabel || + resolveCountLabel(current.kind, current.items.length), rawDetailLabel: - current.kind === "approval" + processBatchSummary?.rawDetailLabel || + (current.kind === "approval" ? "查看待处理项" : current.kind === "artifact" ? "查看产物" : current.kind === "subagent" ? "查看子任务详情" - : "查看执行过程", + : "查看执行过程"), defaultExpanded: shouldDefaultExpand(current.kind, status), startedAt, completedAt, diff --git a/src/components/agent/chat/utils/inputbarRuntimeStatusLine.ts b/src/components/agent/chat/utils/inputbarRuntimeStatusLine.ts new file mode 100644 index 000000000..860873c23 --- /dev/null +++ b/src/components/agent/chat/utils/inputbarRuntimeStatusLine.ts @@ -0,0 +1,319 @@ +import type { AgentTokenUsage } from "@/lib/api/agentProtocol"; +import type { + AgentRuntimeThreadReadModel, + AsterSubagentSessionInfo, + QueuedTurnSnapshot, +} from "@/lib/api/agentRuntime"; +import type { + ActionRequired, + AgentThreadItem, + AgentThreadTurn, + Message, +} from "../types"; +import { + buildAgentTaskRuntimeCardModel, + type AgentTaskRuntimeStatus, + type AgentTaskRuntimeSubtaskStats, +} from "./agentTaskRuntime"; +import { + summarizeThreadProcessBatch, + type ToolBatchSummaryDescriptor, +} from "./toolBatchGrouping"; + +type DateLike = string | number | null; + +export interface InputbarRuntimeStatusLineModel { + status: AgentTaskRuntimeStatus; + detail: string | null; + batchDescriptor: ToolBatchSummaryDescriptor | null; + queuedTurnCount: number; + pendingRequestCount: number; + subtaskStats: AgentTaskRuntimeSubtaskStats | null; + usage?: AgentTokenUsage; + startedAt: DateLike; + completedAt: DateLike; +} + +interface BuildInputbarRuntimeStatusLineModelParams { + messages: Message[]; + turns?: AgentThreadTurn[]; + threadItems?: AgentThreadItem[]; + currentTurnId?: string | null; + threadRead?: AgentRuntimeThreadReadModel | null; + pendingActions?: ActionRequired[]; + queuedTurns?: QueuedTurnSnapshot[]; + childSubagentSessions?: AsterSubagentSessionInfo[]; + isSending?: boolean; +} + +function resolveLatestTurn( + turns: AgentThreadTurn[], + currentTurnId?: string | null, +): AgentThreadTurn | null { + if (currentTurnId) { + const matched = turns.find((turn) => turn.id === currentTurnId); + if (matched) { + return matched; + } + } + return turns[turns.length - 1] || null; +} + +function resolveLatestAssistantMessage(messages: Message[]): Message | null { + return [...messages].reverse().find((message) => message.role === "assistant") || null; +} + +function resolveFallbackStatus(params: { + latestTurn: AgentThreadTurn | null; + threadRead?: AgentRuntimeThreadReadModel | null; + pendingActions: ActionRequired[]; + queuedTurnCount: number; + isSending: boolean; +}): AgentTaskRuntimeStatus | null { + const { latestTurn, threadRead, pendingActions, queuedTurnCount, isSending } = + params; + + if (pendingActions.length > 0 || threadRead?.status === "waiting_request") { + return "waiting_input"; + } + + if (threadRead?.status === "queued" || queuedTurnCount > 0) { + return "queued"; + } + + if ( + isSending || + threadRead?.status === "running" || + threadRead?.status === "interrupting" + ) { + return "running"; + } + + switch (latestTurn?.status) { + case "completed": + return "completed"; + case "failed": + return "failed"; + case "aborted": + return "aborted"; + case "running": + return "running"; + default: + return null; + } +} + +function resolveSubtaskStats( + childSubagentSessions: AsterSubagentSessionInfo[], +): AgentTaskRuntimeSubtaskStats | null { + if (childSubagentSessions.length === 0) { + return null; + } + + return childSubagentSessions.reduce( + (stats, session) => { + const status = session.runtime_status || "idle"; + stats.total += 1; + if (status === "running") { + stats.active += 1; + } else if (status === "queued") { + stats.active += 1; + stats.queued += 1; + } else if (status === "completed" || status === "closed") { + stats.completed += 1; + } else if (status === "failed" || status === "aborted") { + stats.failed += 1; + } + return stats; + }, + { total: 0, active: 0, queued: 0, completed: 0, failed: 0 }, + ); +} + +function resolveLatestTurnTimestamp( + latestTurn: AgentThreadTurn | null, + threadRead: AgentRuntimeThreadReadModel | null | undefined, + key: "startedAt" | "completedAt", +): DateLike { + if (key === "startedAt") { + return ( + latestTurn?.started_at || + threadRead?.diagnostics?.latest_turn_started_at || + null + ); + } + + return ( + latestTurn?.completed_at || + threadRead?.diagnostics?.latest_turn_completed_at || + null + ); +} + +function isProcessThreadItem(item: AgentThreadItem): boolean { + return ( + item.type === "tool_call" || + item.type === "command_execution" || + item.type === "web_search" + ); +} + +function resolveLatestTurnBatchDescriptor( + latestTurn: AgentThreadTurn | null, + threadItems: AgentThreadItem[], +): ToolBatchSummaryDescriptor | null { + if (!latestTurn) { + return null; + } + + return summarizeThreadProcessBatch( + threadItems.filter( + (item) => item.turn_id === latestTurn.id && isProcessThreadItem(item), + ), + ); +} + +function resolveVisibleUsage( + status: AgentTaskRuntimeStatus, + latestAssistant: Message | null, +): AgentTokenUsage | undefined { + if ( + status === "running" || + status === "queued" || + status === "waiting_input" + ) { + return undefined; + } + + return latestAssistant?.usage; +} + +function resolveFallbackDetail(params: { + status: AgentTaskRuntimeStatus; + latestTurn: AgentThreadTurn | null; + latestAssistant: Message | null; + pendingActions: ActionRequired[]; + threadRead?: AgentRuntimeThreadReadModel | null; +}): string | null { + const { status, latestTurn, latestAssistant, pendingActions, threadRead } = + params; + + if (status === "waiting_input") { + return ( + threadRead?.diagnostics?.primary_blocking_summary || + threadRead?.pending_requests?.[0]?.title || + pendingActions[0]?.prompt || + pendingActions[0]?.questions?.[0]?.question || + null + ); + } + + if (status === "failed") { + return latestTurn?.error_message || latestAssistant?.runtimeStatus?.detail || null; + } + + if (status === "aborted") { + return latestAssistant?.runtimeStatus?.detail || "本轮已中断"; + } + + return null; +} + +export function buildInputbarRuntimeStatusLineModel({ + messages, + turns = [], + threadItems = [], + currentTurnId = null, + threadRead = null, + pendingActions = [], + queuedTurns = [], + childSubagentSessions = [], + isSending = false, +}: BuildInputbarRuntimeStatusLineModelParams): InputbarRuntimeStatusLineModel | null { + const latestTurn = resolveLatestTurn(turns, currentTurnId); + const latestAssistant = resolveLatestAssistantMessage(messages); + const fallbackBatchDescriptor = resolveLatestTurnBatchDescriptor( + latestTurn, + threadItems, + ); + const startedAt = resolveLatestTurnTimestamp( + latestTurn, + threadRead, + "startedAt", + ); + const completedAt = resolveLatestTurnTimestamp( + latestTurn, + threadRead, + "completedAt", + ); + const builtTask = buildAgentTaskRuntimeCardModel({ + messages, + turns, + threadItems, + currentTurnId, + threadRead, + pendingActions, + queuedTurns, + childSubagentSessions, + isSending, + }); + + if (builtTask) { + return { + status: builtTask.status, + detail: + builtTask.status === "waiting_input" || + builtTask.status === "failed" || + builtTask.status === "aborted" + ? builtTask.detail + : null, + batchDescriptor: builtTask.batchDescriptor, + queuedTurnCount: builtTask.queuedTurnCount, + pendingRequestCount: builtTask.pendingRequestCount, + subtaskStats: builtTask.subtaskStats, + usage: resolveVisibleUsage(builtTask.status, latestAssistant), + startedAt, + completedAt, + }; + } + + const status = resolveFallbackStatus({ + latestTurn, + threadRead, + pendingActions, + queuedTurnCount: queuedTurns.length, + isSending, + }); + if (!status) { + return null; + } + + const usage = resolveVisibleUsage(status, latestAssistant); + if ( + status === "completed" && + !usage && + !startedAt && + childSubagentSessions.length === 0 + ) { + return null; + } + + return { + status, + detail: resolveFallbackDetail({ + status, + latestTurn, + latestAssistant, + pendingActions, + threadRead, + }), + batchDescriptor: fallbackBatchDescriptor, + queuedTurnCount: queuedTurns.length, + pendingRequestCount: + pendingActions.length || threadRead?.pending_requests?.length || 0, + subtaskStats: resolveSubtaskStats(childSubagentSessions), + usage, + startedAt, + completedAt, + }; +} diff --git a/src/components/agent/chat/utils/internalImagePlaceholder.test.ts b/src/components/agent/chat/utils/internalImagePlaceholder.test.ts index 7e80aaf2e..7fd061f55 100644 --- a/src/components/agent/chat/utils/internalImagePlaceholder.test.ts +++ b/src/components/agent/chat/utils/internalImagePlaceholder.test.ts @@ -128,4 +128,24 @@ describe("internalImagePlaceholder", () => { }), ).toBe(text); }); + + it("应去掉 assistant 消息里的 markdown 阶段结论标题", () => { + const text = "## 阶段结论\n\n已经找到关键线索。"; + + expect( + sanitizeMessageTextForDisplay(text, { + role: "assistant", + }), + ).toBe("已经找到关键线索。"); + }); + + it("应去掉 assistant 消息里的行内阶段结论文案", () => { + const text = "阶段结论:已经找到关键线索。"; + + expect( + sanitizeMessageTextForDisplay(text, { + role: "assistant", + }), + ).toBe("已经找到关键线索。"); + }); }); diff --git a/src/components/agent/chat/utils/internalImagePlaceholder.ts b/src/components/agent/chat/utils/internalImagePlaceholder.ts index 87b09070e..4d3fb9921 100644 --- a/src/components/agent/chat/utils/internalImagePlaceholder.ts +++ b/src/components/agent/chat/utils/internalImagePlaceholder.ts @@ -25,6 +25,8 @@ const TOOL_NARRATION_NAVIGATION_RE = const TOOL_NARRATION_RESULT_RE = /结果如下|结论|我发现|发现了|查到|查到了|显示|表明|说明|意味着|共有|共计|\d+\s*(?:个|条|项|篇|页|处)/i; const TOOL_NARRATION_MAX_LENGTH = 120; +const ASSISTANT_PHASE_SUMMARY_HEADING_RE = /^\s{0,3}#{1,6}\s*阶段结论\s*$/; +const ASSISTANT_PHASE_SUMMARY_INLINE_RE = /^\s*阶段结论[::]\s*/; function collapseDisplayWhitespace(value: string): string { return value @@ -82,6 +84,36 @@ function shouldStripAssistantToolNarration(text: string): boolean { ); } +function stripAssistantPhaseSummaryTitle(text: string): string { + const strippedLines: string[] = []; + const lines = text.split(/\r?\n/); + + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index] || ""; + const trimmed = line.trim(); + + if (ASSISTANT_PHASE_SUMMARY_HEADING_RE.test(trimmed) || trimmed === "阶段结论") { + while (index + 1 < lines.length && !(lines[index + 1] || "").trim()) { + index += 1; + } + continue; + } + + if (ASSISTANT_PHASE_SUMMARY_INLINE_RE.test(trimmed)) { + const stripped = line.replace(ASSISTANT_PHASE_SUMMARY_INLINE_RE, ""); + if (!stripped.trim()) { + continue; + } + strippedLines.push(stripped); + continue; + } + + strippedLines.push(line); + } + + return strippedLines.join("\n").replace(/\n{3,}/g, "\n\n").trim(); +} + export function containsInternalImagePlaceholder(text: string): boolean { return ( BRACKET_IMAGE_PLACEHOLDER_TEST_RE.test(text) || @@ -121,7 +153,7 @@ export function sanitizeMessageTextForDisplay( ): string { const normalized = options.role === "assistant" - ? stripAssistantProtocolResidue(text) + ? stripAssistantPhaseSummaryTitle(stripAssistantProtocolResidue(text)) : text.trim(); if (!normalized) { return ""; diff --git a/src/components/agent/chat/utils/protocolResidue.test.ts b/src/components/agent/chat/utils/protocolResidue.test.ts index b1dc12613..75210a7f8 100644 --- a/src/components/agent/chat/utils/protocolResidue.test.ts +++ b/src/components/agent/chat/utils/protocolResidue.test.ts @@ -73,4 +73,21 @@ describe("protocolResidue", () => { expect(containsAssistantProtocolResidue(normal)).toBe(false); expect(stripAssistantProtocolResidue(normal)).toBe(normal); }); + + it("应清理内部续跑提示泄露的中文残留", () => { + const leaked = [ + "请继续。你上一条回复还是中间过程结论,不是最终答复。", + "", + "上一条回复已经是完整结论了,不是中间过程。我已经基于足够的代码证据给出了:", + "- 项目概览", + "- 16 个对标优化点", + "", + "如果你希望我继续深挖某个方向,再告诉我即可。", + ].join("\n"); + + expect(containsAssistantProtocolResidue(leaked)).toBe(true); + expect(stripAssistantProtocolResidue(leaked)).toBe( + "如果你希望我继续深挖某个方向,再告诉我即可。", + ); + }); }); diff --git a/src/components/agent/chat/utils/protocolResidue.ts b/src/components/agent/chat/utils/protocolResidue.ts index 0d4edd80d..755613ffc 100644 --- a/src/components/agent/chat/utils/protocolResidue.ts +++ b/src/components/agent/chat/utils/protocolResidue.ts @@ -13,6 +13,9 @@ const INTERNAL_PROTOCOL_PARAGRAPH_PATTERNS = [ /\bselect:\s*structuredoutput\b/i, /\bfinal[_\s-]*response\b.*\bsubmit\b.*\bresponse\b/i, /\boutput\b.*\bfinal\b.*\bdeliver\b.*\bartifact\b.*\bdocument\b/i, + /^(?:请继续[。.]?\s*)?你上一条回复没有输出任何内容[。.]?/, + /^(?:请继续[。.]?\s*)?你上一条回复还是中间过程结论,不是最终答复[。.]?/, + /^上一条回复已经是完整结论(?:了)?[,,]\s*不是中间过程(?:[。.,,]\s*我已经基于足够的(?:代码)?证据给出了[::]?)?/, ] as const; const INTERNAL_PROTOCOL_LINE_PATTERNS = [ /^structuredoutput$/i, @@ -21,7 +24,19 @@ const INTERNAL_PROTOCOL_LINE_PATTERNS = [ /^final output tool$/i, /^deliver artifact document$/i, /^output final deliver artifact document$/i, + /^请继续[。.]?$/, + /^你上一条回复没有输出任何内容[。.]?$/, + /^你上一条回复还是中间过程结论,不是最终答复[。.]?$/, + /^上一条回复已经是完整结论(?:了)?[,,]\s*不是中间过程[。.]?$/, + /^我已经基于足够的(?:代码)?证据给出了[::]?$/, ] as const; +const INTERNAL_PROTOCOL_PARAGRAPH_PREFIX_PATTERNS = [ + /^(?:请继续[。.]?\s*)?你上一条回复没有输出任何内容[。.]?/, + /^(?:请继续[。.]?\s*)?你上一条回复还是中间过程结论,不是最终答复[。.]?/, + /^上一条回复已经是完整结论(?:了)?[,,]\s*不是中间过程(?:[。.,,]\s*我已经基于足够的(?:代码)?证据给出了[::]?)?/, +] as const; +const INTERNAL_CONTINUATION_LIST_LINE_RE = + /^(?:[-*•]\s+|\d+[.)]\s+|项目概览$|\d+\s*个对标优化点$|\d+\s*阶段行动路线$)/; function isLikelyProviderTraceJsonLine(line: string): boolean { const normalized = line.trim(); @@ -90,6 +105,19 @@ function isAssistantProtocolResidueParagraph(paragraph: string): boolean { return false; } + const firstLine = normalized + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean); + if ( + firstLine && + INTERNAL_PROTOCOL_PARAGRAPH_PREFIX_PATTERNS.some((pattern) => + pattern.test(firstLine), + ) + ) { + return true; + } + if ( INTERNAL_PROTOCOL_PARAGRAPH_PATTERNS.some((pattern) => pattern.test(normalized), @@ -119,6 +147,47 @@ function normalizeProtocolStripWhitespace(text: string): string { .trim(); } +function stripInternalContinuationResidue(text: string): string { + if (!text) { + return ""; + } + + const lines = text.split(/\r?\n/); + const keptLines: string[] = []; + let skippingContinuationBlock = false; + + for (const line of lines) { + const normalized = line.trim(); + + if ( + INTERNAL_PROTOCOL_PARAGRAPH_PREFIX_PATTERNS.some((pattern) => + pattern.test(normalized), + ) + ) { + skippingContinuationBlock = true; + continue; + } + + if (!skippingContinuationBlock) { + keptLines.push(line); + continue; + } + + if ( + !normalized || + INTERNAL_CONTINUATION_LIST_LINE_RE.test(normalized) || + INTERNAL_PROTOCOL_LINE_PATTERNS.some((pattern) => pattern.test(normalized)) + ) { + continue; + } + + skippingContinuationBlock = false; + keptLines.push(line); + } + + return normalizeProtocolStripWhitespace(keptLines.join("\n")); +} + function stripProviderTraceResidue(text: string): string { if (!text) { return ""; @@ -219,7 +288,13 @@ export function stripAssistantProtocolResidue(text: string): string { } const withoutProviderTrace = stripProviderTraceResidue(text); - const withoutBlocks = withoutProviderTrace.replace(TOOL_PROTOCOL_BLOCK_RE, "\n"); + const withoutInternalContinuation = stripInternalContinuationResidue( + withoutProviderTrace, + ); + const withoutBlocks = withoutInternalContinuation.replace( + TOOL_PROTOCOL_BLOCK_RE, + "\n", + ); const withoutTags = withoutBlocks.replace(TOOL_PROTOCOL_TAG_RE, "\n"); const sanitizedParagraphs = withoutTags .split(/\n{2,}/) diff --git a/src/components/agent/chat/utils/runtimeToolAvailability.test.ts b/src/components/agent/chat/utils/runtimeToolAvailability.test.ts new file mode 100644 index 000000000..cb8328344 --- /dev/null +++ b/src/components/agent/chat/utils/runtimeToolAvailability.test.ts @@ -0,0 +1,83 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + deriveRuntimeToolAvailability, + RUNTIME_TOOL_AVAILABILITY_OVERRIDE_STORAGE_KEY, +} from "./runtimeToolAvailability"; + +describe("runtime tool surface 派生", () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + afterEach(() => { + window.localStorage.clear(); + }); + + it("runtime tool surface 应优先从 runtime_tools 派生 current capability", () => { + const availability = deriveRuntimeToolAvailability({ + agent_initialized: true, + runtime_tools: [ + { name: "WebSearch" }, + { name: "Agent" }, + { name: "SendMessage" }, + { name: "TeamCreate" }, + { name: "TeamDelete" }, + { name: "ListPeers" }, + { name: "TaskCreate" }, + { name: "TaskGet" }, + { name: "TaskList" }, + { name: "TaskUpdate" }, + { name: "TaskOutput" }, + { name: "TaskStop" }, + ], + registry_tools: [{ name: "registry-only" }], + } as Parameters[0]); + + expect(availability).toMatchObject({ + source: "runtime_tools", + known: true, + webSearch: true, + subagentRuntime: true, + taskRuntime: true, + availableToolCount: 12, + }); + }); + + it("runtime tool surface 应支持开发态 localStorage 覆盖,便于真实页面 smoke", () => { + window.localStorage.setItem( + RUNTIME_TOOL_AVAILABILITY_OVERRIDE_STORAGE_KEY, + JSON.stringify({ + 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 availability = deriveRuntimeToolAvailability({ + agent_initialized: true, + runtime_tools: [{ name: "WebSearch" }], + registry_tools: [], + } as Parameters[0]); + + expect(availability).toMatchObject({ + source: "runtime_tools", + known: true, + webSearch: false, + subagentRuntime: false, + taskRuntime: false, + availableToolCount: 2, + missingSubagentCoreTools: ["Agent", "SendMessage"], + missingSubagentTeamTools: ["TeamCreate", "TeamDelete", "ListPeers"], + missingTaskTools: ["TaskCreate"], + }); + }); +}); diff --git a/src/components/agent/chat/utils/runtimeToolAvailability.ts b/src/components/agent/chat/utils/runtimeToolAvailability.ts new file mode 100644 index 000000000..acebea559 --- /dev/null +++ b/src/components/agent/chat/utils/runtimeToolAvailability.ts @@ -0,0 +1,236 @@ +import type { AgentRuntimeToolInventory } from "@/lib/api/agentRuntime"; + +const WEB_SEARCH_TOOL_NAMES = ["WebSearch", "web_search"] as const; +const SUBAGENT_CORE_TOOL_NAMES = ["Agent", "SendMessage"] as const; +const SUBAGENT_TEAM_TOOL_NAMES = [ + "TeamCreate", + "TeamDelete", + "ListPeers", +] as const; +const TASK_TOOL_NAMES = [ + "TaskCreate", + "TaskGet", + "TaskList", + "TaskUpdate", + "TaskOutput", + "TaskStop", +] as const; + +type RuntimeToolAvailabilitySource = + | "runtime_tools" + | "registry_tools" + | "none"; + +export const RUNTIME_TOOL_AVAILABILITY_OVERRIDE_STORAGE_KEY = + "lime:debug:runtime-tool-availability:v1"; + +export interface RuntimeToolAvailability { + source: RuntimeToolAvailabilitySource; + known: boolean; + agentInitialized: boolean; + availableToolCount: number; + webSearch: boolean; + subagentCore: boolean; + subagentTeamTools: boolean; + subagentRuntime: boolean; + taskRuntime: boolean; + missingSubagentCoreTools: string[]; + missingSubagentTeamTools: string[]; + missingTaskTools: string[]; +} + +function isRuntimeToolAvailabilitySource( + value: unknown, +): value is RuntimeToolAvailabilitySource { + return ( + value === "runtime_tools" || value === "registry_tools" || value === "none" + ); +} + +function normalizeStringList(value: unknown): string[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + + return value + .filter((item): item is string => typeof item === "string") + .map((item) => item.trim()) + .filter(Boolean); +} + +function readRuntimeToolAvailabilityOverride(): Partial | null { + if ( + !import.meta.env.DEV || + typeof window === "undefined" || + typeof window.localStorage === "undefined" + ) { + return null; + } + + const raw = window.localStorage.getItem( + RUNTIME_TOOL_AVAILABILITY_OVERRIDE_STORAGE_KEY, + ); + if (!raw) { + return null; + } + + try { + const parsed = JSON.parse(raw) as Record; + const override: Partial = {}; + + if (isRuntimeToolAvailabilitySource(parsed.source)) { + override.source = parsed.source; + } + if (typeof parsed.known === "boolean") { + override.known = parsed.known; + } + if (typeof parsed.agentInitialized === "boolean") { + override.agentInitialized = parsed.agentInitialized; + } + if ( + typeof parsed.availableToolCount === "number" && + Number.isFinite(parsed.availableToolCount) + ) { + override.availableToolCount = parsed.availableToolCount; + } + if (typeof parsed.webSearch === "boolean") { + override.webSearch = parsed.webSearch; + } + if (typeof parsed.subagentCore === "boolean") { + override.subagentCore = parsed.subagentCore; + } + if (typeof parsed.subagentTeamTools === "boolean") { + override.subagentTeamTools = parsed.subagentTeamTools; + } + if (typeof parsed.subagentRuntime === "boolean") { + override.subagentRuntime = parsed.subagentRuntime; + } + if (typeof parsed.taskRuntime === "boolean") { + override.taskRuntime = parsed.taskRuntime; + } + + const missingSubagentCoreTools = normalizeStringList( + parsed.missingSubagentCoreTools, + ); + if (missingSubagentCoreTools) { + override.missingSubagentCoreTools = missingSubagentCoreTools; + } + + const missingSubagentTeamTools = normalizeStringList( + parsed.missingSubagentTeamTools, + ); + if (missingSubagentTeamTools) { + override.missingSubagentTeamTools = missingSubagentTeamTools; + } + + const missingTaskTools = normalizeStringList(parsed.missingTaskTools); + if (missingTaskTools) { + override.missingTaskTools = missingTaskTools; + } + + return Object.keys(override).length > 0 ? override : null; + } catch { + return null; + } +} + +function applyRuntimeToolAvailabilityOverride( + base: RuntimeToolAvailability, +): RuntimeToolAvailability { + const override = readRuntimeToolAvailabilityOverride(); + if (!override) { + return base; + } + + return { + ...base, + ...override, + missingSubagentCoreTools: + override.missingSubagentCoreTools ?? base.missingSubagentCoreTools, + missingSubagentTeamTools: + override.missingSubagentTeamTools ?? base.missingSubagentTeamTools, + missingTaskTools: override.missingTaskTools ?? base.missingTaskTools, + }; +} + +function normalizeToolName(name: string): string { + return name.trim().toLowerCase(); +} + +function collectRuntimeToolNames( + toolInventory?: AgentRuntimeToolInventory | null, +): { + source: RuntimeToolAvailabilitySource; + toolNames: Set; +} { + const runtimeTools = toolInventory?.runtime_tools || []; + if (runtimeTools.length > 0) { + return { + source: "runtime_tools", + toolNames: new Set( + runtimeTools.map((entry) => normalizeToolName(entry.name)), + ), + }; + } + + const registryTools = toolInventory?.registry_tools || []; + if (registryTools.length > 0) { + return { + source: "registry_tools", + toolNames: new Set( + registryTools.map((entry) => normalizeToolName(entry.name)), + ), + }; + } + + return { + source: "none", + toolNames: new Set(), + }; +} + +function missingToolNames( + toolNames: Set, + expectedNames: readonly string[], +): string[] { + return expectedNames.filter( + (toolName) => !toolNames.has(normalizeToolName(toolName)), + ); +} + +export function deriveRuntimeToolAvailability( + toolInventory?: AgentRuntimeToolInventory | null, +): RuntimeToolAvailability { + const { source, toolNames } = collectRuntimeToolNames(toolInventory); + const missingSubagentCoreTools = missingToolNames( + toolNames, + SUBAGENT_CORE_TOOL_NAMES, + ); + const missingSubagentTeamTools = missingToolNames( + toolNames, + SUBAGENT_TEAM_TOOL_NAMES, + ); + const missingTaskTools = missingToolNames(toolNames, TASK_TOOL_NAMES); + const webSearch = + missingToolNames(toolNames, WEB_SEARCH_TOOL_NAMES).length < + WEB_SEARCH_TOOL_NAMES.length; + const subagentCore = missingSubagentCoreTools.length === 0; + const subagentTeamTools = missingSubagentTeamTools.length === 0; + const taskRuntime = missingTaskTools.length === 0; + const agentInitialized = Boolean(toolInventory?.agent_initialized); + + return applyRuntimeToolAvailabilityOverride({ + source, + known: agentInitialized && source !== "none", + agentInitialized, + availableToolCount: toolNames.size, + webSearch, + subagentCore, + subagentTeamTools, + subagentRuntime: subagentCore && subagentTeamTools, + taskRuntime, + missingSubagentCoreTools, + missingSubagentTeamTools, + missingTaskTools, + }); +} diff --git a/src/components/agent/chat/utils/toolBatchGrouping.test.ts b/src/components/agent/chat/utils/toolBatchGrouping.test.ts new file mode 100644 index 000000000..8b66541d6 --- /dev/null +++ b/src/components/agent/chat/utils/toolBatchGrouping.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; + +import type { AgentToolCallState } from "@/lib/api/agentProtocol"; + +import { summarizeStreamingToolBatch } from "./toolBatchGrouping"; + +function createToolCall( + name: string, + argumentsValue?: Record, +): AgentToolCallState { + return { + id: `${name}-1`, + name, + status: "completed", + arguments: argumentsValue ? JSON.stringify(argumentsValue) : undefined, + startTime: new Date("2026-04-14T00:00:00.000Z"), + endTime: new Date("2026-04-14T00:00:01.000Z"), + result: { + success: true, + output: "ok", + }, + }; +} + +describe("toolBatchGrouping", () => { + it("应把 MCP 搜索与读取归入探索批次", () => { + const summary = summarizeStreamingToolBatch([ + createToolCall("mcp__github__search_code", { + query: "repo:lime tool runtime", + }), + createToolCall("mcp__github__get_file_contents", { + path: "docs/guide.md", + }), + ]); + + expect(summary).toEqual( + expect.objectContaining({ + kind: "exploration", + title: "已探索项目", + countLabel: "读 1 / 搜 1", + rawDetailLabel: "展开查看探索明细", + }), + ); + expect(summary?.supportingLines).toContain("查看了 1 个文件,搜索 1 次"); + expect(summary?.supportingLines).toContain("最新线索:guide.md"); + }); + + it("应让 REPL 调用被吸收到探索批次而不打断摘要", () => { + const summary = summarizeStreamingToolBatch([ + createToolCall("Read", { + file_path: "src/main.ts", + }), + createToolCall("REPLTool", { + code: "rg \"tool inventory\" src", + }), + createToolCall("Grep", { + pattern: "tool inventory", + path: "src", + }), + ]); + + expect(summary).toEqual( + expect.objectContaining({ + kind: "exploration", + countLabel: "读 1 / 搜 1", + }), + ); + }); + + it("应继续把浏览器 MCP 步骤聚合为页面检查摘要", () => { + const summary = summarizeStreamingToolBatch([ + createToolCall("mcp__lime-browser__navigate", { + url: "https://example.com", + }), + createToolCall("mcp__lime-browser__click", { + selector: "#cta", + }), + ]); + + expect(summary).toEqual( + expect.objectContaining({ + kind: "browser", + title: "已检查页面", + countLabel: "2 步", + }), + ); + expect(summary?.supportingLines).toContain("检查了 2 个页面步骤"); + expect(summary?.supportingLines).toContain("最近目标:#cta"); + }); +}); diff --git a/src/components/agent/chat/utils/toolBatchGrouping.ts b/src/components/agent/chat/utils/toolBatchGrouping.ts new file mode 100644 index 000000000..d9007da8c --- /dev/null +++ b/src/components/agent/chat/utils/toolBatchGrouping.ts @@ -0,0 +1,422 @@ +import type { AgentToolCallState as ToolCallState } from "@/lib/api/agentProtocol"; +import type { AgentThreadItem } from "../types"; +import { + classifyMcpToolOperationKind, + isBrowserToolName, + normalizeToolNameKey, + parseToolCallArguments, + resolveToolFilePath, +} from "./toolDisplayInfo"; + +export type ToolBatchKind = "exploration" | "browser"; + +export interface ToolBatchSummaryDescriptor { + kind: ToolBatchKind; + title: string; + supportingLines: string[]; + countLabel: string; + rawDetailLabel: string; +} + +type ToolOperationKind = + | "read" + | "search" + | "list" + | "browser" + | "absorbed" + | "other"; + +interface ToolBatchAccumulator { + readCount: number; + searchCount: number; + listCount: number; + browserCount: number; + significantCount: number; + absorbedCount: number; + otherCount: number; + latestHint: string | null; +} + +interface ToolLikeDescriptor { + toolName: string; + argumentsValue?: string | Record; + command?: string | null; + query?: string | null; +} + +function shorten(value: string | null | undefined, maxLength = 72): string | null { + const normalized = value?.trim(); + if (!normalized) { + return null; + } + if (normalized.length <= maxLength) { + return normalized; + } + return `${normalized.slice(0, maxLength - 1).trimEnd()}…`; +} + +function asRecord(value: unknown): Record { + if (!value) { + return {}; + } + + if (typeof value === "string") { + const parsed = parseToolCallArguments(value); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + return parsed as Record; + } + return {}; + } + + if (typeof value === "object" && !Array.isArray(value)) { + return value as Record; + } + + return {}; +} + +function readString( + record: Record, + keys: string[], +): string | null { + for (const key of keys) { + const value = record[key]; + if (typeof value === "string" && value.trim()) { + return value.trim(); + } + } + return null; +} + +function fileNameFromPath(path: string): string { + const normalized = path.replace(/\\/g, "/"); + const parts = normalized.split("/"); + return parts[parts.length - 1] || path; +} + +function resolveBashLikeKind(command: string): ToolOperationKind { + const normalized = command.trim().toLowerCase(); + if (!normalized) { + return "other"; + } + + if ( + /\b(rg|grep|findstr|ag|ack)\b/.test(normalized) || + /\bselect-string\b/.test(normalized) + ) { + return "search"; + } + + if (/\b(ls|tree|dir|fd|find)\b/.test(normalized)) { + return "list"; + } + + if (/\b(cat|head|tail|sed|awk|more|less|wc)\b/.test(normalized)) { + return "read"; + } + + return "other"; +} + +function resolveToolOperationKind( + descriptor: ToolLikeDescriptor, +): ToolOperationKind { + const normalizedName = normalizeToolNameKey(descriptor.toolName); + const args = asRecord(descriptor.argumentsValue); + const mcpOperationKind = classifyMcpToolOperationKind(descriptor.toolName); + + if ( + normalizedName === "toolsearch" || + normalizedName === "repl" || + normalizedName === "listskills" || + normalizedName === "loadskill" + ) { + return "absorbed"; + } + + if (mcpOperationKind) { + return mcpOperationKind; + } + + if (isBrowserToolName(normalizedName)) { + return "browser"; + } + + if ( + normalizedName.includes("search") || + normalizedName.includes("grep") || + normalizedName.includes("query") || + normalizedName.includes("find") || + normalizedName.includes("fetch") || + normalizedName === "web" + ) { + return "search"; + } + + if ( + normalizedName.includes("glob") || + normalizedName.includes("list") || + normalizedName.includes("dir") + ) { + return "list"; + } + + if ( + normalizedName.includes("read") || + normalizedName.includes("view") || + normalizedName.includes("cat") || + normalizedName.includes("open") + ) { + return "read"; + } + + if ( + normalizedName.includes("bash") || + normalizedName.includes("shell") || + normalizedName.includes("exec") || + normalizedName.includes("command") + ) { + const command = + descriptor.command || + readString(args, ["command", "cmd", "script"]) || + ""; + return resolveBashLikeKind(command); + } + + return "other"; +} + +function resolveLatestHint( + descriptor: ToolLikeDescriptor, + operationKind: ToolOperationKind, +): string | null { + const args = asRecord(descriptor.argumentsValue); + if (operationKind === "search") { + return shorten( + descriptor.query || + readString(args, ["query", "q", "pattern", "search", "url"]), + 56, + ); + } + + if (operationKind === "read" || operationKind === "list") { + const filePath = resolveToolFilePath(args); + if (filePath) { + return shorten(fileNameFromPath(filePath), 48); + } + return shorten(readString(args, ["path", "file_path", "directory"]), 48); + } + + if (operationKind === "browser") { + return shorten( + readString(args, ["url", "pageUrl", "page_url", "selector", "target", "label"]), + 56, + ); + } + + const command = + descriptor.command || readString(args, ["command", "cmd", "script"]); + return shorten(command, 56); +} + +function accumulateBatch( + entries: ToolLikeDescriptor[], +): ToolBatchAccumulator { + const accumulator: ToolBatchAccumulator = { + readCount: 0, + searchCount: 0, + listCount: 0, + browserCount: 0, + significantCount: 0, + absorbedCount: 0, + otherCount: 0, + latestHint: null, + }; + + for (const entry of entries) { + const operationKind = resolveToolOperationKind(entry); + switch (operationKind) { + case "read": + accumulator.readCount += 1; + accumulator.significantCount += 1; + break; + case "search": + accumulator.searchCount += 1; + accumulator.significantCount += 1; + break; + case "list": + accumulator.listCount += 1; + accumulator.significantCount += 1; + break; + case "browser": + accumulator.browserCount += 1; + accumulator.significantCount += 1; + break; + case "absorbed": + accumulator.absorbedCount += 1; + break; + default: + accumulator.otherCount += 1; + break; + } + + const hint = resolveLatestHint(entry, operationKind); + if (hint) { + accumulator.latestHint = hint; + } + } + + return accumulator; +} + +function buildExplorationDescriptor( + accumulator: ToolBatchAccumulator, +): ToolBatchSummaryDescriptor | null { + const { readCount, searchCount, listCount, significantCount, otherCount } = + accumulator; + if (significantCount < 2 || otherCount > 0 || accumulator.browserCount > 0) { + return null; + } + + const title = + readCount > 0 && searchCount > 0 + ? "已探索项目" + : readCount > 0 + ? "已查看关键文件" + : searchCount > 0 + ? "已搜索关键线索" + : "已查看目录结构"; + + const detailParts: string[] = []; + if (readCount > 0) { + detailParts.push(`查看了 ${readCount} 个文件`); + } + if (searchCount > 0) { + detailParts.push(`搜索 ${searchCount} 次`); + } + if (listCount > 0) { + detailParts.push(`列了 ${listCount} 个目录`); + } + + const countParts: string[] = []; + if (readCount > 0) { + countParts.push(`读 ${readCount}`); + } + if (searchCount > 0) { + countParts.push(`搜 ${searchCount}`); + } + if (listCount > 0) { + countParts.push(`列 ${listCount}`); + } + + const supportingLines = detailParts.length > 0 ? [detailParts.join(",")] : []; + if (accumulator.latestHint) { + supportingLines.push(`最新线索:${accumulator.latestHint}`); + } + + return { + kind: "exploration", + title, + supportingLines, + countLabel: countParts.join(" / ") || `${significantCount} 步`, + rawDetailLabel: "展开查看探索明细", + }; +} + +function buildBrowserDescriptor( + accumulator: ToolBatchAccumulator, +): ToolBatchSummaryDescriptor | null { + if ( + accumulator.browserCount < 2 || + accumulator.otherCount > 0 || + accumulator.readCount > 0 || + accumulator.searchCount > 0 || + accumulator.listCount > 0 + ) { + return null; + } + + const supportingLines = [`检查了 ${accumulator.browserCount} 个页面步骤`]; + if (accumulator.latestHint) { + supportingLines.push(`最近目标:${accumulator.latestHint}`); + } + + return { + kind: "browser", + title: "已检查页面", + supportingLines, + countLabel: `${accumulator.browserCount} 步`, + rawDetailLabel: "展开查看页面操作明细", + }; +} + +function buildDescriptorFromEntries( + entries: ToolLikeDescriptor[], +): ToolBatchSummaryDescriptor | null { + if (entries.length < 2) { + return null; + } + + const accumulator = accumulateBatch(entries); + return ( + buildExplorationDescriptor(accumulator) || + buildBrowserDescriptor(accumulator) + ); +} + +export function summarizeStreamingToolBatch( + toolCalls: ToolCallState[], +): ToolBatchSummaryDescriptor | null { + return buildDescriptorFromEntries( + toolCalls.map((toolCall) => ({ + toolName: toolCall.name, + argumentsValue: toolCall.arguments, + })), + ); +} + +export function summarizeThreadProcessBatch( + items: AgentThreadItem[], +): ToolBatchSummaryDescriptor | null { + if ( + items.length < 2 || + items.some( + (item) => + item.type !== "tool_call" && + item.type !== "command_execution" && + item.type !== "web_search", + ) + ) { + return null; + } + + const descriptors: ToolLikeDescriptor[] = items.map((item) => { + if (item.type === "command_execution") { + return { + toolName: "exec_command", + command: item.command, + argumentsValue: { command: item.command, cwd: item.cwd }, + }; + } + + if (item.type === "web_search") { + return { + toolName: item.action || "web_search", + query: item.query || item.action || null, + argumentsValue: { query: item.query || item.action || "" }, + }; + } + + return { + toolName: item.tool_name, + argumentsValue: + item.arguments && typeof item.arguments === "object" + ? (item.arguments as Record) + : item.arguments === undefined + ? undefined + : String(item.arguments), + }; + }); + + return buildDescriptorFromEntries(descriptors); +} diff --git a/src/components/agent/chat/utils/toolDisplayInfo.test.ts b/src/components/agent/chat/utils/toolDisplayInfo.test.ts index 728ade9f2..992b337a8 100644 --- a/src/components/agent/chat/utils/toolDisplayInfo.test.ts +++ b/src/components/agent/chat/utils/toolDisplayInfo.test.ts @@ -6,6 +6,7 @@ import { extractSearchQueryLabel, getToolDisplayInfo, normalizeToolNameKey, + resolveToolPrimarySubject, resolveUserFacingToolDisplayLabel, resolveToolDisplayLabel, } from "./toolDisplayInfo"; @@ -27,9 +28,12 @@ const REFERENCE_JS_TOOL_NAME_MAPPINGS = [ ["GrepTool", "grep"], ["LSPTool", "lsp"], ["ListMcpResourcesTool", "listmcpresources"], + ["MCPTool", "mcp"], + ["McpAuthTool", "mcpauth"], ["NotebookEditTool", "notebookedit"], ["PowerShellTool", "powershell"], ["ReadMcpResourceTool", "readmcpresource"], + ["REPLTool", "repl"], ["RemoteTriggerTool", "remotetrigger"], ["ScheduleCronTool", "croncreate"], ["SendMessageTool", "sendmessage"], @@ -66,6 +70,14 @@ describe("toolDisplayInfo", () => { expect(resolveToolDisplayLabel("AskUserQuestionTool")).toBe("用户确认"); expect(resolveToolDisplayLabel("BriefTool")).toBe("用户消息"); expect(resolveToolDisplayLabel("FileReadTool")).toBe("文件读取"); + expect(resolveToolDisplayLabel("ConfigTool")).toBe("运行配置"); + expect(resolveToolDisplayLabel("PowerShellTool")).toBe("PowerShell"); + expect(resolveToolDisplayLabel("WorkflowTool")).toBe("工作流执行"); + expect(resolveToolDisplayLabel("MCPTool")).toBe("MCP 工具"); + expect(resolveToolDisplayLabel("McpAuthTool")).toBe("MCP 授权"); + expect(resolveToolDisplayLabel("REPLTool")).toBe("REPL 执行"); + expect(resolveToolDisplayLabel("EnterWorktreeTool")).toBe("进入工作树"); + expect(resolveToolDisplayLabel("ExitWorktreeTool")).toBe("退出工作树"); expect(resolveToolDisplayLabel("lime_search_web_images")).toBe("联网搜图"); expect(resolveToolDisplayLabel("AgentTool")).toBe("创建子任务"); expect(resolveToolDisplayLabel("SendMessageTool")).toBe("补充说明"); @@ -75,16 +87,85 @@ describe("toolDisplayInfo", () => { expect(resolveToolDisplayLabel("SyntheticOutputTool")).toBe("最终答复"); expect(resolveToolDisplayLabel("AgentOutputTool")).toBe("任务输出"); expect(resolveToolDisplayLabel("BashOutputTool")).toBe("任务输出"); + expect(resolveToolDisplayLabel("lime_create_transcription_task")).toBe( + "转写任务", + ); + expect( + resolveToolDisplayLabel("lime_create_modal_resource_search_task"), + ).toBe("素材检索任务"); + expect(resolveToolDisplayLabel("lime_run_service_skill")).toBe( + "服务技能执行", + ); + expect(resolveToolDisplayLabel("lime_site_recommend")).toBe( + "站点能力推荐", + ); + expect(resolveToolDisplayLabel("mcp__github__search_code")).toBe( + "MCP 搜索", + ); + expect(resolveToolDisplayLabel("mcp__github__get_file_contents")).toBe( + "MCP 读取", + ); }); it("应为用户可见场景提供更自然的工具标签", () => { expect(resolveUserFacingToolDisplayLabel("FileReadTool")).toBe("查看文件"); expect(resolveUserFacingToolDisplayLabel("write_file")).toBe("保存文件"); + expect(resolveUserFacingToolDisplayLabel("LSPTool")).toBe("分析代码"); + expect(resolveUserFacingToolDisplayLabel("ConfigTool")).toBe("查看配置"); + expect(resolveUserFacingToolDisplayLabel("PowerShellTool")).toBe( + "运行命令", + ); + expect(resolveUserFacingToolDisplayLabel("WorkflowTool")).toBe( + "运行工作流", + ); + expect(resolveUserFacingToolDisplayLabel("MCPTool")).toBe("调用 MCP 工具"); + expect(resolveUserFacingToolDisplayLabel("McpAuthTool")).toBe( + "完成 MCP 授权", + ); + expect(resolveUserFacingToolDisplayLabel("REPLTool")).toBe("运行命令"); + expect( + resolveUserFacingToolDisplayLabel("lime_run_service_skill"), + ).toBe("运行服务技能"); + expect(resolveUserFacingToolDisplayLabel("lime_site_recommend")).toBe( + "推荐站点能力", + ); + expect(resolveUserFacingToolDisplayLabel("mcp__github__search_code")).toBe( + "搜索内容", + ); + expect( + resolveUserFacingToolDisplayLabel("mcp__github__get_file_contents"), + ).toBe("查看内容"); + expect(resolveUserFacingToolDisplayLabel("EnterWorktreeTool")).toBe( + "进入工作树", + ); expect(resolveUserFacingToolDisplayLabel("TaskOutput")).toBe( "查看任务结果", ); - expect(resolveUserFacingToolDisplayLabel("mcp__playwright__browser_click")).toBe( - "页面点击", + expect( + resolveUserFacingToolDisplayLabel("mcp__playwright__browser_click"), + ).toBe("页面点击"); + }); + + it("应为站点与任务工具提取更贴近主链的主体对象", () => { + expect( + resolveToolPrimarySubject( + "lime_create_transcription_task", + { sourceUrl: "https://example.com/interview.mp4" }, + null, + ), + ).toBe("https://example.com/interview.mp4"); + expect( + resolveToolPrimarySubject( + "lime_create_modal_resource_search_task", + { query: "科技播客 BGM" }, + null, + ), + ).toBe("科技播客 BGM"); + expect(resolveToolPrimarySubject("lime_site_list", {}, null)).toBe( + "站点能力目录", + ); + expect(getToolDisplayInfo("lime_create_typesetting_task", "running").family).toBe( + "task", ); }); diff --git a/src/components/agent/chat/utils/toolDisplayInfo.ts b/src/components/agent/chat/utils/toolDisplayInfo.ts index b7a3ca9a2..622d9efe8 100644 --- a/src/components/agent/chat/utils/toolDisplayInfo.ts +++ b/src/components/agent/chat/utils/toolDisplayInfo.ts @@ -36,6 +36,11 @@ export type ToolCallFamily = | "vision" | "generic"; +export type McpToolOperationKind = Extract< + ToolCallFamily, + "search" | "list" | "read" | "browser" +>; + export type ToolCallArgumentValue = | string | number @@ -142,6 +147,15 @@ const TOOL_STATUS_ACTIONS = { type ToolStatusActionKey = keyof typeof TOOL_STATUS_ACTIONS; +const MCP_MUTATION_ACTION_RE = + /(?:^|_)(?:create|update|delete|remove|add|set|send|write|edit|patch|run|execute|submit|publish|approve|reject|reply|comment)(?:_|$)/; +const MCP_BROWSER_ACTION_RE = + /(?:^|_)(?:navigate|goto|click|hover|fill|type|select|press|snapshot|screenshot|drag|upload|wait|tabs?|page|browser|runtime|evaluate)(?:_|$)/; +const MCP_SEARCH_ACTION_RE = + /(?:^|_)(?:search|find|lookup|query)(?:_|$)/; +const MCP_LIST_ACTION_RE = /(?:^|_)list(?:_|$)/; +const MCP_READ_ACTION_RE = /(?:^|_)(?:get|read|fetch|open)(?:_|$)/; + const EXACT_TOOL_CONFIGS = new Map([ [ "read", @@ -357,6 +371,49 @@ const EXACT_TOOL_CONFIGS = new Map([ actionKey: "list", }, ], + [ + "mcp", + { + family: "generic", + label: "MCP 工具", + verb: "调用", + icon: Wrench, + groupTitle: "MCP", + actionKey: "generic", + actions: { + failed: "调用失败", + completed: "已调用 MCP 工具", + running: "调用中", + }, + }, + ], + [ + "mcpauth", + { + family: "generic", + label: "MCP 授权", + verb: "授权", + icon: Wrench, + groupTitle: "MCP", + actionKey: "generic", + actions: { + failed: "授权失败", + completed: "已完成 MCP 授权", + running: "授权中", + }, + }, + ], + [ + "repl", + { + family: "command", + label: "REPL 执行", + verb: "运行", + icon: Terminal, + groupTitle: "命令", + actionKey: "command", + }, + ], [ "grep", { @@ -544,6 +601,65 @@ const EXACT_TOOL_CONFIGS = new Map([ actionKey: "read", }, ], + [ + "config", + { + family: "generic", + label: "运行配置", + verb: "调整", + icon: Settings, + groupTitle: "配置", + actionKey: "generic", + actions: { + failed: "调整失败", + completed: "已调整配置", + running: "调整中", + }, + }, + ], + [ + "workflow", + { + family: "generic", + label: "工作流执行", + verb: "执行", + icon: Settings, + groupTitle: "工作流", + actionKey: "generic", + actions: { + failed: "执行失败", + completed: "已执行工作流", + running: "执行中", + }, + }, + ], + [ + "sleep", + { + family: "generic", + label: "等待", + verb: "等待", + icon: Settings, + groupTitle: "执行", + actionKey: "generic", + actions: { + failed: "等待失败", + completed: "已完成等待", + running: "等待中", + }, + }, + ], + [ + "powershell", + { + family: "command", + label: "PowerShell", + verb: "运行", + icon: Terminal, + groupTitle: "命令", + actionKey: "command", + }, + ], [ "skill", { @@ -705,6 +821,38 @@ const EXACT_TOOL_CONFIGS = new Map([ actionKey: "plan", }, ], + [ + "enterworktree", + { + family: "generic", + label: "进入工作树", + verb: "进入", + icon: FolderOpen, + groupTitle: "工作树", + actionKey: "generic", + actions: { + failed: "进入失败", + completed: "已进入工作树", + running: "进入中", + }, + }, + ], + [ + "exitworktree", + { + family: "generic", + label: "退出工作树", + verb: "退出", + icon: FolderOpen, + groupTitle: "工作树", + actionKey: "generic", + actions: { + failed: "退出失败", + completed: "已退出工作树", + running: "退出中", + }, + }, + ], [ "analyzeimage", { @@ -991,6 +1139,17 @@ const EXACT_TOOL_CONFIGS = new Map([ actionKey: "task", }, ], + [ + "limecreatetranscriptiontask", + { + family: "task", + label: "转写任务", + verb: "创建任务", + icon: FilePlus, + groupTitle: "任务", + actionKey: "task", + }, + ], [ "limecreatebroadcastgenerationtask", { @@ -1024,6 +1183,17 @@ const EXACT_TOOL_CONFIGS = new Map([ actionKey: "task", }, ], + [ + "limecreatemodalresourcesearchtask", + { + family: "task", + label: "素材检索任务", + verb: "创建任务", + icon: FilePlus, + groupTitle: "任务", + actionKey: "task", + }, + ], [ "limecreateimagegenerationtask", { @@ -1057,6 +1227,22 @@ const EXACT_TOOL_CONFIGS = new Map([ actionKey: "task", }, ], + [ + "limerunserviceskill", + { + family: "generic", + label: "服务技能执行", + verb: "执行", + icon: Globe, + groupTitle: "服务技能", + actionKey: "generic", + actions: { + failed: "执行失败", + completed: "已执行", + running: "执行中", + }, + }, + ], [ "limesitelist", { @@ -1073,6 +1259,22 @@ const EXACT_TOOL_CONFIGS = new Map([ }, }, ], + [ + "limesiterecommend", + { + family: "search", + label: "站点能力推荐", + verb: "推荐", + icon: Globe, + groupTitle: "站点", + actionKey: "search", + actions: { + failed: "推荐失败", + completed: "已推荐", + running: "推荐中", + }, + }, + ], [ "limesitesearch", { @@ -1123,6 +1325,42 @@ const EXACT_TOOL_CONFIGS = new Map([ ], ]); +const MCP_OPERATION_TOOL_CONFIGS: Record = + { + search: { + family: "search", + label: "MCP 搜索", + verb: "搜索", + icon: Search, + groupTitle: "MCP", + actionKey: "search", + }, + list: { + family: "list", + label: "MCP 列表", + verb: "查看", + icon: FolderOpen, + groupTitle: "MCP", + actionKey: "list", + }, + read: { + family: "read", + label: "MCP 读取", + verb: "查看", + icon: FileText, + groupTitle: "MCP", + actionKey: "read", + }, + browser: { + family: "browser", + label: "MCP 浏览器", + verb: "操作", + icon: Globe, + groupTitle: "MCP", + actionKey: "browser", + }, + }; + const BROWSER_TOOL_MATCHERS: Array<{ match: (name: string) => boolean; config: ToolDisplayConfig; @@ -1453,6 +1691,9 @@ const TOOL_NAME_KEY_ALIASES: Record = { askuserquestiontool: "askuserquestion", brief: "sendusermessage", brieftool: "sendusermessage", + mcptool: "mcp", + mcpauthtool: "mcpauth", + repltool: "repl", sendusermessage: "sendusermessage", sendusermessagetool: "sendusermessage", spawnagent: "agent", @@ -1486,6 +1727,7 @@ const TOOL_NAME_KEY_ALIASES: Record = { crondeletetool: "crondelete", skilltool: "skill", sleeptool: "sleep", + workflowtool: "workflow", syntheticoutputtool: "structuredoutput", taskcreatetool: "taskcreate", taskgettool: "taskget", @@ -1510,6 +1752,12 @@ const USER_FACING_TOOL_LABELS: Record = { 文件读取: "查看文件", 文档读取: "查看文档", 资源读取: "查看内容", + "MCP 搜索": "搜索内容", + "MCP 列表": "查看内容", + "MCP 读取": "查看内容", + "MCP 浏览器": "操作页面", + "MCP 工具": "调用 MCP 工具", + "MCP 授权": "完成 MCP 授权", 文件写入: "保存文件", 文件创建: "保存文件", 文件编辑: "修改文件", @@ -1525,20 +1773,38 @@ const USER_FACING_TOOL_LABELS: Record = { 文档搜索: "查找文档", 文档查询: "查看文档", 网络搜索: "搜索网页", + 代码分析: "分析代码", 图片搜索: "搜索图片", 联网搜图: "搜索图片", 命令执行: "运行命令", + "REPL 执行": "运行命令", + PowerShell: "运行命令", 技能执行: "使用技能", 技能列表: "查看技能", 技能加载: "加载技能", + 运行配置: "查看配置", + 工作流执行: "运行工作流", + 等待: "等待执行", + 任务创建: "创建任务", + 任务列表: "查看任务", + 任务详情: "查看任务", + 任务更新: "更新任务", + 进入计划模式: "进入计划", + 退出计划模式: "退出计划", + 进入工作树: "进入工作树", + 退出工作树: "退出工作树", + 创建团队: "创建团队", + 删除团队: "删除团队", 任务输出: "查看任务结果", 工作区同步: "同步内容", 图像分析: "分析图片", 图片查看: "查看图片", 站点能力目录: "查看站点能力", + 站点能力推荐: "推荐站点能力", 站点能力搜索: "搜索站点能力", 站点能力详情: "查看站点能力", 站点能力执行: "运行站点能力", + 服务技能执行: "运行服务技能", }; export const normalizeToolNameKey = (value: string): string => { @@ -1557,6 +1823,79 @@ export const humanizeToolName = (toolName: string): string => .replace(/[_-]+/g, " ") .trim() || "工具调用"; +export interface ParsedMcpToolName { + serverName: string; + innerToolName: string; + normalizedInnerToolName: string; +} + +export const parseMcpToolName = ( + toolName: string, +): ParsedMcpToolName | null => { + if (!toolName.startsWith("mcp__")) { + return null; + } + + const segments = toolName.split("__"); + if (segments.length < 3) { + return null; + } + + const [, serverName, ...innerParts] = segments; + const innerToolName = innerParts.join("__").trim(); + if (!serverName || !innerToolName) { + return null; + } + + return { + serverName, + innerToolName, + normalizedInnerToolName: innerToolName + .replace(/([a-z0-9])([A-Z])/g, "$1_$2") + .replace(/-/g, "_") + .toLowerCase(), + }; +}; + +export const classifyMcpToolOperationKind = ( + toolName: string, +): McpToolOperationKind | null => { + const parsed = parseMcpToolName(toolName); + if (!parsed) { + return null; + } + + const { serverName, normalizedInnerToolName } = parsed; + const normalizedServerName = serverName.toLowerCase(); + + if (MCP_MUTATION_ACTION_RE.test(normalizedInnerToolName)) { + return null; + } + + if ( + normalizedServerName.includes("browser") || + normalizedServerName.includes("playwright") || + normalizedServerName.includes("chrome") || + MCP_BROWSER_ACTION_RE.test(normalizedInnerToolName) + ) { + return "browser"; + } + + if (MCP_SEARCH_ACTION_RE.test(normalizedInnerToolName)) { + return "search"; + } + + if (MCP_LIST_ACTION_RE.test(normalizedInnerToolName)) { + return "list"; + } + + if (MCP_READ_ACTION_RE.test(normalizedInnerToolName)) { + return "read"; + } + + return null; +}; + export const parseToolCallArguments = ( value?: string, ): Record => { @@ -1596,7 +1935,6 @@ export const isBrowserToolName = (name: string): boolean => "tabs", "open", "presskey", - "type", "selectoption", "drag", "evaluate", @@ -1707,6 +2045,28 @@ export const resolveToolPrimarySubject = ( ); } + if (classifyMcpToolOperationKind(toolName)) { + return resolveToolArgumentPreview(args, [ + "query", + "q", + "pattern", + "path", + "file_path", + "url", + "uri", + "resource_uri", + "resource", + "name", + "id", + "repo", + "repository", + "owner", + "ref", + "key", + "slug", + ]); + } + if ( normalizedName === "webfetch" || normalizedName === "open" || @@ -1739,17 +2099,49 @@ export const resolveToolPrimarySubject = ( ) { return resolveToolArgumentPreview(args, [ "subject", - "taskId", "title", "topic", + "query", + "q", "keyword", "prompt", "description", - "task_id", + "source_url", + "sourceUrl", + "source_path", + "sourcePath", "url", + "resource_type", + "resourceType", + "target_platform", + "targetPlatform", + "extract_goal", + "extractGoal", + "content", + "taskId", + "task_id", ]); } + if (normalizedName === "limerunserviceskill") { + return ( + resolveToolArgumentPreview(args, [ + "skill_title", + "skillTitle", + "service_skill_id", + "serviceSkillId", + "scene_key", + "sceneKey", + "adapter_name", + "name", + ]) || "服务技能" + ); + } + + if (normalizedName === "limesitelist") { + return "站点能力目录"; + } + if (normalizedName === "limesiterun" || normalizedName === "limesiteinfo") { return ( resolveToolArgumentPreview(args, [ @@ -1757,15 +2149,28 @@ export const resolveToolPrimarySubject = ( "skillTitle", "adapter_name", "name", + "save_title", + "saveTitle", "query", "repo", "url", + "profile_key", + "profileKey", + "target_id", + "targetId", ]) || "站点适配器" ); } + if (normalizedName === "limesiterecommend") { + return resolveToolArgumentPreview(args, ["query", "q", "goal"]) || "站点能力"; + } + if (normalizedName === "limesitesearch") { - return resolveToolArgumentPreview(args, ["query", "q"]) || "站点能力"; + return ( + resolveToolArgumentPreview(args, ["query", "q", "adapter_name", "name"]) || + "站点能力" + ); } if (normalizedName === "toolsearch") { @@ -1867,6 +2272,14 @@ export const getToolDisplayInfo = ( } } + const mcpOperationKind = classifyMcpToolOperationKind(toolName); + if (mcpOperationKind) { + return toToolDisplayDescriptor( + MCP_OPERATION_TOOL_CONFIGS[mcpOperationKind], + status, + ); + } + if ( name.includes("workspace") || name.includes("artifact") || @@ -2032,7 +2445,9 @@ export const toUserFacingToolDisplayLabel = (label: string): string => { }; export const resolveUserFacingToolDisplayLabel = (toolName: string): string => - toUserFacingToolDisplayLabel(resolveToolDisplayLabel(toolName).trim() || toolName); + toUserFacingToolDisplayLabel( + resolveToolDisplayLabel(toolName).trim() || toolName, + ); export const buildToolGroupHeadline = (toolCalls: ToolCallState[]): string => { const first = toolCalls[0]!; diff --git a/src/components/agent/chat/utils/toolProcessSummary.test.ts b/src/components/agent/chat/utils/toolProcessSummary.test.ts new file mode 100644 index 000000000..715c3489e --- /dev/null +++ b/src/components/agent/chat/utils/toolProcessSummary.test.ts @@ -0,0 +1,276 @@ +import { describe, expect, it } from "vitest"; + +import type { AgentToolCallState } from "@/lib/api/agentProtocol"; + +import { resolveToolProcessNarrative } from "./toolProcessSummary"; + +function createToolCall( + overrides: Partial, +): AgentToolCallState { + return { + id: "tool-1", + name: "ConfigTool", + status: "completed", + startTime: new Date("2026-04-14T00:00:00.000Z"), + ...overrides, + }; +} + +describe("toolProcessSummary", () => { + it("应为工作树切换提供明确过程文案", () => { + const enterNarrative = resolveToolProcessNarrative( + createToolCall({ + name: "EnterWorktreeTool", + status: "running", + }), + ); + const exitNarrative = resolveToolProcessNarrative( + createToolCall({ + name: "ExitWorktreeTool", + status: "completed", + }), + ); + + expect(enterNarrative.preSummary).toBe("先进入隔离工作树"); + expect(enterNarrative.summary).toBe("先进入隔离工作树"); + expect(exitNarrative.postSummary).toBe("已回到主工作区"); + expect(exitNarrative.summary).toBe("已回到主工作区"); + }); + + it("应为配置与工作流工具提供稳定文案", () => { + const configNarrative = resolveToolProcessNarrative( + createToolCall({ + name: "ConfigTool", + status: "completed", + }), + ); + const workflowNarrative = resolveToolProcessNarrative( + createToolCall({ + name: "WorkflowTool", + status: "completed", + }), + ); + + expect(configNarrative.preSummary).toBe("先查看或调整运行配置"); + expect(configNarrative.postSummary).toBe("已更新运行配置"); + expect(workflowNarrative.preSummary).toBe("先执行预设工作流"); + expect(workflowNarrative.postSummary).toBe("已执行工作流"); + }); + + it("应为等待工具提供显式完成文案", () => { + const narrative = resolveToolProcessNarrative( + createToolCall({ + name: "SleepTool", + status: "completed", + }), + ); + + expect(narrative.preSummary).toBe("先等待一段时间再继续"); + expect(narrative.postSummary).toBe("已完成等待"); + expect(narrative.summary).toBe("已完成等待"); + }); + + it("应为计划模式与最终答复提供专用文案", () => { + const enterPlanNarrative = resolveToolProcessNarrative( + createToolCall({ + name: "EnterPlanModeTool", + status: "running", + }), + ); + const exitPlanNarrative = resolveToolProcessNarrative( + createToolCall({ + name: "ExitPlanModeTool", + status: "completed", + }), + ); + const finalNarrative = resolveToolProcessNarrative( + createToolCall({ + name: "SyntheticOutputTool", + status: "completed", + }), + ); + + expect(enterPlanNarrative.preSummary).toBe("先进入计划模式拆解方案"); + expect(exitPlanNarrative.postSummary).toBe("已退出计划模式"); + expect(finalNarrative.preSummary).toBe("先整理最终答复"); + expect(finalNarrative.postSummary).toBe("已整理最终答复"); + expect(finalNarrative.summary).toBe("已整理最终答复"); + }); + + it("应区分不同任务与计划工具的过程文案", () => { + const taskListNarrative = resolveToolProcessNarrative( + createToolCall({ + name: "TaskListTool", + status: "completed", + }), + ); + const taskOutputNarrative = resolveToolProcessNarrative( + createToolCall({ + name: "TaskOutputTool", + status: "completed", + }), + ); + const taskStopNarrative = resolveToolProcessNarrative( + createToolCall({ + name: "TaskStopTool", + status: "completed", + arguments: JSON.stringify({ task_id: "task-123" }), + }), + ); + const cronListNarrative = resolveToolProcessNarrative( + createToolCall({ + name: "CronListTool", + status: "completed", + }), + ); + + expect(taskListNarrative.preSummary).toBe("先查看任务列表"); + expect(taskListNarrative.postSummary).toBe("已查看任务列表"); + expect(taskOutputNarrative.preSummary).toBe("先查看任务结果"); + expect(taskOutputNarrative.postSummary).toBe("已查看任务结果"); + expect(taskStopNarrative.preSummary).toBe("先终止任务 task-123"); + expect(taskStopNarrative.postSummary).toBe("已终止任务 task-123"); + expect(cronListNarrative.postSummary).toBe("已查看定时触发器"); + }); + + it("应为 MCP 搜索与读取工具生成可读过程文案", () => { + const searchNarrative = resolveToolProcessNarrative( + createToolCall({ + name: "mcp__github__search_code", + status: "running", + arguments: JSON.stringify({ query: "repo:lime tool runtime" }), + }), + ); + const readNarrative = resolveToolProcessNarrative( + createToolCall({ + name: "mcp__github__get_file_contents", + status: "completed", + arguments: JSON.stringify({ path: "docs/guide.md" }), + }), + ); + + expect(searchNarrative.preSummary).toBe("先搜索 repo:lime tool runtime"); + expect(searchNarrative.summary).toBe("先搜索 repo:lime tool runtime"); + expect(readNarrative.preSummary).toBe("先查看 guide.md"); + expect(readNarrative.postSummary).toBe("已查看 guide.md"); + expect(readNarrative.summary).toBe("已查看 guide.md"); + }); + + it("应为站点目录、搜索、详情与执行工具生成站点语义文案", () => { + const siteListNarrative = resolveToolProcessNarrative( + createToolCall({ + name: "lime_site_list", + status: "completed", + }), + ); + const siteSearchNarrative = resolveToolProcessNarrative( + createToolCall({ + name: "lime_site_search", + status: "running", + arguments: JSON.stringify({ query: "GitHub issue 搜索" }), + }), + ); + const siteInfoNarrative = resolveToolProcessNarrative( + createToolCall({ + name: "lime_site_info", + status: "completed", + arguments: JSON.stringify({ adapter_name: "github/search" }), + }), + ); + const siteRunNarrative = resolveToolProcessNarrative( + createToolCall({ + name: "lime_site_run", + status: "running", + arguments: JSON.stringify({ adapter_name: "github/search" }), + }), + ); + + expect(siteListNarrative.preSummary).toBe("先查看可用站点能力"); + expect(siteListNarrative.postSummary).toBe("已查看可用站点能力"); + expect(siteSearchNarrative.preSummary).toBe( + "先搜索 GitHub issue 搜索 相关站点能力", + ); + expect(siteSearchNarrative.summary).toBe( + "先搜索 GitHub issue 搜索 相关站点能力", + ); + expect(siteInfoNarrative.postSummary).toBe( + "已确认 github/search 的参数与登录要求", + ); + expect(siteRunNarrative.preSummary).toBe("先执行站点能力 github/search"); + expect(siteRunNarrative.summary).toBe("先执行站点能力 github/search"); + }); + + it("应为服务技能与站点推荐工具生成专用过程文案", () => { + const serviceSkillNarrative = resolveToolProcessNarrative( + createToolCall({ + name: "lime_run_service_skill", + status: "running", + arguments: JSON.stringify({ skill_title: "渠道预览" }), + }), + ); + const siteRecommendNarrative = resolveToolProcessNarrative( + createToolCall({ + name: "lime_site_recommend", + status: "completed", + arguments: JSON.stringify({ query: "GitHub issue 搜索" }), + }), + ); + + expect(serviceSkillNarrative.preSummary).toBe("先执行服务技能 渠道预览"); + expect(serviceSkillNarrative.summary).toBe("先执行服务技能 渠道预览"); + expect(siteRecommendNarrative.preSummary).toBe( + "先推荐适合 GitHub issue 搜索 的站点能力", + ); + expect(siteRecommendNarrative.postSummary).toBe( + "已推荐适合 GitHub issue 搜索 的站点能力", + ); + }); + + it("应为新补齐的任务工具生成任务提交文案", () => { + const transcriptionNarrative = resolveToolProcessNarrative( + createToolCall({ + name: "lime_create_transcription_task", + status: "completed", + arguments: JSON.stringify({ sourcePath: "/tmp/interview.mp4" }), + }), + ); + const resourceNarrative = resolveToolProcessNarrative( + createToolCall({ + name: "lime_create_modal_resource_search_task", + status: "running", + arguments: JSON.stringify({ query: "科技播客 BGM" }), + }), + ); + const urlParseNarrative = resolveToolProcessNarrative( + createToolCall({ + name: "lime_create_url_parse_task", + status: "completed", + arguments: JSON.stringify({ url: "https://example.com/report" }), + }), + ); + const typesettingNarrative = resolveToolProcessNarrative( + createToolCall({ + name: "lime_create_typesetting_task", + status: "running", + arguments: JSON.stringify({ targetPlatform: "小红书" }), + }), + ); + + expect(transcriptionNarrative.preSummary).toBe( + "先提交 /tmp/interview.mp4 的转写任务", + ); + expect(transcriptionNarrative.postSummary).toBe( + "已提交 /tmp/interview.mp4 的转写任务", + ); + expect(resourceNarrative.preSummary).toBe( + "先提交 科技播客 BGM 的素材检索任务", + ); + expect(resourceNarrative.summary).toBe( + "先提交 科技播客 BGM 的素材检索任务", + ); + expect(urlParseNarrative.postSummary).toBe( + "已提交 https://example.com/report 的链接解析任务", + ); + expect(typesettingNarrative.preSummary).toBe("先提交 小红书 的排版任务"); + }); +}); diff --git a/src/components/agent/chat/utils/toolProcessSummary.ts b/src/components/agent/chat/utils/toolProcessSummary.ts new file mode 100644 index 000000000..b230d3c92 --- /dev/null +++ b/src/components/agent/chat/utils/toolProcessSummary.ts @@ -0,0 +1,1030 @@ +import type { AgentToolCallState as ToolCallState } from "@/lib/api/agentProtocol"; +import { extractLimeToolMetadataBlock } from "../hooks/agentChatToolResult"; +import type { AgentThreadItem } from "../types"; +import { + getHostnameFromUrl, + isUnifiedWebSearchToolName, + resolveSearchResultPreviewItemsFromText, +} from "./searchResultPreview"; +import { + normalizeSiteToolResultSummary, + resolveSiteProjectTargetLabel, +} from "./siteToolResultSummary"; +import { + getToolDisplayInfo, + isBrowserToolName, + normalizeToolNameKey, + parseToolCallArguments, + resolveToolFilePath, + resolveToolPrimarySubject, + type ToolCallArgumentValue, +} from "./toolDisplayInfo"; +import { + normalizeToolSearchResultSummary, + resolveUserFacingToolSearchItemLabel, +} from "./toolSearchResultSummary"; + +type ToolProcessStatus = + | ToolCallState["status"] + | Extract; + +type ToolProcessNarrativeSource = + | "none" + | "error" + | "tool_search" + | "search_results" + | "site" + | "plain_result" + | "generic"; + +export interface ToolProcessNarrative { + preSummary: string | null; + postSummary: string | null; + summary: string | null; + postSource: ToolProcessNarrativeSource; +} + +interface ToolProcessInput { + toolName: string; + argumentsValue?: string | Record; + status: ToolProcessStatus; + output?: string; + error?: string; + metadata?: unknown; +} + +function asRecord(value: unknown): Record | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + return value as Record; +} + +function readString( + record: Record | null, + keys: string[], +): string | null { + if (!record) { + return null; + } + + for (const key of keys) { + const value = record[key]; + if (typeof value === "string" && value.trim()) { + return value.trim(); + } + } + + return null; +} + +function collapseWhitespace(value: string): string { + return value + .replace(/\s+([,。!?、;:,.!?;:])/g, "$1") + .replace(/\s+/g, " ") + .trim(); +} + +function shorten( + value: string | null | undefined, + maxLength = 80, +): string | null { + const normalized = value?.trim(); + if (!normalized) { + return null; + } + + if (normalized.length <= maxLength) { + return normalized; + } + + return `${normalized.slice(0, maxLength - 1).trimEnd()}…`; +} + +function stripFencedCode(value: string): string { + return value.replace(/```[\s\S]*?```/g, "").trim(); +} + +function looksLikeCodeOrJson(value: string): boolean { + return /^(?:[{[]|import\s|export\s|const\s|let\s|var\s|function\s|class\s|if\s*\(|for\s*\(|while\s*\(|return\s|<\w+)/i.test( + value, + ); +} + +function looksLikeOpaqueAck(value: string): boolean { + return /^(?:ok|okay|done|success|completed|true|false|null|undefined)$/i.test( + value.trim(), + ); +} + +function normalizePlainResultLine( + value: string | null | undefined, + maxLength = 96, +): string | null { + const raw = value?.trim(); + if (!raw) { + return null; + } + + const stripped = stripFencedCode(extractLimeToolMetadataBlock(raw).text); + if (!stripped) { + return null; + } + + const line = + stripped + .split(/\r?\n/) + .map((entry) => collapseWhitespace(entry)) + .find(Boolean) || ""; + if (!line || looksLikeCodeOrJson(line) || looksLikeOpaqueAck(line)) { + return null; + } + + return shorten(line, maxLength); +} + +function normalizeArgumentsRecord( + value?: string | Record, +): Record { + if (!value) { + return {}; + } + + if (typeof value === "string") { + return parseToolCallArguments(value) as Record; + } + + return value; +} + +function resolveToolSubject( + toolName: string, + argumentsValue?: string | Record, +): string | null { + const args = normalizeArgumentsRecord(argumentsValue); + const toolArgs = args as Record; + return resolveToolPrimarySubject( + toolName, + toolArgs, + resolveToolFilePath(toolArgs), + ); +} + +function resolveUrlLabel( + args: Record, + metadata: Record | null, +): string | null { + const rawUrl = + readString(args, ["url", "pageUrl", "page_url", "href"]) || + readString(metadata, ["url", "pageUrl", "page_url", "href"]); + if (!rawUrl) { + return null; + } + + const hostname = shorten(getHostnameFromUrl(rawUrl), 48); + return hostname || shorten(rawUrl, 64); +} + +function buildToolSearchPreSummary(args: Record): string { + const query = readString(args, ["query", "q"]) || ""; + if (/^(?:select|tool|tools|name|tag):/i.test(query)) { + return "先确认可用工具和入口"; + } + if (query) { + return `先找能处理 ${shorten(query, 32)} 的工具入口`; + } + return "先确认可用工具和入口"; +} + +function buildToolSearchPostSummary(output: string): string | null { + const summary = normalizeToolSearchResultSummary(output); + if (!summary) { + return null; + } + + const toolNames = summary.tools + .slice(0, 2) + .map((item) => resolveUserFacingToolSearchItemLabel(item.name)) + .filter(Boolean); + const prefix = `已确认可用工具 ${summary.count} 个`; + + if (toolNames.length === 0) { + return prefix; + } + + return `${prefix} · ${toolNames.join(" · ")}`; +} + +function buildWebSearchPostSummary(output: string): string | null { + const items = resolveSearchResultPreviewItemsFromText(output); + if (items.length === 0) { + return null; + } + + return `已找到 ${items.length} 个可参考来源`; +} + +function buildSitePostSummary(metadata: unknown): string | null { + const summary = normalizeSiteToolResultSummary(metadata); + if (!summary) { + return null; + } + + if (summary.saveErrorMessage) { + return `自动保存失败:${shorten(summary.saveErrorMessage, 56)}`; + } + + if (summary.savedContent?.title) { + return `已保存到${resolveSiteProjectTargetLabel({ + source: summary.savedBy, + projectId: summary.savedProjectId || summary.savedContent.projectId, + })}:${summary.savedContent.title}`; + } + + if (summary.savedContent?.markdownRelativePath) { + return "已导出 Markdown 文稿"; + } + + if (summary.saveSkippedProjectId) { + return `未保存到${resolveSiteProjectTargetLabel({ + source: summary.saveSkippedBy, + projectId: summary.saveSkippedProjectId, + })}`; + } + + return null; +} + +const LIME_TASK_SUMMARY_LABELS: Partial> = { + limecreatevideogenerationtask: "视频生成", + limecreatetranscriptiontask: "转写", + limecreatebroadcastgenerationtask: "口播生成", + limecreatecovergenerationtask: "封面生成", + limecreateresourcesearchtask: "素材检索", + limecreatemodalresourcesearchtask: "素材检索", + limecreateimagegenerationtask: "图片生成", + limecreateurlparsetask: "链接解析", + limecreatetypesettingtask: "排版", +}; + +function normalizeNarrativeSubject( + subject: string | null, + placeholders: string[] = [], +): string | null { + const normalized = shorten(subject, 48); + if (!normalized) { + return null; + } + + return placeholders.includes(normalized) ? null : normalized; +} + +function buildLimeTaskSummary( + phase: "pre" | "post", + normalizedName: string, + subject: string | null, +): string | null { + const taskLabel = LIME_TASK_SUMMARY_LABELS[normalizedName]; + if (!taskLabel) { + return null; + } + + const normalizedSubject = normalizeNarrativeSubject(subject); + const prefix = phase === "pre" ? "先提交" : "已提交"; + + return normalizedSubject + ? `${prefix} ${normalizedSubject} 的${taskLabel}任务` + : `${prefix}${taskLabel}任务`; +} + +function buildSiteToolSummary( + phase: "pre" | "post", + normalizedName: string, + subject: string | null, +): string | null { + const normalizedSubject = normalizeNarrativeSubject(subject, [ + "站点能力", + "站点能力目录", + "站点适配器", + ]); + + if (normalizedName === "limesitelist") { + return phase === "pre" ? "先查看可用站点能力" : "已查看可用站点能力"; + } + + if (normalizedName === "limesiterecommend") { + return normalizedSubject + ? `${phase === "pre" ? "先推荐适合" : "已推荐适合"} ${normalizedSubject} 的站点能力` + : phase === "pre" + ? "先推荐合适的站点能力" + : "已推荐站点能力"; + } + + if (normalizedName === "limesitesearch") { + return normalizedSubject + ? `${phase === "pre" ? "先搜索" : "已搜索"} ${normalizedSubject} 相关站点能力` + : phase === "pre" + ? "先搜索站点能力" + : "已搜索站点能力"; + } + + if (normalizedName === "limesiteinfo") { + return normalizedSubject + ? `${phase === "pre" ? "先确认" : "已确认"} ${normalizedSubject} 的参数与登录要求` + : phase === "pre" + ? "先确认站点能力的参数与登录要求" + : "已确认站点能力的参数与登录要求"; + } + + if (normalizedName === "limesiterun") { + return normalizedSubject + ? `${phase === "pre" ? "先执行" : "已执行"}站点能力 ${normalizedSubject}` + : phase === "pre" + ? "先执行站点能力" + : "已执行站点能力"; + } + + return null; +} + +function buildCommandPreSummary( + normalizedName: string, + args: Record, +): string | null { + const command = readString(args, ["command", "cmd", "script"]) || ""; + if (normalizedName === "bash" || normalizedName.includes("shell")) { + if (/^(?:rg|grep|findstr)\b/i.test(command)) { + return "先搜索代码位置"; + } + if (/^(?:sed|cat|head|tail)\b/i.test(command)) { + return "先查看文件片段"; + } + if (/^git\s+status\b/i.test(command)) { + return "先确认工作区状态"; + } + if (/^git\s+diff\b/i.test(command)) { + return "先查看变更差异"; + } + } + + return "先运行命令确认当前状态"; +} + +function buildBrowserPreSummary( + normalizedName: string, + args: Record, + metadata: Record | null, +): string | null { + const urlLabel = resolveUrlLabel(args, metadata); + const target = + readString(args, ["selector", "element", "target", "label", "text"]) || + readString(metadata, ["selector", "element", "target", "label", "text"]); + + if (normalizedName.includes("navigate") || normalizedName.includes("goto")) { + return urlLabel ? `先打开 ${urlLabel}` : "先打开目标页面"; + } + + if ( + normalizedName.includes("snapshot") || + normalizedName.includes("screenshot") + ) { + return "先抓取页面状态"; + } + + if (normalizedName.includes("click")) { + return target ? `先操作 ${shorten(target, 28)}` : "先操作页面元素"; + } + + if ( + normalizedName.includes("fill") || + normalizedName.includes("type") || + normalizedName.includes("selectoption") || + normalizedName.includes("presskey") + ) { + return target ? `先填写 ${shorten(target, 28)}` : "先继续页面操作"; + } + + if ( + normalizedName.includes("evaluate") || + normalizedName.includes("runtime") + ) { + return "先读取页面信息"; + } + + return "先查看页面状态"; +} + +function buildBrowserPostSummary( + normalizedName: string, + args: Record, + metadata: Record | null, +): string | null { + const urlLabel = resolveUrlLabel(args, metadata); + + if (normalizedName.includes("navigate") || normalizedName.includes("goto")) { + return urlLabel ? `已打开 ${urlLabel}` : "已打开目标页面"; + } + + if ( + normalizedName.includes("snapshot") || + normalizedName.includes("screenshot") + ) { + return "已拿到页面快照"; + } + + if ( + normalizedName.includes("evaluate") || + normalizedName.includes("runtime") + ) { + return "已拿到页面状态"; + } + + return "已完成页面操作"; +} + +function buildGenericPostSummary(params: { + toolName: string; + status: ToolProcessStatus; + subject: string | null; +}): string | null { + const { toolName, subject, status } = params; + const normalizedName = normalizeToolNameKey(toolName); + const display = getToolDisplayInfo( + toolName, + status === "in_progress" ? "running" : status, + ); + const normalizedSubject = normalizeNarrativeSubject(subject); + + if (normalizedName === "enterworktree") { + return "已进入隔离工作树"; + } + if (normalizedName === "exitworktree") { + return "已回到主工作区"; + } + if (normalizedName === "config") { + return "已更新运行配置"; + } + if (normalizedName === "workflow") { + return "已执行工作流"; + } + if (normalizedName === "sleep") { + return "已完成等待"; + } + if (normalizedName === "enterplanmode") { + return "已进入计划模式"; + } + if (normalizedName === "exitplanmode") { + return "已退出计划模式"; + } + if (normalizedName === "structuredoutput") { + return "已整理最终答复"; + } + if (normalizedName === "skill") { + return normalizedSubject ? `已执行技能 ${normalizedSubject}` : "已执行技能"; + } + if (normalizedName === "listskills") { + return "已查看可用技能"; + } + if (normalizedName === "loadskill") { + return normalizedSubject ? `已加载技能 ${normalizedSubject}` : "已加载技能"; + } + if (normalizedName === "listmcpresources") { + return "已查看可用 MCP 资源"; + } + if (normalizedName === "readmcpresource") { + return normalizedSubject + ? `已读取 ${normalizedSubject}` + : "已读取 MCP 资源"; + } + if (normalizedName === "tasklist") { + return "已查看任务列表"; + } + if (normalizedName === "taskcreate") { + return normalizedSubject ? `已创建任务 ${normalizedSubject}` : "已创建任务"; + } + if (normalizedName === "taskget") { + return normalizedSubject + ? `已查看任务 ${normalizedSubject}` + : "已查看任务详情"; + } + if (normalizedName === "taskupdate") { + return normalizedSubject ? `已更新任务 ${normalizedSubject}` : "已更新任务"; + } + if (normalizedName === "taskoutput") { + return normalizedSubject + ? `已查看 ${normalizedSubject} 的任务结果` + : "已查看任务结果"; + } + if (normalizedName === "taskstop") { + return normalizedSubject ? `已终止任务 ${normalizedSubject}` : "已终止任务"; + } + if (normalizedName === "teamcreate") { + return normalizedSubject + ? `已创建团队 ${normalizedSubject}` + : "已创建协作团队"; + } + if (normalizedName === "teamdelete") { + return normalizedSubject + ? `已删除团队 ${normalizedSubject}` + : "已删除协作团队"; + } + if (normalizedName === "listpeers") { + return normalizedSubject + ? `已查看 ${normalizedSubject} 的协作成员` + : "已查看团队成员"; + } + if (normalizedName === "croncreate") { + return normalizedSubject + ? `已创建定时触发器 ${normalizedSubject}` + : "已创建定时触发器"; + } + if (normalizedName === "cronlist") { + return "已查看定时触发器"; + } + if (normalizedName === "crondelete") { + return normalizedSubject + ? `已删除定时触发器 ${normalizedSubject}` + : "已删除定时触发器"; + } + if (normalizedName === "remotetrigger") { + return normalizedSubject + ? `已处理 ${normalizedSubject}` + : "已处理远程触发器"; + } + const limeTaskSummary = buildLimeTaskSummary( + "post", + normalizedName, + normalizedSubject, + ); + if (limeTaskSummary) { + return limeTaskSummary; + } + const siteToolSummary = buildSiteToolSummary( + "post", + normalizedName, + normalizedSubject, + ); + if (siteToolSummary) { + return siteToolSummary; + } + if (normalizedName === "limerunserviceskill") { + return normalizedSubject + ? `已执行服务技能 ${normalizedSubject}` + : "已执行服务技能"; + } + if (normalizedName === "mcp") { + return "已完成 MCP 工具调用"; + } + if (normalizedName === "mcpauth") { + return "已完成 MCP 授权"; + } + + switch (display.family) { + case "read": + return normalizedSubject + ? `已查看 ${normalizedSubject}` + : "已查看相关文件"; + case "list": + return normalizedSubject + ? `已定位 ${normalizedSubject}` + : "已定位相关文件"; + case "write": + return normalizedSubject ? `已写入 ${normalizedSubject}` : "已写入文件"; + case "edit": + return normalizedSubject + ? `已修改 ${normalizedSubject}` + : "已修改目标文件"; + case "command": + return "已拿到命令结果"; + case "fetch": + return normalizedSubject + ? `已获取 ${normalizedSubject} 内容` + : "已获取外部内容"; + case "task": + return "已创建任务"; + case "subagent": + return "已把任务拆给子任务继续处理"; + case "search": + return normalizedSubject + ? `已搜索 ${normalizedSubject}` + : "已拿到搜索结果"; + case "browser": + return "已完成页面操作"; + case "plan": + return normalizedSubject + ? `已处理 ${normalizedSubject}` + : "已完成计划操作"; + default: + return normalizedSubject ? `已处理 ${normalizedSubject}` : null; + } +} + +function buildGenericPreSummary(params: { + toolName: string; + argumentsValue?: string | Record; + metadata?: unknown; +}): string | null { + const { toolName, argumentsValue, metadata } = params; + const normalizedName = normalizeToolNameKey(toolName); + const args = normalizeArgumentsRecord(argumentsValue); + const metadataRecord = asRecord(metadata); + const subject = resolveToolSubject(toolName, argumentsValue); + const query = + readString(args, ["query", "q", "pattern", "search_query"]) || + readString(metadataRecord, ["query", "q", "pattern", "search_query"]); + + if (normalizedName === "toolsearch") { + return buildToolSearchPreSummary(args); + } + + if (normalizedName === "agent") { + return "先拆成子任务并行处理"; + } + + if (normalizedName === "sendmessage") { + return "先补充子任务说明"; + } + + if (normalizedName === "waitagent") { + return "先等待子任务返回结果"; + } + + if (normalizedName === "closeagent") { + return "先结束不再需要的子任务"; + } + + if (normalizedName === "askuserquestion") { + return subject + ? `先确认 ${shorten(subject, 40)}` + : "先确认继续执行所需信息"; + } + + if (normalizedName === "enterworktree") { + return "先进入隔离工作树"; + } + + if (normalizedName === "exitworktree") { + return "先回到主工作区"; + } + + if (normalizedName === "config") { + return "先查看或调整运行配置"; + } + + if (normalizedName === "workflow") { + return "先执行预设工作流"; + } + + if (normalizedName === "sleep") { + return "先等待一段时间再继续"; + } + + if (normalizedName === "sendusermessage" || normalizedName === "brief") { + return "先把中间结论同步给主线程"; + } + + if (isUnifiedWebSearchToolName(toolName)) { + return query ? `先搜索 ${shorten(query, 36)}` : "先搜索相关资料"; + } + + if (isBrowserToolName(normalizedName)) { + return buildBrowserPreSummary(normalizedName, args, metadataRecord); + } + + const display = getToolDisplayInfo(toolName, "running"); + const normalizedSubject = normalizeNarrativeSubject(subject); + + if (normalizedName === "enterplanmode") { + return "先进入计划模式拆解方案"; + } + + if (normalizedName === "exitplanmode") { + return "先退出计划模式继续执行"; + } + + if (normalizedName === "structuredoutput") { + return "先整理最终答复"; + } + + if (normalizedName === "skill") { + return normalizedSubject ? `先执行技能 ${normalizedSubject}` : "先执行技能"; + } + + if (normalizedName === "listskills") { + return "先查看可用技能"; + } + + if (normalizedName === "loadskill") { + return normalizedSubject ? `先加载技能 ${normalizedSubject}` : "先加载技能"; + } + + if (normalizedName === "listmcpresources") { + return "先查看可用 MCP 资源"; + } + + if (normalizedName === "readmcpresource") { + return normalizedSubject + ? `先读取 ${normalizedSubject}` + : "先读取 MCP 资源"; + } + + if (normalizedName === "taskcreate") { + return normalizedSubject + ? `先创建任务 ${normalizedSubject}` + : "先创建结构化任务"; + } + + if (normalizedName === "tasklist") { + return "先查看任务列表"; + } + + if (normalizedName === "taskget") { + return normalizedSubject + ? `先查看任务 ${normalizedSubject}` + : "先查看任务详情"; + } + + if (normalizedName === "taskupdate") { + return normalizedSubject + ? `先更新任务 ${normalizedSubject}` + : "先更新任务状态"; + } + + if (normalizedName === "taskoutput") { + return normalizedSubject + ? `先查看 ${normalizedSubject} 的任务结果` + : "先查看任务结果"; + } + + if (normalizedName === "taskstop") { + return normalizedSubject ? `先终止任务 ${normalizedSubject}` : "先终止任务"; + } + + if (normalizedName === "teamcreate") { + return normalizedSubject + ? `先创建团队 ${normalizedSubject}` + : "先创建协作团队"; + } + + if (normalizedName === "teamdelete") { + return normalizedSubject + ? `先删除团队 ${normalizedSubject}` + : "先删除协作团队"; + } + + if (normalizedName === "listpeers") { + return normalizedSubject + ? `先查看 ${normalizedSubject} 的协作成员` + : "先查看团队成员"; + } + + if (normalizedName === "croncreate") { + return normalizedSubject + ? `先创建定时触发器 ${normalizedSubject}` + : "先创建定时触发器"; + } + + if (normalizedName === "cronlist") { + return "先查看定时触发器"; + } + + if (normalizedName === "crondelete") { + return normalizedSubject + ? `先删除定时触发器 ${normalizedSubject}` + : "先删除定时触发器"; + } + + if (normalizedName === "remotetrigger") { + return normalizedSubject + ? `先处理 ${normalizedSubject}` + : "先处理远程触发器"; + } + const limeTaskSummary = buildLimeTaskSummary( + "pre", + normalizedName, + normalizedSubject, + ); + if (limeTaskSummary) { + return limeTaskSummary; + } + const siteToolSummary = buildSiteToolSummary( + "pre", + normalizedName, + normalizedSubject, + ); + if (siteToolSummary) { + return siteToolSummary; + } + + if (normalizedName === "limerunserviceskill") { + return normalizedSubject + ? `先执行服务技能 ${normalizedSubject}` + : "先执行服务技能"; + } + + if (normalizedName === "mcp") { + return "先调用 MCP 工具"; + } + + if (normalizedName === "mcpauth") { + return "先完成 MCP 授权"; + } + + switch (display.family) { + case "read": + return normalizedSubject + ? `先查看 ${normalizedSubject}` + : "先查看相关文件"; + case "list": + if (normalizedName.includes("grep") || normalizedName.includes("glob")) { + return normalizedSubject + ? `先定位 ${normalizedSubject}` + : "先定位相关文件"; + } + return normalizedSubject + ? `先查看 ${normalizedSubject}` + : "先查看目录结构"; + case "command": + return buildCommandPreSummary(normalizedName, args); + case "fetch": { + const urlLabel = resolveUrlLabel(args, metadataRecord); + return urlLabel ? `先获取 ${urlLabel} 内容` : "先获取外部内容"; + } + case "search": + return query ? `先搜索 ${shorten(query, 36)}` : "先搜索相关资料"; + case "write": + return normalizedSubject + ? `准备写入 ${normalizedSubject}` + : "准备写入文件"; + case "edit": + return normalizedSubject + ? `准备修改 ${normalizedSubject}` + : "准备修改目标文件"; + case "task": + return "先创建生成任务"; + case "plan": + return normalizedSubject + ? `先处理 ${normalizedSubject}` + : "先处理计划任务"; + default: + return normalizedSubject ? `先处理 ${normalizedSubject}` : null; + } +} + +function buildNarrative(input: ToolProcessInput): ToolProcessNarrative { + const preSummary = buildGenericPreSummary({ + toolName: input.toolName, + argumentsValue: input.argumentsValue, + metadata: input.metadata, + }); + const normalizedName = normalizeToolNameKey(input.toolName); + const resultOutput = input.output || ""; + const plainError = normalizePlainResultLine(input.error, 88); + const plainOutput = normalizePlainResultLine(resultOutput, 96); + const args = normalizeArgumentsRecord(input.argumentsValue); + const metadata = asRecord(input.metadata); + const subject = resolveToolSubject(input.toolName, input.argumentsValue); + + let postSummary: string | null = null; + let postSource: ToolProcessNarrativeSource = "none"; + + if (input.status === "failed") { + postSummary = + plainError || (plainOutput ? `执行失败:${plainOutput}` : null); + if (postSummary) { + if (!postSummary.startsWith("执行失败:")) { + postSummary = `执行失败:${postSummary}`; + } + postSource = "error"; + } + } + + if (!postSummary) { + const siteSummary = buildSitePostSummary(input.metadata); + if (siteSummary) { + postSummary = siteSummary; + postSource = "site"; + } + } + + if (!postSummary && normalizedName === "toolsearch") { + const toolSearchSummary = buildToolSearchPostSummary(resultOutput); + if (toolSearchSummary) { + postSummary = toolSearchSummary; + postSource = "tool_search"; + } + } + + if (!postSummary && isUnifiedWebSearchToolName(input.toolName)) { + const searchSummary = buildWebSearchPostSummary(resultOutput); + if (searchSummary) { + postSummary = searchSummary; + postSource = "search_results"; + } + } + + if (!postSummary && isBrowserToolName(normalizedName)) { + postSummary = buildBrowserPostSummary(normalizedName, args, metadata); + postSource = postSummary ? "generic" : "none"; + } + + if (!postSummary && plainOutput) { + postSummary = plainOutput; + postSource = "plain_result"; + } + + if (!postSummary) { + postSummary = buildGenericPostSummary({ + toolName: input.toolName, + status: input.status, + subject, + }); + postSource = postSummary ? "generic" : "none"; + } + + const summary = + input.status === "running" || input.status === "in_progress" + ? preSummary + : postSummary || preSummary; + + return { + preSummary, + postSummary, + summary, + postSource, + }; +} + +export function resolveToolProcessNarrative( + toolCall: ToolCallState, +): ToolProcessNarrative { + return buildNarrative({ + toolName: toolCall.name, + argumentsValue: toolCall.arguments, + status: toolCall.status, + output: toolCall.result?.output, + error: toolCall.result?.error, + metadata: toolCall.result?.metadata, + }); +} + +export function resolveAgentThreadToolProcessNarrative( + item: AgentThreadItem, +): ToolProcessNarrative | null { + if (item.type === "tool_call") { + return buildNarrative({ + toolName: item.tool_name, + argumentsValue: asRecord(item.arguments) || undefined, + status: item.status, + output: item.output, + error: item.error, + metadata: item.metadata, + }); + } + + if (item.type === "command_execution") { + return buildNarrative({ + toolName: "exec_command", + argumentsValue: { + command: item.command, + cwd: item.cwd, + }, + status: item.status, + output: item.aggregated_output, + error: item.error, + metadata: + item.exit_code !== undefined + ? { + exit_code: item.exit_code, + cwd: item.cwd, + } + : { cwd: item.cwd }, + }); + } + + if (item.type === "web_search") { + return buildNarrative({ + toolName: item.action || "web_search", + argumentsValue: item.query ? { query: item.query } : undefined, + status: item.status, + output: item.output, + }); + } + + return null; +} + +export function resolveAgentThreadToolProcessPreview( + item: AgentThreadItem, +): string | null { + const narrative = resolveAgentThreadToolProcessNarrative(item); + if (!narrative) { + return null; + } + + if (item.status !== "completed") { + return narrative.summary; + } + + return narrative.postSource !== "generic" ? narrative.summary : null; +} diff --git a/src/components/agent/chat/utils/toolSearchResultSummary.test.ts b/src/components/agent/chat/utils/toolSearchResultSummary.test.ts index 54346616c..efba02f01 100644 --- a/src/components/agent/chat/utils/toolSearchResultSummary.test.ts +++ b/src/components/agent/chat/utils/toolSearchResultSummary.test.ts @@ -76,6 +76,25 @@ describe("toolSearchResultSummary", () => { }); }); + it("应解析 pending_mcp_servers 字段", () => { + const summary = normalizeToolSearchResultSummary( + JSON.stringify({ + query: "slack send", + matches: [], + notes: [], + pending_mcp_servers: ["slack", "playwright"], + }), + ); + + expect(summary).toEqual({ + query: "slack send", + count: 0, + notes: [], + tools: [], + pendingMcpServers: ["slack", "playwright"], + }); + }); + it("应生成来源与状态标签", () => { expect( resolveToolSearchItemSourceLabel({ @@ -106,8 +125,8 @@ describe("toolSearchResultSummary", () => { expect(resolveUserFacingToolSearchItemLabel("TaskOutput")).toBe( "查看任务结果", ); - expect(resolveUserFacingToolSearchItemLabel("mcp__playwright__browser_click")).toBe( - "页面点击", - ); + expect( + resolveUserFacingToolSearchItemLabel("mcp__playwright__browser_click"), + ).toBe("页面点击"); }); }); diff --git a/src/components/agent/chat/utils/toolSearchResultSummary.ts b/src/components/agent/chat/utils/toolSearchResultSummary.ts index a500d1eba..9c05e04c9 100644 --- a/src/components/agent/chat/utils/toolSearchResultSummary.ts +++ b/src/components/agent/chat/utils/toolSearchResultSummary.ts @@ -17,6 +17,7 @@ export interface ToolSearchResultSummary { notes: string[]; tools: ToolSearchResultItemSummary[]; totalDeferredTools?: number; + pendingMcpServers?: string[]; } function asRecord(value: unknown): Record | null { @@ -35,7 +36,9 @@ function readBoolean(value: unknown): boolean | undefined { } function readFiniteNumber(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) ? value : undefined; + return typeof value === "number" && Number.isFinite(value) + ? value + : undefined; } function readStringArray(value: unknown): string[] { @@ -64,21 +67,45 @@ function normalizeToolRecord( return null; } - return { - name, - description: readString(record.description), - source: readString(record.source), - extensionName: readString( - record.extension_name ?? record.extensionName, - ), - status: readString(record.status), - deferredLoading: readBoolean( - record.deferred_loading ?? record.deferredLoading, - ), - alwaysVisible: readBoolean( - record.always_visible ?? record.alwaysVisible, - ), - }; + const summary: ToolSearchResultItemSummary = { name }; + + const description = readString(record.description); + if (description) { + summary.description = description; + } + + const source = readString(record.source); + if (source) { + summary.source = source; + } + + const extensionName = readString( + record.extension_name ?? record.extensionName, + ); + if (extensionName) { + summary.extensionName = extensionName; + } + + const status = readString(record.status); + if (status) { + summary.status = status; + } + + const deferredLoading = readBoolean( + record.deferred_loading ?? record.deferredLoading, + ); + if (deferredLoading !== undefined) { + summary.deferredLoading = deferredLoading; + } + + const alwaysVisible = readBoolean( + record.always_visible ?? record.alwaysVisible, + ); + if (alwaysVisible !== undefined) { + summary.alwaysVisible = alwaysVisible; + } + + return summary; } export function normalizeToolSearchResultSummary( @@ -119,19 +146,40 @@ export function normalizeToolSearchResultSummary( const totalDeferredTools = readFiniteNumber( record.total_deferred_tools ?? record.totalDeferredTools, ); + const pendingMcpServers = readStringArray( + record.pending_mcp_servers ?? record.pendingMcpServers, + ); - if (!query && tools.length === 0 && notes.length === 0 && count === 0) { + if ( + !query && + tools.length === 0 && + notes.length === 0 && + pendingMcpServers.length === 0 && + count === 0 + ) { return null; } - return { - query, - caller, + const summary: ToolSearchResultSummary = { count, notes, tools, - totalDeferredTools, }; + + if (query) { + summary.query = query; + } + if (caller) { + summary.caller = caller; + } + if (totalDeferredTools !== undefined) { + summary.totalDeferredTools = totalDeferredTools; + } + if (pendingMcpServers.length > 0) { + summary.pendingMcpServers = pendingMcpServers; + } + + return summary; } export function resolveToolSearchItemSourceLabel( diff --git a/src/components/agent/chat/utils/topicProjectResolution.test.ts b/src/components/agent/chat/utils/topicProjectResolution.test.ts index 3ea2cdac1..8135b5c15 100644 --- a/src/components/agent/chat/utils/topicProjectResolution.test.ts +++ b/src/components/agent/chat/utils/topicProjectResolution.test.ts @@ -15,6 +15,7 @@ describe("normalizeProjectId", () => { expect(normalizeProjectId("[object Promise]")).toBeNull(); expect(normalizeProjectId("default")).toBeNull(); expect(normalizeProjectId(" Default ")).toBeNull(); + expect(normalizeProjectId("workspace-default")).toBeNull(); }); it("应返回合法项目 ID", () => { diff --git a/src/components/agent/chat/utils/topicProjectResolution.ts b/src/components/agent/chat/utils/topicProjectResolution.ts index 272f92373..4aaba3b03 100644 --- a/src/components/agent/chat/utils/topicProjectResolution.ts +++ b/src/components/agent/chat/utils/topicProjectResolution.ts @@ -6,6 +6,7 @@ export interface ResolveTopicProjectIdOptions { const INVALID_PROJECT_IDS = new Set(["__invalid__", "[object Promise]"]); const DEFAULT_PROJECT_ID_ALIAS = "default"; +const LEGACY_DEFAULT_PROJECT_IDS = new Set(["workspace-default"]); export function isDefaultProjectIdAlias(projectId: unknown): boolean { return ( @@ -14,6 +15,13 @@ export function isDefaultProjectIdAlias(projectId: unknown): boolean { ); } +export function isLegacyDefaultProjectId(projectId: unknown): boolean { + return ( + typeof projectId === "string" && + LEGACY_DEFAULT_PROJECT_IDS.has(projectId.trim().toLowerCase()) + ); +} + export function normalizeProjectId(projectId: unknown): string | null { if (typeof projectId !== "string") { return null; @@ -23,7 +31,8 @@ export function normalizeProjectId(projectId: unknown): string | null { if ( !normalized || INVALID_PROJECT_IDS.has(normalized) || - isDefaultProjectIdAlias(normalized) + isDefaultProjectIdAlias(normalized) || + isLegacyDefaultProjectId(normalized) ) { return null; } diff --git a/src/components/agent/chat/utils/turnSummaryPresentation.ts b/src/components/agent/chat/utils/turnSummaryPresentation.ts index dad50cfab..eb6aa9daf 100644 --- a/src/components/agent/chat/utils/turnSummaryPresentation.ts +++ b/src/components/agent/chat/utils/turnSummaryPresentation.ts @@ -31,6 +31,19 @@ function normalizeLineForMatch(value: string): string { .trim(); } +function isLocalWorkspaceBatchSummary(text: string): boolean { + const compact = normalizeTurnSummaryDisplayText(text).replace(/\s+/g, ""); + if (!compact) { + return false; + } + + return ( + compact.includes("已完成一批本地分析") || + (compact.includes("已完成这一批本地仓库的") && + compact.includes("正在整理这一批结果并判断是否还需要继续取证")) + ); +} + export function normalizeTurnSummaryDisplayText(text?: string | null): string { return (text || "").trim(); } @@ -45,6 +58,10 @@ export function extractTurnSummaryLines(text?: string | null): string[] { export function isInternalRoutingTurnSummaryText( text?: string | null, ): boolean { + if (isLocalWorkspaceBatchSummary(text || "")) { + return true; + } + const lines = extractTurnSummaryLines(text); if (lines.length === 0) { return false; diff --git a/src/components/agent/chat/workspace/WorkspaceConversationScene.tsx b/src/components/agent/chat/workspace/WorkspaceConversationScene.tsx index 4bc7f571e..48d0e3bd0 100644 --- a/src/components/agent/chat/workspace/WorkspaceConversationScene.tsx +++ b/src/components/agent/chat/workspace/WorkspaceConversationScene.tsx @@ -31,7 +31,11 @@ import { type WorkspaceMainAreaProps = Omit< ComponentProps, - "navbarNode" | "contentSyncNoticeNode" | "forceCanvasMode" | "chatContent" | "canvasContent" + | "navbarNode" + | "contentSyncNoticeNode" + | "forceCanvasMode" + | "chatContent" + | "canvasContent" >; type CanvasWorkbenchLayoutProps = ComponentProps; type ChatToolPreferences = { @@ -266,7 +270,9 @@ interface WorkspaceConversationSceneProps extends WorkspaceMainAreaProps { >["onRecommendationClick"]; characters: NonNullable["characters"]>; skills: NonNullable["skills"]>; - serviceSkills: NonNullable["serviceSkills"]>; + serviceSkills: NonNullable< + ComponentProps["serviceSkills"] + >; serviceSkillGroups: NonNullable< ComponentProps["serviceSkillGroups"] >; @@ -534,29 +540,27 @@ export function WorkspaceConversationScene({ (syncStatus === "syncing" || syncStatus === "success" || syncStatus === "error"); - const contentSyncNoticeNode = - shouldShowContentSyncNotice - ? (() => { - const notice = resolveContentSyncNoticeMeta(syncStatus); - const NoticeIcon = notice.Icon; + const contentSyncNoticeNode = shouldShowContentSyncNotice + ? (() => { + const notice = resolveContentSyncNoticeMeta(syncStatus); + const NoticeIcon = notice.Icon; - return ( - - - {notice.label} - - ); - })() - : null; + return ( + + + {notice.label} + + ); + })() + : null; const canvasContent = - !liveCanvasPreview && !teamWorkbenchView ? null : currentImageWorkbenchActive || - (!teamWorkbenchView && shouldShowCanvasLoadingState) ? ( + !liveCanvasPreview && + !teamWorkbenchView ? null : currentImageWorkbenchActive || + (!teamWorkbenchView && shouldShowCanvasLoadingState) ? ( liveCanvasPreview ) : ( void; activeTheme: ComponentProps["activeTheme"]; toolPreferences: ComponentProps["toolPreferences"]; + runtimeToolAvailability: ComponentProps< + typeof AgentRuntimeStrip + >["runtimeToolAvailability"]; isSending: ComponentProps["isSending"]; executionRuntime: ComponentProps< typeof AgentRuntimeStrip @@ -107,6 +110,7 @@ export function GeneralWorkbenchDialogSection({ onOpenChange, activeTheme, toolPreferences, + runtimeToolAvailability, isSending, executionRuntime, isExecutionRuntimeActive, @@ -139,6 +143,7 @@ export function GeneralWorkbenchDialogSection({ ["onRecommendationClick"]; characters: NonNullable["characters"]>; skills: NonNullable["skills"]>; - serviceSkills: NonNullable["serviceSkills"]>; + serviceSkills: NonNullable< + ComponentProps["serviceSkills"] + >; serviceSkillGroups: NonNullable< ComponentProps["serviceSkillGroups"] >; diff --git a/src/components/agent/chat/workspace/useWorkspaceConversationSceneRuntime.tsx b/src/components/agent/chat/workspace/useWorkspaceConversationSceneRuntime.tsx index 22fec9384..7da4c3cd7 100644 --- a/src/components/agent/chat/workspace/useWorkspaceConversationSceneRuntime.tsx +++ b/src/components/agent/chat/workspace/useWorkspaceConversationSceneRuntime.tsx @@ -36,7 +36,10 @@ import { WorkspaceConversationScene } from "./WorkspaceConversationScene"; type InputbarScene = Pick< ReturnType, - "inputbarNode" | "generalWorkbenchDialog" | "teamWorkbenchSurfaceProps" + | "inputbarNode" + | "generalWorkbenchDialog" + | "teamWorkbenchSurfaceProps" + | "runtimeToolAvailability" >; type CanvasScene = Pick< ReturnType, @@ -295,6 +298,7 @@ interface UseWorkspaceConversationSceneRuntimeParams { pendingActions: ConversationScenePresentationParams["messageList"]["pendingActions"]; submittedActionsInFlight: ConversationScenePresentationParams["messageList"]["submittedActionsInFlight"]; queuedTurns: ConversationScenePresentationParams["messageList"]["queuedTurns"]; + childSubagentSessions?: ConversationScenePresentationParams["messageList"]["childSubagentSessions"]; isPreparingSend: boolean; isSending: ConversationScenePresentationParams["messageList"]["isSending"]; stopSending: ConversationScenePresentationParams["messageList"]["onInterruptCurrentTurn"]; @@ -424,6 +428,7 @@ export function useWorkspaceConversationSceneRuntime({ pendingActions = [], submittedActionsInFlight, queuedTurns = [], + childSubagentSessions = [], isPreparingSend, isSending, stopSending, @@ -718,6 +723,7 @@ export function useWorkspaceConversationSceneRuntime({ setAccessMode, onManageProviders: navigationActions.handleManageProviders, toolPreferences: chatToolPreferences, + runtimeToolAvailability: inputbarScene.runtimeToolAvailability, onToolPreferenceChange: (key, enabled) => setChatToolPreferences((previous) => ({ ...previous, @@ -811,6 +817,7 @@ export function useWorkspaceConversationSceneRuntime({ pendingActions, submittedActionsInFlight, queuedTurns, + childSubagentSessions, isRestoringSession: isAutoRestoringSession, isSending, onInterruptCurrentTurn: stopSending, diff --git a/src/components/agent/chat/workspace/useWorkspaceHarnessInventoryRuntime.test.tsx b/src/components/agent/chat/workspace/useWorkspaceHarnessInventoryRuntime.test.tsx index 7436c00e9..7c49debb8 100644 --- a/src/components/agent/chat/workspace/useWorkspaceHarnessInventoryRuntime.test.tsx +++ b/src/components/agent/chat/workspace/useWorkspaceHarnessInventoryRuntime.test.tsx @@ -190,6 +190,45 @@ describe("useWorkspaceHarnessInventoryRuntime", () => { } }); + it("主界面启用后即使详情面板未展开,也应预取工具库存", async () => { + mockGetAgentRuntimeToolInventory.mockResolvedValueOnce({ + agent_initialized: true, + runtime_tools: [ + { name: "WebSearch" }, + { name: "Agent" }, + { name: "SendMessage" }, + { name: "TeamCreate" }, + { name: "TeamDelete" }, + { name: "ListPeers" }, + { name: "TaskCreate" }, + { name: "TaskGet" }, + { name: "TaskList" }, + { name: "TaskUpdate" }, + { name: "TaskOutput" }, + { name: "TaskStop" }, + ], + registry_tools: [], + }); + + const harness = mountHook({ + enabled: true, + harnessPanelVisible: false, + }); + + try { + await act(async () => { + await Promise.resolve(); + }); + + expect(mockGetAgentRuntimeToolInventory).toHaveBeenCalledTimes(1); + expect(harness.getValue().toolInventory).toMatchObject({ + agent_initialized: true, + }); + } finally { + harness.unmount(); + } + }); + it("开关关闭时不应继续读取工具库存,也不应生成工作台摘要", async () => { const harness = mountHook({ enabled: false, diff --git a/src/components/agent/chat/workspace/useWorkspaceHarnessInventoryRuntime.ts b/src/components/agent/chat/workspace/useWorkspaceHarnessInventoryRuntime.ts index 4d71a9010..24a558107 100644 --- a/src/components/agent/chat/workspace/useWorkspaceHarnessInventoryRuntime.ts +++ b/src/components/agent/chat/workspace/useWorkspaceHarnessInventoryRuntime.ts @@ -31,7 +31,7 @@ export function useWorkspaceHarnessInventoryRuntime({ enabled, chatMode, mappedTheme, - harnessPanelVisible, + harnessPanelVisible: _harnessPanelVisible, harnessRequestMetadata, isThemeWorkbench, themeWorkbenchRunState, @@ -103,12 +103,12 @@ export function useWorkspaceHarnessInventoryRuntime({ }, [enabled]); useEffect(() => { - if (!enabled || !harnessPanelVisible) { + if (!enabled) { return; } void refreshToolInventory(); - }, [enabled, harnessPanelVisible, refreshToolInventory]); + }, [enabled, refreshToolInventory]); const generalWorkbenchHarnessSummary = useMemo(() => { if (!enabled || !isThemeWorkbench) { diff --git a/src/components/agent/chat/workspace/useWorkspaceInputbarSceneRuntime.tsx b/src/components/agent/chat/workspace/useWorkspaceInputbarSceneRuntime.tsx index dacf3da30..260a69fee 100644 --- a/src/components/agent/chat/workspace/useWorkspaceInputbarSceneRuntime.tsx +++ b/src/components/agent/chat/workspace/useWorkspaceInputbarSceneRuntime.tsx @@ -19,11 +19,13 @@ import { DEFAULT_CHAT_TOOL_PREFERENCES, type ChatToolPreferences, } from "../utils/chatToolPreferences"; +import { + deriveRuntimeToolAvailability, + type RuntimeToolAvailability, +} from "../utils/runtimeToolAvailability"; import { resolveCanvasTaskFileTarget } from "../utils/taskFileCanvasSync"; import { isRenderableTaskFile } from "./generalWorkbenchHelpers"; -import { - GeneralWorkbenchDialogSection, -} from "./WorkspaceHarnessDialogs"; +import { GeneralWorkbenchDialogSection } from "./WorkspaceHarnessDialogs"; import type { TeamWorkbenchSurfaceProps } from "./chatSurfaceProps"; import type { GeneralWorkbenchEntryPromptState } from "./workspaceSendHelpers"; @@ -222,7 +224,9 @@ interface UseWorkspaceInputbarScenePresentationRuntimeParams { generalWorkbenchEntryPrompt: GeneralWorkbenchEntryPromptState | null; onRestartGeneralWorkbenchEntryPrompt: () => void; onContinueGeneralWorkbenchEntryPrompt: () => Promise | void; - generalWorkbenchDialog: ComponentProps; + generalWorkbenchDialog: ComponentProps< + typeof GeneralWorkbenchDialogSection + >; }; } interface WorkspaceInputbarScenePresentationRuntimeResult { @@ -232,6 +236,7 @@ interface WorkspaceInputbarScenePresentationRuntimeResult { teamWorkbenchSurfaceProps: TeamWorkbenchSurfaceProps; inputbarNode: ReactNode; generalWorkbenchDialog: ReactNode; + runtimeToolAvailability: RuntimeToolAvailability | null | undefined; } type InputbarScenePresentationParams = UseWorkspaceInputbarScenePresentationRuntimeParams; @@ -341,14 +346,15 @@ function useWorkspaceInputbarScenePresentationRuntime({ const generalWorkbenchEntryPromptAccessory = useMemo( () => - inputbarPresentation.generalWorkbenchEntryPrompt ? ( - renderGeneralWorkbenchEntryPromptAccessory({ - prompt: inputbarPresentation.generalWorkbenchEntryPrompt, - onRestart: inputbarPresentation.onRestartGeneralWorkbenchEntryPrompt, - onContinue: - inputbarPresentation.onContinueGeneralWorkbenchEntryPrompt, - }) - ) : null, + inputbarPresentation.generalWorkbenchEntryPrompt + ? renderGeneralWorkbenchEntryPromptAccessory({ + prompt: inputbarPresentation.generalWorkbenchEntryPrompt, + onRestart: + inputbarPresentation.onRestartGeneralWorkbenchEntryPrompt, + onContinue: + inputbarPresentation.onContinueGeneralWorkbenchEntryPrompt, + }) + : null, [ inputbarPresentation.generalWorkbenchEntryPrompt, inputbarPresentation.onContinueGeneralWorkbenchEntryPrompt, @@ -356,12 +362,13 @@ function useWorkspaceInputbarScenePresentationRuntime({ ], ); - const floatingTeamWorkspaceDockProps = useMemo< - ComponentProps | null - >( + const floatingTeamWorkspaceDockProps = useMemo | null>( () => !inputbarPresentation.floatingTeamWorkspaceDock.enabled || - !inputbarPresentation.floatingTeamWorkspaceDock.showFloatingInputOverlay || + !inputbarPresentation.floatingTeamWorkspaceDock + .showFloatingInputOverlay || inputbarPresentation.floatingTeamWorkspaceDock.layoutMode !== "chat" ? null : { @@ -403,10 +410,7 @@ function useWorkspaceInputbarScenePresentationRuntime({ ) : undefined; const inputbarNode = ( - + ); const generalWorkbenchDialog = ( queuedTurns ?? [], [queuedTurns]); const resolvedChatToolPreferences = chatToolPreferences ?? DEFAULT_CHAT_TOOL_PREFERENCES; + const runtimeToolAvailability = useMemo( + () => deriveRuntimeToolAvailability(toolInventory), + [toolInventory], + ); const handleInputbarToolStatesChange = useCallback( ( nextToolStates: Pick< @@ -657,9 +667,11 @@ export function useWorkspaceInputbarSceneRuntime({ [setChatToolPreferences], ); const dockLayoutMode = layoutMode === "chat" ? "chat" : "chat-canvas"; - const resolvedTurns = turns ?? []; + const resolvedTurns = useMemo(() => turns ?? [], [turns]); const latestTurnPrompt = - resolvedTurns.find((turn) => turn.id === currentTurnId)?.prompt_text?.trim() || + resolvedTurns + .find((turn) => turn.id === currentTurnId) + ?.prompt_text?.trim() || resolvedTurns[resolvedTurns.length - 1]?.prompt_text?.trim() || ""; @@ -704,9 +716,7 @@ export function useWorkspaceInputbarSceneRuntime({ contextVariant, variant: isThemeWorkbench ? "workspace" : "default", workflowGate: isThemeWorkbench ? currentGate : null, - workflowSteps: isThemeWorkbench - ? generalWorkbenchWorkflowSteps - : steps, + workflowSteps: isThemeWorkbench ? generalWorkbenchWorkflowSteps : steps, workflowRunState, onSend: handleSend, onStop: handleStopSending, @@ -814,6 +824,7 @@ export function useWorkspaceInputbarSceneRuntime({ onRefreshToolInventory: refreshToolInventory, activeTheme: mappedTheme, toolPreferences: resolvedChatToolPreferences, + runtimeToolAvailability, isSending, executionRuntime: sessionExecutionRuntime, isExecutionRuntimeActive: Boolean(activeExecutionRuntime), diff --git a/src/components/memory/MemoryPage.test.tsx b/src/components/memory/MemoryPage.test.tsx index cbf7d0573..c5b3afb34 100644 --- a/src/components/memory/MemoryPage.test.tsx +++ b/src/components/memory/MemoryPage.test.tsx @@ -246,13 +246,13 @@ describe("MemoryPage", () => { const bodyText = document.body.textContent ?? ""; expect(bodyText).toContain("记忆工作台"); - expect(bodyText).toContain("规则"); - expect(bodyText).toContain("工作记忆"); - expect(bodyText).toContain("长期记忆"); - expect(bodyText).toContain("Team 影子"); - expect(bodyText).toContain("压缩边界"); - expect(bodyText).toContain("五层总览"); - expect(bodyText).toContain("项目资料附属层"); + expect(bodyText).toContain("记忆来源"); + expect(bodyText).toContain("会话记忆"); + expect(bodyText).toContain("记忆类型"); + expect(bodyText).toContain("Team Memory"); + expect(bodyText).toContain("会话压缩"); + expect(bodyText).toContain("Claude Code 记忆作用域"); + expect(bodyText).toContain("不要写进记忆的内容"); }); it("应兼容旧的 durable category 深链,并支持带回创作输入", async () => { @@ -260,7 +260,8 @@ describe("MemoryPage", () => { renderPage({ section: "identity", onNavigate }); await flushPageEffects(); - expect(document.body.textContent ?? "").toContain("长期结构化记忆"); + expect(document.body.textContent ?? "").toContain("Claude Code 记忆类型"); + expect(document.body.textContent ?? "").toContain("当前存量条目"); expect(document.body.textContent ?? "").toContain("夏日短视频语气"); const button = Array.from(document.body.querySelectorAll("button")).find( @@ -303,12 +304,13 @@ describe("MemoryPage", () => { agentEntry: "new-task", projectId: "project-42", entryBannerMessage: "已从灵感库带入“风格”条目,可继续改写后发送。", - initialUserPrompt: expect.stringContaining("标签:小红书、口播、夏日氛围"), + initialUserPrompt: + expect.stringContaining("标签:小红书、口播、夏日氛围"), }), ); }); - it("应支持带着当前会话上下文打开运行时五层预演", async () => { + it("应支持带着当前会话上下文打开运行时记忆命中预演", async () => { window.localStorage.setItem( "lime:team-memory:/tmp/workspace", JSON.stringify({ @@ -368,7 +370,7 @@ describe("MemoryPage", () => { expect(bodyText).toContain("会话:session-runtime-1"); expect(bodyText).toContain("工作区:/tmp/workspace"); expect(bodyText).toContain("本回合记忆预取"); - expect(bodyText).toContain("五层记忆预演"); + expect(bodyText).toContain("记忆命中预演"); expect(bodyText).toContain("研究输出格式偏好"); expect(bodyText).toContain("研究协作队"); expect(bodyText).toContain("【运行时记忆召回】"); @@ -464,17 +466,21 @@ describe("MemoryPage", () => { expect(bodyText).toContain("补强层:规则层、持久层。 摘要内容也有更新。"); expect(bodyText).toContain("规则 +1"); expect(bodyText).toContain("持久 +1"); - expect(bodyText).toContain("工作摘录 【task_plan.md】旧版工作摘录。 -> 【task_plan.md】新版工作摘录。"); + expect(bodyText).toContain( + "工作摘录 【task_plan.md】旧版工作摘录。 -> 【task_plan.md】新版工作摘录。", + ); expect(bodyText).toContain("当前基线"); expect(bodyText).toContain("设为对照基线"); - const switchBaselineButton = Array.from(document.body.querySelectorAll("button")).find( - (element) => element.textContent?.trim() === "设为对照基线", - ); + const switchBaselineButton = Array.from( + document.body.querySelectorAll("button"), + ).find((element) => element.textContent?.trim() === "设为对照基线"); expect(switchBaselineButton).toBeTruthy(); await act(async () => { - switchBaselineButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + switchBaselineButton?.dispatchEvent( + new MouseEvent("click", { bubbles: true }), + ); await Promise.resolve(); }); @@ -492,10 +498,12 @@ describe("MemoryPage", () => { }); await flushPageEffects(); - const rulesButton = Array.from(document.body.querySelectorAll("button")).find( + const rulesButton = Array.from( + document.body.querySelectorAll("button"), + ).find( (element) => - element.textContent?.includes("规则") && - !element.textContent?.includes("看规则"), + element.textContent?.includes("记忆来源") && + !element.textContent?.includes("看来源链"), ); expect(rulesButton).toBeTruthy(); @@ -594,7 +602,9 @@ describe("MemoryPage", () => { const bodyText = document.body.textContent ?? ""; expect(bodyText).toContain("较上一条判断"); - expect(bodyText).toContain("补强层:规则层、工作层、持久层、Team 层、压缩层。 摘要内容也有更新。"); + expect(bodyText).toContain( + "补强层:规则层、工作层、持久层、Team 层、压缩层。 摘要内容也有更新。", + ); expect(bodyText).toContain("规则 +1"); expect(bodyText).toContain("工作 新命中"); expect(bodyText).toContain("持久 +1"); @@ -702,9 +712,9 @@ describe("MemoryPage", () => { expect(document.body.textContent ?? "").toContain("其他工作区记录"); - const sessionButton = Array.from(document.body.querySelectorAll("button")).find( - (element) => element.textContent?.trim() === "当前会话", - ); + const sessionButton = Array.from( + document.body.querySelectorAll("button"), + ).find((element) => element.textContent?.trim() === "当前会话"); expect(sessionButton).toBeTruthy(); await act(async () => { @@ -741,9 +751,9 @@ describe("MemoryPage", () => { expect(document.body.textContent ?? "").toContain("待清空记录"); - const clearButton = Array.from(document.body.querySelectorAll("button")).find( - (element) => element.textContent?.includes("清空历史"), - ); + const clearButton = Array.from( + document.body.querySelectorAll("button"), + ).find((element) => element.textContent?.includes("清空历史")); expect(clearButton).toBeTruthy(); await act(async () => { diff --git a/src/components/memory/MemoryPage.tsx b/src/components/memory/MemoryPage.tsx index 0fa87a0ad..7e3a98cf8 100644 --- a/src/components/memory/MemoryPage.tsx +++ b/src/components/memory/MemoryPage.tsx @@ -1,5 +1,18 @@ -import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; -import { BrainCircuit, Database, FolderKanban, GitBranch, ScrollText, Users } from "lucide-react"; +import { + useCallback, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; +import { + BrainCircuit, + Database, + FolderKanban, + GitBranch, + ScrollText, + Users, +} from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { cn } from "@/lib/utils"; import { getConfig, saveConfig, type Config } from "@/lib/api/appConfig"; @@ -16,7 +29,6 @@ import { type TurnMemoryPrefetchResult, type WorkingMemoryView, } from "@/lib/api/memoryRuntime"; -import { getProjectMemory, type ProjectMemory } from "@/lib/api/memory"; import { getUnifiedMemoryStats, listUnifiedMemories, @@ -54,10 +66,31 @@ import { type RuntimeMemoryPrefetchHistoryScope, } from "@/lib/runtimeMemoryPrefetchHistory"; import { buildLayerMetrics } from "./memoryLayerMetrics"; -import type { MemoryPageParams, MemoryPageSection, Page, PageParams } from "@/types/page"; +import type { + MemoryPageParams, + MemoryPageSection, + Page, + PageParams, +} from "@/types/page"; type DurableCategoryFilter = MemoryCategory | "all"; -type PrimarySection = "home" | "rules" | "working" | "durable" | "team" | "compaction"; +type PrimarySection = + | "home" + | "rules" + | "working" + | "durable" + | "team" + | "compaction"; +type ClaudeMemoryType = "user" | "feedback" | "project" | "reference"; +type SourceBucketKey = + | "managed" + | "user" + | "project" + | "local" + | "rules" + | "auto" + | "durable" + | "additional"; interface MemoryPageProps { onNavigate: (page: Page, pageParams?: PageParams) => void; @@ -85,39 +118,39 @@ const SECTION_META: Array<{ }> = [ { key: "home", - label: "总览", + label: "记忆总览", icon: BrainCircuit, - description: "看清五层记忆是否已经接上。", + description: "按 Claude Code 的来源与作用域查看当前记忆。", }, { key: "rules", - label: "规则", + label: "记忆来源", icon: ScrollText, - description: "AGENTS、规则来源与自动记忆入口。", + description: "Managed、User、Project、Local、Rules、Auto 与 /memories。", }, { key: "working", - label: "工作记忆", + label: "会话记忆", icon: FolderKanban, - description: "会话级计划、发现、进度与错误文件。", + description: "当前 session 的计划、摘录和工作文件。", }, { key: "durable", - label: "长期记忆", + label: "记忆类型", icon: Database, - description: "统一记忆库中的结构化沉淀。", + description: "Claude 四类记忆视角与 Lime 当前存量映射。", }, { key: "team", - label: "Team 影子", + label: "Team Memory", icon: Users, - description: "repo 作用域的协作影子与分工痕迹。", + description: "repo 作用域的协作影子与分工快照。", }, { key: "compaction", - label: "压缩边界", + label: "会话压缩", icon: GitBranch, - description: "最近一次上下文压缩留下的可续接摘要。", + description: "长会话压缩后保留下来的可续接摘要。", }, ]; @@ -137,6 +170,215 @@ const CATEGORY_LABELS: Record = { activity: "收藏", }; +const CLAUDE_MEMORY_TYPE_META: Array<{ + key: ClaudeMemoryType; + label: string; + description: string; + useCase: string; +}> = [ + { + key: "user", + label: "User memory", + description: "记录用户角色、职责、目标与长期偏好,用来调整协作方式。", + useCase: "例如用户的经验背景、沟通偏好、对解释深浅的期待。", + }, + { + key: "feedback", + label: "Feedback memory", + description: "记录用户反复强调的做事方式,包括纠偏和验证通过的做法。", + useCase: "例如“不要做额外重构”“回复保持简洁”“测试要打真实链路”。", + }, + { + key: "project", + label: "Project memory", + description: "记录项目内不易从代码直接推导的目标、约束、时间点和背景。", + useCase: "例如当前主线、冻结窗口、重构动机、团队分工和交付背景。", + }, + { + key: "reference", + label: "Reference memory", + description: "记录外部资料和系统入口,帮助下次知道去哪里查最新事实。", + useCase: "例如文档地址、监控面板、工单系统、知识库目录。", + }, +]; + +const CLAUDE_MEMORY_TYPE_LABELS: Record = { + user: "用户记忆", + feedback: "反馈记忆", + project: "项目记忆", + reference: "参考记忆", +}; + +const LEGACY_CATEGORY_TO_CLAUDE_TYPE: Record = + { + identity: "user", + preference: "feedback", + experience: "project", + activity: "project", + context: "reference", + }; + +const CLAUDE_TYPE_BADGE_CLASS_NAMES: Record = { + user: "border-emerald-200 bg-emerald-50 text-emerald-800", + feedback: "border-emerald-200 bg-emerald-50 text-emerald-800", + project: "border-emerald-200 bg-emerald-50 text-emerald-800", + reference: "border-slate-200 bg-slate-100 text-slate-700", +}; + +const SOURCE_BUCKET_META: Array<{ + key: SourceBucketKey; + label: string; + scope: string; + description: string; + kinds: string[]; + emptyState: string; +}> = [ + { + key: "managed", + label: "Managed memory", + scope: "全局托管", + description: "平台托管的基础政策与默认行为,优先级最高。", + kinds: ["managed_policy"], + emptyState: "当前未发现托管策略文件。", + }, + { + key: "user", + label: "User memory", + scope: "用户级", + description: "全局用户记忆,通常对应当前用户的长期偏好与个人规则。", + kinds: ["user_memory"], + emptyState: "当前未发现用户级记忆文件。", + }, + { + key: "project", + label: "Project memory", + scope: "项目级", + description: "项目共享记忆,通常来自仓库内主记忆文件。", + kinds: ["project_memory", "workspace_agents"], + emptyState: "当前未发现项目级主记忆文件。", + }, + { + key: "local", + label: "Local memory", + scope: "本地项目级", + description: "仅在当前工作区本地生效,不应替代共享主记忆。", + kinds: ["project_local"], + emptyState: "当前未发现本地 local memory 文件。", + }, + { + key: "rules", + label: "Project rules", + scope: "项目规则目录", + description: "规则目录与细粒度规则文件,会在项目范围内参与注入。", + kinds: ["project_rule", "project_rules"], + emptyState: "当前未发现项目规则目录或规则文件。", + }, + { + key: "auto", + label: "Auto memory", + scope: "自动归档", + description: "自动记忆目录与入口文件,承接持续沉淀的 note 与索引。", + kinds: ["auto_memory"], + emptyState: "当前未启用或未发现 auto memory 入口。", + }, + { + key: "durable", + label: "/memories", + scope: "跨会话", + description: "可跨会话访问的 durable memory 根目录,用于共享结构化沉淀。", + kinds: ["durable_memory"], + emptyState: "当前未发现 durable memory 根目录。", + }, + { + key: "additional", + label: "Additional memory", + scope: "附加目录", + description: "额外扫描目录中的补充记忆来源,避免直接混入主链。", + kinds: ["additional_memory"], + emptyState: "当前没有额外目录记忆来源。", + }, +]; + +const MEMORY_SCOPE_CARD_META: Array<{ + key: "user" | "project" | "local" | "auto" | "durable" | "team"; + label: string; + description: string; +}> = [ + { + key: "user", + label: "User memory", + description: "面向当前用户的长期偏好与协作方式。", + }, + { + key: "project", + label: "Project memory", + description: "仓库共享记忆,承接项目背景和约束。", + }, + { + key: "local", + label: "Local memory", + description: "只在本地工作区生效的附加指令与补充说明。", + }, + { + key: "auto", + label: "Auto memory folder", + description: "自动整理的 MEMORY 入口和主题 note。", + }, + { + key: "durable", + label: "/memories", + description: "跨会话可读取的 durable 记忆根目录。", + }, + { + key: "team", + label: "Team memory", + description: "repo-scoped 协作影子,补充团队分工和上下文。", + }, +]; + +const MEMORY_DO_NOT_SAVE = [ + "代码模式、约定、架构、文件路径或项目结构,这些应直接从当前仓库读取。", + "Git 历史、最近改动、谁改了什么,`git log` 和 `git blame` 才是事实源。", + "调试步骤、修复配方或一次性操作过程,真正的结果应该回到代码和提交记录。", + "已经写进 `AGENTS.md`、项目规则或其他记忆文件的内容,不要重复保存。", + "临时任务状态、当前会话上下文、一次性的待办列表,这些属于 working memory,不是 durable memory。", +]; + +const MEMORY_READ_GUARDRAILS = [ + "用户明确要求回忆、检查或记住时,必须读取记忆来源,不要只凭模型记忆作答。", + "如果记忆提到某个文件、函数或 flag 存在,先检查当前仓库是否仍然成立,再给建议。", + "记忆和当前事实冲突时,以当前代码、文件和外部资源的最新状态为准,并及时修正旧记忆。", +]; + +const MEMORY_PAGE_LAYER_COPY: Record< + "rules" | "working" | "durable" | "team" | "compaction", + { + title: string; + description: string; + } +> = { + rules: { + title: "来源链", + description: "当前已解析到可注入的规则与记忆来源文件。", + }, + working: { + title: "会话记忆", + description: "当前会话的 plan、摘录和工作文件正在沉淀。", + }, + durable: { + title: "持久记忆", + description: "跨会话可复用的结构化沉淀已进入 durable memory 视图。", + }, + team: { + title: "Team Memory", + description: "repo 作用域的 Team memory 快照可用于补足协作上下文。", + }, + compaction: { + title: "会话压缩", + description: "长会话压缩摘要可用于后续续接。", + }, +}; + const RUNTIME_HISTORY_SCOPE_META: Array<{ key: RuntimeMemoryPrefetchHistoryScope; label: string; @@ -167,11 +409,21 @@ const RUNTIME_HISTORY_LAYER_LABELS: Record< }; const PANEL_CLASS_NAME = - "rounded-3xl border border-slate-200 bg-white p-5 shadow-sm shadow-slate-950/5"; + "rounded-3xl border border-slate-200/90 bg-white p-5 shadow-sm shadow-slate-950/5"; const BUTTON_CLASS_NAME = - "inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-2 text-sm font-medium text-slate-600 transition hover:border-slate-300 hover:text-slate-900"; + "inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-2 text-sm font-medium text-slate-600 transition hover:border-slate-300 hover:bg-slate-50 hover:text-slate-900"; const ACTIVE_BUTTON_CLASS_NAME = - "border-slate-900 bg-slate-900 text-white hover:border-slate-900 hover:text-white"; + "border-emerald-200 bg-emerald-50 text-emerald-900 shadow-sm shadow-emerald-950/5 hover:border-emerald-300 hover:bg-emerald-100 hover:text-emerald-900"; +const EMERALD_BUTTON_CLASS_NAME = + "border-emerald-700 bg-emerald-700 text-white hover:border-emerald-800 hover:bg-emerald-800 hover:text-white"; +const EMERALD_OUTLINE_BADGE_CLASS_NAME = + "border-emerald-200 bg-white text-emerald-700"; +const EMERALD_BADGE_CLASS_NAME = + "border-emerald-200 bg-emerald-50 text-emerald-700"; +const SLATE_OUTLINE_BADGE_CLASS_NAME = + "border-slate-200 bg-white text-slate-700"; +const SLATE_BADGE_CLASS_NAME = + "border-slate-200 bg-slate-100 text-slate-700"; function formatRelativeTime(timestamp: number | undefined): string { if (!timestamp) { @@ -239,9 +491,9 @@ function resolveRuntimeHistoryPreviewChangeLabel( case "working": return `工作摘录 ${change.previous || "无"} -> ${change.current || "无"}`; case "durable": - return `长期记忆 ${change.previous || "无"} -> ${change.current || "无"}`; + return `持久记忆 ${change.previous || "无"} -> ${change.current || "无"}`; case "team": - return `Team 影子 ${change.previous || "无"} -> ${change.current || "无"}`; + return `Team Memory ${change.previous || "无"} -> ${change.current || "无"}`; case "compaction": return `压缩摘要 ${change.previous || "无"} -> ${change.current || "无"}`; case "user_message": @@ -300,7 +552,7 @@ function resolveRuntimeLayerStabilityPresentation( title: "稳定命中", description: `最近 ${totalEntries} 次都命中${title},且没有出现层值变化。`, className: "border-emerald-200 bg-emerald-50/70", - badgeClassName: "border-emerald-200 bg-white text-emerald-700", + badgeClassName: EMERALD_OUTLINE_BADGE_CLASS_NAME, }; } @@ -308,16 +560,16 @@ function resolveRuntimeLayerStabilityPresentation( return { title: "持续命中", description: `最近 ${totalEntries} 次都命中${title},但层值发生了 ${layer.valueChanges} 次变化。`, - className: "border-amber-200 bg-amber-50/70", - badgeClassName: "border-amber-200 bg-white text-amber-700", + className: "border-emerald-200 bg-emerald-50/40", + badgeClassName: EMERALD_OUTLINE_BADGE_CLASS_NAME, }; } return { title: "间歇命中", description: `最近 ${totalEntries} 次里命中${title} ${layer.hitEntries} 次,发生 ${layer.valueChanges} 次状态变化。`, - className: "border-sky-200 bg-sky-50/70", - badgeClassName: "border-sky-200 bg-white text-sky-700", + className: "border-slate-200 bg-emerald-50/30", + badgeClassName: SLATE_OUTLINE_BADGE_CLASS_NAME, }; } @@ -326,14 +578,14 @@ function resolveRuntimeComparisonAssessmentBadgeClassName( ): string { switch (assessment.status) { case "stronger": - return "border-emerald-200 bg-emerald-50 text-emerald-700"; + return EMERALD_BADGE_CLASS_NAME; case "weaker": return "border-amber-200 bg-amber-50 text-amber-700"; case "mixed": - return "border-sky-200 bg-sky-50 text-sky-700"; + return SLATE_BADGE_CLASS_NAME; case "same": default: - return "border-slate-200 bg-white text-slate-700"; + return SLATE_OUTLINE_BADGE_CLASS_NAME; } } @@ -368,7 +620,10 @@ function resolveSectionState(section?: MemoryPageSection): { }; } -function buildCreationPrompt(entry: UnifiedMemory, categoryLabel: string): string { +function buildCreationPrompt( + entry: UnifiedMemory, + categoryLabel: string, +): string { const lines = [ "请把这条记忆整理成当前创作输入,并保留关键约束。", `灵感分类:${categoryLabel}`, @@ -382,16 +637,57 @@ function buildCreationPrompt(entry: UnifiedMemory, categoryLabel: string): strin return lines.join("\n"); } -function countProjectCoverage(projectMemory: ProjectMemory | null): string { - if (!projectMemory) { - return "未选择项目"; +function getMemoryAvailabilityBadge(status: "loaded" | "exists" | "missing"): { + label: string; + className: string; +} { + switch (status) { + case "loaded": + return { + label: "已加载", + className: EMERALD_BADGE_CLASS_NAME, + }; + case "exists": + return { + label: "已发现", + className: SLATE_BADGE_CLASS_NAME, + }; + case "missing": + default: + return { + label: "未发现", + className: SLATE_OUTLINE_BADGE_CLASS_NAME, + }; } +} - const characterCount = projectMemory.characters.length; - const outlineCount = projectMemory.outline.length; - const hasWorldBuilding = !!projectMemory.world_building?.description?.trim(); +function resolveClaudeMemoryType(category: MemoryCategory): ClaudeMemoryType { + return LEGACY_CATEGORY_TO_CLAUDE_TYPE[category]; +} - return `角色 ${characterCount} / 世界观 ${hasWorldBuilding ? "已填写" : "未填写"} / 大纲 ${outlineCount}`; +function resolveSourceKindLabel(kind: string): string { + switch (kind) { + case "managed_policy": + return "Managed memory"; + case "user_memory": + return "User memory"; + case "project_memory": + case "workspace_agents": + return "Project memory"; + case "project_local": + return "Local memory"; + case "project_rule": + case "project_rules": + return "Project rules"; + case "auto_memory": + return "Auto memory"; + case "durable_memory": + return "/memories"; + case "additional_memory": + return "Additional memory"; + default: + return kind; + } } function MemorySurfacePanel(props: { @@ -404,9 +700,13 @@ function MemorySurfacePanel(props: {
    -

    {props.title}

    +

    + {props.title} +

    {props.description ? ( -

    {props.description}

    +

    + {props.description} +

    ) : null}
    {props.actions} @@ -425,21 +725,28 @@ export function MemoryPage({ onNavigate, pageParams }: MemoryPageProps) { initialState.durableFilter, ); const [config, setConfig] = useState(null); - const [memoryConfig, setMemoryConfig] = useState(DEFAULT_MEMORY_CONFIG); + const [memoryConfig, setMemoryConfig] = useState( + DEFAULT_MEMORY_CONFIG, + ); const [savingConfig, setSavingConfig] = useState(false); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [rulesSources, setRulesSources] = useState(null); - const [autoIndex, setAutoIndex] = useState(null); - const [workingView, setWorkingView] = useState(null); + const [rulesSources, setRulesSources] = + useState(null); + const [autoIndex, setAutoIndex] = useState( + null, + ); + const [workingView, setWorkingView] = useState( + null, + ); const [extractionStatus, setExtractionStatus] = useState(null); - const [unifiedStats, setUnifiedStats] = useState(null); + const [unifiedStats, setUnifiedStats] = + useState(null); const [unifiedMemories, setUnifiedMemories] = useState([]); const [projectId, setProjectId] = useState( getStoredResourceProjectId(), ); - const [projectMemory, setProjectMemory] = useState(null); const [teamSnapshots, setTeamSnapshots] = useState([]); const [runtimePrefetchHistory, setRuntimePrefetchHistory] = useState< RuntimeMemoryPrefetchHistoryEntry[] @@ -452,8 +759,10 @@ export function MemoryPage({ onNavigate, pageParams }: MemoryPageProps) { }); const [runtimeHistoryScope, setRuntimeHistoryScope] = useState("all"); - const [runtimeComparisonBaselineSignature, setRuntimeComparisonBaselineSignature] = - useState(null); + const [ + runtimeComparisonBaselineSignature, + setRuntimeComparisonBaselineSignature, + ] = useState(null); const runtimeSessionId = pageParams?.runtimeSessionId?.trim() || ""; const runtimeWorkingDir = pageParams?.runtimeWorkingDir?.trim() || ""; @@ -480,7 +789,12 @@ export function MemoryPage({ onNavigate, pageParams }: MemoryPageProps) { if (!hasRuntimeContext) { setRuntimeComparisonBaselineSignature(null); } - }, [hasRuntimeContext, runtimeSessionId, runtimeWorkingDir, runtimeUserMessage]); + }, [ + hasRuntimeContext, + runtimeSessionId, + runtimeWorkingDir, + runtimeUserMessage, + ]); useEffect(() => { let disposed = false; @@ -507,10 +821,6 @@ export function MemoryPage({ onNavigate, pageParams }: MemoryPageProps) { getUnifiedMemoryStats(), listUnifiedMemories({ limit: 120 }), ]); - - const nextProjectMemory = projectId - ? await getProjectMemory(projectId).catch(() => null) - : null; const nextTeamSnapshots = typeof window !== "undefined" ? listTeamMemorySnapshots(window.localStorage) @@ -532,14 +842,15 @@ export function MemoryPage({ onNavigate, pageParams }: MemoryPageProps) { setExtractionStatus(nextExtractionStatus); setUnifiedStats(nextUnifiedStats); setUnifiedMemories(nextUnifiedMemories); - setProjectMemory(nextProjectMemory); setTeamSnapshots(nextTeamSnapshots); setRuntimePrefetchHistory(nextRuntimePrefetchHistory); } catch (loadError) { if (disposed) { return; } - setError(loadError instanceof Error ? loadError.message : "记忆页面加载失败"); + setError( + loadError instanceof Error ? loadError.message : "记忆页面加载失败", + ); } finally { if (!disposed) { setLoading(false); @@ -552,13 +863,15 @@ export function MemoryPage({ onNavigate, pageParams }: MemoryPageProps) { return () => { disposed = true; }; - }, [projectId]); + }, []); const filteredMemories = useMemo(() => { if (durableFilter === "all") { return unifiedMemories; } - return unifiedMemories.filter((memory) => memory.category === durableFilter); + return unifiedMemories.filter( + (memory) => memory.category === durableFilter, + ); }, [durableFilter, unifiedMemories]); const layerMetrics = useMemo( @@ -570,7 +883,157 @@ export function MemoryPage({ onNavigate, pageParams }: MemoryPageProps) { teamSnapshotCount: teamSnapshots.length, compactionCount: extractionStatus?.recent_compactions.length || 0, }), - [extractionStatus?.recent_compactions.length, rulesSources?.loaded_sources, teamSnapshots.length, unifiedStats?.total_entries, workingView?.total_entries], + [ + extractionStatus?.recent_compactions.length, + rulesSources?.loaded_sources, + teamSnapshots.length, + unifiedStats?.total_entries, + workingView?.total_entries, + ], + ); + + const durableCountsByCategory = useMemo(() => { + const counts = new Map(); + unifiedStats?.categories.forEach((item) => { + counts.set(item.category, item.count); + }); + return counts; + }, [unifiedStats?.categories]); + + const sourceBuckets = useMemo( + () => + SOURCE_BUCKET_META.map((bucket) => { + const sources = + rulesSources?.sources.filter((source) => + bucket.kinds.includes(source.kind), + ) || []; + const loadedCount = sources.filter((source) => source.loaded).length; + const existsCount = sources.filter((source) => source.exists).length; + const status: "loaded" | "exists" | "missing" = + loadedCount > 0 ? "loaded" : existsCount > 0 ? "exists" : "missing"; + return { + ...bucket, + sources, + loadedCount, + existsCount, + status, + primaryPath: sources[0]?.path || null, + preview: + bucket.key === "auto" && autoIndex?.preview_lines?.length + ? autoIndex.preview_lines.join("\n") + : (sources.find((source) => source.preview)?.preview ?? null), + }; + }), + [autoIndex?.preview_lines, rulesSources?.sources], + ); + + const sourceBucketMap = useMemo( + () => new Map(sourceBuckets.map((bucket) => [bucket.key, bucket])), + [sourceBuckets], + ); + + const memoryScopeCards = useMemo(() => { + return MEMORY_SCOPE_CARD_META.map((card) => { + if (card.key === "team") { + const status = teamSnapshots.length > 0 ? "loaded" : "missing"; + return { + ...card, + status, + detail: + teamSnapshots.length > 0 + ? `${teamSnapshots.length} 个 repo 作用域快照` + : "当前没有本地 Team memory 快照", + helper: + teamSnapshots.length > 0 + ? "用于补充团队分工与协作影子,不替代共享记忆文件。" + : "等待在会话或工作台中产生协作影子后再显示。", + }; + } + + const bucketKey = + card.key === "project" + ? "project" + : card.key === "local" + ? "local" + : card.key === "auto" + ? "auto" + : card.key === "durable" + ? "durable" + : "user"; + const bucket = sourceBucketMap.get(bucketKey); + const status = bucket?.status || "missing"; + + if (card.key === "auto") { + return { + ...card, + status: + autoIndex?.entry_exists || bucket?.loadedCount + ? "loaded" + : autoIndex?.enabled + ? "exists" + : "missing", + detail: + autoIndex?.root_dir || + bucket?.primaryPath || + "未发现 auto memory 根目录", + helper: autoIndex?.entrypoint + ? `入口文件:${autoIndex.entrypoint} · 已索引 ${autoIndex.items.length} 个主题` + : bucket?.emptyState || "当前未启用 auto memory。", + }; + } + + if (card.key === "durable") { + return { + ...card, + status: + (bucket?.loadedCount || 0) > 0 || + (unifiedStats?.total_entries || 0) > 0 + ? "loaded" + : bucket?.status || "missing", + detail: bucket?.primaryPath || "/memories", + helper: `当前可见 ${unifiedStats?.total_entries || 0} 条结构化条目`, + }; + } + + return { + ...card, + status, + detail: + bucket?.primaryPath || bucket?.emptyState || "当前未发现对应来源", + helper: + status === "loaded" + ? `当前已命中 ${bucket?.loadedCount || 0} 条来源` + : bucket?.emptyState || "当前未发现对应来源", + }; + }); + }, [ + autoIndex, + sourceBucketMap, + teamSnapshots.length, + unifiedStats?.total_entries, + ]); + + const claudeTypeCards = useMemo( + () => + CLAUDE_MEMORY_TYPE_META.map((item) => { + const mappedCategories = ( + Object.keys(LEGACY_CATEGORY_TO_CLAUDE_TYPE) as MemoryCategory[] + ).filter((category) => resolveClaudeMemoryType(category) === item.key); + const count = mappedCategories.reduce( + (total, category) => + total + (durableCountsByCategory.get(category) || 0), + 0, + ); + + return { + ...item, + count, + legacyLabels: mappedCategories + .map((category) => CATEGORY_LABELS[category]) + .join("、"), + }; + }), + [durableCountsByCategory], ); const runtimeTeamSnapshot = useMemo(() => { @@ -578,7 +1041,8 @@ export function MemoryPage({ onNavigate, pageParams }: MemoryPageProps) { return null; } - const normalizedWorkingDir = normalizeTeamMemoryRepoScope(runtimeWorkingDir); + const normalizedWorkingDir = + normalizeTeamMemoryRepoScope(runtimeWorkingDir); return ( teamSnapshots.find( (snapshot) => @@ -687,7 +1151,9 @@ export function MemoryPage({ onNavigate, pageParams }: MemoryPageProps) { setConfig(nextConfig); setMemoryConfig(nextConfig.memory || DEFAULT_MEMORY_CONFIG); } catch (saveError) { - setError(saveError instanceof Error ? saveError.message : "保存记忆配置失败"); + setError( + saveError instanceof Error ? saveError.message : "保存记忆配置失败", + ); } finally { setSavingConfig(false); } @@ -718,15 +1184,16 @@ export function MemoryPage({ onNavigate, pageParams }: MemoryPageProps) { function handleBringToCreation(entry: UnifiedMemory) { const categoryLabel = CATEGORY_LABELS[entry.category]; const entryBannerMessage = `已从灵感库带入“${categoryLabel}”条目,可继续改写后发送。`; - const initialRequestMetadata = buildMemoryEntryCreationReplayRequestMetadata({ - id: entry.id, - projectId: projectId || undefined, - category: entry.category, - title: entry.title, - summary: entry.summary, - content: entry.content, - tags: entry.tags, - }); + const initialRequestMetadata = + buildMemoryEntryCreationReplayRequestMetadata({ + id: entry.id, + projectId: projectId || undefined, + category: entry.category, + title: entry.title, + summary: entry.summary, + content: entry.content, + tags: entry.tags, + }); onNavigate( "agent", @@ -752,7 +1219,9 @@ export function MemoryPage({ onNavigate, pageParams }: MemoryPageProps) { ); } - const currentSectionMeta = SECTION_META.find((item) => item.key === activeSection); + const currentSectionMeta = SECTION_META.find( + (item) => item.key === activeSection, + ); const normalizedRuntimeWorkingDir = runtimeWorkingDir ? normalizeTeamMemoryRepoScope(runtimeWorkingDir) : ""; @@ -775,20 +1244,27 @@ export function MemoryPage({ onNavigate, pageParams }: MemoryPageProps) { () => summarizeRuntimeMemoryPrefetchHistory(filteredRuntimePrefetchHistory), [filteredRuntimePrefetchHistory], ); - const displayedRuntimePrefetchHistory = filteredRuntimePrefetchHistory.slice(0, 6); + const displayedRuntimePrefetchHistory = filteredRuntimePrefetchHistory.slice( + 0, + 6, + ); const activeRuntimeHistoryScopeMeta = - RUNTIME_HISTORY_SCOPE_META.find((item) => item.key === runtimeHistoryScope) || - RUNTIME_HISTORY_SCOPE_META[0]; + RUNTIME_HISTORY_SCOPE_META.find( + (item) => item.key === runtimeHistoryScope, + ) || RUNTIME_HISTORY_SCOPE_META[0]; const isRuntimeHistoryEntryActive = useCallback( (entry: RuntimeMemoryPrefetchHistoryEntry) => entry.sessionId === runtimeSessionId && - normalizeTeamMemoryRepoScope(entry.workingDir) === normalizedRuntimeWorkingDir && + normalizeTeamMemoryRepoScope(entry.workingDir) === + normalizedRuntimeWorkingDir && (entry.userMessage || "") === runtimeUserMessage, [runtimeSessionId, normalizedRuntimeWorkingDir, runtimeUserMessage], ); const currentRuntimeHistoryEntry = useMemo( () => - runtimePrefetchHistory.find((entry) => isRuntimeHistoryEntryActive(entry)) || null, + runtimePrefetchHistory.find((entry) => + isRuntimeHistoryEntryActive(entry), + ) || null, [runtimePrefetchHistory, isRuntimeHistoryEntryActive], ); const runtimeComparisonCandidates = useMemo( @@ -841,7 +1317,9 @@ export function MemoryPage({ onNavigate, pageParams }: MemoryPageProps) { return; } - setRuntimeComparisonBaselineSignature(runtimeComparisonCandidates[0].signature); + setRuntimeComparisonBaselineSignature( + runtimeComparisonCandidates[0].signature, + ); }, [runtimeComparisonBaselineSignature, runtimeComparisonCandidates]); function handleClearRuntimeHistory() { clearRuntimeMemoryPrefetchHistory(); @@ -852,53 +1330,56 @@ export function MemoryPage({ onNavigate, pageParams }: MemoryPageProps) {
    ) : null; return ( -
    +
    ) : error ? ( -
    +

    {error}

    ) : null} - {!loading && !error && hasRuntimeContext && activeSection !== "home" ? ( + {!loading && + !error && + hasRuntimeContext && + activeSection !== "home" ? ( navigateToMemorySection("home")} > 返回总览预演 @@ -986,13 +1486,15 @@ export function MemoryPage({ onNavigate, pageParams }: MemoryPageProps) {
    会话:{runtimeSessionId} 工作区:{runtimeWorkingDir} - {runtimeUserMessage ? 本轮输入:{runtimeUserMessage} : null} + {runtimeUserMessage ? ( + 本轮输入:{runtimeUserMessage} + ) : null}
    {runtimePrefetchState.result ? (
    {formatRuntimeLayerStatusLabel( "规则", @@ -1001,17 +1503,19 @@ export function MemoryPage({ onNavigate, pageParams }: MemoryPageProps) { {formatRuntimeLayerStatusLabel( "工作", null, - Boolean(runtimePrefetchState.result.working_memory_excerpt), + Boolean( + runtimePrefetchState.result.working_memory_excerpt, + ), )} {formatRuntimeLayerStatusLabel( "持久", @@ -1020,7 +1524,7 @@ export function MemoryPage({ onNavigate, pageParams }: MemoryPageProps) { {formatRuntimeLayerStatusLabel( "Team", @@ -1029,7 +1533,7 @@ export function MemoryPage({ onNavigate, pageParams }: MemoryPageProps) { {formatRuntimeLayerStatusLabel( "压缩", @@ -1054,8 +1558,10 @@ export function MemoryPage({ onNavigate, pageParams }: MemoryPageProps) { 会话:{runtimeSessionId} 工作区:{runtimeWorkingDir} - Team shadow: - {runtimeTeamSnapshot ? runtimeTeamSnapshot.repoScope : "未命中本地快照"} + Team Memory: + {runtimeTeamSnapshot + ? runtimeTeamSnapshot.repoScope + : "未命中本地快照"}
    setRuntimeComparisonBaselineSignature(null)} + className={cn( + BUTTON_CLASS_NAME, + "border-slate-300 text-slate-700", + )} + onClick={() => + setRuntimeComparisonBaselineSignature(null) + } > 改用最近基线 } >
    - 当前会话:{currentRuntimeHistoryEntry.sessionId} - 基线时间:{formatRelativeTime(runtimeComparisonBaselineEntry.capturedAt)} - 基线来源:{resolveRuntimeHistorySourceLabel(runtimeComparisonBaselineEntry.source)} + + 当前会话:{currentRuntimeHistoryEntry.sessionId} + + + 基线时间: + {formatRelativeTime( + runtimeComparisonBaselineEntry.capturedAt, + )} + + + 基线来源: + {resolveRuntimeHistorySourceLabel( + runtimeComparisonBaselineEntry.source, + )} +

    基线摘要

    - {resolveRuntimeHistorySummary(runtimeComparisonBaselineEntry)} + {resolveRuntimeHistorySummary( + runtimeComparisonBaselineEntry, + )}

    {runtimeComparisonBaselineEntry.userMessage ? (

    @@ -1133,51 +1658,68 @@ export function MemoryPage({ onNavigate, pageParams }: MemoryPageProps) { 具体变化

    - {runtimeComparisonDiff.layerChanges.rulesDelta !== 0 ? ( + {runtimeComparisonDiff.layerChanges.rulesDelta !== + 0 ? ( - 规则 {runtimeComparisonDiff.layerChanges.rulesDelta > 0 ? "+" : ""} + 规则{" "} + {runtimeComparisonDiff.layerChanges.rulesDelta > 0 + ? "+" + : ""} {runtimeComparisonDiff.layerChanges.rulesDelta} ) : null} - {runtimeComparisonDiff.layerChanges.workingChanged !== "same" ? ( + {runtimeComparisonDiff.layerChanges.workingChanged !== + "same" ? ( 工作 - {runtimeComparisonDiff.layerChanges.workingChanged === "added" + {runtimeComparisonDiff.layerChanges + .workingChanged === "added" ? " 新命中" : " 取消命中"} ) : null} - {runtimeComparisonDiff.layerChanges.durableDelta !== 0 ? ( + {runtimeComparisonDiff.layerChanges.durableDelta !== + 0 ? ( - 持久 {runtimeComparisonDiff.layerChanges.durableDelta > 0 ? "+" : ""} + 持久{" "} + {runtimeComparisonDiff.layerChanges.durableDelta > + 0 + ? "+" + : ""} {runtimeComparisonDiff.layerChanges.durableDelta} ) : null} - {runtimeComparisonDiff.layerChanges.teamDelta !== 0 ? ( + {runtimeComparisonDiff.layerChanges.teamDelta !== + 0 ? ( - Team {runtimeComparisonDiff.layerChanges.teamDelta > 0 ? "+" : ""} + Team{" "} + {runtimeComparisonDiff.layerChanges.teamDelta > 0 + ? "+" + : ""} {runtimeComparisonDiff.layerChanges.teamDelta} ) : null} - {runtimeComparisonDiff.layerChanges.compactionChanged !== "same" ? ( + {runtimeComparisonDiff.layerChanges + .compactionChanged !== "same" ? ( 压缩 - {runtimeComparisonDiff.layerChanges.compactionChanged === "added" + {runtimeComparisonDiff.layerChanges + .compactionChanged === "added" ? " 新命中" : " 取消命中"} @@ -1185,14 +1727,18 @@ export function MemoryPage({ onNavigate, pageParams }: MemoryPageProps) {
    {runtimeComparisonDiff.previewChanges.length > 0 ? (
    - {runtimeComparisonDiff.previewChanges.slice(0, 4).map((change, changeIndex) => ( -

    - {resolveRuntimeHistoryPreviewChangeLabel(change)} -

    - ))} + {runtimeComparisonDiff.previewChanges + .slice(0, 4) + .map((change, changeIndex) => ( +

    + {resolveRuntimeHistoryPreviewChangeLabel( + change, + )} +

    + ))}
    ) : null} @@ -1207,7 +1753,7 @@ export function MemoryPage({ onNavigate, pageParams }: MemoryPageProps) { {hasRuntimeContext @@ -1229,7 +1775,10 @@ export function MemoryPage({ onNavigate, pageParams }: MemoryPageProps) { {runtimePrefetchHistory.length > 0 ? (
    {runtimeHistorySummary.totalEntries > 0 ? (
    @@ -1279,8 +1830,8 @@ export function MemoryPage({ onNavigate, pageParams }: MemoryPageProps) { variant="outline" className="border-slate-200 bg-white text-slate-700" > - 压缩层 {runtimeHistorySummary.layerEntryHits.compaction}/ - {runtimeHistorySummary.totalEntries} + 压缩层 {runtimeHistorySummary.layerEntryHits.compaction} + /{runtimeHistorySummary.totalEntries} 0 ? (
    {runtimeHistorySummary.layerStability.map((layer) => { - const presentation = resolveRuntimeLayerStabilityPresentation( - layer, - runtimeHistorySummary.totalEntries, - ); + const presentation = + resolveRuntimeLayerStabilityPresentation( + layer, + runtimeHistorySummary.totalEntries, + ); return (
    0 ? ( displayedRuntimePrefetchHistory.map((entry, index) => { - const previousEntry = filteredRuntimePrefetchHistory[index + 1]; + const previousEntry = + filteredRuntimePrefetchHistory[index + 1]; const diff = previousEntry ? compareRuntimeMemoryPrefetchHistoryEntries( entry, @@ -1348,7 +1901,7 @@ export function MemoryPage({ onNavigate, pageParams }: MemoryPageProps) { className={cn( "rounded-3xl border p-4 transition", isRuntimeHistoryEntryActive(entry) - ? "border-sky-300 bg-sky-50/70" + ? "border-emerald-200 bg-emerald-50/60" : "border-slate-200 bg-slate-50/70", )} > @@ -1362,21 +1915,24 @@ export function MemoryPage({ onNavigate, pageParams }: MemoryPageProps) { variant="outline" className="border-slate-200 bg-white text-slate-700" > - {resolveRuntimeHistorySourceLabel(entry.source)} + {resolveRuntimeHistorySourceLabel( + entry.source, + )} {isRuntimeHistoryEntryActive(entry) ? ( 当前对照 ) : null} {!isRuntimeHistoryEntryActive(entry) && - runtimeComparisonBaselineEntry?.signature === entry.signature ? ( + runtimeComparisonBaselineEntry?.signature === + entry.signature ? ( 对照基线 @@ -1478,48 +2034,62 @@ export function MemoryPage({ onNavigate, pageParams }: MemoryPageProps) { {diff.layerChanges.rulesDelta !== 0 ? ( - 规则 {diff.layerChanges.rulesDelta > 0 ? "+" : ""} + 规则{" "} + {diff.layerChanges.rulesDelta > 0 + ? "+" + : ""} {diff.layerChanges.rulesDelta} ) : null} - {diff.layerChanges.workingChanged !== "same" ? ( + {diff.layerChanges.workingChanged !== + "same" ? ( 工作 - {diff.layerChanges.workingChanged === "added" + {diff.layerChanges + .workingChanged === "added" ? " 新命中" : " 取消命中"} ) : null} - {diff.layerChanges.durableDelta !== 0 ? ( + {diff.layerChanges.durableDelta !== + 0 ? ( - 持久 {diff.layerChanges.durableDelta > 0 ? "+" : ""} + 持久{" "} + {diff.layerChanges.durableDelta > 0 + ? "+" + : ""} {diff.layerChanges.durableDelta} ) : null} {diff.layerChanges.teamDelta !== 0 ? ( - Team {diff.layerChanges.teamDelta > 0 ? "+" : ""} + Team{" "} + {diff.layerChanges.teamDelta > 0 + ? "+" + : ""} {diff.layerChanges.teamDelta} ) : null} - {diff.layerChanges.compactionChanged !== "same" ? ( + {diff.layerChanges.compactionChanged !== + "same" ? ( 压缩 - {diff.layerChanges.compactionChanged === "added" + {diff.layerChanges + .compactionChanged === "added" ? " 新命中" : " 取消命中"} @@ -1527,14 +2097,18 @@ export function MemoryPage({ onNavigate, pageParams }: MemoryPageProps) {
    {diff.previewChanges.length > 0 ? (
    - {diff.previewChanges.slice(0, 2).map((change, changeIndex) => ( -

    - {resolveRuntimeHistoryPreviewChangeLabel(change)} -

    - ))} + {diff.previewChanges + .slice(0, 2) + .map((change, changeIndex) => ( +

    + {resolveRuntimeHistoryPreviewChangeLabel( + change, + )} +

    + ))}
    ) : null} @@ -1555,15 +2129,19 @@ export function MemoryPage({ onNavigate, pageParams }: MemoryPageProps) { type="button" className={cn( BUTTON_CLASS_NAME, - runtimeComparisonBaselineEntry?.signature === entry.signature - ? "border-amber-300 bg-amber-50 text-amber-700" - : "border-amber-200 text-amber-700", + runtimeComparisonBaselineEntry?.signature === + entry.signature + ? "border-slate-300 bg-slate-100 text-slate-700" + : "border-slate-200 text-slate-700", )} onClick={() => - setRuntimeComparisonBaselineSignature(entry.signature) + setRuntimeComparisonBaselineSignature( + entry.signature, + ) } > - {runtimeComparisonBaselineEntry?.signature === entry.signature + {runtimeComparisonBaselineEntry?.signature === + entry.signature ? "当前基线" : "设为对照基线"} @@ -1572,9 +2150,11 @@ export function MemoryPage({ onNavigate, pageParams }: MemoryPageProps) { type="button" className={cn( BUTTON_CLASS_NAME, - "border-sky-200 text-sky-700", + EMERALD_BUTTON_CLASS_NAME, )} - onClick={() => handleOpenRuntimeHistoryEntry(entry)} + onClick={() => + handleOpenRuntimeHistoryEntry(entry) + } > 切换到这次对照 @@ -1598,8 +2178,83 @@ export function MemoryPage({ onNavigate, pageParams }: MemoryPageProps) { +
    + {memoryScopeCards.map((card) => { + const badge = getMemoryAvailabilityBadge(card.status); + return ( +
    +
    +
    +

    + {card.label} +

    +

    + {card.description} +

    +
    + + {badge.label} + +
    +

    + {card.detail} +

    +

    + {card.helper} +

    +
    + ); + })} +
    +
    + + +
    +
    +

    + What NOT to save +

    +
    + {MEMORY_DO_NOT_SAVE.map((item) => ( +

    + {item} +

    + ))} +
    +
    +
    +

    + 使用记忆前的校验 +

    +
    + {MEMORY_READ_GUARDRAILS.map((item) => ( +

    + {item} +

    + ))} +
    +
    +
    +
    + +
    {layerMetrics.cards.map((card) => ( @@ -1608,51 +2263,99 @@ export function MemoryPage({ onNavigate, pageParams }: MemoryPageProps) { className="rounded-3xl border border-slate-200 bg-slate-50/70 p-4" >

    - {card.title} + {MEMORY_PAGE_LAYER_COPY[card.key].title}

    {card.value} - {card.unit} + + {card.unit} + +

    +

    + {card.available + ? MEMORY_PAGE_LAYER_COPY[card.key].description + : card.key === "rules" + ? "当前还没有加载到有效来源。" + : card.key === "working" + ? "当前还没有会话记忆条目。" + : card.key === "durable" + ? "当前还没有可复用的持久记忆。" + : card.key === "team" + ? "当前仓库还没有 Team Memory 快照。" + : "当前还没有可复用的会话压缩摘要。"}

    -

    {card.description}

    ))}
    - - -
    -
    -

    角色

    -

    - {projectMemory?.characters.length || 0} 个 -

    -
    -
    -

    世界观

    -

    - {projectMemory?.world_building?.description ? "已填写" : "未填写"} -

    -
    -
    -

    大纲

    -

    - {projectMemory?.outline.length || 0} 个节点 -

    -
    -
    -
    ) : null} {!loading && !error && activeSection === "rules" ? ( <> +
    + {sourceBuckets.map((bucket) => { + const badge = getMemoryAvailabilityBadge( + bucket.key === "auto" && + autoIndex?.enabled && + bucket.status === "missing" + ? "exists" + : bucket.status, + ); + const detail = + bucket.key === "auto" + ? autoIndex?.root_dir || + bucket.primaryPath || + bucket.emptyState + : bucket.primaryPath || bucket.emptyState; + const helper = + bucket.key === "auto" + ? `入口文件:${autoIndex?.entrypoint || "MEMORY.md"} · 已索引 ${autoIndex?.items.length || 0} 个主题` + : bucket.status === "loaded" + ? `已加载 ${bucket.loadedCount} 条来源` + : bucket.status === "exists" + ? `已发现 ${bucket.existsCount} 条来源,但当前未注入` + : bucket.emptyState; + return ( +
    +
    +
    +

    + {bucket.label} +

    +

    + {bucket.description} +

    +
    + + {badge.label} + +
    +

    + {bucket.scope} +

    +

    + {detail} +

    +

    + {helper} +

    +
    + ); + })} +
    +
    + +
    {rulesSources?.sources.map((source) => ( @@ -1661,13 +2364,21 @@ export function MemoryPage({ onNavigate, pageParams }: MemoryPageProps) { className="rounded-3xl border border-slate-200 bg-slate-50/70 p-4" >
    - - {source.kind} + + {resolveSourceKindLabel(source.kind)} + + + {source.path} - {source.path}

    - {source.loaded ? "已加载" : "未加载"},共 {source.line_count} 行,导入 {source.import_count} 个。 + {source.loaded + ? "已加载" + : source.exists + ? "已发现但当前未加载" + : "文件不存在"} + ,共 {source.line_count} 行,导入 {source.import_count}{" "} + 个。

    {source.preview ? (
    @@ -1678,35 +2389,17 @@ export function MemoryPage({ onNavigate, pageParams }: MemoryPageProps) {
                       ))}
                     
    - - -
    -

    - 入口文件:{autoIndex?.entrypoint || "MEMORY.md"} / 根目录: - {autoIndex?.root_dir || "未检测到"} -

    -

    - 状态:{autoIndex?.enabled ? "已启用" : "未启用"} / 已索引项目: - {autoIndex?.items.length || 0} -

    - {autoIndex?.preview_lines?.length ? ( -
    -                      {autoIndex.preview_lines.join("\n")}
    -                    
    - ) : null} -
    -
    ) : null} {!loading && !error && activeSection === "working" ? ( <>
    {workingView?.sessions.length ? ( @@ -1721,7 +2414,8 @@ export function MemoryPage({ onNavigate, pageParams }: MemoryPageProps) { {session.session_id}

    - {session.total_entries} 条工作记忆,更新于 {formatRelativeTime(session.updated_at)} + {session.total_entries} 条会话记忆,更新于{" "} + {formatRelativeTime(session.updated_at)}

    @@ -1731,16 +2425,24 @@ export function MemoryPage({ onNavigate, pageParams }: MemoryPageProps) { key={`${session.session_id}:${file.file_type}`} className="rounded-2xl border border-slate-200 bg-white p-3" > -

    {file.file_type}

    -

    {file.path}

    -

    {file.summary}

    +

    + {file.file_type} +

    +

    + {file.path} +

    +

    + {file.summary} +

    ))}
    )) ) : ( -

    当前还没有检测到工作记忆文件。

    +

    + 当前还没有检测到会话记忆文件。 +

    )}
    @@ -1750,8 +2452,45 @@ export function MemoryPage({ onNavigate, pageParams }: MemoryPageProps) { {!loading && !error && activeSection === "durable" ? ( <> +
    + {claudeTypeCards.map((item) => ( +
    +
    +
    +

    + {item.label} +

    +

    + {item.description} +

    +
    + + {item.count} 条 + +
    +

    + {item.useCase} +

    +

    + 当前映射:{item.legacyLabels} +

    +
    + ))} +
    +
    + + - {(Object.keys(CATEGORY_LABELS) as MemoryCategory[]).map((category) => ( - - ))} + {(Object.keys(CATEGORY_LABELS) as MemoryCategory[]).map( + (category) => ( + + ), + )}
    } >
    总条数:{unifiedStats?.total_entries || 0} 记忆库:{unifiedStats?.memory_count || 0} + 过滤口径:当前仍按 Lime 存量分类
    {filteredMemories.length ? ( @@ -1794,8 +2537,22 @@ export function MemoryPage({ onNavigate, pageParams }: MemoryPageProps) {
    - - {CATEGORY_LABELS[memory.category]} + + { + CLAUDE_MEMORY_TYPE_LABELS[ + resolveClaudeMemoryType(memory.category) + ] + } + + + 原分类:{CATEGORY_LABELS[memory.category]}

    {memory.title} @@ -1819,7 +2576,10 @@ export function MemoryPage({ onNavigate, pageParams }: MemoryPageProps) {

    @@ -1838,8 +2600,8 @@ export function MemoryPage({ onNavigate, pageParams }: MemoryPageProps) { {!loading && !error && activeSection === "team" ? (
    {teamSnapshots.length ? ( @@ -1848,7 +2610,9 @@ export function MemoryPage({ onNavigate, pageParams }: MemoryPageProps) { key={snapshot.repoScope} className="rounded-3xl border border-slate-200 bg-slate-50/70 p-4" > -

    {snapshot.repoScope}

    +

    + {snapshot.repoScope} +

    {Object.values(snapshot.entries).map((entry) => (
    - {entry.key} + + {entry.key} + {formatRelativeTime(entry.updatedAt)}
    -

    {entry.content}

    +

    + {entry.content} +

    ))}
    )) ) : ( -

    当前没有本地 Team shadow 快照。

    +

    + 当前没有本地 Team memory 快照。 +

    )}
    @@ -1876,7 +2646,7 @@ export function MemoryPage({ onNavigate, pageParams }: MemoryPageProps) { {!loading && !error && activeSection === "compaction" ? (
    @@ -1892,7 +2662,8 @@ export function MemoryPage({ onNavigate, pageParams }: MemoryPageProps) { {snapshot.session_id}

    - turns={snapshot.turn_count || 0} / {formatRelativeTime(snapshot.created_at)} + turns={snapshot.turn_count || 0} /{" "} + {formatRelativeTime(snapshot.created_at)}

    @@ -1902,7 +2673,9 @@ export function MemoryPage({ onNavigate, pageParams }: MemoryPageProps) { )) ) : ( -

    当前还没有上下文压缩摘要。

    +

    + 当前还没有上下文压缩摘要。 +

    )}
    diff --git a/src/components/memory/memoryLayerMetrics.test.ts b/src/components/memory/memoryLayerMetrics.test.ts index 998845022..6ef21181a 100644 --- a/src/components/memory/memoryLayerMetrics.test.ts +++ b/src/components/memory/memoryLayerMetrics.test.ts @@ -12,13 +12,14 @@ describe("buildLayerMetrics", () => { }); expect(result.cards.find((card) => card.key === "rules")?.available).toBe(true); + expect(result.cards.find((card) => card.key === "rules")?.title).toBe("来源链"); expect(result.cards.find((card) => card.key === "working")?.available).toBe(false); expect(result.cards.find((card) => card.key === "durable")?.available).toBe(false); expect(result.readyLayers).toBe(1); expect(result.totalLayers).toBe(5); }); - it("仅工作记忆层有数据时应返回 1/5 可用", () => { + it("仅会话记忆层有数据时应返回 1/5 可用", () => { const result = buildLayerMetrics({ rulesSourceCount: 0, workingEntryCount: 6, @@ -29,11 +30,12 @@ describe("buildLayerMetrics", () => { expect(result.cards.find((card) => card.key === "rules")?.available).toBe(false); expect(result.cards.find((card) => card.key === "working")?.available).toBe(true); + expect(result.cards.find((card) => card.key === "working")?.title).toBe("会话记忆"); expect(result.cards.find((card) => card.key === "team")?.available).toBe(false); expect(result.readyLayers).toBe(1); }); - it("五层都有数据时应返回 5/5 可用", () => { + it("五个命中层都有数据时应返回 5/5 可用", () => { const result = buildLayerMetrics({ rulesSourceCount: 4, workingEntryCount: 12, @@ -48,7 +50,7 @@ describe("buildLayerMetrics", () => { expect(result.cards.find((card) => card.key === "team")?.available).toBe(true); }); - it("仅 Team 影子层有数据时也应判定为可用", () => { + it("仅 Team Memory 层有数据时也应判定为可用", () => { const result = buildLayerMetrics({ rulesSourceCount: 0, workingEntryCount: 0, @@ -58,11 +60,12 @@ describe("buildLayerMetrics", () => { }); expect(result.cards.find((card) => card.key === "team")?.available).toBe(true); + expect(result.cards.find((card) => card.key === "team")?.title).toBe("Team Memory"); expect(result.cards.find((card) => card.key === "team")?.value).toBe(1); expect(result.readyLayers).toBe(1); }); - it("压缩边界缺失时应给出待完善说明", () => { + it("会话压缩缺失时应给出待完善说明", () => { const result = buildLayerMetrics({ rulesSourceCount: 4, workingEntryCount: 2, @@ -74,7 +77,8 @@ describe("buildLayerMetrics", () => { const compactionCard = result.cards.find((card) => card.key === "compaction"); expect(compactionCard?.value).toBe(0); expect(compactionCard?.available).toBe(false); - expect(compactionCard?.description).toContain("还没有可复用的上下文压缩摘要"); + expect(compactionCard?.title).toBe("会话压缩"); + expect(compactionCard?.description).toContain("当前还没有可复用的会话压缩摘要"); expect(result.readyLayers).toBe(3); }); }); diff --git a/src/components/memory/memoryLayerMetrics.ts b/src/components/memory/memoryLayerMetrics.ts index eab518b19..e25ba7ee3 100644 --- a/src/components/memory/memoryLayerMetrics.ts +++ b/src/components/memory/memoryLayerMetrics.ts @@ -27,58 +27,58 @@ export function buildLayerMetrics( const cards: LayerCard[] = [ { key: "rules", - title: "规则层", + title: "来源链", value: input.rulesSourceCount, unit: "源", available: input.rulesSourceCount > 0, description: input.rulesSourceCount > 0 - ? "运行时已检测到可加载的规则与来源文件。" - : "当前还没有加载到有效规则来源。", + ? "当前已解析到可注入的规则与记忆来源文件。" + : "当前还没有加载到有效来源链文件。", }, { key: "working", - title: "工作记忆", + title: "会话记忆", value: input.workingEntryCount, unit: "条", available: input.workingEntryCount > 0, description: input.workingEntryCount > 0 - ? "会话工作记忆文件已经开始沉淀。" - : "当前还没有工作记忆条目。", + ? "当前会话的 plan、摘录和工作文件正在沉淀。" + : "当前还没有会话记忆条目。", }, { key: "durable", - title: "长期记忆", + title: "持久记忆", value: input.durableEntryCount, unit: "条", available: input.durableEntryCount > 0, description: input.durableEntryCount > 0 - ? "统一记忆库中已有可复用的结构化沉淀。" - : "长期记忆库暂时为空。", + ? "跨会话可复用的结构化沉淀已进入 durable memory 视图。" + : "当前还没有可复用的持久记忆。", }, { key: "team", - title: "Team 影子", + title: "Team Memory", value: input.teamSnapshotCount, unit: "份", available: input.teamSnapshotCount > 0, description: input.teamSnapshotCount > 0 - ? "本地已缓存 repo 作用域 Team 协作影子。" - : "当前仓库还没有 Team shadow 快照。", + ? "repo 作用域的 Team memory 快照可用于补足协作上下文。" + : "当前仓库还没有 Team Memory 快照。", }, { key: "compaction", - title: "压缩边界", + title: "会话压缩", value: input.compactionCount, unit: "次", available: input.compactionCount > 0, description: input.compactionCount > 0 - ? "最近已有上下文压缩摘要可供续接。" - : "还没有可复用的上下文压缩摘要。", + ? "长会话压缩摘要可用于后续续接。" + : "当前还没有可复用的会话压缩摘要。", }, ]; diff --git a/src/components/settings-v2/general/memory/index.test.tsx b/src/components/settings-v2/general/memory/index.test.tsx index fc574d239..2d11b23d8 100644 --- a/src/components/settings-v2/general/memory/index.test.tsx +++ b/src/components/settings-v2/general/memory/index.test.tsx @@ -53,7 +53,7 @@ vi.mock("@/components/memory/memoryLayerMetrics", () => ({ cards: [ { key: "rules", - title: "规则层", + title: "来源链", value: 1, unit: "源", available: true, @@ -61,7 +61,7 @@ vi.mock("@/components/memory/memoryLayerMetrics", () => ({ }, { key: "working", - title: "工作记忆", + title: "会话记忆", value: 0, unit: "条", available: false, @@ -69,7 +69,7 @@ vi.mock("@/components/memory/memoryLayerMetrics", () => ({ }, { key: "durable", - title: "长期记忆", + title: "持久记忆", value: 0, unit: "条", available: false, @@ -77,7 +77,7 @@ vi.mock("@/components/memory/memoryLayerMetrics", () => ({ }, { key: "team", - title: "Team 影子", + title: "Team Memory", value: 0, unit: "份", available: false, @@ -85,7 +85,7 @@ vi.mock("@/components/memory/memoryLayerMetrics", () => ({ }, { key: "compaction", - title: "压缩边界", + title: "会话压缩", value: 0, unit: "次", available: false, @@ -274,7 +274,7 @@ describe("MemorySettings", () => { await flushEffects(); expect(getBodyText()).not.toContain( - "管理用户画像、五层记忆来源与自动记忆入口,让代理在长期使用里更稳定地续接规则、上下文与协作状态。", + "管理用户画像、来源链与自动记忆入口,让代理在长期使用里更稳定地续接规则、会话与协作状态。", ); expect(getBodyText()).not.toContain( "单选,用于帮助代理判断你的知识密度和上下文称呼。", @@ -282,7 +282,7 @@ describe("MemorySettings", () => { const heroTip = await hoverTip("记忆设置说明"); expect(getBodyText()).toContain( - "管理用户画像、五层记忆来源与自动记忆入口,让代理在长期使用里更稳定地续接规则、上下文与协作状态。", + "管理用户画像、来源链与自动记忆入口,让代理在长期使用里更稳定地续接规则、会话与协作状态。", ); await leaveTip(heroTip); @@ -300,11 +300,11 @@ describe("MemorySettings", () => { const text = container.textContent ?? ""; expect(text).toContain("记忆"); - expect(text).toContain("管理用户画像、来源策略与自动记忆入口。"); + expect(text).toContain("管理用户画像、来源链策略与自动记忆入口。"); expect(text).toContain("记忆总开关"); expect(text).toContain("偏好画像"); - expect(text).toContain("五层记忆可用性"); - expect(text).toContain("记忆来源策略"); + expect(text).toContain("记忆命中层可用性"); + expect(text).toContain("来源链策略"); expect(text).toContain("自动记忆(Auto Memory)"); }); diff --git a/src/components/settings-v2/general/memory/index.tsx b/src/components/settings-v2/general/memory/index.tsx index 1b690822c..ff2285aeb 100644 --- a/src/components/settings-v2/general/memory/index.tsx +++ b/src/components/settings-v2/general/memory/index.tsx @@ -370,7 +370,7 @@ export function MemorySettings() { }), ); } catch (error) { - console.error("加载五层记忆状态失败:", error); + console.error("加载记忆命中层状态失败:", error); } finally { setLoadingLayerMetrics(false); } @@ -661,12 +661,12 @@ export function MemorySettings() {

    - 管理用户画像、来源策略与自动记忆入口。 + 管理用户画像、来源链策略与自动记忆入口。

    @@ -675,7 +675,7 @@ export function MemorySettings() { 画像完成度:{profileCompletionPercent}% - 五层可用:{readyLayerLabel} + 命中层可用:{readyLayerLabel} 来源命中:{sourceHitLabel} @@ -813,8 +813,8 @@ export function MemorySettings() {
    ) : ( -

    正在加载五层状态...

    +

    + 正在加载记忆命中层状态... +

    )}
    @@ -1388,8 +1390,8 @@ export function MemorySettings() { {effectiveSources diff --git a/src/hooks/useAppShellLayout.test.ts b/src/hooks/useAppShellLayout.test.ts index 94a53dbfe..bf09544bc 100644 --- a/src/hooks/useAppShellLayout.test.ts +++ b/src/hooks/useAppShellLayout.test.ts @@ -62,6 +62,20 @@ describe("getAppShellLayoutState", () => { }); }); + it("memory 页应显示 sidebar 且不追加 agent 间距", () => { + expect( + getAppShellLayoutState({ + currentPage: "memory", + pageParams: {}, + agentHasMessages: false, + }), + ).toEqual({ + shouldHideSidebarForAgent: false, + shouldShowAppSidebar: true, + shouldAddMainContentGap: false, + }); + }); + it("skills 页应显示 sidebar 且不追加 agent 间距", () => { expect( getAppShellLayoutState({ diff --git a/src/hooks/useAppShellLayout.ts b/src/hooks/useAppShellLayout.ts index d7c079261..1a70bfdba 100644 --- a/src/hooks/useAppShellLayout.ts +++ b/src/hooks/useAppShellLayout.ts @@ -20,7 +20,6 @@ export function getAppShellLayoutState(params: { const shouldShowAppSidebar = params.currentPage !== "settings" && - params.currentPage !== "memory" && params.currentPage !== "plugins" && params.currentPage !== "resources" && !shouldHideSidebarForAgent; diff --git a/src/lib/api/agentRuntime/commandManifest.generated.ts b/src/lib/api/agentRuntime/commandManifest.generated.ts index d1e6d58ca..1b9943baf 100644 --- a/src/lib/api/agentRuntime/commandManifest.generated.ts +++ b/src/lib/api/agentRuntime/commandManifest.generated.ts @@ -57,7 +57,7 @@ export const AGENT_RUNTIME_COMMAND_DESCRIPTORS = [ requestType: "AgentRuntimeSubmitTurnRequest", responseType: "void", lifecycle: "current", - mockStrategy: "default-mock", + mockStrategy: "bridge-only", docsSection: "agent-runtime.thread", }, { @@ -67,7 +67,7 @@ export const AGENT_RUNTIME_COMMAND_DESCRIPTORS = [ requestType: "AgentRuntimeInterruptTurnRequest", responseType: "boolean", lifecycle: "current", - mockStrategy: "default-mock", + mockStrategy: "bridge-only", docsSection: "agent-runtime.thread", }, { @@ -137,7 +137,7 @@ export const AGENT_RUNTIME_COMMAND_DESCRIPTORS = [ requestType: "AgentRuntimeRespondActionRequest", responseType: "void", lifecycle: "current", - mockStrategy: "default-mock", + mockStrategy: "bridge-only", docsSection: "agent-runtime.thread", }, { @@ -147,7 +147,7 @@ export const AGENT_RUNTIME_COMMAND_DESCRIPTORS = [ requestType: "{ workspaceId: string; name?: string; executionStrategy?: AsterExecutionStrategy }", responseType: "string", lifecycle: "current", - mockStrategy: "default-mock", + mockStrategy: "bridge-only", docsSection: "agent-runtime.session", }, { @@ -157,7 +157,7 @@ export const AGENT_RUNTIME_COMMAND_DESCRIPTORS = [ requestType: "void", responseType: "AsterSessionInfo[]", lifecycle: "current", - mockStrategy: "default-mock", + mockStrategy: "bridge-only", docsSection: "agent-runtime.session", }, { @@ -167,7 +167,7 @@ export const AGENT_RUNTIME_COMMAND_DESCRIPTORS = [ requestType: "{ sessionId: string }", responseType: "AsterSessionDetail", lifecycle: "current", - mockStrategy: "default-mock", + mockStrategy: "bridge-only", docsSection: "agent-runtime.session", }, { @@ -177,7 +177,7 @@ export const AGENT_RUNTIME_COMMAND_DESCRIPTORS = [ requestType: "AgentRuntimeUpdateSessionRequest", responseType: "void", lifecycle: "current", - mockStrategy: "default-mock", + mockStrategy: "bridge-only", docsSection: "agent-runtime.session", }, { @@ -187,7 +187,7 @@ export const AGENT_RUNTIME_COMMAND_DESCRIPTORS = [ requestType: "{ sessionId: string }", responseType: "void", lifecycle: "current", - mockStrategy: "default-mock", + mockStrategy: "bridge-only", docsSection: "agent-runtime.session", }, { @@ -257,7 +257,7 @@ export const AGENT_RUNTIME_COMMAND_DESCRIPTORS = [ requestType: "AgentRuntimeToolInventoryRequest", responseType: "AgentRuntimeToolInventory", lifecycle: "current", - mockStrategy: "default-mock", + mockStrategy: "bridge-only", docsSection: "agent-runtime.inventory", }, { @@ -267,7 +267,7 @@ export const AGENT_RUNTIME_COMMAND_DESCRIPTORS = [ requestType: "AgentRuntimeSpawnSubagentRequest", responseType: "AgentRuntimeSpawnSubagentResponse", lifecycle: "current", - mockStrategy: "default-mock", + mockStrategy: "bridge-only", docsSection: "agent-runtime.subagent", }, { @@ -277,7 +277,7 @@ export const AGENT_RUNTIME_COMMAND_DESCRIPTORS = [ requestType: "AgentRuntimeSendSubagentInputRequest", responseType: "AgentRuntimeSendSubagentInputResponse", lifecycle: "current", - mockStrategy: "default-mock", + mockStrategy: "bridge-only", docsSection: "agent-runtime.subagent", }, { @@ -287,7 +287,7 @@ export const AGENT_RUNTIME_COMMAND_DESCRIPTORS = [ requestType: "AgentRuntimeWaitSubagentsRequest", responseType: "AgentRuntimeWaitSubagentsResponse", lifecycle: "current", - mockStrategy: "default-mock", + mockStrategy: "bridge-only", docsSection: "agent-runtime.subagent", }, { @@ -297,7 +297,7 @@ export const AGENT_RUNTIME_COMMAND_DESCRIPTORS = [ requestType: "AgentRuntimeResumeSubagentRequest", responseType: "AgentRuntimeResumeSubagentResponse", lifecycle: "current", - mockStrategy: "default-mock", + mockStrategy: "bridge-only", docsSection: "agent-runtime.subagent", }, { @@ -307,7 +307,7 @@ export const AGENT_RUNTIME_COMMAND_DESCRIPTORS = [ requestType: "AgentRuntimeCloseSubagentRequest", responseType: "AgentRuntimeCloseSubagentResponse", lifecycle: "current", - mockStrategy: "default-mock", + mockStrategy: "bridge-only", docsSection: "agent-runtime.subagent", }, ] as const satisfies readonly AgentRuntimeCommandDescriptor[]; diff --git a/src/lib/api/agentRuntime/types.ts b/src/lib/api/agentRuntime/types.ts index 890a8746d..5c2f7cbe1 100644 --- a/src/lib/api/agentRuntime/types.ts +++ b/src/lib/api/agentRuntime/types.ts @@ -947,6 +947,12 @@ export type AgentRuntimeExtensionSourceKind = | "mcp_bridge" | "runtime_extension"; +export type AgentRuntimeToolInventoryRuntimeSourceKind = + | "registry_native" + | "current_surface" + | "runtime_extension" + | "mcp"; + export interface AgentRuntimeToolInventoryRequest { caller?: string; workbench?: boolean; @@ -1022,6 +1028,26 @@ export interface AgentRuntimeToolInventoryExtensionToolEntry { visible_in_context: boolean; } +export interface AgentRuntimeToolInventoryRuntimeEntry { + name: string; + description: string; + source_kind: AgentRuntimeToolInventoryRuntimeSourceKind; + source_label?: string; + status?: string; + catalog_entry_name?: string; + catalog_source?: AgentToolSourceKind; + catalog_lifecycle?: AgentToolLifecycle; + catalog_permission_plane?: AgentToolPermissionPlane; + catalog_workspace_default_allow?: boolean; + deferred_loading: boolean; + always_visible: boolean; + allowed_callers: string[]; + tags: string[]; + input_examples_count: number; + caller_allowed: boolean; + visible_in_context: boolean; +} + export interface AgentRuntimeToolInventoryMcpEntry { server_name: string; name: string; @@ -1041,6 +1067,8 @@ export interface AgentRuntimeToolInventoryCounts { catalog_compat_total: number; catalog_deprecated_total: number; default_allowed_total: number; + runtime_total?: number; + runtime_visible_total?: number; registry_total: number; registry_visible_total: number; registry_catalog_unmapped_total: number; @@ -1066,6 +1094,7 @@ export interface AgentRuntimeToolInventory { counts: AgentRuntimeToolInventoryCounts; catalog_tools: AgentRuntimeToolInventoryCatalogEntry[]; registry_tools: AgentRuntimeToolInventoryRegistryEntry[]; + runtime_tools?: AgentRuntimeToolInventoryRuntimeEntry[]; extension_surfaces: AgentRuntimeToolInventoryExtensionSurfaceEntry[]; extension_tools: AgentRuntimeToolInventoryExtensionToolEntry[]; mcp_tools: AgentRuntimeToolInventoryMcpEntry[]; diff --git a/src/lib/base-setup/compat/serviceSkillCatalogProjection.ts b/src/lib/base-setup/compat/serviceSkillCatalogProjection.ts new file mode 100644 index 000000000..6c7a64255 --- /dev/null +++ b/src/lib/base-setup/compat/serviceSkillCatalogProjection.ts @@ -0,0 +1,234 @@ +import type { + ServiceSkillBundleSummary, + ServiceSkillCatalog, + ServiceSkillExecutionLocation, + ServiceSkillItem, + ServiceSkillSource, + ServiceSkillType, +} from "@/lib/api/serviceSkills"; +import type { + BaseSetupArtifactProfile, + BaseSetupBindingProfile, + BaseSetupBundleRef, + BaseSetupCatalogProjection, + BaseSetupPackage, + BaseSetupPolicyProfile, + BaseSetupProjectionIndex, + BaseSetupScorecardProfile, + BaseSetupSlotProfile, +} from "../types"; + +export interface CompileServiceSkillCatalogProjectionOptions { + tenantId?: string; + syncedAt?: string; +} + +function cloneJsonValue(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +function mapBundleSourceToServiceSkillSource( + bundleRef: BaseSetupBundleRef, +): ServiceSkillSource { + return bundleRef.source === "local" ? "local_custom" : "cloud_catalog"; +} + +function inferSkillType( + projection: BaseSetupCatalogProjection, + bindingProfile: BaseSetupBindingProfile, +): ServiceSkillType { + if (projection.skillType) { + return projection.skillType; + } + if (projection.siteCapabilityBinding || bindingProfile.bindingFamily === "browser_assist") { + return "site"; + } + return "service"; +} + +function inferExecutionLocation( + projection: BaseSetupCatalogProjection, + bindingProfile: BaseSetupBindingProfile, +): ServiceSkillExecutionLocation { + if (bindingProfile.executionLocation) { + return bindingProfile.executionLocation; + } + if (bindingProfile.bindingFamily === "cloud_scene") { + return "cloud_required"; + } + return "client_default"; +} + +function buildBundleSummary( + pkg: BaseSetupPackage, + projection: BaseSetupCatalogProjection, + bundleRef: BaseSetupBundleRef, +): ServiceSkillBundleSummary { + return { + name: bundleRef.id, + description: projection.summary, + compatibility: pkg.compatibility.minAppVersion, + metadata: { + Lime_base_setup_package_id: pkg.id, + Lime_base_setup_package_version: pkg.version, + Lime_projection_id: projection.id, + Lime_target_catalog: projection.targetCatalog, + Lime_artifact_profile_ref: projection.artifactProfileRef, + Lime_scorecard_profile_ref: projection.scorecardProfileRef, + Lime_policy_profile_ref: projection.policyProfileRef, + Lime_composition_blueprint_ref: projection.compositionBlueprintRef ?? "", + }, + resourceSummary: { + hasScripts: false, + hasReferences: false, + hasAssets: false, + }, + standardCompliance: { + isStandard: true, + }, + }; +} + +function createServiceSkillItem(params: { + pkg: BaseSetupPackage; + projection: BaseSetupCatalogProjection; + bundleRef: BaseSetupBundleRef; + slotProfile: BaseSetupSlotProfile; + bindingProfile: BaseSetupBindingProfile; + artifactProfile: BaseSetupArtifactProfile; + policyProfile: BaseSetupPolicyProfile; +}): ServiceSkillItem { + const { + pkg, + projection, + bundleRef, + slotProfile, + bindingProfile, + artifactProfile, + policyProfile, + } = params; + + return { + id: projection.id, + skillKey: projection.skillKey ?? projection.entryKey, + skillType: inferSkillType(projection, bindingProfile), + title: projection.title, + summary: projection.summary, + entryHint: projection.entryHint, + aliases: projection.aliases ? [...projection.aliases] : undefined, + category: projection.category, + outputHint: projection.outputHint, + triggerHints: projection.triggerHints ? [...projection.triggerHints] : undefined, + source: projection.source ?? mapBundleSourceToServiceSkillSource(bundleRef), + runnerType: projection.sceneBinding ? "managed" : bindingProfile.runnerType ?? "instant", + defaultExecutorBinding: bindingProfile.bindingFamily, + executionLocation: inferExecutionLocation(projection, bindingProfile), + defaultArtifactKind: artifactProfile.defaultArtifactKind, + readinessRequirements: projection.readinessRequirements + ? cloneJsonValue(projection.readinessRequirements) + : undefined, + usageGuidelines: projection.usageGuidelines + ? [...projection.usageGuidelines] + : undefined, + setupRequirements: projection.setupRequirements + ? [...projection.setupRequirements] + : undefined, + examples: projection.examples ? [...projection.examples] : undefined, + outputDestination: + artifactProfile.outputDestination ?? undefined, + siteCapabilityBinding: projection.siteCapabilityBinding + ? cloneJsonValue(projection.siteCapabilityBinding) + : undefined, + sceneBinding: projection.sceneBinding + ? cloneJsonValue(projection.sceneBinding) + : undefined, + slotSchema: slotProfile.slots.map((slot) => cloneJsonValue(slot)), + surfaceScopes: policyProfile.surfaceScopes + ? [...policyProfile.surfaceScopes] + : undefined, + promptTemplateKey: projection.promptTemplateKey, + themeTarget: projection.themeTarget, + skillBundle: buildBundleSummary(pkg, projection, bundleRef), + version: projection.version ?? pkg.version, + }; +} + +function buildProjectionIndex( + projections: BaseSetupCatalogProjection[], +): BaseSetupProjectionIndex { + return { + artifactProfileRefsByProjectionId: Object.fromEntries( + projections.map((projection) => [projection.id, projection.artifactProfileRef]), + ), + scorecardProfileRefsByProjectionId: Object.fromEntries( + projections.map((projection) => [projection.id, projection.scorecardProfileRef]), + ), + policyProfileRefsByProjectionId: Object.fromEntries( + projections.map((projection) => [projection.id, projection.policyProfileRef]), + ), + compositionBlueprintRefsByProjectionId: Object.fromEntries( + projections + .filter((projection) => projection.compositionBlueprintRef) + .map((projection) => [ + projection.id, + projection.compositionBlueprintRef as string, + ]), + ), + }; +} + +export function compileServiceSkillCatalogProjection( + pkg: BaseSetupPackage, + options: CompileServiceSkillCatalogProjectionOptions = {}, +): { + catalog: ServiceSkillCatalog; + projectionIndex: BaseSetupProjectionIndex; +} { + const bundleRefs = new Map(pkg.bundleRefs.map((entry) => [entry.id, entry] as const)); + const slotProfiles = new Map(pkg.slotProfiles.map((entry) => [entry.id, entry] as const)); + const bindingProfiles = new Map( + pkg.bindingProfiles.map((entry) => [entry.id, entry] as const), + ); + const artifactProfiles = new Map( + pkg.artifactProfiles.map((entry) => [entry.id, entry] as const), + ); + const policyProfiles = new Map( + pkg.policyProfiles.map((entry) => [entry.id, entry] as const), + ); + + const projections = pkg.catalogProjections.filter( + (projection) => projection.targetCatalog === "service_skill_catalog", + ); + + const items = projections.map((projection) => { + const bundleRef = bundleRefs.get(projection.bundleRefId); + const slotProfile = slotProfiles.get(projection.slotProfileRef); + const bindingProfile = bindingProfiles.get(projection.bindingProfileRef); + const artifactProfile = artifactProfiles.get(projection.artifactProfileRef); + const policyProfile = policyProfiles.get(projection.policyProfileRef); + + if (!bundleRef || !slotProfile || !bindingProfile || !artifactProfile || !policyProfile) { + throw new Error(`无法编译 projection ${projection.id}:存在未解析的引用`); + } + + return createServiceSkillItem({ + pkg, + projection, + bundleRef, + slotProfile, + bindingProfile, + artifactProfile, + policyProfile, + }); + }); + + return { + catalog: { + version: pkg.version, + tenantId: options.tenantId ?? "base-setup", + syncedAt: options.syncedAt ?? new Date().toISOString(), + items, + }, + projectionIndex: buildProjectionIndex(projections), + }; +} diff --git a/src/lib/base-setup/compiler.test.ts b/src/lib/base-setup/compiler.test.ts new file mode 100644 index 000000000..7a3218a4b --- /dev/null +++ b/src/lib/base-setup/compiler.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from "vitest"; +import type { BaseSetupPackage } from "./types"; +import { compileBaseSetupPackage } from "./compiler"; + +function createBaseSetupPackage(): BaseSetupPackage { + return { + id: "service-skill-starter-pack", + version: "0.2.0", + title: "Service Skill Starter Pack", + summary: "给现有工作区提供 compat ServiceSkillCatalog projection。", + bundleRefs: [ + { + id: "article-export-bundle", + source: "builtin", + pathOrUri: "bundles/article-export", + kind: "skill_bundle", + }, + ], + catalogProjections: [ + { + id: "article-export-skill", + targetCatalog: "service_skill_catalog", + entryKey: "x-article-export", + skillKey: "x-article-export", + title: "文章转存", + summary: "把页面转成项目资料包。", + category: "采集", + outputHint: "Markdown bundle", + entryHint: "输入 URL 开始转存", + bundleRefId: "article-export-bundle", + slotProfileRef: "article-export-input", + bindingProfileRef: "browser-export", + artifactProfileRef: "article-export-bundle-profile", + scorecardProfileRef: "article-export-scorecard", + policyProfileRef: "default-exposure", + aliases: ["x转存"], + triggerHints: ["转存页面", "文章导出"], + }, + ], + slotProfiles: [ + { + id: "article-export-input", + slots: [ + { + key: "url", + label: "页面链接", + type: "url", + required: true, + placeholder: "https://example.com", + }, + ], + }, + ], + bindingProfiles: [ + { + id: "browser-export", + bindingFamily: "browser_assist", + runnerType: "instant", + }, + ], + artifactProfiles: [ + { + id: "article-export-bundle-profile", + deliveryContract: "artifact_bundle", + requiredParts: ["index.md", "meta.json"], + viewerKind: "artifact_bundle", + defaultArtifactKind: "report", + outputDestination: "project_resource", + }, + ], + scorecardProfiles: [ + { + id: "article-export-scorecard", + metrics: ["success_rate"], + }, + ], + policyProfiles: [ + { + id: "default-exposure", + enabled: true, + surfaceScopes: ["home", "workspace"], + }, + ], + compatibility: { + minAppVersion: "1.11.0", + requiredKernelCapabilities: ["browser_assist", "artifact_viewer"], + seededFallback: true, + compatCatalogProjection: true, + }, + }; +} + +describe("compileBaseSetupPackage", () => { + it("把基础设置包编译成当前 ServiceSkillCatalog projection", () => { + const compiled = compileBaseSetupPackage(createBaseSetupPackage(), { + tenantId: "tenant-a", + syncedAt: "2026-04-15T00:00:00.000Z", + }); + + expect(compiled.packageId).toBe("service-skill-starter-pack"); + expect(compiled.serviceSkillCatalogProjection.tenantId).toBe("tenant-a"); + expect(compiled.serviceSkillCatalogProjection.items).toHaveLength(1); + expect(compiled.serviceSkillCatalogProjection.items[0]).toEqual( + expect.objectContaining({ + id: "article-export-skill", + skillKey: "x-article-export", + title: "文章转存", + defaultExecutorBinding: "browser_assist", + source: "cloud_catalog", + outputDestination: "project_resource", + }), + ); + expect( + compiled.projectionIndex.artifactProfileRefsByProjectionId[ + "article-export-skill" + ], + ).toBe("article-export-bundle-profile"); + }); + + it("在包校验失败时抛出错误", () => { + const pkg = createBaseSetupPackage(); + pkg.catalogProjections[0].bundleRefId = "missing-bundle"; + + expect(() => compileBaseSetupPackage(pkg)).toThrow( + /Base Setup Package 校验失败/, + ); + }); +}); diff --git a/src/lib/base-setup/compiler.ts b/src/lib/base-setup/compiler.ts new file mode 100644 index 000000000..4cdcd3dbd --- /dev/null +++ b/src/lib/base-setup/compiler.ts @@ -0,0 +1,39 @@ +import { compileServiceSkillCatalogProjection } from "./compat/serviceSkillCatalogProjection"; +import type { + BaseSetupPackage, + CompiledBaseSetupPackage, +} from "./types"; +import { validateBaseSetupPackage } from "./validator"; + +export interface CompileBaseSetupPackageOptions { + tenantId?: string; + syncedAt?: string; +} + +function formatValidationIssues(pkg: BaseSetupPackage): string { + const result = validateBaseSetupPackage(pkg); + return result.issues + .filter((issue) => issue.severity === "error") + .map((issue) => `${issue.code}${issue.path ? `(${issue.path})` : ""}: ${issue.message}`) + .join("; "); +} + +export function compileBaseSetupPackage( + pkg: BaseSetupPackage, + options: CompileBaseSetupPackageOptions = {}, +): CompiledBaseSetupPackage { + const validation = validateBaseSetupPackage(pkg); + if (!validation.ok) { + throw new Error( + `Base Setup Package 校验失败:${formatValidationIssues(pkg) || "unknown error"}`, + ); + } + + const compiled = compileServiceSkillCatalogProjection(pkg, options); + return { + packageId: pkg.id, + packageVersion: pkg.version, + serviceSkillCatalogProjection: compiled.catalog, + projectionIndex: compiled.projectionIndex, + }; +} diff --git a/src/lib/base-setup/rolloutGate.test.ts b/src/lib/base-setup/rolloutGate.test.ts new file mode 100644 index 000000000..19fbbbe8d --- /dev/null +++ b/src/lib/base-setup/rolloutGate.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from "vitest"; +import type { BaseSetupPackage } from "./types"; +import { evaluateBaseSetupRollout } from "./rolloutGate"; + +function createBaseSetupPackage(): BaseSetupPackage { + return { + id: "runtime-gated-pack", + version: "0.1.0", + title: "Runtime Gated Pack", + summary: "验证 rollout gate 的最小包。", + bundleRefs: [ + { + id: "voice-bundle", + source: "remote", + pathOrUri: "lime://bundles/voice", + kind: "skill_bundle", + }, + ], + catalogProjections: [ + { + id: "voice-runtime", + targetCatalog: "service_skill_catalog", + entryKey: "voice-runtime", + title: "配音", + summary: "云端配音运行。", + category: "媒体", + outputHint: "配音结果", + bundleRefId: "voice-bundle", + slotProfileRef: "voice-input", + bindingProfileRef: "cloud-voice", + artifactProfileRef: "voice-artifact", + scorecardProfileRef: "voice-scorecard", + policyProfileRef: "voice-policy", + }, + ], + slotProfiles: [ + { + id: "voice-input", + slots: [ + { + key: "script", + label: "脚本", + type: "textarea", + required: true, + placeholder: "输入要配音的脚本", + }, + ], + }, + ], + bindingProfiles: [ + { + id: "cloud-voice", + bindingFamily: "cloud_scene", + runnerType: "managed", + }, + ], + artifactProfiles: [ + { + id: "voice-artifact", + deliveryContract: "artifact_bundle", + requiredParts: ["audio.mp3"], + viewerKind: "artifact_bundle", + }, + ], + scorecardProfiles: [ + { + id: "voice-scorecard", + metrics: ["acceptance_rate"], + }, + ], + policyProfiles: [ + { + id: "voice-policy", + }, + ], + compatibility: { + minAppVersion: "1.11.0", + requiredKernelCapabilities: ["cloud_scene"], + seededFallback: true, + }, + }; +} + +describe("evaluateBaseSetupRollout", () => { + it("在宿主满足要求时返回 accept", () => { + const result = evaluateBaseSetupRollout(createBaseSetupPackage(), { + appVersion: "1.11.0", + seededFallbackAvailable: true, + }); + + expect(result).toEqual({ decision: "accept" }); + }); + + it("在结构错误且有兜底时返回 fallback_seeded", () => { + const pkg = createBaseSetupPackage(); + pkg.catalogProjections[0].slotProfileRef = "missing-slot"; + + const result = evaluateBaseSetupRollout(pkg, { + appVersion: "1.11.0", + seededFallbackAvailable: true, + }); + + expect(result.decision).toBe("fallback_seeded"); + }); + + it("在版本不满足时返回 reject_upgrade_required", () => { + const result = evaluateBaseSetupRollout(createBaseSetupPackage(), { + appVersion: "1.10.0", + seededFallbackAvailable: true, + }); + + expect(result.decision).toBe("reject_upgrade_required"); + }); +}); diff --git a/src/lib/base-setup/rolloutGate.ts b/src/lib/base-setup/rolloutGate.ts new file mode 100644 index 000000000..58323b96d --- /dev/null +++ b/src/lib/base-setup/rolloutGate.ts @@ -0,0 +1,99 @@ +import { + BASE_SETUP_ALLOWED_BINDING_FAMILIES, + BASE_SETUP_ALLOWED_KERNEL_CAPABILITIES, + BASE_SETUP_VIEWER_KINDS, + type BaseSetupPackage, + type BaseSetupRolloutInput, + type BaseSetupRolloutResult, +} from "./types"; +import { validateBaseSetupPackage } from "./validator"; + +function compareVersions(left: string, right: string): number { + return left.localeCompare(right, undefined, { + numeric: true, + sensitivity: "base", + }); +} + +export function evaluateBaseSetupRollout( + pkg: BaseSetupPackage, + input: BaseSetupRolloutInput, +): BaseSetupRolloutResult { + const validation = input.validationResult ?? validateBaseSetupPackage(pkg); + const hasL2Error = validation.issues.some( + (issue) => issue.level === "L2" && issue.severity === "error", + ); + if (hasL2Error) { + return { + decision: "reject_upgrade_required", + reason: "Base Setup Package 触发了宿主边界错误,需要升级主 App。", + }; + } + + const hasValidationError = validation.issues.some( + (issue) => issue.severity === "error", + ); + if (hasValidationError) { + return input.seededFallbackAvailable + ? { + decision: "fallback_seeded", + reason: "Base Setup Package 结构或引用校验未通过,退回 seeded fallback。", + } + : { + decision: "reject_invalid_package", + reason: "Base Setup Package 结构或引用校验未通过,且当前没有 seeded fallback。", + }; + } + + if (compareVersions(input.appVersion, pkg.compatibility.minAppVersion) < 0) { + return { + decision: "reject_upgrade_required", + reason: `当前 App 版本 ${input.appVersion} 低于包要求的最小版本 ${pkg.compatibility.minAppVersion}。`, + }; + } + + const supportedBindingFamilies = + input.supportedBindingFamilies ?? BASE_SETUP_ALLOWED_BINDING_FAMILIES; + const supportedBindingFamilySet = new Set(supportedBindingFamilies); + const unsupportedBindingFamily = pkg.bindingProfiles.find( + (profile) => !supportedBindingFamilySet.has(profile.bindingFamily), + ); + if (unsupportedBindingFamily) { + return { + decision: "reject_upgrade_required", + reason: `当前宿主不支持 binding family:${unsupportedBindingFamily.bindingFamily}。`, + }; + } + + const supportedViewerKinds = + input.supportedViewerKinds ?? BASE_SETUP_VIEWER_KINDS; + const supportedViewerKindSet = new Set(supportedViewerKinds); + const unsupportedViewer = pkg.artifactProfiles.find( + (profile) => !supportedViewerKindSet.has(profile.viewerKind), + ); + if (unsupportedViewer) { + return { + decision: "reject_upgrade_required", + reason: `当前宿主不支持 viewer kind:${unsupportedViewer.viewerKind}。`, + }; + } + + const supportedKernelCapabilities = + input.supportedKernelCapabilities ?? BASE_SETUP_ALLOWED_KERNEL_CAPABILITIES; + const supportedKernelCapabilitySet = new Set( + supportedKernelCapabilities, + ); + const unsupportedCapability = pkg.compatibility.requiredKernelCapabilities.find( + (capability) => !supportedKernelCapabilitySet.has(capability), + ); + if (unsupportedCapability) { + return { + decision: "reject_upgrade_required", + reason: `当前宿主缺少 kernel capability:${unsupportedCapability}。`, + }; + } + + return { + decision: "accept", + }; +} diff --git a/src/lib/base-setup/types.ts b/src/lib/base-setup/types.ts new file mode 100644 index 000000000..46ea0c387 --- /dev/null +++ b/src/lib/base-setup/types.ts @@ -0,0 +1,234 @@ +import type { + ServiceSkillArtifactKind, + ServiceSkillCatalog, + ServiceSkillExecutionLocation, + ServiceSkillExecutorBinding, + ServiceSkillPromptTemplateKey, + ServiceSkillReadinessRequirements, + ServiceSkillRunnerType, + ServiceSkillSceneBinding, + ServiceSkillSiteCapabilityBinding, + ServiceSkillSlotDefinition, + ServiceSkillSource, + ServiceSkillSurfaceScope, + ServiceSkillType, +} from "@/lib/api/serviceSkills"; + +export const BASE_SETUP_TARGET_CATALOGS = [ + "skill_catalog", + "service_skill_catalog", + "scene_catalog", + "command_catalog", +] as const; + +export const BASE_SETUP_VIEWER_KINDS = [ + "artifact_bundle", + "document", + "table_report", +] as const; + +export const BASE_SETUP_DELIVERY_CONTRACTS = [ + "artifact_bundle", + "project_pack", + "table_report", +] as const; + +export const BASE_SETUP_POLICY_ROLLOUT_STAGES = [ + "seeded", + "bootstrap", + "limited", + "general", +] as const; + +export const BASE_SETUP_ALLOWED_BINDING_FAMILIES = [ + "agent_turn", + "browser_assist", + "automation_job", + "cloud_scene", + "native_skill", +] as const; + +export const BASE_SETUP_ALLOWED_KERNEL_CAPABILITIES = [ + ...BASE_SETUP_ALLOWED_BINDING_FAMILIES, + "workspace_storage", + "artifact_viewer", + "timeline", +] as const; + +export type BaseSetupTargetCatalog = + (typeof BASE_SETUP_TARGET_CATALOGS)[number]; +export type BaseSetupViewerKind = (typeof BASE_SETUP_VIEWER_KINDS)[number]; +export type BaseSetupDeliveryContract = + (typeof BASE_SETUP_DELIVERY_CONTRACTS)[number]; +export type BaseSetupPolicyRolloutStage = + (typeof BASE_SETUP_POLICY_ROLLOUT_STAGES)[number]; +export type BaseSetupAllowedBindingFamily = + (typeof BASE_SETUP_ALLOWED_BINDING_FAMILIES)[number]; +export type BaseSetupAllowedKernelCapability = + (typeof BASE_SETUP_ALLOWED_KERNEL_CAPABILITIES)[number]; + +export interface BaseSetupBundleRef { + id: string; + source: "builtin" | "remote" | "local"; + pathOrUri: string; + kind: "skill_bundle" | "local_bundle"; + versionConstraint?: string; +} + +export interface BaseSetupSlotProfile { + id: string; + slots: ServiceSkillSlotDefinition[]; +} + +export interface BaseSetupBindingProfile { + id: string; + bindingFamily: ServiceSkillExecutorBinding; + runnerType?: ServiceSkillRunnerType; + executionLocation?: ServiceSkillExecutionLocation; + capabilityRefs?: string[]; + timeoutSecs?: number; + retryLimit?: number; +} + +export interface BaseSetupArtifactProfile { + id: string; + deliveryContract: BaseSetupDeliveryContract; + requiredParts: string[]; + viewerKind: BaseSetupViewerKind; + defaultArtifactKind?: ServiceSkillArtifactKind; + outputDestination?: string; +} + +export interface BaseSetupScorecardProfile { + id: string; + metrics: string[]; + failureSignals?: string[]; +} + +export interface BaseSetupPolicyProfile { + id: string; + enabled?: boolean; + surfaceScopes?: ServiceSkillSurfaceScope[]; + rolloutStage?: BaseSetupPolicyRolloutStage; +} + +export interface BaseSetupCompositionDeliveryContract { + requiredParts: string[]; +} + +export interface BaseSetupCompositionStep { + id: string; + bindingProfileRef?: string; +} + +export interface BaseSetupCompositionBlueprint { + id: string; + artifactProfileRef?: string; + deliveryContract?: BaseSetupCompositionDeliveryContract; + steps?: BaseSetupCompositionStep[]; +} + +export interface BaseSetupCatalogProjection { + id: string; + targetCatalog: BaseSetupTargetCatalog; + entryKey: string; + title: string; + summary: string; + category: string; + outputHint: string; + bundleRefId: string; + slotProfileRef: string; + bindingProfileRef: string; + artifactProfileRef: string; + scorecardProfileRef: string; + policyProfileRef: string; + compositionBlueprintRef?: string; + skillKey?: string; + entryHint?: string; + aliases?: string[]; + triggerHints?: string[]; + source?: ServiceSkillSource; + skillType?: ServiceSkillType; + readinessRequirements?: ServiceSkillReadinessRequirements; + usageGuidelines?: string[]; + setupRequirements?: string[]; + examples?: string[]; + promptTemplateKey?: ServiceSkillPromptTemplateKey; + themeTarget?: string; + siteCapabilityBinding?: ServiceSkillSiteCapabilityBinding; + sceneBinding?: ServiceSkillSceneBinding; + version?: string; +} + +export interface BaseSetupCompatibility { + minAppVersion: string; + requiredKernelCapabilities: string[]; + seededFallback?: boolean; + compatCatalogProjection?: boolean; +} + +export interface BaseSetupPackage { + id: string; + version: string; + title: string; + summary: string; + bundleRefs: BaseSetupBundleRef[]; + catalogProjections: BaseSetupCatalogProjection[]; + slotProfiles: BaseSetupSlotProfile[]; + bindingProfiles: BaseSetupBindingProfile[]; + compositionBlueprints?: BaseSetupCompositionBlueprint[]; + artifactProfiles: BaseSetupArtifactProfile[]; + scorecardProfiles: BaseSetupScorecardProfile[]; + policyProfiles: BaseSetupPolicyProfile[]; + compatibility: BaseSetupCompatibility; +} + +export type BaseSetupValidationLevel = "L0" | "L1" | "L2"; +export type BaseSetupValidationSeverity = "error" | "warning"; + +export interface BaseSetupValidationIssue { + level: BaseSetupValidationLevel; + severity: BaseSetupValidationSeverity; + code: string; + message: string; + path?: string; +} + +export interface BaseSetupValidationResult { + ok: boolean; + issues: BaseSetupValidationIssue[]; +} + +export interface BaseSetupProjectionIndex { + artifactProfileRefsByProjectionId: Record; + scorecardProfileRefsByProjectionId: Record; + policyProfileRefsByProjectionId: Record; + compositionBlueprintRefsByProjectionId: Record; +} + +export interface CompiledBaseSetupPackage { + packageId: string; + packageVersion: string; + serviceSkillCatalogProjection: ServiceSkillCatalog; + projectionIndex: BaseSetupProjectionIndex; +} + +export type BaseSetupRolloutDecision = + | "accept" + | "fallback_seeded" + | "reject_invalid_package" + | "reject_upgrade_required"; + +export interface BaseSetupRolloutResult { + decision: BaseSetupRolloutDecision; + reason?: string; +} + +export interface BaseSetupRolloutInput { + appVersion: string; + seededFallbackAvailable: boolean; + supportedBindingFamilies?: readonly ServiceSkillExecutorBinding[]; + supportedViewerKinds?: readonly BaseSetupViewerKind[]; + supportedKernelCapabilities?: readonly string[]; + validationResult?: BaseSetupValidationResult; +} diff --git a/src/lib/base-setup/validator.test.ts b/src/lib/base-setup/validator.test.ts new file mode 100644 index 000000000..99df03433 --- /dev/null +++ b/src/lib/base-setup/validator.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it } from "vitest"; +import type { BaseSetupPackage } from "./types"; +import { validateBaseSetupPackage } from "./validator"; + +function createBaseSetupPackage(): BaseSetupPackage { + return { + id: "multimodal-starter-pack", + version: "0.1.0", + title: "Multimodal Starter Pack", + summary: "把文本目标装配成短视频草稿场景。", + bundleRefs: [ + { + id: "short-video-skills", + source: "remote", + pathOrUri: "lime://bundles/short-video", + kind: "skill_bundle", + }, + ], + catalogProjections: [ + { + id: "short-video-scene", + targetCatalog: "service_skill_catalog", + entryKey: "short-video-draft", + title: "文本生成短视频草稿", + summary: "把文本目标整理成短视频草稿 project pack。", + category: "创作", + outputHint: "project pack + 视频草稿", + bundleRefId: "short-video-skills", + slotProfileRef: "short-video-input", + bindingProfileRef: "hybrid-composition", + artifactProfileRef: "short-video-project-pack", + scorecardProfileRef: "short-video-scorecard", + policyProfileRef: "default-exposure", + compositionBlueprintRef: "short-video-composition-blueprint", + }, + ], + slotProfiles: [ + { + id: "short-video-input", + slots: [ + { + key: "topic", + label: "主题", + type: "text", + required: true, + placeholder: "输入主题", + }, + ], + }, + ], + bindingProfiles: [ + { + id: "hybrid-composition", + bindingFamily: "agent_turn", + runnerType: "instant", + }, + ], + compositionBlueprints: [ + { + id: "short-video-composition-blueprint", + artifactProfileRef: "short-video-project-pack", + deliveryContract: { + requiredParts: [ + "brief", + "storyboard", + "script", + "music_refs", + "video_draft", + "review_note", + ], + }, + steps: [ + { + id: "generate-script", + bindingProfileRef: "hybrid-composition", + }, + ], + }, + ], + artifactProfiles: [ + { + id: "short-video-project-pack", + deliveryContract: "project_pack", + requiredParts: [ + "brief", + "storyboard", + "script", + "music_refs", + "video_draft", + "review_note", + ], + viewerKind: "artifact_bundle", + defaultArtifactKind: "brief", + outputDestination: "workspace", + }, + ], + scorecardProfiles: [ + { + id: "short-video-scorecard", + metrics: ["completion_rate", "acceptance_rate"], + }, + ], + policyProfiles: [ + { + id: "default-exposure", + enabled: true, + surfaceScopes: ["home", "mention", "workspace"], + rolloutStage: "limited", + }, + ], + compatibility: { + minAppVersion: "1.11.0", + requiredKernelCapabilities: ["agent_turn", "artifact_viewer"], + seededFallback: true, + compatCatalogProjection: true, + }, + }; +} + +describe("validateBaseSetupPackage", () => { + it("通过完整包的结构、引用和宿主边界校验", () => { + const result = validateBaseSetupPackage(createBaseSetupPackage()); + + expect(result.ok).toBe(true); + expect(result.issues).toEqual([]); + }); + + it("在 projection 引用缺失时返回 L1 error", () => { + const pkg = createBaseSetupPackage(); + pkg.catalogProjections[0].artifactProfileRef = "missing-artifact"; + + const result = validateBaseSetupPackage(pkg); + + expect(result.ok).toBe(false); + expect(result.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + level: "L1", + code: "missing_projection_ref", + path: "catalogProjections[0].artifactProfileRef", + }), + ]), + ); + }); + + it("在宿主边界不支持时返回 L2 error", () => { + const pkg = createBaseSetupPackage(); + pkg.compatibility.requiredKernelCapabilities = ["managed_agents"]; + + const result = validateBaseSetupPackage(pkg); + + expect(result.ok).toBe(false); + expect(result.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + level: "L2", + code: "unsupported_kernel_capability", + }), + ]), + ); + }); +}); diff --git a/src/lib/base-setup/validator.ts b/src/lib/base-setup/validator.ts new file mode 100644 index 000000000..a9db32c56 --- /dev/null +++ b/src/lib/base-setup/validator.ts @@ -0,0 +1,403 @@ +import { + BASE_SETUP_ALLOWED_BINDING_FAMILIES, + BASE_SETUP_ALLOWED_KERNEL_CAPABILITIES, + BASE_SETUP_TARGET_CATALOGS, + BASE_SETUP_VIEWER_KINDS, + type BaseSetupPackage, + type BaseSetupValidationIssue, + type BaseSetupValidationResult, +} from "./types"; + +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +function pushIssue( + issues: BaseSetupValidationIssue[], + issue: BaseSetupValidationIssue, +): void { + issues.push(issue); +} + +function finalizeIssues( + issues: BaseSetupValidationIssue[], +): BaseSetupValidationResult { + return { + ok: !issues.some((issue) => issue.severity === "error"), + issues, + }; +} + +function collectDuplicateIds( + entries: Array<{ id: string }>, +): Array<{ id: string; indexes: number[] }> { + const indexesById = new Map(); + entries.forEach((entry, index) => { + const current = indexesById.get(entry.id) ?? []; + current.push(index); + indexesById.set(entry.id, current); + }); + + return [...indexesById.entries()] + .filter(([, indexes]) => indexes.length > 1) + .map(([id, indexes]) => ({ id, indexes })); +} + +function validateUniqueIds( + issues: BaseSetupValidationIssue[], + entries: Array<{ id: string }>, + collectionPath: string, +): void { + for (const duplicate of collectDuplicateIds(entries)) { + pushIssue(issues, { + level: "L0", + severity: "error", + code: "duplicate_id", + message: `${collectionPath} 中存在重复 ID:${duplicate.id}`, + path: collectionPath, + }); + } +} + +function validateSlotProfileShape( + issues: BaseSetupValidationIssue[], + pkg: BaseSetupPackage, +): void { + pkg.slotProfiles.forEach((profile, profileIndex) => { + if (!profile.slots.length) { + pushIssue(issues, { + level: "L0", + severity: "warning", + code: "empty_slot_profile", + message: `slotProfiles[${profileIndex}] 未声明任何 slots`, + path: `slotProfiles[${profileIndex}]`, + }); + return; + } + + profile.slots.forEach((slot, slotIndex) => { + if (!isNonEmptyString(slot.key)) { + pushIssue(issues, { + level: "L0", + severity: "error", + code: "invalid_slot_key", + message: "slot.key 不能为空", + path: `slotProfiles[${profileIndex}].slots[${slotIndex}].key`, + }); + } + if (!isNonEmptyString(slot.label)) { + pushIssue(issues, { + level: "L0", + severity: "error", + code: "invalid_slot_label", + message: "slot.label 不能为空", + path: `slotProfiles[${profileIndex}].slots[${slotIndex}].label`, + }); + } + if (!isNonEmptyString(slot.placeholder)) { + pushIssue(issues, { + level: "L0", + severity: "warning", + code: "missing_slot_placeholder", + message: "slot.placeholder 为空,后续表单体验可能不稳定", + path: `slotProfiles[${profileIndex}].slots[${slotIndex}].placeholder`, + }); + } + }); + }); +} + +export function validateBaseSetupStructure( + pkg: BaseSetupPackage, +): BaseSetupValidationResult { + const issues: BaseSetupValidationIssue[] = []; + + if (!isNonEmptyString(pkg.id)) { + pushIssue(issues, { + level: "L0", + severity: "error", + code: "invalid_package_id", + message: "Base Setup Package 的 id 不能为空", + path: "id", + }); + } + if (!isNonEmptyString(pkg.version)) { + pushIssue(issues, { + level: "L0", + severity: "error", + code: "invalid_package_version", + message: "Base Setup Package 的 version 不能为空", + path: "version", + }); + } + if (!isNonEmptyString(pkg.title)) { + pushIssue(issues, { + level: "L0", + severity: "error", + code: "invalid_package_title", + message: "Base Setup Package 的 title 不能为空", + path: "title", + }); + } + if (!isNonEmptyString(pkg.summary)) { + pushIssue(issues, { + level: "L0", + severity: "error", + code: "invalid_package_summary", + message: "Base Setup Package 的 summary 不能为空", + path: "summary", + }); + } + + validateUniqueIds(issues, pkg.bundleRefs, "bundleRefs"); + validateUniqueIds(issues, pkg.catalogProjections, "catalogProjections"); + validateUniqueIds(issues, pkg.slotProfiles, "slotProfiles"); + validateUniqueIds(issues, pkg.bindingProfiles, "bindingProfiles"); + validateUniqueIds(issues, pkg.artifactProfiles, "artifactProfiles"); + validateUniqueIds(issues, pkg.scorecardProfiles, "scorecardProfiles"); + validateUniqueIds(issues, pkg.policyProfiles, "policyProfiles"); + validateUniqueIds( + issues, + pkg.compositionBlueprints ?? [], + "compositionBlueprints", + ); + + if (!pkg.bundleRefs.length) { + pushIssue(issues, { + level: "L0", + severity: "warning", + code: "empty_bundle_refs", + message: "bundleRefs 为空,当前包无法追溯标准来源", + path: "bundleRefs", + }); + } + if (!pkg.catalogProjections.length) { + pushIssue(issues, { + level: "L0", + severity: "warning", + code: "empty_catalog_projections", + message: "catalogProjections 为空,当前包不会产生任何目录项", + path: "catalogProjections", + }); + } + + pkg.catalogProjections.forEach((projection, index) => { + if (!BASE_SETUP_TARGET_CATALOGS.includes(projection.targetCatalog)) { + pushIssue(issues, { + level: "L0", + severity: "error", + code: "invalid_target_catalog", + message: `catalogProjections[${index}] 使用了非法 targetCatalog:${projection.targetCatalog}`, + path: `catalogProjections[${index}].targetCatalog`, + }); + } + if (!isNonEmptyString(projection.entryKey)) { + pushIssue(issues, { + level: "L0", + severity: "error", + code: "missing_entry_key", + message: "catalog projection 的 entryKey 不能为空", + path: `catalogProjections[${index}].entryKey`, + }); + } + if (!isNonEmptyString(projection.title)) { + pushIssue(issues, { + level: "L0", + severity: "error", + code: "missing_projection_title", + message: "catalog projection 的 title 不能为空", + path: `catalogProjections[${index}].title`, + }); + } + if (!isNonEmptyString(projection.summary)) { + pushIssue(issues, { + level: "L0", + severity: "error", + code: "missing_projection_summary", + message: "catalog projection 的 summary 不能为空", + path: `catalogProjections[${index}].summary`, + }); + } + }); + + validateSlotProfileShape(issues, pkg); + + if (!isNonEmptyString(pkg.compatibility.minAppVersion)) { + pushIssue(issues, { + level: "L0", + severity: "error", + code: "missing_min_app_version", + message: "compatibility.minAppVersion 不能为空", + path: "compatibility.minAppVersion", + }); + } + + return finalizeIssues(issues); +} + +export function validateBaseSetupReferences( + pkg: BaseSetupPackage, +): BaseSetupValidationResult { + const issues: BaseSetupValidationIssue[] = []; + const bundleRefIds = new Set(pkg.bundleRefs.map((entry) => entry.id)); + const slotProfileIds = new Set(pkg.slotProfiles.map((entry) => entry.id)); + const bindingProfileIds = new Set(pkg.bindingProfiles.map((entry) => entry.id)); + const artifactProfiles = new Map( + pkg.artifactProfiles.map((entry) => [entry.id, entry] as const), + ); + const scorecardProfileIds = new Set( + pkg.scorecardProfiles.map((entry) => entry.id), + ); + const policyProfileIds = new Set(pkg.policyProfiles.map((entry) => entry.id)); + const compositionBlueprints = new Map( + (pkg.compositionBlueprints ?? []).map((entry) => [entry.id, entry] as const), + ); + + pkg.catalogProjections.forEach((projection, index) => { + const basePath = `catalogProjections[${index}]`; + const refChecks = [ + ["bundleRefId", projection.bundleRefId, bundleRefIds], + ["slotProfileRef", projection.slotProfileRef, slotProfileIds], + ["bindingProfileRef", projection.bindingProfileRef, bindingProfileIds], + ["artifactProfileRef", projection.artifactProfileRef, new Set(artifactProfiles.keys())], + ["scorecardProfileRef", projection.scorecardProfileRef, scorecardProfileIds], + ["policyProfileRef", projection.policyProfileRef, policyProfileIds], + ] as const; + + refChecks.forEach(([field, value, allowedIds]) => { + if (!allowedIds.has(value)) { + pushIssue(issues, { + level: "L1", + severity: "error", + code: "missing_projection_ref", + message: `${field} 引用了不存在的对象:${value}`, + path: `${basePath}.${field}`, + }); + } + }); + + if ( + projection.compositionBlueprintRef && + !compositionBlueprints.has(projection.compositionBlueprintRef) + ) { + pushIssue(issues, { + level: "L1", + severity: "error", + code: "missing_composition_blueprint_ref", + message: `compositionBlueprintRef 引用了不存在的蓝图:${projection.compositionBlueprintRef}`, + path: `${basePath}.compositionBlueprintRef`, + }); + } + }); + + for (const [blueprintId, blueprint] of compositionBlueprints.entries()) { + if (blueprint.artifactProfileRef && !artifactProfiles.has(blueprint.artifactProfileRef)) { + pushIssue(issues, { + level: "L1", + severity: "error", + code: "missing_blueprint_artifact_profile", + message: `compositionBlueprint ${blueprintId} 引用了不存在的 artifactProfile:${blueprint.artifactProfileRef}`, + path: `compositionBlueprints.${blueprintId}.artifactProfileRef`, + }); + } + + if (blueprint.steps?.length) { + blueprint.steps.forEach((step, stepIndex) => { + if (step.bindingProfileRef && !bindingProfileIds.has(step.bindingProfileRef)) { + pushIssue(issues, { + level: "L1", + severity: "error", + code: "missing_step_binding_profile", + message: `compositionBlueprint ${blueprintId} 的步骤引用了不存在的 bindingProfile:${step.bindingProfileRef}`, + path: `compositionBlueprints.${blueprintId}.steps[${stepIndex}].bindingProfileRef`, + }); + } + }); + } + + if ( + blueprint.artifactProfileRef && + blueprint.deliveryContract?.requiredParts?.length + ) { + const artifactProfile = artifactProfiles.get(blueprint.artifactProfileRef); + if (artifactProfile) { + const artifactParts = [...artifactProfile.requiredParts].sort(); + const deliveryParts = [...blueprint.deliveryContract.requiredParts].sort(); + if (artifactParts.join("|") !== deliveryParts.join("|")) { + pushIssue(issues, { + level: "L1", + severity: "error", + code: "delivery_contract_mismatch", + message: `compositionBlueprint ${blueprintId} 的 deliveryContract.requiredParts 与 artifactProfile.requiredParts 不一致`, + path: `compositionBlueprints.${blueprintId}.deliveryContract.requiredParts`, + }); + } + } + } + } + + return finalizeIssues(issues); +} + +export function validateBaseSetupHostBoundary( + pkg: BaseSetupPackage, +): BaseSetupValidationResult { + const issues: BaseSetupValidationIssue[] = []; + const allowedBindingFamilies = new Set( + BASE_SETUP_ALLOWED_BINDING_FAMILIES, + ); + const allowedViewerKinds = new Set(BASE_SETUP_VIEWER_KINDS); + const allowedKernelCapabilities = new Set( + BASE_SETUP_ALLOWED_KERNEL_CAPABILITIES, + ); + + pkg.bindingProfiles.forEach((profile, index) => { + if (!allowedBindingFamilies.has(profile.bindingFamily)) { + pushIssue(issues, { + level: "L2", + severity: "error", + code: "unsupported_binding_family", + message: `bindingProfiles[${index}] 使用了不受支持的 bindingFamily:${profile.bindingFamily}`, + path: `bindingProfiles[${index}].bindingFamily`, + }); + } + }); + + pkg.artifactProfiles.forEach((profile, index) => { + if (!allowedViewerKinds.has(profile.viewerKind)) { + pushIssue(issues, { + level: "L2", + severity: "error", + code: "unsupported_viewer_kind", + message: `artifactProfiles[${index}] 使用了不受支持的 viewerKind:${profile.viewerKind}`, + path: `artifactProfiles[${index}].viewerKind`, + }); + } + }); + + pkg.compatibility.requiredKernelCapabilities.forEach((capability, index) => { + if (!allowedKernelCapabilities.has(capability)) { + pushIssue(issues, { + level: "L2", + severity: "error", + code: "unsupported_kernel_capability", + message: `compatibility.requiredKernelCapabilities[${index}] 使用了不受支持的 capability:${capability}`, + path: `compatibility.requiredKernelCapabilities[${index}]`, + }); + } + }); + + return finalizeIssues(issues); +} + +export function validateBaseSetupPackage( + pkg: BaseSetupPackage, +): BaseSetupValidationResult { + const results = [ + validateBaseSetupStructure(pkg), + validateBaseSetupReferences(pkg), + validateBaseSetupHostBoundary(pkg), + ]; + + return finalizeIssues(results.flatMap((result) => result.issues)); +} diff --git a/src/lib/dev-bridge/http-client.test.ts b/src/lib/dev-bridge/http-client.test.ts index 59f64e10b..d6259afc4 100644 --- a/src/lib/dev-bridge/http-client.test.ts +++ b/src/lib/dev-bridge/http-client.test.ts @@ -130,6 +130,34 @@ describe("http-client", () => { expect(fetchMock.mock.calls[3]?.[0]).toBe("http://127.0.0.1:3030/invoke"); }); + it("agent runtime 命令应使用更长的请求超时窗口", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(new Response(null, { status: 200 })) + .mockImplementationOnce(createAbortablePendingFetch()); + vi.stubGlobal("fetch", fetchMock); + + let settled = false; + const invokePromise = invokeViaHttp("agent_runtime_submit_turn").then( + () => ({ ok: true as const }), + (error) => ({ ok: false as const, error }), + ); + invokePromise.finally(() => { + settled = true; + }); + + await vi.advanceTimersByTimeAsync(2000); + expect(settled).toBe(false); + + await vi.advanceTimersByTimeAsync(9000); + await expect(invokePromise).resolves.toMatchObject({ + ok: false, + error: expect.objectContaining({ + message: expect.stringContaining("timeout after 10000ms"), + }), + }); + }); + it("桥失败短退避期间,事件监听不应继续创建 EventSource 连接", async () => { const fetchMock = createAbortablePendingFetch(); const eventSourceMock = vi.fn(); @@ -150,4 +178,119 @@ describe("http-client", () => { expect(eventSourceMock).not.toHaveBeenCalled(); expect(fetchMock).toHaveBeenCalledTimes(1); }); + + it("事件流在已建立连接后断开时应主动关闭并停止重复告警", async () => { + class MockEventSource { + static instances: MockEventSource[] = []; + + onopen: (() => void) | null = null; + onerror: ((error: Event) => void) | null = null; + onmessage: ((event: MessageEvent) => void) | null = null; + readyState = 0; + readonly close = vi.fn(() => { + this.readyState = 2; + }); + + constructor(readonly url: string) { + MockEventSource.instances.push(this); + } + + emitOpen() { + this.readyState = 1; + this.onopen?.(); + } + + emitError(error = new Event("error")) { + this.onerror?.(error); + } + } + + const fetchMock = vi + .fn() + .mockResolvedValue(new Response(null, { status: 200 })); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); + vi.stubGlobal("fetch", fetchMock); + vi.stubGlobal( + "EventSource", + MockEventSource as unknown as typeof EventSource, + ); + + const unlistenPromise = listenViaHttpEvent("config-changed", vi.fn()); + await vi.advanceTimersByTimeAsync(0); + expect(MockEventSource.instances).toHaveLength(1); + + const source = MockEventSource.instances[0]!; + source.emitOpen(); + const unlisten = await unlistenPromise; + + source.emitError(); + source.emitError(); + + expect(source.close).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledTimes(1); + + unlisten(); + }); + + it("事件流在建立后结束不应把整个桥接误标记为 unavailable", async () => { + class MockEventSource { + static instances: MockEventSource[] = []; + + onopen: (() => void) | null = null; + onerror: ((error: Event) => void) | null = null; + onmessage: ((event: MessageEvent) => void) | null = null; + readyState = 0; + readonly close = vi.fn(() => { + this.readyState = 2; + }); + + constructor(readonly url: string) { + MockEventSource.instances.push(this); + } + + emitOpen() { + this.readyState = 1; + this.onopen?.(); + } + + emitError(error = new Event("error")) { + this.onerror?.(error); + } + } + + const fetchMock = vi + .fn() + .mockResolvedValueOnce(new Response(null, { status: 200 })) + .mockResolvedValueOnce( + new Response(JSON.stringify({ result: ["project-a"] }), { + status: 200, + headers: { + "Content-Type": "application/json", + }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + vi.stubGlobal( + "EventSource", + MockEventSource as unknown as typeof EventSource, + ); + + const unlistenPromise = listenViaHttpEvent("aster_stream_test", vi.fn()); + await vi.advanceTimersByTimeAsync(0); + expect(MockEventSource.instances).toHaveLength(1); + + const source = MockEventSource.instances[0]!; + source.emitOpen(); + const unlisten = await unlistenPromise; + + source.emitError(); + + await expect(invokeViaHttp("workspace_list")).resolves.toEqual([ + "project-a", + ]); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock.mock.calls[1]?.[0]).toBe("http://127.0.0.1:3030/invoke"); + + unlisten(); + }); }); diff --git a/src/lib/dev-bridge/http-client.ts b/src/lib/dev-bridge/http-client.ts index b5bcadeb1..44c6599db 100644 --- a/src/lib/dev-bridge/http-client.ts +++ b/src/lib/dev-bridge/http-client.ts @@ -15,6 +15,7 @@ const BRIDGE_HEALTH_URL = "http://127.0.0.1:3030/health"; const BRIDGE_EVENTS_URL = "http://127.0.0.1:3030/events"; const DEV_BRIDGE_EVENT_CONNECT_TIMEOUT_MS = 1500; const DEV_BRIDGE_REQUEST_TIMEOUT_MS = 1800; +const DEV_BRIDGE_AGENT_RUNTIME_TIMEOUT_MS = 10000; const DEV_BRIDGE_HEALTH_TIMEOUT_MS = 800; const DEV_BRIDGE_HEALTH_CACHE_MS = 10000; const DEV_BRIDGE_FAILURE_COOLDOWN_MS = 3000; @@ -45,6 +46,13 @@ let bridgeLastHealthyAt = 0; let bridgeConnectionBackoffUntil = 0; let bridgeHealthProbePromise: Promise | null = null; +function resolveBridgeRequestTimeoutMs(cmd: string): number { + if (cmd.startsWith("agent_runtime_")) { + return DEV_BRIDGE_AGENT_RUNTIME_TIMEOUT_MS; + } + return DEV_BRIDGE_REQUEST_TIMEOUT_MS; +} + function resolveEventSourceConstructor(): typeof EventSource | null { if ( typeof window !== "undefined" && @@ -324,6 +332,7 @@ export async function listenViaHttpEvent( `${BRIDGE_EVENTS_URL}?event=${encodeURIComponent(normalizedEvent)}`, ); const listeners = new Set>(); + let hubActive = true; let hasOpened = false; let settleOpen: ((value: void | PromiseLike) => void) | null = null; let settleOpenError: ((reason?: unknown) => void) | null = null; @@ -332,10 +341,11 @@ export async function listenViaHttpEvent( settleOpenError = reject; }); const connectTimeout = window.setTimeout(() => { - if (hasOpened) { + if (hasOpened || !hubActive) { return; } markBridgeUnavailable(); + hubActive = false; bridgeEventHubs.delete(normalizedEvent); source.close(); settleOpenError?.( @@ -361,7 +371,11 @@ export async function listenViaHttpEvent( }; source.onopen = () => { + if (!hubActive) { + return; + } hasOpened = true; + markBridgeHealthy(); window.clearTimeout(connectTimeout); settleOpen?.(); settleOpen = null; @@ -369,14 +383,18 @@ export async function listenViaHttpEvent( }; source.onerror = (error) => { + if (!hubActive) { + return; + } console.warn(`[DevBridge] 事件流异常: ${normalizedEvent}`, error); + hubActive = false; + bridgeEventHubs.delete(normalizedEvent); + source.close(); if (hasOpened) { return; } - window.clearTimeout(connectTimeout); markBridgeUnavailable(); - bridgeEventHubs.delete(normalizedEvent); - source.close(); + window.clearTimeout(connectTimeout); settleOpenError?.( new Error(`[DevBridge] 事件流连接失败: ${normalizedEvent}`), ); @@ -423,6 +441,7 @@ export async function invokeViaHttp( args?: unknown, ): Promise { console.log(`[DevBridge] HTTP 调用: ${cmd}`, args); + const timeoutMs = resolveBridgeRequestTimeoutMs(cmd); try { await ensureBridgeReachable(); @@ -436,7 +455,7 @@ export async function invokeViaHttp( }, body: JSON.stringify({ cmd, args } satisfies InvokeRequest), }, - DEV_BRIDGE_REQUEST_TIMEOUT_MS, + timeoutMs, ); if (!response.ok) { @@ -480,6 +499,10 @@ export function __resetDevBridgeHttpStateForTests(): void { bridgeLastHealthyAt = 0; bridgeConnectionBackoffUntil = 0; bridgeHealthProbePromise = null; + for (const hub of bridgeEventHubs.values()) { + hub.source.close(); + } + bridgeEventHubs.clear(); } /** diff --git a/src/lib/dev-bridge/mockPriorityCommands.test.ts b/src/lib/dev-bridge/mockPriorityCommands.test.ts index 1e3085458..886cbe715 100644 --- a/src/lib/dev-bridge/mockPriorityCommands.test.ts +++ b/src/lib/dev-bridge/mockPriorityCommands.test.ts @@ -30,12 +30,32 @@ describe("mockPriorityCommands", () => { it("模型与运行时真相命令在浏览器模式下禁止静默退回 mock", () => { expect(shouldDisallowMockFallbackInBrowser("aster_agent_init")).toBe(true); + expect(shouldDisallowMockFallbackInBrowser("workspace_list")).toBe(true); + expect( + shouldDisallowMockFallbackInBrowser("workspace_get_default"), + ).toBe(true); + expect(shouldDisallowMockFallbackInBrowser("workspace_get")).toBe(true); + expect( + shouldDisallowMockFallbackInBrowser("workspace_ensure_ready"), + ).toBe(true); + expect( + shouldDisallowMockFallbackInBrowser("agent_runtime_submit_turn"), + ).toBe(true); + expect( + shouldDisallowMockFallbackInBrowser("agent_runtime_list_sessions"), + ).toBe(true); + expect( + shouldDisallowMockFallbackInBrowser("agent_runtime_update_session"), + ).toBe(true); expect( shouldDisallowMockFallbackInBrowser("get_model_registry_provider_ids"), ).toBe(true); expect( shouldDisallowMockFallbackInBrowser("get_provider_pool_overview"), ).toBe(true); + expect(shouldDisallowMockFallbackInBrowser("get_provider_ui_state")).toBe( + true, + ); expect(shouldDisallowMockFallbackInBrowser("list_plugin_tasks")).toBe( false, ); diff --git a/src/lib/dev-bridge/mockPriorityCommands.ts b/src/lib/dev-bridge/mockPriorityCommands.ts index 9959d6209..efafc39bf 100644 --- a/src/lib/dev-bridge/mockPriorityCommands.ts +++ b/src/lib/dev-bridge/mockPriorityCommands.ts @@ -85,7 +85,32 @@ const mockPriorityCommands = new Set([ const bridgeTruthCommands = new Set([ "aster_agent_init", "aster_agent_status", + "workspace_list", + "workspace_get_default", + "workspace_get", + "workspace_ensure_ready", + "agent_runtime_submit_turn", + "agent_runtime_interrupt_turn", + "agent_runtime_compact_session", + "agent_runtime_resume_thread", + "agent_runtime_replay_request", + "agent_runtime_get_thread_read", + "agent_runtime_promote_queued_turn", + "agent_runtime_remove_queued_turn", + "agent_runtime_respond_action", + "agent_runtime_create_session", + "agent_runtime_list_sessions", + "agent_runtime_get_session", + "agent_runtime_update_session", + "agent_runtime_delete_session", + "agent_runtime_get_tool_inventory", + "agent_runtime_spawn_subagent", + "agent_runtime_send_subagent_input", + "agent_runtime_wait_subagents", + "agent_runtime_resume_subagent", + "agent_runtime_close_subagent", "get_default_provider", + "get_provider_ui_state", "get_provider_pool_overview", "get_api_key_providers", "get_model_registry", diff --git a/src/lib/governance/agentRuntimeCommandSchema.json b/src/lib/governance/agentRuntimeCommandSchema.json index 7ef821d0b..86a98665c 100644 --- a/src/lib/governance/agentRuntimeCommandSchema.json +++ b/src/lib/governance/agentRuntimeCommandSchema.json @@ -7,7 +7,7 @@ "requestType": "AgentRuntimeSubmitTurnRequest", "responseType": "void", "lifecycle": "current", - "mockStrategy": "default-mock", + "mockStrategy": "bridge-only", "docsSection": "agent-runtime.thread" }, { @@ -16,7 +16,7 @@ "requestType": "AgentRuntimeInterruptTurnRequest", "responseType": "boolean", "lifecycle": "current", - "mockStrategy": "default-mock", + "mockStrategy": "bridge-only", "docsSection": "agent-runtime.thread" }, { @@ -79,7 +79,7 @@ "requestType": "AgentRuntimeRespondActionRequest", "responseType": "void", "lifecycle": "current", - "mockStrategy": "default-mock", + "mockStrategy": "bridge-only", "docsSection": "agent-runtime.thread" }, { @@ -88,7 +88,7 @@ "requestType": "{ workspaceId: string; name?: string; executionStrategy?: AsterExecutionStrategy }", "responseType": "string", "lifecycle": "current", - "mockStrategy": "default-mock", + "mockStrategy": "bridge-only", "docsSection": "agent-runtime.session" }, { @@ -97,7 +97,7 @@ "requestType": "void", "responseType": "AsterSessionInfo[]", "lifecycle": "current", - "mockStrategy": "default-mock", + "mockStrategy": "bridge-only", "docsSection": "agent-runtime.session" }, { @@ -106,7 +106,7 @@ "requestType": "{ sessionId: string }", "responseType": "AsterSessionDetail", "lifecycle": "current", - "mockStrategy": "default-mock", + "mockStrategy": "bridge-only", "docsSection": "agent-runtime.session" }, { @@ -115,7 +115,7 @@ "requestType": "AgentRuntimeUpdateSessionRequest", "responseType": "void", "lifecycle": "current", - "mockStrategy": "default-mock", + "mockStrategy": "bridge-only", "docsSection": "agent-runtime.session" }, { @@ -124,7 +124,7 @@ "requestType": "{ sessionId: string }", "responseType": "void", "lifecycle": "current", - "mockStrategy": "default-mock", + "mockStrategy": "bridge-only", "docsSection": "agent-runtime.session" }, { @@ -187,7 +187,7 @@ "requestType": "AgentRuntimeToolInventoryRequest", "responseType": "AgentRuntimeToolInventory", "lifecycle": "current", - "mockStrategy": "default-mock", + "mockStrategy": "bridge-only", "docsSection": "agent-runtime.inventory" }, { @@ -196,7 +196,7 @@ "requestType": "AgentRuntimeSpawnSubagentRequest", "responseType": "AgentRuntimeSpawnSubagentResponse", "lifecycle": "current", - "mockStrategy": "default-mock", + "mockStrategy": "bridge-only", "docsSection": "agent-runtime.subagent" }, { @@ -205,7 +205,7 @@ "requestType": "AgentRuntimeSendSubagentInputRequest", "responseType": "AgentRuntimeSendSubagentInputResponse", "lifecycle": "current", - "mockStrategy": "default-mock", + "mockStrategy": "bridge-only", "docsSection": "agent-runtime.subagent" }, { @@ -214,7 +214,7 @@ "requestType": "AgentRuntimeWaitSubagentsRequest", "responseType": "AgentRuntimeWaitSubagentsResponse", "lifecycle": "current", - "mockStrategy": "default-mock", + "mockStrategy": "bridge-only", "docsSection": "agent-runtime.subagent" }, { @@ -223,7 +223,7 @@ "requestType": "AgentRuntimeResumeSubagentRequest", "responseType": "AgentRuntimeResumeSubagentResponse", "lifecycle": "current", - "mockStrategy": "default-mock", + "mockStrategy": "bridge-only", "docsSection": "agent-runtime.subagent" }, { @@ -232,7 +232,7 @@ "requestType": "AgentRuntimeCloseSubagentRequest", "responseType": "AgentRuntimeCloseSubagentResponse", "lifecycle": "current", - "mockStrategy": "default-mock", + "mockStrategy": "bridge-only", "docsSection": "agent-runtime.subagent" } ] diff --git a/src/lib/tauri-mock/core.test.ts b/src/lib/tauri-mock/core.test.ts index 235156c9f..bc8d239c1 100644 --- a/src/lib/tauri-mock/core.test.ts +++ b/src/lib/tauri-mock/core.test.ts @@ -61,14 +61,14 @@ describe("tauri-mock/core invoke", () => { .mockImplementation(() => {}); try { - await expect( - invoke("agent_runtime_get_tool_inventory", { - request: { - caller: "assistant", - browserAssist: true, - }, - }), - ).resolves.toEqual( + const result = await invoke("agent_runtime_get_tool_inventory", { + request: { + caller: "assistant", + browserAssist: true, + }, + }); + + expect(result).toEqual( expect.objectContaining({ request: expect.objectContaining({ caller: "assistant", @@ -80,36 +80,78 @@ describe("tauri-mock/core invoke", () => { "ToolSearch", "ListMcpResourcesTool", "ReadMcpResourceTool", - "Bash", "WebSearch", - "WebFetch", "AskUserQuestion", "SendUserMessage", + "Agent", "SendMessage", "TeamCreate", "TeamDelete", "ListPeers", - "RemoteTrigger", "TaskCreate", "Workflow", + "lime_site_recommend", + "lime_site_run", ]), counts: expect.objectContaining({ - catalog_total: expect.any(Number), + catalog_total: 46, registry_visible_total: expect.any(Number), + extension_tool_total: 20, + extension_tool_visible_total: 1, + mcp_tool_total: 20, + mcp_tool_visible_total: 1, }), catalog_tools: expect.arrayContaining([ expect.objectContaining({ name: "ToolSearch" }), expect.objectContaining({ name: "ListMcpResourcesTool" }), - expect.objectContaining({ name: "Bash" }), + expect.objectContaining({ + name: "Bash", + permission_plane: "parameter_restricted", + workspace_default_allow: false, + }), expect.objectContaining({ name: "WebSearch" }), - expect.objectContaining({ name: "WebFetch" }), + expect.objectContaining({ + name: "WebFetch", + permission_plane: "parameter_restricted", + workspace_default_allow: false, + }), expect.objectContaining({ name: "SendUserMessage" }), + expect.objectContaining({ + name: "StructuredOutput", + permission_plane: "session_allowlist", + workspace_default_allow: false, + }), expect.objectContaining({ name: "RemoteTrigger" }), expect.objectContaining({ name: "CronCreate" }), + expect.objectContaining({ name: "lime_site_list" }), + expect.objectContaining({ name: "lime_site_run" }), + expect.objectContaining({ + name: "mcp__lime-browser__", + source: "browser_compatibility", + permission_plane: "caller_filtered", + workspace_default_allow: false, + }), + ]), + extension_surfaces: expect.arrayContaining([ + expect.objectContaining({ + extension_name: "mcp__lime-browser", + available_tools: expect.arrayContaining([ + "navigate", + "click", + "read_page", + "get_page_text", + ]), + loaded_tools: ["mcp__lime-browser__navigate"], + searchable_tools: expect.arrayContaining([ + "mcp__lime-browser__navigate", + "mcp__lime-browser__click", + ]), + }), ]), registry_tools: expect.arrayContaining([ expect.objectContaining({ name: "AskUserQuestion" }), expect.objectContaining({ name: "SendUserMessage" }), + expect.objectContaining({ name: "StructuredOutput" }), expect.objectContaining({ name: "ReadMcpResourceTool" }), expect.objectContaining({ name: "EnterPlanMode" }), expect.objectContaining({ name: "SendMessage" }), @@ -119,12 +161,100 @@ describe("tauri-mock/core invoke", () => { expect.objectContaining({ name: "CronList" }), expect.objectContaining({ name: "TaskOutput" }), expect.objectContaining({ name: "ExitWorktree" }), + expect.objectContaining({ name: "lime_site_search" }), + ]), + extension_tools: expect.arrayContaining([ + expect.objectContaining({ + name: "mcp__lime-browser__navigate", + status: "loaded", + visible_in_context: true, + }), + expect.objectContaining({ + name: "mcp__lime-browser__click", + status: "deferred", + visible_in_context: false, + }), ]), mcp_tools: expect.arrayContaining([ - expect.objectContaining({ name: "mcp__lime-browser__navigate" }), + expect.objectContaining({ + name: "mcp__lime-browser__navigate", + always_visible: true, + visible_in_context: true, + tags: ["browser", "write"], + }), + expect.objectContaining({ + name: "mcp__lime-browser__click", + deferred_loading: true, + visible_in_context: false, + tags: ["browser", "write"], + }), ]), }), ); + expect(result.default_allowed_tools).not.toContain("StructuredOutput"); + } finally { + consoleWarnSpy.mockRestore(); + } + }); + + it("工具库存 fallback mock 应按 workbench + browser surface 补齐当前工具面", async () => { + mocks.invokeViaHttp.mockRejectedValueOnce(new Error("Failed to fetch")); + const consoleWarnSpy = vi + .spyOn(console, "warn") + .mockImplementation(() => {}); + + try { + const result = await invoke("agent_runtime_get_tool_inventory", { + request: { + caller: "assistant", + workbench: true, + browserAssist: true, + }, + }); + + expect(result.request.surface).toEqual( + expect.objectContaining({ + workbench: true, + browser_assist: true, + }), + ); + expect(result.counts.catalog_total).toBe(57); + expect(result.default_allowed_tools).toEqual( + expect.arrayContaining([ + "social_generate_cover_image", + "lime_create_image_generation_task", + "lime_create_transcription_task", + "lime_run_service_skill", + "lime_site_recommend", + "lime_site_run", + ]), + ); + expect(result.default_allowed_tools).not.toContain("mcp__lime-browser__"); + expect(result.catalog_tools).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "social_generate_cover_image" }), + expect.objectContaining({ name: "lime_create_image_generation_task" }), + expect.objectContaining({ name: "lime_run_service_skill" }), + expect.objectContaining({ name: "lime_site_recommend" }), + expect.objectContaining({ name: "mcp__lime-browser__" }), + ]), + ); + expect(result.registry_tools).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "social_generate_cover_image" }), + expect.objectContaining({ name: "lime_search_web_images" }), + expect.objectContaining({ name: "lime_create_typesetting_task" }), + expect.objectContaining({ name: "lime_site_info" }), + ]), + ); + expect(result.counts.mcp_tool_total).toBe(20); + expect(result.mcp_tools).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "mcp__lime-browser__navigate" }), + expect.objectContaining({ name: "mcp__lime-browser__read_page" }), + expect.objectContaining({ name: "mcp__lime-browser__click" }), + ]), + ); } finally { consoleWarnSpy.mockRestore(); } diff --git a/src/lib/tauri-mock/core.ts b/src/lib/tauri-mock/core.ts index 8adf6f4e3..3514e6fb4 100644 --- a/src/lib/tauri-mock/core.ts +++ b/src/lib/tauri-mock/core.ts @@ -212,7 +212,11 @@ type MockToolSpec = { source: string; tags: string[]; input_examples_count: number; + permission_plane?: "session_allowlist" | "parameter_restricted"; + workspace_default_allow?: boolean; + execution_warning_policy?: string; execution_restriction_profile?: string; + execution_sandbox_profile?: string; }; const DEFAULT_MOCK_BROWSER_ACTION_CAPABILITIES = [ @@ -1195,6 +1199,567 @@ type MockReviewDecisionRequest = { notes?: string; }; +const MOCK_PARAMETER_RESTRICTED_TOOL_NAMES = new Set([ + "Bash", + "Read", + "Write", + "Edit", + "Glob", + "Grep", + "WebFetch", + "NotebookEdit", + "PowerShell", + "LSP", +]); + +function resolveMockToolPermissionPlane( + tool: MockToolSpec, +): "session_allowlist" | "parameter_restricted" { + if (tool.permission_plane) { + return tool.permission_plane; + } + return MOCK_PARAMETER_RESTRICTED_TOOL_NAMES.has(tool.name) + ? "parameter_restricted" + : "session_allowlist"; +} + +function resolveMockWorkspaceDefaultAllow(tool: MockToolSpec): boolean { + if (typeof tool.workspace_default_allow === "boolean") { + return tool.workspace_default_allow; + } + return resolveMockToolPermissionPlane(tool) === "session_allowlist"; +} + +function resolveMockExecutionWarningPolicy(tool: MockToolSpec): string { + if (tool.execution_warning_policy) { + return tool.execution_warning_policy; + } + return ["Bash", "PowerShell"].includes(tool.name) + ? "shell_command_risk" + : "none"; +} + +function resolveMockExecutionRestrictionProfile(tool: MockToolSpec): string { + if (tool.execution_restriction_profile) { + return tool.execution_restriction_profile; + } + if (["Bash", "PowerShell"].includes(tool.name)) { + return "workspace_shell_command"; + } + if ( + ["Read", "Write", "Edit", "Glob", "Grep", "NotebookEdit", "LSP"].includes( + tool.name, + ) + ) { + return "workspace_path_required"; + } + return "none"; +} + +function resolveMockExecutionSandboxProfile(tool: MockToolSpec): string { + if (tool.execution_sandbox_profile) { + return tool.execution_sandbox_profile; + } + return ["Bash", "PowerShell"].includes(tool.name) + ? "workspace_command" + : "none"; +} + +const CORE_MOCK_TOOL_SPECS: MockToolSpec[] = [ + { + name: "ToolSearch", + description: "搜索当前会话可用工具与能力清单。", + capabilities: ["web_search"], + source: "lime_injected", + tags: ["search"], + input_examples_count: 1, + }, + { + name: "ListMcpResourcesTool", + description: "列出当前已连接 MCP 服务暴露的资源。", + capabilities: ["web_search"], + source: "lime_injected", + tags: ["mcp", "resource", "list"], + input_examples_count: 1, + }, + { + name: "ReadMcpResourceTool", + description: "按 server 与 uri 读取指定 MCP 资源内容。", + capabilities: ["web_search"], + source: "lime_injected", + tags: ["mcp", "resource", "read"], + input_examples_count: 1, + }, + { + name: "Bash", + description: "执行工作区命令并返回结果。", + capabilities: ["execution"], + source: "aster_builtin", + tags: ["command", "workspace"], + input_examples_count: 1, + }, + { + name: "Read", + description: "读取文件内容。", + capabilities: ["workspace_io"], + source: "aster_builtin", + tags: ["read", "file"], + input_examples_count: 1, + }, + { + name: "Write", + description: "写入文件内容。", + capabilities: ["workspace_io"], + source: "aster_builtin", + tags: ["write", "file"], + input_examples_count: 1, + }, + { + name: "Edit", + description: "按补丁方式编辑文件。", + capabilities: ["workspace_io"], + source: "aster_builtin", + tags: ["edit", "file"], + input_examples_count: 1, + }, + { + name: "Glob", + description: "按模式列出匹配文件。", + capabilities: ["workspace_io"], + source: "aster_builtin", + tags: ["search", "file"], + input_examples_count: 1, + }, + { + name: "Grep", + description: "在工作区中搜索文本。", + capabilities: ["workspace_io"], + source: "aster_builtin", + tags: ["search", "text"], + input_examples_count: 1, + }, + { + name: "WebFetch", + description: "抓取指定网页内容。", + capabilities: ["web_search"], + source: "aster_builtin", + tags: ["web", "fetch"], + input_examples_count: 1, + execution_restriction_profile: "safe_https_url_required", + }, + { + name: "WebSearch", + description: "联网检索公开网页信息。", + capabilities: ["web_search"], + source: "aster_builtin", + tags: ["research"], + input_examples_count: 2, + execution_restriction_profile: "safe_https_url_required", + }, + { + name: "AskUserQuestion", + description: "向用户发起单轮最小必要澄清。", + capabilities: ["planning"], + source: "aster_builtin", + tags: ["clarify"], + input_examples_count: 1, + }, + { + name: "SendUserMessage", + description: "向用户发送一条主可见消息,可用于回复、进度同步或主动提醒。", + capabilities: ["session_control"], + source: "aster_builtin", + tags: ["message", "user"], + input_examples_count: 1, + }, + { + name: "StructuredOutput", + description: "输出结构化最终答复。", + capabilities: ["session_control"], + source: "aster_builtin", + tags: ["response", "output"], + input_examples_count: 1, + permission_plane: "session_allowlist", + workspace_default_allow: false, + }, + { + name: "Agent", + description: "在需要并行处理时派生子代理。", + capabilities: ["delegation"], + source: "lime_injected", + tags: ["delegation"], + input_examples_count: 1, + }, + { + name: "SendMessage", + description: "向已存在的协作成员追加说明或指令。", + capabilities: ["delegation"], + source: "aster_builtin", + tags: ["delegation"], + input_examples_count: 1, + }, + { + name: "TeamCreate", + description: "创建共享 task board 与 team 协作上下文。", + capabilities: ["delegation"], + source: "aster_builtin", + tags: ["delegation", "team"], + input_examples_count: 1, + }, + { + name: "TeamDelete", + description: "删除当前 team 协作上下文。", + capabilities: ["delegation"], + source: "aster_builtin", + tags: ["delegation", "team"], + input_examples_count: 1, + }, + { + name: "ListPeers", + description: "列出当前 team 中可直接通信的协作成员。", + capabilities: ["delegation"], + source: "aster_builtin", + tags: ["delegation", "team"], + input_examples_count: 1, + }, + { + name: "Skill", + description: "加载并执行当前可用技能。", + capabilities: ["skill_execution"], + source: "aster_builtin", + tags: ["skill"], + input_examples_count: 1, + }, + { + name: "Workflow", + description: "执行工作流脚本。", + capabilities: ["execution"], + source: "aster_builtin", + tags: ["workflow"], + input_examples_count: 1, + }, + { + name: "TaskCreate", + description: "创建结构化任务。", + capabilities: ["planning"], + source: "aster_builtin", + tags: ["task"], + input_examples_count: 1, + }, + { + name: "TaskList", + description: "查看结构化任务列表。", + capabilities: ["planning"], + source: "aster_builtin", + tags: ["task"], + input_examples_count: 1, + }, + { + name: "TaskGet", + description: "读取单个结构化任务。", + capabilities: ["planning"], + source: "aster_builtin", + tags: ["task"], + input_examples_count: 1, + }, + { + name: "TaskUpdate", + description: "更新结构化任务状态。", + capabilities: ["planning"], + source: "aster_builtin", + tags: ["task"], + input_examples_count: 1, + }, + { + name: "TaskOutput", + description: "读取任务输出结果。", + capabilities: ["execution"], + source: "aster_builtin", + tags: ["task", "output"], + input_examples_count: 1, + }, + { + name: "TaskStop", + description: "停止正在执行的任务。", + capabilities: ["execution"], + source: "aster_builtin", + tags: ["task"], + input_examples_count: 1, + }, + { + name: "NotebookEdit", + description: "编辑 notebook 单元内容。", + capabilities: ["workspace_io"], + source: "aster_builtin", + tags: ["notebook"], + input_examples_count: 1, + }, + { + name: "EnterPlanMode", + description: "进入计划模式以拆解方案。", + capabilities: ["planning"], + source: "aster_builtin", + tags: ["planning"], + input_examples_count: 1, + }, + { + name: "ExitPlanMode", + description: "退出计划模式并继续执行。", + capabilities: ["planning"], + source: "aster_builtin", + tags: ["planning"], + input_examples_count: 1, + }, + { + name: "EnterWorktree", + description: "进入独立工作树执行隔离修改。", + capabilities: ["workspace_io"], + source: "aster_builtin", + tags: ["worktree"], + input_examples_count: 1, + }, + { + name: "ExitWorktree", + description: "退出独立工作树并回到主工作区。", + capabilities: ["workspace_io"], + source: "aster_builtin", + tags: ["worktree"], + input_examples_count: 1, + }, + { + name: "Config", + description: "查看或调整当前运行配置。", + capabilities: ["session_control"], + source: "aster_builtin", + tags: ["config"], + input_examples_count: 1, + }, + { + name: "Sleep", + description: "等待一段时间后继续执行。", + capabilities: ["execution"], + source: "aster_builtin", + tags: ["timing"], + input_examples_count: 1, + }, + { + name: "PowerShell", + description: "在 PowerShell 环境中执行命令。", + capabilities: ["execution"], + source: "aster_builtin", + tags: ["command", "windows"], + input_examples_count: 1, + }, + { + name: "LSP", + description: "查询语言服务返回的语义信息。", + capabilities: ["workspace_io"], + source: "aster_builtin", + tags: ["code", "lsp"], + input_examples_count: 1, + }, + { + name: "RemoteTrigger", + description: "管理或触发远程 trigger 执行。", + capabilities: ["execution"], + source: "aster_builtin", + tags: ["trigger", "remote"], + input_examples_count: 1, + }, + { + name: "CronCreate", + description: "创建新的定时触发器。", + capabilities: ["planning"], + source: "aster_builtin", + tags: ["trigger", "schedule"], + input_examples_count: 1, + }, + { + name: "CronList", + description: "查看当前可用的定时触发器。", + capabilities: ["planning"], + source: "aster_builtin", + tags: ["trigger", "schedule"], + input_examples_count: 1, + }, + { + name: "CronDelete", + description: "删除指定的定时触发器。", + capabilities: ["planning"], + source: "aster_builtin", + tags: ["trigger", "schedule"], + input_examples_count: 1, + }, +]; + +const WORKBENCH_MOCK_TOOL_SPECS: MockToolSpec[] = [ + { + name: "social_generate_cover_image", + description: "为内容生成封面图片。", + capabilities: ["content_creation"], + source: "lime_injected", + tags: ["content", "image", "cover"], + input_examples_count: 1, + }, + { + name: "lime_create_video_generation_task", + description: "创建视频生成任务。", + capabilities: ["content_creation"], + source: "lime_injected", + tags: ["content", "video", "task"], + input_examples_count: 1, + }, + { + name: "lime_create_transcription_task", + description: "创建转写任务。", + capabilities: ["content_creation"], + source: "lime_injected", + tags: ["content", "audio", "transcription"], + input_examples_count: 1, + }, + { + name: "lime_create_broadcast_generation_task", + description: "创建播报生成任务。", + capabilities: ["content_creation"], + source: "lime_injected", + tags: ["content", "audio", "broadcast"], + input_examples_count: 1, + }, + { + name: "lime_create_cover_generation_task", + description: "创建封面生成任务。", + capabilities: ["content_creation"], + source: "lime_injected", + tags: ["content", "image", "cover"], + input_examples_count: 1, + }, + { + name: "lime_create_modal_resource_search_task", + description: "创建素材检索任务。", + capabilities: ["content_creation"], + source: "lime_injected", + tags: ["content", "resource", "search"], + input_examples_count: 1, + }, + { + name: "lime_search_web_images", + description: "联网搜索图片素材。", + capabilities: ["web_search"], + source: "lime_injected", + tags: ["content", "image", "search"], + input_examples_count: 1, + }, + { + name: "lime_create_image_generation_task", + description: "创建图片生成任务。", + capabilities: ["content_creation"], + source: "lime_injected", + tags: ["content", "image", "task"], + input_examples_count: 1, + }, + { + name: "lime_create_url_parse_task", + description: "创建链接解析任务。", + capabilities: ["content_creation"], + source: "lime_injected", + tags: ["content", "url", "task"], + input_examples_count: 1, + }, + { + name: "lime_create_typesetting_task", + description: "创建排版任务。", + capabilities: ["content_creation"], + source: "lime_injected", + tags: ["content", "typesetting", "task"], + input_examples_count: 1, + }, + { + name: "lime_run_service_skill", + description: "执行当前绑定的服务型场景技能。", + capabilities: ["execution"], + source: "lime_injected", + tags: ["service_skill", "runtime"], + input_examples_count: 1, + }, +]; + +const BROWSER_ASSIST_MOCK_TOOL_SPECS: MockToolSpec[] = [ + { + name: "lime_site_list", + description: "列出可用站点能力。", + capabilities: ["browser_runtime", "web_search"], + source: "lime_injected", + tags: ["site", "browser", "list"], + input_examples_count: 1, + }, + { + name: "lime_site_recommend", + description: "推荐适合当前目标的站点能力。", + capabilities: ["browser_runtime", "web_search"], + source: "lime_injected", + tags: ["site", "browser", "recommend"], + input_examples_count: 1, + }, + { + name: "lime_site_search", + description: "搜索站点能力。", + capabilities: ["browser_runtime", "web_search"], + source: "lime_injected", + tags: ["site", "browser", "search"], + input_examples_count: 1, + }, + { + name: "lime_site_info", + description: "查看站点能力详情。", + capabilities: ["browser_runtime", "web_search"], + source: "lime_injected", + tags: ["site", "browser", "info"], + input_examples_count: 1, + }, + { + name: "lime_site_run", + description: "执行站点能力。", + capabilities: ["browser_runtime", "web_search"], + source: "lime_injected", + tags: ["site", "browser", "run"], + input_examples_count: 1, + }, +]; + +const BROWSER_RUNTIME_PREFIX_CATALOG_ENTRY = { + name: "mcp__lime-browser__", + profiles: ["browser_assist"], + capabilities: ["browser_runtime"], + lifecycle: "current", + source: "browser_compatibility", + permission_plane: "caller_filtered", + workspace_default_allow: false, + execution_warning_policy: "none", + execution_warning_policy_source: "default", + execution_restriction_profile: "none", + execution_restriction_profile_source: "default", + execution_sandbox_profile: "none", + execution_sandbox_profile_source: "default", +} as const; + +function listEnabledBrowserAssistCapabilityKeys(): string[] { + return DEFAULT_MOCK_BROWSER_ACTION_CAPABILITIES.filter( + (capability) => capability.enabled, + ) + .map((capability) => capability.key) + .sort(); +} + +function isLoadedBrowserAssistCapability(key: string): boolean { + return key === "navigate"; +} + +function listEnabledBrowserAssistCapabilities() { + return [...DEFAULT_MOCK_BROWSER_ACTION_CAPABILITIES] + .filter((capability) => capability.enabled) + .sort((left, right) => left.key.localeCompare(right.key)); +} + function buildMockAgentRuntimeToolInventory(request?: { caller?: string; workbench?: boolean; @@ -1205,339 +1770,41 @@ function buildMockAgentRuntimeToolInventory(request?: { workbench: request?.workbench === true, browser_assist: request?.browserAssist === true, }; - const toolSpecs: MockToolSpec[] = [ - { - name: "ToolSearch", - description: "搜索当前会话可用工具与能力清单。", - capabilities: ["web_search"], - source: "lime_injected", - tags: ["search"], - input_examples_count: 1, - }, - { - name: "ListMcpResourcesTool", - description: "列出当前已连接 MCP 服务暴露的资源。", - capabilities: ["web_search"], - source: "lime_injected", - tags: ["mcp", "resource", "list"], - input_examples_count: 1, - }, - { - name: "ReadMcpResourceTool", - description: "按 server 与 uri 读取指定 MCP 资源内容。", - capabilities: ["web_search"], - source: "lime_injected", - tags: ["mcp", "resource", "read"], - input_examples_count: 1, - }, - { - name: "Bash", - description: "执行工作区命令并返回结果。", - capabilities: ["execution"], - source: "aster_builtin", - tags: ["command", "workspace"], - input_examples_count: 1, - }, - { - name: "Read", - description: "读取文件内容。", - capabilities: ["workspace_io"], - source: "aster_builtin", - tags: ["read", "file"], - input_examples_count: 1, - }, - { - name: "Write", - description: "写入文件内容。", - capabilities: ["workspace_io"], - source: "aster_builtin", - tags: ["write", "file"], - input_examples_count: 1, - }, - { - name: "Edit", - description: "按补丁方式编辑文件。", - capabilities: ["workspace_io"], - source: "aster_builtin", - tags: ["edit", "file"], - input_examples_count: 1, - }, - { - name: "Glob", - description: "按模式列出匹配文件。", - capabilities: ["workspace_io"], - source: "aster_builtin", - tags: ["search", "file"], - input_examples_count: 1, - }, - { - name: "Grep", - description: "在工作区中搜索文本。", - capabilities: ["workspace_io"], - source: "aster_builtin", - tags: ["search", "text"], - input_examples_count: 1, - }, - { - name: "WebFetch", - description: "抓取指定网页内容。", - capabilities: ["web_search"], - source: "aster_builtin", - tags: ["web", "fetch"], - input_examples_count: 1, - execution_restriction_profile: "safe_https_url_required", - }, - { - name: "WebSearch", - description: "联网检索公开网页信息。", - capabilities: ["web_search"], - source: "aster_builtin", - tags: ["research"], - input_examples_count: 2, - execution_restriction_profile: "safe_https_url_required", - }, - { - name: "AskUserQuestion", - description: "向用户发起单轮最小必要澄清。", - capabilities: ["planning"], - source: "aster_builtin", - tags: ["clarify"], - input_examples_count: 1, - }, - { - name: "SendUserMessage", - description: "向用户发送一条主可见消息,可用于回复、进度同步或主动提醒。", - capabilities: ["session_control"], - source: "aster_builtin", - tags: ["message", "user"], - input_examples_count: 1, - }, - { - name: "Agent", - description: "在需要并行处理时派生子代理。", - capabilities: ["delegation"], - source: "lime_injected", - tags: ["delegation"], - input_examples_count: 1, - }, - { - name: "SendMessage", - description: "向已存在的协作成员追加说明或指令。", - capabilities: ["delegation"], - source: "aster_builtin", - tags: ["delegation"], - input_examples_count: 1, - }, - { - name: "TeamCreate", - description: "创建共享 task board 与 team 协作上下文。", - capabilities: ["delegation"], - source: "aster_builtin", - tags: ["delegation", "team"], - input_examples_count: 1, - }, - { - name: "TeamDelete", - description: "删除当前 team 协作上下文。", - capabilities: ["delegation"], - source: "aster_builtin", - tags: ["delegation", "team"], - input_examples_count: 1, - }, - { - name: "ListPeers", - description: "列出当前 team 中可直接通信的协作成员。", - capabilities: ["delegation"], - source: "aster_builtin", - tags: ["delegation", "team"], - input_examples_count: 1, - }, - { - name: "Skill", - description: "加载并执行当前可用技能。", - capabilities: ["skill_execution"], - source: "aster_builtin", - tags: ["skill"], - input_examples_count: 1, - }, - { - name: "Workflow", - description: "执行工作流脚本。", - capabilities: ["execution"], - source: "aster_builtin", - tags: ["workflow"], - input_examples_count: 1, - }, - { - name: "TaskCreate", - description: "创建结构化任务。", - capabilities: ["planning"], - source: "aster_builtin", - tags: ["task"], - input_examples_count: 1, - }, - { - name: "TaskList", - description: "查看结构化任务列表。", - capabilities: ["planning"], - source: "aster_builtin", - tags: ["task"], - input_examples_count: 1, - }, - { - name: "TaskGet", - description: "读取单个结构化任务。", - capabilities: ["planning"], - source: "aster_builtin", - tags: ["task"], - input_examples_count: 1, - }, - { - name: "TaskUpdate", - description: "更新结构化任务状态。", - capabilities: ["planning"], - source: "aster_builtin", - tags: ["task"], - input_examples_count: 1, - }, - { - name: "TaskOutput", - description: "读取任务输出结果。", - capabilities: ["planning"], - source: "aster_builtin", - tags: ["task", "output"], - input_examples_count: 1, - }, - { - name: "TaskStop", - description: "停止正在执行的任务。", - capabilities: ["planning"], - source: "aster_builtin", - tags: ["task"], - input_examples_count: 1, - }, - { - name: "NotebookEdit", - description: "编辑 notebook 单元内容。", - capabilities: ["workspace_io"], - source: "aster_builtin", - tags: ["notebook"], - input_examples_count: 1, - }, - { - name: "EnterPlanMode", - description: "进入计划模式以拆解方案。", - capabilities: ["planning"], - source: "aster_builtin", - tags: ["planning"], - input_examples_count: 1, - }, - { - name: "ExitPlanMode", - description: "退出计划模式并继续执行。", - capabilities: ["planning"], - source: "aster_builtin", - tags: ["planning"], - input_examples_count: 1, - }, - { - name: "EnterWorktree", - description: "进入独立工作树执行隔离修改。", - capabilities: ["workspace_io"], - source: "aster_builtin", - tags: ["worktree"], - input_examples_count: 1, - }, - { - name: "ExitWorktree", - description: "退出独立工作树并回到主工作区。", - capabilities: ["workspace_io"], - source: "aster_builtin", - tags: ["worktree"], - input_examples_count: 1, - }, - { - name: "Config", - description: "查看或调整当前运行配置。", - capabilities: ["session_control"], - source: "aster_builtin", - tags: ["config"], - input_examples_count: 1, - }, - { - name: "Sleep", - description: "等待一段时间后继续执行。", - capabilities: ["execution"], - source: "aster_builtin", - tags: ["timing"], - input_examples_count: 1, - }, - { - name: "PowerShell", - description: "在 PowerShell 环境中执行命令。", - capabilities: ["execution"], - source: "aster_builtin", - tags: ["command", "windows"], - input_examples_count: 1, - }, - { - name: "LSP", - description: "查询语言服务返回的语义信息。", - capabilities: ["workspace_io"], - source: "aster_builtin", - tags: ["code", "lsp"], - input_examples_count: 1, - }, - { - name: "RemoteTrigger", - description: "管理或触发远程 trigger 执行。", - capabilities: ["execution"], - source: "aster_builtin", - tags: ["trigger", "remote"], - input_examples_count: 1, - }, - { - name: "CronCreate", - description: "创建新的定时触发器。", - capabilities: ["planning"], - source: "aster_builtin", - tags: ["trigger", "schedule"], - input_examples_count: 1, - }, - { - name: "CronList", - description: "查看当前可用的定时触发器。", - capabilities: ["planning"], - source: "aster_builtin", - tags: ["trigger", "schedule"], - input_examples_count: 1, - }, - { - name: "CronDelete", - description: "删除指定的定时触发器。", - capabilities: ["planning"], - source: "aster_builtin", - tags: ["trigger", "schedule"], - input_examples_count: 1, - }, - ] as const; + ...CORE_MOCK_TOOL_SPECS, + ...(surface.workbench ? WORKBENCH_MOCK_TOOL_SPECS : []), + ...(surface.browser_assist ? BROWSER_ASSIST_MOCK_TOOL_SPECS : []), + ]; - const catalogTools = toolSpecs.map((tool) => ({ - name: tool.name, - profiles: ["core"], - capabilities: [...tool.capabilities], - lifecycle: "current", - source: tool.source, - permission_plane: "session_allowlist", - workspace_default_allow: true, - execution_warning_policy: "none", - execution_warning_policy_source: "default", - execution_restriction_profile: tool.execution_restriction_profile || "none", - execution_restriction_profile_source: "default", - execution_sandbox_profile: "none", - execution_sandbox_profile_source: "default", - })); + const catalogTools = [ + ...toolSpecs.map((tool) => ({ + name: tool.name, + profiles: [ + surface.workbench && + WORKBENCH_MOCK_TOOL_SPECS.some((entry) => entry.name === tool.name) + ? "workbench" + : surface.browser_assist && + BROWSER_ASSIST_MOCK_TOOL_SPECS.some( + (entry) => entry.name === tool.name, + ) + ? "browser_assist" + : "core", + ], + capabilities: [...tool.capabilities], + lifecycle: "current", + source: tool.source, + permission_plane: resolveMockToolPermissionPlane(tool), + workspace_default_allow: resolveMockWorkspaceDefaultAllow(tool), + execution_warning_policy: resolveMockExecutionWarningPolicy(tool), + execution_warning_policy_source: "default", + execution_restriction_profile: + resolveMockExecutionRestrictionProfile(tool), + execution_restriction_profile_source: "default", + execution_sandbox_profile: resolveMockExecutionSandboxProfile(tool), + execution_sandbox_profile_source: "default", + })), + ...(surface.browser_assist ? [BROWSER_RUNTIME_PREFIX_CATALOG_ENTRY] : []), + ]; const registryTools = toolSpecs.map((tool) => ({ name: tool.name, @@ -1545,14 +1812,14 @@ function buildMockAgentRuntimeToolInventory(request?: { catalog_entry_name: tool.name, catalog_source: tool.source, catalog_lifecycle: "current", - catalog_permission_plane: "session_allowlist", - catalog_workspace_default_allow: true, - catalog_execution_warning_policy: "none", + catalog_permission_plane: resolveMockToolPermissionPlane(tool), + catalog_workspace_default_allow: resolveMockWorkspaceDefaultAllow(tool), + catalog_execution_warning_policy: resolveMockExecutionWarningPolicy(tool), catalog_execution_warning_policy_source: "default", catalog_execution_restriction_profile: - tool.execution_restriction_profile || "none", + resolveMockExecutionRestrictionProfile(tool), catalog_execution_restriction_profile_source: "default", - catalog_execution_sandbox_profile: "none", + catalog_execution_sandbox_profile: resolveMockExecutionSandboxProfile(tool), catalog_execution_sandbox_profile_source: "default", deferred_loading: false, always_visible: true, @@ -1562,6 +1829,7 @@ function buildMockAgentRuntimeToolInventory(request?: { caller_allowed: true, visible_in_context: true, })); + registryTools.sort((left, right) => left.name.localeCompare(right.name)); const extensionSurfaces = surface.browser_assist ? [ @@ -1571,48 +1839,140 @@ function buildMockAgentRuntimeToolInventory(request?: { source_kind: "mcp_bridge", deferred_loading: false, allowed_caller: caller, - available_tools: ["navigate"], + available_tools: listEnabledBrowserAssistCapabilityKeys(), always_expose_tools: ["navigate"], loaded_tools: ["mcp__lime-browser__navigate"], - searchable_tools: ["mcp__lime-browser__navigate"], + searchable_tools: listEnabledBrowserAssistCapabilityKeys().map( + (key) => `mcp__lime-browser__${key}`, + ), }, ] : []; const extensionTools = surface.browser_assist - ? [ - { - name: "mcp__lime-browser__navigate", - description: "导航到目标网页。", + ? listEnabledBrowserAssistCapabilityKeys().map((key) => { + const loaded = isLoadedBrowserAssistCapability(key); + return { + name: `mcp__lime-browser__${key}`, + description: + DEFAULT_MOCK_BROWSER_ACTION_CAPABILITIES.find( + (capability) => capability.key === key, + )?.description || "浏览器协助工具。", extension_name: "mcp__lime-browser", source_kind: "mcp_bridge", - deferred_loading: false, + deferred_loading: !loaded, allowed_caller: caller, - status: "loaded", + status: loaded ? "loaded" : "deferred", caller_allowed: true, - visible_in_context: true, - }, - ] + visible_in_context: loaded, + }; + }) : []; const mcpTools = surface.browser_assist - ? [ - { + ? listEnabledBrowserAssistCapabilities().map((capability) => { + const loaded = isLoadedBrowserAssistCapability(capability.key); + return { server_name: "lime-browser", - name: "mcp__lime-browser__navigate", - description: "导航到目标网页。", - deferred_loading: false, - always_visible: true, + name: `mcp__lime-browser__${capability.key}`, + description: capability.description, + deferred_loading: !loaded, + always_visible: loaded, allowed_callers: [caller], - tags: ["browser"], + tags: ["browser", capability.group], input_examples_count: 1, caller_allowed: true, - visible_in_context: true, - }, - ] + visible_in_context: loaded, + }; + }) : []; + const runtimeTools: Array<{ + name: string; + description: string; + source_kind: + | "registry_native" + | "current_surface" + | "runtime_extension" + | "mcp"; + source_label?: string; + status?: string; + catalog_entry_name?: string; + catalog_source?: string; + catalog_lifecycle?: string; + catalog_permission_plane?: string; + catalog_workspace_default_allow?: boolean; + deferred_loading: boolean; + always_visible: boolean; + allowed_callers: string[]; + tags: string[]; + input_examples_count: number; + caller_allowed: boolean; + visible_in_context: boolean; + }> = []; + const pushRuntimeTool = (tool: (typeof runtimeTools)[number]) => { + if ( + runtimeTools.some( + (entry) => entry.name.toLowerCase() === tool.name.toLowerCase(), + ) + ) { + return; + } + runtimeTools.push(tool); + }; + registryTools.forEach((entry) => { + pushRuntimeTool({ + name: entry.name, + description: entry.description, + source_kind: "registry_native", + catalog_entry_name: entry.catalog_entry_name, + catalog_source: entry.catalog_source, + catalog_lifecycle: entry.catalog_lifecycle, + catalog_permission_plane: entry.catalog_permission_plane, + catalog_workspace_default_allow: entry.catalog_workspace_default_allow, + deferred_loading: entry.deferred_loading, + always_visible: entry.always_visible, + allowed_callers: entry.allowed_callers, + tags: entry.tags, + input_examples_count: entry.input_examples_count, + caller_allowed: entry.caller_allowed, + visible_in_context: entry.visible_in_context, + }); + }); + extensionTools.forEach((entry) => { + pushRuntimeTool({ + name: entry.name, + description: entry.description, + source_kind: "runtime_extension", + source_label: entry.extension_name, + status: entry.status, + deferred_loading: entry.deferred_loading, + always_visible: false, + allowed_callers: entry.allowed_caller ? [entry.allowed_caller] : [], + tags: [], + input_examples_count: 0, + caller_allowed: entry.caller_allowed, + visible_in_context: entry.visible_in_context, + }); + }); + mcpTools.forEach((entry) => { + pushRuntimeTool({ + name: entry.name, + description: entry.description, + source_kind: "mcp", + source_label: entry.server_name, + deferred_loading: entry.deferred_loading, + always_visible: entry.always_visible, + allowed_callers: entry.allowed_callers, + tags: entry.tags, + input_examples_count: entry.input_examples_count, + caller_allowed: entry.caller_allowed, + visible_in_context: entry.visible_in_context, + }); + }); + runtimeTools.sort((left, right) => left.name.localeCompare(right.name)); const defaultAllowedTools = registryTools .filter((entry) => entry.catalog_workspace_default_allow) .map((entry) => entry.name); + defaultAllowedTools.sort((left, right) => left.localeCompare(right)); return { request: { @@ -1631,6 +1991,10 @@ function buildMockAgentRuntimeToolInventory(request?: { catalog_compat_total: 0, catalog_deprecated_total: 0, default_allowed_total: defaultAllowedTools.length, + runtime_total: runtimeTools.length, + runtime_visible_total: runtimeTools.filter( + (entry) => entry.visible_in_context, + ).length, registry_total: registryTools.length, registry_visible_total: registryTools.filter( (entry) => entry.visible_in_context, @@ -1651,6 +2015,7 @@ function buildMockAgentRuntimeToolInventory(request?: { }, catalog_tools: catalogTools, registry_tools: registryTools, + runtime_tools: runtimeTools, extension_surfaces: extensionSurfaces, extension_tools: extensionTools, mcp_tools: mcpTools,
  • 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