diff --git a/.codex/skills/openspec-onboard/SKILL.md b/.codex/skills/openspec-onboard/SKILL.md deleted file mode 100644 index 014e4017..00000000 --- a/.codex/skills/openspec-onboard/SKILL.md +++ /dev/null @@ -1,554 +0,0 @@ ---- -name: openspec-onboard -description: Guided onboarding for OpenSpec - walk through a complete workflow cycle with narration and real codebase work. -license: MIT -compatibility: Requires openspec CLI. -metadata: - author: openspec - version: "1.0" - generatedBy: "1.3.0" ---- - -Guide the user through their first complete OpenSpec workflow cycle. This is a teaching experience—you'll do real work in their codebase while explaining each step. - ---- - -## Preflight - -Before starting, check if the OpenSpec CLI is installed: - -```bash -# Unix/macOS -openspec --version 2>&1 || echo "CLI_NOT_INSTALLED" -# Windows (PowerShell) -# if (Get-Command openspec -ErrorAction SilentlyContinue) { openspec --version } else { echo "CLI_NOT_INSTALLED" } -``` - -**If CLI not installed:** -> OpenSpec CLI is not installed. Install it first, then come back to `/opsx:onboard`. - -Stop here if not installed. - ---- - -## Phase 1: Welcome - -Display: - -``` -## Welcome to OpenSpec! - -I'll walk you through a complete change cycle—from idea to implementation—using a real task in your codebase. Along the way, you'll learn the workflow by doing it. - -**What we'll do:** -1. Pick a small, real task in your codebase -2. Explore the problem briefly -3. Create a change (the container for our work) -4. Build the artifacts: proposal → specs → design → tasks -5. Implement the tasks -6. Archive the completed change - -**Time:** ~15-20 minutes - -Let's start by finding something to work on. -``` - ---- - -## Phase 2: Task Selection - -### Codebase Analysis - -Scan the codebase for small improvement opportunities. Look for: - -1. **TODO/FIXME comments** - Search for `TODO`, `FIXME`, `HACK`, `XXX` in code files -2. **Missing error handling** - `catch` blocks that swallow errors, risky operations without try-catch -3. **Functions without tests** - Cross-reference `src/` with test directories -4. **Type issues** - `any` types in TypeScript files (`: any`, `as any`) -5. **Debug artifacts** - `console.log`, `console.debug`, `debugger` statements in non-debug code -6. **Missing validation** - User input handlers without validation - -Also check recent git activity: -```bash -# Unix/macOS -git log --oneline -10 2>/dev/null || echo "No git history" -# Windows (PowerShell) -# git log --oneline -10 2>$null; if ($LASTEXITCODE -ne 0) { echo "No git history" } -``` - -### Present Suggestions - -From your analysis, present 3-4 specific suggestions: - -``` -## Task Suggestions - -Based on scanning your codebase, here are some good starter tasks: - -**1. [Most promising task]** - Location: `src/path/to/file.ts:42` - Scope: ~1-2 files, ~20-30 lines - Why it's good: [brief reason] - -**2. [Second task]** - Location: `src/another/file.ts` - Scope: ~1 file, ~15 lines - Why it's good: [brief reason] - -**3. [Third task]** - Location: [location] - Scope: [estimate] - Why it's good: [brief reason] - -**4. Something else?** - Tell me what you'd like to work on. - -Which task interests you? (Pick a number or describe your own) -``` - -**If nothing found:** Fall back to asking what the user wants to build: -> I didn't find obvious quick wins in your codebase. What's something small you've been meaning to add or fix? - -### Scope Guardrail - -If the user picks or describes something too large (major feature, multi-day work): - -``` -That's a valuable task, but it's probably larger than ideal for your first OpenSpec run-through. - -For learning the workflow, smaller is better—it lets you see the full cycle without getting stuck in implementation details. - -**Options:** -1. **Slice it smaller** - What's the smallest useful piece of [their task]? Maybe just [specific slice]? -2. **Pick something else** - One of the other suggestions, or a different small task? -3. **Do it anyway** - If you really want to tackle this, we can. Just know it'll take longer. - -What would you prefer? -``` - -Let the user override if they insist—this is a soft guardrail. - ---- - -## Phase 3: Explore Demo - -Once a task is selected, briefly demonstrate explore mode: - -``` -Before we create a change, let me quickly show you **explore mode**—it's how you think through problems before committing to a direction. -``` - -Spend 1-2 minutes investigating the relevant code: -- Read the file(s) involved -- Draw a quick ASCII diagram if it helps -- Note any considerations - -``` -## Quick Exploration - -[Your brief analysis—what you found, any considerations] - -┌─────────────────────────────────────────┐ -│ [Optional: ASCII diagram if helpful] │ -└─────────────────────────────────────────┘ - -Explore mode (`/opsx:explore`) is for this kind of thinking—investigating before implementing. You can use it anytime you need to think through a problem. - -Now let's create a change to hold our work. -``` - -**PAUSE** - Wait for user acknowledgment before proceeding. - ---- - -## Phase 4: Create the Change - -**EXPLAIN:** -``` -## Creating a Change - -A "change" in OpenSpec is a container for all the thinking and planning around a piece of work. It lives in `openspec/changes//` and holds your artifacts—proposal, specs, design, tasks. - -Let me create one for our task. -``` - -**DO:** Create the change with a derived kebab-case name: -```bash -openspec new change "" -``` - -**SHOW:** -``` -Created: `openspec/changes//` - -The folder structure: -``` -openspec/changes// -├── proposal.md ← Why we're doing this (empty, we'll fill it) -├── design.md ← How we'll build it (empty) -├── specs/ ← Detailed requirements (empty) -└── tasks.md ← Implementation checklist (empty) -``` - -Now let's fill in the first artifact—the proposal. -``` - ---- - -## Phase 5: Proposal - -**EXPLAIN:** -``` -## The Proposal - -The proposal captures **why** we're making this change and **what** it involves at a high level. It's the "elevator pitch" for the work. - -I'll draft one based on our task. -``` - -**DO:** Draft the proposal content (don't save yet): - -``` -Here's a draft proposal: - ---- - -## Why - -[1-2 sentences explaining the problem/opportunity] - -## What Changes - -[Bullet points of what will be different] - -## Capabilities - -### New Capabilities -- ``: [brief description] - -### Modified Capabilities - - -## Impact - -- `src/path/to/file.ts`: [what changes] -- [other files if applicable] - ---- - -Does this capture the intent? I can adjust before we save it. -``` - -**PAUSE** - Wait for user approval/feedback. - -After approval, save the proposal: -```bash -openspec instructions proposal --change "" --json -``` -Then write the content to `openspec/changes//proposal.md`. - -``` -Proposal saved. This is your "why" document—you can always come back and refine it as understanding evolves. - -Next up: specs. -``` - ---- - -## Phase 6: Specs - -**EXPLAIN:** -``` -## Specs - -Specs define **what** we're building in precise, testable terms. They use a requirement/scenario format that makes expected behavior crystal clear. - -For a small task like this, we might only need one spec file. -``` - -**DO:** Create the spec file: -```bash -# Unix/macOS -mkdir -p openspec/changes//specs/ -# Windows (PowerShell) -# New-Item -ItemType Directory -Force -Path "openspec/changes//specs/" -``` - -Draft the spec content: - -``` -Here's the spec: - ---- - -## ADDED Requirements - -### Requirement: - - - -#### Scenario: - -- **WHEN** -- **THEN** -- **AND** - ---- - -This format—WHEN/THEN/AND—makes requirements testable. You can literally read them as test cases. -``` - -Save to `openspec/changes//specs//spec.md`. - ---- - -## Phase 7: Design - -**EXPLAIN:** -``` -## Design - -The design captures **how** we'll build it—technical decisions, tradeoffs, approach. - -For small changes, this might be brief. That's fine—not every change needs deep design discussion. -``` - -**DO:** Draft design.md: - -``` -Here's the design: - ---- - -## Context - -[Brief context about the current state] - -## Goals / Non-Goals - -**Goals:** -- [What we're trying to achieve] - -**Non-Goals:** -- [What's explicitly out of scope] - -## Decisions - -### Decision 1: [Key decision] - -[Explanation of approach and rationale] - ---- - -For a small task, this captures the key decisions without over-engineering. -``` - -Save to `openspec/changes//design.md`. - ---- - -## Phase 8: Tasks - -**EXPLAIN:** -``` -## Tasks - -Finally, we break the work into implementation tasks—checkboxes that drive the apply phase. - -These should be small, clear, and in logical order. -``` - -**DO:** Generate tasks based on specs and design: - -``` -Here are the implementation tasks: - ---- - -## 1. [Category or file] - -- [ ] 1.1 [Specific task] -- [ ] 1.2 [Specific task] - -## 2. Verify - -- [ ] 2.1 [Verification step] - ---- - -Each checkbox becomes a unit of work in the apply phase. Ready to implement? -``` - -**PAUSE** - Wait for user to confirm they're ready to implement. - -Save to `openspec/changes//tasks.md`. - ---- - -## Phase 9: Apply (Implementation) - -**EXPLAIN:** -``` -## Implementation - -Now we implement each task, checking them off as we go. I'll announce each one and occasionally note how the specs/design informed the approach. -``` - -**DO:** For each task: - -1. Announce: "Working on task N: [description]" -2. Implement the change in the codebase -3. Reference specs/design naturally: "The spec says X, so I'm doing Y" -4. Mark complete in tasks.md: `- [ ]` → `- [x]` -5. Brief status: "✓ Task N complete" - -Keep narration light—don't over-explain every line of code. - -After all tasks: - -``` -## Implementation Complete - -All tasks done: -- [x] Task 1 -- [x] Task 2 -- [x] ... - -The change is implemented! One more step—let's archive it. -``` - ---- - -## Phase 10: Archive - -**EXPLAIN:** -``` -## Archiving - -When a change is complete, we archive it. This moves it from `openspec/changes/` to `openspec/changes/archive/YYYY-MM-DD-/`. - -Archived changes become your project's decision history—you can always find them later to understand why something was built a certain way. -``` - -**DO:** -```bash -openspec archive "" -``` - -**SHOW:** -``` -Archived to: `openspec/changes/archive/YYYY-MM-DD-/` - -The change is now part of your project's history. The code is in your codebase, the decision record is preserved. -``` - ---- - -## Phase 11: Recap & Next Steps - -``` -## Congratulations! - -You just completed a full OpenSpec cycle: - -1. **Explore** - Thought through the problem -2. **New** - Created a change container -3. **Proposal** - Captured WHY -4. **Specs** - Defined WHAT in detail -5. **Design** - Decided HOW -6. **Tasks** - Broke it into steps -7. **Apply** - Implemented the work -8. **Archive** - Preserved the record - -This same rhythm works for any size change—a small fix or a major feature. - ---- - -## Command Reference - -**Core workflow:** - - | Command | What it does | - |-------------------|--------------------------------------------| - | `/opsx:propose` | Create a change and generate all artifacts | - | `/opsx:explore` | Think through problems before/during work | - | `/opsx:apply` | Implement tasks from a change | - | `/opsx:archive` | Archive a completed change | - -**Additional commands:** - - | Command | What it does | - |--------------------|----------------------------------------------------------| - | `/opsx:new` | Start a new change, step through artifacts one at a time | - | `/opsx:continue` | Continue working on an existing change | - | `/opsx:ff` | Fast-forward: create all artifacts at once | - | `/opsx:verify` | Verify implementation matches artifacts | - ---- - -## What's Next? - -Try `/opsx:propose` on something you actually want to build. You've got the rhythm now! -``` - ---- - -## Graceful Exit Handling - -### User wants to stop mid-way - -If the user says they need to stop, want to pause, or seem disengaged: - -``` -No problem! Your change is saved at `openspec/changes//`. - -To pick up where we left off later: -- `/opsx:continue ` - Resume artifact creation -- `/opsx:apply ` - Jump to implementation (if tasks exist) - -The work won't be lost. Come back whenever you're ready. -``` - -Exit gracefully without pressure. - -### User just wants command reference - -If the user says they just want to see the commands or skip the tutorial: - -``` -## OpenSpec Quick Reference - -**Core workflow:** - - | Command | What it does | - |--------------------------|--------------------------------------------| - | `/opsx:propose ` | Create a change and generate all artifacts | - | `/opsx:explore` | Think through problems (no code changes) | - | `/opsx:apply ` | Implement tasks | - | `/opsx:archive ` | Archive when done | - -**Additional commands:** - - | Command | What it does | - |---------------------------|-------------------------------------| - | `/opsx:new ` | Start a new change, step by step | - | `/opsx:continue ` | Continue an existing change | - | `/opsx:ff ` | Fast-forward: all artifacts at once | - | `/opsx:verify ` | Verify implementation | - -Try `/opsx:propose` to start your first change. -``` - -Exit gracefully. - ---- - -## Guardrails - -- **Follow the EXPLAIN → DO → SHOW → PAUSE pattern** at key transitions (after explore, after proposal draft, after tasks, after archive) -- **Keep narration light** during implementation—teach without lecturing -- **Don't skip phases** even if the change is small—the goal is teaching the workflow -- **Pause for acknowledgment** at marked points, but don't over-pause -- **Handle exits gracefully**—never pressure the user to continue -- **Use real codebase tasks**—don't simulate or use fake examples -- **Adjust scope gently**—guide toward smaller tasks but respect user choice diff --git a/.codex/skills/openspec-propose/SKILL.md b/.codex/skills/openspec-propose/SKILL.md new file mode 100644 index 00000000..4b57621a --- /dev/null +++ b/.codex/skills/openspec-propose/SKILL.md @@ -0,0 +1,110 @@ +--- +name: openspec-propose +description: Propose a new change with all artifacts generated in one step. Use when the user wants to quickly describe what they want to build and get a complete proposal with design, specs, and tasks ready for implementation. +license: MIT +compatibility: Requires openspec CLI. +metadata: + author: openspec + version: "1.0" + generatedBy: "1.3.0" +--- + +Propose a new change - create the change and generate all artifacts in one step. + +I'll create a change with artifacts: +- proposal.md (what & why) +- design.md (how) +- tasks.md (implementation steps) + +When ready to implement, run /opsx:apply + +--- + +**Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build. + +**Steps** + +1. **If no clear input provided, ask what they want to build** + + Use the **AskUserQuestion tool** (open-ended, no preset options) to ask: + > "What change do you want to work on? Describe what you want to build or fix." + + From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`). + + **IMPORTANT**: Do NOT proceed without understanding what the user wants to build. + +2. **Create the change directory** + ```bash + openspec new change "" + ``` + This creates a scaffolded change at `openspec/changes//` with `.openspec.yaml`. + +3. **Get the artifact build order** + ```bash + openspec status --change "" --json + ``` + Parse the JSON to get: + - `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`) + - `artifacts`: list of all artifacts with their status and dependencies + +4. **Create artifacts in sequence until apply-ready** + + Use the **TodoWrite tool** to track progress through the artifacts. + + Loop through artifacts in dependency order (artifacts with no pending dependencies first): + + a. **For each artifact that is `ready` (dependencies satisfied)**: + - Get instructions: + ```bash + openspec instructions --change "" --json + ``` + - The instructions JSON includes: + - `context`: Project background (constraints for you - do NOT include in output) + - `rules`: Artifact-specific rules (constraints for you - do NOT include in output) + - `template`: The structure to use for your output file + - `instruction`: Schema-specific guidance for this artifact type + - `outputPath`: Where to write the artifact + - `dependencies`: Completed artifacts to read for context + - Read any completed dependency files for context + - Create the artifact file using `template` as the structure + - Apply `context` and `rules` as constraints - but do NOT copy them into the file + - Show brief progress: "Created " + + b. **Continue until all `applyRequires` artifacts are complete** + - After creating each artifact, re-run `openspec status --change "" --json` + - Check if every artifact ID in `applyRequires` has `status: "done"` in the artifacts array + - Stop when all `applyRequires` artifacts are done + + c. **If an artifact requires user input** (unclear context): + - Use **AskUserQuestion tool** to clarify + - Then continue with creation + +5. **Show final status** + ```bash + openspec status --change "" + ``` + +**Output** + +After completing all artifacts, summarize: +- Change name and location +- List of artifacts created with brief descriptions +- What's ready: "All artifacts created! Ready for implementation." +- Prompt: "Run `/opsx:apply` or ask me to implement to start working on the tasks." + +**Artifact Creation Guidelines** + +- Follow the `instruction` field from `openspec instructions` for each artifact type +- The schema defines what each artifact should contain - follow it +- Read dependency artifacts for context before creating new ones +- Use `template` as the structure for your output file - fill in its sections +- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file + - Do NOT copy ``, ``, `` blocks into the artifact + - These guide what you write, but should never appear in the output + +**Guardrails** +- Create ALL artifacts needed for implementation (as defined by schema's `apply.requires`) +- Always read dependency artifacts before creating a new one +- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum +- If a change with that name already exists, ask if user wants to continue it or create a new one +- Verify each artifact file exists after writing before proceeding to next diff --git a/.gitignore b/.gitignore index 69e5c193..49016462 100644 --- a/.gitignore +++ b/.gitignore @@ -56,4 +56,5 @@ coverage/ # AI .kilo/ -.claude/ \ No newline at end of file +.claude/ +.worktrees/ diff --git a/AGENTS.md b/AGENTS.md index a74517ed..e1e9ac93 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,8 +39,8 @@ node scripts/check-crate-boundaries.mjs --strict # 严格模式 - `server` 是唯一组合根,通过 `bootstrap_server_runtime()` 组装所有组件 - `application` 不依赖任何 `adapter-*`,只依赖 `core` + `kernel` + `session-runtime` -- 治理层使用 `AppGovernance`(`astrcode-application`),不使用旧 `RuntimeGovernance`(`astrcode-runtime`) -- 能力语义统一使用 `CapabilitySpec`(`astrcode-core`),传输层使用 `CapabilityDescriptor`(`astrcode-protocol`) +- 治理层使用 `AppGovernance`(`astrcode-application`) +- 能力语义统一使用 `CapabilitySpec`(`astrcode-core`),传输层使用 `CapabilityWireDescriptor`(`astrcode-protocol`) ## 代码规范 @@ -48,19 +48,9 @@ node scripts/check-crate-boundaries.mjs --strict # 严格模式 - 不需要向后兼容,优先良好架构,期望最佳实践而不是打补丁 - Git 提交信息使用 emoji + type + scope 风格(如 `✨ feat(module): brief description`) -## 提交前验证 - -每次提交前按顺序执行: - -1. `cargo fmt --all` — 格式化代码 -2. `cargo clippy --all-targets --all-features -- -D warnings` — 修复所有警告 -3. `cargo test --workspace` — 确保所有测试通过 -4. 确认变更内容后写出描述性提交信息 - ## Gotchas -- 前端css不允许出现webview相关内容这会导致应用端无法下滑窗口 - 文档必须使用中文 - 使用 `node scripts/check-crate-boundaries.mjs` 验证 crate 依赖规则没有被违反 - `src-tauri` 是 Tauri 薄壳,不含业务逻辑 -- `server` 组合根在 `crates/server/src/bootstrap/runtime.rs` \ No newline at end of file +- `server` 组合根在 `crates/server/src/bootstrap/runtime.rs` diff --git a/ASTRCODE_EXPLORATION_REPORT.md b/ASTRCODE_EXPLORATION_REPORT.md index 0c8ca615..512da7c2 100644 --- a/ASTRCODE_EXPLORATION_REPORT.md +++ b/ASTRCODE_EXPLORATION_REPORT.md @@ -79,6 +79,9 @@ - **配置模型**:稳定的配置结构和解析逻辑 - **事件模型**:`AgentEvent`、`StorageEvent` 等领域事件 +与之对应,`CapabilityWireDescriptor` 只存在于 `protocol/plugin` 边界, +用于插件握手和 wire 传输;它不是运行时内部的第二能力模型。 + **设计亮点**: - 完全不依赖其他 crate,保证领域模型的纯粹性 - 使用 `async-trait` 定义异步接口,支持依赖倒置 @@ -159,6 +162,12 @@ pub struct CapabilitySpec { - 权限和副作用声明 - 稳定性标记 +对应的 `CapabilityWireDescriptor` 只是协议载荷名称: + +- 它在当前实现里复用 `CapabilitySpec` 的结构与校验 +- 但职责上仍然只是 transport DTO +- 运行时内部的 prompt、router、policy、plugin supervisor 决策都应围绕 `CapabilitySpec` + ### 2. 事件驱动架构 采用 **Event Sourcing** 模式: @@ -271,41 +280,6 @@ pub enum LlmEvent { - DeepSeek API - 运行时模型切换 -## 发现的问题和建议 - -### 1. 架构层面的优势 - -**优点**: -- ✅ 清晰的分层架构,职责分离明确 -- ✅ 严格的依赖管理,防止架构腐烂 -- ✅ 类型安全的领域建模 -- ✅ 事件驱动架构,支持时间旅行 -- ✅ 组合根模式,依赖关系清晰 - -### 2. 潜在的技术债务 - -**中等优先级**: -- ⚠️ `upstream_collaboration_context` 中的 parent_turn_id 回退可能使用过期值 -- ⚠️ 一些模块仍然较大,可能需要进一步拆分 -- ⚠️ 测试覆盖率有待提高 - -**低优先级**: -- ℹ️ 文档可以更加完善 -- ℹ️ 某些错误处理可以更加精细 - -### 3. 设计决策的观察 - -**值得学习的设计**: -1. **无兼容层策略**:不维护向后兼容,优先良好架构 -2. **组合根模式**:所有依赖在一个地方装配 -3. **事件优先架构**:状态变更通过事件流表达 -4. **能力统一模型**:所有扩展点通过能力系统表达 - -**可能的改进空间**: -1. **性能优化**:某些热点路径可以进一步优化 -2. **错误恢复**:增强错误恢复和重试机制 -3. **可观测性**:增加更详细的指标和追踪 - ## 项目规模评估 ### 代码规模 @@ -357,4 +331,4 @@ Astrcode 是一个架构设计优秀的 AI 编程助手项目,展现了高水 - `README.md`:项目介绍和快速开始 - `CODE_REVIEW_ISSUES.md`:代码审查示例 -这个项目展现了如何在实际项目中应用软件工程的最佳实践,是一个高质量的开源项目参考。 \ No newline at end of file +这个项目展现了如何在实际项目中应用软件工程的最佳实践,是一个高质量的开源项目参考。 diff --git a/CLAUDE.md b/CLAUDE.md index db21f28c..e1e9ac93 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,7 +40,7 @@ node scripts/check-crate-boundaries.mjs --strict # 严格模式 - `server` 是唯一组合根,通过 `bootstrap_server_runtime()` 组装所有组件 - `application` 不依赖任何 `adapter-*`,只依赖 `core` + `kernel` + `session-runtime` - 治理层使用 `AppGovernance`(`astrcode-application`) -- 能力语义统一使用 `CapabilitySpec`(`astrcode-core`),传输层使用 `CapabilityDescriptor`(`astrcode-protocol`) +- 能力语义统一使用 `CapabilitySpec`(`astrcode-core`),传输层使用 `CapabilityWireDescriptor`(`astrcode-protocol`) ## 代码规范 @@ -48,19 +48,9 @@ node scripts/check-crate-boundaries.mjs --strict # 严格模式 - 不需要向后兼容,优先良好架构,期望最佳实践而不是打补丁 - Git 提交信息使用 emoji + type + scope 风格(如 `✨ feat(module): brief description`) -## 提交前验证 - -每次提交前按顺序执行: - -1. `cargo fmt --all` — 格式化代码 -2. `cargo clippy --all-targets --all-features -- -D warnings` — 修复所有警告 -3. `cargo test --workspace` — 确保所有测试通过 -4. 确认变更内容后写出描述性提交信息 - ## Gotchas -- 前端css不允许出现webview相关内容这会导致应用端无法下滑窗口 - 文档必须使用中文 - 使用 `node scripts/check-crate-boundaries.mjs` 验证 crate 依赖规则没有被违反 - `src-tauri` 是 Tauri 薄壳,不含业务逻辑 -- `server` 组合根在 `crates/server/src/bootstrap/runtime.rs` \ No newline at end of file +- `server` 组合根在 `crates/server/src/bootstrap/runtime.rs` diff --git a/Cargo.lock b/Cargo.lock index 0d0b3061..cb8ea8de 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -178,6 +178,7 @@ dependencies = [ "async-trait", "base64 0.22.1", "futures-util", + "libc", "log", "notify", "reqwest", @@ -220,7 +221,6 @@ dependencies = [ name = "astrcode-adapter-storage" version = "0.1.0" dependencies = [ - "astrcode-adapter-mcp", "astrcode-core", "async-trait", "chrono", @@ -238,7 +238,6 @@ dependencies = [ name = "astrcode-adapter-tools" version = "0.1.0" dependencies = [ - "astrcode-adapter-skills", "astrcode-core", "async-trait", "base64 0.22.1", @@ -284,13 +283,16 @@ dependencies = [ "async-trait", "clap", "crossterm", + "pulldown-cmark", "ratatui", "reqwest", "serde", "serde_json", + "textwrap", "thiserror 2.0.18", "tokio", - "unicode-width 0.2.0", + "unicode-segmentation", + "unicode-width", ] [[package]] @@ -324,18 +326,6 @@ dependencies = [ "uuid", ] -[[package]] -name = "astrcode-debug-workbench" -version = "0.1.0" -dependencies = [ - "astrcode-application", - "astrcode-core", - "chrono", - "serde", - "tempfile", - "tokio", -] - [[package]] name = "astrcode-example-plugin" version = "0.1.0" @@ -381,6 +371,7 @@ dependencies = [ name = "astrcode-protocol" version = "0.1.0" dependencies = [ + "astrcode-core", "serde", "serde_json", "thiserror 2.0.18", @@ -390,6 +381,7 @@ dependencies = [ name = "astrcode-sdk" version = "0.1.0" dependencies = [ + "astrcode-core", "astrcode-protocol", "serde", "serde_json", @@ -410,7 +402,6 @@ dependencies = [ "astrcode-adapter-tools", "astrcode-application", "astrcode-core", - "astrcode-debug-workbench", "astrcode-kernel", "astrcode-plugin", "astrcode-protocol", @@ -517,7 +508,7 @@ dependencies = [ "futures-lite", "parking", "polling", - "rustix 1.1.4", + "rustix", "slab", "windows-sys 0.61.2", ] @@ -559,7 +550,7 @@ dependencies = [ "cfg-if", "event-listener", "futures-lite", - "rustix 1.1.4", + "rustix", ] [[package]] @@ -585,7 +576,7 @@ dependencies = [ "cfg-if", "futures-core", "futures-io", - "rustix 1.1.4", + "rustix", "signal-hook-registry", "slab", "windows-sys 0.61.2", @@ -653,6 +644,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -785,15 +785,30 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec 0.6.3", +] + [[package]] name = "bit-set" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" dependencies = [ - "bit-vec", + "bit-vec 0.8.0", ] +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + [[package]] name = "bit-vec" version = "0.8.0" @@ -971,12 +986,6 @@ dependencies = [ "toml 0.9.12+spec-1.1.0", ] -[[package]] -name = "cassowary" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" - [[package]] name = "castaway" version = "0.2.4" @@ -1129,9 +1138,9 @@ dependencies = [ [[package]] name = "compact_str" -version = "0.8.1" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" dependencies = [ "castaway", "cfg-if", @@ -1156,6 +1165,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "cookie" version = "0.18.1" @@ -1270,15 +1288,17 @@ checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" [[package]] name = "crossterm" -version = "0.28.1" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ "bitflags 2.11.0", "crossterm_winapi", + "derive_more 2.1.1", + "document-features", "mio", "parking_lot", - "rustix 0.38.44", + "rustix", "signal-hook", "signal-hook-mio", "winapi", @@ -1303,6 +1323,16 @@ dependencies = [ "typenum", ] +[[package]] +name = "csscolorparser" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" +dependencies = [ + "lab", + "phf 0.11.3", +] + [[package]] name = "cssparser" version = "0.29.6" @@ -1401,6 +1431,12 @@ dependencies = [ "parking_lot_core", ] +[[package]] +name = "deltae" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" + [[package]] name = "deranged" version = "0.5.8" @@ -1417,7 +1453,7 @@ version = "0.99.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" dependencies = [ - "convert_case", + "convert_case 0.4.0", "proc-macro2", "quote", "rustc_version", @@ -1439,6 +1475,7 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ + "convert_case 0.10.0", "proc-macro2", "quote", "rustc_version", @@ -1531,13 +1568,22 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "dom_query" version = "0.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" dependencies = [ - "bit-set", + "bit-set 0.8.0", "cssparser 0.36.0", "foldhash 0.2.0", "html5ever 0.38.0", @@ -1700,6 +1746,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + [[package]] name = "event-listener" version = "5.4.1" @@ -1721,6 +1776,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fancy-regex" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +dependencies = [ + "bit-set 0.5.3", + "regex", +] + [[package]] name = "fastrand" version = "2.3.0" @@ -1746,12 +1811,35 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "finl_unicode" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + [[package]] name = "flate2" version = "1.1.9" @@ -2053,6 +2141,15 @@ dependencies = [ "version_check", ] +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width", +] + [[package]] name = "getrandom" version = "0.1.16" @@ -2284,8 +2381,6 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "allocator-api2", - "equivalent", "foldhash 0.1.5", ] @@ -2294,6 +2389,11 @@ name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] [[package]] name = "heck" @@ -2734,9 +2834,9 @@ checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "itertools" -version = "0.13.0" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" dependencies = [ "either", ] @@ -2882,6 +2982,17 @@ dependencies = [ "serde_json", ] +[[package]] +name = "kasuari" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" +dependencies = [ + "hashbrown 0.16.1", + "portable-atomic", + "thiserror 2.0.18", +] + [[package]] name = "keyboard-types" version = "0.7.0" @@ -2925,6 +3036,18 @@ dependencies = [ "selectors 0.24.0", ] +[[package]] +name = "lab" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "leb128fmt" version = "0.1.0" @@ -2991,10 +3114,13 @@ dependencies = [ ] [[package]] -name = "linux-raw-sys" -version = "0.4.15" +name = "line-clipping" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" +checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" +dependencies = [ + "bitflags 2.11.0", +] [[package]] name = "linux-raw-sys" @@ -3008,6 +3134,12 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.14" @@ -3025,11 +3157,11 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "lru" -version = "0.12.5" +version = "0.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" dependencies = [ - "hashbrown 0.15.5", + "hashbrown 0.16.1", ] [[package]] @@ -3044,6 +3176,16 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" +[[package]] +name = "mac_address" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" +dependencies = [ + "nix", + "winapi", +] + [[package]] name = "markup5ever" version = "0.14.1" @@ -3098,6 +3240,12 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memmem" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" + [[package]] name = "memoffset" version = "0.9.1" @@ -3123,6 +3271,12 @@ dependencies = [ "unicase", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -3202,12 +3356,35 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + [[package]] name = "nodrop" version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "notify" version = "8.2.0" @@ -3241,6 +3418,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -3272,6 +3460,21 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + +[[package]] +name = "numtoa" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aa2c4e539b869820a2b82e1aef6ff40aa85e65decdd5185e83fb4b1249cd00f" + [[package]] name = "objc2" version = "0.6.4" @@ -3431,6 +3634,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + [[package]] name = "ordered-stream" version = "0.2.0" @@ -3505,12 +3717,6 @@ dependencies = [ "windows-link 0.2.1", ] -[[package]] -name = "paste" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" - [[package]] name = "pathdiff" version = "0.2.3" @@ -3523,6 +3729,49 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pest" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pest_meta" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +dependencies = [ + "pest", + "sha2", +] + [[package]] name = "phf" version = "0.8.0" @@ -3769,7 +4018,7 @@ dependencies = [ "concurrent-queue", "hermit-abi", "pin-project-lite", - "rustix 1.1.4", + "rustix", "windows-sys 0.61.2", ] @@ -3902,6 +4151,18 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pulldown-cmark" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57206b407293d2bcd3af849ce869d52068623f19e1b5ff8e8778e3309439682b" +dependencies = [ + "bitflags 2.11.0", + "getopts", + "memchr", + "unicase", +] + [[package]] name = "quick-xml" version = "0.38.4" @@ -4126,23 +4387,99 @@ dependencies = [ [[package]] name = "ratatui" -version = "0.29.0" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +checksum = "d1ce67fb8ba4446454d1c8dbaeda0557ff5e94d39d5e5ed7f10a65eb4c8266bc" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-macros", + "ratatui-termion", + "ratatui-termwiz", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293" dependencies = [ "bitflags 2.11.0", - "cassowary", "compact_str", - "crossterm", + "hashbrown 0.16.1", "indoc", - "instability", "itertools", + "kasuari", "lru", - "paste", "strum", + "thiserror 2.0.18", "unicode-segmentation", "unicode-truncate", - "unicode-width 0.2.0", + "unicode-width", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "577c9b9f652b4c121fb25c6a391dd06406d3b092ba68827e6d2f09550edc54b3" +dependencies = [ + "cfg-if", + "crossterm", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-macros" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7f1342a13e83e4bb9d0b793d0ea762be633f9582048c892ae9041ef39c936f4" +dependencies = [ + "ratatui-core", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-termion" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cade85a8591fbc911e147951422f0d6fd40f4948b271b6216c7dc01838996f8" +dependencies = [ + "instability", + "ratatui-core", + "termion", +] + +[[package]] +name = "ratatui-termwiz" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f76fe0bd0ed4295f0321b1676732e2454024c15a35d01904ddb315afd3d545c" +dependencies = [ + "ratatui-core", + "termwiz", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db" +dependencies = [ + "bitflags 2.11.0", + "hashbrown 0.16.1", + "indoc", + "instability", + "itertools", + "line-clipping", + "ratatui-core", + "strum", + "time", + "unicode-segmentation", + "unicode-width", ] [[package]] @@ -4313,19 +4650,6 @@ dependencies = [ "semver", ] -[[package]] -name = "rustix" -version = "0.38.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" -dependencies = [ - "bitflags 2.11.0", - "errno", - "libc", - "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", -] - [[package]] name = "rustix" version = "1.1.4" @@ -4335,7 +4659,7 @@ dependencies = [ "bitflags 2.11.0", "errno", "libc", - "linux-raw-sys 0.12.1", + "linux-raw-sys", "windows-sys 0.61.2", ] @@ -4886,6 +5210,12 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "smawk" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" + [[package]] name = "socket2" version = "0.6.3" @@ -5013,23 +5343,22 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "strum" -version = "0.26.3" +version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" dependencies = [ "strum_macros", ] [[package]] name = "strum_macros" -version = "0.26.4" +version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "rustversion", "syn 2.0.117", ] @@ -5421,7 +5750,7 @@ dependencies = [ "fastrand", "getrandom 0.4.2", "once_cell", - "rustix 1.1.4", + "rustix", "windows-sys 0.61.2", ] @@ -5446,6 +5775,90 @@ dependencies = [ "utf-8", ] +[[package]] +name = "terminfo" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" +dependencies = [ + "fnv", + "nom", + "phf 0.11.3", + "phf_codegen 0.11.3", +] + +[[package]] +name = "termion" +version = "4.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f44138a9ae08f0f502f24104d82517ef4da7330c35acd638f1f29d3cd5475ecb" +dependencies = [ + "libc", + "numtoa", +] + +[[package]] +name = "termios" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" +dependencies = [ + "libc", +] + +[[package]] +name = "termwiz" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" +dependencies = [ + "anyhow", + "base64 0.22.1", + "bitflags 2.11.0", + "fancy-regex", + "filedescriptor", + "finl_unicode", + "fixedbitset", + "hex", + "lazy_static", + "libc", + "log", + "memmem", + "nix", + "num-derive", + "num-traits", + "ordered-float", + "pest", + "pest_derive", + "phf 0.11.3", + "sha2", + "signal-hook", + "siphasher 1.0.2", + "terminfo", + "termios", + "thiserror 1.0.69", + "ucd-trie", + "unicode-segmentation", + "vtparse", + "wezterm-bidi", + "wezterm-blob-leases", + "wezterm-color-types", + "wezterm-dynamic", + "wezterm-input-types", + "winapi", +] + +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "smawk", + "unicode-linebreak", + "unicode-width", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -5494,7 +5907,9 @@ checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", + "libc", "num-conv", + "num_threads", "powerfmt", "serde_core", "time-core", @@ -5840,6 +6255,12 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "uds_windows" version = "1.2.1" @@ -5904,6 +6325,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + [[package]] name = "unicode-segmentation" version = "1.13.2" @@ -5912,26 +6339,20 @@ checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" [[package]] name = "unicode-truncate" -version = "1.1.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" dependencies = [ "itertools", "unicode-segmentation", - "unicode-width 0.1.14", + "unicode-width", ] [[package]] name = "unicode-width" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" - -[[package]] -name = "unicode-width" -version = "0.2.0" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" [[package]] name = "unicode-xid" @@ -6006,6 +6427,7 @@ version = "1.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" dependencies = [ + "atomic", "getrandom 0.4.2", "js-sys", "serde_core", @@ -6045,6 +6467,15 @@ dependencies = [ "libc", ] +[[package]] +name = "vtparse" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" +dependencies = [ + "utf8parse", +] + [[package]] name = "walkdir" version = "2.5.0" @@ -6204,7 +6635,7 @@ checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" dependencies = [ "cc", "downcast-rs", - "rustix 1.1.4", + "rustix", "scoped-tls", "smallvec", "wayland-sys", @@ -6217,7 +6648,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" dependencies = [ "bitflags 2.11.0", - "rustix 1.1.4", + "rustix", "wayland-backend", "wayland-scanner", ] @@ -6377,6 +6808,78 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "wezterm-bidi" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" +dependencies = [ + "log", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-blob-leases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" +dependencies = [ + "getrandom 0.3.4", + "mac_address", + "sha2", + "thiserror 1.0.69", + "uuid", +] + +[[package]] +name = "wezterm-color-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" +dependencies = [ + "csscolorparser", + "deltae", + "lazy_static", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-dynamic" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" +dependencies = [ + "log", + "ordered-float", + "strsim", + "thiserror 1.0.69", + "wezterm-dynamic-derive", +] + +[[package]] +name = "wezterm-dynamic-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "wezterm-input-types" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" +dependencies = [ + "bitflags 1.3.2", + "euclid", + "lazy_static", + "serde", + "wezterm-dynamic", +] + [[package]] name = "winapi" version = "0.3.9" @@ -7052,7 +7555,7 @@ dependencies = [ "hex", "libc", "ordered-stream", - "rustix 1.1.4", + "rustix", "serde", "serde_repr", "tracing", diff --git a/Cargo.toml b/Cargo.toml index cd8a71e4..61c403f7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,7 +16,6 @@ members = [ "crates/sdk", "crates/adapter-tools", "crates/adapter-mcp", - "crates/debug-workbench", "crates/server", "examples/example-plugin", "src-tauri", diff --git a/PROJECT_ARCHITECTURE.md b/PROJECT_ARCHITECTURE.md index db87aecc..8676be66 100644 --- a/PROJECT_ARCHITECTURE.md +++ b/PROJECT_ARCHITECTURE.md @@ -82,6 +82,8 @@ crates/ - HTTP request/response DTO - SSE event DTO - wire-level enum / payload +- `CapabilityWireDescriptor` 是 plugin/protocol 边界上的 transport DTO; + `CapabilitySpec` 仍然是运行时内部唯一的能力语义真相 - `protocol` 可以表达: - 字段命名 - 可选字段 @@ -150,7 +152,7 @@ agent delegation experience 也遵循同样的分层边界: - 共享协作协议由 `adapter-prompt::contributors::workflow_examples` 承担,只负责四工具的通用决策规则与 fresh / resumed / restricted 三种 delegation mode。 - 行为模板目录由 `adapter-prompt::contributors::agent_profile_summary` 承担,只回答“什么样的 child 适合做什么事”,不能伪装成 capability 授权目录。 - child 专属 execution contract 只能通过 `PromptDeclaration` 注入,沿 child launch / resume 的 submission-time 链路进入 prompt,不能回退到工具 description 或 profile 文本拼装。 -- `observe` / collaboration result 中的 reuse / close / respawn 建议只能是 advisory projection;真正的事实源仍然是 lifecycle、turn outcome、mailbox 投影与 resolved capability surface。 +- `observe` / collaboration result 中的 reuse / close / respawn 建议只能是 advisory projection;真正的事实源仍然是 lifecycle、turn outcome、input queue 投影与 resolved capability surface。 ### 4.5 `session-runtime` @@ -161,7 +163,7 @@ agent delegation experience 也遵循同样的分层边界: - transcript snapshot/replay - interrupt/compact/run_turn - branch/subrun 的单 session 执行真相 - - child delivery / mailbox / observe 的会话内推进部分 +- child delivery / input queue / observe 的会话内推进部分 - context window / compaction / request assembly - `session-runtime` 不负责: - 全局 plugin discovery @@ -174,7 +176,7 @@ agent delegation experience 也遵循同样的分层边界: 1. **优先 Event Log,再做投影** - 长期目标是把 session 视为 append-only event log,而不是一组可变字段。 - - durable 真相来自 `SessionLog`;`history view`、`branch snapshot`、mailbox replay 等读模型都应以 log 投影为准。 + - durable 真相来自 `SessionLog`;`history view`、`branch snapshot`、input queue replay 等读模型都应以 log 投影为准。 - `SessionState` 不是“纯 log projection”,而是 `projection cache + live execution control`:例如 `running`、`cancel token`、turn lease、待执行 compact 请求这类运行态协调信息允许只存在于 live state。 - 当前项目已经部分符合这个方向:`EventStore.append/replay`、`SessionState` 内部 projector、`history/replay` 查询都建立在事件回放之上。 - 长期仍应继续收口:减少“字段即 durable 真相”的写法,让 `SessionLog -> Projection` 与 `LiveControlState` 的边界都显式可见。 @@ -193,13 +195,13 @@ agent delegation experience 也遵循同样的分层边界: - compaction 策略可替换 - budget 决策与 prompt 拼装解耦 -4. **Mailbox / child delivery 优先使用类型化消息契约** +4. **Input Queue / child delivery 优先使用类型化消息契约** - child delivery、interrupt、observe、wake、close 这些能力,长期应尽量通过明确的消息类型表达,而不是靠共享可变状态和隐式约定拼接。 - - 当前项目已经有 durable mailbox 事件、Envelope DTO 与投影;这部分是 durable contract,不应再重复包装出第二套“伪类型化 mailbox”。 + - 当前项目已经有 durable input queue 事件、Envelope DTO 与投影;这部分是 durable contract,不应再重复包装出第二套“伪类型化 input queue”。 - 后续真正需要继续 typed 化的是 `SessionActor` / wake / interrupt / close 的 live command path,让 actor loop 按明确消息处理运行态协调,而不是继续扩张共享状态与隐式约定。 5. **Query 与 Command 逻辑继续分离** - - 写侧负责追加事件、推进 turn、驱动 mailbox。 + - 写侧负责追加事件、推进 turn、驱动 input queue。 - 读侧负责 transcript snapshot、stream replay、context view 等投影查询。 - 当前项目已经开始分离:有 `query/` 模块,也有 `SessionQueries` / `SessionCommands` 这类内部 helper。 - `SessionRuntime` 对外仍是过渡 façade,但应只承担构造和薄委托;新增读写能力时,优先落到 `query` 或 `command` 子域,再由 façade 暴露稳定入口。 @@ -222,7 +224,7 @@ agent delegation experience 也遵循同样的分层边界: - allowed:replay/live 订阅语义、scope/filter、状态来源整合 - forbidden:同步快照投影算法、turn 推进、副作用 - `query` - - allowed:拉取、快照、投影、durable replay 后的只读恢复 + - allowed:拉取、快照、投影、durable replay 后的只读恢复、conversation/tool display authoritative read model - forbidden:推进、副作用、长时间持有运行态协调逻辑 - `factory` - allowed:执行输入或执行对象构造 @@ -230,8 +232,8 @@ agent delegation experience 也遵循同样的分层边界: `application` 在这个边界上继续保持: -- allowed:参数校验、权限检查、错误归类、跨 session 编排、稳定 `SessionRuntime` API 调用 -- forbidden:单 session 终态投影细节、durable append 细节、observe 快照拼装细节 +- allowed:参数校验、权限检查、错误归类、跨 session 编排、稳定 `SessionRuntime` API 调用、conversation/tool display facts 编排 +- forbidden:单 session 终态投影细节、durable append 细节、observe 快照拼装细节、tool block 聚合与 replay/live 去重规则 ### 4.6 `adapter-*` @@ -271,7 +273,11 @@ agent delegation experience 也遵循同样的分层边界: - `conversation v1` 是当前唯一的产品读协议,统一承担 snapshot、stream、child summaries、slash candidates 与 control state 的 authoritative read model。 - 旧 `/api/sessions/*/view`、`/history`、`/events` 产品读面已删除;客户端不得再通过本地 reducer 重放原始事件来重建 UI 真相。 -- `server` 内部的 `terminal_projection` 负责把 `application` 返回的 `ConversationFacts` 投影成 `protocol` DTO;它是纯 projection,不做业务校验。 +- `conversation` contract 必须直接表达后端收敛后的 tool display 语义: + - 一个 tool call 只对应一个 authoritative tool block + - stdout/stderr 通过同一 block 的 stream patch 增量更新 + - error、duration、truncated 与 childRef 都是一等字段 +- `server` 内部的 `terminal_projection` 负责把 `application` 返回的 `ConversationFacts` 投影成 `protocol` DTO;它是纯 projection,不做业务校验,也不重新承担 tool 聚合。 - `client` crate 只负责: - bootstrap exchange 后的 typed HTTP/SSE facade - 结构化错误归一化 @@ -295,10 +301,12 @@ agent delegation experience 也遵循同样的分层边界: - `cli` 不允许在本地复制平行业务语义: - slash command 只是输入壳,语义必须映射到稳定 server contract - child pane / transcript 只能消费 authoritative conversation surface + - 不得通过 sibling `tool_stream`、metadata fallback 或本地 regroup 恢复工具展示真相 ## 5. 关键不变量 - `CapabilitySpec` 是运行时内部唯一能力语义模型。 +- `CapabilityWireDescriptor` 只允许出现在协议边界与 transport adapter 中,不能成为运行时内部事实源。 - HTTP 状态码映射只在 `server` 层发生。 - `SessionActor` 不直接持有 provider;统一经由 `kernel` gateway 或已解析句柄。 - `application::App` 不保存 session shadow state;session 列表、history、replay、turn 推进都由 `session-runtime` 提供。 diff --git a/README.md b/README.md index 2841aabd..1bda441e 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,52 @@ # AstrCode -一个 AI 编程助手应用,支持桌面端(Tauri)和浏览器端,基于 HTTP/SSE Server 架构实现前后端解耦。 +一个 AI 编程助手,支持桌面端(Tauri)、浏览器端和终端(CLI),基于 Rust + React 构建的 HTTP/SSE 分层架构。 ## 功能特性 -- **多模型支持**:支持 OpenAI 兼容 API(DeepSeek、OpenAI 等),运行时可切换 Profile 和 Model -- **流式响应**:实时显示 AI 生成的代码和文本 -- **多工具调用**:内置文件操作、代码搜索、Shell 执行等工具 -- **会话管理**:支持多会话切换、按项目分组、会话历史浏览 -- **插件系统**:支持 stdio 插件扩展能力 -- **双模式运行**: +- **多模型支持**:支持 Anthropic Claude、OpenAI 兼容 API(DeepSeek、OpenAI 等),运行时切换 Profile 和 Model +- **流式响应**:实时显示 AI 生成的代码和文本,支持 thinking 内容展示 +- **内置工具集**:文件读写、编辑、搜索、Shell 执行、Skill 加载等 +- **Agent 协作**:支持主/子 Agent 模式,内置 spawn / send / observe / close 工具链 +- **Skill 系统**:Claude 风格两阶段 Skill 加载,支持项目级、用户级和内置 Skill +- **MCP 支持**:完整的 Model Context Protocol 接入,支持 stdio / HTTP / SSE 传输 +- **插件系统**:基于 stdio JSON-RPC 的插件扩展,提供 Rust SDK(未完善) +- **会话管理**:多会话切换、按项目分组、事件溯源持久化、会话历史浏览 +- **三种运行模式**: - **桌面端**:Tauri 打包,自动管理本地 Server - **浏览器端**:独立运行 Server,浏览器访问 + - **终端**:ratatui TUI 界面,本地或远程连接 Server + +## 技术栈 + +| 层级 | 技术 | +|------|------| +| 后端 | Rust (nightly), Axum, Tokio, Tower | +| 前端 | React 18, TypeScript, Vite, Tailwind CSS | +| 桌面端 | Tauri 2 | +| 终端 | ratatui, crossterm | +| 通信 | HTTP/SSE, JSON-RPC (stdio) | +| 持久化 | JSONL 事件日志, 文件系统存储 | +| CI | GitHub Actions, cargo-deny | ## 内置工具 | 工具 | 描述 | |------|------| | `Skill` | 按需加载 Claude 风格 `SKILL.md` 指南与 `references/` / `scripts/` 等资源 | -| `read_file` | 读取文件内容 | -| `write_file` | 写入或创建文件,并返回结构化 diff metadata | -| `edit_file` | 精确替换文件内容(唯一匹配验证),并返回结构化 diff metadata | -| `list_dir` | 列出目录内容 | -| `find_files` | Glob 模式文件搜索 | +| `readFile` | 读取文件内容 | +| `writeFile` | 写入或创建文件,并返回结构化 diff metadata | +| `editFile` | 精确替换文件内容(唯一匹配验证),并返回结构化 diff metadata | +| `apply_patch` | 应用 unified diff 格式的多文件批量变更 | +| `listDir` | 列出目录内容 | +| `findFiles` | Glob 模式文件搜索 | | `grep` | 正则表达式内容搜索 | | `shell` | 执行 Shell 命令,stdout/stderr 以流式事件增量展示 | +| `tool_search` | 搜索可用工具 | +| `spawn` | 创建子 Agent | +| `send` | 向 Agent 发送消息 | +| `observe` | 观察 Agent 状态 | +| `close` | 关闭 Agent | ## 快速开始 @@ -44,11 +66,11 @@ npm install cd frontend && npm install ``` -执行根目录或 `frontend` 的 `npm install` 时,会自动把仓库的 `core.hooksPath` 指向 `.githooks/`。仓库现在按三层校验运行: +执行 `npm install` 时,会自动把仓库的 `core.hooksPath` 指向 `.githooks/`。三层校验: -- `pre-commit`:只做快检查,自动格式化 Rust / 前端改动,修复已暂存 TS/TSX 的 ESLint 问题,并阻止大文件、冲突标记和明显密钥泄漏进入提交。 -- `pre-push`:做中等检查,运行 `cargo check --workspace`、`cargo test --workspace --exclude astrcode --lib` 和前端 `typecheck`。 -- GitHub Actions:保留完整校验,执行格式检查、clippy、全量 Rust 测试、前端 lint/format 校验,以及依赖审查与发布构建流程。 +- `pre-commit`:快速检查 — 自动格式化 Rust / 前端改动,修复已暂存 TS/TSX 的 ESLint 问题,阻止大文件、冲突标记和密钥泄漏 +- `pre-push`:中等检查 — `cargo check --workspace`、`cargo test --workspace --exclude astrcode --lib` 和前端 `typecheck` +- GitHub Actions:完整校验 — 格式检查、clippy、全量 Rust 测试、前端 lint/format、依赖审查与发布构建 ### 开发模式 @@ -98,11 +120,6 @@ cd frontend && npm run build "id": "deepseek-chat", "maxTokens": 8096, "contextLimit": 128000 - }, - { - "id": "deepseek-reasoner", - "maxTokens": 8096, - "contextLimit": 128000 } ] } @@ -110,16 +127,6 @@ cd frontend && npm run build } ``` -`runtime` 用于放置 AstrCode 自己的进程级运行参数,例如: - -```json -{ - "runtime": { - "maxToolConcurrency": 10 - } -} -``` - ### API Key 配置 `apiKey` 字段支持三种方式: @@ -128,33 +135,14 @@ cd frontend && npm run build 2. **明文字面量**:直接填写 API Key(如 `sk-xxxx`) 3. **字面量前缀**:`literal:MY_VALUE`,用于强制把看起来像环境变量名的字符串按普通文本处理 -推荐优先使用 `env:...`,这样配置文件的含义最明确,不会让用户误以为 AstrCode 会自动把任意裸字符串当成环境变量读取。 - -### 模型 limits 配置 - -`models` 现在是对象列表,而不再是纯字符串数组: +推荐优先使用 `env:...`,配置含义最明确。 -- OpenAI-compatible profile 必须为每个模型手动设置 `maxTokens` 和 `contextLimit` -- Anthropic profile 会在运行时通过 `GET /v1/models/{model_id}` 自动获取 `max_input_tokens` 和 `max_tokens` -- 如果 Anthropic 远端探测失败,但本地模型对象里同时写了 `maxTokens` 和 `contextLimit`,运行时会回退到本地值 +### 模型配置 -这让上下文窗口和最大输出 token 的来源保持单一且清晰,不再由 provider 内部各自硬编码。 +`models` 为对象列表,每个模型需要配置 `maxTokens` 和 `contextLimit`: -### 内建环境变量 - -项目自定义环境变量按类别集中维护在 `crates/application/src/config/constants.rs`,底层稳定常量源头在 `crates/core/src/env.rs`,避免低层 crate 反向依赖应用编排层。 - -| 类别 | 环境变量 | 作用 | -|------|------|------| -| Home / 测试隔离 | `ASTRCODE_HOME_DIR` | 覆盖 Astrcode 的 home 目录 | -| Home / 测试隔离 | `ASTRCODE_TEST_HOME` | 为测试隔离临时 home 目录 | -| Plugin | `ASTRCODE_PLUGIN_DIRS` | 追加插件发现目录,按系统路径分隔符解析 | -| Provider 默认值 | `DEEPSEEK_API_KEY` | DeepSeek 默认 profile 的 API Key | -| Provider 默认值 | `ANTHROPIC_API_KEY` | Anthropic 默认 profile 的 API Key | -| Runtime | `ASTRCODE_MAX_TOOL_CONCURRENCY` | `runtime.maxToolConcurrency` 未设置时的并发工具上限兜底 | -| Build / Tauri | `TAURI_ENV_TARGET_TRIPLE` | 构建 sidecar 时指定目标 triple | - -像 `OPENAI_API_KEY` 这类自定义 profile 使用的环境变量仍然允许自由命名,但不属于平台内建环境变量目录。 +- **OpenAI-compatible profile**:手动设置 `maxTokens` 和 `contextLimit` +- **Anthropic profile**:`contextLimit` 默认 200,000,`maxTokens` 默认 8,192;若配置中显式设置了这些值则使用配置值 ### 多 Profile 配置 @@ -165,118 +153,194 @@ cd frontend && npm run build "profiles": [ { "name": "deepseek", + "providerKind": "openai-compatible", "baseUrl": "https://api.deepseek.com", "apiKey": "env:DEEPSEEK_API_KEY", - "models": [ - { - "id": "deepseek-chat", - "maxTokens": 8096, - "contextLimit": 128000 - } - ] + "models": [{ "id": "deepseek-chat", "maxTokens": 8096, "contextLimit": 128000 }] + }, + { + "name": "anthropic", + "providerKind": "anthropic", + "baseUrl": "https://api.anthropic.com", + "apiKey": "env:ANTHROPIC_API_KEY", + "models": [{ "id": "claude-sonnet-4-5-20250514" }] }, { "name": "openai", + "providerKind": "openai-compatible", "baseUrl": "https://api.openai.com", "apiKey": "env:OPENAI_API_KEY", "models": [ - { - "id": "gpt-4o", - "maxTokens": 16384, - "contextLimit": 200000 - }, - { - "id": "gpt-4o-mini", - "maxTokens": 16384, - "contextLimit": 128000 - } + { "id": "gpt-4o", "maxTokens": 16384, "contextLimit": 200000 }, + { "id": "gpt-4o-mini", "maxTokens": 16384, "contextLimit": 128000 } ] } ] } ``` +### Runtime 配置 + +`runtime` 用于放置 AstrCode 进程级运行参数: + +```json +{ + "runtime": { + "maxToolConcurrency": 10, + "compactKeepRecentTurns": 4, + "compactKeepRecentUserMessages": 8, + "compactMaxOutputTokens": 20000 + } +} +``` + +| 参数 | 默认值 | 说明 | +|------|--------|------| +| `maxToolConcurrency` | 10 | 并发工具上限 | +| `compactKeepRecentTurns` | 4 | 压缩时保留最近的 turn 数 | +| `compactKeepRecentUserMessages` | 8 | 压缩时额外保留最近真实用户消息的数量(原文重新注入) | +| `compactMaxOutputTokens` | 20000 | 压缩请求的最大输出 token 上限(自动取模型限制的较小值) | + +### 内建环境变量 + +项目自定义环境变量按类别集中维护在 `crates/application/src/config/constants.rs`: + +| 类别 | 环境变量 | 作用 | +|------|----------|------| +| Home / 测试隔离 | `ASTRCODE_HOME_DIR` | 覆盖 AstrCode 的 home 目录 | +| Home / 测试隔离 | `ASTRCODE_TEST_HOME` | 为测试隔离临时 home 目录 | +| Plugin | `ASTRCODE_PLUGIN_DIRS` | 追加插件发现目录,按系统路径分隔符解析 | +| Provider 默认值 | `DEEPSEEK_API_KEY` | DeepSeek 默认 profile 的 API Key | +| Provider 默认值 | `ANTHROPIC_API_KEY` | Anthropic 默认 profile 的 API Key | +| Runtime | `ASTRCODE_MAX_TOOL_CONCURRENCY` | 并发工具上限兜底 | +| Build / Tauri | `TAURI_ENV_TARGET_TRIPLE` | 构建 sidecar 时指定目标 triple | + ## 项目结构 ``` AstrCode/ ├── crates/ -│ ├── core/ # 领域语义、强类型 ID、端口契约、稳定配置模型 -│ ├── protocol/ # HTTP / SSE / Plugin DTO +│ ├── core/ # 领域模型、强类型 ID、端口契约、CapabilitySpec、稳定配置 +│ ├── protocol/ # HTTP/SSE/Plugin DTO 与 wire 类型(含 CapabilityWireDescriptor) │ ├── kernel/ # 全局控制面:surface / registry / agent tree / events │ ├── session-runtime/ # 单会话真相:state / turn / replay / context window │ ├── application/ # 用例编排、执行控制、治理与观测 │ ├── server/ # Axum HTTP/SSE 边界与唯一组合根 -│ ├── adapter-storage/ # EventStore、ConfigStore 等存储实现 -│ ├── adapter-llm/ # LLM provider 适配 -│ ├── adapter-prompt/ # Prompt provider 适配 -│ ├── adapter-tools/ # 内置工具与 capability 桥接 -│ ├── adapter-skills/ # Skill 加载与物化 -│ ├── adapter-mcp/ # MCP 传输、连接管理与资源接入 -│ ├── adapter-agents/ # Agent profile 加载 -│ ├── plugin/ # stdio 插件模型与宿主基础设施 -│ └── sdk/ # 插件作者 API -├── examples/ # 示例插件与示例 manifest -├── src-tauri/ # Tauri 薄壳:sidecar 管理、窗口控制 -├── frontend/ # React + TypeScript + Vite UI -│ ├── src/ -│ │ ├── components/ # React 组件 -│ │ ├── hooks/ # 自定义 hooks -│ │ └── lib/ # 工具函数 -└── scripts/ # 开发脚本 +│ ├── adapter-storage/ # JSONL 事件日志持久化与文件系统存储 +│ ├── adapter-llm/ # LLM provider(Anthropic / OpenAI-compatible) +│ ├── adapter-prompt/ # Prompt 组装(贡献者模式 + 分层缓存构建) +│ ├── adapter-tools/ # 内置工具定义与 Agent 协作工具 +│ ├── adapter-skills/ # Skill 发现、解析、物化与目录管理 +│ ├── adapter-mcp/ # MCP 协议支持(stdio/HTTP/SSE 传输 + 能力桥接) +│ ├── adapter-agents/ # Agent profile 加载与注册表(builtin/user/project 级) +│ ├── client/ # 类型化 HTTP/SSE 客户端 SDK +│ ├── cli/ # 终端 TUI 客户端(ratatui) +│ ├── plugin/ # stdio JSON-RPC 插件宿主基础设施 +│ └── sdk/ # 插件开发者 Rust SDK +├── examples/ # 示例插件与示例 manifest +├── src-tauri/ # Tauri 薄壳:sidecar 管理、窗口控制、bootstrap 注入 +├── frontend/ # React + TypeScript + Vite + Tailwind CSS +│ └── src/ +│ ├── components/ # React 组件(Chat / Sidebar / Settings) +│ ├── hooks/ # 自定义 hooks(useAgent 等) +│ └── lib/ # API 客户端、SSE 事件处理、工具函数 +└── scripts/ # 开发脚本(Git hooks、crate 边界检查等) ``` ## 架构 -### HTTP/SSE Server 架构 - -系统采用前后端分离架构,Server 是唯一的业务入口: +### 分层架构概览 ``` -┌─────────────────────────────────────────────────────────┐ -│ Frontend │ -│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ -│ │ React UI │───▶│ useAgent │───▶│ HostBridge │ │ -│ └─────────────┘ │ fetch/SSE │ │ (桌面/浏览器)│ │ -│ └──────┬──────┘ └─────────────┘ │ -└────────────────────────────┼────────────────────────────┘ - │ HTTP/SSE - ▼ -┌────────────────────────────────────────────────────────┐ -│ astrcode-server │ -│ ┌─────────────┐ ┌──────────────┐ ┌────────────┐ │ -│ │ Axum Router│───▶│ application │───▶│ kernel │ │ -│ │ /api/* │ │ App / Gov. │ │ surface │ │ -│ └─────────────┘ └──────┬───────┘ └─────┬──────┘ │ -│ ┌─────────────┐ │ │ │ -│ │Protocol DTO │◀──────────┘ ┌──────▼──────┐ │ -│ └─────────────┘ │session- │ │ -│ ┌─────────────┐ │runtime │ │ -│ │Auth/Bootstrap│ │turn/replay │ │ -│ └─────────────┘ └─────────────┘ │ -└────────────────────────────────────────────────────────┘ +┌─────────────────────────────────────────────────────────────┐ +│ 前端(三种接入方式) │ +│ ┌──────────┐ ┌──────────┐ ┌──────────────────────────┐ │ +│ │ Tauri UI │ │ Browser │ │ CLI (ratatui TUI) │ │ +│ │ HostBrdg │ │ fetch/SSE│ │ client crate + launcher │ │ +│ └────┬─────┘ └────┬─────┘ └────────────┬─────────────┘ │ +└───────┼──────────────┼──────────────────────┼────────────────┘ + │ │ HTTP/SSE │ HTTP/SSE + ▼ ▼ ▼ +┌─────────────────────────────────────────────────────────────┐ +│ astrcode-server │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │ +│ │ Axum Router │─▶│ application │─▶│ kernel │ │ +│ │ /api/* │ │ App / Gov. │ │ surface / events │ │ +│ └──────────────┘ └──────┬───────┘ └────────┬─────────┘ │ +│ ┌──────────────┐ │ │ │ +│ │ Protocol DTO │◀────────┤ ┌────────▼────────┐ │ +│ └──────────────┘ │ │ session-runtime │ │ +│ ┌──────────────┐ │ │ turn / replay │ │ +│ │ Auth/Bootstrp│ │ │ context window │ │ +│ └──────────────┘ │ └─────────────────┘ │ +│ ▼ │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ adapter-* : storage | llm | prompt | tools | skills │ │ +│ │ | mcp | agents │ │ +│ └──────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ ``` -### 当前核心分层 +### 核心分层职责 + +- **`core`**:领域语义、强类型 ID、端口契约、`CapabilitySpec`、稳定配置模型。不依赖传输层或具体实现;`CapabilitySpec` 是运行时内部的能力语义真相。 +- **`protocol`**:HTTP/SSE/Plugin 的 DTO 与 wire 类型,仅依赖 `core`;其中 `CapabilityWireDescriptor` 只承担协议边界传输职责,不是运行时内部的能力真相。 +- **`kernel`**:全局控制面 — capability router/registry、agent tree、统一事件协调。 +- **`session-runtime`**:单会话真相 — turn 执行、事件回放、compact(保留最近用户消息 + 摘要 + 输出上限控制)、context window、input queue 推进。 +- **`application`**:用例编排入口(`App`)+ 治理入口(`AppGovernance`),负责参数校验、权限、策略、reload 编排。通过 `AppAgentPromptSubmission` 端口向 session-runtime 提交 turn。 +- **`server`**:HTTP/SSE 边界与唯一组合根(`bootstrap/runtime.rs`),只负责 DTO 映射和装配。 +- **`adapter-*`**:端口实现层,不持有业务真相,不偷渡业务策略。核心类型(`LlmProvider`、`LlmRequest`、`EventStore` 等)统一在 `core` 定义,adapter 仅提供具体实现。 + +### Agent 协作 + +- 内置 Agent profile:explore、reviewer、execute +- Agent 文件来源:builtin + 用户级(`~/.astrcode/agents`)+ 项目级(`.astrcode/agents`,祖先链扫描) +- 子 Agent spawn 时按 task-scoped capability grant 裁剪能力面 +- Agent 工具链:`spawn` -> `send` -> `observe` -> `close` 全生命周期管理 + +### Skill 系统 + +- 两阶段加载:system prompt 先展示 skill 索引,命中后再调用 `Skill` tool 加载完整 `SKILL.md` +- 目录格式:`skill-name/SKILL.md`(Markdown + YAML frontmatter) +- 加载来源:builtin(运行时物化到 `~/.astrcode/runtime/builtin-skills/`)+ 项目级 + 用户级 +- 资产目录(`references/`、`scripts/`)随 skill 一起索引 + +### MCP 支持 + +- 完整 MCP 协议实现:JSON-RPC 消息、工具/prompt/资源/skill 桥接 +- 传输方式:stdio、HTTP、SSE +- 连接管理:状态机、自动重连、热加载 +- 配置集成:通过 config.json 声明 MCP server,reload 时统一刷新 + +### 插件系统 + +- 基于 stdio JSON-RPC 双向通信 +- 插件生命周期管理(discovered -> loaded -> failed -> disabled) +- 能力路由与权限检查 +- 流式执行支持 +- 提供 Rust SDK(`crates/sdk`),包含 `ToolHandler`、`HookRegistry`、`PluginContext`、`StreamWriter` +- 插件握手交换的是 `CapabilityWireDescriptor`;宿主内部消费和决策始终基于 `CapabilitySpec` -- `application::App` 是同步业务用例入口,`application::AppGovernance` 是治理与 reload 入口。 -- `kernel` 维护统一 capability surface、tool/router、agent tree 与全局控制合同。 -- `session-runtime` 只回答单个 session 的真相,包括 turn 执行、重放、compact、context window 和 mailbox 推进。 -- `server` 只负责 DTO 映射、HTTP/SSE 状态码与组合根,不再暴露旧式 `RuntimeService` 门面。 +### 会话持久化与上下文压缩 -### Reload 与观测语义 +- JSONL 格式追加写入(append-only event log) +- 存储路径:`~/.astrcode/projects//sessions//` +- 文件锁并发保护(`active-turn.lock`) +- Query / Command 逻辑分离 -- `POST /api/config/reload` 现在走统一治理入口,而不是“只重读配置文件”。 -- 一次 reload 会串起:配置重载、MCP 配置刷新、plugin 重新发现、skill base 更新,以及 kernel capability surface 的一次性替换。 -- 如果存在运行中的 session,请求会被拒绝并返回冲突错误,避免半刷新期间出现执行语义漂移。 -- runtime status 接口返回真实 observability 快照,包含 `sessionRehydrate`、`sseCatchUp`、`turnExecution`、`subrunExecution` 与 `executionDiagnostics`,不再长期返回零值占位。 +**上下文压缩(Compact)**: -### Skill 架构 +- 触发方式:自动(token 阈值触发)和手动(`/compact` 命令或 API) +- 压缩策略:保留最近 N 个 turn 的完整上下文,对更早的历史生成结构化摘要 +- 最近用户消息保留:压缩后原样重新注入最近 N 条真实用户消息,确保模型不会丢失当前意图 +- 用户上下文摘要:为保留的用户消息生成极短目的摘要(`recent_user_context_digest`),帮助模型快速定位目标 +- 输出控制:压缩请求有独立的 `max_output_tokens` 上限,防止压缩本身消耗过多 token -- skill 采用 Claude 风格的两阶段模型:system prompt 先给模型看 skill 索引,命中后再调用内置 `Skill` tool 加载完整 `SKILL.md`。 -- skill 目录格式固定为 `skill-name/SKILL.md`,会读取 `name` 和 `description`,其余 Claude 风格 frontmatter 字段会被忽略。 -- 项目级 skill 会从工作目录祖先链上的 `.claude/skills/` 与 `.astrcode/skills/` 一并解析;用户级 skill 会从 `~/.claude/skills/` 与 `~/.astrcode/skills/` 解析。 -- `references/`、`scripts/` 等目录会作为 skill 资产一起索引;builtin skill 整目录会在运行时物化到 `~/.astrcode/runtime/builtin-skills/`,方便 shell 直接执行其中脚本。 +### 治理与重载 + +- `POST /api/config/reload` 走统一治理入口,串起:配置重载 -> MCP 刷新 -> plugin 重新发现 -> skill 更新 -> kernel capability surface 原子替换 +- 运行中存在 session 时拒绝 reload,避免半刷新导致执行语义漂移 +- capability surface 替换失败时保留旧状态继续服务 ### Tauri 桌面端 @@ -293,13 +357,13 @@ Tauri 仅作为"薄壳",负责: | `/api/auth/exchange` | POST | Token 认证交换 | | `/api/sessions` | GET/POST | 会话列表/创建 | | `/api/sessions/{id}/messages` | GET | 获取会话消息 | -| `/api/sessions/{id}/prompts` | POST | 提交 prompt | +| `/api/sessions/{id}/prompts` | POST | 提交 prompt(支持 `tokenBudget` / `maxSteps` / `manualCompact` 执行控制) | | `/api/sessions/{id}/interrupt` | POST | 中断会话 | | `/api/sessions/{id}/events` | GET (SSE) | 实时事件流 | | `/api/sessions/{id}` | DELETE | 删除会话 | | `/api/projects` | DELETE | 删除项目(所有会话) | | `/api/config` | GET | 获取配置 | -| `/api/config/reload` | POST | 通过治理入口重载配置与统一能力面 | +| `/api/config/reload` | POST | 统一治理重载 | | `/api/config/active-selection` | POST | 保存当前选择 | | `/api/models/current` | GET | 当前模型信息 | | `/api/models` | GET | 可用模型列表 | @@ -319,21 +383,17 @@ Tauri 仅作为"薄壳",负责: | `assistantMessage` | 最终助手消息 | | `toolCallStart` | 工具调用开始 | | `toolCallResult` | 工具调用结果 | +| `promptMetrics` | 回合级 token / 缓存命中率指标 | +| `compactApplied` | 上下文压缩完成,携带压缩摘要信息 | | `turnDone` | 对话回合结束 | | `error` | 错误信息 | -会话提交与 agent 执行入口都支持可选执行控制: - -- `tokenBudget`:只覆盖本次执行的 token 预算,不污染全局配置 -- `maxSteps`:只覆盖本次 turn 的最大 step 数 -- `manualCompact`:用于显式登记手动 compact;当 session 忙碌时会由服务端登记并在当前 turn 结束后执行 - ## 开发指南 ### 代码检查 ```bash -# 本地 push 前检查 +# 本地 push 前快速检查 cargo check --workspace cargo test --workspace --exclude astrcode --lib cd frontend && npm run typecheck @@ -344,12 +404,6 @@ cargo clippy --all-targets --all-features -- -D warnings cargo test --workspace --exclude astrcode node scripts/check-crate-boundaries.mjs cd frontend && npm run typecheck && npm run lint && npm run format:check - -# 前端检查 -cd frontend -npm run typecheck -npm run lint -npm run format:check ``` ### 代码格式化 @@ -383,27 +437,36 @@ cargo deny check bans ## CI/CD -项目使用 4 个 GitHub Actions workflow,分工如下: +项目使用 4 个 GitHub Actions workflow: -- `rust-check`:完整 Rust 质量门禁,执行 `cargo fmt --all -- --check`、`cargo clippy --all-targets --all-features -- -D warnings`、`cargo test --workspace --exclude astrcode` -- `frontend-check`:完整前端门禁,执行 `cd frontend && npm run typecheck && npm run lint && npm run format:check` -- `dependency-audit`:当 `Cargo.lock` 或 `deny.toml` 变更时执行 `cargo deny check bans` -- `tauri-build`:在发布 tag 时构建 Tauri 桌面端 +| Workflow | 触发条件 | 执行内容 | +|----------|----------|----------| +| `rust-check` | push/PR 到 master(Rust 文件变更) | fmt、clippy、crate 边界检查、回归测试、全量测试(Ubuntu + Windows) | +| `frontend-check` | push/PR 到 master(前端文件变更) | typecheck、lint、format 检查 | +| `dependency-audit` | `Cargo.lock` / `deny.toml` 变更 | `cargo deny check bans` | +| `tauri-build` | 发布 tag (`v*`) | 三平台(Ubuntu/Windows/macOS)Tauri 构建 | ## 许可证 本项目采用 **Apache License 2.0 with Commons Clause** 许可证。 -- ✅ 个人使用、学习和研究:**允许** -- ✅ 非商业开源项目使用:**允许** -- ⚠️ **商业用途**:需先获得作者许可,请联系作者 +- 允许个人使用、学习和研究 +- 允许非商业开源项目使用 +- **商业用途**需先获得作者许可 + +联系方式: + +- Email: 1879483647@qq.com +- GitHub Issues: https://github.com/whatevertogo/Astrcode/issues 详见 [LICENSE](LICENSE) 文件了解详情。 ## 致谢 - [Tauri](https://tauri.app/) - 跨平台桌面应用框架 -- [React](https://react.dev/) - 前端框架 -- [Vite](https://vitejs.dev/) - 构建工具 - [Axum](https://github.com/tokio-rs/axum) - Web 框架 - [Tokio](https://tokio.rs/) - 异步运行时 +- [React](https://react.dev/) - 前端框架 +- [Vite](https://vitejs.dev/) - 构建工具 +- [Tailwind CSS](https://tailwindcss.com/) - CSS 框架 +- [ratatui](https://ratatui.rs/) - 终端 UI 框架 diff --git a/crates/adapter-agents/src/builtin_agents/plan.md b/crates/adapter-agents/src/builtin_agents/plan.md deleted file mode 100644 index bfd25c0c..00000000 --- a/crates/adapter-agents/src/builtin_agents/plan.md +++ /dev/null @@ -1,92 +0,0 @@ ---- -name: plan -description: 分析需求并生成计划,不执行改写。 -tools: ["readFile", "grep"] ---- -You are a PLANNING AGENT, pairing with the user to create a detailed, actionable plan. - -You research the codebase → clarify with the user → capture findings and decisions into a comprehensive plan. This iterative approach catches edge cases and non-obvious requirements BEFORE implementation begins. - -Your SOLE responsibility is planning. NEVER start implementation. - -**Current plan**: `.astrcode/plans/plan.md` - update or write it using tools. - - -- STOP if you consider running file editing tools — plans are for others to execute. The only write tool you have is #tool:vscode/memory for persisting plans. -- Use #tool:vscode/askQuestions freely to clarify requirements — don't make large assumptions -- Present a well-researched plan with loose ends tied BEFORE implementation - - - -Cycle through these phases based on user input. This is iterative, not linear. If the user task is highly ambiguous, do only *Discovery* to outline a draft plan, then move on to alignment before fleshing out the full plan. - -## 1. Discovery - -Run the *Explore* subagent to gather context, analogous existing features to use as implementation templates, and potential blockers or ambiguities. When the task spans multiple independent areas (e.g., frontend + backend, different features, separate repos), launch **2-3 *Explore* subagents in parallel** — one per area — to speed up discovery. - -Update the plan with your findings. - -## 2. Alignment - -If research reveals major ambiguities or if you need to validate assumptions: -- Use #tool:vscode/askQuestions to clarify intent with the user. -- Surface discovered technical constraints or alternative approaches -- If answers significantly change the scope, loop back to **Discovery** - -## 3. Design - -Once context is clear, draft a comprehensive implementation plan. - -The plan should reflect: -- Structured concise enough to be scannable and detailed enough for effective execution -- Step-by-step implementation with explicit dependencies — mark which steps can run in parallel vs. which block on prior steps -- For plans with many steps, group into named phases that are each independently verifiable -- Verification steps for validating the implementation, both automated and manual -- Critical architecture to reuse or use as reference — reference specific functions, types, or patterns, not just file names -- Critical files to be modified (with full paths) -- Explicit scope boundaries — what's included and what's deliberately excluded -- Reference decisions from the discussion -- Leave no ambiguity - -Save the comprehensive plan document to `/memories/session/plan.md` via #tool:vscode/memory, then show the scannable plan to the user for review. You MUST show plan to the user, as the plan file is for persistence only, not a substitute for showing it to the user. - -## 4. Refinement - -On user input after showing the plan: -- Changes requested → revise and present updated plan. Update `/memories/session/plan.md` to keep the documented plan in sync -- Questions asked → clarify, or use #tool:vscode/askQuestions for follow-ups -- Alternatives wanted → loop back to **Discovery** with new subagent -- Approval given → acknowledge, the user can now use handoff buttons - -Keep iterating until explicit approval or handoff. - - - -```markdown -## Plan: {Title (2-10 words)} - -{TL;DR - what, why, and how (your recommended approach).} - -**Steps** -1. {Implementation step-by-step — note dependency ("*depends on N*") or parallelism ("*parallel with step N*") when applicable} -2. {For plans with 5+ steps, group steps into named phases with enough detail to be independently actionable} - -**Relevant files** -- `{full/path/to/file}` — {what to modify or reuse, referencing specific functions/patterns} - -**Verification** -1. {Verification steps for validating the implementation (**Specific** tasks, tests, commands, MCP tools, etc; not generic statements)} - -**Decisions** (if applicable) -- {Decision, assumptions, and includes/excluded scope} - -**Further Considerations** (if applicable, 1-3 items) -1. {Clarifying question with recommendation. Option A / Option B / Option C} -2. {…} -``` - -Rules: -- NO code blocks — describe changes, link to files and specific symbols/functions -- NO blocking questions at the end — ask during workflow via #tool:vscode/askQuestions -- The plan MUST be presented to the user, don't just mention the plan file. - \ No newline at end of file diff --git a/crates/adapter-agents/src/lib.rs b/crates/adapter-agents/src/lib.rs index 365f94de..c0482b71 100644 --- a/crates/adapter-agents/src/lib.rs +++ b/crates/adapter-agents/src/lib.rs @@ -253,10 +253,6 @@ fn builtin_agents() -> &'static [BuiltinAgent] { path: "builtin://explore.md", content: include_str!("builtin_agents/explore.md"), }, - BuiltinAgent { - path: "builtin://plan.md", - content: include_str!("builtin_agents/plan.md"), - }, BuiltinAgent { path: "builtin://reviewer.md", content: include_str!("builtin_agents/reviewer.md"), diff --git a/crates/adapter-llm/src/anthropic.rs b/crates/adapter-llm/src/anthropic.rs deleted file mode 100644 index a838992b..00000000 --- a/crates/adapter-llm/src/anthropic.rs +++ /dev/null @@ -1,2390 +0,0 @@ -//! # Anthropic Messages API 提供者 -//! -//! 实现了 [`LlmProvider`] trait,对接 Anthropic Claude 系列模型。 -//! -//! ## 协议特性 -//! -//! - **Extended Thinking**: 自动为 Claude 模型启用深度推理模式(`thinking` 配置), 预算 token 设为 -//! `max_tokens` 的 75%,保留至少 25% 给实际输出 -//! - **Prompt Caching**: 优先对分层 system blocks 放置 `ephemeral` breakpoint,并在消息尾部保留 -//! 一个缓存边界,复用 KV cache -//! - **SSE 流式解析**: Anthropic 使用多行 SSE 块格式(`event: ...\ndata: {...}\n\n`), 与 OpenAI -//! 的单行 `data: {...}` 不同,因此有独立的解析逻辑 -//! - **内容块模型**: Anthropic 响应由多种内容块组成(text / tool_use / thinking), 使用 -//! `Vec` 灵活处理未知或新增的块类型 -//! -//! ## 流式事件分派 -//! -//! Anthropic SSE 事件类型: -//! - `content_block_start`: 新内容块开始(文本或工具调用) -//! - `content_block_delta`: 增量内容(text_delta / thinking_delta / signature_delta / -//! input_json_delta) -//! - `message_stop`: 流结束信号 -//! - `message_start / message_delta`: 提取 usage / stop_reason 等元数据 -//! - `content_block_stop / ping`: 元数据事件,静默忽略 - -use std::{ - fmt, - sync::{Arc, Mutex}, -}; - -use astrcode_core::{ - AstrError, CancelToken, LlmMessage, ReasoningContent, Result, SystemPromptBlock, - SystemPromptLayer, ToolCallRequest, ToolDefinition, -}; -use async_trait::async_trait; -use futures_util::StreamExt; -use log::{debug, warn}; -use serde::Serialize; -use serde_json::{Value, json}; -use tokio::select; - -use crate::{ - EventSink, FinishReason, LlmAccumulator, LlmClientConfig, LlmEvent, LlmOutput, LlmProvider, - LlmRequest, LlmUsage, ModelLimits, Utf8StreamDecoder, build_http_client, - cache_tracker::CacheTracker, classify_http_error, emit_event, is_retryable_status, - wait_retry_delay, -}; -const ANTHROPIC_VERSION: &str = "2023-06-01"; -const ANTHROPIC_CACHE_BREAKPOINT_LIMIT: usize = 4; - -fn summarize_request_for_diagnostics(request: &AnthropicRequest) -> Value { - let messages = request - .messages - .iter() - .map(|message| { - let block_types = message - .content - .iter() - .map(AnthropicContentBlock::block_type) - .collect::>(); - json!({ - "role": message.role, - "blockTypes": block_types, - "blockCount": message.content.len(), - "cacheControlCount": message - .content - .iter() - .filter(|block| block.has_cache_control()) - .count(), - }) - }) - .collect::>(); - let system = match &request.system { - None => Value::Null, - Some(AnthropicSystemPrompt::Text(text)) => json!({ - "kind": "text", - "chars": text.chars().count(), - }), - Some(AnthropicSystemPrompt::Blocks(blocks)) => json!({ - "kind": "blocks", - "count": blocks.len(), - "cacheControlCount": blocks - .iter() - .filter(|block| block.cache_control.is_some()) - .count(), - "chars": blocks.iter().map(|block| block.text.chars().count()).sum::(), - }), - }; - let tools = request.tools.as_ref().map(|tools| { - json!({ - "count": tools.len(), - "names": tools.iter().map(|tool| tool.name.clone()).collect::>(), - "cacheControlCount": tools - .iter() - .filter(|tool| tool.cache_control.is_some()) - .count(), - }) - }); - - json!({ - "model": request.model, - "maxTokens": request.max_tokens, - "topLevelCacheControl": request.cache_control.is_some(), - "hasThinking": request.thinking.is_some(), - "stream": request.stream.unwrap_or(false), - "system": system, - "messages": messages, - "tools": tools, - }) -} - -/// Anthropic Claude API 提供者实现。 -/// -/// 封装了 HTTP 客户端、API 密钥和模型配置,提供统一的 [`LlmProvider`] 接口。 -/// -/// ## 设计要点 -/// -/// - HTTP 客户端在构造时创建,使用共享的超时策略(连接 10s / 读取 90s) -/// - `limits.max_output_tokens` 同时控制请求体的上限和 extended thinking 的预算计算 -/// - Debug 实现会隐藏 API 密钥(显示为 ``) -#[derive(Clone)] -pub struct AnthropicProvider { - client: reqwest::Client, - client_config: LlmClientConfig, - messages_api_url: String, - api_key: String, - model: String, - /// 运行时已解析好的模型 limits。 - /// - /// Anthropic 的上下文窗口来自 Models API,不应该继续在 provider 内写死。 - limits: ModelLimits, - /// 缓存失效检测跟踪器 - cache_tracker: Arc>, -} - -impl fmt::Debug for AnthropicProvider { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("AnthropicProvider") - .field("client", &self.client) - .field("messages_api_url", &self.messages_api_url) - .field("api_key", &"") - .field("model", &self.model) - .field("limits", &self.limits) - .field("client_config", &self.client_config) - .field("cache_tracker", &"") - .finish() - } -} - -impl AnthropicProvider { - /// 创建新的 Anthropic 提供者实例。 - /// - /// `limits.max_output_tokens` 同时用于: - /// 1. 请求体中的 `max_tokens` 字段(输出上限) - /// 2. Extended thinking 预算计算(75% 的 max_tokens) - pub fn new( - messages_api_url: String, - api_key: String, - model: String, - limits: ModelLimits, - client_config: LlmClientConfig, - ) -> Result { - Ok(Self { - client: build_http_client(client_config)?, - client_config, - messages_api_url, - api_key, - model, - limits, - cache_tracker: Arc::new(Mutex::new(CacheTracker::new())), - }) - } - - /// 构建 Anthropic Messages API 请求体。 - /// - /// - 将 `LlmMessage` 转换为 Anthropic 格式的内容块数组 - /// - 对分层 system blocks 和消息尾部启用 prompt caching(KV cache 复用) - /// - 如果启用了工具,附加工具定义 - /// - 根据模型名称和 max_tokens 自动配置 extended thinking - fn build_request( - &self, - messages: &[LlmMessage], - tools: &[ToolDefinition], - system_prompt: Option<&str>, - system_prompt_blocks: &[SystemPromptBlock], - stream: bool, - ) -> AnthropicRequest { - let use_official_endpoint = is_official_anthropic_api_url(&self.messages_api_url); - let use_automatic_cache = use_official_endpoint; - let mut remaining_cache_breakpoints = ANTHROPIC_CACHE_BREAKPOINT_LIMIT; - let request_cache_control = if use_automatic_cache { - remaining_cache_breakpoints = remaining_cache_breakpoints.saturating_sub(1); - Some(AnthropicCacheControl::ephemeral()) - } else { - None - }; - - let mut anthropic_messages = to_anthropic_messages( - messages, - MessageBuildOptions { - include_reasoning_blocks: use_official_endpoint, - }, - ); - let tools = if tools.is_empty() { - None - } else { - Some(to_anthropic_tools(tools, &mut remaining_cache_breakpoints)) - }; - let system = to_anthropic_system( - system_prompt, - system_prompt_blocks, - &mut remaining_cache_breakpoints, - ); - - if !use_automatic_cache { - enable_message_caching(&mut anthropic_messages, remaining_cache_breakpoints); - } - - AnthropicRequest { - model: self.model.clone(), - max_tokens: self.limits.max_output_tokens.min(u32::MAX as usize) as u32, - cache_control: request_cache_control, - messages: anthropic_messages, - system, - tools, - stream: stream.then_some(true), - // Why: 第三方 Anthropic 兼容网关常见只支持基础 messages 子集; - // 在非官方 endpoint 下关闭 `thinking` 字段,避免触发参数校验失败。 - thinking: if use_official_endpoint { - thinking_config_for_model( - &self.model, - self.limits.max_output_tokens.min(u32::MAX as usize) as u32, - ) - } else { - None - }, - } - } - - async fn send_request( - &self, - request: &AnthropicRequest, - cancel: CancelToken, - ) -> Result { - // 调试日志:打印请求信息(不暴露完整 API Key) - let api_key_preview = if self.api_key.len() > 8 { - format!( - "{}...{}", - &self.api_key[..4], - &self.api_key[self.api_key.len() - 4..] - ) - } else { - "****".to_string() - }; - debug!( - "Anthropic request: url={}, api_key_preview={}, model={}", - self.messages_api_url, api_key_preview, self.model - ); - if !is_official_anthropic_api_url(&self.messages_api_url) { - debug!( - "Anthropic-compatible request summary: {}", - summarize_request_for_diagnostics(request) - ); - } - - for attempt in 0..=self.client_config.max_retries { - let send_future = self - .client - .post(&self.messages_api_url) - .header("x-api-key", &self.api_key) - .header("anthropic-version", ANTHROPIC_VERSION) - .header(reqwest::header::CONTENT_TYPE, "application/json") - .json(request) - .send(); - - let response = select! { - _ = crate::cancelled(cancel.clone()) => { - return Err(AstrError::LlmInterrupted); - } - result = send_future => result.map_err(|e| AstrError::http("failed to call anthropic endpoint", e)) - }; - - match response { - Ok(response) => { - let status = response.status(); - if status == reqwest::StatusCode::UNAUTHORIZED { - // 读取响应体以便调试 - let body = response.text().await.unwrap_or_default(); - warn!( - "Anthropic 401 Unauthorized: url={}, api_key_preview={}, response={}", - self.messages_api_url, - if self.api_key.len() > 8 { - format!( - "{}...{}", - &self.api_key[..4], - &self.api_key[self.api_key.len() - 4..] - ) - } else { - "****".to_string() - }, - body - ); - return Err(AstrError::InvalidApiKey("Anthropic".to_string())); - } - if status.is_success() { - return Ok(response); - } - - let body = response.text().await.unwrap_or_default(); - if is_retryable_status(status) && attempt < self.client_config.max_retries { - wait_retry_delay( - attempt, - cancel.clone(), - self.client_config.retry_base_delay, - ) - .await?; - continue; - } - - if status.is_client_error() - && !is_official_anthropic_api_url(&self.messages_api_url) - { - warn!( - "Anthropic-compatible request rejected: url={}, status={}, \ - request_summary={}, response={}", - self.messages_api_url, - status.as_u16(), - summarize_request_for_diagnostics(request), - body - ); - } - - // 使用结构化错误分类 (P4.3) - return Err(classify_http_error(status.as_u16(), &body).into()); - }, - Err(error) => { - if error.is_retryable() && attempt < self.client_config.max_retries { - wait_retry_delay( - attempt, - cancel.clone(), - self.client_config.retry_base_delay, - ) - .await?; - continue; - } - return Err(error); - }, - } - } - - // 所有路径都会通过 return 退出循环;若到达此处说明逻辑有误, - // 返回 Internal 而非 panic 以保证运行时安全 - Err(AstrError::Internal( - "retry loop should have returned on all paths".into(), - )) - } -} - -#[async_trait] -impl LlmProvider for AnthropicProvider { - fn supports_cache_metrics(&self) -> bool { - true - } - - fn prompt_metrics_input_tokens(&self, usage: LlmUsage) -> usize { - usage - .input_tokens - .saturating_add(usage.cache_read_input_tokens) - } - - async fn generate(&self, request: LlmRequest, sink: Option) -> Result { - let cancel = request.cancel; - - // 检测缓存失效并记录原因 - let system_prompt_text = request.system_prompt.as_deref().unwrap_or(""); - let tool_names: Vec = request.tools.iter().map(|t| t.name.clone()).collect(); - - if let Ok(mut tracker) = self.cache_tracker.lock() { - let break_reasons = - tracker.check_and_update(system_prompt_text, &tool_names, &self.model, "anthropic"); - - if !break_reasons.is_empty() { - debug!("[CACHE] Cache break detected: {:?}", break_reasons); - } - } - - let body = self.build_request( - &request.messages, - &request.tools, - request.system_prompt.as_deref(), - &request.system_prompt_blocks, - sink.is_some(), - ); - let response = self.send_request(&body, cancel.clone()).await?; - - match sink { - None => { - let payload: AnthropicResponse = response - .json() - .await - .map_err(|e| AstrError::http("failed to parse anthropic response", e))?; - Ok(response_to_output(payload)) - }, - Some(sink) => { - let mut stream = response.bytes_stream(); - let mut sse_buffer = String::new(); - let mut utf8_decoder = Utf8StreamDecoder::default(); - let mut accumulator = LlmAccumulator::default(); - // 流式路径下从 message_delta 的 stop_reason 提取 (P4.2) - let mut stream_stop_reason: Option = None; - let mut stream_usage = AnthropicUsage::default(); - - loop { - let next_item = select! { - _ = crate::cancelled(cancel.clone()) => { - return Err(AstrError::LlmInterrupted); - } - item = stream.next() => item, - }; - - let Some(item) = next_item else { - break; - }; - - let bytes = item.map_err(|e| { - AstrError::http("failed to read anthropic response stream", e) - })?; - let Some(chunk_text) = utf8_decoder - .push(&bytes, "anthropic response stream was not valid utf-8")? - else { - continue; - }; - - if consume_sse_text_chunk( - &chunk_text, - &mut sse_buffer, - &mut accumulator, - &sink, - &mut stream_stop_reason, - &mut stream_usage, - )? { - let mut output = accumulator.finish(); - // 优先使用 API 返回的 stop_reason,否则使用推断值 - if let Some(reason) = stream_stop_reason.as_deref() { - output.finish_reason = FinishReason::from_api_value(reason); - } - output.usage = stream_usage.into_llm_usage(); - - // 记录流式响应的缓存状态 - if let Some(ref u) = output.usage { - let input = u.input_tokens; - let cache_read = u.cache_read_input_tokens; - let cache_creation = u.cache_creation_input_tokens; - let total_prompt_tokens = input.saturating_add(cache_read); - - if cache_read == 0 && cache_creation > 0 { - debug!( - "Cache miss (streaming): writing {} tokens to cache (total \ - prompt: {}, uncached input: {})", - cache_creation, total_prompt_tokens, input - ); - } else if cache_read > 0 { - let hit_rate = - (cache_read as f32 / total_prompt_tokens as f32) * 100.0; - debug!( - "Cache hit (streaming): {:.1}% ({} / {} prompt tokens, \ - creation: {}, uncached input: {})", - hit_rate, - cache_read, - total_prompt_tokens, - cache_creation, - input - ); - } else { - debug!( - "Cache disabled or unavailable (streaming, total prompt: {} \ - tokens)", - total_prompt_tokens - ); - } - } - - return Ok(output); - } - } - - if let Some(tail_text) = - utf8_decoder.finish("anthropic response stream was not valid utf-8")? - { - let done = consume_sse_text_chunk( - &tail_text, - &mut sse_buffer, - &mut accumulator, - &sink, - &mut stream_stop_reason, - &mut stream_usage, - )?; - if done { - let mut output = accumulator.finish(); - if let Some(reason) = stream_stop_reason.as_deref() { - output.finish_reason = FinishReason::from_api_value(reason); - } - output.usage = stream_usage.into_llm_usage(); - return Ok(output); - } - } - - flush_sse_buffer( - &mut sse_buffer, - &mut accumulator, - &sink, - &mut stream_stop_reason, - &mut stream_usage, - )?; - let mut output = accumulator.finish(); - if let Some(reason) = stream_stop_reason.as_deref() { - output.finish_reason = FinishReason::from_api_value(reason); - } - output.usage = stream_usage.into_llm_usage(); - Ok(output) - }, - } - } - - fn model_limits(&self) -> ModelLimits { - self.limits - } -} - -/// 将 `LlmMessage` 转换为 Anthropic 格式的消息结构。 -/// -/// Anthropic 使用内容块数组(而非纯文本),因此需要按消息类型分派: -/// - User 消息 → 单个 `text` 内容块 -/// - Assistant 消息 → 可能包含 `thinking`、`text`、`tool_use` 多个块 -/// - Tool 消息 → 单个 `tool_result` 内容块 -#[derive(Clone, Copy)] -struct MessageBuildOptions { - include_reasoning_blocks: bool, -} - -fn to_anthropic_messages( - messages: &[LlmMessage], - options: MessageBuildOptions, -) -> Vec { - let mut anthropic_messages = Vec::with_capacity(messages.len()); - let mut pending_user_blocks = Vec::new(); - - let flush_pending_user_blocks = - |anthropic_messages: &mut Vec, - pending_user_blocks: &mut Vec| { - if pending_user_blocks.is_empty() { - return; - } - - anthropic_messages.push(AnthropicMessage { - role: "user".to_string(), - content: std::mem::take(pending_user_blocks), - }); - }; - - for message in messages { - match message { - LlmMessage::User { content, .. } => { - pending_user_blocks.push(AnthropicContentBlock::Text { - text: content.clone(), - cache_control: None, - }); - }, - LlmMessage::Assistant { - content, - tool_calls, - reasoning, - } => { - flush_pending_user_blocks(&mut anthropic_messages, &mut pending_user_blocks); - - let mut blocks = Vec::new(); - if options.include_reasoning_blocks { - if let Some(reasoning) = reasoning { - blocks.push(AnthropicContentBlock::Thinking { - thinking: reasoning.content.clone(), - signature: reasoning.signature.clone(), - cache_control: None, - }); - } - } - // Anthropic assistant 消息可以直接包含 tool_use 块,不要求前置 text 块。 - // 仅在确实有文本时写入 text 块,避免向兼容网关发送空 text 导致参数校验失败。 - if !content.is_empty() { - blocks.push(AnthropicContentBlock::Text { - text: content.clone(), - cache_control: None, - }); - } - blocks.extend( - tool_calls - .iter() - .map(|call| AnthropicContentBlock::ToolUse { - id: call.id.clone(), - name: call.name.clone(), - input: call.args.clone(), - cache_control: None, - }), - ); - if blocks.is_empty() { - blocks.push(AnthropicContentBlock::Text { - text: String::new(), - cache_control: None, - }); - } - - anthropic_messages.push(AnthropicMessage { - role: "assistant".to_string(), - content: blocks, - }); - }, - LlmMessage::Tool { - tool_call_id, - content, - } => { - pending_user_blocks.push(AnthropicContentBlock::ToolResult { - tool_use_id: tool_call_id.clone(), - content: content.clone(), - cache_control: None, - }); - }, - } - } - - flush_pending_user_blocks(&mut anthropic_messages, &mut pending_user_blocks); - anthropic_messages -} - -/// 在最近的消息内容块上启用显式 prompt caching。 -/// -/// 只有在自定义 Anthropic 网关上才需要这条兜底路径。官方 Anthropic endpoint 使用顶层 -/// 自动缓存来追踪不断增长的对话尾部,避免显式断点超过 4 个 slot。 -fn enable_message_caching(messages: &mut [AnthropicMessage], max_breakpoints: usize) -> usize { - if messages.is_empty() || max_breakpoints == 0 { - return 0; - } - - let mut used = 0; - for msg in messages.iter_mut().rev() { - if used >= max_breakpoints { - break; - } - - let Some(block) = msg - .content - .iter_mut() - .rev() - .find(|block| block.can_use_explicit_cache_control()) - else { - continue; - }; - - if block.set_cache_control_if_allowed(true) { - used += 1; - } - } - - used -} - -fn consume_cache_breakpoint(remaining: &mut usize) -> bool { - if *remaining == 0 { - return false; - } - - *remaining -= 1; - true -} - -fn is_official_anthropic_api_url(url: &str) -> bool { - reqwest::Url::parse(url) - .ok() - .and_then(|url| { - url.host_str() - .map(|host| host.eq_ignore_ascii_case("api.anthropic.com")) - }) - .unwrap_or(false) -} - -fn cache_control_if_allowed(remaining: &mut usize) -> Option { - consume_cache_breakpoint(remaining).then(AnthropicCacheControl::ephemeral) -} - -fn cacheable_system_layer(layer: SystemPromptLayer) -> bool { - !matches!(layer, SystemPromptLayer::Dynamic) -} - -fn cacheable_text(text: &str) -> bool { - !text.is_empty() -} - -/// 将 `ToolDefinition` 转换为 Anthropic 工具定义格式。 -fn to_anthropic_tools( - tools: &[ToolDefinition], - remaining_cache_breakpoints: &mut usize, -) -> Vec { - if tools.is_empty() { - return Vec::new(); - } - - let last_cacheable_index = tools - .iter() - .rposition(|tool| cacheable_text(&tool.name) || cacheable_text(&tool.description)); - - tools - .iter() - .enumerate() - .map(|(index, tool)| { - let cache_control = if Some(index) == last_cacheable_index { - cache_control_if_allowed(remaining_cache_breakpoints) - } else { - None - }; - - AnthropicTool { - name: tool.name.clone(), - description: tool.description.clone(), - input_schema: tool.parameters.clone(), - cache_control, - } - }) - .collect() -} - -fn to_anthropic_system( - system_prompt: Option<&str>, - system_prompt_blocks: &[SystemPromptBlock], - remaining_cache_breakpoints: &mut usize, -) -> Option { - if !system_prompt_blocks.is_empty() { - return Some(AnthropicSystemPrompt::Blocks( - system_prompt_blocks - .iter() - .map(|block| { - let text = block.render(); - let cache_control = if block.cache_boundary - && cacheable_system_layer(block.layer) - && cacheable_text(&text) - { - cache_control_if_allowed(remaining_cache_breakpoints) - } else { - None - }; - - AnthropicSystemBlock { - type_: "text".to_string(), - text, - cache_control, - } - }) - .collect(), - )); - } - - system_prompt.map(|value| AnthropicSystemPrompt::Text(value.to_string())) -} - -/// 将 Anthropic 非流式响应转换为统一的 `LlmOutput`。 -/// -/// 遍历内容块数组,根据块类型分派: -/// - `text`: 拼接到输出内容 -/// - `tool_use`: 提取 id、name、input 构造工具调用请求 -/// - `thinking`: 提取推理内容和签名 -/// - 未知类型:记录警告并跳过 -/// -/// TODO:更好的办法? -/// `stop_reason` 映射到统一的 `FinishReason` (P4.2): -/// - `end_turn` → Stop -/// - `max_tokens` → MaxTokens -/// - `tool_use` → ToolCalls -/// - `stop_sequence` → Stop -fn response_to_output(response: AnthropicResponse) -> LlmOutput { - let usage = response.usage.and_then(AnthropicUsage::into_llm_usage); - - // 记录缓存状态 - if let Some(ref u) = usage { - let input = u.input_tokens; - let cache_read = u.cache_read_input_tokens; - let cache_creation = u.cache_creation_input_tokens; - let total_prompt_tokens = input.saturating_add(cache_read); - - if cache_read == 0 && cache_creation > 0 { - debug!( - "Cache miss: writing {} tokens to cache (total prompt: {}, uncached input: {})", - cache_creation, total_prompt_tokens, input - ); - } else if cache_read > 0 { - let hit_rate = (cache_read as f32 / total_prompt_tokens as f32) * 100.0; - debug!( - "Cache hit: {:.1}% ({} / {} prompt tokens, creation: {}, uncached input: {})", - hit_rate, cache_read, total_prompt_tokens, cache_creation, input - ); - } else { - debug!( - "Cache disabled or unavailable (total prompt: {} tokens)", - total_prompt_tokens - ); - } - } - - let mut content = String::new(); - let mut tool_calls = Vec::new(); - let mut reasoning = None; - - for block in response.content { - match block_type(&block) { - Some("text") => { - if let Some(text) = block.get("text").and_then(Value::as_str) { - content.push_str(text); - } - }, - Some("tool_use") => { - let id = match block.get("id").and_then(Value::as_str) { - Some(id) if !id.is_empty() => id.to_string(), - _ => { - warn!("anthropic: tool_use block missing non-empty id, skipping"); - continue; - }, - }; - let name = match block.get("name").and_then(Value::as_str) { - Some(name) if !name.is_empty() => name.to_string(), - _ => { - warn!("anthropic: tool_use block missing non-empty name, skipping"); - continue; - }, - }; - let args = block.get("input").cloned().unwrap_or(Value::Null); - tool_calls.push(ToolCallRequest { id, name, args }); - }, - Some("thinking") => { - if let Some(thinking) = block.get("thinking").and_then(Value::as_str) { - reasoning = Some(ReasoningContent { - content: thinking.to_string(), - signature: block - .get("signature") - .and_then(Value::as_str) - .map(str::to_string), - }); - } - }, - Some(other) => { - warn!("anthropic: unknown content block type: {}", other); - }, - None => { - warn!("anthropic: content block missing type"); - }, - } - } - - // Anthropic stop_reason 映射到统一 FinishReason - let finish_reason = response - .stop_reason - .as_deref() - .map(|reason| match reason { - "end_turn" | "stop_sequence" => FinishReason::Stop, - "max_tokens" => FinishReason::MaxTokens, - "tool_use" => FinishReason::ToolCalls, - other => FinishReason::Other(other.to_string()), - }) - .unwrap_or_else(|| { - if !tool_calls.is_empty() { - FinishReason::ToolCalls - } else { - FinishReason::Stop - } - }); - - LlmOutput { - content, - tool_calls, - reasoning, - usage, - finish_reason, - } -} - -/// 从 JSON Value 中提取内容块的类型字段。 -fn block_type(value: &Value) -> Option<&str> { - value.get("type").and_then(Value::as_str) -} - -/// 解析单个 Anthropic SSE 块。 -/// -/// Anthropic SSE 块由多行组成(`event: ...\ndata: {...}\n\n`), -/// 本函数提取事件类型和 JSON payload,支持事件类型回退到 payload 中的 `type` 字段。 -fn parse_sse_block(block: &str) -> Result> { - let trimmed = block.trim(); - if trimmed.is_empty() { - return Ok(None); - } - - let mut event_type = None; - let mut data_lines = Vec::new(); - - for line in trimmed.lines() { - if let Some(value) = sse_field_value(line, "event") { - event_type = Some(value.trim().to_string()); - } else if let Some(value) = sse_field_value(line, "data") { - data_lines.push(value); - } - } - - if data_lines.is_empty() { - return Ok(None); - } - - let data = data_lines.join("\n"); - let data = data.trim(); - if data.is_empty() { - return Ok(None); - } - - // 兼容部分 Anthropic 网关沿用 OpenAI 风格的流结束哨兵。 - // 如果这里严格要求 JSON,会在流尾直接误报 parse error。 - if data == "[DONE]" { - return Ok(Some(( - "message_stop".to_string(), - json!({ "type": "message_stop" }), - ))); - } - - let payload = serde_json::from_str::(data) - .map_err(|error| AstrError::parse("failed to parse anthropic sse payload", error))?; - let event_type = event_type - .or_else(|| { - payload - .get("type") - .and_then(Value::as_str) - .map(str::to_string) - }) - .unwrap_or_default(); - - Ok(Some((event_type, payload))) -} - -fn sse_field_value<'a>(line: &'a str, field: &str) -> Option<&'a str> { - let value = line.strip_prefix(field)?.strip_prefix(':')?; - - // SSE 规范只忽略冒号后的一个可选空格;这里兼容 `data:...` 和 `data: ...`, - // 同时保留业务数据中其余前导空白,避免悄悄改写 payload。 - Some(value.strip_prefix(' ').unwrap_or(value)) -} - -/// 从 `content_block_start` 事件 payload 中提取内容块。 -/// -/// Anthropic 在 `content_block_start` 事件中将块数据放在 `content_block` 字段, -/// 但某些事件可能直接放在根级别,因此有回退逻辑。 -fn extract_start_block(payload: &Value) -> &Value { - payload.get("content_block").unwrap_or(payload) -} - -/// 从 `content_block_delta` 事件 payload 中提取增量数据。 -/// -/// Anthropic 在 `content_block_delta` 事件中将增量数据放在 `delta` 字段。 -fn extract_delta_block(payload: &Value) -> &Value { - payload.get("delta").unwrap_or(payload) -} - -fn anthropic_stream_error(payload: &Value) -> AstrError { - let error = payload.get("error").unwrap_or(payload); - let message = error - .get("message") - .or_else(|| error.get("msg")) - .or_else(|| payload.get("message")) - .and_then(Value::as_str) - .unwrap_or("anthropic stream returned an error event"); - - let mut error_type = error - .get("type") - .or_else(|| error.get("code")) - .or_else(|| payload.get("error_type")) - .or_else(|| payload.get("code")) - .and_then(Value::as_str) - .unwrap_or("unknown_error"); - - // Why: 部分兼容网关不回传结构化 error.type,只给中文文案。 - // 这类错误本质仍是请求参数错误,不应退化成 internal stream error。 - let message_lower = message.to_lowercase(); - if matches!(error_type, "unknown_error" | "error") - && (message_lower.contains("参数非法") - || message_lower.contains("invalid request") - || message_lower.contains("invalid parameter") - || message_lower.contains("invalid arguments") - || (message_lower.contains("messages") && message_lower.contains("illegal"))) - { - error_type = "invalid_request_error"; - } - - let detail = format!("{error_type}: {message}"); - - match error_type { - "invalid_request_error" => classify_http_error(400, &detail).into(), - "authentication_error" => classify_http_error(401, &detail).into(), - "permission_error" => classify_http_error(403, &detail).into(), - "not_found_error" => classify_http_error(404, &detail).into(), - "rate_limit_error" => classify_http_error(429, &detail).into(), - "overloaded_error" => classify_http_error(529, &detail).into(), - "api_error" => classify_http_error(500, &detail).into(), - _ => classify_http_error(400, &detail).into(), - } -} - -/// 处理单个 Anthropic SSE 块,返回 `(is_done, stop_reason)`。 -/// -/// Anthropic SSE 事件类型分派: -/// - `content_block_start`: 新内容块开始(可能是文本或工具调用) -/// - `content_block_delta`: 增量内容(文本/思考/签名/工具参数) -/// - `message_stop`: 流结束信号,返回 is_done=true -/// - `message_delta`: 包含 `stop_reason`,用于检测 max_tokens 截断 (P4.2) -/// - `message_start/content_block_stop/ping`: 元数据事件,静默忽略 -fn process_sse_block( - block: &str, - accumulator: &mut LlmAccumulator, - sink: &EventSink, -) -> Result { - let Some((event_type, payload)) = parse_sse_block(block)? else { - return Ok(SseProcessResult::default()); - }; - - match event_type.as_str() { - "content_block_start" => { - let index = payload - .get("index") - .and_then(Value::as_u64) - .unwrap_or_default() as usize; - let block = extract_start_block(&payload); - - // 工具调用块开始时,发射 ToolCallDelta(id + name,参数为空) - if block_type(block) == Some("tool_use") { - emit_event( - LlmEvent::ToolCallDelta { - index, - id: block.get("id").and_then(Value::as_str).map(str::to_string), - name: block - .get("name") - .and_then(Value::as_str) - .map(str::to_string), - arguments_delta: String::new(), - }, - accumulator, - sink, - ); - } - Ok(SseProcessResult::default()) - }, - "content_block_delta" => { - let index = payload - .get("index") - .and_then(Value::as_u64) - .unwrap_or_default() as usize; - let delta = extract_delta_block(&payload); - - // 根据增量类型分派到对应的事件 - match block_type(delta) { - Some("text_delta") => { - if let Some(text) = delta.get("text").and_then(Value::as_str) { - emit_event(LlmEvent::TextDelta(text.to_string()), accumulator, sink); - } - }, - Some("thinking_delta") => { - if let Some(text) = delta.get("thinking").and_then(Value::as_str) { - emit_event(LlmEvent::ThinkingDelta(text.to_string()), accumulator, sink); - } - }, - Some("signature_delta") => { - if let Some(signature) = delta.get("signature").and_then(Value::as_str) { - emit_event( - LlmEvent::ThinkingSignature(signature.to_string()), - accumulator, - sink, - ); - } - }, - Some("input_json_delta") => { - // 工具调用参数增量,partial_json 是 JSON 的片段 - emit_event( - LlmEvent::ToolCallDelta { - index, - id: None, - name: None, - arguments_delta: delta - .get("partial_json") - .and_then(Value::as_str) - .unwrap_or_default() - .to_string(), - }, - accumulator, - sink, - ); - }, - _ => {}, - } - Ok(SseProcessResult::default()) - }, - "message_stop" => Ok(SseProcessResult { - done: true, - ..SseProcessResult::default() - }), - // message_delta 可能包含 stop_reason (P4.2) - "message_delta" => { - let stop_reason = payload - .get("delta") - .and_then(|d| d.get("stop_reason")) - .and_then(Value::as_str) - .map(str::to_string); - Ok(SseProcessResult { - stop_reason, - usage: extract_usage_from_payload(&event_type, &payload), - ..SseProcessResult::default() - }) - }, - "message_start" => Ok(SseProcessResult { - usage: extract_usage_from_payload(&event_type, &payload), - ..SseProcessResult::default() - }), - "content_block_stop" | "ping" => Ok(SseProcessResult::default()), - "error" => Err(anthropic_stream_error(&payload)), - other => { - warn!("anthropic: unknown sse event: {}", other); - Ok(SseProcessResult::default()) - }, - } -} - -/// 为模型生成 extended thinking 配置。 -/// -/// 当 max_tokens >= 2 时启用 thinking 模式,预算 token 数为 max_tokens 的 75%(向下取整)。 -/// -/// ## 设计动机 -/// -/// Extended thinking 让模型在输出前进行深度推理,提升复杂任务的回答质量。 -/// 预算设为 75% 是为了保留至少 25% 的 token 给实际输出内容。 -/// 如果预算为 0 或等于 max_tokens,则不启用(避免无意义配置)。 -/// -/// 默认为所有模型启用此功能。如果模型不支持,API 会忽略此参数。 -fn thinking_config_for_model(_model: &str, max_tokens: u32) -> Option { - if max_tokens < 2 { - return None; - } - - let budget_tokens = max_tokens.saturating_mul(3) / 4; - if budget_tokens == 0 || budget_tokens >= max_tokens { - return None; - } - - Some(AnthropicThinking { - type_: "enabled".to_string(), - budget_tokens, - }) -} - -/// 在 SSE 缓冲区中查找下一个完整的 SSE 块边界。 -/// -/// Anthropic SSE 块由双换行符分隔(`\r\n\r\n` 或 `\n\n`)。 -/// 返回 `(块结束位置, 分隔符长度)`,如果未找到完整块则返回 `None`。 -fn next_sse_block(buffer: &str) -> Option<(usize, usize)> { - if let Some(idx) = buffer.find("\r\n\r\n") { - return Some((idx, 4)); - } - if let Some(idx) = buffer.find("\n\n") { - return Some((idx, 2)); - } - None -} - -fn consume_sse_text_chunk( - chunk_text: &str, - sse_buffer: &mut String, - accumulator: &mut LlmAccumulator, - sink: &EventSink, - stop_reason_out: &mut Option, - usage_out: &mut AnthropicUsage, -) -> Result { - sse_buffer.push_str(chunk_text); - - while let Some((block_end, delimiter_len)) = next_sse_block(sse_buffer) { - let block: String = sse_buffer.drain(..block_end + delimiter_len).collect(); - let block = &block[..block_end]; - - let result = process_sse_block(block, accumulator, sink)?; - if let Some(r) = result.stop_reason { - *stop_reason_out = Some(r); - } - if let Some(usage) = result.usage { - usage_out.merge_from(usage); - } - if result.done { - return Ok(true); - } - } - - Ok(false) -} - -fn flush_sse_buffer( - sse_buffer: &mut String, - accumulator: &mut LlmAccumulator, - sink: &EventSink, - stop_reason_out: &mut Option, - usage_out: &mut AnthropicUsage, -) -> Result<()> { - if sse_buffer.trim().is_empty() { - sse_buffer.clear(); - return Ok(()); - } - - let result = process_sse_block(sse_buffer, accumulator, sink)?; - if let Some(r) = result.stop_reason { - *stop_reason_out = Some(r); - } - if let Some(usage) = result.usage { - usage_out.merge_from(usage); - } - sse_buffer.clear(); - Ok(()) -} - -// --------------------------------------------------------------------------- -// Anthropic API 请求/响应 DTO(仅用于 serde 序列化/反序列化) -// --------------------------------------------------------------------------- - -/// Anthropic Messages API 请求体。 -/// -/// 注意:`stream` 字段为 `Option`,`None` 时表示非流式模式, -/// 这样可以在序列化时省略该字段(Anthropic API 默认非流式)。 -#[derive(Debug, Serialize)] -struct AnthropicRequest { - model: String, - max_tokens: u32, - #[serde(skip_serializing_if = "Option::is_none")] - cache_control: Option, - messages: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - system: Option, - #[serde(skip_serializing_if = "Option::is_none")] - tools: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - stream: Option, - #[serde(skip_serializing_if = "Option::is_none")] - thinking: Option, -} - -#[derive(Debug, Serialize)] -#[serde(untagged)] -enum AnthropicSystemPrompt { - Text(String), - Blocks(Vec), -} - -#[derive(Debug, Serialize)] -struct AnthropicSystemBlock { - #[serde(rename = "type")] - type_: String, - text: String, - #[serde(skip_serializing_if = "Option::is_none")] - cache_control: Option, -} - -/// Anthropic extended thinking 配置。 -/// -/// `budget_tokens` 指定推理过程可使用的最大 token 数, -/// 不计入最终输出的 `max_tokens` 限制。 -/// -/// ## 设计动机 -/// -/// Extended thinking 让 Claude 在输出前进行深度推理,提升复杂任务的回答质量。 -/// 预算设为 75% 是为了保留至少 25% 的 token 给实际输出内容。 -#[derive(Debug, Serialize)] -struct AnthropicThinking { - #[serde(rename = "type")] - type_: String, - budget_tokens: u32, -} - -/// Anthropic 消息(包含角色和内容块数组)。 -/// -/// Anthropic 的消息结构与 OpenAI 不同:`content` 是内容块数组而非纯文本, -/// 这使得单条消息可以混合文本、推理、工具调用等多种内容类型。 -#[derive(Debug, Serialize)] -struct AnthropicMessage { - role: String, - content: Vec, -} - -/// Anthropic 内容块——消息内容由多个块组成。 -/// -/// 使用 `#[serde(tag = "type")]` 实现内部标记序列化, -/// 每个变体对应一个 `type` 值(`text`、`thinking`、`tool_use`、`tool_result`)。 -/// -/// ## 缓存控制 -/// -/// 每个块可选携带 `cache_control` 字段,标记为 `ephemeral` 类型时, -/// Anthropic 后端会将该块作为缓存前缀的一部分,用于 KV cache 复用。 -#[derive(Debug, Serialize)] -#[serde(tag = "type", rename_all = "snake_case")] -enum AnthropicContentBlock { - Text { - text: String, - #[serde(skip_serializing_if = "Option::is_none")] - cache_control: Option, - }, - Thinking { - thinking: String, - #[serde(skip_serializing_if = "Option::is_none")] - signature: Option, - #[serde(skip_serializing_if = "Option::is_none")] - cache_control: Option, - }, - ToolUse { - id: String, - name: String, - input: Value, - #[serde(skip_serializing_if = "Option::is_none")] - cache_control: Option, - }, - ToolResult { - tool_use_id: String, - content: String, - #[serde(skip_serializing_if = "Option::is_none")] - cache_control: Option, - }, -} - -/// Anthropic prompt caching 控制标记。 -/// -/// `type: "ephemeral"` 告诉 Anthropic 后端该块可作为缓存前缀的一部分。 -/// 缓存是临时的(ephemeral),不保证长期有效,但在短时间内重复请求可以显著减少延迟。 -#[derive(Debug, Clone, Serialize)] -struct AnthropicCacheControl { - #[serde(rename = "type")] - type_: String, -} - -impl AnthropicCacheControl { - /// 创建 ephemeral 类型的缓存控制标记。 - fn ephemeral() -> Self { - Self { - type_: "ephemeral".to_string(), - } - } -} - -impl AnthropicContentBlock { - fn block_type(&self) -> &'static str { - match self { - AnthropicContentBlock::Text { .. } => "text", - AnthropicContentBlock::Thinking { .. } => "thinking", - AnthropicContentBlock::ToolUse { .. } => "tool_use", - AnthropicContentBlock::ToolResult { .. } => "tool_result", - } - } - - fn has_cache_control(&self) -> bool { - match self { - AnthropicContentBlock::Text { cache_control, .. } - | AnthropicContentBlock::Thinking { cache_control, .. } - | AnthropicContentBlock::ToolUse { cache_control, .. } - | AnthropicContentBlock::ToolResult { cache_control, .. } => cache_control.is_some(), - } - } - - /// 判断内容块是否适合显式 `cache_control`。 - /// - /// 出于兼容网关的稳健性,显式缓存断点仅打在 text 块上; - /// thinking / tool_use / tool_result 不设置 cache_control,避免部分兼容实现的参数校验失败。 - fn can_use_explicit_cache_control(&self) -> bool { - match self { - AnthropicContentBlock::Text { text, .. } => cacheable_text(text), - AnthropicContentBlock::Thinking { .. } => false, - AnthropicContentBlock::ToolUse { .. } => false, - AnthropicContentBlock::ToolResult { .. } => false, - } - } - - /// 为允许显式缓存的内容块设置或清除 `cache_control` 标记。 - fn set_cache_control_if_allowed(&mut self, enabled: bool) -> bool { - if enabled && !self.can_use_explicit_cache_control() { - return false; - } - - let control = if enabled { - Some(AnthropicCacheControl::ephemeral()) - } else { - None - }; - match self { - AnthropicContentBlock::Text { cache_control, .. } => *cache_control = control, - AnthropicContentBlock::Thinking { .. } => return false, - AnthropicContentBlock::ToolUse { cache_control, .. } => *cache_control = control, - AnthropicContentBlock::ToolResult { cache_control, .. } => *cache_control = control, - } - true - } -} - -/// Anthropic 工具定义。 -/// -/// 与 OpenAI 不同,Anthropic 工具定义不需要 `type` 字段, -/// 直接使用 `name`、`description`、`input_schema` 三个字段。 -#[derive(Debug, Serialize)] -struct AnthropicTool { - name: String, - description: String, - input_schema: Value, - - #[serde(skip_serializing_if = "Option::is_none")] - cache_control: Option, -} - -/// Anthropic Messages API 非流式响应体。 -/// -/// NOTE: `content` 使用 `Vec` 而非强类型结构体, -/// 因为 Anthropic 响应可能包含多种内容块类型(text / tool_use / thinking), -/// 使用 `Value` 可以灵活处理未知或新增的块类型,避免每次 API 更新都要修改 DTO。 -#[derive(Debug, serde::Deserialize)] -struct AnthropicResponse { - content: Vec, - #[allow(dead_code)] - stop_reason: Option, - #[serde(default)] - usage: Option, -} - -/// Anthropic 响应中的 token 用量统计。 -/// -/// 两个字段均为 `Option` 且带 `#[serde(default)]`, -/// 因为某些旧版 API 或特殊响应可能不包含用量信息。 -#[derive(Debug, Clone, Default, serde::Deserialize)] -struct AnthropicUsage { - #[serde(default)] - input_tokens: Option, - #[serde(default)] - output_tokens: Option, - #[serde(default)] - cache_creation_input_tokens: Option, - #[serde(default)] - cache_read_input_tokens: Option, - #[serde(default)] - cache_creation: Option, -} - -#[derive(Debug, Clone, Default, serde::Deserialize)] -struct AnthropicCacheCreationUsage { - #[serde(default)] - ephemeral_5m_input_tokens: Option, - #[serde(default)] - ephemeral_1h_input_tokens: Option, -} - -impl AnthropicUsage { - fn merge_from(&mut self, other: Self) { - self.input_tokens = other.input_tokens.or(self.input_tokens); - self.cache_creation_input_tokens = other - .cache_creation_input_tokens - .or(self.cache_creation_input_tokens); - self.cache_read_input_tokens = other - .cache_read_input_tokens - .or(self.cache_read_input_tokens); - self.cache_creation = other.cache_creation.or_else(|| self.cache_creation.take()); - // output_tokens 在流式事件里通常是累计值,优先保留最新的非空值。 - self.output_tokens = other.output_tokens.or(self.output_tokens); - } - - fn into_llm_usage(self) -> Option { - let cache_creation_input_tokens = self.cache_creation_input_tokens.or_else(|| { - self.cache_creation - .as_ref() - .map(AnthropicCacheCreationUsage::total_input_tokens) - }); - - if self.input_tokens.is_none() - && self.output_tokens.is_none() - && cache_creation_input_tokens.is_none() - && self.cache_read_input_tokens.is_none() - { - return None; - } - - Some(LlmUsage { - input_tokens: self.input_tokens.unwrap_or_default() as usize, - output_tokens: self.output_tokens.unwrap_or_default() as usize, - cache_creation_input_tokens: cache_creation_input_tokens.unwrap_or_default() as usize, - cache_read_input_tokens: self.cache_read_input_tokens.unwrap_or_default() as usize, - }) - } -} - -impl AnthropicCacheCreationUsage { - fn total_input_tokens(&self) -> u64 { - self.ephemeral_5m_input_tokens - .unwrap_or_default() - .saturating_add(self.ephemeral_1h_input_tokens.unwrap_or_default()) - } -} - -#[derive(Debug, Default)] -struct SseProcessResult { - done: bool, - stop_reason: Option, - usage: Option, -} - -fn extract_usage_from_payload(event_type: &str, payload: &Value) -> Option { - match event_type { - "message_start" => payload - .get("message") - .and_then(|message| message.get("usage")) - .and_then(parse_usage_value), - "message_delta" => payload - .get("usage") - .or_else(|| payload.get("delta").and_then(|delta| delta.get("usage"))) - .and_then(parse_usage_value), - _ => None, - } -} - -fn parse_usage_value(value: &Value) -> Option { - serde_json::from_value::(value.clone()).ok() -} - -#[cfg(test)] -mod tests { - use std::sync::{Arc, Mutex}; - - use astrcode_core::UserMessageOrigin; - use serde_json::json; - - use super::*; - use crate::sink_collector; - - #[test] - fn to_anthropic_messages_does_not_inject_empty_text_block_for_tool_use() { - let messages = vec![LlmMessage::Assistant { - content: "".to_string(), - tool_calls: vec![ToolCallRequest { - id: "call_123".to_string(), - name: "test_tool".to_string(), - args: json!({"arg": "value"}), - }], - reasoning: None, - }]; - - let anthropic_messages = to_anthropic_messages( - &messages, - MessageBuildOptions { - include_reasoning_blocks: true, - }, - ); - assert_eq!(anthropic_messages.len(), 1); - - let msg = &anthropic_messages[0]; - assert_eq!(msg.role, "assistant"); - assert_eq!(msg.content.len(), 1); - - match &msg.content[0] { - AnthropicContentBlock::ToolUse { id, name, .. } => { - assert_eq!(id, "call_123"); - assert_eq!(name, "test_tool"); - }, - _ => panic!("Expected ToolUse block"), - } - } - - #[test] - fn to_anthropic_messages_groups_consecutive_tool_results_into_one_user_message() { - let messages = vec![ - LlmMessage::Assistant { - content: String::new(), - tool_calls: vec![ - ToolCallRequest { - id: "call_1".to_string(), - name: "read_file".to_string(), - args: json!({"path": "a.rs"}), - }, - ToolCallRequest { - id: "call_2".to_string(), - name: "grep".to_string(), - args: json!({"pattern": "spawn"}), - }, - ], - reasoning: None, - }, - LlmMessage::Tool { - tool_call_id: "call_1".to_string(), - content: "file content".to_string(), - }, - LlmMessage::Tool { - tool_call_id: "call_2".to_string(), - content: "grep result".to_string(), - }, - ]; - - let anthropic_messages = to_anthropic_messages( - &messages, - MessageBuildOptions { - include_reasoning_blocks: true, - }, - ); - - assert_eq!(anthropic_messages.len(), 2); - assert_eq!(anthropic_messages[0].role, "assistant"); - assert_eq!(anthropic_messages[1].role, "user"); - assert_eq!(anthropic_messages[1].content.len(), 2); - assert!(matches!( - &anthropic_messages[1].content[0], - AnthropicContentBlock::ToolResult { tool_use_id, content, .. } - if tool_use_id == "call_1" && content == "file content" - )); - assert!(matches!( - &anthropic_messages[1].content[1], - AnthropicContentBlock::ToolResult { tool_use_id, content, .. } - if tool_use_id == "call_2" && content == "grep result" - )); - } - - #[test] - fn to_anthropic_messages_keeps_user_text_after_tool_results_in_same_message() { - let messages = vec![ - LlmMessage::Assistant { - content: String::new(), - tool_calls: vec![ToolCallRequest { - id: "call_1".to_string(), - name: "read_file".to_string(), - args: json!({"path": "a.rs"}), - }], - reasoning: None, - }, - LlmMessage::Tool { - tool_call_id: "call_1".to_string(), - content: "file content".to_string(), - }, - LlmMessage::User { - content: "请继续总结发现。".to_string(), - origin: UserMessageOrigin::User, - }, - ]; - - let anthropic_messages = to_anthropic_messages( - &messages, - MessageBuildOptions { - include_reasoning_blocks: true, - }, - ); - - assert_eq!(anthropic_messages.len(), 2); - assert_eq!(anthropic_messages[1].role, "user"); - assert_eq!(anthropic_messages[1].content.len(), 2); - assert!(matches!( - &anthropic_messages[1].content[0], - AnthropicContentBlock::ToolResult { tool_use_id, content, .. } - if tool_use_id == "call_1" && content == "file content" - )); - assert!(matches!( - &anthropic_messages[1].content[1], - AnthropicContentBlock::Text { text, .. } if text == "请继续总结发现。" - )); - } - - #[test] - fn response_to_output_parses_text_tool_use_and_thinking() { - let output = response_to_output(AnthropicResponse { - content: vec![ - json!({ "type": "text", "text": "hello " }), - json!({ - "type": "tool_use", - "id": "call_1", - "name": "search", - "input": { "q": "rust" } - }), - json!({ "type": "text", "text": "world" }), - json!({ "type": "thinking", "thinking": "pondering", "signature": "sig-1" }), - ], - stop_reason: Some("tool_use".to_string()), - usage: None, - }); - - assert_eq!(output.content, "hello world"); - assert_eq!(output.tool_calls.len(), 1); - assert_eq!(output.tool_calls[0].id, "call_1"); - assert_eq!(output.tool_calls[0].args, json!({ "q": "rust" })); - assert_eq!( - output.reasoning, - Some(ReasoningContent { - content: "pondering".to_string(), - signature: Some("sig-1".to_string()), - }) - ); - } - - #[test] - fn streaming_sse_parses_tool_calls_and_text() { - let mut accumulator = LlmAccumulator::default(); - let events = Arc::new(Mutex::new(Vec::new())); - let sink = sink_collector(events.clone()); - let mut sse_buffer = String::new(); - - let chunk = concat!( - "event: content_block_start\n", - "data: {\"index\":1,\"type\":\"tool_use\",\"id\":\"call_1\",\"name\":\"search\"}\n\n", - "event: content_block_delta\n", - "data: {\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"\ - q\\\":\\\"ru\"}}\n\n", - "event: content_block_delta\n", - "data: {\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"st\\\"\ - }\"}}\n\n", - "event: content_block_delta\n", - "data: {\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"hello\"}}\n\n", - "event: message_stop\n", - "data: {\"type\":\"message_stop\"}\n\n" - ); - - let mut stop_reason_out: Option = None; - let mut usage_out = AnthropicUsage::default(); - let done = consume_sse_text_chunk( - chunk, - &mut sse_buffer, - &mut accumulator, - &sink, - &mut stop_reason_out, - &mut usage_out, - ) - .expect("stream chunk should parse"); - - assert!(done); - let output = accumulator.finish(); - let events = events.lock().expect("lock").clone(); - - assert!(events.iter().any(|event| { - matches!( - event, - LlmEvent::ToolCallDelta { index, id, name, arguments_delta } - if *index == 1 - && id.as_deref() == Some("call_1") - && name.as_deref() == Some("search") - && arguments_delta.is_empty() - ) - })); - assert!( - events - .iter() - .any(|event| matches!(event, LlmEvent::TextDelta(text) if text == "hello")) - ); - assert_eq!(output.content, "hello"); - assert_eq!(output.tool_calls.len(), 1); - assert_eq!(output.tool_calls[0].args, json!({ "q": "rust" })); - } - - #[test] - fn parse_sse_block_accepts_data_lines_without_space_after_colon() { - let block = concat!( - "event:content_block_delta\n", - "data:{\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"hello\"}}\n" - ); - - let parsed = parse_sse_block(block) - .expect("block should parse") - .expect("block should contain payload"); - - assert_eq!(parsed.0, "content_block_delta"); - assert_eq!(parsed.1["delta"]["text"], json!("hello")); - } - - #[test] - fn parse_sse_block_treats_done_sentinel_as_message_stop() { - let parsed = parse_sse_block("data: [DONE]\n") - .expect("done sentinel should parse") - .expect("done sentinel should produce payload"); - - assert_eq!(parsed.0, "message_stop"); - assert_eq!(parsed.1["type"], json!("message_stop")); - } - - #[test] - fn parse_sse_block_ignores_empty_data_payload() { - let parsed = parse_sse_block("event: ping\ndata:\n"); - assert!(matches!(parsed, Ok(None))); - } - - #[test] - fn streaming_sse_error_event_surfaces_structured_provider_failure() { - let mut accumulator = LlmAccumulator::default(); - let events = Arc::new(Mutex::new(Vec::new())); - let sink = sink_collector(events); - let mut sse_buffer = String::new(); - let mut stop_reason_out: Option = None; - let mut usage_out = AnthropicUsage::default(); - - let error = consume_sse_text_chunk( - concat!( - "event: error\n", - "data: {\"type\":\"error\",\"error\":{\"type\":\"overloaded_error\",", - "\"message\":\"capacity exhausted\"}}\n\n" - ), - &mut sse_buffer, - &mut accumulator, - &sink, - &mut stop_reason_out, - &mut usage_out, - ) - .expect_err("error event should terminate the stream with a structured error"); - - match error { - AstrError::LlmRequestFailed { status, body } => { - assert_eq!(status, 529); - assert!(body.contains("overloaded_error")); - assert!(body.contains("capacity exhausted")); - }, - other => panic!("unexpected error variant: {other:?}"), - } - } - - #[test] - fn streaming_sse_error_event_without_type_still_maps_to_request_error() { - let mut accumulator = LlmAccumulator::default(); - let events = Arc::new(Mutex::new(Vec::new())); - let sink = sink_collector(events); - let mut sse_buffer = String::new(); - let mut stop_reason_out: Option = None; - let mut usage_out = AnthropicUsage::default(); - - let error = consume_sse_text_chunk( - concat!( - "event: error\n", - "data: {\"type\":\"error\",\"error\":{\"message\":\"messages 参数非法\"}}\n\n" - ), - &mut sse_buffer, - &mut accumulator, - &sink, - &mut stop_reason_out, - &mut usage_out, - ) - .expect_err("error event should terminate the stream with a structured error"); - - match error { - AstrError::LlmRequestFailed { status, body } => { - assert_eq!(status, 400); - assert!(body.contains("invalid_request_error")); - assert!(body.contains("messages 参数非法")); - }, - other => panic!("unexpected error variant: {other:?}"), - } - } - - #[test] - fn build_request_serializes_system_and_thinking_when_applicable() { - let provider = AnthropicProvider::new( - "https://api.anthropic.com/v1/messages".to_string(), - "sk-ant-test".to_string(), - "claude-sonnet-4-5".to_string(), - ModelLimits { - context_window: 200_000, - max_output_tokens: 8096, - }, - LlmClientConfig::default(), - ) - .expect("provider should build"); - let request = provider.build_request( - &[LlmMessage::User { - content: "hi".to_string(), - origin: UserMessageOrigin::User, - }], - &[], - Some("Follow the rules"), - &[], - true, - ); - let body = serde_json::to_value(&request).expect("request should serialize"); - - assert_eq!(body["cache_control"]["type"], json!("ephemeral")); - assert_eq!( - body.get("system").and_then(Value::as_str), - Some("Follow the rules") - ); - assert_eq!( - body.get("thinking") - .and_then(|value| value.get("type")) - .and_then(Value::as_str), - Some("enabled") - ); - } - - fn count_cache_control_fields(value: &Value) -> usize { - match value { - Value::Object(map) => { - usize::from(map.contains_key("cache_control")) - + map.values().map(count_cache_control_fields).sum::() - }, - Value::Array(values) => values.iter().map(count_cache_control_fields).sum(), - _ => 0, - } - } - - #[test] - fn official_anthropic_uses_automatic_cache_and_caps_explicit_breakpoints() { - let provider = AnthropicProvider::new( - "https://api.anthropic.com/v1/messages".to_string(), - "sk-ant-test".to_string(), - "claude-sonnet-4-5".to_string(), - ModelLimits { - context_window: 200_000, - max_output_tokens: 8096, - }, - LlmClientConfig::default(), - ) - .expect("provider should build"); - let system_blocks = (0..5) - .map(|index| SystemPromptBlock { - title: format!("Stable {index}"), - content: format!("stable content {index}"), - cache_boundary: true, - layer: SystemPromptLayer::Stable, - }) - .collect::>(); - let tools = vec![ToolDefinition { - name: "search".to_string(), - description: "Search indexed data.".to_string(), - parameters: json!({ "type": "object" }), - }]; - let request = provider.build_request( - &[LlmMessage::User { - content: "hi".to_string(), - origin: UserMessageOrigin::User, - }], - &tools, - None, - &system_blocks, - false, - ); - let body = serde_json::to_value(&request).expect("request should serialize"); - - assert_eq!(body["cache_control"]["type"], json!("ephemeral")); - assert!( - count_cache_control_fields(&body) <= ANTHROPIC_CACHE_BREAKPOINT_LIMIT, - "official request should keep automatic + explicit cache controls within the provider \ - limit" - ); - assert!( - body["messages"][0]["content"][0] - .get("cache_control") - .is_none(), - "official endpoint uses top-level automatic cache for the message tail" - ); - } - - #[test] - fn custom_anthropic_gateway_uses_explicit_message_tail_without_top_level_cache() { - let provider = AnthropicProvider::new( - "https://gateway.example.com/anthropic/v1/messages".to_string(), - "sk-ant-test".to_string(), - "claude-sonnet-4-5".to_string(), - ModelLimits { - context_window: 200_000, - max_output_tokens: 8096, - }, - LlmClientConfig::default(), - ) - .expect("provider should build"); - let request = provider.build_request( - &[ - LlmMessage::User { - content: "first".to_string(), - origin: UserMessageOrigin::User, - }, - LlmMessage::User { - content: "second".to_string(), - origin: UserMessageOrigin::User, - }, - ], - &[], - None, - &[], - false, - ); - let body = serde_json::to_value(&request).expect("request should serialize"); - - assert!(body.get("cache_control").is_none()); - assert_eq!(body["messages"].as_array().map(Vec::len), Some(1)); - assert_eq!( - body["messages"][0]["content"][1]["cache_control"]["type"], - json!("ephemeral") - ); - assert!( - count_cache_control_fields(&body) <= ANTHROPIC_CACHE_BREAKPOINT_LIMIT, - "custom gateways only receive explicit cache controls within the provider limit" - ); - } - - #[test] - fn custom_gateway_request_disables_extended_thinking_payloads() { - let provider = AnthropicProvider::new( - "https://gateway.example.com/anthropic/v1/messages".to_string(), - "sk-ant-test".to_string(), - "claude-sonnet-4-5".to_string(), - ModelLimits { - context_window: 200_000, - max_output_tokens: 8096, - }, - LlmClientConfig::default(), - ) - .expect("provider should build"); - let request = provider.build_request( - &[LlmMessage::Assistant { - content: "".to_string(), - tool_calls: vec![], - reasoning: Some(ReasoningContent { - content: "thinking".to_string(), - signature: Some("sig".to_string()), - }), - }], - &[], - None, - &[], - false, - ); - let body = serde_json::to_value(&request).expect("request should serialize"); - - assert!(body.get("thinking").is_none()); - assert_eq!(body["messages"][0]["content"][0]["type"], json!("text")); - assert_eq!(body["messages"][0]["content"][0]["text"], json!("")); - } - - #[test] - fn provider_keeps_custom_messages_api_url() { - let provider = AnthropicProvider::new( - "https://gateway.example.com/anthropic/v1/messages".to_string(), - "sk-ant-test".to_string(), - "claude-sonnet-4-5".to_string(), - ModelLimits { - context_window: 200_000, - max_output_tokens: 8096, - }, - LlmClientConfig::default(), - ) - .expect("provider should build"); - - assert_eq!( - provider.messages_api_url, - "https://gateway.example.com/anthropic/v1/messages" - ); - } - - #[test] - fn build_request_serializes_system_blocks_with_cache_boundaries() { - let provider = AnthropicProvider::new( - "https://api.anthropic.com/v1/messages".to_string(), - "sk-ant-test".to_string(), - "claude-sonnet-4-5".to_string(), - ModelLimits { - context_window: 200_000, - max_output_tokens: 8096, - }, - LlmClientConfig::default(), - ) - .expect("provider should build"); - let request = provider.build_request( - &[LlmMessage::User { - content: "hi".to_string(), - origin: UserMessageOrigin::User, - }], - &[], - Some("ignored fallback"), - &[SystemPromptBlock { - title: "Stable".to_string(), - content: "stable".to_string(), - cache_boundary: true, - layer: astrcode_core::SystemPromptLayer::Stable, - }], - false, - ); - let body = serde_json::to_value(&request).expect("request should serialize"); - - assert!(body.get("system").is_some_and(Value::is_array)); - assert_eq!( - body["system"][0]["cache_control"]["type"], - json!("ephemeral") - ); - } - - #[test] - fn build_request_only_marks_cache_boundaries_at_layer_transitions() { - let provider = AnthropicProvider::new( - "https://api.anthropic.com/v1/messages".to_string(), - "sk-ant-test".to_string(), - "claude-sonnet-4-5".to_string(), - ModelLimits { - context_window: 200_000, - max_output_tokens: 8096, - }, - LlmClientConfig::default(), - ) - .expect("provider should build"); - let request = provider.build_request( - &[LlmMessage::User { - content: "hi".to_string(), - origin: UserMessageOrigin::User, - }], - &[], - Some("ignored fallback"), - &[ - SystemPromptBlock { - title: "Stable 1".to_string(), - content: "stable content 1".to_string(), - cache_boundary: false, - layer: astrcode_core::SystemPromptLayer::Stable, - }, - SystemPromptBlock { - title: "Stable 2".to_string(), - content: "stable content 2".to_string(), - cache_boundary: false, - layer: astrcode_core::SystemPromptLayer::Stable, - }, - SystemPromptBlock { - title: "Stable 3".to_string(), - content: "stable content 3".to_string(), - cache_boundary: true, - layer: astrcode_core::SystemPromptLayer::Stable, - }, - SystemPromptBlock { - title: "Semi 1".to_string(), - content: "semi content 1".to_string(), - cache_boundary: false, - layer: astrcode_core::SystemPromptLayer::SemiStable, - }, - SystemPromptBlock { - title: "Semi 2".to_string(), - content: "semi content 2".to_string(), - cache_boundary: true, - layer: astrcode_core::SystemPromptLayer::SemiStable, - }, - SystemPromptBlock { - title: "Inherited 1".to_string(), - content: "inherited content 1".to_string(), - cache_boundary: true, - layer: astrcode_core::SystemPromptLayer::Inherited, - }, - SystemPromptBlock { - title: "Dynamic 1".to_string(), - content: "dynamic content 1".to_string(), - cache_boundary: true, - layer: astrcode_core::SystemPromptLayer::Dynamic, - }, - ], - false, - ); - let body = serde_json::to_value(&request).expect("request should serialize"); - - assert!(body.get("system").is_some_and(Value::is_array)); - assert_eq!(body["system"].as_array().unwrap().len(), 7); - - // Stable 层内的前两个 block 不应该有 cache_control - assert!( - body["system"][0].get("cache_control").is_none(), - "stable1 should not have cache_control" - ); - assert!( - body["system"][1].get("cache_control").is_none(), - "stable2 should not have cache_control" - ); - - // Stable 层的最后一个 block 应该有 cache_control - assert_eq!( - body["system"][2]["cache_control"]["type"], - json!("ephemeral"), - "stable3 should have cache_control" - ); - - // SemiStable 层的第一个 block 不应该有 cache_control - assert!( - body["system"][3].get("cache_control").is_none(), - "semi1 should not have cache_control" - ); - - // SemiStable 层的最后一个 block 应该有 cache_control - assert_eq!( - body["system"][4]["cache_control"]["type"], - json!("ephemeral"), - "semi2 should have cache_control" - ); - - // Inherited 层允许独立缓存 - assert_eq!( - body["system"][5]["cache_control"]["type"], - json!("ephemeral"), - "inherited1 should have cache_control" - ); - - // Dynamic 层不缓存(避免浪费,因为内容变化频繁) - // TODO: 更好的做法?实现更好的kv缓存? - assert!( - body["system"][6].get("cache_control").is_none(), - "dynamic1 should not have cache_control (Dynamic layer is not cached)" - ); - } - - #[test] - fn response_to_output_parses_cache_usage_fields() { - let output = response_to_output(AnthropicResponse { - content: vec![json!({ "type": "text", "text": "ok" })], - stop_reason: Some("end_turn".to_string()), - usage: Some(AnthropicUsage { - input_tokens: Some(100), - output_tokens: Some(20), - cache_creation_input_tokens: Some(80), - cache_read_input_tokens: Some(60), - cache_creation: None, - }), - }); - - assert_eq!( - output.usage, - Some(LlmUsage { - input_tokens: 100, - output_tokens: 20, - cache_creation_input_tokens: 80, - cache_read_input_tokens: 60, - }) - ); - } - - #[test] - fn response_to_output_parses_nested_cache_creation_usage_fields() { - let output = response_to_output(AnthropicResponse { - content: vec![json!({ "type": "text", "text": "ok" })], - stop_reason: Some("end_turn".to_string()), - usage: Some(AnthropicUsage { - input_tokens: Some(100), - output_tokens: Some(20), - cache_creation_input_tokens: None, - cache_read_input_tokens: Some(60), - cache_creation: Some(AnthropicCacheCreationUsage { - ephemeral_5m_input_tokens: Some(30), - ephemeral_1h_input_tokens: Some(50), - }), - }), - }); - - assert_eq!( - output.usage, - Some(LlmUsage { - input_tokens: 100, - output_tokens: 20, - cache_creation_input_tokens: 80, - cache_read_input_tokens: 60, - }) - ); - } - - #[test] - fn anthropic_provider_reports_cache_metrics_support() { - let provider = AnthropicProvider::new( - "https://api.anthropic.com/v1/messages".to_string(), - "sk-ant-test".to_string(), - "claude-sonnet-4-5".to_string(), - ModelLimits { - context_window: 200_000, - max_output_tokens: 8096, - }, - LlmClientConfig::default(), - ) - .expect("provider should build"); - - assert!(provider.supports_cache_metrics()); - } - - #[test] - fn streaming_sse_extracts_usage_from_message_events() { - let mut accumulator = LlmAccumulator::default(); - let events = Arc::new(Mutex::new(Vec::new())); - let sink = sink_collector(events); - let mut usage_out = AnthropicUsage::default(); - let mut stop_reason_out = None; - let mut sse_buffer = String::new(); - - let chunk = concat!( - "event: message_start\n", - "data: {\"type\":\"message_start\",\"message\":{\"usage\":{\"input_tokens\":120,\"\ - cache_creation_input_tokens\":90,\"cache_read_input_tokens\":70}}}\n\n", - "event: message_delta\n", - "data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":\ - {\"output_tokens\":33}}\n\n", - "event: message_stop\n", - "data: {\"type\":\"message_stop\"}\n\n" - ); - - let done = consume_sse_text_chunk( - chunk, - &mut sse_buffer, - &mut accumulator, - &sink, - &mut stop_reason_out, - &mut usage_out, - ) - .expect("stream chunk should parse"); - - assert!(done); - assert_eq!(stop_reason_out.as_deref(), Some("end_turn")); - assert_eq!( - usage_out.into_llm_usage(), - Some(LlmUsage { - input_tokens: 120, - output_tokens: 33, - cache_creation_input_tokens: 90, - cache_read_input_tokens: 70, - }) - ); - } - - #[test] - fn streaming_sse_handles_multibyte_text_split_across_chunks() { - let mut accumulator = LlmAccumulator::default(); - let events = Arc::new(Mutex::new(Vec::new())); - let sink = sink_collector(events.clone()); - let mut sse_buffer = String::new(); - let mut decoder = Utf8StreamDecoder::default(); - let mut stop_reason_out = None; - let mut usage_out = AnthropicUsage::default(); - let chunk = concat!( - "event: content_block_delta\n", - "data: {\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"你", - "好\"}}\n\n", - "event: message_stop\n", - "data: {\"type\":\"message_stop\"}\n\n" - ); - let bytes = chunk.as_bytes(); - let split_index = chunk - .find("好") - .expect("chunk should contain multibyte char") - + 1; - - let first_text = decoder - .push( - &bytes[..split_index], - "anthropic response stream was not valid utf-8", - ) - .expect("first split should decode"); - let second_text = decoder - .push( - &bytes[split_index..], - "anthropic response stream was not valid utf-8", - ) - .expect("second split should decode"); - - let first_done = first_text - .as_deref() - .map(|text| { - consume_sse_text_chunk( - text, - &mut sse_buffer, - &mut accumulator, - &sink, - &mut stop_reason_out, - &mut usage_out, - ) - }) - .transpose() - .expect("first chunk should parse") - .unwrap_or(false); - let second_done = second_text - .as_deref() - .map(|text| { - consume_sse_text_chunk( - text, - &mut sse_buffer, - &mut accumulator, - &sink, - &mut stop_reason_out, - &mut usage_out, - ) - }) - .transpose() - .expect("second chunk should parse") - .unwrap_or(false); - - assert!(!first_done); - assert!(second_done); - let output = accumulator.finish(); - let events = events.lock().expect("lock").clone(); - - assert!( - events - .iter() - .any(|event| matches!(event, LlmEvent::TextDelta(text) if text == "你好")) - ); - assert_eq!(output.content, "你好"); - } -} diff --git a/crates/adapter-llm/src/anthropic/dto.rs b/crates/adapter-llm/src/anthropic/dto.rs new file mode 100644 index 00000000..a4ac3e70 --- /dev/null +++ b/crates/adapter-llm/src/anthropic/dto.rs @@ -0,0 +1,342 @@ +use serde::Serialize; +use serde_json::Value; + +use crate::LlmUsage; + +pub(super) fn cacheable_text(text: &str) -> bool { + !text.is_empty() +} + +/// Anthropic Messages API 请求体。 +/// +/// 注意:`stream` 字段为 `Option`,`None` 时表示非流式模式, +/// 这样可以在序列化时省略该字段(Anthropic API 默认非流式)。 +#[derive(Debug, Serialize)] +pub(super) struct AnthropicRequest { + pub(super) model: String, + pub(super) max_tokens: u32, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) cache_control: Option, + pub(super) messages: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) system: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) tools: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) stream: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) thinking: Option, +} + +#[derive(Debug, Serialize)] +#[serde(untagged)] +pub(super) enum AnthropicSystemPrompt { + Text(String), + Blocks(Vec), +} + +#[derive(Debug, Serialize)] +pub(super) struct AnthropicSystemBlock { + #[serde(rename = "type")] + pub(super) type_: String, + pub(super) text: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) cache_control: Option, +} + +/// Anthropic extended thinking 配置。 +/// +/// `budget_tokens` 指定推理过程可使用的最大 token 数, +/// 不计入最终输出的 `max_tokens` 限制。 +/// +/// ## 设计动机 +/// +/// Extended thinking 让 Claude 在输出前进行深度推理,提升复杂任务的回答质量。 +/// 预算设为 75% 是为了保留至少 25% 的 token 给实际输出内容。 +#[derive(Debug, Serialize)] +pub(super) struct AnthropicThinking { + #[serde(rename = "type")] + pub(super) type_: String, + pub(super) budget_tokens: u32, +} + +/// Anthropic 消息(包含角色和内容块数组)。 +/// +/// Anthropic 的消息结构与 OpenAI 不同:`content` 是内容块数组而非纯文本, +/// 这使得单条消息可以混合文本、推理、工具调用等多种内容类型。 +#[derive(Debug, Serialize)] +pub(super) struct AnthropicMessage { + pub(super) role: String, + pub(super) content: Vec, +} + +/// Anthropic 内容块——消息内容由多个块组成。 +/// +/// 使用 `#[serde(tag = "type")]` 实现内部标记序列化, +/// 每个变体对应一个 `type` 值(`text`、`thinking`、`tool_use`、`tool_result`)。 +/// +/// ## 缓存控制 +/// +/// 每个块可选携带 `cache_control` 字段,标记为 `ephemeral` 类型时, +/// Anthropic 后端会将该块作为缓存前缀的一部分,用于 KV cache 复用。 +#[derive(Debug, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub(super) enum AnthropicContentBlock { + Text { + text: String, + #[serde(skip_serializing_if = "Option::is_none")] + cache_control: Option, + }, + Thinking { + thinking: String, + #[serde(skip_serializing_if = "Option::is_none")] + signature: Option, + #[serde(skip_serializing_if = "Option::is_none")] + cache_control: Option, + }, + ToolUse { + id: String, + name: String, + input: Value, + #[serde(skip_serializing_if = "Option::is_none")] + cache_control: Option, + }, + ToolResult { + tool_use_id: String, + content: String, + #[serde(skip_serializing_if = "Option::is_none")] + cache_control: Option, + }, +} + +/// Anthropic prompt caching 控制标记。 +/// +/// `type: "ephemeral"` 告诉 Anthropic 后端该块可作为缓存前缀的一部分。 +/// 缓存是临时的(ephemeral),不保证长期有效,但在短时间内重复请求可以显著减少延迟。 +#[derive(Debug, Clone, Serialize)] +pub(super) struct AnthropicCacheControl { + #[serde(rename = "type")] + type_: String, +} + +impl AnthropicCacheControl { + /// 创建 ephemeral 类型的缓存控制标记。 + pub(super) fn ephemeral() -> Self { + Self { + type_: "ephemeral".to_string(), + } + } +} + +impl AnthropicContentBlock { + pub(super) fn block_type(&self) -> &'static str { + match self { + AnthropicContentBlock::Text { .. } => "text", + AnthropicContentBlock::Thinking { .. } => "thinking", + AnthropicContentBlock::ToolUse { .. } => "tool_use", + AnthropicContentBlock::ToolResult { .. } => "tool_result", + } + } + + pub(super) fn has_cache_control(&self) -> bool { + match self { + AnthropicContentBlock::Text { cache_control, .. } + | AnthropicContentBlock::Thinking { cache_control, .. } + | AnthropicContentBlock::ToolUse { cache_control, .. } + | AnthropicContentBlock::ToolResult { cache_control, .. } => cache_control.is_some(), + } + } + + /// 判断内容块是否适合显式 `cache_control`。 + /// + /// 出于兼容网关的稳健性,显式缓存断点仅打在 text 块上; + /// thinking / tool_use / tool_result 不设置 cache_control,避免部分兼容实现的参数校验失败。 + pub(super) fn can_use_explicit_cache_control(&self) -> bool { + match self { + AnthropicContentBlock::Text { text, .. } => cacheable_text(text), + AnthropicContentBlock::Thinking { .. } => false, + AnthropicContentBlock::ToolUse { .. } => false, + AnthropicContentBlock::ToolResult { .. } => false, + } + } + + /// 为允许显式缓存的内容块设置或清除 `cache_control` 标记。 + pub(super) fn set_cache_control_if_allowed(&mut self, enabled: bool) -> bool { + if enabled && !self.can_use_explicit_cache_control() { + return false; + } + + let control = if enabled { + Some(AnthropicCacheControl::ephemeral()) + } else { + None + }; + match self { + AnthropicContentBlock::Text { cache_control, .. } + | AnthropicContentBlock::Thinking { cache_control, .. } + | AnthropicContentBlock::ToolUse { cache_control, .. } + | AnthropicContentBlock::ToolResult { cache_control, .. } => *cache_control = control, + } + true + } +} + +/// Anthropic 工具定义。 +/// +/// 与 OpenAI 不同,Anthropic 工具定义不需要 `type` 字段, +/// 直接使用 `name`、`description`、`input_schema` 三个字段。 +#[derive(Debug, Serialize)] +pub(super) struct AnthropicTool { + pub(super) name: String, + pub(super) description: String, + pub(super) input_schema: Value, + + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) cache_control: Option, +} + +/// Anthropic Messages API 非流式响应体。 +/// +/// NOTE: `content` 使用 `Vec` 而非强类型结构体, +/// 因为 Anthropic 响应可能包含多种内容块类型(text / tool_use / thinking), +/// 使用 `Value` 可以灵活处理未知或新增的块类型,避免每次 API 更新都要修改 DTO。 +#[derive(Debug, serde::Deserialize)] +pub(super) struct AnthropicResponse { + pub(super) content: Vec, + #[allow(dead_code)] + pub(super) stop_reason: Option, + #[serde(default)] + pub(super) usage: Option, +} + +/// Anthropic 响应中的 token 用量统计。 +/// +/// 两个字段均为 `Option` 且带 `#[serde(default)]`, +/// 因为某些旧版 API 或特殊响应可能不包含用量信息。 +#[derive(Debug, Clone, Default, serde::Deserialize)] +pub(super) struct AnthropicUsage { + #[serde(default)] + pub(super) input_tokens: Option, + #[serde(default)] + pub(super) output_tokens: Option, + #[serde(default)] + pub(super) cache_creation_input_tokens: Option, + #[serde(default)] + pub(super) cache_read_input_tokens: Option, + #[serde(default)] + pub(super) cache_creation: Option, +} + +#[derive(Debug, Clone, Default, serde::Deserialize)] +pub(super) struct AnthropicCacheCreationUsage { + #[serde(default)] + pub(super) ephemeral_5m_input_tokens: Option, + #[serde(default)] + pub(super) ephemeral_1h_input_tokens: Option, +} + +impl AnthropicUsage { + pub(super) fn merge_from(&mut self, other: Self) { + self.input_tokens = other.input_tokens.or(self.input_tokens); + self.cache_creation_input_tokens = other + .cache_creation_input_tokens + .or(self.cache_creation_input_tokens); + self.cache_read_input_tokens = other + .cache_read_input_tokens + .or(self.cache_read_input_tokens); + self.cache_creation = other.cache_creation.or_else(|| self.cache_creation.take()); + // output_tokens 在流式事件里通常是累计值,优先保留最新的非空值。 + self.output_tokens = other.output_tokens.or(self.output_tokens); + } + + pub(super) fn into_llm_usage(self) -> Option { + let cache_creation_input_tokens = self.cache_creation_input_tokens.or_else(|| { + self.cache_creation + .as_ref() + .map(AnthropicCacheCreationUsage::total_input_tokens) + }); + + if self.input_tokens.is_none() + && self.output_tokens.is_none() + && cache_creation_input_tokens.is_none() + && self.cache_read_input_tokens.is_none() + { + return None; + } + + Some(LlmUsage { + input_tokens: self.input_tokens.unwrap_or_default() as usize, + output_tokens: self.output_tokens.unwrap_or_default() as usize, + cache_creation_input_tokens: cache_creation_input_tokens.unwrap_or_default() as usize, + cache_read_input_tokens: self.cache_read_input_tokens.unwrap_or_default() as usize, + }) + } +} + +impl AnthropicCacheCreationUsage { + fn total_input_tokens(&self) -> u64 { + self.ephemeral_5m_input_tokens + .unwrap_or_default() + .saturating_add(self.ephemeral_1h_input_tokens.unwrap_or_default()) + } +} + +#[derive(Debug, Default)] +pub(super) struct SseProcessResult { + pub(super) done: bool, + pub(super) stop_reason: Option, + pub(super) usage: Option, +} + +pub(super) fn extract_usage_from_payload( + event_type: &str, + payload: &Value, +) -> Option { + match event_type { + "message_start" => payload + .get("message") + .and_then(|message| message.get("usage")) + .and_then(parse_usage_value), + "message_delta" => payload + .get("usage") + .or_else(|| payload.get("delta").and_then(|delta| delta.get("usage"))) + .and_then(parse_usage_value), + _ => None, + } +} + +fn parse_usage_value(value: &Value) -> Option { + serde_json::from_value::(value.clone()).ok() +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::{AnthropicCacheControl, AnthropicContentBlock}; + + #[test] + fn clearing_cache_control_reports_success_for_non_text_blocks() { + let mut block = AnthropicContentBlock::Thinking { + thinking: "reasoning".to_string(), + signature: None, + cache_control: Some(AnthropicCacheControl::ephemeral()), + }; + + assert!(block.set_cache_control_if_allowed(false)); + assert!(!block.has_cache_control()); + } + + #[test] + fn enabling_cache_control_still_rejects_unsupported_blocks() { + let mut block = AnthropicContentBlock::ToolUse { + id: "call_1".to_string(), + name: "search".to_string(), + input: json!({ "q": "rust" }), + cache_control: None, + }; + + assert!(!block.set_cache_control_if_allowed(true)); + assert!(!block.has_cache_control()); + } +} diff --git a/crates/adapter-llm/src/anthropic/mod.rs b/crates/adapter-llm/src/anthropic/mod.rs new file mode 100644 index 00000000..9a091c6d --- /dev/null +++ b/crates/adapter-llm/src/anthropic/mod.rs @@ -0,0 +1,32 @@ +//! # Anthropic Messages API 提供者 +//! +//! 实现了 [`LlmProvider`] trait,对接 Anthropic Claude 系列模型。 +//! +//! ## 协议特性 +//! +//! - **Extended Thinking**: 自动为 Claude 模型启用深度推理模式(`thinking` 配置), 预算 token 设为 +//! `max_tokens` 的 75%,保留至少 25% 给实际输出 +//! - **Prompt Caching**: 优先对分层 system blocks 放置 `ephemeral` breakpoint,并在消息尾部保留 +//! 一个缓存边界,复用 KV cache +//! - **SSE 流式解析**: Anthropic 使用多行 SSE 块格式(`event: ...\ndata: {...}\n\n`), 与 OpenAI +//! 的单行 `data: {...}` 不同,因此有独立的解析逻辑 +//! - **内容块模型**: Anthropic 响应由多种内容块组成(text / tool_use / thinking), 使用 +//! `Vec` 灵活处理未知或新增的块类型 +//! +//! ## 流式事件分派 +//! +//! Anthropic SSE 事件类型: +//! - `content_block_start`: 新内容块开始(文本或工具调用) +//! - `content_block_delta`: 增量内容(text_delta / thinking_delta / signature_delta / +//! input_json_delta) +//! - `message_stop`: 流结束信号 +//! - `message_start / message_delta`: 提取 usage / stop_reason 等元数据 +//! - `content_block_stop / ping`: 元数据事件,静默忽略 + +mod dto; +mod provider; +mod request; +mod response; +mod stream; + +pub use provider::AnthropicProvider; diff --git a/crates/adapter-llm/src/anthropic/provider.rs b/crates/adapter-llm/src/anthropic/provider.rs new file mode 100644 index 00000000..38c5c356 --- /dev/null +++ b/crates/adapter-llm/src/anthropic/provider.rs @@ -0,0 +1,518 @@ +use std::{ + fmt, + sync::{Arc, Mutex}, +}; + +use astrcode_core::{ + AstrError, CancelToken, LlmMessage, Result, SystemPromptBlock, ToolDefinition, +}; +use async_trait::async_trait; +use futures_util::StreamExt; +use log::{debug, warn}; +use tokio::select; + +use super::{ + dto::{AnthropicCacheControl, AnthropicRequest, AnthropicResponse, AnthropicUsage}, + request::{ + ANTHROPIC_CACHE_BREAKPOINT_LIMIT, MessageBuildOptions, enable_message_caching, + is_official_anthropic_api_url, summarize_request_for_diagnostics, + thinking_config_for_model, to_anthropic_messages, to_anthropic_system, to_anthropic_tools, + }, + response::response_to_output, + stream::{consume_sse_text_chunk, flush_sse_buffer}, +}; +use crate::{ + EventSink, FinishReason, LlmAccumulator, LlmClientConfig, LlmOutput, LlmProvider, LlmRequest, + ModelLimits, Utf8StreamDecoder, build_http_client, cache_tracker::CacheTracker, + classify_http_error, is_retryable_status, wait_retry_delay, +}; + +const ANTHROPIC_VERSION: &str = "2023-06-01"; + +/// Anthropic Claude API 提供者实现。 +/// +/// 封装了 HTTP 客户端、API 密钥和模型配置,提供统一的 [`LlmProvider`] 接口。 +/// +/// ## 设计要点 +/// +/// - HTTP 客户端在构造时创建,使用共享的超时策略(连接 10s / 读取 90s) +/// - `limits.max_output_tokens` 同时控制请求体的上限和 extended thinking 的预算计算 +/// - Debug 实现会隐藏 API 密钥(显示为 ``) +#[derive(Clone)] +pub struct AnthropicProvider { + client: reqwest::Client, + client_config: LlmClientConfig, + messages_api_url: String, + api_key: String, + model: String, + /// 运行时已解析好的模型 limits。 + /// + /// Anthropic 的上下文窗口来自 Models API,不应该继续在 provider 内写死。 + limits: ModelLimits, + /// 缓存失效检测跟踪器 + cache_tracker: Arc>, +} + +impl fmt::Debug for AnthropicProvider { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("AnthropicProvider") + .field("client", &self.client) + .field("messages_api_url", &self.messages_api_url) + .field("api_key", &"") + .field("model", &self.model) + .field("limits", &self.limits) + .field("client_config", &self.client_config) + .field("cache_tracker", &"") + .finish() + } +} + +impl AnthropicProvider { + /// 创建新的 Anthropic 提供者实例。 + /// + /// `limits.max_output_tokens` 同时用于: + /// 1. 请求体中的 `max_tokens` 字段(输出上限) + /// 2. Extended thinking 预算计算(75% 的 max_tokens) + pub fn new( + messages_api_url: String, + api_key: String, + model: String, + limits: ModelLimits, + client_config: LlmClientConfig, + ) -> Result { + Ok(Self { + client: build_http_client(client_config)?, + client_config, + messages_api_url, + api_key, + model, + limits, + cache_tracker: Arc::new(Mutex::new(CacheTracker::new())), + }) + } + + /// 构建 Anthropic Messages API 请求体。 + /// + /// - 将 `LlmMessage` 转换为 Anthropic 格式的内容块数组 + /// - 对分层 system blocks 和消息尾部启用 prompt caching(KV cache 复用) + /// - 如果启用了工具,附加工具定义 + /// - 根据模型名称和 max_tokens 自动配置 extended thinking + pub(super) fn build_request( + &self, + messages: &[LlmMessage], + tools: &[ToolDefinition], + system_prompt: Option<&str>, + system_prompt_blocks: &[SystemPromptBlock], + max_output_tokens_override: Option, + stream: bool, + ) -> AnthropicRequest { + let effective_max_output_tokens = max_output_tokens_override + .unwrap_or(self.limits.max_output_tokens) + .min(self.limits.max_output_tokens); + let use_official_endpoint = is_official_anthropic_api_url(&self.messages_api_url); + let use_automatic_cache = use_official_endpoint; + let mut remaining_cache_breakpoints = ANTHROPIC_CACHE_BREAKPOINT_LIMIT; + let request_cache_control = if use_automatic_cache { + remaining_cache_breakpoints = remaining_cache_breakpoints.saturating_sub(1); + Some(AnthropicCacheControl::ephemeral()) + } else { + None + }; + + let mut anthropic_messages = to_anthropic_messages( + messages, + MessageBuildOptions { + include_reasoning_blocks: use_official_endpoint, + }, + ); + let tools = if tools.is_empty() { + None + } else { + Some(to_anthropic_tools(tools, &mut remaining_cache_breakpoints)) + }; + let system = to_anthropic_system( + system_prompt, + system_prompt_blocks, + &mut remaining_cache_breakpoints, + ); + + if !use_automatic_cache { + enable_message_caching(&mut anthropic_messages, remaining_cache_breakpoints); + } + + AnthropicRequest { + model: self.model.clone(), + max_tokens: effective_max_output_tokens.min(u32::MAX as usize) as u32, + cache_control: request_cache_control, + messages: anthropic_messages, + system, + tools, + stream: stream.then_some(true), + // Why: 第三方 Anthropic 兼容网关常见只支持基础 messages 子集; + // 在非官方 endpoint 下关闭 `thinking` 字段,避免触发参数校验失败。 + thinking: if use_official_endpoint { + thinking_config_for_model( + &self.model, + effective_max_output_tokens.min(u32::MAX as usize) as u32, + ) + } else { + None + }, + } + } + + async fn send_request( + &self, + request: &AnthropicRequest, + cancel: CancelToken, + ) -> Result { + // 调试日志:打印请求信息(不暴露完整 API Key) + let api_key_preview = if self.api_key.len() > 8 { + format!( + "{}...{}", + &self.api_key[..4], + &self.api_key[self.api_key.len() - 4..] + ) + } else { + "****".to_string() + }; + debug!( + "Anthropic request: url={}, api_key_preview={}, model={}", + self.messages_api_url, api_key_preview, self.model + ); + if !is_official_anthropic_api_url(&self.messages_api_url) { + debug!( + "Anthropic-compatible request summary: {}", + summarize_request_for_diagnostics(request) + ); + } + + for attempt in 0..=self.client_config.max_retries { + let send_future = self + .client + .post(&self.messages_api_url) + .header("x-api-key", &self.api_key) + .header("anthropic-version", ANTHROPIC_VERSION) + .header(reqwest::header::CONTENT_TYPE, "application/json") + .json(request) + .send(); + + let response = select! { + _ = crate::cancelled(cancel.clone()) => { + return Err(AstrError::LlmInterrupted); + } + result = send_future => result.map_err(|e| AstrError::http("failed to call anthropic endpoint", e)) + }; + + match response { + Ok(response) => { + let status = response.status(); + if status == reqwest::StatusCode::UNAUTHORIZED { + // 读取响应体以便调试 + let body = response.text().await.unwrap_or_default(); + warn!( + "Anthropic 401 Unauthorized: url={}, api_key_preview={}, response={}", + self.messages_api_url, + if self.api_key.len() > 8 { + format!( + "{}...{}", + &self.api_key[..4], + &self.api_key[self.api_key.len() - 4..] + ) + } else { + "****".to_string() + }, + body + ); + return Err(AstrError::InvalidApiKey("Anthropic".to_string())); + } + if status.is_success() { + return Ok(response); + } + + let body = response.text().await.unwrap_or_default(); + if is_retryable_status(status) && attempt < self.client_config.max_retries { + wait_retry_delay( + attempt, + cancel.clone(), + self.client_config.retry_base_delay, + ) + .await?; + continue; + } + + if status.is_client_error() + && !is_official_anthropic_api_url(&self.messages_api_url) + { + warn!( + "Anthropic-compatible request rejected: url={}, status={}, \ + request_summary={}, response={}", + self.messages_api_url, + status.as_u16(), + summarize_request_for_diagnostics(request), + body + ); + } + + // 使用结构化错误分类 (P4.3) + return Err(classify_http_error(status.as_u16(), &body).into()); + }, + Err(error) => { + if error.is_retryable() && attempt < self.client_config.max_retries { + wait_retry_delay( + attempt, + cancel.clone(), + self.client_config.retry_base_delay, + ) + .await?; + continue; + } + return Err(error); + }, + } + } + + // 所有路径都会通过 return 退出循环;若到达此处说明逻辑有误, + // 返回 Internal 而非 panic 以保证运行时安全 + Err(AstrError::Internal( + "retry loop should have returned on all paths".into(), + )) + } +} + +#[async_trait] +impl LlmProvider for AnthropicProvider { + fn supports_cache_metrics(&self) -> bool { + true + } + + async fn generate(&self, request: LlmRequest, sink: Option) -> Result { + let cancel = request.cancel; + + // 检测缓存失效并记录原因 + let system_prompt_text = request + .prompt_cache_hints + .as_ref() + .map(cacheable_prefix_cache_key) + .unwrap_or_else(|| request.system_prompt.clone().unwrap_or_default()); + let tool_names: Vec = request.tools.iter().map(|t| t.name.clone()).collect(); + + if let Ok(mut tracker) = self.cache_tracker.lock() { + let break_reasons = tracker.check_and_update( + &system_prompt_text, + &tool_names, + &self.model, + "anthropic", + ); + + if !break_reasons.is_empty() { + debug!( + "[CACHE] Cache break detected: {:?}, unchanged_layers={:?}", + break_reasons, + request + .prompt_cache_hints + .as_ref() + .map(|hints| hints.unchanged_layers.as_slice()) + .unwrap_or(&[]) + ); + } + } + + let body = self.build_request( + &request.messages, + &request.tools, + request.system_prompt.as_deref(), + &request.system_prompt_blocks, + request.max_output_tokens_override, + sink.is_some(), + ); + let response = self.send_request(&body, cancel.clone()).await?; + + match sink { + None => { + let payload: AnthropicResponse = response + .json() + .await + .map_err(|e| AstrError::http("failed to parse anthropic response", e))?; + Ok(response_to_output(payload)) + }, + Some(sink) => { + let mut stream = response.bytes_stream(); + let mut sse_buffer = String::new(); + let mut utf8_decoder = Utf8StreamDecoder::default(); + let mut accumulator = LlmAccumulator::default(); + // 流式路径下从 message_delta 的 stop_reason 提取 (P4.2) + let mut stream_stop_reason: Option = None; + let mut stream_usage = AnthropicUsage::default(); + + loop { + let next_item = select! { + _ = crate::cancelled(cancel.clone()) => { + return Err(AstrError::LlmInterrupted); + } + item = stream.next() => item, + }; + + let Some(item) = next_item else { + break; + }; + + let bytes = item.map_err(|e| { + AstrError::http("failed to read anthropic response stream", e) + })?; + let Some(chunk_text) = utf8_decoder + .push(&bytes, "anthropic response stream was not valid utf-8")? + else { + continue; + }; + + if consume_sse_text_chunk( + &chunk_text, + &mut sse_buffer, + &mut accumulator, + &sink, + &mut stream_stop_reason, + &mut stream_usage, + )? { + let mut output = accumulator.finish(); + // 优先使用 API 返回的 stop_reason,否则使用推断值 + if let Some(reason) = stream_stop_reason.as_deref() { + output.finish_reason = FinishReason::from_api_value(reason); + } + output.usage = stream_usage.into_llm_usage(); + + // 记录流式响应的缓存状态 + if let Some(ref u) = output.usage { + let input = u.input_tokens; + let cache_read = u.cache_read_input_tokens; + let cache_creation = u.cache_creation_input_tokens; + let total_prompt_tokens = input.saturating_add(cache_read); + + if cache_read == 0 && cache_creation > 0 { + debug!( + "Cache miss (streaming): writing {} tokens to cache (total \ + prompt: {}, uncached input: {})", + cache_creation, total_prompt_tokens, input + ); + } else if cache_read > 0 { + let hit_rate = + (cache_read as f32 / total_prompt_tokens as f32) * 100.0; + debug!( + "Cache hit (streaming): {:.1}% ({} / {} prompt tokens, \ + creation: {}, uncached input: {})", + hit_rate, + cache_read, + total_prompt_tokens, + cache_creation, + input + ); + } else { + debug!( + "Cache disabled or unavailable (streaming, total prompt: {} \ + tokens)", + total_prompt_tokens + ); + } + } + + return Ok(output); + } + } + + if let Some(tail_text) = + utf8_decoder.finish("anthropic response stream was not valid utf-8")? + { + let done = consume_sse_text_chunk( + &tail_text, + &mut sse_buffer, + &mut accumulator, + &sink, + &mut stream_stop_reason, + &mut stream_usage, + )?; + if done { + let mut output = accumulator.finish(); + if let Some(reason) = stream_stop_reason.as_deref() { + output.finish_reason = FinishReason::from_api_value(reason); + } + output.usage = stream_usage.into_llm_usage(); + return Ok(output); + } + } + + flush_sse_buffer( + &mut sse_buffer, + &mut accumulator, + &sink, + &mut stream_stop_reason, + &mut stream_usage, + )?; + let mut output = accumulator.finish(); + if let Some(reason) = stream_stop_reason.as_deref() { + output.finish_reason = FinishReason::from_api_value(reason); + } + output.usage = stream_usage.into_llm_usage(); + Ok(output) + }, + } + } + + fn model_limits(&self) -> ModelLimits { + self.limits + } +} + +fn cacheable_prefix_cache_key(hints: &astrcode_core::PromptCacheHints) -> String { + [ + hints.layer_fingerprints.stable.as_deref(), + hints.layer_fingerprints.semi_stable.as_deref(), + hints.layer_fingerprints.inherited.as_deref(), + ] + .into_iter() + .flatten() + .collect::>() + .join("|") +} + +#[cfg(test)] +mod tests { + use super::AnthropicProvider; + use crate::{LlmClientConfig, LlmProvider, ModelLimits}; + + #[test] + fn provider_keeps_custom_messages_api_url() { + let provider = AnthropicProvider::new( + "https://gateway.example.com/anthropic/v1/messages".to_string(), + "sk-ant-test".to_string(), + "claude-sonnet-4-5".to_string(), + ModelLimits { + context_window: 200_000, + max_output_tokens: 8096, + }, + LlmClientConfig::default(), + ) + .expect("provider should build"); + + assert_eq!( + provider.messages_api_url, + "https://gateway.example.com/anthropic/v1/messages" + ); + } + + #[test] + fn anthropic_provider_reports_cache_metrics_support() { + let provider = AnthropicProvider::new( + "https://api.anthropic.com/v1/messages".to_string(), + "sk-ant-test".to_string(), + "claude-sonnet-4-5".to_string(), + ModelLimits { + context_window: 200_000, + max_output_tokens: 8096, + }, + LlmClientConfig::default(), + ) + .expect("provider should build"); + + assert!(provider.supports_cache_metrics()); + } +} diff --git a/crates/adapter-llm/src/anthropic/request.rs b/crates/adapter-llm/src/anthropic/request.rs new file mode 100644 index 00000000..42551eee --- /dev/null +++ b/crates/adapter-llm/src/anthropic/request.rs @@ -0,0 +1,843 @@ +use astrcode_core::{LlmMessage, SystemPromptBlock, SystemPromptLayer, ToolDefinition}; +use serde_json::{Value, json}; + +use super::dto::{ + AnthropicCacheControl, AnthropicContentBlock, AnthropicMessage, AnthropicRequest, + AnthropicSystemBlock, AnthropicSystemPrompt, AnthropicThinking, AnthropicTool, cacheable_text, +}; + +pub(super) const ANTHROPIC_CACHE_BREAKPOINT_LIMIT: usize = 4; + +/// 将 `LlmMessage` 转换为 Anthropic 格式的消息结构。 +/// +/// Anthropic 使用内容块数组(而非纯文本),因此需要按消息类型分派: +/// - User 消息 → 单个 `text` 内容块 +/// - Assistant 消息 → 可能包含 `thinking`、`text`、`tool_use` 多个块 +/// - Tool 消息 → 单个 `tool_result` 内容块 +#[derive(Clone, Copy)] +pub(super) struct MessageBuildOptions { + pub(super) include_reasoning_blocks: bool, +} + +pub(super) fn summarize_request_for_diagnostics(request: &AnthropicRequest) -> Value { + let messages = request + .messages + .iter() + .map(|message| { + let block_types = message + .content + .iter() + .map(AnthropicContentBlock::block_type) + .collect::>(); + json!({ + "role": message.role, + "blockTypes": block_types, + "blockCount": message.content.len(), + "cacheControlCount": message + .content + .iter() + .filter(|block| block.has_cache_control()) + .count(), + }) + }) + .collect::>(); + let system = match &request.system { + None => Value::Null, + Some(AnthropicSystemPrompt::Text(text)) => json!({ + "kind": "text", + "chars": text.chars().count(), + }), + Some(AnthropicSystemPrompt::Blocks(blocks)) => json!({ + "kind": "blocks", + "count": blocks.len(), + "cacheControlCount": blocks + .iter() + .filter(|block| block.cache_control.is_some()) + .count(), + "chars": blocks.iter().map(|block| block.text.chars().count()).sum::(), + }), + }; + let tools = request.tools.as_ref().map(|tools| { + json!({ + "count": tools.len(), + "names": tools.iter().map(|tool| tool.name.clone()).collect::>(), + "cacheControlCount": tools + .iter() + .filter(|tool| tool.cache_control.is_some()) + .count(), + }) + }); + + json!({ + "model": request.model, + "maxTokens": request.max_tokens, + "topLevelCacheControl": request.cache_control.is_some(), + "hasThinking": request.thinking.is_some(), + "stream": request.stream.unwrap_or(false), + "system": system, + "messages": messages, + "tools": tools, + }) +} + +pub(super) fn to_anthropic_messages( + messages: &[LlmMessage], + options: MessageBuildOptions, +) -> Vec { + let mut anthropic_messages = Vec::with_capacity(messages.len()); + let mut pending_user_blocks = Vec::new(); + + let flush_pending_user_blocks = + |anthropic_messages: &mut Vec, + pending_user_blocks: &mut Vec| { + if pending_user_blocks.is_empty() { + return; + } + + anthropic_messages.push(AnthropicMessage { + role: "user".to_string(), + content: std::mem::take(pending_user_blocks), + }); + }; + + for message in messages { + match message { + LlmMessage::User { content, .. } => { + pending_user_blocks.push(AnthropicContentBlock::Text { + text: content.clone(), + cache_control: None, + }); + }, + LlmMessage::Assistant { + content, + tool_calls, + reasoning, + } => { + flush_pending_user_blocks(&mut anthropic_messages, &mut pending_user_blocks); + + let mut blocks = Vec::new(); + if options.include_reasoning_blocks { + if let Some(reasoning) = reasoning { + blocks.push(AnthropicContentBlock::Thinking { + thinking: reasoning.content.clone(), + signature: reasoning.signature.clone(), + cache_control: None, + }); + } + } + // Anthropic assistant 消息可以直接包含 tool_use 块,不要求前置 text 块。 + // 仅在确实有文本时写入 text 块,避免向兼容网关发送空 text 导致参数校验失败。 + if !content.is_empty() { + blocks.push(AnthropicContentBlock::Text { + text: content.clone(), + cache_control: None, + }); + } + blocks.extend( + tool_calls + .iter() + .map(|call| AnthropicContentBlock::ToolUse { + id: call.id.clone(), + name: call.name.clone(), + input: call.args.clone(), + cache_control: None, + }), + ); + if blocks.is_empty() { + blocks.push(AnthropicContentBlock::Text { + text: String::new(), + cache_control: None, + }); + } + + anthropic_messages.push(AnthropicMessage { + role: "assistant".to_string(), + content: blocks, + }); + }, + LlmMessage::Tool { + tool_call_id, + content, + } => { + pending_user_blocks.push(AnthropicContentBlock::ToolResult { + tool_use_id: tool_call_id.clone(), + content: content.clone(), + cache_control: None, + }); + }, + } + } + + flush_pending_user_blocks(&mut anthropic_messages, &mut pending_user_blocks); + anthropic_messages +} + +/// 在最近的消息内容块上启用显式 prompt caching。 +/// +/// 只有在自定义 Anthropic 网关上才需要这条兜底路径。官方 Anthropic endpoint 使用顶层 +/// 自动缓存来追踪不断增长的对话尾部,避免显式断点超过 4 个 slot。 +pub(super) fn enable_message_caching( + messages: &mut [AnthropicMessage], + max_breakpoints: usize, +) -> usize { + if messages.is_empty() || max_breakpoints == 0 { + return 0; + } + + let mut used = 0; + for msg in messages.iter_mut().rev() { + if used >= max_breakpoints { + break; + } + + let Some(block) = msg + .content + .iter_mut() + .rev() + .find(|block| block.can_use_explicit_cache_control()) + else { + continue; + }; + + if block.set_cache_control_if_allowed(true) { + used += 1; + } + } + + used +} + +fn consume_cache_breakpoint(remaining: &mut usize) -> bool { + if *remaining == 0 { + return false; + } + + *remaining -= 1; + true +} + +pub(super) fn is_official_anthropic_api_url(url: &str) -> bool { + reqwest::Url::parse(url) + .ok() + .and_then(|url| { + url.host_str() + .map(|host| host.eq_ignore_ascii_case("api.anthropic.com")) + }) + .unwrap_or(false) +} + +fn cache_control_if_allowed(remaining: &mut usize) -> Option { + consume_cache_breakpoint(remaining).then(AnthropicCacheControl::ephemeral) +} + +// Dynamic 层不参与缓存,动态内容每轮都变 +fn cacheable_system_layer(layer: SystemPromptLayer) -> bool { + !matches!(layer, SystemPromptLayer::Dynamic) +} + +/// 将 `ToolDefinition` 转换为 Anthropic 工具定义格式。 +pub(super) fn to_anthropic_tools( + tools: &[ToolDefinition], + remaining_cache_breakpoints: &mut usize, +) -> Vec { + if tools.is_empty() { + return Vec::new(); + } + + let last_cacheable_index = tools + .iter() + .rposition(|tool| cacheable_text(&tool.name) || cacheable_text(&tool.description)); + + tools + .iter() + .enumerate() + .map(|(index, tool)| { + let cache_control = if Some(index) == last_cacheable_index { + cache_control_if_allowed(remaining_cache_breakpoints) + } else { + None + }; + + AnthropicTool { + name: tool.name.clone(), + description: tool.description.clone(), + input_schema: tool.parameters.clone(), + cache_control, + } + }) + .collect() +} + +pub(super) fn to_anthropic_system( + system_prompt: Option<&str>, + system_prompt_blocks: &[SystemPromptBlock], + remaining_cache_breakpoints: &mut usize, +) -> Option { + if !system_prompt_blocks.is_empty() { + return Some(AnthropicSystemPrompt::Blocks( + system_prompt_blocks + .iter() + .map(|block| { + let text = block.render(); + let cache_control = if block.cache_boundary + && cacheable_system_layer(block.layer) + && cacheable_text(&text) + { + cache_control_if_allowed(remaining_cache_breakpoints) + } else { + None + }; + + AnthropicSystemBlock { + type_: "text".to_string(), + text, + cache_control, + } + }) + .collect(), + )); + } + + system_prompt.map(|value| AnthropicSystemPrompt::Text(value.to_string())) +} + +/// 为模型生成 extended thinking 配置。 +/// +/// 当 max_tokens >= 2 时启用 thinking 模式,预算 token 数为 max_tokens 的 75%(向下取整)。 +/// +/// ## 设计动机 +/// +/// Extended thinking 让模型在输出前进行深度推理,提升复杂任务的回答质量。 +/// 预算设为 75% 是为了保留至少 25% 的 token 给实际输出内容。 +/// 如果预算为 0 或等于 max_tokens,则不启用(避免无意义配置)。 +/// +/// 默认为所有模型启用此功能。如果模型不支持,API 会忽略此参数。 +pub(super) fn thinking_config_for_model( + _model: &str, + max_tokens: u32, +) -> Option { + if max_tokens < 2 { + return None; + } + + let budget_tokens = max_tokens.saturating_mul(3) / 4; + if budget_tokens == 0 || budget_tokens >= max_tokens { + return None; + } + + Some(AnthropicThinking { + type_: "enabled".to_string(), + budget_tokens, + }) +} + +#[cfg(test)] +mod tests { + use astrcode_core::{ + LlmMessage, ReasoningContent, SystemPromptBlock, SystemPromptLayer, ToolCallRequest, + ToolDefinition, UserMessageOrigin, + }; + use serde_json::{Value, json}; + + use super::{ANTHROPIC_CACHE_BREAKPOINT_LIMIT, MessageBuildOptions, to_anthropic_messages}; + use crate::{ + LlmClientConfig, ModelLimits, + anthropic::{dto::AnthropicContentBlock, provider::AnthropicProvider}, + }; + + #[test] + fn to_anthropic_messages_does_not_inject_empty_text_block_for_tool_use() { + let messages = vec![LlmMessage::Assistant { + content: "".to_string(), + tool_calls: vec![ToolCallRequest { + id: "call_123".to_string(), + name: "test_tool".to_string(), + args: json!({"arg": "value"}), + }], + reasoning: None, + }]; + + let anthropic_messages = to_anthropic_messages( + &messages, + MessageBuildOptions { + include_reasoning_blocks: true, + }, + ); + assert_eq!(anthropic_messages.len(), 1); + + let msg = &anthropic_messages[0]; + assert_eq!(msg.role, "assistant"); + assert_eq!(msg.content.len(), 1); + + match &msg.content[0] { + AnthropicContentBlock::ToolUse { id, name, .. } => { + assert_eq!(id, "call_123"); + assert_eq!(name, "test_tool"); + }, + _ => panic!("Expected ToolUse block"), + } + } + + #[test] + fn to_anthropic_messages_groups_consecutive_tool_results_into_one_user_message() { + let messages = vec![ + LlmMessage::Assistant { + content: String::new(), + tool_calls: vec![ + ToolCallRequest { + id: "call_1".to_string(), + name: "read_file".to_string(), + args: json!({"path": "a.rs"}), + }, + ToolCallRequest { + id: "call_2".to_string(), + name: "grep".to_string(), + args: json!({"pattern": "spawn"}), + }, + ], + reasoning: None, + }, + LlmMessage::Tool { + tool_call_id: "call_1".to_string(), + content: "file content".to_string(), + }, + LlmMessage::Tool { + tool_call_id: "call_2".to_string(), + content: "grep result".to_string(), + }, + ]; + + let anthropic_messages = to_anthropic_messages( + &messages, + MessageBuildOptions { + include_reasoning_blocks: true, + }, + ); + + assert_eq!(anthropic_messages.len(), 2); + assert_eq!(anthropic_messages[0].role, "assistant"); + assert_eq!(anthropic_messages[1].role, "user"); + assert_eq!(anthropic_messages[1].content.len(), 2); + assert!(matches!( + &anthropic_messages[1].content[0], + AnthropicContentBlock::ToolResult { tool_use_id, content, .. } + if tool_use_id == "call_1" && content == "file content" + )); + assert!(matches!( + &anthropic_messages[1].content[1], + AnthropicContentBlock::ToolResult { tool_use_id, content, .. } + if tool_use_id == "call_2" && content == "grep result" + )); + } + + #[test] + fn to_anthropic_messages_keeps_user_text_after_tool_results_in_same_message() { + let messages = vec![ + LlmMessage::Assistant { + content: String::new(), + tool_calls: vec![ToolCallRequest { + id: "call_1".to_string(), + name: "read_file".to_string(), + args: json!({"path": "a.rs"}), + }], + reasoning: None, + }, + LlmMessage::Tool { + tool_call_id: "call_1".to_string(), + content: "file content".to_string(), + }, + LlmMessage::User { + content: "请继续总结发现。".to_string(), + origin: UserMessageOrigin::User, + }, + ]; + + let anthropic_messages = to_anthropic_messages( + &messages, + MessageBuildOptions { + include_reasoning_blocks: true, + }, + ); + + assert_eq!(anthropic_messages.len(), 2); + assert_eq!(anthropic_messages[1].role, "user"); + assert_eq!(anthropic_messages[1].content.len(), 2); + assert!(matches!( + &anthropic_messages[1].content[0], + AnthropicContentBlock::ToolResult { tool_use_id, content, .. } + if tool_use_id == "call_1" && content == "file content" + )); + assert!(matches!( + &anthropic_messages[1].content[1], + AnthropicContentBlock::Text { text, .. } if text == "请继续总结发现。" + )); + } + + #[test] + fn build_request_serializes_system_and_thinking_when_applicable() { + let provider = AnthropicProvider::new( + "https://api.anthropic.com/v1/messages".to_string(), + "sk-ant-test".to_string(), + "claude-sonnet-4-5".to_string(), + ModelLimits { + context_window: 200_000, + max_output_tokens: 8096, + }, + LlmClientConfig::default(), + ) + .expect("provider should build"); + let request = provider.build_request( + &[LlmMessage::User { + content: "hi".to_string(), + origin: UserMessageOrigin::User, + }], + &[], + Some("Follow the rules"), + &[], + None, + true, + ); + let body = serde_json::to_value(&request).expect("request should serialize"); + + assert_eq!(body["cache_control"]["type"], json!("ephemeral")); + assert_eq!( + body.get("system").and_then(Value::as_str), + Some("Follow the rules") + ); + assert_eq!( + body.get("thinking") + .and_then(|value| value.get("type")) + .and_then(Value::as_str), + Some("enabled") + ); + } + + fn count_cache_control_fields(value: &Value) -> usize { + match value { + Value::Object(map) => { + usize::from(map.contains_key("cache_control")) + + map.values().map(count_cache_control_fields).sum::() + }, + Value::Array(values) => values.iter().map(count_cache_control_fields).sum(), + _ => 0, + } + } + + #[test] + fn official_anthropic_uses_automatic_cache_and_caps_explicit_breakpoints() { + let provider = AnthropicProvider::new( + "https://api.anthropic.com/v1/messages".to_string(), + "sk-ant-test".to_string(), + "claude-sonnet-4-5".to_string(), + ModelLimits { + context_window: 200_000, + max_output_tokens: 8096, + }, + LlmClientConfig::default(), + ) + .expect("provider should build"); + let system_blocks = (0..5) + .map(|index| SystemPromptBlock { + title: format!("Stable {index}"), + content: format!("stable content {index}"), + cache_boundary: true, + layer: SystemPromptLayer::Stable, + }) + .collect::>(); + let tools = vec![ToolDefinition { + name: "search".to_string(), + description: "Search indexed data.".to_string(), + parameters: json!({ "type": "object" }), + }]; + let request = provider.build_request( + &[LlmMessage::User { + content: "hi".to_string(), + origin: UserMessageOrigin::User, + }], + &tools, + None, + &system_blocks, + None, + false, + ); + let body = serde_json::to_value(&request).expect("request should serialize"); + + assert_eq!(body["cache_control"]["type"], json!("ephemeral")); + assert!( + count_cache_control_fields(&body) <= ANTHROPIC_CACHE_BREAKPOINT_LIMIT, + "official request should keep automatic + explicit cache controls within the provider \ + limit" + ); + assert!( + body["messages"][0]["content"][0] + .get("cache_control") + .is_none(), + "official endpoint uses top-level automatic cache for the message tail" + ); + } + + #[test] + fn custom_anthropic_gateway_uses_explicit_message_tail_without_top_level_cache() { + let provider = AnthropicProvider::new( + "https://gateway.example.com/anthropic/v1/messages".to_string(), + "sk-ant-test".to_string(), + "claude-sonnet-4-5".to_string(), + ModelLimits { + context_window: 200_000, + max_output_tokens: 8096, + }, + LlmClientConfig::default(), + ) + .expect("provider should build"); + let request = provider.build_request( + &[ + LlmMessage::User { + content: "first".to_string(), + origin: UserMessageOrigin::User, + }, + LlmMessage::User { + content: "second".to_string(), + origin: UserMessageOrigin::User, + }, + ], + &[], + None, + &[], + None, + false, + ); + let body = serde_json::to_value(&request).expect("request should serialize"); + + assert!(body.get("cache_control").is_none()); + assert_eq!(body["messages"].as_array().map(Vec::len), Some(1)); + assert_eq!( + body["messages"][0]["content"][1]["cache_control"]["type"], + json!("ephemeral") + ); + assert!( + count_cache_control_fields(&body) <= ANTHROPIC_CACHE_BREAKPOINT_LIMIT, + "custom gateways only receive explicit cache controls within the provider limit" + ); + } + + #[test] + fn custom_gateway_request_disables_extended_thinking_payloads() { + let provider = AnthropicProvider::new( + "https://gateway.example.com/anthropic/v1/messages".to_string(), + "sk-ant-test".to_string(), + "claude-sonnet-4-5".to_string(), + ModelLimits { + context_window: 200_000, + max_output_tokens: 8096, + }, + LlmClientConfig::default(), + ) + .expect("provider should build"); + let request = provider.build_request( + &[LlmMessage::Assistant { + content: "".to_string(), + tool_calls: vec![], + reasoning: Some(ReasoningContent { + content: "thinking".to_string(), + signature: Some("sig".to_string()), + }), + }], + &[], + None, + &[], + None, + false, + ); + let body = serde_json::to_value(&request).expect("request should serialize"); + + assert!(body.get("thinking").is_none()); + assert_eq!(body["messages"][0]["content"][0]["type"], json!("text")); + assert_eq!(body["messages"][0]["content"][0]["text"], json!("")); + } + + #[test] + fn build_request_serializes_system_blocks_with_cache_boundaries() { + let provider = AnthropicProvider::new( + "https://api.anthropic.com/v1/messages".to_string(), + "sk-ant-test".to_string(), + "claude-sonnet-4-5".to_string(), + ModelLimits { + context_window: 200_000, + max_output_tokens: 8096, + }, + LlmClientConfig::default(), + ) + .expect("provider should build"); + let request = provider.build_request( + &[LlmMessage::User { + content: "hi".to_string(), + origin: UserMessageOrigin::User, + }], + &[], + Some("ignored fallback"), + &[SystemPromptBlock { + title: "Stable".to_string(), + content: "stable".to_string(), + cache_boundary: true, + layer: SystemPromptLayer::Stable, + }], + None, + false, + ); + let body = serde_json::to_value(&request).expect("request should serialize"); + + assert!(body.get("system").is_some_and(Value::is_array)); + assert_eq!( + body["system"][0]["cache_control"]["type"], + json!("ephemeral") + ); + } + + #[test] + fn build_request_only_marks_cache_boundaries_at_layer_transitions() { + let provider = AnthropicProvider::new( + "https://api.anthropic.com/v1/messages".to_string(), + "sk-ant-test".to_string(), + "claude-sonnet-4-5".to_string(), + ModelLimits { + context_window: 200_000, + max_output_tokens: 8096, + }, + LlmClientConfig::default(), + ) + .expect("provider should build"); + let request = provider.build_request( + &[LlmMessage::User { + content: "hi".to_string(), + origin: UserMessageOrigin::User, + }], + &[], + Some("ignored fallback"), + &[ + SystemPromptBlock { + title: "Stable 1".to_string(), + content: "stable content 1".to_string(), + cache_boundary: false, + layer: SystemPromptLayer::Stable, + }, + SystemPromptBlock { + title: "Stable 2".to_string(), + content: "stable content 2".to_string(), + cache_boundary: false, + layer: SystemPromptLayer::Stable, + }, + SystemPromptBlock { + title: "Stable 3".to_string(), + content: "stable content 3".to_string(), + cache_boundary: true, + layer: SystemPromptLayer::Stable, + }, + SystemPromptBlock { + title: "Semi 1".to_string(), + content: "semi content 1".to_string(), + cache_boundary: false, + layer: SystemPromptLayer::SemiStable, + }, + SystemPromptBlock { + title: "Semi 2".to_string(), + content: "semi content 2".to_string(), + cache_boundary: true, + layer: SystemPromptLayer::SemiStable, + }, + SystemPromptBlock { + title: "Inherited 1".to_string(), + content: "inherited content 1".to_string(), + cache_boundary: true, + layer: SystemPromptLayer::Inherited, + }, + SystemPromptBlock { + title: "Dynamic 1".to_string(), + content: "dynamic content 1".to_string(), + cache_boundary: true, + layer: SystemPromptLayer::Dynamic, + }, + ], + None, + false, + ); + let body = serde_json::to_value(&request).expect("request should serialize"); + + assert!(body.get("system").is_some_and(Value::is_array)); + assert_eq!( + body["system"] + .as_array() + .expect("system should be an array") + .len(), + 7 + ); + + // Stable 层内的前两个 block 不应该有 cache_control + assert!( + body["system"][0].get("cache_control").is_none(), + "stable1 should not have cache_control" + ); + assert!( + body["system"][1].get("cache_control").is_none(), + "stable2 should not have cache_control" + ); + + // Stable 层的最后一个 block 应该有 cache_control + assert_eq!( + body["system"][2]["cache_control"]["type"], + json!("ephemeral"), + "stable3 should have cache_control" + ); + + // SemiStable 层的第一个 block 不应该有 cache_control + assert!( + body["system"][3].get("cache_control").is_none(), + "semi1 should not have cache_control" + ); + + // SemiStable 层的最后一个 block 应该有 cache_control + assert_eq!( + body["system"][4]["cache_control"]["type"], + json!("ephemeral"), + "semi2 should have cache_control" + ); + + // Inherited 层允许独立缓存 + assert_eq!( + body["system"][5]["cache_control"]["type"], + json!("ephemeral"), + "inherited1 should have cache_control" + ); + + // Dynamic 层不缓存(避免浪费,因为内容变化频繁) + // TODO: 更好的做法?实现更好的kv缓存? + assert!( + body["system"][6].get("cache_control").is_none(), + "dynamic1 should not have cache_control (Dynamic layer is not cached)" + ); + } + + #[test] + fn build_request_honors_request_level_max_output_tokens_override() { + let provider = AnthropicProvider::new( + "https://api.anthropic.com/v1/messages".to_string(), + "sk-ant-test".to_string(), + "claude-sonnet-4-5".to_string(), + ModelLimits { + context_window: 200_000, + max_output_tokens: 8096, + }, + LlmClientConfig::default(), + ) + .expect("provider should build"); + let messages = [LlmMessage::User { + content: "hi".to_string(), + origin: UserMessageOrigin::User, + }]; + + let capped = provider.build_request(&messages, &[], None, &[], Some(2048), false); + let clamped = provider.build_request(&messages, &[], None, &[], Some(16_000), false); + + assert_eq!(capped.max_tokens, 2048); + assert_eq!(clamped.max_tokens, 8096); + } +} diff --git a/crates/adapter-llm/src/anthropic/response.rs b/crates/adapter-llm/src/anthropic/response.rs new file mode 100644 index 00000000..2fcae911 --- /dev/null +++ b/crates/adapter-llm/src/anthropic/response.rs @@ -0,0 +1,226 @@ +use astrcode_core::{ReasoningContent, ToolCallRequest}; +use log::{debug, warn}; +use serde_json::Value; + +use super::dto::{AnthropicResponse, AnthropicUsage}; +use crate::{FinishReason, LlmOutput}; + +/// 将 Anthropic 非流式响应转换为统一的 `LlmOutput`。 +/// +/// 遍历内容块数组,根据块类型分派: +/// - `text`: 拼接到输出内容 +/// - `tool_use`: 提取 id、name、input 构造工具调用请求 +/// - `thinking`: 提取推理内容和签名 +/// - 未知类型:记录警告并跳过 +/// +/// TODO:更好的办法? +/// `stop_reason` 映射到统一的 `FinishReason` (P4.2): +/// - `end_turn` → Stop +/// - `max_tokens` → MaxTokens +/// - `tool_use` → ToolCalls +/// - `stop_sequence` → Stop +pub(super) fn response_to_output(response: AnthropicResponse) -> LlmOutput { + let usage = response.usage.and_then(AnthropicUsage::into_llm_usage); + + // 记录缓存状态 + if let Some(ref u) = usage { + let input = u.input_tokens; + let cache_read = u.cache_read_input_tokens; + let cache_creation = u.cache_creation_input_tokens; + let total_prompt_tokens = input.saturating_add(cache_read); + + if cache_read == 0 && cache_creation > 0 { + debug!( + "Cache miss: writing {} tokens to cache (total prompt: {}, uncached input: {})", + cache_creation, total_prompt_tokens, input + ); + } else if cache_read > 0 { + let hit_rate = (cache_read as f32 / total_prompt_tokens as f32) * 100.0; + debug!( + "Cache hit: {:.1}% ({} / {} prompt tokens, creation: {}, uncached input: {})", + hit_rate, cache_read, total_prompt_tokens, cache_creation, input + ); + } else { + debug!( + "Cache disabled or unavailable (total prompt: {} tokens)", + total_prompt_tokens + ); + } + } + + let mut content = String::new(); + let mut tool_calls = Vec::new(); + let mut reasoning = None; + + for block in response.content { + match block_type(&block) { + Some("text") => { + if let Some(text) = block.get("text").and_then(Value::as_str) { + content.push_str(text); + } + }, + Some("tool_use") => { + let id = match block.get("id").and_then(Value::as_str) { + Some(id) if !id.is_empty() => id.to_string(), + _ => { + warn!("anthropic: tool_use block missing non-empty id, skipping"); + continue; + }, + }; + let name = match block.get("name").and_then(Value::as_str) { + Some(name) if !name.is_empty() => name.to_string(), + _ => { + warn!("anthropic: tool_use block missing non-empty name, skipping"); + continue; + }, + }; + let args = block.get("input").cloned().unwrap_or(Value::Null); + tool_calls.push(ToolCallRequest { id, name, args }); + }, + Some("thinking") => { + if let Some(thinking) = block.get("thinking").and_then(Value::as_str) { + reasoning = Some(ReasoningContent { + content: thinking.to_string(), + signature: block + .get("signature") + .and_then(Value::as_str) + .map(str::to_string), + }); + } + }, + Some(other) => { + warn!("anthropic: unknown content block type: {}", other); + }, + None => { + warn!("anthropic: content block missing type"); + }, + } + } + + // Anthropic stop_reason 映射到统一 FinishReason + let finish_reason = response + .stop_reason + .as_deref() + .map(|reason| match reason { + "end_turn" | "stop_sequence" => FinishReason::Stop, + "max_tokens" => FinishReason::MaxTokens, + "tool_use" => FinishReason::ToolCalls, + other => FinishReason::Other(other.to_string()), + }) + .unwrap_or_else(|| { + if !tool_calls.is_empty() { + FinishReason::ToolCalls + } else { + FinishReason::Stop + } + }); + + LlmOutput { + content, + tool_calls, + reasoning, + usage, + finish_reason, + } +} + +/// 从 JSON Value 中提取内容块的类型字段。 +fn block_type(value: &Value) -> Option<&str> { + value.get("type").and_then(Value::as_str) +} + +#[cfg(test)] +mod tests { + use astrcode_core::ReasoningContent; + use serde_json::json; + + use super::response_to_output; + use crate::{ + LlmUsage, + anthropic::dto::{AnthropicCacheCreationUsage, AnthropicResponse, AnthropicUsage}, + }; + + #[test] + fn response_to_output_parses_text_tool_use_and_thinking() { + let output = response_to_output(AnthropicResponse { + content: vec![ + json!({ "type": "text", "text": "hello " }), + json!({ + "type": "tool_use", + "id": "call_1", + "name": "search", + "input": { "q": "rust" } + }), + json!({ "type": "text", "text": "world" }), + json!({ "type": "thinking", "thinking": "pondering", "signature": "sig-1" }), + ], + stop_reason: Some("tool_use".to_string()), + usage: None, + }); + + assert_eq!(output.content, "hello world"); + assert_eq!(output.tool_calls.len(), 1); + assert_eq!(output.tool_calls[0].id, "call_1"); + assert_eq!(output.tool_calls[0].args, json!({ "q": "rust" })); + assert_eq!( + output.reasoning, + Some(ReasoningContent { + content: "pondering".to_string(), + signature: Some("sig-1".to_string()), + }) + ); + } + + #[test] + fn response_to_output_parses_cache_usage_fields() { + let output = response_to_output(AnthropicResponse { + content: vec![json!({ "type": "text", "text": "ok" })], + stop_reason: Some("end_turn".to_string()), + usage: Some(AnthropicUsage { + input_tokens: Some(100), + output_tokens: Some(20), + cache_creation_input_tokens: Some(80), + cache_read_input_tokens: Some(60), + cache_creation: None, + }), + }); + + assert_eq!( + output.usage, + Some(LlmUsage { + input_tokens: 100, + output_tokens: 20, + cache_creation_input_tokens: 80, + cache_read_input_tokens: 60, + }) + ); + } + + #[test] + fn response_to_output_parses_nested_cache_creation_usage_fields() { + let output = response_to_output(AnthropicResponse { + content: vec![json!({ "type": "text", "text": "ok" })], + stop_reason: Some("end_turn".to_string()), + usage: Some(AnthropicUsage { + input_tokens: Some(100), + output_tokens: Some(20), + cache_creation_input_tokens: None, + cache_read_input_tokens: Some(60), + cache_creation: Some(AnthropicCacheCreationUsage { + ephemeral_5m_input_tokens: Some(30), + ephemeral_1h_input_tokens: Some(50), + }), + }), + }); + + assert_eq!( + output.usage, + Some(LlmUsage { + input_tokens: 100, + output_tokens: 20, + cache_creation_input_tokens: 80, + cache_read_input_tokens: 60, + }) + ); + } +} diff --git a/crates/adapter-llm/src/anthropic/stream.rs b/crates/adapter-llm/src/anthropic/stream.rs new file mode 100644 index 00000000..19e1150a --- /dev/null +++ b/crates/adapter-llm/src/anthropic/stream.rs @@ -0,0 +1,671 @@ +use astrcode_core::{AstrError, Result}; +use log::warn; +use serde_json::{Value, json}; + +use super::dto::{AnthropicUsage, SseProcessResult, extract_usage_from_payload}; +use crate::{EventSink, LlmAccumulator, LlmEvent, classify_http_error, emit_event}; + +/// 解析单个 Anthropic SSE 块。 +/// +/// Anthropic SSE 块由多行组成(`event: ...\ndata: {...}\n\n`), +/// 本函数提取事件类型和 JSON payload,支持事件类型回退到 payload 中的 `type` 字段。 +pub(super) fn parse_sse_block(block: &str) -> Result> { + let trimmed = block.trim(); + if trimmed.is_empty() { + return Ok(None); + } + + let mut event_type = None; + let mut data_lines = Vec::new(); + + for line in trimmed.lines() { + if let Some(value) = sse_field_value(line, "event") { + event_type = Some(value.trim().to_string()); + } else if let Some(value) = sse_field_value(line, "data") { + data_lines.push(value); + } + } + + if data_lines.is_empty() { + return Ok(None); + } + + let data = data_lines.join("\n"); + let data = data.trim(); + if data.is_empty() { + return Ok(None); + } + + // 兼容部分 Anthropic 网关沿用 OpenAI 风格的流结束哨兵。 + // 如果这里严格要求 JSON,会在流尾直接误报 parse error。 + if data == "[DONE]" { + return Ok(Some(( + "message_stop".to_string(), + json!({ "type": "message_stop" }), + ))); + } + + let payload = serde_json::from_str::(data) + .map_err(|error| AstrError::parse("failed to parse anthropic sse payload", error))?; + let event_type = event_type + .or_else(|| { + payload + .get("type") + .and_then(Value::as_str) + .map(str::to_string) + }) + .unwrap_or_default(); + + Ok(Some((event_type, payload))) +} + +fn sse_field_value<'a>(line: &'a str, field: &str) -> Option<&'a str> { + let value = line.strip_prefix(field)?.strip_prefix(':')?; + + // SSE 规范只忽略冒号后的一个可选空格;这里兼容 `data:...` 和 `data: ...`, + // 同时保留业务数据中其余前导空白,避免悄悄改写 payload。 + Some(value.strip_prefix(' ').unwrap_or(value)) +} + +/// 从 `content_block_start` 事件 payload 中提取内容块。 +/// +/// Anthropic 在 `content_block_start` 事件中将块数据放在 `content_block` 字段, +/// 但某些事件可能直接放在根级别,因此有回退逻辑。 +fn extract_start_block(payload: &Value) -> &Value { + payload.get("content_block").unwrap_or(payload) +} + +/// 从 `content_block_delta` 事件 payload 中提取增量数据。 +/// +/// Anthropic 在 `content_block_delta` 事件中将增量数据放在 `delta` 字段。 +fn extract_delta_block(payload: &Value) -> &Value { + payload.get("delta").unwrap_or(payload) +} + +pub(super) fn anthropic_stream_error(payload: &Value) -> AstrError { + let error = payload.get("error").unwrap_or(payload); + let message = error + .get("message") + .or_else(|| error.get("msg")) + .or_else(|| payload.get("message")) + .and_then(Value::as_str) + .unwrap_or("anthropic stream returned an error event"); + + let mut error_type = error + .get("type") + .or_else(|| error.get("code")) + .or_else(|| payload.get("error_type")) + .or_else(|| payload.get("code")) + .and_then(Value::as_str) + .unwrap_or("unknown_error"); + + // Why: 部分兼容网关不回传结构化 error.type,只给中文文案。 + // 这类错误本质仍是请求参数错误,不应退化成 internal stream error。 + let message_lower = message.to_lowercase(); + if matches!(error_type, "unknown_error" | "error") + && (message_lower.contains("参数非法") + || message_lower.contains("invalid request") + || message_lower.contains("invalid parameter") + || message_lower.contains("invalid arguments") + || (message_lower.contains("messages") && message_lower.contains("illegal"))) + { + error_type = "invalid_request_error"; + } + + let detail = format!("{error_type}: {message}"); + + match error_type { + "invalid_request_error" => classify_http_error(400, &detail).into(), + "authentication_error" => classify_http_error(401, &detail).into(), + "permission_error" => classify_http_error(403, &detail).into(), + "not_found_error" => classify_http_error(404, &detail).into(), + "rate_limit_error" => classify_http_error(429, &detail).into(), + "overloaded_error" => classify_http_error(529, &detail).into(), + "api_error" => classify_http_error(500, &detail).into(), + _ => classify_http_error(400, &detail).into(), + } +} + +/// 处理单个 Anthropic SSE 块,返回 `(is_done, stop_reason)`。 +/// +/// Anthropic SSE 事件类型分派: +/// - `content_block_start`: 新内容块开始(可能是文本或工具调用) +/// - `content_block_delta`: 增量内容(文本/思考/签名/工具参数) +/// - `message_stop`: 流结束信号,返回 is_done=true +/// - `message_delta`: 包含 `stop_reason`,用于检测 max_tokens 截断 (P4.2) +/// - `message_start/content_block_stop/ping`: 元数据事件,静默忽略 +fn process_sse_block( + block: &str, + accumulator: &mut LlmAccumulator, + sink: &EventSink, +) -> Result { + let Some((event_type, payload)) = parse_sse_block(block)? else { + return Ok(SseProcessResult::default()); + }; + + match event_type.as_str() { + "content_block_start" => { + let index = payload + .get("index") + .and_then(Value::as_u64) + .unwrap_or_default() as usize; + let block = extract_start_block(&payload); + + // 工具调用块开始时,发射 ToolCallDelta(id + name,参数为空) + if block_type(block) == Some("tool_use") { + emit_event( + LlmEvent::ToolCallDelta { + index, + id: block.get("id").and_then(Value::as_str).map(str::to_string), + name: block + .get("name") + .and_then(Value::as_str) + .map(str::to_string), + arguments_delta: String::new(), + }, + accumulator, + sink, + ); + } + Ok(SseProcessResult::default()) + }, + "content_block_delta" => { + let index = payload + .get("index") + .and_then(Value::as_u64) + .unwrap_or_default() as usize; + let delta = extract_delta_block(&payload); + + // 根据增量类型分派到对应的事件 + match block_type(delta) { + Some("text_delta") => { + if let Some(text) = delta.get("text").and_then(Value::as_str) { + emit_event(LlmEvent::TextDelta(text.to_string()), accumulator, sink); + } + }, + Some("thinking_delta") => { + if let Some(text) = delta.get("thinking").and_then(Value::as_str) { + emit_event(LlmEvent::ThinkingDelta(text.to_string()), accumulator, sink); + } + }, + Some("signature_delta") => { + if let Some(signature) = delta.get("signature").and_then(Value::as_str) { + emit_event( + LlmEvent::ThinkingSignature(signature.to_string()), + accumulator, + sink, + ); + } + }, + Some("input_json_delta") => { + // 工具调用参数增量,partial_json 是 JSON 的片段 + emit_event( + LlmEvent::ToolCallDelta { + index, + id: None, + name: None, + arguments_delta: delta + .get("partial_json") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + }, + accumulator, + sink, + ); + }, + _ => {}, + } + Ok(SseProcessResult::default()) + }, + "message_stop" => Ok(SseProcessResult { + done: true, + ..SseProcessResult::default() + }), + // message_delta 可能包含 stop_reason (P4.2) + "message_delta" => { + let stop_reason = payload + .get("delta") + .and_then(|d| d.get("stop_reason")) + .and_then(Value::as_str) + .map(str::to_string); + Ok(SseProcessResult { + stop_reason, + usage: extract_usage_from_payload(&event_type, &payload), + ..SseProcessResult::default() + }) + }, + "message_start" => Ok(SseProcessResult { + usage: extract_usage_from_payload(&event_type, &payload), + ..SseProcessResult::default() + }), + "content_block_stop" | "ping" => Ok(SseProcessResult::default()), + "error" => Err(anthropic_stream_error(&payload)), + other => { + warn!("anthropic: unknown sse event: {}", other); + Ok(SseProcessResult::default()) + }, + } +} + +/// 在 SSE 缓冲区中查找下一个完整的 SSE 块边界。 +/// +/// Anthropic SSE 块由双换行符分隔(`\r\n\r\n` 或 `\n\n`)。 +/// 返回 `(块结束位置, 分隔符长度)`,如果未找到完整块则返回 `None`。 +fn next_sse_block(buffer: &str) -> Option<(usize, usize)> { + if let Some(idx) = buffer.find("\r\n\r\n") { + return Some((idx, 4)); + } + if let Some(idx) = buffer.find("\n\n") { + return Some((idx, 2)); + } + None +} + +fn apply_sse_process_result( + result: SseProcessResult, + stop_reason_out: &mut Option, + usage_out: &mut AnthropicUsage, +) -> bool { + if let Some(r) = result.stop_reason { + *stop_reason_out = Some(r); + } + if let Some(usage) = result.usage { + usage_out.merge_from(usage); + } + result.done +} + +pub(super) fn consume_sse_text_chunk( + chunk_text: &str, + sse_buffer: &mut String, + accumulator: &mut LlmAccumulator, + sink: &EventSink, + stop_reason_out: &mut Option, + usage_out: &mut AnthropicUsage, +) -> Result { + sse_buffer.push_str(chunk_text); + + while let Some((block_end, delimiter_len)) = next_sse_block(sse_buffer) { + let block: String = sse_buffer.drain(..block_end + delimiter_len).collect(); + let block = &block[..block_end]; + + let result = process_sse_block(block, accumulator, sink)?; + if apply_sse_process_result(result, stop_reason_out, usage_out) { + return Ok(true); + } + } + + Ok(false) +} + +pub(super) fn flush_sse_buffer( + sse_buffer: &mut String, + accumulator: &mut LlmAccumulator, + sink: &EventSink, + stop_reason_out: &mut Option, + usage_out: &mut AnthropicUsage, +) -> Result<()> { + if sse_buffer.trim().is_empty() { + sse_buffer.clear(); + return Ok(()); + } + + while let Some((block_end, delimiter_len)) = next_sse_block(sse_buffer) { + let block: String = sse_buffer.drain(..block_end + delimiter_len).collect(); + let block = &block[..block_end]; + + let result = process_sse_block(block, accumulator, sink)?; + if apply_sse_process_result(result, stop_reason_out, usage_out) { + sse_buffer.clear(); + return Ok(()); + } + } + + if !sse_buffer.trim().is_empty() { + let result = process_sse_block(sse_buffer, accumulator, sink)?; + apply_sse_process_result(result, stop_reason_out, usage_out); + } + sse_buffer.clear(); + Ok(()) +} + +fn block_type(value: &Value) -> Option<&str> { + value.get("type").and_then(Value::as_str) +} + +#[cfg(test)] +mod tests { + use std::sync::{Arc, Mutex}; + + use serde_json::json; + + use super::{consume_sse_text_chunk, flush_sse_buffer, parse_sse_block}; + use crate::{ + LlmAccumulator, LlmEvent, LlmUsage, Utf8StreamDecoder, anthropic::dto::AnthropicUsage, + sink_collector, + }; + + #[test] + fn streaming_sse_parses_tool_calls_and_text() { + let mut accumulator = LlmAccumulator::default(); + let events = Arc::new(Mutex::new(Vec::new())); + let sink = sink_collector(events.clone()); + let mut sse_buffer = String::new(); + + let chunk = concat!( + "event: content_block_start\n", + "data: {\"index\":1,\"type\":\"tool_use\",\"id\":\"call_1\",\"name\":\"search\"}\n\n", + "event: content_block_delta\n", + "data: {\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"\ + q\\\":\\\"ru\"}}\n\n", + "event: content_block_delta\n", + "data: {\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"st\\\"\ + }\"}}\n\n", + "event: content_block_delta\n", + "data: {\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"hello\"}}\n\n", + "event: message_stop\n", + "data: {\"type\":\"message_stop\"}\n\n" + ); + + let mut stop_reason_out: Option = None; + let mut usage_out = AnthropicUsage::default(); + let done = consume_sse_text_chunk( + chunk, + &mut sse_buffer, + &mut accumulator, + &sink, + &mut stop_reason_out, + &mut usage_out, + ) + .expect("stream chunk should parse"); + + assert!(done); + let output = accumulator.finish(); + let events = events.lock().expect("lock").clone(); + + assert!(events.iter().any(|event| { + matches!( + event, + LlmEvent::ToolCallDelta { index, id, name, arguments_delta } + if *index == 1 + && id.as_deref() == Some("call_1") + && name.as_deref() == Some("search") + && arguments_delta.is_empty() + ) + })); + assert!( + events + .iter() + .any(|event| matches!(event, LlmEvent::TextDelta(text) if text == "hello")) + ); + assert_eq!(output.content, "hello"); + assert_eq!(output.tool_calls.len(), 1); + assert_eq!(output.tool_calls[0].args, json!({ "q": "rust" })); + } + + #[test] + fn parse_sse_block_accepts_data_lines_without_space_after_colon() { + let block = concat!( + "event:content_block_delta\n", + "data:{\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"hello\"}}\n" + ); + + let parsed = parse_sse_block(block) + .expect("block should parse") + .expect("block should contain payload"); + + assert_eq!(parsed.0, "content_block_delta"); + assert_eq!(parsed.1["delta"]["text"], json!("hello")); + } + + #[test] + fn parse_sse_block_treats_done_sentinel_as_message_stop() { + let parsed = parse_sse_block("data: [DONE]\n") + .expect("done sentinel should parse") + .expect("done sentinel should produce payload"); + + assert_eq!(parsed.0, "message_stop"); + assert_eq!(parsed.1["type"], json!("message_stop")); + } + + #[test] + fn parse_sse_block_ignores_empty_data_payload() { + let parsed = parse_sse_block("event: ping\ndata:\n"); + assert!(matches!(parsed, Ok(None))); + } + + #[test] + fn streaming_sse_error_event_surfaces_structured_provider_failure() { + let mut accumulator = LlmAccumulator::default(); + let events = Arc::new(Mutex::new(Vec::new())); + let sink = sink_collector(events); + let mut sse_buffer = String::new(); + let mut stop_reason_out: Option = None; + let mut usage_out = AnthropicUsage::default(); + + let error = consume_sse_text_chunk( + concat!( + "event: error\n", + "data: {\"type\":\"error\",\"error\":{\"type\":\"overloaded_error\",", + "\"message\":\"capacity exhausted\"}}\n\n" + ), + &mut sse_buffer, + &mut accumulator, + &sink, + &mut stop_reason_out, + &mut usage_out, + ) + .expect_err("error event should terminate the stream with a structured error"); + + match error { + astrcode_core::AstrError::LlmRequestFailed { status, body } => { + assert_eq!(status, 529); + assert!(body.contains("overloaded_error")); + assert!(body.contains("capacity exhausted")); + }, + other => panic!("unexpected error variant: {other:?}"), + } + } + + #[test] + fn streaming_sse_error_event_without_type_still_maps_to_request_error() { + let mut accumulator = LlmAccumulator::default(); + let events = Arc::new(Mutex::new(Vec::new())); + let sink = sink_collector(events); + let mut sse_buffer = String::new(); + let mut stop_reason_out: Option = None; + let mut usage_out = AnthropicUsage::default(); + + let error = consume_sse_text_chunk( + concat!( + "event: error\n", + "data: {\"type\":\"error\",\"error\":{\"message\":\"messages 参数非法\"}}\n\n" + ), + &mut sse_buffer, + &mut accumulator, + &sink, + &mut stop_reason_out, + &mut usage_out, + ) + .expect_err("error event should terminate the stream with a structured error"); + + match error { + astrcode_core::AstrError::LlmRequestFailed { status, body } => { + assert_eq!(status, 400); + assert!(body.contains("invalid_request_error")); + assert!(body.contains("messages 参数非法")); + }, + other => panic!("unexpected error variant: {other:?}"), + } + } + + #[test] + fn streaming_sse_extracts_usage_from_message_events() { + let mut accumulator = LlmAccumulator::default(); + let events = Arc::new(Mutex::new(Vec::new())); + let sink = sink_collector(events); + let mut usage_out = AnthropicUsage::default(); + let mut stop_reason_out = None; + let mut sse_buffer = String::new(); + + let chunk = concat!( + "event: message_start\n", + "data: {\"type\":\"message_start\",\"message\":{\"usage\":{\"input_tokens\":120,\"\ + cache_creation_input_tokens\":90,\"cache_read_input_tokens\":70}}}\n\n", + "event: message_delta\n", + "data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":\ + {\"output_tokens\":33}}\n\n", + "event: message_stop\n", + "data: {\"type\":\"message_stop\"}\n\n" + ); + + let done = consume_sse_text_chunk( + chunk, + &mut sse_buffer, + &mut accumulator, + &sink, + &mut stop_reason_out, + &mut usage_out, + ) + .expect("stream chunk should parse"); + + assert!(done); + assert_eq!(stop_reason_out.as_deref(), Some("end_turn")); + assert_eq!( + usage_out.into_llm_usage(), + Some(LlmUsage { + input_tokens: 120, + output_tokens: 33, + cache_creation_input_tokens: 90, + cache_read_input_tokens: 70, + }) + ); + } + + #[test] + fn streaming_sse_handles_multibyte_text_split_across_chunks() { + let mut accumulator = LlmAccumulator::default(); + let events = Arc::new(Mutex::new(Vec::new())); + let sink = sink_collector(events.clone()); + let mut sse_buffer = String::new(); + let mut decoder = Utf8StreamDecoder::default(); + let mut stop_reason_out = None; + let mut usage_out = AnthropicUsage::default(); + let chunk = concat!( + "event: content_block_delta\n", + "data: {\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"你", + "好\"}}\n\n", + "event: message_stop\n", + "data: {\"type\":\"message_stop\"}\n\n" + ); + let bytes = chunk.as_bytes(); + let split_index = chunk + .find("好") + .expect("chunk should contain multibyte char") + + 1; + + let first_text = decoder + .push( + &bytes[..split_index], + "anthropic response stream was not valid utf-8", + ) + .expect("first split should decode"); + let second_text = decoder + .push( + &bytes[split_index..], + "anthropic response stream was not valid utf-8", + ) + .expect("second split should decode"); + + let first_done = first_text + .as_deref() + .map(|text| { + consume_sse_text_chunk( + text, + &mut sse_buffer, + &mut accumulator, + &sink, + &mut stop_reason_out, + &mut usage_out, + ) + }) + .transpose() + .expect("first chunk should parse") + .unwrap_or(false); + let second_done = second_text + .as_deref() + .map(|text| { + consume_sse_text_chunk( + text, + &mut sse_buffer, + &mut accumulator, + &sink, + &mut stop_reason_out, + &mut usage_out, + ) + }) + .transpose() + .expect("second chunk should parse") + .unwrap_or(false); + + assert!(!first_done); + assert!(second_done); + let output = accumulator.finish(); + let events = events.lock().expect("lock").clone(); + + assert!( + events + .iter() + .any(|event| matches!(event, LlmEvent::TextDelta(text) if text == "你好")) + ); + assert_eq!(output.content, "你好"); + } + + #[test] + fn flush_sse_buffer_processes_all_complete_blocks_before_tail_block() { + let mut accumulator = LlmAccumulator::default(); + let events = Arc::new(Mutex::new(Vec::new())); + let sink = sink_collector(events.clone()); + let mut sse_buffer = concat!( + "event: content_block_delta\n", + "data: {\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"hello\"}}\n\n", + "event: message_delta\n", + "data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":", + "{\"output_tokens\":7}}" + ) + .to_string(); + let mut stop_reason_out = None; + let mut usage_out = AnthropicUsage::default(); + + flush_sse_buffer( + &mut sse_buffer, + &mut accumulator, + &sink, + &mut stop_reason_out, + &mut usage_out, + ) + .expect("flush should process buffered blocks"); + + let output = accumulator.finish(); + let events = events.lock().expect("lock").clone(); + + assert!(sse_buffer.is_empty()); + assert!( + events + .iter() + .any(|event| matches!(event, LlmEvent::TextDelta(text) if text == "hello")) + ); + assert_eq!(output.content, "hello"); + assert_eq!(stop_reason_out.as_deref(), Some("end_turn")); + assert_eq!( + usage_out.into_llm_usage(), + Some(LlmUsage { + input_tokens: 0, + output_tokens: 7, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }) + ); + } +} diff --git a/crates/adapter-llm/src/core_port.rs b/crates/adapter-llm/src/core_port.rs deleted file mode 100644 index 94162216..00000000 --- a/crates/adapter-llm/src/core_port.rs +++ /dev/null @@ -1,141 +0,0 @@ -//! 桥接 `adapter-llm` 内部 trait 与 `core::ports::LlmProvider`。 -//! -//! adapter-llm 有自己的 LlmProvider trait 和配套类型(LlmRequest/LlmOutput/LlmEvent 等), -//! 它们与 `core::ports` 中定义的端口类型结构相同但类型路径不同。 -//! 本模块通过 trivial 转换让 OpenAiProvider / AnthropicProvider 同时满足 core 端口契约, -//! 使 server 组合根可以直接注入 adapter 实例而无需 Noop 替代。 - -use std::sync::Arc; - -use astrcode_core::ports::{self, LlmFinishReason}; -use async_trait::async_trait; - -use crate::{ - FinishReason, LlmEvent, LlmOutput, LlmProvider, anthropic::AnthropicProvider, - openai::OpenAiProvider, -}; - -// ── 类型转换辅助 ────────────────────────────────────────── - -/// `adapter-llm::LlmRequest` → `core::ports::LlmRequest` -fn convert_request(req: ports::LlmRequest) -> crate::LlmRequest { - crate::LlmRequest { - messages: req.messages, - tools: req.tools, - cancel: req.cancel, - system_prompt: req.system_prompt, - system_prompt_blocks: req.system_prompt_blocks, - } -} - -/// `adapter-llm::LlmOutput` → `core::ports::LlmOutput` -fn convert_output(out: LlmOutput) -> ports::LlmOutput { - ports::LlmOutput { - content: out.content, - tool_calls: out.tool_calls, - reasoning: out.reasoning, - usage: out.usage.map(convert_usage), - finish_reason: convert_finish_reason(out.finish_reason), - } -} - -fn convert_usage(u: crate::LlmUsage) -> ports::LlmUsage { - ports::LlmUsage { - input_tokens: u.input_tokens, - output_tokens: u.output_tokens, - cache_creation_input_tokens: u.cache_creation_input_tokens, - cache_read_input_tokens: u.cache_read_input_tokens, - } -} - -fn convert_finish_reason(r: FinishReason) -> LlmFinishReason { - match r { - FinishReason::Stop => LlmFinishReason::Stop, - FinishReason::MaxTokens => LlmFinishReason::MaxTokens, - FinishReason::ToolCalls => LlmFinishReason::ToolCalls, - FinishReason::Other(s) => LlmFinishReason::Other(s), - } -} - -/// `adapter-llm::LlmEvent` → `core::ports::LlmEvent` -fn convert_event(event: LlmEvent) -> ports::LlmEvent { - match event { - LlmEvent::TextDelta(t) => ports::LlmEvent::TextDelta(t), - LlmEvent::ThinkingDelta(t) => ports::LlmEvent::ThinkingDelta(t), - LlmEvent::ThinkingSignature(s) => ports::LlmEvent::ThinkingSignature(s), - LlmEvent::ToolCallDelta { - index, - id, - name, - arguments_delta, - } => ports::LlmEvent::ToolCallDelta { - index, - id, - name, - arguments_delta, - }, - } -} - -/// 将 core 端的 sink 包装为 adapter 内部的 EventSink。 -/// -/// adapter 内部 generate() 会产出 `adapter::LlmEvent`, -/// 我们把每个 event 转为 `core::ports::LlmEvent` 后转发给外部 sink。 -fn wrap_sink(core_sink: ports::LlmEventSink) -> crate::EventSink { - Arc::new(move |event: LlmEvent| { - core_sink(convert_event(event)); - }) -} - -fn convert_limits(limits: crate::ModelLimits) -> ports::ModelLimits { - ports::ModelLimits { - context_window: limits.context_window, - max_output_tokens: limits.max_output_tokens, - } -} - -// ── core::ports::LlmProvider 实现 ───────────────────────── - -#[async_trait] -impl ports::LlmProvider for OpenAiProvider { - async fn generate( - &self, - request: ports::LlmRequest, - sink: Option, - ) -> astrcode_core::Result { - let internal_request = convert_request(request); - let internal_sink = sink.map(wrap_sink); - let output = LlmProvider::generate(self, internal_request, internal_sink).await?; - Ok(convert_output(output)) - } - - fn model_limits(&self) -> ports::ModelLimits { - convert_limits(LlmProvider::model_limits(self)) - } - - fn supports_cache_metrics(&self) -> bool { - LlmProvider::supports_cache_metrics(self) - } -} - -#[async_trait] -impl ports::LlmProvider for AnthropicProvider { - async fn generate( - &self, - request: ports::LlmRequest, - sink: Option, - ) -> astrcode_core::Result { - let internal_request = convert_request(request); - let internal_sink = sink.map(wrap_sink); - let output = LlmProvider::generate(self, internal_request, internal_sink).await?; - Ok(convert_output(output)) - } - - fn model_limits(&self) -> ports::ModelLimits { - convert_limits(LlmProvider::model_limits(self)) - } - - fn supports_cache_metrics(&self) -> bool { - LlmProvider::supports_cache_metrics(self) - } -} diff --git a/crates/adapter-llm/src/lib.rs b/crates/adapter-llm/src/lib.rs index bdbe953d..d6297ac5 100644 --- a/crates/adapter-llm/src/lib.rs +++ b/crates/adapter-llm/src/lib.rs @@ -41,22 +41,22 @@ //! - [`anthropic`] — Anthropic Messages API 实现 //! - [`openai`] — OpenAI Chat Completions API 兼容实现 -use std::{collections::HashMap, sync::Arc, time::Duration}; +use std::{collections::HashMap, time::Duration}; -use astrcode_core::{ - AstrError, CancelToken, LlmMessage, ModelRequest, ReasoningContent, Result, SystemPromptBlock, - ToolCallRequest, ToolDefinition, -}; -use async_trait::async_trait; +use astrcode_core::{AstrError, CancelToken, LlmEvent, ReasoningContent, Result, ToolCallRequest}; use log::warn; use serde_json::Value; use tokio::{select, time::sleep}; pub mod anthropic; pub mod cache_tracker; -pub mod core_port; pub mod openai; +pub use astrcode_core::{ + LlmEventSink as EventSink, LlmFinishReason as FinishReason, LlmOutput, LlmProvider, LlmRequest, + LlmUsage, ModelLimits, +}; + // --------------------------------------------------------------------------- // Structured LLM error types (P4.3) // --------------------------------------------------------------------------- @@ -161,76 +161,6 @@ pub fn classify_http_error(status: u16, body: &str) -> LlmError { // Finish reason (P4.2) // --------------------------------------------------------------------------- -/// LLM 响应结束原因,用于判断是否需要自动继续生成。 -#[derive(Debug, Clone, PartialEq, Eq, Default)] -pub enum FinishReason { - /// 模型自然结束输出 - #[default] - Stop, - /// 输出被 max_tokens 限制截断,需要继续生成 - MaxTokens, - /// 模型调用了工具 - ToolCalls, - /// 其他未知原因 - Other(String), -} - -impl FinishReason { - /// 判断是否因 max_tokens 截断。 - pub fn is_max_tokens(&self) -> bool { - matches!(self, FinishReason::MaxTokens) - } - - /// 从 OpenAI/Anthropic API 返回的 finish_reason/stop_reason 字符串解析。 - /// - /// 支持两种 API 的值: - /// - OpenAI: `stop`, `max_tokens`, `length`, `tool_calls`, `content_filter` - /// - Anthropic: `end_turn`, `max_tokens`, `tool_use`, `stop_sequence` - pub fn from_api_value(value: &str) -> Self { - match value { - // OpenAI 值 - "stop" => FinishReason::Stop, - "max_tokens" | "length" => FinishReason::MaxTokens, - "tool_calls" => FinishReason::ToolCalls, - // Anthropic 值 - "end_turn" | "stop_sequence" => FinishReason::Stop, - "tool_use" => FinishReason::ToolCalls, - other => FinishReason::Other(other.to_string()), - } - } -} - -/// 模型能力限制,用于请求预算决策。 -/// -/// 包含上下文窗口大小和最大输出 token 数。它们在 provider 构造阶段就已经被解析为权威值 -/// 或本地手动值,后续的 agent loop 只消费这一份统一结果。 -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct ModelLimits { - /// 模型支持的上下文窗口大小(token 数) - pub context_window: usize, - /// 模型单次响应允许的最大输出 token 数 - pub max_output_tokens: usize, -} - -/// 模型调用的 token 用量统计。 -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct LlmUsage { - /// 输入(prompt)消耗的 token 数 - pub input_tokens: usize, - /// 输出(completion)消耗的 token 数 - pub output_tokens: usize, - /// 本次请求中新写入 provider cache 的输入 token 数。 - pub cache_creation_input_tokens: usize, - /// 本次请求从 provider cache 读取的输入 token 数。 - pub cache_read_input_tokens: usize, -} - -impl LlmUsage { - pub fn total_tokens(self) -> usize { - self.input_tokens.saturating_add(self.output_tokens) - } -} - // --------------------------------------------------------------------------- // Cancel helper (moved from runtime::cancel) // --------------------------------------------------------------------------- @@ -483,146 +413,12 @@ fn debug_utf8_bytes(bytes: &[u8], valid_up_to: usize, invalid_len: Option /// 返回的 sink 会将每个事件追加到提供的 `Mutex>` 中, /// 方便测试断言验证事件序列。 #[cfg(test)] -pub fn sink_collector(events: Arc>>) -> EventSink { - Arc::new(move |event| { +pub fn sink_collector(events: std::sync::Arc>>) -> EventSink { + std::sync::Arc::new(move |event| { events.lock().expect("lock").push(event); }) } -/// 运行时范围的模型调用请求。 -/// -/// 封装了模型调用所需的最小上下文:消息历史、可用工具定义、取消令牌和可选的系统提示。 -/// 不包含提供者发现、API 密钥管理等前置逻辑,这些由调用方在构造本结构体之前处理。 -#[derive(Clone, Debug)] -pub struct LlmRequest { - pub messages: Vec, - pub tools: Arc<[ToolDefinition]>, - pub cancel: CancelToken, - pub system_prompt: Option, - pub system_prompt_blocks: Vec, -} - -impl LlmRequest { - pub fn new( - messages: Vec, - tools: impl Into>, - cancel: CancelToken, - ) -> Self { - Self { - messages, - tools: tools.into(), - cancel, - system_prompt: None, - system_prompt_blocks: Vec::new(), - } - } - - pub fn with_system(mut self, prompt: impl Into) -> Self { - self.system_prompt = Some(prompt.into()); - self - } - - pub fn from_model_request(request: ModelRequest, cancel: CancelToken) -> Self { - Self { - messages: request.messages, - tools: request.tools.into(), - cancel, - system_prompt: request.system_prompt, - system_prompt_blocks: request.system_prompt_blocks, - } - } -} - -/// 流式 LLM 响应事件。 -/// -/// 每个事件代表响应流中的一个增量片段,由 [`LlmAccumulator`] 重新组装为完整输出。 -/// - `TextDelta`: 普通文本增量 -/// - `ThinkingDelta`: 推理过程增量(extended thinking / reasoning) -/// - `ThinkingSignature`: 推理签名(Anthropic 特有,用于验证 thinking 完整性) -/// - `ToolCallDelta`: 工具调用增量(id、name 在首个 delta 中出现,arguments 逐片段拼接) -#[derive(Clone, Debug)] -pub enum LlmEvent { - TextDelta(String), - ThinkingDelta(String), - ThinkingSignature(String), - ToolCallDelta { - index: usize, - id: Option, - name: Option, - arguments_delta: String, - }, -} - -/// 模型调用的完整输出。 -/// -/// 由 [`LlmAccumulator::finish`] 组装而成,包含所有文本内容、工具调用请求和推理内容。 -/// 非流式路径下,`usage` 字段会被填充;流式路径下 `usage` 为 `None`(Anthropic 流式 -/// 响应不返回用量,OpenAI 流式响应的用量在最后一个 chunk 中但当前未提取)。 -/// -/// `finish_reason` 字段用于判断输出是否被 max_tokens 截断 (P4.2)。 -#[derive(Clone, Debug, Default)] -pub struct LlmOutput { - pub content: String, - pub tool_calls: Vec, - pub reasoning: Option, - pub usage: Option, - /// 输出结束原因,用于检测 max_tokens 截断。 - pub finish_reason: FinishReason, -} - -/// 事件回调类型别名。 -/// -/// 用于接收流式 [`LlmEvent`] 的异步回调,通常由前端或上层运行时订阅。 -pub type EventSink = Arc; - -/// LLM 提供者 trait。 -/// -/// 这是运行时与 LLM 后端交互的核心抽象。每个实现封装了特定 API 的协议细节 -/// (认证、请求格式、SSE 解析等),对外暴露统一的调用接口。 -/// -/// ## 设计约束 -/// -/// - 不管理 API 密钥或模型发现,这些由调用方在构造具体提供者实例时处理 -/// - `generate()` 执行单次模型调用,不维护多轮对话状态 -/// - 流式路径下,事件通过 `sink` 实时发射,同时内部累加返回完整输出 -#[async_trait] -pub trait LlmProvider: Send + Sync { - /// 执行一次模型调用。 - /// - /// `sink` 参数控制调用模式: - /// - `None`: 非流式模式,等待完整响应后返回 - /// - `Some(sink)`: 流式模式,实时发射 [`LlmEvent`] 到 sink,同时累加返回完整输出 - /// - /// 取消令牌通过 `request.cancel` 传递,任何时刻取消都会立即中断请求。 - async fn generate(&self, request: LlmRequest, sink: Option) -> Result; - - /// 当前 provider 是否原生暴露缓存 token 指标。 - /// - /// OpenAI 兼容接口目前只依赖自动前缀缓存,缺少稳定的 token 统计;Anthropic 则会明确 - /// 返回 cache creation/read 字段。上层通过这个开关决定是否把 0 值解释成“真实指标” - /// 还是“provider 不支持”。 - fn supports_cache_metrics(&self) -> bool { - false - } - - /// 将 provider 原始 usage 里的输入 token 规范化成适合前端展示的 prompt 总量。 - /// - /// 某些 provider(如 Anthropic)会把缓存读取的 token 单独放在 - /// `cache_read_input_tokens`,而 `input_tokens` 只表示本次实际重新发送/计费的部分。 - /// 前端展示“缓存命中率”时需要一个统一口径的总输入值,因此默认直接回放 - /// `usage.input_tokens`,特殊 provider 再自行覆盖。 - fn prompt_metrics_input_tokens(&self, usage: LlmUsage) -> usize { - usage.input_tokens - } - - /// 返回模型的上下文窗口估算。 - /// - /// 用于调用方判断当前消息历史是否接近上下文限制,触发压缩或截断。 - /// - /// 返回值应已经是 provider 构造阶段解析好的稳定 limits,而不是在这里临时猜测。 - fn model_limits(&self) -> ModelLimits; -} - #[derive(Default)] pub struct LlmAccumulator { pub content: String, diff --git a/crates/adapter-llm/src/openai.rs b/crates/adapter-llm/src/openai.rs index a46cfe82..715364a1 100644 --- a/crates/adapter-llm/src/openai.rs +++ b/crates/adapter-llm/src/openai.rs @@ -29,7 +29,8 @@ use std::{ }; use astrcode_core::{ - AstrError, CancelToken, LlmMessage, ReasoningContent, Result, ToolCallRequest, ToolDefinition, + AstrError, CancelToken, LlmMessage, PromptCacheHints, ReasoningContent, Result, + ToolCallRequest, ToolDefinition, }; use async_trait::async_trait; use futures_util::StreamExt; @@ -112,14 +113,19 @@ impl OpenAiProvider { /// 不需要显式标记 `cache_control`,API 自动缓存 >= 1024 tokens 的 prompt 前缀。 /// 分层 system blocks 的排列顺序(Stable → SemiStable → Inherited → Dynamic)天然提供稳定的 /// 前缀,对 OpenAI 的自动 prefix matching 最友好。 - fn build_request<'a>( - &'a self, - messages: &'a [LlmMessage], - tools: &'a [ToolDefinition], - system_prompt: Option<&'a str>, - system_prompt_blocks: &'a [astrcode_core::SystemPromptBlock], - stream: bool, - ) -> OpenAiChatRequest<'a> { + fn build_request<'a>(&'a self, input: OpenAiBuildRequestInput<'a>) -> OpenAiChatRequest<'a> { + let OpenAiBuildRequestInput { + messages, + tools, + system_prompt, + system_prompt_blocks, + prompt_cache_hints, + max_output_tokens_override, + stream, + } = input; + let effective_max_output_tokens = max_output_tokens_override + .unwrap_or(self.limits.max_output_tokens) + .min(self.limits.max_output_tokens); let system_count = if !system_prompt_blocks.is_empty() { system_prompt_blocks.len() } else if system_prompt.is_some() { @@ -153,10 +159,16 @@ impl OpenAiProvider { OpenAiChatRequest { model: &self.model, - max_tokens: self.limits.max_output_tokens.min(u32::MAX as usize) as u32, + max_tokens: effective_max_output_tokens.min(u32::MAX as usize) as u32, messages: request_messages, prompt_cache_key: self.should_send_prompt_cache_key().then(|| { - build_prompt_cache_key(&self.model, system_prompt, system_prompt_blocks, tools) + build_prompt_cache_key( + &self.model, + system_prompt, + system_prompt_blocks, + prompt_cache_hints, + tools, + ) }), prompt_cache_retention: None, tools: if tools.is_empty() { @@ -260,13 +272,27 @@ fn build_prompt_cache_key( model: &str, system_prompt: Option<&str>, system_prompt_blocks: &[astrcode_core::SystemPromptBlock], + prompt_cache_hints: Option<&PromptCacheHints>, tools: &[ToolDefinition], ) -> String { let mut hasher = DefaultHasher::new(); "astrcode-openai-prompt-cache-v1".hash(&mut hasher); model.hash(&mut hasher); - if !system_prompt_blocks.is_empty() { + if let Some(hints) = prompt_cache_hints { + if let Some(stable) = &hints.layer_fingerprints.stable { + "stable".hash(&mut hasher); + stable.hash(&mut hasher); + } + if let Some(semi_stable) = &hints.layer_fingerprints.semi_stable { + "semi_stable".hash(&mut hasher); + semi_stable.hash(&mut hasher); + } + if let Some(inherited) = &hints.layer_fingerprints.inherited { + "inherited".hash(&mut hasher); + inherited.hash(&mut hasher); + } + } else if !system_prompt_blocks.is_empty() { for block in system_prompt_blocks { format!("{:?}", block.layer).hash(&mut hasher); block.title.hash(&mut hasher); @@ -300,13 +326,15 @@ impl LlmProvider for OpenAiProvider { /// - **流式**(`sink = Some`):逐块读取 SSE 响应,实时发射事件并累加 async fn generate(&self, request: LlmRequest, sink: Option) -> Result { let cancel = request.cancel; - let req = self.build_request( - &request.messages, - &request.tools, - request.system_prompt.as_deref(), - &request.system_prompt_blocks, - sink.is_some(), - ); + let req = self.build_request(OpenAiBuildRequestInput { + messages: &request.messages, + tools: &request.tools, + system_prompt: request.system_prompt.as_deref(), + system_prompt_blocks: &request.system_prompt_blocks, + prompt_cache_hints: request.prompt_cache_hints.as_ref(), + max_output_tokens_override: request.max_output_tokens_override, + stream: sink.is_some(), + }); let response = self.send_request(&req, cancel.clone()).await?; match sink { @@ -757,6 +785,16 @@ struct OpenAiChatRequest<'a> { stream: bool, } +struct OpenAiBuildRequestInput<'a> { + messages: &'a [LlmMessage], + tools: &'a [ToolDefinition], + system_prompt: Option<&'a str>, + system_prompt_blocks: &'a [astrcode_core::SystemPromptBlock], + prompt_cache_hints: Option<&'a PromptCacheHints>, + max_output_tokens_override: Option, + stream: bool, +} + /// OpenAI 请求消息(user / assistant / system / tool)。 /// /// 与 Anthropic 的内容块数组不同,OpenAI 使用扁平的消息结构: @@ -1031,7 +1069,15 @@ mod tests { content: "hi".to_string(), origin: UserMessageOrigin::User, }]; - let request = provider.build_request(&messages, &[], Some("Follow the rules"), &[], false); + let request = provider.build_request(OpenAiBuildRequestInput { + messages: &messages, + tools: &[], + system_prompt: Some("Follow the rules"), + system_prompt_blocks: &[], + prompt_cache_hints: None, + max_output_tokens_override: None, + stream: false, + }); assert_eq!(request.messages[0].role, "system"); assert_eq!( @@ -1085,7 +1131,15 @@ mod tests { layer: astrcode_core::SystemPromptLayer::Inherited, }, ]; - let request = provider.build_request(&messages, &[], None, &system_blocks, false); + let request = provider.build_request(OpenAiBuildRequestInput { + messages: &messages, + tools: &[], + system_prompt: None, + system_prompt_blocks: &system_blocks, + prompt_cache_hints: None, + max_output_tokens_override: None, + stream: false, + }); let body = serde_json::to_value(&request).expect("request should serialize"); // 应该有 4 个 system 消息 + 1 个 user 消息,无 cache_control 字段 @@ -1140,22 +1194,27 @@ mod tests { ) .expect("provider should build"); - let official_body = serde_json::to_value(official.build_request( - &messages, - &[], - Some("Follow the rules"), - &[], - false, - )) - .expect("request should serialize"); - let compatible_body = serde_json::to_value(compatible.build_request( - &messages, - &[], - Some("Follow the rules"), - &[], - false, - )) + let official_body = serde_json::to_value(official.build_request(OpenAiBuildRequestInput { + messages: &messages, + tools: &[], + system_prompt: Some("Follow the rules"), + system_prompt_blocks: &[], + prompt_cache_hints: None, + max_output_tokens_override: None, + stream: false, + })) .expect("request should serialize"); + let compatible_body = + serde_json::to_value(compatible.build_request(OpenAiBuildRequestInput { + messages: &messages, + tools: &[], + system_prompt: Some("Follow the rules"), + system_prompt_blocks: &[], + prompt_cache_hints: None, + max_output_tokens_override: None, + stream: false, + })) + .expect("request should serialize"); assert!( official_body @@ -1167,6 +1226,47 @@ mod tests { assert!(compatible_body.get("prompt_cache_key").is_none()); } + #[test] + fn build_request_honors_request_level_max_output_tokens_override() { + let provider = OpenAiProvider::new( + "https://api.openai.com/v1/chat/completions".to_string(), + "sk-test".to_string(), + "gpt-4.1".to_string(), + ModelLimits { + context_window: 128_000, + max_output_tokens: 2048, + }, + LlmClientConfig::default(), + ) + .expect("provider should build"); + let messages = [LlmMessage::User { + content: "hi".to_string(), + origin: UserMessageOrigin::User, + }]; + + let capped = provider.build_request(OpenAiBuildRequestInput { + messages: &messages, + tools: &[], + system_prompt: None, + system_prompt_blocks: &[], + prompt_cache_hints: None, + max_output_tokens_override: Some(1024), + stream: false, + }); + let clamped = provider.build_request(OpenAiBuildRequestInput { + messages: &messages, + tools: &[], + system_prompt: None, + system_prompt_blocks: &[], + prompt_cache_hints: None, + max_output_tokens_override: Some(4096), + stream: false, + }); + + assert_eq!(capped.max_tokens, 1024); + assert_eq!(clamped.max_tokens, 2048); + } + #[tokio::test] async fn generate_non_streaming_parses_text_and_tool_calls() { let body = json!({ diff --git a/crates/adapter-mcp/Cargo.toml b/crates/adapter-mcp/Cargo.toml index a61535dd..5008ad88 100644 --- a/crates/adapter-mcp/Cargo.toml +++ b/crates/adapter-mcp/Cargo.toml @@ -19,3 +19,6 @@ serde_json.workspace = true thiserror.workspace = true tokio.workspace = true uuid.workspace = true + +[target.'cfg(unix)'.dependencies] +libc = "0.2" diff --git a/crates/adapter-mcp/src/bridge/resource_tool.rs b/crates/adapter-mcp/src/bridge/resource_tool.rs index 0b1cb0e9..8b6b5a15 100644 --- a/crates/adapter-mcp/src/bridge/resource_tool.rs +++ b/crates/adapter-mcp/src/bridge/resource_tool.rs @@ -176,7 +176,7 @@ impl CapabilityInvoker for ReadMcpResourceTool { json!({ "uri": content.uri, "mimeType": content.mime_type, - "text": rendered, + "text": rendered.output, }) } else if let Some(blob) = &content.blob { match persist_blob_content( diff --git a/crates/adapter-mcp/src/bridge/tool_bridge.rs b/crates/adapter-mcp/src/bridge/tool_bridge.rs index c1f8c7c5..871b698f 100644 --- a/crates/adapter-mcp/src/bridge/tool_bridge.rs +++ b/crates/adapter-mcp/src/bridge/tool_bridge.rs @@ -263,7 +263,9 @@ mod tests { #[tokio::test] async fn test_bridge_capability_spec() { let (mock_transport, _) = create_connected_mock().await; - let client = McpClient::connect(mock_transport).await.unwrap(); + let client = McpClient::connect(mock_transport) + .await + .expect("client connect should succeed"); let bridge = McpToolBridge::new( "test-server", diff --git a/crates/adapter-mcp/src/config/approval.rs b/crates/adapter-mcp/src/config/approval.rs index c8b3a649..6bf74d6c 100644 --- a/crates/adapter-mcp/src/config/approval.rs +++ b/crates/adapter-mcp/src/config/approval.rs @@ -165,7 +165,11 @@ mod tests { &self, _project_path: &str, ) -> std::result::Result, String> { - Ok(self.approvals.lock().unwrap().clone()) + Ok(self + .approvals + .lock() + .expect("mock lock should not be poisoned") + .clone()) } fn save_approval( @@ -173,7 +177,10 @@ mod tests { _project_path: &str, data: &McpApprovalData, ) -> std::result::Result<(), String> { - let mut approvals = self.approvals.lock().unwrap(); + let mut approvals = self + .approvals + .lock() + .expect("mock lock should not be poisoned"); // 更新或新增 if let Some(existing) = approvals .iter_mut() @@ -187,7 +194,10 @@ mod tests { } fn clear_approvals(&self, _project_path: &str) -> std::result::Result<(), String> { - self.approvals.lock().unwrap().clear(); + self.approvals + .lock() + .expect("mock lock should not be poisoned") + .clear(); Ok(()) } } @@ -209,7 +219,7 @@ mod tests { manager .approve("/project", "stdio:npx:server", "user") - .unwrap(); + .expect("approve should succeed"); assert!(manager.is_approved("/project", "stdio:npx:server")); assert_eq!( @@ -223,7 +233,9 @@ mod tests { let store = MockSettingsStore::new(); let manager = McpApprovalManager::new(Box::new(store)); - manager.reject("/project", "stdio:npx:server").unwrap(); + manager + .reject("/project", "stdio:npx:server") + .expect("reject should succeed"); assert!(!manager.is_approved("/project", "stdio:npx:server")); assert_eq!( @@ -237,12 +249,16 @@ mod tests { let store = MockSettingsStore::new(); // 预先添加一个 pending 状态的服务器 - store.approvals.lock().unwrap().push(McpApprovalData { - server_signature: "stdio:npx:server-a".to_string(), - status: McpApprovalStatus::Pending, - approved_at: None, - approved_by: None, - }); + store + .approvals + .lock() + .expect("mock lock should not be poisoned") + .push(McpApprovalData { + server_signature: "stdio:npx:server-a".to_string(), + status: McpApprovalStatus::Pending, + approved_at: None, + approved_by: None, + }); let manager = McpApprovalManager::new(Box::new(store)); @@ -256,33 +272,45 @@ mod tests { let store = MockSettingsStore::new(); let manager = McpApprovalManager::new(Box::new(store)); - manager.reject("/project", "stdio:npx:server").unwrap(); + manager + .reject("/project", "stdio:npx:server") + .expect("reject should succeed"); assert!(!manager.is_approved("/project", "stdio:npx:server")); manager .approve("/project", "stdio:npx:server", "admin") - .unwrap(); + .expect("approve should succeed"); assert!(manager.is_approved("/project", "stdio:npx:server")); } #[test] fn test_approve_all() { let store = MockSettingsStore::new(); - store.approvals.lock().unwrap().push(McpApprovalData { - server_signature: "stdio:npx:server-a".to_string(), - status: McpApprovalStatus::Pending, - approved_at: None, - approved_by: None, - }); - store.approvals.lock().unwrap().push(McpApprovalData { - server_signature: "stdio:npx:server-b".to_string(), - status: McpApprovalStatus::Rejected, - approved_at: None, - approved_by: None, - }); + store + .approvals + .lock() + .expect("mock lock should not be poisoned") + .push(McpApprovalData { + server_signature: "stdio:npx:server-a".to_string(), + status: McpApprovalStatus::Pending, + approved_at: None, + approved_by: None, + }); + store + .approvals + .lock() + .expect("mock lock should not be poisoned") + .push(McpApprovalData { + server_signature: "stdio:npx:server-b".to_string(), + status: McpApprovalStatus::Rejected, + approved_at: None, + approved_by: None, + }); let manager = McpApprovalManager::new(Box::new(store)); - let count = manager.approve_all("/project", "admin").unwrap(); + let count = manager + .approve_all("/project", "admin") + .expect("approve_all should succeed"); assert_eq!(count, 2); assert!(manager.is_approved("/project", "stdio:npx:server-a")); @@ -292,18 +320,26 @@ mod tests { #[test] fn test_all_server_statuses() { let store = MockSettingsStore::new(); - store.approvals.lock().unwrap().push(McpApprovalData { - server_signature: "stdio:npx:server-a".to_string(), - status: McpApprovalStatus::Approved, - approved_at: Some("t=123".to_string()), - approved_by: Some("user".to_string()), - }); - store.approvals.lock().unwrap().push(McpApprovalData { - server_signature: "stdio:npx:server-b".to_string(), - status: McpApprovalStatus::Pending, - approved_at: None, - approved_by: None, - }); + store + .approvals + .lock() + .expect("mock lock should not be poisoned") + .push(McpApprovalData { + server_signature: "stdio:npx:server-a".to_string(), + status: McpApprovalStatus::Approved, + approved_at: Some("t=123".to_string()), + approved_by: Some("user".to_string()), + }); + store + .approvals + .lock() + .expect("mock lock should not be poisoned") + .push(McpApprovalData { + server_signature: "stdio:npx:server-b".to_string(), + status: McpApprovalStatus::Pending, + approved_at: None, + approved_by: None, + }); let manager = McpApprovalManager::new(Box::new(store)); let statuses = manager.all_server_statuses("/project"); diff --git a/crates/adapter-mcp/src/config/loader.rs b/crates/adapter-mcp/src/config/loader.rs index fa43318b..b4e3da6d 100644 --- a/crates/adapter-mcp/src/config/loader.rs +++ b/crates/adapter-mcp/src/config/loader.rs @@ -273,7 +273,8 @@ mod tests { } }"#; - let configs = McpConfigManager::load_from_json(json, McpConfigScope::Project).unwrap(); + let configs = McpConfigManager::load_from_json(json, McpConfigScope::Project) + .expect("config should parse"); assert_eq!(configs.len(), 1); assert_eq!(configs[0].name, "filesystem"); assert!(matches!( @@ -296,7 +297,8 @@ mod tests { } }"#; - let configs = McpConfigManager::load_from_json(json, McpConfigScope::Project).unwrap(); + let configs = McpConfigManager::load_from_json(json, McpConfigScope::Project) + .expect("config should parse"); assert_eq!(configs.len(), 1); assert_eq!(configs[0].name, "filesystem"); assert!(matches!( @@ -323,7 +325,7 @@ mod tests { assert!(result.is_err()); assert!( result - .unwrap_err() + .expect_err("explicit stdio without command should fail") .to_string() .contains("declared as stdio but missing command") ); @@ -341,7 +343,8 @@ mod tests { } }"#; - let configs = McpConfigManager::load_from_json(json, McpConfigScope::User).unwrap(); + let configs = McpConfigManager::load_from_json(json, McpConfigScope::User) + .expect("config should parse"); assert_eq!(configs.len(), 1); assert!(matches!( configs[0].transport, @@ -360,7 +363,8 @@ mod tests { } }"#; - let configs = McpConfigManager::load_from_json(json, McpConfigScope::Project).unwrap(); + let configs = McpConfigManager::load_from_json(json, McpConfigScope::Project) + .expect("config should parse"); assert_eq!(configs.len(), 1); assert!(!configs[0].enabled); } @@ -374,7 +378,8 @@ mod tests { } }"#; - let configs = McpConfigManager::load_from_json(json, McpConfigScope::Project).unwrap(); + let configs = McpConfigManager::load_from_json(json, McpConfigScope::Project) + .expect("config should parse"); assert_eq!(configs.len(), 1); // 第二个被去重 // HashMap 迭代顺序不确定,保留的是先遇到的那个 assert!(configs[0].name == "fs1" || configs[0].name == "fs2"); @@ -390,7 +395,12 @@ mod tests { let result = McpConfigManager::load_from_json(json, McpConfigScope::Project); assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("neither")); + assert!( + result + .expect_err("no command/url should fail") + .to_string() + .contains("neither") + ); } #[test] @@ -405,7 +415,7 @@ mod tests { assert!(result.is_err()); assert!( result - .unwrap_err() + .expect_err("unknown transport type should fail") .to_string() .contains("unknown transport type") ); @@ -424,21 +434,21 @@ mod tests { let result = McpConfigManager::load_from_json(json, McpConfigScope::Project); assert!(result.is_err()); - let err = result.unwrap_err().to_string(); + let err = result.expect_err("missing env var should fail").to_string(); assert!(err.contains("NONEXISTENT_VAR_12345"), "Error: {}", err); } #[test] fn test_expand_env_vars_with_existing_var() { std::env::set_var("TEST_MCP_HOME", "/test/path"); - let result = expand_env_vars("${TEST_MCP_HOME}/server").unwrap(); + let result = expand_env_vars("${TEST_MCP_HOME}/server").expect("env var should resolve"); assert_eq!(result, "/test/path/server"); std::env::remove_var("TEST_MCP_HOME"); } #[test] fn test_expand_env_vars_no_vars() { - let result = expand_env_vars("plain-string").unwrap(); + let result = expand_env_vars("plain-string").expect("plain string should resolve"); assert_eq!(result, "plain-string"); } diff --git a/crates/adapter-mcp/src/config/settings_port.rs b/crates/adapter-mcp/src/config/settings_port.rs index 12206382..5e6e3367 100644 --- a/crates/adapter-mcp/src/config/settings_port.rs +++ b/crates/adapter-mcp/src/config/settings_port.rs @@ -1,56 +1,5 @@ //! # MCP Settings 接口层 //! -//! 定义 `McpSettingsStore` trait 和审批数据 DTO。 -//! 这是纯接口层,不含任何 IO 实现——具体实现由 runtime 在 bootstrap 时注入。 +//! 审批 DTO 与持久化端口已下沉到 `astrcode-core`,adapter-mcp 仅做重导出。 -use serde::{Deserialize, Serialize}; - -/// MCP 审批数据——记录单个服务器的审批状态。 -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct McpApprovalData { - /// 服务器签名(用于唯一标识,如 command:args 或 URL)。 - pub server_signature: String, - /// 审批状态。 - pub status: McpApprovalStatus, - /// 审批时间(ISO 8601 格式)。 - #[serde(default, skip_serializing_if = "Option::is_none")] - pub approved_at: Option, - /// 审批来源(用户标识或 "auto")。 - #[serde(default, skip_serializing_if = "Option::is_none")] - pub approved_by: Option, -} - -/// MCP 服务器审批状态。 -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum McpApprovalStatus { - /// 等待用户审批。 - Pending, - /// 用户已批准。 - Approved, - /// 用户已拒绝。 - Rejected, -} - -/// MCP settings 持久化接口。 -/// -/// adapter-mcp 通过此 trait 读写审批数据, -/// 不直接读写 settings 文件。 -/// 具体实现由 runtime 在 bootstrap 时注入。 -pub trait McpSettingsStore: Send + Sync { - /// 加载指定项目的所有审批数据。 - fn load_approvals( - &self, - project_path: &str, - ) -> std::result::Result, String>; - - /// 保存审批数据。 - fn save_approval( - &self, - project_path: &str, - data: &McpApprovalData, - ) -> std::result::Result<(), String>; - - /// 清理指定项目的所有审批记录。 - fn clear_approvals(&self, project_path: &str) -> std::result::Result<(), String>; -} +pub use astrcode_core::{McpApprovalData, McpApprovalStatus, McpSettingsStore}; diff --git a/crates/adapter-mcp/src/manager/hot_reload.rs b/crates/adapter-mcp/src/manager/hot_reload.rs index b87137dc..c3f2fecb 100644 --- a/crates/adapter-mcp/src/manager/hot_reload.rs +++ b/crates/adapter-mcp/src/manager/hot_reload.rs @@ -158,7 +158,7 @@ mod tests { fn test_resolve_mcp_json_path() { let dir = std::env::temp_dir().join("mcp_hot_reload_test_resolve"); let _ = fs::remove_dir_all(&dir); - fs::create_dir_all(&dir).unwrap(); + fs::create_dir_all(&dir).expect("temp dir should be created"); let path = resolve_mcp_json_path(&dir); assert_eq!(path, dir.join(".mcp.json")); @@ -170,8 +170,8 @@ mod tests { fn test_resolve_existing_mcp_json() { let dir = std::env::temp_dir().join("mcp_hot_reload_test_existing"); let _ = fs::remove_dir_all(&dir); - fs::create_dir_all(&dir).unwrap(); - fs::write(dir.join(".mcp.json"), "{}").unwrap(); + fs::create_dir_all(&dir).expect("temp dir should be created"); + fs::write(dir.join(".mcp.json"), "{}").expect("write .mcp.json"); let path = resolve_mcp_json_path(&dir); assert!(path.exists()); diff --git a/crates/adapter-mcp/src/manager/reconnect.rs b/crates/adapter-mcp/src/manager/reconnect.rs index 7a1a0632..7bc71cff 100644 --- a/crates/adapter-mcp/src/manager/reconnect.rs +++ b/crates/adapter-mcp/src/manager/reconnect.rs @@ -6,7 +6,7 @@ use std::{ collections::HashMap, - sync::{Arc, Mutex as StdMutex}, + sync::{Arc, Mutex as StdMutex, MutexGuard as StdMutexGuard}, }; use log::{info, warn}; @@ -51,7 +51,7 @@ impl McpReconnectManager { // 已有活跃重连任务则跳过 { - let tasks = self.tasks.lock().unwrap(); + let tasks = self.tasks_guard(); if let Some(handle) = tasks.get(&server_name) { if !handle.is_finished() { warn!( @@ -74,7 +74,7 @@ impl McpReconnectManager { )); { - let mut tasks = self.tasks.lock().unwrap(); + let mut tasks = self.tasks_guard(); // 清理已完成的旧任务 tasks.retain(|_, h| !h.is_finished()); tasks.insert(name_clone, handle); @@ -83,7 +83,7 @@ impl McpReconnectManager { /// 取消指定服务器的重连任务。 pub fn cancel_reconnect(&self, server_name: &str) { - let mut tasks = self.tasks.lock().unwrap(); + let mut tasks = self.tasks_guard(); if let Some(handle) = tasks.remove(server_name) { handle.abort(); info!("MCP reconnect task cancelled for '{}'", server_name); @@ -92,7 +92,7 @@ impl McpReconnectManager { /// 取消所有重连任务。 pub fn cancel_all(&self) { - let mut tasks = self.tasks.lock().unwrap(); + let mut tasks = self.tasks_guard(); let count = tasks.len(); for (_, handle) in tasks.drain() { handle.abort(); @@ -105,7 +105,7 @@ impl McpReconnectManager { /// 指定服务器是否有活跃的重连任务。 #[allow(dead_code)] pub fn is_reconnecting(&self, server_name: &str) -> bool { - let tasks = self.tasks.lock().unwrap(); + let tasks = self.tasks_guard(); tasks .get(server_name) .map(|h| !h.is_finished()) @@ -115,9 +115,15 @@ impl McpReconnectManager { /// 清理已完成的重连任务。 #[allow(dead_code)] pub fn cleanup_finished(&self) { - let mut tasks = self.tasks.lock().unwrap(); + let mut tasks = self.tasks_guard(); tasks.retain(|_, h| !h.is_finished()); } + + fn tasks_guard(&self) -> StdMutexGuard<'_, HashMap>> { + self.tasks + .lock() + .expect("MCP reconnect task registry lock should not be poisoned") + } } /// 重连循环。 diff --git a/crates/adapter-mcp/src/protocol/client.rs b/crates/adapter-mcp/src/protocol/client.rs index f27d7442..acc4a36c 100644 --- a/crates/adapter-mcp/src/protocol/client.rs +++ b/crates/adapter-mcp/src/protocol/client.rs @@ -408,14 +408,19 @@ mod tests { async fn setup_connected_client() -> McpClient { let (mock_transport, _) = create_connected_mock().await; - McpClient::connect(mock_transport).await.unwrap() + McpClient::connect(mock_transport) + .await + .expect("client connect should succeed") } #[tokio::test] async fn test_handshake_success() { let client = setup_connected_client().await; assert!(client.server_info().is_some()); - assert_eq!(client.server_info().unwrap().name, "test-server"); + assert_eq!( + client.server_info().expect("server_info should exist").name, + "test-server" + ); assert!(client.capabilities().is_some()); assert_eq!(client.instructions(), Some("Test server instructions")); } @@ -447,9 +452,14 @@ mod tests { // 添加 tools/list 响应到 transport // 验证 capabilities 中 tools 存在 - let caps = client.capabilities().unwrap(); + let caps = client.capabilities().expect("capabilities should exist"); assert!(caps.tools.is_some()); - assert!(caps.tools.as_ref().unwrap().list_changed); + assert!( + caps.tools + .as_ref() + .expect("tools capability should exist") + .list_changed + ); } #[tokio::test] diff --git a/crates/adapter-mcp/src/transport/http.rs b/crates/adapter-mcp/src/transport/http.rs index bd66299a..d2cd1cff 100644 --- a/crates/adapter-mcp/src/transport/http.rs +++ b/crates/adapter-mcp/src/transport/http.rs @@ -12,7 +12,7 @@ use astrcode_core::{AstrError, Result}; use async_trait::async_trait; use futures_util::StreamExt; use log::{debug, info, warn}; -use reqwest::Client; +use reqwest::{Client, StatusCode}; use super::McpTransport; use crate::protocol::types::{JsonRpcNotification, JsonRpcRequest, JsonRpcResponse}; @@ -129,6 +129,7 @@ impl McpTransport for StreamableHttpTransport { let mut req_builder = client .post(&self.url) .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") .body(body); for (key, value) in &self.headers { @@ -142,11 +143,19 @@ impl McpTransport for StreamableHttpTransport { .await .map_err(|e| AstrError::http("MCP HTTP notification", e))?; - if !response.status().is_success() { - warn!( - "MCP HTTP notification returned {} (notifications are fire-and-forget)", - response.status() - ); + let status = response.status(); + if !status.is_success() { + if should_downgrade_notification_status(¬ification, status) { + debug!( + "MCP HTTP notification '{}' returned {} and was ignored for compatibility", + notification.method, status + ); + } else { + warn!( + "MCP HTTP notification '{}' returned {} (notifications are fire-and-forget)", + notification.method, status + ); + } } Ok(()) @@ -234,6 +243,13 @@ fn parse_sse_event_jsonrpc(event: &str) -> Result> { Ok(Some(response)) } +fn should_downgrade_notification_status( + notification: &JsonRpcNotification, + status: StatusCode, +) -> bool { + notification.method == "notifications/initialized" && status == StatusCode::BAD_REQUEST +} + #[cfg(test)] mod tests { use super::*; @@ -251,15 +267,15 @@ mod tests { #[tokio::test] async fn test_start_sets_active() { let mut transport = StreamableHttpTransport::new("http://localhost:8080/mcp", Vec::new()); - transport.start().await.unwrap(); + transport.start().await.expect("start should succeed"); assert!(transport.is_active()); } #[tokio::test] async fn test_close_deactivates() { let mut transport = StreamableHttpTransport::new("http://localhost:8080/mcp", Vec::new()); - transport.start().await.unwrap(); - transport.close().await.unwrap(); + transport.start().await.expect("start should succeed"); + transport.close().await.expect("close should succeed"); assert!(!transport.is_active()); } @@ -284,7 +300,7 @@ mod tests { let event = "id:1\nevent:message\ndata:{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"ok\":true}}"; let response = parse_sse_event_jsonrpc(event) - .unwrap() + .expect("parsing should succeed") .expect("json-rpc response"); assert_eq!(response.id, Some(serde_json::json!(1))); } @@ -296,4 +312,22 @@ mod tests { assert!(event.contains("data:{\"jsonrpc\":\"2.0\"}")); assert_eq!(buffer, "partial"); } + + #[test] + fn initialized_bad_request_is_downgraded() { + let notification = JsonRpcNotification::new("notifications/initialized"); + assert!(should_downgrade_notification_status( + ¬ification, + StatusCode::BAD_REQUEST + )); + } + + #[test] + fn other_notification_errors_still_warn() { + let notification = JsonRpcNotification::new("notifications/cancelled"); + assert!(!should_downgrade_notification_status( + ¬ification, + StatusCode::BAD_REQUEST + )); + } } diff --git a/crates/adapter-mcp/src/transport/sse.rs b/crates/adapter-mcp/src/transport/sse.rs index e8eb4b18..44e84b56 100644 --- a/crates/adapter-mcp/src/transport/sse.rs +++ b/crates/adapter-mcp/src/transport/sse.rs @@ -180,15 +180,15 @@ mod tests { #[tokio::test] async fn test_start_sets_active() { let mut transport = SseTransport::new("http://localhost:8080/sse", Vec::new()); - transport.start().await.unwrap(); + transport.start().await.expect("start should succeed"); assert!(transport.is_active()); } #[tokio::test] async fn test_close_deactivates() { let mut transport = SseTransport::new("http://localhost:8080/sse", Vec::new()); - transport.start().await.unwrap(); - transport.close().await.unwrap(); + transport.start().await.expect("start should succeed"); + transport.close().await.expect("close should succeed"); assert!(!transport.is_active()); } diff --git a/crates/adapter-mcp/src/transport/stdio.rs b/crates/adapter-mcp/src/transport/stdio.rs index 8a6f6bfa..de49a688 100644 --- a/crates/adapter-mcp/src/transport/stdio.rs +++ b/crates/adapter-mcp/src/transport/stdio.rs @@ -51,6 +51,27 @@ impl StdioTransport { env, } } + + #[cfg(unix)] + fn send_unix_signal(child: &Child, signal: i32, signal_name: &str) { + let Some(pid) = child.id() else { + info!( + "skip sending {} to MCP server because the process id is unavailable", + signal_name + ); + return; + }; + + // libc::kill 是 Unix 发送进程信号的底层系统调用,这里只在 unix 平台使用。 + let result = unsafe { libc::kill(pid as i32, signal) }; + if result != 0 { + let error = std::io::Error::last_os_error(); + info!( + "failed to send {} to MCP server pid {}: {}", + signal_name, pid, error + ); + } + } } #[async_trait] @@ -184,29 +205,19 @@ impl McpTransport for StdioTransport { use tokio::time::{Duration, timeout}; // SIGINT - unsafe { - libc::kill(child.id() as i32, libc::SIGINT); - } + Self::send_unix_signal(&child, libc::SIGINT, "SIGINT"); - match timeout(Duration::from_secs(5), child.wait()).await { - Ok(Ok(_)) => { - info!("MCP server exited gracefully after SIGINT"); - return Ok(()); - }, - _ => {}, + if let Ok(Ok(_)) = timeout(Duration::from_secs(5), child.wait()).await { + info!("MCP server exited gracefully after SIGINT"); + return Ok(()); } // SIGTERM - unsafe { - libc::kill(child.id() as i32, libc::SIGTERM); - } + Self::send_unix_signal(&child, libc::SIGTERM, "SIGTERM"); - match timeout(Duration::from_secs(5), child.wait()).await { - Ok(Ok(_)) => { - info!("MCP server exited after SIGTERM"); - return Ok(()); - }, - _ => {}, + if let Ok(Ok(_)) = timeout(Duration::from_secs(5), child.wait()).await { + info!("MCP server exited after SIGTERM"); + return Ok(()); } // SIGKILL diff --git a/crates/adapter-prompt/src/composer.rs b/crates/adapter-prompt/src/composer.rs index e3b36407..41befa93 100644 --- a/crates/adapter-prompt/src/composer.rs +++ b/crates/adapter-prompt/src/composer.rs @@ -31,7 +31,7 @@ use std::{ }; use anyhow::{Result, anyhow}; -use astrcode_core::{LlmMessage, UserMessageOrigin}; +use astrcode_core::{LlmMessage, PromptCacheHints, UserMessageOrigin}; use super::{ BlockCondition, BlockContent, BlockKind, BlockSpec, PromptBlock, PromptContext, @@ -109,6 +109,7 @@ pub struct PromptComposer { pub struct PromptBuildOutput { pub plan: PromptPlan, pub diagnostics: PromptDiagnostics, + pub cache_hints: PromptCacheHints, } /// 贡献者缓存条目。 @@ -218,7 +219,11 @@ impl PromptComposer { let candidates = self.filter_duplicate_block_ids(candidates, &mut diagnostics)?; self.resolve_candidates(candidates, ctx, &mut plan, &mut diagnostics)?; - Ok(PromptBuildOutput { plan, diagnostics }) + Ok(PromptBuildOutput { + plan, + diagnostics, + cache_hints: PromptCacheHints::default(), + }) } /// 收集单个 contributor 的贡献,带缓存逻辑。 diff --git a/crates/adapter-prompt/src/contributors/capability_prompt.rs b/crates/adapter-prompt/src/contributors/capability_prompt.rs index 729559f5..bbfdb8ea 100644 --- a/crates/adapter-prompt/src/contributors/capability_prompt.rs +++ b/crates/adapter-prompt/src/contributors/capability_prompt.rs @@ -161,7 +161,9 @@ fn build_tool_summary_block( ) -> BlockSpec { let mut content = String::from( "Use the narrowest tool that can answer the request. Prefer read-only inspection before \ - mutation. All paths must stay inside the working directory.", + mutation. All paths must stay inside the working directory. When a tool returns a \ + persisted-result reference for large output, keep the reference in context and inspect \ + it with `readFile` chunks instead of asking the tool to inline the whole result again.", ); if !tool_guides.is_empty() { @@ -445,6 +447,7 @@ mod tests { assert!(read_index < write_index); assert!(write_index < external_tool_index); assert!(content.contains("When To Use `tool_search`")); + assert!(content.contains("persisted-result reference")); assert!( contribution .blocks diff --git a/crates/adapter-prompt/src/contributors/identity.rs b/crates/adapter-prompt/src/contributors/identity.rs index eab22788..529debbb 100644 --- a/crates/adapter-prompt/src/contributors/identity.rs +++ b/crates/adapter-prompt/src/contributors/identity.rs @@ -20,12 +20,12 @@ use crate::{BlockKind, BlockSpec, PromptContext, PromptContribution, PromptContr /// 优先读取 `~/.astrcode/IDENTITY.md`,不存在时使用默认描述。 pub struct IdentityContributor; -const DEFAULT_IDENTITY: &str = "\ -You are AstrCode, a local AI coding agent running on the user's machine. You help with coding \ - tasks, file editing, and terminal commands. Work like a proactive \ - engineering partner: build context before acting, choose the \ - narrowest effective tool, and carry tasks through implementation \ - and verification when feasible."; +const DEFAULT_IDENTITY: &str = + "\ +You are AstrCode, a genius-level engineer and team leader. Code is your expression — correct, \ + maintainable. Thoroughly understand before precisely executing; pursue perfect and elegant \ + best practices, root-causing problems rather than patching symptoms. In complex tasks, \ + orchestrate agent-tool collaboration to coordinate resources and drive projects to success."; /// Returns the path to the user-wide IDENTITY.md file. pub fn user_identity_md_path() -> Option { diff --git a/crates/adapter-prompt/src/contributors/mod.rs b/crates/adapter-prompt/src/contributors/mod.rs index 19ab41b7..a5e81af8 100644 --- a/crates/adapter-prompt/src/contributors/mod.rs +++ b/crates/adapter-prompt/src/contributors/mod.rs @@ -7,6 +7,7 @@ //! - [`AgentsMdContributor`]:用户和项目级 AGENTS.md 规则 //! - [`CapabilityPromptContributor`]:工具使用指南 //! - [`PromptDeclarationContributor`]:外部注入的 PromptDeclaration system blocks +//! - [`ResponseStyleContributor`]:用户可见输出风格与收尾格式约束 //! - [`SkillSummaryContributor`]:Skill 索引摘要 //! - [`WorkflowExamplesContributor`]:Few-shot 示例对话 @@ -16,6 +17,7 @@ pub mod capability_prompt; pub mod environment; pub mod identity; pub mod prompt_declaration; +pub mod response_style; pub mod shared; pub mod skill_summary; pub mod workflow_examples; @@ -26,6 +28,7 @@ pub use capability_prompt::CapabilityPromptContributor; pub use environment::EnvironmentContributor; pub use identity::{IdentityContributor, load_identity_md, user_identity_md_path}; pub use prompt_declaration::PromptDeclarationContributor; +pub use response_style::ResponseStyleContributor; pub use shared::{cache_marker_for_path, user_astrcode_file_path}; pub use skill_summary::SkillSummaryContributor; pub use workflow_examples::WorkflowExamplesContributor; diff --git a/crates/adapter-prompt/src/contributors/response_style.rs b/crates/adapter-prompt/src/contributors/response_style.rs new file mode 100644 index 00000000..8400d168 --- /dev/null +++ b/crates/adapter-prompt/src/contributors/response_style.rs @@ -0,0 +1,83 @@ +//! 响应风格贡献者。 +//! +//! 为模型补充稳定的用户沟通风格与收尾格式约束, +//! 避免输出退化成工具日志或未验证的结论。 + +use async_trait::async_trait; + +use crate::{BlockKind, BlockSpec, PromptContext, PromptContribution, PromptContributor}; + +pub struct ResponseStyleContributor; + +const RESPONSE_STYLE_GUIDANCE: &str = + "\ +Write for the user, not for a console log. Lead with the answer, action, or next step when it is \ + clear.\n\nWhen the task needs tools, multiple steps, or noticeable wait time:\n- Before the \ + first tool call, briefly state what you are going to do.\n- Give short progress updates when \ + you confirm something important, change direction, or make meaningful progress after a \ + stretch of silence.\n- Use complete sentences and enough context that the user can resume \ + cold.\n\nDo not present a guess, lead, or partial result as if it were confirmed. \ + Distinguish a suspicion from a supported finding, and distinguish both from the final \ + conclusion.\n\nPrefer clear prose over running debug-log narration. Use light structure only \ + when it improves readability.\n\nWhen closing out implementation work, briefly cover:\n- \ + what changed,\n- why this shape is correct,\n- what you verified,\n- any remaining risk or \ + next step if verification was partial."; + +#[async_trait] +impl PromptContributor for ResponseStyleContributor { + fn contributor_id(&self) -> &'static str { + "response-style" + } + + fn cache_version(&self) -> u64 { + 1 + } + + async fn contribute(&self, _ctx: &PromptContext) -> PromptContribution { + PromptContribution { + blocks: vec![ + BlockSpec::system_text( + "response-style", + BlockKind::SkillGuide, + "Response Style", + RESPONSE_STYLE_GUIDANCE, + ) + .with_category("communication") + .with_tag("source:builtin"), + ], + ..PromptContribution::default() + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::BlockContent; + + #[tokio::test] + async fn renders_response_style_guidance_block() { + let contribution = ResponseStyleContributor + .contribute(&PromptContext { + working_dir: "/workspace/demo".to_string(), + tool_names: Vec::new(), + capability_specs: Vec::new(), + prompt_declarations: Vec::new(), + agent_profiles: Vec::new(), + skills: Vec::new(), + step_index: 0, + turn_index: 0, + vars: Default::default(), + }) + .await; + + assert_eq!(contribution.blocks.len(), 1); + assert_eq!(contribution.blocks[0].kind, BlockKind::SkillGuide); + let BlockContent::Text(content) = &contribution.blocks[0].content else { + panic!("response style should render as text"); + }; + assert!(content.contains("Before the first tool call")); + assert!(content.contains("Do not present a guess")); + assert!(content.contains("what changed")); + } +} diff --git a/crates/adapter-prompt/src/contributors/workflow_examples.rs b/crates/adapter-prompt/src/contributors/workflow_examples.rs index 71198035..ab21673e 100644 --- a/crates/adapter-prompt/src/contributors/workflow_examples.rs +++ b/crates/adapter-prompt/src/contributors/workflow_examples.rs @@ -3,10 +3,7 @@ //! 提供 few-shot 示例对话,教导模型"先收集上下文再修改代码"的行为模式。 //! 仅在第一步(step_index == 0)时生效,以 prepend 方式插入到对话消息中。 //! -//! 同时提供子 Agent 协作决策指导:当父 Agent 收到子 Agent 交付结果后, -//! 指导模型如何决定关闭或保留子 Agent。 - -use astrcode_core::config::DEFAULT_MAX_SUBRUN_DEPTH; +//! 子 Agent 协作指导现在由上游治理声明注入;本 contributor 只保留 few-shot 示例。 use async_trait::async_trait; use crate::{ @@ -16,8 +13,6 @@ use crate::{ pub struct WorkflowExamplesContributor; -const AGENT_COLLABORATION_TOOLS: &[&str] = &["spawn", "send", "observe", "close"]; - #[async_trait] impl PromptContributor for WorkflowExamplesContributor { fn contributor_id(&self) -> &'static str { @@ -84,69 +79,6 @@ impl PromptContributor for WorkflowExamplesContributor { ); } - if has_agent_collaboration_tools(ctx) { - let max_depth = collaboration_depth_limit(ctx).unwrap_or(DEFAULT_MAX_SUBRUN_DEPTH); - let max_spawn_per_turn = collaboration_spawn_limit(ctx).unwrap_or(3); - blocks.push( - BlockSpec::system_text( - "child-collaboration-guidance", - BlockKind::CollaborationGuide, - "Child Agent Collaboration Guide", - format!( - "Use the child-agent tools as one decision protocol.\n\nKeep `agentId` \ - exact. Copy it byte-for-byte in later `send`, `observe`, and `close` \ - calls. Never renumber it, never zero-pad it, and never invent `agent-01` \ - when the tool result says `agent-1`.\n\nDefault protocol:\n1. `spawn` \ - only for a new isolated responsibility with real parallel or \ - context-isolation value.\n2. `send` when the same child should take one \ - concrete next step on the same responsibility branch.\n3. `observe` only \ - when the next decision depends on current child state.\n4. `close` when \ - the branch is done or no longer useful.\n\nDelegation modes:\n- Fresh \ - child: use `spawn` for a new responsibility branch. Give the child a \ - full briefing: task scope, boundaries, expected deliverable, and any \ - important focus or exclusion. Do not treat a short nudge like 'take a \ - look' as a sufficient fresh-child brief.\n- Resumed child: use `send` \ - when the same child should continue the same responsibility branch. Send \ - one concrete delta instruction or clarification, not a full re-briefing \ - of the original task.\n- Restricted child: when you narrow a child with \ - `capabilityGrant`, assign only work that fits that reduced capability \ - surface. If the next step needs tools the restricted child does not \ - have, choose a different child or do the work locally instead of forcing \ - a mismatch.\n\n`Idle` is normal and reusable. Do not respawn just \ - because a child finished one turn. Reuse an idle child with \ - `send(agentId, message)` when the responsibility stays the same. If you \ - are unsure whether the child is still running, idle, or terminated, call \ - `observe(agentId)` once and act on the result.\n\nSpawn sparingly. The \ - runtime enforces a maximum child depth of {max_depth} and at most \ - {max_spawn_per_turn} new children per turn. Start with one child unless \ - there are clearly separate workstreams. Do not blanket-spawn agents just \ - to explore a repo broadly.\n\nAvoid waste:\n- Do not loop on `observe` \ - with no decision attached.\n- If a child is still running and you are \ - simply waiting, use your current shell tool to sleep briefly instead of \ - spending another tool call on `observe`.\n- Do not stack speculative \ - `send` calls.\n- Do not spawn a new child when an existing idle child \ - already owns the responsibility.\n\nIf a delivery satisfies the request, \ - `close` the branch. If the same child should continue, `send` one \ - precise follow-up. If you see the same `deliveryId` again after \ - recovery, treat it as the same delivery, not a new task.\n\nWhen you are \ - the child on a delegated task, use upstream `send(kind + payload)` to \ - deliver a formal message to your direct parent. Report `progress`, \ - `completed`, `failed`, or `close_request` explicitly. Do not wait for \ - the parent to infer state from raw intermediate steps, and do not end \ - with an open loop like '继续观察中' unless you are also sending a \ - non-terminal `progress` delivery that keeps the branch alive.\n\nWhen \ - you are the parent and receive a child delivery, treat it as a decision \ - point. Do not leave it hanging and do not immediately re-observe the \ - same child unless the state is unclear. Decide immediately whether the \ - result is complete enough to `close` the branch, or whether the same \ - child should continue with one concrete `send` follow-up that names the \ - exact next step." - ), - ) - .with_priority(600), - ); - } - PromptContribution { blocks, ..PromptContribution::default() @@ -154,30 +86,10 @@ impl PromptContributor for WorkflowExamplesContributor { } } -fn has_agent_collaboration_tools(ctx: &PromptContext) -> bool { - ctx.tool_names.iter().any(|tool_name| { - AGENT_COLLABORATION_TOOLS - .iter() - .any(|candidate| tool_name == candidate) - }) -} - fn should_add_tool_search_example(ctx: &PromptContext) -> bool { has_tool_search(ctx) && has_external_tools(ctx) } -fn collaboration_depth_limit(ctx: &PromptContext) -> Option { - ctx.vars - .get("agent.max_subrun_depth") - .and_then(|value| value.parse::().ok()) -} - -fn collaboration_spawn_limit(ctx: &PromptContext) -> Option { - ctx.vars - .get("agent.max_spawn_per_turn") - .and_then(|value| value.parse::().ok()) -} - fn has_tool_search(ctx: &PromptContext) -> bool { ctx.tool_names .iter() @@ -297,7 +209,7 @@ mod tests { } #[tokio::test] - async fn adds_collaboration_guidance_only_when_agent_tools_are_available() { + async fn does_not_add_collaboration_guidance_directly() { let _guard = TestEnvGuard::new(); let composer = PromptComposer::with_options(PromptComposerOptions { validation_level: ValidationLevel::Strict, @@ -322,84 +234,12 @@ mod tests { let output = composer.build(&ctx).await.expect("build should succeed"); - let collaboration_block = output - .plan - .system_blocks - .iter() - .find(|block| block.id == "child-collaboration-guidance") - .expect("collaboration guidance block should exist"); - assert!(collaboration_block.content.contains("Keep `agentId` exact")); - assert!(collaboration_block.content.contains(&format!( - "maximum child depth of {DEFAULT_MAX_SUBRUN_DEPTH}" - ))); - assert!(collaboration_block.content.contains("Default protocol:")); - assert!(collaboration_block.content.contains("Delegation modes:")); - assert!(collaboration_block.content.contains("Fresh child:")); - assert!(collaboration_block.content.contains("Resumed child:")); - assert!(collaboration_block.content.contains("Restricted child:")); - assert!( - collaboration_block - .content - .contains("same `deliveryId` again") - ); - assert!( - collaboration_block - .content - .contains("`Idle` is normal and reusable") - ); - assert!( - collaboration_block - .content - .contains("Do not loop on `observe`") - ); - assert!( - collaboration_block - .content - .contains("use upstream `send(kind + payload)`") - ); - assert!(collaboration_block.content.contains("`close_request`")); - assert!(collaboration_block.content.contains("exact next step")); - } - - #[tokio::test] - async fn collaboration_guidance_uses_configured_depth_limit() { - let _guard = TestEnvGuard::new(); - let composer = PromptComposer::with_options(PromptComposerOptions { - validation_level: ValidationLevel::Strict, - ..PromptComposerOptions::default() - }); - - let mut vars = std::collections::HashMap::new(); - vars.insert("agent.max_subrun_depth".to_string(), "5".to_string()); - let ctx = PromptContext { - working_dir: "/workspace/demo".to_string(), - tool_names: vec![ - "spawn".to_string(), - "send".to_string(), - "observe".to_string(), - "close".to_string(), - ], - capability_specs: Vec::new(), - prompt_declarations: Vec::new(), - agent_profiles: Vec::new(), - skills: Vec::new(), - step_index: 0, - turn_index: 0, - vars, - }; - - let output = composer.build(&ctx).await.expect("build should succeed"); - - let collaboration_block = output - .plan - .system_blocks - .iter() - .find(|block| block.id == "child-collaboration-guidance") - .expect("collaboration guidance block should exist"); assert!( - collaboration_block - .content - .contains("maximum child depth of 5") + output + .plan + .system_blocks + .iter() + .all(|block| block.id != "child-collaboration-guidance") ); } } diff --git a/crates/adapter-prompt/src/core_port.rs b/crates/adapter-prompt/src/core_port.rs index 53f71867..60888ba2 100644 --- a/crates/adapter-prompt/src/core_port.rs +++ b/crates/adapter-prompt/src/core_port.rs @@ -4,14 +4,15 @@ //! 本模块将其适配到 `LayeredPromptBuilder` 的完整 prompt 构建能力上。 use astrcode_core::{ - Result, SystemPromptBlock, - ports::{PromptBuildOutput, PromptBuildRequest, PromptProvider}, + Result, SystemPromptBlock, SystemPromptLayer, + ports::{PromptBuildCacheMetrics, PromptBuildOutput, PromptBuildRequest, PromptProvider}, }; use async_trait::async_trait; use serde_json::Value; use crate::{ PromptAgentProfileSummary, PromptContext, PromptDeclaration, PromptSkillSummary, + diagnostics::DiagnosticReason, layered_builder::{LayeredPromptBuilder, default_layered_prompt_builder}, }; @@ -78,29 +79,21 @@ impl PromptProvider for ComposerPromptProvider { .map_err(|e| astrcode_core::AstrError::Internal(e.to_string()))?; let system_prompt = output.plan.render_system().unwrap_or_default(); - - // 将 ordered system blocks 转为 core 的 SystemPromptBlock 格式 - let system_prompt_blocks: Vec = output - .plan - .ordered_system_blocks() - .into_iter() - .map(|block| SystemPromptBlock { - title: block.title.clone(), - content: block.content.clone(), - cache_boundary: false, - layer: block.layer, - }) - .collect(); + let prompt_cache_hints = output.cache_hints.clone(); + let system_prompt_blocks = build_system_prompt_blocks(&output.plan); Ok(PromptBuildOutput { system_prompt, system_prompt_blocks, + prompt_cache_hints: prompt_cache_hints.clone(), + cache_metrics: summarize_prompt_cache_metrics(&output), metadata: serde_json::json!({ "extra_tools_count": output.plan.extra_tools.len(), "diagnostics_count": output.diagnostics.items.len(), "profile": request.profile, "step_index": request.step_index, "turn_index": request.turn_index, + "promptCacheHints": prompt_cache_hints, }), }) } @@ -161,6 +154,52 @@ fn build_prompt_vars(request: &PromptBuildRequest) -> std::collections::HashMap< vars } +fn summarize_prompt_cache_metrics(output: &crate::PromptBuildOutput) -> PromptBuildCacheMetrics { + let mut metrics = PromptBuildCacheMetrics::default(); + for diagnostic in &output.diagnostics.items { + match &diagnostic.reason { + DiagnosticReason::CacheReuseHit { .. } => { + metrics.reuse_hits = metrics.reuse_hits.saturating_add(1); + }, + DiagnosticReason::CacheReuseMiss { .. } => { + metrics.reuse_misses = metrics.reuse_misses.saturating_add(1); + }, + _ => {}, + } + } + metrics.unchanged_layers = output.cache_hints.unchanged_layers.clone(); + metrics +} + +fn build_system_prompt_blocks(plan: &crate::PromptPlan) -> Vec { + let ordered = plan.ordered_system_blocks(); + let mut last_cacheable_index = std::collections::HashMap::::new(); + for (index, block) in ordered.iter().enumerate() { + if cacheable_prompt_layer(block.layer) { + last_cacheable_index.insert(block.layer, index); + } + } + ordered + .into_iter() + .enumerate() + .map(|(index, block)| SystemPromptBlock { + title: block.title.clone(), + content: block.content.clone(), + cache_boundary: last_cacheable_index + .get(&block.layer) + .is_some_and(|candidate| *candidate == index), + layer: block.layer, + }) + .collect() +} + +fn cacheable_prompt_layer(layer: SystemPromptLayer) -> bool { + matches!( + layer, + SystemPromptLayer::Stable | SystemPromptLayer::SemiStable | SystemPromptLayer::Inherited + ) +} + fn insert_json_string( vars: &mut std::collections::HashMap, key: &str, diff --git a/crates/adapter-prompt/src/layered_builder.rs b/crates/adapter-prompt/src/layered_builder.rs index 8c6d4b12..d75962d2 100644 --- a/crates/adapter-prompt/src/layered_builder.rs +++ b/crates/adapter-prompt/src/layered_builder.rs @@ -4,12 +4,14 @@ //! `PromptPlan.system_blocks` 的层级元数据中,供 Anthropic prompt caching 使用。 use std::{ - collections::HashMap, + collections::{HashMap, hash_map::DefaultHasher}, + hash::{Hash, Hasher}, sync::{Arc, Mutex}, time::{Duration, Instant}, }; use anyhow::Result; +use astrcode_core::{PromptCacheHints, PromptLayerFingerprints}; use super::{ PromptBuildOutput, PromptComposer, PromptComposerOptions, PromptContext, PromptContributor, @@ -79,7 +81,7 @@ struct LayerCache { #[derive(Debug, Clone, PartialEq, Eq)] enum CacheLookupResult { - Hit(PromptBuildOutput), + Hit(Box), Miss { invalidation_reason: String }, } @@ -130,6 +132,7 @@ impl LayeredPromptBuilder { pub async fn build(&self, ctx: &PromptContext) -> Result { let mut diagnostics = PromptDiagnostics::default(); let mut plan = PromptPlan::default(); + let mut cache_hints = PromptCacheHints::default(); for (layer_type, contributors) in [ (LayerType::Stable, &self.stable_contributors), @@ -138,11 +141,16 @@ impl LayeredPromptBuilder { (LayerType::Dynamic, &self.dynamic_contributors), ] { let output = self.build_layer(contributors, ctx, layer_type).await?; + merge_layer_cache_hints(&mut cache_hints, layer_type, &output.cache_hints); diagnostics.items.extend(output.diagnostics.items); plan.extend_with_layer(output.plan, layer_type.prompt_layer()); } - Ok(PromptBuildOutput { plan, diagnostics }) + Ok(PromptBuildOutput { + plan, + diagnostics, + cache_hints, + }) } async fn build_layer( @@ -155,6 +163,7 @@ impl LayeredPromptBuilder { return Ok(PromptBuildOutput { plan: PromptPlan::default(), diagnostics: PromptDiagnostics::default(), + cache_hints: PromptCacheHints::default(), }); } @@ -165,7 +174,9 @@ impl LayeredPromptBuilder { let mut combined = PromptBuildOutput { plan: PromptPlan::default(), diagnostics: PromptDiagnostics::default(), + cache_hints: PromptCacheHints::default(), }; + let mut layer_unchanged = layer_type != LayerType::Dynamic; for contributor in contributors { let contributor_id = contributor.contributor_id(); @@ -183,11 +194,12 @@ impl LayeredPromptBuilder { .extend(output.diagnostics.items.clone()); combined .plan - .extend_with_layer(output.plan, layer_type.prompt_layer()); + .extend_with_layer(output.plan.clone(), layer_type.prompt_layer()); }, CacheLookupResult::Miss { invalidation_reason, } => { + layer_unchanged = false; combined.diagnostics.push_cache_reuse_miss( cache_key.clone(), Some(fingerprint.clone()), @@ -206,6 +218,18 @@ impl LayeredPromptBuilder { } } + if layer_unchanged { + combined + .cache_hints + .unchanged_layers + .push(layer_type.prompt_layer()); + } + set_layer_fingerprint( + &mut combined.cache_hints.layer_fingerprints, + layer_type, + fingerprint_rendered_layer(layer_type, &combined.plan), + ); + Ok(combined) } @@ -245,7 +269,7 @@ impl LayeredPromptBuilder { }; if entry.fingerprint == fingerprint && !is_cache_expired(entry, &self.options, layer_type) { - CacheLookupResult::Hit(entry.output.clone()) + CacheLookupResult::Hit(Box::new(entry.output.clone())) } else if is_cache_expired(entry, &self.options, layer_type) { CacheLookupResult::Miss { invalidation_reason: "ttl_expired".to_string(), @@ -331,6 +355,7 @@ pub fn default_layered_prompt_builder() -> LayeredPromptBuilder { .with_stable_layer(vec![ Arc::new(crate::contributors::IdentityContributor), Arc::new(crate::contributors::EnvironmentContributor), + Arc::new(crate::contributors::ResponseStyleContributor), ]) .with_semi_stable_layer(vec![ Arc::new(crate::contributors::AgentsMdContributor), @@ -366,6 +391,62 @@ fn compute_layer_fingerprint( .join("|") } +fn merge_layer_cache_hints( + target: &mut PromptCacheHints, + layer_type: LayerType, + source: &PromptCacheHints, +) { + set_layer_fingerprint( + &mut target.layer_fingerprints, + layer_type, + layer_fingerprint(source, layer_type).cloned(), + ); + if source + .unchanged_layers + .iter() + .any(|layer| *layer == layer_type.prompt_layer()) + { + target.unchanged_layers.push(layer_type.prompt_layer()); + } +} + +fn set_layer_fingerprint( + fingerprints: &mut PromptLayerFingerprints, + layer_type: LayerType, + fingerprint: Option, +) { + match layer_type { + LayerType::Stable => fingerprints.stable = fingerprint, + LayerType::SemiStable => fingerprints.semi_stable = fingerprint, + LayerType::Inherited => fingerprints.inherited = fingerprint, + LayerType::Dynamic => fingerprints.dynamic = fingerprint, + } +} + +fn layer_fingerprint(hints: &PromptCacheHints, layer_type: LayerType) -> Option<&String> { + match layer_type { + LayerType::Stable => hints.layer_fingerprints.stable.as_ref(), + LayerType::SemiStable => hints.layer_fingerprints.semi_stable.as_ref(), + LayerType::Inherited => hints.layer_fingerprints.inherited.as_ref(), + LayerType::Dynamic => hints.layer_fingerprints.dynamic.as_ref(), + } +} + +fn fingerprint_rendered_layer(layer_type: LayerType, plan: &PromptPlan) -> Option { + let mut hasher = DefaultHasher::new(); + let mut matched = false; + for block in plan.ordered_system_blocks() { + if block.layer != layer_type.prompt_layer() { + continue; + } + matched = true; + block.id.hash(&mut hasher); + block.title.hash(&mut hasher); + block.content.hash(&mut hasher); + } + matched.then(|| format!("{:x}", hasher.finish())) +} + fn is_cache_expired( entry: &LayerCacheEntry, options: &LayeredBuilderOptions, @@ -492,6 +573,7 @@ mod tests { output: PromptBuildOutput { plan: PromptPlan::default(), diagnostics: PromptDiagnostics::default(), + cache_hints: PromptCacheHints::default(), }, }; let options = LayeredBuilderOptions { diff --git a/crates/adapter-skills/src/lib.rs b/crates/adapter-skills/src/lib.rs index ae453a82..44e34523 100644 --- a/crates/adapter-skills/src/lib.rs +++ b/crates/adapter-skills/src/lib.rs @@ -6,7 +6,7 @@ mod skill_loader; mod skill_spec; pub use builtin_skills::load_builtin_skills; -pub use skill_catalog::{SkillCatalog, merge_skill_layers}; +pub use skill_catalog::{LayeredSkillCatalog, merge_skill_layers}; pub use skill_loader::{ SKILL_FILE_NAME, SKILL_TOOL_NAME, SkillFrontmatter, collect_asset_files, load_project_skills, load_user_skills, parse_skill_md, skill_roots_cache_marker, diff --git a/crates/adapter-skills/src/skill_catalog.rs b/crates/adapter-skills/src/skill_catalog.rs index ce9875ee..115e7463 100644 --- a/crates/adapter-skills/src/skill_catalog.rs +++ b/crates/adapter-skills/src/skill_catalog.rs @@ -22,9 +22,10 @@ use std::{ path::{Path, PathBuf}, - sync::{Arc, RwLock}, + sync::{Arc, RwLock, RwLockReadGuard, RwLockWriteGuard}, }; +use astrcode_core::SkillCatalog as SkillCatalogPort; use log::debug; use crate::{ @@ -40,7 +41,7 @@ use crate::{ /// Base skills 包含 builtin、plugin、mcp 来源的 skill, /// 在 runtime 装配时一次性构建。User 和 project skill 在每次解析时动态加载。 #[derive(Debug, Clone)] -pub struct SkillCatalog { +pub struct LayeredSkillCatalog { /// Base skills(builtin + plugin + mcp),按优先级排序。 /// 使用 RwLock 支持并发读取和原子替换。 base_skills: Arc>>, @@ -51,7 +52,7 @@ pub struct SkillCatalog { user_home_dir: Option, } -impl SkillCatalog { +impl LayeredSkillCatalog { /// 创建新的 SkillCatalog。 /// /// `base_skills` 应按优先级从低到高排序(builtin < mcp < plugin), @@ -84,13 +85,13 @@ impl SkillCatalog { /// 用于 runtime reload 场景,新的 base skills 会完全替换旧的。 /// 调用方应确保 `new_base_skills` 已按优先级排序。 pub fn replace_base_skills(&self, new_base_skills: Vec) { - let mut guard = self.base_skills.write().unwrap(); + let mut guard = self.write_base_skills(); *guard = normalize_base_skills(new_base_skills); } /// 获取当前 base skills 的快照。 pub fn base_skills(&self) -> Vec { - let guard = self.base_skills.read().unwrap(); + let guard = self.read_base_skills(); guard.clone() } @@ -105,11 +106,30 @@ impl SkillCatalog { let base = self.base_skills(); resolve_skills(&base, self.user_home_dir.as_deref(), working_dir) } + + fn read_base_skills(&self) -> RwLockReadGuard<'_, Vec> { + self.base_skills + .read() + .expect("skill catalog base_skills lock should not be poisoned") + } + + fn write_base_skills(&self) -> RwLockWriteGuard<'_, Vec> { + self.base_skills + .write() + .expect("skill catalog base_skills lock should not be poisoned") + } +} + +impl SkillCatalogPort for LayeredSkillCatalog { + fn resolve_for_working_dir(&self, working_dir: &str) -> Vec { + let base = self.base_skills(); + resolve_skills(&base, self.user_home_dir.as_deref(), working_dir) + } } /// 合并 base skills、user skills 和 project skills。 /// -/// 这是 `SkillCatalog::resolve_for_working_dir` 的核心逻辑。 +/// 这是 `LayeredSkillCatalog::resolve_for_working_dir` 的核心逻辑。 /// /// 保持为 crate 内部函数,避免外部调用方绕过 `SkillCatalog` /// 直接把 skill 解析重新分散到各处。 @@ -217,17 +237,20 @@ mod tests { make_skill("git-commit", SkillSource::Mcp), make_skill("git-commit", SkillSource::Plugin), ]; - let catalog = SkillCatalog::new(base); + let catalog = LayeredSkillCatalog::new(base); // 这里只验证 base skill 的归一化顺序,避免测试受本机 user/project skill 目录污染。 let normalized = catalog.base_skills(); let git_skill = normalized.iter().find(|s| s.id == "git-commit"); assert!(git_skill.is_some()); - assert_eq!(git_skill.unwrap().source, SkillSource::Plugin); + assert_eq!( + git_skill.expect("git-commit skill should exist").source, + SkillSource::Plugin + ); } #[test] fn test_catalog_replace_base_skills() { - let catalog = SkillCatalog::new(vec![make_skill("old-skill", SkillSource::Builtin)]); + let catalog = LayeredSkillCatalog::new(vec![make_skill("old-skill", SkillSource::Builtin)]); assert_eq!(catalog.base_skills().len(), 1); catalog.replace_base_skills(vec![ @@ -252,7 +275,7 @@ mod tests { ) .expect("user skill file should be written"); - let catalog = SkillCatalog::new_with_home_dir(Vec::new(), temp_home.path()); + let catalog = LayeredSkillCatalog::new_with_home_dir(Vec::new(), temp_home.path()); let resolved = catalog.resolve_for_working_dir(&temp_home.path().to_string_lossy()); assert!(resolved.iter().any(|skill| skill.id == "clarify-first")); diff --git a/crates/adapter-skills/src/skill_spec.rs b/crates/adapter-skills/src/skill_spec.rs index 0266f9f6..c5f29bcd 100644 --- a/crates/adapter-skills/src/skill_spec.rs +++ b/crates/adapter-skills/src/skill_spec.rs @@ -1,148 +1,3 @@ -//! Skill 规格定义与名称校验。 -//! -//! 本模块定义了 skill 的核心数据结构 [`SkillSpec`],以及 skill 名称的校验和规范化逻辑。 -//! -//! # Skill 名称规则 -//! -//! Skill 名称必须为 kebab-case(小写字母、数字、连字符),且必须与文件夹名一致。 -//! 这是 Claude-style skill 的约定,确保名称的一致性和可预测性。 +//! Skill 规格定义与名称校验由 `astrcode-core` 提供。 -use serde::{Deserialize, Serialize}; - -/// Skill 的来源。 -/// -/// 用于追踪 skill 是从哪里加载的,影响覆盖优先级和诊断标签。 -/// 优先级顺序:Builtin < Mcp < Plugin < User < Project(后者覆盖前者)。 -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] -#[serde(rename_all = "snake_case")] -pub enum SkillSource { - #[default] - Builtin, - User, - Project, - Plugin, - Mcp, -} - -impl SkillSource { - pub fn as_tag(&self) -> &'static str { - match self { - Self::Builtin => "source:builtin", - Self::User => "source:user", - Self::Project => "source:project", - Self::Plugin => "source:plugin", - Self::Mcp => "source:mcp", - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct SkillSpec { - /// Skill 的唯一标识符(kebab-case,与文件夹名一致)。 - pub id: String, - /// Skill 的显示名称(与 `id` 相同)。 - pub name: String, - /// Skill 的简短描述,用于 system prompt 中的索引展示。 - /// - /// 这是两阶段 skill 模型的第一阶段:模型通过描述判断何时调用 `Skill` tool。 - pub description: String, - /// Skill 的完整指南正文。 - /// - /// 在两阶段模型中,只有当模型调用 `Skill` tool 时才加载此内容。 - pub guide: String, - /// Skill 目录的根路径(运行时填充)。 - pub skill_root: Option, - /// Skill 目录中的资产文件列表(如 `references/`、`scripts/` 下的文件)。 - pub asset_files: Vec, - /// 此 skill 允许调用的工具列表。 - /// - /// 用于限制 skill 执行时的能力边界,builtin skill 由 `build.rs` 配置。 - pub allowed_tools: Vec, - /// Skill 的来源,影响覆盖优先级和诊断标签。 - pub source: SkillSource, -} - -impl SkillSpec { - /// 检查此 skill 是否匹配请求的名称。 - /// - /// 比较时进行大小写不敏感和斜杠容忍处理, - /// 使得 `/repo-search` 和 `REPO SEARCH` 都能匹配 `repo-search`。 - pub fn matches_requested_name(&self, requested_name: &str) -> bool { - let requested_name = normalize_skill_name(requested_name); - // `id` is already validated as kebab-case at parse time, so normalize - // is strictly for the caller-provided side — both sides land in the - // same canonical form for comparison. - requested_name == normalize_skill_name(&self.id) - } -} - -/// 检查名称是否为合法的 skill 名称。 -/// -/// 合法名称仅允许小写 ASCII 字母、数字和连字符,且不能为空。 -/// 这是 Claude-style skill 的强制要求。 -pub fn is_valid_skill_name(name: &str) -> bool { - !name.is_empty() - && name - .chars() - .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-') -} - -/// 规范化 skill 名称。 -/// -/// 将输入转换为小写、去除首尾空白和前导斜杠、将非字母数字字符替换为空格后合并。 -/// 用于用户输入与 skill id 的模糊匹配。 -pub fn normalize_skill_name(value: &str) -> String { - value - .trim() - .trim_start_matches('/') - .chars() - .map(|ch| { - if ch.is_ascii_alphanumeric() || (!ch.is_ascii() && ch.is_alphanumeric()) { - ch.to_ascii_lowercase() - } else { - ' ' - } - }) - .collect::() - .split_whitespace() - .collect::>() - .join(" ") -} - -#[cfg(test)] -mod tests { - use super::*; - - fn project_skill(id: &str, name: &str, description: &str) -> SkillSpec { - SkillSpec { - id: id.to_string(), - name: name.to_string(), - description: description.to_string(), - guide: "guide".to_string(), - skill_root: None, - asset_files: Vec::new(), - allowed_tools: Vec::new(), - source: SkillSource::Project, - } - } - - #[test] - fn skill_name_matching_is_case_insensitive_and_slash_tolerant() { - let skill = project_skill("repo-search", "repo-search", "Search the repo"); - - assert!(skill.matches_requested_name("repo-search")); - assert!(skill.matches_requested_name("/repo-search")); - assert!(skill.matches_requested_name("REPO SEARCH")); - assert!(!skill.matches_requested_name("edit-file")); - } - - #[test] - fn validates_claude_style_skill_names() { - assert!(is_valid_skill_name("git-commit")); - assert!(is_valid_skill_name("pdf2")); - assert!(!is_valid_skill_name("Git-Commit")); - assert!(!is_valid_skill_name("git_commit")); - assert!(!is_valid_skill_name("")); - } -} +pub use astrcode_core::{SkillSource, SkillSpec, is_valid_skill_name, normalize_skill_name}; diff --git a/crates/adapter-storage/Cargo.toml b/crates/adapter-storage/Cargo.toml index e8892907..e6e22d9e 100644 --- a/crates/adapter-storage/Cargo.toml +++ b/crates/adapter-storage/Cargo.toml @@ -6,7 +6,6 @@ license.workspace = true authors.workspace = true [dependencies] -astrcode-adapter-mcp = { path = "../adapter-mcp" } astrcode-core = { path = "../core" } async-trait.workspace = true chrono.workspace = true diff --git a/crates/adapter-storage/src/core_port.rs b/crates/adapter-storage/src/core_port.rs deleted file mode 100644 index dfb371db..00000000 --- a/crates/adapter-storage/src/core_port.rs +++ /dev/null @@ -1,6 +0,0 @@ -//! `adapter-storage` 面向组合根暴露的 `EventStore` 入口。 -//! -//! `FileSystemSessionRepository` 现在直接实现 `core::ports::EventStore`, -//! 这里仅保留兼容名称,避免组合根继续感知旧的 `SessionManager` 桥接层。 - -pub use crate::session::FileSystemSessionRepository as FsEventStore; diff --git a/crates/adapter-storage/src/lib.rs b/crates/adapter-storage/src/lib.rs index 1b37a236..39a026cb 100644 --- a/crates/adapter-storage/src/lib.rs +++ b/crates/adapter-storage/src/lib.rs @@ -25,7 +25,6 @@ //! 以便竞争者获取当前持有者信息并做出相应处理。 pub mod config_store; -pub mod core_port; pub mod mcp_settings_store; pub mod session; diff --git a/crates/adapter-storage/src/mcp_settings_store.rs b/crates/adapter-storage/src/mcp_settings_store.rs index 4e0845c3..0593a701 100644 --- a/crates/adapter-storage/src/mcp_settings_store.rs +++ b/crates/adapter-storage/src/mcp_settings_store.rs @@ -8,7 +8,7 @@ use std::{ path::{Path, PathBuf}, }; -use astrcode_adapter_mcp::config::{McpApprovalData, McpSettingsStore}; +use astrcode_core::{McpApprovalData, McpSettingsStore}; use serde::{Deserialize, Serialize}; /// 基于 JSON 文件的 MCP 审批设置存储。 @@ -116,7 +116,7 @@ fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), String> { #[cfg(test)] mod tests { - use astrcode_adapter_mcp::config::McpApprovalStatus; + use astrcode_core::McpApprovalStatus; use super::*; diff --git a/crates/adapter-storage/src/session/batch_appender.rs b/crates/adapter-storage/src/session/batch_appender.rs new file mode 100644 index 00000000..fa51a386 --- /dev/null +++ b/crates/adapter-storage/src/session/batch_appender.rs @@ -0,0 +1,241 @@ +use std::{ + collections::{HashMap, VecDeque}, + path::{Path, PathBuf}, + sync::{Arc, Mutex as StdMutex}, + time::Duration, +}; + +use astrcode_core::{SessionRecoveryCheckpoint, StorageEvent, StoredEvent, store::StoreResult}; +use tokio::sync::{Mutex, Notify, oneshot}; + +use super::{event_log::EventLog, paths::resolve_existing_session_path_from_projects_root}; + +const BATCH_DRAIN_WINDOW: Duration = Duration::from_millis(50); + +pub(crate) type SharedAppenderRegistry = Arc>>>; + +struct PendingAppend { + event: StorageEvent, + reply: oneshot::Sender>, +} + +#[derive(Default)] +struct BatchAppenderState { + queue: VecDeque, + flush_scheduled: bool, + draining: bool, + paused: bool, +} + +pub(crate) struct BatchAppender { + session_id: String, + projects_root: Option, + state: Mutex, + notify: Notify, + log: StdMutex>, +} + +impl BatchAppender { + pub(crate) fn new(session_id: String, projects_root: Option) -> Self { + Self { + session_id, + projects_root, + state: Mutex::new(BatchAppenderState::default()), + notify: Notify::new(), + log: StdMutex::new(None), + } + } + + pub(crate) async fn append(self: &Arc, event: StorageEvent) -> StoreResult { + let (tx, rx) = oneshot::channel(); + let mut state = self.state.lock().await; + state.queue.push_back(PendingAppend { event, reply: tx }); + if !state.paused && !state.flush_scheduled { + state.flush_scheduled = true; + drop(state); + self.spawn_flush_cycle(); + } else { + drop(state); + } + rx.await.map_err(|_| { + crate::internal_io_error(format!( + "batch appender for session '{}' dropped response channel", + self.session_id + )) + })? + } + + pub(crate) async fn checkpoint_with_payload( + self: &Arc, + checkpoint: SessionRecoveryCheckpoint, + work: F, + ) -> StoreResult + where + T: Send + 'static, + F: FnOnce(&Path, &SessionRecoveryCheckpoint) -> StoreResult + Send + 'static, + { + self.pause_and_drain().await; + let appender = Arc::clone(self); + let result = tokio::task::spawn_blocking(move || { + let path = appender.event_log_path()?; + let mut guard = appender.log.lock().map_err(|_| { + crate::internal_io_error(format!( + "batch appender log mutex poisoned for session '{}'", + appender.session_id + )) + })?; + let previous = guard.take(); + drop(guard); + drop(previous); + work(&path, &checkpoint) + }) + .await + .map_err(|error| { + crate::internal_io_error(format!( + "checkpoint task for session '{}' failed to join: {error}", + self.session_id + )) + })?; + self.resume_after_barrier().await; + result + } + + fn spawn_flush_cycle(self: &Arc) { + let appender = Arc::clone(self); + tokio::spawn(async move { + tokio::time::sleep(BATCH_DRAIN_WINDOW).await; + appender.flush_pending_batch().await; + }); + } + + async fn flush_pending_batch(self: Arc) { + let pending = { + let mut state = self.state.lock().await; + if state.paused || state.queue.is_empty() { + state.flush_scheduled = false; + state.draining = false; + self.notify.notify_waiters(); + return; + } + state.draining = true; + state.flush_scheduled = false; + state.queue.drain(..).collect::>() + }; + let events = pending + .iter() + .map(|pending| pending.event.clone()) + .collect::>(); + let result = { + let appender = Arc::clone(&self); + tokio::task::spawn_blocking(move || appender.append_batch_blocking(&events)).await + }; + match result { + Ok(Ok(stored_events)) => { + for (pending, stored) in pending.into_iter().zip(stored_events) { + let _ = pending.reply.send(Ok(stored)); + } + }, + Ok(Err(error)) => { + let message = error.to_string(); + for pending in pending { + let _ = pending + .reply + .send(Err(crate::internal_io_error(message.clone()))); + } + }, + Err(error) => { + let message = format!( + "batch appender for session '{}' failed to join: {error}", + self.session_id + ); + for pending in pending { + let _ = pending + .reply + .send(Err(crate::internal_io_error(message.clone()))); + } + }, + } + + let should_reschedule = { + let mut state = self.state.lock().await; + state.draining = false; + let should_reschedule = + !state.paused && !state.flush_scheduled && !state.queue.is_empty(); + if should_reschedule { + state.flush_scheduled = true; + } + self.notify.notify_waiters(); + should_reschedule + }; + if should_reschedule { + self.spawn_flush_cycle(); + } + } + + async fn pause_and_drain(&self) { + loop { + let notified = { + let mut state = self.state.lock().await; + if !state.paused && !state.draining && state.queue.is_empty() { + state.paused = true; + None + } else { + Some(self.notify.notified()) + } + }; + if let Some(notified) = notified { + notified.await; + continue; + } + return; + } + } + + async fn resume_after_barrier(self: &Arc) { + let should_schedule = { + let mut state = self.state.lock().await; + state.paused = false; + let should_schedule = !state.flush_scheduled && !state.queue.is_empty(); + if should_schedule { + state.flush_scheduled = true; + } + self.notify.notify_waiters(); + should_schedule + }; + if should_schedule { + self.spawn_flush_cycle(); + } + } + + fn append_batch_blocking(&self, events: &[StorageEvent]) -> StoreResult> { + let mut guard = self.log.lock().map_err(|_| { + crate::internal_io_error(format!( + "batch appender log mutex poisoned for session '{}'", + self.session_id + )) + })?; + if guard.is_none() { + *guard = Some(self.open_event_log()?); + } + guard + .as_mut() + .expect("event log should be available") + .append_batch(events) + } + + fn open_event_log(&self) -> StoreResult { + match &self.projects_root { + Some(projects_root) => EventLog::open_in_projects_root(projects_root, &self.session_id), + None => EventLog::open(&self.session_id), + } + } + + fn event_log_path(&self) -> StoreResult { + match &self.projects_root { + Some(projects_root) => { + resolve_existing_session_path_from_projects_root(projects_root, &self.session_id) + }, + None => super::paths::resolve_existing_session_path(&self.session_id), + } + } +} diff --git a/crates/adapter-storage/src/session/checkpoint.rs b/crates/adapter-storage/src/session/checkpoint.rs new file mode 100644 index 00000000..4028274d --- /dev/null +++ b/crates/adapter-storage/src/session/checkpoint.rs @@ -0,0 +1,383 @@ +#[cfg(not(windows))] +use std::fs::File; +use std::{ + fs::{self, OpenOptions}, + io::{BufWriter, Write}, + path::{Path, PathBuf}, +}; + +use astrcode_core::{RecoveredSessionState, SessionRecoveryCheckpoint, StoredEvent}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::{ + iterator::EventLogIterator, + paths::{ + checkpoint_snapshot_path, checkpoint_snapshot_path_from_projects_root, + latest_checkpoint_marker_path, latest_checkpoint_marker_path_from_projects_root, + resolve_existing_session_path, resolve_existing_session_path_from_projects_root, + snapshots_dir, snapshots_dir_from_projects_root, + }, +}; +use crate::Result; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct LatestCheckpointMarker { + checkpoint_storage_seq: u64, + file_name: String, +} + +pub(crate) fn recover_session( + projects_root: Option<&Path>, + session_id: &str, +) -> Result { + let event_log_path = match projects_root { + Some(projects_root) => { + resolve_existing_session_path_from_projects_root(projects_root, session_id)? + }, + None => resolve_existing_session_path(session_id)?, + }; + let Some(checkpoint) = load_active_checkpoint(projects_root, session_id)? else { + return Ok(RecoveredSessionState { + checkpoint: None, + tail_events: EventLogIterator::from_path(&event_log_path)? + .collect::>>()?, + }); + }; + let tail_events = EventLogIterator::from_path(&event_log_path)? + .filter_map(|result| match result { + Ok(stored) if stored.storage_seq > checkpoint.checkpoint_storage_seq => { + Some(Ok(stored)) + }, + Ok(_) => None, + Err(error) => Some(Err(error)), + }) + .collect::>>()?; + Ok(RecoveredSessionState { + checkpoint: Some(checkpoint), + tail_events, + }) +} + +pub(crate) fn persist_checkpoint( + projects_root: Option<&Path>, + event_log_path: &Path, + session_id: &str, + checkpoint: &SessionRecoveryCheckpoint, +) -> Result<()> { + let snapshot_dir = match projects_root { + Some(projects_root) => snapshots_dir_from_projects_root(projects_root, session_id)?, + None => snapshots_dir(session_id)?, + }; + fs::create_dir_all(&snapshot_dir).map_err(|error| { + crate::io_error( + format!( + "failed to create snapshots directory '{}'", + snapshot_dir.display() + ), + error, + ) + })?; + + let checkpoint_path = match projects_root { + Some(projects_root) => checkpoint_snapshot_path_from_projects_root( + projects_root, + session_id, + checkpoint.checkpoint_storage_seq, + )?, + None => checkpoint_snapshot_path(session_id, checkpoint.checkpoint_storage_seq)?, + }; + let marker_path = match projects_root { + Some(projects_root) => { + latest_checkpoint_marker_path_from_projects_root(projects_root, session_id)? + }, + None => latest_checkpoint_marker_path(session_id)?, + }; + let snapshot_tmp = temp_path(&checkpoint_path, "snapshot"); + let marker_tmp = temp_path(&marker_path, "marker"); + let log_tmp = temp_path(event_log_path, "rewrite"); + let log_backup = event_log_path.with_extension("jsonl.bak"); + + let tail_events = EventLogIterator::from_path(event_log_path)? + .filter_map(|result| match result { + Ok(stored) if stored.storage_seq > checkpoint.checkpoint_storage_seq => { + Some(Ok(stored)) + }, + Ok(_) => None, + Err(error) => Some(Err(error)), + }) + .collect::>>()?; + + write_json_file(&snapshot_tmp, checkpoint)?; + write_json_file( + &marker_tmp, + &LatestCheckpointMarker { + checkpoint_storage_seq: checkpoint.checkpoint_storage_seq, + file_name: checkpoint_path + .file_name() + .and_then(|value| value.to_str()) + .ok_or_else(|| { + crate::internal_io_error(format!( + "checkpoint path '{}' has no valid file name", + checkpoint_path.display() + )) + })? + .to_string(), + }, + )?; + write_events_file(&log_tmp, &tail_events)?; + + fs::rename(&snapshot_tmp, &checkpoint_path).map_err(|error| { + crate::io_error( + format!( + "failed to commit checkpoint snapshot '{}' -> '{}'", + snapshot_tmp.display(), + checkpoint_path.display() + ), + error, + ) + })?; + sync_dir(&snapshot_dir)?; + + replace_file(&marker_tmp, &marker_path)?; + sync_dir(&snapshot_dir)?; + + if log_backup.exists() { + fs::remove_file(&log_backup).map_err(|error| { + crate::io_error( + format!( + "failed to remove stale log backup '{}'", + log_backup.display() + ), + error, + ) + })?; + } + + fs::rename(event_log_path, &log_backup).map_err(|error| { + crate::io_error( + format!( + "failed to rotate event log '{}' -> '{}'", + event_log_path.display(), + log_backup.display() + ), + error, + ) + })?; + if let Err(error) = fs::rename(&log_tmp, event_log_path) { + restore_rotated_event_log(&log_backup, event_log_path).map_err(|restore_error| { + crate::internal_io_error(format!( + "failed to restore rotated event log '{}' -> '{}' after promote failure: {}", + log_backup.display(), + event_log_path.display(), + restore_error + )) + })?; + return Err(crate::io_error( + format!( + "failed to promote rewritten event log '{}' -> '{}'", + log_tmp.display(), + event_log_path.display() + ), + error, + )); + } + sync_dir( + event_log_path + .parent() + .ok_or_else(|| crate::internal_io_error("event log path missing parent"))?, + )?; + + fs::remove_file(&log_backup).map_err(|error| { + crate::io_error( + format!( + "failed to remove old event log backup '{}'", + log_backup.display() + ), + error, + ) + })?; + + Ok(()) +} + +fn load_active_checkpoint( + projects_root: Option<&Path>, + session_id: &str, +) -> Result> { + let marker_path = match projects_root { + Some(projects_root) => { + latest_checkpoint_marker_path_from_projects_root(projects_root, session_id)? + }, + None => latest_checkpoint_marker_path(session_id)?, + }; + if !marker_path.exists() { + return Ok(None); + } + let marker = read_json_file::(&marker_path)?; + let snapshot_dir = match projects_root { + Some(projects_root) => snapshots_dir_from_projects_root(projects_root, session_id)?, + None => snapshots_dir(session_id)?, + }; + let checkpoint_path = snapshot_dir.join(marker.file_name); + if !checkpoint_path.exists() { + return Err(crate::internal_io_error(format!( + "checkpoint marker '{}' points to missing snapshot '{}'", + marker_path.display(), + checkpoint_path.display() + ))); + } + Ok(Some(read_json_file(&checkpoint_path)?)) +} + +fn read_json_file(path: &Path) -> Result +where + T: for<'de> Deserialize<'de>, +{ + let bytes = fs::read(path) + .map_err(|error| crate::io_error(format!("failed to read '{}'", path.display()), error))?; + serde_json::from_slice(&bytes) + .map_err(|error| crate::parse_error(format!("failed to parse '{}'", path.display()), error)) +} + +fn write_json_file(path: &Path, value: &T) -> Result<()> +where + T: Serialize, +{ + let file = OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .open(path) + .map_err(|error| crate::io_error(format!("failed to open '{}'", path.display()), error))?; + let mut writer = BufWriter::new(file); + serde_json::to_writer(&mut writer, value).map_err(|error| { + crate::parse_error(format!("failed to serialize '{}'", path.display()), error) + })?; + writer + .flush() + .map_err(|error| crate::io_error(format!("failed to flush '{}'", path.display()), error))?; + writer + .get_ref() + .sync_all() + .map_err(|error| crate::io_error(format!("failed to sync '{}'", path.display()), error))?; + sync_dir( + path.parent() + .ok_or_else(|| crate::internal_io_error("json file path missing parent"))?, + )?; + Ok(()) +} + +fn write_events_file(path: &Path, events: &[StoredEvent]) -> Result<()> { + let file = OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .open(path) + .map_err(|error| crate::io_error(format!("failed to open '{}'", path.display()), error))?; + let mut writer = BufWriter::new(file); + for stored in events { + serde_json::to_writer(&mut writer, stored).map_err(|error| { + crate::parse_error(format!("failed to serialize '{}'", path.display()), error) + })?; + writeln!(writer).map_err(|error| { + crate::io_error(format!("failed to write '{}'", path.display()), error) + })?; + } + writer + .flush() + .map_err(|error| crate::io_error(format!("failed to flush '{}'", path.display()), error))?; + writer + .get_ref() + .sync_all() + .map_err(|error| crate::io_error(format!("failed to sync '{}'", path.display()), error))?; + sync_dir( + path.parent() + .ok_or_else(|| crate::internal_io_error("event file path missing parent"))?, + )?; + Ok(()) +} + +fn replace_file(from: &Path, to: &Path) -> Result<()> { + if to.exists() { + fs::remove_file(to).map_err(|error| { + crate::io_error( + format!("failed to remove existing file '{}'", to.display()), + error, + ) + })?; + } + fs::rename(from, to).map_err(|error| { + crate::io_error( + format!( + "failed to rename '{}' -> '{}'", + from.display(), + to.display() + ), + error, + ) + })?; + Ok(()) +} + +fn restore_rotated_event_log(log_backup: &Path, event_log_path: &Path) -> Result<()> { + if event_log_path.exists() { + fs::remove_file(event_log_path).map_err(|error| { + crate::io_error( + format!( + "failed to remove partially promoted event log '{}'", + event_log_path.display() + ), + error, + ) + })?; + } + fs::rename(log_backup, event_log_path).map_err(|error| { + crate::io_error( + format!( + "failed to restore event log backup '{}' -> '{}'", + log_backup.display(), + event_log_path.display() + ), + error, + ) + })?; + Ok(()) +} + +fn sync_dir(path: &Path) -> Result<()> { + #[cfg(windows)] + { + // TODO: Windows 目录刷盘目前只能 best-effort 跳过;后续需要补平台专用实现, + // 以收紧 checkpoint/rename 在断电或进程崩溃场景下的元数据持久化保证。 + let _ = path; + Ok(()) + } + + #[cfg(not(windows))] + let dir = File::open(path).map_err(|error| { + crate::io_error( + format!("failed to open directory '{}'", path.display()), + error, + ) + })?; + #[cfg(not(windows))] + { + dir.sync_all().map_err(|error| { + crate::io_error( + format!("failed to sync directory '{}'", path.display()), + error, + ) + }) + } +} + +fn temp_path(base: &Path, label: &str) -> PathBuf { + let suffix = Uuid::new_v4(); + let file_name = base + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("checkpoint"); + base.with_file_name(format!("{file_name}.{label}.{suffix}.tmp")) +} diff --git a/crates/adapter-storage/src/session/event_log.rs b/crates/adapter-storage/src/session/event_log.rs index 22e6e281..66c26851 100644 --- a/crates/adapter-storage/src/session/event_log.rs +++ b/crates/adapter-storage/src/session/event_log.rs @@ -192,14 +192,31 @@ impl EventLog { /// 序列化为 JSON 行写入文件,然后立即 flush 并 sync 到磁盘。 /// 返回包含已分配 `storage_seq` 的 `StoredEvent`。 pub fn append_stored(&mut self, event: &StorageEvent) -> Result { - let stored = StoredEvent { - storage_seq: self.next_storage_seq, - event: event.clone(), - }; + Ok(self + .append_batch(std::slice::from_ref(event))? + .into_iter() + .next() + .expect("single append should always produce one stored event")) + } - serde_json::to_writer(&mut self.writer, &stored) - .map_err(|e| crate::parse_error("failed to serialize StoredEvent", e))?; - writeln!(self.writer).map_err(|e| crate::io_error("failed to write newline", e))?; + pub fn append_batch(&mut self, events: &[StorageEvent]) -> Result> { + let mut stored_events = Vec::with_capacity(events.len()); + for event in events { + let stored = StoredEvent { + storage_seq: self.next_storage_seq, + event: event.clone(), + }; + serde_json::to_writer(&mut self.writer, &stored) + .map_err(|e| crate::parse_error("failed to serialize StoredEvent", e))?; + writeln!(self.writer).map_err(|e| crate::io_error("failed to write newline", e))?; + self.next_storage_seq = self.next_storage_seq.saturating_add(1); + stored_events.push(stored); + } + self.flush_and_sync()?; + Ok(stored_events) + } + + pub(crate) fn flush_and_sync(&mut self) -> Result<()> { self.writer .flush() .map_err(|e| crate::io_error("failed to flush event log", e))?; @@ -207,8 +224,7 @@ impl EventLog { .get_ref() .sync_all() .map_err(|e| crate::io_error("failed to sync event log", e))?; - self.next_storage_seq = self.next_storage_seq.saturating_add(1); - Ok(stored) + Ok(()) } /// 回放指定路径的会话文件中的所有事件。 diff --git a/crates/adapter-storage/src/session/iterator.rs b/crates/adapter-storage/src/session/iterator.rs index 05a076c6..35afcf14 100644 --- a/crates/adapter-storage/src/session/iterator.rs +++ b/crates/adapter-storage/src/session/iterator.rs @@ -182,10 +182,10 @@ mod tests { event: StorageEvent { turn_id: Some("turn-parent".to_string()), agent: AgentEventContext { - agent_id: Some("agent-child".to_string()), - parent_turn_id: Some("turn-parent".to_string()), + agent_id: Some("agent-child".into()), + parent_turn_id: Some("turn-parent".into()), agent_profile: Some("review".to_string()), - sub_run_id: Some("subrun-1".to_string()), + sub_run_id: Some("subrun-1".into()), parent_sub_run_id: None, invocation_kind: Some(InvocationKind::SubRun), storage_mode: Some(SubRunStorageMode::IndependentSession), diff --git a/crates/adapter-storage/src/session/mod.rs b/crates/adapter-storage/src/session/mod.rs index d0787a12..88b56366 100644 --- a/crates/adapter-storage/src/session/mod.rs +++ b/crates/adapter-storage/src/session/mod.rs @@ -20,6 +20,8 @@ //! └── active-turn.json # 锁持有者元数据 //! ``` +mod batch_appender; +mod checkpoint; mod event_log; mod iterator; mod paths; diff --git a/crates/adapter-storage/src/session/paths.rs b/crates/adapter-storage/src/session/paths.rs index 6348f613..91977caf 100644 --- a/crates/adapter-storage/src/session/paths.rs +++ b/crates/adapter-storage/src/session/paths.rs @@ -218,6 +218,47 @@ pub(crate) fn session_turn_metadata_path_from_projects_root( ) } +pub(crate) fn snapshots_dir(session_id: &str) -> Result { + Ok(resolve_existing_session_dir(session_id)?.join("snapshots")) +} + +pub(crate) fn snapshots_dir_from_projects_root( + projects_root: &Path, + session_id: &str, +) -> Result { + Ok( + resolve_existing_session_dir_from_projects_root(projects_root, session_id)? + .join("snapshots"), + ) +} + +pub(crate) fn checkpoint_snapshot_path( + session_id: &str, + checkpoint_storage_seq: u64, +) -> Result { + Ok(snapshots_dir(session_id)?.join(format!("checkpoint-{checkpoint_storage_seq}.json"))) +} + +pub(crate) fn checkpoint_snapshot_path_from_projects_root( + projects_root: &Path, + session_id: &str, + checkpoint_storage_seq: u64, +) -> Result { + Ok(snapshots_dir_from_projects_root(projects_root, session_id)? + .join(format!("checkpoint-{checkpoint_storage_seq}.json"))) +} + +pub(crate) fn latest_checkpoint_marker_path(session_id: &str) -> Result { + Ok(snapshots_dir(session_id)?.join("latest-checkpoint.json")) +} + +pub(crate) fn latest_checkpoint_marker_path_from_projects_root( + projects_root: &Path, + session_id: &str, +) -> Result { + Ok(snapshots_dir_from_projects_root(projects_root, session_id)?.join("latest-checkpoint.json")) +} + /// 枚举所有项目下的 sessions 目录。 /// /// 遍历 `projects_root` 下的每个子目录,查找其 `sessions` 子目录, diff --git a/crates/adapter-storage/src/session/repository.rs b/crates/adapter-storage/src/session/repository.rs index 3b3dadd0..fb85198a 100644 --- a/crates/adapter-storage/src/session/repository.rs +++ b/crates/adapter-storage/src/session/repository.rs @@ -1,14 +1,20 @@ -use std::path::{Path, PathBuf}; +use std::{ + path::{Path, PathBuf}, + sync::Arc, +}; use astrcode_core::{ - DeleteProjectResult, Result, SessionId, SessionMeta, SessionTurnAcquireResult, StorageEvent, - StoredEvent, + DeleteProjectResult, RecoveredSessionState, Result, SessionId, SessionMeta, + SessionRecoveryCheckpoint, SessionTurnAcquireResult, StorageEvent, StoredEvent, ports::EventStore, store::{EventLogWriter, SessionManager, StoreResult}, }; use async_trait::async_trait; +use tokio::sync::Mutex; use super::{ + batch_appender::{BatchAppender, SharedAppenderRegistry}, + checkpoint, event_log::EventLog, iterator::EventLogIterator, paths::resolve_existing_session_path, @@ -16,15 +22,17 @@ use super::{ }; /// 基于本地文件系统的会话仓储实现。 -#[derive(Debug, Default, Clone)] +#[derive(Clone)] pub struct FileSystemSessionRepository { projects_root: Option, + appenders: SharedAppenderRegistry, } impl FileSystemSessionRepository { pub fn new() -> Self { Self { projects_root: None, + appenders: default_appender_registry(), } } @@ -35,6 +43,7 @@ impl FileSystemSessionRepository { pub fn new_with_projects_root(projects_root: PathBuf) -> Self { Self { projects_root: Some(projects_root), + appenders: default_appender_registry(), } } @@ -73,6 +82,24 @@ impl FileSystemSessionRepository { log.append_stored(event) } + pub fn recover_session_sync(&self, session_id: &str) -> StoreResult { + checkpoint::recover_session(self.projects_root.as_deref(), session_id) + } + + pub fn checkpoint_session_sync( + &self, + event_log_path: &Path, + session_id: &str, + checkpoint: &SessionRecoveryCheckpoint, + ) -> StoreResult<()> { + checkpoint::persist_checkpoint( + self.projects_root.as_deref(), + event_log_path, + session_id, + checkpoint, + ) + } + pub fn replay_events_sync(&self, session_id: &str) -> StoreResult> { let path = match &self.projects_root { Some(projects_root) => super::paths::resolve_existing_session_path_from_projects_root( @@ -140,6 +167,25 @@ impl FileSystemSessionRepository { }; EventLog::last_storage_seq_from_path(&path) } + + async fn appender_for_session(&self, session_id: &str) -> Arc { + let mut registry = self.appenders.lock().await; + if let Some(appender) = registry.get(session_id) { + return Arc::clone(appender); + } + let appender = Arc::new(BatchAppender::new( + session_id.to_string(), + self.projects_root.clone(), + )); + registry.insert(session_id.to_string(), Arc::clone(&appender)); + appender + } +} + +impl Default for FileSystemSessionRepository { + fn default() -> Self { + Self::new() + } } #[async_trait] @@ -155,24 +201,47 @@ impl EventStore for FileSystemSessionRepository { } async fn append(&self, session_id: &SessionId, event: &StorageEvent) -> Result { + let appender = self.appender_for_session(session_id.as_str()).await; + appender + .append(event.clone()) + .await + .map_err(crate::map_store_error) + } + + async fn replay(&self, session_id: &SessionId) -> Result> { let repo = self.clone(); let session_id = session_id.to_string(); - let event = event.clone(); - run_blocking("append storage event", move || { - repo.append_sync(&session_id, &event) + run_blocking("replay storage events", move || { + repo.replay_events_sync(&session_id) }) .await } - async fn replay(&self, session_id: &SessionId) -> Result> { + async fn recover_session(&self, session_id: &SessionId) -> Result { let repo = self.clone(); let session_id = session_id.to_string(); - run_blocking("replay storage events", move || { - repo.replay_events_sync(&session_id) + run_blocking("recover storage session", move || { + repo.recover_session_sync(&session_id) }) .await } + async fn checkpoint_session( + &self, + session_id: &SessionId, + checkpoint: &SessionRecoveryCheckpoint, + ) -> Result<()> { + let appender = self.appender_for_session(session_id.as_str()).await; + let repo = self.clone(); + let session_id_string = session_id.to_string(); + appender + .checkpoint_with_payload(checkpoint.clone(), move |event_log_path, checkpoint| { + repo.checkpoint_session_sync(event_log_path, &session_id_string, checkpoint) + }) + .await + .map_err(crate::map_store_error) + } + async fn try_acquire_turn( &self, session_id: &SessionId, @@ -224,6 +293,10 @@ impl EventStore for FileSystemSessionRepository { } } +fn default_appender_registry() -> SharedAppenderRegistry { + Arc::new(Mutex::new(std::collections::HashMap::new())) +} + async fn run_blocking(label: &'static str, work: F) -> Result where T: Send + 'static, @@ -299,3 +372,192 @@ impl SessionManager for FileSystemSessionRepository { self.delete_sessions_by_working_dir_sync(working_dir) } } + +#[cfg(test)] +mod tests { + use std::{collections::HashMap, time::Instant}; + + use astrcode_core::{ + AgentEventContext, AgentState, EventStore, LlmMessage, ModeId, Phase, + SessionRecoveryCheckpoint, StorageEvent, StorageEventPayload, UserMessageOrigin, + }; + + use super::*; + use crate::session::paths::checkpoint_snapshot_path_from_projects_root; + + fn user_message_event(turn_id: &str, content: &str) -> StorageEvent { + StorageEvent { + turn_id: Some(turn_id.to_string()), + agent: AgentEventContext::default(), + payload: StorageEventPayload::UserMessage { + content: content.to_string(), + origin: UserMessageOrigin::User, + timestamp: chrono::Utc::now(), + }, + } + } + + #[tokio::test] + async fn append_batches_events_with_contiguous_storage_seq() { + let temp = tempfile::tempdir().expect("tempdir should exist"); + let repo = FileSystemSessionRepository::new_with_projects_root(temp.path().to_path_buf()); + let session_id = SessionId::from("session-batch-1".to_string()); + let working_dir = temp.path().join("work"); + std::fs::create_dir_all(&working_dir).expect("working dir should exist"); + repo.ensure_session(&session_id, &working_dir) + .await + .expect("session should be created"); + + let started = Instant::now(); + let first_event = user_message_event("turn-1", "first"); + let second_event = user_message_event("turn-2", "second"); + let first = repo.append(&session_id, &first_event); + let second = repo.append(&session_id, &second_event); + let (first, second) = tokio::join!(first, second); + let elapsed = started.elapsed(); + + let first = first.expect("first append should succeed"); + let second = second.expect("second append should succeed"); + let mut seqs = vec![first.storage_seq, second.storage_seq]; + seqs.sort_unstable(); + + assert_eq!(seqs, vec![1, 2]); + assert!( + elapsed >= std::time::Duration::from_millis(40), + "batch append should wait for the drain window before returning" + ); + assert_eq!( + repo.replay_events_sync(session_id.as_str()) + .expect("replay should succeed") + .len(), + 2 + ); + } + + #[tokio::test] + async fn recover_session_uses_checkpoint_plus_tail_events() { + let temp = tempfile::tempdir().expect("tempdir should exist"); + let repo = FileSystemSessionRepository::new_with_projects_root(temp.path().to_path_buf()); + let session_id = SessionId::from("session-recovery-1".to_string()); + let working_dir = temp.path().join("work"); + std::fs::create_dir_all(&working_dir).expect("working dir should exist"); + repo.ensure_session(&session_id, &working_dir) + .await + .expect("session should be created"); + + repo.append(&session_id, &user_message_event("turn-1", "first")) + .await + .expect("first append should succeed"); + repo.append(&session_id, &user_message_event("turn-2", "second")) + .await + .expect("second append should succeed"); + let tail = repo + .append(&session_id, &user_message_event("turn-3", "tail")) + .await + .expect("tail append should succeed"); + + repo.checkpoint_session( + &session_id, + &SessionRecoveryCheckpoint { + agent_state: AgentState { + session_id: session_id.to_string(), + working_dir: working_dir.clone(), + messages: vec![LlmMessage::User { + content: "checkpoint".to_string(), + origin: UserMessageOrigin::User, + }], + phase: Phase::Idle, + mode_id: ModeId::default(), + turn_count: 2, + last_assistant_at: None, + }, + phase: Phase::Idle, + last_mode_changed_at: None, + child_nodes: HashMap::new(), + active_tasks: HashMap::new(), + input_queue_projection_index: HashMap::new(), + checkpoint_storage_seq: 2, + }, + ) + .await + .expect("checkpoint should succeed"); + + let recovered = repo + .recover_session(&session_id) + .await + .expect("recovery should succeed"); + + assert_eq!( + recovered + .checkpoint + .expect("checkpoint should exist") + .checkpoint_storage_seq, + 2 + ); + assert_eq!(recovered.tail_events.len(), 1); + assert_eq!(recovered.tail_events[0].storage_seq, tail.storage_seq); + assert_eq!( + repo.replay_events_sync(session_id.as_str()) + .expect("replay should succeed") + .into_iter() + .map(|stored| stored.storage_seq) + .collect::>(), + vec![tail.storage_seq] + ); + } + + #[tokio::test] + async fn recover_session_fails_when_marker_points_to_missing_snapshot() { + let temp = tempfile::tempdir().expect("tempdir should exist"); + let repo = FileSystemSessionRepository::new_with_projects_root(temp.path().to_path_buf()); + let session_id = SessionId::from("session-recovery-missing-snapshot".to_string()); + let working_dir = temp.path().join("work"); + std::fs::create_dir_all(&working_dir).expect("working dir should exist"); + repo.ensure_session(&session_id, &working_dir) + .await + .expect("session should be created"); + + repo.append(&session_id, &user_message_event("turn-1", "first")) + .await + .expect("append should succeed"); + repo.checkpoint_session( + &session_id, + &SessionRecoveryCheckpoint { + agent_state: AgentState { + session_id: session_id.to_string(), + working_dir: working_dir.clone(), + messages: vec![LlmMessage::User { + content: "checkpoint".to_string(), + origin: UserMessageOrigin::User, + }], + phase: Phase::Idle, + mode_id: ModeId::default(), + turn_count: 1, + last_assistant_at: None, + }, + phase: Phase::Idle, + last_mode_changed_at: None, + child_nodes: HashMap::new(), + active_tasks: HashMap::new(), + input_queue_projection_index: HashMap::new(), + checkpoint_storage_seq: 1, + }, + ) + .await + .expect("checkpoint should succeed"); + + let snapshot_path = + checkpoint_snapshot_path_from_projects_root(temp.path(), session_id.as_str(), 1) + .expect("snapshot path should resolve"); + std::fs::remove_file(&snapshot_path).expect("snapshot should be removable"); + + let error = repo + .recover_session(&session_id) + .await + .expect_err("recovery should fail when snapshot is missing"); + assert!( + error.to_string().contains("points to missing snapshot"), + "unexpected error: {error}" + ); + } +} diff --git a/crates/adapter-tools/Cargo.toml b/crates/adapter-tools/Cargo.toml index a6b5a7fd..39cbf603 100644 --- a/crates/adapter-tools/Cargo.toml +++ b/crates/adapter-tools/Cargo.toml @@ -6,7 +6,6 @@ license.workspace = true authors.workspace = true [dependencies] -astrcode-adapter-skills = { path = "../adapter-skills" } astrcode-core = { path = "../core" } async-trait.workspace = true base64.workspace = true diff --git a/crates/adapter-tools/src/agent_tools/collab_result_mapping.rs b/crates/adapter-tools/src/agent_tools/collab_result_mapping.rs index 4905d3be..316ba854 100644 --- a/crates/adapter-tools/src/agent_tools/collab_result_mapping.rs +++ b/crates/adapter-tools/src/agent_tools/collab_result_mapping.rs @@ -2,16 +2,17 @@ //! //! 与 `result_mapping` 拆开是因为协作工具的返回类型是 `CollaborationResult`, //! 其结构与 spawn 的 `SubRunResult` 完全不同: -//! - CollaborationResult 侧重 accepted/failure/summary 三元组 +//! - CollaborationResult 侧重 variant + summary/delegation 的稳定组合 //! - SubRunResult 侧重 status/handoff/artifacts 组合 //! //! 映射策略: -//! - `accepted` → ok(表示操作被 runtime 接受) -//! - `failure` → error(描述拒绝或运行时错误的原因) +//! - 协作结果本身已经表示 accepted 的动作结果,因此 `ok` 固定为 `true` //! - `summary` → output(LLM 可见的文本摘要) //! - 整个 CollaborationResult 序列化为 metadata(供前端消费) -use astrcode_core::{CollaborationResult, DelegationMetadata, ToolExecutionResult}; +use astrcode_core::{ + CollaborationResult, DelegationMetadata, ExecutionResultCommon, ToolExecutionResult, +}; use serde_json::json; /// 协作工具的错误结果(参数校验失败等)。 @@ -22,16 +23,14 @@ pub(crate) fn collaboration_error_result( tool_name: &str, message: String, ) -> ToolExecutionResult { - ToolExecutionResult { + ToolExecutionResult::from_common( tool_call_id, - tool_name: tool_name.to_string(), - ok: false, - output: String::new(), - error: Some(message), - metadata: None, - duration_ms: 0, - truncated: false, - } + tool_name, + false, + String::new(), + None, + ExecutionResultCommon::failure(message, None, 0, false), + ) } /// 将 CollaborationResult 映射为 ToolExecutionResult。 @@ -42,8 +41,7 @@ pub(crate) fn map_collaboration_result( tool_name: &str, result: CollaborationResult, ) -> ToolExecutionResult { - let error = result.failure.clone(); - let output = result.summary.clone().unwrap_or_default(); + let output = result.summary().unwrap_or_default().to_string(); let metadata = Some(match serde_json::to_value(&result) { Ok(mut value) => { inject_advisory_projection(&mut value, &result); @@ -51,21 +49,32 @@ pub(crate) fn map_collaboration_result( }, Err(serialization_error) => json!({ "schema": "collaborationResult", - "accepted": result.accepted, - "kind": format!("{:?}", result.kind), + "accepted": true, + "kind": result_kind_label(&result), "serializationError": serialization_error.to_string(), }), }); - ToolExecutionResult { + ToolExecutionResult::from_common( tool_call_id, - tool_name: tool_name.to_string(), - ok: result.accepted, + tool_name, + true, output, - error, - metadata, - duration_ms: 0, - truncated: false, + result.agent_ref().cloned(), + ExecutionResultCommon { + error: None, + metadata, + duration_ms: 0, + truncated: false, + }, + ) +} + +fn result_kind_label(result: &CollaborationResult) -> &'static str { + match result { + CollaborationResult::Sent { .. } => "sent", + CollaborationResult::Observed { .. } => "observed", + CollaborationResult::Closed { .. } => "closed", } } @@ -79,27 +88,11 @@ fn inject_advisory_projection(metadata: &mut serde_json::Value, result: &Collabo } fn build_advisory_projection(result: &CollaborationResult) -> Option { - let delegation = result.delegation.as_ref().or_else(|| { - result - .observe_result - .as_ref() - .and_then(|observe| observe.delegation.as_ref()) - }); - let next_step = result.observe_result.as_ref().map(|observe| { - json!({ - "preferredAction": observe.recommended_next_action, - "reason": observe.recommended_reason, - "deliveryFreshness": observe.delivery_freshness, - }) - }); + let delegation = result.delegation(); let branch = delegation.map(branch_advisory); - - if next_step.is_none() && branch.is_none() { - return None; - } + branch.as_ref()?; Some(json!({ - "nextStep": next_step, "branch": branch, })) } diff --git a/crates/adapter-tools/src/agent_tools/mod.rs b/crates/adapter-tools/src/agent_tools/mod.rs index c2ad5456..e6bba37f 100644 --- a/crates/adapter-tools/src/agent_tools/mod.rs +++ b/crates/adapter-tools/src/agent_tools/mod.rs @@ -8,8 +8,8 @@ mod send_tool; mod spawn_tool; pub use astrcode_core::{ - CloseAgentParams, CollaborationResult, CollaborationResultKind, ObserveParams, SendAgentParams, - SendToChildParams, SendToParentParams, SpawnAgentParams, + CloseAgentParams, CollaborationResult, ObserveParams, SendAgentParams, SendToChildParams, + SendToParentParams, SpawnAgentParams, }; pub use close_tool::CloseAgentTool; pub use collaboration_executor::CollaborationExecutor; diff --git a/crates/adapter-tools/src/agent_tools/observe_tool.rs b/crates/adapter-tools/src/agent_tools/observe_tool.rs index 0833339a..37824766 100644 --- a/crates/adapter-tools/src/agent_tools/observe_tool.rs +++ b/crates/adapter-tools/src/agent_tools/observe_tool.rs @@ -1,7 +1,7 @@ //! # observe 工具 //! -//! 四工具模型中的观测工具。返回目标 child agent 的增强快照, -//! 融合 live control state、对话投影和 mailbox 派生信息。 +//! 四工具模型中的观测工具。返回目标 child agent 的只读快照, +//! 融合 live control state 与对话投影。 use std::sync::Arc; @@ -19,10 +19,10 @@ use crate::agent_tools::{ const TOOL_NAME: &str = "observe"; -/// 获取目标 child agent 增强快照的观测工具。 +/// 获取目标 child agent 只读快照的观测工具。 /// /// 只返回直接子 agent 的快照,非直接父、兄弟、跨树调用被拒绝。 -/// 快照融合三层数据:live lifecycle、对话投影、mailbox 派生摘要。 +/// 快照只返回状态、任务与最近输出尾部。 pub struct ObserveAgentTool { executor: Arc, } @@ -39,12 +39,13 @@ Use `observe` to decide the next action for one direct child. - Use the exact `agentId` returned earlier - Call it only when you cannot decide between `wait`, `send`, or `close` without a fresh snapshot -- Read both the raw facts and the advisory fields in the result -- Treat the mailbox section as a short tail view of recent messages, not as full history +- Read the snapshot fields directly; `observe` no longer returns advisory next-step fields +- Treat the tail fields as short excerpts, not as full history Do not poll repeatedly with no decision attached. If you are simply waiting for a running child, pause briefly with your current shell tool (for example `sleep`) instead of spending another -tool call on `observe`. Do not use it for unrelated agents."# +tool call on `observe`. Do not alternate `sleep -> observe -> sleep -> observe` while no new +delivery or decision point has appeared. Do not use it for unrelated agents."# .to_string() } @@ -92,18 +93,15 @@ impl Tool for ObserveAgentTool { "Only returns snapshots for direct child agents. Never rewrite `agent-1` as \ `agent-01`.", ) - .caveat( - "`observe` returns raw lifecycle/outcome facts plus advisory decision fields. \ - Treat the advice as guidance, not as a replacement for the facts.", - ) .caveat( "Prefer one well-timed observe over repeated checking. If you are just \ waiting for a running child, use your current shell tool to sleep briefly and \ - then continue, instead of polling `observe` again.", + then continue, instead of polling `observe` again. Do not alternate \ + `sleep -> observe -> sleep -> observe` while no new delivery has arrived.", ) .caveat( - "`observe` only exposes a short mailbox tail and latest output excerpt. It is \ - intentionally not a full mailbox or transcript dump.", + "`observe` only exposes recent output and the last turn tail. It is \ + intentionally not a full transcript dump.", ) .prompt_tag("collaboration"), ) diff --git a/crates/adapter-tools/src/agent_tools/result_mapping.rs b/crates/adapter-tools/src/agent_tools/result_mapping.rs index 047aaac7..3bf2589a 100644 --- a/crates/adapter-tools/src/agent_tools/result_mapping.rs +++ b/crates/adapter-tools/src/agent_tools/result_mapping.rs @@ -1,11 +1,11 @@ //! `spawn` 的结果映射逻辑。 //! //! 将 runtime 返回的 `SubRunResult` 映射为统一的 `ToolExecutionResult`, -//! 并从 handoff artifacts 中提取 `ChildAgentRef` 注入 metadata, +//! 并从 handoff artifacts 中提取 `ChildAgentRef` 注入 typed 字段, //! 使 LLM 后续协作工具(send/observe/close)可以直接复用同一 agentId。 use astrcode_core::{ - AgentLifecycleStatus, AgentTurnOutcome, ChildAgentRef, ChildSessionLineageKind, SubRunResult, + ChildAgentRef, ChildSessionLineageKind, ExecutionResultCommon, SubRunResult, SubRunStatus, ToolExecutionResult, }; use serde_json::{Value, json}; @@ -15,16 +15,14 @@ const SUBRUN_RESULT_SCHEMA: &str = "subRunResult"; /// 参数校验失败的快捷构造。 pub(crate) fn invalid_params_result(tool_call_id: String, message: String) -> ToolExecutionResult { - ToolExecutionResult { + ToolExecutionResult::from_common( tool_call_id, - tool_name: TOOL_NAME.to_string(), - ok: false, - output: String::new(), - error: Some(message), - metadata: None, - duration_ms: 0, - truncated: false, - } + TOOL_NAME, + false, + String::new(), + None, + ExecutionResultCommon::failure(message, None, 0, false), + ) } /// 将 SubRunResult 映射为 LLM 可见的 ToolExecutionResult。 @@ -34,23 +32,26 @@ pub(crate) fn invalid_params_result(tool_call_id: String, message: String) -> To /// 2. 注入 openSessionId 供前端直接打开子会话视图 /// 3. 根据 lifecycle + last_turn_outcome 决定 ok/error/output 的组合方式 pub(crate) fn map_subrun_result(tool_call_id: String, result: SubRunResult) -> ToolExecutionResult { + let child_ref = extract_child_ref(&result); let error = result - .failure - .as_ref() + .failure() .map(|failure| failure.technical_message.clone()); let output = tool_output_for_result(&result); let metadata = subrun_metadata(&result); - ToolExecutionResult { + ToolExecutionResult::from_common( tool_call_id, - tool_name: TOOL_NAME.to_string(), - ok: !is_failed_outcome(&result), + TOOL_NAME, + !is_failed_outcome(&result), output, - error, - metadata: Some(metadata), - duration_ms: 0, - truncated: false, - } + child_ref, + ExecutionResultCommon { + error, + metadata: Some(metadata), + duration_ms: 0, + truncated: false, + }, + ) } /// 判断子运行是否因失败结束。 @@ -58,63 +59,20 @@ pub(crate) fn map_subrun_result(tool_call_id: String, result: SubRunResult) -> T /// 旧逻辑直接匹配 AgentStatus::Failed;拆分后,"失败"由 Idle + Failed outcome 表达。 /// Running 状态说明子 agent 仍在后台执行,不是失败。 fn is_failed_outcome(result: &SubRunResult) -> bool { - matches!( - (result.lifecycle, result.last_turn_outcome), - (AgentLifecycleStatus::Idle, Some(AgentTurnOutcome::Failed)) - ) + matches!(result.status(), SubRunStatus::Failed) } -/// 组装 metadata:schema + outcome + handoff + agentRef + openSessionId。 +/// 组装 metadata:schema + outcome + handoff + failure + result。 /// -/// agentRef 和 openSessionId 是后续协作工具(send/observe/close) -/// 定位子 agent 的唯一入口,必须从 handoff artifacts 中精确提取。 +/// child/session 真相不再写入 metadata,而是通过 typed `child_ref` 暴露。 fn subrun_metadata(result: &SubRunResult) -> Value { - let mut metadata = json!({ + json!({ "schema": SUBRUN_RESULT_SCHEMA, - "outcome": status_label(result.lifecycle, result.last_turn_outcome), - "handoff": result.handoff, - "failure": result.failure, + "outcome": result.status().label(), + "handoff": result.handoff(), + "failure": result.failure(), "result": result, - }); - if let Value::Object(object) = &mut metadata { - object.insert( - "schema".to_string(), - Value::String(SUBRUN_RESULT_SCHEMA.to_string()), - ); - if let Some(child_ref) = extract_child_ref(result) { - if let Ok(value) = serde_json::to_value(&child_ref) { - object.insert("agentRef".to_string(), value); - } - object.insert( - "openSessionId".to_string(), - Value::String(child_ref.open_session_id.clone()), - ); - } - } - metadata -} - -/// 根据 lifecycle + last_turn_outcome 生成面向 LLM 的状态标签。 -/// -/// 映射规则: -/// - Pending / Running / Terminated:直接取枚举名的 snake_case 形式 -/// - Idle:需要看 last_turn_outcome 来区分 completed/failed/cancelled/token_exceeded -fn status_label( - lifecycle: AgentLifecycleStatus, - outcome: Option, -) -> &'static str { - match lifecycle { - AgentLifecycleStatus::Pending => "pending", - AgentLifecycleStatus::Running => "running", - AgentLifecycleStatus::Terminated => "terminated", - AgentLifecycleStatus::Idle => match outcome { - Some(AgentTurnOutcome::Completed) => "completed", - Some(AgentTurnOutcome::Failed) => "failed", - Some(AgentTurnOutcome::Cancelled) => "cancelled", - Some(AgentTurnOutcome::TokenExceeded) => "token_exceeded", - None => "completed", // Idle 且无 outcome 视为正常完成 - }, - } + }) } /// 从 handoff artifacts 中提取 ChildAgentRef。 @@ -129,7 +87,7 @@ fn status_label( /// 任一必需 artifact 缺失则返回 None——说明 runtime 未正确写入 handoff, /// 这种情况下后续协作工具会因找不到 agent 而报错,属于可观测的 bug。 fn extract_child_ref(result: &SubRunResult) -> Option { - let handoff = result.handoff.as_ref()?; + let handoff = result.handoff()?; let sub_run_id = artifact_id(&handoff.artifacts, "subRun")?; let agent_id = artifact_id(&handoff.artifacts, "agent")?; let session_id = artifact_id(&handoff.artifacts, "parentSession")?; @@ -137,14 +95,18 @@ fn extract_child_ref(result: &SubRunResult) -> Option { let parent_agent_id = artifact_id(&handoff.artifacts, "parentAgent"); Some(ChildAgentRef { - agent_id, - session_id, - sub_run_id, - parent_agent_id, - parent_sub_run_id: artifact_id(&handoff.artifacts, "parentSubRun"), + identity: astrcode_core::ChildExecutionIdentity { + agent_id: agent_id.into(), + session_id: session_id.into(), + sub_run_id: sub_run_id.into(), + }, + parent: astrcode_core::ParentExecutionRef { + parent_agent_id: parent_agent_id.map(Into::into), + parent_sub_run_id: artifact_id(&handoff.artifacts, "parentSubRun").map(Into::into), + }, lineage_kind: ChildSessionLineageKind::Spawn, - status: result.lifecycle, - open_session_id, + status: result.status().lifecycle(), + open_session_id: open_session_id.into(), }) } @@ -163,14 +125,12 @@ fn artifact_id(artifacts: &[astrcode_core::ArtifactRef], kind: &str) -> Option String { if is_failed_outcome(result) { result - .failure - .as_ref() + .failure() .map(|failure| failure.display_message.clone()) .unwrap_or_else(|| "子 Agent 执行失败。".to_string()) } else { result - .handoff - .as_ref() + .handoff() .and_then(|handoff| handoff.delivery.as_ref()) .map(|delivery| delivery.payload.message().to_string()) .unwrap_or_else(|| "子 Agent 未返回交付。".to_string()) diff --git a/crates/adapter-tools/src/agent_tools/send_tool.rs b/crates/adapter-tools/src/agent_tools/send_tool.rs index 4f2fc406..685f220e 100644 --- a/crates/adapter-tools/src/agent_tools/send_tool.rs +++ b/crates/adapter-tools/src/agent_tools/send_tool.rs @@ -32,8 +32,8 @@ impl SendAgentTool { Use `send` in one of two shapes: -- Downstream: `agentId + message (+ context)` sends the next concrete instruction to a direct child -- Upstream: `kind + payload` sends a typed delivery to the direct parent from a child context +- Downstream: `direction="child" + agentId + message (+ context)` sends the next concrete instruction to a direct child +- Upstream: `direction="parent" + kind + payload` sends a typed delivery to the direct parent from a child context Do not use `send` for status checks, vague reminders, sibling chat, or cross-tree routing."# .to_string() @@ -96,6 +96,11 @@ Do not use `send` for status checks, vague reminders, sibling chat, or cross-tre "type": "string", "description": "Target direct child stable ID." }, + "direction": { + "type": "string", + "enum": ["child", "parent"], + "description": "Direct collaboration edge direction." + }, "message": { "type": "string", "description": "Concrete instruction for the child." @@ -115,40 +120,41 @@ Do not use `send` for status checks, vague reminders, sibling chat, or cross-tre }, "oneOf": [ { - "required": ["agentId", "message"], - "not": { - "anyOf": [ - { "required": ["kind"] }, - { "required": ["payload"] } - ] + "required": ["direction", "agentId", "message"], + "properties": { + "direction": { "const": "child" } } }, { "oneOf": [ { - "required": ["kind", "payload"], + "required": ["direction", "kind", "payload"], "properties": { + "direction": { "const": "parent" }, "kind": { "const": "progress" }, "payload": progress_payload } }, { - "required": ["kind", "payload"], + "required": ["direction", "kind", "payload"], "properties": { + "direction": { "const": "parent" }, "kind": { "const": "completed" }, "payload": completed_payload } }, { - "required": ["kind", "payload"], + "required": ["direction", "kind", "payload"], "properties": { + "direction": { "const": "parent" }, "kind": { "const": "failed" }, "payload": failed_payload } }, { - "required": ["kind", "payload"], + "required": ["direction", "kind", "payload"], "properties": { + "direction": { "const": "parent" }, "kind": { "const": "close_request" }, "payload": close_request_payload } @@ -188,8 +194,8 @@ impl Tool for SendAgentTool { .prompt( ToolPromptMetadata::new( "Send a downstream instruction or an upstream typed delivery on the direct collaboration edge.", - "Use `send` with `agentId + message` when you need a direct child to continue. \ - Use `send` with `kind + payload` when you need to report progress, completion, \ + "Use `send` with `direction=\"child\" + agentId + message` when you need a direct child to continue. \ + Use `send` with `direction=\"parent\" + kind + payload` when you need to report progress, completion, \ failure, or a close request to your direct parent. The same middle-layer agent \ can use both directions in one turn.", ) @@ -200,7 +206,9 @@ impl Tool for SendAgentTool { .caveat( "Do not use `send` for status checks. If you already know a child is still \ running and are simply waiting, do not call `observe` repeatedly either; wait \ - briefly with your current shell tool instead.", + briefly with your current shell tool instead. Do not alternate \ + `sleep -> observe -> sleep -> observe` while the child has not produced a \ + new delivery.", ) .caveat( "Messages must stay on the direct parent/child edge. No sibling chat, no \ diff --git a/crates/adapter-tools/src/agent_tools/tests.rs b/crates/adapter-tools/src/agent_tools/tests.rs index fec05a01..7d800284 100644 --- a/crates/adapter-tools/src/agent_tools/tests.rs +++ b/crates/adapter-tools/src/agent_tools/tests.rs @@ -2,9 +2,10 @@ use std::sync::{Arc, Mutex}; use astrcode_core::{ AgentLifecycleStatus, AgentTurnOutcome, ArtifactRef, CancelToken, ChildAgentRef, - ChildSessionLineageKind, CloseAgentParams, CollaborationResult, CollaborationResultKind, - CompletedParentDeliveryPayload, DelegationMetadata, ObserveParams, ParentDelivery, - ParentDeliveryOrigin, ParentDeliveryPayload, ParentDeliveryTerminalSemantics, + ChildExecutionIdentity, ChildSessionLineageKind, CloseAgentParams, CollaborationResult, + CompletedParentDeliveryPayload, CompletedSubRunOutcome, DelegationMetadata, + FailedSubRunOutcome, ObserveParams, ParentDelivery, ParentDeliveryOrigin, + ParentDeliveryPayload, ParentDeliveryTerminalSemantics, ParentExecutionRef, ProgressParentDeliveryPayload, SendAgentParams, SendToChildParams, SendToParentParams, SpawnAgentParams, SpawnCapabilityGrant, SubRunFailure, SubRunFailureCode, SubRunHandoff, SubRunResult, Tool, ToolContext, @@ -21,6 +22,20 @@ struct RecordingExecutor { calls: Mutex>, } +fn boxed_subagent_executor(executor: Arc) -> Arc +where + T: SubAgentExecutor + 'static, +{ + executor +} + +fn boxed_collaboration_executor(executor: Arc) -> Arc +where + T: CollaborationExecutor + 'static, +{ + executor +} + #[async_trait] impl SubAgentExecutor for RecordingExecutor { async fn launch( @@ -29,10 +44,9 @@ impl SubAgentExecutor for RecordingExecutor { _ctx: &ToolContext, ) -> astrcode_core::Result { self.calls.lock().expect("calls lock").push(params); - Ok(SubRunResult { - lifecycle: AgentLifecycleStatus::Idle, - last_turn_outcome: Some(AgentTurnOutcome::Completed), - handoff: Some(SubRunHandoff { + Ok(SubRunResult::Completed { + outcome: CompletedSubRunOutcome::Completed, + handoff: SubRunHandoff { findings: vec!["checked".to_string()], artifacts: Vec::new(), delivery: Some(ParentDelivery { @@ -46,8 +60,7 @@ impl SubAgentExecutor for RecordingExecutor { artifacts: Vec::new(), }), }), - }), - failure: None, + }, }) } } @@ -65,7 +78,7 @@ async fn spawn_agent_tool_parses_params_and_returns_summary() { let executor = Arc::new(RecordingExecutor { calls: Mutex::new(Vec::new()), }); - let tool = SpawnAgentTool::new(executor.clone()); + let tool = SpawnAgentTool::new(boxed_subagent_executor(executor.clone())); let result = tool .execute( @@ -106,9 +119,9 @@ async fn spawn_agent_tool_parses_params_and_returns_summary() { #[tokio::test] async fn spawn_agent_tool_reports_invalid_params_as_tool_failure() { - let tool = SpawnAgentTool::new(Arc::new(RecordingExecutor { + let tool = SpawnAgentTool::new(boxed_subagent_executor(Arc::new(RecordingExecutor { calls: Mutex::new(Vec::new()), - })); + }))); let result = tool .execute( @@ -133,7 +146,7 @@ fn tool_description_is_stable_and_excludes_dynamic_profile_listing() { let executor = Arc::new(RecordingExecutor { calls: Mutex::new(Vec::new()), }); - let tool = SpawnAgentTool::new(executor); + let tool = SpawnAgentTool::new(boxed_subagent_executor(executor)); let definition = tool.definition(); @@ -153,7 +166,7 @@ fn spawn_tool_exposes_prompt_metadata_for_tool_summary_indexing() { let executor = Arc::new(RecordingExecutor { calls: Mutex::new(Vec::new()), }); - let tool = SpawnAgentTool::new(executor); + let tool = SpawnAgentTool::new(boxed_subagent_executor(executor)); let prompt = tool .capability_metadata() @@ -170,7 +183,7 @@ fn spawn_tool_exposes_prompt_metadata_for_tool_summary_indexing() { fn send_observe_close_prompt_metadata_stays_action_oriented() { let executor = Arc::new(RecordingCollabExecutor::new()); - let send_prompt = SendAgentTool::new(executor.clone()) + let send_prompt = SendAgentTool::new(boxed_collaboration_executor(executor.clone())) .capability_metadata() .prompt .expect("send should expose prompt metadata"); @@ -178,21 +191,31 @@ fn send_observe_close_prompt_metadata_stays_action_oriented() { assert!(send_prompt.guide.contains("direct child")); assert!(send_prompt.guide.contains("direct parent")); assert!(send_prompt.guide.contains("both directions in one turn")); + assert!( + send_prompt.caveats.iter().any( + |caveat| caveat.contains("Do not alternate `sleep -> observe -> sleep -> observe`") + ) + ); - let observe_prompt = ObserveAgentTool::new(executor.clone()) + let observe_prompt = ObserveAgentTool::new(boxed_collaboration_executor(executor.clone())) .capability_metadata() .prompt .expect("observe should expose prompt metadata"); assert!(observe_prompt.summary.contains("decide the next action")); assert!(observe_prompt.guide.contains("`wait`, `send`, or `close`")); assert!(observe_prompt.guide.contains("current child state")); + assert!( + observe_prompt.caveats.iter().any( + |caveat| caveat.contains("Do not alternate `sleep -> observe -> sleep -> observe`") + ) + ); assert!( !observe_prompt .guide .contains("Should I `send` another instruction") ); - let close_prompt = CloseAgentTool::new(executor) + let close_prompt = CloseAgentTool::new(boxed_collaboration_executor(executor)) .capability_metadata() .prompt .expect("close should expose prompt metadata"); @@ -215,10 +238,8 @@ async fn spawn_agent_tool_preserves_running_outcome_in_metadata() { _params: SpawnAgentParams, _ctx: &ToolContext, ) -> astrcode_core::Result { - Ok(SubRunResult { - lifecycle: AgentLifecycleStatus::Running, - last_turn_outcome: None, - handoff: Some(SubRunHandoff { + Ok(SubRunResult::Running { + handoff: SubRunHandoff { findings: vec!["status=running".to_string()], artifacts: Vec::new(), delivery: Some(ParentDelivery { @@ -230,13 +251,12 @@ async fn spawn_agent_tool_preserves_running_outcome_in_metadata() { message: "running".to_string(), }), }), - }), - failure: None, + }, }) } } - let tool = SpawnAgentTool::new(Arc::new(RunningExecutor)); + let tool = SpawnAgentTool::new(boxed_subagent_executor(Arc::new(RunningExecutor))); let result = tool .execute( "call-running".to_string(), @@ -270,23 +290,21 @@ async fn spawn_agent_tool_surfaces_failure_display_and_technical_messages_separa _params: SpawnAgentParams, _ctx: &ToolContext, ) -> astrcode_core::Result { - Ok(SubRunResult { - lifecycle: AgentLifecycleStatus::Idle, - last_turn_outcome: Some(AgentTurnOutcome::Failed), - handoff: None, - failure: Some(SubRunFailure { + Ok(SubRunResult::Failed { + outcome: FailedSubRunOutcome::Failed, + failure: SubRunFailure { code: SubRunFailureCode::Transport, display_message: "子 Agent 调用模型时网络连接中断,未完成任务。".to_string(), technical_message: "HTTP request error: failed to read anthropic response \ stream" .to_string(), retryable: true, - }), + }, }) } } - let tool = SpawnAgentTool::new(Arc::new(FailingExecutor)); + let tool = SpawnAgentTool::new(boxed_subagent_executor(Arc::new(FailingExecutor))); let result = tool .execute( "call-failed".to_string(), @@ -321,10 +339,8 @@ async fn spawn_agent_tool_background_returns_subrun_artifact() { _params: SpawnAgentParams, _ctx: &ToolContext, ) -> astrcode_core::Result { - Ok(SubRunResult { - lifecycle: AgentLifecycleStatus::Running, - last_turn_outcome: None, - handoff: Some(SubRunHandoff { + Ok(SubRunResult::Running { + handoff: SubRunHandoff { findings: Vec::new(), artifacts: vec![ ArtifactRef { @@ -369,13 +385,12 @@ async fn spawn_agent_tool_background_returns_subrun_artifact() { message: "spawn 已在后台启动。".to_string(), }), }), - }), - failure: None, + }, }) } } - let tool = SpawnAgentTool::new(Arc::new(BackgroundExecutor)); + let tool = SpawnAgentTool::new(boxed_subagent_executor(Arc::new(BackgroundExecutor))); let result = tool .execute( "call-background".to_string(), @@ -402,19 +417,16 @@ async fn spawn_agent_tool_background_returns_subrun_artifact() { assert_eq!(artifact_kind, Some("subRun")); assert_eq!( result - .metadata + .child_ref .as_ref() - .and_then(|value| value.get("openSessionId")) - .and_then(|value| value.as_str()), + .map(|child_ref| child_ref.open_session_id.as_str()), Some("session-child-42") ); assert_eq!( result - .metadata + .child_ref .as_ref() - .and_then(|value| value.get("agentRef")) - .and_then(|value| value.get("agentId")) - .and_then(|value| value.as_str()), + .map(|child_ref| child_ref.agent_id().as_str()), Some("agent-42") ); } @@ -430,10 +442,8 @@ async fn tool_flow_reuses_spawned_agent_id_for_send_and_close() { _params: SpawnAgentParams, _ctx: &ToolContext, ) -> astrcode_core::Result { - Ok(SubRunResult { - lifecycle: AgentLifecycleStatus::Running, - last_turn_outcome: None, - handoff: Some(SubRunHandoff { + Ok(SubRunResult::Running { + handoff: SubRunHandoff { findings: Vec::new(), artifacts: vec![ ArtifactRef { @@ -478,16 +488,15 @@ async fn tool_flow_reuses_spawned_agent_id_for_send_and_close() { message: "spawn 已在后台启动。".to_string(), }), }), - }), - failure: None, + }, }) } } - let spawn_tool = SpawnAgentTool::new(Arc::new(BackgroundExecutor)); + let spawn_tool = SpawnAgentTool::new(boxed_subagent_executor(Arc::new(BackgroundExecutor))); let executor = Arc::new(RecordingCollabExecutor::new()); - let send_tool = SendAgentTool::new(executor.clone()); - let close_tool = CloseAgentTool::new(executor.clone()); + let send_tool = SendAgentTool::new(boxed_collaboration_executor(executor.clone())); + let close_tool = CloseAgentTool::new(boxed_collaboration_executor(executor.clone())); let spawned = spawn_tool .execute( @@ -501,11 +510,9 @@ async fn tool_flow_reuses_spawned_agent_id_for_send_and_close() { .await .expect("spawn should succeed"); let spawned_agent_id = spawned - .metadata + .child_ref .as_ref() - .and_then(|value| value.get("agentRef")) - .and_then(|value| value.get("agentId")) - .and_then(|value| value.as_str()) + .map(|child_ref| child_ref.agent_id().as_str()) .expect("spawn should expose a stable agentId") .to_string(); @@ -513,6 +520,7 @@ async fn tool_flow_reuses_spawned_agent_id_for_send_and_close() { .execute( "call-flow-send".to_string(), json!({ + "direction": "child", "agentId": spawned_agent_id, "message": "继续执行第二轮" }), @@ -538,13 +546,13 @@ async fn tool_flow_reuses_spawned_agent_id_for_send_and_close() { assert_eq!(send_calls.len(), 1); assert!(matches!( &send_calls[0], - SendAgentParams::ToChild(SendToChildParams { agent_id, .. }) if agent_id == "agent-99" + SendAgentParams::ToChild(SendToChildParams { agent_id, .. }) if agent_id.as_str() == "agent-99" )); drop(send_calls); let close_calls = executor.close_calls.lock().expect("lock"); assert_eq!(close_calls.len(), 1); - assert_eq!(close_calls[0].agent_id, "agent-99"); + assert_eq!(close_calls[0].agent_id.as_str(), "agent-99"); } // ─── 协作工具测试 ─────────────────────────────────────────── @@ -568,14 +576,18 @@ impl RecordingCollabExecutor { fn sample_child_ref() -> ChildAgentRef { ChildAgentRef { - agent_id: "agent-42".to_string(), - session_id: "session-parent".to_string(), - sub_run_id: "subrun-42".to_string(), - parent_agent_id: Some("agent-parent".to_string()), - parent_sub_run_id: Some("subrun-parent".to_string()), + identity: ChildExecutionIdentity { + agent_id: "agent-42".into(), + session_id: "session-parent".into(), + sub_run_id: "subrun-42".into(), + }, + parent: ParentExecutionRef { + parent_agent_id: Some("agent-parent".into()), + parent_sub_run_id: Some("subrun-parent".into()), + }, lineage_kind: ChildSessionLineageKind::Spawn, status: AgentLifecycleStatus::Running, - open_session_id: "session-child-42".to_string(), + open_session_id: "session-child-42".into(), } } @@ -605,17 +617,11 @@ impl CollaborationExecutor for RecordingCollabExecutor { _ctx: &ToolContext, ) -> astrcode_core::Result { self.send_calls.lock().expect("lock").push(params); - Ok(CollaborationResult { - accepted: true, - kind: CollaborationResultKind::Sent, + Ok(CollaborationResult::Sent { agent_ref: Some(sample_child_ref()), - delivery_id: Some("delivery-1".to_string()), + delivery_id: Some("delivery-1".into()), summary: Some("消息已发送".to_string()), - observe_result: None, delegation: Some(sample_delegation(false)), - cascade: None, - closed_root_agent_id: None, - failure: None, }) } @@ -625,17 +631,10 @@ impl CollaborationExecutor for RecordingCollabExecutor { _ctx: &ToolContext, ) -> astrcode_core::Result { self.close_calls.lock().expect("lock").push(params); - Ok(CollaborationResult { - accepted: true, - kind: CollaborationResultKind::Closed, - agent_ref: None, - delivery_id: None, + Ok(CollaborationResult::Closed { summary: Some("子 Agent 已关闭".to_string()), - observe_result: None, - delegation: None, - cascade: Some(true), - closed_root_agent_id: Some("agent-42".to_string()), - failure: None, + cascade: true, + closed_root_agent_id: "agent-42".into(), }) } @@ -646,39 +645,21 @@ impl CollaborationExecutor for RecordingCollabExecutor { ) -> astrcode_core::Result { let agent_id = params.agent_id.clone(); self.observe_calls.lock().expect("lock").push(params); - Ok(CollaborationResult { - accepted: true, - kind: CollaborationResultKind::Observed, - agent_ref: Some(sample_child_ref()), - delivery_id: None, - summary: Some(format!( - "子 Agent {} 当前为 Idle;建议 send_or_close:上一轮已完成。", - agent_id - )), - observe_result: Some(astrcode_core::ObserveAgentResult { - agent_id, - sub_run_id: "subrun-42".to_string(), - session_id: "session-parent".to_string(), - open_session_id: "session-child-42".to_string(), - parent_agent_id: "agent-parent".to_string(), + Ok(CollaborationResult::Observed { + agent_ref: sample_child_ref(), + summary: format!("子 Agent {} 当前为 Idle;最近输出:done。", agent_id), + observe_result: Box::new(astrcode_core::ObserveSnapshot { + agent_id: agent_id.to_string(), + session_id: "session-child-42".to_string(), lifecycle_status: AgentLifecycleStatus::Idle, last_turn_outcome: Some(AgentTurnOutcome::Completed), phase: "Idle".to_string(), turn_count: 1, - pending_message_count: 0, active_task: None, - pending_task: None, - recent_mailbox_messages: vec!["最近一条 mailbox 摘要".to_string()], - last_output: Some("done".to_string()), - delegation: Some(sample_delegation(false)), - recommended_next_action: "send_or_close".to_string(), - recommended_reason: "上一轮已完成。".to_string(), - delivery_freshness: "ready_for_follow_up".to_string(), + last_output_tail: Some("done".to_string()), + last_turn_tail: vec!["最近一条 input queue 摘要".to_string()], }), delegation: Some(sample_delegation(false)), - cascade: None, - closed_root_agent_id: None, - failure: None, }) } } @@ -688,12 +669,13 @@ impl CollaborationExecutor for RecordingCollabExecutor { #[tokio::test] async fn send_agent_tool_parses_downstream_params_and_delegates_to_executor() { let executor = Arc::new(RecordingCollabExecutor::new()); - let tool = SendAgentTool::new(executor.clone()); + let tool = SendAgentTool::new(boxed_collaboration_executor(executor.clone())); let result = tool .execute( "call-send-1".to_string(), json!({ + "direction": "child", "agentId": "agent-42", "message": "请修改第三部分", "context": "关注性能" @@ -714,7 +696,7 @@ async fn send_agent_tool_parses_downstream_params_and_delegates_to_executor() { agent_id, message, context, - }) if agent_id == "agent-42" + }) if agent_id.as_str() == "agent-42" && message == "请修改第三部分" && context.as_deref() == Some("关注性能") )); @@ -723,12 +705,13 @@ async fn send_agent_tool_parses_downstream_params_and_delegates_to_executor() { #[tokio::test] async fn send_agent_tool_parses_upstream_params_and_delegates_to_executor() { let executor = Arc::new(RecordingCollabExecutor::new()); - let tool = SendAgentTool::new(executor.clone()); + let tool = SendAgentTool::new(boxed_collaboration_executor(executor.clone())); let result = tool .execute( "call-send-upstream".to_string(), json!({ + "direction": "parent", "kind": "completed", "payload": { "message": "子任务已完成", @@ -760,7 +743,9 @@ async fn send_agent_tool_parses_upstream_params_and_delegates_to_executor() { #[tokio::test] async fn send_agent_tool_rejects_missing_branch_shape() { - let tool = SendAgentTool::new(Arc::new(RecordingCollabExecutor::new())); + let tool = SendAgentTool::new(boxed_collaboration_executor(Arc::new( + RecordingCollabExecutor::new(), + ))); let result = tool .execute( @@ -782,12 +767,14 @@ async fn send_agent_tool_rejects_missing_branch_shape() { #[tokio::test] async fn send_agent_tool_rejects_empty_downstream_message() { - let tool = SendAgentTool::new(Arc::new(RecordingCollabExecutor::new())); + let tool = SendAgentTool::new(boxed_collaboration_executor(Arc::new( + RecordingCollabExecutor::new(), + ))); let result = tool .execute( "call-send-empty-downstream".to_string(), - json!({"agentId": "agent-42", "message": " "}), + json!({"direction": "child", "agentId": "agent-42", "message": " "}), &tool_context(), ) .await @@ -804,12 +791,15 @@ async fn send_agent_tool_rejects_empty_downstream_message() { #[tokio::test] async fn send_agent_tool_rejects_empty_upstream_message() { - let tool = SendAgentTool::new(Arc::new(RecordingCollabExecutor::new())); + let tool = SendAgentTool::new(boxed_collaboration_executor(Arc::new( + RecordingCollabExecutor::new(), + ))); let result = tool .execute( "call-send-empty-upstream".to_string(), json!({ + "direction": "parent", "kind": "progress", "payload": { "message": " " } }), @@ -829,7 +819,9 @@ async fn send_agent_tool_rejects_empty_upstream_message() { #[test] fn send_agent_tool_schema_uses_openai_compatible_top_level_object() { - let tool = SendAgentTool::new(Arc::new(RecordingCollabExecutor::new())); + let tool = SendAgentTool::new(boxed_collaboration_executor(Arc::new( + RecordingCollabExecutor::new(), + ))); let schema = tool.definition().parameters; @@ -856,7 +848,7 @@ fn send_agent_tool_schema_uses_openai_compatible_top_level_object() { #[tokio::test] async fn close_agent_tool_parses_params_and_returns_cascade_info() { let executor = Arc::new(RecordingCollabExecutor::new()); - let tool = CloseAgentTool::new(executor.clone()); + let tool = CloseAgentTool::new(boxed_collaboration_executor(executor.clone())); let result = tool .execute( @@ -872,12 +864,14 @@ async fn close_agent_tool_parses_params_and_returns_cascade_info() { assert_eq!(result.tool_name, "close"); let calls = executor.close_calls.lock().expect("lock"); assert_eq!(calls.len(), 1); - assert_eq!(calls[0].agent_id, "agent-42"); + assert_eq!(calls[0].agent_id.as_str(), "agent-42"); } #[tokio::test] async fn close_agent_tool_rejects_empty_agent_id() { - let tool = CloseAgentTool::new(Arc::new(RecordingCollabExecutor::new())); + let tool = CloseAgentTool::new(boxed_collaboration_executor(Arc::new( + RecordingCollabExecutor::new(), + ))); let result = tool .execute( @@ -902,7 +896,7 @@ async fn close_agent_tool_rejects_empty_agent_id() { #[tokio::test] async fn observe_agent_tool_parses_params_and_delegates_to_executor() { let executor = Arc::new(RecordingCollabExecutor::new()); - let tool = ObserveAgentTool::new(executor.clone()); + let tool = ObserveAgentTool::new(boxed_collaboration_executor(executor.clone())); let result = tool .execute( @@ -919,14 +913,14 @@ async fn observe_agent_tool_parses_params_and_delegates_to_executor() { result .metadata .as_ref() - .and_then(|value| value.get("observeResult")) - .and_then(|value| value.get("recommendedNextAction")) + .and_then(|value| value.get("observe_result")) + .and_then(|value| value.get("phase")) .and_then(|value| value.as_str()) - .is_some_and(|value| value == "send_or_close") + .is_some_and(|value| value == "Idle") ); let calls = executor.observe_calls.lock().expect("lock"); assert_eq!(calls.len(), 1); - assert_eq!(calls[0].agent_id, "agent-42"); + assert_eq!(calls[0].agent_id.as_str(), "agent-42"); } #[test] @@ -934,36 +928,21 @@ fn collaboration_result_metadata_projects_idle_reuse_and_branch_mismatch_hints() let mapped = map_collaboration_result( "call-observe-advisory".to_string(), "observe", - CollaborationResult { - accepted: true, - kind: CollaborationResultKind::Observed, - agent_ref: Some(sample_child_ref()), - delivery_id: None, - summary: Some("子 Agent agent-42 当前为 Idle。".to_string()), - observe_result: Some(astrcode_core::ObserveAgentResult { + CollaborationResult::Observed { + agent_ref: sample_child_ref(), + summary: "子 Agent agent-42 当前为 Idle。".to_string(), + observe_result: Box::new(astrcode_core::ObserveSnapshot { agent_id: "agent-42".to_string(), - sub_run_id: "subrun-42".to_string(), - session_id: "session-parent".to_string(), - open_session_id: "session-child-42".to_string(), - parent_agent_id: "agent-parent".to_string(), + session_id: "session-child-42".to_string(), lifecycle_status: AgentLifecycleStatus::Idle, last_turn_outcome: Some(AgentTurnOutcome::Completed), phase: "Idle".to_string(), turn_count: 1, - pending_message_count: 0, active_task: None, - pending_task: None, - recent_mailbox_messages: Vec::new(), - last_output: Some("done".to_string()), - delegation: Some(sample_delegation(false)), - recommended_next_action: "send_or_close".to_string(), - recommended_reason: "同一责任分支可继续 send。".to_string(), - delivery_freshness: "ready_for_follow_up".to_string(), + last_turn_tail: Vec::new(), + last_output_tail: Some("done".to_string()), }), delegation: Some(sample_delegation(false)), - cascade: None, - closed_root_agent_id: None, - failure: None, }, ); @@ -994,36 +973,21 @@ fn collaboration_result_metadata_projects_restricted_child_broader_tool_hint() { let mapped = map_collaboration_result( "call-observe-restricted".to_string(), "observe", - CollaborationResult { - accepted: true, - kind: CollaborationResultKind::Observed, - agent_ref: Some(sample_child_ref()), - delivery_id: None, - summary: Some("restricted child idle".to_string()), - observe_result: Some(astrcode_core::ObserveAgentResult { + CollaborationResult::Observed { + agent_ref: sample_child_ref(), + summary: "restricted child idle".to_string(), + observe_result: Box::new(astrcode_core::ObserveSnapshot { agent_id: "agent-42".to_string(), - sub_run_id: "subrun-42".to_string(), - session_id: "session-parent".to_string(), - open_session_id: "session-child-42".to_string(), - parent_agent_id: "agent-parent".to_string(), + session_id: "session-child-42".to_string(), lifecycle_status: AgentLifecycleStatus::Idle, last_turn_outcome: Some(AgentTurnOutcome::Completed), phase: "Idle".to_string(), turn_count: 1, - pending_message_count: 0, active_task: None, - pending_task: None, - recent_mailbox_messages: Vec::new(), - last_output: Some("done".to_string()), - delegation: Some(sample_delegation(true)), - recommended_next_action: "send_or_close".to_string(), - recommended_reason: "同一责任分支且工具面匹配时可继续复用。".to_string(), - delivery_freshness: "ready_for_follow_up".to_string(), + last_turn_tail: Vec::new(), + last_output_tail: Some("done".to_string()), }), delegation: Some(sample_delegation(true)), - cascade: None, - closed_root_agent_id: None, - failure: None, }, ); @@ -1051,7 +1015,9 @@ fn collaboration_result_metadata_projects_restricted_child_broader_tool_hint() { #[tokio::test] async fn observe_agent_tool_rejects_empty_agent_id() { - let tool = ObserveAgentTool::new(Arc::new(RecordingCollabExecutor::new())); + let tool = ObserveAgentTool::new(boxed_collaboration_executor(Arc::new( + RecordingCollabExecutor::new(), + ))); let result = tool .execute( @@ -1077,7 +1043,7 @@ async fn observe_agent_tool_rejects_empty_agent_id() { fn collaboration_prompt_metadata_stays_action_oriented() { let executor = Arc::new(RecordingCollabExecutor::new()); - let send_prompt = SendAgentTool::new(executor.clone()) + let send_prompt = SendAgentTool::new(boxed_collaboration_executor(executor.clone())) .capability_metadata() .prompt .expect("send should expose prompt metadata"); @@ -1085,16 +1051,26 @@ fn collaboration_prompt_metadata_stays_action_oriented() { assert!(send_prompt.guide.contains("direct child")); assert!(send_prompt.guide.contains("direct parent")); assert!(send_prompt.guide.contains("both directions in one turn")); + assert!( + send_prompt.caveats.iter().any( + |caveat| caveat.contains("Do not alternate `sleep -> observe -> sleep -> observe`") + ) + ); - let observe_prompt = ObserveAgentTool::new(executor.clone()) + let observe_prompt = ObserveAgentTool::new(boxed_collaboration_executor(executor.clone())) .capability_metadata() .prompt .expect("observe should expose prompt metadata"); assert!(observe_prompt.summary.contains("decide the next action")); assert!(observe_prompt.guide.contains("`wait`, `send`, or `close`")); assert!(observe_prompt.guide.contains("current child state")); + assert!( + observe_prompt.caveats.iter().any( + |caveat| caveat.contains("Do not alternate `sleep -> observe -> sleep -> observe`") + ) + ); - let close_prompt = CloseAgentTool::new(executor) + let close_prompt = CloseAgentTool::new(boxed_collaboration_executor(executor)) .capability_metadata() .prompt .expect("close should expose prompt metadata"); @@ -1110,9 +1086,13 @@ fn collaboration_prompt_metadata_stays_action_oriented() { fn collaboration_tools_registered_in_public_surface() { let executor = Arc::new(RecordingCollabExecutor::new()); let tools: Vec> = vec![ - Box::new(SendAgentTool::new(executor.clone())), - Box::new(ObserveAgentTool::new(executor.clone())), - Box::new(CloseAgentTool::new(executor)), + Box::new(SendAgentTool::new(boxed_collaboration_executor( + executor.clone(), + ))) as Box, + Box::new(ObserveAgentTool::new(boxed_collaboration_executor( + executor.clone(), + ))) as Box, + Box::new(CloseAgentTool::new(boxed_collaboration_executor(executor))) as Box, ]; let names: Vec = tools.iter().map(|t| t.definition().name.clone()).collect(); @@ -1123,24 +1103,29 @@ fn collaboration_tools_registered_in_public_surface() { fn collaboration_tool_definitions_exclude_runtime_internals() { let executor = Arc::new(RecordingCollabExecutor::new()); - let send_def = SendAgentTool::new(executor.clone()).definition(); + let send_def = SendAgentTool::new(boxed_collaboration_executor(executor.clone())).definition(); assert!(!send_def.description.contains("AgentControl")); assert!(!send_def.description.contains("AgentInboxEnvelope")); - let close_def = CloseAgentTool::new(executor.clone()).definition(); + let close_def = + CloseAgentTool::new(boxed_collaboration_executor(executor.clone())).definition(); assert!(!close_def.description.contains("CancelToken")); - let observe_def = ObserveAgentTool::new(executor).definition(); - assert!(!observe_def.description.contains("MailboxProjection")); + let observe_def = ObserveAgentTool::new(boxed_collaboration_executor(executor)).definition(); + assert!(!observe_def.description.contains("InputQueueProjection")); } #[test] fn old_tool_names_not_in_definitions() { let executor = Arc::new(RecordingCollabExecutor::new()); let tools: Vec> = vec![ - Box::new(SendAgentTool::new(executor.clone())), - Box::new(ObserveAgentTool::new(executor.clone())), - Box::new(CloseAgentTool::new(executor)), + Box::new(SendAgentTool::new(boxed_collaboration_executor( + executor.clone(), + ))) as Box, + Box::new(ObserveAgentTool::new(boxed_collaboration_executor( + executor.clone(), + ))) as Box, + Box::new(CloseAgentTool::new(boxed_collaboration_executor(executor))) as Box, ]; for tool in &tools { diff --git a/crates/adapter-tools/src/builtin_tools/apply_patch.rs b/crates/adapter-tools/src/builtin_tools/apply_patch.rs index 52d33d83..927711a0 100644 --- a/crates/adapter-tools/src/builtin_tools/apply_patch.rs +++ b/crates/adapter-tools/src/builtin_tools/apply_patch.rs @@ -23,11 +23,11 @@ use astrcode_core::{ }; use async_trait::async_trait; use serde::Deserialize; -use serde_json::json; +use serde_json::{Map, json}; use crate::builtin_tools::fs_common::{ - build_text_change_report, check_cancel, is_symlink, is_unc_path, read_utf8_file, resolve_path, - write_text_file, + build_text_change_report, check_cancel, ensure_not_canonical_session_plan_write_target, + is_symlink, is_unc_path, read_utf8_file, resolve_path, write_text_file, }; /// ApplyPatch 工具实现。 @@ -123,12 +123,17 @@ fn parse_patch(patch: &str) -> Result> { continue; } - if line.starts_with("--- ") { - let old_path = strip_diff_prefix(line.strip_prefix("--- ").unwrap()); + if let Some(old_path_line) = line.strip_prefix("--- ") { + let old_path = strip_diff_prefix(old_path_line); i += 1; - if i < lines.len() && lines[i].starts_with("+++ ") { - let new_path = strip_diff_prefix(lines[i].strip_prefix("+++ ").unwrap()); + if i < lines.len() { + let Some(new_path_line) = lines[i].strip_prefix("+++ ") else { + return Err(AstrError::Validation( + "patch format error: expected '+++ new_path' after '--- old_path'".into(), + )); + }; + let new_path = strip_diff_prefix(new_path_line); i += 1; let hunks = parse_hunks(&lines, &mut i)?; @@ -145,10 +150,6 @@ fn parse_patch(patch: &str) -> Result> { }, hunks, }); - } else { - return Err(AstrError::Validation( - "patch format error: expected '+++ new_path' after '--- old_path'".into(), - )); } } else { return Err(AstrError::Validation(format!( @@ -203,7 +204,11 @@ fn parse_hunks(lines: &[&str], i: &mut usize) -> Result> { hunk_lines.push(HunkLine::Context(String::new())); *i += 1; } else { - match l.chars().next().unwrap() { + let Some(prefix) = l.chars().next() else { + *i += 1; + continue; + }; + match prefix { ' ' => { hunk_lines.push(HunkLine::Context(l.chars().skip(1).collect())); *i += 1; @@ -511,6 +516,17 @@ async fn apply_file_patch(file_patch: &FilePatch, ctx: &ToolContext) -> FileChan }; }, }; + if let Err(error) = + ensure_not_canonical_session_plan_write_target(ctx, &target_path, "apply_patch") + { + return FileChange { + change_type: change_type.into(), + path: target_path_str.clone(), + applied: false, + summary: error.to_string(), + error: Some(error.to_string()), + }; + } // UNC 路径检查:防止 Windows NTLM 凭据泄露 if is_unc_path(&target_path) { @@ -535,10 +551,13 @@ async fn apply_file_patch(file_patch: &FilePatch, ctx: &ToolContext) -> FileChan path: target_path_str.clone(), applied: false, summary: format!( - "refusing to patch symlink '{}' (symlinks may point outside working directory)", + "refusing to patch symlink '{}' (symlinks may point outside the intended target \ + path)", target_path.display() ), - error: Some("refusing to patch symlink (may point outside working directory)".into()), + error: Some( + "refusing to patch symlink (may point outside the intended target path)".into(), + ), }; } @@ -711,7 +730,7 @@ impl Tool for ApplyPatchTool { ToolCapabilityMetadata::builtin() .tags(["filesystem", "write", "patch", "diff"]) .permission("filesystem.write") - .side_effect(SideEffect::Workspace) + .side_effect(SideEffect::Local) .prompt( ToolPromptMetadata::new( "Apply a unified diff patch across one or more files.", @@ -779,6 +798,7 @@ impl Tool for ApplyPatchTool { let applied = results.iter().filter(|r| r.applied).count(); let failed = total_files - applied; + let first_error = results.iter().find_map(|result| result.error.clone()); let (ok, output, error) = if failed == 0 { ( true, @@ -789,7 +809,7 @@ impl Tool for ApplyPatchTool { ( false, format!("apply_patch: all {total_files} file(s) failed to apply"), - Some(format!("{failed} file(s) failed to apply")), + first_error.or(Some(format!("{failed} file(s) failed to apply"))), ) } else { ( @@ -811,6 +831,7 @@ impl Tool for ApplyPatchTool { output, error, metadata: Some(metadata), + child_ref: None, duration_ms: started_at.elapsed().as_millis() as u64, truncated: false, }) @@ -833,6 +854,7 @@ fn make_error_result( "filesApplied": 0, "filesFailed": 0, })), + child_ref: None, duration_ms: started_at.elapsed().as_millis() as u64, truncated: false, }) @@ -846,18 +868,15 @@ fn build_apply_patch_metadata( let file_results: Vec = results .iter() .map(|r| { - let mut obj = json!({ - "path": r.path, - "changeType": r.change_type, - "applied": r.applied, - "summary": r.summary, - }); + let mut obj = Map::new(); + obj.insert("path".to_string(), json!(r.path)); + obj.insert("changeType".to_string(), json!(r.change_type)); + obj.insert("applied".to_string(), json!(r.applied)); + obj.insert("summary".to_string(), json!(r.summary)); if let Some(err) = &r.error { - obj.as_object_mut() - .unwrap() - .insert("error".to_string(), json!(err)); + obj.insert("error".to_string(), json!(err)); } - obj + serde_json::Value::Object(obj) }) .collect(); @@ -1124,6 +1143,33 @@ mod tests { assert_eq!(content, "fn foo() {\r\n new();\r\n}\r\n"); } + #[tokio::test] + async fn apply_patch_allows_relative_path_outside_working_dir() { + let parent = tempfile::tempdir().expect("tempdir"); + let workspace = parent.path().join("workspace"); + tokio::fs::create_dir_all(&workspace) + .await + .expect("workspace should be created"); + let tool = ApplyPatchTool; + + let patch = "--- /dev/null\n+++ b/../outside.txt\n@@ -0,0 +1,1 @@\n+outside patch\n"; + + let result = tool + .execute( + "tc-patch-outside".into(), + json!({ "patch": patch }), + &test_tool_context_for(&workspace), + ) + .await + .expect("should execute"); + + assert!(result.ok, "should succeed: {}", result.output); + let content = tokio::fs::read_to_string(parent.path().join("outside.txt")) + .await + .expect("outside file should be readable"); + assert_eq!(content, "outside patch"); + } + #[tokio::test] async fn apply_patch_delete_validates_existing_content() { let temp = tempfile::tempdir().expect("tempdir"); @@ -1150,4 +1196,30 @@ mod tests { "file should remain when delete validation fails" ); } + + #[tokio::test] + async fn apply_patch_rejects_canonical_session_plan_targets() { + let temp = tempfile::tempdir().expect("tempdir"); + let tool = ApplyPatchTool; + let patch = "--- /dev/null\n+++ \ + b/.astrcode-test-state/sessions/session-test/plan/cleanup-crates.md\n@@ -0,0 \ + +1,1 @@\n+# Plan: Cleanup crates\n"; + + let result = tool + .execute( + "tc-patch-plan".into(), + json!({ "patch": patch }), + &test_tool_context_for(temp.path()), + ) + .await + .expect("should return result"); + + assert!(!result.ok); + assert!( + result + .error + .unwrap_or_default() + .contains("upsertSessionPlan") + ); + } } diff --git a/crates/adapter-tools/src/builtin_tools/edit_file.rs b/crates/adapter-tools/src/builtin_tools/edit_file.rs index 03073a58..cce2794b 100644 --- a/crates/adapter-tools/src/builtin_tools/edit_file.rs +++ b/crates/adapter-tools/src/builtin_tools/edit_file.rs @@ -32,9 +32,10 @@ use serde::Deserialize; use serde_json::json; use crate::builtin_tools::fs_common::{ - build_text_change_report, capture_file_observation, check_cancel, file_observation_matches, - is_symlink, is_unc_path, load_file_observation, read_utf8_file, remember_file_observation, - resolve_path, write_text_file, + build_text_change_report, capture_file_observation, check_cancel, + ensure_not_canonical_session_plan_write_target, file_observation_matches, is_symlink, + is_unc_path, load_file_observation, read_utf8_file, remember_file_observation, resolve_path, + write_text_file, }; /// 可编辑文件的最大大小(1 GiB)。 @@ -143,7 +144,7 @@ impl Tool for EditFileTool { "properties": { "path": { "type": "string", - "description": "File path to edit (relative to workspace or absolute)." + "description": "File path to edit (relative to the current working directory or absolute)." }, "oldStr": { "type": "string", @@ -183,7 +184,7 @@ impl Tool for EditFileTool { ToolCapabilityMetadata::builtin() .tags(["filesystem", "write", "edit"]) .permission("filesystem.write") - .side_effect(SideEffect::Workspace) + .side_effect(SideEffect::Local) .prompt( ToolPromptMetadata::new( "Apply a narrow, safety-checked string replacement inside an existing file.", @@ -275,6 +276,7 @@ impl Tool for EditFileTool { let started_at = Instant::now(); let path = resolve_path(ctx, &args.path)?; + ensure_not_canonical_session_plan_write_target(ctx, &path, "editFile")?; // UNC 路径检查:防止 Windows NTLM 凭据泄露 if is_unc_path(&path) { @@ -292,6 +294,7 @@ impl Tool for EditFileTool { "path": path.to_string_lossy(), "uncPath": true, })), + child_ref: None, duration_ms: started_at.elapsed().as_millis() as u64, truncated: false, }); @@ -305,13 +308,15 @@ impl Tool for EditFileTool { ok: false, output: String::new(), error: Some(format!( - "refusing to edit symlink '{}' (symlinks may point outside working directory)", + "refusing to edit symlink '{}' (symlinks may point outside the intended \ + target path)", path.display() )), metadata: Some(json!({ "path": path.to_string_lossy(), "isSymlink": true, })), + child_ref: None, duration_ms: started_at.elapsed().as_millis() as u64, truncated: false, }); @@ -346,6 +351,7 @@ impl Tool for EditFileTool { "bytes": metadata.len(), "tooLarge": true, })), + child_ref: None, duration_ms: started_at.elapsed().as_millis() as u64, truncated: false, }); @@ -452,6 +458,7 @@ impl Tool for EditFileTool { }, error: None, metadata: Some(metadata), + child_ref: None, duration_ms: started_at.elapsed().as_millis() as u64, truncated: false, }) @@ -506,6 +513,7 @@ fn make_edit_error_result( metadata: Some(json!({ "path": path.to_string_lossy(), })), + child_ref: None, duration_ms: started_at.elapsed().as_millis() as u64, truncated: false, }) @@ -901,6 +909,39 @@ mod tests { assert_eq!(meta["editsApplied"], json!(2)); } + #[tokio::test] + async fn edit_file_allows_relative_path_outside_working_dir() { + let parent = tempfile::tempdir().expect("tempdir should be created"); + let workspace = parent.path().join("workspace"); + let outside = parent.path().join("outside.txt"); + tokio::fs::create_dir_all(&workspace) + .await + .expect("workspace should be created"); + tokio::fs::write(&outside, "alpha beta") + .await + .expect("outside file should be written"); + let tool = EditFileTool; + + let result = tool + .execute( + "tc-edit-outside".to_string(), + json!({ + "path": "../outside.txt", + "oldStr": "alpha", + "newStr": "omega" + }), + &test_tool_context_for(&workspace), + ) + .await + .expect("editFile should execute"); + + assert!(result.ok); + let content = tokio::fs::read_to_string(&outside) + .await + .expect("outside file should be readable"); + assert_eq!(content, "omega beta"); + } + #[tokio::test] async fn edit_file_batch_edits_rejects_empty_array() { let temp = tempfile::tempdir().expect("tempdir should be created"); @@ -925,6 +966,45 @@ mod tests { assert!(result.unwrap_err().to_string().contains("cannot be empty")); } + #[tokio::test] + async fn edit_file_rejects_canonical_session_plan_targets() { + let temp = tempfile::tempdir().expect("tempdir should be created"); + let file = temp + .path() + .join(".astrcode-test-state") + .join("sessions") + .join("session-test") + .join("plan") + .join("cleanup-crates.md"); + tokio::fs::create_dir_all(file.parent().expect("plan file should have a parent")) + .await + .expect("plan dir should be created"); + tokio::fs::write(&file, "# Plan: Cleanup crates\n") + .await + .expect("seed write should work"); + let tool = EditFileTool; + + let result = tool + .execute( + "tc-edit-plan".to_string(), + json!({ + "path": file.to_string_lossy(), + "oldStr": "Cleanup crates", + "newStr": "Prompt governance" + }), + &test_tool_context_for(temp.path()), + ) + .await; + + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("upsertSessionPlan") + ); + } + #[tokio::test] async fn edit_file_cannot_mix_single_and_batch() { let temp = tempfile::tempdir().expect("tempdir should be created"); diff --git a/crates/adapter-tools/src/builtin_tools/enter_plan_mode.rs b/crates/adapter-tools/src/builtin_tools/enter_plan_mode.rs new file mode 100644 index 00000000..2942c4a5 --- /dev/null +++ b/crates/adapter-tools/src/builtin_tools/enter_plan_mode.rs @@ -0,0 +1,198 @@ +//! `enterPlanMode` 工具。 +//! +//! 允许模型在执行 mode 中显式切换到 plan mode,把“先规划再执行”做成正式状态迁移, +//! 而不是只靠提示词暗示。 + +use std::time::Instant; + +use astrcode_core::{ + AstrError, ModeId, Result, SideEffect, Tool, ToolCapabilityMetadata, ToolContext, + ToolDefinition, ToolExecutionResult, ToolPromptMetadata, +}; +use async_trait::async_trait; +use serde::Deserialize; +use serde_json::{Value, json}; + +use crate::builtin_tools::mode_transition::emit_mode_changed; + +#[derive(Default)] +pub struct EnterPlanModeTool; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct EnterPlanModeArgs { + #[serde(default)] + reason: Option, +} + +#[async_trait] +impl Tool for EnterPlanModeTool { + fn definition(&self) -> ToolDefinition { + ToolDefinition { + name: "enterPlanMode".to_string(), + description: "Switch the current session into plan mode before doing planning-heavy \ + work." + .to_string(), + parameters: json!({ + "type": "object", + "properties": { + "reason": { + "type": "string", + "description": "Optional short reason for switching into plan mode." + } + }, + "additionalProperties": false + }), + } + } + + fn capability_metadata(&self) -> ToolCapabilityMetadata { + ToolCapabilityMetadata::builtin() + .tags(["plan", "mode", "session"]) + .side_effect(SideEffect::Local) + .prompt( + ToolPromptMetadata::new( + "Switch the current session into plan mode.", + "Use `enterPlanMode` when the task needs an explicit planning phase before \ + execution, or when the user directly asks for a plan. After entering, \ + inspect the relevant code and tests, keep updating the session plan artifact \ + until it is executable, then use `exitPlanMode` to present the finalized \ + plan.", + ) + .example( + "{ reason: \"Need to inspect the codebase and propose a safe refactor plan\" }", + ) + .prompt_tag("plan"), + ) + } + + async fn execute( + &self, + tool_call_id: String, + args: Value, + ctx: &ToolContext, + ) -> Result { + let started_at = Instant::now(); + let args: EnterPlanModeArgs = serde_json::from_value(args) + .map_err(|error| AstrError::parse("invalid args for enterPlanMode", error))?; + let from_mode_id = ctx.current_mode_id().clone(); + let to_mode_id = ModeId::plan(); + + if from_mode_id == to_mode_id { + return Ok(ToolExecutionResult { + tool_call_id, + tool_name: "enterPlanMode".to_string(), + ok: true, + output: "session is already in plan mode".to_string(), + error: None, + metadata: Some(mode_metadata( + &from_mode_id, + &to_mode_id, + false, + args.reason.as_deref(), + )), + child_ref: None, + duration_ms: started_at.elapsed().as_millis() as u64, + truncated: false, + }); + } + + emit_mode_changed( + ctx, + "enterPlanMode", + from_mode_id.clone(), + to_mode_id.clone(), + ) + .await?; + + Ok(ToolExecutionResult { + tool_call_id, + tool_name: "enterPlanMode".to_string(), + ok: true, + output: "entered plan mode; inspect the relevant code and tests, keep refining the \ + session plan artifact until it is executable, then use exitPlanMode to \ + present it for user review." + .to_string(), + error: None, + metadata: Some(mode_metadata( + &from_mode_id, + &to_mode_id, + true, + args.reason.as_deref(), + )), + child_ref: None, + duration_ms: started_at.elapsed().as_millis() as u64, + truncated: false, + }) + } +} + +fn mode_metadata( + from_mode_id: &ModeId, + to_mode_id: &ModeId, + changed: bool, + reason: Option<&str>, +) -> Value { + json!({ + "schema": "modeTransition", + "fromModeId": from_mode_id.as_str(), + "toModeId": to_mode_id.as_str(), + "modeChanged": changed, + "reason": reason, + }) +} + +#[cfg(test)] +mod tests { + use std::sync::{Arc, Mutex}; + + use astrcode_core::{StorageEvent, StorageEventPayload}; + + use super::*; + use crate::test_support::test_tool_context_for; + + struct RecordingSink { + events: Arc>>, + } + + #[async_trait] + impl astrcode_core::ToolEventSink for RecordingSink { + async fn emit(&self, event: StorageEvent) -> Result<()> { + self.events + .lock() + .expect("recording sink lock should work") + .push(event); + Ok(()) + } + } + + #[tokio::test] + async fn enter_plan_mode_emits_mode_change_event() { + let tool = EnterPlanModeTool; + let events = Arc::new(Mutex::new(Vec::new())); + let ctx = test_tool_context_for(std::env::temp_dir()) + .with_current_mode_id(ModeId::code()) + .with_event_sink(Arc::new(RecordingSink { + events: Arc::clone(&events), + })); + + let result = tool + .execute( + "tc-enter-plan".to_string(), + json!({ "reason": "Need a plan first" }), + &ctx, + ) + .await + .expect("enterPlanMode should execute"); + + assert!(result.ok); + let events = events.lock().expect("recording sink lock should work"); + assert!(matches!( + events.as_slice(), + [StorageEvent { + payload: StorageEventPayload::ModeChanged { from, to, .. }, + .. + }] if *from == ModeId::code() && *to == ModeId::plan() + )); + } +} diff --git a/crates/adapter-tools/src/builtin_tools/exit_plan_mode.rs b/crates/adapter-tools/src/builtin_tools/exit_plan_mode.rs new file mode 100644 index 00000000..c4411af1 --- /dev/null +++ b/crates/adapter-tools/src/builtin_tools/exit_plan_mode.rs @@ -0,0 +1,495 @@ +//! `exitPlanMode` 工具。 +//! +//! 当计划已经被打磨到可执行程度时,使用该工具把 canonical plan 工件正式呈递给前端, +//! 同时把 session 切回 code mode,等待用户批准或要求修订。 + +use std::{fs, path::Path, time::Instant}; + +use astrcode_core::{ + AstrError, ModeId, Result, SideEffect, Tool, ToolCapabilityMetadata, ToolContext, + ToolDefinition, ToolExecutionResult, ToolPromptMetadata, session_plan_content_digest, +}; +use async_trait::async_trait; +use chrono::Utc; +use serde_json::json; + +use crate::builtin_tools::{ + mode_transition::emit_mode_changed, + session_plan::{ + SessionPlanStatus, load_session_plan_state, persist_session_plan_state, + session_plan_markdown_path, session_plan_paths, + }, +}; + +#[derive(Default)] +pub struct ExitPlanModeTool; + +const REQUIRED_PLAN_HEADINGS: &[&str] = &[ + "## Context", + "## Goal", + "## Existing Code To Reuse", + "## Implementation Steps", + "## Verification", +]; +const FINAL_REVIEW_CHECKLIST: &[&str] = &[ + "Re-check assumptions against the code you already inspected.", + "Look for missing edge cases, affected files, and integration boundaries.", + "Confirm the verification steps are specific enough to prove the change works.", + "If the plan changes, persist it with upsertSessionPlan before retrying exitPlanMode.", +]; + +#[derive(Debug, Clone, PartialEq, Eq)] +struct PlanExitBlockers { + missing_headings: Vec, + invalid_sections: Vec, +} + +impl PlanExitBlockers { + fn is_empty(&self) -> bool { + self.missing_headings.is_empty() && self.invalid_sections.is_empty() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ReviewPendingKind { + RevisePlan, + FinalReview, +} + +#[async_trait] +impl Tool for ExitPlanModeTool { + fn definition(&self) -> ToolDefinition { + ToolDefinition { + name: "exitPlanMode".to_string(), + description: "Present the current session plan to the user and switch back to code \ + mode." + .to_string(), + parameters: json!({ + "type": "object", + "additionalProperties": false + }), + } + } + + fn capability_metadata(&self) -> ToolCapabilityMetadata { + ToolCapabilityMetadata::builtin() + .tags(["plan", "mode", "session"]) + .side_effect(SideEffect::Local) + .prompt( + ToolPromptMetadata::new( + "Present the current session plan to the user and leave plan mode.", + "Only use `exitPlanMode` after you have inspected the code, persisted the \ + current canonical plan artifact, and refined it until it is executable. If \ + the plan is still vague, missing risks, or lacking verification steps, keep \ + updating `upsertSessionPlan` instead of exiting.", + ) + .caveat( + "`exitPlanMode` first checks whether the current plan is executable, then \ + enforces one internal final-review checkpoint before it actually exits. Keep \ + that review out of the plan artifact itself unless the user explicitly asks \ + for it.", + ) + .example("{}") + .prompt_tag("plan") + .always_include(true), + ) + } + + async fn execute( + &self, + tool_call_id: String, + args: serde_json::Value, + ctx: &ToolContext, + ) -> Result { + let started_at = Instant::now(); + if !args.is_null() && args != json!({}) { + return Err(AstrError::Validation( + "exitPlanMode does not accept arguments".to_string(), + )); + } + + if ctx.current_mode_id() != &ModeId::plan() { + return Err(AstrError::Validation(format!( + "exitPlanMode can only be called from plan mode, current mode is '{}'", + ctx.current_mode_id() + ))); + } + + let paths = session_plan_paths(ctx)?; + let Some(mut state) = load_session_plan_state(&paths.state_path)? else { + return Err(AstrError::Validation( + "cannot exit plan mode because no session plan artifact exists yet".to_string(), + )); + }; + + let current_plan_slug = state.active_plan_slug.clone(); + let current_plan_title = state.title.clone(); + let plan_path = session_plan_markdown_path(&paths.plan_dir, ¤t_plan_slug); + let plan_content = fs::read_to_string(&plan_path).map_err(|error| { + AstrError::io( + format!("failed reading session plan file '{}'", plan_path.display()), + error, + ) + })?; + let blockers = validate_plan_readiness(&plan_content); + if !blockers.is_empty() { + return Ok(review_pending_result( + tool_call_id, + started_at, + ¤t_plan_title, + &plan_path, + &blockers, + ReviewPendingKind::RevisePlan, + )); + } + + let plan_digest = session_plan_content_digest(plan_content.trim()); + if state.reviewed_plan_digest.as_deref() != Some(plan_digest.as_str()) { + // 这里故意不立刻退出 plan mode。 + // 设计目标是把“最后一次自审”保留为内部流程,而不是把 review 段落写进计划正文: + // 当前计划版本第一次调用 exitPlanMode 只登记一个自审检查点; + // 如果模型自审后认为计划无需再改,再次调用 exitPlanMode 才真正呈递给前端。 + state.reviewed_plan_digest = Some(plan_digest); + persist_session_plan_state(&paths.state_path, &state)?; + return Ok(review_pending_result( + tool_call_id, + started_at, + ¤t_plan_title, + &plan_path, + &blockers, + ReviewPendingKind::FinalReview, + )); + } + + let now = Utc::now(); + state.status = SessionPlanStatus::AwaitingApproval; + state.updated_at = now; + state.approved_at = None; + persist_session_plan_state(&paths.state_path, &state)?; + + emit_mode_changed(ctx, "exitPlanMode", ModeId::plan(), ModeId::code()).await?; + + Ok(ToolExecutionResult { + tool_call_id, + tool_name: "exitPlanMode".to_string(), + ok: true, + output: format!( + "presented the session plan '{}' for user review from {}.\n\n{}", + state.title, + plan_path.display(), + plan_content.trim() + ), + error: None, + metadata: Some(json!({ + "schema": "sessionPlanExit", + "mode": { + "fromModeId": "plan", + "toModeId": "code", + "modeChanged": true, + }, + "plan": { + "title": state.title, + "status": state.status.as_str(), + "slug": current_plan_slug, + "planPath": plan_path.to_string_lossy(), + "content": plan_content.trim(), + "updatedAt": state.updated_at.to_rfc3339(), + } + })), + child_ref: None, + duration_ms: started_at.elapsed().as_millis() as u64, + truncated: false, + }) + } +} + +fn validate_plan_readiness(content: &str) -> PlanExitBlockers { + let trimmed = content.trim(); + if trimmed.is_empty() { + return PlanExitBlockers { + missing_headings: REQUIRED_PLAN_HEADINGS + .iter() + .map(|heading| (*heading).to_string()) + .collect(), + invalid_sections: Vec::new(), + }; + } + + let missing_headings = REQUIRED_PLAN_HEADINGS + .iter() + .copied() + .filter(|heading| !trimmed.contains(heading)) + .map(str::to_string) + .collect::>(); + + let mut invalid_sections = Vec::new(); + if let Err(error) = ensure_actionable_section(trimmed, "## Implementation Steps") { + invalid_sections.push(error); + } + if let Err(error) = ensure_actionable_section(trimmed, "## Verification") { + invalid_sections.push(error); + } + + PlanExitBlockers { + missing_headings, + invalid_sections, + } +} + +fn ensure_actionable_section(content: &str, heading: &str) -> std::result::Result<(), String> { + let section = section_body(content, heading) + .ok_or_else(|| format!("session plan is missing required section '{}'", heading))?; + let has_actionable_line = section.lines().map(str::trim).any(|line| { + !line.is_empty() + && (line.starts_with("- ") + || line.starts_with("* ") + || line.chars().next().is_some_and(|ch| ch.is_ascii_digit())) + }); + if has_actionable_line { + return Ok(()); + } + Err(format!( + "session plan section '{}' must contain concrete actionable items before exiting plan mode", + heading + )) +} + +fn section_body<'a>(content: &'a str, heading: &str) -> Option<&'a str> { + let start = content.find(heading)?; + let after_heading = &content[start + heading.len()..]; + let next_heading_offset = after_heading.find("\n## "); + Some(match next_heading_offset { + Some(offset) => &after_heading[..offset], + None => after_heading, + }) +} + +fn review_pending_result( + tool_call_id: String, + started_at: Instant, + title: &str, + plan_path: &Path, + blockers: &PlanExitBlockers, + kind: ReviewPendingKind, +) -> ToolExecutionResult { + let mut checklist = match kind { + ReviewPendingKind::RevisePlan => vec![ + "The plan is not executable yet. Revise the canonical session plan before exiting \ + plan mode." + .to_string(), + ], + ReviewPendingKind::FinalReview => vec![ + "Run one internal final review before exiting plan mode. Keep that review out of the \ + plan artifact itself." + .to_string(), + "If the review changes the plan, persist the updated plan with upsertSessionPlan and \ + retry exitPlanMode later." + .to_string(), + ], + }; + checklist.push("Final review checklist:".to_string()); + checklist.extend( + FINAL_REVIEW_CHECKLIST + .iter() + .enumerate() + .map(|(index, item)| format!("{}. {}", index + 1, item)), + ); + + if !blockers.missing_headings.is_empty() { + checklist.push(format!( + "Missing sections: {}", + blockers.missing_headings.join(", ") + )); + } + if !blockers.invalid_sections.is_empty() { + checklist.push(format!( + "Sections to strengthen: {}", + blockers.invalid_sections.join("; ") + )); + } + + ToolExecutionResult { + tool_call_id, + tool_name: "exitPlanMode".to_string(), + ok: true, + output: checklist.join("\n"), + error: None, + metadata: Some(json!({ + "schema": "sessionPlanExitReviewPending", + "plan": { + "title": title, + "planPath": plan_path.to_string_lossy(), + }, + "review": { + "kind": match kind { + ReviewPendingKind::RevisePlan => "revise_plan", + ReviewPendingKind::FinalReview => "final_review", + }, + "checklist": FINAL_REVIEW_CHECKLIST, + }, + "blockers": { + "missingHeadings": blockers.missing_headings, + "invalidSections": blockers.invalid_sections, + } + })), + child_ref: None, + duration_ms: started_at.elapsed().as_millis() as u64, + truncated: false, + } +} + +#[cfg(test)] +mod tests { + use std::sync::{Arc, Mutex}; + + use astrcode_core::{StorageEvent, StorageEventPayload}; + + use super::*; + use crate::{ + builtin_tools::upsert_session_plan::UpsertSessionPlanTool, + test_support::test_tool_context_for, + }; + + struct RecordingSink { + events: Arc>>, + } + + #[async_trait] + impl astrcode_core::ToolEventSink for RecordingSink { + async fn emit(&self, event: StorageEvent) -> Result<()> { + self.events + .lock() + .expect("recording sink lock should work") + .push(event); + Ok(()) + } + } + + #[tokio::test] + async fn exit_plan_mode_requires_internal_review_before_presenting_plan() { + let temp = tempfile::tempdir().expect("tempdir should exist"); + let upsert = UpsertSessionPlanTool; + let events = Arc::new(Mutex::new(Vec::new())); + let ctx = test_tool_context_for(temp.path()) + .with_current_mode_id(ModeId::plan()) + .with_event_sink(Arc::new(RecordingSink { + events: Arc::clone(&events), + })); + + upsert + .execute( + "tc-plan-seed".to_string(), + json!({ + "title": "Cleanup crates", + "status": "draft", + "content": "# Plan: Cleanup crates\n\n## Context\n- current crates are inconsistent\n\n## Goal\n- align crate boundaries\n\n## Scope\n- runtime and adapter cleanup\n\n## Non-Goals\n- change transport protocol\n\n## Existing Code To Reuse\n- reuse current capability routing\n\n## Implementation Steps\n- audit crate dependencies\n- introduce shared plan tools\n\n## Verification\n- run targeted Rust and frontend checks\n\n## Open Questions\n- none" + }), + &ctx, + ) + .await + .expect("seed plan should succeed"); + + let first_attempt = ExitPlanModeTool + .execute("tc-plan-exit-1".to_string(), json!({}), &ctx) + .await + .expect("first exitPlanMode call should return review pending"); + + assert!(first_attempt.ok); + let first_metadata = first_attempt.metadata.expect("metadata should exist"); + assert_eq!( + first_metadata["schema"], + json!("sessionPlanExitReviewPending") + ); + assert_eq!(first_metadata["review"]["kind"], json!("final_review")); + + let result = ExitPlanModeTool + .execute("tc-plan-exit-2".to_string(), json!({}), &ctx) + .await + .expect("second exitPlanMode call should succeed"); + + assert!(result.ok); + let metadata = result.metadata.expect("metadata should exist"); + assert_eq!(metadata["schema"], json!("sessionPlanExit")); + assert_eq!(metadata["plan"]["status"], json!("awaiting_approval")); + + let state_path = session_plan_paths(&ctx) + .expect("plan paths should resolve") + .state_path; + let state = load_session_plan_state(&state_path) + .expect("state should load") + .expect("state should exist"); + assert_eq!(state.status, SessionPlanStatus::AwaitingApproval); + assert!(state.reviewed_plan_digest.is_some()); + + let events = events.lock().expect("recording sink lock should work"); + assert!(matches!( + events.as_slice(), + [StorageEvent { + payload: StorageEventPayload::ModeChanged { from, to, .. }, + .. + }] if *from == ModeId::plan() && *to == ModeId::code() + )); + } + + #[tokio::test] + async fn exit_plan_mode_returns_review_pending_for_incomplete_plan() { + let temp = tempfile::tempdir().expect("tempdir should exist"); + let upsert = UpsertSessionPlanTool; + let ctx = test_tool_context_for(temp.path()) + .with_current_mode_id(ModeId::plan()) + .with_event_sink(Arc::new(RecordingSink { + events: Arc::new(Mutex::new(Vec::new())), + })); + + upsert + .execute( + "tc-plan-seed".to_string(), + json!({ + "title": "Cleanup crates", + "status": "draft", + "content": "# Plan: Cleanup crates\n\n## Context\n- current crates are inconsistent\n\n## Goal\n- align crate boundaries\n\n## Implementation Steps\n- audit crate dependencies\n\n## Verification\nrun targeted Rust checks" + }), + &ctx, + ) + .await + .expect("seed plan should succeed"); + + let result = ExitPlanModeTool + .execute("tc-plan-exit".to_string(), json!({}), &ctx) + .await + .expect("tool should return a review-pending result"); + + assert!(result.ok); + let metadata = result.metadata.expect("metadata should exist"); + assert_eq!(metadata["schema"], json!("sessionPlanExitReviewPending")); + assert_eq!(metadata["review"]["kind"], json!("revise_plan")); + assert_eq!( + metadata["blockers"]["missingHeadings"][0], + json!("## Existing Code To Reuse") + ); + assert!(result.output.contains("not executable yet")); + } + + #[test] + fn validate_plan_readiness_accepts_plan_without_plan_review_section() { + let content = "# Plan: Cleanup crates + +## Context +- current crates are inconsistent + +## Goal +- align crate boundaries + +## Existing Code To Reuse +- reuse current capability routing + +## Implementation Steps +- audit crate dependencies + +## Verification +- run targeted Rust checks +"; + + assert!(validate_plan_readiness(content).is_empty()); + } +} diff --git a/crates/adapter-tools/src/builtin_tools/find_files.rs b/crates/adapter-tools/src/builtin_tools/find_files.rs index 78027bc7..8fbfad8b 100644 --- a/crates/adapter-tools/src/builtin_tools/find_files.rs +++ b/crates/adapter-tools/src/builtin_tools/find_files.rs @@ -25,7 +25,8 @@ use serde::Deserialize; use serde_json::json; use crate::builtin_tools::fs_common::{ - check_cancel, json_output, resolve_path, session_dir_for_tool_results, + check_cancel, json_output, merge_persisted_tool_output_metadata, resolve_path, + session_dir_for_tool_results, }; /// FindFiles 工具实现。 @@ -72,7 +73,7 @@ impl Tool for FindFilesTool { }, "root": { "type": "string", - "description": "Root directory to search from (default: working directory)" + "description": "Root directory to search from (default: current working directory)" }, "maxResults": { "type": "integer", @@ -105,15 +106,15 @@ impl Tool for FindFilesTool { ToolPromptMetadata::new( "Find candidate files by glob when you know the filename pattern but not the \ exact path.", - "Find files by glob pattern inside the workspace. Use this before `grep` when \ - you only know a filename, extension, or glob. Supported common glob forms \ - include `**/*.rs` (recursive), `*.toml` (current dir), and `*.{json,toml}` \ - (alternation). When using `root`, the glob pattern is relative to that root, \ - not the workspace root. Results are sorted by modification time.", + "Find files by glob pattern under a known search root. Use this before `grep` \ + when you only know a filename, extension, or glob. Supported common glob \ + forms include `**/*.rs` (recursive), `*.toml` (current dir), and \ + `*.{json,toml}` (alternation). When using `root`, the glob pattern is \ + relative to that root. Results are sorted by modification time.", ) .caveat( - "Pattern must stay inside the workspace. Truncated at 200 results — narrow \ - with `root` or a more specific glob.", + "Pattern must stay relative to the search root. Truncated at 200 results — \ + narrow with `root` or a more specific glob.", ) .example( "Find all Cargo.toml: { pattern: \"**/Cargo.toml\" }. Limit to ./crates/: { \ @@ -185,20 +186,25 @@ impl Tool for FindFilesTool { &output, ctx.resolved_inline_limit(), ); + let mut metadata = serde_json::Map::new(); + metadata.insert("pattern".to_string(), json!(args.pattern)); + metadata.insert("root".to_string(), json!(root.to_string_lossy())); + metadata.insert("count".to_string(), json!(paths.len())); + metadata.insert("truncated".to_string(), json!(truncated)); + metadata.insert( + "respectGitignore".to_string(), + json!(args.respect_gitignore), + ); + merge_persisted_tool_output_metadata(&mut metadata, final_output.persisted.as_ref()); Ok(ToolExecutionResult { tool_call_id, tool_name: "findFiles".to_string(), ok: true, - output: final_output, + output: final_output.output, error: None, - metadata: Some(json!({ - "pattern": args.pattern, - "root": root.to_string_lossy(), - "count": paths.len(), - "truncated": truncated, - "respectGitignore": args.respect_gitignore, - })), + metadata: Some(serde_json::Value::Object(metadata)), + child_ref: None, duration_ms: started_at.elapsed().as_millis() as u64, truncated, }) @@ -294,7 +300,7 @@ fn collect_files_with_ignore( fn validate_glob_pattern(pattern: &str) -> Result<()> { if looks_like_windows_drive_relative_path(pattern) { return Err(AstrError::Validation(format!( - "glob pattern '{}' must stay within the working directory", + "glob pattern '{}' must stay relative to the search root", pattern ))); } @@ -302,7 +308,7 @@ fn validate_glob_pattern(pattern: &str) -> Result<()> { let path = Path::new(pattern); if path.is_absolute() { return Err(AstrError::Validation(format!( - "glob pattern '{}' must stay within the working directory", + "glob pattern '{}' must stay relative to the search root", pattern ))); } @@ -311,7 +317,7 @@ fn validate_glob_pattern(pattern: &str) -> Result<()> { match component { Component::ParentDir | Component::Prefix(_) | Component::RootDir => { return Err(AstrError::Validation(format!( - "glob pattern '{}' must stay within the working directory", + "glob pattern '{}' must stay relative to the search root", pattern ))); }, @@ -466,7 +472,7 @@ mod tests { assert!( error .to_string() - .contains("must stay within the working directory") + .contains("must stay relative to the search root") ); } @@ -579,6 +585,46 @@ mod tests { assert!(paths[1].ends_with("old.txt")); } + #[tokio::test] + async fn find_files_allows_root_outside_working_dir() { + let parent = tempfile::tempdir().expect("tempdir should be created"); + let workspace = parent.path().join("workspace"); + let outside = parent.path().join("outside"); + tokio::fs::create_dir_all(&workspace) + .await + .expect("workspace should be created"); + tokio::fs::create_dir_all(&outside) + .await + .expect("outside dir should be created"); + tokio::fs::write(outside.join("found.txt"), "hello") + .await + .expect("outside file should be written"); + let tool = FindFilesTool; + + let result = tool + .execute( + "tc-find-outside".to_string(), + json!({ + "pattern": "*.txt", + "root": "../outside" + }), + &test_tool_context_for(&workspace), + ) + .await + .expect("findFiles should succeed"); + + assert!(result.ok); + let paths: Vec = + serde_json::from_str(&result.output).expect("output should be valid json"); + assert_eq!(paths.len(), 1); + assert_eq!( + paths[0], + canonical_tool_path(outside.join("found.txt")) + .to_string_lossy() + .to_string() + ); + } + #[test] fn find_files_prompt_metadata_mentions_grep_hand_off() { let prompt = FindFilesTool diff --git a/crates/adapter-tools/src/builtin_tools/fs_common.rs b/crates/adapter-tools/src/builtin_tools/fs_common.rs index 4293cfdf..6960acf8 100644 --- a/crates/adapter-tools/src/builtin_tools/fs_common.rs +++ b/crates/adapter-tools/src/builtin_tools/fs_common.rs @@ -2,7 +2,7 @@ //! //! 提供所有文件工具共享的基础设施: //! -//! - **路径沙箱**: `resolve_path` 确保所有路径操作不逃逸工作目录 +//! - **路径解析**: `resolve_path` 将相对路径锚定到工作目录,并允许访问宿主机绝对路径 //! - **取消检查**: `check_cancel` 在长操作的关键节点检查用户取消 //! - **文件 I/O**: `read_utf8_file` / `write_text_file` 统一 UTF-8 读写 //! - **Diff 生成**: `build_text_change_report` 手工实现 unified diff @@ -24,7 +24,10 @@ use std::{ time::SystemTime, }; -use astrcode_core::{AstrError, CancelToken, Result, ToolContext, project::project_dir}; +use astrcode_core::{ + AstrError, CancelToken, PersistedToolOutput, PersistedToolResult, Result, ToolContext, + project::project_dir, +}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; @@ -77,19 +80,34 @@ pub fn is_symlink(path: &Path) -> Result { } } -/// 将路径解析为工作目录内的绝对路径,拒绝逃逸路径。 +/// 将路径解析为宿主机上的绝对路径。 +/// +/// 相对路径始终锚定到当前工具上下文的工作目录;绝对路径直接保留并解析。 /// -/// **为什么使用 `resolve_for_boundary_check` 而非 `fs::canonicalize`**: +/// **为什么使用 `resolve_for_host_access` 而非 `fs::canonicalize`**: /// canonicalize 要求路径在磁盘上存在,但 writeFile/editFile 经常操作 -/// 尚不存在的文件。resolve_for_boundary_check 从路径尾部向上找到第一个 +/// 尚不存在的文件。resolve_for_host_access 从路径尾部向上找到第一个 /// 存在的祖先进行 canonicalize,再拼回缺失部分。 pub fn resolve_path(ctx: &ToolContext, path: &Path) -> Result { - resolve_path_with_root( + let canonical_working_dir = canonicalize_path( ctx.working_dir(), - path, - "working directory", - "failed to canonicalize working directory", - ) + &format!( + "failed to canonicalize working directory '{}'", + ctx.working_dir().display() + ), + )?; + let base = if path.is_absolute() { + path.to_path_buf() + } else { + canonical_working_dir.join(path) + }; + resolve_for_host_access(&normalize_lexically(&base)) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedReadTarget { + pub path: PathBuf, + pub persisted_relative_path: Option, } /// 读取工具额外允许访问当前会话下的持久化结果目录。 @@ -98,20 +116,79 @@ pub fn resolve_path(ctx: &ToolContext, path: &Path) -> Result { /// 供后续 `readFile` 读取。这里仅对 `tool-results/**` 相对路径开放额外根目录, /// 避免把工作区外的任意文件都暴露给只读工具。 pub fn resolve_read_path(ctx: &ToolContext, path: &Path) -> Result { + Ok(resolve_read_target(ctx, path)?.path) +} + +pub fn resolve_read_target(ctx: &ToolContext, path: &Path) -> Result { + if let Some(target) = resolve_session_tool_results_target(ctx, path)? { + return Ok(target); + } + + Ok(ResolvedReadTarget { + path: resolve_path(ctx, path)?, + persisted_relative_path: None, + }) +} + +fn resolve_session_tool_results_target( + ctx: &ToolContext, + path: &Path, +) -> Result> { + let session_root = session_dir_for_tool_results(ctx)?; + let tool_results_root = session_root.join(TOOL_RESULTS_DIR); + + if path.is_absolute() { + if !tool_results_root.exists() { + return Ok(None); + } + + let canonical_tool_results_root = canonicalize_path( + &tool_results_root, + &format!( + "failed to canonicalize session tool-results directory '{}'", + tool_results_root.display() + ), + )?; + let canonical_session_root = canonicalize_path( + &session_root, + &format!( + "failed to canonicalize session directory '{}'", + session_root.display() + ), + )?; + let resolved = resolve_for_host_access(&normalize_lexically(path))?; + if !is_path_within_root(&resolved, &canonical_tool_results_root) { + return Ok(None); + } + + let relative_path = resolved + .strip_prefix(&canonical_session_root) + .unwrap_or(&resolved) + .to_string_lossy() + .replace('\\', "/"); + return Ok(Some(ResolvedReadTarget { + path: resolved, + persisted_relative_path: Some(relative_path), + })); + } + if should_use_session_tool_results_root(path) { - let session_root = session_dir_for_tool_results(ctx)?; let session_candidate = session_root.join(path); if session_candidate.exists() { - return resolve_path_with_root( + let resolved = resolve_path_with_root( &session_root, path, "session tool-results directory", "failed to canonicalize session tool-results directory", - ); + )?; + return Ok(Some(ResolvedReadTarget { + path: resolved, + persisted_relative_path: Some(path.to_string_lossy().replace('\\', "/")), + })); } } - resolve_path(ctx, path) + Ok(None) } /// 读取文件内容为 UTF-8 字符串。 @@ -163,6 +240,41 @@ pub fn session_dir_for_tool_results(ctx: &ToolContext) -> Result { Ok(project_dir.join("sessions").join(ctx.session_id())) } +/// 拒绝通用文件写工具直接修改 canonical session plan。 +/// +/// session plan 的正式写入口必须统一走 `upsertSessionPlan`,否则会让 `state.json` +/// 与 markdown artifact 脱节,并污染 conversation 对 canonical plan 的投影语义。 +pub fn ensure_not_canonical_session_plan_write_target( + ctx: &ToolContext, + path: &Path, + tool_name: &str, +) -> Result<()> { + let plan_dir = resolve_for_host_access(&normalize_lexically( + &session_dir_for_tool_results(ctx)?.join("plan"), + ))?; + if !is_path_within_root(path, &plan_dir) { + return Ok(()); + } + + let is_canonical_plan_file = path + .extension() + .and_then(|value| value.to_str()) + .is_some_and(|value| value.eq_ignore_ascii_case("md")) + || path + .file_name() + .and_then(|value| value.to_str()) + .is_some_and(|value| value.eq_ignore_ascii_case("state.json")); + if !is_canonical_plan_file { + return Ok(()); + } + + Err(AstrError::Validation(format!( + "`{tool_name}` cannot modify canonical session plan artifacts under '{}'; use \ + upsertSessionPlan instead", + plan_dir.display() + ))) +} + /// 文件观察快照。 /// /// `readFile` 成功后记录当前版本,`editFile` 写入前用它检测文件是否已被外部修改。 @@ -498,7 +610,7 @@ fn resolve_path_with_root( canonical_root.join(path) }; - let resolved = resolve_for_boundary_check(&normalize_lexically(&base))?; + let resolved = resolve_for_host_access(&normalize_lexically(&base))?; if is_path_within_root(&resolved, &canonical_root) { return Ok(resolved); } @@ -515,7 +627,7 @@ fn resolve_path_with_root( /// /// 当路径尾部组件尚不存在时(如 writeFile 创建新文件), /// 向上找到第一个存在的祖先 canonicalize 后拼回缺失部分。 -fn resolve_for_boundary_check(path: &Path) -> Result { +fn resolve_for_host_access(path: &Path) -> Result { if path.exists() { return canonicalize_path( path, @@ -528,13 +640,13 @@ fn resolve_for_boundary_check(path: &Path) -> Result { while !current.exists() { let Some(name) = current.file_name() else { return Err(AstrError::Validation(format!( - "path '{}' cannot be resolved under the working directory", + "path '{}' cannot be resolved on the host filesystem", path.display() ))); }; let Some(parent) = current.parent() else { return Err(AstrError::Validation(format!( - "path '{}' cannot be resolved under the working directory", + "path '{}' cannot be resolved on the host filesystem", path.display() ))); }; @@ -614,9 +726,12 @@ pub fn maybe_persist_large_tool_result( tool_call_id: &str, content: &str, force_inline: bool, -) -> String { +) -> PersistedToolResult { if force_inline { - return content.to_string(); + return PersistedToolResult { + output: content.to_string(), + persisted: None, + }; } astrcode_core::tool_result_persist::maybe_persist_tool_result( session_dir, @@ -626,6 +741,17 @@ pub fn maybe_persist_large_tool_result( ) } +pub fn merge_persisted_tool_output_metadata( + metadata: &mut serde_json::Map, + persisted_output: Option<&PersistedToolOutput>, +) { + let Some(persisted_output) = persisted_output else { + return; + }; + + metadata.insert("persistedOutput".to_string(), json!(persisted_output)); +} + #[cfg(test)] mod tests { use super::*; @@ -647,11 +773,12 @@ mod tests { fs::create_dir_all(&working_dir).expect("workspace should be created"); let ctx = test_tool_context_for(&working_dir); - let err = resolve_path(&ctx, Path::new("../outside.txt")) - .expect_err("escaping path should be rejected"); + let resolved = + resolve_path(&ctx, Path::new("../outside.txt")).expect("outside path should resolve"); + let expected = resolve_path(&ctx, Path::new("../outside.txt")) + .expect("outside path should resolve consistently"); - assert!(matches!(err, AstrError::Validation(_))); - assert!(err.to_string().contains("escapes working directory")); + assert_eq!(resolved, expected); } #[test] @@ -666,6 +793,20 @@ mod tests { assert_eq!(resolved, canonical_tool_path(&file)); } + #[test] + fn resolve_path_allows_absolute_path_outside_working_dir() { + let parent = tempfile::tempdir().expect("tempdir should be created"); + let workspace = parent.path().join("workspace"); + let outside = parent.path().join("outside.txt"); + fs::create_dir_all(&workspace).expect("workspace should be created"); + fs::write(&outside, "hello").expect("outside file should be created"); + let ctx = test_tool_context_for(&workspace); + + let resolved = resolve_path(&ctx, &outside).expect("absolute host path should resolve"); + + assert_eq!(resolved, canonical_tool_path(&outside)); + } + #[test] fn is_path_within_root_ignores_trailing_separators() { let temp = tempfile::tempdir().expect("tempdir should be created"); @@ -720,6 +861,31 @@ mod tests { assert_eq!(resolved, canonical_tool_path(&workspace_file)); } + #[test] + fn resolve_read_target_allows_absolute_session_tool_results_path() { + let temp = tempfile::tempdir().expect("tempdir should be created"); + let ctx = test_tool_context_for(temp.path()); + let session_dir = + session_dir_for_tool_results(&ctx).expect("session tool-results dir should resolve"); + let persisted = session_dir.join(TOOL_RESULTS_DIR).join("absolute.txt"); + fs::create_dir_all( + persisted + .parent() + .expect("persisted file should have a parent"), + ) + .expect("tool-results dir should be created"); + fs::write(&persisted, "persisted").expect("persisted output should be written"); + + let resolved = resolve_read_target(&ctx, &canonical_tool_path(&persisted)) + .expect("absolute persisted path should resolve"); + + assert_eq!(resolved.path, canonical_tool_path(&persisted)); + assert_eq!( + resolved.persisted_relative_path.as_deref(), + Some("tool-results/absolute.txt") + ); + } + #[test] fn session_dir_for_tool_results_prefers_context_override_root() { let temp = tempfile::tempdir().expect("tempdir should be created"); diff --git a/crates/adapter-tools/src/builtin_tools/grep.rs b/crates/adapter-tools/src/builtin_tools/grep.rs index ee149ea0..1e6e130f 100644 --- a/crates/adapter-tools/src/builtin_tools/grep.rs +++ b/crates/adapter-tools/src/builtin_tools/grep.rs @@ -30,8 +30,8 @@ use serde::{Deserialize, Serialize}; use serde_json::json; use crate::builtin_tools::fs_common::{ - check_cancel, maybe_persist_large_tool_result, read_utf8_file, resolve_path, - session_dir_for_tool_results, + check_cancel, maybe_persist_large_tool_result, merge_persisted_tool_output_metadata, + read_utf8_file, resolve_path, session_dir_for_tool_results, }; /// 匹配行最大显示字符数。 @@ -148,7 +148,7 @@ impl Tool for GrepTool { }, "path": { "type": "string", - "description": "Optional. File or directory to search in (defaults to current working directory)" + "description": "Optional. File or directory to search in (defaults to the current working directory)" }, "recursive": { "type": "boolean", @@ -250,8 +250,7 @@ impl Tool for GrepTool { .map_err(|e| AstrError::parse(explain_grep_args_error(&e), e))?; let path = match &args.path { Some(p) => resolve_path(ctx, p)?, - None => std::env::current_dir() - .map_err(|e| AstrError::io("failed to get current directory", e))?, + None => ctx.working_dir().to_path_buf(), }; let started_at = Instant::now(); let regex = RegexBuilder::new(&args.pattern) @@ -309,22 +308,33 @@ impl Tool for GrepTool { let session_dir = session_dir_for_tool_results(ctx)?; let final_output = maybe_persist_large_tool_result(&session_dir, &tool_call_id, &output, false); - let is_persisted = final_output.starts_with(""); + let is_persisted = final_output.persisted.is_some(); + let mut metadata = serde_json::Map::new(); + metadata.insert("pattern".to_string(), json!(args.pattern)); + metadata.insert("returned".to_string(), json!(result.matched_files.len())); + metadata.insert("has_more".to_string(), json!(result.has_more)); + metadata.insert( + "truncated".to_string(), + json!(result.has_more || is_persisted), + ); + metadata.insert("skipped_files".to_string(), json!(result.skipped_files)); + metadata.insert( + "message".to_string(), + json!(grep_empty_message(offset, result.matched_files.is_empty())), + ); + metadata.insert("output_mode".to_string(), json!("files_with_matches")); + merge_persisted_tool_output_metadata( + &mut metadata, + final_output.persisted.as_ref(), + ); Ok(ToolExecutionResult { tool_call_id, tool_name: "grep".to_string(), ok: true, - output: final_output, + output: final_output.output, error: None, - metadata: Some(json!({ - "pattern": args.pattern, - "returned": result.matched_files.len(), - "has_more": result.has_more, - "truncated": result.has_more || is_persisted, - "skipped_files": result.skipped_files, - "message": grep_empty_message(offset, result.matched_files.is_empty()), - "output_mode": "files_with_matches", - })), + metadata: Some(serde_json::Value::Object(metadata)), + child_ref: None, duration_ms: started_at.elapsed().as_millis() as u64, truncated: result.has_more || is_persisted, }) @@ -336,21 +346,29 @@ impl Tool for GrepTool { let session_dir = session_dir_for_tool_results(ctx)?; let final_output = maybe_persist_large_tool_result(&session_dir, &tool_call_id, &output, false); - let is_persisted = final_output.starts_with(""); + let is_persisted = final_output.persisted.is_some(); + let mut metadata = serde_json::Map::new(); + metadata.insert("pattern".to_string(), json!(args.pattern)); + metadata.insert("total_files".to_string(), json!(result.counts.len())); + metadata.insert("truncated".to_string(), json!(is_persisted)); + metadata.insert("skipped_files".to_string(), json!(result.skipped_files)); + metadata.insert( + "message".to_string(), + json!(grep_empty_message(0, result.counts.is_empty())), + ); + metadata.insert("output_mode".to_string(), json!("count")); + merge_persisted_tool_output_metadata( + &mut metadata, + final_output.persisted.as_ref(), + ); Ok(ToolExecutionResult { tool_call_id, tool_name: "grep".to_string(), ok: true, - output: final_output, + output: final_output.output, error: None, - metadata: Some(json!({ - "pattern": args.pattern, - "total_files": result.counts.len(), - "truncated": is_persisted, - "skipped_files": result.skipped_files, - "message": grep_empty_message(0, result.counts.is_empty()), - "output_mode": "count", - })), + metadata: Some(serde_json::Value::Object(metadata)), + child_ref: None, duration_ms: started_at.elapsed().as_millis() as u64, truncated: is_persisted, }) @@ -639,23 +657,28 @@ fn build_content_result( // 溢出存盘检查 let session_dir = session_dir_for_tool_results(ctx)?; let final_output = maybe_persist_large_tool_result(&session_dir, &tool_call_id, &output, false); - let is_persisted = final_output.starts_with(""); + let is_persisted = final_output.persisted.is_some(); + let mut metadata = serde_json::Map::new(); + metadata.insert("pattern".to_string(), json!(args.pattern)); + metadata.insert("returned".to_string(), json!(matches.len())); + metadata.insert("has_more".to_string(), json!(has_more)); + metadata.insert("truncated".to_string(), json!(has_more || is_persisted)); + metadata.insert("skipped_files".to_string(), json!(skipped_files)); + metadata.insert("offset_applied".to_string(), json!(offset)); + metadata.insert( + "message".to_string(), + json!(grep_empty_message(offset, matches.is_empty())), + ); + merge_persisted_tool_output_metadata(&mut metadata, final_output.persisted.as_ref()); Ok(ToolExecutionResult { tool_call_id, tool_name: "grep".to_string(), ok: true, - output: final_output, + output: final_output.output, error: None, - metadata: Some(json!({ - "pattern": args.pattern, - "returned": matches.len(), - "has_more": has_more, - "truncated": has_more || is_persisted, - "skipped_files": skipped_files, - "offset_applied": offset, - "message": grep_empty_message(offset, matches.is_empty()), - })), + metadata: Some(serde_json::Value::Object(metadata)), + child_ref: None, duration_ms: started_at.elapsed().as_millis() as u64, truncated: has_more || is_persisted, }) @@ -1306,12 +1329,50 @@ mod tests { let matches: Vec = serde_json::from_str(&result.output).expect("output should be valid json"); assert_eq!(matches.len(), 1); - assert_eq!(matches[0].before.as_ref().unwrap().len(), 2); - assert_eq!(matches[0].before.as_ref().unwrap()[0], "line1"); - assert_eq!(matches[0].before.as_ref().unwrap()[1], "line2"); - assert_eq!(matches[0].after.as_ref().unwrap().len(), 2); - assert_eq!(matches[0].after.as_ref().unwrap()[0], "line4"); - assert_eq!(matches[0].after.as_ref().unwrap()[1], "line5"); + assert_eq!( + matches[0] + .before + .as_ref() + .expect("before context should exist") + .len(), + 2 + ); + assert_eq!( + matches[0] + .before + .as_ref() + .expect("before context should exist")[0], + "line1" + ); + assert_eq!( + matches[0] + .before + .as_ref() + .expect("before context should exist")[1], + "line2" + ); + assert_eq!( + matches[0] + .after + .as_ref() + .expect("after context should exist") + .len(), + 2 + ); + assert_eq!( + matches[0] + .after + .as_ref() + .expect("after context should exist")[0], + "line4" + ); + assert_eq!( + matches[0] + .after + .as_ref() + .expect("after context should exist")[1], + "line5" + ); } #[tokio::test] @@ -1386,6 +1447,38 @@ mod tests { assert!(matches[0].file.ends_with("main.rs")); } + #[tokio::test] + async fn grep_allows_path_outside_working_dir() { + let parent = tempfile::tempdir().expect("tempdir should be created"); + let workspace = parent.path().join("workspace"); + let outside = parent.path().join("outside.txt"); + tokio::fs::create_dir_all(&workspace) + .await + .expect("workspace should be created"); + tokio::fs::write(&outside, "needle outside\n") + .await + .expect("outside file should be written"); + let tool = GrepTool; + + let result = tool + .execute( + "tc-grep-outside".to_string(), + json!({ + "pattern": "needle", + "path": "../outside.txt" + }), + &test_tool_context_for(&workspace), + ) + .await + .expect("grep should succeed"); + + assert!(result.ok); + let matches: Vec = + serde_json::from_str(&result.output).expect("output should be valid json"); + assert_eq!(matches.len(), 1); + assert!(matches[0].file.ends_with("outside.txt")); + } + #[tokio::test] async fn grep_persists_large_results_and_read_file_can_open_them() { let temp = tempfile::tempdir().expect("tempdir should be created"); @@ -1414,21 +1507,20 @@ mod tests { .expect("grep should succeed"); assert!(result.output.starts_with("")); - let persisted_relative = result - .output - .lines() - .find_map(|line| line.split("Full output saved to: ").nth(1)) - .expect("persisted output path should be present") - .trim(); + let metadata = result.metadata.as_ref().expect("metadata should exist"); + let persisted_absolute = metadata["persistedOutput"]["absolutePath"] + .as_str() + .expect("persisted absolute path should be present"); + assert!(result.output.contains(persisted_absolute)); let read_tool = ReadFileTool; let read_result = read_tool .execute( "tc-read-persisted".to_string(), json!({ - "path": persisted_relative, - "maxChars": 200000, - "lineNumbers": false + "path": persisted_absolute, + "charOffset": 0, + "maxChars": 200000 }), &ctx, ) @@ -1438,18 +1530,20 @@ mod tests { assert!(read_result.ok); assert!(read_result.output.starts_with('[')); assert!(read_result.output.contains("target_0")); + let read_metadata = read_result.metadata.expect("metadata should exist"); + assert_eq!(read_metadata["persistedRead"], json!(true)); } #[test] fn extract_match_text_returns_first_capture_group() { - let re = regex::Regex::new(r"fn\s+(\w+)").unwrap(); + let re = regex::Regex::new(r"fn\s+(\w+)").expect("regex should compile"); let text = extract_match_text(&re, "pub fn greet(name: &str)"); assert_eq!(text, Some("greet".to_string())); } #[test] fn extract_match_text_returns_full_match_when_no_groups() { - let re = regex::Regex::new(r"pub fn").unwrap(); + let re = regex::Regex::new(r"pub fn").expect("regex should compile"); let text = extract_match_text(&re, "pub fn main()"); assert_eq!(text, Some("pub fn".to_string())); } diff --git a/crates/adapter-tools/src/builtin_tools/list_dir.rs b/crates/adapter-tools/src/builtin_tools/list_dir.rs index 80de8526..63837910 100644 --- a/crates/adapter-tools/src/builtin_tools/list_dir.rs +++ b/crates/adapter-tools/src/builtin_tools/list_dir.rs @@ -80,7 +80,7 @@ impl Tool for ListDirTool { "properties": { "path": { "type": "string", - "description": "Absolute or relative directory path. Defaults to working directory if omitted." + "description": "Absolute or relative directory path. Defaults to the current working directory if omitted." }, "maxEntries": { "type": "integer", @@ -269,6 +269,7 @@ impl Tool for ListDirTool { SortBy::Size => "size", }, })), + child_ref: None, duration_ms: started_at.elapsed().as_millis() as u64, truncated, }) @@ -424,4 +425,36 @@ mod tests { let metadata = result.metadata.expect("metadata should exist"); assert_eq!(metadata["message"], json!("Directory is empty.")); } + + #[tokio::test] + async fn list_dir_allows_relative_path_outside_working_dir() { + let parent = tempfile::tempdir().expect("tempdir should be created"); + let workspace = parent.path().join("workspace"); + let outside = parent.path().join("outside"); + tokio::fs::create_dir_all(&workspace) + .await + .expect("workspace should be created"); + tokio::fs::create_dir_all(&outside) + .await + .expect("outside dir should be created"); + tokio::fs::write(outside.join("note.txt"), "hello") + .await + .expect("outside file should be created"); + let tool = ListDirTool; + + let result = tool + .execute( + "tc-list-outside".to_string(), + json!({"path": "../outside"}), + &test_tool_context_for(&workspace), + ) + .await + .expect("listDir should succeed"); + + assert!(result.ok); + let entries: Vec = + serde_json::from_str(&result.output).expect("output should be valid json"); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0]["name"], "note.txt"); + } } diff --git a/crates/adapter-tools/src/builtin_tools/mod.rs b/crates/adapter-tools/src/builtin_tools/mod.rs index 87cf57aa..b37eefcf 100644 --- a/crates/adapter-tools/src/builtin_tools/mod.rs +++ b/crates/adapter-tools/src/builtin_tools/mod.rs @@ -11,6 +11,10 @@ pub mod apply_patch; /// 文件编辑工具:唯一字符串替换 pub mod edit_file; +/// 进入 plan mode:让模型显式切换到规划阶段 +pub mod enter_plan_mode; +/// 退出 plan mode:把计划正式呈递给前端并切回 code +pub mod exit_plan_mode; /// 文件查找工具:glob 模式匹配 pub mod find_files; /// 文件系统公共工具:路径解析、取消检查、diff 生成 @@ -19,13 +23,21 @@ pub mod fs_common; pub mod grep; /// 目录列表工具:浅层条目枚举 pub mod list_dir; +/// mode 切换共享辅助 +pub mod mode_transition; /// 文件读取工具:UTF-8 文本读取 pub mod read_file; +/// session 计划工件共享读写辅助 +pub mod session_plan; /// Shell 命令执行工具:流式 stdout/stderr pub mod shell; /// 技能工具:按需加载 skill 指令 pub mod skill_tool; +/// 执行期 task 快照写入工具:维护当前 owner 的工作清单 +pub mod task_write; /// 外部工具搜索:按需展开 MCP/plugin 工具 schema pub mod tool_search; +/// session 计划工件写工具:仅允许写当前 session 的 plan 目录 +pub mod upsert_session_plan; /// 文件写入工具:创建/覆盖文本文件 pub mod write_file; diff --git a/crates/adapter-tools/src/builtin_tools/mode_transition.rs b/crates/adapter-tools/src/builtin_tools/mode_transition.rs new file mode 100644 index 00000000..b011ad8f --- /dev/null +++ b/crates/adapter-tools/src/builtin_tools/mode_transition.rs @@ -0,0 +1,33 @@ +//! mode 切换工具共享辅助。 +//! +//! `enterPlanMode` 与 `exitPlanMode` 都需要发出相同的 `ModeChanged` 事件, +//! 这里集中实现,避免工具层对同一条领域事件各自维护一份写法。 + +use astrcode_core::{ + AgentEventContext, AstrError, ModeId, Result, StorageEvent, StorageEventPayload, ToolContext, +}; +use chrono::Utc; + +pub async fn emit_mode_changed( + ctx: &ToolContext, + tool_name: &'static str, + from: ModeId, + to: ModeId, +) -> Result<()> { + let Some(event_sink) = ctx.event_sink() else { + return Err(AstrError::Internal(format!( + "{tool_name} requires an attached tool event sink" + ))); + }; + event_sink + .emit(StorageEvent { + turn_id: None, + agent: AgentEventContext::default(), + payload: StorageEventPayload::ModeChanged { + from, + to, + timestamp: Utc::now(), + }, + }) + .await +} diff --git a/crates/adapter-tools/src/builtin_tools/read_file.rs b/crates/adapter-tools/src/builtin_tools/read_file.rs index 2a9f8443..f91df419 100644 --- a/crates/adapter-tools/src/builtin_tools/read_file.rs +++ b/crates/adapter-tools/src/builtin_tools/read_file.rs @@ -28,7 +28,8 @@ use serde::Deserialize; use serde_json::json; use crate::builtin_tools::fs_common::{ - check_cancel, remember_file_observation, resolve_read_path, session_dir_for_tool_results, + check_cancel, merge_persisted_tool_output_metadata, remember_file_observation, + resolve_read_target, session_dir_for_tool_results, }; /// 二进制检测采样大小(前 N 字节)。 @@ -86,6 +87,9 @@ struct ReadFileArgs { /// 最大返回字符数,默认 20,000。 #[serde(default)] max_chars: Option, + /// 已持久化工具结果的字符窗口起点(0-based)。 + #[serde(default)] + char_offset: Option, /// 起始行号(0-based),用于跳过文件头部。 #[serde(default)] offset: Option, @@ -228,6 +232,11 @@ impl Tool for ReadFileTool { "minimum": 0, "description": "Starting line number (0-based). Skips lines before this offset." }, + "charOffset": { + "type": "integer", + "minimum": 0, + "description": "Starting character offset for persisted tool-result reads." + }, "limit": { "type": "integer", "minimum": 1, @@ -253,18 +262,24 @@ impl Tool for ReadFileTool { .compact_clearable(true) .prompt( ToolPromptMetadata::new( - "Read file contents — supports text, images (base64), targeted line-range \ - reads.", - "Use after `grep`/`findFiles` gives you a path. Set `offset` (**0-based** \ - line) + `limit` to read a specific range. Set `lineNumbers: false` to skip \ - line-number prefixes. `maxChars` (default 20000) includes line-number \ - prefixes in its budget.", + "Read file contents — supports text, images (base64), persisted tool-result \ + chunks, and targeted line-range reads.", + "Use after `grep`/`findFiles` gives you a path. For normal source files, use \ + `offset` (**0-based** line) + `limit` to read a specific range; set \ + `lineNumbers: false` to skip line-number prefixes. For persisted large tool \ + results, prefer chunked reads with `charOffset` + `maxChars` instead of \ + trying to inline the whole file again.", ) .caveat( - "If output is truncated, use `offset` + `limit` to read the next chunk — do \ - not retry with a larger `maxChars`.", + "If output is truncated, continue from the next chunk. For normal files, use \ + `offset` + `limit`; for persisted tool results, use `charOffset` + \ + `maxChars`. Do not retry by requesting the whole large result again.", ) .example("Read lines 50–100: { path: \"src/main.rs\", offset: 50, limit: 50 }") + .example( + "Read the first chunk of a persisted tool result: { path: \ + \"C:/.../tool-results/call-1.txt\", charOffset: 0, maxChars: 20000 }", + ) .prompt_tag("filesystem") .always_include(true), ) @@ -302,12 +317,15 @@ impl Tool for ReadFileTool { "path": raw_path.to_string_lossy(), "deviceFile": true, })), + child_ref: None, duration_ms: started_at.elapsed().as_millis() as u64, truncated: false, }); } - let path = resolve_read_path(ctx, &args.path)?; + let target = resolve_read_target(ctx, &args.path)?; + let path = target.path; + let is_persisted_tool_result = target.persisted_relative_path.is_some(); // 图片文件处理:返回 base64 编码 if is_image_file(&path) { @@ -328,6 +346,7 @@ impl Tool for ReadFileTool { "bytes": file_size, "fileType": "image", })), + child_ref: None, duration_ms: started_at.elapsed().as_millis() as u64, truncated: false, }), @@ -340,6 +359,7 @@ impl Tool for ReadFileTool { metadata: Some(json!({ "path": path.to_string_lossy(), })), + child_ref: None, duration_ms: started_at.elapsed().as_millis() as u64, truncated: false, }), @@ -348,6 +368,58 @@ impl Tool for ReadFileTool { let max_chars = args.max_chars.unwrap_or(20_000); + if is_persisted_tool_result { + if args.offset.is_some() || args.limit.is_some() { + return Err(AstrError::Validation( + "persisted tool-result reads do not support line-based `offset`/`limit`; use \ + `charOffset` + `maxChars` instead" + .to_string(), + )); + } + + let text = read_persisted_tool_result(&path)?; + let char_offset = args.char_offset.unwrap_or(0); + let persisted_chunk = read_persisted_tool_result_chunk(&text, char_offset, max_chars); + let recommended_next_args = persisted_chunk.next_char_offset.map(|next_char_offset| { + json!({ + "path": path.to_string_lossy(), + "charOffset": next_char_offset, + "maxChars": max_chars, + }) + }); + + return Ok(ToolExecutionResult { + tool_call_id, + tool_name: "readFile".to_string(), + ok: true, + output: persisted_chunk.text, + error: None, + metadata: Some(json!({ + "path": path.to_string_lossy(), + "absolutePath": path.to_string_lossy(), + "bytes": total_utf8_bytes(&text), + "persistedRead": true, + "charOffset": char_offset, + "returnedChars": persisted_chunk.returned_chars, + "nextCharOffset": persisted_chunk.next_char_offset, + "hasMore": persisted_chunk.has_more, + "recommendedNextArgs": recommended_next_args, + "relativePath": target.persisted_relative_path, + "truncated": persisted_chunk.has_more, + })), + child_ref: None, + duration_ms: started_at.elapsed().as_millis() as u64, + truncated: persisted_chunk.has_more, + }); + } + + if args.char_offset.is_some() { + return Err(AstrError::Validation( + "`charOffset` is only supported when reading persisted tool-result files" + .to_string(), + )); + } + // 二进制文件检测:避免将二进制文件内容作为乱码返回,浪费 context window if is_binary_file(&path)? { let metadata = std::fs::metadata(&path).ok(); @@ -367,6 +439,7 @@ impl Tool for ReadFileTool { "bytes": file_size, "binary": true, })), + child_ref: None, duration_ms: started_at.elapsed().as_millis() as u64, truncated: false, }); @@ -441,20 +514,60 @@ impl Tool for ReadFileTool { &text, ctx.resolved_inline_limit(), ); + merge_persisted_tool_output_metadata(&mut meta_object, final_output.persisted.as_ref()); Ok(ToolExecutionResult { tool_call_id, tool_name: "readFile".to_string(), ok: true, - output: final_output, + output: final_output.output, error: None, metadata: Some(serde_json::Value::Object(meta_object)), + child_ref: None, duration_ms: started_at.elapsed().as_millis() as u64, truncated, }) } } +#[derive(Debug, Clone, PartialEq, Eq)] +struct PersistedToolResultChunk { + text: String, + returned_chars: usize, + next_char_offset: Option, + has_more: bool, +} + +fn read_persisted_tool_result(path: &Path) -> Result { + fs::read_to_string(path) + .map_err(|e| AstrError::io(format!("failed reading file '{}'", path.display()), e)) +} + +fn total_utf8_bytes(text: &str) -> usize { + text.len() +} + +fn read_persisted_tool_result_chunk( + text: &str, + char_offset: usize, + max_chars: usize, +) -> PersistedToolResultChunk { + let total_chars = text.chars().count(); + let start = char_offset.min(total_chars); + let end = start.saturating_add(max_chars).min(total_chars); + let start_byte = char_count_to_byte_offset(text, start); + let end_byte = char_count_to_byte_offset(text, end); + let returned_chars = end.saturating_sub(start); + let has_more = end < total_chars; + + PersistedToolResultChunk { + text: text[start_byte..end_byte].to_string(), + returned_chars, + next_char_offset: has_more.then_some(end), + has_more, + } +} + /// 计算行号的显示宽度(字符数)。 /// /// 例如 999 行需要 3 位宽度, 1000 行需要 4 位宽度。 @@ -611,7 +724,10 @@ fn read_lines_range( #[cfg(test)] mod tests { use super::*; - use crate::test_support::test_tool_context_for; + use crate::{ + builtin_tools::fs_common::session_dir_for_tool_results, + test_support::{canonical_tool_path, test_tool_context_for}, + }; #[tokio::test] async fn read_file_tool_marks_truncated_output() { @@ -779,7 +895,7 @@ mod tests { assert!(!result.ok); let error = result.error.unwrap_or_default(); assert!(error.contains("device files")); - assert!(result.metadata.unwrap()["deviceFile"] == json!(true)); + assert!(result.metadata.expect("metadata should exist")["deviceFile"] == json!(true)); } #[tokio::test] @@ -924,4 +1040,193 @@ mod tests { assert_eq!(metadata["bytes"], json!(19)); assert!(metadata.get("fileType").is_none()); } + + #[tokio::test] + async fn read_file_allows_relative_path_outside_working_dir() { + let parent = tempfile::tempdir().expect("tempdir should be created"); + let workspace = parent.path().join("workspace"); + let outside = parent.path().join("outside.txt"); + tokio::fs::create_dir_all(&workspace) + .await + .expect("workspace should be created"); + tokio::fs::write(&outside, "outside") + .await + .expect("outside file should be written"); + let tool = ReadFileTool; + + let result = tool + .execute( + "tc-read-outside".to_string(), + json!({ + "path": "../outside.txt", + "lineNumbers": false + }), + &test_tool_context_for(&workspace), + ) + .await + .expect("readFile should succeed"); + + assert!(result.ok); + assert_eq!(result.output, "outside"); + let metadata = result.metadata.expect("metadata should exist"); + assert_eq!( + metadata["path"], + json!(canonical_tool_path(&outside).to_string_lossy().to_string()) + ); + } + + #[tokio::test] + async fn read_file_reads_first_persisted_chunk_by_absolute_path() { + let temp = tempfile::tempdir().expect("tempdir should be created"); + let ctx = test_tool_context_for(temp.path()); + let session_dir = + session_dir_for_tool_results(&ctx).expect("session tool-results dir should resolve"); + let persisted = session_dir.join("tool-results").join("chunked.json"); + tokio::fs::create_dir_all( + persisted + .parent() + .expect("persisted file should have parent"), + ) + .await + .expect("tool-results dir should be created"); + tokio::fs::write(&persisted, "ABCDEFGHIJ") + .await + .expect("persisted output should be written"); + let tool = ReadFileTool; + + let result = tool + .execute( + "tc-read-persisted-first".to_string(), + json!({ + "path": canonical_tool_path(&persisted).to_string_lossy(), + "charOffset": 0, + "maxChars": 4 + }), + &ctx, + ) + .await + .expect("readFile should open persisted tool result"); + + assert!(result.ok); + assert_eq!(result.output, "ABCD"); + assert!(result.truncated); + let metadata = result.metadata.expect("metadata should exist"); + assert_eq!(metadata["persistedRead"], json!(true)); + assert_eq!(metadata["charOffset"], json!(0)); + assert_eq!(metadata["returnedChars"], json!(4)); + assert_eq!(metadata["nextCharOffset"], json!(4)); + assert_eq!(metadata["hasMore"], json!(true)); + assert_eq!(metadata["relativePath"], json!("tool-results/chunked.json")); + assert_eq!( + metadata["absolutePath"], + json!(canonical_tool_path(&persisted)) + ); + } + + #[tokio::test] + async fn read_file_reads_follow_up_persisted_chunk_without_re_persisting() { + let temp = tempfile::tempdir().expect("tempdir should be created"); + let ctx = test_tool_context_for(temp.path()); + let session_dir = + session_dir_for_tool_results(&ctx).expect("session tool-results dir should resolve"); + let persisted = session_dir.join("tool-results").join("chunked-large.json"); + tokio::fs::create_dir_all( + persisted + .parent() + .expect("persisted file should have parent"), + ) + .await + .expect("tool-results dir should be created"); + let content = "[{\"id\":0}, {\"id\":1}, {\"id\":2}, {\"id\":3}]"; + tokio::fs::write(&persisted, content) + .await + .expect("persisted output should be written"); + let tool = ReadFileTool; + + let result = tool + .execute( + "tc-read-persisted-second".to_string(), + json!({ + "path": canonical_tool_path(&persisted).to_string_lossy(), + "charOffset": 5, + "maxChars": 8 + }), + &ctx, + ) + .await + .expect("readFile should page persisted tool result"); + + assert!(result.ok); + assert_eq!(result.output, "\":0}, {\""); + assert!(!result.output.contains("")); + let metadata = result.metadata.expect("metadata should exist"); + assert_eq!(metadata["persistedRead"], json!(true)); + assert_eq!(metadata["charOffset"], json!(5)); + assert_eq!(metadata["returnedChars"], json!(8)); + } + + #[tokio::test] + async fn read_file_rejects_line_pagination_for_persisted_tool_results() { + let temp = tempfile::tempdir().expect("tempdir should be created"); + let ctx = test_tool_context_for(temp.path()); + let session_dir = + session_dir_for_tool_results(&ctx).expect("session tool-results dir should resolve"); + let persisted = session_dir.join("tool-results").join("chunked.txt"); + tokio::fs::create_dir_all( + persisted + .parent() + .expect("persisted file should have parent"), + ) + .await + .expect("tool-results dir should be created"); + tokio::fs::write(&persisted, "line0\nline1\nline2\n") + .await + .expect("persisted output should be written"); + let tool = ReadFileTool; + + let err = tool + .execute( + "tc-read-persisted-invalid".to_string(), + json!({ + "path": canonical_tool_path(&persisted).to_string_lossy(), + "offset": 1, + "limit": 1 + }), + &ctx, + ) + .await + .expect_err("persisted tool results should reject line pagination"); + + assert!( + err.to_string() + .contains("persisted tool-result reads do not support") + ); + } + + #[tokio::test] + async fn read_file_rejects_char_offset_for_regular_files() { + let temp = tempfile::tempdir().expect("tempdir should be created"); + let file = temp.path().join("sample.txt"); + tokio::fs::write(&file, "line0\nline1\n") + .await + .expect("write should work"); + let tool = ReadFileTool; + + let err = tool + .execute( + "tc-read-regular-invalid".to_string(), + json!({ + "path": file.to_string_lossy(), + "charOffset": 2 + }), + &test_tool_context_for(temp.path()), + ) + .await + .expect_err("regular files should reject charOffset"); + + assert!( + err.to_string() + .contains("only supported when reading persisted") + ); + } } diff --git a/crates/adapter-tools/src/builtin_tools/session_plan.rs b/crates/adapter-tools/src/builtin_tools/session_plan.rs new file mode 100644 index 00000000..45896a1a --- /dev/null +++ b/crates/adapter-tools/src/builtin_tools/session_plan.rs @@ -0,0 +1,74 @@ +//! session 计划工件的共享读写辅助。 +//! +//! `upsertSessionPlan`、`exitPlanMode` 等工具都需要读写同一份单 plan 状态; +//! 这里集中维护状态结构和路径规则,避免多处各自漂移。 + +use std::{ + fs, + path::{Path, PathBuf}, +}; + +use astrcode_core::{AstrError, Result, ToolContext}; +pub use astrcode_core::{SessionPlanState, SessionPlanStatus}; + +use crate::builtin_tools::fs_common::session_dir_for_tool_results; + +pub const PLAN_DIR_NAME: &str = "plan"; +pub const PLAN_STATE_FILE_NAME: &str = "state.json"; +pub const PLAN_PATH_TIMESTAMP_FORMAT: &str = "%Y%m%dT%H%M%SZ"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SessionPlanPaths { + pub plan_dir: PathBuf, + pub state_path: PathBuf, +} + +pub fn session_plan_paths(ctx: &ToolContext) -> Result { + let plan_dir = session_dir_for_tool_results(ctx)?.join(PLAN_DIR_NAME); + Ok(SessionPlanPaths { + state_path: plan_dir.join(PLAN_STATE_FILE_NAME), + plan_dir, + }) +} + +pub fn session_plan_markdown_path(plan_dir: &Path, slug: &str) -> PathBuf { + plan_dir.join(format!("{slug}.md")) +} + +pub fn load_session_plan_state(path: &Path) -> Result> { + if !path.exists() { + return Ok(None); + } + let content = fs::read_to_string(path) + .map_err(|error| AstrError::io(format!("failed reading '{}'", path.display()), error))?; + serde_json::from_str::(&content) + .map(Some) + .map_err(|error| AstrError::parse("failed to parse session plan state", error)) +} + +pub fn persist_session_plan_state(path: &Path, state: &SessionPlanState) -> Result<()> { + let Some(parent) = path.parent() else { + return Err(AstrError::Internal(format!( + "session plan state '{}' has no parent directory", + path.display() + ))); + }; + fs::create_dir_all(parent).map_err(|error| { + AstrError::io( + format!( + "failed creating session plan directory '{}'", + parent.display() + ), + error, + ) + })?; + let content = serde_json::to_string_pretty(state) + .map_err(|error| AstrError::parse("failed to serialize session plan state", error))?; + fs::write(path, content).map_err(|error| { + AstrError::io( + format!("failed writing session plan state '{}'", path.display()), + error, + ) + })?; + Ok(()) +} diff --git a/crates/adapter-tools/src/builtin_tools/shell.rs b/crates/adapter-tools/src/builtin_tools/shell.rs index 7638d03e..7666c01c 100644 --- a/crates/adapter-tools/src/builtin_tools/shell.rs +++ b/crates/adapter-tools/src/builtin_tools/shell.rs @@ -41,7 +41,9 @@ use async_trait::async_trait; use serde::Deserialize; use serde_json::json; -use crate::builtin_tools::fs_common::{check_cancel, resolve_path, session_dir_for_tool_results}; +use crate::builtin_tools::fs_common::{ + check_cancel, merge_persisted_tool_output_metadata, resolve_path, session_dir_for_tool_results, +}; /// Shell 工具实现。 /// @@ -396,8 +398,8 @@ impl Tool for ShellTool { inspection commands. Prefer dedicated tools instead of `shell` for file \ reading (`readFile`), code search (`grep`/`findFiles`), and structured \ file edits (`editFile`/`apply_patch`). Keep commands scoped to the \ - workspace, explain risky commands before running them, and prefer \ - read-only inspection before mutation." + intended host paths, explain risky commands before running them, and \ + prefer read-only inspection before mutation." ), ) .caveat( @@ -507,28 +509,33 @@ impl Tool for ShellTool { &output, ctx.resolved_inline_limit(), ); + let mut metadata = serde_json::Map::new(); + metadata.insert("command".to_string(), json!(command_text)); + metadata.insert("cwd".to_string(), json!(cwd_text.clone())); + metadata.insert("shell".to_string(), json!(shell_display.clone())); + metadata.insert("exitCode".to_string(), json!(-1)); + metadata.insert("streamed".to_string(), json!(true)); + metadata.insert("timedOut".to_string(), json!(true)); + metadata.insert( + "display".to_string(), + json!({ + "kind": "terminal", + "command": args.command, + "cwd": cwd_text, + "shell": spec.display_shell, + "exitCode": -1, + }), + ); + merge_persisted_tool_output_metadata(&mut metadata, output.persisted.as_ref()); return Ok(ToolExecutionResult { tool_call_id, tool_name: "shell".to_string(), ok: false, - output, + output: output.output, error: Some(format!("shell command timed out after {timeout_secs}s")), - metadata: Some(json!({ - "command": command_text, - "cwd": cwd_text.clone(), - "shell": shell_display.clone(), - "exitCode": -1, - "streamed": true, - "timedOut": true, - "display": { - "kind": "terminal", - "command": args.command, - "cwd": cwd_text, - "shell": spec.display_shell, - "exitCode": -1, - }, - })), + metadata: Some(serde_json::Value::Object(metadata)), + child_ref: None, duration_ms: started_at.elapsed().as_millis() as u64, truncated: false, }); @@ -581,36 +588,47 @@ impl Tool for ShellTool { &output, ctx.resolved_inline_limit(), ); + let mut metadata = serde_json::Map::new(); + metadata.insert("command".to_string(), json!(command_text)); + metadata.insert("cwd".to_string(), json!(cwd_text.clone())); + metadata.insert("shell".to_string(), json!(shell_display)); + metadata.insert("exitCode".to_string(), json!(exit_code)); + metadata.insert("streamed".to_string(), json!(true)); + metadata.insert("stdoutBytes".to_string(), json!(stdout_capture.bytes_read)); + metadata.insert("stderrBytes".to_string(), json!(stderr_capture.bytes_read)); + metadata.insert( + "stdoutTruncated".to_string(), + json!(stdout_capture.truncated), + ); + metadata.insert( + "stderrTruncated".to_string(), + json!(stderr_capture.truncated), + ); + metadata.insert( + "display".to_string(), + json!({ + "kind": "terminal", + "command": args.command, + "cwd": cwd_text, + "shell": spec.display_shell, + "exitCode": exit_code, + }), + ); + metadata.insert("truncated".to_string(), json!(truncated)); + merge_persisted_tool_output_metadata(&mut metadata, output.persisted.as_ref()); Ok(ToolExecutionResult { tool_call_id, tool_name: "shell".to_string(), ok, - output, + output: output.output, error: if ok { None } else { Some(format!("shell command exited with code {}", exit_code)) }, - metadata: Some(json!({ - "command": command_text, - "cwd": cwd_text.clone(), - "shell": shell_display, - "exitCode": exit_code, - "streamed": true, - "stdoutBytes": stdout_capture.bytes_read, - "stderrBytes": stderr_capture.bytes_read, - "stdoutTruncated": stdout_capture.truncated, - "stderrTruncated": stderr_capture.truncated, - "display": { - "kind": "terminal", - "command": args.command, - "cwd": cwd_text, - "shell": spec.display_shell, - "exitCode": exit_code, - }, - "truncated": truncated, - })), + metadata: Some(serde_json::Value::Object(metadata)), + child_ref: None, duration_ms: started_at.elapsed().as_millis() as u64, truncated, }) @@ -657,13 +675,13 @@ fn default_shell_for_prompt() -> String { #[cfg(test)] mod tests { - use std::{collections::VecDeque, io}; + use std::{collections::VecDeque, io, path::Path}; use astrcode_core::ToolOutputDelta; use tokio::sync::mpsc; use super::*; - use crate::test_support::test_tool_context_for; + use crate::{builtin_tools::read_file::ReadFileTool, test_support::test_tool_context_for}; struct ChunkedReader { chunks: VecDeque>, @@ -823,6 +841,104 @@ mod tests { assert!(result.output.contains("ok")); } + #[tokio::test] + async fn shell_persists_large_output_and_read_file_can_open_it() { + let temp = tempfile::tempdir().expect("tempdir should be created"); + let shell_ctx = test_tool_context_for(temp.path()) + .with_resolved_inline_limit(4 * 1024) + .with_max_output_size(20 * 1024); + let tool = ShellTool; + let args = if cfg!(windows) { + json!({ + "command": "[Console]::Write(('x' * 10000))", + "shell": "pwsh" + }) + } else { + json!({ + "command": "yes x | head -c 10000", + "shell": "sh" + }) + }; + + let result = tool + .execute("tc-shell-persisted".to_string(), args, &shell_ctx) + .await + .expect("shell tool should persist oversized output"); + + assert!(result.ok); + assert!(result.output.starts_with("")); + let metadata = result.metadata.as_ref().expect("metadata should exist"); + let persisted_absolute = metadata["persistedOutput"]["absolutePath"] + .as_str() + .expect("persisted absolute path should be present"); + assert!(Path::new(persisted_absolute).exists()); + + let read_tool = ReadFileTool; + let read_result = read_tool + .execute( + "tc-shell-read-persisted".to_string(), + json!({ + "path": persisted_absolute, + "charOffset": 0, + "maxChars": 2048 + }), + &test_tool_context_for(temp.path()), + ) + .await + .expect("readFile should open persisted shell output"); + + assert!(read_result.ok); + assert!(read_result.output.contains('x')); + assert!(read_result.output.len() >= 1024); + let read_metadata = read_result.metadata.expect("metadata should exist"); + assert_eq!(read_metadata["persistedRead"], json!(true)); + } + + #[tokio::test] + async fn shell_allows_cwd_outside_working_dir() { + let parent = tempfile::tempdir().expect("tempdir should be created"); + let workspace = parent.path().join("workspace"); + let outside = parent.path().join("outside"); + tokio::fs::create_dir_all(&workspace) + .await + .expect("workspace should be created"); + tokio::fs::create_dir_all(&outside) + .await + .expect("outside dir should be created"); + let tool = ShellTool; + let args = if cfg!(windows) { + json!({ + "command": "(Get-Location).Path", + "shell": "pwsh", + "cwd": outside.to_string_lossy() + }) + } else { + json!({ + "command": "pwd", + "shell": "sh", + "cwd": outside.to_string_lossy() + }) + }; + + let result = tool + .execute( + "tc-shell-cwd-outside".to_string(), + args, + &test_tool_context_for(&workspace), + ) + .await + .expect("shell tool should execute"); + + assert!(result.ok); + let metadata = result.metadata.expect("metadata should exist"); + let expected_cwd = resolve_path(&test_tool_context_for(&workspace), &outside) + .expect("cwd should resolve consistently"); + assert_eq!( + metadata["cwd"], + json!(expected_cwd.to_string_lossy().to_string()) + ); + } + #[tokio::test] async fn shell_tool_rejects_blank_command() { let tool = ShellTool; diff --git a/crates/adapter-tools/src/builtin_tools/skill_tool.rs b/crates/adapter-tools/src/builtin_tools/skill_tool.rs index b8581e49..1a9f243f 100644 --- a/crates/adapter-tools/src/builtin_tools/skill_tool.rs +++ b/crates/adapter-tools/src/builtin_tools/skill_tool.rs @@ -9,20 +9,21 @@ //! //! ## 事实源 //! -//! 唯一事实源为 `SkillCatalog`(adapter-skills), +//! 唯一事实源为 `SkillCatalog` 端口, //! SkillTool 不做独立缓存或发现。 use std::sync::Arc; -use astrcode_adapter_skills::{SKILL_TOOL_NAME, SkillCatalog, SkillSpec, normalize_skill_name}; use astrcode_core::{ - Result, SideEffect, Tool, ToolCapabilityMetadata, ToolContext, ToolDefinition, - ToolExecutionResult, ToolPromptMetadata, + Result, SideEffect, SkillCatalog, SkillSpec, Tool, ToolCapabilityMetadata, ToolContext, + ToolDefinition, ToolExecutionResult, ToolPromptMetadata, normalize_skill_name, }; use async_trait::async_trait; use serde::Deserialize; use serde_json::{Value, json}; +const SKILL_TOOL_NAME: &str = "Skill"; + /// Skill 工具的输入参数。 #[derive(Debug, Deserialize)] struct SkillToolInput { @@ -39,11 +40,11 @@ struct SkillToolInput { /// 每次执行时基于当前 working dir 查询 catalog, /// 确保 surface 替换后不会残留旧 skill。 pub struct SkillTool { - skill_catalog: Arc, + skill_catalog: Arc, } impl SkillTool { - pub fn new(skill_catalog: Arc) -> Self { + pub fn new(skill_catalog: Arc) -> Self { Self { skill_catalog } } } @@ -127,6 +128,7 @@ impl Tool for SkillTool { output: render_skill_content(skill, parsed_input.args.as_deref(), ctx.session_id()), error: None, metadata: None, + child_ref: None, duration_ms: 0, truncated: false, }) @@ -141,6 +143,7 @@ fn skill_error(tool_call_id: String, error: String) -> ToolExecutionResult { output: String::new(), error: Some(error), metadata: None, + child_ref: None, duration_ms: 0, truncated: false, } @@ -199,14 +202,43 @@ fn normalize_skill_path(path: &str) -> String { #[cfg(test)] mod tests { - use std::sync::Arc; + use std::sync::{Arc, RwLock}; - use astrcode_adapter_skills::{SkillCatalog, SkillSource, SkillSpec}; - use astrcode_core::{CancelToken, ToolContext}; + use astrcode_core::{CancelToken, SkillCatalog, SkillSource, SkillSpec, ToolContext}; use serde_json::json; use super::*; + #[derive(Default)] + struct FakeSkillCatalog { + skills: RwLock>, + } + + impl FakeSkillCatalog { + fn new(skills: Vec) -> Self { + Self { + skills: RwLock::new(skills), + } + } + + fn replace_base_skills(&self, skills: Vec) { + let mut guard = self + .skills + .write() + .expect("fake skill catalog lock should not be poisoned"); + *guard = skills; + } + } + + impl SkillCatalog for FakeSkillCatalog { + fn resolve_for_working_dir(&self, _working_dir: &str) -> Vec { + self.skills + .read() + .expect("fake skill catalog lock should not be poisoned") + .clone() + } + } + fn tool_context() -> ToolContext { ToolContext::new("session-1".into(), std::env::temp_dir(), CancelToken::new()) } @@ -226,7 +258,7 @@ mod tests { #[tokio::test] async fn loads_and_expands_skill_content() { - let catalog = Arc::new(SkillCatalog::new(vec![sample_skill()])); + let catalog = Arc::new(FakeSkillCatalog::new(vec![sample_skill()])); let tool = SkillTool::new(catalog); let result = tool @@ -250,7 +282,7 @@ mod tests { #[tokio::test] async fn rejects_unknown_skills() { - let catalog = Arc::new(SkillCatalog::new(vec![sample_skill()])); + let catalog = Arc::new(FakeSkillCatalog::new(vec![sample_skill()])); let tool = SkillTool::new(catalog); let result = tool @@ -273,8 +305,8 @@ mod tests { #[tokio::test] async fn reads_latest_skill_catalog_without_stale_cache() { - let catalog = Arc::new(SkillCatalog::new(vec![sample_skill()])); - let tool = SkillTool::new(Arc::clone(&catalog)); + let catalog = Arc::new(FakeSkillCatalog::new(vec![sample_skill()])); + let tool = SkillTool::new(catalog.clone()); catalog.replace_base_skills(vec![SkillSpec { id: "repo-search".to_string(), diff --git a/crates/adapter-tools/src/builtin_tools/task_write.rs b/crates/adapter-tools/src/builtin_tools/task_write.rs new file mode 100644 index 00000000..ef7b2f99 --- /dev/null +++ b/crates/adapter-tools/src/builtin_tools/task_write.rs @@ -0,0 +1,345 @@ +//! `taskWrite` 工具。 +//! +//! 该工具只维护执行期 task 快照,不触碰 canonical session plan。 + +use std::time::Instant; + +use astrcode_core::{ + AstrError, ExecutionTaskItem, ExecutionTaskSnapshotMetadata, ExecutionTaskStatus, Result, + SideEffect, TaskSnapshot, Tool, ToolCapabilityMetadata, ToolContext, ToolDefinition, + ToolExecutionResult, ToolPromptMetadata, +}; +use async_trait::async_trait; +use serde::Deserialize; +use serde_json::json; + +use crate::builtin_tools::fs_common::check_cancel; + +const MAX_TASK_ITEMS: usize = 20; + +#[derive(Default)] +pub struct TaskWriteTool; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct TaskWriteArgs { + #[serde(default)] + items: Vec, +} + +#[async_trait] +impl Tool for TaskWriteTool { + fn definition(&self) -> ToolDefinition { + ToolDefinition { + name: "taskWrite".to_string(), + description: "Persist the current execution-task snapshot for this execution owner." + .to_string(), + parameters: json!({ + "type": "object", + "properties": { + "items": { + "type": "array", + "description": "Full execution-task snapshot for the current owner. Pass an empty array to clear active tasks.", + "maxItems": MAX_TASK_ITEMS, + "items": { + "type": "object", + "properties": { + "content": { + "type": "string", + "description": "Imperative task title, for example 'Update runtime projection tests'." + }, + "status": { + "type": "string", + "enum": ["pending", "in_progress", "completed"] + }, + "activeForm": { + "type": "string", + "description": "Present-progress phrase used while the task is actively being worked on." + } + }, + "required": ["content", "status"], + "additionalProperties": false + } + } + }, + "required": ["items"], + "additionalProperties": false + }), + } + } + + fn capability_metadata(&self) -> ToolCapabilityMetadata { + ToolCapabilityMetadata::builtin() + .tags(["task", "execution", "progress"]) + .side_effect(SideEffect::Local) + .prompt( + ToolPromptMetadata::new( + "Maintain the current execution-task snapshot for this branch of work.", + "Use `taskWrite` for non-trivial execution that benefits from an externalized \ + task list. Always send the full current snapshot, not a patch. Prefer it for \ + multi-step implementation, user requests with multiple deliverables, or work \ + that must survive long turns and session recovery.", + ) + .caveat( + "Do not use `taskWrite` for trivial one-step work, pure Q&A, or tasks that \ + can be completed in roughly three straightforward actions.", + ) + .caveat( + "Keep exactly one item in `in_progress` at a time. Mark an item `in_progress` \ + before starting it, and mark it `completed` immediately after it is truly \ + finished.", + ) + .caveat( + "Every item should have a concise imperative `content`. Any `in_progress` \ + item must also include `activeForm`, such as '正在补充会话投影测试'.", + ) + .caveat( + "Passing an empty array clears active tasks. Completed-only snapshots are \ + also treated as cleared by the runtime projection.", + ) + .example( + "{ items: [{ content: \"补齐 runtime task 投影\", status: \"in_progress\", \ + activeForm: \"正在补齐 runtime task 投影\" }, { content: \"验证 prompt \ + 注入\", status: \"pending\", activeForm: \"准备验证 prompt 注入\" }] }", + ) + .prompt_tag("task") + .always_include(true), + ) + } + + async fn execute( + &self, + tool_call_id: String, + input: serde_json::Value, + ctx: &ToolContext, + ) -> Result { + check_cancel(ctx.cancel())?; + + let args: TaskWriteArgs = serde_json::from_value(input) + .map_err(|error| AstrError::parse("invalid args for taskWrite", error))?; + validate_items(&args.items)?; + + let started_at = Instant::now(); + let snapshot = TaskSnapshot { + owner: resolve_task_owner(ctx), + items: args.items, + }; + let metadata = ExecutionTaskSnapshotMetadata::from_snapshot(&snapshot); + let (pending_count, in_progress_count, completed_count) = count_statuses(&snapshot.items); + let cleared = metadata.cleared; + + Ok(ToolExecutionResult { + tool_call_id, + tool_name: "taskWrite".to_string(), + ok: true, + output: if cleared { + "cleared active execution tasks".to_string() + } else { + format!( + "updated execution tasks: {in_progress_count} in progress, {pending_count} \ + pending, {completed_count} completed" + ) + }, + error: None, + metadata: Some( + serde_json::to_value(metadata) + .map_err(|error| AstrError::parse("invalid taskWrite metadata", error))?, + ), + child_ref: None, + duration_ms: started_at.elapsed().as_millis() as u64, + truncated: false, + }) + } +} + +fn validate_items(items: &[ExecutionTaskItem]) -> Result<()> { + if items.len() > MAX_TASK_ITEMS { + return Err(AstrError::Validation(format!( + "taskWrite accepts at most {MAX_TASK_ITEMS} items per snapshot" + ))); + } + + let in_progress_count = items + .iter() + .filter(|item| item.status == ExecutionTaskStatus::InProgress) + .count(); + if in_progress_count > 1 { + return Err(AstrError::Validation( + "taskWrite snapshot must contain at most one in_progress item".to_string(), + )); + } + + for (index, item) in items.iter().enumerate() { + if item.content.trim().is_empty() { + return Err(AstrError::Validation(format!( + "taskWrite item #{index} content must not be empty" + ))); + } + if item + .active_form + .as_deref() + .is_some_and(|value| value.trim().is_empty()) + { + return Err(AstrError::Validation(format!( + "taskWrite item #{} activeForm must not be blank when provided", + index + ))); + } + if item.status == ExecutionTaskStatus::InProgress + && item + .active_form + .as_deref() + .is_none_or(|value| value.trim().is_empty()) + { + return Err(AstrError::Validation(format!( + "taskWrite item #{} with status in_progress must include activeForm", + index + ))); + } + } + + Ok(()) +} + +fn resolve_task_owner(ctx: &ToolContext) -> String { + ctx.agent_context() + .agent_id + .as_ref() + .map(ToString::to_string) + .unwrap_or_else(|| ctx.session_id().to_string()) +} + +fn count_statuses(items: &[ExecutionTaskItem]) -> (usize, usize, usize) { + items + .iter() + .fold((0usize, 0usize, 0usize), |counts, item| match item.status { + ExecutionTaskStatus::Pending => (counts.0 + 1, counts.1, counts.2), + ExecutionTaskStatus::InProgress => (counts.0, counts.1 + 1, counts.2), + ExecutionTaskStatus::Completed => (counts.0, counts.1, counts.2 + 1), + }) +} + +#[cfg(test)] +mod tests { + use astrcode_core::AgentEventContext; + use serde_json::Value; + + use super::*; + use crate::test_support::test_tool_context_for; + + fn metadata_snapshot(result: &ToolExecutionResult) -> ExecutionTaskSnapshotMetadata { + serde_json::from_value(result.metadata.clone().expect("metadata should exist")) + .expect("task metadata should decode") + } + + #[tokio::test] + async fn task_write_accepts_valid_snapshot() { + let temp = tempfile::tempdir().expect("tempdir should exist"); + let tool = TaskWriteTool; + let ctx = test_tool_context_for(temp.path()).with_agent_context(AgentEventContext { + agent_id: Some("agent-main".into()), + ..AgentEventContext::default() + }); + + let result = tool + .execute( + "tc-task-valid".to_string(), + json!({ + "items": [ + { + "content": "实现 task 投影", + "status": "in_progress", + "activeForm": "正在实现 task 投影" + }, + { + "content": "补充服务端映射测试", + "status": "pending" + } + ] + }), + &ctx, + ) + .await + .expect("taskWrite should execute"); + + assert!(result.ok); + let metadata = metadata_snapshot(&result); + assert_eq!(metadata.owner, "agent-main"); + assert!(!metadata.cleared); + assert_eq!(metadata.items.len(), 2); + } + + #[tokio::test] + async fn task_write_rejects_invalid_snapshot() { + let temp = tempfile::tempdir().expect("tempdir should exist"); + let tool = TaskWriteTool; + + let error = tool + .execute( + "tc-task-invalid".to_string(), + json!({ + "items": [ + { + "content": "任务 A", + "status": "in_progress", + "activeForm": "正在处理任务 A" + }, + { + "content": "任务 B", + "status": "in_progress", + "activeForm": "正在处理任务 B" + } + ] + }), + &test_tool_context_for(temp.path()), + ) + .await + .expect_err("multiple in_progress items should be rejected"); + + assert!(error.to_string().contains("at most one in_progress item")); + } + + #[tokio::test] + async fn task_write_rejects_oversized_snapshot() { + let temp = tempfile::tempdir().expect("tempdir should exist"); + let tool = TaskWriteTool; + let items = (0..=MAX_TASK_ITEMS) + .map(|index| { + json!({ + "content": format!("任务 {index}"), + "status": "pending" + }) + }) + .collect::>(); + + let error = tool + .execute( + "tc-task-oversized".to_string(), + json!({ "items": items }), + &test_tool_context_for(temp.path()), + ) + .await + .expect_err("oversized snapshot should be rejected"); + + assert!(error.to_string().contains("at most 20 items")); + } + + #[tokio::test] + async fn task_write_falls_back_to_session_owner_without_agent_id() { + let temp = tempfile::tempdir().expect("tempdir should exist"); + let tool = TaskWriteTool; + + let result = tool + .execute( + "tc-task-owner".to_string(), + json!({ "items": [] }), + &test_tool_context_for(temp.path()), + ) + .await + .expect("taskWrite should execute"); + + let metadata = metadata_snapshot(&result); + assert_eq!(metadata.owner, "session-test"); + assert!(metadata.cleared); + } +} diff --git a/crates/adapter-tools/src/builtin_tools/tool_search.rs b/crates/adapter-tools/src/builtin_tools/tool_search.rs index 2a2b2d47..cc16c14b 100644 --- a/crates/adapter-tools/src/builtin_tools/tool_search.rs +++ b/crates/adapter-tools/src/builtin_tools/tool_search.rs @@ -188,6 +188,7 @@ impl Tool for ToolSearchTool { "returned": payload.len(), "query": args.query, })), + child_ref: None, duration_ms: 0, truncated: false, }) diff --git a/crates/adapter-tools/src/builtin_tools/upsert_session_plan.rs b/crates/adapter-tools/src/builtin_tools/upsert_session_plan.rs new file mode 100644 index 00000000..7a13e812 --- /dev/null +++ b/crates/adapter-tools/src/builtin_tools/upsert_session_plan.rs @@ -0,0 +1,388 @@ +//! `upsertSessionPlan` 工具。 +//! +//! 该工具只允许写当前 session 下的 `plan/` 目录和 `state.json`, +//! 作为 canonical session plan 的唯一受限写入口。 + +use std::{fs, time::Instant}; + +use astrcode_core::{ + AstrError, Result, SessionPlanState, SessionPlanStatus, SideEffect, Tool, + ToolCapabilityMetadata, ToolContext, ToolDefinition, ToolExecutionResult, ToolPromptMetadata, +}; +use async_trait::async_trait; +use chrono::Utc; +use serde::Deserialize; +use serde_json::json; + +use crate::builtin_tools::{ + fs_common::check_cancel, + session_plan::{ + PLAN_PATH_TIMESTAMP_FORMAT, load_session_plan_state, persist_session_plan_state, + session_plan_markdown_path, session_plan_paths, + }, +}; + +#[derive(Default)] +pub struct UpsertSessionPlanTool; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct UpsertSessionPlanArgs { + title: String, + content: String, + #[serde(default)] + status: Option, +} + +#[async_trait] +impl Tool for UpsertSessionPlanTool { + fn definition(&self) -> ToolDefinition { + ToolDefinition { + name: "upsertSessionPlan".to_string(), + description: "Create or overwrite the canonical session plan artifact and its state." + .to_string(), + parameters: json!({ + "type": "object", + "properties": { + "title": { + "type": "string", + "description": "Human-readable plan title." + }, + "content": { + "type": "string", + "description": "Full markdown body to persist into the canonical session plan file." + }, + "status": { + "type": "string", + "enum": ["draft", "awaiting_approval", "approved", "completed", "superseded"], + "description": "Plan state to persist alongside the markdown artifact." + } + }, + "required": ["title", "content"], + "additionalProperties": false + }), + } + } + + fn capability_metadata(&self) -> ToolCapabilityMetadata { + ToolCapabilityMetadata::builtin() + .tags(["filesystem", "write", "plan"]) + .permission("filesystem.write") + .side_effect(SideEffect::Local) + .prompt( + ToolPromptMetadata::new( + "Create or update the canonical session plan artifact.", + "Use `upsertSessionPlan` when plan mode needs to persist the canonical \ + session plan markdown and its `state.json`. This tool is the only supported \ + writer for `sessions//plan/**`.", + ) + .caveat( + "A session has exactly one canonical plan. Revise that plan for the same \ + task; if the task changes, overwrite the current canonical plan instead of \ + creating another one.", + ) + .example( + "{ title: \"Cleanup crates\", content: \"# Plan: Cleanup crates\\n...\", \ + status: \"draft\" }", + ) + .prompt_tag("plan") + .always_include(true), + ) + } + + async fn execute( + &self, + tool_call_id: String, + args: serde_json::Value, + ctx: &ToolContext, + ) -> Result { + check_cancel(ctx.cancel())?; + + let args: UpsertSessionPlanArgs = serde_json::from_value(args) + .map_err(|error| AstrError::parse("invalid args for upsertSessionPlan", error))?; + let title = args.title.trim(); + if title.is_empty() { + return Err(AstrError::Validation( + "plan title must not be empty".to_string(), + )); + } + let content = args.content.trim(); + if content.is_empty() { + return Err(AstrError::Validation( + "plan markdown content must not be empty".to_string(), + )); + } + + let started_at = Instant::now(); + let paths = session_plan_paths(ctx)?; + let now = Utc::now(); + let existing = load_session_plan_state(&paths.state_path)?; + let slug = existing + .as_ref() + .map(|state| state.active_plan_slug.clone()) + .or_else(|| slugify(&args.title)) + .unwrap_or_else(|| format!("plan-{}", Utc::now().format(PLAN_PATH_TIMESTAMP_FORMAT))); + let plan_path = session_plan_markdown_path(&paths.plan_dir, &slug); + let status = args.status.unwrap_or(SessionPlanStatus::Draft); + + fs::create_dir_all(&paths.plan_dir).map_err(|error| { + AstrError::io( + format!( + "failed creating session plan directory '{}'", + paths.plan_dir.display() + ), + error, + ) + })?; + fs::write(&plan_path, format!("{content}\n")).map_err(|error| { + AstrError::io( + format!("failed writing session plan file '{}'", plan_path.display()), + error, + ) + })?; + + let state = SessionPlanState { + active_plan_slug: slug.clone(), + title: title.to_string(), + status: status.clone(), + created_at: existing + .as_ref() + .map(|state| state.created_at) + .unwrap_or(now), + updated_at: now, + reviewed_plan_digest: None, + approved_at: match status { + SessionPlanStatus::Approved => existing + .as_ref() + .and_then(|state| state.approved_at) + .or(Some(now)), + _ => None, + }, + archived_plan_digest: existing + .as_ref() + .and_then(|state| state.archived_plan_digest.clone()), + archived_at: existing.as_ref().and_then(|state| state.archived_at), + }; + persist_session_plan_state(&paths.state_path, &state)?; + + Ok(ToolExecutionResult { + tool_call_id, + tool_name: "upsertSessionPlan".to_string(), + ok: true, + output: format!( + "updated session plan '{}' at {}", + title, + plan_path.display() + ), + error: None, + metadata: Some(json!({ + "planPath": plan_path.to_string_lossy(), + "slug": slug, + "status": state.status.as_str(), + "title": state.title, + "updatedAt": state.updated_at.to_rfc3339(), + })), + child_ref: None, + duration_ms: started_at.elapsed().as_millis() as u64, + truncated: false, + }) + } +} + +fn slugify(input: &str) -> Option { + let mut normalized = String::new(); + let mut last_dash = false; + for ch in input.chars().map(|ch| ch.to_ascii_lowercase()) { + if ch.is_ascii_alphanumeric() { + normalized.push(ch); + last_dash = false; + continue; + } + if (ch == '-' || ch == '_' || ch.is_whitespace()) && !last_dash && !normalized.is_empty() { + normalized.push('-'); + last_dash = true; + } + } + let normalized = normalized.trim_matches('-').to_string(); + if normalized.is_empty() { + None + } else { + Some(normalized) + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + use crate::test_support::test_tool_context_for; + + #[tokio::test] + async fn upsert_session_plan_creates_canonical_plan_state() { + let temp = tempfile::tempdir().expect("tempdir should exist"); + let tool = UpsertSessionPlanTool; + let result = tool + .execute( + "tc-plan-create".to_string(), + json!({ + "title": "Cleanup crates", + "content": "# Plan: Cleanup crates\n\n## Context", + "status": "draft" + }), + &test_tool_context_for(temp.path()), + ) + .await + .expect("tool should execute"); + + assert!(result.ok); + let plan_dir = temp + .path() + .join(".astrcode-test-state") + .join("sessions") + .join("session-test") + .join("plan"); + let metadata = result.metadata.expect("metadata should exist"); + let slug = metadata["slug"].as_str().expect("slug should exist"); + assert!(plan_dir.join(format!("{slug}.md")).exists()); + assert!(plan_dir.join("state.json").exists()); + } + + #[tokio::test] + async fn upsert_session_plan_reuses_existing_slug() { + let temp = tempfile::tempdir().expect("tempdir should exist"); + let tool = UpsertSessionPlanTool; + let ctx = test_tool_context_for(temp.path()); + + let first = tool + .execute( + "tc-plan-initial".to_string(), + json!({ + "title": "Cleanup crates", + "content": "# Plan: Cleanup crates", + "status": "draft" + }), + &ctx, + ) + .await + .expect("initial write should work"); + let first_slug = first + .metadata + .as_ref() + .and_then(|metadata| metadata["slug"].as_str()) + .expect("slug should exist") + .to_string(); + + let result = tool + .execute( + "tc-plan-update".to_string(), + json!({ + "title": "Cleanup crates revised", + "content": "# Plan: Cleanup crates revised", + "status": "awaiting_approval" + }), + &ctx, + ) + .await + .expect("update should execute"); + + assert!(result.ok); + assert_eq!( + result.metadata.expect("metadata should exist")["slug"], + json!(first_slug) + ); + } + + #[tokio::test] + async fn upsert_session_plan_preserves_archive_markers() { + let temp = tempfile::tempdir().expect("tempdir should exist"); + let tool = UpsertSessionPlanTool; + let ctx = test_tool_context_for(temp.path()); + + tool.execute( + "tc-plan-first".to_string(), + json!({ + "title": "Cleanup crates", + "content": "# Plan: Cleanup crates", + "status": "approved" + }), + &ctx, + ) + .await + .expect("first plan should work"); + + let state_path = session_plan_paths(&ctx) + .expect("plan paths should resolve") + .state_path; + let mut state = load_session_plan_state(&state_path) + .expect("state should load") + .expect("state should exist"); + state.archived_plan_digest = Some("digest-a".to_string()); + state.archived_at = Some(Utc::now()); + persist_session_plan_state(&state_path, &state).expect("state should persist"); + + tool.execute( + "tc-plan-second".to_string(), + json!({ + "title": "Cleanup crates revised", + "content": "# Plan: Cleanup crates revised", + "status": "draft" + }), + &ctx, + ) + .await + .expect("second write should work"); + + let state = load_session_plan_state(&state_path) + .expect("state should load") + .expect("state should exist"); + assert_eq!(state.archived_plan_digest.as_deref(), Some("digest-a")); + assert!(state.reviewed_plan_digest.is_none()); + } + + #[tokio::test] + async fn upsert_session_plan_preserves_existing_custom_slug_from_state() { + let temp = tempfile::tempdir().expect("tempdir should exist"); + let tool = UpsertSessionPlanTool; + let ctx = test_tool_context_for(temp.path()); + let paths = session_plan_paths(&ctx).expect("plan paths should resolve"); + let now = Utc::now(); + let existing_slug = "my-custom-slug".to_string(); + + persist_session_plan_state( + &paths.state_path, + &SessionPlanState { + active_plan_slug: existing_slug.clone(), + title: "Existing title".to_string(), + status: SessionPlanStatus::Draft, + created_at: now, + updated_at: now, + reviewed_plan_digest: None, + approved_at: None, + archived_plan_digest: None, + archived_at: None, + }, + ) + .expect("existing state should persist"); + + let result = tool + .execute( + "tc-plan-custom-slug".to_string(), + json!({ + "title": "Completely different title", + "content": "# Plan: revised", + "status": "draft" + }), + &ctx, + ) + .await + .expect("update should execute"); + + assert!(result.ok); + assert_eq!( + result.metadata.expect("metadata should exist")["slug"], + json!(existing_slug) + ); + assert!(paths.plan_dir.join("my-custom-slug.md").exists()); + } +} diff --git a/crates/adapter-tools/src/builtin_tools/write_file.rs b/crates/adapter-tools/src/builtin_tools/write_file.rs index 6ef8f942..a8bbd2dc 100644 --- a/crates/adapter-tools/src/builtin_tools/write_file.rs +++ b/crates/adapter-tools/src/builtin_tools/write_file.rs @@ -19,8 +19,9 @@ use serde::Deserialize; use serde_json::json; use crate::builtin_tools::fs_common::{ - TextChangeReport, build_text_change_report, check_cancel, is_symlink, is_unc_path, - read_utf8_file, resolve_path, write_text_file, + TextChangeReport, build_text_change_report, check_cancel, + ensure_not_canonical_session_plan_write_target, is_symlink, is_unc_path, read_utf8_file, + resolve_path, write_text_file, }; /// WriteFile 工具实现。 @@ -71,7 +72,7 @@ impl Tool for WriteFileTool { ToolCapabilityMetadata::builtin() .tags(["filesystem", "write"]) .permission("filesystem.write") - .side_effect(SideEffect::Workspace) + .side_effect(SideEffect::Local) .prompt( ToolPromptMetadata::new( "Create or fully replace a text file when the whole target content is known.", @@ -81,8 +82,8 @@ impl Tool for WriteFileTool { ) .caveat( "Overwrites the entire file. `createDirs` defaults to false — parent \ - directories must exist or set it to true. Path must stay inside the working \ - directory.", + directories must exist or set it to true. Relative paths resolve from the \ + current working directory; absolute host paths are allowed.", ) .caveat( "For small edits to existing files, prefer `editFile` or `apply_patch` to \ @@ -109,6 +110,7 @@ impl Tool for WriteFileTool { .map_err(|e| AstrError::parse("invalid args for writeFile", e))?; let started_at = Instant::now(); let path = resolve_path(ctx, &args.path)?; + ensure_not_canonical_session_plan_write_target(ctx, &path, "writeFile")?; // UNC 路径检查:防止 Windows NTLM 凭据泄露 if is_unc_path(&path) { @@ -126,6 +128,7 @@ impl Tool for WriteFileTool { "path": path.to_string_lossy(), "uncPath": true, })), + child_ref: None, duration_ms: started_at.elapsed().as_millis() as u64, truncated: false, }); @@ -139,14 +142,15 @@ impl Tool for WriteFileTool { ok: false, output: String::new(), error: Some(format!( - "refusing to write to symlink '{}' (symlinks may point outside working \ - directory)", + "refusing to write to symlink '{}' (symlinks may point outside the intended \ + target path)", path.display() )), metadata: Some(json!({ "path": path.to_string_lossy(), "isSymlink": true, })), + child_ref: None, duration_ms: started_at.elapsed().as_millis() as u64, truncated: false, }); @@ -199,6 +203,7 @@ impl Tool for WriteFileTool { output: report.summary, error: None, metadata: Some(metadata), + child_ref: None, duration_ms: started_at.elapsed().as_millis() as u64, truncated: false, }) @@ -317,4 +322,61 @@ mod tests { assert!(err.to_string().contains("failed writing file")); } + + #[tokio::test] + async fn write_file_allows_relative_path_outside_working_dir() { + let parent = tempfile::tempdir().expect("tempdir should be created"); + let workspace = parent.path().join("workspace"); + let outside = parent.path().join("outside.txt"); + tokio::fs::create_dir_all(&workspace) + .await + .expect("workspace should be created"); + let tool = WriteFileTool; + + let result = tool + .execute( + "tc-write-outside".to_string(), + json!({ + "path": "../outside.txt", + "content": "outside" + }), + &test_tool_context_for(&workspace), + ) + .await + .expect("writeFile should execute"); + + assert!(result.ok); + let content = tokio::fs::read_to_string(&outside) + .await + .expect("outside file should be readable"); + assert_eq!(content, "outside"); + } + + #[tokio::test] + async fn write_file_rejects_canonical_session_plan_targets() { + let temp = tempfile::tempdir().expect("tempdir should be created"); + let tool = WriteFileTool; + let target = temp + .path() + .join(".astrcode-test-state") + .join("sessions") + .join("session-test") + .join("plan") + .join("cleanup-crates.md"); + + let err = tool + .execute( + "tc-write-plan".to_string(), + json!({ + "path": target.to_string_lossy(), + "content": "# Plan: Cleanup crates", + "createDirs": true + }), + &test_tool_context_for(temp.path()), + ) + .await + .expect_err("canonical plan writes should be rejected"); + + assert!(err.to_string().contains("upsertSessionPlan")); + } } diff --git a/crates/adapter-tools/src/test_support.rs b/crates/adapter-tools/src/test_support.rs index 1159fd8b..613d9990 100644 --- a/crates/adapter-tools/src/test_support.rs +++ b/crates/adapter-tools/src/test_support.rs @@ -9,9 +9,11 @@ pub fn test_tool_context_for(path: impl Into) -> ToolContext { let session_storage_root = cwd.join(".astrcode-test-state"); ToolContext::new("session-test".to_string().into(), cwd, CancelToken::new()) .with_session_storage_root(session_storage_root) - // 测试上下文使用 100KB 内联阈值,与 readFile 的 max_result_inline_size(100_000) 对齐。 - // 避免工具测试中 readFile 二次持久化已持久化的 grep 结果。 - .with_resolved_inline_limit(100_000) + // 测试上下文把 readFile 的最终内联阈值抬高到 1MB。 + // grep 等工具仍使用各自固定的持久化阈值,所以大结果会按预期先落盘; + // 但后续 readFile 读取这些持久化文件时,不应因为临时目录路径更长等环境差异 + // 再次被持久化,导致测试对输出形态出现非确定性。 + .with_resolved_inline_limit(1_000_000) } pub fn canonical_tool_path(path: impl AsRef) -> PathBuf { diff --git a/crates/application/Cargo.toml b/crates/application/Cargo.toml index 1499ae3c..b31a8352 100644 --- a/crates/application/Cargo.toml +++ b/crates/application/Cargo.toml @@ -20,4 +20,5 @@ log.workspace = true uuid.workspace = true [dev-dependencies] +astrcode-core = { path = "../core", features = ["test-support"] } tempfile.workspace = true diff --git a/crates/application/src/agent/context.rs b/crates/application/src/agent/context.rs new file mode 100644 index 00000000..d718f85b --- /dev/null +++ b/crates/application/src/agent/context.rs @@ -0,0 +1,564 @@ +//! Agent 协作事实记录与上下文构建。 +//! +//! 从 agent/mod.rs 提取出的两个关注点: +//! - `CollaborationFactRecord`:记录一次协作动作(spawn/send/observe/close)的结构化事实 +//! - `AgentOrchestrationService` 的上下文构建方法(root/child 的 event context) + +use std::path::Path; + +use astrcode_core::{ + AgentCollaborationActionKind, AgentCollaborationFact, AgentCollaborationOutcomeKind, + AgentCollaborationPolicyContext, AgentEventContext, ChildExecutionIdentity, InvocationKind, + ModeId, ResolvedExecutionLimitsSnapshot, Result, SubRunHandle, ToolContext, +}; + +use super::{ + AgentOrchestrationError, AgentOrchestrationService, IMPLICIT_ROOT_PROFILE_ID, + root_execution_event_context, subrun_event_context, +}; +use crate::governance_surface::{GOVERNANCE_POLICY_REVISION, collaboration_policy_context}; + +pub(crate) struct CollaborationFactRecord<'a> { + pub(crate) action: AgentCollaborationActionKind, + pub(crate) outcome: AgentCollaborationOutcomeKind, + pub(crate) session_id: &'a str, + pub(crate) turn_id: &'a str, + pub(crate) parent_agent_id: Option, + pub(crate) child: Option<&'a SubRunHandle>, + pub(crate) delivery_id: Option, + pub(crate) reason_code: Option, + pub(crate) summary: Option, + pub(crate) latency_ms: Option, + pub(crate) source_tool_call_id: Option, + pub(crate) policy: Option, + pub(crate) governance_revision: Option, + pub(crate) mode_id: Option, +} + +impl<'a> CollaborationFactRecord<'a> { + pub(crate) fn new( + action: AgentCollaborationActionKind, + outcome: AgentCollaborationOutcomeKind, + session_id: &'a str, + turn_id: &'a str, + ) -> Self { + Self { + action, + outcome, + session_id, + turn_id, + parent_agent_id: None, + child: None, + delivery_id: None, + reason_code: None, + summary: None, + latency_ms: None, + source_tool_call_id: None, + policy: None, + governance_revision: None, + mode_id: None, + } + } + + pub(crate) fn parent_agent_id(mut self, parent_agent_id: Option) -> Self { + self.parent_agent_id = parent_agent_id; + self + } + + pub(crate) fn child(mut self, child: &'a SubRunHandle) -> Self { + self.child = Some(child); + self + } + + pub(crate) fn delivery_id(mut self, delivery_id: impl Into) -> Self { + self.delivery_id = Some(delivery_id.into()); + self + } + + pub(crate) fn reason_code(mut self, reason_code: impl Into) -> Self { + self.reason_code = Some(reason_code.into()); + self + } + + pub(crate) fn summary(mut self, summary: impl Into) -> Self { + self.summary = Some(summary.into()); + self + } + + pub(crate) fn latency_ms(mut self, latency_ms: u64) -> Self { + self.latency_ms = Some(latency_ms); + self + } + + pub(crate) fn source_tool_call_id(mut self, source_tool_call_id: Option) -> Self { + self.source_tool_call_id = source_tool_call_id; + self + } + + pub(crate) fn policy(mut self, policy: AgentCollaborationPolicyContext) -> Self { + self.policy = Some(policy); + self + } + + pub(crate) fn governance_revision(mut self, governance_revision: impl Into) -> Self { + self.governance_revision = Some(governance_revision.into()); + self + } + + pub(crate) fn mode_id(mut self, mode_id: Option) -> Self { + self.mode_id = mode_id; + self + } +} + +pub(crate) struct ToolCollaborationContext { + runtime: astrcode_core::ResolvedRuntimeConfig, + session_id: String, + turn_id: String, + parent_agent_id: Option, + source_tool_call_id: Option, + policy: AgentCollaborationPolicyContext, + governance_revision: String, + mode_id: ModeId, +} + +pub(crate) struct ToolCollaborationContextInput { + pub(crate) runtime: astrcode_core::ResolvedRuntimeConfig, + pub(crate) session_id: String, + pub(crate) turn_id: String, + pub(crate) parent_agent_id: Option, + pub(crate) source_tool_call_id: Option, + pub(crate) policy: AgentCollaborationPolicyContext, + pub(crate) governance_revision: String, + pub(crate) mode_id: ModeId, +} + +impl ToolCollaborationContext { + pub(crate) fn new(input: ToolCollaborationContextInput) -> Self { + Self { + runtime: input.runtime, + session_id: input.session_id, + turn_id: input.turn_id, + parent_agent_id: input.parent_agent_id, + source_tool_call_id: input.source_tool_call_id, + policy: input.policy, + governance_revision: input.governance_revision, + mode_id: input.mode_id, + } + } + + pub(crate) fn with_parent_agent_id(mut self, parent_agent_id: Option) -> Self { + self.parent_agent_id = parent_agent_id; + self + } + + pub(crate) fn runtime(&self) -> &astrcode_core::ResolvedRuntimeConfig { + &self.runtime + } + + pub(crate) fn session_id(&self) -> &str { + &self.session_id + } + + pub(crate) fn turn_id(&self) -> &str { + &self.turn_id + } + + pub(crate) fn parent_agent_id(&self) -> Option { + self.parent_agent_id.clone() + } + + pub(crate) fn source_tool_call_id(&self) -> Option { + self.source_tool_call_id.clone() + } + + pub(crate) fn policy(&self) -> AgentCollaborationPolicyContext { + self.policy.clone() + } + + pub(crate) fn governance_revision(&self) -> &str { + &self.governance_revision + } + + pub(crate) fn mode_id(&self) -> &ModeId { + &self.mode_id + } + + pub(crate) fn fact<'a>( + &'a self, + action: AgentCollaborationActionKind, + outcome: AgentCollaborationOutcomeKind, + ) -> CollaborationFactRecord<'a> { + CollaborationFactRecord::new(action, outcome, &self.session_id, &self.turn_id) + .parent_agent_id(self.parent_agent_id()) + .source_tool_call_id(self.source_tool_call_id()) + .policy(self.policy()) + .governance_revision(self.governance_revision()) + .mode_id(Some(self.mode_id().clone())) + } +} + +pub(crate) fn implicit_session_root_agent_id(session_id: &str) -> String { + format!( + "root-agent:{}", + astrcode_session_runtime::normalize_session_id(session_id) + ) +} + +fn default_resolved_limits_for_gateway( + gateway: &astrcode_kernel::KernelGateway, + max_steps: Option, +) -> ResolvedExecutionLimitsSnapshot { + ResolvedExecutionLimitsSnapshot { + allowed_tools: gateway.capabilities().tool_names(), + max_steps, + } +} + +/// 为 handle 补填 resolved_limits。 +/// +/// 某些老路径注册的 root agent(如隐式注册)可能没有在注册时就写入 limits, +/// 此函数检测到空 allowed_tools 时从 gateway 全量 capability 补填。 +async fn ensure_handle_has_resolved_limits( + kernel: &dyn crate::AgentKernelPort, + gateway: &astrcode_kernel::KernelGateway, + handle: SubRunHandle, + max_steps: Option, +) -> std::result::Result { + if !handle.resolved_limits.allowed_tools.is_empty() { + return Ok(handle); + } + + super::persist_resolved_limits_for_handle( + kernel, + handle, + default_resolved_limits_for_gateway(gateway, max_steps), + ) + .await +} + +impl AgentOrchestrationService { + pub(super) fn resolve_runtime_config_for_working_dir( + &self, + working_dir: &Path, + ) -> std::result::Result { + self.config_service + .load_resolved_runtime_config(Some(working_dir)) + .map_err(|error| AgentOrchestrationError::Internal(error.to_string())) + } + + pub(super) async fn resolve_runtime_config_for_session( + &self, + session_id: &str, + ) -> std::result::Result { + let working_dir = self + .session_runtime + .get_session_working_dir(session_id) + .await + .map_err(AgentOrchestrationError::from)?; + self.resolve_runtime_config_for_working_dir(Path::new(&working_dir)) + } + + pub(super) fn resolve_subagent_profile( + &self, + working_dir: &Path, + profile_id: &str, + ) -> std::result::Result { + self.profiles + .find_profile(working_dir, profile_id) + .map_err(|error| match error { + crate::ApplicationError::NotFound(message) => { + AgentOrchestrationError::NotFound(message) + }, + crate::ApplicationError::InvalidArgument(message) => { + AgentOrchestrationError::InvalidInput(message) + }, + other => AgentOrchestrationError::Internal(other.to_string()), + }) + } + + pub(super) async fn append_collaboration_fact( + &self, + fact: AgentCollaborationFact, + ) -> std::result::Result<(), AgentOrchestrationError> { + let turn_id = fact.turn_id.clone(); + let parent_session_id = fact.parent_session_id.clone(); + let event_agent = if let Some(parent_agent_id) = fact.parent_agent_id.as_deref() { + self.kernel + .get_handle(parent_agent_id) + .await + .map(|handle| { + if handle.depth == 0 { + root_execution_event_context(handle.agent_id, handle.agent_profile) + } else { + subrun_event_context(&handle) + } + }) + .unwrap_or_default() + } else { + AgentEventContext::default() + }; + self.session_runtime + .append_agent_collaboration_fact( + &parent_session_id, + &turn_id, + event_agent, + fact.clone(), + ) + .await + .map_err(AgentOrchestrationError::from)?; + self.metrics.record_agent_collaboration_fact(&fact); + Ok(()) + } + + pub(super) async fn record_collaboration_fact( + &self, + runtime: &astrcode_core::ResolvedRuntimeConfig, + record: CollaborationFactRecord<'_>, + ) -> std::result::Result<(), AgentOrchestrationError> { + let fact = AgentCollaborationFact { + fact_id: format!("acf-{}", uuid::Uuid::new_v4()).into(), + action: record.action, + outcome: record.outcome, + parent_session_id: record.session_id.to_string().into(), + turn_id: record.turn_id.to_string().into(), + parent_agent_id: record.parent_agent_id.map(Into::into), + child_identity: record.child.and_then(|handle| { + handle + .child_session_id + .clone() + .map(|child_session_id| ChildExecutionIdentity { + agent_id: handle.agent_id.clone(), + session_id: child_session_id, + sub_run_id: handle.sub_run_id.clone(), + }) + }), + delivery_id: record.delivery_id.map(Into::into), + reason_code: record.reason_code, + summary: record.summary, + latency_ms: record.latency_ms, + source_tool_call_id: record.source_tool_call_id.map(Into::into), + governance_revision: record.governance_revision, + mode_id: record.mode_id, + policy: record + .policy + .unwrap_or_else(|| collaboration_policy_context(runtime)), + }; + self.append_collaboration_fact(fact).await + } + + pub(super) async fn tool_collaboration_context( + &self, + ctx: &ToolContext, + ) -> std::result::Result { + let runtime = self.resolve_runtime_config_for_working_dir(ctx.working_dir())?; + let mode_id = self + .session_runtime + .session_mode_state(ctx.session_id()) + .await + .map_err(AgentOrchestrationError::from)? + .current_mode_id; + Ok(ToolCollaborationContext::new( + ToolCollaborationContextInput { + runtime: runtime.clone(), + session_id: ctx.session_id().to_string(), + turn_id: ctx.turn_id().unwrap_or("unknown-turn").to_string(), + parent_agent_id: ctx.agent_context().agent_id.clone().map(Into::into), + source_tool_call_id: ctx.tool_call_id().map(ToString::to_string), + policy: collaboration_policy_context(&runtime), + governance_revision: GOVERNANCE_POLICY_REVISION.to_string(), + mode_id, + }, + )) + } + + pub(super) async fn record_fact_best_effort( + &self, + runtime: &astrcode_core::ResolvedRuntimeConfig, + record: CollaborationFactRecord<'_>, + ) { + let _ = self.record_collaboration_fact(runtime, record).await; + } + + pub(super) async fn reject_with_fact( + &self, + runtime: &astrcode_core::ResolvedRuntimeConfig, + record: CollaborationFactRecord<'_>, + error: AgentOrchestrationError, + ) -> std::result::Result { + self.record_fact_best_effort(runtime, record).await; + Err(error) + } + + pub(super) async fn reject_spawn( + &self, + collaboration: &ToolCollaborationContext, + reason_code: &str, + error: AgentOrchestrationError, + ) -> Result { + self.record_fact_best_effort( + collaboration.runtime(), + collaboration + .fact( + AgentCollaborationActionKind::Spawn, + AgentCollaborationOutcomeKind::Rejected, + ) + .reason_code(reason_code) + .summary(error.to_string()), + ) + .await; + Err(super::map_orchestration_error(error)) + } + + pub(super) async fn fail_spawn_internal( + &self, + collaboration: &ToolCollaborationContext, + reason_code: &str, + message: String, + ) -> Result { + self.record_fact_best_effort( + collaboration.runtime(), + collaboration + .fact( + AgentCollaborationActionKind::Spawn, + AgentCollaborationOutcomeKind::Failed, + ) + .reason_code(reason_code) + .summary(message.clone()), + ) + .await; + Err(astrcode_core::AstrError::Internal(message)) + } + + /// 确保当前 ToolContext 中存在一个有效的 parent agent handle。 + /// + /// 查找策略(按优先级): + /// 1. ctx 中已有显式 agent_id → 直接查找已有 handle;若为 depth=0 且无 resolved_limits 则补填 + /// 2. ctx 中已有显式 agent_id 但无 handle → 若是 RootExecution 调用则自动注册,否则报 NotFound + /// 3. ctx 中无 agent_id → 按 session 查找隐式 root handle + /// 4. 以上都找不到 → 注册隐式 root agent(`root-agent:{session_id}`) + /// + /// 返回的 handle 保证已携带 resolved_limits(来自 gateway 全量 capability)。 + pub(super) async fn ensure_parent_agent_handle( + &self, + ctx: &ToolContext, + ) -> std::result::Result { + let session_id = ctx.session_id().to_string(); + let explicit_agent_id = ctx + .agent_context() + .agent_id + .clone() + .filter(|agent_id| !agent_id.trim().is_empty()); + + if let Some(agent_id) = explicit_agent_id { + if let Some(handle) = self.kernel.get_handle(&agent_id).await { + if handle.depth == 0 && handle.resolved_limits.allowed_tools.is_empty() { + return ensure_handle_has_resolved_limits( + self.kernel.as_ref(), + &self.kernel.gateway(), + handle, + None, + ) + .await + .map_err(AgentOrchestrationError::Internal); + } + return Ok(handle); + } + + let is_root_execution = matches!( + ctx.agent_context().invocation_kind, + Some(InvocationKind::RootExecution) + ); + if is_root_execution { + let profile_id = ctx + .agent_context() + .agent_profile + .clone() + .filter(|profile_id| !profile_id.trim().is_empty()) + .unwrap_or_else(|| IMPLICIT_ROOT_PROFILE_ID.to_string()); + let handle = self + .kernel + .register_root_agent(agent_id.to_string(), session_id, profile_id) + .await + .map_err(|error| { + AgentOrchestrationError::Internal(format!( + "failed to register root agent for parent context: {error}" + )) + })?; + return ensure_handle_has_resolved_limits( + self.kernel.as_ref(), + &self.kernel.gateway(), + handle, + None, + ) + .await + .map_err(AgentOrchestrationError::Internal); + } + + return Err(AgentOrchestrationError::NotFound(format!( + "agent '{}' not found", + agent_id + ))); + } + + if let Some(handle) = self.kernel.find_root_handle_for_session(&session_id).await { + return ensure_handle_has_resolved_limits( + self.kernel.as_ref(), + &self.kernel.gateway(), + handle, + None, + ) + .await + .map_err(AgentOrchestrationError::Internal); + } + + let handle = self + .kernel + .register_root_agent( + implicit_session_root_agent_id(&session_id), + session_id, + IMPLICIT_ROOT_PROFILE_ID.to_string(), + ) + .await + .map_err(|error| { + AgentOrchestrationError::Internal(format!( + "failed to register implicit root agent for session parent context: {error}" + )) + })?; + ensure_handle_has_resolved_limits( + self.kernel.as_ref(), + &self.kernel.gateway(), + handle, + None, + ) + .await + .map_err(AgentOrchestrationError::Internal) + } + + /// 校验当前 turn 的 spawn 预算是否耗尽。 + /// + /// 每个 parent agent 在单个 turn 内可 spawn 的子 agent 数量受 + /// `collaboration_policy.max_spawn_per_turn` 限制。 + /// 超限时引导 LLM 复用已有 child(send/observe/close)。 + pub(super) async fn enforce_spawn_budget_for_turn( + &self, + parent_agent_id: &str, + parent_turn_id: &str, + max_spawn_per_turn: usize, + ) -> std::result::Result<(), AgentOrchestrationError> { + let spawned_for_turn = self + .kernel + .count_children_spawned_for_turn(parent_agent_id, parent_turn_id) + .await; + + if spawned_for_turn >= max_spawn_per_turn { + return Err(AgentOrchestrationError::InvalidInput(format!( + "spawn budget exhausted for this turn ({spawned_for_turn}/{max_spawn_per_turn}); \ + reuse an existing child with send/observe/close, or continue the work in the \ + current agent" + ))); + } + + Ok(()) + } +} diff --git a/crates/application/src/agent/mod.rs b/crates/application/src/agent/mod.rs index c6805c39..f45f51fb 100644 --- a/crates/application/src/agent/mod.rs +++ b/crates/application/src/agent/mod.rs @@ -12,6 +12,7 @@ //! - 不直接依赖 adapter-* //! - 不缓存 session 引用 +mod context; mod observe; mod routing; mod terminal; @@ -19,25 +20,34 @@ mod terminal; pub(crate) mod test_support; mod wake; -use std::{path::Path, sync::Arc}; +use std::{ + collections::HashMap, + sync::{Arc, Mutex}, +}; use astrcode_core::{ - AgentCollaborationActionKind, AgentCollaborationFact, AgentCollaborationOutcomeKind, - AgentCollaborationPolicyContext, AgentEventContext, AgentLifecycleStatus, AgentMailboxEnvelope, - AgentTurnOutcome, ArtifactRef, CloseAgentParams, CollaborationResult, DelegationMetadata, - InvocationKind, ObserveParams, ParentDelivery, ParentDeliveryOrigin, ParentDeliveryPayload, - ParentDeliveryTerminalSemantics, ProgressParentDeliveryPayload, PromptDeclaration, - PromptDeclarationKind, PromptDeclarationRenderTarget, PromptDeclarationSource, + AgentCollaborationActionKind, AgentCollaborationOutcomeKind, AgentEventContext, + AgentLifecycleStatus, AgentTurnOutcome, ArtifactRef, CloseAgentParams, CollaborationResult, + DelegationMetadata, ObserveParams, ParentDelivery, ParentDeliveryOrigin, ParentDeliveryPayload, + ParentDeliveryTerminalSemantics, ProgressParentDeliveryPayload, QueuedInputEnvelope, ResolvedExecutionLimitsSnapshot, Result, RuntimeMetricsRecorder, SendAgentParams, - SpawnAgentParams, SubRunHandle, SubRunHandoff, SubRunResult, SystemPromptLayer, ToolContext, + SpawnAgentParams, SubRunHandle, SubRunHandoff, SubRunResult, ToolContext, }; use async_trait::async_trait; +pub(crate) use context::{ + CollaborationFactRecord, ToolCollaborationContext, ToolCollaborationContextInput, + implicit_session_root_agent_id, +}; use thiserror::Error; use crate::{ AgentKernelPort, AgentSessionPort, config::ConfigService, execution::{ProfileResolutionService, SubagentExecutionRequest, launch_subagent}, + governance_surface::{ + GOVERNANCE_POLICY_REVISION, GovernanceSurfaceAssembler, build_delegation_metadata, + effective_allowed_tools_for_limits, + }, lifecycle::TaskRegistry, }; @@ -59,7 +69,7 @@ impl From for AgentOrchestrationError { } pub(crate) fn root_execution_event_context( - agent_id: impl Into, + agent_id: impl Into, profile_id: impl Into, ) -> AgentEventContext { AgentEventContext::root_execution(agent_id, profile_id) @@ -85,305 +95,9 @@ pub(crate) fn subrun_event_context_for_parent_turn( } pub(crate) const IMPLICIT_ROOT_PROFILE_ID: &str = "default"; -pub(crate) const AGENT_COLLABORATION_POLICY_REVISION: &str = "agent-collaboration-v1"; - -pub(crate) struct CollaborationFactRecord<'a> { - pub(crate) action: AgentCollaborationActionKind, - pub(crate) outcome: AgentCollaborationOutcomeKind, - pub(crate) session_id: &'a str, - pub(crate) turn_id: &'a str, - pub(crate) parent_agent_id: Option, - pub(crate) child: Option<&'a SubRunHandle>, - pub(crate) delivery_id: Option, - pub(crate) reason_code: Option, - pub(crate) summary: Option, - pub(crate) latency_ms: Option, - pub(crate) source_tool_call_id: Option, -} - -impl<'a> CollaborationFactRecord<'a> { - pub(crate) fn new( - action: AgentCollaborationActionKind, - outcome: AgentCollaborationOutcomeKind, - session_id: &'a str, - turn_id: &'a str, - ) -> Self { - Self { - action, - outcome, - session_id, - turn_id, - parent_agent_id: None, - child: None, - delivery_id: None, - reason_code: None, - summary: None, - latency_ms: None, - source_tool_call_id: None, - } - } - - pub(crate) fn parent_agent_id(mut self, parent_agent_id: Option) -> Self { - self.parent_agent_id = parent_agent_id; - self - } - - pub(crate) fn child(mut self, child: &'a SubRunHandle) -> Self { - self.child = Some(child); - self - } - - pub(crate) fn delivery_id(mut self, delivery_id: impl Into) -> Self { - self.delivery_id = Some(delivery_id.into()); - self - } - - pub(crate) fn reason_code(mut self, reason_code: impl Into) -> Self { - self.reason_code = Some(reason_code.into()); - self - } - - pub(crate) fn summary(mut self, summary: impl Into) -> Self { - self.summary = Some(summary.into()); - self - } - - pub(crate) fn latency_ms(mut self, latency_ms: u64) -> Self { - self.latency_ms = Some(latency_ms); - self - } +pub(crate) const AGENT_COLLABORATION_POLICY_REVISION: &str = GOVERNANCE_POLICY_REVISION; +const MAX_OBSERVE_GUARD_ENTRIES: usize = 1024; - pub(crate) fn source_tool_call_id(mut self, source_tool_call_id: Option) -> Self { - self.source_tool_call_id = source_tool_call_id; - self - } -} - -pub(crate) struct ToolCollaborationContext { - runtime: astrcode_core::ResolvedRuntimeConfig, - session_id: String, - turn_id: String, - parent_agent_id: Option, - source_tool_call_id: Option, -} - -impl ToolCollaborationContext { - pub(crate) fn new( - runtime: astrcode_core::ResolvedRuntimeConfig, - session_id: String, - turn_id: String, - parent_agent_id: Option, - source_tool_call_id: Option, - ) -> Self { - Self { - runtime, - session_id, - turn_id, - parent_agent_id, - source_tool_call_id, - } - } - - pub(crate) fn with_parent_agent_id(mut self, parent_agent_id: Option) -> Self { - self.parent_agent_id = parent_agent_id; - self - } - - pub(crate) fn runtime(&self) -> &astrcode_core::ResolvedRuntimeConfig { - &self.runtime - } - - pub(crate) fn session_id(&self) -> &str { - &self.session_id - } - - pub(crate) fn turn_id(&self) -> &str { - &self.turn_id - } - - pub(crate) fn parent_agent_id(&self) -> Option { - self.parent_agent_id.clone() - } - - pub(crate) fn source_tool_call_id(&self) -> Option { - self.source_tool_call_id.clone() - } - - pub(crate) fn fact<'a>( - &'a self, - action: AgentCollaborationActionKind, - outcome: AgentCollaborationOutcomeKind, - ) -> CollaborationFactRecord<'a> { - CollaborationFactRecord::new(action, outcome, &self.session_id, &self.turn_id) - .parent_agent_id(self.parent_agent_id()) - .source_tool_call_id(self.source_tool_call_id()) - } -} - -pub(crate) fn implicit_session_root_agent_id(session_id: &str) -> String { - // 为什么按 session 生成 synthetic root id: - // `AgentControl` 以 agent_id 作为全局索引键,普通会话若都共用 `root-agent` - // 会把不同 session 的父子树混在一起。 - format!( - "root-agent:{}", - astrcode_session_runtime::normalize_session_id(session_id) - ) -} - -fn default_resolved_limits_for_gateway( - gateway: &astrcode_kernel::KernelGateway, - max_steps: Option, -) -> ResolvedExecutionLimitsSnapshot { - ResolvedExecutionLimitsSnapshot { - allowed_tools: gateway.capabilities().tool_names(), - max_steps, - } -} - -fn effective_tool_names_for_handle( - handle: &SubRunHandle, - gateway: &astrcode_kernel::KernelGateway, -) -> Vec { - if handle.resolved_limits.allowed_tools.is_empty() { - gateway.capabilities().tool_names() - } else { - handle.resolved_limits.allowed_tools.clone() - } -} - -fn compact_delegation_summary(description: &str, prompt: &str) -> String { - let candidate = if !description.trim().is_empty() { - description.trim() - } else { - prompt.trim() - }; - let normalized = candidate.split_whitespace().collect::>().join(" "); - let mut chars = normalized.chars(); - let truncated = chars.by_ref().take(160).collect::(); - if chars.next().is_some() { - format!("{truncated}…") - } else { - truncated - } -} - -fn capability_limit_summary(allowed_tools: &[String]) -> Option { - if allowed_tools.is_empty() { - return None; - } - - Some(format!( - "本分支当前只允许使用这些工具:{}。", - allowed_tools.join(", ") - )) -} - -pub(crate) fn build_delegation_metadata( - description: &str, - prompt: &str, - resolved_limits: &ResolvedExecutionLimitsSnapshot, - restricted: bool, -) -> DelegationMetadata { - let responsibility_summary = compact_delegation_summary(description, prompt); - let reuse_scope_summary = if restricted { - "只有当下一步仍属于同一责任分支,且所需操作仍落在当前收缩后的 capability surface \ - 内时,才应继续复用这个 child。" - .to_string() - } else { - "只有当下一步仍属于同一责任分支时,才应继续复用这个 child;若责任边界已经改变,应 close \ - 当前分支并重新选择更合适的执行主体。" - .to_string() - }; - - DelegationMetadata { - responsibility_summary, - reuse_scope_summary, - restricted, - capability_limit_summary: restricted - .then(|| capability_limit_summary(&resolved_limits.allowed_tools)) - .flatten(), - } -} - -pub(crate) fn build_fresh_child_contract(metadata: &DelegationMetadata) -> PromptDeclaration { - let mut content = format!( - "You are a delegated child responsible for one isolated branch.\n\nResponsibility \ - branch:\n- {}\n\nFresh-child rule:\n- Treat this as a new responsibility branch with its \ - own ownership boundary.\n- Do not expand into unrelated exploration or \ - implementation.\n\nUnified send contract:\n- Use downstream `send(agentId + message)` \ - only when you need a direct child to continue a more specific sub-branch.\n- When this \ - branch reaches progress, completion, failure, or a close request, use upstream \ - `send(kind + payload)` to report to your direct parent.\n- Do not wait for an extra \ - confirmation loop before reporting terminal state.\n\nReuse boundary:\n- {}", - metadata.responsibility_summary, metadata.reuse_scope_summary - ); - if let Some(limit_summary) = &metadata.capability_limit_summary { - content.push_str(&format!( - "\n\nCapability limit:\n- {limit_summary}\n- Do not take work that needs tools \ - outside this surface." - )); - } - - PromptDeclaration { - block_id: "child.execution.contract".to_string(), - title: "Child Execution Contract".to_string(), - content, - render_target: PromptDeclarationRenderTarget::System, - layer: SystemPromptLayer::Inherited, - kind: PromptDeclarationKind::ExtensionInstruction, - priority_hint: Some(585), - always_include: true, - source: PromptDeclarationSource::Builtin, - capability_name: None, - origin: Some("child-contract:fresh".to_string()), - } -} - -pub(crate) fn build_resumed_child_contract( - metadata: &DelegationMetadata, - message: &str, - context: Option<&str>, -) -> PromptDeclaration { - let mut content = format!( - "You are continuing an existing delegated child branch.\n\nResponsibility continuity:\n- \ - Keep ownership of the same branch: {}\n\nResumed-child rule:\n- Prioritize the latest \ - delta instruction from the parent.\n- Do not restate or reinterpret the whole original \ - brief unless the new delta requires it.\n\nDelta instruction:\n- {}", - metadata.responsibility_summary, - message.trim() - ); - if let Some(context) = context.filter(|value| !value.trim().is_empty()) { - content.push_str(&format!("\n- Supplementary context: {}", context.trim())); - } - content.push_str(&format!( - "\n\nUnified send contract:\n- Keep using downstream `send(agentId + message)` only for \ - direct child delegation inside the same branch.\n- Use upstream `send(kind + payload)` \ - to report concrete progress, completion, failure, or a close request back to your direct \ - parent.\n- Do not restate the whole branch transcript when reporting upward.\n\nReuse \ - boundary:\n- {}", - metadata.reuse_scope_summary - )); - if let Some(limit_summary) = &metadata.capability_limit_summary { - content.push_str(&format!( - "\n\nCapability limit:\n- {limit_summary}\n- If the delta now needs broader tools, \ - stop stretching this child and let the parent choose a different branch." - )); - } - - PromptDeclaration { - block_id: "child.execution.contract".to_string(), - title: "Child Execution Contract".to_string(), - content, - render_target: PromptDeclarationRenderTarget::System, - layer: SystemPromptLayer::Inherited, - kind: PromptDeclarationKind::ExtensionInstruction, - priority_hint: Some(585), - always_include: true, - source: PromptDeclarationSource::Builtin, - capability_name: None, - origin: Some("child-contract:resumed".to_string()), - } -} pub(crate) async fn persist_resolved_limits_for_handle( kernel: &dyn AgentKernelPort, @@ -429,40 +143,31 @@ pub(crate) async fn persist_delegation_for_handle( Ok(handle) } -async fn ensure_handle_has_resolved_limits( - kernel: &dyn AgentKernelPort, - gateway: &astrcode_kernel::KernelGateway, - handle: SubRunHandle, - max_steps: Option, -) -> std::result::Result { - if !handle.resolved_limits.allowed_tools.is_empty() { - return Ok(handle); - } - - persist_resolved_limits_for_handle( - kernel, - handle, - default_resolved_limits_for_gateway(gateway, max_steps), - ) - .await -} - -pub(crate) fn child_delivery_mailbox_envelope( +/// 将 child terminal notification 包装为 durable input queue 信封。 +/// +/// 提取 notification 中 delivery 的消息文本作为信封内容, +/// 同时携带 sender 的 lifecycle status、turn outcome 和 open session id, +/// 供父级 wake turn 消费时了解子代理的最新状态。 +pub(crate) fn child_delivery_input_queue_envelope( notification: &astrcode_core::ChildSessionNotification, target_agent_id: String, -) -> AgentMailboxEnvelope { - AgentMailboxEnvelope { +) -> QueuedInputEnvelope { + QueuedInputEnvelope { delivery_id: notification.notification_id.clone(), - from_agent_id: notification.child_ref.agent_id.clone(), + from_agent_id: notification.child_ref.agent_id().to_string(), to_agent_id: target_agent_id, message: terminal_notification_message(notification), queued_at: chrono::Utc::now(), - sender_lifecycle_status: notification.status, + sender_lifecycle_status: notification.child_ref.status, sender_last_turn_outcome: terminal_notification_turn_outcome(notification), - sender_open_session_id: notification.child_ref.open_session_id.clone(), + sender_open_session_id: notification.child_ref.open_session_id.to_string(), } } +/// 从 notification 的 delivery payload 中提取可读消息文本。 +/// +/// 优先使用 delivery.payload.message(),为空时回退到默认提示。 +/// 这是终端通知、durable input queue、wake turn 共享的消息提取逻辑。 pub(crate) fn terminal_notification_message( notification: &astrcode_core::ChildSessionNotification, ) -> String { @@ -475,10 +180,15 @@ pub(crate) fn terminal_notification_message( .unwrap_or_else(|| "子 Agent 未提供可读交付。".to_string()) } +/// 从 child notification 推断 sender 的 last turn outcome。 +/// +/// 仅在 child 处于 Idle 状态时才有有效的 outcome(表示已完成一轮 turn)。 +/// 根据 delivery payload 类型映射:Completed → Completed, Failed → Failed, CloseRequest → +/// Cancelled。 用于 durable input queue 信封中的 sender 状态追踪。 pub(crate) fn terminal_notification_turn_outcome( notification: &astrcode_core::ChildSessionNotification, ) -> Option { - if !matches!(notification.status, AgentLifecycleStatus::Idle) { + if !matches!(notification.child_ref.status, AgentLifecycleStatus::Idle) { return None; } if let Some(delivery) = ¬ification.delivery { @@ -500,10 +210,7 @@ pub(crate) fn terminal_notification_turn_outcome( } pub(crate) fn child_open_session_id(child: &SubRunHandle) -> String { - child - .child_session_id - .clone() - .unwrap_or_else(|| child.session_id.clone()) + child.open_session_id().to_string() } pub(crate) fn artifact_ref( @@ -541,6 +248,10 @@ pub(crate) fn child_collaboration_artifacts( artifacts } +/// 构建子代理协作的 artifact 引用列表(subRun + agent + parentSession + session + parentAgent)。 +/// +/// `include_parent_sub_run` 控制 是否包含 parentSubRun artifact, +/// spawn handoff 时不需要(因为已有 subRun),其他场景需要。 pub(crate) fn child_reference_artifacts( child: &SubRunHandle, parent_session_id: &str, @@ -665,8 +376,10 @@ pub struct AgentOrchestrationService { session_runtime: Arc, config_service: Arc, profiles: Arc, + governance_surface: Arc, task_registry: Arc, metrics: Arc, + observe_guard: Arc>, } impl AgentOrchestrationService { @@ -675,6 +388,7 @@ impl AgentOrchestrationService { session_runtime: Arc, config_service: Arc, profiles: Arc, + governance_surface: Arc, task_registry: Arc, metrics: Arc, ) -> Self { @@ -683,322 +397,71 @@ impl AgentOrchestrationService { session_runtime, config_service, profiles, + governance_surface, task_registry, metrics, + observe_guard: Arc::new(Mutex::new(ObserveGuardState::default())), } } +} - /// 解析指定工作目录的有效 RuntimeConfig。 - fn resolve_runtime_config_for_working_dir( - &self, - working_dir: &Path, - ) -> std::result::Result { - self.config_service - .load_resolved_runtime_config(Some(working_dir)) - .map_err(|error| AgentOrchestrationError::Internal(error.to_string())) - } - - /// 解析指定 session 对应工作目录的有效 RuntimeConfig。 - async fn resolve_runtime_config_for_session( - &self, - session_id: &str, - ) -> std::result::Result { - let working_dir = self - .session_runtime - .get_session_working_dir(session_id) - .await - .map_err(AgentOrchestrationError::from)?; - self.resolve_runtime_config_for_working_dir(Path::new(&working_dir)) - } - - /// 解析子 agent profile,将 ApplicationError 映射为编排层错误。 - /// NotFound → NotFound(提示用户检查 profile id), - /// InvalidArgument → InvalidInput(参数格式问题), - /// 其余一律降级为 Internal(避免泄露内部细节)。 - fn resolve_subagent_profile( - &self, - working_dir: &Path, - profile_id: &str, - ) -> std::result::Result { - self.profiles - .find_profile(working_dir, profile_id) - .map_err(|error| match error { - crate::ApplicationError::NotFound(message) => { - AgentOrchestrationError::NotFound(message) - }, - crate::ApplicationError::InvalidArgument(message) => { - AgentOrchestrationError::InvalidInput(message) - }, - other => AgentOrchestrationError::Internal(other.to_string()), - }) - } - - fn collaboration_policy_context( - &self, - runtime: &astrcode_core::ResolvedRuntimeConfig, - ) -> AgentCollaborationPolicyContext { - AgentCollaborationPolicyContext { - policy_revision: AGENT_COLLABORATION_POLICY_REVISION.to_string(), - max_subrun_depth: runtime.agent.max_subrun_depth, - max_spawn_per_turn: runtime.agent.max_spawn_per_turn, - } - } - - async fn append_collaboration_fact( - &self, - fact: AgentCollaborationFact, - ) -> std::result::Result<(), AgentOrchestrationError> { - let turn_id = fact.turn_id.clone(); - let parent_session_id = fact.parent_session_id.clone(); - // 根据父级 agent 的 depth 决定 event context 类型: - // depth == 0 是根级执行(用 root_execution_event_context), - // 否则是子运行(用 subrun_event_context 保持 child session 血缘)。 - let event_agent = if let Some(parent_agent_id) = fact.parent_agent_id.as_deref() { - self.kernel - .get_handle(parent_agent_id) - .await - .map(|handle| { - if handle.depth == 0 { - root_execution_event_context(handle.agent_id, handle.agent_profile) - } else { - subrun_event_context(&handle) - } - }) - .unwrap_or_default() - } else { - AgentEventContext::default() - }; - self.session_runtime - .append_agent_collaboration_fact( - &parent_session_id, - &turn_id, - event_agent, - fact.clone(), - ) - .await - .map_err(AgentOrchestrationError::from)?; - self.metrics.record_agent_collaboration_fact(&fact); - Ok(()) - } - - async fn record_collaboration_fact( - &self, - runtime: &astrcode_core::ResolvedRuntimeConfig, - record: CollaborationFactRecord<'_>, - ) -> std::result::Result<(), AgentOrchestrationError> { - let fact = AgentCollaborationFact { - fact_id: format!("acf-{}", uuid::Uuid::new_v4()), - action: record.action, - outcome: record.outcome, - parent_session_id: record.session_id.to_string(), - turn_id: record.turn_id.to_string(), - parent_agent_id: record.parent_agent_id, - child_agent_id: record.child.map(|handle| handle.agent_id.clone()), - child_session_id: record - .child - .and_then(|handle| handle.child_session_id.clone()), - child_sub_run_id: record.child.map(|handle| handle.sub_run_id.clone()), - delivery_id: record.delivery_id, - reason_code: record.reason_code, - summary: record.summary, - latency_ms: record.latency_ms, - source_tool_call_id: record.source_tool_call_id, - policy: self.collaboration_policy_context(runtime), - }; - self.append_collaboration_fact(fact).await - } - - fn tool_collaboration_context( - &self, - ctx: &ToolContext, - ) -> std::result::Result { - Ok(ToolCollaborationContext::new( - self.resolve_runtime_config_for_working_dir(ctx.working_dir())?, - ctx.session_id().to_string(), - ctx.turn_id().unwrap_or("unknown-turn").to_string(), - ctx.agent_context().agent_id.clone(), - ctx.tool_call_id().map(ToString::to_string), - )) - } - - async fn record_fact_best_effort( - &self, - runtime: &astrcode_core::ResolvedRuntimeConfig, - record: CollaborationFactRecord<'_>, - ) { - let _ = self.record_collaboration_fact(runtime, record).await; - } - - async fn reject_with_fact( - &self, - runtime: &astrcode_core::ResolvedRuntimeConfig, - record: CollaborationFactRecord<'_>, - error: AgentOrchestrationError, - ) -> std::result::Result { - self.record_fact_best_effort(runtime, record).await; - Err(error) - } - - async fn reject_spawn( - &self, - collaboration: &ToolCollaborationContext, - reason_code: &str, - error: AgentOrchestrationError, - ) -> Result { - self.record_fact_best_effort( - collaboration.runtime(), - collaboration - .fact( - AgentCollaborationActionKind::Spawn, - AgentCollaborationOutcomeKind::Rejected, - ) - .reason_code(reason_code) - .summary(error.to_string()), - ) - .await; - Err(map_orchestration_error(error)) - } - - async fn fail_spawn_internal( - &self, - collaboration: &ToolCollaborationContext, - reason_code: &str, - message: String, - ) -> Result { - self.record_fact_best_effort( - collaboration.runtime(), - collaboration - .fact( - AgentCollaborationActionKind::Spawn, - AgentCollaborationOutcomeKind::Failed, - ) - .reason_code(reason_code) - .summary(message.clone()), - ) - .await; - Err(astrcode_core::AstrError::Internal(message)) - } - - /// 三级解析确保父级 agent handle 存在: - /// 1. 显式 agent_id → 直接查找,未找到且为 RootExecution 则自动注册 - /// 2. 无显式 id → 按 session 查找已有 root agent - /// 3. 都没有 → 注册一个隐式 root agent(synthetic agent id) - async fn ensure_parent_agent_handle( - &self, - ctx: &ToolContext, - ) -> std::result::Result { - let session_id = ctx.session_id().to_string(); - let explicit_agent_id = ctx - .agent_context() - .agent_id - .clone() - .filter(|agent_id| !agent_id.trim().is_empty()); - - if let Some(agent_id) = explicit_agent_id { - if let Some(handle) = self.kernel.get_handle(&agent_id).await { - if handle.depth == 0 && handle.resolved_limits.allowed_tools.is_empty() { - return ensure_handle_has_resolved_limits( - self.kernel.as_ref(), - &self.kernel.gateway(), - handle, - None, - ) - .await - .map_err(AgentOrchestrationError::Internal); - } - return Ok(handle); - } - - let is_root_execution = matches!( - ctx.agent_context().invocation_kind, - Some(InvocationKind::RootExecution) - ); - if is_root_execution { - let profile_id = ctx - .agent_context() - .agent_profile - .clone() - .filter(|profile_id| !profile_id.trim().is_empty()) - .unwrap_or_else(|| IMPLICIT_ROOT_PROFILE_ID.to_string()); - let handle = self - .kernel - .register_root_agent(agent_id, session_id, profile_id) - .await - .map_err(|error| { - AgentOrchestrationError::Internal(format!( - "failed to register root agent for parent context: {error}" - )) - })?; - return ensure_handle_has_resolved_limits( - self.kernel.as_ref(), - &self.kernel.gateway(), - handle, - None, - ) - .await - .map_err(AgentOrchestrationError::Internal); - } +#[derive(Debug, Clone, PartialEq, Eq)] +struct ObserveSnapshotSignature { + lifecycle_status: AgentLifecycleStatus, + last_turn_outcome: Option, + phase: String, + turn_count: u32, + active_task: Option, + last_output_tail: Option, + last_turn_tail: Vec, +} - return Err(AgentOrchestrationError::NotFound(format!( - "agent '{}' not found", - agent_id - ))); - } +#[derive(Debug, Clone)] +struct ObserveGuardEntry { + sequence: u64, + signature: ObserveSnapshotSignature, +} - if let Some(handle) = self.kernel.find_root_handle_for_session(&session_id).await { - return ensure_handle_has_resolved_limits( - self.kernel.as_ref(), - &self.kernel.gateway(), - handle, - None, - ) - .await - .map_err(AgentOrchestrationError::Internal); - } +#[derive(Debug, Default)] +struct ObserveGuardState { + next_sequence: u64, + entries: HashMap, +} - let handle = self - .kernel - .register_root_agent( - implicit_session_root_agent_id(&session_id), - session_id, - IMPLICIT_ROOT_PROFILE_ID.to_string(), - ) - .await - .map_err(|error| { - AgentOrchestrationError::Internal(format!( - "failed to register implicit root agent for session parent context: {error}" - )) - })?; - ensure_handle_has_resolved_limits( - self.kernel.as_ref(), - &self.kernel.gateway(), - handle, - None, - ) - .await - .map_err(AgentOrchestrationError::Internal) +impl ObserveGuardState { + fn is_unchanged(&self, key: &str, signature: &ObserveSnapshotSignature) -> bool { + self.entries + .get(key) + .is_some_and(|entry| &entry.signature == signature) + } + + fn remember(&mut self, key: String, signature: ObserveSnapshotSignature) { + let sequence = self.next_sequence; + self.next_sequence = self.next_sequence.saturating_add(1); + self.entries.insert( + key.clone(), + ObserveGuardEntry { + sequence, + signature, + }, + ); + self.evict_oldest_if_needed(&key); } - async fn enforce_spawn_budget_for_turn( - &self, - parent_agent_id: &str, - parent_turn_id: &str, - max_spawn_per_turn: usize, - ) -> std::result::Result<(), AgentOrchestrationError> { - let spawned_for_turn = self - .kernel - .count_children_spawned_for_turn(parent_agent_id, parent_turn_id) - .await; - - if spawned_for_turn >= max_spawn_per_turn { - return Err(AgentOrchestrationError::InvalidInput(format!( - "spawn budget exhausted for this turn ({spawned_for_turn}/{max_spawn_per_turn}); \ - reuse an existing child with send/observe/close, or continue the work in the \ - current agent" - ))); + fn evict_oldest_if_needed(&mut self, keep_key: &str) { + if self.entries.len() <= MAX_OBSERVE_GUARD_ENTRIES { + return; } - - Ok(()) + let Some(oldest_key) = self + .entries + .iter() + .filter(|(key, _)| key.as_str() != keep_key) + .min_by_key(|(_, entry)| entry.sequence) + .map(|(key, _)| key.clone()) + else { + return; + }; + self.entries.remove(&oldest_key); } } @@ -1013,9 +476,10 @@ impl astrcode_core::SubAgentExecutor for AgentOrchestrationService { .map_err(map_orchestration_error)?; let collaboration = self .tool_collaboration_context(ctx) + .await .map_err(map_orchestration_error)? - .with_parent_agent_id(Some(parent_handle.agent_id.clone())); - let parent_agent_id = parent_handle.agent_id.clone(); + .with_parent_agent_id(Some(parent_handle.agent_id.to_string())); + let parent_agent_id = parent_handle.agent_id.to_string(); let parent_turn_id = collaboration.turn_id().to_string(); let parent_session_id = collaboration.session_id().to_string(); let profile_id = params @@ -1038,13 +502,14 @@ impl astrcode_core::SubAgentExecutor for AgentOrchestrationService { parent_agent_id: parent_agent_id.clone(), parent_turn_id: parent_turn_id.clone(), working_dir: ctx.working_dir().display().to_string(), + mode_id: collaboration.mode_id().clone(), profile, description: spawn_description.clone(), task: params.prompt, context: params.context, - parent_allowed_tools: effective_tool_names_for_handle( - &parent_handle, + parent_allowed_tools: effective_allowed_tools_for_limits( &self.kernel.gateway(), + &parent_handle.resolved_limits, ), capability_grant: params.capability_grant, source_tool_call_id: ctx.tool_call_id().map(ToString::to_string), @@ -1053,7 +518,7 @@ impl astrcode_core::SubAgentExecutor for AgentOrchestrationService { .enforce_spawn_budget_for_turn( &parent_agent_id, &request.parent_turn_id, - runtime_config.agent.max_spawn_per_turn, + collaboration.policy().max_spawn_per_turn, ) .await { @@ -1065,6 +530,7 @@ impl astrcode_core::SubAgentExecutor for AgentOrchestrationService { let accepted = match launch_subagent( self.kernel.as_ref(), self.session_runtime.as_ref(), + self.governance_surface.as_ref(), request, runtime_config.clone(), &self.metrics, @@ -1125,10 +591,8 @@ impl astrcode_core::SubAgentExecutor for AgentOrchestrationService { &parent_agent_id, ); - Ok(SubRunResult { - lifecycle: AgentLifecycleStatus::Running, - last_turn_outcome: None, - handoff: Some(SubRunHandoff { + Ok(SubRunResult::Running { + handoff: SubRunHandoff { findings: Vec::new(), artifacts: handoff_artifacts, delivery: Some(ParentDelivery { @@ -1144,8 +608,7 @@ impl astrcode_core::SubAgentExecutor for AgentOrchestrationService { }, }), }), - }), - failure: None, + }, }) } } @@ -1189,22 +652,25 @@ impl astrcode_core::CollaborationExecutor for AgentOrchestrationService { mod tests { use astrcode_core::{ AgentCollaborationActionKind, AgentCollaborationOutcomeKind, AgentLifecycleStatus, - CancelToken, ChildAgentRef, ChildSessionLineageKind, ChildSessionNotification, - ChildSessionNotificationKind, ResolvedExecutionLimitsSnapshot, SessionId, SpawnAgentParams, - StorageEventPayload, ToolContext, agent::executor::SubAgentExecutor, + CancelToken, ChildAgentRef, ChildExecutionIdentity, ChildSessionLineageKind, + ChildSessionNotification, ChildSessionNotificationKind, ParentExecutionRef, + ResolvedExecutionLimitsSnapshot, SessionId, SpawnAgentParams, StorageEventPayload, + ToolContext, agent::executor::SubAgentExecutor, }; use super::{ - IMPLICIT_ROOT_PROFILE_ID, build_delegation_metadata, build_fresh_child_contract, - build_resumed_child_contract, child_delivery_mailbox_envelope, + IMPLICIT_ROOT_PROFILE_ID, build_delegation_metadata, child_delivery_input_queue_envelope, implicit_session_root_agent_id, root_execution_event_context, terminal_notification_message, terminal_notification_turn_outcome, }; - use crate::agent::test_support::{ - TestLlmBehavior, build_agent_test_harness, build_agent_test_harness_with_agent_config, + use crate::{ + agent::test_support::{ + TestLlmBehavior, build_agent_test_harness, build_agent_test_harness_with_agent_config, + }, + governance_surface::{build_fresh_child_contract, build_resumed_child_contract}, }; - fn assert_no_legacy_kernel_agent_methods(source: &str, file: &str) { + fn assert_no_removed_kernel_agent_methods(source: &str, file: &str) { let production_source = source .split_once("#[cfg(test)]") .map(|(prefix, _)| prefix) @@ -1223,7 +689,7 @@ mod tests { for pattern in forbidden { assert!( !production_source.contains(pattern), - "{file} should use kernel.agent() stable surface instead of legacy Kernel method \ + "{file} should use kernel.agent() stable surface instead of removed Kernel method \ {pattern}" ); } @@ -1266,27 +732,30 @@ mod tests { ]; for (file, source) in sources { - assert_no_legacy_kernel_agent_methods(source, file); + assert_no_removed_kernel_agent_methods(source, file); assert_agent_control_boundary(source, file); } } #[test] - fn child_delivery_mailbox_envelope_reuses_terminal_projection_fields() { + fn child_delivery_input_queue_envelope_reuses_terminal_projection_fields() { let notification = ChildSessionNotification { - notification_id: "delivery-1".to_string(), + notification_id: "delivery-1".to_string().into(), child_ref: ChildAgentRef { - agent_id: "agent-child".to_string(), - session_id: "session-parent".to_string(), - sub_run_id: "subrun-child".to_string(), - parent_agent_id: Some("agent-parent".to_string()), - parent_sub_run_id: Some("subrun-parent".to_string()), + identity: ChildExecutionIdentity { + agent_id: "agent-child".to_string().into(), + session_id: "session-parent".to_string().into(), + sub_run_id: "subrun-child".to_string().into(), + }, + parent: ParentExecutionRef { + parent_agent_id: Some("agent-parent".to_string().into()), + parent_sub_run_id: Some("subrun-parent".to_string().into()), + }, lineage_kind: ChildSessionLineageKind::Spawn, status: AgentLifecycleStatus::Idle, - open_session_id: "session-child".to_string(), + open_session_id: "session-child".to_string().into(), }, kind: ChildSessionNotificationKind::Delivered, - status: AgentLifecycleStatus::Idle, source_tool_call_id: None, delivery: Some(astrcode_core::ParentDelivery { idempotency_key: "delivery-1".to_string(), @@ -1303,7 +772,8 @@ mod tests { }), }; - let envelope = child_delivery_mailbox_envelope(¬ification, "agent-parent".to_string()); + let envelope = + child_delivery_input_queue_envelope(¬ification, "agent-parent".to_string()); assert_eq!(terminal_notification_message(¬ification), "final reply"); assert_eq!( @@ -1393,8 +863,7 @@ mod tests { .expect("subagent should launch with implicit root parent"); let parent_agent_artifact = result - .handoff - .as_ref() + .handoff() .expect("handoff should exist") .artifacts .iter() @@ -1458,7 +927,7 @@ mod tests { .await .expect("subagent should launch"); - let handoff = result.handoff.expect("handoff should exist"); + let handoff = result.handoff().cloned().expect("handoff should exist"); let child_agent_id = handoff .artifacts .iter() @@ -1479,12 +948,16 @@ mod tests { .await .expect("child handle should exist"); assert_eq!( - child_handle.session_id, parent.session_id, + child_handle.session_id.to_string(), + parent.session_id, "independent child should remain attached to parent session in control tree" ); assert_eq!( - child_handle.child_session_id.as_deref(), - Some(child_session_id.as_str()), + child_handle + .child_session_id + .as_ref() + .map(|id| id.to_string()), + Some(child_session_id.clone()), "independent child should carry its open child session id" ); assert_eq!( diff --git a/crates/application/src/agent/observe.rs b/crates/application/src/agent/observe.rs index 3374c24b..8f8cca52 100644 --- a/crates/application/src/agent/observe.rs +++ b/crates/application/src/agent/observe.rs @@ -1,27 +1,27 @@ //! # 四工具模型 — Observe 实现 //! -//! `observe` 是四工具模型(send / observe / close / interrupt)中的只读观察操作。 -//! 从旧 runtime/service/agent/observe.rs 迁入,去掉对 RuntimeService 的依赖。 -//! -//! 快照聚合两层: -//! 1. 从 kernel AgentControl 获取 lifecycle / turn_outcome -//! 2. 从 session-runtime 获取稳定 observe 视图 +//! `observe` 现在只返回只读快照,不再派生下一步建议,也不再暴露 input queue +//! 的内部补洞语义。 use astrcode_core::{ AgentCollaborationActionKind, AgentCollaborationOutcomeKind, AgentLifecycleStatus, - CollaborationResult, CollaborationResultKind, ObserveAgentResult, ObserveParams, + CollaborationResult, ObserveParams, ObserveSnapshot, }; -use super::AgentOrchestrationService; +use super::{AgentOrchestrationService, ObserveSnapshotSignature}; impl AgentOrchestrationService { - /// 获取目标 child agent 的增强快照(四工具模型 observe)。 + /// 获取目标 child agent 的只读快照。 + /// + /// 返回子代理的 lifecycle、phase、turn count、active task、最近输出等状态信息。 + /// 内置幂等去重:同一 turn 内连续 observe 相同状态会被拒绝(state_unchanged), + /// 防止 LLM 在等待子代理完成时无意义地反复 poll。 pub async fn observe_child( &self, params: ObserveParams, ctx: &astrcode_core::ToolContext, ) -> Result { - let collaboration = self.tool_collaboration_context(ctx)?; + let collaboration = self.tool_collaboration_context(ctx).await?; params .validate() .map_err(super::AgentOrchestrationError::from)?; @@ -40,7 +40,6 @@ impl AgentOrchestrationService { .get_lifecycle(¶ms.agent_id) .await .unwrap_or(AgentLifecycleStatus::Pending); - let last_turn_outcome = self.kernel.get_turn_outcome(¶ms.agent_id).await; let open_session_id = child @@ -48,7 +47,7 @@ impl AgentOrchestrationService { .clone() .unwrap_or_else(|| child.session_id.clone()); - let observe_snapshot = self + let snapshot = self .session_runtime .observe_agent_session(&open_session_id, ¶ms.agent_id, lifecycle_status) .await @@ -57,55 +56,47 @@ impl AgentOrchestrationService { "failed to build observe snapshot: {e}" )) })?; - let recommended_next_action = recommended_next_action( - lifecycle_status, - observe_snapshot.pending_message_count, - observe_snapshot.active_task.as_deref(), - observe_snapshot.pending_task.as_deref(), - ) - .to_string(); - let recommended_reason = recommended_reason( - lifecycle_status, - last_turn_outcome, - observe_snapshot.pending_message_count, - observe_snapshot.active_task.as_deref(), - observe_snapshot.pending_task.as_deref(), - child.delegation.as_ref(), - ); - let delivery_freshness = delivery_freshness( - lifecycle_status, - observe_snapshot.pending_message_count, - observe_snapshot.active_task.as_deref(), - observe_snapshot.pending_task.as_deref(), - ) - .to_string(); - - let observe_result = ObserveAgentResult { - agent_id: child.agent_id.clone(), - sub_run_id: child.sub_run_id.clone(), - session_id: child.session_id.clone(), - open_session_id, - parent_agent_id: child.parent_agent_id.clone().unwrap_or_default(), + + let observe_result = ObserveSnapshot { + agent_id: child.agent_id.to_string(), + session_id: open_session_id.to_string(), lifecycle_status, last_turn_outcome, - phase: format!("{:?}", observe_snapshot.phase), - turn_count: observe_snapshot.turn_count, - pending_message_count: observe_snapshot.pending_message_count, - active_task: observe_snapshot.active_task, - pending_task: observe_snapshot.pending_task, - recent_mailbox_messages: observe_snapshot.recent_mailbox_messages, - last_output: observe_snapshot.last_output, - delegation: child.delegation.clone(), - recommended_next_action, - recommended_reason, - delivery_freshness, + phase: format!("{:?}", snapshot.phase), + turn_count: snapshot.turn_count, + active_task: snapshot.active_task, + last_output_tail: snapshot.last_output_tail, + last_turn_tail: snapshot.last_turn_tail, }; + let signature = observe_signature(&observe_result); + if self.observe_snapshot_is_unchanged(&child, &collaboration, &signature)? { + let error = super::AgentOrchestrationError::InvalidInput( + "child state is unchanged since the previous observe in this turn; wait with \ + shell sleep and observe again only after a new delivery or state change" + .to_string(), + ); + return self + .reject_with_fact( + collaboration.runtime(), + collaboration + .fact( + AgentCollaborationActionKind::Observe, + AgentCollaborationOutcomeKind::Rejected, + ) + .child(&child) + .reason_code("state_unchanged") + .summary(error.to_string()), + error, + ) + .await; + } + self.remember_observe_snapshot(&child, &collaboration, signature)?; log::info!( - "observe: snapshot for child agent '{}' (lifecycle={:?}, pending={})", + "observe: snapshot for child agent '{}' (lifecycle={:?}, phase={})", params.agent_id, lifecycle_status, - observe_result.pending_message_count + observe_result.phase ); self.record_fact_best_effort( collaboration.runtime(), @@ -115,174 +106,101 @@ impl AgentOrchestrationService { AgentCollaborationOutcomeKind::Accepted, ) .child(&child) - .summary(format_observe_summary(&observe_result)), + .summary(format_observe_summary( + &observe_result, + child.delegation.as_ref(), + )), ) .await; - Ok(CollaborationResult { - accepted: true, - kind: CollaborationResultKind::Observed, - agent_ref: Some( - self.project_child_ref_status(self.build_child_ref_from_handle(&child).await) - .await, - ), - delivery_id: None, - summary: Some(format_observe_summary(&observe_result)), - observe_result: Some(observe_result), + Ok(CollaborationResult::Observed { + agent_ref: self + .project_child_ref_status(self.build_child_ref_from_handle(&child).await) + .await, + summary: format_observe_summary(&observe_result, child.delegation.as_ref()), + observe_result: Box::new(observe_result), delegation: child.delegation.clone(), - cascade: None, - closed_root_agent_id: None, - failure: None, }) } -} -fn recommended_next_action( - lifecycle_status: AgentLifecycleStatus, - pending_message_count: usize, - active_task: Option<&str>, - pending_task: Option<&str>, -) -> &'static str { - match lifecycle_status { - AgentLifecycleStatus::Pending | AgentLifecycleStatus::Running => "wait", - AgentLifecycleStatus::Terminated => "none", - AgentLifecycleStatus::Idle if active_task.is_some() || pending_task.is_some() => "wait", - AgentLifecycleStatus::Idle if pending_message_count > 0 => "wait", - AgentLifecycleStatus::Idle => "send_or_close", + fn observe_snapshot_is_unchanged( + &self, + child: &astrcode_core::SubRunHandle, + collaboration: &super::ToolCollaborationContext, + signature: &ObserveSnapshotSignature, + ) -> std::result::Result { + let guard_key = observe_guard_key(child, collaboration); + let guard = self.observe_guard.lock().map_err(|_| { + super::AgentOrchestrationError::Internal("observe guard lock poisoned".to_string()) + })?; + Ok(guard.is_unchanged(&guard_key, signature)) + } + + fn remember_observe_snapshot( + &self, + child: &astrcode_core::SubRunHandle, + collaboration: &super::ToolCollaborationContext, + signature: ObserveSnapshotSignature, + ) -> std::result::Result<(), super::AgentOrchestrationError> { + let guard_key = observe_guard_key(child, collaboration); + let mut guard = self.observe_guard.lock().map_err(|_| { + super::AgentOrchestrationError::Internal("observe guard lock poisoned".to_string()) + })?; + guard.remember(guard_key, signature); + Ok(()) } } -fn recommended_reason( - lifecycle_status: AgentLifecycleStatus, - last_turn_outcome: Option, - pending_message_count: usize, - active_task: Option<&str>, - pending_task: Option<&str>, - delegation: Option<&astrcode_core::DelegationMetadata>, +fn observe_guard_key( + child: &astrcode_core::SubRunHandle, + collaboration: &super::ToolCollaborationContext, ) -> String { - let branch_summary = delegation - .map(|metadata| format!("责任分支:{}。", metadata.responsibility_summary)) - .unwrap_or_default(); - let restricted_summary = delegation - .and_then(|metadata| metadata.capability_limit_summary.as_ref()) - .map(|summary| format!(" {}", summary)) - .unwrap_or_default(); - - match lifecycle_status { - AgentLifecycleStatus::Pending | AgentLifecycleStatus::Running => { - if let Some(task) = active_task { - format!("{branch_summary}子 Agent 仍在处理当前任务:{task}{restricted_summary}") - } else if let Some(task) = pending_task { - format!( - "{branch_summary}子 Agent 还有待消费的 mailbox \ - 任务:{task}{restricted_summary}" - ) - } else { - format!( - "{branch_summary}子 Agent 仍在执行中,当前更适合继续等待。{restricted_summary}" - ) - } - }, - AgentLifecycleStatus::Terminated => format!( - "{branch_summary}子 Agent 已终止,不能再接收 send;如需继续工作应改由当前 Agent \ - 或新的分支处理。{restricted_summary}" - ), - AgentLifecycleStatus::Idle if active_task.is_some() || pending_task.is_some() => { - format!( - "{branch_summary}子 Agent 当前空闲,但还有待处理任务痕迹,先等待当前 mailbox \ - 周期稳定。{restricted_summary}" - ) - }, - AgentLifecycleStatus::Idle if pending_message_count > 0 => { - format!( - "{branch_summary}子 Agent 已空闲,但 mailbox \ - 里仍有待处理消息;先等待这些消息被消费。{restricted_summary}" - ) - }, - AgentLifecycleStatus::Idle => match last_turn_outcome { - Some(astrcode_core::AgentTurnOutcome::Completed) => format!( - "{branch_summary}子 Agent 已完成上一轮工作;如果责任边界不变可直接 send \ - 复用,否则 close 结束该分支。{}{}", - delegation - .map(|metadata| metadata.reuse_scope_summary.as_str()) - .unwrap_or(""), - restricted_summary - ), - Some(astrcode_core::AgentTurnOutcome::Failed) => format!( - "{branch_summary}子 Agent 上一轮失败;若要继续同一责任可 send 明确返工要求,否则 \ - close 止损。{}{}", - delegation - .map(|metadata| metadata.reuse_scope_summary.as_str()) - .unwrap_or(""), - restricted_summary - ), - Some(astrcode_core::AgentTurnOutcome::Cancelled) => format!( - "{branch_summary}子 Agent 上一轮已取消;通常更适合 \ - close,只有在确实要复用同一分支时才 send。{}{}", - delegation - .map(|metadata| metadata.reuse_scope_summary.as_str()) - .unwrap_or(""), - restricted_summary - ), - Some(astrcode_core::AgentTurnOutcome::TokenExceeded) => format!( - "{branch_summary}子 Agent 上一轮受 token \ - 限制中断;若继续复用,请先收窄任务范围后再 send。{}{}", - delegation - .map(|metadata| metadata.reuse_scope_summary.as_str()) - .unwrap_or(""), - restricted_summary - ), - None => format!( - "{branch_summary}子 Agent 当前空闲,可根据责任是否继续存在来选择 send 或 \ - close。{}{}", - delegation - .map(|metadata| metadata.reuse_scope_summary.as_str()) - .unwrap_or(""), - restricted_summary - ), - }, - } + format!( + "{}:{}:{}:{}", + collaboration.session_id(), + collaboration.turn_id(), + collaboration.parent_agent_id().unwrap_or_default(), + child.agent_id + ) } -fn delivery_freshness( - lifecycle_status: AgentLifecycleStatus, - pending_message_count: usize, - active_task: Option<&str>, - pending_task: Option<&str>, -) -> &'static str { - match lifecycle_status { - AgentLifecycleStatus::Pending | AgentLifecycleStatus::Running => "pending_child_work", - AgentLifecycleStatus::Terminated => "terminated", - AgentLifecycleStatus::Idle if active_task.is_some() || pending_task.is_some() => { - "pending_child_work" - }, - AgentLifecycleStatus::Idle if pending_message_count > 0 => "pending_child_work", - AgentLifecycleStatus::Idle => "ready_for_follow_up", +fn observe_signature(result: &ObserveSnapshot) -> ObserveSnapshotSignature { + ObserveSnapshotSignature { + lifecycle_status: result.lifecycle_status, + last_turn_outcome: result.last_turn_outcome, + phase: result.phase.clone(), + turn_count: result.turn_count, + active_task: result.active_task.clone(), + last_output_tail: result.last_output_tail.clone(), + last_turn_tail: result.last_turn_tail.clone(), } } -fn format_observe_summary(result: &ObserveAgentResult) -> String { - let branch_prefix = result - .delegation - .as_ref() - .map(|metadata| format!("责任分支:{};", metadata.responsibility_summary)) - .unwrap_or_default(); - let base = format!( - "子 Agent {} 当前为 {:?};{}建议 {}:{}", - result.agent_id, - result.lifecycle_status, - branch_prefix, - result.recommended_next_action, - result.recommended_reason - ); - if result.recent_mailbox_messages.is_empty() { - return base; +fn format_observe_summary( + result: &ObserveSnapshot, + delegation: Option<&astrcode_core::DelegationMetadata>, +) -> String { + let mut parts = Vec::new(); + parts.push(format!( + "子 Agent {} 当前为 {:?}", + result.agent_id, result.lifecycle_status + )); + if let Some(metadata) = delegation { + parts.push(format!("责任分支:{}", metadata.responsibility_summary)); } - - format!( - "{base};最近 mailbox 摘要:{}", - result.recent_mailbox_messages.join(" | ") - ) + if let Some(task) = result.active_task.as_deref() { + parts.push(format!("当前任务:{task}")); + } + if let Some(output) = result.last_output_tail.as_deref() { + parts.push(format!("最近输出:{output}")); + } + if !result.last_turn_tail.is_empty() { + parts.push(format!( + "最后一轮尾部:{}", + result.last_turn_tail.join(" | ") + )); + } + parts.join(";") } #[cfg(test)] @@ -290,115 +208,64 @@ mod tests { use std::time::Duration; use astrcode_core::{ - AgentCollaborationActionKind, AgentCollaborationOutcomeKind, CancelToken, - DelegationMetadata, ObserveParams, SessionId, StorageEventPayload, ToolContext, + AgentCollaborationActionKind, AgentCollaborationOutcomeKind, CancelToken, ObserveParams, + SessionId, StorageEventPayload, ToolContext, agent::executor::{CollaborationExecutor, SubAgentExecutor}, }; use tokio::time::sleep; - use super::{ - delivery_freshness, format_observe_summary, recommended_next_action, recommended_reason, + use super::format_observe_summary; + use crate::agent::{ + ObserveGuardState, ObserveSnapshotSignature, + test_support::{TestLlmBehavior, build_agent_test_harness}, }; - use crate::agent::test_support::{TestLlmBehavior, build_agent_test_harness}; #[test] - fn recommendation_helpers_prefer_wait_for_running_child() { - assert_eq!( - recommended_next_action( - astrcode_core::AgentLifecycleStatus::Running, - 0, - Some("scan repo"), - None, - ), - "wait" - ); - assert_eq!( - delivery_freshness( - astrcode_core::AgentLifecycleStatus::Running, - 0, - Some("scan repo"), - None, - ), - "pending_child_work" - ); - assert!( - recommended_reason( - astrcode_core::AgentLifecycleStatus::Running, - None, - 0, - Some("scan repo"), - None, - None, - ) - .contains("scan repo") - ); - } - - #[test] - fn recommendation_helpers_prefer_send_or_close_for_idle_child() { - assert_eq!( - recommended_next_action(astrcode_core::AgentLifecycleStatus::Idle, 0, None, None), - "send_or_close" - ); - assert_eq!( - delivery_freshness(astrcode_core::AgentLifecycleStatus::Idle, 0, None, None), - "ready_for_follow_up" - ); - } - - #[test] - fn observe_summary_is_decision_oriented() { - let result = astrcode_core::ObserveAgentResult { + fn observe_summary_is_snapshot_oriented() { + let result = astrcode_core::ObserveSnapshot { agent_id: "agent-7".to_string(), - sub_run_id: "subrun-7".to_string(), - session_id: "session-parent".to_string(), - open_session_id: "session-child".to_string(), - parent_agent_id: "agent-root".to_string(), + session_id: "session-child".to_string(), lifecycle_status: astrcode_core::AgentLifecycleStatus::Idle, last_turn_outcome: Some(astrcode_core::AgentTurnOutcome::Completed), phase: "Idle".to_string(), turn_count: 1, - pending_message_count: 0, - active_task: None, - pending_task: None, - recent_mailbox_messages: vec!["最近一条消息".to_string()], - last_output: Some("done".to_string()), - delegation: None, - recommended_next_action: "send_or_close".to_string(), - recommended_reason: "上一轮已完成".to_string(), - delivery_freshness: "ready_for_follow_up".to_string(), + active_task: Some("整理结论".to_string()), + last_output_tail: Some("done".to_string()), + last_turn_tail: vec!["最近一条消息".to_string()], }; - let summary = format_observe_summary(&result); - assert!(summary.contains("建议 send_or_close")); + let summary = format_observe_summary(&result, None); assert!(summary.contains("agent-7")); - assert!(summary.contains("最近 mailbox 摘要")); + assert!(summary.contains("当前任务:整理结论")); + assert!(summary.contains("最后一轮尾部")); } #[test] - fn recommended_reason_keeps_restricted_branch_boundary_visible() { - let reason = recommended_reason( - astrcode_core::AgentLifecycleStatus::Idle, - Some(astrcode_core::AgentTurnOutcome::Completed), - 0, - None, - None, - Some(&DelegationMetadata { - responsibility_summary: "只检查缓存层".to_string(), - reuse_scope_summary: "只有当下一步仍属于同一责任分支,\ - 且所需操作仍落在当前收缩后的 capability surface \ - 内时,才应继续复用这个 child。" - .to_string(), - restricted: true, - capability_limit_summary: Some( - "本分支当前只允许使用这些工具:readFile, grep。".to_string(), - ), - }), - ); + fn observe_guard_state_is_bounded() { + let mut state = ObserveGuardState::default(); + for index in 0..1100 { + state.remember( + format!("session:turn-{index}:parent:child"), + ObserveSnapshotSignature { + lifecycle_status: astrcode_core::AgentLifecycleStatus::Idle, + last_turn_outcome: Some(astrcode_core::AgentTurnOutcome::Completed), + phase: "Idle".to_string(), + turn_count: index as u32, + active_task: None, + last_output_tail: None, + last_turn_tail: Vec::new(), + }, + ); + } - assert!(reason.contains("只检查缓存层")); - assert!(reason.contains("当前收缩后的 capability surface")); - assert!(reason.contains("readFile, grep")); + assert!( + state.entries.len() <= 1024, + "observe guard should evict old entries instead of unbounded growth" + ); + assert!( + state.entries.contains_key("session:turn-1099:parent:child"), + "latest observe snapshot should be retained" + ); } #[tokio::test] @@ -449,8 +316,7 @@ mod tests { .await .expect("spawn should succeed"); let child_agent_id = launched - .handoff - .as_ref() + .handoff() .and_then(|handoff| { handoff .artifacts @@ -483,15 +349,14 @@ mod tests { .await .expect("observe should succeed"); - let observe_result = result.observe_result.expect("observe result should exist"); - assert_eq!(observe_result.recommended_next_action, "send_or_close"); - assert_eq!(observe_result.delivery_freshness, "ready_for_follow_up"); - assert!( - result - .summary - .unwrap_or_default() - .contains("建议 send_or_close") + let observe_result = result + .observe_result() + .expect("observe result should exist"); + assert_eq!( + observe_result.lifecycle_status, + astrcode_core::AgentLifecycleStatus::Idle ); + assert!(result.summary().unwrap_or_default().contains("子 Agent")); let parent_events = harness .session_runtime @@ -503,7 +368,117 @@ mod tests { StorageEventPayload::AgentCollaborationFact { fact, .. } if fact.action == AgentCollaborationActionKind::Observe && fact.outcome == AgentCollaborationOutcomeKind::Accepted - && fact.child_agent_id.as_deref() == Some(observe_result.agent_id.as_str()) + && fact.child_agent_id().map(|id| id.as_str()) + == Some(observe_result.agent_id.as_str()) + ))); + } + + #[tokio::test] + async fn observe_child_rejects_unchanged_snapshot_in_same_turn() { + let harness = build_agent_test_harness(TestLlmBehavior::Succeed { + content: "初始工作完成。".to_string(), + }) + .expect("test harness should build"); + let project = tempfile::tempdir().expect("tempdir should be created"); + let parent = harness + .session_runtime + .create_session(project.path().display().to_string()) + .await + .expect("parent session should be created"); + harness + .kernel + .agent_control() + .register_root_agent( + "root-agent".to_string(), + parent.session_id.clone(), + "root-profile".to_string(), + ) + .await + .expect("root agent should be registered"); + let parent_ctx = ToolContext::new( + parent.session_id.clone().into(), + project.path().to_path_buf(), + CancelToken::new(), + ) + .with_turn_id("turn-parent") + .with_agent_context(super::super::root_execution_event_context( + "root-agent", + "root-profile", + )); + + let launched = harness + .service + .launch( + astrcode_core::SpawnAgentParams { + r#type: Some("reviewer".to_string()), + description: "检查 crates".to_string(), + prompt: "请检查 crates 目录".to_string(), + context: None, + capability_grant: None, + }, + &parent_ctx, + ) + .await + .expect("spawn should succeed"); + let child_agent_id = launched + .handoff() + .and_then(|handoff| { + handoff + .artifacts + .iter() + .find(|artifact| artifact.kind == "agent") + .map(|artifact| artifact.id.clone()) + }) + .expect("child agent artifact should exist"); + for _ in 0..20 { + if harness + .kernel + .agent() + .get_lifecycle(&child_agent_id) + .await + .is_some_and(|lifecycle| lifecycle == astrcode_core::AgentLifecycleStatus::Idle) + { + break; + } + sleep(Duration::from_millis(20)).await; + } + + harness + .service + .observe( + ObserveParams { + agent_id: child_agent_id.clone(), + }, + &parent_ctx, + ) + .await + .expect("first observe should succeed"); + + let error = harness + .service + .observe( + ObserveParams { + agent_id: child_agent_id.clone(), + }, + &parent_ctx, + ) + .await + .expect_err("second observe should reject unchanged snapshot"); + + assert!(error.to_string().contains("child state is unchanged")); + + let parent_events = harness + .session_runtime + .replay_stored_events(&SessionId::from(parent.session_id.clone())) + .await + .expect("parent events should replay"); + assert!(parent_events.iter().any(|stored| matches!( + &stored.event.payload, + StorageEventPayload::AgentCollaborationFact { fact, .. } + if fact.action == AgentCollaborationActionKind::Observe + && fact.outcome == AgentCollaborationOutcomeKind::Rejected + && fact.reason_code.as_deref() == Some("state_unchanged") + && fact.child_agent_id().map(|id| id.as_str()) == Some(child_agent_id.as_str()) ))); } } diff --git a/crates/application/src/agent/routing.rs b/crates/application/src/agent/routing.rs index 995f08c5..03bf42e2 100644 --- a/crates/application/src/agent/routing.rs +++ b/crates/application/src/agent/routing.rs @@ -1,20 +1,26 @@ //! agent 协作路由与权限校验。 //! -//! 从旧 runtime/service/agent/routing.rs 迁入,去掉对 RuntimeService 的依赖, //! 改为通过 Kernel + SessionRuntime 完成所有操作。 +#[path = "routing_collaboration_flow.rs"] +mod collaboration_flow; use astrcode_core::{ AgentCollaborationActionKind, AgentCollaborationOutcomeKind, AgentInboxEnvelope, AgentLifecycleStatus, ChildAgentRef, ChildSessionNotification, ChildSessionNotificationKind, - CloseAgentParams, CollaborationResult, CollaborationResultKind, InboxEnvelopeKind, - MailboxDiscardedPayload, MailboxQueuedPayload, ParentDelivery, ParentDeliveryOrigin, - ParentDeliveryPayload, ParentDeliveryTerminalSemantics, SendAgentParams, SendToChildParams, - SendToParentParams, SubRunHandle, + CloseAgentParams, CollaborationResult, InboxEnvelopeKind, InputDiscardedPayload, + InputQueuedPayload, ParentDelivery, ParentDeliveryOrigin, ParentDeliveryPayload, + ParentDeliveryTerminalSemantics, SendAgentParams, SendToChildParams, SendToParentParams, + SubRunHandle, }; +use collaboration_flow::parent_delivery_label; use super::{ - AgentOrchestrationService, build_delegation_metadata, build_resumed_child_contract, - subrun_event_context, + AgentOrchestrationError, AgentOrchestrationService, ToolCollaborationContext, + build_delegation_metadata, subrun_event_context, +}; +use crate::governance_surface::{ + GovernanceBusyPolicy, ResumedChildGovernanceInput, collaboration_policy_context, + effective_allowed_tools_for_limits, }; impl AgentOrchestrationService { @@ -126,7 +132,7 @@ impl AgentOrchestrationService { params: SendToChildParams, ctx: &astrcode_core::ToolContext, ) -> Result { - let collaboration = self.tool_collaboration_context(ctx)?; + let collaboration = self.tool_collaboration_context(ctx).await?; let child = self .require_direct_child_handle( @@ -165,12 +171,22 @@ impl AgentOrchestrationService { .await } + /// 子代理上行消息投递(send to parent)。 + /// + /// 完整校验链: + /// 1. 验证调用者有 child agent context + /// 2. 验证调用者的 handle 存在 + /// 3. 验证存在 direct parent agent(root agent 不能上行) + /// 4. 验证 parent handle 存在且未终止 + /// 5. 验证有 source turn id + /// + /// 投递后触发父级 reactivation,让父级消费这条 delivery。 async fn send_to_parent( &self, params: SendToParentParams, ctx: &astrcode_core::ToolContext, ) -> Result { - let fallback_collaboration = self.tool_collaboration_context(ctx)?; + let fallback_collaboration = self.tool_collaboration_context(ctx).await?; let Some(child_agent_id) = ctx.agent_context().agent_id.as_deref() else { let error = super::AgentOrchestrationError::InvalidInput( "upstream send requires a child agent execution context".to_string(), @@ -383,20 +399,14 @@ impl AgentOrchestrationService { .await; let child_ref = self.build_child_ref_from_handle(&child).await; - Ok(CollaborationResult { - accepted: true, - kind: CollaborationResultKind::Sent, + Ok(CollaborationResult::Sent { agent_ref: Some(self.project_child_ref_status(child_ref).await), delivery_id: Some(notification.notification_id.clone()), summary: Some(format!( "已向 direct parent 发送 {} 消息。", parent_delivery_label(&payload) )), - observe_result: None, delegation: child.delegation.clone(), - cascade: None, - closed_root_agent_id: None, - failure: None, }) } @@ -417,1295 +427,30 @@ impl AgentOrchestrationService { }, }; - Ok(super::ToolCollaborationContext::new( - self.resolve_runtime_config_for_session(&child.session_id) - .await?, - child.session_id.clone(), - parent_turn_id, - child.parent_agent_id.clone(), - ctx.tool_call_id().map(ToString::to_string), - )) - } - - async fn build_explicit_parent_delivery_notification( - &self, - child: &SubRunHandle, - payload: &ParentDeliveryPayload, - ctx: &astrcode_core::ToolContext, - source_turn_id: &str, - ) -> ChildSessionNotification { - let status = self - .kernel - .get_lifecycle(&child.agent_id) - .await - .unwrap_or(child.lifecycle); - let notification_id = explicit_parent_delivery_id( - &child.sub_run_id, - source_turn_id, - ctx.tool_call_id().map(ToString::to_string).as_deref(), - payload, - ); - - ChildSessionNotification { - notification_id: notification_id.clone(), - child_ref: ChildAgentRef { - agent_id: child.agent_id.clone(), - session_id: child.session_id.clone(), - sub_run_id: child.sub_run_id.clone(), - parent_agent_id: child.parent_agent_id.clone(), - parent_sub_run_id: child.parent_sub_run_id.clone(), - lineage_kind: child.lineage_kind, - status, - open_session_id: super::child_open_session_id(child), - }, - kind: parent_delivery_notification_kind(payload), - status, - source_tool_call_id: ctx.tool_call_id().map(ToString::to_string), - delivery: Some(ParentDelivery { - idempotency_key: notification_id, - origin: ParentDeliveryOrigin::Explicit, - terminal_semantics: parent_delivery_terminal_semantics(payload), - source_turn_id: Some(source_turn_id.to_string()), - payload: payload.clone(), - }), - } - } - - /// 关闭子 agent 及其子树(close 协作工具的业务逻辑)。 - pub async fn close_child( - &self, - params: CloseAgentParams, - ctx: &astrcode_core::ToolContext, - ) -> Result { - let collaboration = self.tool_collaboration_context(ctx)?; - params - .validate() - .map_err(super::AgentOrchestrationError::from)?; - - let target = self - .require_direct_child_handle( - ¶ms.agent_id, - AgentCollaborationActionKind::Close, - ctx, - &collaboration, - ) + let runtime = self + .resolve_runtime_config_for_session(child.session_id.as_str()) .await?; - - // 收集子树用于 durable discard - let subtree_handles = self.kernel.collect_subtree_handles(¶ms.agent_id).await; - let mut discard_targets = Vec::with_capacity(subtree_handles.len() + 1); - discard_targets.push(target.clone()); - discard_targets.extend(subtree_handles.iter().cloned()); - - self.append_durable_mailbox_discard_batch(&discard_targets, ctx) - .await?; - - // 执行 terminate - let cancelled = self - .kernel - .terminate_subtree(¶ms.agent_id) - .await - .ok_or_else(|| { - super::AgentOrchestrationError::NotFound(format!( - "agent '{}' terminate failed (not found or already finalized)", - params.agent_id - )) - })?; - - let subtree_count = subtree_handles.len(); - let summary = if subtree_count > 0 { - format!( - "已级联关闭子 Agent {} 及 {} 个后代。", - params.agent_id, subtree_count - ) - } else { - format!("已关闭子 Agent {}。", params.agent_id) - }; - self.record_fact_best_effort( - collaboration.runtime(), - collaboration - .fact( - AgentCollaborationActionKind::Close, - AgentCollaborationOutcomeKind::Closed, - ) - .child(&target) - .summary(summary.clone()), - ) - .await; - - Ok(CollaborationResult { - accepted: true, - kind: CollaborationResultKind::Closed, - agent_ref: None, - delivery_id: None, - summary: Some(summary), - observe_result: None, - delegation: None, - cascade: Some(true), - closed_root_agent_id: Some(cancelled.agent_id.clone()), - failure: None, - }) - } - - /// 从 SubRunHandle 构造 ChildAgentRef。 - pub(super) async fn build_child_ref_from_handle(&self, handle: &SubRunHandle) -> ChildAgentRef { - ChildAgentRef { - agent_id: handle.agent_id.clone(), - session_id: handle.session_id.clone(), - sub_run_id: handle.sub_run_id.clone(), - parent_agent_id: handle.parent_agent_id.clone(), - parent_sub_run_id: handle.parent_sub_run_id.clone(), - lineage_kind: handle.lineage_kind, - status: handle.lifecycle, - open_session_id: handle - .child_session_id - .clone() - .unwrap_or_else(|| handle.session_id.clone()), - } - } - - /// 用 live 控制面的最新 lifecycle 投影更新 ChildAgentRef。 - pub(super) async fn project_child_ref_status( - &self, - mut child_ref: ChildAgentRef, - ) -> ChildAgentRef { - let lifecycle = self.kernel.get_lifecycle(&child_ref.agent_id).await; - let last_turn_outcome = self.kernel.get_turn_outcome(&child_ref.agent_id).await; - if let Some(lifecycle) = lifecycle { - child_ref.status = - project_collaboration_lifecycle(lifecycle, last_turn_outcome, child_ref.status); - } - child_ref - } - - /// resume 失败时恢复之前 drain 出的 inbox 信封。 - /// 必须在 resume 前先 drain(否则无法取到 pending 消息来组合 resume prompt), - /// 但如果 resume 本身失败,必须把信封放回去,避免消息丢失。 - async fn restore_pending_inbox(&self, agent_id: &str, pending: Vec) { - for envelope in pending { - if self.kernel.deliver(agent_id, envelope).await.is_none() { - log::warn!( - "failed to restore drained inbox after resume error: agent='{}'", - agent_id - ); - break; - } - } - } - - async fn restore_pending_inbox_and_fail( - &self, - agent_id: &str, - pending: Vec, - message: String, - ) -> Result { - self.restore_pending_inbox(agent_id, pending).await; - Err(super::AgentOrchestrationError::Internal(message)) - } - - async fn resume_idle_child_if_needed( - &self, - child: &SubRunHandle, - params: &SendToChildParams, - ctx: &astrcode_core::ToolContext, - collaboration: &super::ToolCollaborationContext, - lifecycle: Option, - ) -> Result, super::AgentOrchestrationError> { - if !matches!(lifecycle, Some(AgentLifecycleStatus::Idle)) || child.lifecycle.occupies_slot() - { - return Ok(None); - } - - let pending = self - .kernel - .drain_inbox(&child.agent_id) - .await - .unwrap_or_default(); - let resume_message = compose_reusable_child_message(&pending, params); - let current_parent_turn_id = ctx.turn_id().unwrap_or(&child.parent_turn_id).to_string(); - - let Some(reused_handle) = self - .kernel - .resume(¶ms.agent_id, ¤t_parent_turn_id) - .await - else { - self.restore_pending_inbox(&child.agent_id, pending).await; - return Ok(None); - }; - - log::info!( - "send: reusable child agent '{}' restarted with new turn (subRunId='{}')", - params.agent_id, - reused_handle.sub_run_id - ); - - let Some(child_session_id) = reused_handle - .child_session_id - .as_ref() - .or(child.child_session_id.as_ref()) - else { - return self - .restore_pending_inbox_and_fail( - &child.agent_id, - pending, - format!( - "agent '{}' resume failed: missing child session id", - params.agent_id - ), - ) - .await; - }; - - let allowed_tools = - super::effective_tool_names_for_handle(&reused_handle, &self.kernel.gateway()); - let scoped_router = match self - .kernel - .gateway() - .capabilities() - .subset_for_tools_checked(&allowed_tools) - { - Ok(router) => router, - Err(error) => { - return self - .restore_pending_inbox_and_fail( - &child.agent_id, - pending, - format!( - "agent '{}' resume capability resolution failed: {error}", - params.agent_id - ), - ) - .await; - }, - }; - - let fallback_delegation = build_delegation_metadata( - "", - params.message.as_str(), - &reused_handle.resolved_limits, - false, - ); - let resume_delegation = reused_handle - .delegation - .clone() - .unwrap_or(fallback_delegation); - - let accepted = match self + let mode_id = self .session_runtime - .submit_prompt_for_agent_with_submission( - child_session_id, - resume_message, - self.resolve_runtime_config_for_session(child_session_id) - .await?, - astrcode_session_runtime::AgentPromptSubmission { - agent: astrcode_core::AgentEventContext::from(&reused_handle), - capability_router: Some(scoped_router), - prompt_declarations: vec![build_resumed_child_contract( - &resume_delegation, - params.message.as_str(), - params.context.as_deref(), - )], - resolved_limits: Some(reused_handle.resolved_limits.clone()), - source_tool_call_id: ctx.tool_call_id().map(ToString::to_string), - }, - ) + .session_mode_state(child.session_id.as_str()) .await - { - Ok(accepted) => accepted, - Err(error) => { - return self - .restore_pending_inbox_and_fail( - &child.agent_id, - pending, - format!("agent '{}' resume submit failed: {error}", params.agent_id), - ) - .await; - }, - }; - self.spawn_child_turn_terminal_watcher( - reused_handle.clone(), - accepted.session_id.to_string(), - accepted.turn_id.to_string(), - ctx.session_id().to_string(), - current_parent_turn_id, - ctx.tool_call_id().map(ToString::to_string), - ); - - let child_ref = self.build_child_ref_from_handle(&reused_handle).await; - self.record_fact_best_effort( - collaboration.runtime(), - collaboration - .fact( - AgentCollaborationActionKind::Send, - AgentCollaborationOutcomeKind::Reused, - ) - .child(&reused_handle) - .summary("idle child resumed"), - ) - .await; - Ok(Some(CollaborationResult { - accepted: true, - kind: CollaborationResultKind::Sent, - agent_ref: Some(self.project_child_ref_status(child_ref).await), - delivery_id: None, - summary: Some(format!( - "子 Agent {} 已恢复,并开始处理新的具体指令。", - params.agent_id - )), - observe_result: None, - delegation: reused_handle.delegation.clone(), - cascade: None, - closed_root_agent_id: None, - failure: None, - })) - } - - async fn queue_message_for_active_child( - &self, - child: &SubRunHandle, - params: &SendToChildParams, - ctx: &astrcode_core::ToolContext, - collaboration: &super::ToolCollaborationContext, - ) -> Result { - let delivery_id = format!("delivery-{}", uuid::Uuid::new_v4()); - let envelope = astrcode_core::AgentInboxEnvelope { - delivery_id: delivery_id.clone(), - from_agent_id: ctx.agent_context().agent_id.clone().unwrap_or_default(), - to_agent_id: params.agent_id.clone(), - kind: InboxEnvelopeKind::ParentMessage, - message: params.message.clone(), - context: params.context.clone(), - is_final: false, - summary: None, - findings: Vec::new(), - artifacts: Vec::new(), - }; - self.append_durable_mailbox_queue(child, &envelope, ctx) - .await?; - - self.kernel - .deliver(&child.agent_id, envelope) - .await - .ok_or_else(|| { - super::AgentOrchestrationError::NotFound(format!( - "agent '{}' inbox not available", - params.agent_id - )) - })?; - - let child_ref = self.build_child_ref_from_handle(child).await; - log::info!( - "send: message sent to child agent '{}' (subRunId='{}')", - params.agent_id, - child.sub_run_id - ); - self.record_fact_best_effort( - collaboration.runtime(), - collaboration - .fact( - AgentCollaborationActionKind::Send, - AgentCollaborationOutcomeKind::Queued, - ) - .child(child) - .delivery_id(delivery_id.clone()) - .summary("message queued for running child"), - ) - .await; - - Ok(CollaborationResult { - accepted: true, - kind: CollaborationResultKind::Sent, - agent_ref: Some(self.project_child_ref_status(child_ref).await), - delivery_id: Some(delivery_id), - summary: Some(format!( - "子 Agent {} 正在运行;消息已进入 mailbox 排队,待当前工作完成后处理。", - params.agent_id - )), - observe_result: None, - delegation: child.delegation.clone(), - cascade: None, - closed_root_agent_id: None, - failure: None, - }) - } -} - -/// 将待处理的 inbox 信封与新的 send 输入拼接为 resume 消息。 -fn compose_reusable_child_message( - pending: &[astrcode_core::AgentInboxEnvelope], - params: &astrcode_core::SendToChildParams, -) -> String { - let mut parts = pending - .iter() - .filter(|envelope| { - matches!( - envelope.kind, - astrcode_core::InboxEnvelopeKind::ParentMessage - ) - }) - .map(render_parent_message_envelope) - .collect::>(); - parts.push(render_parent_message_input( - params.message.as_str(), - params.context.as_deref(), - )); - - if parts.len() == 1 { - return parts.pop().unwrap_or_default(); - } - - let enumerated = parts - .into_iter() - .enumerate() - .map(|(index, part)| format!("{}. {}", index + 1, part)) - .collect::>() - .join("\n\n"); - format!("请按顺序处理以下追加要求:\n\n{enumerated}") -} - -fn parent_delivery_terminal_semantics( - payload: &ParentDeliveryPayload, -) -> ParentDeliveryTerminalSemantics { - match payload { - ParentDeliveryPayload::Progress(_) => ParentDeliveryTerminalSemantics::NonTerminal, - ParentDeliveryPayload::Completed(_) - | ParentDeliveryPayload::Failed(_) - | ParentDeliveryPayload::CloseRequest(_) => ParentDeliveryTerminalSemantics::Terminal, - } -} - -fn parent_delivery_notification_kind( - payload: &ParentDeliveryPayload, -) -> ChildSessionNotificationKind { - match payload { - ParentDeliveryPayload::Progress(_) => ChildSessionNotificationKind::ProgressSummary, - ParentDeliveryPayload::Completed(_) => ChildSessionNotificationKind::Delivered, - ParentDeliveryPayload::Failed(_) => ChildSessionNotificationKind::Failed, - ParentDeliveryPayload::CloseRequest(_) => ChildSessionNotificationKind::Closed, - } -} - -fn parent_delivery_label(payload: &ParentDeliveryPayload) -> &'static str { - match payload { - ParentDeliveryPayload::Progress(_) => "progress", - ParentDeliveryPayload::Completed(_) => "completed", - ParentDeliveryPayload::Failed(_) => "failed", - ParentDeliveryPayload::CloseRequest(_) => "close_request", - } -} - -fn explicit_parent_delivery_id( - sub_run_id: &str, - source_turn_id: &str, - source_tool_call_id: Option<&str>, - payload: &ParentDeliveryPayload, -) -> String { - let tool_call_id = source_tool_call_id.unwrap_or("tool-call-missing"); - format!( - "child-send:{sub_run_id}:{source_turn_id}:{tool_call_id}:{}", - parent_delivery_label(payload) - ) -} - -fn render_parent_message_envelope(envelope: &astrcode_core::AgentInboxEnvelope) -> String { - render_parent_message_input(envelope.message.as_str(), envelope.context.as_deref()) -} - -fn render_parent_message_input(message: &str, context: Option<&str>) -> String { - match context { - Some(context) if !context.trim().is_empty() => { - format!("{message}\n\n补充上下文:{context}") - }, - _ => message.to_string(), - } -} - -impl AgentOrchestrationService { - pub(super) async fn append_durable_mailbox_queue( - &self, - child: &SubRunHandle, - envelope: &AgentInboxEnvelope, - ctx: &astrcode_core::ToolContext, - ) -> astrcode_core::Result<()> { - let target_session_id = child - .child_session_id - .clone() - .unwrap_or_else(|| child.session_id.clone()); - - let sender_agent_id = ctx.agent_context().agent_id.clone().unwrap_or_default(); - let sender_lifecycle_status = if sender_agent_id.is_empty() { - AgentLifecycleStatus::Running - } else { - self.kernel - .get_lifecycle(&sender_agent_id) - .await - .unwrap_or(AgentLifecycleStatus::Running) - }; - let sender_last_turn_outcome = if sender_agent_id.is_empty() { - None - } else { - self.kernel.get_turn_outcome(&sender_agent_id).await - }; - let sender_open_session_id = ctx - .agent_context() - .child_session_id - .clone() - .unwrap_or_else(|| ctx.session_id().to_string()); + .map_err(super::AgentOrchestrationError::from)? + .current_mode_id; - let payload = MailboxQueuedPayload { - envelope: astrcode_core::AgentMailboxEnvelope { - delivery_id: envelope.delivery_id.clone(), - from_agent_id: envelope.from_agent_id.clone(), - to_agent_id: envelope.to_agent_id.clone(), - message: render_parent_message_input( - &envelope.message, - envelope.context.as_deref(), - ), - queued_at: chrono::Utc::now(), - sender_lifecycle_status, - sender_last_turn_outcome, - sender_open_session_id, + Ok(super::ToolCollaborationContext::new( + super::ToolCollaborationContextInput { + runtime: runtime.clone(), + session_id: child.session_id.to_string(), + turn_id: parent_turn_id.to_string(), + parent_agent_id: child.parent_agent_id.clone().map(Into::into), + source_tool_call_id: ctx.tool_call_id().map(ToString::to_string), + policy: collaboration_policy_context(&runtime), + governance_revision: super::AGENT_COLLABORATION_POLICY_REVISION.to_string(), + mode_id, }, - }; - - self.session_runtime - .append_agent_mailbox_queued( - &target_session_id, - ctx.turn_id().unwrap_or(&child.parent_turn_id), - subrun_event_context(child), - payload, - ) - .await?; - Ok(()) - } - - pub(super) async fn append_durable_mailbox_discard_batch( - &self, - handles: &[SubRunHandle], - ctx: &astrcode_core::ToolContext, - ) -> astrcode_core::Result<()> { - for handle in handles { - self.append_durable_mailbox_discard(handle, ctx).await?; - } - Ok(()) - } - - async fn append_durable_mailbox_discard( - &self, - handle: &SubRunHandle, - ctx: &astrcode_core::ToolContext, - ) -> astrcode_core::Result<()> { - let target_session_id = handle - .child_session_id - .clone() - .unwrap_or_else(|| handle.session_id.clone()); - let pending_delivery_ids = self - .session_runtime - .pending_delivery_ids_for_agent(&target_session_id, &handle.agent_id) - .await?; - if pending_delivery_ids.is_empty() { - return Ok(()); - } - - self.session_runtime - .append_agent_mailbox_discarded( - &target_session_id, - ctx.turn_id().unwrap_or(&handle.parent_turn_id), - astrcode_core::AgentEventContext::default(), - MailboxDiscardedPayload { - target_agent_id: handle.agent_id.clone(), - delivery_ids: pending_delivery_ids, - }, - ) - .await?; - Ok(()) - } -} - -/// 将 live 控制面的 lifecycle + outcome 投影回 `ChildAgentRef` 的 lifecycle。 -/// -/// `Idle` + `None` outcome 的含义是:agent 已空闲但还没有完成过一轮 turn, -/// 此时保留调用方传入的 fallback 状态(通常是 handle 上的旧 lifecycle)。 -fn project_collaboration_lifecycle( - lifecycle: AgentLifecycleStatus, - last_turn_outcome: Option, - fallback: AgentLifecycleStatus, -) -> AgentLifecycleStatus { - match lifecycle { - AgentLifecycleStatus::Pending => AgentLifecycleStatus::Pending, - AgentLifecycleStatus::Running => AgentLifecycleStatus::Running, - AgentLifecycleStatus::Idle => match last_turn_outcome { - Some(_) => AgentLifecycleStatus::Idle, - None => fallback, - }, - AgentLifecycleStatus::Terminated => AgentLifecycleStatus::Terminated, + )) } } #[cfg(test)] -mod tests { - use std::time::{Duration, Instant}; - - use astrcode_core::{ - AgentCollaborationActionKind, AgentCollaborationOutcomeKind, CancelToken, CloseAgentParams, - CompletedParentDeliveryPayload, ObserveParams, ParentDeliveryPayload, SendAgentParams, - SendToChildParams, SendToParentParams, SessionId, SpawnAgentParams, StorageEventPayload, - ToolContext, - agent::executor::{CollaborationExecutor, SubAgentExecutor}, - }; - use tokio::time::sleep; - - use super::super::{root_execution_event_context, subrun_event_context}; - use crate::{ - AgentKernelPort, AppKernelPort, - agent::test_support::{TestLlmBehavior, build_agent_test_harness}, - lifecycle::governance::ObservabilitySnapshotProvider, - }; - - async fn spawn_direct_child( - harness: &crate::agent::test_support::AgentTestHarness, - parent_session_id: &str, - working_dir: &std::path::Path, - ) -> (String, String) { - harness - .kernel - .agent_control() - .register_root_agent( - "root-agent".to_string(), - parent_session_id.to_string(), - "root-profile".to_string(), - ) - .await - .expect("root agent should be registered"); - let parent_ctx = ToolContext::new( - parent_session_id.to_string().into(), - working_dir.to_path_buf(), - CancelToken::new(), - ) - .with_turn_id("turn-parent") - .with_agent_context(root_execution_event_context("root-agent", "root-profile")); - - let launched = harness - .service - .launch( - SpawnAgentParams { - r#type: Some("reviewer".to_string()), - description: "检查 crates".to_string(), - prompt: "请检查 crates 目录".to_string(), - context: None, - capability_grant: None, - }, - &parent_ctx, - ) - .await - .expect("spawn should succeed"); - let child_agent_id = launched - .handoff - .as_ref() - .and_then(|handoff| { - handoff - .artifacts - .iter() - .find(|artifact| artifact.kind == "agent") - .map(|artifact| artifact.id.clone()) - }) - .expect("child agent artifact should exist"); - for _ in 0..20 { - if harness - .kernel - .get_lifecycle(&child_agent_id) - .await - .is_some_and(|lifecycle| lifecycle == astrcode_core::AgentLifecycleStatus::Idle) - { - break; - } - sleep(Duration::from_millis(20)).await; - } - (child_agent_id, parent_ctx.session_id().to_string()) - } - - #[tokio::test] - async fn collaboration_calls_reject_non_direct_child() { - let harness = build_agent_test_harness(TestLlmBehavior::Succeed { - content: "完成。".to_string(), - }) - .expect("test harness should build"); - let project = tempfile::tempdir().expect("tempdir should be created"); - - let parent_a = harness - .session_runtime - .create_session(project.path().display().to_string()) - .await - .expect("parent session A should be created"); - let (child_agent_id, _) = - spawn_direct_child(&harness, &parent_a.session_id, project.path()).await; - - let parent_b = harness - .session_runtime - .create_session(project.path().display().to_string()) - .await - .expect("parent session B should be created"); - harness - .kernel - .agent_control() - .register_root_agent( - "other-root".to_string(), - parent_b.session_id.clone(), - "root-profile".to_string(), - ) - .await - .expect("other root agent should be registered"); - let other_ctx = ToolContext::new( - parent_b.session_id.clone().into(), - project.path().to_path_buf(), - CancelToken::new(), - ) - .with_turn_id("turn-other") - .with_agent_context(root_execution_event_context("other-root", "root-profile")); - - let send_error = harness - .service - .send( - SendAgentParams::ToChild(SendToChildParams { - agent_id: child_agent_id.clone(), - message: "继续".to_string(), - context: None, - }), - &other_ctx, - ) - .await - .expect_err("send should reject non-direct child"); - assert!(send_error.to_string().contains("direct child")); - - let observe_error = harness - .service - .observe( - ObserveParams { - agent_id: child_agent_id.clone(), - }, - &other_ctx, - ) - .await - .expect_err("observe should reject non-direct child"); - assert!(observe_error.to_string().contains("direct child")); - - let close_error = harness - .service - .close( - CloseAgentParams { - agent_id: child_agent_id, - }, - &other_ctx, - ) - .await - .expect_err("close should reject non-direct child"); - assert!(close_error.to_string().contains("direct child")); - - let parent_b_events = harness - .session_runtime - .replay_stored_events(&SessionId::from(parent_b.session_id.clone())) - .await - .expect("other parent events should replay"); - assert!(parent_b_events.iter().any(|stored| matches!( - &stored.event.payload, - StorageEventPayload::AgentCollaborationFact { fact, .. } - if fact.action == AgentCollaborationActionKind::Send - && fact.outcome == AgentCollaborationOutcomeKind::Rejected - && fact.reason_code.as_deref() == Some("ownership_mismatch") - ))); - } - - #[tokio::test] - async fn send_to_idle_child_reports_resume_semantics() { - let harness = build_agent_test_harness(TestLlmBehavior::Succeed { - content: "完成。".to_string(), - }) - .expect("test harness should build"); - let project = tempfile::tempdir().expect("tempdir should be created"); - let parent = harness - .session_runtime - .create_session(project.path().display().to_string()) - .await - .expect("parent session should be created"); - let (child_agent_id, parent_session_id) = - spawn_direct_child(&harness, &parent.session_id, project.path()).await; - let parent_ctx = ToolContext::new( - parent_session_id.into(), - project.path().to_path_buf(), - CancelToken::new(), - ) - .with_turn_id("turn-parent-2") - .with_agent_context(root_execution_event_context("root-agent", "root-profile")); - - let result = harness - .service - .send( - SendAgentParams::ToChild(SendToChildParams { - agent_id: child_agent_id, - message: "请继续整理结论".to_string(), - context: None, - }), - &parent_ctx, - ) - .await - .expect("send should succeed"); - - assert_eq!(result.delivery_id, None); - assert!( - result - .summary - .as_deref() - .is_some_and(|summary| summary.contains("已恢复")) - ); - assert_eq!( - result - .delegation - .as_ref() - .map(|metadata| metadata.responsibility_summary.as_str()), - Some("检查 crates"), - "resumed child should keep the original responsibility branch metadata" - ); - assert_eq!( - result - .agent_ref - .as_ref() - .map(|child_ref| child_ref.lineage_kind), - Some(astrcode_core::ChildSessionLineageKind::Resume), - "resumed child projection should expose resume lineage instead of masquerading as \ - spawn" - ); - let resumed_child = harness - .kernel - .get_handle( - result - .agent_ref - .as_ref() - .map(|child_ref| child_ref.agent_id.as_str()) - .expect("child ref should exist"), - ) - .await - .expect("resumed child handle should exist"); - assert_eq!(resumed_child.parent_turn_id, "turn-parent-2"); - assert_eq!( - resumed_child.lineage_kind, - astrcode_core::ChildSessionLineageKind::Resume - ); - } - - #[tokio::test] - async fn send_to_running_child_reports_mailbox_queue_semantics() { - let harness = build_agent_test_harness(TestLlmBehavior::Succeed { - content: "完成。".to_string(), - }) - .expect("test harness should build"); - let project = tempfile::tempdir().expect("tempdir should be created"); - let parent = harness - .session_runtime - .create_session(project.path().display().to_string()) - .await - .expect("parent session should be created"); - let (child_agent_id, parent_session_id) = - spawn_direct_child(&harness, &parent.session_id, project.path()).await; - let parent_ctx = ToolContext::new( - parent_session_id.into(), - project.path().to_path_buf(), - CancelToken::new(), - ) - .with_turn_id("turn-parent-3") - .with_agent_context(root_execution_event_context("root-agent", "root-profile")); - - let _ = harness - .kernel - .agent_control() - .set_lifecycle( - &child_agent_id, - astrcode_core::AgentLifecycleStatus::Running, - ) - .await; - - let result = harness - .service - .send( - SendAgentParams::ToChild(SendToChildParams { - agent_id: child_agent_id, - message: "继续第二轮".to_string(), - context: Some("只看 CI".to_string()), - }), - &parent_ctx, - ) - .await - .expect("send should succeed"); - - assert!(result.delivery_id.is_some()); - assert!( - result - .summary - .as_deref() - .is_some_and(|summary| summary.contains("mailbox 排队")) - ); - } - - #[tokio::test] - async fn send_to_parent_rejects_root_execution_without_direct_parent() { - let harness = build_agent_test_harness(TestLlmBehavior::Succeed { - content: "完成。".to_string(), - }) - .expect("test harness should build"); - let project = tempfile::tempdir().expect("tempdir should be created"); - let parent = harness - .session_runtime - .create_session(project.path().display().to_string()) - .await - .expect("parent session should be created"); - harness - .kernel - .agent_control() - .register_root_agent( - "root-agent".to_string(), - parent.session_id.clone(), - "root-profile".to_string(), - ) - .await - .expect("root agent should be registered"); - - let root_ctx = ToolContext::new( - parent.session_id.clone().into(), - project.path().to_path_buf(), - CancelToken::new(), - ) - .with_turn_id("turn-root") - .with_agent_context(root_execution_event_context("root-agent", "root-profile")); - - let error = harness - .service - .send( - SendAgentParams::ToParent(SendToParentParams { - payload: ParentDeliveryPayload::Completed(CompletedParentDeliveryPayload { - message: "根节点不应该上行".to_string(), - findings: Vec::new(), - artifacts: Vec::new(), - }), - }), - &root_ctx, - ) - .await - .expect_err("root agent should not be able to send upward"); - assert!(error.to_string().contains("no direct parent")); - - let events = harness - .session_runtime - .replay_stored_events(&SessionId::from(parent.session_id.clone())) - .await - .expect("parent events should replay"); - assert!(events.iter().any(|stored| matches!( - &stored.event.payload, - StorageEventPayload::AgentCollaborationFact { fact, .. } - if fact.action == AgentCollaborationActionKind::Delivery - && fact.outcome == AgentCollaborationOutcomeKind::Rejected - && fact.reason_code.as_deref() == Some("missing_direct_parent") - ))); - } - - #[tokio::test] - async fn send_to_parent_from_resumed_child_routes_to_current_parent_turn() { - let harness = build_agent_test_harness(TestLlmBehavior::Succeed { - content: "完成。".to_string(), - }) - .expect("test harness should build"); - let project = tempfile::tempdir().expect("tempdir should be created"); - let parent = harness - .session_runtime - .create_session(project.path().display().to_string()) - .await - .expect("parent session should be created"); - let (child_agent_id, parent_session_id) = - spawn_direct_child(&harness, &parent.session_id, project.path()).await; - let parent_ctx = ToolContext::new( - parent_session_id.into(), - project.path().to_path_buf(), - CancelToken::new(), - ) - .with_turn_id("turn-parent-2") - .with_agent_context(root_execution_event_context("root-agent", "root-profile")); - - harness - .service - .send( - SendAgentParams::ToChild(SendToChildParams { - agent_id: child_agent_id.clone(), - message: "继续整理并向我汇报".to_string(), - context: None, - }), - &parent_ctx, - ) - .await - .expect("send should resume idle child"); - - let resumed_child = harness - .kernel - .get_handle(&child_agent_id) - .await - .expect("resumed child handle should exist"); - let child_ctx = ToolContext::new( - resumed_child - .child_session_id - .clone() - .expect("child session id should exist") - .into(), - project.path().to_path_buf(), - CancelToken::new(), - ) - .with_turn_id("turn-child-report-2") - .with_agent_context(subrun_event_context(&resumed_child)); - let metrics_before = harness.metrics.snapshot(); - - let result = harness - .service - .send( - SendAgentParams::ToParent(SendToParentParams { - payload: ParentDeliveryPayload::Completed(CompletedParentDeliveryPayload { - message: "继续推进后的显式上报".to_string(), - findings: Vec::new(), - artifacts: Vec::new(), - }), - }), - &child_ctx, - ) - .await - .expect("resumed child should be able to send upward"); - - assert!(result.delivery_id.is_some()); - let deadline = Instant::now() + Duration::from_secs(5); - loop { - if harness - .kernel - .agent_control() - .pending_parent_delivery_count(&parent.session_id) - .await - == 0 - { - break; - } - assert!( - Instant::now() < deadline, - "explicit upstream send should trigger parent wake and drain delivery queue" - ); - sleep(Duration::from_millis(20)).await; - } - - let parent_events = harness - .session_runtime - .replay_stored_events(&SessionId::from(parent.session_id.clone())) - .await - .expect("parent events should replay"); - assert!(parent_events.iter().any(|stored| matches!( - &stored.event.payload, - StorageEventPayload::ChildSessionNotification { notification, .. } - if stored.event.turn_id.as_deref() == Some("turn-parent-2") - && notification.child_ref.sub_run_id == resumed_child.sub_run_id - && notification.child_ref.lineage_kind - == astrcode_core::ChildSessionLineageKind::Resume - && notification.delivery.as_ref().is_some_and(|delivery| { - delivery.origin == astrcode_core::ParentDeliveryOrigin::Explicit - && delivery.payload.message() == "继续推进后的显式上报" - }) - ))); - assert!( - !parent_events.iter().any(|stored| matches!( - &stored.event.payload, - StorageEventPayload::ChildSessionNotification { notification, .. } - if stored.event.turn_id.as_deref() == Some("turn-parent") - && notification.delivery.as_ref().is_some_and(|delivery| { - delivery.payload.message() == "继续推进后的显式上报" - }) - )), - "resumed child delivery must target the current parent turn instead of the stale \ - spawn turn" - ); - assert!( - parent_events.iter().any(|stored| matches!( - &stored.event.payload, - StorageEventPayload::AgentMailboxQueued { payload } - if payload.envelope.message == "继续推进后的显式上报" - )), - "explicit upstream send should enqueue the same delivery for parent wake consumption" - ); - assert!( - parent_events.iter().any(|stored| matches!( - &stored.event.payload, - StorageEventPayload::UserMessage { content, .. } - if content.contains("delivery_id: child-send:") - && content.contains("message: 继续推进后的显式上报") - )), - "parent wake prompt should consume the explicit upstream delivery" - ); - let metrics = harness.metrics.snapshot(); - assert!( - metrics.execution_diagnostics.parent_reactivation_requested - - metrics_before - .execution_diagnostics - .parent_reactivation_requested - >= 1 - ); - assert!( - metrics.execution_diagnostics.parent_reactivation_succeeded - - metrics_before - .execution_diagnostics - .parent_reactivation_succeeded - >= 1 - ); - assert!( - metrics.execution_diagnostics.delivery_buffer_wake_succeeded - - metrics_before - .execution_diagnostics - .delivery_buffer_wake_succeeded - >= 1 - ); - } - - #[tokio::test] - async fn send_to_parent_rejects_when_direct_parent_is_terminated() { - let harness = build_agent_test_harness(TestLlmBehavior::Succeed { - content: "完成。".to_string(), - }) - .expect("test harness should build"); - let project = tempfile::tempdir().expect("tempdir should be created"); - let parent = harness - .session_runtime - .create_session(project.path().display().to_string()) - .await - .expect("parent session should be created"); - let (child_agent_id, _) = - spawn_direct_child(&harness, &parent.session_id, project.path()).await; - let child_handle = harness - .kernel - .get_handle(&child_agent_id) - .await - .expect("child handle should exist"); - - let _ = harness - .kernel - .agent_control() - .set_lifecycle( - "root-agent", - astrcode_core::AgentLifecycleStatus::Terminated, - ) - .await; - - let child_ctx = ToolContext::new( - child_handle - .child_session_id - .clone() - .expect("child session id should exist") - .into(), - project.path().to_path_buf(), - CancelToken::new(), - ) - .with_turn_id("turn-child-report") - .with_agent_context(subrun_event_context(&child_handle)); - - let error = harness - .service - .send( - SendAgentParams::ToParent(SendToParentParams { - payload: ParentDeliveryPayload::Completed(CompletedParentDeliveryPayload { - message: "父级已终止".to_string(), - findings: Vec::new(), - artifacts: Vec::new(), - }), - }), - &child_ctx, - ) - .await - .expect_err("terminated parent should reject upward send"); - assert!(error.to_string().contains("terminated")); - - let parent_events = harness - .session_runtime - .replay_stored_events(&SessionId::from(parent.session_id.clone())) - .await - .expect("parent events should replay"); - assert!(parent_events.iter().any(|stored| matches!( - &stored.event.payload, - StorageEventPayload::AgentCollaborationFact { fact, .. } - if fact.action == AgentCollaborationActionKind::Delivery - && fact.outcome == AgentCollaborationOutcomeKind::Rejected - && fact.reason_code.as_deref() == Some("parent_terminated") - ))); - } - - #[tokio::test] - async fn close_reports_cascade_scope_for_descendants() { - let harness = build_agent_test_harness(TestLlmBehavior::Succeed { - content: "完成。".to_string(), - }) - .expect("test harness should build"); - let project = tempfile::tempdir().expect("tempdir should be created"); - let parent = harness - .session_runtime - .create_session(project.path().display().to_string()) - .await - .expect("parent session should be created"); - let (child_agent_id, parent_session_id) = - spawn_direct_child(&harness, &parent.session_id, project.path()).await; - - let child_handle = harness - .kernel - .agent() - .get_handle(&child_agent_id) - .await - .expect("child handle should exist"); - let child_ctx = ToolContext::new( - child_handle - .child_session_id - .clone() - .expect("child session id should exist") - .into(), - project.path().to_path_buf(), - CancelToken::new(), - ) - .with_turn_id("turn-child-1") - .with_agent_context(subrun_event_context(&child_handle)); - let _grandchild = harness - .service - .launch( - SpawnAgentParams { - r#type: Some("reviewer".to_string()), - description: "进一步检查".to_string(), - prompt: "请进一步检查测试覆盖".to_string(), - context: None, - capability_grant: None, - }, - &child_ctx, - ) - .await - .expect("grandchild spawn should succeed"); - - let parent_ctx = ToolContext::new( - parent_session_id.into(), - project.path().to_path_buf(), - CancelToken::new(), - ) - .with_turn_id("turn-parent-close") - .with_agent_context(root_execution_event_context("root-agent", "root-profile")); - - let result = harness - .service - .close( - CloseAgentParams { - agent_id: child_agent_id, - }, - &parent_ctx, - ) - .await - .expect("close should succeed"); - - assert_eq!(result.cascade, Some(true)); - assert!( - result - .summary - .as_deref() - .is_some_and(|summary| summary.contains("1 个后代")) - ); - } -} +mod tests; diff --git a/crates/application/src/agent/routing/child_send.rs b/crates/application/src/agent/routing/child_send.rs new file mode 100644 index 00000000..0da4ed9b --- /dev/null +++ b/crates/application/src/agent/routing/child_send.rs @@ -0,0 +1,323 @@ +use super::*; + +impl AgentOrchestrationService { + /// resume 失败时恢复之前 drain 出的 inbox 信封。 + /// + /// 必须在 resume 前先 drain(否则无法取到 pending 消息来组合 resume prompt), + /// 但如果 resume 本身失败,必须把信封放回去,避免消息丢失。 + async fn restore_pending_inbox(&self, agent_id: &str, pending: Vec) { + for envelope in pending { + if self.kernel.deliver(agent_id, envelope).await.is_none() { + log::warn!( + "failed to restore drained inbox after resume error: agent='{}'", + agent_id + ); + break; + } + } + } + + async fn restore_pending_inbox_and_fail( + &self, + agent_id: &str, + pending: Vec, + message: String, + ) -> Result { + self.restore_pending_inbox(agent_id, pending).await; + Err(AgentOrchestrationError::Internal(message)) + } + + /// 如果子 agent 处于 Idle 且不占据并发槽位(如 Resume lineage), + /// 则尝试 resume 它以处理新消息,而非排队等待。 + pub(in crate::agent) async fn resume_idle_child_if_needed( + &self, + child: &SubRunHandle, + params: &SendToChildParams, + ctx: &astrcode_core::ToolContext, + collaboration: &ToolCollaborationContext, + lifecycle: Option, + ) -> Result, AgentOrchestrationError> { + if !matches!(lifecycle, Some(AgentLifecycleStatus::Idle)) || child.lifecycle.occupies_slot() + { + return Ok(None); + } + + let pending = self + .kernel + .drain_inbox(&child.agent_id) + .await + .unwrap_or_default(); + let resume_message = compose_reusable_child_message(&pending, params); + let current_parent_turn_id = ctx.turn_id().unwrap_or(&child.parent_turn_id).to_string(); + + let Some(reused_handle) = self + .kernel + .resume(¶ms.agent_id, ¤t_parent_turn_id) + .await + else { + self.restore_pending_inbox(&child.agent_id, pending).await; + return Ok(None); + }; + + log::info!( + "send: reusable child agent '{}' restarted with new turn (subRunId='{}')", + params.agent_id, + reused_handle.sub_run_id + ); + + let Some(child_session_id) = reused_handle + .child_session_id + .as_ref() + .or(child.child_session_id.as_ref()) + else { + return self + .restore_pending_inbox_and_fail( + &child.agent_id, + pending, + format!( + "agent '{}' resume failed: missing child session id", + params.agent_id + ), + ) + .await; + }; + + let fallback_delegation = build_delegation_metadata( + "", + params.message.as_str(), + &reused_handle.resolved_limits, + false, + ); + let resume_delegation = reused_handle + .delegation + .clone() + .unwrap_or(fallback_delegation); + let runtime = match self + .resolve_runtime_config_for_session(child_session_id) + .await + { + Ok(runtime) => runtime, + Err(error) => { + return self + .restore_pending_inbox_and_fail( + &child.agent_id, + pending, + format!( + "agent '{}' resume runtime resolution failed: {error}", + params.agent_id + ), + ) + .await; + }, + }; + let working_dir = match self + .session_runtime + .get_session_working_dir(child_session_id) + .await + { + Ok(working_dir) => working_dir, + Err(error) => { + return self + .restore_pending_inbox_and_fail( + &child.agent_id, + pending, + format!( + "agent '{}' resume working directory resolution failed: {error}", + params.agent_id + ), + ) + .await; + }, + }; + let resumed_turn_id = format!("turn-{}", chrono::Utc::now().timestamp_millis()); + let resumed_input = ResumedChildGovernanceInput { + session_id: child_session_id.to_string(), + turn_id: resumed_turn_id.clone(), + working_dir, + mode_id: collaboration.mode_id().clone(), + runtime: runtime.clone(), + allowed_tools: effective_allowed_tools_for_limits( + &self.kernel.gateway(), + &reused_handle.resolved_limits, + ), + resolved_limits: reused_handle.resolved_limits.clone(), + delegation: Some(resume_delegation.clone()), + message: params.message.clone(), + context: params.context.clone(), + busy_policy: GovernanceBusyPolicy::RejectOnBusy, + }; + let surface = match self + .governance_surface + .resumed_child_surface(self.kernel.as_ref(), resumed_input) + { + Ok(surface) => surface, + Err(error) => { + return self + .restore_pending_inbox_and_fail( + &child.agent_id, + pending, + format!( + "agent '{}' resume governance failed: {error}", + params.agent_id + ), + ) + .await; + }, + }; + match self + .session_runtime + .try_submit_prompt_for_agent_with_turn_id( + child_session_id, + resumed_turn_id.clone().into(), + resume_message.clone(), + surface.runtime.clone(), + surface.into_submission( + subrun_event_context(&reused_handle), + collaboration.source_tool_call_id(), + ), + ) + .await + { + Ok(Some(accepted)) => accepted, + Ok(None) => { + self.restore_pending_inbox(&child.agent_id, pending).await; + return Ok(None); + }, + Err(error) => { + return self + .restore_pending_inbox_and_fail( + &child.agent_id, + pending, + format!( + "agent '{}' resume prompt submission failed: {error}", + params.agent_id + ), + ) + .await; + }, + }; + + let child_ref = self.build_child_ref_from_handle(&reused_handle).await; + self.record_fact_best_effort( + collaboration.runtime(), + collaboration + .fact( + AgentCollaborationActionKind::Send, + AgentCollaborationOutcomeKind::Delivered, + ) + .child(&reused_handle) + .summary("message delivered by reusing idle child"), + ) + .await; + + Ok(Some(CollaborationResult::Sent { + agent_ref: Some(self.project_child_ref_status(child_ref).await), + delivery_id: None, + summary: Some(format!( + "子 Agent {} 已恢复空闲执行上下文并开始处理新消息。", + params.agent_id + )), + delegation: Some(resume_delegation), + })) + } + + /// 当子 agent 正在运行时,将消息排入 input queue 并持久化 durable InputQueued 事件。 + pub(in crate::agent) async fn queue_message_for_active_child( + &self, + child: &SubRunHandle, + params: &SendToChildParams, + ctx: &astrcode_core::ToolContext, + collaboration: &ToolCollaborationContext, + ) -> Result { + let delivery_id = format!( + "send:{}:{}", + ctx.turn_id().unwrap_or("unknown-turn"), + ctx.tool_call_id().unwrap_or("tool-call-missing") + ); + let envelope = AgentInboxEnvelope { + delivery_id: delivery_id.clone(), + from_agent_id: ctx + .agent_context() + .agent_id + .clone() + .map(|id| id.to_string()) + .unwrap_or_default(), + to_agent_id: child.agent_id.to_string(), + kind: InboxEnvelopeKind::ParentMessage, + message: params.message.clone(), + context: params.context.clone(), + is_final: false, + summary: None, + findings: Vec::new(), + artifacts: Vec::new(), + }; + self.kernel + .deliver(&child.agent_id, envelope.clone()) + .await + .ok_or_else(|| { + AgentOrchestrationError::NotFound(format!( + "agent '{}' not found while queueing message", + params.agent_id + )) + })?; + self.append_durable_input_queue(child, &envelope, ctx) + .await + .map_err(AgentOrchestrationError::from)?; + + let child_ref = self.build_child_ref_from_handle(child).await; + self.record_fact_best_effort( + collaboration.runtime(), + collaboration + .fact( + AgentCollaborationActionKind::Send, + AgentCollaborationOutcomeKind::Queued, + ) + .child(child) + .delivery_id(delivery_id.clone()) + .summary("message queued for running child"), + ) + .await; + + Ok(CollaborationResult::Sent { + agent_ref: Some(self.project_child_ref_status(child_ref).await), + delivery_id: Some(delivery_id.into()), + summary: Some(format!( + "子 Agent {} 正在运行;消息已进入 input queue 排队,待当前工作完成后处理。", + params.agent_id + )), + delegation: child.delegation.clone(), + }) + } +} + +fn compose_reusable_child_message( + pending: &[astrcode_core::AgentInboxEnvelope], + params: &astrcode_core::SendToChildParams, +) -> String { + let mut parts = pending + .iter() + .filter(|envelope| { + matches!( + envelope.kind, + astrcode_core::InboxEnvelopeKind::ParentMessage + ) + }) + .map(parent_delivery::render_parent_message_envelope) + .collect::>(); + parts.push(parent_delivery::render_parent_message_input( + params.message.as_str(), + params.context.as_deref(), + )); + + if parts.len() == 1 { + return parts.pop().unwrap_or_default(); + } + + let enumerated = parts + .into_iter() + .enumerate() + .map(|(index, part)| format!("{}. {}", index + 1, part)) + .collect::>() + .join("\n\n"); + format!("请按顺序处理以下追加要求:\n\n{enumerated}") +} diff --git a/crates/application/src/agent/routing/parent_delivery.rs b/crates/application/src/agent/routing/parent_delivery.rs new file mode 100644 index 00000000..fef799ed --- /dev/null +++ b/crates/application/src/agent/routing/parent_delivery.rs @@ -0,0 +1,197 @@ +use super::*; + +impl AgentOrchestrationService { + pub(in crate::agent) async fn build_explicit_parent_delivery_notification( + &self, + child: &SubRunHandle, + payload: &ParentDeliveryPayload, + ctx: &astrcode_core::ToolContext, + source_turn_id: &str, + ) -> ChildSessionNotification { + let status = self + .kernel + .get_lifecycle(&child.agent_id) + .await + .unwrap_or(child.lifecycle); + let notification_id = explicit_parent_delivery_id( + &child.sub_run_id, + source_turn_id, + ctx.tool_call_id().map(ToString::to_string).as_deref(), + payload, + ); + + ChildSessionNotification { + notification_id: notification_id.clone().into(), + child_ref: child.child_ref_with_status(status), + kind: parent_delivery_notification_kind(payload), + source_tool_call_id: ctx.tool_call_id().map(ToString::to_string).map(Into::into), + delivery: Some(ParentDelivery { + idempotency_key: notification_id, + origin: ParentDeliveryOrigin::Explicit, + terminal_semantics: parent_delivery_terminal_semantics(payload), + source_turn_id: Some(source_turn_id.to_string()), + payload: payload.clone(), + }), + } + } + + pub(in crate::agent) async fn append_durable_input_queue( + &self, + child: &SubRunHandle, + envelope: &AgentInboxEnvelope, + ctx: &astrcode_core::ToolContext, + ) -> astrcode_core::Result<()> { + let target_session_id = child + .child_session_id + .clone() + .unwrap_or_else(|| child.session_id.clone()) + .to_string(); + + let sender_agent_id = ctx.agent_context().agent_id.clone().unwrap_or_default(); + let sender_lifecycle_status = if sender_agent_id.is_empty() { + AgentLifecycleStatus::Running + } else { + self.kernel + .get_lifecycle(&sender_agent_id) + .await + .unwrap_or(AgentLifecycleStatus::Running) + }; + let sender_last_turn_outcome = if sender_agent_id.is_empty() { + None + } else { + self.kernel.get_turn_outcome(&sender_agent_id).await + }; + let sender_open_session_id = ctx + .agent_context() + .child_session_id + .clone() + .unwrap_or_else(|| ctx.session_id().to_string().into()); + + let payload = InputQueuedPayload { + envelope: astrcode_core::QueuedInputEnvelope { + delivery_id: envelope.delivery_id.clone().into(), + from_agent_id: envelope.from_agent_id.clone(), + to_agent_id: envelope.to_agent_id.clone(), + message: render_parent_message_input( + &envelope.message, + envelope.context.as_deref(), + ), + queued_at: chrono::Utc::now(), + sender_lifecycle_status, + sender_last_turn_outcome, + sender_open_session_id: sender_open_session_id.to_string(), + }, + }; + + self.session_runtime + .append_agent_input_queued( + &target_session_id, + ctx.turn_id().unwrap_or(child.parent_turn_id.as_str()), + subrun_event_context(child), + payload, + ) + .await?; + Ok(()) + } + + pub(in crate::agent) async fn append_durable_input_queue_discard_batch( + &self, + handles: &[SubRunHandle], + ctx: &astrcode_core::ToolContext, + ) -> astrcode_core::Result<()> { + for handle in handles { + self.append_durable_input_queue_discard(handle, ctx).await?; + } + Ok(()) + } + + async fn append_durable_input_queue_discard( + &self, + handle: &SubRunHandle, + ctx: &astrcode_core::ToolContext, + ) -> astrcode_core::Result<()> { + let target_session_id = handle + .child_session_id + .clone() + .unwrap_or_else(|| handle.session_id.clone()); + let pending_delivery_ids = self + .session_runtime + .pending_delivery_ids_for_agent(&target_session_id, &handle.agent_id) + .await?; + if pending_delivery_ids.is_empty() { + return Ok(()); + } + + self.session_runtime + .append_agent_input_discarded( + &target_session_id, + ctx.turn_id().unwrap_or(&handle.parent_turn_id), + astrcode_core::AgentEventContext::default(), + InputDiscardedPayload { + target_agent_id: handle.agent_id.to_string(), + delivery_ids: pending_delivery_ids.into_iter().map(Into::into).collect(), + }, + ) + .await?; + Ok(()) + } +} + +fn parent_delivery_terminal_semantics( + payload: &ParentDeliveryPayload, +) -> ParentDeliveryTerminalSemantics { + match payload { + ParentDeliveryPayload::Progress(_) => ParentDeliveryTerminalSemantics::NonTerminal, + ParentDeliveryPayload::Completed(_) + | ParentDeliveryPayload::Failed(_) + | ParentDeliveryPayload::CloseRequest(_) => ParentDeliveryTerminalSemantics::Terminal, + } +} + +fn parent_delivery_notification_kind( + payload: &ParentDeliveryPayload, +) -> ChildSessionNotificationKind { + match payload { + ParentDeliveryPayload::Progress(_) => ChildSessionNotificationKind::ProgressSummary, + ParentDeliveryPayload::Completed(_) => ChildSessionNotificationKind::Delivered, + ParentDeliveryPayload::Failed(_) => ChildSessionNotificationKind::Failed, + ParentDeliveryPayload::CloseRequest(_) => ChildSessionNotificationKind::Closed, + } +} + +pub(super) fn parent_delivery_label(payload: &ParentDeliveryPayload) -> &'static str { + match payload { + ParentDeliveryPayload::Progress(_) => "progress", + ParentDeliveryPayload::Completed(_) => "completed", + ParentDeliveryPayload::Failed(_) => "failed", + ParentDeliveryPayload::CloseRequest(_) => "close_request", + } +} + +fn explicit_parent_delivery_id( + sub_run_id: &str, + source_turn_id: &str, + source_tool_call_id: Option<&str>, + payload: &ParentDeliveryPayload, +) -> String { + let tool_call_id = source_tool_call_id.unwrap_or("tool-call-missing"); + format!( + "child-send:{sub_run_id}:{source_turn_id}:{tool_call_id}:{}", + parent_delivery_label(payload) + ) +} + +pub(super) fn render_parent_message_envelope( + envelope: &astrcode_core::AgentInboxEnvelope, +) -> String { + render_parent_message_input(envelope.message.as_str(), envelope.context.as_deref()) +} + +pub(super) fn render_parent_message_input(message: &str, context: Option<&str>) -> String { + match context { + Some(context) if !context.trim().is_empty() => { + format!("{message}\n\n补充上下文:{context}") + }, + _ => message.to_string(), + } +} diff --git a/crates/application/src/agent/routing/tests.rs b/crates/application/src/agent/routing/tests.rs new file mode 100644 index 00000000..ff6b039e --- /dev/null +++ b/crates/application/src/agent/routing/tests.rs @@ -0,0 +1,642 @@ +use std::time::{Duration, Instant}; + +use astrcode_core::{ + AgentCollaborationActionKind, AgentCollaborationOutcomeKind, CancelToken, CloseAgentParams, + CompletedParentDeliveryPayload, ObserveParams, ParentDeliveryPayload, SendAgentParams, + SendToChildParams, SendToParentParams, SessionId, SpawnAgentParams, StorageEventPayload, + ToolContext, + agent::executor::{CollaborationExecutor, SubAgentExecutor}, +}; +use tokio::time::sleep; + +use super::super::{root_execution_event_context, subrun_event_context}; +use crate::{ + AgentKernelPort, AppKernelPort, + agent::test_support::{TestLlmBehavior, build_agent_test_harness}, + lifecycle::governance::ObservabilitySnapshotProvider, +}; + +async fn spawn_direct_child( + harness: &crate::agent::test_support::AgentTestHarness, + parent_session_id: &str, + working_dir: &std::path::Path, +) -> (String, String) { + harness + .kernel + .agent_control() + .register_root_agent( + "root-agent".to_string(), + parent_session_id.to_string(), + "root-profile".to_string(), + ) + .await + .expect("root agent should be registered"); + let parent_ctx = ToolContext::new( + parent_session_id.to_string().into(), + working_dir.to_path_buf(), + CancelToken::new(), + ) + .with_turn_id("turn-parent") + .with_agent_context(root_execution_event_context("root-agent", "root-profile")); + + let launched = harness + .service + .launch( + SpawnAgentParams { + r#type: Some("reviewer".to_string()), + description: "检查 crates".to_string(), + prompt: "请检查 crates 目录".to_string(), + context: None, + capability_grant: None, + }, + &parent_ctx, + ) + .await + .expect("spawn should succeed"); + let child_agent_id = launched + .handoff() + .and_then(|handoff| { + handoff + .artifacts + .iter() + .find(|artifact| artifact.kind == "agent") + .map(|artifact| artifact.id.clone()) + }) + .expect("child agent artifact should exist"); + for _ in 0..20 { + if harness + .kernel + .get_lifecycle(&child_agent_id) + .await + .is_some_and(|lifecycle| lifecycle == astrcode_core::AgentLifecycleStatus::Idle) + { + break; + } + sleep(Duration::from_millis(20)).await; + } + (child_agent_id, parent_ctx.session_id().to_string()) +} + +#[tokio::test] +async fn collaboration_calls_reject_non_direct_child() { + let harness = build_agent_test_harness(TestLlmBehavior::Succeed { + content: "完成。".to_string(), + }) + .expect("test harness should build"); + let project = tempfile::tempdir().expect("tempdir should be created"); + + let parent_a = harness + .session_runtime + .create_session(project.path().display().to_string()) + .await + .expect("parent session A should be created"); + let (child_agent_id, _) = + spawn_direct_child(&harness, &parent_a.session_id, project.path()).await; + + let parent_b = harness + .session_runtime + .create_session(project.path().display().to_string()) + .await + .expect("parent session B should be created"); + harness + .kernel + .agent_control() + .register_root_agent( + "other-root".to_string(), + parent_b.session_id.clone(), + "root-profile".to_string(), + ) + .await + .expect("other root agent should be registered"); + let other_ctx = ToolContext::new( + parent_b.session_id.clone().into(), + project.path().to_path_buf(), + CancelToken::new(), + ) + .with_turn_id("turn-other") + .with_agent_context(root_execution_event_context("other-root", "root-profile")); + + let send_error = harness + .service + .send( + SendAgentParams::ToChild(SendToChildParams { + agent_id: child_agent_id.clone().into(), + message: "继续".to_string(), + context: None, + }), + &other_ctx, + ) + .await + .expect_err("send should reject non-direct child"); + assert!(send_error.to_string().contains("direct child")); + + let observe_error = harness + .service + .observe( + ObserveParams { + agent_id: child_agent_id.clone(), + }, + &other_ctx, + ) + .await + .expect_err("observe should reject non-direct child"); + assert!(observe_error.to_string().contains("direct child")); + + let close_error = harness + .service + .close( + CloseAgentParams { + agent_id: child_agent_id.into(), + }, + &other_ctx, + ) + .await + .expect_err("close should reject non-direct child"); + assert!(close_error.to_string().contains("direct child")); + + let parent_b_events = harness + .session_runtime + .replay_stored_events(&SessionId::from(parent_b.session_id.clone())) + .await + .expect("other parent events should replay"); + assert!(parent_b_events.iter().any(|stored| matches!( + &stored.event.payload, + StorageEventPayload::AgentCollaborationFact { fact, .. } + if fact.action == AgentCollaborationActionKind::Send + && fact.outcome == AgentCollaborationOutcomeKind::Rejected + && fact.reason_code.as_deref() == Some("ownership_mismatch") + ))); +} + +#[tokio::test] +async fn send_to_idle_child_reports_resume_semantics() { + let harness = build_agent_test_harness(TestLlmBehavior::Succeed { + content: "完成。".to_string(), + }) + .expect("test harness should build"); + let project = tempfile::tempdir().expect("tempdir should be created"); + let parent = harness + .session_runtime + .create_session(project.path().display().to_string()) + .await + .expect("parent session should be created"); + let (child_agent_id, parent_session_id) = + spawn_direct_child(&harness, &parent.session_id, project.path()).await; + let parent_ctx = ToolContext::new( + parent_session_id.into(), + project.path().to_path_buf(), + CancelToken::new(), + ) + .with_turn_id("turn-parent-2") + .with_agent_context(root_execution_event_context("root-agent", "root-profile")); + + let result = harness + .service + .send( + SendAgentParams::ToChild(SendToChildParams { + agent_id: child_agent_id.into(), + message: "请继续整理结论".to_string(), + context: None, + }), + &parent_ctx, + ) + .await + .expect("send should succeed"); + + assert_eq!(result.delivery_id(), None); + assert!( + result + .summary() + .is_some_and(|summary| summary.contains("已恢复")) + ); + assert_eq!( + result + .delegation() + .map(|metadata| metadata.responsibility_summary.as_str()), + Some("检查 crates"), + "resumed child should keep the original responsibility branch metadata" + ); + assert_eq!( + result.agent_ref().map(|child_ref| child_ref.lineage_kind), + Some(astrcode_core::ChildSessionLineageKind::Resume), + "resumed child projection should expose resume lineage instead of masquerading as spawn" + ); + let resumed_child = harness + .kernel + .get_handle( + result + .agent_ref() + .map(|child_ref| child_ref.agent_id().as_str()) + .expect("child ref should exist"), + ) + .await + .expect("resumed child handle should exist"); + assert_eq!(resumed_child.parent_turn_id, "turn-parent-2".into()); + assert_eq!( + resumed_child.lineage_kind, + astrcode_core::ChildSessionLineageKind::Resume + ); +} + +#[tokio::test] +async fn send_to_running_child_reports_input_queue_semantics() { + let harness = build_agent_test_harness(TestLlmBehavior::Succeed { + content: "完成。".to_string(), + }) + .expect("test harness should build"); + let project = tempfile::tempdir().expect("tempdir should be created"); + let parent = harness + .session_runtime + .create_session(project.path().display().to_string()) + .await + .expect("parent session should be created"); + let (child_agent_id, parent_session_id) = + spawn_direct_child(&harness, &parent.session_id, project.path()).await; + let parent_ctx = ToolContext::new( + parent_session_id.into(), + project.path().to_path_buf(), + CancelToken::new(), + ) + .with_turn_id("turn-parent-3") + .with_agent_context(root_execution_event_context("root-agent", "root-profile")); + + let _ = harness + .kernel + .agent_control() + .set_lifecycle( + &child_agent_id, + astrcode_core::AgentLifecycleStatus::Running, + ) + .await; + + let result = harness + .service + .send( + SendAgentParams::ToChild(SendToChildParams { + agent_id: child_agent_id.into(), + message: "继续第二轮".to_string(), + context: Some("只看 CI".to_string()), + }), + &parent_ctx, + ) + .await + .expect("send should succeed"); + + assert!(result.delivery_id().is_some()); + assert!( + result + .summary() + .is_some_and(|summary| summary.contains("input queue 排队")) + ); +} + +#[tokio::test] +async fn send_to_parent_rejects_root_execution_without_direct_parent() { + let harness = build_agent_test_harness(TestLlmBehavior::Succeed { + content: "完成。".to_string(), + }) + .expect("test harness should build"); + let project = tempfile::tempdir().expect("tempdir should be created"); + let parent = harness + .session_runtime + .create_session(project.path().display().to_string()) + .await + .expect("parent session should be created"); + harness + .kernel + .agent_control() + .register_root_agent( + "root-agent".to_string(), + parent.session_id.clone(), + "root-profile".to_string(), + ) + .await + .expect("root agent should be registered"); + + let root_ctx = ToolContext::new( + parent.session_id.clone().into(), + project.path().to_path_buf(), + CancelToken::new(), + ) + .with_turn_id("turn-root") + .with_agent_context(root_execution_event_context("root-agent", "root-profile")); + + let error = harness + .service + .send( + SendAgentParams::ToParent(SendToParentParams { + payload: ParentDeliveryPayload::Completed(CompletedParentDeliveryPayload { + message: "根节点不应该上行".to_string(), + findings: Vec::new(), + artifacts: Vec::new(), + }), + }), + &root_ctx, + ) + .await + .expect_err("root agent should not be able to send upward"); + assert!(error.to_string().contains("no direct parent")); + + let events = harness + .session_runtime + .replay_stored_events(&SessionId::from(parent.session_id.clone())) + .await + .expect("parent events should replay"); + assert!(events.iter().any(|stored| matches!( + &stored.event.payload, + StorageEventPayload::AgentCollaborationFact { fact, .. } + if fact.action == AgentCollaborationActionKind::Delivery + && fact.outcome == AgentCollaborationOutcomeKind::Rejected + && fact.reason_code.as_deref() == Some("missing_direct_parent") + ))); +} + +#[tokio::test] +async fn send_to_parent_from_resumed_child_routes_to_current_parent_turn() { + let harness = build_agent_test_harness(TestLlmBehavior::Succeed { + content: "完成。".to_string(), + }) + .expect("test harness should build"); + let project = tempfile::tempdir().expect("tempdir should be created"); + let parent = harness + .session_runtime + .create_session(project.path().display().to_string()) + .await + .expect("parent session should be created"); + let (child_agent_id, parent_session_id) = + spawn_direct_child(&harness, &parent.session_id, project.path()).await; + let parent_ctx = ToolContext::new( + parent_session_id.into(), + project.path().to_path_buf(), + CancelToken::new(), + ) + .with_turn_id("turn-parent-2") + .with_agent_context(root_execution_event_context("root-agent", "root-profile")); + + harness + .service + .send( + SendAgentParams::ToChild(SendToChildParams { + agent_id: child_agent_id.clone().into(), + message: "继续整理并向我汇报".to_string(), + context: None, + }), + &parent_ctx, + ) + .await + .expect("send should resume idle child"); + + let resumed_child = harness + .kernel + .get_handle(&child_agent_id) + .await + .expect("resumed child handle should exist"); + let child_ctx = ToolContext::new( + resumed_child + .child_session_id + .clone() + .expect("child session id should exist"), + project.path().to_path_buf(), + CancelToken::new(), + ) + .with_turn_id("turn-child-report-2") + .with_agent_context(subrun_event_context(&resumed_child)); + let metrics_before = harness.metrics.snapshot(); + + let result = harness + .service + .send( + SendAgentParams::ToParent(SendToParentParams { + payload: ParentDeliveryPayload::Completed(CompletedParentDeliveryPayload { + message: "继续推进后的显式上报".to_string(), + findings: Vec::new(), + artifacts: Vec::new(), + }), + }), + &child_ctx, + ) + .await + .expect("resumed child should be able to send upward"); + + assert!(result.delivery_id().is_some()); + let deadline = Instant::now() + Duration::from_secs(5); + loop { + let parent_events = harness + .session_runtime + .replay_stored_events(&SessionId::from(parent.session_id.clone())) + .await + .expect("parent events should replay during wake wait"); + if parent_events.iter().any(|stored| { + matches!( + &stored.event.payload, + StorageEventPayload::UserMessage { content, origin, .. } + if *origin == astrcode_core::UserMessageOrigin::QueuedInput + && content.contains("继续推进后的显式上报") + ) + }) { + break; + } + assert!( + Instant::now() < deadline, + "explicit upstream send should trigger parent wake and consume the queued input" + ); + sleep(Duration::from_millis(20)).await; + } + + let parent_events = harness + .session_runtime + .replay_stored_events(&SessionId::from(parent.session_id.clone())) + .await + .expect("parent events should replay"); + assert!(parent_events.iter().any(|stored| matches!( + &stored.event.payload, + StorageEventPayload::ChildSessionNotification { notification, .. } + if stored.event.turn_id.as_deref() == Some("turn-parent-2") + && notification.child_ref.sub_run_id() == &resumed_child.sub_run_id + && notification.child_ref.lineage_kind + == astrcode_core::ChildSessionLineageKind::Resume + && notification.delivery.as_ref().is_some_and(|delivery| { + delivery.origin == astrcode_core::ParentDeliveryOrigin::Explicit + && delivery.payload.message() == "继续推进后的显式上报" + }) + ))); + assert!( + !parent_events.iter().any(|stored| matches!( + &stored.event.payload, + StorageEventPayload::ChildSessionNotification { notification, .. } + if stored.event.turn_id.as_deref() == Some("turn-parent") + && notification.delivery.as_ref().is_some_and(|delivery| { + delivery.payload.message() == "继续推进后的显式上报" + }) + )), + "resumed child delivery must target the current parent turn instead of the stale spawn \ + turn" + ); + assert!( + parent_events.iter().any(|stored| matches!( + &stored.event.payload, + StorageEventPayload::AgentInputQueued { payload } + if payload.envelope.message == "继续推进后的显式上报" + )), + "explicit upstream send should enqueue the same delivery for parent wake consumption" + ); + assert!( + parent_events.iter().any(|stored| matches!( + &stored.event.payload, + StorageEventPayload::UserMessage { content, origin, .. } + if *origin == astrcode_core::UserMessageOrigin::QueuedInput + && content.contains("继续推进后的显式上报") + )), + "parent wake turn should consume the explicit upstream delivery as queued input" + ); + let metrics = harness.metrics.snapshot(); + assert!( + metrics.execution_diagnostics.parent_reactivation_requested + >= metrics_before + .execution_diagnostics + .parent_reactivation_requested + ); +} + +#[tokio::test] +async fn send_to_parent_rejects_when_direct_parent_is_terminated() { + let harness = build_agent_test_harness(TestLlmBehavior::Succeed { + content: "完成。".to_string(), + }) + .expect("test harness should build"); + let project = tempfile::tempdir().expect("tempdir should be created"); + let parent = harness + .session_runtime + .create_session(project.path().display().to_string()) + .await + .expect("parent session should be created"); + let (child_agent_id, _) = + spawn_direct_child(&harness, &parent.session_id, project.path()).await; + let child_handle = harness + .kernel + .get_handle(&child_agent_id) + .await + .expect("child handle should exist"); + + let _ = harness + .kernel + .agent_control() + .set_lifecycle( + "root-agent", + astrcode_core::AgentLifecycleStatus::Terminated, + ) + .await; + + let child_ctx = ToolContext::new( + child_handle + .child_session_id + .clone() + .expect("child session id should exist"), + project.path().to_path_buf(), + CancelToken::new(), + ) + .with_turn_id("turn-child-report") + .with_agent_context(subrun_event_context(&child_handle)); + + let error = harness + .service + .send( + SendAgentParams::ToParent(SendToParentParams { + payload: ParentDeliveryPayload::Completed(CompletedParentDeliveryPayload { + message: "父级已终止".to_string(), + findings: Vec::new(), + artifacts: Vec::new(), + }), + }), + &child_ctx, + ) + .await + .expect_err("terminated parent should reject upward send"); + assert!(error.to_string().contains("terminated")); + + let parent_events = harness + .session_runtime + .replay_stored_events(&SessionId::from(parent.session_id.clone())) + .await + .expect("parent events should replay"); + assert!(parent_events.iter().any(|stored| matches!( + &stored.event.payload, + StorageEventPayload::AgentCollaborationFact { fact, .. } + if fact.action == AgentCollaborationActionKind::Delivery + && fact.outcome == AgentCollaborationOutcomeKind::Rejected + && fact.reason_code.as_deref() == Some("parent_terminated") + ))); +} + +#[tokio::test] +async fn close_reports_cascade_scope_for_descendants() { + let harness = build_agent_test_harness(TestLlmBehavior::Succeed { + content: "完成。".to_string(), + }) + .expect("test harness should build"); + let project = tempfile::tempdir().expect("tempdir should be created"); + let parent = harness + .session_runtime + .create_session(project.path().display().to_string()) + .await + .expect("parent session should be created"); + let (child_agent_id, parent_session_id) = + spawn_direct_child(&harness, &parent.session_id, project.path()).await; + + let child_handle = harness + .kernel + .agent() + .get_handle(&child_agent_id) + .await + .expect("child handle should exist"); + let child_ctx = ToolContext::new( + child_handle + .child_session_id + .clone() + .expect("child session id should exist"), + project.path().to_path_buf(), + CancelToken::new(), + ) + .with_turn_id("turn-child-1") + .with_agent_context(subrun_event_context(&child_handle)); + let _grandchild = harness + .service + .launch( + SpawnAgentParams { + r#type: Some("reviewer".to_string()), + description: "进一步检查".to_string(), + prompt: "请进一步检查测试覆盖".to_string(), + context: None, + capability_grant: None, + }, + &child_ctx, + ) + .await + .expect("grandchild spawn should succeed"); + + let parent_ctx = ToolContext::new( + parent_session_id.into(), + project.path().to_path_buf(), + CancelToken::new(), + ) + .with_turn_id("turn-parent-close") + .with_agent_context(root_execution_event_context("root-agent", "root-profile")); + + let result = harness + .service + .close( + CloseAgentParams { + agent_id: child_agent_id.into(), + }, + &parent_ctx, + ) + .await + .expect("close should succeed"); + + assert_eq!(result.cascade(), Some(true)); + assert!( + result + .summary() + .is_some_and(|summary| summary.contains("1 个后代")) + ); +} diff --git a/crates/application/src/agent/routing_collaboration_flow.rs b/crates/application/src/agent/routing_collaboration_flow.rs new file mode 100644 index 00000000..b8d77447 --- /dev/null +++ b/crates/application/src/agent/routing_collaboration_flow.rs @@ -0,0 +1,129 @@ +use super::*; + +#[path = "routing/child_send.rs"] +mod child_send; +#[path = "routing/parent_delivery.rs"] +mod parent_delivery; + +impl AgentOrchestrationService { + /// 关闭子 agent 及其整个子树(close 协作工具的业务逻辑)。 + /// + /// 流程: + /// 1. 验证调用者是目标子 agent 的直接父级 + /// 2. 收集子树所有 handle 用于 durable discard + /// 3. 持久化 InputDiscarded 事件,标记待处理消息为已丢弃 + /// 4. 执行 kernel.terminate_subtree() 级联终止 + /// 5. 记录 Close collaboration fact + pub(in crate::agent) async fn close_child( + &self, + params: CloseAgentParams, + ctx: &astrcode_core::ToolContext, + ) -> Result { + let collaboration = self.tool_collaboration_context(ctx).await?; + params + .validate() + .map_err(super::AgentOrchestrationError::from)?; + + let target = self + .require_direct_child_handle( + ¶ms.agent_id, + AgentCollaborationActionKind::Close, + ctx, + &collaboration, + ) + .await?; + + let subtree_handles = self.kernel.collect_subtree_handles(¶ms.agent_id).await; + let mut discard_targets = Vec::with_capacity(subtree_handles.len() + 1); + discard_targets.push(target.clone()); + discard_targets.extend(subtree_handles.iter().cloned()); + + self.append_durable_input_queue_discard_batch(&discard_targets, ctx) + .await?; + + let cancelled = self + .kernel + .terminate_subtree(¶ms.agent_id) + .await + .ok_or_else(|| { + super::AgentOrchestrationError::NotFound(format!( + "agent '{}' terminate failed (not found or already finalized)", + params.agent_id + )) + })?; + + let subtree_count = subtree_handles.len(); + let summary = if subtree_count > 0 { + format!( + "已级联关闭子 Agent {} 及 {} 个后代。", + params.agent_id, subtree_count + ) + } else { + format!("已关闭子 Agent {}。", params.agent_id) + }; + self.record_fact_best_effort( + collaboration.runtime(), + collaboration + .fact( + AgentCollaborationActionKind::Close, + AgentCollaborationOutcomeKind::Closed, + ) + .child(&target) + .summary(summary.clone()), + ) + .await; + + Ok(CollaborationResult::Closed { + summary: Some(summary), + cascade: true, + closed_root_agent_id: cancelled.agent_id.clone(), + }) + } + + /// 从 SubRunHandle 构造 ChildAgentRef。 + pub(in crate::agent) async fn build_child_ref_from_handle( + &self, + handle: &SubRunHandle, + ) -> ChildAgentRef { + handle.child_ref() + } + + /// 用 live 控制面的最新 lifecycle 投影更新 ChildAgentRef。 + pub(in crate::agent) async fn project_child_ref_status( + &self, + mut child_ref: ChildAgentRef, + ) -> ChildAgentRef { + let lifecycle = self.kernel.get_lifecycle(child_ref.agent_id()).await; + let last_turn_outcome = self.kernel.get_turn_outcome(child_ref.agent_id()).await; + if let Some(lifecycle) = lifecycle { + child_ref.status = + project_collaboration_lifecycle(lifecycle, last_turn_outcome, child_ref.status); + } + child_ref + } +} + +pub(super) fn parent_delivery_label(payload: &ParentDeliveryPayload) -> &'static str { + parent_delivery::parent_delivery_label(payload) +} + +/// 将 live 控制面的 lifecycle + outcome 投影回 `ChildAgentRef` 的 lifecycle。 +/// +/// `Idle` + `None` outcome 的含义是:agent 已空闲但还没有完成过一轮 turn, +/// 此时保留调用方传入的 fallback 状态(通常是 handle 上的旧 lifecycle)。 +/// 这避免了把刚 spawn 还没执行过 turn 的 agent 误标为 Idle。 +fn project_collaboration_lifecycle( + lifecycle: AgentLifecycleStatus, + last_turn_outcome: Option, + fallback: AgentLifecycleStatus, +) -> AgentLifecycleStatus { + match lifecycle { + AgentLifecycleStatus::Pending => AgentLifecycleStatus::Pending, + AgentLifecycleStatus::Running => AgentLifecycleStatus::Running, + AgentLifecycleStatus::Idle => match last_turn_outcome { + Some(_) => AgentLifecycleStatus::Idle, + None => fallback, + }, + AgentLifecycleStatus::Terminated => AgentLifecycleStatus::Terminated, + } +} diff --git a/crates/application/src/agent/terminal.rs b/crates/application/src/agent/terminal.rs index e176189d..b3ceb4d1 100644 --- a/crates/application/src/agent/terminal.rs +++ b/crates/application/src/agent/terminal.rs @@ -1,17 +1,23 @@ +//! Child turn 终态投递与父侧通知投影。 +//! +//! 当子代理 turn 结束时,将终态结果(completed/failed/close_request)投影为 +//! `ChildSessionNotification`,通过 wake 机制投递到父侧 input queue 驱动父级决策。 + use std::time::Instant; use astrcode_core::{ AgentCollaborationActionKind, AgentCollaborationOutcomeKind, AgentLifecycleStatus, - AgentTurnOutcome, ChildAgentRef, ChildSessionNotification, ChildSessionNotificationKind, - CloseRequestParentDeliveryPayload, CompletedParentDeliveryPayload, FailedParentDeliveryPayload, - ParentDelivery, ParentDeliveryOrigin, ParentDeliveryPayload, ParentDeliveryTerminalSemantics, - ProgressParentDeliveryPayload, StorageEventPayload, SubRunFailure, SubRunFailureCode, - SubRunHandoff, SubRunResult, + AgentTurnOutcome, ChildSessionNotification, ChildSessionNotificationKind, + CloseRequestParentDeliveryPayload, CompletedParentDeliveryPayload, CompletedSubRunOutcome, + FailedParentDeliveryPayload, FailedSubRunOutcome, ParentDelivery, ParentDeliveryOrigin, + ParentDeliveryPayload, ParentDeliveryTerminalSemantics, ProgressParentDeliveryPayload, + StorageEventPayload, SubRunFailure, SubRunFailureCode, SubRunHandoff, SubRunResult, + SubRunStatus, }; use super::{ AgentOrchestrationError, AgentOrchestrationService, child_collaboration_artifacts, - child_open_session_id, subrun_event_context_for_parent_turn, terminal_notification_message, + subrun_event_context_for_parent_turn, terminal_notification_message, }; /// child turn 终态投递到父侧的内部投影层。 @@ -29,6 +35,7 @@ struct ChildTerminalDeliveryProjection { /// 注意:这里显式携带 parent routing truth。 /// `ChildAgentRef` 只用于 stable child reference / projection, /// 禁止再从 `child_ref.session_id` 反推父侧 notification 的落点。 +/// 所有 notification 必须通过显式传入的 `parent_session_id` + `parent_turn_id` 路由。 pub(super) struct ChildTurnTerminalContext { child: astrcode_core::SubRunHandle, execution_session_id: String, @@ -61,6 +68,10 @@ impl ChildTurnTerminalContext { } impl AgentOrchestrationService { + /// 启动后台 watcher 等待 child turn 结束后执行终态收口。 + /// + /// 注册到 task_registry 以便在 shutdown 时统一 abort。 + /// watcher 完成后执行:终态映射 → fallback delivery → 父级 reactivation。 pub(super) fn spawn_child_turn_terminal_watcher( &self, child: astrcode_core::SubRunHandle, @@ -101,6 +112,15 @@ impl AgentOrchestrationService { self.finalize_child_turn_with_outcome(watch, outcome).await } + /// Child turn 终态收口主流程。 + /// + /// 1. 将 turn outcome 映射为 `SubRunResult`(TokenExceeded 视为完成而非失败) + /// 2. 原子更新 live tree 的 lifecycle 和 turn outcome + /// 3. 记录子代理执行指标 + /// 4. 检查是否已有显式 terminal delivery(如 send_to_parent 产生的), 如果有则跳过 fallback + /// delivery + /// 5. 投影出 fallback `ChildSessionNotification` 并追加到父 session + /// 6. 触发父级 reactivation(wake) pub(super) async fn finalize_child_turn_with_outcome( &self, watch: ChildTurnTerminalContext, @@ -121,7 +141,7 @@ impl AgentOrchestrationService { .await; self.metrics.record_subrun_execution( watch.started_at.elapsed().as_millis() as u64, - to_subrun_execution_outcome(outcome.outcome), + outcome.outcome, None, None, Some(watch.child.storage_mode), @@ -139,29 +159,17 @@ impl AgentOrchestrationService { } let fallback_notification_id = child_terminal_notification_id( - &watch.child.sub_run_id, + watch.child.sub_run_id.as_str(), &watch.execution_turn_id, - result.lifecycle, - result.last_turn_outcome, + result.status(), ); let delivery = project_child_terminal_delivery(&result, &fallback_notification_id); let notification_id = delivery.delivery.idempotency_key.clone(); let notification = ChildSessionNotification { - notification_id: notification_id.clone(), - child_ref: ChildAgentRef { - agent_id: watch.child.agent_id.clone(), - // 这里继续保留现有 ChildAgentRef 读侧语义,不把它作为父侧路由真相使用。 - session_id: watch.child.session_id.clone(), - sub_run_id: watch.child.sub_run_id.clone(), - parent_agent_id: watch.child.parent_agent_id.clone(), - parent_sub_run_id: watch.child.parent_sub_run_id.clone(), - lineage_kind: watch.child.lineage_kind, - status: delivery.status, - open_session_id: child_open_session_id(&watch.child), - }, + notification_id: notification_id.clone().into(), + child_ref: watch.child.child_ref_with_status(delivery.status), kind: delivery.kind, - status: delivery.status, - source_tool_call_id: watch.source_tool_call_id, + source_tool_call_id: watch.source_tool_call_id.map(Into::into), delivery: Some(delivery.delivery), }; @@ -184,11 +192,16 @@ impl AgentOrchestrationService { &watch.parent_session_id, &watch.parent_turn_id, ) - .parent_agent_id(watch.child.parent_agent_id.clone()) + .parent_agent_id(watch.child.parent_agent_id.clone().map(|id| id.to_string())) .child(&watch.child) .delivery_id(notification.notification_id.clone()) .summary(terminal_notification_message(¬ification)) - .source_tool_call_id(notification.source_tool_call_id.clone()), + .source_tool_call_id( + notification + .source_tool_call_id + .clone() + .map(|id| id.to_string()), + ), ) .await; self.reactivate_parent_agent_if_idle( @@ -234,7 +247,7 @@ impl AgentOrchestrationService { Ok(stored.iter().any(|stored| match &stored.event.payload { StorageEventPayload::ChildSessionNotification { notification, .. } => { notification.delivery.as_ref().is_some_and(|delivery| { - notification.child_ref.agent_id == watch.child.agent_id + notification.child_ref.agent_id() == &watch.child.agent_id && delivery.origin == ParentDeliveryOrigin::Explicit && delivery.terminal_semantics == ParentDeliveryTerminalSemantics::Terminal && delivery.source_turn_id.as_deref() @@ -258,18 +271,25 @@ fn build_child_subrun_result( outcome: &astrcode_session_runtime::ProjectedTurnOutcome, ) -> SubRunResult { match outcome.outcome { - AgentTurnOutcome::Completed | AgentTurnOutcome::TokenExceeded => SubRunResult { - lifecycle: AgentLifecycleStatus::Idle, - last_turn_outcome: Some(outcome.outcome), - handoff: Some(SubRunHandoff { + AgentTurnOutcome::Completed | AgentTurnOutcome::TokenExceeded => SubRunResult::Completed { + outcome: match outcome.outcome { + AgentTurnOutcome::Completed => CompletedSubRunOutcome::Completed, + AgentTurnOutcome::TokenExceeded => CompletedSubRunOutcome::TokenExceeded, + AgentTurnOutcome::Failed | AgentTurnOutcome::Cancelled => unreachable!(), + }, + handoff: SubRunHandoff { findings: Vec::new(), artifacts: child_handoff_artifacts(child, parent_session_id), delivery: Some(ParentDelivery { idempotency_key: child_terminal_notification_id( - &child.sub_run_id, + child.sub_run_id.as_str(), source_turn_id, - AgentLifecycleStatus::Idle, - Some(outcome.outcome), + match outcome.outcome { + AgentTurnOutcome::Completed => SubRunStatus::Completed, + AgentTurnOutcome::TokenExceeded => SubRunStatus::TokenExceeded, + AgentTurnOutcome::Failed => SubRunStatus::Failed, + AgentTurnOutcome::Cancelled => SubRunStatus::Cancelled, + }, ), origin: ParentDeliveryOrigin::Fallback, terminal_semantics: ParentDeliveryTerminalSemantics::Terminal, @@ -280,14 +300,15 @@ fn build_child_subrun_result( artifacts: child_handoff_artifacts(child, parent_session_id), }), }), - }), - failure: None, + }, }, - AgentTurnOutcome::Failed | AgentTurnOutcome::Cancelled => SubRunResult { - lifecycle: AgentLifecycleStatus::Idle, - last_turn_outcome: Some(outcome.outcome), - handoff: None, - failure: Some(SubRunFailure { + AgentTurnOutcome::Failed | AgentTurnOutcome::Cancelled => SubRunResult::Failed { + outcome: match outcome.outcome { + AgentTurnOutcome::Failed => FailedSubRunOutcome::Failed, + AgentTurnOutcome::Cancelled => FailedSubRunOutcome::Cancelled, + AgentTurnOutcome::Completed | AgentTurnOutcome::TokenExceeded => unreachable!(), + }, + failure: SubRunFailure { code: match outcome.outcome { AgentTurnOutcome::Cancelled => SubRunFailureCode::Interrupted, AgentTurnOutcome::Failed => SubRunFailureCode::Internal, @@ -297,7 +318,7 @@ fn build_child_subrun_result( display_message: outcome.summary.clone(), technical_message: outcome.technical_message.clone(), retryable: !matches!(outcome.outcome, AgentTurnOutcome::Cancelled), - }), + }, }, } } @@ -309,51 +330,48 @@ fn child_handoff_artifacts( child_collaboration_artifacts(child, parent_session_id, true) } -fn child_terminal_notification_id( - sub_run_id: &str, - turn_id: &str, - lifecycle: AgentLifecycleStatus, - outcome: Option, -) -> String { - format!( - "child-terminal:{sub_run_id}:{turn_id}:{}", - status_label(lifecycle, outcome) - ) +fn child_terminal_notification_id(sub_run_id: &str, turn_id: &str, status: SubRunStatus) -> String { + format!("child-terminal:{sub_run_id}:{turn_id}:{}", status.label()) } /// 从 `SubRunResult` 投影出 `ChildTerminalDeliveryProjection`。 +/// +/// 优先使用 result handoff 中携带的显式 delivery(由 send_to_parent 产生), +/// 如果没有则构造 fallback delivery。Fallback 会根据终态类型生成 +/// 对应的 payload(Completed / Failed / CloseRequest)。 fn project_child_terminal_delivery( result: &SubRunResult, fallback_notification_id: &str, ) -> ChildTerminalDeliveryProjection { - let (kind, status) = match result.last_turn_outcome { - Some(AgentTurnOutcome::Completed | AgentTurnOutcome::TokenExceeded) => ( + let status_projection = result.status(); + let last_turn_outcome = status_projection.last_turn_outcome(); + let (kind, status) = match status_projection { + SubRunStatus::Completed | SubRunStatus::TokenExceeded => ( ChildSessionNotificationKind::Delivered, AgentLifecycleStatus::Idle, ), - Some(AgentTurnOutcome::Failed) => ( + SubRunStatus::Failed => ( ChildSessionNotificationKind::Failed, AgentLifecycleStatus::Idle, ), - Some(AgentTurnOutcome::Cancelled) => ( + SubRunStatus::Cancelled => ( ChildSessionNotificationKind::Closed, AgentLifecycleStatus::Idle, ), - None => ( + SubRunStatus::Running => ( ChildSessionNotificationKind::ProgressSummary, - result.lifecycle, + status_projection.lifecycle(), ), }; let delivery = result - .handoff - .as_ref() + .handoff() .and_then(|handoff| handoff.delivery.as_ref()) .cloned() .unwrap_or_else(|| ParentDelivery { idempotency_key: fallback_notification_id.to_string(), origin: ParentDeliveryOrigin::Fallback, - terminal_semantics: match result.last_turn_outcome { + terminal_semantics: match last_turn_outcome { Some(AgentTurnOutcome::Completed) | Some(AgentTurnOutcome::TokenExceeded) | Some(AgentTurnOutcome::Failed) @@ -361,16 +379,15 @@ fn project_child_terminal_delivery( None => ParentDeliveryTerminalSemantics::NonTerminal, }, source_turn_id: None, - payload: match result.last_turn_outcome { + payload: match last_turn_outcome { Some(AgentTurnOutcome::Completed | AgentTurnOutcome::TokenExceeded) => { let message = result - .handoff - .as_ref() + .handoff() .and_then(|handoff| handoff.delivery.as_ref()) .map(|delivery| delivery.payload.message().trim()) .filter(|message| !message.is_empty()) .map(ToString::to_string) - .unwrap_or_else(|| match result.last_turn_outcome { + .unwrap_or_else(|| match last_turn_outcome { Some(AgentTurnOutcome::Completed) => { "子 Agent 已完成,但没有返回可读总结。".to_string() }, @@ -384,19 +401,17 @@ fn project_child_terminal_delivery( ParentDeliveryPayload::Completed(CompletedParentDeliveryPayload { message, findings: result - .handoff - .as_ref() + .handoff() .map(|handoff| handoff.findings.clone()) .unwrap_or_default(), artifacts: result - .handoff - .as_ref() + .handoff() .map(|handoff| handoff.artifacts.clone()) .unwrap_or_default(), }) }, Some(AgentTurnOutcome::Failed) => { - let failure = result.failure.as_ref(); + let failure = result.failure(); let message = failure .map(|failure| failure.display_message.trim()) .filter(|message| !message.is_empty()) @@ -430,40 +445,14 @@ fn project_child_terminal_delivery( } } -fn to_subrun_execution_outcome(outcome: AgentTurnOutcome) -> astrcode_core::SubRunExecutionOutcome { - match outcome { - AgentTurnOutcome::Completed => astrcode_core::SubRunExecutionOutcome::Completed, - AgentTurnOutcome::Failed => astrcode_core::SubRunExecutionOutcome::Failed, - AgentTurnOutcome::Cancelled => astrcode_core::SubRunExecutionOutcome::Aborted, - AgentTurnOutcome::TokenExceeded => astrcode_core::SubRunExecutionOutcome::TokenExceeded, - } -} - -fn status_label( - lifecycle: AgentLifecycleStatus, - outcome: Option, -) -> &'static str { - match outcome { - Some(AgentTurnOutcome::Completed) => "completed", - Some(AgentTurnOutcome::Cancelled) => "cancelled", - Some(AgentTurnOutcome::Failed) => "failed", - Some(AgentTurnOutcome::TokenExceeded) => "token_exceeded", - None => match lifecycle { - AgentLifecycleStatus::Pending => "pending", - AgentLifecycleStatus::Running => "running", - AgentLifecycleStatus::Idle => "idle", - AgentLifecycleStatus::Terminated => "terminated", - }, - } -} - #[cfg(test)] mod tests { use std::time::{Duration, Instant}; use astrcode_core::{ - AgentEventContext, AgentLifecycleStatus, ChildSessionNotificationKind, Phase, SessionId, - StorageEvent, StorageEventPayload, SubRunStorageMode, + AgentEventContext, AgentLifecycleStatus, ChildAgentRef, ChildExecutionIdentity, + ChildSessionNotificationKind, ParentExecutionRef, Phase, SessionId, StorageEvent, + StorageEventPayload, SubRunStorageMode, }; use astrcode_session_runtime::{append_and_broadcast, complete_session_execution}; @@ -541,7 +530,7 @@ mod tests { parent.session_id.clone(), Some(child.session_id.clone()), "turn-parent".to_string(), - Some(root.agent_id.clone()), + Some(root.agent_id.to_string()), SubRunStorageMode::IndependentSession, ) .await @@ -617,19 +606,19 @@ mod tests { assert!( parent_events.iter().any(|stored| matches!( &stored.event.payload, - StorageEventPayload::AgentMailboxQueued { payload } + StorageEventPayload::AgentInputQueued { payload } if payload.envelope.message == "子 Agent 总结" )), - "durable mailbox message should reuse child final excerpt" + "durable input queue message should reuse child final excerpt" ); assert!( parent_events.iter().any(|stored| matches!( &stored.event.payload, - StorageEventPayload::UserMessage { content, .. } - if content.contains("delivery_id: child-terminal:") - && content.contains("message: 子 Agent 总结") + StorageEventPayload::UserMessage { content, origin, .. } + if *origin == astrcode_core::UserMessageOrigin::QueuedInput + && content.contains("子 Agent 总结") )), - "wake prompt should consume the same delivery summary" + "wake turn should consume the same delivery summary as queued input" ); let metrics = harness.metrics.snapshot(); assert_eq!( @@ -681,7 +670,7 @@ mod tests { parent.session_id.clone(), Some(child.session_id.clone()), "turn-parent".to_string(), - Some(root.agent_id.clone()), + Some(root.agent_id.to_string()), SubRunStorageMode::IndependentSession, ) .await @@ -751,15 +740,13 @@ mod tests { artifacts: Vec::new(), }), }; - let result = SubRunResult { - lifecycle: AgentLifecycleStatus::Idle, - last_turn_outcome: Some(AgentTurnOutcome::Completed), - handoff: Some(SubRunHandoff { + let result = SubRunResult::Completed { + outcome: CompletedSubRunOutcome::Completed, + handoff: SubRunHandoff { findings: vec!["finding-1".to_string()], artifacts: Vec::new(), delivery: Some(explicit_delivery.clone()), - }), - failure: None, + }, }; let projection = project_child_terminal_delivery( @@ -812,7 +799,7 @@ mod tests { parent.session_id.clone(), Some(child.session_id.clone()), "turn-parent".to_string(), - Some(root.agent_id.clone()), + Some(root.agent_id.to_string()), SubRunStorageMode::IndependentSession, ) .await @@ -825,20 +812,25 @@ mod tests { &parent.session_id, "turn-parent", &ChildSessionNotification { - notification_id: "child-terminal:subrun-test:turn-child:completed".to_string(), + notification_id: "child-terminal:subrun-test:turn-child:completed" + .to_string() + .into(), child_ref: ChildAgentRef { - agent_id: child_handle.agent_id.clone(), - // 故意写错:验证内部不再从 child_ref.session_id 反推父侧路由。 - session_id: wrong_parent.session_id.clone(), - sub_run_id: child_handle.sub_run_id.clone(), - parent_agent_id: child_handle.parent_agent_id.clone(), - parent_sub_run_id: child_handle.parent_sub_run_id.clone(), + identity: ChildExecutionIdentity { + agent_id: child_handle.agent_id.clone(), + // 故意写错:验证内部不再从 child_ref.session_id 反推父侧路由。 + session_id: wrong_parent.session_id.clone().into(), + sub_run_id: child_handle.sub_run_id.clone(), + }, + parent: ParentExecutionRef { + parent_agent_id: child_handle.parent_agent_id.clone(), + parent_sub_run_id: child_handle.parent_sub_run_id.clone(), + }, lineage_kind: ChildSessionLineageKind::Spawn, status: AgentLifecycleStatus::Idle, - open_session_id: child.session_id.clone(), + open_session_id: child.session_id.clone().into(), }, kind: ChildSessionNotificationKind::Delivered, - status: AgentLifecycleStatus::Idle, source_tool_call_id: None, delivery: Some(ParentDelivery { idempotency_key: "child-terminal:subrun-test:turn-child:completed" @@ -926,7 +918,7 @@ mod tests { root_session.session_id.clone(), Some(middle_session.session_id.clone()), "turn-root".to_string(), - Some(root.agent_id.clone()), + Some(root.agent_id.to_string()), SubRunStorageMode::IndependentSession, ) .await @@ -939,7 +931,7 @@ mod tests { middle_session.session_id.clone(), Some(leaf_session.session_id.clone()), "turn-middle".to_string(), - Some(middle.agent_id.clone()), + Some(middle.agent_id.to_string()), SubRunStorageMode::IndependentSession, ) .await @@ -993,7 +985,7 @@ mod tests { root_events.iter().any(|stored| matches!( &stored.event.payload, StorageEventPayload::ChildSessionNotification { notification, .. } - if notification.child_ref.agent_id == middle.agent_id + if notification.child_ref.agent_id() == &middle.agent_id && notification.kind == ChildSessionNotificationKind::Delivered )), "middle should publish its own terminal delivery even when a descendant is still \ diff --git a/crates/application/src/agent/test_support.rs b/crates/application/src/agent/test_support.rs index 19323d54..a83d2af4 100644 --- a/crates/application/src/agent/test_support.rs +++ b/crates/application/src/agent/test_support.rs @@ -1,3 +1,8 @@ +//! Agent 编排子域的测试基础设施。 +//! +//! 提供 `AgentTestHarness` 和 `AgentTestEnvGuard`,用于在隔离环境中测试 +//! `AgentOrchestrationService` 的协作编排逻辑,无需启动真实 session-runtime。 + use std::{ collections::HashMap, path::Path, @@ -21,8 +26,8 @@ use serde_json::Value; use crate::{ AgentKernelPort, AgentOrchestrationService, AgentSessionPort, ApplicationError, ConfigService, - ProfileResolutionService, RuntimeObservabilityCollector, execution::ProfileProvider, - lifecycle::TaskRegistry, + GovernanceSurfaceAssembler, ProfileResolutionService, RuntimeObservabilityCollector, + execution::ProfileProvider, lifecycle::TaskRegistry, }; pub(crate) struct AgentTestHarness { @@ -83,6 +88,7 @@ pub(crate) fn build_agent_test_harness_with_agent_config( session_port, config_service.clone(), profiles.clone(), + Arc::new(GovernanceSurfaceAssembler::default()), task_registry, metrics.clone(), ); @@ -113,6 +119,7 @@ pub(crate) fn sample_profile(id: &str) -> AgentProfile { } pub(crate) struct AgentTestEnvGuard { + _lock: std::sync::MutexGuard<'static, ()>, _temp_home: tempfile::TempDir, previous_test_home: Option, } @@ -164,6 +171,9 @@ impl ConfigStore for TestConfigStore { impl AgentTestEnvGuard { fn new() -> Self { + let lock = astrcode_core::test_support::env_lock() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); let temp_home = tempfile::tempdir().expect("temp home should be created"); let previous_test_home = std::env::var_os(astrcode_core::home::ASTRCODE_TEST_HOME_ENV); std::env::set_var( @@ -171,6 +181,7 @@ impl AgentTestEnvGuard { temp_home.path(), ); Self { + _lock: lock, _temp_home: temp_home, previous_test_home, } @@ -276,6 +287,8 @@ impl PromptProvider for TestPromptProvider { Ok(PromptBuildOutput { system_prompt: "test".to_string(), system_prompt_blocks: Vec::new(), + prompt_cache_hints: Default::default(), + cache_metrics: Default::default(), metadata: Value::Null, }) } diff --git a/crates/application/src/agent/wake.rs b/crates/application/src/agent/wake.rs index 62da1631..e296a042 100644 --- a/crates/application/src/agent/wake.rs +++ b/crates/application/src/agent/wake.rs @@ -1,22 +1,30 @@ //! 父级 delivery 唤醒调度。 //! //! wake 是跨会话协作编排,不属于 session-runtime 的单会话真相面。 -//! 这里负责把 child terminal delivery 追加到 durable mailbox、排入 kernel queue, +//! 这里负责把 child terminal delivery 追加到 durable input queue、排入 kernel queue, //! 再通过“不分叉”的父级 wake turn 继续驱动父 agent。 use astrcode_core::{ AgentCollaborationActionKind, AgentCollaborationOutcomeKind, AgentEventContext, - MailboxBatchAckedPayload, MailboxBatchStartedPayload, MailboxQueuedPayload, - StorageEventPayload, TurnId, + InputBatchAckedPayload, InputBatchStartedPayload, InputQueuedPayload, StorageEventPayload, + TurnId, }; use super::{ AgentOrchestrationError, AgentOrchestrationService, CollaborationFactRecord, - child_delivery_mailbox_envelope, root_execution_event_context, subrun_event_context, + child_delivery_input_queue_envelope, root_execution_event_context, subrun_event_context, terminal_notification_message, }; +use crate::AppAgentPromptSubmission; + +const MAX_AUTOMATIC_INPUT_FOLLOW_UPS: u8 = 8; impl AgentOrchestrationService { + /// 父级 delivery 唤醒调度入口。 + /// + /// 当子 agent turn 终态或显式上行 send 产生 delivery 时调用, + /// 执行三步:持久化 durable input queue → 排入 kernel delivery buffer → 尝试启动父级 wake + /// turn。 任何一步失败都会记录指标但不会传播错误(best-effort)。 pub async fn reactivate_parent_agent_if_idle( &self, parent_session_id: &str, @@ -27,14 +35,14 @@ impl AgentOrchestrationService { let parent_session_id = astrcode_session_runtime::normalize_session_id(parent_session_id); if let Err(error) = self - .append_parent_delivery_mailbox_queue(&parent_session_id, parent_turn_id, notification) + .append_parent_delivery_input_queue(&parent_session_id, parent_turn_id, notification) .await { log::warn!( - "failed to persist durable parent mailbox queue before wake: parentSession='{}', \ + "failed to persist durable parent input queue before wake: parentSession='{}', \ childAgent='{}', deliveryId='{}', error='{}'", parent_session_id, - notification.child_ref.agent_id, + notification.child_ref.agent_id(), notification.notification_id, error ); @@ -62,7 +70,7 @@ impl AgentOrchestrationService { } if let Err(error) = self - .try_start_parent_delivery_turn(&parent_session_id) + .try_start_parent_delivery_turn(&parent_session_id, MAX_AUTOMATIC_INPUT_FOLLOW_UPS) .await { self.metrics.record_parent_reactivation_failed(); @@ -70,16 +78,27 @@ impl AgentOrchestrationService { "failed to schedule parent wake turn from child delivery: parentSession='{}', \ childAgent='{}', subRunId='{}', error='{}'", parent_session_id, - notification.child_ref.agent_id, - notification.child_ref.sub_run_id, + notification.child_ref.agent_id(), + notification.child_ref.sub_run_id(), error ); } } + /// 尝试为父级 session 启动一个 delivery 消费 turn。 + /// + /// 流程: + /// 1. reconcile:从 durable 存储恢复可能丢失的 delivery + /// 2. checkout:从 kernel buffer 批量取出待消费 delivery + /// 3. 提交 queued_inputs prompt(而非普通用户 prompt) + /// 4. 如果提交失败或 session 忙,requeue 让后续重试 + /// 5. 成功后启动 wake completion watcher 等待终态 + /// + /// `remaining_follow_ups` 控制自动 follow-up 深度,防止无限递归消费。 pub async fn try_start_parent_delivery_turn( &self, parent_session_id: &str, + remaining_follow_ups: u8, ) -> Result { let parent_session_id = astrcode_session_runtime::normalize_session_id(parent_session_id); self.reconcile_parent_delivery_queue(&parent_session_id) @@ -100,7 +119,7 @@ impl AgentOrchestrationService { .collect::>(); let target_agent_id = delivery_batch .first() - .and_then(|delivery| delivery.notification.child_ref.parent_agent_id.clone()) + .and_then(|delivery| delivery.notification.child_ref.parent_agent_id().cloned()) .ok_or_else(|| { AgentOrchestrationError::InvalidInput( "parent delivery batch missing target parent agent id".to_string(), @@ -108,16 +127,16 @@ impl AgentOrchestrationService { })?; let wake_agent = self.resolve_wake_agent_context(&delivery_batch).await; let wake_turn_id = TurnId::from(format!("turn-{}", chrono::Utc::now().timestamp_millis())); - let wake_prompt = build_wake_prompt_from_deliveries(&delivery_batch); + let queued_inputs = queued_inputs_from_deliveries(&delivery_batch); let accepted = match self .session_runtime - .try_submit_prompt_for_agent_with_turn_id( + .submit_queued_inputs_for_agent_with_turn_id( &parent_session_id, wake_turn_id.clone(), - wake_prompt, + queued_inputs, self.resolve_runtime_config_for_session(&parent_session_id) .await?, - astrcode_session_runtime::AgentPromptSubmission { + AppAgentPromptSubmission { agent: wake_agent.clone(), ..Default::default() }, @@ -152,8 +171,8 @@ impl AgentOrchestrationService { .await { log::warn!( - "failed to persist parent mailbox batch start: parentSession='{}', turnId='{}', \ - error='{}'", + "failed to persist parent input queue batch start: parentSession='{}', \ + turnId='{}', error='{}'", parent_session_id, wake_turn_id, error @@ -164,7 +183,8 @@ impl AgentOrchestrationService { parent_session_id, accepted.turn_id.to_string(), delivery_batch, - target_agent_id, + target_agent_id.to_string(), + remaining_follow_ups, ); Ok(true) } @@ -175,6 +195,7 @@ impl AgentOrchestrationService { turn_id: String, batch_deliveries: Vec, target_agent_id: String, + remaining_follow_ups: u8, ) { let service = self.clone(); let handle = tokio::spawn(async move { @@ -184,6 +205,7 @@ impl AgentOrchestrationService { turn_id, batch_deliveries, target_agent_id, + remaining_follow_ups, ) .await { @@ -195,12 +217,22 @@ impl AgentOrchestrationService { self.task_registry.register_turn_task(handle); } + /// 父级 wake turn 完成后的收口处理。 + /// + /// 判断 wake turn 是否成功(Idle + TurnDone + 无 Error): + /// - 成功:消费 delivery batch、记录 Consumed fact、 如果还有剩余 delivery 则自动触发下一轮 + /// follow-up + /// - 失败:requeue delivery batch 让后续重试 + /// + /// 关键设计:wake turn 不会向更上一级制造新的 terminal delivery, + /// 避免"协作协调 turn"被误当成新的 child work turn 而形成自激膨胀。 async fn finalize_parent_wake_turn( &self, parent_session_id: String, turn_id: String, batch_deliveries: Vec, target_agent_id: String, + remaining_follow_ups: u8, ) -> Result<(), AgentOrchestrationError> { let batch_delivery_ids = batch_deliveries .iter() @@ -220,7 +252,7 @@ impl AgentOrchestrationService { }); // 为什么 wake turn 不再自动向更上一级制造 terminal delivery: // Claude Code 的稳定点是“worker 每轮进入 idle,但 idle 通知只是状态转换,不代表 - // 又生成了一项新的上游任务”。这里保持同样边界:wake 只负责消费当前 mailbox batch, + // 又生成了一项新的上游任务”。这里保持同样边界:wake 只负责消费当前 input queue batch, // 避免把协作协调 turn 误当成新的 child work turn,从而形成自激膨胀。 if wake_succeeded { @@ -243,7 +275,7 @@ impl AgentOrchestrationService { for delivery in &batch_deliveries { if let Some(child_handle) = self .kernel - .get_handle(&delivery.notification.child_ref.agent_id) + .get_handle(delivery.notification.child_ref.agent_id()) .await { self.record_fact_best_effort( @@ -255,7 +287,12 @@ impl AgentOrchestrationService { &turn_id, ) .parent_agent_id( - delivery.notification.child_ref.parent_agent_id.clone(), + delivery + .notification + .child_ref + .parent_agent_id() + .cloned() + .map(|id| id.to_string()), ) .child(&child_handle) .delivery_id(delivery.delivery_id.clone()) @@ -264,16 +301,42 @@ impl AgentOrchestrationService { (chrono::Utc::now().timestamp_millis() - delivery.queued_at_ms) .max(0) as u64, ) - .source_tool_call_id(delivery.notification.source_tool_call_id.clone()), + .source_tool_call_id( + delivery + .notification + .source_tool_call_id + .clone() + .map(|id| id.to_string()), + ), ) .await; } } self.metrics.record_parent_reactivation_succeeded(); self.metrics.record_delivery_buffer_wake_succeeded(); - let _ = self - .try_start_parent_delivery_turn(&parent_session_id) - .await?; + if remaining_follow_ups > 1 { + let _ = self + .try_start_parent_delivery_turn( + &parent_session_id, + remaining_follow_ups.saturating_sub(1), + ) + .await?; + } else { + let remaining_queued_inputs = self + .kernel + .pending_parent_delivery_count(&parent_session_id) + .await; + if remaining_queued_inputs == 0 { + return Ok(()); + } + log::warn!( + "automatic parent input follow-up limit reached: parentSession='{}', \ + remainingQueuedInputs='{}', maxAutomaticFollowUps='{}'", + parent_session_id, + remaining_queued_inputs, + MAX_AUTOMATIC_INPUT_FOLLOW_UPS + ); + } return Ok(()); } @@ -293,7 +356,7 @@ impl AgentOrchestrationService { Ok(()) } - async fn append_parent_delivery_mailbox_queue( + async fn append_parent_delivery_input_queue( &self, parent_session_id: &str, parent_turn_id: &str, @@ -301,8 +364,8 @@ impl AgentOrchestrationService { ) -> Result<(), AgentOrchestrationError> { let target_agent_id = notification .child_ref - .parent_agent_id - .clone() + .parent_agent_id() + .cloned() .ok_or_else(|| { AgentOrchestrationError::InvalidInput( "child terminal delivery missing direct parent agent id".to_string(), @@ -310,12 +373,15 @@ impl AgentOrchestrationService { })?; self.session_runtime - .append_agent_mailbox_queued( + .append_agent_input_queued( parent_session_id, parent_turn_id, AgentEventContext::default(), - MailboxQueuedPayload { - envelope: child_delivery_mailbox_envelope(notification, target_agent_id), + InputQueuedPayload { + envelope: child_delivery_input_queue_envelope( + notification, + target_agent_id.to_string(), + ), }, ) .await @@ -332,15 +398,15 @@ impl AgentOrchestrationService { event_agent: &AgentEventContext, ) -> Result<(), AgentOrchestrationError> { self.session_runtime - .append_agent_mailbox_batch_started( + .append_agent_input_batch_started( parent_session_id, turn_id, event_agent.clone(), - MailboxBatchStartedPayload { + InputBatchStartedPayload { target_agent_id: target_agent_id.to_string(), turn_id: turn_id.to_string(), batch_id: parent_wake_batch_id(turn_id), - delivery_ids: batch_delivery_ids.to_vec(), + delivery_ids: batch_delivery_ids.iter().cloned().map(Into::into).collect(), }, ) .await @@ -356,15 +422,15 @@ impl AgentOrchestrationService { batch_delivery_ids: &[String], ) -> Result<(), AgentOrchestrationError> { self.session_runtime - .append_agent_mailbox_batch_acked( + .append_agent_input_batch_acked( parent_session_id, turn_id, AgentEventContext::default(), - MailboxBatchAckedPayload { + InputBatchAckedPayload { target_agent_id: target_agent_id.to_string(), turn_id: turn_id.to_string(), batch_id: parent_wake_batch_id(turn_id), - delivery_ids: batch_delivery_ids.to_vec(), + delivery_ids: batch_delivery_ids.iter().cloned().map(Into::into).collect(), }, ) .await @@ -372,6 +438,12 @@ impl AgentOrchestrationService { Ok(()) } + /// 从 durable 存储恢复可能丢失的 parent delivery。 + /// + /// 场景:进程崩溃后重启,kernel buffer 中的内存状态已丢失, + /// 但 durable input queue 中仍有未消费的 queued 事件。 + /// 此函数通过 `recoverable_parent_deliveries` 从存储事件中 + /// 重建 delivery 并重新排入 kernel buffer。 async fn reconcile_parent_delivery_queue( &self, parent_session_id: &str, @@ -392,7 +464,7 @@ impl AgentOrchestrationService { .unwrap_or_default(); if let Some(child_handle) = self .kernel - .get_handle(&pending.notification.child_ref.agent_id) + .get_handle(pending.notification.child_ref.agent_id()) .await { self.record_fact_best_effort( @@ -403,12 +475,25 @@ impl AgentOrchestrationService { parent_session_id, &pending.parent_turn_id, ) - .parent_agent_id(pending.notification.child_ref.parent_agent_id.clone()) + .parent_agent_id( + pending + .notification + .child_ref + .parent_agent_id() + .cloned() + .map(|id| id.to_string()), + ) .child(&child_handle) .delivery_id(pending.delivery_id.clone()) .reason_code("durable_recovery") .summary(terminal_notification_message(&pending.notification)) - .source_tool_call_id(pending.notification.source_tool_call_id.clone()), + .source_tool_call_id( + pending + .notification + .source_tool_call_id + .clone() + .map(|id| id.to_string()), + ), ) .await; } @@ -430,7 +515,7 @@ impl AgentOrchestrationService { ) -> AgentEventContext { let Some(target_agent_id) = deliveries .first() - .and_then(|delivery| delivery.notification.child_ref.parent_agent_id.clone()) + .and_then(|delivery| delivery.notification.child_ref.parent_agent_id().cloned()) else { return AgentEventContext::default(); }; @@ -452,45 +537,19 @@ fn parent_wake_batch_id(turn_id: &str) -> String { format!("parent-wake-batch:{turn_id}") } -fn build_wake_prompt_from_deliveries( +fn queued_inputs_from_deliveries( deliveries: &[astrcode_kernel::PendingParentDelivery], -) -> String { - let guidance = "父级协作唤醒:下面是 direct child agent 刚交付给你的结果。\nTreat each \ - delivery as a new message from that child agent and continue the parent \ - task.\n如果 child 已完成,请整合结果并继续当前任务;如果 child \ - 失败或提前关闭,请决定是修正、重试还是向上游明确报告。\n不要忽略这些交付,\ - 也不要在没有具体修正指令时让 child 重复已经完成的工作。\n如果你看到相同 \ - delivery_id 再次出现,把它当作同一条消息的重放,而不是新任务。"; - let parts = deliveries +) -> Vec { + deliveries .iter() .map(|delivery| { format!( - "[Agent Mailbox Message]\ndelivery_id: {}\nfrom_agent_id: \ - {}\nsender_lifecycle_status: {:?}\nmessage: {}", - delivery.delivery_id, - delivery.notification.child_ref.agent_id, - delivery.notification.status, + "子 Agent {} 刚交付了一条结果:\n{}", + delivery.notification.child_ref.agent_id(), terminal_notification_message(&delivery.notification), ) }) - .collect::>(); - - if parts.len() == 1 { - return format!( - "{guidance}\n\n{}", - parts.into_iter().next().unwrap_or_default() - ); - } - - format!( - "{guidance}\n\n请按顺序处理以下子 Agent 交付结果:\n\n{}", - parts - .into_iter() - .enumerate() - .map(|(index, part)| format!("{}. {}", index + 1, part)) - .collect::>() - .join("\n\n") - ) + .collect() } #[cfg(test)] @@ -498,9 +557,10 @@ mod tests { use std::time::{Duration, Instant}; use astrcode_core::{ - AgentEventContext, AgentLifecycleStatus, AgentMailboxEnvelope, CancelToken, ChildAgentRef, - ChildSessionLineageKind, ChildSessionNotification, ChildSessionNotificationKind, - EventStore, Phase, SessionId, StorageEvent, StoredEvent, + AgentEventContext, AgentLifecycleStatus, CancelToken, ChildAgentRef, + ChildExecutionIdentity, ChildSessionLineageKind, ChildSessionNotification, + ChildSessionNotificationKind, EventStore, ParentExecutionRef, Phase, QueuedInputEnvelope, + SessionId, StorageEvent, StoredEvent, }; use astrcode_session_runtime::{ append_and_broadcast, complete_session_execution, prepare_session_execution, @@ -521,20 +581,23 @@ mod tests { kind: ChildSessionNotificationKind, ) -> ChildSessionNotification { ChildSessionNotification { - notification_id: format!("delivery-{kind:?}").to_lowercase(), + notification_id: format!("delivery-{kind:?}").to_lowercase().into(), child_ref: ChildAgentRef { - agent_id: "agent-child".to_string(), - session_id: parent_session_id.to_string(), - sub_run_id: "subrun-child".to_string(), - parent_agent_id: Some(parent_agent_id.to_string()), - parent_sub_run_id: Some("subrun-parent".to_string()), + identity: ChildExecutionIdentity { + agent_id: "agent-child".to_string().into(), + session_id: parent_session_id.to_string().into(), + sub_run_id: "subrun-child".to_string().into(), + }, + parent: ParentExecutionRef { + parent_agent_id: Some(parent_agent_id.to_string().into()), + parent_sub_run_id: Some("subrun-parent".to_string().into()), + }, lineage_kind: ChildSessionLineageKind::Spawn, status: AgentLifecycleStatus::Idle, - open_session_id: "session-child".to_string(), + open_session_id: "session-child".to_string().into(), }, kind, - status: AgentLifecycleStatus::Idle, - source_tool_call_id: Some("tool-call-1".to_string()), + source_tool_call_id: Some("tool-call-1".to_string().into()), delivery: Some(astrcode_core::ParentDelivery { idempotency_key: format!("delivery-{kind:?}").to_lowercase(), origin: astrcode_core::ParentDeliveryOrigin::Explicit, @@ -689,7 +752,7 @@ mod tests { complete_session_execution(parent_state.as_ref(), Phase::Idle); let started = harness .service - .try_start_parent_delivery_turn(&parent.session_id) + .try_start_parent_delivery_turn(&parent.session_id, MAX_AUTOMATIC_INPUT_FOLLOW_UPS) .await .expect("retry should succeed"); assert!(started, "idle parent should start wake turn on retry"); @@ -762,7 +825,7 @@ mod tests { root_session.session_id.clone(), Some(middle_session.session_id.clone()), "turn-root".to_string(), - Some(root.agent_id.clone()), + Some(root.agent_id.to_string()), astrcode_core::SubRunStorageMode::IndependentSession, ) .await @@ -775,19 +838,22 @@ mod tests { .expect("middle lifecycle should update"); let leaf_delivery = ChildSessionNotification { - notification_id: "leaf-terminal:turn-leaf:completed".to_string(), + notification_id: "leaf-terminal:turn-leaf:completed".to_string().into(), child_ref: ChildAgentRef { - agent_id: "agent-leaf".to_string(), - session_id: middle_session.session_id.clone(), - sub_run_id: "subrun-leaf".to_string(), - parent_agent_id: Some(middle.agent_id.clone()), - parent_sub_run_id: Some(middle.sub_run_id.clone()), + identity: ChildExecutionIdentity { + agent_id: "agent-leaf".to_string().into(), + session_id: middle_session.session_id.clone().into(), + sub_run_id: "subrun-leaf".to_string().into(), + }, + parent: ParentExecutionRef { + parent_agent_id: Some(middle.agent_id.clone()), + parent_sub_run_id: Some(middle.sub_run_id.clone()), + }, lineage_kind: ChildSessionLineageKind::Spawn, status: AgentLifecycleStatus::Idle, - open_session_id: "session-leaf".to_string(), + open_session_id: "session-leaf".to_string().into(), }, kind: ChildSessionNotificationKind::Delivered, - status: AgentLifecycleStatus::Idle, source_tool_call_id: None, delivery: Some(astrcode_core::ParentDelivery { idempotency_key: "leaf-terminal:turn-leaf:completed".to_string(), @@ -844,7 +910,7 @@ mod tests { !root_events.iter().any(|stored| matches!( &stored.event.payload, StorageEventPayload::ChildSessionNotification { notification, .. } - if notification.child_ref.agent_id == middle.agent_id + if notification.child_ref.agent_id() == &middle.agent_id )), "wake turn is a coordination turn and must not auto-manufacture a new upward delivery" ); @@ -948,9 +1014,9 @@ mod tests { harness .service - .append_parent_delivery_mailbox_queue(&parent.session_id, "turn-parent", ¬ification) + .append_parent_delivery_input_queue(&parent.session_id, "turn-parent", ¬ification) .await - .expect("durable mailbox queue should append"); + .expect("durable input queue should append"); let mut translator = astrcode_core::EventTranslator::new( parent_state.current_phase().expect("phase should load"), ); @@ -971,7 +1037,7 @@ mod tests { let started = harness .service - .try_start_parent_delivery_turn(&parent.session_id) + .try_start_parent_delivery_turn(&parent.session_id, MAX_AUTOMATIC_INPUT_FOLLOW_UPS) .await .expect("wake should recover pending durable delivery"); assert!(started, "recovered durable delivery should start wake turn"); @@ -1001,20 +1067,20 @@ mod tests { .expect("parent events should replay"); assert!(parent_events.iter().any(|stored| matches!( &stored.event.payload, - StorageEventPayload::AgentMailboxBatchStarted { payload } - if payload.target_agent_id == root.agent_id + StorageEventPayload::AgentInputBatchStarted { payload } + if payload.target_agent_id == root.agent_id.to_string() && payload.delivery_ids == vec![notification.notification_id.clone()] ))); assert!(parent_events.iter().any(|stored| matches!( &stored.event.payload, - StorageEventPayload::AgentMailboxBatchAcked { payload } - if payload.target_agent_id == root.agent_id + StorageEventPayload::AgentInputBatchAcked { payload } + if payload.target_agent_id == root.agent_id.to_string() && payload.delivery_ids == vec![notification.notification_id.clone()] ))); } #[test] - fn wake_prompt_uses_delivery_message_without_legacy_fields() { + fn queued_inputs_use_delivery_message_without_removed_fields() { let delivered = sample_notification( "session-parent", "agent-parent", @@ -1029,7 +1095,7 @@ mod tests { assert_eq!(terminal_notification_message(&delivered), "最终回复摘录"); assert_eq!(terminal_notification_message(&failed), "子 Agent 已完成"); - let prompt = build_wake_prompt_from_deliveries(&[ + let queued_inputs = queued_inputs_from_deliveries(&[ astrcode_kernel::PendingParentDelivery { delivery_id: "delivery-1".to_string(), parent_session_id: "session-parent".to_string(), @@ -1045,10 +1111,10 @@ mod tests { notification: failed, }, ]); - assert!(prompt.contains("Treat each delivery as a new message from that child agent")); - assert!(prompt.contains("不要忽略这些交付")); - assert!(prompt.contains("message: 最终回复摘录")); - assert!(prompt.contains("message: 子 Agent 已完成")); + assert_eq!(queued_inputs.len(), 2); + assert!(queued_inputs[0].contains("子 Agent")); + assert!(queued_inputs[0].contains("最终回复摘录")); + assert!(queued_inputs[1].contains("子 Agent 已完成")); } #[test] @@ -1059,7 +1125,7 @@ mod tests { ChildSessionNotificationKind::Delivered, ); let failed = ChildSessionNotification { - notification_id: "delivery-failed".to_string(), + notification_id: "delivery-failed".to_string().into(), ..sample_notification( "session-parent", "agent-parent", @@ -1073,11 +1139,11 @@ mod tests { event: StorageEvent { turn_id: Some("turn-wake-1".to_string()), agent: AgentEventContext::default(), - payload: StorageEventPayload::AgentMailboxQueued { - payload: MailboxQueuedPayload { - envelope: AgentMailboxEnvelope { + payload: StorageEventPayload::AgentInputQueued { + payload: InputQueuedPayload { + envelope: QueuedInputEnvelope { delivery_id: delivered.notification_id.clone(), - from_agent_id: delivered.child_ref.agent_id.clone(), + from_agent_id: delivered.child_ref.agent_id().to_string(), to_agent_id: "agent-parent".to_string(), message: terminal_notification_message(&delivered), queued_at: chrono::Utc::now(), @@ -1085,7 +1151,10 @@ mod tests { sender_last_turn_outcome: terminal_notification_turn_outcome( &delivered, ), - sender_open_session_id: delivered.child_ref.open_session_id.clone(), + sender_open_session_id: delivered + .child_ref + .open_session_id + .to_string(), }, }, }, @@ -1096,8 +1165,8 @@ mod tests { event: StorageEvent { turn_id: Some("turn-wake-1".to_string()), agent: AgentEventContext::default(), - payload: StorageEventPayload::AgentMailboxBatchStarted { - payload: MailboxBatchStartedPayload { + payload: StorageEventPayload::AgentInputBatchStarted { + payload: InputBatchStartedPayload { target_agent_id: "agent-parent".to_string(), turn_id: "turn-wake-1".to_string(), batch_id: parent_wake_batch_id("turn-wake-1"), @@ -1112,11 +1181,11 @@ mod tests { event: StorageEvent { turn_id: Some("turn-parent".to_string()), agent: AgentEventContext::default(), - payload: StorageEventPayload::AgentMailboxQueued { - payload: MailboxQueuedPayload { - envelope: AgentMailboxEnvelope { + payload: StorageEventPayload::AgentInputQueued { + payload: InputQueuedPayload { + envelope: QueuedInputEnvelope { delivery_id: failed.notification_id.clone(), - from_agent_id: failed.child_ref.agent_id.clone(), + from_agent_id: failed.child_ref.agent_id().to_string(), to_agent_id: "agent-parent".to_string(), message: terminal_notification_message(&failed), queued_at: chrono::Utc::now(), @@ -1124,7 +1193,10 @@ mod tests { sender_last_turn_outcome: terminal_notification_turn_outcome( &failed, ), - sender_open_session_id: failed.child_ref.open_session_id.clone(), + sender_open_session_id: failed + .child_ref + .open_session_id + .to_string(), }, }, }, @@ -1135,6 +1207,6 @@ mod tests { let recovered = astrcode_session_runtime::recoverable_parent_deliveries(&events); assert_eq!(recovered.len(), 1); - assert_eq!(recovered[0].delivery_id, failed.notification_id); + assert_eq!(recovered[0].delivery_id, failed.notification_id.to_string()); } } diff --git a/crates/application/src/agent_use_cases.rs b/crates/application/src/agent_use_cases.rs index 51aa982c..b1400aaf 100644 --- a/crates/application/src/agent_use_cases.rs +++ b/crates/application/src/agent_use_cases.rs @@ -1,5 +1,18 @@ -/// ! 这是 App 的用例实现,不是 ports -use crate::{App, ApplicationError}; +//! Agent 控制用例(`App` 的 agent 相关方法)。 +//! +//! 通过 kernel 的稳定控制合同实现 agent 状态查询、子运行生命周期管理等用例。 + +use astrcode_core::{ + AgentEventContext, AgentLifecycleStatus, AgentTurnOutcome, InvocationKind, + ResolvedExecutionLimitsSnapshot, ResolvedSubagentContextOverrides, StorageEventPayload, + StoredEvent, SubRunResult, SubRunStorageMode, +}; +use astrcode_kernel::SubRunStatusView; + +use crate::{ + AgentExecuteSummary, App, ApplicationError, RootExecutionRequest, SubRunStatusSourceSummary, + SubRunStatusSummary, summarize_session_meta, +}; impl App { // ── Agent 控制用例(通过 kernel 稳定控制合同) ────────── @@ -8,7 +21,7 @@ impl App { pub async fn get_subrun_status( &self, agent_id: &str, - ) -> Result, ApplicationError> { + ) -> Result, ApplicationError> { self.validate_non_empty("agentId", agent_id)?; Ok(self.kernel.query_subrun_status(agent_id).await) } @@ -17,16 +30,115 @@ impl App { pub async fn get_root_agent_status( &self, session_id: &str, - ) -> Result, ApplicationError> { + ) -> Result, ApplicationError> { self.validate_non_empty("sessionId", session_id)?; Ok(self.kernel.query_root_status(session_id).await) } /// 列出所有 agent 状态。 - pub async fn list_agent_statuses(&self) -> Vec { + pub async fn list_agent_statuses(&self) -> Vec { self.kernel.list_statuses().await } + /// 执行 root agent 并返回共享摘要输入。 + pub async fn execute_root_agent_summary( + &self, + request: RootExecutionRequest, + ) -> Result { + let accepted = self.execute_root_agent(request).await?; + let session_id = accepted.session_id.to_string(); + Ok(AgentExecuteSummary { + accepted: true, + message: format!( + "agent '{}' execution accepted; subscribe to \ + /api/v1/conversation/sessions/{}/stream for progress", + accepted.agent_id.as_deref().unwrap_or("unknown-agent"), + session_id + ), + session_id: Some(session_id), + turn_id: Some(accepted.turn_id.to_string()), + agent_id: accepted.agent_id.map(|value| value.to_string()), + }) + } + + /// 查询指定 session/sub-run 的共享状态摘要。 + /// + /// 查找策略(按优先级): + /// 1. Live 状态:从 kernel 获取 sub-run 或 root agent 的实时状态 + /// 2. Durable 状态:遍历 child session 的存储事件,投影出持久化的 sub-run 状态 + /// 3. 都找不到:返回默认的 Idle 状态摘要 + /// + /// Durable 路径用于进程重启后 kernel 内存状态已丢失的场景。 + pub async fn get_subrun_status_summary( + &self, + session_id: &str, + requested_subrun_id: &str, + ) -> Result { + self.validate_non_empty("sessionId", session_id)?; + self.validate_non_empty("subRunId", requested_subrun_id)?; + + if let Some(view) = self.get_subrun_status(requested_subrun_id).await? { + return Ok(summarize_live_subrun_status(view, session_id.to_string())); + } + + if let Some(view) = self.get_root_agent_status(session_id).await? { + if view.sub_run_id == requested_subrun_id { + return Ok(summarize_live_subrun_status(view, session_id.to_string())); + } + return Err(ApplicationError::NotFound(format!( + "subrun '{}' not found in session '{}'", + requested_subrun_id, session_id + ))); + } + + if let Some(summary) = self + .durable_subrun_status_summary(session_id, requested_subrun_id) + .await? + { + return Ok(summary); + } + + Ok(default_subrun_status_summary( + session_id.to_string(), + requested_subrun_id.to_string(), + )) + } + + /// 从 durable 存储事件中投影子运行状态。 + /// + /// 遍历所有 child session 的存储事件,寻找匹配 requested_subrun_id 的 + /// SubRunStarted / SubRunFinished 事件,构建状态摘要。 + /// 用于进程重启后 kernel 内存状态已丢失的场景。 + async fn durable_subrun_status_summary( + &self, + parent_session_id: &str, + requested_subrun_id: &str, + ) -> Result, ApplicationError> { + let child_sessions = self + .list_sessions() + .await? + .into_iter() + .map(summarize_session_meta) + .filter(|summary| summary.parent_session_id.as_deref() == Some(parent_session_id)) + .collect::>(); + + for child_session in child_sessions { + let stored_events = self + .session_stored_events(&child_session.session_id) + .await?; + if let Some(summary) = project_durable_subrun_status_summary( + parent_session_id, + &child_session.session_id, + requested_subrun_id, + &stored_events, + ) { + return Ok(Some(summary)); + } + } + + Ok(None) + } + /// 关闭 agent 及其子树。 pub async fn close_agent( &self, @@ -41,8 +153,7 @@ impl App { agent_id ))); }; - if handle.session_id != session_id { - // 显式校验归属,避免仅凭 agent_id 跨 session 关闭不相关子树。 + if handle.session_id.as_str() != session_id { return Err(ApplicationError::NotFound(format!( "agent '{}' not found in session '{}'", agent_id, session_id @@ -54,3 +165,398 @@ impl App { .map_err(|error| ApplicationError::Internal(error.to_string())) } } + +fn summarize_live_subrun_status(view: SubRunStatusView, session_id: String) -> SubRunStatusSummary { + SubRunStatusSummary { + sub_run_id: view.sub_run_id, + tool_call_id: None, + source: SubRunStatusSourceSummary::Live, + agent_id: view.agent_id, + agent_profile: view.agent_profile, + session_id, + child_session_id: view.child_session_id, + depth: view.depth, + parent_agent_id: view.parent_agent_id, + parent_sub_run_id: None, + storage_mode: SubRunStorageMode::IndependentSession, + lifecycle: view.lifecycle, + last_turn_outcome: view.last_turn_outcome, + result: None, + step_count: None, + estimated_tokens: None, + resolved_overrides: None, + resolved_limits: Some(view.resolved_limits), + } +} + +fn default_subrun_status_summary(session_id: String, sub_run_id: String) -> SubRunStatusSummary { + SubRunStatusSummary { + sub_run_id, + tool_call_id: None, + source: SubRunStatusSourceSummary::Live, + agent_id: "root-agent".to_string(), + agent_profile: "default".to_string(), + session_id, + child_session_id: None, + depth: 0, + parent_agent_id: None, + parent_sub_run_id: None, + storage_mode: SubRunStorageMode::IndependentSession, + lifecycle: AgentLifecycleStatus::Idle, + last_turn_outcome: None, + result: None, + step_count: None, + estimated_tokens: None, + resolved_overrides: None, + resolved_limits: Some(ResolvedExecutionLimitsSnapshot { + allowed_tools: Vec::new(), + max_steps: None, + }), + } +} + +#[derive(Debug, Clone)] +struct DurableSubRunStatusProjection { + sub_run_id: String, + tool_call_id: Option, + agent_id: String, + agent_profile: String, + child_session_id: String, + depth: usize, + parent_agent_id: Option, + parent_sub_run_id: Option, + lifecycle: AgentLifecycleStatus, + last_turn_outcome: Option, + result: Option, + step_count: Option, + estimated_tokens: Option, + resolved_overrides: Option, + resolved_limits: ResolvedExecutionLimitsSnapshot, +} + +fn project_durable_subrun_status_summary( + parent_session_id: &str, + child_session_id: &str, + requested_subrun_id: &str, + stored_events: &[StoredEvent], +) -> Option { + let mut projection: Option = None; + + for stored in stored_events { + let agent = &stored.event.agent; + if !matches_requested_subrun(agent, requested_subrun_id) { + continue; + } + + match &stored.event.payload { + StorageEventPayload::SubRunStarted { + tool_call_id, + resolved_overrides, + resolved_limits, + .. + } => { + projection = Some(DurableSubRunStatusProjection { + sub_run_id: agent + .sub_run_id + .clone() + .unwrap_or_else(|| requested_subrun_id.to_string().into()) + .to_string(), + tool_call_id: tool_call_id.clone(), + agent_id: agent + .agent_id + .clone() + .unwrap_or_else(|| requested_subrun_id.to_string().into()) + .to_string(), + agent_profile: agent + .agent_profile + .clone() + .unwrap_or_else(|| "unknown".to_string()), + child_session_id: child_session_id.to_string(), + depth: 1, + parent_agent_id: None, + parent_sub_run_id: agent.parent_sub_run_id.clone().map(|id| id.to_string()), + lifecycle: AgentLifecycleStatus::Running, + last_turn_outcome: None, + result: None, + step_count: None, + estimated_tokens: None, + resolved_overrides: Some(resolved_overrides.clone()), + resolved_limits: resolved_limits.clone(), + }); + }, + StorageEventPayload::SubRunFinished { + tool_call_id, + result, + step_count, + estimated_tokens, + .. + } => { + let entry = projection.get_or_insert_with(|| DurableSubRunStatusProjection { + sub_run_id: agent + .sub_run_id + .clone() + .unwrap_or_else(|| requested_subrun_id.to_string().into()) + .to_string(), + tool_call_id: None, + agent_id: agent + .agent_id + .clone() + .unwrap_or_else(|| requested_subrun_id.to_string().into()) + .to_string(), + agent_profile: agent + .agent_profile + .clone() + .unwrap_or_else(|| "unknown".to_string()), + child_session_id: child_session_id.to_string(), + depth: 1, + parent_agent_id: None, + parent_sub_run_id: agent.parent_sub_run_id.clone().map(|id| id.to_string()), + lifecycle: result.status().lifecycle(), + last_turn_outcome: result.status().last_turn_outcome(), + result: None, + step_count: None, + estimated_tokens: None, + resolved_overrides: None, + resolved_limits: ResolvedExecutionLimitsSnapshot::default(), + }); + entry.tool_call_id = tool_call_id.clone().or_else(|| entry.tool_call_id.clone()); + entry.lifecycle = result.status().lifecycle(); + entry.last_turn_outcome = result.status().last_turn_outcome(); + entry.result = Some(result.clone()); + entry.step_count = Some(*step_count); + entry.estimated_tokens = Some(*estimated_tokens); + }, + _ => {}, + } + } + + projection.map(|projection| SubRunStatusSummary { + sub_run_id: projection.sub_run_id, + tool_call_id: projection.tool_call_id, + source: SubRunStatusSourceSummary::Durable, + agent_id: projection.agent_id, + agent_profile: projection.agent_profile, + session_id: parent_session_id.to_string(), + child_session_id: Some(projection.child_session_id), + depth: projection.depth, + parent_agent_id: projection.parent_agent_id, + parent_sub_run_id: projection.parent_sub_run_id, + storage_mode: SubRunStorageMode::IndependentSession, + lifecycle: projection.lifecycle, + last_turn_outcome: projection.last_turn_outcome, + result: projection.result, + step_count: projection.step_count, + estimated_tokens: projection.estimated_tokens, + resolved_overrides: projection.resolved_overrides, + resolved_limits: Some(projection.resolved_limits), + }) +} + +fn matches_requested_subrun(agent: &AgentEventContext, requested_subrun_id: &str) -> bool { + if agent.invocation_kind != Some(InvocationKind::SubRun) { + return false; + } + + agent.sub_run_id.as_deref() == Some(requested_subrun_id) + || agent.agent_id.as_deref() == Some(requested_subrun_id) +} + +#[cfg(test)] +mod tests { + use astrcode_core::{ + AgentTurnOutcome, ArtifactRef, CompletedParentDeliveryPayload, CompletedSubRunOutcome, + ForkMode, ParentDelivery, ParentDeliveryOrigin, ParentDeliveryPayload, + ParentDeliveryTerminalSemantics, ResolvedExecutionLimitsSnapshot, + ResolvedSubagentContextOverrides, StorageEvent, StorageEventPayload, SubRunResult, + SubRunStorageMode, + }; + + use super::project_durable_subrun_status_summary; + use crate::{AgentEventContext, StoredEvent, SubRunHandoff}; + + #[test] + fn durable_subrun_projection_preserves_typed_handoff_delivery() { + let child_agent = AgentEventContext::sub_run( + "agent-child", + "turn-parent", + "reviewer", + "subrun-child", + Some("subrun-parent".into()), + SubRunStorageMode::IndependentSession, + Some("session-child".into()), + ); + let explicit_delivery = ParentDelivery { + idempotency_key: "delivery-explicit".to_string(), + origin: ParentDeliveryOrigin::Explicit, + terminal_semantics: ParentDeliveryTerminalSemantics::Terminal, + source_turn_id: Some("turn-child".to_string()), + payload: ParentDeliveryPayload::Completed(CompletedParentDeliveryPayload { + message: "显式交付".to_string(), + findings: vec!["finding-1".to_string()], + artifacts: vec![ArtifactRef { + kind: "session".to_string(), + id: "session-child".to_string(), + label: "Child Session".to_string(), + session_id: Some("session-child".to_string()), + storage_seq: None, + uri: None, + }], + }), + }; + let stored_events = vec![StoredEvent { + storage_seq: 1, + event: StorageEvent { + turn_id: Some("turn-child".to_string()), + agent: child_agent.clone(), + payload: StorageEventPayload::SubRunFinished { + tool_call_id: Some("call-1".to_string()), + result: SubRunResult::Completed { + outcome: CompletedSubRunOutcome::Completed, + handoff: SubRunHandoff { + findings: vec!["finding-1".to_string()], + artifacts: vec![ArtifactRef { + kind: "session".to_string(), + id: "session-child".to_string(), + label: "Child Session".to_string(), + session_id: Some("session-child".to_string()), + storage_seq: None, + uri: None, + }], + delivery: Some(explicit_delivery.clone()), + }, + }, + timestamp: Some(chrono::Utc::now()), + step_count: 3, + estimated_tokens: 120, + }, + }, + }]; + + let projection = project_durable_subrun_status_summary( + "session-parent", + "session-child", + "subrun-child", + &stored_events, + ) + .expect("projection should exist"); + + let result = projection.result.expect("durable result should exist"); + let handoff = match result { + SubRunResult::Running { handoff } | SubRunResult::Completed { handoff, .. } => handoff, + SubRunResult::Failed { .. } => panic!("expected successful durable handoff"), + }; + let delivery = handoff + .delivery + .expect("typed delivery should survive durable projection"); + assert_eq!(delivery.idempotency_key, "delivery-explicit"); + assert_eq!(delivery.origin, ParentDeliveryOrigin::Explicit); + assert_eq!( + delivery.terminal_semantics, + ParentDeliveryTerminalSemantics::Terminal + ); + match delivery.payload { + ParentDeliveryPayload::Completed(payload) => { + assert_eq!(payload.message, "显式交付"); + assert_eq!(payload.findings, vec!["finding-1".to_string()]); + }, + payload => panic!("unexpected delivery payload: {payload:?}"), + } + } + + #[test] + fn resolved_overrides_projection_preserves_fork_mode() { + let projection = project_durable_subrun_status_summary( + "session-parent", + "session-child", + "subrun-child", + &[StoredEvent { + storage_seq: 1, + event: StorageEvent { + turn_id: Some("turn-child".to_string()), + agent: AgentEventContext::sub_run( + "agent-child", + "turn-parent", + "reviewer", + "subrun-child", + Some("subrun-parent".into()), + SubRunStorageMode::IndependentSession, + Some("session-child".into()), + ), + payload: StorageEventPayload::SubRunStarted { + tool_call_id: Some("call-1".to_string()), + resolved_overrides: ResolvedSubagentContextOverrides { + fork_mode: Some(ForkMode::LastNTurns(7)), + ..ResolvedSubagentContextOverrides::default() + }, + resolved_limits: ResolvedExecutionLimitsSnapshot::default(), + timestamp: Some(chrono::Utc::now()), + }, + }, + }], + ) + .expect("projection should exist"); + + assert_eq!( + projection + .resolved_overrides + .expect("resolved overrides should exist") + .fork_mode, + Some(ForkMode::LastNTurns(7)) + ); + } + + #[test] + fn durable_subrun_projection_maps_token_exceeded_to_successful_handoff_result() { + let child_agent = AgentEventContext::sub_run( + "agent-child", + "turn-parent", + "reviewer", + "subrun-child", + Some("subrun-parent".into()), + SubRunStorageMode::IndependentSession, + Some("session-child".into()), + ); + let stored_events = vec![StoredEvent { + storage_seq: 1, + event: StorageEvent { + turn_id: Some("turn-child".to_string()), + agent: child_agent, + payload: StorageEventPayload::SubRunFinished { + tool_call_id: Some("call-1".to_string()), + result: SubRunResult::Completed { + outcome: CompletedSubRunOutcome::TokenExceeded, + handoff: SubRunHandoff { + findings: vec!["partial-finding".to_string()], + artifacts: Vec::new(), + delivery: None, + }, + }, + timestamp: Some(chrono::Utc::now()), + step_count: 5, + estimated_tokens: 2048, + }, + }, + }]; + + let projection = project_durable_subrun_status_summary( + "session-parent", + "session-child", + "subrun-child", + &stored_events, + ) + .expect("projection should exist"); + + let result = projection.result.expect("durable result should exist"); + match result { + SubRunResult::Completed { outcome, handoff } => { + assert_eq!(outcome, CompletedSubRunOutcome::TokenExceeded); + assert_eq!(handoff.findings, vec!["partial-finding".to_string()]); + }, + other => panic!("expected token exceeded handoff result, got {other:?}"), + } + assert_eq!( + projection.last_turn_outcome, + Some(AgentTurnOutcome::TokenExceeded) + ); + } +} diff --git a/crates/application/src/composer/mod.rs b/crates/application/src/composer/mod.rs index d1f8fe27..78256141 100644 --- a/crates/application/src/composer/mod.rs +++ b/crates/application/src/composer/mod.rs @@ -3,25 +3,13 @@ //! 提供 composer 输入候选列表的查询和过滤用例。 //! 候选来源包括:命令、技能、能力(通过 `KernelGateway` 查询)。 +pub use astrcode_core::{ComposerOption, ComposerOptionActionKind, ComposerOptionKind}; use astrcode_kernel::KernelGateway; // ============================================================ // 业务模型 // ============================================================ -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum ComposerOptionKind { - Command, - Skill, - Capability, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum ComposerOptionActionKind { - InsertText, - ExecuteCommand, -} - #[derive(Debug, Clone, PartialEq, Eq)] pub struct ComposerOptionsRequest { pub query: Option, @@ -39,19 +27,6 @@ impl Default for ComposerOptionsRequest { } } -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ComposerOption { - pub kind: ComposerOptionKind, - pub id: String, - pub title: String, - pub description: String, - pub insert_text: String, - pub action_kind: ComposerOptionActionKind, - pub action_value: String, - pub badges: Vec, - pub keywords: Vec, -} - #[derive(Debug, Clone, PartialEq, Eq)] pub struct ComposerSkillSummary { pub id: String, diff --git a/crates/application/src/config/api_key.rs b/crates/application/src/config/api_key.rs index f87e2864..abc66f4c 100644 --- a/crates/application/src/config/api_key.rs +++ b/crates/application/src/config/api_key.rs @@ -78,6 +78,9 @@ mod tests { #[test] fn literal_api_key_resolved() { let profile = test_profile(Some("literal:sk-123")); - assert_eq!(resolve_api_key(&profile).unwrap(), "sk-123"); + assert_eq!( + resolve_api_key(&profile).expect("literal key should resolve"), + "sk-123" + ); } } diff --git a/crates/application/src/config/constants.rs b/crates/application/src/config/constants.rs index 35c8b674..0186d176 100644 --- a/crates/application/src/config/constants.rs +++ b/crates/application/src/config/constants.rs @@ -129,10 +129,10 @@ pub use astrcode_core::config::{ DEFAULT_MAX_TOOL_CONCURRENCY, DEFAULT_MAX_TRACKED_FILES, DEFAULT_MICRO_COMPACT_GAP_THRESHOLD_SECS, DEFAULT_MICRO_COMPACT_KEEP_RECENT_RESULTS, DEFAULT_PARENT_DELIVERY_CAPACITY, DEFAULT_RECOVERY_TOKEN_BUDGET, - DEFAULT_RECOVERY_TRUNCATE_BYTES, DEFAULT_SESSION_BROADCAST_CAPACITY, - DEFAULT_SESSION_RECENT_RECORD_LIMIT, DEFAULT_SUMMARY_RESERVE_TOKENS, - DEFAULT_TOOL_RESULT_INLINE_LIMIT, DEFAULT_TOOL_RESULT_MAX_BYTES, - DEFAULT_TOOL_RESULT_PREVIEW_LIMIT, + DEFAULT_RECOVERY_TRUNCATE_BYTES, DEFAULT_RESERVED_CONTEXT_SIZE, + DEFAULT_SESSION_BROADCAST_CAPACITY, DEFAULT_SESSION_RECENT_RECORD_LIMIT, + DEFAULT_SUMMARY_RESERVE_TOKENS, DEFAULT_TOOL_RESULT_INLINE_LIMIT, + DEFAULT_TOOL_RESULT_MAX_BYTES, DEFAULT_TOOL_RESULT_PREVIEW_LIMIT, }; // ============================================================ diff --git a/crates/application/src/config/env_resolver.rs b/crates/application/src/config/env_resolver.rs index c2076fd9..1ef7999f 100644 --- a/crates/application/src/config/env_resolver.rs +++ b/crates/application/src/config/env_resolver.rs @@ -94,7 +94,7 @@ mod tests { #[test] fn literal_prefix_bypasses_env() { assert_eq!( - parse_env_value("literal:my-secret-key").unwrap(), + parse_env_value("literal:my-secret-key").expect("literal should parse"), ParsedEnvValue::Literal("my-secret-key") ); } @@ -102,7 +102,7 @@ mod tests { #[test] fn env_prefix_parses() { assert_eq!( - parse_env_value("env:MY_KEY").unwrap(), + parse_env_value("env:MY_KEY").expect("env prefix should parse"), ParsedEnvValue::ExplicitEnv("MY_KEY") ); } @@ -110,7 +110,7 @@ mod tests { #[test] fn bare_uppercase_with_underscore_is_optional_env() { assert_eq!( - parse_env_value("MY_API_KEY").unwrap(), + parse_env_value("MY_API_KEY").expect("uppercase with underscore should parse"), ParsedEnvValue::OptionalEnv("MY_API_KEY") ); } @@ -118,7 +118,7 @@ mod tests { #[test] fn plain_text_is_literal() { assert_eq!( - parse_env_value("hello world").unwrap(), + parse_env_value("hello world").expect("plain text should parse"), ParsedEnvValue::Literal("hello world") ); } diff --git a/crates/application/src/config/mod.rs b/crates/application/src/config/mod.rs index 9b3d67cf..86ff1766 100644 --- a/crates/application/src/config/mod.rs +++ b/crates/application/src/config/mod.rs @@ -24,7 +24,7 @@ use std::{ sync::Arc, }; -use astrcode_core::{Config, ConfigOverlay}; +use astrcode_core::{Config, ConfigOverlay, TestConnectionResult}; pub use astrcode_core::{ config::{ DEFAULT_API_SESSION_TTL_HOURS, DEFAULT_AUTO_COMPACT_ENABLED, @@ -38,10 +38,11 @@ pub use astrcode_core::{ DEFAULT_MAX_SPAWN_PER_TURN, DEFAULT_MAX_STEPS, DEFAULT_MAX_SUBRUN_DEPTH, DEFAULT_MAX_TOOL_CONCURRENCY, DEFAULT_MAX_TRACKED_FILES, DEFAULT_PARENT_DELIVERY_CAPACITY, DEFAULT_RECOVERY_TOKEN_BUDGET, DEFAULT_RECOVERY_TRUNCATE_BYTES, - DEFAULT_SESSION_BROADCAST_CAPACITY, DEFAULT_SESSION_RECENT_RECORD_LIMIT, - DEFAULT_SUMMARY_RESERVE_TOKENS, DEFAULT_TOOL_RESULT_INLINE_LIMIT, - DEFAULT_TOOL_RESULT_MAX_BYTES, DEFAULT_TOOL_RESULT_PREVIEW_LIMIT, ResolvedAgentConfig, - ResolvedRuntimeConfig, max_tool_concurrency, resolve_agent_config, resolve_runtime_config, + DEFAULT_RESERVED_CONTEXT_SIZE, DEFAULT_SESSION_BROADCAST_CAPACITY, + DEFAULT_SESSION_RECENT_RECORD_LIMIT, DEFAULT_SUMMARY_RESERVE_TOKENS, + DEFAULT_TOOL_RESULT_INLINE_LIMIT, DEFAULT_TOOL_RESULT_MAX_BYTES, + DEFAULT_TOOL_RESULT_PREVIEW_LIMIT, ResolvedAgentConfig, ResolvedRuntimeConfig, + max_tool_concurrency, resolve_agent_config, resolve_runtime_config, }, ports::{ConfigStore, McpConfigFileScope}, }; @@ -61,21 +62,33 @@ use tokio::sync::RwLock; use crate::ApplicationError; -/// 模型连通性测试结果。 -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct TestConnectionResult { - pub success: bool, - pub provider: String, - pub model: String, - pub error: Option, -} - /// 配置用例入口:负责配置的读取、写入、校验和装配。 pub struct ConfigService { pub(super) store: Arc, pub(super) config: Arc>, } +/// 单个 profile 的摘要输入。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConfigProfileSummary { + pub name: String, + pub base_url: String, + pub api_key_preview: String, + pub models: Vec, +} + +/// 已解析的配置摘要输入。 +/// +/// 这是 protocol `ConfigView` 的共享 projection input, +/// server 只需要补上 `config_path` 和协议外层壳。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedConfigSummary { + pub active_profile: String, + pub active_model: String, + pub profiles: Vec, + pub warning: Option, +} + impl ConfigService { /// 从存储创建配置服务,加载并验证配置。 pub fn new(store: Arc) -> Self { @@ -196,6 +209,88 @@ impl ConfigService { } } +/// 生成配置摘要输入,供协议层投影复用。 +pub fn resolve_config_summary(config: &Config) -> Result { + if config.profiles.is_empty() { + return Ok(ResolvedConfigSummary { + active_profile: String::new(), + active_model: String::new(), + profiles: Vec::new(), + warning: Some("no profiles configured".to_string()), + }); + } + + let profiles = config + .profiles + .iter() + .map(|profile| ConfigProfileSummary { + name: profile.name.clone(), + base_url: profile.base_url.clone(), + api_key_preview: api_key_preview(profile.api_key.as_deref()), + models: profile + .models + .iter() + .map(|model| model.id.clone()) + .collect(), + }) + .collect(); + + let selection = selection::resolve_active_selection( + &config.active_profile, + &config.active_model, + &config.profiles, + )?; + + Ok(ResolvedConfigSummary { + active_profile: selection.active_profile, + active_model: selection.active_model, + profiles, + warning: selection.warning, + }) +} + +/// 生成 API key 的安全预览字符串。 +/// +/// 规则: +/// - `None` 或空字符串 → "未配置" +/// - `env:VAR_NAME` 前缀 → "环境变量: VAR_NAME"(不读取实际值) +/// - `literal:KEY` 前缀 → 显示 **** + 最后 4 个字符 +/// - 纯大写+下划线且是有效环境变量名 → "环境变量: NAME" +/// - 长度 > 4 → 显示 "****" + 最后 4 个字符 +/// - 其他 → "****" +pub fn api_key_preview(api_key: Option<&str>) -> String { + match api_key.map(str::trim) { + None | Some("") => "未配置".to_string(), + Some(value) if value.starts_with("env:") => { + let env_name = value.trim_start_matches("env:").trim(); + if env_name.is_empty() { + "未配置".to_string() + } else { + format!("环境变量: {}", env_name) + } + }, + Some(value) if value.starts_with("literal:") => { + let key = value.trim_start_matches("literal:").trim(); + masked_key_preview(key) + }, + Some(value) if is_env_var_name(value) && std::env::var_os(value).is_some() => { + format!("环境变量: {}", value) + }, + Some(value) => masked_key_preview(value), + } +} + +fn masked_key_preview(value: &str) -> String { + let char_starts: Vec = value.char_indices().map(|(index, _)| index).collect(); + + if char_starts.len() <= 4 { + "****".to_string() + } else { + let suffix_start = char_starts[char_starts.len() - 4]; + format!("****{}", &value[suffix_start..]) + } +} + /// 应用项目 overlay 到基础配置(仅覆盖显式设置的字段)。 fn apply_overlay(mut base: Config, overlay: ConfigOverlay) -> Config { if let Some(active_profile) = overlay.active_profile { @@ -219,9 +314,15 @@ pub fn is_env_var_name(value: &str) -> bool { #[cfg(test)] mod tests { + use astrcode_core::{ModelConfig, Profile}; + use super::*; use crate::config::test_support::TestConfigStore; + fn model(id: &str) -> ModelConfig { + ModelConfig::new(id) + } + #[test] fn load_resolved_runtime_config_materializes_defaults() { let service = ConfigService::new(Arc::new(TestConfigStore::default())); @@ -263,4 +364,60 @@ mod tests { assert_eq!(runtime.agent.max_subrun_depth, 5); assert_eq!(runtime.agent.max_spawn_per_turn, 2); } + + #[test] + fn api_key_preview_masks_utf8_literal_without_panicking() { + assert_eq!( + api_key_preview(Some("literal:令牌甲乙丙丁")), + "****甲乙丙丁" + ); + } + + #[test] + fn api_key_preview_masks_utf8_plain_value_without_panicking() { + assert_eq!(api_key_preview(Some("令牌甲乙丙丁戊")), "****乙丙丁戊"); + } + + #[test] + fn resolve_config_summary_builds_preview_and_selection() { + let config = Config { + active_profile: "missing".to_string(), + active_model: "missing-model".to_string(), + profiles: vec![Profile { + name: "deepseek".to_string(), + base_url: "https://example.com".to_string(), + api_key: Some("literal:abc12345".to_string()), + models: vec![model("deepseek-chat"), model("deepseek-reasoner")], + ..Profile::default() + }], + ..Config::default() + }; + + let summary = resolve_config_summary(&config).expect("summary should resolve"); + + assert_eq!(summary.active_profile, "deepseek"); + assert_eq!(summary.active_model, "deepseek-chat"); + assert!(summary.warning.is_some()); + assert_eq!(summary.profiles.len(), 1); + assert_eq!(summary.profiles[0].api_key_preview, "****2345"); + assert_eq!( + summary.profiles[0].models, + vec!["deepseek-chat".to_string(), "deepseek-reasoner".to_string()] + ); + } + + #[test] + fn resolve_config_summary_returns_empty_state_for_missing_profiles() { + let config = Config { + profiles: Vec::new(), + ..Config::default() + }; + + let summary = resolve_config_summary(&config).expect("summary should resolve"); + + assert_eq!(summary.active_profile, ""); + assert_eq!(summary.active_model, ""); + assert!(summary.profiles.is_empty()); + assert_eq!(summary.warning.as_deref(), Some("no profiles configured")); + } } diff --git a/crates/application/src/config/selection.rs b/crates/application/src/config/selection.rs index 727de9af..4864df59 100644 --- a/crates/application/src/config/selection.rs +++ b/crates/application/src/config/selection.rs @@ -7,7 +7,8 @@ //! - profile 无 model → 返回错误 use astrcode_core::{ - ActiveSelection, Config, CurrentModelSelection, ModelConfig, ModelOption, Profile, + ActiveSelection, Config, CurrentModelSelection, ModelConfig, ModelOption, ModelSelection, + Profile, }; use crate::ApplicationError; @@ -79,11 +80,11 @@ pub fn resolve_current_model(config: &Config) -> Result Vec { .profiles .iter() .flat_map(|profile| { - profile.models.iter().map(|model| ModelOption { - profile_name: profile.name.clone(), - model: model.id.clone(), - provider_kind: profile.provider_kind.clone(), + profile.models.iter().map(|model| { + ModelSelection::new( + profile.name.clone(), + model.id.clone(), + profile.provider_kind.clone(), + ) }) }) .collect() diff --git a/crates/application/src/config/test_support.rs b/crates/application/src/config/test_support.rs index 2b75fb4a..d569f28b 100644 --- a/crates/application/src/config/test_support.rs +++ b/crates/application/src/config/test_support.rs @@ -1,3 +1,7 @@ +//! 配置服务测试桩。 +//! +//! 提供内存实现的 `ConfigStore`,用于配置相关单元测试。 + use std::{ path::{Path, PathBuf}, sync::Mutex, diff --git a/crates/application/src/config/validation.rs b/crates/application/src/config/validation.rs index a9a3dc30..35e94968 100644 --- a/crates/application/src/config/validation.rs +++ b/crates/application/src/config/validation.rs @@ -82,11 +82,12 @@ fn validate_runtime_params(runtime: &astrcode_core::RuntimeConfig) -> Result<()> runtime.micro_compact_keep_recent_results => "runtime.microCompactKeepRecentResults", runtime.max_consecutive_failures => "runtime.maxConsecutiveFailures", runtime.recovery_truncate_bytes => "runtime.recoveryTruncateBytes", + runtime.reserved_context_size => "runtime.reservedContextSize", )?; validate_positive_fields!( runtime.compact_keep_recent_turns => "runtime.compactKeepRecentTurns", - runtime.max_reactive_compact_attempts => "runtime.maxReactiveCompactAttempts", + runtime.compact_max_retry_attempts => "runtime.compactMaxRetryAttempts", runtime.max_output_continuation_attempts => "runtime.maxOutputContinuationAttempts", runtime.max_continuations => "runtime.maxContinuations", )?; @@ -321,7 +322,7 @@ mod tests { version: String::new(), ..Config::default() }; - let result = normalize_config(config).unwrap(); + let result = normalize_config(config).expect("normalize should succeed"); assert_eq!(result.version, "1"); } diff --git a/crates/application/src/errors.rs b/crates/application/src/errors.rs index 0d4b14f5..d67da74f 100644 --- a/crates/application/src/errors.rs +++ b/crates/application/src/errors.rs @@ -1,3 +1,8 @@ +//! 应用层错误类型。 +//! +//! `ApplicationError` 是 application 层唯一的错误枚举, +//! 通过 `From` 转换桥接 core / session-runtime 的底层错误。 + use thiserror::Error; #[derive(Debug, Error)] diff --git a/crates/application/src/execution/control.rs b/crates/application/src/execution/control.rs index af0b6c11..07e18b04 100644 --- a/crates/application/src/execution/control.rs +++ b/crates/application/src/execution/control.rs @@ -1,19 +1,6 @@ -use crate::ApplicationError; +//! 执行控制参数 re-export。 +//! +//! 将 `astrcode_core::ExecutionControl` 直接 re-export, +//! 供 application 各模块统一从 `execution::ExecutionControl` 引入。 -/// 执行控制输入。 -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct ExecutionControl { - pub max_steps: Option, - pub manual_compact: Option, -} - -impl ExecutionControl { - pub fn validate(&self) -> Result<(), ApplicationError> { - if matches!(self.max_steps, Some(0)) { - return Err(ApplicationError::InvalidArgument( - "field 'maxSteps' must be greater than 0".to_string(), - )); - } - Ok(()) - } -} +pub use astrcode_core::ExecutionControl; diff --git a/crates/application/src/execution/mod.rs b/crates/application/src/execution/mod.rs index 5bd54b54..8873b210 100644 --- a/crates/application/src/execution/mod.rs +++ b/crates/application/src/execution/mod.rs @@ -16,6 +16,9 @@ pub use subagent::{SubagentExecutionRequest, launch_subagent}; use crate::ApplicationError; +/// 将 context 信息拼接到 task 前面,形成完整的执行指令。 +/// +/// context 非空时格式为 `{context}\n\n{task}`,context 为空时直接返回 task。 pub(super) fn merge_task_with_context(task: &str, context: Option<&str>) -> String { match context { Some(context) if !context.trim().is_empty() => { @@ -25,6 +28,10 @@ pub(super) fn merge_task_with_context(task: &str, context: Option<&str>) -> Stri } } +/// 校验 profile 的 mode 是否在允许列表内。 +/// +/// 根执行只允许 Primary / All,子代理执行只允许 SubAgent / All。 +/// 不匹配时返回带上下文的错误信息。 pub(super) fn ensure_profile_mode( profile: &AgentProfile, allowed_modes: &[AgentMode], diff --git a/crates/application/src/execution/profiles.rs b/crates/application/src/execution/profiles.rs index 7a809849..b792158b 100644 --- a/crates/application/src/execution/profiles.rs +++ b/crates/application/src/execution/profiles.rs @@ -5,7 +5,7 @@ use std::{ path::{Path, PathBuf}, - sync::{Arc, RwLock}, + sync::{Arc, RwLock, RwLockReadGuard, RwLockWriteGuard}, }; use astrcode_core::AgentProfile; @@ -75,7 +75,7 @@ impl ProfileResolutionService { /// 全局 profile 列表(不绑定 working-dir)。 pub fn resolve_global(&self) -> Result>, ApplicationError> { { - let guard = self.global_cache.read().unwrap(); + let guard = self.read_global_cache(); if let Some(ref cached) = *guard { return Ok(Arc::clone(cached)); } @@ -84,7 +84,7 @@ impl ProfileResolutionService { let profiles = self.provider.load_global().map(Arc::new)?; { - let mut guard = self.global_cache.write().unwrap(); + let mut guard = self.write_global_cache(); *guard = Some(Arc::clone(&profiles)); } @@ -139,7 +139,7 @@ impl ProfileResolutionService { /// 使全局缓存失效。 pub fn invalidate_global(&self) { self.cache.clear(); - let mut guard = self.global_cache.write().unwrap(); + let mut guard = self.write_global_cache(); *guard = None; } @@ -148,6 +148,18 @@ impl ProfileResolutionService { self.cache.clear(); self.invalidate_global(); } + + fn read_global_cache(&self) -> RwLockReadGuard<'_, Option>>> { + self.global_cache + .read() + .expect("global profile cache lock should not be poisoned") + } + + fn write_global_cache(&self) -> RwLockWriteGuard<'_, Option>>> { + self.global_cache + .write() + .expect("global profile cache lock should not be poisoned") + } } /// 规范化路径用于缓存键。 @@ -278,9 +290,9 @@ mod tests { test_profile("plan"), ])); let service = ProfileResolutionService::new(provider.clone()); - let dir = std::env::current_dir().unwrap(); + let dir = std::env::current_dir().expect("current_dir should be available"); - let profiles = service.resolve(&dir).unwrap(); + let profiles = service.resolve(&dir).expect("resolve should succeed"); assert_eq!(profiles.len(), 2); assert_eq!(provider.load_count(), 1); } @@ -289,10 +301,10 @@ mod tests { fn resolve_hits_cache_on_second_call() { let provider = Arc::new(StubProfileProvider::new(vec![test_profile("explore")])); let service = ProfileResolutionService::new(provider.clone()); - let dir = std::env::current_dir().unwrap(); + let dir = std::env::current_dir().expect("current_dir should be available"); - let _first = service.resolve(&dir).unwrap(); - let second = service.resolve(&dir).unwrap(); + let _first = service.resolve(&dir).expect("resolve should succeed"); + let second = service.resolve(&dir).expect("resolve should succeed"); assert_eq!(second.len(), 1); assert_eq!(provider.load_count(), 1, "不应重复加载"); } @@ -304,9 +316,11 @@ mod tests { test_profile("plan"), ])); let service = ProfileResolutionService::new(provider); - let dir = std::env::current_dir().unwrap(); + let dir = std::env::current_dir().expect("current_dir should be available"); - let profile = service.find_profile(&dir, "plan").unwrap(); + let profile = service + .find_profile(&dir, "plan") + .expect("find_profile should find 'plan'"); assert_eq!(profile.id, "plan"); } @@ -314,9 +328,11 @@ mod tests { fn find_profile_returns_not_found_when_missing() { let provider = Arc::new(StubProfileProvider::new(vec![test_profile("explore")])); let service = ProfileResolutionService::new(provider); - let dir = std::env::current_dir().unwrap(); + let dir = std::env::current_dir().expect("current_dir should be available"); - let err = service.find_profile(&dir, "nonexistent").unwrap_err(); + let err = service + .find_profile(&dir, "nonexistent") + .expect_err("find_profile should fail for nonexistent"); assert!( matches!(err, ApplicationError::NotFound(ref msg) if msg.contains("nonexistent")), "should be NotFound: {err}" @@ -327,12 +343,14 @@ mod tests { fn find_profile_returns_not_found_even_with_cache_hit() { let provider = Arc::new(StubProfileProvider::new(vec![test_profile("explore")])); let service = ProfileResolutionService::new(provider); - let dir = std::env::current_dir().unwrap(); + let dir = std::env::current_dir().expect("current_dir should be available"); // 首次访问,缓存生效 - let _ = service.resolve(&dir).unwrap(); + let _ = service.resolve(&dir).expect("resolve should succeed"); // 再次查询不存在的 profile:命中缓存但返回 NotFound - let err = service.find_profile(&dir, "missing").unwrap_err(); + let err = service + .find_profile(&dir, "missing") + .expect_err("find_profile should fail for missing"); assert!( matches!(err, ApplicationError::NotFound(_)), "缓存命中不影响业务校验: {err}" @@ -343,13 +361,13 @@ mod tests { fn invalidate_clears_cache_for_path() { let provider = Arc::new(StubProfileProvider::new(vec![test_profile("explore")])); let service = ProfileResolutionService::new(provider.clone()); - let dir = std::env::current_dir().unwrap(); + let dir = std::env::current_dir().expect("current_dir should be available"); - let _ = service.resolve(&dir).unwrap(); + let _ = service.resolve(&dir).expect("resolve should succeed"); assert_eq!(provider.load_count(), 1); service.invalidate(&dir); - let _ = service.resolve(&dir).unwrap(); + let _ = service.resolve(&dir).expect("resolve should succeed"); assert_eq!(provider.load_count(), 2, "失效后应重新加载"); } @@ -357,15 +375,19 @@ mod tests { fn invalidate_all_clears_everything() { let provider = Arc::new(StubProfileProvider::new(vec![test_profile("explore")])); let service = ProfileResolutionService::new(provider.clone()); - let dir = std::env::current_dir().unwrap(); + let dir = std::env::current_dir().expect("current_dir should be available"); - let _ = service.resolve(&dir).unwrap(); - let _ = service.resolve_global().unwrap(); + let _ = service.resolve(&dir).expect("resolve should succeed"); + let _ = service + .resolve_global() + .expect("resolve_global should succeed"); service.invalidate_all(); - let _ = service.resolve(&dir).unwrap(); - let _ = service.resolve_global().unwrap(); + let _ = service.resolve(&dir).expect("resolve should succeed"); + let _ = service + .resolve_global() + .expect("resolve_global should succeed"); assert_eq!(provider.load_count(), 2, "全部失效后应重新加载"); assert_eq!(provider.global_count(), 2, "全部失效后应重新加载全局"); } @@ -375,8 +397,12 @@ mod tests { let provider = Arc::new(StubProfileProvider::new(vec![test_profile("explore")])); let service = ProfileResolutionService::new(provider.clone()); - let _first = service.resolve_global().unwrap(); - let second = service.resolve_global().unwrap(); + let _first = service + .resolve_global() + .expect("resolve_global should succeed"); + let second = service + .resolve_global() + .expect("resolve_global should succeed"); assert_eq!(second.len(), 1); assert_eq!(provider.global_count(), 1, "全局缓存应命中"); } @@ -389,7 +415,9 @@ mod tests { ])); let service = ProfileResolutionService::new(provider); - let profile = service.find_global_profile("plan").unwrap(); + let profile = service + .find_global_profile("plan") + .expect("find_global_profile should find 'plan'"); assert_eq!(profile.id, "plan"); } @@ -398,7 +426,9 @@ mod tests { let provider = Arc::new(StubProfileProvider::new(vec![])); let service = ProfileResolutionService::new(provider); - let err = service.find_global_profile("nobody").unwrap_err(); + let err = service + .find_global_profile("nobody") + .expect_err("find_global_profile should fail for 'nobody'"); assert!( matches!(err, ApplicationError::NotFound(ref msg) if msg.contains("nobody")), "should be NotFound: {err}" @@ -409,16 +439,20 @@ mod tests { fn invalidate_global_also_clears_scoped_cache() { let provider = Arc::new(MutableProfileProvider::new(vec![test_profile("explore")])); let service = ProfileResolutionService::new(provider.clone()); - let dir = std::env::current_dir().unwrap(); + let dir = std::env::current_dir().expect("current_dir should be available"); - let _ = service.resolve(&dir).unwrap(); - let _ = service.resolve_global().unwrap(); + let _ = service.resolve(&dir).expect("resolve should succeed"); + let _ = service + .resolve_global() + .expect("resolve_global should succeed"); assert_eq!(provider.load_count(), 1); assert_eq!(provider.global_count(), 1); service.invalidate_global(); - let _ = service.resolve(&dir).unwrap(); - let _ = service.resolve_global().unwrap(); + let _ = service.resolve(&dir).expect("resolve should succeed"); + let _ = service + .resolve_global() + .expect("resolve_global should succeed"); assert_eq!(provider.load_count(), 2, "全局失效也应清理 scoped cache"); assert_eq!(provider.global_count(), 2, "全局 cache 应重新加载"); } @@ -427,9 +461,9 @@ mod tests { fn invalidate_reloads_future_requests_without_mutating_existing_snapshot() { let provider = Arc::new(MutableProfileProvider::new(vec![test_profile("explore")])); let service = ProfileResolutionService::new(provider.clone()); - let dir = std::env::current_dir().unwrap(); + let dir = std::env::current_dir().expect("current_dir should be available"); - let first = service.resolve(&dir).unwrap(); + let first = service.resolve(&dir).expect("resolve should succeed"); assert_eq!(first[0].description, "test explore"); provider.replace_profiles(vec![AgentProfile { @@ -437,7 +471,7 @@ mod tests { ..test_profile("explore") }]); service.invalidate(&dir); - let second = service.resolve(&dir).unwrap(); + let second = service.resolve(&dir).expect("resolve should succeed"); assert_eq!(first[0].description, "test explore"); assert_eq!(second[0].description, "updated explore"); diff --git a/crates/application/src/execution/root.rs b/crates/application/src/execution/root.rs index c621aad1..9f263725 100644 --- a/crates/application/src/execution/root.rs +++ b/crates/application/src/execution/root.rs @@ -8,8 +8,7 @@ use std::{path::Path, sync::Arc}; use astrcode_core::{ - AgentMode, ExecutionAccepted, ResolvedExecutionLimitsSnapshot, ResolvedRuntimeConfig, - SubagentContextOverrides, + AgentMode, ExecutionAccepted, ModeId, ResolvedRuntimeConfig, SubagentContextOverrides, }; use crate::{ @@ -19,6 +18,7 @@ use crate::{ execution::{ ExecutionControl, ProfileResolutionService, ensure_profile_mode, merge_task_with_context, }, + governance_surface::{GovernanceSurfaceAssembler, RootGovernanceInput}, }; /// 根代理执行请求。 @@ -44,11 +44,11 @@ pub async fn execute_root_agent( kernel: &dyn AppKernelPort, session_runtime: &dyn AppSessionPort, profiles: &Arc, + governance: &GovernanceSurfaceAssembler, request: RootExecutionRequest, - mut runtime_config: ResolvedRuntimeConfig, + runtime_config: ResolvedRuntimeConfig, ) -> Result { validate_root_request(&request)?; - apply_execution_control(&mut runtime_config, request.control.as_ref()); validate_root_context_overrides_supported(request.context_overrides.as_ref())?; let profile = profiles.find_profile(Path::new(&request.working_dir), &request.agent_id)?; @@ -68,10 +68,19 @@ pub async fn execute_root_agent( ) .await .map_err(|e| ApplicationError::Internal(format!("failed to register root agent: {e}")))?; - let resolved_limits = ResolvedExecutionLimitsSnapshot { - allowed_tools: kernel.gateway().capabilities().tool_names(), - max_steps: Some(runtime_config.max_steps as u32), - }; + let surface = governance.root_surface( + kernel, + RootGovernanceInput { + session_id: session.session_id.clone(), + turn_id: astrcode_core::generate_turn_id(), + working_dir: request.working_dir.clone(), + profile: profile_id.clone(), + mode_id: ModeId::default(), + runtime: runtime_config, + control: request.control.clone(), + }, + )?; + let resolved_limits = surface.resolved_limits.clone(); if kernel .set_resolved_limits(&handle.agent_id, resolved_limits.clone()) .await @@ -92,11 +101,11 @@ pub async fn execute_root_agent( .submit_prompt_for_agent( &session.session_id, merged_task, - runtime_config, - astrcode_session_runtime::AgentPromptSubmission { - agent: root_execution_event_context(handle.agent_id.clone(), profile_id), - ..Default::default() - }, + surface.runtime.clone(), + surface.into_submission( + root_execution_event_context(handle.agent_id.clone(), profile_id), + None, + ), ) .await .map_err(ApplicationError::from)?; @@ -131,6 +140,10 @@ fn validate_root_request(request: &RootExecutionRequest) -> Result<(), Applicati Ok(()) } +/// 校验根执行请求不支持 context overrides。 +/// +/// 根执行没有"父上下文"可继承,任何显式 overrides 都不会真正改变执行输入。 +/// 宁可明确拒绝,也不要伪装成"已接受但生效未知"。 fn validate_root_context_overrides_supported( overrides: Option<&SubagentContextOverrides>, ) -> Result<(), ApplicationError> { @@ -157,18 +170,6 @@ fn ensure_root_profile_mode(profile: &astrcode_core::AgentProfile) -> Result<(), ) } -fn apply_execution_control( - runtime_config: &mut ResolvedRuntimeConfig, - control: Option<&ExecutionControl>, -) { - let Some(control) = control else { - return; - }; - if let Some(max_steps) = control.max_steps { - runtime_config.max_steps = max_steps as usize; - } -} - #[cfg(test)] mod tests { use super::*; @@ -259,20 +260,6 @@ mod tests { assert_eq!(merged, "main task"); } - #[test] - fn apply_execution_control_overrides_runtime_config() { - let mut runtime = ResolvedRuntimeConfig::default(); - apply_execution_control( - &mut runtime, - Some(&ExecutionControl { - max_steps: Some(5), - manual_compact: None, - }), - ); - - assert_eq!(runtime.max_steps, 5); - } - #[test] fn validate_root_context_overrides_accepts_empty_overrides() { validate_root_context_overrides_supported(Some(&SubagentContextOverrides::default())) diff --git a/crates/application/src/execution/subagent.rs b/crates/application/src/execution/subagent.rs index 7b0065bb..13b739bb 100644 --- a/crates/application/src/execution/subagent.rs +++ b/crates/application/src/execution/subagent.rs @@ -6,21 +6,22 @@ use std::sync::Arc; use astrcode_core::{ - AgentLifecycleStatus, AgentMode, AgentProfile, ExecutionAccepted, - ResolvedExecutionLimitsSnapshot, ResolvedRuntimeConfig, RuntimeMetricsRecorder, - SpawnCapabilityGrant, normalize_non_empty_unique_string_list, + AgentLifecycleStatus, AgentMode, AgentProfile, ExecutionAccepted, ModeId, + ResolvedRuntimeConfig, RuntimeMetricsRecorder, SpawnCapabilityGrant, }; use astrcode_kernel::AgentControlError; -use astrcode_session_runtime::AgentPromptSubmission; use crate::{ AgentKernelPort, AgentSessionPort, agent::{ - build_delegation_metadata, build_fresh_child_contract, persist_delegation_for_handle, - persist_resolved_limits_for_handle, subrun_event_context, + persist_delegation_for_handle, persist_resolved_limits_for_handle, subrun_event_context, }, errors::ApplicationError, execution::{ensure_profile_mode, merge_task_with_context}, + governance_surface::{ + FreshChildGovernanceInput, GovernanceBusyPolicy, GovernanceSurfaceAssembler, + build_delegation_metadata, + }, }; /// 子代理执行请求。 @@ -29,6 +30,7 @@ pub struct SubagentExecutionRequest { pub parent_agent_id: String, pub parent_turn_id: String, pub working_dir: String, + pub mode_id: ModeId, pub profile: AgentProfile, pub description: String, pub task: String, @@ -49,29 +51,37 @@ pub struct SubagentExecutionRequest { pub async fn launch_subagent( kernel: &dyn AgentKernelPort, session_runtime: &dyn AgentSessionPort, + governance: &GovernanceSurfaceAssembler, request: SubagentExecutionRequest, runtime_config: ResolvedRuntimeConfig, metrics: &Arc, ) -> Result { validate_subagent_request(&request)?; ensure_subagent_profile_mode(&request.profile)?; - let resolved_limits = resolve_child_execution_limits( - &request.parent_allowed_tools, - request.capability_grant.as_ref(), - runtime_config.max_steps as u32, - )?; - let scoped_gateway = kernel - .gateway() - .subset_for_tools_checked(&resolved_limits.allowed_tools) - .map_err(|error| ApplicationError::InvalidArgument(error.to_string()))?; - let scoped_router = scoped_gateway.capabilities().clone(); + let surface = governance + .fresh_child_surface( + kernel, + session_runtime, + FreshChildGovernanceInput { + session_id: request.parent_session_id.clone(), + turn_id: request.parent_turn_id.clone(), + working_dir: request.working_dir.clone(), + mode_id: request.mode_id.clone(), + runtime: runtime_config, + parent_allowed_tools: request.parent_allowed_tools.clone(), + capability_grant: request.capability_grant.clone(), + description: request.description.clone(), + task: request.task.clone(), + busy_policy: GovernanceBusyPolicy::BranchOnBusy, + }, + ) + .await?; let delegation = build_delegation_metadata( request.description.as_str(), request.task.as_str(), - &resolved_limits, + &surface.resolved_limits, request.capability_grant.is_some(), ); - let contract = build_fresh_child_contract(&delegation); let child_session = session_runtime .create_child_session(&request.working_dir, &request.parent_session_id) @@ -99,68 +109,32 @@ pub async fn launch_subagent( handle.agent_id ))); } - let handle = persist_resolved_limits_for_handle(kernel, handle, resolved_limits.clone()) - .await - .map_err(ApplicationError::Internal)?; + let handle = + persist_resolved_limits_for_handle(kernel, handle, surface.resolved_limits.clone()) + .await + .map_err(ApplicationError::Internal)?; let handle = persist_delegation_for_handle(kernel, handle, delegation) .await .map_err(ApplicationError::Internal)?; - let merged_task = merge_task_with_context(&request.task, request.context.as_deref()); let mut accepted = session_runtime .submit_prompt_for_agent_with_submission( &child_session.session_id, merged_task, - runtime_config, - AgentPromptSubmission { - agent: subrun_event_context(&handle), - capability_router: Some(scoped_router), - prompt_declarations: vec![contract], - resolved_limits: Some(resolved_limits), - source_tool_call_id: request.source_tool_call_id, - }, + surface.runtime.clone(), + surface.into_submission( + subrun_event_context(&handle), + request.source_tool_call_id.clone(), + ), ) .await .map_err(ApplicationError::from)?; metrics.record_child_spawned(); - accepted.agent_id = Some(handle.agent_id.into()); + accepted.agent_id = Some(handle.agent_id); Ok(accepted) } -fn resolve_child_execution_limits( - parent_allowed_tools: &[String], - capability_grant: Option<&SpawnCapabilityGrant>, - max_steps: u32, -) -> Result { - let parent_allowed_tools = - normalize_non_empty_unique_string_list(parent_allowed_tools, "parentAllowedTools") - .map_err(ApplicationError::from)?; - let allowed_tools = match capability_grant { - Some(grant) => { - let requested_tools = grant - .normalized_allowed_tools() - .map_err(ApplicationError::from)?; - let allowed_tools = requested_tools - .into_iter() - .filter(|tool| parent_allowed_tools.iter().any(|parent| parent == tool)) - .collect::>(); - if allowed_tools.is_empty() { - return Err(ApplicationError::InvalidArgument( - "capability grant 与父级当前可继承工具无交集".to_string(), - )); - } - allowed_tools - }, - None => parent_allowed_tools, - }; - - Ok(ResolvedExecutionLimitsSnapshot { - allowed_tools, - max_steps: Some(max_steps), - }) -} - fn validate_subagent_request(request: &SubagentExecutionRequest) -> Result<(), ApplicationError> { if request.parent_session_id.trim().is_empty() { return Err(ApplicationError::InvalidArgument( @@ -189,6 +163,11 @@ fn ensure_subagent_profile_mode(profile: &AgentProfile) -> Result<(), Applicatio ensure_profile_mode(profile, &[AgentMode::SubAgent, AgentMode::All], "subagent") } +/// 将 kernel spawn 错误映射为用户友好的应用层错误。 +/// +/// - MaxDepthExceeded → InvalidArgument(提示复用已有 child) +/// - MaxConcurrentExceeded → Conflict(提示等待或关闭已有 child) +/// - ParentAgentNotFound → NotFound fn map_spawn_error(error: AgentControlError) -> ApplicationError { match error { AgentControlError::MaxDepthExceeded { current, max } => { @@ -215,6 +194,7 @@ mod tests { use astrcode_core::{AgentMode, AgentProfile}; use super::*; + use crate::governance_surface::GovernanceSurfaceAssembler; fn test_profile() -> AgentProfile { AgentProfile { @@ -235,6 +215,7 @@ mod tests { parent_agent_id: "root-agent".to_string(), parent_turn_id: "turn-1".to_string(), working_dir: "/tmp/project".to_string(), + mode_id: ModeId::default(), profile: test_profile(), description: "探索代码".to_string(), task: "explore the code".to_string(), @@ -327,44 +308,7 @@ mod tests { } #[test] - fn resolve_child_execution_limits_inherits_parent_tools_when_no_grant() { - let limits = resolve_child_execution_limits( - &["read_file".to_string(), "grep".to_string()], - None, - 12, - ) - .expect("limits should resolve"); - - assert_eq!(limits.allowed_tools, vec!["read_file", "grep"]); - assert_eq!(limits.max_steps, Some(12)); - } - - #[test] - fn resolve_child_execution_limits_intersects_parent_and_grant() { - let limits = resolve_child_execution_limits( - &["read_file".to_string(), "grep".to_string()], - Some(&SpawnCapabilityGrant { - allowed_tools: vec!["grep".to_string(), "write_file".to_string()], - }), - 8, - ) - .expect("limits should resolve"); - - assert_eq!(limits.allowed_tools, vec!["grep"]); - assert_eq!(limits.max_steps, Some(8)); - } - - #[test] - fn resolve_child_execution_limits_rejects_disjoint_grant() { - let error = resolve_child_execution_limits( - &["read_file".to_string(), "grep".to_string()], - Some(&SpawnCapabilityGrant { - allowed_tools: vec!["write_file".to_string()], - }), - 8, - ) - .expect_err("disjoint grants should fail fast"); - - assert!(error.to_string().contains("无交集")); + fn governance_surface_builder_exists_for_subagent_paths() { + let _assembler = GovernanceSurfaceAssembler::default(); } } diff --git a/crates/application/src/governance_surface/assembler.rs b/crates/application/src/governance_surface/assembler.rs new file mode 100644 index 00000000..07836f07 --- /dev/null +++ b/crates/application/src/governance_surface/assembler.rs @@ -0,0 +1,381 @@ +//! 治理面装配器。 +//! +//! `GovernanceSurfaceAssembler` 是治理面子域的核心:将 mode spec、runtime 配置、 +//! 执行控制等输入编译成 `ResolvedGovernanceSurface`,供 turn 提交时一次性消费。 +//! +//! 装配过程: +//! 1. 从 `ModeCatalog` 查找 mode spec → 编译 `CapabilitySelector` 得到工具白名单 +//! 2. 构建 `PolicyContext` 和 `AgentCollaborationPolicyContext` +//! 3. 注入 prompt declarations(mode prompt + 协作指导 + skill 声明) +//! 4. 解析 busy policy(是否在 session busy 时分支或拒绝) + +use astrcode_core::{ + ResolvedExecutionLimitsSnapshot, ResolvedRuntimeConfig, ResolvedSubagentContextOverrides, + ResolvedTurnEnvelope, +}; +use astrcode_kernel::CapabilityRouter; + +use super::{ + BuildSurfaceInput, FreshChildGovernanceInput, GovernanceBusyPolicy, ResolvedGovernanceSurface, + ResumedChildGovernanceInput, RootGovernanceInput, SessionGovernanceInput, +}; +use crate::{ + AgentSessionPort, AppKernelPort, ApplicationError, CompiledModeEnvelope, ComposerResolvedSkill, + ExecutionControl, ModeCatalog, compile_mode_envelope, compile_mode_envelope_for_child, +}; + +#[derive(Debug, Clone)] +pub struct GovernanceSurfaceAssembler { + mode_catalog: ModeCatalog, +} + +impl GovernanceSurfaceAssembler { + pub fn new(mode_catalog: ModeCatalog) -> Self { + Self { mode_catalog } + } + + pub fn runtime_with_control( + &self, + mut runtime: ResolvedRuntimeConfig, + control: Option<&ExecutionControl>, + allow_manual_compact: bool, + ) -> Result { + if let Some(control) = control { + control.validate()?; + if let Some(max_steps) = control.max_steps { + runtime.max_steps = max_steps as usize; + } + if !allow_manual_compact && control.manual_compact.is_some() { + return Err(ApplicationError::InvalidArgument( + "manualCompact is not valid for prompt submission".to_string(), + )); + } + } + Ok(runtime) + } + + pub fn build_submission_skill_declaration( + &self, + skill: &ComposerResolvedSkill, + user_prompt: Option<&str>, + ) -> astrcode_core::PromptDeclaration { + let mut content = format!( + "The user explicitly selected the `{}` skill for this turn.\n\nSelected skill:\n- id: \ + {}\n- description: {}\n\nTurn contract:\n- Call the `Skill` tool for `{}` before \ + continuing.\n- Treat the user's message as the task-specific instruction for this \ + skill.\n- If the user message is empty, follow the skill's default workflow and ask \ + only if blocked.\n- Do not silently substitute a different skill unless `{}` is \ + unavailable.", + skill.id, + skill.id, + skill.description.trim(), + skill.id, + skill.id + ); + if let Some(user_prompt) = user_prompt.map(str::trim).filter(|value| !value.is_empty()) { + content.push_str(&format!("\n- User prompt focus: {user_prompt}")); + } + astrcode_core::PromptDeclaration { + block_id: format!("submission.skill.{}", skill.id), + title: format!("Selected Skill: {}", skill.id), + content, + render_target: astrcode_core::PromptDeclarationRenderTarget::System, + layer: astrcode_core::SystemPromptLayer::Dynamic, + kind: astrcode_core::PromptDeclarationKind::ExtensionInstruction, + priority_hint: Some(590), + always_include: true, + source: astrcode_core::PromptDeclarationSource::Builtin, + capability_name: None, + origin: Some(format!("skill-slash:{}", skill.id)), + } + } + + fn compile_mode_surface( + &self, + kernel: &dyn AppKernelPort, + mode_id: &astrcode_core::ModeId, + extra_prompt_declarations: Vec, + ) -> Result { + let spec = self.mode_catalog.get(mode_id).ok_or_else(|| { + ApplicationError::InvalidArgument(format!("unknown mode '{}'", mode_id)) + })?; + compile_mode_envelope( + kernel.gateway().capabilities(), + &spec, + extra_prompt_declarations, + ) + .map_err(ApplicationError::from) + } + + fn compile_child_mode_surface( + &self, + kernel: &dyn AppKernelPort, + mode_id: &astrcode_core::ModeId, + parent_allowed_tools: &[String], + capability_grant: Option<&astrcode_core::SpawnCapabilityGrant>, + ) -> Result { + let spec = self.mode_catalog.get(mode_id).ok_or_else(|| { + ApplicationError::InvalidArgument(format!("unknown mode '{}'", mode_id)) + })?; + compile_mode_envelope_for_child( + kernel.gateway().capabilities(), + &spec, + parent_allowed_tools, + capability_grant, + ) + .map_err(ApplicationError::from) + } + + fn build_surface( + &self, + input: BuildSurfaceInput, + ) -> Result { + let BuildSurfaceInput { + session_id, + turn_id, + working_dir, + profile, + compiled, + runtime, + requested_busy_policy, + resolved_overrides, + injected_messages, + leading_prompt_declaration, + } = input; + let mut prompt_declarations = compiled.envelope.prompt_declarations.clone(); + if let Some(leading) = leading_prompt_declaration { + prompt_declarations.insert(0, leading); + } + prompt_declarations.extend(super::prompt::collaboration_prompt_declarations( + &compiled.envelope.allowed_tools, + runtime.agent.max_subrun_depth, + runtime.agent.max_spawn_per_turn, + )); + let busy_policy = super::policy::resolve_busy_policy( + compiled.envelope.submit_busy_policy, + requested_busy_policy, + ); + let surface = ResolvedGovernanceSurface { + mode_id: compiled.envelope.mode_id.clone(), + runtime: runtime.clone(), + capability_router: compiled.capability_router, + prompt_declarations, + resolved_limits: ResolvedExecutionLimitsSnapshot { + allowed_tools: compiled.envelope.allowed_tools.clone(), + max_steps: Some(runtime.max_steps as u32), + }, + resolved_overrides, + injected_messages, + policy_context: super::policy::build_policy_context( + &session_id, + &turn_id, + &working_dir, + &profile, + &compiled.envelope, + ), + collaboration_policy: super::collaboration_policy_context(&runtime), + approval: super::policy::default_approval_pipeline( + &session_id, + &turn_id, + &compiled.envelope, + ), + governance_revision: super::GOVERNANCE_POLICY_REVISION.to_string(), + busy_policy, + diagnostics: compiled.envelope.diagnostics.clone(), + }; + surface.validate()?; + Ok(surface) + } + + pub fn session_surface( + &self, + kernel: &dyn AppKernelPort, + input: SessionGovernanceInput, + ) -> Result { + let runtime = self.runtime_with_control(input.runtime, input.control.as_ref(), false)?; + let compiled = + self.compile_mode_surface(kernel, &input.mode_id, input.extra_prompt_declarations)?; + self.build_surface(BuildSurfaceInput { + session_id: input.session_id, + turn_id: input.turn_id, + working_dir: input.working_dir, + profile: input.profile, + compiled, + runtime, + requested_busy_policy: input.busy_policy, + resolved_overrides: None, + injected_messages: Vec::new(), + leading_prompt_declaration: None, + }) + } + + pub fn root_surface( + &self, + kernel: &dyn AppKernelPort, + input: RootGovernanceInput, + ) -> Result { + self.session_surface( + kernel, + SessionGovernanceInput { + session_id: input.session_id, + turn_id: input.turn_id, + working_dir: input.working_dir, + profile: input.profile, + mode_id: input.mode_id, + runtime: input.runtime, + control: input.control, + extra_prompt_declarations: Vec::new(), + busy_policy: GovernanceBusyPolicy::BranchOnBusy, + }, + ) + } + + pub async fn fresh_child_surface( + &self, + kernel: &dyn AppKernelPort, + session_runtime: &dyn AgentSessionPort, + input: FreshChildGovernanceInput, + ) -> Result { + let compiled = self.compile_child_mode_surface( + kernel, + &input.mode_id, + &input.parent_allowed_tools, + input.capability_grant.as_ref(), + )?; + let resolved_overrides = ResolvedSubagentContextOverrides { + fork_mode: compiled.envelope.fork_mode.clone(), + ..ResolvedSubagentContextOverrides::default() + }; + let injected_messages = super::resolve_inherited_parent_messages( + session_runtime, + &input.session_id, + &resolved_overrides, + ) + .await?; + let delegation = super::build_delegation_metadata( + input.description.as_str(), + input.task.as_str(), + &ResolvedExecutionLimitsSnapshot { + allowed_tools: compiled.envelope.allowed_tools.clone(), + max_steps: Some(input.runtime.max_steps as u32), + }, + compiled.envelope.child_policy.restricted, + ); + self.build_surface(BuildSurfaceInput { + session_id: input.session_id, + turn_id: input.turn_id, + working_dir: input.working_dir, + profile: "subagent".to_string(), + compiled, + runtime: input.runtime, + requested_busy_policy: input.busy_policy, + resolved_overrides: Some(resolved_overrides), + injected_messages, + leading_prompt_declaration: Some(super::build_fresh_child_contract(&delegation)), + }) + } + + pub fn resumed_child_surface( + &self, + kernel: &dyn AppKernelPort, + input: ResumedChildGovernanceInput, + ) -> Result { + // resumed child 复用首次 spawn 时解析的 limits,因此用 resolved_limits 覆盖 runtime 默认值 + let mut runtime = input.runtime; + if let Some(max_steps) = input.resolved_limits.max_steps { + runtime.max_steps = max_steps as usize; + } + let compiled = self.compile_mode_surface(kernel, &input.mode_id, Vec::new())?; + // 工具白名单优先级:input.allowed_tools > resolved_limits > mode 编译结果 + let allowed_tools = if input.allowed_tools.is_empty() { + if input.resolved_limits.allowed_tools.is_empty() { + compiled.envelope.allowed_tools.clone() + } else { + input.resolved_limits.allowed_tools.clone() + } + } else { + input.allowed_tools + }; + let delegation = input.delegation.unwrap_or_else(|| { + super::build_delegation_metadata( + "", + input.message.as_str(), + &input.resolved_limits, + false, + ) + }); + // 当 allowed_tools 与 mode 编译结果一致时复用 router,否则重建子集 router + let compiled = CompiledModeEnvelope { + capability_router: if allowed_tools == compiled.envelope.allowed_tools { + compiled.capability_router + } else if allowed_tools.is_empty() { + Some(CapabilityRouter::empty()) + } else { + Some( + kernel + .gateway() + .capabilities() + .subset_for_tools_checked(&allowed_tools) + .map_err(|error| ApplicationError::InvalidArgument(error.to_string()))?, + ) + }, + envelope: ResolvedTurnEnvelope { + allowed_tools: allowed_tools.clone(), + ..compiled.envelope + }, + spec: compiled.spec, + }; + self.build_surface(BuildSurfaceInput { + session_id: input.session_id, + turn_id: input.turn_id, + working_dir: input.working_dir, + profile: "subagent".to_string(), + compiled, + runtime, + requested_busy_policy: input.busy_policy, + resolved_overrides: None, + injected_messages: Vec::new(), + leading_prompt_declaration: Some(super::build_resumed_child_contract( + &delegation, + input.message.as_str(), + input.context.as_deref(), + )), + }) + } + + pub fn tool_collaboration_context( + &self, + runtime: ResolvedRuntimeConfig, + session_id: String, + turn_id: String, + parent_agent_id: Option, + source_tool_call_id: Option, + mode_id: astrcode_core::ModeId, + ) -> super::ToolCollaborationGovernanceContext { + super::ToolCollaborationGovernanceContext::new( + super::ToolCollaborationGovernanceContextInput { + runtime: runtime.clone(), + session_id, + turn_id, + parent_agent_id, + source_tool_call_id, + policy: super::collaboration_policy_context(&runtime), + governance_revision: super::GOVERNANCE_POLICY_REVISION.to_string(), + mode_id, + }, + ) + } + + pub fn mode_catalog(&self) -> &ModeCatalog { + &self.mode_catalog + } +} + +impl Default for GovernanceSurfaceAssembler { + fn default() -> Self { + Self::new( + crate::mode::builtin_mode_catalog() + .expect("builtin governance mode catalog should build"), + ) + } +} diff --git a/crates/application/src/governance_surface/inherited.rs b/crates/application/src/governance_surface/inherited.rs new file mode 100644 index 00000000..3fb528c8 --- /dev/null +++ b/crates/application/src/governance_surface/inherited.rs @@ -0,0 +1,116 @@ +//! 父级上下文继承:子代理启动时从父 session 继承消息。 +//! +//! 支持两种继承策略: +//! - **Compact summary**:从父消息中提取压缩摘要,给子代理一个精简的上下文概览 +//! - **Recent tail**:按 fork mode 截取父消息尾部(LastNTurns 或 FullHistory) + +use astrcode_core::{ + ForkMode, LlmMessage, ResolvedSubagentContextOverrides, UserMessageOrigin, project, +}; + +use crate::{AgentSessionPort, ApplicationError}; + +pub(crate) async fn resolve_inherited_parent_messages( + session_runtime: &dyn AgentSessionPort, + parent_session_id: &str, + overrides: &ResolvedSubagentContextOverrides, +) -> Result, ApplicationError> { + let parent_events = session_runtime + .session_stored_events(&astrcode_core::SessionId::from( + parent_session_id.to_string(), + )) + .await + .map_err(ApplicationError::from)?; + let projected = project( + &parent_events + .iter() + .map(|stored| stored.event.clone()) + .collect::>(), + ); + Ok(build_inherited_messages(&projected.messages, overrides)) +} + +pub(crate) fn build_inherited_messages( + parent_messages: &[LlmMessage], + overrides: &ResolvedSubagentContextOverrides, +) -> Vec { + let mut inherited = Vec::new(); + + if overrides.include_compact_summary { + if let Some(summary) = parent_messages.iter().find(|message| { + matches!( + message, + LlmMessage::User { + origin: UserMessageOrigin::CompactSummary, + .. + } + ) + }) { + inherited.push(summary.clone()); + } + } + + if overrides.include_recent_tail { + inherited.extend(select_inherited_recent_tail( + parent_messages, + overrides.fork_mode.as_ref(), + )); + } + + inherited +} + +/// 从父消息中选择要继承的最近尾部。 +/// 先排除 CompactSummary 消息(已单独处理),再按 fork_mode 截取。 +pub(crate) fn select_inherited_recent_tail( + parent_messages: &[LlmMessage], + fork_mode: Option<&ForkMode>, +) -> Vec { + let non_summary_messages = parent_messages + .iter() + .filter(|message| { + !matches!( + message, + LlmMessage::User { + origin: UserMessageOrigin::CompactSummary, + .. + } + ) + }) + .cloned() + .collect::>(); + + match fork_mode { + Some(ForkMode::LastNTurns(turns)) => { + tail_messages_for_last_n_turns(&non_summary_messages, *turns) + }, + Some(ForkMode::FullHistory) | None => non_summary_messages, + } +} + +/// 从尾部倒数 `turns` 个 User 消息作为 turn 边界,截取最近的 N 个 turn。 +fn tail_messages_for_last_n_turns(messages: &[LlmMessage], turns: usize) -> Vec { + if turns == 0 || messages.is_empty() { + return Vec::new(); + } + + let mut remaining_turns = turns; + let mut start_index = 0usize; + for (index, message) in messages.iter().enumerate().rev() { + if matches!( + message, + LlmMessage::User { + origin: UserMessageOrigin::User, + .. + } + ) { + remaining_turns = remaining_turns.saturating_sub(1); + start_index = index; + if remaining_turns == 0 { + break; + } + } + } + + messages[start_index..].to_vec() +} diff --git a/crates/application/src/governance_surface/mod.rs b/crates/application/src/governance_surface/mod.rs new file mode 100644 index 00000000..94828865 --- /dev/null +++ b/crates/application/src/governance_surface/mod.rs @@ -0,0 +1,224 @@ +//! # 治理面子域(Governance Surface) +//! +//! 统一管理每次 turn 的治理决策:工具白名单、审批策略、子代理委派策略、协作指导 prompt。 +//! +//! 核心流程:`*GovernanceInput` → `GovernanceSurfaceAssembler` → `ResolvedGovernanceSurface` → +//! `AppAgentPromptSubmission` +//! +//! 入口场景: +//! - **Session turn**:`session_surface()` — 用户直接发起的 turn +//! - **Root execution**:`root_surface()` — 根代理首次执行(委托到 session_surface) +//! - **Fresh child**:`fresh_child_surface()` — spawn 新子代理,需要继承父级上下文 +//! - **Resumed child**:`resumed_child_surface()` — 向已有子代理 send 消息,复用已有策略 + +mod assembler; +mod inherited; +mod policy; +mod prompt; +#[cfg(test)] +mod tests; + +pub use assembler::GovernanceSurfaceAssembler; +use astrcode_core::{ + AgentCollaborationPolicyContext, CapabilityCall, LlmMessage, ModeId, PolicyContext, + ResolvedExecutionLimitsSnapshot, ResolvedRuntimeConfig, ResolvedSubagentContextOverrides, + SpawnCapabilityGrant, +}; +use astrcode_kernel::CapabilityRouter; +pub(crate) use inherited::resolve_inherited_parent_messages; +#[cfg(test)] +pub(crate) use inherited::{build_inherited_messages, select_inherited_recent_tail}; +pub use policy::{ + GOVERNANCE_APPROVAL_MODE_INHERIT, GOVERNANCE_POLICY_REVISION, + ToolCollaborationGovernanceContext, ToolCollaborationGovernanceContextInput, + collaboration_policy_context, effective_allowed_tools_for_limits, +}; +pub use prompt::{ + build_delegation_metadata, build_fresh_child_contract, build_resumed_child_contract, +}; + +use crate::{ + ApplicationError, CompiledModeEnvelope, ExecutionControl, ports::AppAgentPromptSubmission, +}; + +/// Session busy 时的行为策略。 +/// +/// - `BranchOnBusy`:自动创建分支 session 继续处理(默认) +/// - `RejectOnBusy`:拒绝请求并返回错误 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GovernanceBusyPolicy { + BranchOnBusy, + RejectOnBusy, +} + +/// 审批管线状态。 +/// +/// 如果 mode 要求审批某些能力调用,`pending` 会携带一个 `ApprovalPending` 占位骨架, +/// 在实际执行前需要用户确认。 +#[derive(Clone, PartialEq, Default)] +pub struct GovernanceApprovalPipeline { + pub pending: Option>, +} + +/// 编译完成的治理面,一次性消费的 turn 级上下文快照。 +/// +/// 包含工具白名单、审批管线、prompt declarations、注入消息、协作策略等全部治理决策。 +/// 通过 `into_submission()` 转换为应用层提交载荷,再交给 session 端口适配到底层 runtime。 +#[derive(Clone)] +pub struct ResolvedGovernanceSurface { + pub mode_id: ModeId, + pub runtime: ResolvedRuntimeConfig, + pub capability_router: Option, + pub prompt_declarations: Vec, + pub resolved_limits: ResolvedExecutionLimitsSnapshot, + pub resolved_overrides: Option, + pub injected_messages: Vec, + pub policy_context: PolicyContext, + pub collaboration_policy: AgentCollaborationPolicyContext, + pub approval: GovernanceApprovalPipeline, + pub governance_revision: String, + pub busy_policy: GovernanceBusyPolicy, + pub diagnostics: Vec, +} + +impl ResolvedGovernanceSurface { + pub fn validate(&self) -> Result<(), ApplicationError> { + if self.governance_revision.trim().is_empty() { + return Err(ApplicationError::Internal( + "governance revision must not be empty".to_string(), + )); + } + if self.collaboration_policy.policy_revision != self.governance_revision { + return Err(ApplicationError::Internal( + "collaboration policy revision must match governance surface revision".to_string(), + )); + } + Ok(()) + } + + pub fn allowed_capability_names(&self) -> Vec { + self.resolved_limits.allowed_tools.clone() + } + + pub fn prompt_facts_context(&self) -> astrcode_core::PromptGovernanceContext { + astrcode_core::PromptGovernanceContext { + allowed_capability_names: self.allowed_capability_names(), + mode_id: Some(self.mode_id.clone()), + approval_mode: if self.approval.pending.is_some() { + "required".to_string() + } else { + GOVERNANCE_APPROVAL_MODE_INHERIT.to_string() + }, + policy_revision: self.governance_revision.clone(), + max_subrun_depth: Some(self.collaboration_policy.max_subrun_depth), + max_spawn_per_turn: Some(self.collaboration_policy.max_spawn_per_turn), + } + } + + pub fn into_submission( + self, + agent: astrcode_core::AgentEventContext, + source_tool_call_id: Option, + ) -> AppAgentPromptSubmission { + let prompt_governance = self.prompt_facts_context(); + AppAgentPromptSubmission { + agent, + capability_router: self.capability_router, + prompt_declarations: self.prompt_declarations, + resolved_limits: Some(self.resolved_limits), + resolved_overrides: self.resolved_overrides, + injected_messages: self.injected_messages, + source_tool_call_id, + policy_context: Some(self.policy_context), + governance_revision: Some(self.governance_revision), + approval: self.approval.pending, + prompt_governance: Some(prompt_governance), + } + } + + pub async fn check_model_request( + &self, + engine: &dyn astrcode_core::PolicyEngine, + request: astrcode_core::ModelRequest, + ) -> astrcode_core::Result { + engine + .check_model_request(request, &self.policy_context) + .await + } + + pub async fn check_capability_call( + &self, + engine: &dyn astrcode_core::PolicyEngine, + call: CapabilityCall, + ) -> astrcode_core::Result> { + engine + .check_capability_call(call, &self.policy_context) + .await + } +} + +struct BuildSurfaceInput { + session_id: String, + turn_id: String, + working_dir: String, + profile: String, + compiled: CompiledModeEnvelope, + runtime: ResolvedRuntimeConfig, + requested_busy_policy: GovernanceBusyPolicy, + resolved_overrides: Option, + injected_messages: Vec, + leading_prompt_declaration: Option, +} + +#[derive(Debug, Clone)] +pub struct SessionGovernanceInput { + pub session_id: String, + pub turn_id: String, + pub working_dir: String, + pub profile: String, + pub mode_id: ModeId, + pub runtime: ResolvedRuntimeConfig, + pub control: Option, + pub extra_prompt_declarations: Vec, + pub busy_policy: GovernanceBusyPolicy, +} + +#[derive(Debug, Clone)] +pub struct RootGovernanceInput { + pub session_id: String, + pub turn_id: String, + pub working_dir: String, + pub profile: String, + pub mode_id: ModeId, + pub runtime: ResolvedRuntimeConfig, + pub control: Option, +} + +#[derive(Debug, Clone)] +pub struct FreshChildGovernanceInput { + pub session_id: String, + pub turn_id: String, + pub working_dir: String, + pub mode_id: ModeId, + pub runtime: ResolvedRuntimeConfig, + pub parent_allowed_tools: Vec, + pub capability_grant: Option, + pub description: String, + pub task: String, + pub busy_policy: GovernanceBusyPolicy, +} + +#[derive(Debug, Clone)] +pub struct ResumedChildGovernanceInput { + pub session_id: String, + pub turn_id: String, + pub working_dir: String, + pub mode_id: ModeId, + pub runtime: ResolvedRuntimeConfig, + pub allowed_tools: Vec, + pub resolved_limits: ResolvedExecutionLimitsSnapshot, + pub delegation: Option, + pub message: String, + pub context: Option, + pub busy_policy: GovernanceBusyPolicy, +} diff --git a/crates/application/src/governance_surface/policy.rs b/crates/application/src/governance_surface/policy.rs new file mode 100644 index 00000000..7c555438 --- /dev/null +++ b/crates/application/src/governance_surface/policy.rs @@ -0,0 +1,198 @@ +//! 治理策略上下文与审批管线构建。 +//! +//! 提供三个核心功能: +//! - 构建协作策略上下文(`collaboration_policy_context`),包含 depth/spawn 限制 +//! - 构建审批管线(`default_approval_pipeline`),当 mode 要求审批时安装占位骨架 +//! - 计算有效工具列表(`effective_allowed_tools_for_limits`),空列表回退到全量 + +use astrcode_core::{ + AgentCollaborationPolicyContext, ApprovalPending, ApprovalRequest, CapabilityCall, ModeId, + PolicyContext, ResolvedExecutionLimitsSnapshot, ResolvedRuntimeConfig, ResolvedTurnEnvelope, +}; +use serde_json::{Value, json}; + +use super::{GovernanceApprovalPipeline, GovernanceBusyPolicy}; + +pub const GOVERNANCE_POLICY_REVISION: &str = "governance-surface-v1"; +pub const GOVERNANCE_APPROVAL_MODE_INHERIT: &str = "inherit"; + +#[derive(Debug, Clone)] +pub struct ToolCollaborationGovernanceContext { + runtime: ResolvedRuntimeConfig, + session_id: String, + turn_id: String, + parent_agent_id: Option, + source_tool_call_id: Option, + policy: AgentCollaborationPolicyContext, + governance_revision: String, + mode_id: ModeId, +} + +#[derive(Debug, Clone)] +pub struct ToolCollaborationGovernanceContextInput { + pub runtime: ResolvedRuntimeConfig, + pub session_id: String, + pub turn_id: String, + pub parent_agent_id: Option, + pub source_tool_call_id: Option, + pub policy: AgentCollaborationPolicyContext, + pub governance_revision: String, + pub mode_id: ModeId, +} + +impl ToolCollaborationGovernanceContext { + pub fn new(input: ToolCollaborationGovernanceContextInput) -> Self { + Self { + runtime: input.runtime, + session_id: input.session_id, + turn_id: input.turn_id, + parent_agent_id: input.parent_agent_id, + source_tool_call_id: input.source_tool_call_id, + policy: input.policy, + governance_revision: input.governance_revision, + mode_id: input.mode_id, + } + } + + pub fn runtime(&self) -> &ResolvedRuntimeConfig { + &self.runtime + } + + pub fn session_id(&self) -> &str { + &self.session_id + } + + pub fn turn_id(&self) -> &str { + &self.turn_id + } + + pub fn parent_agent_id(&self) -> Option { + self.parent_agent_id.clone() + } + + pub fn source_tool_call_id(&self) -> Option { + self.source_tool_call_id.clone() + } + + pub fn policy(&self) -> &AgentCollaborationPolicyContext { + &self.policy + } + + pub fn governance_revision(&self) -> &str { + &self.governance_revision + } + + pub fn mode_id(&self) -> &ModeId { + &self.mode_id + } +} + +pub fn collaboration_policy_context( + runtime: &ResolvedRuntimeConfig, +) -> AgentCollaborationPolicyContext { + AgentCollaborationPolicyContext { + policy_revision: GOVERNANCE_POLICY_REVISION.to_string(), + max_subrun_depth: runtime.agent.max_subrun_depth, + max_spawn_per_turn: runtime.agent.max_spawn_per_turn, + } +} + +pub fn effective_allowed_tools_for_limits( + gateway: &astrcode_kernel::KernelGateway, + resolved_limits: &ResolvedExecutionLimitsSnapshot, +) -> Vec { + if resolved_limits.allowed_tools.is_empty() { + gateway.capabilities().tool_names() + } else { + resolved_limits.allowed_tools.clone() + } +} + +pub(super) fn build_policy_context( + session_id: &str, + turn_id: &str, + working_dir: &str, + profile: &str, + envelope: &ResolvedTurnEnvelope, +) -> PolicyContext { + PolicyContext { + session_id: session_id.to_string(), + turn_id: turn_id.to_string(), + step_index: 0, + working_dir: working_dir.to_string(), + profile: profile.to_string(), + metadata: json!({ + "governanceRevision": GOVERNANCE_POLICY_REVISION, + "modeId": envelope.mode_id, + "allowedCapabilityNames": envelope.allowed_tools, + "modeDiagnostics": envelope.diagnostics, + }), + } +} + +pub(super) fn default_approval_pipeline( + session_id: &str, + turn_id: &str, + envelope: &ResolvedTurnEnvelope, +) -> GovernanceApprovalPipeline { + if !envelope.action_policies.requires_approval() { + return GovernanceApprovalPipeline { pending: None }; + } + // 安装占位审批骨架:当前 disabled,后续会接入真实审批引擎 + GovernanceApprovalPipeline { + pending: Some(ApprovalPending { + request: ApprovalRequest { + request_id: format!("approval-skeleton:{session_id}:{turn_id}"), + session_id: session_id.to_string(), + turn_id: turn_id.to_string(), + capability: astrcode_core::CapabilitySpec::builder( + "governance.approval.placeholder", + astrcode_core::CapabilityKind::Tool, + ) + .description("placeholder approval skeleton") + .schema(json!({"type": "object"}), json!({"type": "object"})) + .build() + .expect("placeholder capability should build"), + payload: json!({ + "modeId": envelope.mode_id, + "allowedCapabilityNames": envelope.allowed_tools, + }), + prompt: "Governance approval skeleton is installed but disabled by default." + .to_string(), + default: astrcode_core::ApprovalDefault::Allow, + metadata: json!({ + "disabled": true, + "governanceRevision": GOVERNANCE_POLICY_REVISION, + "modeId": envelope.mode_id, + }), + }, + action: CapabilityCall { + request_id: format!("approval-call:{session_id}:{turn_id}"), + capability: astrcode_core::CapabilitySpec::builder( + "governance.approval.placeholder", + astrcode_core::CapabilityKind::Tool, + ) + .description("placeholder approval skeleton") + .schema(json!({"type": "object"}), json!({"type": "object"})) + .build() + .expect("placeholder capability should build"), + payload: Value::Null, + metadata: json!({ + "disabled": true, + "modeId": envelope.mode_id, + }), + }, + }), + } +} + +/// 解析 busy policy:mode 级别 RejectOnBusy 强制覆盖,否则使用请求方指定的策略。 +pub(super) fn resolve_busy_policy( + submit_busy_policy: astrcode_core::SubmitBusyPolicy, + requested_busy_policy: GovernanceBusyPolicy, +) -> GovernanceBusyPolicy { + match submit_busy_policy { + astrcode_core::SubmitBusyPolicy::BranchOnBusy => requested_busy_policy, + astrcode_core::SubmitBusyPolicy::RejectOnBusy => GovernanceBusyPolicy::RejectOnBusy, + } +} diff --git a/crates/application/src/governance_surface/prompt.rs b/crates/application/src/governance_surface/prompt.rs new file mode 100644 index 00000000..4e6f3569 --- /dev/null +++ b/crates/application/src/governance_surface/prompt.rs @@ -0,0 +1,224 @@ +//! 治理 prompt 声明构建。 +//! +//! 生成子代理委派相关的 prompt declarations: +//! - `build_delegation_metadata`:构建委派元数据(责任摘要、复用边界、能力限制) +//! - `build_fresh_child_contract`:新子代理的系统 prompt 契约 +//! - `build_resumed_child_contract`:继续委派的增量指令 prompt +//! - `collaboration_prompt_declarations`:四工具协作指导 prompt + +use astrcode_core::{ + PromptDeclaration, PromptDeclarationKind, PromptDeclarationRenderTarget, + PromptDeclarationSource, ResolvedExecutionLimitsSnapshot, SystemPromptLayer, +}; + +const AGENT_COLLABORATION_TOOLS: &[&str] = &["spawn", "send", "observe", "close"]; + +pub fn build_delegation_metadata( + description: &str, + prompt: &str, + resolved_limits: &ResolvedExecutionLimitsSnapshot, + restricted: bool, +) -> astrcode_core::DelegationMetadata { + let responsibility_summary = compact_delegation_summary(description, prompt); + let reuse_scope_summary = if restricted { + "只有当下一步仍属于同一责任分支,且所需操作仍落在当前收缩后的 capability surface \ + 内时,才应继续复用这个 child。" + .to_string() + } else { + "只有当下一步仍属于同一责任分支时,才应继续复用这个 child;若责任边界已经改变,应 close \ + 当前分支并重新选择更合适的执行主体。" + .to_string() + }; + + astrcode_core::DelegationMetadata { + responsibility_summary, + reuse_scope_summary, + restricted, + capability_limit_summary: restricted + .then(|| capability_limit_summary(&resolved_limits.allowed_tools)) + .flatten(), + } +} + +pub fn build_fresh_child_contract( + metadata: &astrcode_core::DelegationMetadata, +) -> PromptDeclaration { + let mut content = format!( + "You are a delegated child responsible for one isolated branch.\n\nResponsibility \ + branch:\n- {}\n\nFresh-child rule:\n- Treat this as a new responsibility branch with its \ + own ownership boundary.\n- Do not expand into unrelated exploration or \ + implementation.\n\nUnified send contract:\n- Use downstream `send(agentId + message)` \ + only when you need a direct child to continue a more specific sub-branch.\n- When this \ + branch reaches progress, completion, failure, or a close request, use upstream \ + `send(kind + payload)` to report to your direct parent.\n- Do not wait for an extra \ + confirmation loop before reporting terminal state.\n\nReuse boundary:\n- {}", + metadata.responsibility_summary, metadata.reuse_scope_summary + ); + if let Some(limit_summary) = &metadata.capability_limit_summary { + content.push_str(&format!( + "\n\nCapability limit:\n- {limit_summary}\n- Do not take work that needs tools \ + outside this surface." + )); + } + + PromptDeclaration { + block_id: "child.execution.contract".to_string(), + title: "Child Execution Contract".to_string(), + content, + render_target: PromptDeclarationRenderTarget::System, + layer: SystemPromptLayer::Inherited, + kind: PromptDeclarationKind::ExtensionInstruction, + priority_hint: Some(585), + always_include: true, + source: PromptDeclarationSource::Builtin, + capability_name: None, + origin: Some("child-contract:fresh".to_string()), + } +} + +pub fn build_resumed_child_contract( + metadata: &astrcode_core::DelegationMetadata, + message: &str, + context: Option<&str>, +) -> PromptDeclaration { + let mut content = format!( + "You are continuing an existing delegated child branch.\n\nResponsibility continuity:\n- \ + Keep ownership of the same branch: {}\n\nResumed-child rule:\n- Prioritize the latest \ + delta instruction from the parent.\n- Do not restate or reinterpret the whole original \ + brief unless the new delta requires it.\n\nDelta instruction:\n- {}", + metadata.responsibility_summary, + message.trim() + ); + if let Some(context) = context.filter(|value| !value.trim().is_empty()) { + content.push_str(&format!("\n- Supplementary context: {}", context.trim())); + } + content.push_str(&format!( + "\n\nUnified send contract:\n- Keep using downstream `send(agentId + message)` only for \ + direct child delegation inside the same branch.\n- Use upstream `send(kind + payload)` \ + to report concrete progress, completion, failure, or a close request back to your direct \ + parent.\n- Do not restate the whole branch transcript when reporting upward.\n\nReuse \ + boundary:\n- {}", + metadata.reuse_scope_summary + )); + if let Some(limit_summary) = &metadata.capability_limit_summary { + content.push_str(&format!( + "\n\nCapability limit:\n- {limit_summary}\n- If the delta now needs broader tools, \ + stop stretching this child and let the parent choose a different branch." + )); + } + + PromptDeclaration { + block_id: "child.execution.contract".to_string(), + title: "Child Execution Contract".to_string(), + content, + render_target: PromptDeclarationRenderTarget::System, + layer: SystemPromptLayer::Inherited, + kind: PromptDeclarationKind::ExtensionInstruction, + priority_hint: Some(585), + always_include: true, + source: PromptDeclarationSource::Builtin, + capability_name: None, + origin: Some("child-contract:resumed".to_string()), + } +} + +pub(super) fn collaboration_prompt_declarations( + allowed_tools: &[String], + max_depth: usize, + max_spawn_per_turn: usize, +) -> Vec { + if !allowed_tools.iter().any(|tool_name| { + AGENT_COLLABORATION_TOOLS + .iter() + .any(|candidate| tool_name == candidate) + }) { + return Vec::new(); + } + + vec![PromptDeclaration { + block_id: "governance.collaboration.guide".to_string(), + title: "Child Agent Collaboration Guide".to_string(), + content: format!( + "Use the child-agent tools as one decision protocol.\n\nKeep `agentId` exact. Copy it \ + byte-for-byte in later `send`, `observe`, and `close` calls. Never renumber it, \ + never zero-pad it, and never invent `agent-01` when the tool result says \ + `agent-1`.\n\nDefault protocol:\n1. `spawn` only for a new isolated responsibility \ + with real parallel or context-isolation value.\n2. `send` when the same child should \ + take one concrete next step on the same responsibility branch.\n3. `observe` only \ + when the next decision depends on current child state.\n4. `close` when the branch \ + is done or no longer useful.\n\nDelegation modes:\n- Fresh child: use `spawn` for a \ + new responsibility branch. Give the child a full briefing: task scope, boundaries, \ + expected deliverable, and any important focus or exclusion. Do not treat a short \ + nudge like 'take a look' as a sufficient fresh-child brief.\n- Resumed child: use \ + `send` when the same child should continue the same responsibility branch. Send one \ + concrete delta instruction or clarification, not a full re-briefing of the original \ + task.\n- Restricted child: when you narrow a child with `capabilityGrant`, assign \ + only work that fits that reduced capability surface. If the next step needs tools \ + the restricted child does not have, choose a different child or do the work locally \ + instead of forcing a mismatch.\n\n`Idle` is normal and reusable. Do not respawn just \ + because a child finished one turn. Reuse an idle child with `send(agentId, message)` \ + when the responsibility stays the same. If you are unsure whether the child is still \ + running, idle, or terminated, call `observe(agentId)` once and act on the \ + result.\n\nSpawn sparingly. The runtime enforces a maximum child depth of \ + {max_depth} and at most {max_spawn_per_turn} new children per turn. Start with one \ + child unless there are clearly separate workstreams. Do not blanket-spawn agents \ + just to explore a repo broadly.\n\nAvoid waste:\n- Do not loop on `observe` with no \ + decision attached.\n- If a child is still running and you are simply waiting, prefer \ + a brief shell sleep over spending another tool call on `observe`.\n- Pick one wait \ + mode per pause: either `observe` now because you need a snapshot for the next \ + decision, or sleep briefly because you are only waiting. Do not alternate `shell` \ + and `observe` in a polling loop.\n- After a wait, call `observe` only when the next \ + decision depends on the child's current state.\n- Do not immediately re-`observe` \ + the same child after a fresh delivery unless the state is genuinely ambiguous.\n- Do \ + not stack speculative `send` calls.\n- Do not spawn a new child when an existing \ + idle child already owns the responsibility.\n\nIf a delivery satisfies the request, \ + `close` the branch. If the same child should continue, `send` one precise follow-up. \ + If you see the same `deliveryId` again after recovery, treat it as the same \ + delivery, not a new task.\n\nWhen you are the child on a delegated task, use \ + upstream `send(kind + payload)` to deliver a formal message to your direct parent. \ + Report `progress`, `completed`, `failed`, or `close_request` explicitly. Do not wait \ + for the parent to infer state from raw intermediate steps, and do not end with an \ + open loop like '继续观察中' unless you are also sending a non-terminal `progress` \ + delivery that keeps the branch alive.\n\nWhen you are the parent and receive a child \ + delivery, treat it as a decision point. Do not leave it hanging and do not \ + immediately re-observe the same child unless the state is unclear. Decide \ + immediately whether the result is complete enough to `close` the branch, or whether \ + the same child should continue with one concrete `send` follow-up that names the \ + exact next step." + ), + render_target: PromptDeclarationRenderTarget::System, + layer: SystemPromptLayer::Dynamic, + kind: PromptDeclarationKind::ExtensionInstruction, + priority_hint: Some(600), + always_include: true, + source: PromptDeclarationSource::Builtin, + capability_name: None, + origin: Some("governance:collaboration-guide".to_string()), + }] +} + +fn compact_delegation_summary(description: &str, prompt: &str) -> String { + let candidate = if !description.trim().is_empty() { + description.trim() + } else { + prompt.trim() + }; + let normalized = candidate.split_whitespace().collect::>().join(" "); + let mut chars = normalized.chars(); + let truncated = chars.by_ref().take(160).collect::(); + if chars.next().is_some() { + format!("{truncated}…") + } else { + truncated + } +} + +fn capability_limit_summary(allowed_tools: &[String]) -> Option { + if allowed_tools.is_empty() { + return None; + } + Some(format!( + "本分支当前只允许使用这些工具:{}。", + allowed_tools.join(", ") + )) +} diff --git a/crates/application/src/governance_surface/tests.rs b/crates/application/src/governance_surface/tests.rs new file mode 100644 index 00000000..e362e7a8 --- /dev/null +++ b/crates/application/src/governance_surface/tests.rs @@ -0,0 +1,426 @@ +//! 治理面子域集成测试。 +//! +//! 验证 `GovernanceSurfaceAssembler` 在不同场景下的端到端行为: +//! - session turn 治理面构建与 prompt declarations 注入 +//! - fresh/resumed child 治理面继承与委派策略 +//! - 工具白名单、审批管线、协作策略上下文的正确性 +//! - 各种 capability selector(all / subset / none / union / difference)的编译结果 + +use std::sync::Arc; + +use astrcode_core::{ + AllowAllPolicyEngine, ApprovalDefault, AstrError, CapabilityKind, CapabilitySpec, LlmMessage, + LlmOutput, LlmProvider, LlmRequest, ModeId, ModelLimits, ModelRequest, PromptBuildOutput, + PromptBuildRequest, PromptProvider, ResolvedExecutionLimitsSnapshot, ResolvedRuntimeConfig, + ResourceProvider, ResourceReadResult, ResourceRequestContext, SideEffect, Stability, + UserMessageOrigin, +}; +use async_trait::async_trait; +use serde_json::{Value, json}; + +use super::{ + FreshChildGovernanceInput, GOVERNANCE_POLICY_REVISION, GovernanceApprovalPipeline, + GovernanceBusyPolicy, GovernanceSurfaceAssembler, ResolvedGovernanceSurface, + ResumedChildGovernanceInput, RootGovernanceInput, SessionGovernanceInput, + build_inherited_messages, collaboration_policy_context, select_inherited_recent_tail, +}; +use crate::{ExecutionControl, test_support::StubSessionPort}; + +#[derive(Debug)] +struct TestTool { + name: &'static str, +} + +#[async_trait] +impl astrcode_core::Tool for TestTool { + fn definition(&self) -> astrcode_core::ToolDefinition { + astrcode_core::ToolDefinition { + name: self.name.to_string(), + description: self.name.to_string(), + parameters: json!({"type":"object"}), + } + } + + fn capability_spec( + &self, + ) -> std::result::Result { + CapabilitySpec::builder(self.name, CapabilityKind::Tool) + .description(self.name) + .schema(json!({"type":"object"}), json!({"type":"object"})) + .side_effect(SideEffect::Workspace) + .stability(Stability::Stable) + .build() + } + + async fn execute( + &self, + tool_call_id: String, + _input: Value, + _ctx: &astrcode_core::ToolContext, + ) -> astrcode_core::Result { + Ok(astrcode_core::ToolExecutionResult { + tool_call_id, + tool_name: self.name.to_string(), + ok: true, + output: String::new(), + child_ref: None, + error: None, + metadata: None, + duration_ms: 0, + truncated: false, + }) + } +} + +fn router() -> astrcode_kernel::CapabilityRouter { + astrcode_kernel::CapabilityRouter::builder() + .register_invoker(Arc::new( + astrcode_kernel::ToolCapabilityInvoker::new(Arc::new(TestTool { name: "spawn" })) + .expect("tool should build"), + )) + .register_invoker(Arc::new( + astrcode_kernel::ToolCapabilityInvoker::new(Arc::new(TestTool { name: "readFile" })) + .expect("tool should build"), + )) + .build() + .expect("router should build") +} + +#[derive(Debug)] +struct NoopLlmProvider; + +#[async_trait] +impl LlmProvider for NoopLlmProvider { + async fn generate( + &self, + _request: LlmRequest, + _sink: Option, + ) -> astrcode_core::Result { + Err(AstrError::Validation("noop".to_string())) + } + + fn model_limits(&self) -> ModelLimits { + ModelLimits { + context_window: 32_000, + max_output_tokens: 4_096, + } + } +} + +#[derive(Debug)] +struct NoopPromptProvider; + +#[async_trait] +impl PromptProvider for NoopPromptProvider { + async fn build_prompt( + &self, + _request: PromptBuildRequest, + ) -> astrcode_core::Result { + Ok(PromptBuildOutput { + system_prompt: "noop".to_string(), + system_prompt_blocks: Vec::new(), + prompt_cache_hints: Default::default(), + cache_metrics: Default::default(), + metadata: json!({}), + }) + } +} + +#[derive(Debug)] +struct NoopResourceProvider; + +#[async_trait] +impl ResourceProvider for NoopResourceProvider { + async fn read_resource( + &self, + uri: &str, + _context: &ResourceRequestContext, + ) -> astrcode_core::Result { + Ok(ResourceReadResult { + uri: uri.to_string(), + content: json!({}), + metadata: json!({}), + }) + } +} + +#[test] +fn session_surface_builds_collaboration_prompt_and_policy_context() { + let kernel = astrcode_kernel::Kernel::builder() + .with_capabilities(router()) + .with_llm_provider(Arc::new(NoopLlmProvider)) + .with_prompt_provider(Arc::new(NoopPromptProvider)) + .with_resource_provider(Arc::new(NoopResourceProvider)) + .build() + .expect("kernel should build"); + let assembler = GovernanceSurfaceAssembler::default(); + let surface = assembler + .session_surface( + &kernel, + SessionGovernanceInput { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + working_dir: ".".to_string(), + profile: "coding".to_string(), + mode_id: ModeId::code(), + runtime: ResolvedRuntimeConfig::default(), + control: None, + extra_prompt_declarations: Vec::new(), + busy_policy: GovernanceBusyPolicy::BranchOnBusy, + }, + ) + .expect("surface should build"); + + assert_eq!(surface.governance_revision, GOVERNANCE_POLICY_REVISION); + assert!( + surface + .prompt_declarations + .iter() + .any(|declaration| declaration.origin.as_deref() + == Some("governance:collaboration-guide")) + ); + assert_eq!(surface.prompt_facts_context().approval_mode, "inherit"); +} + +#[tokio::test] +async fn surface_policy_pipeline_defaults_to_allow_all() { + let surface = ResolvedGovernanceSurface { + mode_id: ModeId::code(), + runtime: ResolvedRuntimeConfig::default(), + capability_router: None, + prompt_declarations: Vec::new(), + resolved_limits: ResolvedExecutionLimitsSnapshot { + allowed_tools: vec!["readFile".to_string()], + max_steps: Some(4), + }, + resolved_overrides: None, + injected_messages: Vec::new(), + policy_context: astrcode_core::PolicyContext { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + step_index: 0, + working_dir: ".".to_string(), + profile: "coding".to_string(), + metadata: json!({}), + }, + collaboration_policy: collaboration_policy_context(&ResolvedRuntimeConfig::default()), + approval: GovernanceApprovalPipeline { + pending: Some(astrcode_core::ApprovalPending { + request: astrcode_core::ApprovalRequest { + request_id: "approval".to_string(), + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + capability: CapabilitySpec::builder("placeholder", CapabilityKind::Tool) + .description("placeholder") + .schema(json!({"type":"object"}), json!({"type":"object"})) + .build() + .expect("placeholder should build"), + payload: Value::Null, + prompt: "disabled".to_string(), + default: ApprovalDefault::Allow, + metadata: json!({}), + }, + action: astrcode_core::CapabilityCall { + request_id: "approval-call".to_string(), + capability: CapabilitySpec::builder("placeholder", CapabilityKind::Tool) + .description("placeholder") + .schema(json!({"type":"object"}), json!({"type":"object"})) + .build() + .expect("placeholder should build"), + payload: Value::Null, + metadata: json!({}), + }, + }), + }, + governance_revision: GOVERNANCE_POLICY_REVISION.to_string(), + busy_policy: GovernanceBusyPolicy::BranchOnBusy, + diagnostics: Vec::new(), + }; + let request = ModelRequest { + messages: vec![LlmMessage::User { + content: "hello".to_string(), + origin: UserMessageOrigin::User, + }], + tools: Vec::new(), + system_prompt: Some("system".to_string()), + system_prompt_blocks: Vec::new(), + }; + let checked = surface + .check_model_request(&AllowAllPolicyEngine, request) + .await + .expect("request should pass"); + assert_eq!(checked.system_prompt.as_deref(), Some("system")); + assert!(surface.approval.pending.is_some()); +} + +#[test] +fn inherited_messages_follow_compact_and_tail_policy() { + let inherited = build_inherited_messages( + &[ + LlmMessage::User { + content: "summary".to_string(), + origin: UserMessageOrigin::CompactSummary, + }, + LlmMessage::User { + content: "turn-1".to_string(), + origin: UserMessageOrigin::User, + }, + LlmMessage::Assistant { + content: "answer-1".to_string(), + tool_calls: Vec::new(), + reasoning: None, + }, + LlmMessage::User { + content: "turn-2".to_string(), + origin: UserMessageOrigin::User, + }, + ], + &astrcode_core::ResolvedSubagentContextOverrides { + include_compact_summary: true, + include_recent_tail: true, + fork_mode: Some(astrcode_core::ForkMode::LastNTurns(1)), + ..astrcode_core::ResolvedSubagentContextOverrides::default() + }, + ); + assert_eq!(inherited.len(), 2); +} + +#[test] +fn root_surface_applies_execution_control_without_special_case_logic() { + let kernel = astrcode_kernel::Kernel::builder() + .with_capabilities(router()) + .with_llm_provider(Arc::new(NoopLlmProvider)) + .with_prompt_provider(Arc::new(NoopPromptProvider)) + .with_resource_provider(Arc::new(NoopResourceProvider)) + .build() + .expect("kernel should build"); + let assembler = GovernanceSurfaceAssembler::default(); + let surface = assembler + .root_surface( + &kernel, + RootGovernanceInput { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + working_dir: ".".to_string(), + profile: "coding".to_string(), + mode_id: ModeId::code(), + runtime: ResolvedRuntimeConfig::default(), + control: Some(ExecutionControl { + max_steps: Some(7), + manual_compact: None, + }), + }, + ) + .expect("surface should build"); + + assert!(surface.capability_router.is_none()); + assert_eq!(surface.resolved_limits.max_steps, Some(7)); + assert_eq!(surface.busy_policy, GovernanceBusyPolicy::BranchOnBusy); +} + +#[tokio::test] +async fn fresh_child_surface_restricts_tools_and_inherits_governance_defaults() { + let kernel = astrcode_kernel::Kernel::builder() + .with_capabilities(router()) + .with_llm_provider(Arc::new(NoopLlmProvider)) + .with_prompt_provider(Arc::new(NoopPromptProvider)) + .with_resource_provider(Arc::new(NoopResourceProvider)) + .build() + .expect("kernel should build"); + let assembler = GovernanceSurfaceAssembler::default(); + let session_runtime = StubSessionPort::default(); + let surface = assembler + .fresh_child_surface( + &kernel, + &session_runtime, + FreshChildGovernanceInput { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + working_dir: ".".to_string(), + mode_id: ModeId::code(), + runtime: ResolvedRuntimeConfig::default(), + parent_allowed_tools: vec!["spawn".to_string(), "readFile".to_string()], + capability_grant: Some(astrcode_core::SpawnCapabilityGrant { + allowed_tools: vec!["readFile".to_string()], + }), + description: "只做读取".to_string(), + task: "inspect file".to_string(), + busy_policy: GovernanceBusyPolicy::BranchOnBusy, + }, + ) + .await + .expect("surface should build"); + + assert_eq!( + surface.resolved_limits.allowed_tools, + vec!["readFile".to_string()] + ); + assert!(surface.capability_router.is_some()); + assert!( + surface + .prompt_declarations + .iter() + .any(|declaration| declaration.origin.as_deref() == Some("child-contract:fresh")) + ); +} + +#[test] +fn resumed_child_surface_reuses_existing_limits_and_contract_source() { + let kernel = astrcode_kernel::Kernel::builder() + .with_capabilities(router()) + .with_llm_provider(Arc::new(NoopLlmProvider)) + .with_prompt_provider(Arc::new(NoopPromptProvider)) + .with_resource_provider(Arc::new(NoopResourceProvider)) + .build() + .expect("kernel should build"); + let assembler = GovernanceSurfaceAssembler::default(); + let limits = ResolvedExecutionLimitsSnapshot { + allowed_tools: vec!["readFile".to_string()], + max_steps: Some(5), + }; + let surface = assembler + .resumed_child_surface( + &kernel, + ResumedChildGovernanceInput { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + working_dir: ".".to_string(), + mode_id: ModeId::code(), + runtime: ResolvedRuntimeConfig::default(), + allowed_tools: Vec::new(), + resolved_limits: limits.clone(), + delegation: None, + message: "continue with the same branch".to_string(), + context: Some("keep scope tight".to_string()), + busy_policy: GovernanceBusyPolicy::RejectOnBusy, + }, + ) + .expect("surface should build"); + + assert_eq!(surface.resolved_limits.allowed_tools, limits.allowed_tools); + assert_eq!(surface.resolved_limits.max_steps, limits.max_steps); + assert_eq!(surface.busy_policy, GovernanceBusyPolicy::RejectOnBusy); + assert!( + surface + .prompt_declarations + .iter() + .any(|declaration| declaration.origin.as_deref() == Some("child-contract:resumed")) + ); +} + +#[test] +fn select_inherited_recent_tail_keeps_full_history_without_fork_mode() { + let messages = vec![ + LlmMessage::User { + content: "hello".to_string(), + origin: UserMessageOrigin::User, + }, + LlmMessage::Assistant { + content: "world".to_string(), + tool_calls: Vec::new(), + reasoning: None, + }, + ]; + + assert_eq!(select_inherited_recent_tail(&messages, None), messages); +} diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index 51308fd0..b09b3444 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -1,12 +1,27 @@ +//! # Astrcode 应用层 +//! +//! 纯业务编排层,不依赖任何 adapter-* crate,只依赖 core / kernel / session-runtime。 +//! +//! 核心职责: +//! - 通过 `App` 结构体暴露所有业务用例入口 +//! - 持有并编排 governance surface(治理面)、mode catalog(模式目录)等基础设施 +//! - 通过 port trait 与 adapter 层解耦(AppKernelPort / AppSessionPort / ComposerSkillPort) + use std::{path::Path, sync::Arc}; use astrcode_core::AgentProfile; use tokio::sync::broadcast; +use crate::config::ConfigService; + mod agent_use_cases; +mod governance_surface; mod ports; +mod session_plan; mod session_use_cases; -mod terminal_use_cases; +mod terminal_queries; +#[cfg(test)] +mod test_support; pub mod agent; pub mod composer; @@ -15,6 +30,7 @@ pub mod errors; pub mod execution; pub mod lifecycle; pub mod mcp; +pub mod mode; pub mod observability; pub mod terminal; pub mod watch; @@ -23,119 +39,55 @@ pub use agent::AgentOrchestrationService; pub use astrcode_core::{ AgentEvent, AgentEventContext, AgentLifecycleStatus, AgentMode, AgentTurnOutcome, ArtifactRef, AstrError, CapabilitySpec, ChildAgentRef, ChildSessionLineageKind, - ChildSessionNotificationKind, CompactTrigger, Config, ExecutionAccepted, ForkMode, - InvocationKind, InvocationMode, LocalServerInfo, Phase, PluginHealth, PluginState, - ResolvedExecutionLimitsSnapshot, ResolvedSubagentContextOverrides, SessionEventRecord, - SessionMeta, StorageEventPayload, StoredEvent, SubRunFailure, SubRunFailureCode, SubRunHandoff, - SubRunResult, SubRunStorageMode, SubagentContextOverrides, ToolOutputStream, - format_local_rfc3339, plugin::PluginEntry, + ChildSessionNotificationKind, CompactTrigger, ComposerOption, ComposerOptionActionKind, + ComposerOptionKind, Config, ExecutionAccepted, ForkMode, InvocationKind, InvocationMode, + LocalServerInfo, Phase, PluginHealth, PluginState, ResolvedExecutionLimitsSnapshot, + ResolvedSubagentContextOverrides, SessionEventRecord, SessionMeta, StorageEventPayload, + StoredEvent, SubRunFailure, SubRunFailureCode, SubRunHandoff, SubRunResult, SubRunStorageMode, + SubagentContextOverrides, TestConnectionResult, ToolOutputStream, format_local_rfc3339, + plugin::PluginEntry, }; pub use astrcode_kernel::SubRunStatusView; pub use astrcode_session_runtime::{ SessionCatalogEvent, SessionControlStateSnapshot, SessionEventFilterSpec, SessionReplay, SessionTranscriptSnapshot, SubRunEventScope, TurnCollaborationSummary, TurnSummary, }; -pub use composer::{ - ComposerOption, ComposerOptionActionKind, ComposerOptionKind, ComposerOptionsRequest, - ComposerSkillSummary, -}; -pub use config::{ - // 常量与解析函数 - ALL_ASTRCODE_ENV_VARS, - ANTHROPIC_API_KEY_ENV, - ANTHROPIC_MESSAGES_API_URL, - ANTHROPIC_MODELS_API_URL, - ANTHROPIC_VERSION, - ASTRCODE_HOME_DIR_ENV, - ASTRCODE_MAX_TOOL_CONCURRENCY_ENV, - ASTRCODE_PLUGIN_DIRS_ENV, - ASTRCODE_TEST_HOME_ENV, - ASTRCODE_TOOL_INLINE_LIMIT_PREFIX, - ASTRCODE_TOOL_RESULT_INLINE_LIMIT_ENV, - BUILD_ENV_VARS, - CURRENT_CONFIG_VERSION, - ConfigService, - DEEPSEEK_API_KEY_ENV, - DEFAULT_API_SESSION_TTL_HOURS, - DEFAULT_AUTO_COMPACT_ENABLED, - DEFAULT_COMPACT_KEEP_RECENT_TURNS, - DEFAULT_COMPACT_THRESHOLD_PERCENT, - DEFAULT_FINALIZED_AGENT_RETAIN_LIMIT, - DEFAULT_INBOX_CAPACITY, - DEFAULT_LLM_CONNECT_TIMEOUT_SECS, - DEFAULT_LLM_MAX_RETRIES, - DEFAULT_LLM_READ_TIMEOUT_SECS, - DEFAULT_LLM_RETRY_BASE_DELAY_MS, - DEFAULT_MAX_CONCURRENT_AGENTS, - DEFAULT_MAX_CONCURRENT_BRANCH_DEPTH, - DEFAULT_MAX_CONSECUTIVE_FAILURES, - DEFAULT_MAX_GREP_LINES, - DEFAULT_MAX_IMAGE_SIZE, - DEFAULT_MAX_OUTPUT_CONTINUATION_ATTEMPTS, - DEFAULT_MAX_REACTIVE_COMPACT_ATTEMPTS, - DEFAULT_MAX_RECOVERED_FILES, - DEFAULT_MAX_STEPS, - DEFAULT_MAX_SUBRUN_DEPTH, - DEFAULT_MAX_TOOL_CONCURRENCY, - DEFAULT_MAX_TRACKED_FILES, - DEFAULT_OPENAI_CONTEXT_LIMIT, - DEFAULT_PARENT_DELIVERY_CAPACITY, - DEFAULT_RECOVERY_TOKEN_BUDGET, - DEFAULT_RECOVERY_TRUNCATE_BYTES, - DEFAULT_SESSION_BROADCAST_CAPACITY, - DEFAULT_SESSION_RECENT_RECORD_LIMIT, - DEFAULT_SUMMARY_RESERVE_TOKENS, - DEFAULT_TOOL_RESULT_INLINE_LIMIT, - DEFAULT_TOOL_RESULT_MAX_BYTES, - DEFAULT_TOOL_RESULT_PREVIEW_LIMIT, - ENV_REFERENCE_PREFIX, - HOME_ENV_VARS, - LITERAL_VALUE_PREFIX, - McpConfigFileScope, - PLUGIN_ENV_VARS, - PROVIDER_API_KEY_ENV_VARS, - PROVIDER_KIND_ANTHROPIC, - PROVIDER_KIND_OPENAI, - RUNTIME_ENV_VARS, - ResolvedAgentConfig, - ResolvedRuntimeConfig, - TAURI_ENV_TARGET_TRIPLE_ENV, - TestConnectionResult, - is_env_var_name, - list_model_options, - max_tool_concurrency, - resolve_active_selection, - resolve_agent_config, - resolve_anthropic_messages_api_url, - resolve_anthropic_models_api_url, - resolve_current_model, - resolve_openai_chat_completions_api_url, - resolve_runtime_config, -}; +pub use composer::{ComposerOptionsRequest, ComposerSkillSummary}; pub use errors::ApplicationError; pub use execution::{ExecutionControl, ProfileResolutionService, RootExecutionRequest}; +pub use governance_surface::{ + FreshChildGovernanceInput, GOVERNANCE_APPROVAL_MODE_INHERIT, GOVERNANCE_POLICY_REVISION, + GovernanceBusyPolicy, GovernanceSurfaceAssembler, ResolvedGovernanceSurface, + ResumedChildGovernanceInput, RootGovernanceInput, SessionGovernanceInput, + ToolCollaborationGovernanceContext, build_delegation_metadata, build_fresh_child_contract, + build_resumed_child_contract, collaboration_policy_context, effective_allowed_tools_for_limits, +}; pub use lifecycle::governance::{ AppGovernance, ObservabilitySnapshotProvider, RuntimeGovernancePort, RuntimeGovernanceSnapshot, RuntimeReloader, SessionInfoProvider, }; -pub use mcp::{McpConfigScope, McpPort, McpServerStatusView, McpService, RegisterMcpServerInput}; +pub use mcp::{ + McpActionSummary, McpConfigScope, McpPort, McpServerStatusSummary, McpServerStatusView, + McpService, RegisterMcpServerInput, +}; +pub use mode::{ + BuiltinModeCatalog, CompiledModeEnvelope, ModeCatalog, ModeSummary, builtin_mode_catalog, + compile_capability_selector, compile_mode_envelope, compile_mode_envelope_for_child, + validate_mode_transition, +}; pub use observability::{ AgentCollaborationScorecardSnapshot, ExecutionDiagnosticsSnapshot, GovernanceSnapshot, OperationMetricsSnapshot, ReloadResult, ReplayMetricsSnapshot, ReplayPath, - RuntimeObservabilityCollector, RuntimeObservabilitySnapshot, SubRunExecutionMetricsSnapshot, + ResolvedRuntimeStatusSummary, RuntimeCapabilitySummary, RuntimeObservabilityCollector, + RuntimeObservabilitySnapshot, RuntimePluginSummary, SubRunExecutionMetricsSnapshot, + resolve_runtime_status_summary, }; pub use ports::{ - AgentKernelPort, AgentSessionPort, AppKernelPort, AppSessionPort, ComposerSkillPort, -}; -pub use terminal::{ - ConversationChildSummaryFacts, ConversationControlFacts, ConversationFacts, ConversationFocus, - ConversationRehydrateFacts, ConversationRehydrateReason, ConversationResumeCandidateFacts, - ConversationSlashAction, ConversationSlashCandidateFacts, ConversationStreamFacts, - ConversationStreamReplayFacts, TerminalChildSummaryFacts, TerminalControlFacts, TerminalFacts, - TerminalRehydrateFacts, TerminalRehydrateReason, TerminalResumeCandidateFacts, - TerminalSlashAction, TerminalSlashCandidateFacts, TerminalStreamFacts, - TerminalStreamReplayFacts, + AgentKernelPort, AgentSessionPort, AppAgentPromptSubmission, AppKernelPort, AppSessionPort, + ComposerResolvedSkill, ComposerSkillPort, }; +pub use session_plan::{ProjectPlanArchiveDetail, ProjectPlanArchiveSummary}; +pub use session_use_cases::summarize_session_meta; pub use watch::{WatchEvent, WatchPort, WatchService, WatchSource}; /// 唯一业务用例入口。 @@ -146,6 +98,8 @@ pub struct App { config_service: Arc, composer_service: Arc, composer_skills: Arc, + governance_surface: Arc, + mode_catalog: Arc, mcp_service: Arc, agent_service: Arc, } @@ -157,13 +111,93 @@ pub struct CompactSessionAccepted { pub deferred: bool, } +/// prompt 提交成功后的共享摘要输入。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PromptAcceptedSummary { + pub turn_id: String, + pub session_id: String, + pub branched_from_session_id: Option, + pub accepted_control: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PromptSkillInvocation { + pub skill_id: String, + pub user_prompt: Option, +} + +/// 手动 compact 的共享摘要输入。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CompactSessionSummary { + pub accepted: bool, + pub deferred: bool, + pub message: String, +} + +/// session 列表项的共享摘要输入。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SessionListSummary { + pub session_id: String, + pub working_dir: String, + pub display_name: String, + pub title: String, + pub created_at: String, + pub updated_at: String, + pub parent_session_id: Option, + pub parent_storage_seq: Option, + pub phase: Phase, +} + +/// root agent 执行接受后的共享摘要输入。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AgentExecuteSummary { + pub accepted: bool, + pub message: String, + pub session_id: Option, + pub turn_id: Option, + pub agent_id: Option, +} + +/// sub-run 状态来源。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SubRunStatusSourceSummary { + Live, + Durable, +} + +/// sub-run 状态的共享摘要输入。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SubRunStatusSummary { + pub sub_run_id: String, + pub tool_call_id: Option, + pub source: SubRunStatusSourceSummary, + pub agent_id: String, + pub agent_profile: String, + pub session_id: String, + pub child_session_id: Option, + pub depth: usize, + pub parent_agent_id: Option, + pub parent_sub_run_id: Option, + pub storage_mode: SubRunStorageMode, + pub lifecycle: AgentLifecycleStatus, + pub last_turn_outcome: Option, + pub result: Option, + pub step_count: Option, + pub estimated_tokens: Option, + pub resolved_overrides: Option, + pub resolved_limits: Option, +} + impl App { + #[allow(clippy::too_many_arguments)] pub fn new( kernel: Arc, session_runtime: Arc, profiles: Arc, config_service: Arc, composer_skills: Arc, + governance_surface: Arc, + mode_catalog: Arc, mcp_service: Arc, agent_service: Arc, ) -> Self { @@ -174,6 +208,8 @@ impl App { config_service, composer_service: Arc::new(composer::ComposerService::new()), composer_skills, + governance_surface, + mode_catalog, mcp_service, agent_service, } @@ -207,6 +243,14 @@ impl App { &self.composer_skills } + pub fn governance_surface(&self) -> &Arc { + &self.governance_surface + } + + pub fn mode_catalog(&self) -> &Arc { + &self.mode_catalog + } + pub fn agent(&self) -> &Arc { &self.agent_service } @@ -226,6 +270,7 @@ impl App { self.kernel.as_ref(), self.session_runtime.as_ref(), &self.profiles, + self.governance_surface.as_ref(), request, runtime, ) diff --git a/crates/application/src/mcp/mod.rs b/crates/application/src/mcp/mod.rs index 52e05923..c7db29d2 100644 --- a/crates/application/src/mcp/mod.rs +++ b/crates/application/src/mcp/mod.rs @@ -44,6 +44,28 @@ pub struct McpServerStatusView { pub server_signature: String, } +/// MCP 服务器状态的共享摘要输入。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct McpServerStatusSummary { + pub name: String, + pub scope: String, + pub enabled: bool, + pub status: String, + pub error: Option, + pub tool_count: usize, + pub prompt_count: usize, + pub resource_count: usize, + pub pending_approval: bool, + pub server_signature: String, +} + +/// MCP 动作返回的共享摘要输入。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct McpActionSummary { + pub ok: bool, + pub message: Option, +} + /// 注册 MCP 服务器的业务输入。 /// /// 包含业务层面的配置信息(名称、超时等), @@ -119,6 +141,14 @@ impl McpService { self.port.list_server_status().await } + pub async fn list_status_summary(&self) -> Vec { + self.list_status() + .await + .into_iter() + .map(McpServerStatusSummary::from) + .collect() + } + /// 用例:审批 MCP 服务器。 pub async fn approve_server(&self, server_signature: &str) -> Result<(), ApplicationError> { self.port.approve_server(server_signature).await @@ -167,6 +197,32 @@ impl McpService { } } +impl McpActionSummary { + pub fn ok() -> Self { + Self { + ok: true, + message: None, + } + } +} + +impl From for McpServerStatusSummary { + fn from(value: McpServerStatusView) -> Self { + Self { + name: value.name, + scope: value.scope, + enabled: value.enabled, + status: value.state, + error: value.error, + tool_count: value.tool_count, + prompt_count: value.prompt_count, + resource_count: value.resource_count, + pending_approval: value.pending_approval, + server_signature: value.server_signature, + } + } +} + impl std::fmt::Debug for McpService { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("McpService").finish_non_exhaustive() diff --git a/crates/application/src/mode/builtin_prompts.rs b/crates/application/src/mode/builtin_prompts.rs new file mode 100644 index 00000000..50725cdd --- /dev/null +++ b/crates/application/src/mode/builtin_prompts.rs @@ -0,0 +1,32 @@ +//! 内置 mode prompt 资源。 +//! +//! 计划模式的主流程和模板拆成 markdown 资源文件,便于后续直接微调文本, +//! 避免继续把完整提示词硬编码在 Rust 字符串里。 + +pub(crate) fn code_mode_prompt() -> &'static str { + "You are in execution mode. Prefer direct progress, make concrete code changes when needed, \ + and use delegation only when isolation or parallelism materially helps. If the user \ + explicitly wants a plan or the task clearly needs up-front planning, call `enterPlanMode` \ + first instead of mixing planning into execution." +} + +pub(crate) fn review_mode_prompt() -> &'static str { + "You are in review mode. Prioritize findings, risks, regressions, and verification gaps. Stay \ + read-only and avoid speculative edits." +} + +pub(crate) fn plan_mode_prompt() -> &'static str { + include_str!("builtin_prompts/plan_mode.md") +} + +pub(crate) fn plan_mode_reentry_prompt() -> &'static str { + include_str!("builtin_prompts/plan_mode_reentry.md") +} + +pub(crate) fn plan_mode_exit_prompt() -> &'static str { + include_str!("builtin_prompts/plan_mode_exit.md") +} + +pub(crate) fn plan_template_prompt() -> &'static str { + include_str!("builtin_prompts/plan_template.md") +} diff --git a/crates/application/src/mode/builtin_prompts/plan_mode.md b/crates/application/src/mode/builtin_prompts/plan_mode.md new file mode 100644 index 00000000..7bb986f1 --- /dev/null +++ b/crates/application/src/mode/builtin_prompts/plan_mode.md @@ -0,0 +1,37 @@ +You are in plan mode. + +Your job is to produce and maintain a session-scoped plan artifact before implementation. + +Plan mode contract: +- Use `upsertSessionPlan` to create or update the session plan artifact. +- `upsertSessionPlan` is the only canonical writer for `sessions//plan/**`. +- A session has exactly one canonical plan artifact. +- While you are still working on the same task, keep revising that single plan. +- If the user clearly changed the task/topic inside the same session, overwrite the current plan instead of creating another canonical plan. +- Stay in this mode until the plan is concrete enough to execute; if it is still vague, incomplete, or risky, keep revising the artifact instead of exiting. +- Keep the plan scoped to one concrete task or change topic. +- Plan in this mode should follow this order: + 1. inspect the relevant code and tests enough to understand the current behavior and constraints + 2. draft the plan artifact + 3. reflect on the draft, tighten weak steps, and check for missing risks or validation gaps + 4. if the plan is still not executable, update the artifact again and repeat the review loop + 5. only then call `exitPlanMode` to present the finalized plan to the user for approval +- Do not skip the code-reading phase before drafting the plan. +- Keep the code inspection relevant and sufficient; read enough to ground the plan in the actual implementation instead of guessing. +- Before showing the plan to the user, critique it yourself: + 1. look for incorrect assumptions + 2. look for missing edge cases or affected files + 3. look for weak verification steps + 4. revise the plan artifact if needed +- Treat every `exitPlanMode` attempt as a final-review gate: + 1. before calling it, internally review the plan against assumptions, edge cases, affected files, and verification strength + 2. keep that review out of the plan artifact itself unless the user explicitly asks to see it + 3. if the review changes the plan, update the artifact with `upsertSessionPlan` + 4. the first `exitPlanMode` call for a given plan revision may return a review-pending result as a normal checkpoint + 5. after that internal review pass, call `exitPlanMode` again only if the plan is still executable +- The first user-visible response should usually come after you have both inspected the code and updated the plan artifact. +- Ask concise clarification questions when missing details would materially change scope or design. +- Do not perform implementation work in this mode. +- Do not call `exitPlanMode` until the plan contains concrete implementation steps and verification steps. +- After `exitPlanMode`, summarize the plan plainly and ask the user to approve it or request revisions. +- Do not silently switch to execution. Execution starts only after the user explicitly approves the plan. diff --git a/crates/application/src/mode/builtin_prompts/plan_mode_exit.md b/crates/application/src/mode/builtin_prompts/plan_mode_exit.md new file mode 100644 index 00000000..51f48731 --- /dev/null +++ b/crates/application/src/mode/builtin_prompts/plan_mode_exit.md @@ -0,0 +1,6 @@ +The session has exited plan mode and is now back in code mode. + +Execution contract: +- Use the approved session plan artifact as the primary implementation reference. +- The user approval already happened; do not ask for plan approval again. +- Start implementation immediately unless the user message clearly requests more planning. diff --git a/crates/application/src/mode/builtin_prompts/plan_mode_reentry.md b/crates/application/src/mode/builtin_prompts/plan_mode_reentry.md new file mode 100644 index 00000000..3371c492 --- /dev/null +++ b/crates/application/src/mode/builtin_prompts/plan_mode_reentry.md @@ -0,0 +1,6 @@ +The session already has a canonical plan artifact. + +Re-entry contract: +- Read the current plan artifact first. +- If the user is continuing the same task, revise the current plan. +- If the user clearly changed the task/topic, overwrite the current plan instead of creating another canonical plan. diff --git a/crates/application/src/mode/builtin_prompts/plan_template.md b/crates/application/src/mode/builtin_prompts/plan_template.md new file mode 100644 index 00000000..cb7be199 --- /dev/null +++ b/crates/application/src/mode/builtin_prompts/plan_template.md @@ -0,0 +1,17 @@ +# Plan: + +## Context + +## Goal + +## Scope + +## Non-Goals + +## Existing Code To Reuse + +## Implementation Steps + +## Verification + +## Open Questions diff --git a/crates/application/src/mode/catalog.rs b/crates/application/src/mode/catalog.rs new file mode 100644 index 00000000..fa4f153f --- /dev/null +++ b/crates/application/src/mode/catalog.rs @@ -0,0 +1,287 @@ +//! 治理模式注册目录。 +//! +//! `ModeCatalog` 管理所有可用的治理模式(内置 + 插件扩展),提供: +//! - 按 ModeId 查找 mode spec +//! - 列出所有可用 mode 的摘要(供 API 返回) +//! - 热替换插件 mode(bootstrap / reload 时调用 `replace_plugin_modes`) +//! +//! 内置三种 mode: +//! - **Code**:默认执行模式,保留完整能力面与委派能力 +//! - **Plan**:规划模式,只暴露只读工具,禁止委派 +//! - **Review**:审查模式,严格只读,禁止委派,收紧步数 + +use std::{ + collections::BTreeMap, + sync::{Arc, RwLock}, +}; + +use astrcode_core::{ + ActionPolicies, ActionPolicyEffect, ActionPolicyRule, CapabilitySelector, ChildPolicySpec, + GovernanceModeSpec, ModeExecutionPolicySpec, ModeId, PromptProgramEntry, Result, + SubmitBusyPolicy, TransitionPolicySpec, +}; + +use super::builtin_prompts::{code_mode_prompt, plan_mode_prompt, review_mode_prompt}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ModeSummary { + pub id: ModeId, + pub name: String, + pub description: String, +} + +#[derive(Debug, Clone)] +pub struct ModeCatalogEntry { + pub spec: GovernanceModeSpec, + pub builtin: bool, +} + +#[derive(Debug, Clone, Default)] +pub struct ModeCatalogSnapshot { + pub entries: BTreeMap<String, ModeCatalogEntry>, +} + +impl ModeCatalogSnapshot { + pub fn get(&self, mode_id: &ModeId) -> Option<&ModeCatalogEntry> { + self.entries.get(mode_id.as_str()) + } + + pub fn list(&self) -> Vec<ModeSummary> { + self.entries + .values() + .map(|entry| ModeSummary { + id: entry.spec.id.clone(), + name: entry.spec.name.clone(), + description: entry.spec.description.clone(), + }) + .collect() + } +} + +#[derive(Debug, Clone)] +pub struct ModeCatalog { + snapshot: Arc<RwLock<ModeCatalogSnapshot>>, +} + +impl ModeCatalog { + pub fn new( + builtin_modes: impl IntoIterator<Item = GovernanceModeSpec>, + plugin_modes: impl IntoIterator<Item = GovernanceModeSpec>, + ) -> Result<Self> { + let snapshot = build_snapshot(builtin_modes, plugin_modes)?; + Ok(Self { + snapshot: Arc::new(RwLock::new(snapshot)), + }) + } + + pub fn snapshot(&self) -> ModeCatalogSnapshot { + self.snapshot + .read() + .expect("mode catalog lock poisoned") + .clone() + } + + pub fn list(&self) -> Vec<ModeSummary> { + self.snapshot().list() + } + + pub fn get(&self, mode_id: &ModeId) -> Option<GovernanceModeSpec> { + self.snapshot().get(mode_id).map(|entry| entry.spec.clone()) + } + + pub fn replace_plugin_modes( + &self, + plugin_modes: impl IntoIterator<Item = GovernanceModeSpec>, + ) -> Result<()> { + let current = self.snapshot(); + let builtin_modes = current + .entries + .values() + .filter(|entry| entry.builtin) + .map(|entry| entry.spec.clone()) + .collect::<Vec<_>>(); + let snapshot = build_snapshot(builtin_modes, plugin_modes)?; + *self.snapshot.write().expect("mode catalog lock poisoned") = snapshot; + Ok(()) + } +} + +pub type BuiltinModeCatalog = ModeCatalog; + +pub fn builtin_mode_catalog() -> Result<ModeCatalog> { + ModeCatalog::new(builtin_mode_specs(), Vec::new()) +} + +fn build_snapshot( + builtin_modes: impl IntoIterator<Item = GovernanceModeSpec>, + plugin_modes: impl IntoIterator<Item = GovernanceModeSpec>, +) -> Result<ModeCatalogSnapshot> { + let mut entries = BTreeMap::new(); + for (builtin, spec) in builtin_modes + .into_iter() + .map(|spec| (true, spec)) + .chain(plugin_modes.into_iter().map(|spec| (false, spec))) + { + spec.validate()?; + entries.insert( + spec.id.as_str().to_string(), + ModeCatalogEntry { spec, builtin }, + ); + } + Ok(ModeCatalogSnapshot { entries }) +} + +fn builtin_mode_specs() -> Vec<GovernanceModeSpec> { + let transitions = TransitionPolicySpec { + allowed_targets: vec![ModeId::code(), ModeId::plan(), ModeId::review()], + }; + + vec![ + GovernanceModeSpec { + id: ModeId::code(), + name: "Code".to_string(), + description: "默认执行模式,保留完整能力面与委派能力。".to_string(), + capability_selector: CapabilitySelector::AllTools, + action_policies: ActionPolicies::default(), + child_policy: ChildPolicySpec { + allow_delegation: true, + allow_recursive_delegation: true, + default_mode_id: Some(ModeId::code()), + ..ChildPolicySpec::default() + }, + execution_policy: ModeExecutionPolicySpec { + submit_busy_policy: Some(SubmitBusyPolicy::BranchOnBusy), + ..ModeExecutionPolicySpec::default() + }, + prompt_program: vec![PromptProgramEntry { + block_id: "mode.code".to_string(), + title: "Execution Mode".to_string(), + content: code_mode_prompt().to_string(), + priority_hint: Some(600), + }], + transition_policy: transitions.clone(), + }, + GovernanceModeSpec { + id: ModeId::plan(), + name: "Plan".to_string(), + description: "规划模式,只暴露只读工具并禁止继续委派。".to_string(), + capability_selector: CapabilitySelector::Union(vec![ + CapabilitySelector::Difference { + base: Box::new(CapabilitySelector::AllTools), + subtract: Box::new(CapabilitySelector::Union(vec![ + CapabilitySelector::SideEffect(astrcode_core::SideEffect::Local), + CapabilitySelector::SideEffect(astrcode_core::SideEffect::Workspace), + CapabilitySelector::SideEffect(astrcode_core::SideEffect::External), + CapabilitySelector::Tag("agent".to_string()), + ])), + }, + CapabilitySelector::Name("exitPlanMode".to_string()), + CapabilitySelector::Name("upsertSessionPlan".to_string()), + ]), + action_policies: ActionPolicies { + default_effect: ActionPolicyEffect::Allow, + rules: vec![ActionPolicyRule { + selector: CapabilitySelector::Tag("agent".to_string()), + effect: ActionPolicyEffect::Deny, + }], + }, + child_policy: ChildPolicySpec { + allow_delegation: false, + allow_recursive_delegation: false, + default_mode_id: Some(ModeId::code()), + restricted: true, + reuse_scope_summary: Some( + "当前 mode 不允许继续派生 child branch;先完成规划,再由后续执行 turn \ + 决定是否委派。" + .to_string(), + ), + ..ChildPolicySpec::default() + }, + execution_policy: ModeExecutionPolicySpec { + submit_busy_policy: Some(SubmitBusyPolicy::RejectOnBusy), + ..ModeExecutionPolicySpec::default() + }, + prompt_program: vec![PromptProgramEntry { + block_id: "mode.plan".to_string(), + title: "Planning Mode".to_string(), + content: plan_mode_prompt().to_string(), + priority_hint: Some(600), + }], + transition_policy: transitions.clone(), + }, + GovernanceModeSpec { + id: ModeId::review(), + name: "Review".to_string(), + description: "审查模式,只保留严格只读工具并收紧步数。".to_string(), + capability_selector: CapabilitySelector::Intersection(vec![ + CapabilitySelector::AllTools, + CapabilitySelector::SideEffect(astrcode_core::SideEffect::None), + CapabilitySelector::Difference { + base: Box::new(CapabilitySelector::AllTools), + subtract: Box::new(CapabilitySelector::Tag("agent".to_string())), + }, + ]), + action_policies: ActionPolicies { + default_effect: ActionPolicyEffect::Allow, + rules: vec![ActionPolicyRule { + selector: CapabilitySelector::Tag("agent".to_string()), + effect: ActionPolicyEffect::Deny, + }], + }, + child_policy: ChildPolicySpec { + allow_delegation: false, + allow_recursive_delegation: false, + default_mode_id: Some(ModeId::review()), + restricted: true, + reuse_scope_summary: Some( + "当前 mode 仅允许本地审查,不允许在同一 turn 内扩张成新的执行分支。" + .to_string(), + ), + ..ChildPolicySpec::default() + }, + execution_policy: ModeExecutionPolicySpec { + submit_busy_policy: Some(SubmitBusyPolicy::RejectOnBusy), + ..ModeExecutionPolicySpec::default() + }, + prompt_program: vec![PromptProgramEntry { + block_id: "mode.review".to_string(), + title: "Review Mode".to_string(), + content: review_mode_prompt().to_string(), + priority_hint: Some(600), + }], + transition_policy: transitions, + }, + ] +} + +#[cfg(test)] +mod tests { + use astrcode_core::{CapabilitySelector, ModeId, Result}; + + use super::builtin_mode_catalog; + + #[test] + fn builtin_catalog_contains_three_builtin_modes() -> Result<()> { + let catalog = builtin_mode_catalog()?; + let ids = catalog + .list() + .into_iter() + .map(|summary| summary.id) + .collect::<Vec<_>>(); + assert_eq!(ids, vec![ModeId::code(), ModeId::plan(), ModeId::review()]); + Ok(()) + } + + #[test] + fn builtin_review_mode_uses_read_only_selector_shape() -> Result<()> { + let catalog = builtin_mode_catalog()?; + let review = catalog + .get(&ModeId::review()) + .expect("review mode should exist"); + assert!(matches!( + review.capability_selector, + CapabilitySelector::Intersection(_) + )); + Ok(()) + } +} diff --git a/crates/application/src/mode/compiler.rs b/crates/application/src/mode/compiler.rs new file mode 100644 index 00000000..3577a951 --- /dev/null +++ b/crates/application/src/mode/compiler.rs @@ -0,0 +1,432 @@ +//! 治理模式编译器。 +//! +//! 将声明式的 `GovernanceModeSpec` 编译为运行时可消费的 `CompiledModeEnvelope`: +//! - 通过 `CapabilitySelector` 从全量 capability 中筛选出允许的工具名列表 +//! - 递归处理组合选择器(Union / Intersection / Difference) +//! - 为子代理额外计算继承后的工具白名单(parent ∩ mode ∩ grant) +//! - 生成 mode prompt declarations 和子代理策略 + +use std::collections::BTreeSet; + +use astrcode_core::{ + AstrError, CapabilitySelector, CapabilitySpec, GovernanceModeSpec, PromptDeclaration, + PromptDeclarationKind, PromptDeclarationRenderTarget, PromptDeclarationSource, + ResolvedTurnEnvelope, Result, SpawnCapabilityGrant, SystemPromptLayer, +}; +use astrcode_kernel::CapabilityRouter; + +#[derive(Clone)] +pub struct CompiledModeEnvelope { + pub spec: GovernanceModeSpec, + pub envelope: ResolvedTurnEnvelope, + pub capability_router: Option<CapabilityRouter>, +} + +pub fn compile_capability_selector( + capability_specs: &[CapabilitySpec], + selector: &CapabilitySelector, +) -> Result<Vec<String>> { + let selected = evaluate_selector(capability_specs, selector)?; + Ok(selected.into_iter().collect()) +} + +pub fn compile_mode_envelope( + base_router: &CapabilityRouter, + spec: &GovernanceModeSpec, + extra_prompt_declarations: Vec<PromptDeclaration>, +) -> Result<CompiledModeEnvelope> { + let allowed_tools = + compile_capability_selector(&base_router.capability_specs(), &spec.capability_selector)?; + let child_allowed_tools = + child_allowed_tools(&base_router.capability_specs(), spec, &allowed_tools, None)?; + let prompt_declarations = mode_prompt_declarations(spec, extra_prompt_declarations); + let envelope = ResolvedTurnEnvelope { + mode_id: spec.id.clone(), + allowed_tools: allowed_tools.clone(), + prompt_declarations: prompt_declarations.clone(), + action_policies: spec.action_policies.clone(), + child_policy: astrcode_core::ResolvedChildPolicy { + mode_id: spec + .child_policy + .default_mode_id + .clone() + .unwrap_or_default(), + allow_delegation: spec.child_policy.allow_delegation, + allow_recursive_delegation: spec.child_policy.allow_recursive_delegation, + allowed_profile_ids: spec.child_policy.allowed_profile_ids.clone(), + allowed_tools: child_allowed_tools, + restricted: spec.child_policy.restricted, + fork_mode: spec + .child_policy + .fork_mode + .clone() + .or(spec.execution_policy.fork_mode.clone()), + reuse_scope_summary: spec.child_policy.reuse_scope_summary.clone(), + }, + submit_busy_policy: spec + .execution_policy + .submit_busy_policy + .unwrap_or(astrcode_core::SubmitBusyPolicy::BranchOnBusy), + fork_mode: spec.execution_policy.fork_mode.clone(), + diagnostics: if allowed_tools.is_empty() { + vec![format!( + "mode '{}' compiled to an empty tool surface", + spec.id + )] + } else { + Vec::new() + }, + }; + let capability_router = subset_router(base_router, &allowed_tools)?; + Ok(CompiledModeEnvelope { + spec: spec.clone(), + envelope, + capability_router, + }) +} + +pub fn compile_mode_envelope_for_child( + base_router: &CapabilityRouter, + spec: &GovernanceModeSpec, + parent_allowed_tools: &[String], + capability_grant: Option<&SpawnCapabilityGrant>, +) -> Result<CompiledModeEnvelope> { + let mode_allowed_tools = + compile_capability_selector(&base_router.capability_specs(), &spec.capability_selector)?; + // 子代理工具 = parent ∩ mode;parent 为空时直接取 mode 全量 + let effective_parent_allowed_tools = if parent_allowed_tools.is_empty() { + mode_allowed_tools + } else { + parent_allowed_tools + .iter() + .filter(|tool| { + mode_allowed_tools + .iter() + .any(|candidate| candidate == *tool) + }) + .cloned() + .collect::<Vec<_>>() + }; + let child_tools = child_allowed_tools( + &base_router.capability_specs(), + spec, + &effective_parent_allowed_tools, + capability_grant, + )?; + let prompt_declarations = mode_prompt_declarations(spec, Vec::new()); + let envelope = ResolvedTurnEnvelope { + mode_id: spec.id.clone(), + allowed_tools: child_tools.clone(), + prompt_declarations: prompt_declarations.clone(), + action_policies: spec.action_policies.clone(), + child_policy: astrcode_core::ResolvedChildPolicy { + mode_id: spec + .child_policy + .default_mode_id + .clone() + .unwrap_or_default(), + allow_delegation: spec.child_policy.allow_delegation, + allow_recursive_delegation: spec.child_policy.allow_recursive_delegation, + allowed_profile_ids: spec.child_policy.allowed_profile_ids.clone(), + allowed_tools: child_tools.clone(), + restricted: spec.child_policy.restricted || capability_grant.is_some(), + fork_mode: spec + .child_policy + .fork_mode + .clone() + .or(spec.execution_policy.fork_mode.clone()), + reuse_scope_summary: spec.child_policy.reuse_scope_summary.clone(), + }, + submit_busy_policy: spec + .execution_policy + .submit_busy_policy + .unwrap_or(astrcode_core::SubmitBusyPolicy::BranchOnBusy), + fork_mode: spec + .execution_policy + .fork_mode + .clone() + .or(spec.child_policy.fork_mode.clone()), + diagnostics: if child_tools.is_empty() { + vec![format!( + "child mode '{}' compiled to an empty inheritable tool surface", + spec.id + )] + } else { + Vec::new() + }, + }; + let capability_router = subset_router(base_router, &child_tools)?; + Ok(CompiledModeEnvelope { + spec: spec.clone(), + envelope, + capability_router, + }) +} + +fn evaluate_selector( + capability_specs: &[CapabilitySpec], + selector: &CapabilitySelector, +) -> Result<BTreeSet<String>> { + let tools = capability_specs + .iter() + .filter(|spec| spec.kind.is_tool()) + .collect::<Vec<_>>(); + Ok(match selector { + CapabilitySelector::AllTools => tools + .into_iter() + .map(|spec| spec.name.to_string()) + .collect(), + CapabilitySelector::Name(name) => tools + .into_iter() + .filter(|spec| spec.name.as_str() == name.as_str()) + .map(|spec| spec.name.to_string()) + .collect(), + CapabilitySelector::Kind(kind) => tools + .into_iter() + .filter(|spec| spec.kind == *kind) + .map(|spec| spec.name.to_string()) + .collect(), + CapabilitySelector::SideEffect(side_effect) => tools + .into_iter() + .filter(|spec| spec.side_effect == *side_effect) + .map(|spec| spec.name.to_string()) + .collect(), + CapabilitySelector::Tag(tag) => tools + .into_iter() + .filter(|spec| spec.tags.iter().any(|candidate| candidate == tag)) + .map(|spec| spec.name.to_string()) + .collect(), + CapabilitySelector::Union(selectors) => { + let mut combined = BTreeSet::new(); + for selector in selectors { + combined.extend(evaluate_selector(capability_specs, selector)?); + } + combined + }, + CapabilitySelector::Intersection(selectors) => { + let mut iter = selectors.iter(); + let Some(first) = iter.next() else { + return Ok(BTreeSet::new()); + }; + let mut combined = evaluate_selector(capability_specs, first)?; + for selector in iter { + let next = evaluate_selector(capability_specs, selector)?; + combined = combined.intersection(&next).cloned().collect(); + } + combined + }, + CapabilitySelector::Difference { base, subtract } => { + let base = evaluate_selector(capability_specs, base)?; + let subtract = evaluate_selector(capability_specs, subtract)?; + base.difference(&subtract).cloned().collect() + }, + }) +} + +fn child_allowed_tools( + capability_specs: &[CapabilitySpec], + spec: &GovernanceModeSpec, + parent_allowed_tools: &[String], + capability_grant: Option<&SpawnCapabilityGrant>, +) -> Result<Vec<String>> { + if !spec.child_policy.allow_delegation { + return Ok(Vec::new()); + } + let mut allowed = if let Some(selector) = &spec.child_policy.capability_selector { + let selected = evaluate_selector(capability_specs, selector)?; + parent_allowed_tools + .iter() + .filter(|tool| selected.contains(tool.as_str())) + .cloned() + .collect::<Vec<_>>() + } else { + parent_allowed_tools.to_vec() + }; + if let Some(grant) = capability_grant { + grant.validate()?; + let requested = grant.normalized_allowed_tools()?; + allowed.retain(|tool| requested.iter().any(|candidate| candidate == tool)); + } + Ok(allowed) +} + +fn mode_prompt_declarations( + spec: &GovernanceModeSpec, + extra_prompt_declarations: Vec<PromptDeclaration>, +) -> Vec<PromptDeclaration> { + let mut declarations = spec + .prompt_program + .iter() + .map(|entry| PromptDeclaration { + block_id: entry.block_id.clone(), + title: entry.title.clone(), + content: entry.content.clone(), + render_target: PromptDeclarationRenderTarget::System, + layer: SystemPromptLayer::Dynamic, + kind: PromptDeclarationKind::ExtensionInstruction, + priority_hint: entry.priority_hint, + always_include: true, + source: PromptDeclarationSource::Builtin, + capability_name: None, + origin: Some(format!("mode:{}", spec.id)), + }) + .collect::<Vec<_>>(); + declarations.extend(extra_prompt_declarations); + declarations +} + +fn subset_router( + base_router: &CapabilityRouter, + allowed_tools: &[String], +) -> Result<Option<CapabilityRouter>> { + let all_tools = base_router.tool_names(); + let allowed_set = allowed_tools + .iter() + .map(String::as_str) + .collect::<BTreeSet<_>>(); + let all_set = all_tools + .iter() + .map(String::as_str) + .collect::<BTreeSet<_>>(); + if allowed_set == all_set { + return Ok(None); + } + if allowed_tools.is_empty() { + return Ok(Some(CapabilityRouter::empty())); + } + Ok(Some( + base_router + .subset_for_tools_checked(allowed_tools) + .map_err(|error| AstrError::Validation(error.to_string()))?, + )) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use astrcode_core::{ + CapabilityContext, CapabilityExecutionResult, CapabilityInvoker, CapabilityKind, + CapabilitySpec, SideEffect, + }; + use astrcode_kernel::CapabilityRouter; + use async_trait::async_trait; + use serde_json::Value; + + use super::compile_capability_selector; + use crate::mode::builtin_mode_catalog; + + #[derive(Debug)] + struct FakeCapabilityInvoker { + spec: CapabilitySpec, + } + + impl FakeCapabilityInvoker { + fn new(spec: CapabilitySpec) -> Self { + Self { spec } + } + } + + #[async_trait] + impl CapabilityInvoker for FakeCapabilityInvoker { + fn capability_spec(&self) -> CapabilitySpec { + self.spec.clone() + } + + async fn invoke( + &self, + _payload: Value, + _ctx: &CapabilityContext, + ) -> astrcode_core::Result<CapabilityExecutionResult> { + Ok(CapabilityExecutionResult::ok( + self.spec.name.to_string(), + Value::Null, + )) + } + } + + fn router() -> CapabilityRouter { + CapabilityRouter::builder() + .register_invoker(Arc::new(FakeCapabilityInvoker::new( + CapabilitySpec::builder("readFile", CapabilityKind::Tool) + .description("read") + .schema( + serde_json::json!({"type":"object"}), + serde_json::json!({"type":"object"}), + ) + .tags(["filesystem", "read"]) + .side_effect(SideEffect::None) + .build() + .expect("readFile should build"), + ))) + .register_invoker(Arc::new(FakeCapabilityInvoker::new( + CapabilitySpec::builder("writeFile", CapabilityKind::Tool) + .description("write") + .schema( + serde_json::json!({"type":"object"}), + serde_json::json!({"type":"object"}), + ) + .tags(["filesystem", "write"]) + .side_effect(SideEffect::Workspace) + .build() + .expect("writeFile should build"), + ))) + .register_invoker(Arc::new(FakeCapabilityInvoker::new( + CapabilitySpec::builder("taskWrite", CapabilityKind::Tool) + .description("task") + .schema( + serde_json::json!({"type":"object"}), + serde_json::json!({"type":"object"}), + ) + .tags(["task", "execution"]) + .side_effect(SideEffect::Local) + .build() + .expect("taskWrite should build"), + ))) + .register_invoker(Arc::new(FakeCapabilityInvoker::new( + CapabilitySpec::builder("spawn", CapabilityKind::Tool) + .description("spawn") + .schema( + serde_json::json!({"type":"object"}), + serde_json::json!({"type":"object"}), + ) + .tags(["agent"]) + .side_effect(SideEffect::None) + .build() + .expect("spawn should build"), + ))) + .build() + .expect("router should build") + } + + #[test] + fn builtin_modes_compile_expected_tool_equivalence() { + let router = router(); + let catalog = builtin_mode_catalog().expect("builtin catalog should build"); + + let code = catalog.get(&astrcode_core::ModeId::code()).unwrap(); + let plan = catalog.get(&astrcode_core::ModeId::plan()).unwrap(); + let review = catalog.get(&astrcode_core::ModeId::review()).unwrap(); + + assert_eq!( + compile_capability_selector(&router.capability_specs(), &code.capability_selector) + .expect("code selector should compile"), + vec![ + "readFile".to_string(), + "spawn".to_string(), + "taskWrite".to_string(), + "writeFile".to_string() + ] + ); + assert_eq!( + compile_capability_selector(&router.capability_specs(), &plan.capability_selector) + .expect("plan selector should compile"), + vec!["readFile".to_string()] + ); + assert_eq!( + compile_capability_selector(&router.capability_specs(), &review.capability_selector) + .expect("review selector should compile"), + vec!["readFile".to_string()] + ); + } +} diff --git a/crates/application/src/mode/mod.rs b/crates/application/src/mode/mod.rs new file mode 100644 index 00000000..571eb83e --- /dev/null +++ b/crates/application/src/mode/mod.rs @@ -0,0 +1,24 @@ +//! # 治理模式子域(Governance Mode) +//! +//! 管理 session 可用的治理模式(code / plan / review 及插件扩展 mode)。 +//! +//! 三个子模块各司其职: +//! - `catalog`:模式注册目录,支持内置 + 插件扩展,可热替换插件 mode +//! - `compiler`:将 `GovernanceModeSpec` 编译为 `ResolvedTurnEnvelope`(工具白名单 + 策略 + +//! prompt) +//! - `validator`:校验 mode 之间的合法转换 + +pub(crate) mod builtin_prompts; +mod catalog; +mod compiler; +mod validator; + +pub use catalog::{ + BuiltinModeCatalog, ModeCatalog, ModeCatalogEntry, ModeCatalogSnapshot, ModeSummary, + builtin_mode_catalog, +}; +pub use compiler::{ + CompiledModeEnvelope, compile_capability_selector, compile_mode_envelope, + compile_mode_envelope_for_child, +}; +pub use validator::{ModeTransitionDecision, validate_mode_transition}; diff --git a/crates/application/src/mode/validator.rs b/crates/application/src/mode/validator.rs new file mode 100644 index 00000000..53b8c802 --- /dev/null +++ b/crates/application/src/mode/validator.rs @@ -0,0 +1,74 @@ +//! 治理模式转换校验。 +//! +//! 校验 session 从一个 mode 切换到另一个 mode 是否合法: +//! 检查当前 mode 的 `transition_policy.allowed_targets` 是否包含目标 mode。 + +use astrcode_core::{AstrError, GovernanceModeSpec, ModeId, Result}; + +use super::ModeCatalog; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ModeTransitionDecision { + pub from_mode_id: ModeId, + pub to_mode_id: ModeId, + pub target: GovernanceModeSpec, +} + +pub fn validate_mode_transition( + catalog: &ModeCatalog, + from_mode_id: &ModeId, + to_mode_id: &ModeId, +) -> Result<ModeTransitionDecision> { + let current = catalog + .get(from_mode_id) + .ok_or_else(|| AstrError::Validation(format!("unknown current mode '{}'", from_mode_id)))?; + let target = catalog + .get(to_mode_id) + .ok_or_else(|| AstrError::Validation(format!("unknown target mode '{}'", to_mode_id)))?; + if !current + .transition_policy + .allowed_targets + .iter() + .any(|candidate| candidate == to_mode_id) + { + return Err(AstrError::Validation(format!( + "mode transition '{}' -> '{}' is not allowed", + from_mode_id, to_mode_id + ))); + } + Ok(ModeTransitionDecision { + from_mode_id: from_mode_id.clone(), + to_mode_id: to_mode_id.clone(), + target, + }) +} + +#[cfg(test)] +mod tests { + use super::validate_mode_transition; + use crate::mode::builtin_mode_catalog; + + #[test] + fn builtin_transition_accepts_known_target() { + let catalog = builtin_mode_catalog().expect("builtin catalog should build"); + let decision = validate_mode_transition( + &catalog, + &astrcode_core::ModeId::code(), + &astrcode_core::ModeId::plan(), + ) + .expect("transition should succeed"); + assert_eq!(decision.to_mode_id, astrcode_core::ModeId::plan()); + } + + #[test] + fn unknown_target_is_rejected() { + let catalog = builtin_mode_catalog().expect("builtin catalog should build"); + let error = validate_mode_transition( + &catalog, + &astrcode_core::ModeId::code(), + &astrcode_core::ModeId::from("missing"), + ) + .expect_err("unknown target should fail"); + assert!(error.to_string().contains("unknown target mode")); + } +} diff --git a/crates/application/src/observability/collector.rs b/crates/application/src/observability/collector.rs index f1712e3c..34c38743 100644 --- a/crates/application/src/observability/collector.rs +++ b/crates/application/src/observability/collector.rs @@ -1,3 +1,14 @@ +//! 运行时可观测性指标收集器。 +//! +//! `RuntimeObservabilityCollector` 实现 `RuntimeMetricsRecorder` 和 +//! `ObservabilitySnapshotProvider` 两个 trait,在内存中聚合运行时指标: +//! - 子代理执行计时与终态统计 +//! - 父级 reactivation 成功/失败计数 +//! - delivery buffer 队列状态 +//! - agent 协作事实追踪 +//! +//! 快照通过 `snapshot()` 返回不可变结构供 API 层消费。 + use std::{ collections::HashMap, sync::{ @@ -8,7 +19,7 @@ use std::{ use astrcode_core::{ AgentCollaborationActionKind, AgentCollaborationFact, AgentCollaborationOutcomeKind, - RuntimeMetricsRecorder, SubRunExecutionOutcome, SubRunStorageMode, + AgentTurnOutcome, RuntimeMetricsRecorder, SubRunStorageMode, }; use crate::{ @@ -99,7 +110,7 @@ struct SubRunMetrics { total: AtomicU64, failures: AtomicU64, completed: AtomicU64, - aborted: AtomicU64, + cancelled: AtomicU64, token_exceeded: AtomicU64, independent_session_total: AtomicU64, total_duration_ms: AtomicU64, @@ -114,7 +125,7 @@ impl SubRunMetrics { fn record( &self, duration_ms: u64, - outcome: SubRunExecutionOutcome, + outcome: AgentTurnOutcome, step_count: Option<u32>, estimated_tokens: Option<u64>, storage_mode: Option<SubRunStorageMode>, @@ -124,16 +135,16 @@ impl SubRunMetrics { .fetch_add(duration_ms, Ordering::Relaxed); self.last_duration_ms.store(duration_ms, Ordering::Relaxed); match outcome { - SubRunExecutionOutcome::Completed => { + AgentTurnOutcome::Completed => { self.completed.fetch_add(1, Ordering::Relaxed); }, - SubRunExecutionOutcome::Failed => { + AgentTurnOutcome::Failed => { self.failures.fetch_add(1, Ordering::Relaxed); }, - SubRunExecutionOutcome::Aborted => { - self.aborted.fetch_add(1, Ordering::Relaxed); + AgentTurnOutcome::Cancelled => { + self.cancelled.fetch_add(1, Ordering::Relaxed); }, - SubRunExecutionOutcome::TokenExceeded => { + AgentTurnOutcome::TokenExceeded => { self.token_exceeded.fetch_add(1, Ordering::Relaxed); }, } @@ -159,7 +170,7 @@ impl SubRunMetrics { total: self.total.load(Ordering::Relaxed), failures: self.failures.load(Ordering::Relaxed), completed: self.completed.load(Ordering::Relaxed), - aborted: self.aborted.load(Ordering::Relaxed), + cancelled: self.cancelled.load(Ordering::Relaxed), token_exceeded: self.token_exceeded.load(Ordering::Relaxed), independent_session_total: self.independent_session_total.load(Ordering::Relaxed), total_duration_ms: self.total_duration_ms.load(Ordering::Relaxed), @@ -270,7 +281,7 @@ impl CollaborationMetricsState { match fact.outcome { AgentCollaborationOutcomeKind::Accepted => { self.spawn_accepted = self.spawn_accepted.saturating_add(1); - if let Some(child_id) = fact.child_agent_id.as_deref() { + if let Some(child_id) = fact.child_agent_id().map(|id| id.as_str()) { self.child_states.entry(child_id.to_string()).or_default(); } }, @@ -285,13 +296,13 @@ impl CollaborationMetricsState { match fact.outcome { AgentCollaborationOutcomeKind::Reused => { self.send_reused = self.send_reused.saturating_add(1); - self.mark_child_reused(fact.child_agent_id.as_deref()); - self.satisfy_pending_observe(fact.child_agent_id.as_deref()); + self.mark_child_reused(fact.child_agent_id().map(|id| id.as_str())); + self.satisfy_pending_observe(fact.child_agent_id().map(|id| id.as_str())); }, AgentCollaborationOutcomeKind::Queued => { self.send_queued = self.send_queued.saturating_add(1); - self.mark_child_reused(fact.child_agent_id.as_deref()); - self.satisfy_pending_observe(fact.child_agent_id.as_deref()); + self.mark_child_reused(fact.child_agent_id().map(|id| id.as_str())); + self.satisfy_pending_observe(fact.child_agent_id().map(|id| id.as_str())); }, AgentCollaborationOutcomeKind::Rejected | AgentCollaborationOutcomeKind::Failed => { self.send_rejected = self.send_rejected.saturating_add(1); @@ -304,7 +315,7 @@ impl CollaborationMetricsState { match fact.outcome { AgentCollaborationOutcomeKind::Accepted => { self.observe_calls = self.observe_calls.saturating_add(1); - if let Some(child_id) = fact.child_agent_id.as_deref() { + if let Some(child_id) = fact.child_agent_id().map(|id| id.as_str()) { self.pending_observes .entry(child_id.to_string()) .or_default(); @@ -321,7 +332,7 @@ impl CollaborationMetricsState { match fact.outcome { AgentCollaborationOutcomeKind::Closed => { self.close_calls = self.close_calls.saturating_add(1); - if let Some(child_id) = fact.child_agent_id.as_deref() { + if let Some(child_id) = fact.child_agent_id().map(|id| id.as_str()) { self.child_states .entry(child_id.to_string()) .or_default() @@ -340,7 +351,7 @@ impl CollaborationMetricsState { match fact.outcome { AgentCollaborationOutcomeKind::Delivered => { self.delivery_delivered = self.delivery_delivered.saturating_add(1); - if let Some(child_id) = fact.child_agent_id.as_deref() { + if let Some(child_id) = fact.child_agent_id().map(|id| id.as_str()) { self.child_states .entry(child_id.to_string()) .or_default() @@ -501,7 +512,7 @@ impl RuntimeMetricsRecorder for RuntimeObservabilityCollector { fn record_subrun_execution( &self, duration_ms: u64, - outcome: SubRunExecutionOutcome, + outcome: AgentTurnOutcome, step_count: Option<u32>, estimated_tokens: Option<u64>, storage_mode: Option<SubRunStorageMode>, @@ -593,8 +604,8 @@ impl RuntimeMetricsRecorder for RuntimeObservabilityCollector { mod tests { use astrcode_core::{ AgentCollaborationActionKind, AgentCollaborationFact, AgentCollaborationOutcomeKind, - AgentCollaborationPolicyContext, RuntimeMetricsRecorder, SubRunExecutionOutcome, - SubRunStorageMode, + AgentCollaborationPolicyContext, AgentTurnOutcome, ChildExecutionIdentity, + RuntimeMetricsRecorder, SubRunStorageMode, }; use super::RuntimeObservabilityCollector; @@ -609,7 +620,7 @@ mod tests { collector.record_turn_execution(30, true); collector.record_subrun_execution( 12, - SubRunExecutionOutcome::Completed, + AgentTurnOutcome::Completed, Some(3), Some(1200), Some(SubRunStorageMode::IndependentSession), @@ -660,77 +671,93 @@ mod tests { fn collector_snapshot_derives_collaboration_scorecard() { let collector = RuntimeObservabilityCollector::new(); let policy = AgentCollaborationPolicyContext { - policy_revision: "agent-collaboration-v1".to_string(), + policy_revision: "governance-surface-v1".to_string(), max_subrun_depth: 3, max_spawn_per_turn: 3, }; collector.record_agent_collaboration_fact(&AgentCollaborationFact { - fact_id: "fact-spawn".to_string(), + fact_id: "fact-spawn".to_string().into(), action: AgentCollaborationActionKind::Spawn, outcome: AgentCollaborationOutcomeKind::Accepted, - parent_session_id: "session-parent".to_string(), - turn_id: "turn-1".to_string(), - parent_agent_id: Some("agent-root".to_string()), - child_agent_id: Some("agent-child".to_string()), - child_session_id: Some("session-child".to_string()), - child_sub_run_id: Some("subrun-child".to_string()), + parent_session_id: "session-parent".to_string().into(), + turn_id: "turn-1".to_string().into(), + parent_agent_id: Some("agent-root".to_string().into()), + child_identity: Some(ChildExecutionIdentity { + agent_id: "agent-child".to_string().into(), + session_id: "session-child".to_string().into(), + sub_run_id: "subrun-child".to_string().into(), + }), delivery_id: None, reason_code: None, summary: Some("spawned".to_string()), latency_ms: None, - source_tool_call_id: Some("call-1".to_string()), + source_tool_call_id: Some("call-1".to_string().into()), + governance_revision: Some("governance-surface-v1".to_string()), + mode_id: Some(astrcode_core::ModeId::code()), policy: policy.clone(), }); collector.record_agent_collaboration_fact(&AgentCollaborationFact { - fact_id: "fact-observe".to_string(), + fact_id: "fact-observe".to_string().into(), action: AgentCollaborationActionKind::Observe, outcome: AgentCollaborationOutcomeKind::Accepted, - parent_session_id: "session-parent".to_string(), - turn_id: "turn-1".to_string(), - parent_agent_id: Some("agent-root".to_string()), - child_agent_id: Some("agent-child".to_string()), - child_session_id: Some("session-child".to_string()), - child_sub_run_id: Some("subrun-child".to_string()), + parent_session_id: "session-parent".to_string().into(), + turn_id: "turn-1".to_string().into(), + parent_agent_id: Some("agent-root".to_string().into()), + child_identity: Some(ChildExecutionIdentity { + agent_id: "agent-child".to_string().into(), + session_id: "session-child".to_string().into(), + sub_run_id: "subrun-child".to_string().into(), + }), delivery_id: None, reason_code: None, summary: Some("observe".to_string()), latency_ms: None, - source_tool_call_id: Some("call-2".to_string()), + source_tool_call_id: Some("call-2".to_string().into()), + governance_revision: Some("governance-surface-v1".to_string()), + mode_id: Some(astrcode_core::ModeId::code()), policy: policy.clone(), }); collector.record_agent_collaboration_fact(&AgentCollaborationFact { - fact_id: "fact-send".to_string(), + fact_id: "fact-send".to_string().into(), action: AgentCollaborationActionKind::Send, outcome: AgentCollaborationOutcomeKind::Reused, - parent_session_id: "session-parent".to_string(), - turn_id: "turn-1".to_string(), - parent_agent_id: Some("agent-root".to_string()), - child_agent_id: Some("agent-child".to_string()), - child_session_id: Some("session-child".to_string()), - child_sub_run_id: Some("subrun-child".to_string()), + parent_session_id: "session-parent".to_string().into(), + turn_id: "turn-1".to_string().into(), + parent_agent_id: Some("agent-root".to_string().into()), + child_identity: Some(ChildExecutionIdentity { + agent_id: "agent-child".to_string().into(), + session_id: "session-child".to_string().into(), + sub_run_id: "subrun-child".to_string().into(), + }), delivery_id: None, reason_code: None, summary: Some("reused".to_string()), latency_ms: None, - source_tool_call_id: Some("call-3".to_string()), + source_tool_call_id: Some("call-3".to_string().into()), + governance_revision: Some("governance-surface-v1".to_string()), + mode_id: Some(astrcode_core::ModeId::code()), policy: policy.clone(), }); collector.record_agent_collaboration_fact(&AgentCollaborationFact { - fact_id: "fact-delivery".to_string(), + fact_id: "fact-delivery".to_string().into(), action: AgentCollaborationActionKind::Delivery, outcome: AgentCollaborationOutcomeKind::Consumed, - parent_session_id: "session-parent".to_string(), - turn_id: "turn-2".to_string(), - parent_agent_id: Some("agent-root".to_string()), - child_agent_id: Some("agent-child".to_string()), - child_session_id: Some("session-child".to_string()), - child_sub_run_id: Some("subrun-child".to_string()), - delivery_id: Some("delivery-1".to_string()), + parent_session_id: "session-parent".to_string().into(), + turn_id: "turn-2".to_string().into(), + parent_agent_id: Some("agent-root".to_string().into()), + child_identity: Some(ChildExecutionIdentity { + agent_id: "agent-child".to_string().into(), + session_id: "session-child".to_string().into(), + sub_run_id: "subrun-child".to_string().into(), + }), + delivery_id: Some("delivery-1".to_string().into()), reason_code: None, summary: Some("consumed".to_string()), latency_ms: Some(250), source_tool_call_id: None, + governance_revision: Some("governance-surface-v1".to_string()), + mode_id: Some(astrcode_core::ModeId::code()), policy, }); diff --git a/crates/application/src/observability/metrics_snapshot.rs b/crates/application/src/observability/metrics_snapshot.rs index 208d6287..0c3be3ee 100644 --- a/crates/application/src/observability/metrics_snapshot.rs +++ b/crates/application/src/observability/metrics_snapshot.rs @@ -1,127 +1,10 @@ //! # 可观测性指标快照类型 //! -//! 纯数据 DTO,从 `runtime::service::observability::metrics` 迁出的快照定义。 -//! 这些类型不含任何 trait object 或运行时依赖,仅用于跨层传递指标数据。 -//! 实际的指标收集器仍在旧 runtime 中,Phase 10 组合根接线时桥接。 - -/// 回放路径:优先缓存,不足时回退到磁盘。 -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum ReplayPath { - /// 从内存缓存读取(快速路径) - Cache, - /// 从磁盘 JSONL 文件加载(慢速回退路径) - DiskFallback, -} - -/// 单一操作的指标快照。 -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct OperationMetricsSnapshot { - /// 总操作次数 - pub total: u64, - /// 失败次数 - pub failures: u64, - /// 累计耗时(毫秒) - pub total_duration_ms: u64, - /// 最近一次操作的耗时(毫秒) - pub last_duration_ms: u64, - /// 历史最大单次操作耗时(毫秒) - pub max_duration_ms: u64, -} - -/// SSE 回放操作的指标快照。 -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct ReplayMetricsSnapshot { - /// 基础操作指标(总次数、失败率、耗时等) - pub totals: OperationMetricsSnapshot, - /// 缓存命中次数 - pub cache_hits: u64, - /// 磁盘回退次数(说明缓存不足的情况) - pub disk_fallbacks: u64, - /// 成功恢复的事件总数 - pub recovered_events: u64, -} - -/// 子执行域共享观测指标快照。 -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct SubRunExecutionMetricsSnapshot { - pub total: u64, - pub failures: u64, - pub completed: u64, - pub aborted: u64, - pub token_exceeded: u64, - pub independent_session_total: u64, - pub total_duration_ms: u64, - pub last_duration_ms: u64, - pub total_steps: u64, - pub last_step_count: u64, - pub total_estimated_tokens: u64, - pub last_estimated_tokens: u64, -} - -/// 子会话/缓存/legacy cutover 的结构化观测指标快照。 -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct ExecutionDiagnosticsSnapshot { - pub child_spawned: u64, - pub child_started_persisted: u64, - pub child_terminal_persisted: u64, - pub parent_reactivation_requested: u64, - pub parent_reactivation_succeeded: u64, - pub parent_reactivation_failed: u64, - pub lineage_mismatch_parent_agent: u64, - pub lineage_mismatch_parent_session: u64, - pub lineage_mismatch_child_session: u64, - pub lineage_mismatch_descriptor_missing: u64, - pub cache_reuse_hits: u64, - pub cache_reuse_misses: u64, - pub delivery_buffer_queued: u64, - pub delivery_buffer_dequeued: u64, - pub delivery_buffer_wake_requested: u64, - pub delivery_buffer_wake_succeeded: u64, - pub delivery_buffer_wake_failed: u64, -} - -/// Agent collaboration 评估读模型。 -/// -/// 这些字段全部由 raw collaboration facts 派生, -/// 用于判断 agent-tool 是否真的创造了协作价值。 -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct AgentCollaborationScorecardSnapshot { - pub total_facts: u64, - pub spawn_accepted: u64, - pub spawn_rejected: u64, - pub send_reused: u64, - pub send_queued: u64, - pub send_rejected: u64, - pub observe_calls: u64, - pub observe_rejected: u64, - pub observe_followed_by_action: u64, - pub close_calls: u64, - pub close_rejected: u64, - pub delivery_delivered: u64, - pub delivery_consumed: u64, - pub delivery_replayed: u64, - pub orphan_child_count: u64, - pub child_reuse_ratio_bps: Option<u64>, - pub observe_to_action_ratio_bps: Option<u64>, - pub spawn_to_delivery_ratio_bps: Option<u64>, - pub orphan_child_ratio_bps: Option<u64>, - pub avg_delivery_latency_ms: Option<u64>, - pub max_delivery_latency_ms: Option<u64>, -} - -/// 运行时可观测性快照,包含各类操作的指标。 -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct RuntimeObservabilitySnapshot { - /// 会话重水合(从磁盘加载已有会话)的指标 - pub session_rehydrate: OperationMetricsSnapshot, - /// SSE 追赶(客户端重连时回放历史)的指标 - pub sse_catch_up: ReplayMetricsSnapshot, - /// Turn 执行的指标 - pub turn_execution: OperationMetricsSnapshot, - /// 子执行域共享观测指标 - pub subrun_execution: SubRunExecutionMetricsSnapshot, - /// 子会话/缓存/legacy cutover 的结构化观测指标 - pub execution_diagnostics: ExecutionDiagnosticsSnapshot, - /// agent-tool 协作效果评估读模型 - pub agent_collaboration: AgentCollaborationScorecardSnapshot, -} +//! owner 已下沉到 `astrcode_core::observability`。 +//! application 这里只保留 re-export,避免继续维护第二套共享语义定义。 + +pub use astrcode_core::{ + AgentCollaborationScorecardSnapshot, ExecutionDiagnosticsSnapshot, OperationMetricsSnapshot, + ReplayMetricsSnapshot, ReplayPath, RuntimeObservabilitySnapshot, + SubRunExecutionMetricsSnapshot, +}; diff --git a/crates/application/src/observability/mod.rs b/crates/application/src/observability/mod.rs index 17de663e..840436af 100644 --- a/crates/application/src/observability/mod.rs +++ b/crates/application/src/observability/mod.rs @@ -32,6 +32,104 @@ pub struct GovernanceSnapshot { pub plugins: Vec<PluginEntry>, } +/// runtime capability 的共享摘要输入。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RuntimeCapabilitySummary { + pub name: String, + pub kind: String, + pub description: String, + pub profiles: Vec<String>, + pub streaming: bool, +} + +/// runtime plugin 的共享摘要输入。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RuntimePluginSummary { + pub name: String, + pub version: String, + pub description: String, + pub state: astrcode_core::PluginState, + pub health: astrcode_core::PluginHealth, + pub failure_count: u32, + pub failure: Option<String>, + pub warnings: Vec<String>, + pub last_checked_at: Option<String>, + pub capabilities: Vec<RuntimeCapabilitySummary>, +} + +/// 已解析的 runtime 状态摘要输入。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedRuntimeStatusSummary { + pub runtime_name: String, + pub runtime_kind: String, + pub loaded_session_count: usize, + pub running_session_ids: Vec<String>, + pub plugin_search_paths: Vec<String>, + pub metrics: RuntimeObservabilitySnapshot, + pub capabilities: Vec<RuntimeCapabilitySummary>, + pub plugins: Vec<RuntimePluginSummary>, +} + +/// 将治理快照解析为协议层可复用的摘要输入。 +pub fn resolve_runtime_status_summary( + snapshot: GovernanceSnapshot, +) -> ResolvedRuntimeStatusSummary { + ResolvedRuntimeStatusSummary { + runtime_name: snapshot.runtime_name, + runtime_kind: snapshot.runtime_kind, + loaded_session_count: snapshot.loaded_session_count, + running_session_ids: snapshot.running_session_ids, + plugin_search_paths: snapshot + .plugin_search_paths + .into_iter() + .map(|path| path.display().to_string()) + .collect(), + metrics: snapshot.metrics, + capabilities: snapshot + .capabilities + .into_iter() + .map(resolve_runtime_capability_summary) + .collect(), + plugins: snapshot + .plugins + .into_iter() + .map(resolve_runtime_plugin_summary) + .collect(), + } +} + +fn resolve_runtime_capability_summary(spec: CapabilitySpec) -> RuntimeCapabilitySummary { + RuntimeCapabilitySummary { + name: spec.name.to_string(), + kind: spec.kind.as_str().to_string(), + description: spec.description, + profiles: spec.profiles, + streaming: matches!( + spec.invocation_mode, + astrcode_core::InvocationMode::Streaming + ), + } +} + +fn resolve_runtime_plugin_summary(entry: PluginEntry) -> RuntimePluginSummary { + RuntimePluginSummary { + name: entry.manifest.name, + version: entry.manifest.version, + description: entry.manifest.description, + state: entry.state, + health: entry.health, + failure_count: entry.failure_count, + failure: entry.failure, + warnings: entry.warnings, + last_checked_at: entry.last_checked_at, + capabilities: entry + .capabilities + .into_iter() + .map(resolve_runtime_capability_summary) + .collect(), + } +} + /// 运行时重载操作的结果。 #[derive(Debug, Clone)] pub struct ReloadResult { @@ -40,3 +138,172 @@ pub struct ReloadResult { /// 重载完成的时间 pub reloaded_at: chrono::DateTime<chrono::Utc>, } + +#[cfg(test)] +mod tests { + use astrcode_core::{ + AgentCollaborationScorecardSnapshot, CapabilitySpec, ExecutionDiagnosticsSnapshot, + OperationMetricsSnapshot, PluginHealth, PluginManifest, PluginState, PluginType, + ReplayMetricsSnapshot, SideEffect, Stability, SubRunExecutionMetricsSnapshot, + plugin::PluginEntry, + }; + use serde_json::json; + + use super::{GovernanceSnapshot, RuntimeObservabilitySnapshot, resolve_runtime_status_summary}; + + fn capability(name: &str, streaming: bool) -> CapabilitySpec { + let mut builder = CapabilitySpec::builder(name, "tool") + .description(format!("{name} description")) + .schema(json!({ "type": "object" }), json!({ "type": "object" })) + .profiles(["coding"]); + if streaming { + builder = builder.invocation_mode(astrcode_core::InvocationMode::Streaming); + } + builder + .side_effect(SideEffect::None) + .stability(Stability::Stable) + .build() + .expect("capability should build") + } + + fn manifest(name: &str) -> PluginManifest { + PluginManifest { + name: name.to_string(), + version: "0.1.0".to_string(), + description: format!("{name} manifest"), + plugin_type: vec![PluginType::Tool], + capabilities: Vec::new(), + executable: Some("plugin.exe".to_string()), + args: Vec::new(), + working_dir: None, + repository: None, + } + } + + fn metrics() -> RuntimeObservabilitySnapshot { + RuntimeObservabilitySnapshot { + session_rehydrate: OperationMetricsSnapshot { + total: 3, + failures: 1, + total_duration_ms: 12, + last_duration_ms: 4, + max_duration_ms: 7, + }, + sse_catch_up: ReplayMetricsSnapshot { + totals: OperationMetricsSnapshot { + total: 4, + failures: 0, + total_duration_ms: 8, + last_duration_ms: 2, + max_duration_ms: 5, + }, + cache_hits: 6, + disk_fallbacks: 1, + recovered_events: 20, + }, + turn_execution: OperationMetricsSnapshot { + total: 5, + failures: 1, + total_duration_ms: 15, + last_duration_ms: 5, + max_duration_ms: 9, + }, + subrun_execution: SubRunExecutionMetricsSnapshot { + total: 6, + failures: 1, + completed: 5, + cancelled: 0, + token_exceeded: 0, + independent_session_total: 2, + total_duration_ms: 18, + last_duration_ms: 3, + total_steps: 11, + last_step_count: 2, + total_estimated_tokens: 200, + last_estimated_tokens: 40, + }, + execution_diagnostics: ExecutionDiagnosticsSnapshot { + child_spawned: 1, + child_started_persisted: 1, + child_terminal_persisted: 1, + parent_reactivation_requested: 1, + parent_reactivation_succeeded: 1, + parent_reactivation_failed: 0, + lineage_mismatch_parent_agent: 0, + lineage_mismatch_parent_session: 0, + lineage_mismatch_child_session: 0, + lineage_mismatch_descriptor_missing: 0, + cache_reuse_hits: 2, + cache_reuse_misses: 1, + delivery_buffer_queued: 3, + delivery_buffer_dequeued: 3, + delivery_buffer_wake_requested: 1, + delivery_buffer_wake_succeeded: 1, + delivery_buffer_wake_failed: 0, + }, + agent_collaboration: AgentCollaborationScorecardSnapshot { + total_facts: 2, + spawn_accepted: 1, + spawn_rejected: 1, + send_reused: 0, + send_queued: 1, + send_rejected: 0, + observe_calls: 1, + observe_rejected: 0, + observe_followed_by_action: 1, + close_calls: 0, + close_rejected: 0, + delivery_delivered: 1, + delivery_consumed: 1, + delivery_replayed: 0, + orphan_child_count: 0, + child_reuse_ratio_bps: Some(5000), + observe_to_action_ratio_bps: Some(10000), + spawn_to_delivery_ratio_bps: Some(10000), + orphan_child_ratio_bps: Some(0), + avg_delivery_latency_ms: Some(12), + max_delivery_latency_ms: Some(12), + }, + } + } + + #[test] + fn resolve_runtime_status_summary_projects_snapshot_inputs() { + let snapshot = GovernanceSnapshot { + runtime_name: "astrcode".to_string(), + runtime_kind: "desktop".to_string(), + loaded_session_count: 2, + running_session_ids: vec!["session-a".to_string()], + plugin_search_paths: vec!["C:/plugins".into()], + metrics: metrics(), + capabilities: vec![capability("tool.repo.inspect", true)], + plugins: vec![PluginEntry { + manifest: manifest("repo-plugin"), + state: PluginState::Initialized, + health: PluginHealth::Healthy, + failure_count: 0, + capabilities: vec![capability("tool.repo.inspect", false)], + failure: None, + warnings: vec!["skill warning".to_string()], + last_checked_at: Some("2026-04-16T12:00:00+08:00".to_string()), + }], + }; + + let summary = resolve_runtime_status_summary(snapshot); + + assert_eq!(summary.runtime_name, "astrcode"); + assert_eq!(summary.plugin_search_paths, vec!["C:/plugins".to_string()]); + assert_eq!(summary.capabilities.len(), 1); + assert!(summary.capabilities[0].streaming); + assert_eq!(summary.capabilities[0].kind, "tool"); + assert_eq!(summary.plugins.len(), 1); + assert_eq!(summary.plugins[0].name, "repo-plugin"); + assert_eq!(summary.plugins[0].capabilities.len(), 1); + assert!(!summary.plugins[0].capabilities[0].streaming); + assert_eq!( + summary.plugins[0].last_checked_at.as_deref(), + Some("2026-04-16T12:00:00+08:00") + ); + assert_eq!(summary.metrics.session_rehydrate.total, 3); + } +} diff --git a/crates/application/src/ports/agent_kernel.rs b/crates/application/src/ports/agent_kernel.rs index 02af73fa..943373c2 100644 --- a/crates/application/src/ports/agent_kernel.rs +++ b/crates/application/src/ports/agent_kernel.rs @@ -1,3 +1,13 @@ +//! Agent 编排子域依赖的 kernel 稳定端口。 +//! +//! `AgentKernelPort` 继承 `AppKernelPort`,扩展了 agent 编排所需的全部 kernel 操作: +//! lifecycle 管理、子 agent spawn/resume/terminate、inbox 投递、parent delivery 队列。 +//! +//! 为什么单独抽 trait:`AgentOrchestrationService` 需要的控制面明显大于 `App`, +//! 避免 `AppKernelPort` 被动膨胀成新的大而全 façade。 +//! +//! 同时提供 `Kernel` 对 `AgentKernelPort` 的 blanket impl。 + use astrcode_core::{ AgentInboxEnvelope, AgentLifecycleStatus, AgentTurnOutcome, ChildSessionNotification, DelegationMetadata, SubRunHandle, @@ -59,6 +69,7 @@ pub trait AgentKernelPort: AppKernelPort { &self, parent_session_id: &str, ) -> Option<Vec<PendingParentDelivery>>; + async fn pending_parent_delivery_count(&self, parent_session_id: &str) -> usize; async fn requeue_parent_delivery_batch(&self, parent_session_id: &str, delivery_ids: &[String]); async fn consume_parent_delivery_batch( &self, @@ -184,6 +195,12 @@ impl AgentKernelPort for Kernel { .await } + async fn pending_parent_delivery_count(&self, parent_session_id: &str) -> usize { + self.agent_control() + .pending_parent_delivery_count(parent_session_id) + .await + } + async fn requeue_parent_delivery_batch( &self, parent_session_id: &str, diff --git a/crates/application/src/ports/agent_session.rs b/crates/application/src/ports/agent_session.rs index 10a17c2c..5363c06d 100644 --- a/crates/application/src/ports/agent_session.rs +++ b/crates/application/src/ports/agent_session.rs @@ -1,16 +1,25 @@ +//! Agent 编排子域依赖的 session 稳定端口。 +//! +//! `AgentSessionPort` 继承 `AppSessionPort`,扩展了 agent 协作编排所需的全部 session 操作: +//! child session 建立、prompt 提交(带 turn id)、durable input queue 管理、 +//! collaboration fact 追加、observe 快照、turn 终态等待。 +//! +//! 先按职责分组在一个端口中表达完整协作流程,未来根据演化决定是否继续瘦身。 +//! +//! 同时提供 `SessionRuntime` 对 `AgentSessionPort` 的 blanket impl。 + use astrcode_core::{ AgentCollaborationFact, AgentEventContext, AgentLifecycleStatus, ExecutionAccepted, - MailboxBatchAckedPayload, MailboxBatchStartedPayload, MailboxDiscardedPayload, - MailboxQueuedPayload, ResolvedRuntimeConfig, SessionMeta, StoredEvent, TurnId, + InputBatchAckedPayload, InputBatchStartedPayload, InputDiscardedPayload, InputQueuedPayload, + ResolvedRuntimeConfig, SessionMeta, StoredEvent, TurnId, }; use astrcode_kernel::PendingParentDelivery; use astrcode_session_runtime::{ - AgentObserveSnapshot, AgentPromptSubmission, ProjectedTurnOutcome, SessionRuntime, - TurnTerminalSnapshot, + AgentObserveSnapshot, ProjectedTurnOutcome, SessionRuntime, TurnTerminalSnapshot, }; use async_trait::async_trait; -use super::AppSessionPort; +use super::{AppAgentPromptSubmission, AppSessionPort}; /// Agent 编排子域依赖的 session 稳定端口。 /// @@ -29,7 +38,7 @@ pub trait AgentSessionPort: AppSessionPort { session_id: &str, text: String, runtime: ResolvedRuntimeConfig, - submission: AgentPromptSubmission, + submission: AppAgentPromptSubmission, ) -> astrcode_core::Result<ExecutionAccepted>; async fn try_submit_prompt_for_agent_with_turn_id( &self, @@ -37,37 +46,45 @@ pub trait AgentSessionPort: AppSessionPort { turn_id: TurnId, text: String, runtime: ResolvedRuntimeConfig, - submission: AgentPromptSubmission, + submission: AppAgentPromptSubmission, + ) -> astrcode_core::Result<Option<ExecutionAccepted>>; + async fn submit_queued_inputs_for_agent_with_turn_id( + &self, + session_id: &str, + turn_id: TurnId, + queued_inputs: Vec<String>, + runtime: ResolvedRuntimeConfig, + submission: AppAgentPromptSubmission, ) -> astrcode_core::Result<Option<ExecutionAccepted>>; - // Durable mailbox / collaboration 事件追加。 - async fn append_agent_mailbox_queued( + // Durable input queue / collaboration 事件追加。 + async fn append_agent_input_queued( &self, session_id: &str, turn_id: &str, agent: AgentEventContext, - payload: MailboxQueuedPayload, + payload: InputQueuedPayload, ) -> astrcode_core::Result<StoredEvent>; - async fn append_agent_mailbox_discarded( + async fn append_agent_input_discarded( &self, session_id: &str, turn_id: &str, agent: AgentEventContext, - payload: MailboxDiscardedPayload, + payload: InputDiscardedPayload, ) -> astrcode_core::Result<StoredEvent>; - async fn append_agent_mailbox_batch_started( + async fn append_agent_input_batch_started( &self, session_id: &str, turn_id: &str, agent: AgentEventContext, - payload: MailboxBatchStartedPayload, + payload: InputBatchStartedPayload, ) -> astrcode_core::Result<StoredEvent>; - async fn append_agent_mailbox_batch_acked( + async fn append_agent_input_batch_acked( &self, session_id: &str, turn_id: &str, agent: AgentEventContext, - payload: MailboxBatchAckedPayload, + payload: InputBatchAckedPayload, ) -> astrcode_core::Result<StoredEvent>; async fn append_child_session_notification( &self, @@ -131,9 +148,9 @@ impl AgentSessionPort for SessionRuntime { session_id: &str, text: String, runtime: ResolvedRuntimeConfig, - submission: AgentPromptSubmission, + submission: AppAgentPromptSubmission, ) -> astrcode_core::Result<ExecutionAccepted> { - self.submit_prompt_for_agent_with_submission(session_id, text, runtime, submission) + self.submit_prompt_for_agent_with_submission(session_id, text, runtime, submission.into()) .await } @@ -143,56 +160,78 @@ impl AgentSessionPort for SessionRuntime { turn_id: TurnId, text: String, runtime: ResolvedRuntimeConfig, - submission: AgentPromptSubmission, + submission: AppAgentPromptSubmission, ) -> astrcode_core::Result<Option<ExecutionAccepted>> { self.try_submit_prompt_for_agent_with_turn_id( - session_id, turn_id, text, runtime, submission, + session_id, + turn_id, + text, + runtime, + submission.into(), + ) + .await + } + + async fn submit_queued_inputs_for_agent_with_turn_id( + &self, + session_id: &str, + turn_id: TurnId, + queued_inputs: Vec<String>, + runtime: ResolvedRuntimeConfig, + submission: AppAgentPromptSubmission, + ) -> astrcode_core::Result<Option<ExecutionAccepted>> { + self.submit_queued_inputs_for_agent_with_turn_id( + session_id, + turn_id, + queued_inputs, + runtime, + submission.into(), ) .await } - // Durable mailbox / collaboration 事件追加。 - async fn append_agent_mailbox_queued( + // Durable input queue / collaboration 事件追加。 + async fn append_agent_input_queued( &self, session_id: &str, turn_id: &str, agent: AgentEventContext, - payload: MailboxQueuedPayload, + payload: InputQueuedPayload, ) -> astrcode_core::Result<StoredEvent> { - self.append_agent_mailbox_queued(session_id, turn_id, agent, payload) + self.append_agent_input_queued(session_id, turn_id, agent, payload) .await } - async fn append_agent_mailbox_discarded( + async fn append_agent_input_discarded( &self, session_id: &str, turn_id: &str, agent: AgentEventContext, - payload: MailboxDiscardedPayload, + payload: InputDiscardedPayload, ) -> astrcode_core::Result<StoredEvent> { - self.append_agent_mailbox_discarded(session_id, turn_id, agent, payload) + self.append_agent_input_discarded(session_id, turn_id, agent, payload) .await } - async fn append_agent_mailbox_batch_started( + async fn append_agent_input_batch_started( &self, session_id: &str, turn_id: &str, agent: AgentEventContext, - payload: MailboxBatchStartedPayload, + payload: InputBatchStartedPayload, ) -> astrcode_core::Result<StoredEvent> { - self.append_agent_mailbox_batch_started(session_id, turn_id, agent, payload) + self.append_agent_input_batch_started(session_id, turn_id, agent, payload) .await } - async fn append_agent_mailbox_batch_acked( + async fn append_agent_input_batch_acked( &self, session_id: &str, turn_id: &str, agent: AgentEventContext, - payload: MailboxBatchAckedPayload, + payload: InputBatchAckedPayload, ) -> astrcode_core::Result<StoredEvent> { - self.append_agent_mailbox_batch_acked(session_id, turn_id, agent, payload) + self.append_agent_input_batch_acked(session_id, turn_id, agent, payload) .await } diff --git a/crates/application/src/ports/app_kernel.rs b/crates/application/src/ports/app_kernel.rs index 9d675084..0637c1e7 100644 --- a/crates/application/src/ports/app_kernel.rs +++ b/crates/application/src/ports/app_kernel.rs @@ -1,3 +1,12 @@ +//! `App` 依赖的 kernel 稳定端口。 +//! +//! 定义 `AppKernelPort` trait,将应用层与 kernel 具体实现解耦。 +//! `App` 只需要一组稳定的 agent 控制与 capability 查询契约, +//! 不直接绑定 `Kernel` 的内部结构。 +//! +//! 同时提供 `Kernel` 对 `AppKernelPort` 的 blanket impl, +//! 组合根在 `bootstrap_server_runtime()` 中只需一次 `Arc<Kernel>` 即可满足两个端口的约束。 + use astrcode_core::SubRunHandle; use astrcode_kernel::{ AgentControlError, CloseSubtreeResult, Kernel, KernelGateway, SubRunStatusView, diff --git a/crates/application/src/ports/app_session.rs b/crates/application/src/ports/app_session.rs index d92ee649..9b34869a 100644 --- a/crates/application/src/ports/app_session.rs +++ b/crates/application/src/ports/app_session.rs @@ -1,14 +1,25 @@ +//! `App` 依赖的 session-runtime 稳定端口。 +//! +//! 定义 `AppSessionPort` trait,将应用层与 `SessionRuntime` 具体实现解耦。 +//! `App` 只编排 session 用例(创建、提交、快照、compact 等), +//! 不直接耦合 `SessionRuntime` 的内部状态管理。 +//! +//! 同时提供 `SessionRuntime` 对 `AppSessionPort` 的 blanket impl。 + use astrcode_core::{ ChildSessionNode, DeleteProjectResult, ExecutionAccepted, ResolvedRuntimeConfig, SessionId, - SessionMeta, StoredEvent, + SessionMeta, StoredEvent, TaskSnapshot, }; use astrcode_session_runtime::{ - AgentPromptSubmission, SessionCatalogEvent, SessionControlStateSnapshot, SessionReplay, + ConversationSnapshotFacts, ConversationStreamReplayFacts, ForkPoint, ForkResult, + SessionCatalogEvent, SessionControlStateSnapshot, SessionModeSnapshot, SessionReplay, SessionRuntime, SessionTranscriptSnapshot, }; use async_trait::async_trait; use tokio::sync::broadcast; +use super::AppAgentPromptSubmission; + /// `App` 依赖的 session-runtime 稳定端口。 /// /// Why: `App` 只编排 session 用例,不应直接耦合 `SessionRuntime` 的具体结构。 @@ -18,6 +29,11 @@ pub trait AppSessionPort: Send + Sync { async fn list_session_metas(&self) -> astrcode_core::Result<Vec<SessionMeta>>; async fn create_session(&self, working_dir: String) -> astrcode_core::Result<SessionMeta>; + async fn fork_session( + &self, + session_id: &SessionId, + fork_point: ForkPoint, + ) -> astrcode_core::Result<ForkResult>; async fn delete_session(&self, session_id: &str) -> astrcode_core::Result<()>; async fn delete_project(&self, working_dir: &str) -> astrcode_core::Result<DeleteProjectResult>; @@ -27,22 +43,42 @@ pub trait AppSessionPort: Send + Sync { session_id: &str, text: String, runtime: ResolvedRuntimeConfig, - submission: AgentPromptSubmission, + submission: AppAgentPromptSubmission, ) -> astrcode_core::Result<ExecutionAccepted>; async fn interrupt_session(&self, session_id: &str) -> astrcode_core::Result<()>; async fn compact_session( &self, session_id: &str, runtime: ResolvedRuntimeConfig, + instructions: Option<String>, ) -> astrcode_core::Result<bool>; async fn session_transcript_snapshot( &self, session_id: &str, ) -> astrcode_core::Result<SessionTranscriptSnapshot>; + async fn conversation_snapshot( + &self, + session_id: &str, + ) -> astrcode_core::Result<ConversationSnapshotFacts>; async fn session_control_state( &self, session_id: &str, ) -> astrcode_core::Result<SessionControlStateSnapshot>; + async fn active_task_snapshot( + &self, + session_id: &str, + owner: &str, + ) -> astrcode_core::Result<Option<TaskSnapshot>>; + async fn session_mode_state( + &self, + session_id: &str, + ) -> astrcode_core::Result<SessionModeSnapshot>; + async fn switch_mode( + &self, + session_id: &str, + from: astrcode_core::ModeId, + to: astrcode_core::ModeId, + ) -> astrcode_core::Result<StoredEvent>; async fn session_child_nodes( &self, session_id: &str, @@ -56,6 +92,11 @@ pub trait AppSessionPort: Send + Sync { session_id: &str, last_event_id: Option<&str>, ) -> astrcode_core::Result<SessionReplay>; + async fn conversation_stream_replay( + &self, + session_id: &str, + last_event_id: Option<&str>, + ) -> astrcode_core::Result<ConversationStreamReplayFacts>; } #[async_trait] @@ -72,6 +113,14 @@ impl AppSessionPort for SessionRuntime { self.create_session(working_dir).await } + async fn fork_session( + &self, + session_id: &SessionId, + fork_point: ForkPoint, + ) -> astrcode_core::Result<ForkResult> { + self.fork_session(session_id, fork_point).await + } + async fn delete_session(&self, session_id: &str) -> astrcode_core::Result<()> { self.delete_session(session_id).await } @@ -92,9 +141,9 @@ impl AppSessionPort for SessionRuntime { session_id: &str, text: String, runtime: ResolvedRuntimeConfig, - submission: AgentPromptSubmission, + submission: AppAgentPromptSubmission, ) -> astrcode_core::Result<ExecutionAccepted> { - self.submit_prompt_for_agent(session_id, text, runtime, submission) + self.submit_prompt_for_agent(session_id, text, runtime, submission.into()) .await } @@ -106,8 +155,10 @@ impl AppSessionPort for SessionRuntime { &self, session_id: &str, runtime: ResolvedRuntimeConfig, + instructions: Option<String>, ) -> astrcode_core::Result<bool> { - self.compact_session(session_id, runtime).await + self.compact_session(session_id, runtime, instructions) + .await } async fn session_transcript_snapshot( @@ -117,6 +168,13 @@ impl AppSessionPort for SessionRuntime { self.session_transcript_snapshot(session_id).await } + async fn conversation_snapshot( + &self, + session_id: &str, + ) -> astrcode_core::Result<ConversationSnapshotFacts> { + self.conversation_snapshot(session_id).await + } + async fn session_control_state( &self, session_id: &str, @@ -124,6 +182,30 @@ impl AppSessionPort for SessionRuntime { self.session_control_state(session_id).await } + async fn active_task_snapshot( + &self, + session_id: &str, + owner: &str, + ) -> astrcode_core::Result<Option<TaskSnapshot>> { + self.active_task_snapshot(session_id, owner).await + } + + async fn session_mode_state( + &self, + session_id: &str, + ) -> astrcode_core::Result<SessionModeSnapshot> { + self.session_mode_state(session_id).await + } + + async fn switch_mode( + &self, + session_id: &str, + from: astrcode_core::ModeId, + to: astrcode_core::ModeId, + ) -> astrcode_core::Result<StoredEvent> { + self.switch_mode(session_id, from, to).await + } + async fn session_child_nodes( &self, session_id: &str, @@ -145,4 +227,13 @@ impl AppSessionPort for SessionRuntime { ) -> astrcode_core::Result<SessionReplay> { self.session_replay(session_id, last_event_id).await } + + async fn conversation_stream_replay( + &self, + session_id: &str, + last_event_id: Option<&str>, + ) -> astrcode_core::Result<ConversationStreamReplayFacts> { + self.conversation_stream_replay(session_id, last_event_id) + .await + } } diff --git a/crates/application/src/ports/composer_skill.rs b/crates/application/src/ports/composer_skill.rs index 6f4752b0..82c0b9ab 100644 --- a/crates/application/src/ports/composer_skill.rs +++ b/crates/application/src/ports/composer_skill.rs @@ -1,11 +1,25 @@ +//! Composer 输入补全的 skill 查询端口。 +//! +//! 定义 `ComposerSkillPort` trait 和 `ComposerResolvedSkill` 类型, +//! 将 composer 输入补全与 adapter-skills 的实现细节解耦。 +//! 应用层不应直接依赖 `adapter-skills`,而是通过此端口获取当前会话可见的 skill 信息。 + use std::path::Path; use crate::ComposerSkillSummary; +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ComposerResolvedSkill { + pub id: String, + pub description: String, + pub guide: String, +} + /// `App` 依赖的 skill 补全端口。 /// /// Why: composer 输入补全需要看到当前会话可见的 skill, /// 但应用层不应直接依赖 `adapter-skills` 的实现细节。 pub trait ComposerSkillPort: Send + Sync { fn list_skill_summaries(&self, working_dir: &Path) -> Vec<ComposerSkillSummary>; + fn resolve_skill(&self, working_dir: &Path, skill_id: &str) -> Option<ComposerResolvedSkill>; } diff --git a/crates/application/src/ports/mod.rs b/crates/application/src/ports/mod.rs index 1315f9df..2bf88a4f 100644 --- a/crates/application/src/ports/mod.rs +++ b/crates/application/src/ports/mod.rs @@ -1,11 +1,22 @@ +//! # 应用层端口(Port) +//! +//! 定义 application 层与外部系统交互的 trait 契约,实现依赖反转: +//! - `AppKernelPort`:`App` 依赖的 kernel 控制面 +//! - `AgentKernelPort`:Agent 编排子域扩展的 kernel 端口 +//! - `AppSessionPort`:`App` 依赖的 session-runtime 稳定端口 +//! - `AgentSessionPort`:Agent 编排子域扩展的 session 端口 +//! - `ComposerSkillPort`:composer 输入补全的 skill 查询端口 + mod agent_kernel; mod agent_session; mod app_kernel; mod app_session; mod composer_skill; +mod session_submission; pub use agent_kernel::AgentKernelPort; pub use agent_session::AgentSessionPort; pub use app_kernel::AppKernelPort; pub use app_session::AppSessionPort; -pub use composer_skill::ComposerSkillPort; +pub use composer_skill::{ComposerResolvedSkill, ComposerSkillPort}; +pub use session_submission::AppAgentPromptSubmission; diff --git a/crates/application/src/ports/session_submission.rs b/crates/application/src/ports/session_submission.rs new file mode 100644 index 00000000..c1132c33 --- /dev/null +++ b/crates/application/src/ports/session_submission.rs @@ -0,0 +1,44 @@ +//! 应用层定义的 agent prompt 提交载荷。 +//! +//! Why: application 可以表达“要提交什么治理上下文”, +//! 但不应该直接依赖 session-runtime 的具体提交结构。 + +use astrcode_core::{ + AgentEventContext, CapabilityCall, LlmMessage, PolicyContext, PromptDeclaration, + PromptGovernanceContext, ResolvedExecutionLimitsSnapshot, ResolvedSubagentContextOverrides, +}; +use astrcode_kernel::CapabilityRouter; + +/// 应用层提交给 session 端口的稳定载荷。 +#[derive(Clone, Default)] +pub struct AppAgentPromptSubmission { + pub agent: AgentEventContext, + pub capability_router: Option<CapabilityRouter>, + pub prompt_declarations: Vec<PromptDeclaration>, + pub resolved_limits: Option<ResolvedExecutionLimitsSnapshot>, + pub resolved_overrides: Option<ResolvedSubagentContextOverrides>, + pub injected_messages: Vec<LlmMessage>, + pub source_tool_call_id: Option<String>, + pub policy_context: Option<PolicyContext>, + pub governance_revision: Option<String>, + pub approval: Option<astrcode_core::ApprovalPending<CapabilityCall>>, + pub prompt_governance: Option<PromptGovernanceContext>, +} + +impl From<AppAgentPromptSubmission> for astrcode_session_runtime::AgentPromptSubmission { + fn from(value: AppAgentPromptSubmission) -> Self { + Self { + agent: value.agent, + capability_router: value.capability_router, + prompt_declarations: value.prompt_declarations, + resolved_limits: value.resolved_limits, + resolved_overrides: value.resolved_overrides, + injected_messages: value.injected_messages, + source_tool_call_id: value.source_tool_call_id, + policy_context: value.policy_context, + governance_revision: value.governance_revision, + approval: value.approval.map(Box::new), + prompt_governance: value.prompt_governance, + } + } +} diff --git a/crates/application/src/session_plan.rs b/crates/application/src/session_plan.rs new file mode 100644 index 00000000..1bbb72fd --- /dev/null +++ b/crates/application/src/session_plan.rs @@ -0,0 +1,791 @@ +//! session 级计划工件。 +//! +//! 这里维护 session 下唯一 canonical plan 的路径规则、状态模型、审批归档和 prompt 注入, +//! 保持 plan mode 的流程真相收敛在 application,而不是散落在 handler / tool / UI。 + +use std::{ + fs, + path::{Path, PathBuf}, +}; + +use astrcode_core::{ + ModeId, PromptDeclaration, SessionPlanState, SessionPlanStatus, project::project_dir, + session_plan_content_digest, +}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::{ApplicationError, mode::builtin_prompts}; + +const PLAN_DIR_NAME: &str = "plan"; +const PLAN_ARCHIVE_DIR_NAME: &str = "plan-archives"; +const PLAN_STATE_FILE_NAME: &str = "state.json"; +const PLAN_ARCHIVE_FILE_NAME: &str = "plan.md"; +const PLAN_ARCHIVE_METADATA_FILE_NAME: &str = "metadata.json"; +const PLAN_PATH_TIMESTAMP_FORMAT: &str = "%Y%m%dT%H%M%SZ"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SessionPlanSummary { + pub slug: String, + pub path: String, + pub status: String, + pub title: String, + pub updated_at: DateTime<Utc>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct SessionPlanControlSummary { + pub active_plan: Option<SessionPlanSummary>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PlanPromptContext { + pub target_plan_path: String, + pub target_plan_exists: bool, + pub target_plan_slug: String, + pub active_plan: Option<SessionPlanSummary>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PlanApprovalParseResult { + pub approved: bool, + pub matched_phrase: Option<&'static str>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProjectPlanArchiveMetadata { + pub archive_id: String, + pub title: String, + pub source_session_id: String, + pub source_plan_slug: String, + pub source_plan_path: String, + pub approved_at: DateTime<Utc>, + pub archived_at: DateTime<Utc>, + pub status: String, + pub content_digest: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProjectPlanArchiveSummary { + pub metadata: ProjectPlanArchiveMetadata, + pub archive_dir: String, + pub plan_path: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProjectPlanArchiveDetail { + pub summary: ProjectPlanArchiveSummary, + pub content: String, +} + +fn io_error(action: &str, path: &Path, error: std::io::Error) -> ApplicationError { + ApplicationError::Internal(format!("{action} '{}' failed: {error}", path.display())) +} + +pub(crate) fn session_plan_dir( + session_id: &str, + working_dir: &Path, +) -> Result<PathBuf, ApplicationError> { + Ok(project_dir(working_dir) + .map_err(|error| { + ApplicationError::Internal(format!( + "failed to resolve project directory for '{}': {error}", + working_dir.display() + )) + })? + .join("sessions") + .join(session_id) + .join(PLAN_DIR_NAME)) +} + +fn project_plan_archive_dir(working_dir: &Path) -> Result<PathBuf, ApplicationError> { + Ok(project_dir(working_dir) + .map_err(|error| { + ApplicationError::Internal(format!( + "failed to resolve project directory for '{}': {error}", + working_dir.display() + )) + })? + .join(PLAN_ARCHIVE_DIR_NAME)) +} + +fn session_plan_state_path( + session_id: &str, + working_dir: &Path, +) -> Result<PathBuf, ApplicationError> { + Ok(session_plan_dir(session_id, working_dir)?.join(PLAN_STATE_FILE_NAME)) +} + +fn session_plan_markdown_path( + session_id: &str, + working_dir: &Path, + slug: &str, +) -> Result<PathBuf, ApplicationError> { + Ok(session_plan_dir(session_id, working_dir)?.join(format!("{slug}.md"))) +} + +fn archive_paths( + working_dir: &Path, + archive_id: &str, +) -> Result<(PathBuf, PathBuf, PathBuf), ApplicationError> { + validate_archive_id(archive_id)?; + let archive_dir = project_plan_archive_dir(working_dir)?.join(archive_id); + Ok(( + archive_dir.clone(), + archive_dir.join(PLAN_ARCHIVE_FILE_NAME), + archive_dir.join(PLAN_ARCHIVE_METADATA_FILE_NAME), + )) +} + +pub(crate) fn load_session_plan_state( + session_id: &str, + working_dir: &Path, +) -> Result<Option<SessionPlanState>, ApplicationError> { + let path = session_plan_state_path(session_id, working_dir)?; + if !path.exists() { + return Ok(None); + } + let content = fs::read_to_string(&path).map_err(|error| io_error("reading", &path, error))?; + serde_json::from_str::<SessionPlanState>(&content) + .map(Some) + .map_err(|error| { + ApplicationError::Internal(format!( + "failed to parse session plan state '{}': {error}", + path.display() + )) + }) +} + +pub(crate) fn session_plan_control_summary( + session_id: &str, + working_dir: &Path, +) -> Result<SessionPlanControlSummary, ApplicationError> { + Ok(SessionPlanControlSummary { + active_plan: active_plan_summary(session_id, working_dir)?, + }) +} + +pub(crate) fn active_plan_summary( + session_id: &str, + working_dir: &Path, +) -> Result<Option<SessionPlanSummary>, ApplicationError> { + let Some(state) = load_session_plan_state(session_id, working_dir)? else { + return Ok(None); + }; + Ok(Some(plan_summary(session_id, working_dir, &state)?)) +} + +pub(crate) fn build_plan_prompt_context( + session_id: &str, + working_dir: &Path, + user_text: &str, +) -> Result<PlanPromptContext, ApplicationError> { + if let Some(active_plan) = active_plan_summary(session_id, working_dir)? { + return Ok(PlanPromptContext { + target_plan_path: active_plan.path.clone(), + target_plan_exists: Path::new(&active_plan.path).exists(), + target_plan_slug: active_plan.slug.clone(), + active_plan: Some(active_plan), + }); + } + + let suggested_slug = slugify_plan_topic(user_text) + .unwrap_or_else(|| format!("plan-{}", Utc::now().format(PLAN_PATH_TIMESTAMP_FORMAT))); + let path = session_plan_markdown_path(session_id, working_dir, &suggested_slug)?; + Ok(PlanPromptContext { + target_plan_path: path.display().to_string(), + target_plan_exists: false, + target_plan_slug: suggested_slug, + active_plan: None, + }) +} + +pub(crate) fn build_plan_prompt_declarations( + session_id: &str, + context: &PlanPromptContext, +) -> Vec<PromptDeclaration> { + let active_plan_line = context + .active_plan + .as_ref() + .map(|plan| { + format!( + "- activePlan: slug={}, title={}, status={}, path={}", + plan.slug, plan.title, plan.status, plan.path + ) + }) + .unwrap_or_else(|| "- activePlan: (none)".to_string()); + let mut declarations = vec![PromptDeclaration { + block_id: format!("session.plan.facts.{session_id}"), + title: "Session Plan Artifact".to_string(), + content: format!( + "Session plan facts:\n- targetPlanPath: {}\n- targetPlanExists: {}\n- targetPlanSlug: \ + {}\n{}\n\nUse `upsertSessionPlan` as the only canonical write path for session \ + plans. This session has exactly one canonical plan artifact. When continuing the \ + same task, revise the current plan. When the user clearly changes tasks, overwrite \ + the current plan instead of creating another canonical plan. Only call \ + `exitPlanMode` after the current plan is executable and ready for user review.", + context.target_plan_path, + context.target_plan_exists, + context.target_plan_slug, + active_plan_line, + ), + render_target: astrcode_core::PromptDeclarationRenderTarget::System, + layer: astrcode_core::SystemPromptLayer::Dynamic, + kind: astrcode_core::PromptDeclarationKind::ExtensionInstruction, + priority_hint: Some(605), + always_include: true, + source: astrcode_core::PromptDeclarationSource::Builtin, + capability_name: None, + origin: Some("session-plan:facts".to_string()), + }]; + + if context.active_plan.is_some() { + declarations.push(PromptDeclaration { + block_id: format!("session.plan.reentry.{session_id}"), + title: "Plan Re-entry".to_string(), + content: builtin_prompts::plan_mode_reentry_prompt().to_string(), + render_target: astrcode_core::PromptDeclarationRenderTarget::System, + layer: astrcode_core::SystemPromptLayer::Dynamic, + kind: astrcode_core::PromptDeclarationKind::ExtensionInstruction, + priority_hint: Some(604), + always_include: true, + source: astrcode_core::PromptDeclarationSource::Builtin, + capability_name: None, + origin: Some("session-plan:reentry".to_string()), + }); + } else { + declarations.push(PromptDeclaration { + block_id: format!("session.plan.template.{session_id}"), + title: "Plan Template".to_string(), + content: builtin_prompts::plan_template_prompt().to_string(), + render_target: astrcode_core::PromptDeclarationRenderTarget::System, + layer: astrcode_core::SystemPromptLayer::Dynamic, + kind: astrcode_core::PromptDeclarationKind::ExtensionInstruction, + priority_hint: Some(604), + always_include: true, + source: astrcode_core::PromptDeclarationSource::Builtin, + capability_name: None, + origin: Some("session-plan:template".to_string()), + }); + } + + declarations +} + +pub(crate) fn build_plan_exit_declaration( + session_id: &str, + summary: &SessionPlanSummary, +) -> PromptDeclaration { + PromptDeclaration { + block_id: format!("session.plan.exit.{session_id}"), + title: "Plan Mode Exit".to_string(), + content: format!( + "{}\n\nApproved plan artifact:\n- path: {}\n- slug: {}\n- title: {}\n- status: {}", + builtin_prompts::plan_mode_exit_prompt(), + summary.path, + summary.slug, + summary.title, + summary.status + ), + render_target: astrcode_core::PromptDeclarationRenderTarget::System, + layer: astrcode_core::SystemPromptLayer::Dynamic, + kind: astrcode_core::PromptDeclarationKind::ExtensionInstruction, + priority_hint: Some(605), + always_include: true, + source: astrcode_core::PromptDeclarationSource::Builtin, + capability_name: None, + origin: Some("session-plan:exit".to_string()), + } +} + +pub(crate) fn parse_plan_approval(text: &str) -> PlanApprovalParseResult { + let normalized_english = text + .to_ascii_lowercase() + .split_whitespace() + .collect::<Vec<_>>() + .join(" "); + for phrase in ["approved", "go ahead", "implement it"] { + if normalized_english == phrase + || (phrase != "implement it" && normalized_english.starts_with(&format!("{phrase} "))) + { + return PlanApprovalParseResult { + approved: true, + matched_phrase: Some(phrase), + }; + } + } + + let normalized_chinese = text + .chars() + .filter(|ch| !ch.is_whitespace() && !is_common_punctuation(*ch)) + .collect::<String>(); + for phrase in ["同意", "可以", "按这个做", "开始实现"] { + let matched = match phrase { + "同意" | "可以" => normalized_chinese == phrase, + _ => normalized_chinese == phrase || normalized_chinese.starts_with(phrase), + }; + if matched { + return PlanApprovalParseResult { + approved: true, + matched_phrase: Some(phrase), + }; + } + } + + PlanApprovalParseResult { + approved: false, + matched_phrase: None, + } +} + +pub(crate) fn active_plan_requires_approval(state: Option<&SessionPlanState>) -> bool { + state.is_some_and(|state| state.status == SessionPlanStatus::AwaitingApproval) +} + +pub(crate) fn mark_active_session_plan_approved( + session_id: &str, + working_dir: &Path, +) -> Result<Option<SessionPlanSummary>, ApplicationError> { + let Some(mut state) = load_session_plan_state(session_id, working_dir)? else { + return Ok(None); + }; + if state.status != SessionPlanStatus::AwaitingApproval { + return Ok(None); + } + + let plan_path = session_plan_markdown_path(session_id, working_dir, &state.active_plan_slug)?; + let plan_content = + fs::read_to_string(&plan_path).map_err(|error| io_error("reading", &plan_path, error))?; + let plan_content = plan_content.trim().to_string(); + let content_digest = session_plan_content_digest(&plan_content); + let now = Utc::now(); + + state.status = SessionPlanStatus::Approved; + state.updated_at = now; + state.approved_at = Some(now); + if state.archived_plan_digest.as_deref() != Some(content_digest.as_str()) { + let archive_summary = write_plan_archive_snapshot( + session_id, + working_dir, + &state, + &plan_path, + &plan_content, + &content_digest, + now, + )?; + state.archived_plan_digest = Some(content_digest); + state.archived_at = Some(archive_summary.metadata.archived_at); + } + persist_plan_state(&session_plan_state_path(session_id, working_dir)?, &state)?; + Ok(Some(plan_summary(session_id, working_dir, &state)?)) +} + +pub(crate) fn copy_session_plan_artifacts( + source_session_id: &str, + target_session_id: &str, + working_dir: &Path, +) -> Result<(), ApplicationError> { + let source_dir = session_plan_dir(source_session_id, working_dir)?; + if !source_dir.exists() { + return Ok(()); + } + let target_dir = session_plan_dir(target_session_id, working_dir)?; + copy_dir_recursive(&source_dir, &target_dir) +} + +pub(crate) fn current_mode_requires_plan_context(mode_id: &ModeId) -> bool { + mode_id == &ModeId::plan() +} + +pub(crate) fn list_project_plan_archives( + working_dir: &Path, +) -> Result<Vec<ProjectPlanArchiveSummary>, ApplicationError> { + let archive_root = project_plan_archive_dir(working_dir)?; + if !archive_root.exists() { + return Ok(Vec::new()); + } + let mut items = Vec::new(); + for entry in fs::read_dir(&archive_root) + .map_err(|error| io_error("reading directory", &archive_root, error))? + { + let entry = + entry.map_err(|error| io_error("reading directory entry", &archive_root, error))?; + let archive_dir = entry.path(); + if !entry + .file_type() + .map_err(|error| io_error("reading file type", &archive_dir, error))? + .is_dir() + { + continue; + } + let metadata_path = archive_dir.join(PLAN_ARCHIVE_METADATA_FILE_NAME); + let plan_path = archive_dir.join(PLAN_ARCHIVE_FILE_NAME); + if !metadata_path.exists() || !plan_path.exists() { + continue; + } + let metadata = fs::read_to_string(&metadata_path) + .map_err(|error| io_error("reading", &metadata_path, error)) + .and_then(|content| { + serde_json::from_str::<ProjectPlanArchiveMetadata>(&content).map_err(|error| { + ApplicationError::Internal(format!( + "failed to parse plan archive metadata '{}': {error}", + metadata_path.display() + )) + }) + })?; + items.push(ProjectPlanArchiveSummary { + archive_dir: archive_dir.display().to_string(), + plan_path: plan_path.display().to_string(), + metadata, + }); + } + items.sort_by(|left, right| { + right + .metadata + .archived_at + .cmp(&left.metadata.archived_at) + .then_with(|| left.metadata.archive_id.cmp(&right.metadata.archive_id)) + }); + Ok(items) +} + +pub(crate) fn read_project_plan_archive( + working_dir: &Path, + archive_id: &str, +) -> Result<Option<ProjectPlanArchiveDetail>, ApplicationError> { + let (archive_dir, plan_path, metadata_path) = archive_paths(working_dir, archive_id)?; + if !plan_path.exists() || !metadata_path.exists() { + return Ok(None); + } + let metadata = fs::read_to_string(&metadata_path) + .map_err(|error| io_error("reading", &metadata_path, error)) + .and_then(|content| { + serde_json::from_str::<ProjectPlanArchiveMetadata>(&content).map_err(|error| { + ApplicationError::Internal(format!( + "failed to parse plan archive metadata '{}': {error}", + metadata_path.display() + )) + }) + })?; + let content = + fs::read_to_string(&plan_path).map_err(|error| io_error("reading", &plan_path, error))?; + Ok(Some(ProjectPlanArchiveDetail { + summary: ProjectPlanArchiveSummary { + metadata, + archive_dir: archive_dir.display().to_string(), + plan_path: plan_path.display().to_string(), + }, + content, + })) +} + +fn persist_plan_state(path: &Path, state: &SessionPlanState) -> Result<(), ApplicationError> { + let Some(parent) = path.parent() else { + return Err(ApplicationError::Internal(format!( + "session plan state '{}' has no parent directory", + path.display() + ))); + }; + fs::create_dir_all(parent).map_err(|error| io_error("creating directory", parent, error))?; + let content = serde_json::to_string_pretty(state).map_err(|error| { + ApplicationError::Internal(format!( + "failed to serialize session plan state '{}': {error}", + path.display() + )) + })?; + fs::write(path, content).map_err(|error| io_error("writing", path, error)) +} + +fn plan_summary( + session_id: &str, + working_dir: &Path, + state: &SessionPlanState, +) -> Result<SessionPlanSummary, ApplicationError> { + Ok(SessionPlanSummary { + slug: state.active_plan_slug.clone(), + path: session_plan_markdown_path(session_id, working_dir, &state.active_plan_slug)? + .display() + .to_string(), + status: state.status.to_string(), + title: state.title.clone(), + updated_at: state.updated_at, + }) +} + +fn write_plan_archive_snapshot( + session_id: &str, + working_dir: &Path, + state: &SessionPlanState, + plan_path: &Path, + plan_content: &str, + content_digest: &str, + approved_at: DateTime<Utc>, +) -> Result<ProjectPlanArchiveSummary, ApplicationError> { + let archived_at = Utc::now(); + let archive_root = project_plan_archive_dir(working_dir)?; + fs::create_dir_all(&archive_root) + .map_err(|error| io_error("creating directory", &archive_root, error))?; + let archive_id = reserve_archive_id(&archive_root, approved_at, &state.active_plan_slug)?; + let (archive_dir, archive_plan_path, metadata_path) = archive_paths(working_dir, &archive_id)?; + fs::create_dir_all(&archive_dir) + .map_err(|error| io_error("creating directory", &archive_dir, error))?; + fs::write(&archive_plan_path, format!("{plan_content}\n")) + .map_err(|error| io_error("writing", &archive_plan_path, error))?; + let metadata = ProjectPlanArchiveMetadata { + archive_id: archive_id.clone(), + title: state.title.clone(), + source_session_id: session_id.to_string(), + source_plan_slug: state.active_plan_slug.clone(), + source_plan_path: plan_path.display().to_string(), + approved_at, + archived_at, + status: SessionPlanStatus::Approved.to_string(), + content_digest: content_digest.to_string(), + }; + let metadata_content = serde_json::to_string_pretty(&metadata).map_err(|error| { + ApplicationError::Internal(format!( + "failed to serialize plan archive metadata '{}': {error}", + metadata_path.display() + )) + })?; + fs::write(&metadata_path, metadata_content) + .map_err(|error| io_error("writing", &metadata_path, error))?; + Ok(ProjectPlanArchiveSummary { + metadata, + archive_dir: archive_dir.display().to_string(), + plan_path: archive_plan_path.display().to_string(), + }) +} + +fn reserve_archive_id( + archive_root: &Path, + approved_at: DateTime<Utc>, + slug: &str, +) -> Result<String, ApplicationError> { + let base = format!( + "{}-{}", + approved_at.format(PLAN_PATH_TIMESTAMP_FORMAT), + slug + ); + for attempt in 0..=99 { + let candidate = if attempt == 0 { + base.clone() + } else { + format!("{base}-{attempt}") + }; + if !archive_root.join(&candidate).exists() { + return Ok(candidate); + } + } + Err(ApplicationError::Internal(format!( + "failed to reserve a unique plan archive id for slug '{}'", + slug + ))) +} + +fn validate_archive_id(archive_id: &str) -> Result<(), ApplicationError> { + let archive_id = archive_id.trim(); + if archive_id.is_empty() { + return Err(ApplicationError::InvalidArgument( + "archiveId must not be empty".to_string(), + )); + } + if archive_id.contains("..") + || archive_id.contains('/') + || archive_id.contains('\\') + || Path::new(archive_id).is_absolute() + { + return Err(ApplicationError::InvalidArgument(format!( + "archiveId '{}' is invalid", + archive_id + ))); + } + Ok(()) +} + +fn copy_dir_recursive(source: &Path, target: &Path) -> Result<(), ApplicationError> { + fs::create_dir_all(target).map_err(|error| io_error("creating directory", target, error))?; + for entry in + fs::read_dir(source).map_err(|error| io_error("reading directory", source, error))? + { + let entry = entry.map_err(|error| io_error("reading directory entry", source, error))?; + let source_path = entry.path(); + let target_path = target.join(entry.file_name()); + let file_type = entry + .file_type() + .map_err(|error| io_error("reading file type", &source_path, error))?; + if file_type.is_dir() { + copy_dir_recursive(&source_path, &target_path)?; + } else { + fs::copy(&source_path, &target_path) + .map_err(|error| io_error("copying file", &source_path, error))?; + } + } + Ok(()) +} + +fn is_common_punctuation(ch: char) -> bool { + matches!( + ch, + ',' | '.' | ';' | ':' | '!' | '?' | ',' | '。' | ';' | ':' | '!' | '?' | '、' + ) +} + +fn slugify_plan_topic(input: &str) -> Option<String> { + let mut slug = String::new(); + let mut last_dash = false; + for ch in input.chars().map(|ch| ch.to_ascii_lowercase()) { + if ch.is_ascii_alphanumeric() { + slug.push(ch); + last_dash = false; + continue; + } + if !last_dash && !slug.is_empty() { + slug.push('-'); + last_dash = true; + } + if slug.len() >= 48 { + break; + } + } + let slug = slug.trim_matches('-').to_string(); + if slug.is_empty() { None } else { Some(slug) } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_plan_approval_is_conservative() { + assert!(parse_plan_approval("同意").approved); + assert!(parse_plan_approval("按这个做,开始吧").approved); + assert!(parse_plan_approval("approved please continue").approved); + assert!(!parse_plan_approval("可以再想想").approved); + assert!(!parse_plan_approval("don't implement it yet").approved); + } + + #[test] + fn copy_session_plan_artifacts_ignores_missing_source() { + let temp = tempfile::tempdir().expect("tempdir should exist"); + copy_session_plan_artifacts("session-a", "session-b", temp.path()) + .expect("missing source should be ignored"); + } + + #[test] + fn session_plan_state_round_trips_through_json_schema() { + let state = SessionPlanState { + active_plan_slug: "cleanup-crates".to_string(), + title: "Cleanup crates".to_string(), + status: SessionPlanStatus::AwaitingApproval, + created_at: Utc::now(), + updated_at: Utc::now(), + reviewed_plan_digest: Some("abc".to_string()), + approved_at: None, + archived_plan_digest: Some("def".to_string()), + archived_at: None, + }; + + let encoded = serde_json::to_string(&state).expect("state should serialize"); + let decoded = + serde_json::from_str::<SessionPlanState>(&encoded).expect("state should deserialize"); + assert_eq!(decoded.active_plan_slug, "cleanup-crates"); + assert_eq!(decoded.archived_plan_digest.as_deref(), Some("def")); + } + + #[test] + fn build_plan_prompt_declarations_include_single_plan_facts() { + let declarations = build_plan_prompt_declarations( + "session-a", + &PlanPromptContext { + target_plan_path: "/tmp/cleanup-crates.md".to_string(), + target_plan_exists: false, + target_plan_slug: "cleanup-crates".to_string(), + active_plan: None, + }, + ); + + assert_eq!(declarations.len(), 2); + assert!( + declarations[0] + .content + .contains("targetPlanPath: /tmp/cleanup-crates.md") + ); + assert!( + declarations[0] + .content + .contains("overwrite the current plan instead of creating another canonical plan") + ); + assert!(declarations[1].content.contains("## Implementation Steps")); + } + + #[test] + fn reserve_archive_id_adds_suffix_on_collision() { + let temp = tempfile::tempdir().expect("tempdir should exist"); + let root = temp.path(); + fs::create_dir_all(root.join("20260419T000000Z-cleanup-crates")) + .expect("seed dir should exist"); + let candidate = reserve_archive_id( + root, + DateTime::parse_from_rfc3339("2026-04-19T00:00:00Z") + .expect("datetime should parse") + .with_timezone(&Utc), + "cleanup-crates", + ) + .expect("candidate should be reserved"); + assert_eq!(candidate, "20260419T000000Z-cleanup-crates-1"); + } + + #[test] + fn read_project_plan_archive_returns_saved_content() { + let _guard = astrcode_core::test_support::TestEnvGuard::new(); + let working_dir = _guard.home_dir().join("workspace"); + fs::create_dir_all(&working_dir).expect("workspace should exist"); + let archive_root = + project_plan_archive_dir(&working_dir).expect("archive root should resolve"); + fs::create_dir_all(archive_root.join("archive-a")).expect("archive dir should exist"); + fs::write( + archive_root.join("archive-a").join(PLAN_ARCHIVE_FILE_NAME), + "# Plan\n", + ) + .expect("plan should be written"); + fs::write( + archive_root + .join("archive-a") + .join(PLAN_ARCHIVE_METADATA_FILE_NAME), + serde_json::to_string_pretty(&ProjectPlanArchiveMetadata { + archive_id: "archive-a".to_string(), + title: "Cleanup crates".to_string(), + source_session_id: "session-a".to_string(), + source_plan_slug: "cleanup-crates".to_string(), + source_plan_path: "/tmp/cleanup-crates.md".to_string(), + approved_at: Utc::now(), + archived_at: Utc::now(), + status: "approved".to_string(), + content_digest: "abc".to_string(), + }) + .expect("metadata should serialize"), + ) + .expect("metadata should be written"); + + let archive = read_project_plan_archive(&working_dir, "archive-a") + .expect("archive should load") + .expect("archive should exist"); + assert_eq!(archive.summary.metadata.archive_id, "archive-a"); + assert_eq!(archive.content, "# Plan\n"); + } + + #[test] + fn read_project_plan_archive_rejects_path_traversal_archive_id() { + let temp = tempfile::tempdir().expect("tempdir should exist"); + let working_dir = temp.path().join("workspace"); + fs::create_dir_all(&working_dir).expect("workspace should exist"); + + let error = read_project_plan_archive(&working_dir, "../secrets") + .expect_err("path traversal archive id should be rejected"); + + assert!(matches!(error, ApplicationError::InvalidArgument(_))); + assert!(error.to_string().contains("archiveId")); + } +} diff --git a/crates/application/src/session_use_cases.rs b/crates/application/src/session_use_cases.rs index 7c5db1db..f4e0dbfa 100644 --- a/crates/application/src/session_use_cases.rs +++ b/crates/application/src/session_use_cases.rs @@ -1,17 +1,31 @@ -/// ! 这是 App 的用例实现,不是 ports +//! Session 用例(`App` 的 session 相关方法)。 +//! +//! 用户直接发起的 session 操作:prompt 提交、compact、mode 切换、 +//! session 列表查询、快照查询等。这些方法组装治理面并委托到 session-runtime。 + use std::path::Path; use astrcode_core::{ - AgentEventContext, ChildSessionNode, DeleteProjectResult, ExecutionAccepted, SessionMeta, - StoredEvent, + AgentEventContext, ChildSessionNode, DeleteProjectResult, ExecutionAccepted, ModeId, + PromptDeclaration, SessionMeta, StoredEvent, }; use crate::{ - App, ApplicationError, CompactSessionAccepted, ExecutionControl, SessionControlStateSnapshot, - SessionReplay, SessionTranscriptSnapshot, + App, ApplicationError, CompactSessionAccepted, CompactSessionSummary, ExecutionControl, + ModeSummary, ProjectPlanArchiveDetail, ProjectPlanArchiveSummary, PromptAcceptedSummary, + PromptSkillInvocation, SessionControlStateSnapshot, SessionListSummary, SessionReplay, + SessionTranscriptSnapshot, agent::{ IMPLICIT_ROOT_PROFILE_ID, implicit_session_root_agent_id, root_execution_event_context, }, + format_local_rfc3339, + governance_surface::{GovernanceBusyPolicy, SessionGovernanceInput}, + session_plan::{ + active_plan_requires_approval, build_plan_exit_declaration, build_plan_prompt_context, + build_plan_prompt_declarations, copy_session_plan_artifacts, + current_mode_requires_plan_context, list_project_plan_archives, load_session_plan_state, + mark_active_session_plan_approved, parse_plan_approval, read_project_plan_archive, + }, }; impl App { @@ -41,6 +55,46 @@ impl App { .map_err(ApplicationError::from) } + pub async fn fork_session( + &self, + session_id: &str, + fork_point: astrcode_session_runtime::ForkPoint, + ) -> Result<SessionMeta, ApplicationError> { + self.validate_non_empty("sessionId", session_id)?; + let source_working_dir = self + .session_runtime + .get_session_working_dir(session_id) + .await?; + let normalized_session_id = astrcode_session_runtime::normalize_session_id(session_id); + let result = self + .session_runtime + .fork_session( + &astrcode_core::SessionId::from(normalized_session_id), + fork_point, + ) + .await + .map_err(ApplicationError::from)?; + let meta = self + .session_runtime + .list_session_metas() + .await + .map_err(ApplicationError::from)? + .into_iter() + .find(|meta| meta.session_id == result.new_session_id.as_str()) + .ok_or_else(|| { + ApplicationError::Internal(format!( + "forked session '{}' was created but metadata is unavailable", + result.new_session_id + )) + })?; + copy_session_plan_artifacts( + session_id, + result.new_session_id.as_str(), + Path::new(&source_working_dir), + )?; + Ok(meta) + } + pub async fn delete_project( &self, working_dir: &str, @@ -51,12 +105,28 @@ impl App { .map_err(ApplicationError::from) } + pub fn list_project_plan_archives( + &self, + working_dir: &Path, + ) -> Result<Vec<ProjectPlanArchiveSummary>, ApplicationError> { + list_project_plan_archives(working_dir) + } + + pub fn read_project_plan_archive( + &self, + working_dir: &Path, + archive_id: &str, + ) -> Result<Option<ProjectPlanArchiveDetail>, ApplicationError> { + self.validate_non_empty("archiveId", archive_id)?; + read_project_plan_archive(working_dir, archive_id) + } + pub async fn submit_prompt( &self, session_id: &str, text: String, ) -> Result<ExecutionAccepted, ApplicationError> { - self.submit_prompt_with_control(session_id, text, None) + self.submit_prompt_with_options(session_id, text, None, None) .await } @@ -66,7 +136,27 @@ impl App { text: String, control: Option<ExecutionControl>, ) -> Result<ExecutionAccepted, ApplicationError> { - self.validate_non_empty("prompt", &text)?; + self.submit_prompt_with_options(session_id, text, control, None) + .await + } + + /// 带 skill 调用选项的 prompt 提交。 + /// + /// 完整流程: + /// 1. 规范化文本(处理 skill invocation 与纯文本的交互) + /// 2. 校验 ExecutionControl 参数 + /// 3. 加载 runtime 配置 + 确保 session root agent context + /// 4. 若有 skill invocation,解析 skill 并构建 prompt declaration + /// 5. 构建治理面(工具白名单、审批策略、协作指导等) + /// 6. 委托 session-runtime 提交 prompt + pub async fn submit_prompt_with_options( + &self, + session_id: &str, + text: String, + control: Option<ExecutionControl>, + skill_invocation: Option<PromptSkillInvocation>, + ) -> Result<ExecutionAccepted, ApplicationError> { + let text = normalize_submission_text(text, skill_invocation.as_ref())?; if let Some(control) = &control { control.validate()?; } @@ -74,34 +164,98 @@ impl App { .session_runtime .get_session_working_dir(session_id) .await?; - let mut runtime = self + let runtime = self .config_service .load_resolved_runtime_config(Some(Path::new(&working_dir)))?; - if let Some(control) = control { - if control.manual_compact.is_some() { - return Err(ApplicationError::InvalidArgument( - "manualCompact is not valid for prompt submission".to_string(), - )); + let root_agent = self.ensure_session_root_agent_context(session_id).await?; + let mut current_mode_id = self + .session_runtime + .session_mode_state(session_id) + .await + .map_err(ApplicationError::from)? + .current_mode_id; + let mut prompt_declarations = Vec::new(); + let plan_state = load_session_plan_state(session_id, Path::new(&working_dir))?; + let plan_approval = parse_plan_approval(&text); + + if active_plan_requires_approval(plan_state.as_ref()) && plan_approval.approved { + let approved_plan = + mark_active_session_plan_approved(session_id, Path::new(&working_dir))?; + if current_mode_id == ModeId::plan() { + self.switch_mode(session_id, ModeId::code()).await?; + current_mode_id = ModeId::code(); } - if let Some(max_steps) = control.max_steps { - runtime.max_steps = max_steps as usize; + if let Some(summary) = approved_plan { + prompt_declarations.push(build_plan_exit_declaration(session_id, &summary)); } + } else if current_mode_id == ModeId::plan() + && current_mode_requires_plan_context(¤t_mode_id) + && !plan_approval.approved + { + let context = build_plan_prompt_context(session_id, Path::new(&working_dir), &text)?; + prompt_declarations.extend(build_plan_prompt_declarations(session_id, &context)); } - let root_agent = self.ensure_session_root_agent_context(session_id).await?; + + if let Some(skill_invocation) = skill_invocation { + prompt_declarations.push( + self.build_submission_skill_declaration( + Path::new(&working_dir), + &skill_invocation, + )?, + ); + } + let surface = self.governance_surface.session_surface( + self.kernel.as_ref(), + SessionGovernanceInput { + session_id: session_id.to_string(), + turn_id: astrcode_core::generate_turn_id(), + working_dir: working_dir.clone(), + profile: root_agent + .agent_profile + .clone() + .unwrap_or_else(|| IMPLICIT_ROOT_PROFILE_ID.to_string()), + mode_id: current_mode_id, + runtime, + control, + extra_prompt_declarations: prompt_declarations, + busy_policy: GovernanceBusyPolicy::BranchOnBusy, + }, + )?; self.session_runtime .submit_prompt_for_agent( session_id, text, - runtime, - astrcode_session_runtime::AgentPromptSubmission { - agent: root_agent, - ..Default::default() - }, + surface.runtime.clone(), + surface.into_submission(root_agent, None), ) .await .map_err(ApplicationError::from) } + pub async fn submit_prompt_summary( + &self, + session_id: &str, + text: String, + control: Option<ExecutionControl>, + skill_invocation: Option<PromptSkillInvocation>, + ) -> Result<PromptAcceptedSummary, ApplicationError> { + let accepted_control = normalize_prompt_control(control)?; + let accepted = self + .submit_prompt_with_options( + session_id, + text, + accepted_control.clone(), + skill_invocation, + ) + .await?; + Ok(PromptAcceptedSummary { + turn_id: accepted.turn_id.to_string(), + session_id: accepted.session_id.to_string(), + branched_from_session_id: accepted.branched_from_session_id, + accepted_control, + }) + } + pub async fn interrupt_session(&self, session_id: &str) -> Result<(), ApplicationError> { self.session_runtime .interrupt_session(session_id) @@ -113,13 +267,24 @@ impl App { &self, session_id: &str, ) -> Result<CompactSessionAccepted, ApplicationError> { - self.compact_session_with_control(session_id, None).await + self.compact_session_with_options(session_id, None, None) + .await } pub async fn compact_session_with_control( &self, session_id: &str, control: Option<ExecutionControl>, + ) -> Result<CompactSessionAccepted, ApplicationError> { + self.compact_session_with_options(session_id, control, None) + .await + } + + pub async fn compact_session_with_options( + &self, + session_id: &str, + control: Option<ExecutionControl>, + instructions: Option<String>, ) -> Result<CompactSessionAccepted, ApplicationError> { if let Some(control) = &control { control.validate()?; @@ -143,12 +308,36 @@ impl App { .load_resolved_runtime_config(Some(Path::new(&working_dir)))?; let deferred = self .session_runtime - .compact_session(session_id, runtime) + .compact_session(session_id, runtime, instructions) .await .map_err(ApplicationError::from)?; Ok(CompactSessionAccepted { deferred }) } + pub async fn compact_session_summary( + &self, + session_id: &str, + control: Option<ExecutionControl>, + instructions: Option<String>, + ) -> Result<CompactSessionSummary, ApplicationError> { + let accepted = self + .compact_session_with_options( + session_id, + normalize_compact_control(control), + normalize_compact_instructions(instructions), + ) + .await?; + Ok(CompactSessionSummary { + accepted: true, + deferred: accepted.deferred, + message: if accepted.deferred { + "手动 compact 已登记,会在当前 turn 完成后执行。".to_string() + } else { + "手动 compact 已执行。".to_string() + }, + }) + } + pub async fn session_transcript_snapshot( &self, session_id: &str, @@ -169,6 +358,49 @@ impl App { .map_err(ApplicationError::from) } + pub async fn list_modes(&self) -> Result<Vec<ModeSummary>, ApplicationError> { + Ok(self.mode_catalog.list()) + } + + pub async fn session_mode_state( + &self, + session_id: &str, + ) -> Result<astrcode_session_runtime::SessionModeSnapshot, ApplicationError> { + self.session_runtime + .session_mode_state(session_id) + .await + .map_err(ApplicationError::from) + } + + pub async fn switch_mode( + &self, + session_id: &str, + target_mode_id: ModeId, + ) -> Result<astrcode_session_runtime::SessionModeSnapshot, ApplicationError> { + let current = self + .session_runtime + .session_mode_state(session_id) + .await + .map_err(ApplicationError::from)?; + if current.current_mode_id == target_mode_id { + return Ok(current); + } + crate::validate_mode_transition( + self.mode_catalog.as_ref(), + ¤t.current_mode_id, + &target_mode_id, + ) + .map_err(ApplicationError::from)?; + self.session_runtime + .switch_mode(session_id, current.current_mode_id, target_mode_id) + .await + .map_err(ApplicationError::from)?; + self.session_runtime + .session_mode_state(session_id) + .await + .map_err(ApplicationError::from) + } + /// 返回指定 session 的 durable 存储事件。 /// /// Debug Workbench 需要基于服务端真相构造 trace, @@ -210,6 +442,11 @@ impl App { .map_err(ApplicationError::from) } + /// 确保 session 存在一个 root agent context,如果没有则自动注册隐式 root agent。 + /// + /// 查找逻辑:先通过 kernel 查找已有 handle,找不到则注册隐式 root agent + /// (ID 为 `root-agent:{session_id}`,profile 为 `default`)。 + /// 这是 prompt 提交前的前置步骤,保证 session 总有一个可用的 agent context。 pub(crate) async fn ensure_session_root_agent_context( &self, session_id: &str, @@ -246,4 +483,105 @@ impl App { handle.agent_profile, )) } + + fn build_submission_skill_declaration( + &self, + working_dir: &Path, + skill_invocation: &PromptSkillInvocation, + ) -> Result<PromptDeclaration, ApplicationError> { + let skill = self + .composer_skills + .resolve_skill(working_dir, &skill_invocation.skill_id) + .ok_or_else(|| { + ApplicationError::InvalidArgument(format!( + "unknown skill slash command: /{}", + skill_invocation.skill_id + )) + })?; + Ok(self + .governance_surface + .build_submission_skill_declaration(&skill, skill_invocation.user_prompt.as_deref())) + } +} + +pub fn summarize_session_meta(meta: SessionMeta) -> SessionListSummary { + SessionListSummary { + session_id: meta.session_id, + working_dir: meta.working_dir, + display_name: meta.display_name, + title: meta.title, + created_at: format_local_rfc3339(meta.created_at), + updated_at: format_local_rfc3339(meta.updated_at), + parent_session_id: meta.parent_session_id, + parent_storage_seq: meta.parent_storage_seq, + phase: meta.phase, + } +} + +fn normalize_prompt_control( + control: Option<ExecutionControl>, +) -> Result<Option<ExecutionControl>, ApplicationError> { + if let Some(control) = &control { + control.validate()?; + } + Ok(control) +} + +/// 规范化 prompt 提交文本,处理 skill invocation 与纯文本的交互。 +/// +/// - 纯文本提交:不允许空文本 +/// - Skill invocation:文本可以为空(由 skill prompt 填充), 但如果同时提供了文本和 skill +/// userPrompt,两者必须一致 +fn normalize_submission_text( + text: String, + skill_invocation: Option<&PromptSkillInvocation>, +) -> Result<String, ApplicationError> { + let text = text.trim().to_string(); + let Some(skill_invocation) = skill_invocation else { + if text.is_empty() { + return Err(ApplicationError::InvalidArgument( + "prompt must not be empty".to_string(), + )); + } + return Ok(text); + }; + + let skill_prompt = skill_invocation + .user_prompt + .as_deref() + .map(str::trim) + .unwrap_or_default() + .to_string(); + if !text.is_empty() && !skill_prompt.is_empty() && text != skill_prompt { + return Err(ApplicationError::InvalidArgument( + "skillInvocation.userPrompt must match prompt text".to_string(), + )); + } + + if !text.is_empty() { + Ok(text) + } else { + Ok(skill_prompt) + } +} + +/// 为手动 compact 请求构建 ExecutionControl。 +/// +/// 强制设置 `manual_compact = true`(如果调用方未指定), +/// 因为 compact 的语义要求这个标志。 +fn normalize_compact_control(control: Option<ExecutionControl>) -> Option<ExecutionControl> { + let mut control = control.unwrap_or(ExecutionControl { + max_steps: None, + manual_compact: None, + }); + if control.manual_compact.is_none() { + control.manual_compact = Some(true); + } + Some(control) +} + +fn normalize_compact_instructions(instructions: Option<String>) -> Option<String> { + instructions + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) } diff --git a/crates/application/src/terminal/mod.rs b/crates/application/src/terminal/mod.rs index 26899e6d..f9a711ca 100644 --- a/crates/application/src/terminal/mod.rs +++ b/crates/application/src/terminal/mod.rs @@ -1,7 +1,18 @@ -use astrcode_core::{ChildSessionNode, Phase, SessionEventRecord}; +//! 终端层数据模型与投影辅助。 +//! +//! 定义面向前端的事件流数据模型(`TerminalFacts`、`ConversationSlashCandidateFacts` 等) +//! 以及从 session-runtime 快照到终端视图的投影辅助函数。 + +use astrcode_core::{ + ChildAgentRef, ChildSessionNode, CompactAppliedMeta, CompactTrigger, ExecutionTaskStatus, Phase, +}; +use astrcode_session_runtime::{ + ConversationSnapshotFacts as RuntimeConversationSnapshotFacts, + ConversationStreamReplayFacts as RuntimeConversationStreamReplayFacts, +}; use chrono::{DateTime, Utc}; -use crate::{ComposerOptionKind, SessionReplay, SessionTranscriptSnapshot}; +use crate::ComposerOptionKind; #[derive(Debug, Clone, PartialEq, Eq, Default)] pub enum ConversationFocus { @@ -12,11 +23,51 @@ pub enum ConversationFocus { }, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TerminalLastCompactMetaFacts { + pub trigger: CompactTrigger, + pub meta: CompactAppliedMeta, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PlanReferenceFacts { + pub slug: String, + pub path: String, + pub status: String, + pub title: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TaskItemFacts { + pub content: String, + pub status: ExecutionTaskStatus, + pub active_form: Option<String>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConversationControlSummary { + pub phase: Phase, + pub can_submit_prompt: bool, + pub can_request_compact: bool, + pub compact_pending: bool, + pub compacting: bool, + pub active_turn_id: Option<String>, + pub last_compact_meta: Option<TerminalLastCompactMetaFacts>, + pub current_mode_id: String, + pub active_plan: Option<PlanReferenceFacts>, + pub active_tasks: Option<Vec<TaskItemFacts>>, +} + #[derive(Debug, Clone)] pub struct TerminalControlFacts { pub phase: Phase, pub active_turn_id: Option<String>, pub manual_compact_pending: bool, + pub compacting: bool, + pub last_compact_meta: Option<TerminalLastCompactMetaFacts>, + pub current_mode_id: String, + pub active_plan: Option<PlanReferenceFacts>, + pub active_tasks: Option<Vec<TaskItemFacts>>, } pub type ConversationControlFacts = TerminalControlFacts; @@ -32,17 +83,32 @@ pub struct TerminalChildSummaryFacts { pub type ConversationChildSummaryFacts = TerminalChildSummaryFacts; +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConversationChildSummarySummary { + pub child_session_id: String, + pub child_agent_id: String, + pub title: String, + pub lifecycle: astrcode_core::AgentLifecycleStatus, + pub latest_output_summary: Option<String>, + pub child_ref: Option<ChildAgentRef>, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub enum TerminalSlashAction { CreateSession, OpenResume, RequestCompact, - OpenSkillPalette, InsertText { text: String }, } pub type ConversationSlashAction = TerminalSlashAction; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConversationSlashActionSummary { + InsertText, + ExecuteCommand, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct TerminalSlashCandidateFacts { pub kind: ComposerOptionKind, @@ -56,6 +122,23 @@ pub struct TerminalSlashCandidateFacts { pub type ConversationSlashCandidateFacts = TerminalSlashCandidateFacts; +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConversationSlashCandidateSummary { + pub id: String, + pub title: String, + pub description: String, + pub keywords: Vec<String>, + pub action_kind: ConversationSlashActionSummary, + pub action_value: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConversationAuthoritativeSummary { + pub control: ConversationControlSummary, + pub child_summaries: Vec<ConversationChildSummarySummary>, + pub slash_candidates: Vec<ConversationSlashCandidateSummary>, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct TerminalResumeCandidateFacts { pub session_id: String, @@ -74,7 +157,7 @@ pub type ConversationResumeCandidateFacts = TerminalResumeCandidateFacts; pub struct TerminalFacts { pub active_session_id: String, pub session_title: String, - pub transcript: SessionTranscriptSnapshot, + pub transcript: RuntimeConversationSnapshotFacts, pub control: TerminalControlFacts, pub child_summaries: Vec<TerminalChildSummaryFacts>, pub slash_candidates: Vec<TerminalSlashCandidateFacts>, @@ -85,8 +168,7 @@ pub type ConversationFacts = TerminalFacts; #[derive(Debug)] pub struct TerminalStreamReplayFacts { pub active_session_id: String, - pub seed_records: Vec<SessionEventRecord>, - pub replay: SessionReplay, + pub replay: RuntimeConversationStreamReplayFacts, pub control: TerminalControlFacts, pub child_summaries: Vec<TerminalChildSummaryFacts>, pub slash_candidates: Vec<TerminalSlashCandidateFacts>, @@ -119,8 +201,10 @@ pub enum TerminalStreamFacts { pub type ConversationStreamFacts = TerminalStreamFacts; -pub(crate) fn latest_transcript_cursor(records: &[SessionEventRecord]) -> Option<String> { - records.last().map(|record| record.event_id.clone()) +pub(crate) fn latest_transcript_cursor( + snapshot: &RuntimeConversationSnapshotFacts, +) -> Option<String> { + snapshot.cursor.clone() } pub fn truncate_terminal_summary(content: &str) -> String { @@ -134,3 +218,102 @@ pub fn truncate_terminal_summary(content: &str) -> String { truncated } } + +pub fn summarize_conversation_control( + control: &TerminalControlFacts, +) -> ConversationControlSummary { + ConversationControlSummary { + phase: control.phase, + can_submit_prompt: matches!( + control.phase, + Phase::Idle | Phase::Done | Phase::Interrupted + ), + can_request_compact: !control.manual_compact_pending && !control.compacting, + compact_pending: control.manual_compact_pending, + compacting: control.compacting, + active_turn_id: control.active_turn_id.clone(), + last_compact_meta: control.last_compact_meta.clone(), + current_mode_id: control.current_mode_id.clone(), + active_plan: control.active_plan.clone(), + active_tasks: control.active_tasks.clone(), + } +} + +pub fn summarize_conversation_child_summary( + summary: &TerminalChildSummaryFacts, +) -> ConversationChildSummarySummary { + ConversationChildSummarySummary { + child_session_id: summary.node.child_session_id.to_string(), + child_agent_id: summary.node.agent_id().to_string(), + title: summary + .title + .clone() + .or_else(|| summary.display_name.clone()) + .unwrap_or_else(|| summary.node.child_session_id.to_string()), + lifecycle: summary.node.status, + latest_output_summary: summary.recent_output.clone(), + child_ref: Some(summary.node.child_ref()), + } +} + +pub fn summarize_conversation_child_ref( + child_ref: &ChildAgentRef, +) -> ConversationChildSummarySummary { + ConversationChildSummarySummary { + child_session_id: child_ref.open_session_id.to_string(), + child_agent_id: child_ref.agent_id().to_string(), + title: child_ref.agent_id().to_string(), + lifecycle: child_ref.status, + latest_output_summary: None, + child_ref: Some(child_ref.clone()), + } +} + +pub fn summarize_conversation_slash_candidate( + candidate: &TerminalSlashCandidateFacts, +) -> ConversationSlashCandidateSummary { + let (action_kind, action_value) = match &candidate.action { + TerminalSlashAction::CreateSession => ( + ConversationSlashActionSummary::ExecuteCommand, + "/new".to_string(), + ), + TerminalSlashAction::OpenResume => ( + ConversationSlashActionSummary::ExecuteCommand, + "/resume".to_string(), + ), + TerminalSlashAction::RequestCompact => ( + ConversationSlashActionSummary::ExecuteCommand, + "/compact".to_string(), + ), + TerminalSlashAction::InsertText { text } => { + (ConversationSlashActionSummary::InsertText, text.clone()) + }, + }; + + ConversationSlashCandidateSummary { + id: candidate.id.clone(), + title: candidate.title.clone(), + description: candidate.description.clone(), + keywords: candidate.keywords.clone(), + action_kind, + action_value, + } +} + +pub fn summarize_conversation_authoritative( + control: &TerminalControlFacts, + child_summaries: &[TerminalChildSummaryFacts], + slash_candidates: &[TerminalSlashCandidateFacts], +) -> ConversationAuthoritativeSummary { + ConversationAuthoritativeSummary { + control: summarize_conversation_control(control), + child_summaries: child_summaries + .iter() + .map(summarize_conversation_child_summary) + .collect(), + slash_candidates: slash_candidates + .iter() + .map(summarize_conversation_slash_candidate) + .collect(), + } +} diff --git a/crates/application/src/terminal_queries/cursor.rs b/crates/application/src/terminal_queries/cursor.rs new file mode 100644 index 00000000..d11727da --- /dev/null +++ b/crates/application/src/terminal_queries/cursor.rs @@ -0,0 +1,43 @@ +//! 游标格式校验与比较工具。 +//! +//! 游标格式为 `{storage_seq}.{subindex}`,用于分页查询时标记位置。 +//! `cursor_is_after_head` 判断请求的游标是否已超过最新位置(即客户端是否有未读数据)。 + +use crate::ApplicationError; + +pub(super) fn validate_cursor_format(cursor: &str) -> Result<(), ApplicationError> { + let Some((storage_seq, subindex)) = cursor.split_once('.') else { + return Err(ApplicationError::InvalidArgument(format!( + "invalid cursor '{cursor}'" + ))); + }; + if storage_seq.parse::<u64>().is_err() || subindex.parse::<u32>().is_err() { + return Err(ApplicationError::InvalidArgument(format!( + "invalid cursor '{cursor}'" + ))); + } + Ok(()) +} + +pub(super) fn cursor_is_after_head( + requested_cursor: &str, + latest_cursor: Option<&str>, +) -> Result<bool, ApplicationError> { + let Some(latest_cursor) = latest_cursor else { + return Ok(false); + }; + Ok(parse_cursor(requested_cursor)? > parse_cursor(latest_cursor)?) +} + +fn parse_cursor(cursor: &str) -> Result<(u64, u32), ApplicationError> { + let (storage_seq, subindex) = cursor + .split_once('.') + .ok_or_else(|| ApplicationError::InvalidArgument(format!("invalid cursor '{cursor}'")))?; + let storage_seq = storage_seq + .parse::<u64>() + .map_err(|_| ApplicationError::InvalidArgument(format!("invalid cursor '{cursor}'")))?; + let subindex = subindex + .parse::<u32>() + .map_err(|_| ApplicationError::InvalidArgument(format!("invalid cursor '{cursor}'")))?; + Ok((storage_seq, subindex)) +} diff --git a/crates/application/src/terminal_queries/mod.rs b/crates/application/src/terminal_queries/mod.rs new file mode 100644 index 00000000..0f347065 --- /dev/null +++ b/crates/application/src/terminal_queries/mod.rs @@ -0,0 +1,36 @@ +//! # 终端查询子域 +//! +//! 从旧 `terminal_use_cases.rs` 拆分而来,按职责分为四个查询模块: +//! - `cursor`:游标格式校验与比较 +//! - `resume`:会话恢复候选列表 +//! - `snapshot`:会话快照查询(conversation + transcript) +//! - `summary`:会话摘要提取 + +mod cursor; +mod resume; +mod snapshot; +mod summary; +#[cfg(test)] +mod tests; + +use astrcode_session_runtime::SessionControlStateSnapshot; + +use crate::terminal::{PlanReferenceFacts, TerminalControlFacts, TerminalLastCompactMetaFacts}; + +fn map_control_facts(control: SessionControlStateSnapshot) -> TerminalControlFacts { + TerminalControlFacts { + phase: control.phase, + active_turn_id: control.active_turn_id, + manual_compact_pending: control.manual_compact_pending, + compacting: control.compacting, + last_compact_meta: control + .last_compact_meta + .map(|meta| TerminalLastCompactMetaFacts { + trigger: meta.trigger, + meta: meta.meta, + }), + current_mode_id: control.current_mode_id.to_string(), + active_plan: None::<PlanReferenceFacts>, + active_tasks: None, + } +} diff --git a/crates/application/src/terminal_queries/resume.rs b/crates/application/src/terminal_queries/resume.rs new file mode 100644 index 00000000..8f6a65bd --- /dev/null +++ b/crates/application/src/terminal_queries/resume.rs @@ -0,0 +1,315 @@ +//! 会话恢复候选列表查询。 +//! +//! 根据搜索关键词和限制数量,从 session 列表中筛选出可恢复的会话候选项, +//! 按更新时间倒序排列。支持按标题、工作目录、会话 ID 模糊匹配。 + +use std::{cmp::Reverse, collections::HashSet, path::Path}; + +use astrcode_session_runtime::ROOT_AGENT_ID; + +use crate::{ + App, ApplicationError, ComposerOptionKind, ComposerOptionsRequest, SessionMeta, + session_plan::session_plan_control_summary, + terminal::{ + ConversationAuthoritativeSummary, ConversationFocus, TaskItemFacts, + TerminalChildSummaryFacts, TerminalControlFacts, TerminalResumeCandidateFacts, + TerminalSlashAction, TerminalSlashCandidateFacts, summarize_conversation_authoritative, + }, +}; + +impl App { + pub async fn terminal_resume_candidates( + &self, + query: Option<&str>, + limit: usize, + ) -> Result<Vec<TerminalResumeCandidateFacts>, ApplicationError> { + let metas = self.session_runtime.list_session_metas().await?; + let query = normalize_query(query); + let limit = normalize_limit(limit); + let mut items = metas + .into_iter() + .filter(|meta| resume_candidate_matches(meta, query.as_deref())) + .map(|meta| TerminalResumeCandidateFacts { + session_id: meta.session_id, + title: meta.title, + display_name: meta.display_name, + working_dir: meta.working_dir, + updated_at: meta.updated_at, + created_at: meta.created_at, + phase: meta.phase, + parent_session_id: meta.parent_session_id, + }) + .collect::<Vec<_>>(); + + items.sort_by_key(|item| Reverse(item.updated_at)); + items.truncate(limit); + Ok(items) + } + + pub async fn terminal_child_summaries( + &self, + session_id: &str, + ) -> Result<Vec<TerminalChildSummaryFacts>, ApplicationError> { + self.conversation_child_summaries(session_id, &ConversationFocus::Root) + .await + } + + pub async fn conversation_child_summaries( + &self, + session_id: &str, + focus: &ConversationFocus, + ) -> Result<Vec<TerminalChildSummaryFacts>, ApplicationError> { + self.validate_non_empty("sessionId", session_id)?; + let focus_session_id = self + .resolve_conversation_focus_session_id(session_id, focus) + .await?; + let children = self + .session_runtime + .session_child_nodes(&focus_session_id) + .await?; + let session_metas = self.session_runtime.list_session_metas().await?; + + let summaries = children + .into_iter() + .filter(|node| node.parent_sub_run_id().is_none()) + .map(|node| async { + self.require_permission( + node.parent_session_id.as_str() == focus_session_id, + format!( + "child '{}' is not visible from session '{}'", + node.sub_run_id(), + focus_session_id + ), + )?; + let child_meta = session_metas + .iter() + .find(|meta| meta.session_id == node.child_session_id.as_str()); + let child_transcript = self + .session_runtime + .conversation_snapshot(node.child_session_id.as_str()) + .await?; + Ok::<_, ApplicationError>(TerminalChildSummaryFacts { + node, + phase: child_transcript.phase, + title: child_meta.map(|meta| meta.title.clone()), + display_name: child_meta.map(|meta| meta.display_name.clone()), + recent_output: super::summary::latest_terminal_summary(&child_transcript), + }) + }) + .collect::<Vec<_>>(); + + let mut resolved = Vec::with_capacity(summaries.len()); + for summary in summaries { + resolved.push(summary.await?); + } + resolved.sort_by(|left, right| left.node.sub_run_id().cmp(right.node.sub_run_id())); + Ok(resolved) + } + + pub async fn terminal_slash_candidates( + &self, + session_id: &str, + query: Option<&str>, + ) -> Result<Vec<TerminalSlashCandidateFacts>, ApplicationError> { + self.validate_non_empty("sessionId", session_id)?; + let working_dir = self + .session_runtime + .get_session_working_dir(session_id) + .await?; + let query = normalize_query(query); + let control = self.terminal_control_facts(session_id).await?; + let mut candidates = terminal_builtin_candidates(&control); + candidates.extend( + self.list_composer_options( + session_id, + ComposerOptionsRequest { + query: query.clone(), + kinds: vec![ComposerOptionKind::Skill], + limit: 50, + }, + ) + .await? + .into_iter() + .map(|option| TerminalSlashCandidateFacts { + kind: option.kind, + id: option.id.clone(), + title: option.title, + description: option.description, + keywords: option.keywords, + badges: option.badges, + action: TerminalSlashAction::InsertText { + text: format!("/{}", option.id), + }, + }), + ); + + if let Some(query) = query.as_deref() { + candidates.retain(|candidate| slash_candidate_matches(candidate, query)); + } + + let _ = Path::new(&working_dir); + Ok(candidates) + } + + pub async fn terminal_control_facts( + &self, + session_id: &str, + ) -> Result<TerminalControlFacts, ApplicationError> { + let control = self + .session_runtime + .session_control_state(session_id) + .await?; + let mut facts = super::map_control_facts(control); + let working_dir = self + .session_runtime + .get_session_working_dir(session_id) + .await?; + // TODO(task-panel): 当前 control read model 只读取 root owner 的 task snapshot。 + // 后续若支持多 owner 并行展示,需要把这里扩成 owner 列表查询与聚合映射, + // 而不是继续把 conversation 面板固定到单一 ROOT_AGENT_ID。 + facts.active_tasks = self + .session_runtime + .active_task_snapshot(session_id, ROOT_AGENT_ID) + .await? + .map(|snapshot| { + snapshot + .items + .into_iter() + .map(|item| TaskItemFacts { + content: item.content, + status: item.status, + active_form: item.active_form, + }) + .collect() + }); + let plan_summary = session_plan_control_summary(session_id, Path::new(&working_dir))?; + facts.active_plan = + plan_summary + .active_plan + .map(|plan| crate::terminal::PlanReferenceFacts { + slug: plan.slug, + path: plan.path, + status: plan.status, + title: plan.title, + }); + Ok(facts) + } + + pub async fn conversation_authoritative_summary( + &self, + session_id: &str, + focus: &ConversationFocus, + ) -> Result<ConversationAuthoritativeSummary, ApplicationError> { + Ok(summarize_conversation_authoritative( + &self.terminal_control_facts(session_id).await?, + &self.conversation_child_summaries(session_id, focus).await?, + &self.terminal_slash_candidates(session_id, None).await?, + )) + } + + pub(super) async fn resolve_conversation_focus_session_id( + &self, + root_session_id: &str, + focus: &ConversationFocus, + ) -> Result<String, ApplicationError> { + match focus { + ConversationFocus::Root => Ok(root_session_id.to_string()), + ConversationFocus::SubRun { sub_run_id } => { + let mut pending = vec![root_session_id.to_string()]; + let mut visited = HashSet::new(); + + while let Some(session_id) = pending.pop() { + if !visited.insert(session_id.clone()) { + continue; + } + for node in self + .session_runtime + .session_child_nodes(&session_id) + .await? + { + if node.sub_run_id().as_str() == *sub_run_id { + return Ok(node.child_session_id.to_string()); + } + pending.push(node.child_session_id.to_string()); + } + } + + Err(ApplicationError::NotFound(format!( + "sub-run '{}' not found under session '{}'", + sub_run_id, root_session_id + ))) + }, + } + } +} + +fn normalize_query(query: Option<&str>) -> Option<String> { + query + .map(str::trim) + .filter(|query| !query.is_empty()) + .map(|query| query.to_lowercase()) +} + +fn normalize_limit(limit: usize) -> usize { + if limit == 0 { 20 } else { limit } +} + +fn resume_candidate_matches(meta: &SessionMeta, query: Option<&str>) -> bool { + let Some(query) = query else { + return true; + }; + [ + meta.session_id.as_str(), + meta.title.as_str(), + meta.display_name.as_str(), + meta.working_dir.as_str(), + ] + .iter() + .any(|field| field.to_lowercase().contains(query)) +} + +fn terminal_builtin_candidates(control: &TerminalControlFacts) -> Vec<TerminalSlashCandidateFacts> { + let mut candidates = vec![ + TerminalSlashCandidateFacts { + kind: ComposerOptionKind::Command, + id: "new".to_string(), + title: "新建会话".to_string(), + description: "创建新 session 并切换焦点".to_string(), + keywords: vec!["new".to_string(), "session".to_string()], + badges: vec!["built-in".to_string()], + action: TerminalSlashAction::CreateSession, + }, + TerminalSlashCandidateFacts { + kind: ComposerOptionKind::Command, + id: "resume".to_string(), + title: "恢复会话".to_string(), + description: "搜索并切换到已有 session".to_string(), + keywords: vec!["resume".to_string(), "switch".to_string()], + badges: vec!["built-in".to_string()], + action: TerminalSlashAction::OpenResume, + }, + ]; + + if !control.manual_compact_pending && !control.compacting { + candidates.push(TerminalSlashCandidateFacts { + kind: ComposerOptionKind::Command, + id: "compact".to_string(), + title: "压缩上下文".to_string(), + description: "向服务端提交显式 compact 控制请求".to_string(), + keywords: vec!["compact".to_string(), "compress".to_string()], + badges: vec!["built-in".to_string()], + action: TerminalSlashAction::RequestCompact, + }); + } + candidates +} + +fn slash_candidate_matches(candidate: &TerminalSlashCandidateFacts, query: &str) -> bool { + candidate.id.to_lowercase().contains(query) + || candidate.title.to_lowercase().contains(query) + || candidate.description.to_lowercase().contains(query) + || candidate + .keywords + .iter() + .any(|keyword| keyword.to_lowercase().contains(query)) +} diff --git a/crates/application/src/terminal_queries/snapshot.rs b/crates/application/src/terminal_queries/snapshot.rs new file mode 100644 index 00000000..34a0a64e --- /dev/null +++ b/crates/application/src/terminal_queries/snapshot.rs @@ -0,0 +1,121 @@ +//! 会话快照查询。 +//! +//! 从 session-runtime 获取 conversation/transcript 快照并映射为 +//! terminal 层的事实模型(`TerminalFacts` / `ConversationStreamReplayFacts`)。 + +use crate::{ + App, ApplicationError, + terminal::{ + ConversationFocus, TerminalFacts, TerminalRehydrateFacts, TerminalRehydrateReason, + TerminalStreamFacts, TerminalStreamReplayFacts, + }, +}; + +impl App { + pub async fn conversation_snapshot_facts( + &self, + session_id: &str, + focus: ConversationFocus, + ) -> Result<TerminalFacts, ApplicationError> { + self.validate_non_empty("sessionId", session_id)?; + let focus_session_id = self + .resolve_conversation_focus_session_id(session_id, &focus) + .await?; + let transcript = self + .session_runtime + .conversation_snapshot(&focus_session_id) + .await?; + let session_title = self + .session_runtime + .list_session_metas() + .await? + .into_iter() + .find(|meta| meta.session_id == session_id) + .map(|meta| meta.title) + .ok_or_else(|| { + ApplicationError::NotFound(format!("session '{session_id}' not found")) + })?; + let control = self.terminal_control_facts(session_id).await?; + let child_summaries = self + .conversation_child_summaries(session_id, &focus) + .await?; + let slash_candidates = self.terminal_slash_candidates(session_id, None).await?; + + Ok(TerminalFacts { + active_session_id: session_id.to_string(), + session_title, + transcript, + control, + child_summaries, + slash_candidates, + }) + } + + pub async fn terminal_snapshot_facts( + &self, + session_id: &str, + ) -> Result<TerminalFacts, ApplicationError> { + self.conversation_snapshot_facts(session_id, ConversationFocus::Root) + .await + } + + pub async fn conversation_stream_facts( + &self, + session_id: &str, + last_event_id: Option<&str>, + focus: ConversationFocus, + ) -> Result<TerminalStreamFacts, ApplicationError> { + self.validate_non_empty("sessionId", session_id)?; + let focus_session_id = self + .resolve_conversation_focus_session_id(session_id, &focus) + .await?; + + if let Some(requested_cursor) = last_event_id { + super::cursor::validate_cursor_format(requested_cursor)?; + let transcript = self + .session_runtime + .conversation_snapshot(&focus_session_id) + .await?; + let latest_cursor = crate::terminal::latest_transcript_cursor(&transcript); + if super::cursor::cursor_is_after_head(requested_cursor, latest_cursor.as_deref())? { + return Ok(TerminalStreamFacts::RehydrateRequired( + TerminalRehydrateFacts { + session_id: session_id.to_string(), + requested_cursor: requested_cursor.to_string(), + latest_cursor, + reason: TerminalRehydrateReason::CursorExpired, + }, + )); + } + } + + let replay = self + .session_runtime + .conversation_stream_replay(&focus_session_id, last_event_id) + .await?; + let control = self.terminal_control_facts(session_id).await?; + let child_summaries = self + .conversation_child_summaries(session_id, &focus) + .await?; + let slash_candidates = self.terminal_slash_candidates(session_id, None).await?; + + Ok(TerminalStreamFacts::Replay(Box::new( + TerminalStreamReplayFacts { + active_session_id: session_id.to_string(), + replay, + control, + child_summaries, + slash_candidates, + }, + ))) + } + + pub async fn terminal_stream_facts( + &self, + session_id: &str, + last_event_id: Option<&str>, + ) -> Result<TerminalStreamFacts, ApplicationError> { + self.conversation_stream_facts(session_id, last_event_id, ConversationFocus::Root) + .await + } +} diff --git a/crates/application/src/terminal_queries/summary.rs b/crates/application/src/terminal_queries/summary.rs new file mode 100644 index 00000000..fb3a87bb --- /dev/null +++ b/crates/application/src/terminal_queries/summary.rs @@ -0,0 +1,87 @@ +//! 终端摘要提取。 +//! +//! 从 conversation snapshot 中提取最新一条有意义的摘要文本, +//! 按 block 类型降级选择:assistant markdown → tool call summary/error → child handoff → error → +//! system note。 所有候选项都为空时回退到游标位置。 + +use astrcode_session_runtime::{ + ConversationBlockFacts, ConversationChildHandoffBlockFacts, ConversationErrorBlockFacts, + ConversationPlanBlockFacts, ConversationSnapshotFacts, ConversationSystemNoteBlockFacts, + ToolCallBlockFacts, +}; + +use crate::terminal::{latest_transcript_cursor, truncate_terminal_summary}; + +pub(super) fn latest_terminal_summary(snapshot: &ConversationSnapshotFacts) -> Option<String> { + snapshot + .blocks + .iter() + .rev() + .find_map(summary_from_block) + .or_else(|| latest_transcript_cursor(snapshot).map(|cursor| format!("cursor:{cursor}"))) +} + +fn summary_from_block(block: &ConversationBlockFacts) -> Option<String> { + match block { + ConversationBlockFacts::Assistant(block) => summary_from_markdown(&block.markdown), + ConversationBlockFacts::Plan(block) => summary_from_plan_block(block), + ConversationBlockFacts::ToolCall(block) => summary_from_tool_call(block), + ConversationBlockFacts::ChildHandoff(block) => summary_from_child_handoff(block), + ConversationBlockFacts::Error(block) => summary_from_error_block(block), + ConversationBlockFacts::SystemNote(block) => summary_from_system_note(block), + ConversationBlockFacts::User(_) | ConversationBlockFacts::Thinking(_) => None, + } +} + +fn summary_from_markdown(markdown: &str) -> Option<String> { + (!markdown.trim().is_empty()).then(|| truncate_terminal_summary(markdown)) +} + +fn summary_from_tool_call(block: &ToolCallBlockFacts) -> Option<String> { + block + .summary + .as_deref() + .filter(|summary| !summary.trim().is_empty()) + .map(truncate_terminal_summary) + .or_else(|| { + block + .error + .as_deref() + .filter(|error| !error.trim().is_empty()) + .map(truncate_terminal_summary) + }) + .or_else(|| summary_from_markdown(&block.streams.stderr)) + .or_else(|| summary_from_markdown(&block.streams.stdout)) +} + +fn summary_from_plan_block(block: &ConversationPlanBlockFacts) -> Option<String> { + block + .summary + .as_deref() + .filter(|summary| !summary.trim().is_empty()) + .map(truncate_terminal_summary) + .or_else(|| { + block + .content + .as_deref() + .filter(|content| !content.trim().is_empty()) + .map(truncate_terminal_summary) + }) + .or_else(|| summary_from_markdown(&block.title)) +} + +fn summary_from_child_handoff(block: &ConversationChildHandoffBlockFacts) -> Option<String> { + block + .message + .as_deref() + .filter(|message| !message.trim().is_empty()) + .map(truncate_terminal_summary) +} + +fn summary_from_error_block(block: &ConversationErrorBlockFacts) -> Option<String> { + summary_from_markdown(&block.message) +} + +fn summary_from_system_note(block: &ConversationSystemNoteBlockFacts) -> Option<String> { + summary_from_markdown(&block.markdown) +} diff --git a/crates/application/src/terminal_queries/tests.rs b/crates/application/src/terminal_queries/tests.rs new file mode 100644 index 00000000..3851a486 --- /dev/null +++ b/crates/application/src/terminal_queries/tests.rs @@ -0,0 +1,708 @@ +//! 终端查询子域集成测试。 +//! +//! 验证终端查询在完整应用栈上的端到端行为,使用真实的 `App` 组装 +//! (而非 mock),覆盖: +//! - 会话恢复候选列表过滤 +//! - 快照查询与游标比较 +//! - 终端摘要提取 + +use std::{path::Path, sync::Arc, time::Duration}; + +use astrcode_core::{AgentEvent, ExecutionTaskItem, ExecutionTaskStatus, TaskSnapshot}; +use astrcode_session_runtime::{ + ConversationBlockFacts, SessionControlStateSnapshot, SessionRuntime, +}; +use async_trait::async_trait; +use tokio::time::timeout; + +use crate::{ + App, AppKernelPort, AppSessionPort, ApplicationError, ComposerResolvedSkill, ComposerSkillPort, + ConfigService, McpConfigScope, McpPort, McpServerStatusView, McpService, + ProfileResolutionService, + agent::{ + AgentOrchestrationService, + test_support::{TestLlmBehavior, build_agent_test_harness}, + }, + composer::ComposerSkillSummary, + mcp::RegisterMcpServerInput, + terminal::{ConversationFocus, TerminalRehydrateReason, TerminalStreamFacts}, + test_support::StubSessionPort, +}; + +struct StaticComposerSkillPort { + summaries: Vec<ComposerSkillSummary>, +} + +impl ComposerSkillPort for StaticComposerSkillPort { + fn list_skill_summaries(&self, _working_dir: &Path) -> Vec<ComposerSkillSummary> { + self.summaries.clone() + } + + fn resolve_skill(&self, _working_dir: &Path, skill_id: &str) -> Option<ComposerResolvedSkill> { + self.summaries + .iter() + .find(|summary| summary.id == skill_id) + .map(|summary| ComposerResolvedSkill { + id: summary.id.clone(), + description: summary.description.clone(), + guide: format!("guide for {}", summary.id), + }) + } +} + +struct NoopMcpPort; + +#[async_trait] +impl McpPort for NoopMcpPort { + async fn list_server_status(&self) -> Vec<McpServerStatusView> { + Vec::new() + } + + async fn approve_server(&self, _server_signature: &str) -> Result<(), ApplicationError> { + Ok(()) + } + + async fn reject_server(&self, _server_signature: &str) -> Result<(), ApplicationError> { + Ok(()) + } + + async fn reconnect_server(&self, _name: &str) -> Result<(), ApplicationError> { + Ok(()) + } + + async fn reset_project_choices(&self) -> Result<(), ApplicationError> { + Ok(()) + } + + async fn upsert_server(&self, _input: &RegisterMcpServerInput) -> Result<(), ApplicationError> { + Ok(()) + } + + async fn remove_server( + &self, + _scope: McpConfigScope, + _name: &str, + ) -> Result<(), ApplicationError> { + Ok(()) + } + + async fn set_server_enabled( + &self, + _scope: McpConfigScope, + _name: &str, + _enabled: bool, + ) -> Result<(), ApplicationError> { + Ok(()) + } +} + +struct TerminalAppHarness { + app: App, + session_runtime: Arc<SessionRuntime>, +} + +fn build_terminal_app_harness(skill_ids: &[&str]) -> TerminalAppHarness { + build_terminal_app_harness_with_behavior( + skill_ids, + TestLlmBehavior::Succeed { + content: "子代理已完成。".to_string(), + }, + ) +} + +fn build_terminal_app_harness_with_behavior( + skill_ids: &[&str], + llm_behavior: TestLlmBehavior, +) -> TerminalAppHarness { + let harness = build_agent_test_harness(llm_behavior).expect("agent test harness should build"); + let kernel: Arc<dyn AppKernelPort> = harness.kernel.clone(); + let session_runtime = harness.session_runtime.clone(); + let session_port: Arc<dyn AppSessionPort> = session_runtime.clone(); + let app = build_terminal_app( + kernel, + session_port, + harness.config_service.clone(), + harness.profiles.clone(), + Arc::new(StaticComposerSkillPort { + summaries: skill_ids + .iter() + .map(|id| ComposerSkillSummary::new(*id, format!("{id} description"))) + .collect(), + }), + Arc::new(harness.service.clone()), + ); + TerminalAppHarness { + app, + session_runtime, + } +} + +fn build_terminal_app( + kernel: Arc<dyn AppKernelPort>, + session_port: Arc<dyn AppSessionPort>, + config: Arc<ConfigService>, + profiles: Arc<ProfileResolutionService>, + composer_skills: Arc<dyn ComposerSkillPort>, + agent_service: Arc<AgentOrchestrationService>, +) -> App { + let mcp_service = Arc::new(McpService::new(Arc::new(NoopMcpPort))); + App::new( + kernel, + session_port, + profiles, + config, + composer_skills, + Arc::new(crate::governance_surface::GovernanceSurfaceAssembler::default()), + Arc::new(crate::mode::builtin_mode_catalog().expect("builtin mode catalog should build")), + mcp_service, + agent_service, + ) +} + +#[tokio::test] +async fn terminal_stream_facts_expose_live_llm_deltas_before_durable_completion() { + let harness = build_terminal_app_harness_with_behavior( + &[], + TestLlmBehavior::Stream { + reasoning_chunks: vec!["先".to_string(), "整理".to_string()], + text_chunks: vec!["流".to_string(), "式".to_string()], + final_content: "流式完成".to_string(), + final_reasoning: Some("先整理".to_string()), + }, + ); + let project = tempfile::tempdir().expect("tempdir should be created"); + let session = harness + .app + .create_session(project.path().display().to_string()) + .await + .expect("session should be created"); + + let TerminalStreamFacts::Replay(replay) = harness + .app + .terminal_stream_facts(&session.session_id, None) + .await + .expect("stream facts should build") + else { + panic!("fresh stream should start from replay facts"); + }; + let mut live_receiver = replay.replay.replay.live_receiver; + + let accepted = harness + .app + .submit_prompt(&session.session_id, "请流式回答".to_string()) + .await + .expect("prompt should submit"); + + let mut live_events = Vec::new(); + for _ in 0..4 { + live_events.push( + timeout(Duration::from_secs(1), live_receiver.recv()) + .await + .expect("live delta should arrive in time") + .expect("live receiver should stay open"), + ); + } + + assert!(matches!( + &live_events[0], + AgentEvent::ThinkingDelta { delta, .. } if delta == "先" + )); + assert!(matches!( + &live_events[1], + AgentEvent::ThinkingDelta { delta, .. } if delta == "整理" + )); + assert!(matches!( + &live_events[2], + AgentEvent::ModelDelta { delta, .. } if delta == "流" + )); + assert!(matches!( + &live_events[3], + AgentEvent::ModelDelta { delta, .. } if delta == "式" + )); + + harness + .session_runtime + .wait_for_turn_terminal_snapshot(&session.session_id, accepted.turn_id.as_str()) + .await + .expect("turn should settle"); + + let snapshot = harness + .app + .terminal_snapshot_facts(&session.session_id) + .await + .expect("terminal snapshot should build"); + assert!(snapshot.transcript.blocks.iter().any(|block| matches!( + block, + ConversationBlockFacts::Assistant(block) if block.markdown == "流式完成" + ))); + assert!(snapshot.transcript.blocks.iter().any(|block| matches!( + block, + ConversationBlockFacts::Thinking(block) if block.markdown == "先整理" + ))); +} + +#[tokio::test] +async fn terminal_snapshot_facts_hydrate_history_control_and_slash_candidates() { + let harness = build_terminal_app_harness(&["openspec-apply-change"]); + let project = tempfile::tempdir().expect("tempdir should be created"); + let session = harness + .app + .create_session(project.path().display().to_string()) + .await + .expect("session should be created"); + harness + .app + .submit_prompt(&session.session_id, "请总结当前仓库".to_string()) + .await + .expect("prompt should submit"); + + let facts = harness + .app + .terminal_snapshot_facts(&session.session_id) + .await + .expect("terminal snapshot should build"); + + assert_eq!(facts.active_session_id, session.session_id); + assert!(!facts.transcript.blocks.is_empty()); + assert!(facts.transcript.cursor.is_some()); + assert!( + facts + .slash_candidates + .iter() + .any(|candidate| candidate.id == "new") + ); + assert!( + facts + .slash_candidates + .iter() + .any(|candidate| candidate.id == "resume") + ); + assert!( + facts + .slash_candidates + .iter() + .any(|candidate| candidate.id == "compact") + ); + assert!( + facts + .slash_candidates + .iter() + .any(|candidate| candidate.id == "openspec-apply-change") + ); + assert!( + facts + .slash_candidates + .iter() + .all(|candidate| candidate.id != "skill") + ); +} + +#[tokio::test] +async fn terminal_stream_facts_returns_replay_for_valid_cursor() { + let harness = build_terminal_app_harness(&[]); + let project = tempfile::tempdir().expect("tempdir should be created"); + let session = harness + .app + .create_session(project.path().display().to_string()) + .await + .expect("session should be created"); + harness + .app + .submit_prompt(&session.session_id, "hello".to_string()) + .await + .expect("prompt should submit"); + let snapshot = harness + .app + .terminal_snapshot_facts(&session.session_id) + .await + .expect("snapshot should build"); + let cursor = snapshot.transcript.cursor.clone(); + + let facts = harness + .app + .terminal_stream_facts(&session.session_id, cursor.as_deref()) + .await + .expect("stream facts should build"); + + match facts { + TerminalStreamFacts::Replay(replay) => { + assert_eq!(replay.active_session_id, session.session_id); + assert!(replay.replay.replay.history.is_empty()); + assert!(replay.replay.replay_frames.is_empty()); + assert_eq!( + replay + .replay + .seed_records + .last() + .map(|record| record.event_id.as_str()), + snapshot.transcript.cursor.as_deref() + ); + }, + TerminalStreamFacts::RehydrateRequired(_) => { + panic!("valid cursor should not require rehydrate"); + }, + } +} + +#[tokio::test] +async fn terminal_stream_facts_falls_back_to_rehydrate_for_future_cursor() { + let harness = build_terminal_app_harness(&[]); + let project = tempfile::tempdir().expect("tempdir should be created"); + let session = harness + .app + .create_session(project.path().display().to_string()) + .await + .expect("session should be created"); + harness + .app + .submit_prompt(&session.session_id, "hello".to_string()) + .await + .expect("prompt should submit"); + + let facts = harness + .app + .terminal_stream_facts(&session.session_id, Some("999999.9")) + .await + .expect("stream facts should build"); + + match facts { + TerminalStreamFacts::Replay(_) => { + panic!("future cursor should require rehydrate"); + }, + TerminalStreamFacts::RehydrateRequired(rehydrate) => { + assert_eq!(rehydrate.reason, TerminalRehydrateReason::CursorExpired); + assert_eq!(rehydrate.requested_cursor, "999999.9"); + assert!(rehydrate.latest_cursor.is_some()); + }, + } +} + +#[tokio::test] +async fn terminal_resume_candidates_use_server_fact_and_recent_sorting() { + let harness = build_terminal_app_harness(&[]); + let project = tempfile::tempdir().expect("tempdir should be created"); + let older_dir = project.path().join("older"); + let newer_dir = project.path().join("newer"); + std::fs::create_dir_all(&older_dir).expect("older dir should exist"); + std::fs::create_dir_all(&newer_dir).expect("newer dir should exist"); + let older = harness + .app + .create_session(older_dir.display().to_string()) + .await + .expect("older session should be created"); + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + let newer = harness + .app + .create_session(newer_dir.display().to_string()) + .await + .expect("newer session should be created"); + + let candidates = harness + .app + .terminal_resume_candidates(Some("newer"), 20) + .await + .expect("resume candidates should build"); + + assert_eq!(candidates.len(), 1); + assert_eq!(candidates[0].session_id, newer.session_id); + let all_candidates = harness + .app + .terminal_resume_candidates(None, 20) + .await + .expect("resume candidates should build"); + assert_eq!(all_candidates[0].session_id, newer.session_id); + assert_eq!(all_candidates[1].session_id, older.session_id); +} + +#[tokio::test] +async fn terminal_child_summaries_only_return_direct_visible_children() { + let harness = build_terminal_app_harness(&[]); + let project = tempfile::tempdir().expect("tempdir should be created"); + let parent_dir = project.path().join("parent"); + let child_dir = project.path().join("child"); + let unrelated_dir = project.path().join("unrelated"); + std::fs::create_dir_all(&parent_dir).expect("parent dir should exist"); + std::fs::create_dir_all(&child_dir).expect("child dir should exist"); + std::fs::create_dir_all(&unrelated_dir).expect("unrelated dir should exist"); + let parent = harness + .session_runtime + .create_session(parent_dir.display().to_string()) + .await + .expect("parent session should be created"); + let child = harness + .session_runtime + .create_session(child_dir.display().to_string()) + .await + .expect("child session should be created"); + let unrelated = harness + .session_runtime + .create_session(unrelated_dir.display().to_string()) + .await + .expect("unrelated session should be created"); + + let root = harness + .app + .ensure_session_root_agent_context(&parent.session_id) + .await + .expect("root context should exist"); + + harness + .session_runtime + .append_child_session_notification( + &parent.session_id, + "turn-parent", + root.clone(), + astrcode_core::ChildSessionNotification { + notification_id: "child-1".to_string().into(), + child_ref: astrcode_core::ChildAgentRef { + identity: astrcode_core::ChildExecutionIdentity { + agent_id: "agent-child".to_string().into(), + session_id: parent.session_id.clone().into(), + sub_run_id: "subrun-child".to_string().into(), + }, + parent: astrcode_core::ParentExecutionRef { + parent_agent_id: root.agent_id.clone(), + parent_sub_run_id: None, + }, + lineage_kind: astrcode_core::ChildSessionLineageKind::Spawn, + status: astrcode_core::AgentLifecycleStatus::Running, + open_session_id: child.session_id.clone().into(), + }, + kind: astrcode_core::ChildSessionNotificationKind::Started, + source_tool_call_id: Some("tool-call-1".to_string().into()), + delivery: Some(astrcode_core::ParentDelivery { + idempotency_key: "child-1".to_string(), + origin: astrcode_core::ParentDeliveryOrigin::Explicit, + terminal_semantics: astrcode_core::ParentDeliveryTerminalSemantics::NonTerminal, + source_turn_id: Some("turn-child".to_string()), + payload: astrcode_core::ParentDeliveryPayload::Progress( + astrcode_core::ProgressParentDeliveryPayload { + message: "child progress".to_string(), + }, + ), + }), + }, + ) + .await + .expect("child notification should append"); + + let accepted = harness + .app + .submit_prompt(&child.session_id, "child output".to_string()) + .await + .expect("child prompt should submit"); + harness + .session_runtime + .wait_for_turn_terminal_snapshot(&child.session_id, accepted.turn_id.as_str()) + .await + .expect("child turn should settle"); + harness + .app + .submit_prompt(&unrelated.session_id, "ignore me".to_string()) + .await + .expect("unrelated prompt should submit"); + + let children = harness + .app + .terminal_child_summaries(&parent.session_id) + .await + .expect("child summaries should build"); + + assert_eq!(children.len(), 1); + assert_eq!(children[0].node.child_session_id, child.session_id.into()); + assert!( + children[0] + .recent_output + .as_deref() + .is_some_and(|summary| summary.contains("子代理已完成")) + ); +} + +#[tokio::test] +async fn conversation_focus_snapshot_reads_child_session_transcript() { + let harness = build_terminal_app_harness(&[]); + let project = tempfile::tempdir().expect("tempdir should be created"); + let parent_dir = project.path().join("parent"); + let child_dir = project.path().join("child"); + std::fs::create_dir_all(&parent_dir).expect("parent dir should exist"); + std::fs::create_dir_all(&child_dir).expect("child dir should exist"); + let parent = harness + .session_runtime + .create_session(parent_dir.display().to_string()) + .await + .expect("parent session should be created"); + let child = harness + .session_runtime + .create_session(child_dir.display().to_string()) + .await + .expect("child session should be created"); + let root = harness + .app + .ensure_session_root_agent_context(&parent.session_id) + .await + .expect("root context should exist"); + + harness + .session_runtime + .append_child_session_notification( + &parent.session_id, + "turn-parent", + root.clone(), + astrcode_core::ChildSessionNotification { + notification_id: "child-1".to_string().into(), + child_ref: astrcode_core::ChildAgentRef { + identity: astrcode_core::ChildExecutionIdentity { + agent_id: "agent-child".to_string().into(), + session_id: parent.session_id.clone().into(), + sub_run_id: "subrun-child".to_string().into(), + }, + parent: astrcode_core::ParentExecutionRef { + parent_agent_id: root.agent_id.clone(), + parent_sub_run_id: None, + }, + lineage_kind: astrcode_core::ChildSessionLineageKind::Spawn, + status: astrcode_core::AgentLifecycleStatus::Running, + open_session_id: child.session_id.clone().into(), + }, + kind: astrcode_core::ChildSessionNotificationKind::Started, + source_tool_call_id: Some("tool-call-1".to_string().into()), + delivery: Some(astrcode_core::ParentDelivery { + idempotency_key: "child-1".to_string(), + origin: astrcode_core::ParentDeliveryOrigin::Explicit, + terminal_semantics: astrcode_core::ParentDeliveryTerminalSemantics::NonTerminal, + source_turn_id: Some("turn-child".to_string()), + payload: astrcode_core::ParentDeliveryPayload::Progress( + astrcode_core::ProgressParentDeliveryPayload { + message: "child progress".to_string(), + }, + ), + }), + }, + ) + .await + .expect("child notification should append"); + + harness + .app + .submit_prompt(&parent.session_id, "parent prompt".to_string()) + .await + .expect("parent prompt should submit"); + harness + .app + .submit_prompt(&child.session_id, "child prompt".to_string()) + .await + .expect("child prompt should submit"); + + let facts = harness + .app + .conversation_snapshot_facts( + &parent.session_id, + ConversationFocus::SubRun { + sub_run_id: "subrun-child".to_string(), + }, + ) + .await + .expect("conversation focus snapshot should build"); + + assert_eq!(facts.active_session_id, parent.session_id); + assert!(facts.transcript.blocks.iter().any(|block| matches!( + block, + ConversationBlockFacts::User(block) if block.markdown == "child prompt" + ))); + assert!(facts.transcript.blocks.iter().all(|block| !matches!( + block, + ConversationBlockFacts::User(block) if block.markdown == "parent prompt" + ))); + assert!(facts.child_summaries.is_empty()); +} + +#[test] +fn cursor_is_after_head_treats_equal_cursor_as_caught_up() { + assert!( + !super::cursor::cursor_is_after_head("12.3", Some("12.3")) + .expect("equal cursor should parse") + ); + assert!( + super::cursor::cursor_is_after_head("12.4", Some("12.3")) + .expect("newer cursor should parse") + ); + assert!( + !super::cursor::cursor_is_after_head("12.2", Some("12.3")) + .expect("older cursor should parse") + ); +} + +#[tokio::test] +async fn terminal_control_facts_include_authoritative_active_tasks() { + let harness = build_agent_test_harness(TestLlmBehavior::Succeed { + content: "unused".to_string(), + }) + .expect("agent test harness should build"); + let project = tempfile::tempdir().expect("tempdir should be created"); + let session_port: Arc<dyn AppSessionPort> = Arc::new(StubSessionPort { + working_dir: Some(project.path().display().to_string()), + control_state: Some(SessionControlStateSnapshot { + phase: astrcode_core::Phase::Idle, + active_turn_id: Some("turn-1".to_string()), + manual_compact_pending: false, + compacting: false, + last_compact_meta: None, + current_mode_id: astrcode_core::ModeId::code(), + last_mode_changed_at: None, + }), + active_task_snapshot: Some(TaskSnapshot { + owner: astrcode_session_runtime::ROOT_AGENT_ID.to_string(), + items: vec![ + ExecutionTaskItem { + content: "实现 authoritative task panel".to_string(), + status: ExecutionTaskStatus::InProgress, + active_form: Some("正在实现 authoritative task panel".to_string()), + }, + ExecutionTaskItem { + content: "补充前端 hydration 测试".to_string(), + status: ExecutionTaskStatus::Pending, + active_form: None, + }, + ], + }), + ..StubSessionPort::default() + }); + let app = build_terminal_app( + harness.kernel.clone(), + session_port, + harness.config_service.clone(), + harness.profiles.clone(), + Arc::new(StaticComposerSkillPort { + summaries: Vec::new(), + }), + Arc::new(harness.service.clone()), + ); + + let control = app + .terminal_control_facts("session-test") + .await + .expect("terminal control should build"); + + assert_eq!(control.current_mode_id, "code"); + assert_eq!(control.active_turn_id.as_deref(), Some("turn-1")); + assert!(control.active_plan.is_none()); + assert!( + !project.path().join(".astrcode").exists(), + "task facts query must not materialize canonical session plan artifacts" + ); + assert_eq!( + control.active_tasks, + Some(vec![ + crate::terminal::TaskItemFacts { + content: "实现 authoritative task panel".to_string(), + status: ExecutionTaskStatus::InProgress, + active_form: Some("正在实现 authoritative task panel".to_string()), + }, + crate::terminal::TaskItemFacts { + content: "补充前端 hydration 测试".to_string(), + status: ExecutionTaskStatus::Pending, + active_form: None, + }, + ]) + ); +} diff --git a/crates/application/src/terminal_use_cases.rs b/crates/application/src/terminal_use_cases.rs deleted file mode 100644 index 89f3c310..00000000 --- a/crates/application/src/terminal_use_cases.rs +++ /dev/null @@ -1,1050 +0,0 @@ -use std::{cmp::Reverse, collections::HashSet, path::Path}; - -use astrcode_core::{AgentEvent, SessionEventRecord}; -use astrcode_session_runtime::SessionControlStateSnapshot; - -use crate::{ - App, ApplicationError, ComposerOptionKind, ComposerOptionsRequest, SessionMeta, - SessionTranscriptSnapshot, - terminal::{ - ConversationFocus, TerminalChildSummaryFacts, TerminalControlFacts, TerminalFacts, - TerminalRehydrateFacts, TerminalRehydrateReason, TerminalResumeCandidateFacts, - TerminalSlashAction, TerminalSlashCandidateFacts, TerminalStreamFacts, - TerminalStreamReplayFacts, latest_transcript_cursor, truncate_terminal_summary, - }, -}; - -impl App { - pub async fn conversation_snapshot_facts( - &self, - session_id: &str, - focus: ConversationFocus, - ) -> Result<TerminalFacts, ApplicationError> { - self.validate_non_empty("sessionId", session_id)?; - let focus_session_id = self - .resolve_conversation_focus_session_id(session_id, &focus) - .await?; - let transcript = self - .session_runtime - .session_transcript_snapshot(&focus_session_id) - .await?; - let session_title = self - .session_runtime - .list_session_metas() - .await? - .into_iter() - .find(|meta| meta.session_id == session_id) - .map(|meta| meta.title) - .ok_or_else(|| { - ApplicationError::NotFound(format!("session '{session_id}' not found")) - })?; - let control = self.terminal_control_facts(session_id).await?; - let child_summaries = self - .conversation_child_summaries(session_id, &focus) - .await?; - let slash_candidates = self.terminal_slash_candidates(session_id, None).await?; - - Ok(TerminalFacts { - active_session_id: session_id.to_string(), - session_title, - transcript, - control, - child_summaries, - slash_candidates, - }) - } - - pub async fn terminal_snapshot_facts( - &self, - session_id: &str, - ) -> Result<TerminalFacts, ApplicationError> { - self.conversation_snapshot_facts(session_id, ConversationFocus::Root) - .await - } - - pub async fn conversation_stream_facts( - &self, - session_id: &str, - last_event_id: Option<&str>, - focus: ConversationFocus, - ) -> Result<TerminalStreamFacts, ApplicationError> { - self.validate_non_empty("sessionId", session_id)?; - let focus_session_id = self - .resolve_conversation_focus_session_id(session_id, &focus) - .await?; - - if let Some(requested_cursor) = last_event_id { - validate_cursor_format(requested_cursor)?; - let transcript = self - .session_runtime - .session_transcript_snapshot(&focus_session_id) - .await?; - if cursor_is_after_head(requested_cursor, transcript.cursor.as_deref())? { - return Ok(TerminalStreamFacts::RehydrateRequired( - TerminalRehydrateFacts { - session_id: session_id.to_string(), - requested_cursor: requested_cursor.to_string(), - latest_cursor: transcript.cursor, - reason: TerminalRehydrateReason::CursorExpired, - }, - )); - } - } - - let replay = self - .session_runtime - .session_replay(&focus_session_id, last_event_id) - .await?; - let seed_records = self - .session_runtime - .session_transcript_snapshot(&focus_session_id) - .await? - .records; - let control = self.terminal_control_facts(session_id).await?; - let child_summaries = self - .conversation_child_summaries(session_id, &focus) - .await?; - let slash_candidates = self.terminal_slash_candidates(session_id, None).await?; - - Ok(TerminalStreamFacts::Replay(Box::new( - TerminalStreamReplayFacts { - active_session_id: session_id.to_string(), - seed_records, - replay, - control, - child_summaries, - slash_candidates, - }, - ))) - } - - pub async fn terminal_stream_facts( - &self, - session_id: &str, - last_event_id: Option<&str>, - ) -> Result<TerminalStreamFacts, ApplicationError> { - self.conversation_stream_facts(session_id, last_event_id, ConversationFocus::Root) - .await - } - - pub async fn terminal_resume_candidates( - &self, - query: Option<&str>, - limit: usize, - ) -> Result<Vec<TerminalResumeCandidateFacts>, ApplicationError> { - let metas = self.session_runtime.list_session_metas().await?; - let query = normalize_query(query); - let limit = normalize_limit(limit); - let mut items = metas - .into_iter() - .filter(|meta| resume_candidate_matches(meta, query.as_deref())) - .map(|meta| TerminalResumeCandidateFacts { - session_id: meta.session_id, - title: meta.title, - display_name: meta.display_name, - working_dir: meta.working_dir, - updated_at: meta.updated_at, - created_at: meta.created_at, - phase: meta.phase, - parent_session_id: meta.parent_session_id, - }) - .collect::<Vec<_>>(); - - items.sort_by_key(|item| Reverse(item.updated_at)); - items.truncate(limit); - Ok(items) - } - - pub async fn terminal_child_summaries( - &self, - session_id: &str, - ) -> Result<Vec<TerminalChildSummaryFacts>, ApplicationError> { - self.conversation_child_summaries(session_id, &ConversationFocus::Root) - .await - } - - pub async fn conversation_child_summaries( - &self, - session_id: &str, - focus: &ConversationFocus, - ) -> Result<Vec<TerminalChildSummaryFacts>, ApplicationError> { - self.validate_non_empty("sessionId", session_id)?; - let focus_session_id = self - .resolve_conversation_focus_session_id(session_id, focus) - .await?; - let children = self - .session_runtime - .session_child_nodes(&focus_session_id) - .await?; - let session_metas = self.session_runtime.list_session_metas().await?; - - let summaries = children - .into_iter() - .filter(|node| node.parent_sub_run_id.is_none()) - .map(|node| async { - self.require_permission( - node.parent_session_id == focus_session_id, - format!( - "child '{}' is not visible from session '{}'", - node.sub_run_id, focus_session_id - ), - )?; - let child_meta = session_metas - .iter() - .find(|meta| meta.session_id == node.child_session_id); - let child_transcript = self - .session_runtime - .session_transcript_snapshot(&node.child_session_id) - .await?; - Ok::<_, ApplicationError>(TerminalChildSummaryFacts { - node, - phase: child_transcript.phase, - title: child_meta.map(|meta| meta.title.clone()), - display_name: child_meta.map(|meta| meta.display_name.clone()), - recent_output: latest_terminal_summary(&child_transcript), - }) - }) - .collect::<Vec<_>>(); - - let mut resolved = Vec::with_capacity(summaries.len()); - for summary in summaries { - resolved.push(summary.await?); - } - resolved.sort_by(|left, right| left.node.sub_run_id.cmp(&right.node.sub_run_id)); - Ok(resolved) - } - - async fn resolve_conversation_focus_session_id( - &self, - root_session_id: &str, - focus: &ConversationFocus, - ) -> Result<String, ApplicationError> { - match focus { - ConversationFocus::Root => Ok(root_session_id.to_string()), - ConversationFocus::SubRun { sub_run_id } => { - let mut pending = vec![root_session_id.to_string()]; - let mut visited = HashSet::new(); - - while let Some(session_id) = pending.pop() { - if !visited.insert(session_id.clone()) { - continue; - } - for node in self - .session_runtime - .session_child_nodes(&session_id) - .await? - { - if node.sub_run_id == *sub_run_id { - return Ok(node.child_session_id); - } - pending.push(node.child_session_id); - } - } - - Err(ApplicationError::NotFound(format!( - "sub-run '{}' not found under session '{}'", - sub_run_id, root_session_id - ))) - }, - } - } - - pub async fn terminal_slash_candidates( - &self, - session_id: &str, - query: Option<&str>, - ) -> Result<Vec<TerminalSlashCandidateFacts>, ApplicationError> { - self.validate_non_empty("sessionId", session_id)?; - let working_dir = self - .session_runtime - .get_session_working_dir(session_id) - .await?; - let query = normalize_query(query); - let control = self.terminal_control_facts(session_id).await?; - let mut candidates = terminal_builtin_candidates(&control); - candidates.extend( - self.list_composer_options( - session_id, - ComposerOptionsRequest { - query: query.clone(), - kinds: vec![ComposerOptionKind::Skill], - limit: 50, - }, - ) - .await? - .into_iter() - .map(|option| TerminalSlashCandidateFacts { - kind: option.kind, - id: option.id.clone(), - title: option.title, - description: option.description, - keywords: option.keywords, - badges: option.badges, - action: TerminalSlashAction::InsertText { - text: format!("/skill {}", option.id), - }, - }), - ); - - if let Some(query) = query.as_deref() { - candidates.retain(|candidate| slash_candidate_matches(candidate, query)); - } - - // Why: 顶层 palette 只暴露固定命令与可见 skill,不把 capability 噪声直接塞给终端。 - let _ = Path::new(&working_dir); - Ok(candidates) - } - - pub async fn terminal_control_facts( - &self, - session_id: &str, - ) -> Result<TerminalControlFacts, ApplicationError> { - let control = self - .session_runtime - .session_control_state(session_id) - .await?; - Ok(map_control_facts(control)) - } -} - -fn map_control_facts(control: SessionControlStateSnapshot) -> TerminalControlFacts { - TerminalControlFacts { - phase: control.phase, - active_turn_id: control.active_turn_id, - manual_compact_pending: control.manual_compact_pending, - } -} - -fn validate_cursor_format(cursor: &str) -> Result<(), ApplicationError> { - let Some((storage_seq, subindex)) = cursor.split_once('.') else { - return Err(ApplicationError::InvalidArgument(format!( - "invalid cursor '{cursor}'" - ))); - }; - if storage_seq.parse::<u64>().is_err() || subindex.parse::<u32>().is_err() { - return Err(ApplicationError::InvalidArgument(format!( - "invalid cursor '{cursor}'" - ))); - } - Ok(()) -} - -fn cursor_is_after_head( - requested_cursor: &str, - latest_cursor: Option<&str>, -) -> Result<bool, ApplicationError> { - let Some(latest_cursor) = latest_cursor else { - return Ok(false); - }; - Ok(parse_cursor(requested_cursor)? > parse_cursor(latest_cursor)?) -} - -fn parse_cursor(cursor: &str) -> Result<(u64, u32), ApplicationError> { - let (storage_seq, subindex) = cursor - .split_once('.') - .ok_or_else(|| ApplicationError::InvalidArgument(format!("invalid cursor '{cursor}'")))?; - let storage_seq = storage_seq - .parse::<u64>() - .map_err(|_| ApplicationError::InvalidArgument(format!("invalid cursor '{cursor}'")))?; - let subindex = subindex - .parse::<u32>() - .map_err(|_| ApplicationError::InvalidArgument(format!("invalid cursor '{cursor}'")))?; - Ok((storage_seq, subindex)) -} - -fn normalize_query(query: Option<&str>) -> Option<String> { - query - .map(str::trim) - .filter(|query| !query.is_empty()) - .map(|query| query.to_lowercase()) -} - -fn normalize_limit(limit: usize) -> usize { - if limit == 0 { 20 } else { limit } -} - -fn resume_candidate_matches(meta: &SessionMeta, query: Option<&str>) -> bool { - let Some(query) = query else { - return true; - }; - [ - meta.session_id.as_str(), - meta.title.as_str(), - meta.display_name.as_str(), - meta.working_dir.as_str(), - ] - .iter() - .any(|field| field.to_lowercase().contains(query)) -} - -fn terminal_builtin_candidates(control: &TerminalControlFacts) -> Vec<TerminalSlashCandidateFacts> { - let mut candidates = vec![ - TerminalSlashCandidateFacts { - kind: ComposerOptionKind::Command, - id: "new".to_string(), - title: "新建会话".to_string(), - description: "创建新 session 并切换焦点".to_string(), - keywords: vec!["new".to_string(), "session".to_string()], - badges: vec!["built-in".to_string()], - action: TerminalSlashAction::CreateSession, - }, - TerminalSlashCandidateFacts { - kind: ComposerOptionKind::Command, - id: "resume".to_string(), - title: "恢复会话".to_string(), - description: "搜索并切换到已有 session".to_string(), - keywords: vec!["resume".to_string(), "switch".to_string()], - badges: vec!["built-in".to_string()], - action: TerminalSlashAction::OpenResume, - }, - TerminalSlashCandidateFacts { - kind: ComposerOptionKind::Command, - id: "skill".to_string(), - title: "插入技能".to_string(), - description: "打开 skill 候选面板".to_string(), - keywords: vec!["skill".to_string(), "prompt".to_string()], - badges: vec!["built-in".to_string()], - action: TerminalSlashAction::OpenSkillPalette, - }, - ]; - - if !control.manual_compact_pending { - candidates.push(TerminalSlashCandidateFacts { - kind: ComposerOptionKind::Command, - id: "compact".to_string(), - title: "压缩上下文".to_string(), - description: "向服务端提交显式 compact 控制请求".to_string(), - keywords: vec!["compact".to_string(), "compress".to_string()], - badges: vec!["built-in".to_string()], - action: TerminalSlashAction::RequestCompact, - }); - } - candidates -} - -fn slash_candidate_matches(candidate: &TerminalSlashCandidateFacts, query: &str) -> bool { - candidate.id.to_lowercase().contains(query) - || candidate.title.to_lowercase().contains(query) - || candidate.description.to_lowercase().contains(query) - || candidate - .keywords - .iter() - .any(|keyword| keyword.to_lowercase().contains(query)) -} - -fn latest_terminal_summary(transcript: &SessionTranscriptSnapshot) -> Option<String> { - transcript - .records - .iter() - .rev() - .find_map(summary_from_record) - .or_else(|| { - latest_transcript_cursor(&transcript.records).map(|cursor| format!("cursor:{cursor}")) - }) -} - -fn summary_from_record(record: &SessionEventRecord) -> Option<String> { - match &record.event { - AgentEvent::AssistantMessage { content, .. } if !content.trim().is_empty() => { - Some(truncate_terminal_summary(content)) - }, - AgentEvent::ToolCallResult { result, .. } if !result.output.trim().is_empty() => { - Some(truncate_terminal_summary(&result.output)) - }, - AgentEvent::ToolCallResult { result, .. } => result - .error - .as_deref() - .filter(|error| !error.trim().is_empty()) - .map(truncate_terminal_summary), - AgentEvent::ChildSessionNotification { notification, .. } => notification - .delivery - .as_ref() - .map(|delivery| delivery.payload.message()) - .filter(|message| !message.trim().is_empty()) - .map(truncate_terminal_summary), - AgentEvent::Error { message, .. } if !message.trim().is_empty() => { - Some(truncate_terminal_summary(message)) - }, - _ => None, - } -} - -#[cfg(test)] -mod tests { - use std::{path::Path, sync::Arc, time::Duration}; - - use astrcode_session_runtime::SessionRuntime; - use async_trait::async_trait; - use tokio::time::timeout; - - use super::*; - use crate::{ - AppKernelPort, AppSessionPort, ComposerSkillPort, ConfigService, McpConfigScope, McpPort, - McpServerStatusView, McpService, ProfileResolutionService, - agent::{ - AgentOrchestrationService, - test_support::{TestLlmBehavior, build_agent_test_harness}, - }, - composer::ComposerSkillSummary, - mcp::RegisterMcpServerInput, - }; - - struct StaticComposerSkillPort { - summaries: Vec<ComposerSkillSummary>, - } - - impl ComposerSkillPort for StaticComposerSkillPort { - fn list_skill_summaries(&self, _working_dir: &Path) -> Vec<ComposerSkillSummary> { - self.summaries.clone() - } - } - - struct NoopMcpPort; - - #[async_trait] - impl McpPort for NoopMcpPort { - async fn list_server_status(&self) -> Vec<McpServerStatusView> { - Vec::new() - } - - async fn approve_server(&self, _server_signature: &str) -> Result<(), ApplicationError> { - Ok(()) - } - - async fn reject_server(&self, _server_signature: &str) -> Result<(), ApplicationError> { - Ok(()) - } - - async fn reconnect_server(&self, _name: &str) -> Result<(), ApplicationError> { - Ok(()) - } - - async fn reset_project_choices(&self) -> Result<(), ApplicationError> { - Ok(()) - } - - async fn upsert_server( - &self, - _input: &RegisterMcpServerInput, - ) -> Result<(), ApplicationError> { - Ok(()) - } - - async fn remove_server( - &self, - _scope: McpConfigScope, - _name: &str, - ) -> Result<(), ApplicationError> { - Ok(()) - } - - async fn set_server_enabled( - &self, - _scope: McpConfigScope, - _name: &str, - _enabled: bool, - ) -> Result<(), ApplicationError> { - Ok(()) - } - } - - struct TerminalAppHarness { - app: App, - session_runtime: Arc<SessionRuntime>, - } - - fn build_terminal_app_harness(skill_ids: &[&str]) -> TerminalAppHarness { - build_terminal_app_harness_with_behavior( - skill_ids, - TestLlmBehavior::Succeed { - content: "子代理已完成。".to_string(), - }, - ) - } - - fn build_terminal_app_harness_with_behavior( - skill_ids: &[&str], - llm_behavior: TestLlmBehavior, - ) -> TerminalAppHarness { - let harness = - build_agent_test_harness(llm_behavior).expect("agent test harness should build"); - let kernel: Arc<dyn AppKernelPort> = harness.kernel.clone(); - let session_runtime = harness.session_runtime.clone(); - let session_port: Arc<dyn AppSessionPort> = session_runtime.clone(); - let config: Arc<ConfigService> = harness.config_service.clone(); - let profiles: Arc<ProfileResolutionService> = harness.profiles.clone(); - let composer_skills: Arc<dyn ComposerSkillPort> = Arc::new(StaticComposerSkillPort { - summaries: skill_ids - .iter() - .map(|id| ComposerSkillSummary::new(*id, format!("{id} description"))) - .collect(), - }); - let mcp_service = Arc::new(McpService::new(Arc::new(NoopMcpPort))); - let agent_service: Arc<AgentOrchestrationService> = Arc::new(harness.service.clone()); - let app = App::new( - kernel, - session_port, - profiles, - config, - composer_skills, - mcp_service, - agent_service, - ); - TerminalAppHarness { - app, - session_runtime, - } - } - - #[tokio::test] - async fn terminal_stream_facts_expose_live_llm_deltas_before_durable_completion() { - let harness = build_terminal_app_harness_with_behavior( - &[], - TestLlmBehavior::Stream { - reasoning_chunks: vec!["先".to_string(), "整理".to_string()], - text_chunks: vec!["流".to_string(), "式".to_string()], - final_content: "流式完成".to_string(), - final_reasoning: Some("先整理".to_string()), - }, - ); - let project = tempfile::tempdir().expect("tempdir should be created"); - let session = harness - .app - .create_session(project.path().display().to_string()) - .await - .expect("session should be created"); - - let TerminalStreamFacts::Replay(replay) = harness - .app - .terminal_stream_facts(&session.session_id, None) - .await - .expect("stream facts should build") - else { - panic!("fresh stream should start from replay facts"); - }; - let mut live_receiver = replay.replay.live_receiver; - - let accepted = harness - .app - .submit_prompt(&session.session_id, "请流式回答".to_string()) - .await - .expect("prompt should submit"); - - let mut live_events = Vec::new(); - for _ in 0..4 { - live_events.push( - timeout(Duration::from_secs(1), live_receiver.recv()) - .await - .expect("live delta should arrive in time") - .expect("live receiver should stay open"), - ); - } - - assert!(matches!( - &live_events[0], - AgentEvent::ThinkingDelta { delta, .. } if delta == "先" - )); - assert!(matches!( - &live_events[1], - AgentEvent::ThinkingDelta { delta, .. } if delta == "整理" - )); - assert!(matches!( - &live_events[2], - AgentEvent::ModelDelta { delta, .. } if delta == "流" - )); - assert!(matches!( - &live_events[3], - AgentEvent::ModelDelta { delta, .. } if delta == "式" - )); - - harness - .session_runtime - .wait_for_turn_terminal_snapshot(&session.session_id, accepted.turn_id.as_str()) - .await - .expect("turn should settle"); - - let snapshot = harness - .app - .terminal_snapshot_facts(&session.session_id) - .await - .expect("terminal snapshot should build"); - assert!(snapshot.transcript.records.iter().any(|record| matches!( - &record.event, - AgentEvent::AssistantMessage { - content, - reasoning_content, - .. - } if content == "流式完成" - && reasoning_content - .as_ref() - .is_some_and(|reasoning| reasoning == "先整理") - ))); - } - - #[tokio::test] - async fn terminal_snapshot_facts_hydrate_history_control_and_slash_candidates() { - let harness = build_terminal_app_harness(&["openspec-apply-change"]); - let project = tempfile::tempdir().expect("tempdir should be created"); - let session = harness - .app - .create_session(project.path().display().to_string()) - .await - .expect("session should be created"); - harness - .app - .submit_prompt(&session.session_id, "请总结当前仓库".to_string()) - .await - .expect("prompt should submit"); - - let facts = harness - .app - .terminal_snapshot_facts(&session.session_id) - .await - .expect("terminal snapshot should build"); - - assert_eq!(facts.active_session_id, session.session_id); - assert!(!facts.transcript.records.is_empty()); - assert!(facts.transcript.cursor.is_some()); - assert!( - facts - .slash_candidates - .iter() - .any(|candidate| candidate.id == "new") - ); - assert!( - facts - .slash_candidates - .iter() - .any(|candidate| candidate.id == "resume") - ); - assert!( - facts - .slash_candidates - .iter() - .any(|candidate| candidate.id == "compact") - ); - assert!( - facts - .slash_candidates - .iter() - .any(|candidate| candidate.id == "openspec-apply-change") - ); - } - - #[tokio::test] - async fn terminal_stream_facts_returns_replay_for_valid_cursor() { - let harness = build_terminal_app_harness(&[]); - let project = tempfile::tempdir().expect("tempdir should be created"); - let session = harness - .app - .create_session(project.path().display().to_string()) - .await - .expect("session should be created"); - harness - .app - .submit_prompt(&session.session_id, "hello".to_string()) - .await - .expect("prompt should submit"); - let snapshot = harness - .app - .terminal_snapshot_facts(&session.session_id) - .await - .expect("snapshot should build"); - let cursor = snapshot - .transcript - .records - .first() - .map(|record| record.event_id.clone()); - - let facts = harness - .app - .terminal_stream_facts(&session.session_id, cursor.as_deref()) - .await - .expect("stream facts should build"); - - match facts { - TerminalStreamFacts::Replay(replay) => { - assert_eq!(replay.active_session_id, session.session_id); - assert!(replay.replay.history.len() <= snapshot.transcript.records.len()); - }, - TerminalStreamFacts::RehydrateRequired(_) => { - panic!("valid cursor should not require rehydrate"); - }, - } - } - - #[tokio::test] - async fn terminal_stream_facts_falls_back_to_rehydrate_for_future_cursor() { - let harness = build_terminal_app_harness(&[]); - let project = tempfile::tempdir().expect("tempdir should be created"); - let session = harness - .app - .create_session(project.path().display().to_string()) - .await - .expect("session should be created"); - harness - .app - .submit_prompt(&session.session_id, "hello".to_string()) - .await - .expect("prompt should submit"); - - let facts = harness - .app - .terminal_stream_facts(&session.session_id, Some("999999.9")) - .await - .expect("stream facts should build"); - - match facts { - TerminalStreamFacts::Replay(_) => { - panic!("future cursor should require rehydrate"); - }, - TerminalStreamFacts::RehydrateRequired(rehydrate) => { - assert_eq!(rehydrate.reason, TerminalRehydrateReason::CursorExpired); - assert_eq!(rehydrate.requested_cursor, "999999.9"); - assert!(rehydrate.latest_cursor.is_some()); - }, - } - } - - #[tokio::test] - async fn terminal_resume_candidates_use_server_fact_and_recent_sorting() { - let harness = build_terminal_app_harness(&[]); - let project = tempfile::tempdir().expect("tempdir should be created"); - let older_dir = project.path().join("older"); - let newer_dir = project.path().join("newer"); - std::fs::create_dir_all(&older_dir).expect("older dir should exist"); - std::fs::create_dir_all(&newer_dir).expect("newer dir should exist"); - let older = harness - .app - .create_session(older_dir.display().to_string()) - .await - .expect("older session should be created"); - tokio::time::sleep(std::time::Duration::from_millis(5)).await; - let newer = harness - .app - .create_session(newer_dir.display().to_string()) - .await - .expect("newer session should be created"); - - let candidates = harness - .app - .terminal_resume_candidates(Some("newer"), 20) - .await - .expect("resume candidates should build"); - - assert_eq!(candidates.len(), 1); - assert_eq!(candidates[0].session_id, newer.session_id); - let all_candidates = harness - .app - .terminal_resume_candidates(None, 20) - .await - .expect("resume candidates should build"); - assert_eq!(all_candidates[0].session_id, newer.session_id); - assert_eq!(all_candidates[1].session_id, older.session_id); - } - - #[tokio::test] - async fn terminal_child_summaries_only_return_direct_visible_children() { - let harness = build_terminal_app_harness(&[]); - let project = tempfile::tempdir().expect("tempdir should be created"); - let parent_dir = project.path().join("parent"); - let child_dir = project.path().join("child"); - let unrelated_dir = project.path().join("unrelated"); - std::fs::create_dir_all(&parent_dir).expect("parent dir should exist"); - std::fs::create_dir_all(&child_dir).expect("child dir should exist"); - std::fs::create_dir_all(&unrelated_dir).expect("unrelated dir should exist"); - let parent = harness - .session_runtime - .create_session(parent_dir.display().to_string()) - .await - .expect("parent session should be created"); - let child = harness - .session_runtime - .create_session(child_dir.display().to_string()) - .await - .expect("child session should be created"); - let unrelated = harness - .session_runtime - .create_session(unrelated_dir.display().to_string()) - .await - .expect("unrelated session should be created"); - - let root = harness - .app - .ensure_session_root_agent_context(&parent.session_id) - .await - .expect("root context should exist"); - - harness - .session_runtime - .append_child_session_notification( - &parent.session_id, - "turn-parent", - root.clone(), - astrcode_core::ChildSessionNotification { - notification_id: "child-1".to_string(), - child_ref: astrcode_core::ChildAgentRef { - agent_id: "agent-child".to_string(), - session_id: parent.session_id.clone(), - sub_run_id: "subrun-child".to_string(), - parent_agent_id: root.agent_id.clone(), - parent_sub_run_id: None, - lineage_kind: astrcode_core::ChildSessionLineageKind::Spawn, - status: astrcode_core::AgentLifecycleStatus::Running, - open_session_id: child.session_id.clone(), - }, - kind: astrcode_core::ChildSessionNotificationKind::Started, - status: astrcode_core::AgentLifecycleStatus::Running, - source_tool_call_id: Some("tool-call-1".to_string()), - delivery: Some(astrcode_core::ParentDelivery { - idempotency_key: "child-1".to_string(), - origin: astrcode_core::ParentDeliveryOrigin::Explicit, - terminal_semantics: - astrcode_core::ParentDeliveryTerminalSemantics::NonTerminal, - source_turn_id: Some("turn-child".to_string()), - payload: astrcode_core::ParentDeliveryPayload::Progress( - astrcode_core::ProgressParentDeliveryPayload { - message: "child progress".to_string(), - }, - ), - }), - }, - ) - .await - .expect("child notification should append"); - - let accepted = harness - .app - .submit_prompt(&child.session_id, "child output".to_string()) - .await - .expect("child prompt should submit"); - harness - .session_runtime - .wait_for_turn_terminal_snapshot(&child.session_id, accepted.turn_id.as_str()) - .await - .expect("child turn should settle"); - harness - .app - .submit_prompt(&unrelated.session_id, "ignore me".to_string()) - .await - .expect("unrelated prompt should submit"); - - let children = harness - .app - .terminal_child_summaries(&parent.session_id) - .await - .expect("child summaries should build"); - - assert_eq!(children.len(), 1); - assert_eq!(children[0].node.child_session_id, child.session_id); - assert!( - children[0] - .recent_output - .as_deref() - .is_some_and(|summary| summary.contains("子代理已完成")) - ); - } - - #[tokio::test] - async fn conversation_focus_snapshot_reads_child_session_transcript() { - let harness = build_terminal_app_harness(&[]); - let project = tempfile::tempdir().expect("tempdir should be created"); - let parent_dir = project.path().join("parent"); - let child_dir = project.path().join("child"); - std::fs::create_dir_all(&parent_dir).expect("parent dir should exist"); - std::fs::create_dir_all(&child_dir).expect("child dir should exist"); - let parent = harness - .session_runtime - .create_session(parent_dir.display().to_string()) - .await - .expect("parent session should be created"); - let child = harness - .session_runtime - .create_session(child_dir.display().to_string()) - .await - .expect("child session should be created"); - let root = harness - .app - .ensure_session_root_agent_context(&parent.session_id) - .await - .expect("root context should exist"); - - harness - .session_runtime - .append_child_session_notification( - &parent.session_id, - "turn-parent", - root.clone(), - astrcode_core::ChildSessionNotification { - notification_id: "child-1".to_string(), - child_ref: astrcode_core::ChildAgentRef { - agent_id: "agent-child".to_string(), - session_id: parent.session_id.clone(), - sub_run_id: "subrun-child".to_string(), - parent_agent_id: root.agent_id.clone(), - parent_sub_run_id: None, - lineage_kind: astrcode_core::ChildSessionLineageKind::Spawn, - status: astrcode_core::AgentLifecycleStatus::Running, - open_session_id: child.session_id.clone(), - }, - kind: astrcode_core::ChildSessionNotificationKind::Started, - status: astrcode_core::AgentLifecycleStatus::Running, - source_tool_call_id: Some("tool-call-1".to_string()), - delivery: Some(astrcode_core::ParentDelivery { - idempotency_key: "child-1".to_string(), - origin: astrcode_core::ParentDeliveryOrigin::Explicit, - terminal_semantics: - astrcode_core::ParentDeliveryTerminalSemantics::NonTerminal, - source_turn_id: Some("turn-child".to_string()), - payload: astrcode_core::ParentDeliveryPayload::Progress( - astrcode_core::ProgressParentDeliveryPayload { - message: "child progress".to_string(), - }, - ), - }), - }, - ) - .await - .expect("child notification should append"); - - harness - .app - .submit_prompt(&parent.session_id, "parent prompt".to_string()) - .await - .expect("parent prompt should submit"); - harness - .app - .submit_prompt(&child.session_id, "child prompt".to_string()) - .await - .expect("child prompt should submit"); - - let facts = harness - .app - .conversation_snapshot_facts( - &parent.session_id, - ConversationFocus::SubRun { - sub_run_id: "subrun-child".to_string(), - }, - ) - .await - .expect("conversation focus snapshot should build"); - - assert_eq!(facts.active_session_id, parent.session_id); - assert!(facts.transcript.records.iter().any(|record| matches!( - &record.event, - AgentEvent::UserMessage { content, .. } if content == "child prompt" - ))); - assert!(facts.transcript.records.iter().all(|record| !matches!( - &record.event, - AgentEvent::UserMessage { content, .. } if content == "parent prompt" - ))); - assert!(facts.child_summaries.is_empty()); - } - - #[test] - fn cursor_is_after_head_treats_equal_cursor_as_caught_up() { - assert!(!cursor_is_after_head("12.3", Some("12.3")).expect("equal cursor should parse")); - assert!(cursor_is_after_head("12.4", Some("12.3")).expect("newer cursor should parse")); - assert!(!cursor_is_after_head("12.2", Some("12.3")).expect("older cursor should parse")); - } -} diff --git a/crates/application/src/test_support.rs b/crates/application/src/test_support.rs new file mode 100644 index 00000000..d3652b41 --- /dev/null +++ b/crates/application/src/test_support.rs @@ -0,0 +1,327 @@ +//! 应用层测试桩。 +//! +//! 提供 `StubSessionPort`,实现 `AppSessionPort` + `AgentSessionPort` 两个 trait, +//! 用于 `application` 内部单元测试,避免依赖真实 `SessionRuntime`。 + +use astrcode_core::{ + AgentCollaborationFact, AgentEventContext, AgentLifecycleStatus, DeleteProjectResult, + ExecutionAccepted, InputBatchAckedPayload, InputBatchStartedPayload, InputDiscardedPayload, + InputQueuedPayload, ModeId, ResolvedRuntimeConfig, SessionId, SessionMeta, StoredEvent, + TaskSnapshot, TurnId, +}; +use astrcode_kernel::PendingParentDelivery; +use astrcode_session_runtime::{ + AgentObserveSnapshot, ConversationSnapshotFacts, ConversationStreamReplayFacts, ForkPoint, + ForkResult, ProjectedTurnOutcome, SessionCatalogEvent, SessionControlStateSnapshot, + SessionModeSnapshot, SessionReplay, SessionTranscriptSnapshot, TurnTerminalSnapshot, +}; +use async_trait::async_trait; +use tokio::sync::broadcast; + +use crate::{AgentSessionPort, AppAgentPromptSubmission, AppSessionPort}; + +fn unimplemented_for_test(area: &str) -> ! { + panic!("not used in {area}") +} + +#[derive(Debug, Default)] +pub(crate) struct StubSessionPort { + pub(crate) stored_events: Vec<StoredEvent>, + pub(crate) working_dir: Option<String>, + pub(crate) control_state: Option<SessionControlStateSnapshot>, + pub(crate) active_task_snapshot: Option<TaskSnapshot>, +} + +#[async_trait] +impl AppSessionPort for StubSessionPort { + fn subscribe_catalog_events(&self) -> broadcast::Receiver<SessionCatalogEvent> { + let (_tx, rx) = broadcast::channel(1); + rx + } + + async fn list_session_metas(&self) -> astrcode_core::Result<Vec<SessionMeta>> { + unimplemented_for_test("application test stub") + } + + async fn create_session(&self, _working_dir: String) -> astrcode_core::Result<SessionMeta> { + unimplemented_for_test("application test stub") + } + + async fn fork_session( + &self, + _session_id: &SessionId, + _fork_point: ForkPoint, + ) -> astrcode_core::Result<ForkResult> { + unimplemented_for_test("application test stub") + } + + async fn delete_session(&self, _session_id: &str) -> astrcode_core::Result<()> { + unimplemented_for_test("application test stub") + } + + async fn delete_project( + &self, + _working_dir: &str, + ) -> astrcode_core::Result<DeleteProjectResult> { + unimplemented_for_test("application test stub") + } + + async fn get_session_working_dir(&self, _session_id: &str) -> astrcode_core::Result<String> { + Ok(self.working_dir.clone().unwrap_or_else(|| ".".to_string())) + } + + async fn submit_prompt_for_agent( + &self, + _session_id: &str, + _text: String, + _runtime: ResolvedRuntimeConfig, + _submission: AppAgentPromptSubmission, + ) -> astrcode_core::Result<ExecutionAccepted> { + unimplemented_for_test("application test stub") + } + + async fn interrupt_session(&self, _session_id: &str) -> astrcode_core::Result<()> { + unimplemented_for_test("application test stub") + } + + async fn compact_session( + &self, + _session_id: &str, + _runtime: ResolvedRuntimeConfig, + _instructions: Option<String>, + ) -> astrcode_core::Result<bool> { + unimplemented_for_test("application test stub") + } + + async fn session_transcript_snapshot( + &self, + _session_id: &str, + ) -> astrcode_core::Result<SessionTranscriptSnapshot> { + unimplemented_for_test("application test stub") + } + + async fn conversation_snapshot( + &self, + _session_id: &str, + ) -> astrcode_core::Result<ConversationSnapshotFacts> { + unimplemented_for_test("application test stub") + } + + async fn session_control_state( + &self, + _session_id: &str, + ) -> astrcode_core::Result<SessionControlStateSnapshot> { + Ok(self + .control_state + .clone() + .unwrap_or(SessionControlStateSnapshot { + phase: astrcode_core::Phase::Idle, + active_turn_id: None, + manual_compact_pending: false, + compacting: false, + last_compact_meta: None, + current_mode_id: ModeId::code(), + last_mode_changed_at: None, + })) + } + + async fn active_task_snapshot( + &self, + _session_id: &str, + _owner: &str, + ) -> astrcode_core::Result<Option<TaskSnapshot>> { + Ok(self.active_task_snapshot.clone()) + } + + async fn session_mode_state( + &self, + _session_id: &str, + ) -> astrcode_core::Result<SessionModeSnapshot> { + Ok(SessionModeSnapshot { + current_mode_id: ModeId::code(), + last_mode_changed_at: None, + }) + } + + async fn switch_mode( + &self, + _session_id: &str, + _from: ModeId, + _to: ModeId, + ) -> astrcode_core::Result<StoredEvent> { + unimplemented_for_test("application test stub") + } + + async fn session_child_nodes( + &self, + _session_id: &str, + ) -> astrcode_core::Result<Vec<astrcode_core::ChildSessionNode>> { + unimplemented_for_test("application test stub") + } + + async fn session_stored_events( + &self, + _session_id: &SessionId, + ) -> astrcode_core::Result<Vec<StoredEvent>> { + Ok(self.stored_events.clone()) + } + + async fn session_replay( + &self, + _session_id: &str, + _last_event_id: Option<&str>, + ) -> astrcode_core::Result<SessionReplay> { + unimplemented_for_test("application test stub") + } + + async fn conversation_stream_replay( + &self, + _session_id: &str, + _last_event_id: Option<&str>, + ) -> astrcode_core::Result<ConversationStreamReplayFacts> { + unimplemented_for_test("application test stub") + } +} + +#[async_trait] +impl AgentSessionPort for StubSessionPort { + async fn create_child_session( + &self, + _working_dir: &str, + _parent_session_id: &str, + ) -> astrcode_core::Result<SessionMeta> { + unimplemented_for_test("application test stub") + } + + async fn submit_prompt_for_agent_with_submission( + &self, + _session_id: &str, + _text: String, + _runtime: ResolvedRuntimeConfig, + _submission: AppAgentPromptSubmission, + ) -> astrcode_core::Result<ExecutionAccepted> { + unimplemented_for_test("application test stub") + } + + async fn try_submit_prompt_for_agent_with_turn_id( + &self, + _session_id: &str, + _turn_id: TurnId, + _text: String, + _runtime: ResolvedRuntimeConfig, + _submission: AppAgentPromptSubmission, + ) -> astrcode_core::Result<Option<ExecutionAccepted>> { + unimplemented_for_test("application test stub") + } + + async fn submit_queued_inputs_for_agent_with_turn_id( + &self, + _session_id: &str, + _turn_id: TurnId, + _queued_inputs: Vec<String>, + _runtime: ResolvedRuntimeConfig, + _submission: AppAgentPromptSubmission, + ) -> astrcode_core::Result<Option<ExecutionAccepted>> { + unimplemented_for_test("application test stub") + } + + async fn append_agent_input_queued( + &self, + _session_id: &str, + _turn_id: &str, + _agent: AgentEventContext, + _payload: InputQueuedPayload, + ) -> astrcode_core::Result<StoredEvent> { + unimplemented_for_test("application test stub") + } + + async fn append_agent_input_discarded( + &self, + _session_id: &str, + _turn_id: &str, + _agent: AgentEventContext, + _payload: InputDiscardedPayload, + ) -> astrcode_core::Result<StoredEvent> { + unimplemented_for_test("application test stub") + } + + async fn append_agent_input_batch_started( + &self, + _session_id: &str, + _turn_id: &str, + _agent: AgentEventContext, + _payload: InputBatchStartedPayload, + ) -> astrcode_core::Result<StoredEvent> { + unimplemented_for_test("application test stub") + } + + async fn append_agent_input_batch_acked( + &self, + _session_id: &str, + _turn_id: &str, + _agent: AgentEventContext, + _payload: InputBatchAckedPayload, + ) -> astrcode_core::Result<StoredEvent> { + unimplemented_for_test("application test stub") + } + + async fn append_child_session_notification( + &self, + _session_id: &str, + _turn_id: &str, + _agent: AgentEventContext, + _notification: astrcode_core::ChildSessionNotification, + ) -> astrcode_core::Result<StoredEvent> { + unimplemented_for_test("application test stub") + } + + async fn append_agent_collaboration_fact( + &self, + _session_id: &str, + _turn_id: &str, + _agent: AgentEventContext, + _fact: AgentCollaborationFact, + ) -> astrcode_core::Result<StoredEvent> { + unimplemented_for_test("application test stub") + } + + async fn pending_delivery_ids_for_agent( + &self, + _session_id: &str, + _agent_id: &str, + ) -> astrcode_core::Result<Vec<String>> { + unimplemented_for_test("application test stub") + } + + async fn recoverable_parent_deliveries( + &self, + _parent_session_id: &str, + ) -> astrcode_core::Result<Vec<PendingParentDelivery>> { + unimplemented_for_test("application test stub") + } + + async fn observe_agent_session( + &self, + _open_session_id: &str, + _target_agent_id: &str, + _lifecycle_status: AgentLifecycleStatus, + ) -> astrcode_core::Result<AgentObserveSnapshot> { + unimplemented_for_test("application test stub") + } + + async fn project_turn_outcome( + &self, + _session_id: &str, + _turn_id: &str, + ) -> astrcode_core::Result<ProjectedTurnOutcome> { + unimplemented_for_test("application test stub") + } + + async fn wait_for_turn_terminal_snapshot( + &self, + _session_id: &str, + _turn_id: &str, + ) -> astrcode_core::Result<TurnTerminalSnapshot> { + unimplemented_for_test("application test stub") + } +} diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 08addb77..ada8caef 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -11,11 +11,14 @@ astrcode-core = { path = "../core" } anyhow.workspace = true async-trait.workspace = true clap = { version = "4.5", features = ["derive"] } -crossterm = "0.28" +crossterm = "0.29.0" +pulldown-cmark = "0.9.6" reqwest.workspace = true -ratatui = "0.29" +ratatui = { version = "0.30.0", features = ["scrolling-regions"] } serde.workspace = true serde_json.workspace = true +textwrap = "0.16.2" thiserror.workspace = true tokio.workspace = true +unicode-segmentation = "1.13.2" unicode-width = "0.2" diff --git a/crates/cli/src/app/coordinator.rs b/crates/cli/src/app/coordinator.rs index 9eac7d3c..1bd14b67 100644 --- a/crates/cli/src/app/coordinator.rs +++ b/crates/cli/src/app/coordinator.rs @@ -4,45 +4,42 @@ use anyhow::Result; use astrcode_client::{ AstrcodeClientTransport, AstrcodeCompactSessionRequest, AstrcodeConversationBannerErrorCodeDto, AstrcodeConversationErrorEnvelopeDto, AstrcodeCreateSessionRequest, - AstrcodeExecutionControlDto, AstrcodePromptRequest, ConversationStreamItem, + AstrcodeExecutionControlDto, AstrcodePromptRequest, AstrcodePromptSkillInvocation, + AstrcodeSaveActiveSelectionRequest, AstrcodeSwitchModeRequest, ConversationStreamItem, }; use super::{ - Action, AppController, filter_resume_sessions, required_working_dir, slash_query_from_input, + Action, AppController, SnapshotLoadedAction, filter_model_options, filter_resume_sessions, + model_query_from_input, required_working_dir, resume_query_from_input, + slash_candidates_with_local_commands, slash_query_from_input, }; use crate::{ - command::{Command, InputAction, OverlayAction, classify_input, filter_slash_candidates}, - state::{OverlayState, StreamRenderMode}, + command::{Command, InputAction, PaletteAction, classify_input, filter_slash_candidates}, + state::{PaletteState, StreamRenderMode}, }; impl<T> AppController<T> where T: AstrcodeClientTransport + 'static, { + fn dispatch_async<F>(&self, operation: F) + where + F: std::future::Future<Output = Option<Action>> + Send + 'static, + { + let sender = self.actions_tx.clone(); + tokio::spawn(async move { + if let Some(action) = operation.await { + let _ = sender.send(action); + } + }); + } + pub(super) async fn submit_current_input(&mut self) { let input = self.state.take_input(); - match classify_input(input.as_str()) { + match classify_input(input, &self.state.conversation.slash_candidates) { InputAction::Empty => {}, InputAction::SubmitPrompt { text } => { - let Some(session_id) = self.state.conversation.active_session_id.clone() else { - self.state.set_error_status("no active session"); - return; - }; - self.state.set_status("submitting prompt"); - let client = self.client.clone(); - let sender = self.actions_tx.clone(); - tokio::spawn(async move { - let result = client - .submit_prompt( - &session_id, - AstrcodePromptRequest { - text, - control: None, - }, - ) - .await; - let _ = sender.send(Action::PromptSubmitted { session_id, result }); - }); + self.submit_prompt_request(text, None).await; }, InputAction::RunCommand(command) => { self.execute_command(command).await; @@ -50,18 +47,25 @@ where } } - pub(super) async fn execute_overlay_action(&mut self, action: OverlayAction) -> Result<()> { + pub(super) async fn execute_palette_action(&mut self, action: PaletteAction) -> Result<()> { match action { - OverlayAction::SwitchSession { session_id } => { - self.state.close_overlay(); + PaletteAction::SwitchSession { session_id } => { + self.state.close_palette(); self.begin_session_hydration(session_id).await; }, - OverlayAction::ReplaceInput { text } => { - self.state.close_overlay(); + PaletteAction::SelectModel { + profile_name, + model, + } => { + self.state.close_palette(); + self.apply_model_selection(profile_name, model).await; + }, + PaletteAction::ReplaceInput { text } => { + self.state.close_palette(); self.state.replace_input(text); }, - OverlayAction::RunCommand(command) => { - self.state.close_overlay(); + PaletteAction::RunCommand(command) => { + self.state.close_palette(); self.execute_command(command).await; }, } @@ -79,22 +83,91 @@ where }, }; let client = self.client.clone(); - let sender = self.actions_tx.clone(); self.state.set_status("creating session"); - tokio::spawn(async move { + self.dispatch_async(async move { let result = client .create_session(AstrcodeCreateSessionRequest { working_dir }) .await; - let _ = sender.send(Action::SessionCreated(result)); + Some(Action::SessionCreated(result)) }); }, Command::Resume { query } => { let query = query.unwrap_or_default(); let items = filter_resume_sessions(&self.state.conversation.sessions, query.as_str()); + self.state.replace_input(if query.is_empty() { + "/resume".to_string() + } else { + format!("/resume {query}") + }); self.state.set_resume_query(query, items); self.refresh_sessions().await; }, + Command::Model { query } => { + let query = query.unwrap_or_default(); + let items = filter_model_options(&self.state.shell.model_options, query.as_str()); + self.state.replace_input(if query.is_empty() { + "/model".to_string() + } else { + format!("/model {query}") + }); + self.state.set_model_query(query.clone(), items); + self.refresh_model_options(query).await; + }, + Command::Mode { query } => { + let query = query.unwrap_or_default(); + if query.is_empty() { + let Some(session_id) = self.state.conversation.active_session_id.clone() else { + let available = self + .state + .shell + .available_modes + .iter() + .map(|mode| mode.id.as_str()) + .collect::<Vec<_>>() + .join(", "); + if available.is_empty() { + self.state.set_error_status("no active session"); + } else { + self.state.set_error_status(format!( + "no active session · available modes: {available}" + )); + } + return; + }; + let client = self.client.clone(); + self.state.set_status("loading mode state"); + self.dispatch_async(async move { + let result = client.get_session_mode(&session_id).await; + Some(Action::SessionModeLoaded { session_id, result }) + }); + return; + } + + let Some(session_id) = self.state.conversation.active_session_id.clone() else { + self.state.set_error_status("no active session"); + return; + }; + let requested_mode_id = query; + let client = self.client.clone(); + self.state + .set_status(format!("switching mode to {requested_mode_id}")); + self.dispatch_async(async move { + let result = client + .switch_mode( + &session_id, + AstrcodeSwitchModeRequest { + mode_id: requested_mode_id.clone(), + }, + ) + .await; + Some(Action::ModeSwitched { + session_id, + requested_mode_id, + result, + }) + }); + }, Command::Compact => { let Some(session_id) = self.state.conversation.active_session_id.clone() else { self.state.set_error_status("no active session"); @@ -112,9 +185,8 @@ where return; } let client = self.client.clone(); - let sender = self.actions_tx.clone(); self.state.set_status("requesting compact"); - tokio::spawn(async move { + self.dispatch_async(async move { let result = client .request_compact( &session_id, @@ -123,14 +195,23 @@ where max_steps: None, manual_compact: Some(true), }), + instructions: None, }, ) .await; - let _ = sender.send(Action::CompactRequested { session_id, result }); + Some(Action::CompactRequested { session_id, result }) }); }, - Command::Skill { query } => { - self.open_slash_palette(query.unwrap_or_default()).await; + Command::SkillInvoke { skill_id, prompt } => { + let text = prompt.clone().unwrap_or_default(); + self.submit_prompt_request( + text, + Some(AstrcodePromptSkillInvocation { + skill_id, + user_prompt: prompt, + }), + ) + .await; }, Command::Unknown { raw } => { self.state @@ -148,10 +229,12 @@ where self.state .set_status(format!("hydrating session {}", session_id)); let client = self.client.clone(); - let sender = self.actions_tx.clone(); - tokio::spawn(async move { + self.dispatch_async(async move { let result = client.fetch_conversation_snapshot(&session_id, None).await; - let _ = sender.send(Action::SnapshotLoaded { session_id, result }); + Some(Action::SnapshotLoaded(Box::new(SnapshotLoadedAction { + session_id, + result, + }))) }); } @@ -204,18 +287,56 @@ where pub(super) async fn refresh_sessions(&self) { let client = self.client.clone(); - let sender = self.actions_tx.clone(); - tokio::spawn(async move { + self.dispatch_async(async move { let result = client.list_sessions().await; - let _ = sender.send(Action::SessionsRefreshed(result)); + Some(Action::SessionsRefreshed(result)) + }); + } + + pub(super) async fn refresh_current_model(&self) { + let client = self.client.clone(); + self.dispatch_async(async move { + let result = client.get_current_model().await; + Some(Action::CurrentModelLoaded(result)) + }); + } + + pub(super) async fn refresh_modes(&self) { + let client = self.client.clone(); + self.dispatch_async(async move { + let result = client.list_modes().await; + Some(Action::ModesLoaded(result)) + }); + } + + pub(super) async fn refresh_model_options(&self, query: String) { + let client = self.client.clone(); + self.dispatch_async(async move { + let result = client.list_models().await; + Some(Action::ModelOptionsLoaded { query, result }) }); } pub(super) async fn open_slash_palette(&mut self, query: String) { + if !self + .state + .interaction + .composer + .as_str() + .trim_start() + .starts_with('/') + { + self.state.replace_input("/".to_string()); + } + let candidates = slash_candidates_with_local_commands( + &self.state.conversation.slash_candidates, + &self.state.shell.available_modes, + query.as_str(), + ); let items = if query.trim().is_empty() { - self.state.conversation.slash_candidates.clone() + candidates } else { - filter_slash_candidates(&self.state.conversation.slash_candidates, &query) + filter_slash_candidates(&candidates, &query) }; self.state.set_slash_query(query.clone(), items); self.refresh_slash_candidates(query).await; @@ -226,34 +347,80 @@ where return; }; let client = self.client.clone(); - let sender = self.actions_tx.clone(); - tokio::spawn(async move { + self.dispatch_async(async move { let result = client .list_conversation_slash_candidates(&session_id, Some(query.as_str())) .await; - let _ = sender.send(Action::SlashCandidatesLoaded { query, result }); + Some(Action::SlashCandidatesLoaded { query, result }) }); } - pub(super) async fn refresh_overlay_query(&mut self) { - match &self.state.interaction.overlay { - OverlayState::Resume(resume) => { - let items = filter_resume_sessions( - &self.state.conversation.sessions, - resume.query.as_str(), + pub(super) async fn refresh_palette_query(&mut self) { + match &self.state.interaction.palette { + PaletteState::Resume(_) => { + if !self + .state + .interaction + .composer + .as_str() + .trim_start() + .starts_with("/resume") + { + self.state.close_palette(); + return; + } + let query = resume_query_from_input(self.state.interaction.composer.as_str()); + let items = + filter_resume_sessions(&self.state.conversation.sessions, query.as_str()); + self.state.set_resume_query(query, items); + }, + PaletteState::Slash(_) => { + if !self + .state + .interaction + .composer + .as_str() + .trim_start() + .starts_with('/') + { + self.state.close_palette(); + return; + } + let query = self.slash_query_for_current_input(); + let candidates = slash_candidates_with_local_commands( + &self.state.conversation.slash_candidates, + &self.state.shell.available_modes, + query.as_str(), ); - self.state.set_resume_query(resume.query.clone(), items); + self.state + .set_slash_query(query.clone(), filter_slash_candidates(&candidates, &query)); + self.refresh_slash_candidates(query).await; }, - OverlayState::SlashPalette(palette) => { - self.refresh_slash_candidates(palette.query.clone()).await; + PaletteState::Model(_) => { + if !self + .state + .interaction + .composer + .as_str() + .trim_start() + .starts_with("/model") + { + self.state.close_palette(); + return; + } + let query = model_query_from_input(self.state.interaction.composer.as_str()); + self.state.set_model_query( + query.clone(), + filter_model_options(&self.state.shell.model_options, query.as_str()), + ); + self.refresh_model_options(query).await; }, - OverlayState::DebugLogs(_) => {}, - OverlayState::None => {}, + PaletteState::Closed => {}, } } - pub(super) fn refresh_resume_overlay(&mut self) { - let OverlayState::Resume(resume) = &self.state.interaction.overlay else { + pub(super) fn refresh_resume_palette(&mut self) { + let PaletteState::Resume(resume) = &self.state.interaction.palette else { return; }; let items = @@ -298,6 +465,50 @@ where } pub(super) fn slash_query_for_current_input(&self) -> String { - slash_query_from_input(self.state.interaction.composer.input.as_str()) + slash_query_from_input(self.state.interaction.composer.as_str()) + } + + async fn apply_model_selection(&mut self, profile_name: String, model: String) { + self.state.set_status(format!("switching model to {model}")); + let client = self.client.clone(); + self.dispatch_async(async move { + let result = client + .save_active_selection(AstrcodeSaveActiveSelectionRequest { + active_profile: profile_name.clone(), + active_model: model.clone(), + }) + .await; + Some(Action::ModelSelectionSaved { + profile_name, + model, + result, + }) + }); + } + + async fn submit_prompt_request( + &mut self, + text: String, + skill_invocation: Option<AstrcodePromptSkillInvocation>, + ) { + let Some(session_id) = self.state.conversation.active_session_id.clone() else { + self.state.set_error_status("no active session"); + return; + }; + self.state.set_status("submitting prompt"); + let client = self.client.clone(); + self.dispatch_async(async move { + let result = client + .submit_prompt( + &session_id, + AstrcodePromptRequest { + text, + skill_invocation, + control: None, + }, + ) + .await; + Some(Action::PromptSubmitted { session_id, result }) + }); } } diff --git a/crates/cli/src/app/mod.rs b/crates/cli/src/app/mod.rs index 7d8130fe..8b492b58 100644 --- a/crates/cli/src/app/mod.rs +++ b/crates/cli/src/app/mod.rs @@ -16,19 +16,21 @@ use anyhow::{Context, Result}; use astrcode_client::{ AstrcodeClient, AstrcodeClientError, AstrcodeClientTransport, AstrcodeConversationSlashCandidatesResponseDto, AstrcodeConversationSnapshotResponseDto, + AstrcodeCurrentModelInfoDto, AstrcodeModeSummaryDto, AstrcodeModelOptionDto, AstrcodePromptAcceptedResponse, AstrcodeReqwestTransport, AstrcodeSessionListItem, - ClientConfig, ConversationStreamItem, + AstrcodeSessionModeStateDto, ClientConfig, ConversationStreamItem, }; use clap::Parser; use crossterm::{ event::{ - self, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture, - Event as CrosstermEvent, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, + self, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, + Event as CrosstermEvent, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseEvent, + MouseEventKind, }, execute, - terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, + terminal::{disable_raw_mode, enable_raw_mode}, }; -use ratatui::{Terminal, backend::CrosstermBackend}; +use ratatui::backend::CrosstermBackend; use tokio::{ sync::mpsc, task::JoinHandle, @@ -36,11 +38,14 @@ use tokio::{ }; use crate::{ + bottom_pane::{BottomPaneState, SurfaceLayout, render_bottom_pane}, capability::TerminalCapabilities, - command::overlay_action, + chat::ChatSurfaceState, + command::{fuzzy_contains, palette_action}, launcher::{LaunchOptions, Launcher, LauncherSession, SystemManagedServer}, - render, - state::{CliState, OverlayState, PaneFocus, StreamRenderMode}, + state::{CliState, PaletteState, PaneFocus, StreamRenderMode}, + tui::TuiRuntime, + ui::{CodexTheme, overlay::render_browser_overlay}, }; #[derive(Debug, Parser)] @@ -59,6 +64,12 @@ struct CliArgs { server_binary: Option<PathBuf>, } +#[derive(Debug)] +struct SnapshotLoadedAction { + session_id: String, + result: Result<AstrcodeConversationSnapshotResponseDto, AstrcodeClientError>, +} + #[derive(Debug)] enum Action { Tick, @@ -68,13 +79,11 @@ enum Action { width: u16, height: u16, }, + Mouse(MouseEvent), Quit, SessionsRefreshed(Result<Vec<AstrcodeSessionListItem>, AstrcodeClientError>), SessionCreated(Result<AstrcodeSessionListItem, AstrcodeClientError>), - SnapshotLoaded { - session_id: String, - result: Result<AstrcodeConversationSnapshotResponseDto, AstrcodeClientError>, - }, + SnapshotLoaded(Box<SnapshotLoadedAction>), StreamBatch { session_id: String, items: Vec<ConversationStreamItem>, @@ -83,14 +92,34 @@ enum Action { query: String, result: Result<AstrcodeConversationSlashCandidatesResponseDto, AstrcodeClientError>, }, + CurrentModelLoaded(Result<AstrcodeCurrentModelInfoDto, AstrcodeClientError>), + ModesLoaded(Result<Vec<AstrcodeModeSummaryDto>, AstrcodeClientError>), + ModelOptionsLoaded { + query: String, + result: Result<Vec<AstrcodeModelOptionDto>, AstrcodeClientError>, + }, PromptSubmitted { session_id: String, result: Result<AstrcodePromptAcceptedResponse, AstrcodeClientError>, }, + ModelSelectionSaved { + profile_name: String, + model: String, + result: Result<(), AstrcodeClientError>, + }, CompactRequested { session_id: String, result: Result<astrcode_client::AstrcodeCompactSessionResponse, AstrcodeClientError>, }, + SessionModeLoaded { + session_id: String, + result: Result<AstrcodeSessionModeStateDto, AstrcodeClientError>, + }, + ModeSwitched { + session_id: String, + requested_mode_id: String, + result: Result<AstrcodeSessionModeStateDto, AstrcodeClientError>, + }, } pub async fn run_from_env() -> Result<()> { @@ -131,10 +160,12 @@ async fn run_app(launcher_session: LauncherSession<SystemManagedServer>) -> Resu capabilities, ), debug_tap, - actions_tx.clone(), - actions_rx, + AppControllerChannels::new(actions_tx.clone(), actions_rx), ); + controller.refresh_current_model().await; + controller.refresh_modes().await; + controller.refresh_model_options(String::new()).await; controller.bootstrap().await?; let terminal_result = run_terminal_loop(&mut controller, actions_tx.clone()).await; @@ -151,60 +182,40 @@ async fn run_terminal_loop( controller: &mut AppController, actions_tx: mpsc::UnboundedSender<Action>, ) -> Result<()> { - enable_raw_mode().context("enable raw mode failed")?; - let mut stdout = io::stdout(); - if controller.state.shell.capabilities.alt_screen { - execute!(stdout, EnterAlternateScreen).context("enter alternate screen failed")?; - } - if controller.state.shell.capabilities.mouse { - execute!(stdout, EnableMouseCapture).context("enable mouse capture failed")?; - } - if controller.state.shell.capabilities.bracketed_paste { - execute!(stdout, EnableBracketedPaste).context("enable bracketed paste failed")?; - } + let terminal_guard = TerminalRestoreGuard::enter(controller.state.shell.capabilities)?; + let stdout = io::stdout(); let backend = CrosstermBackend::new(stdout); - let mut terminal = Terminal::new(backend).context("create terminal backend failed")?; + let mut runtime = TuiRuntime::with_backend(backend).context("create TUI runtime failed")?; let input_handle = InputHandle::spawn(actions_tx.clone()); let tick_handle = spawn_tick_loop(actions_tx); - let loop_result = run_event_loop(controller, &mut terminal).await; + let loop_result = run_event_loop(controller, &mut runtime).await; input_handle.stop(); - tick_handle.abort(); - - disable_raw_mode().context("disable raw mode failed")?; - if controller.state.shell.capabilities.bracketed_paste { - execute!(terminal.backend_mut(), DisableBracketedPaste) - .context("disable bracketed paste failed")?; - } - if controller.state.shell.capabilities.mouse { - execute!(terminal.backend_mut(), DisableMouseCapture) - .context("disable mouse capture failed")?; - } - if controller.state.shell.capabilities.alt_screen { - execute!(terminal.backend_mut(), LeaveAlternateScreen) - .context("leave alternate screen failed")?; - } - terminal.show_cursor().context("show cursor failed")?; + tick_handle.stop().await; + runtime + .terminal_mut() + .show_cursor() + .context("show cursor failed")?; + drop(terminal_guard); loop_result } async fn run_event_loop( controller: &mut AppController, - terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, + runtime: &mut TuiRuntime<CrosstermBackend<io::Stdout>>, ) -> Result<()> { - terminal - .draw(|frame| render::render(frame, &mut controller.state)) - .context("initial draw failed")?; + redraw(controller, runtime).context("initial draw failed")?; + controller.state.render.take_frame_dirty(); while let Some(action) = controller.actions_rx.recv().await { controller.handle_action(action).await?; - terminal - .draw(|frame| render::render(frame, &mut controller.state)) - .context("redraw failed")?; + if controller.state.render.take_frame_dirty() { + redraw(controller, runtime).context("redraw failed")?; + } if controller.should_quit { break; } @@ -213,6 +224,38 @@ async fn run_event_loop( Ok(()) } +fn redraw( + controller: &mut AppController, + runtime: &mut TuiRuntime<CrosstermBackend<io::Stdout>>, +) -> Result<()> { + let size = runtime.screen_size().context("read terminal size failed")?; + let theme = CodexTheme::new(controller.state.shell.capabilities); + let mut chat = controller + .chat_surface + .build_frame(&controller.state, &theme, size.width); + runtime.stage_history_lines( + std::mem::take(&mut chat.history_lines) + .into_iter() + .map(|line| crate::ui::history_line_to_ratatui(line, &theme)), + ); + let pane = BottomPaneState::from_cli(&controller.state, &chat, &theme, size.width); + let layout = SurfaceLayout::new(size, controller.state.render.active_overlay, &pane); + runtime + .draw( + layout.viewport_height(), + controller.state.render.active_overlay.is_open(), + |frame, area| { + if controller.state.render.active_overlay.is_open() { + render_browser_overlay(frame, &controller.state, &theme); + } else { + render_bottom_pane(frame, area, &controller.state, &pane, &theme); + } + }, + ) + .context("draw CLI surface failed")?; + Ok(()) +} + #[derive(Clone, Default)] struct SharedStreamPacer { inner: Arc<std::sync::Mutex<StreamPacerState>>, @@ -272,6 +315,7 @@ impl SharedStreamPacer { struct AppController<T = AstrcodeReqwestTransport> { client: AstrcodeClient<T>, state: CliState, + chat_surface: ChatSurfaceState, debug_tap: Option<crate::launcher::DebugLogTap>, actions_tx: mpsc::UnboundedSender<Action>, actions_rx: mpsc::UnboundedReceiver<Action>, @@ -282,6 +326,43 @@ struct AppController<T = AstrcodeReqwestTransport> { should_quit: bool, } +struct AppControllerChannels { + tx: mpsc::UnboundedSender<Action>, + rx: mpsc::UnboundedReceiver<Action>, +} + +impl AppControllerChannels { + fn new(tx: mpsc::UnboundedSender<Action>, rx: mpsc::UnboundedReceiver<Action>) -> Self { + Self { tx, rx } + } +} + +struct TerminalRestoreGuard { + capabilities: TerminalCapabilities, +} + +impl TerminalRestoreGuard { + fn enter(capabilities: TerminalCapabilities) -> Result<Self> { + enable_raw_mode().context("enable raw mode failed")?; + let mut stdout = io::stdout(); + if capabilities.bracketed_paste { + execute!(stdout, EnableBracketedPaste).context("enable bracketed paste failed")?; + } + Ok(Self { capabilities }) + } +} + +impl Drop for TerminalRestoreGuard { + fn drop(&mut self) { + let _ = disable_raw_mode(); + let mut stdout = io::stdout(); + if self.capabilities.bracketed_paste { + let _ = execute!(stdout, DisableBracketedPaste); + } + let _ = execute!(stdout, DisableMouseCapture); + } +} + impl<T> AppController<T> where T: AstrcodeClientTransport + 'static, @@ -290,15 +371,15 @@ where client: AstrcodeClient<T>, state: CliState, debug_tap: Option<crate::launcher::DebugLogTap>, - actions_tx: mpsc::UnboundedSender<Action>, - actions_rx: mpsc::UnboundedReceiver<Action>, + channels: AppControllerChannels, ) -> Self { Self { client, state, + chat_surface: ChatSurfaceState::default(), debug_tap, - actions_tx, - actions_rx, + actions_tx: channels.tx, + actions_rx: channels.rx, pending_session_id: None, pending_bootstrap_session_refresh: false, stream_task: None, @@ -308,6 +389,7 @@ where } async fn bootstrap(&mut self) -> Result<()> { + // bootstrap 故意复用异步命令链路,避免在初始化阶段维护一套单独的 /new 创建路径。 self.pending_bootstrap_session_refresh = true; self.execute_command(crate::command::Command::New).await; Ok(()) @@ -319,6 +401,13 @@ where } } + async fn consume_bootstrap_refresh(&mut self) { + if self.pending_bootstrap_session_refresh { + self.pending_bootstrap_session_refresh = false; + self.refresh_sessions().await; + } + } + async fn handle_action(&mut self, action: Action) -> Result<()> { match action { Action::Tick => { @@ -329,15 +418,17 @@ where } let (mode, pending, oldest) = self.stream_pacer.update_mode(); self.state.set_stream_mode(mode, pending, oldest); + self.state.advance_thinking_playback(); }, Action::Quit => self.should_quit = true, - Action::Resize { width, height } => self.state.set_viewport_size(width, height), + Action::Resize { width, height } => self.state.note_terminal_resize(width, height), + Action::Mouse(mouse) => self.handle_mouse(mouse), Action::Key(key) => self.handle_key(key).await?, Action::Paste(text) => self.handle_paste(text).await?, Action::SessionsRefreshed(result) => match result { Ok(sessions) => { self.state.update_sessions(sessions); - self.refresh_resume_overlay(); + self.refresh_resume_palette(); }, Err(error) => self.apply_status_error(error), }, @@ -353,35 +444,28 @@ where }, Err(error) => { self.apply_status_error(error); - if self.pending_bootstrap_session_refresh { - self.pending_bootstrap_session_refresh = false; - self.refresh_sessions().await; - } + self.consume_bootstrap_refresh().await; }, }, - Action::SnapshotLoaded { session_id, result } => { + Action::SnapshotLoaded(payload) => { + let SnapshotLoadedAction { session_id, result } = *payload; if !self.pending_session_matches(session_id.as_str()) { return Ok(()); } match result { Ok(snapshot) => { self.pending_session_id = None; + self.chat_surface.reset(); self.state.activate_snapshot(snapshot); self.state .set_status(format!("attached to session {}", session_id)); self.open_stream_for_active_session().await; - if self.pending_bootstrap_session_refresh { - self.pending_bootstrap_session_refresh = false; - self.refresh_sessions().await; - } + self.consume_bootstrap_refresh().await; }, Err(error) => { self.pending_session_id = None; self.apply_hydration_error(error); - if self.pending_bootstrap_session_refresh { - self.pending_bootstrap_session_refresh = false; - self.refresh_sessions().await; - } + self.consume_bootstrap_refresh().await; }, } }, @@ -399,7 +483,7 @@ where self.state.set_stream_mode(mode, pending, oldest); }, Action::SlashCandidatesLoaded { query, result } => { - let OverlayState::SlashPalette(palette) = &self.state.interaction.overlay else { + let PaletteState::Slash(palette) = &self.state.interaction.palette else { return Ok(()); }; if palette.query != query { @@ -408,23 +492,72 @@ where match result { Ok(candidates) => { - self.state.set_slash_query(query, candidates.items); + let items = slash_candidates_with_local_commands( + &candidates.items, + &self.state.shell.available_modes, + query.as_str(), + ); + self.state.set_slash_query(query, items); }, Err(error) => self.apply_status_error(error), } }, + Action::CurrentModelLoaded(result) => match result { + Ok(current_model) => self.state.update_current_model(current_model), + Err(error) => self.apply_status_error(error), + }, + Action::ModesLoaded(result) => match result { + Ok(modes) => self.state.update_modes(modes), + Err(error) => self.apply_status_error(error), + }, + Action::ModelOptionsLoaded { query, result } => match result { + Ok(model_options) => { + self.state.update_model_options(model_options.clone()); + if let PaletteState::Model(palette) = &self.state.interaction.palette { + if palette.query == query { + self.state.set_model_query( + query, + filter_model_options(&model_options, palette.query.as_str()), + ); + } + } + }, + Err(error) => self.apply_status_error(error), + }, Action::PromptSubmitted { session_id, result } => { if !self.active_session_matches(session_id.as_str()) { return Ok(()); } match result { - Ok(response) => { - self.state - .set_status(format!("prompt accepted: turn {}", response.turn_id)); - }, + Ok(_response) => self.state.set_status("ready"), Err(error) => self.apply_status_error(error), } }, + Action::ModelSelectionSaved { + profile_name, + model, + result, + } => match result { + Ok(()) => { + let provider_kind = self + .state + .shell + .model_options + .iter() + .find(|option| option.profile_name == profile_name && option.model == model) + .map(|option| option.provider_kind.clone()) + .unwrap_or_else(|| "unknown".to_string()); + self.state + .update_current_model(AstrcodeCurrentModelInfoDto { + profile_name, + model: model.clone(), + provider_kind, + }); + self.state.set_status(format!("ready · model {model}")); + self.refresh_current_model().await; + }, + Err(error) => self.apply_status_error(error), + }, Action::CompactRequested { session_id, result } => { if !self.active_session_matches(session_id.as_str()) { return Ok(()); @@ -436,6 +569,52 @@ where Err(error) => self.apply_status_error(error), } }, + Action::SessionModeLoaded { session_id, result } => { + if !self.active_session_matches(session_id.as_str()) { + return Ok(()); + } + match result { + Ok(mode) => { + let available = self + .state + .shell + .available_modes + .iter() + .map(|summary| summary.id.as_str()) + .collect::<Vec<_>>() + .join(", "); + if available.is_empty() { + self.state + .set_status(format!("mode {}", mode.current_mode_id)); + } else { + self.state.set_status(format!( + "mode {} · available: {}", + mode.current_mode_id, available + )); + } + }, + Err(error) => self.apply_status_error(error), + } + }, + Action::ModeSwitched { + session_id, + requested_mode_id, + result, + } => { + if !self.active_session_matches(session_id.as_str()) { + return Ok(()); + } + match result { + Ok(mode) => { + self.state.set_status(format!( + "mode {} · next turn will use {}", + mode.current_mode_id, requested_mode_id + )); + self.refresh_modes().await; + }, + Err(error) => self.apply_status_error(error), + } + }, } Ok(()) } @@ -452,74 +631,113 @@ where return Ok(()); } + if key.modifiers.contains(KeyModifiers::CONTROL) && matches!(key.code, KeyCode::Char('t')) { + self.state.toggle_browser(); + return Ok(()); + } + + if key.modifiers.contains(KeyModifiers::CONTROL) && matches!(key.code, KeyCode::Char('o')) { + return Ok(()); + } + + if self.state.interaction.browser.open { + match key.code { + KeyCode::Esc => { + self.state.toggle_browser(); + }, + KeyCode::Home => self.state.browser_first(), + KeyCode::End => self.state.browser_last(), + KeyCode::Up => self.state.browser_prev(1), + KeyCode::Down => self.state.browser_next(1), + KeyCode::PageUp => self.state.browser_prev(5), + KeyCode::PageDown => self.state.browser_next(5), + KeyCode::Enter => self.state.toggle_selected_cell_expanded(), + _ => {}, + } + return Ok(()); + } + match key.code { - KeyCode::F(2) => self.state.toggle_debug_overlay(), - KeyCode::Esc => self.state.close_overlay(), + KeyCode::Esc if self.state.interaction.has_palette() => { + self.state.close_palette(); + }, + KeyCode::Esc => {}, KeyCode::Left => { - if !matches!(self.state.interaction.overlay, OverlayState::None) { - return Ok(()); - } - self.state.cycle_focus_backward(); + self.state.move_cursor_left(); }, KeyCode::Right => { - if !matches!(self.state.interaction.overlay, OverlayState::None) { + self.state.move_cursor_right(); + }, + KeyCode::Home => { + self.state.move_cursor_home(); + }, + KeyCode::End => { + self.state.move_cursor_end(); + }, + KeyCode::BackTab => { + if !matches!(self.state.interaction.palette, PaletteState::Closed) { return Ok(()); } - self.state.cycle_focus_forward(); + self.state.interaction.set_focus(PaneFocus::Composer); + self.state.render.mark_dirty(); }, - KeyCode::Up => { - if !matches!(self.state.interaction.overlay, OverlayState::None) { - self.state.overlay_prev(); - } else if matches!(self.state.interaction.pane_focus, PaneFocus::ChildPane) { - self.state.child_prev(); - } else { - self.state.scroll_up(); + KeyCode::Tab => { + if !matches!(self.state.interaction.palette, PaletteState::Closed) { + return Ok(()); } + self.state.interaction.set_focus(PaneFocus::Composer); + self.state.render.mark_dirty(); }, - KeyCode::Down => { - if !matches!(self.state.interaction.overlay, OverlayState::None) { - self.state.overlay_next(); - } else if matches!(self.state.interaction.pane_focus, PaneFocus::ChildPane) { - self.state.child_next(); - } else { - self.state.scroll_down(); - } + KeyCode::Up if !matches!(self.state.interaction.palette, PaletteState::Closed) => { + self.state.palette_prev(); }, + KeyCode::Up => {}, + KeyCode::Down if !matches!(self.state.interaction.palette, PaletteState::Closed) => { + self.state.palette_next(); + }, + KeyCode::Down => {}, + KeyCode::PageUp | KeyCode::PageDown => {}, KeyCode::Enter => { if key.modifiers.contains(KeyModifiers::SHIFT) { - if matches!(self.state.interaction.overlay, OverlayState::None) + if matches!(self.state.interaction.palette, PaletteState::Closed) && matches!(self.state.interaction.pane_focus, PaneFocus::Composer) { self.state.insert_newline(); } - } else if let Some(selection) = self.state.selected_overlay() { - self.execute_overlay_action(overlay_action(selection)) + } else if let Some(selection) = self.state.selected_palette() { + self.execute_palette_action(palette_action(selection)) .await?; - } else if matches!(self.state.interaction.pane_focus, PaneFocus::ChildPane) { - self.state.toggle_child_focus(); } else { - self.submit_current_input().await; + match self.state.interaction.pane_focus { + PaneFocus::Composer => self.submit_current_input().await, + PaneFocus::Palette | PaneFocus::Browser => {}, + } } }, KeyCode::Backspace => { - if matches!(self.state.interaction.overlay, OverlayState::None) { + if matches!(self.state.interaction.palette, PaletteState::Closed) { self.state.pop_input(); } else { - self.state.overlay_query_pop(); - self.refresh_overlay_query().await; + self.state.pop_input(); + self.refresh_palette_query().await; } }, - KeyCode::Tab => { - let query = self.slash_query_for_current_input(); - self.open_slash_palette(query).await; + KeyCode::Delete => { + self.state.delete_input(); + if !matches!(self.state.interaction.palette, PaletteState::Closed) { + self.refresh_palette_query().await; + } }, KeyCode::Char(ch) => { - if matches!(self.state.interaction.overlay, OverlayState::None) { - self.state.interaction.pane_focus = PaneFocus::Composer; + if !matches!(self.state.interaction.palette, PaletteState::Closed) { self.state.push_input(ch); + self.refresh_palette_query().await; } else { - self.state.overlay_query_push(ch); - self.refresh_overlay_query().await; + self.state.push_input(ch); + if ch == '/' { + let query = self.slash_query_for_current_input(); + self.open_slash_palette(query).await; + } } }, _ => {}, @@ -529,16 +747,25 @@ where } async fn handle_paste(&mut self, text: String) -> Result<()> { - if matches!(self.state.interaction.overlay, OverlayState::None) { - self.state.interaction.pane_focus = PaneFocus::Composer; - self.state.append_input(text.as_str()); - } else { - self.state.overlay_query_append(text.as_str()); - self.refresh_overlay_query().await; + self.state.append_input(text.as_str()); + if !matches!(self.state.interaction.palette, PaletteState::Closed) { + self.refresh_palette_query().await; } Ok(()) } + fn handle_mouse(&mut self, mouse: MouseEvent) { + match mouse.kind { + MouseEventKind::ScrollUp | MouseEventKind::ScrollDown => {}, + MouseEventKind::Down(_) => { + let _ = mouse; + self.state.interaction.set_focus(PaneFocus::Composer); + self.state.render.mark_dirty(); + }, + _ => {}, + } + } + fn active_session_matches(&self, session_id: &str) -> bool { self.state.conversation.active_session_id.as_deref() == Some(session_id) } @@ -571,6 +798,11 @@ impl InputHandle { break; } }, + Ok(CrosstermEvent::Mouse(mouse)) => { + if actions_tx.send(Action::Mouse(mouse)).is_err() { + break; + } + }, Ok(CrosstermEvent::Resize(width, height)) => { if actions_tx.send(Action::Resize { width, height }).is_err() { break; @@ -600,17 +832,44 @@ impl InputHandle { } } -fn spawn_tick_loop(actions_tx: mpsc::UnboundedSender<Action>) -> JoinHandle<()> { - tokio::spawn(async move { - let mut interval = time::interval(Duration::from_millis(250)); - interval.set_missed_tick_behavior(MissedTickBehavior::Skip); - loop { - interval.tick().await; - if actions_tx.send(Action::Tick).is_err() { - break; +fn spawn_tick_loop(actions_tx: mpsc::UnboundedSender<Action>) -> TickHandle { + TickHandle::spawn(actions_tx) +} + +struct TickHandle { + stop: Arc<AtomicBool>, + join: Option<JoinHandle<()>>, +} + +impl TickHandle { + fn spawn(actions_tx: mpsc::UnboundedSender<Action>) -> Self { + let stop = Arc::new(AtomicBool::new(false)); + let stop_flag = Arc::clone(&stop); + let join = tokio::spawn(async move { + let mut interval = time::interval(Duration::from_millis(250)); + interval.set_missed_tick_behavior(MissedTickBehavior::Skip); + loop { + interval.tick().await; + if stop_flag.load(Ordering::Relaxed) { + break; + } + if actions_tx.send(Action::Tick).is_err() { + break; + } } + }); + Self { + stop, + join: Some(join), } - }) + } + + async fn stop(mut self) { + self.stop.store(true, Ordering::Relaxed); + if let Some(join) = self.join.take() { + let _ = join.await; + } + } } fn resolve_working_dir(cli_value: Option<PathBuf>) -> Result<PathBuf> { @@ -632,17 +891,18 @@ fn filter_resume_sessions( sessions: &[AstrcodeSessionListItem], query: &str, ) -> Vec<AstrcodeSessionListItem> { - let query = query.trim().to_lowercase(); let mut items = sessions .iter() .filter(|session| { - if query.is_empty() { - return true; - } - session.session_id.to_lowercase().contains(&query) - || session.title.to_lowercase().contains(&query) - || session.display_name.to_lowercase().contains(&query) - || session.working_dir.to_lowercase().contains(&query) + fuzzy_contains( + query, + [ + session.session_id.clone(), + session.title.clone(), + session.display_name.clone(), + session.working_dir.clone(), + ], + ) }) .cloned() .collect::<Vec<_>>(); @@ -650,12 +910,144 @@ fn filter_resume_sessions( items } +fn slash_candidates_with_local_commands( + candidates: &[astrcode_client::AstrcodeConversationSlashCandidateDto], + modes: &[astrcode_client::AstrcodeModeSummaryDto], + query: &str, +) -> Vec<astrcode_client::AstrcodeConversationSlashCandidateDto> { + let mut merged = candidates.to_vec(); + let model_candidate = astrcode_client::AstrcodeConversationSlashCandidateDto { + id: "model".to_string(), + title: "/model".to_string(), + description: "选择当前已配置的模型".to_string(), + keywords: vec!["model".to_string(), "profile".to_string()], + action_kind: astrcode_client::AstrcodeConversationSlashActionKindDto::ExecuteCommand, + action_value: "/model".to_string(), + }; + + if !merged + .iter() + .any(|candidate| candidate.id == model_candidate.id) + && fuzzy_contains( + query, + [ + model_candidate.id.clone(), + model_candidate.title.clone(), + model_candidate.description.clone(), + ], + ) + { + merged.push(model_candidate); + } + + let mode_candidate = astrcode_client::AstrcodeConversationSlashCandidateDto { + id: "mode".to_string(), + title: "/mode".to_string(), + description: "查看或切换当前 session 的治理 mode".to_string(), + keywords: vec![ + "mode".to_string(), + "governance".to_string(), + "plan".to_string(), + "review".to_string(), + "code".to_string(), + ], + action_kind: astrcode_client::AstrcodeConversationSlashActionKindDto::ExecuteCommand, + action_value: "/mode".to_string(), + }; + if !merged + .iter() + .any(|candidate| candidate.id == mode_candidate.id) + && fuzzy_contains( + query, + [ + mode_candidate.id.clone(), + mode_candidate.title.clone(), + mode_candidate.description.clone(), + ], + ) + { + merged.push(mode_candidate); + } + + for mode in modes { + let candidate = astrcode_client::AstrcodeConversationSlashCandidateDto { + id: format!("mode:{}", mode.id), + title: format!("/mode {}", mode.id), + description: format!("切换到 {} · {}", mode.name, mode.description), + keywords: vec![ + "mode".to_string(), + "governance".to_string(), + mode.id.clone(), + mode.name.clone(), + ], + action_kind: astrcode_client::AstrcodeConversationSlashActionKindDto::ExecuteCommand, + action_value: format!("/mode {}", mode.id), + }; + if !merged.iter().any(|existing| existing.id == candidate.id) + && fuzzy_contains( + query, + [ + candidate.id.clone(), + candidate.title.clone(), + candidate.description.clone(), + ], + ) + { + merged.push(candidate); + } + } + + merged +} + +fn filter_model_options( + options: &[AstrcodeModelOptionDto], + query: &str, +) -> Vec<AstrcodeModelOptionDto> { + let mut items = options + .iter() + .filter(|option| { + fuzzy_contains( + query, + [ + option.model.clone(), + option.profile_name.clone(), + option.provider_kind.clone(), + ], + ) + }) + .cloned() + .collect::<Vec<_>>(); + items.sort_by(|left, right| left.model.cmp(&right.model)); + items +} + fn slash_query_from_input(input: &str) -> String { let trimmed = input.trim(); - if let Some(query) = trimmed.strip_prefix("/skill") { - return query.trim().to_string(); - } - trimmed.trim_start_matches('/').trim().to_string() + let command = trimmed.trim_start_matches('/'); + command + .split_whitespace() + .next() + .unwrap_or_default() + .to_string() +} + +fn resume_query_from_input(input: &str) -> String { + input + .trim() + .strip_prefix("/resume") + .map(str::trim) + .unwrap_or_default() + .to_string() +} + +fn model_query_from_input(input: &str) -> String { + input + .trim() + .strip_prefix("/model") + .map(str::trim) + .unwrap_or_default() + .to_string() } #[cfg(test)] @@ -708,6 +1100,18 @@ mod tests { } } + #[test] + fn model_query_from_input_extracts_optional_filter() { + assert_eq!(super::model_query_from_input("/model"), ""); + assert_eq!(super::model_query_from_input("/model claude"), "claude"); + } + + #[test] + fn slash_candidates_with_local_commands_includes_model_entry() { + let items = super::slash_candidates_with_local_commands(&[], &[], "model"); + assert!(items.iter().any(|item| item.id == "model")); + } + #[derive(Debug)] enum MockCall { Request { @@ -804,7 +1208,9 @@ mod tests { "phase": "idle", "canSubmitPrompt": true, "canRequestCompact": true, - "compactPending": false + "compactPending": false, + "compacting": false, + "currentModeId": "default" }, "blocks": [{ "kind": "assistant", @@ -917,8 +1323,7 @@ mod tests { ascii_capabilities(), ), None, - actions_tx, - actions_rx, + AppControllerChannels::new(actions_tx, actions_rx), ); controller.state.update_sessions(vec![session( "session-old", @@ -999,8 +1404,7 @@ mod tests { ascii_capabilities(), ), None, - actions_tx, - actions_rx, + AppControllerChannels::new(actions_tx, actions_rx), ); controller.state.update_sessions(vec![existing]); @@ -1017,6 +1421,111 @@ mod tests { transport.assert_consumed(); } + #[tokio::test] + async fn submitting_prompt_restores_transcript_tail_follow_mode() { + let transport = MockTransport::default(); + transport.push(MockCall::Request { + expected: AstrcodeTransportRequest { + method: AstrcodeTransportMethod::Post, + url: "http://localhost:5529/api/sessions/session-1/prompts".to_string(), + auth_token: Some("session-token".to_string()), + query: Vec::new(), + json_body: Some(json!({ + "text": "hello" + })), + }, + result: Ok(AstrcodeTransportResponse { + status: 202, + body: json!({ + "sessionId": "session-1", + "accepted": true, + "turnId": "turn-1" + }) + .to_string(), + }), + }); + + let (actions_tx, actions_rx) = mpsc::unbounded_channel(); + let mut controller = AppController::new( + client_with_transport(transport.clone()), + CliState::new( + "http://localhost:5529".to_string(), + Some(PathBuf::from("D:/repo-a")), + ascii_capabilities(), + ), + None, + AppControllerChannels::new(actions_tx, actions_rx), + ); + controller.state.conversation.active_session_id = Some("session-1".to_string()); + controller.state.replace_input("hello"); + + controller.submit_current_input().await; + + handle_next_action(&mut controller).await; + assert_eq!(controller.state.interaction.status.message, "ready"); + transport.assert_consumed(); + } + + #[tokio::test] + async fn submitting_skill_slash_sends_structured_skill_invocation() { + let transport = MockTransport::default(); + transport.push(MockCall::Request { + expected: AstrcodeTransportRequest { + method: AstrcodeTransportMethod::Post, + url: "http://localhost:5529/api/sessions/session-1/prompts".to_string(), + auth_token: Some("session-token".to_string()), + query: Vec::new(), + json_body: Some(json!({ + "text": "修复失败测试", + "skillInvocation": { + "skillId": "review", + "userPrompt": "修复失败测试" + } + })), + }, + result: Ok(AstrcodeTransportResponse { + status: 202, + body: json!({ + "sessionId": "session-1", + "accepted": true, + "turnId": "turn-2" + }) + .to_string(), + }), + }); + + let (actions_tx, actions_rx) = mpsc::unbounded_channel(); + let mut controller = AppController::new( + client_with_transport(transport.clone()), + CliState::new( + "http://localhost:5529".to_string(), + Some(PathBuf::from("D:/repo-a")), + ascii_capabilities(), + ), + None, + AppControllerChannels::new(actions_tx, actions_rx), + ); + controller.state.conversation.active_session_id = Some("session-1".to_string()); + controller.state.conversation.slash_candidates = + vec![astrcode_client::AstrcodeConversationSlashCandidateDto { + id: "review".to_string(), + title: "Review".to_string(), + description: "review skill".to_string(), + keywords: vec!["review".to_string()], + action_kind: astrcode_client::AstrcodeConversationSlashActionKindDto::InsertText, + action_value: "/review".to_string(), + }]; + controller + .state + .replace_input("/review 修复失败测试".to_string()); + + controller.submit_current_input().await; + + handle_next_action(&mut controller).await; + assert_eq!(controller.state.interaction.status.message, "ready"); + transport.assert_consumed(); + } + #[tokio::test] async fn end_to_end_acceptance_covers_resume_compact_skill_and_single_active_stream_switch() { let transport = MockTransport::default(); @@ -1059,12 +1568,12 @@ mod tests { status: 200, body: json!({ "items": [{ - "id": "skill-review", + "id": "review", "title": "Review skill", "description": "插入 review skill", "keywords": ["review"], "actionKind": "insert_text", - "actionValue": "/skill review" + "actionValue": "/review" }] }) .to_string(), @@ -1138,8 +1647,7 @@ mod tests { ascii_capabilities(), ), None, - actions_tx, - actions_rx, + AppControllerChannels::new(actions_tx, actions_rx), ); controller .state @@ -1159,13 +1667,10 @@ mod tests { "session one should hydrate one transcript block" ); - controller - .execute_command(Command::Skill { - query: Some("review".to_string()), - }) - .await; + controller.state.replace_input("/review".to_string()); + controller.open_slash_palette("review".to_string()).await; handle_next_action(&mut controller).await; - let OverlayState::SlashPalette(palette) = &controller.state.interaction.overlay else { + let PaletteState::Slash(palette) = &controller.state.interaction.palette else { panic!("skill command should open slash palette"); }; assert_eq!(palette.query, "review"); @@ -1183,17 +1688,17 @@ mod tests { query: Some("repo-b".to_string()), }) .await; - let OverlayState::Resume(resume) = &controller.state.interaction.overlay else { - panic!("resume command should open resume overlay"); + let PaletteState::Resume(resume) = &controller.state.interaction.palette else { + panic!("resume command should open resume palette"); }; assert_eq!(resume.query, "repo-b"); handle_next_action(&mut controller).await; let selection = controller .state - .selected_overlay() - .expect("resume overlay should keep a selection"); + .selected_palette() + .expect("resume palette should keep a selection"); controller - .execute_overlay_action(overlay_action(selection)) + .execute_palette_action(palette_action(selection)) .await .expect("resume selection should switch session"); handle_next_action(&mut controller).await; diff --git a/crates/cli/src/bottom_pane/layout.rs b/crates/cli/src/bottom_pane/layout.rs new file mode 100644 index 00000000..88ab07e4 --- /dev/null +++ b/crates/cli/src/bottom_pane/layout.rs @@ -0,0 +1,68 @@ +use ratatui::layout::{Rect, Size}; + +use super::BottomPaneState; +use crate::state::ActiveOverlay; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SurfaceLayout { + pub screen: Rect, + pub viewport: Rect, + pub overlay: Option<Rect>, +} + +impl SurfaceLayout { + pub fn new(size: Size, overlay: ActiveOverlay, pane: &BottomPaneState) -> Self { + let screen = Rect::new(0, 0, size.width, size.height); + if overlay.is_open() { + return Self { + screen, + viewport: screen, + overlay: Some(screen), + }; + } + + let viewport_height = pane.desired_height(size.height); + let viewport = Rect::new( + 0, + size.height.saturating_sub(viewport_height), + size.width, + viewport_height, + ); + Self { + screen, + viewport, + overlay: None, + } + } + + pub fn viewport_height(self) -> u16 { + self.viewport.height + } +} + +#[cfg(test)] +mod tests { + use ratatui::layout::Size; + + use super::SurfaceLayout; + use crate::{bottom_pane::BottomPaneState, state::ActiveOverlay}; + + #[test] + fn overlay_uses_full_screen_viewport() { + let layout = SurfaceLayout::new( + Size::new(80, 24), + ActiveOverlay::Browser, + &BottomPaneState::default(), + ); + assert_eq!(layout.viewport.height, 24); + assert_eq!(layout.overlay, Some(layout.screen)); + } + + #[test] + fn inline_layout_anchors_viewport_to_bottom() { + let pane = BottomPaneState::default(); + let layout = SurfaceLayout::new(Size::new(80, 24), ActiveOverlay::None, &pane); + assert_eq!(layout.viewport.bottom(), 24); + assert!(layout.viewport.height >= 4); + } +} diff --git a/crates/cli/src/bottom_pane/mod.rs b/crates/cli/src/bottom_pane/mod.rs new file mode 100644 index 00000000..4e4a442d --- /dev/null +++ b/crates/cli/src/bottom_pane/mod.rs @@ -0,0 +1,7 @@ +mod layout; +mod model; +mod render; + +pub use layout::SurfaceLayout; +pub use model::BottomPaneState; +pub use render::render_bottom_pane; diff --git a/crates/cli/src/bottom_pane/model.rs b/crates/cli/src/bottom_pane/model.rs new file mode 100644 index 00000000..95cf9410 --- /dev/null +++ b/crates/cli/src/bottom_pane/model.rs @@ -0,0 +1,235 @@ +use ratatui::text::Line; + +use crate::{ + chat::ChatSurfaceFrame, + state::{CliState, PaletteState, WrappedLine, WrappedLineStyle}, + ui::{CodexTheme, line_to_ratatui, materialize_wrapped_lines, palette_lines}, +}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BottomPaneMode { + EmptySessionMinimal { + welcome_lines: Vec<Line<'static>>, + }, + ActiveSession { + status_line: Option<Line<'static>>, + detail_lines: Vec<Line<'static>>, + preview_lines: Vec<Line<'static>>, + }, +} + +impl Default for BottomPaneMode { + fn default() -> Self { + Self::ActiveSession { + status_line: None, + detail_lines: Vec::new(), + preview_lines: Vec::new(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct BottomPaneState { + pub mode: BottomPaneMode, + pub composer_line_count: usize, + pub palette_title: Option<String>, + pub palette_lines: Vec<Line<'static>>, +} + +impl BottomPaneState { + pub fn from_cli( + state: &CliState, + chat: &ChatSurfaceFrame, + theme: &CodexTheme, + width: u16, + ) -> Self { + let palette_title = palette_title(&state.interaction.palette); + let palette_lines = if palette_title.is_some() { + palette_lines( + &state.interaction.palette, + usize::from(width.saturating_sub(4).max(1)), + theme, + ) + .into_iter() + .map(|line| line_to_ratatui(&line, theme)) + .collect() + } else { + Vec::new() + }; + + Self { + mode: if should_show_empty_session_minimal(state, chat) { + BottomPaneMode::EmptySessionMinimal { + welcome_lines: build_welcome_lines(state), + } + } else { + BottomPaneMode::ActiveSession { + status_line: active_status_line(state, chat) + .map(|line| line_to_ratatui(&line, theme)), + detail_lines: materialize_wrapped_lines( + &chat.detail_lines, + usize::from(width.max(1)), + theme, + ), + preview_lines: materialize_wrapped_lines( + &chat.preview_lines, + usize::from(width.max(1)), + theme, + ), + } + }, + composer_line_count: state.interaction.composer.line_count(), + palette_title, + palette_lines, + } + } + + pub fn desired_height(&self, total_height: u16) -> u16 { + let palette_height = self.palette_height(); + let composer_height = composer_height(total_height, self.composer_line_count); + let content_height = match &self.mode { + BottomPaneMode::EmptySessionMinimal { welcome_lines } => { + (welcome_lines.len() as u16 + 2).clamp(5, 6) + }, + BottomPaneMode::ActiveSession { + status_line, + detail_lines, + preview_lines, + } => (status_line.is_some() as u16) + .saturating_add(detail_lines.len().min(2) as u16) + .saturating_add(preview_lines.len().min(3) as u16), + }; + + content_height + .saturating_add(palette_height) + .saturating_add(composer_height) + .clamp(bottom_pane_min_height(total_height), total_height.max(1)) + } + + pub fn palette_height(&self) -> u16 { + if self.palette_title.is_some() { + (self.palette_lines.len() as u16 + 2).clamp(3, 7) + } else { + 0 + } + } +} + +pub fn composer_height(total_height: u16, line_count: usize) -> u16 { + let preferred = line_count.clamp(1, 3) as u16; + if total_height <= 4 { 1 } else { preferred } +} + +fn bottom_pane_min_height(total_height: u16) -> u16 { + if total_height <= 8 { 3 } else { 4 } +} + +fn should_show_empty_session_minimal(state: &CliState, chat: &ChatSurfaceFrame) -> bool { + state.conversation.transcript.is_empty() + && chat.status_line.is_none() + && chat.detail_lines.is_empty() + && chat.preview_lines.is_empty() + && !state.interaction.browser.open +} + +fn build_welcome_lines(state: &CliState) -> Vec<Line<'static>> { + let model = state + .shell + .current_model + .as_ref() + .map(|model| model.model.clone()) + .unwrap_or_else(|| "loading".to_string()); + let directory = state + .shell + .working_dir + .as_ref() + .and_then(|path| path.file_name()) + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_else(|| "~".to_string()); + + vec![ + Line::from(">_ Astrcode CLI"), + Line::from(format!("model: {model}")), + Line::from(format!("directory: {directory}")), + ] +} + +fn active_status_line(state: &CliState, chat: &ChatSurfaceFrame) -> Option<WrappedLine> { + if state.interaction.status.is_error { + return Some(WrappedLine::plain( + WrappedLineStyle::Plain, + format!("• {}", state.interaction.status.message), + )); + } + let trimmed = state.interaction.status.message.trim(); + if !trimmed.is_empty() && trimmed != "ready" { + return Some(WrappedLine::plain( + WrappedLineStyle::Plain, + format!("• {trimmed}"), + )); + } + chat.status_line.clone() +} + +fn palette_title(palette: &PaletteState) -> Option<String> { + match palette { + PaletteState::Slash(_) => Some("/ commands".to_string()), + PaletteState::Resume(_) => Some("/resume".to_string()), + PaletteState::Model(_) => Some("/model".to_string()), + PaletteState::Closed => None, + } +} + +#[cfg(test)] +mod tests { + use astrcode_client::AstrcodeCurrentModelInfoDto; + + use super::{ + BottomPaneMode, BottomPaneState, composer_height, should_show_empty_session_minimal, + }; + use crate::{ + capability::{ColorLevel, GlyphMode, TerminalCapabilities}, + chat::ChatSurfaceFrame, + state::CliState, + ui::CodexTheme, + }; + + fn capabilities() -> TerminalCapabilities { + TerminalCapabilities { + color: ColorLevel::Ansi16, + glyphs: GlyphMode::Unicode, + alt_screen: false, + mouse: false, + bracketed_paste: false, + } + } + + #[test] + fn empty_session_uses_minimal_mode() { + let mut state = CliState::new("http://127.0.0.1:5529".to_string(), None, capabilities()); + state.shell.current_model = Some(AstrcodeCurrentModelInfoDto { + profile_name: "default".to_string(), + model: "glm-5.1".to_string(), + provider_kind: "glm".to_string(), + }); + let chat = ChatSurfaceFrame::default(); + assert!(should_show_empty_session_minimal(&state, &chat)); + + let theme = CodexTheme::new(state.shell.capabilities); + let pane = BottomPaneState::from_cli(&state, &chat, &theme, 80); + match &pane.mode { + BottomPaneMode::EmptySessionMinimal { welcome_lines } => { + assert_eq!(welcome_lines.len(), 3); + assert!(welcome_lines[0].to_string().contains("Astrcode CLI")); + }, + BottomPaneMode::ActiveSession { .. } => panic!("empty session should use minimal mode"), + } + assert!((6..=8).contains(&pane.desired_height(24))); + } + + #[test] + fn composer_height_stays_single_line_by_default() { + assert_eq!(composer_height(24, 1), 1); + assert_eq!(composer_height(24, 4), 3); + } +} diff --git a/crates/cli/src/bottom_pane/render.rs b/crates/cli/src/bottom_pane/render.rs new file mode 100644 index 00000000..a0a20f0a --- /dev/null +++ b/crates/cli/src/bottom_pane/render.rs @@ -0,0 +1,178 @@ +use ratatui::{ + layout::{Constraint, Direction, Layout, Rect}, + widgets::{Block, Borders, Clear, Paragraph}, +}; + +use super::{ + BottomPaneState, + model::{BottomPaneMode, composer_height}, +}; +use crate::{ + state::{CliState, PaneFocus}, + ui::{CodexTheme, ThemePalette, composer::render_composer, custom_terminal::Frame}, +}; + +pub fn render_bottom_pane( + frame: &mut Frame<'_>, + area: Rect, + state: &CliState, + pane: &BottomPaneState, + theme: &CodexTheme, +) { + let popup_height = pane.palette_height(); + let composer_height = composer_height(area.height, pane.composer_line_count); + + let body_height = area + .height + .saturating_sub(popup_height) + .saturating_sub(composer_height); + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(body_height), + Constraint::Length(popup_height), + Constraint::Length(composer_height), + ]) + .split(area); + + render_body(frame, chunks[0], pane, theme); + render_palette(frame, chunks[1], pane, theme); + render_composer_area(frame, chunks[2], state, theme); +} + +fn render_body(frame: &mut Frame<'_>, area: Rect, pane: &BottomPaneState, theme: &CodexTheme) { + match &pane.mode { + BottomPaneMode::EmptySessionMinimal { welcome_lines } => { + if area.height == 0 { + return; + } + let card_height = (welcome_lines.len() as u16 + 2).min(area.height); + let card_area = Rect::new(area.x, area.y, area.width.min(48), card_height); + let block = Block::default() + .borders(Borders::ALL) + .border_style(theme.menu_block_style()); + let inner = block.inner(card_area); + frame.render_widget(block, card_area); + frame.render_widget(Paragraph::new(welcome_lines.clone()), inner); + }, + BottomPaneMode::ActiveSession { + status_line, + detail_lines, + preview_lines, + } => { + let status_height = status_line.is_some() as u16; + let detail_height = detail_lines.len().min(2) as u16; + let preview_height = preview_lines.len().min(3) as u16; + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(status_height), + Constraint::Length(detail_height), + Constraint::Length(preview_height), + Constraint::Min(0), + ]) + .split(area); + + if let Some(line) = status_line { + frame.render_widget(Paragraph::new(vec![line.clone()]), chunks[0]); + } + if detail_height > 0 { + frame.render_widget( + Paragraph::new( + detail_lines + .iter() + .take(detail_height as usize) + .cloned() + .collect::<Vec<_>>(), + ), + chunks[1], + ); + } + if preview_height > 0 { + let visible_preview = preview_tail(preview_lines, preview_height as usize); + frame.render_widget(Paragraph::new(visible_preview), chunks[2]); + } + }, + } +} + +fn render_palette(frame: &mut Frame<'_>, area: Rect, pane: &BottomPaneState, theme: &CodexTheme) { + if area.height == 0 { + return; + } + if let Some(title) = &pane.palette_title { + frame.render_widget(Clear, area); + let block = Block::default() + .borders(Borders::ALL) + .border_style(theme.menu_block_style()) + .title(title.clone()); + let inner = block.inner(area); + frame.render_widget(block, area); + frame.render_widget(Paragraph::new(pane.palette_lines.clone()), inner); + } +} + +fn render_composer_area(frame: &mut Frame<'_>, area: Rect, state: &CliState, theme: &CodexTheme) { + if area.height == 0 { + return; + } + let focused = matches!( + state.interaction.pane_focus, + PaneFocus::Composer | PaneFocus::Palette + ); + let composer = render_composer( + &state.interaction.composer, + area.width.saturating_sub(2), + area.height, + focused, + ); + let prompt = theme.glyph("›", ">"); + let composer_lines = composer + .lines + .into_iter() + .enumerate() + .map(|(index, line)| { + if index == 0 { + ratatui::text::Line::from(format!("{prompt} {}", line)) + } else { + ratatui::text::Line::from(format!(" {}", line)) + } + }) + .collect::<Vec<_>>(); + frame.render_widget(Paragraph::new(composer_lines), area); + if let Some((cursor_x, cursor_y)) = composer.cursor { + frame.set_cursor_position((area.x + cursor_x + 2, area.y + cursor_y)); + } +} + +fn preview_tail( + lines: &[ratatui::text::Line<'static>], + visible_lines: usize, +) -> Vec<ratatui::text::Line<'static>> { + let skip = lines.len().saturating_sub(visible_lines); + lines.iter().skip(skip).cloned().collect() +} + +#[cfg(test)] +mod tests { + use ratatui::text::Line; + + use super::preview_tail; + + #[test] + fn preview_tail_prefers_latest_lines() { + let lines = vec![ + Line::from("line-1"), + Line::from("line-2"), + Line::from("line-3"), + Line::from("line-4"), + ]; + + let visible = preview_tail(&lines, 2) + .into_iter() + .map(|line| line.to_string()) + .collect::<Vec<_>>(); + + assert_eq!(visible, vec!["line-3".to_string(), "line-4".to_string()]); + } +} diff --git a/crates/cli/src/chat/mod.rs b/crates/cli/src/chat/mod.rs new file mode 100644 index 00000000..0fe714d1 --- /dev/null +++ b/crates/cli/src/chat/mod.rs @@ -0,0 +1,3 @@ +mod surface; + +pub use surface::{ChatSurfaceFrame, ChatSurfaceState}; diff --git a/crates/cli/src/chat/surface.rs b/crates/cli/src/chat/surface.rs new file mode 100644 index 00000000..6ac2af75 --- /dev/null +++ b/crates/cli/src/chat/surface.rs @@ -0,0 +1,260 @@ +use std::collections::HashSet; + +use crate::{ + state::{ + CliState, TranscriptCell, TranscriptCellKind, TranscriptCellStatus, WrappedLine, + WrappedLineStyle, + }, + ui::{ + CodexTheme, + cells::{RenderableCell, TranscriptCellView}, + }, +}; + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct ChatSurfaceFrame { + pub history_lines: Vec<WrappedLine>, + pub status_line: Option<WrappedLine>, + pub detail_lines: Vec<WrappedLine>, + pub preview_lines: Vec<WrappedLine>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct ChatSurfaceState { + committed_cells: HashSet<String>, +} + +impl ChatSurfaceState { + pub fn reset(&mut self) { + self.committed_cells.clear(); + } + + pub fn build_frame( + &mut self, + state: &CliState, + theme: &CodexTheme, + width: u16, + ) -> ChatSurfaceFrame { + let mut frame = ChatSurfaceFrame::default(); + let content_width = usize::from(width.max(28)); + + for cell in state.transcript_cells() { + if cell_is_streaming(&cell) { + self.apply_active_cell(&cell, state, theme, content_width, &mut frame); + continue; + } + self.commit_completed_cell(&cell, state, theme, content_width, &mut frame); + } + + if let Some(banner) = &state.conversation.banner { + frame.status_line = Some(status_line(format!("• {}", banner.error.message))); + frame.detail_lines.insert( + 0, + plain_line(" 当前流需要重新同步,建议等待自动恢复或重新加载快照。"), + ); + } + + frame + } + + fn apply_active_cell( + &mut self, + cell: &TranscriptCell, + state: &CliState, + theme: &CodexTheme, + width: usize, + frame: &mut ChatSurfaceFrame, + ) { + let rendered = trim_trailing_blank_lines(render_cell_lines(cell, state, theme, width)); + + match &cell.kind { + TranscriptCellKind::Assistant { .. } => { + frame.status_line = Some(status_line("• 正在生成回复")); + frame.preview_lines = rendered; + }, + TranscriptCellKind::Thinking { .. } => { + frame.status_line = Some(status_line("• 正在思考")); + frame.detail_lines = rendered; + }, + TranscriptCellKind::ToolCall { tool_name, .. } => { + frame.status_line = Some(status_line(format!("• 正在运行 {tool_name}"))); + frame.detail_lines = rendered; + }, + _ => {}, + } + } + + fn commit_completed_cell( + &mut self, + cell: &TranscriptCell, + state: &CliState, + theme: &CodexTheme, + width: usize, + frame: &mut ChatSurfaceFrame, + ) { + if !self.committed_cells.insert(cell.id.clone()) { + return; + } + let rendered = render_cell_lines(cell, state, theme, width); + frame.history_lines.extend(rendered); + } +} + +fn plain_line(content: impl Into<String>) -> WrappedLine { + WrappedLine::plain(WrappedLineStyle::Plain, content) +} + +fn status_line(content: impl Into<String>) -> WrappedLine { + WrappedLine::plain(WrappedLineStyle::Plain, content) +} + +fn cell_is_streaming(cell: &TranscriptCell) -> bool { + match &cell.kind { + TranscriptCellKind::Assistant { status, .. } + | TranscriptCellKind::Thinking { status, .. } + | TranscriptCellKind::ToolCall { status, .. } => { + matches!(status, TranscriptCellStatus::Streaming) + }, + _ => false, + } +} + +fn render_cell_lines( + cell: &TranscriptCell, + state: &CliState, + theme: &CodexTheme, + width: usize, +) -> Vec<WrappedLine> { + let view = TranscriptCellView { + selected: false, + expanded: state.is_cell_expanded(cell.id.as_str()) || cell.expanded, + thinking: thinking_state_for_cell(cell, state), + }; + cell.render_lines(width, state.shell.capabilities, theme, &view) +} + +fn trim_trailing_blank_lines(mut lines: Vec<WrappedLine>) -> Vec<WrappedLine> { + while lines.last().is_some_and(WrappedLine::is_blank) { + lines.pop(); + } + lines +} + +fn thinking_state_for_cell( + cell: &TranscriptCell, + state: &CliState, +) -> Option<crate::state::ThinkingPresentationState> { + let TranscriptCellKind::Thinking { body, status } = &cell.kind else { + return None; + }; + Some(state.thinking_playback.present( + &state.thinking_pool, + cell.id.as_str(), + body.as_str(), + *status, + state.is_cell_expanded(cell.id.as_str()) || cell.expanded, + )) +} + +#[cfg(test)] +mod tests { + use astrcode_client::{ + AstrcodeConversationAssistantBlockDto, AstrcodeConversationBlockDto, + AstrcodeConversationBlockStatusDto, + }; + + use super::ChatSurfaceState; + use crate::{ + capability::{ColorLevel, GlyphMode, TerminalCapabilities}, + state::{CliState, WrappedLine}, + ui::CodexTheme, + }; + + fn capabilities() -> TerminalCapabilities { + TerminalCapabilities { + color: ColorLevel::Ansi16, + glyphs: GlyphMode::Unicode, + alt_screen: false, + mouse: false, + bracketed_paste: false, + } + } + + fn assistant_block( + id: &str, + status: AstrcodeConversationBlockStatusDto, + markdown: &str, + ) -> AstrcodeConversationBlockDto { + AstrcodeConversationBlockDto::Assistant(AstrcodeConversationAssistantBlockDto { + id: id.to_string(), + turn_id: Some("turn-1".to_string()), + status, + markdown: markdown.to_string(), + }) + } + + fn line_texts(lines: &[WrappedLine]) -> Vec<String> { + lines.iter().map(WrappedLine::text).collect() + } + + #[test] + fn streaming_assistant_progressively_commits_history_and_keeps_tail_in_preview() { + let mut state = CliState::new("http://127.0.0.1:5529".to_string(), None, capabilities()); + state.conversation.transcript = vec![assistant_block( + "assistant-1", + AstrcodeConversationBlockStatusDto::Streaming, + "- 第1项:这是一个足够长的列表项,用来制造稳定折行。\n- \ + 第2项:这是一个足够长的列表项,用来制造稳定折行。\n- \ + 第3项:这是一个足够长的列表项,用来制造稳定折行。\n- \ + 第4项:这是一个足够长的列表项,用来制造稳定折行。\n- \ + 第5项:这是一个足够长的列表项,用来制造稳定折行。\n- \ + 第6项:这是一个足够长的列表项,用来制造稳定折行。", + )]; + let theme = CodexTheme::new(state.shell.capabilities); + let mut surface = ChatSurfaceState::default(); + + let frame = surface.build_frame(&state, &theme, 28); + let history = line_texts(&frame.history_lines); + let preview = line_texts(&frame.preview_lines); + + assert!(history.is_empty()); + assert!(preview.iter().any(|line| line.contains("第1项"))); + assert!(preview.iter().any(|line| line.contains("第6项"))); + + let second = surface.build_frame(&state, &theme, 28); + assert!(second.history_lines.is_empty()); + assert_eq!(line_texts(&second.preview_lines), preview); + } + + #[test] + fn completing_streaming_assistant_only_commits_remaining_tail_once() { + let mut state = CliState::new("http://127.0.0.1:5529".to_string(), None, capabilities()); + state.conversation.transcript = vec![assistant_block( + "assistant-1", + AstrcodeConversationBlockStatusDto::Streaming, + "前言\n\n- 第一项\n- 第二项\n第5行\n第6行\n第7行\n第8行\n第9行\n第10行", + )]; + let theme = CodexTheme::new(state.shell.capabilities); + let mut surface = ChatSurfaceState::default(); + + let _ = surface.build_frame(&state, &theme, 80); + + state.conversation.transcript = vec![assistant_block( + "assistant-1", + AstrcodeConversationBlockStatusDto::Complete, + "前言\n\n- 第一项\n- 第二项\n第5行\n第6行\n第7行\n第8行\n第9行\n第10行", + )]; + + let completed = surface.build_frame(&state, &theme, 80); + let history = line_texts(&completed.history_lines); + + assert!(history.iter().any(|line| line.contains("前言"))); + assert!(history.iter().any(|line| line.contains("- 第一项"))); + assert!(history.iter().any(|line| line.contains("- 第二项"))); + assert!(history.iter().any(|line| line.contains("第10行"))); + assert!(completed.preview_lines.is_empty()); + + let repeated = surface.build_frame(&state, &theme, 80); + assert!(repeated.history_lines.is_empty()); + } +} diff --git a/crates/cli/src/command/mod.rs b/crates/cli/src/command/mod.rs index d88a2d82..c3aa2f0a 100644 --- a/crates/cli/src/command/mod.rs +++ b/crates/cli/src/command/mod.rs @@ -2,7 +2,7 @@ use astrcode_client::{ AstrcodeConversationSlashActionKindDto, AstrcodeConversationSlashCandidateDto, }; -use crate::state::OverlaySelection; +use crate::state::PaletteSelection; #[derive(Debug, Clone, PartialEq, Eq)] pub enum InputAction { @@ -14,20 +14,37 @@ pub enum InputAction { #[derive(Debug, Clone, PartialEq, Eq)] pub enum Command { New, - Resume { query: Option<String> }, + Resume { + query: Option<String>, + }, + Model { + query: Option<String>, + }, + Mode { + query: Option<String>, + }, Compact, - Skill { query: Option<String> }, - Unknown { raw: String }, + SkillInvoke { + skill_id: String, + prompt: Option<String>, + }, + Unknown { + raw: String, + }, } #[derive(Debug, Clone, PartialEq, Eq)] -pub enum OverlayAction { +pub enum PaletteAction { SwitchSession { session_id: String }, ReplaceInput { text: String }, + SelectModel { profile_name: String, model: String }, RunCommand(Command), } -pub fn classify_input(input: &str) -> InputAction { +pub fn classify_input( + input: String, + slash_candidates: &[AstrcodeConversationSlashCandidateDto], +) -> InputAction { let trimmed = input.trim(); if trimmed.is_empty() { return InputAction::Empty; @@ -39,24 +56,41 @@ pub fn classify_input(input: &str) -> InputAction { }; } - InputAction::RunCommand(parse_command(trimmed)) + InputAction::RunCommand(parse_command(trimmed, slash_candidates)) } -pub fn overlay_action(selection: OverlaySelection) -> OverlayAction { +pub fn fuzzy_contains(query: &str, fields: impl IntoIterator<Item = String>) -> bool { + let query = query.trim().to_lowercase(); + if query.is_empty() { + return true; + } + fields + .into_iter() + .any(|field| field.to_lowercase().contains(&query)) +} + +pub fn palette_action(selection: PaletteSelection) -> PaletteAction { match selection { - OverlaySelection::ResumeSession(session_id) => OverlayAction::SwitchSession { session_id }, - OverlaySelection::SlashCandidate(candidate) => match candidate.action_kind { - AstrcodeConversationSlashActionKindDto::InsertText => OverlayAction::ReplaceInput { + PaletteSelection::ResumeSession(session_id) => PaletteAction::SwitchSession { session_id }, + PaletteSelection::ModelOption(option) => PaletteAction::SelectModel { + profile_name: option.profile_name, + model: option.model, + }, + PaletteSelection::SlashCandidate(candidate) => match candidate.action_kind { + AstrcodeConversationSlashActionKindDto::InsertText => PaletteAction::ReplaceInput { text: candidate.action_value, }, AstrcodeConversationSlashActionKindDto::ExecuteCommand => { - OverlayAction::RunCommand(parse_command(candidate.action_value.as_str())) + PaletteAction::RunCommand(parse_command(candidate.action_value.as_str(), &[])) }, }, } } -pub fn parse_command(command: &str) -> Command { +pub fn parse_command( + command: &str, + slash_candidates: &[AstrcodeConversationSlashCandidateDto], +) -> Command { let trimmed = command.trim(); let mut parts = trimmed.splitn(2, char::is_whitespace); let head = parts.next().unwrap_or_default(); @@ -69,8 +103,25 @@ pub fn parse_command(command: &str) -> Command { match head { "/new" => Command::New, "/resume" => Command::Resume { query: tail }, + "/model" => Command::Model { query: tail }, + "/mode" => Command::Mode { query: tail }, "/compact" => Command::Compact, - "/skill" => Command::Skill { query: tail }, + _ if head.starts_with('/') => { + let skill_id = head.trim_start_matches('/'); + if slash_candidates.iter().any(|candidate| { + candidate.action_kind == AstrcodeConversationSlashActionKindDto::InsertText + && candidate.action_value == format!("/{skill_id}") + }) { + Command::SkillInvoke { + skill_id: skill_id.to_string(), + prompt: tail, + } + } else { + Command::Unknown { + raw: trimmed.to_string(), + } + } + }, _ => Command::Unknown { raw: trimmed.to_string(), }, @@ -81,21 +132,16 @@ pub fn filter_slash_candidates( candidates: &[AstrcodeConversationSlashCandidateDto], query: &str, ) -> Vec<AstrcodeConversationSlashCandidateDto> { - let query = query.trim().to_lowercase(); - if query.is_empty() { - return candidates.to_vec(); - } - candidates .iter() .filter(|candidate| { - candidate.id.to_lowercase().contains(&query) - || candidate.title.to_lowercase().contains(&query) - || candidate.description.to_lowercase().contains(&query) - || candidate - .keywords - .iter() - .any(|keyword| keyword.to_lowercase().contains(&query)) + fuzzy_contains( + query, + std::iter::once(candidate.id.clone()) + .chain(std::iter::once(candidate.title.clone())) + .chain(std::iter::once(candidate.description.clone())) + .chain(candidate.keywords.iter().cloned()), + ) }) .cloned() .collect() @@ -107,28 +153,61 @@ mod tests { #[test] fn parses_built_in_commands() { - assert_eq!(parse_command("/new"), Command::New); + assert_eq!(parse_command("/new", &[]), Command::New); assert_eq!( - parse_command("/resume terminal"), + parse_command("/resume terminal", &[]), Command::Resume { query: Some("terminal".to_string()) } ); assert_eq!( - parse_command("/skill review"), - Command::Skill { + parse_command("/model claude", &[]), + Command::Model { + query: Some("claude".to_string()) + } + ); + assert_eq!( + parse_command("/mode review", &[]), + Command::Mode { query: Some("review".to_string()) } ); + assert_eq!( + parse_command( + "/review 修复失败测试", + &[AstrcodeConversationSlashCandidateDto { + id: "review".to_string(), + title: "Review".to_string(), + description: "Review current changes".to_string(), + keywords: vec!["review".to_string()], + action_kind: AstrcodeConversationSlashActionKindDto::InsertText, + action_value: "/review".to_string(), + }] + ), + Command::SkillInvoke { + skill_id: "review".to_string(), + prompt: Some("修复失败测试".to_string()) + } + ); } #[test] fn classifies_plain_prompt_without_command_semantics() { assert_eq!( - classify_input("实现 terminal v1"), + classify_input("实现 terminal v1".to_string(), &[]), InputAction::SubmitPrompt { text: "实现 terminal v1".to_string() } ); } + + #[test] + fn unknown_slash_command_stays_unknown_when_skill_is_not_visible() { + assert_eq!( + parse_command("/review 修复失败测试", &[]), + Command::Unknown { + raw: "/review 修复失败测试".to_string() + } + ); + } } diff --git a/crates/cli/src/launcher/mod.rs b/crates/cli/src/launcher/mod.rs index c30c1de0..b4e997e3 100644 --- a/crates/cli/src/launcher/mod.rs +++ b/crates/cli/src/launcher/mod.rs @@ -5,7 +5,7 @@ use std::{ fs, path::{Path, PathBuf}, process::Stdio, - sync::{Arc, Mutex}, + sync::{Arc, Mutex, OnceLock}, time::Duration, }; @@ -548,29 +548,34 @@ fn named_binary_exists_in_dir(directory: &Path, binary: &Path) -> bool { } fn windows_binary_extensions() -> Vec<String> { - env::var_os("PATHEXT") - .map(|raw| { - raw.to_string_lossy() - .split(';') - .filter_map(|item| { - let trimmed = item.trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed.to_ascii_lowercase()) - } + static WINDOWS_BINARY_EXTENSIONS: OnceLock<Vec<String>> = OnceLock::new(); + WINDOWS_BINARY_EXTENSIONS + .get_or_init(|| { + env::var_os("PATHEXT") + .map(|raw| { + raw.to_string_lossy() + .split(';') + .filter_map(|item| { + let trimmed = item.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_ascii_lowercase()) + } + }) + .collect::<Vec<_>>() + }) + .filter(|extensions| !extensions.is_empty()) + .unwrap_or_else(|| { + vec![ + ".com".to_string(), + ".exe".to_string(), + ".bat".to_string(), + ".cmd".to_string(), + ] }) - .collect::<Vec<_>>() - }) - .filter(|extensions| !extensions.is_empty()) - .unwrap_or_else(|| { - vec![ - ".com".to_string(), - ".exe".to_string(), - ".bat".to_string(), - ".cmd".to_string(), - ] }) + .clone() } fn current_directory() -> Option<PathBuf> { diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index 7a3b7883..bdd88cc8 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -1,7 +1,10 @@ pub mod app; +pub mod bottom_pane; pub mod capability; +pub mod chat; pub mod command; pub mod launcher; pub mod render; pub mod state; +pub mod tui; pub mod ui; diff --git a/crates/cli/src/render/mod.rs b/crates/cli/src/render/mod.rs index 2acb5ad0..d425d5e4 100644 --- a/crates/cli/src/render/mod.rs +++ b/crates/cli/src/render/mod.rs @@ -1,359 +1 @@ -use ratatui::{ - Frame, - layout::{Constraint, Direction, Flex, Layout, Rect}, - widgets::{Block, Borders, Clear, Paragraph, Wrap}, -}; - -use crate::{ - state::CliState, - ui::{self, BottomPaneView, CodexTheme, ComposerPane, ThemePalette}, -}; - -pub fn render(frame: &mut Frame<'_>, state: &mut CliState) { - state.set_viewport_size(frame.area().width, frame.area().height); - - let header_height = ui::header_lines(state, frame.area().width).len() as u16; - let bottom_height = ComposerPane::new(state) - .desired_height(frame.area().width) - .clamp( - 5, - frame.area().height.saturating_sub(header_height + 2).max(5), - ); - - let layout = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(header_height), - Constraint::Min(1), - Constraint::Length(bottom_height), - ]) - .split(frame.area()); - - let theme = CodexTheme::new(state.shell.capabilities); - - frame.render_widget( - Paragraph::new( - ui::header_lines(state, layout[0].width) - .iter() - .map(|line| ui::line_to_ratatui(line, state.shell.capabilities)) - .collect::<Vec<_>>(), - ) - .wrap(Wrap { trim: false }), - layout[0], - ); - - let side_pane_visible = - !state.conversation.child_summaries.is_empty() && layout[1].width >= 105; - let body = if side_pane_visible { - Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Min(1), - Constraint::Length(1), - Constraint::Length(30), - ]) - .split(layout[1]) - } else { - Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Min(1)]) - .split(layout[1]) - }; - - render_transcript(frame, state, body[0]); - if side_pane_visible { - render_vertical_divider(frame, body[1], &theme); - render_side_pane(frame, state, body[2]); - } - - render_bottom_pane(frame, state, layout[2], &ComposerPane::new(state), &theme); - - if let Some(title) = ui::overlay_title(&state.interaction.overlay) { - let overlay_lines = - ui::centered_overlay_lines(state, frame.area().width.saturating_sub(20)); - let overlay_height = overlay_lines.len().saturating_add(2).clamp(6, 22) as u16; - let overlay_area = centered_rect(72, overlay_height, frame.area()); - frame.render_widget(Clear, overlay_area); - frame.render_widget( - Paragraph::new( - overlay_lines - .iter() - .map(|line| ui::line_to_ratatui(line, state.shell.capabilities)) - .collect::<Vec<_>>(), - ) - .block( - Block::default() - .title(title) - .borders(Borders::ALL) - .border_style(theme.overlay_border_style()), - ) - .wrap(Wrap { trim: false }), - overlay_area, - ); - } -} - -fn render_transcript(frame: &mut Frame<'_>, state: &mut CliState, area: Rect) { - let transcript_width = area.width; - let transcript_lines = if state.render.transcript_cache.width == transcript_width - && state.render.transcript_cache.revision == state.render.transcript_revision - { - state.render.transcript_cache.lines.clone() - } else { - let lines = ui::transcript_lines(state, transcript_width); - state.update_transcript_cache(transcript_width, lines.clone()); - lines - }; - - let scroll = transcript_scroll_offset( - transcript_lines.len(), - area.height, - state.interaction.scroll_anchor, - state.interaction.follow_transcript_tail, - ); - - frame.render_widget( - Paragraph::new( - transcript_lines - .iter() - .map(|line| ui::line_to_ratatui(line, state.shell.capabilities)) - .collect::<Vec<_>>(), - ) - .wrap(Wrap { trim: false }) - .scroll((scroll, 0)), - area, - ); -} - -fn render_side_pane(frame: &mut Frame<'_>, state: &CliState, area: Rect) { - frame.render_widget( - Paragraph::new( - ui::child_pane_lines(state, area.width) - .iter() - .map(|line| ui::line_to_ratatui(line, state.shell.capabilities)) - .collect::<Vec<_>>(), - ) - .wrap(Wrap { trim: false }), - area, - ); -} - -fn render_bottom_pane( - frame: &mut Frame<'_>, - state: &CliState, - area: Rect, - composer: &ComposerPane<'_>, - theme: &CodexTheme, -) { - frame.render_widget( - Paragraph::new( - composer - .lines(area.width) - .iter() - .map(|line| ui::line_to_ratatui(line, state.shell.capabilities)) - .collect::<Vec<_>>(), - ) - .wrap(Wrap { trim: false }) - .style(theme.muted_block_style()), - area, - ); -} - -fn render_vertical_divider(frame: &mut Frame<'_>, area: Rect, theme: &CodexTheme) { - let line = theme - .vertical_divider() - .repeat(usize::from(area.height.max(1))); - let divider = line - .chars() - .map(|glyph| glyph.to_string()) - .collect::<Vec<_>>() - .join("\n"); - frame.render_widget( - Paragraph::new(divider).style(theme.line_style(crate::state::WrappedLineStyle::Border)), - area, - ); -} - -fn transcript_scroll_offset( - total_lines: usize, - viewport_height: u16, - anchor_from_bottom: u16, - follow_tail: bool, -) -> u16 { - let max_scroll = total_lines.saturating_sub(usize::from(viewport_height)); - let top_offset = if follow_tail { - max_scroll - } else { - max_scroll.saturating_sub(usize::from(anchor_from_bottom)) - }; - top_offset.try_into().unwrap_or(u16::MAX) -} - -fn centered_rect(width_percent: u16, height: u16, area: Rect) -> Rect { - let vertical = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(area.height.saturating_sub(height) / 2), - Constraint::Length(height), - Constraint::Min(0), - ]) - .split(area); - - Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(width_percent)]) - .flex(Flex::Center) - .split(vertical[1])[0] -} - -#[cfg(test)] -mod tests { - use astrcode_client::{ - AstrcodeConversationAgentLifecycleDto, AstrcodeConversationSlashActionKindDto, - AstrcodeConversationSlashCandidateDto, - }; - use ratatui::{Terminal, backend::TestBackend}; - - use super::render; - use crate::{ - capability::{ColorLevel, GlyphMode, TerminalCapabilities}, - state::CliState, - }; - - fn capabilities(glyphs: GlyphMode) -> TerminalCapabilities { - TerminalCapabilities { - color: ColorLevel::Ansi16, - glyphs, - alt_screen: false, - mouse: false, - bracketed_paste: false, - } - } - - #[test] - fn renders_workspace_scaffold() { - let backend = TestBackend::new(100, 30); - let mut terminal = Terminal::new(backend).expect("terminal"); - let mut state = CliState::new( - "http://127.0.0.1:5529".to_string(), - None, - capabilities(GlyphMode::Unicode), - ); - - terminal - .draw(|frame| render(frame, &mut state)) - .expect("draw"); - let text = terminal - .backend() - .buffer() - .content - .iter() - .map(|cell| cell.symbol()) - .collect::<String>(); - assert!(text.contains("Astrcode workspace")); - assert!(text.contains("Find and fix a bug in @filename")); - } - - #[test] - fn renders_ascii_dividers_in_ascii_mode() { - let backend = TestBackend::new(80, 24); - let mut terminal = Terminal::new(backend).expect("terminal"); - let mut state = CliState::new( - "http://127.0.0.1:5529".to_string(), - None, - capabilities(GlyphMode::Ascii), - ); - - terminal - .draw(|frame| render(frame, &mut state)) - .expect("draw"); - let text = terminal - .backend() - .buffer() - .content - .iter() - .map(|cell| cell.symbol()) - .collect::<String>(); - assert!(text.contains("-")); - assert!(text.contains(">")); - } - - #[test] - fn renders_child_agents_side_pane() { - let backend = TestBackend::new(120, 30); - let mut terminal = Terminal::new(backend).expect("terminal"); - let mut state = CliState::new( - "http://127.0.0.1:5529".to_string(), - None, - capabilities(GlyphMode::Unicode), - ); - state.conversation.child_summaries.push( - astrcode_client::AstrcodeConversationChildSummaryDto { - child_session_id: "child-1".to_string(), - child_agent_id: "agent-1".to_string(), - title: "Repo inspector".to_string(), - lifecycle: AstrcodeConversationAgentLifecycleDto::Running, - latest_output_summary: Some("checking repository layout".to_string()), - child_ref: None, - }, - ); - - terminal - .draw(|frame| render(frame, &mut state)) - .expect("draw"); - let text = terminal - .backend() - .buffer() - .content - .iter() - .map(|cell| cell.symbol()) - .collect::<String>(); - assert!(text.contains("child sessions")); - assert!(text.contains("Repo inspector")); - } - - #[test] - fn renders_embedded_command_palette_in_bottom_pane() { - let backend = TestBackend::new(100, 30); - let mut terminal = Terminal::new(backend).expect("terminal"); - let mut state = CliState::new( - "http://127.0.0.1:5529".to_string(), - None, - capabilities(GlyphMode::Unicode), - ); - state.set_slash_query( - "review", - vec![AstrcodeConversationSlashCandidateDto { - id: "review".to_string(), - title: "Review current changes".to_string(), - description: "对当前工作区变更运行 review".to_string(), - keywords: vec!["review".to_string()], - action_kind: AstrcodeConversationSlashActionKindDto::ExecuteCommand, - action_value: "/review".to_string(), - }], - ); - - terminal - .draw(|frame| render(frame, &mut state)) - .expect("draw"); - let text = terminal - .backend() - .buffer() - .content - .iter() - .map(|cell| cell.symbol()) - .collect::<String>(); - assert!(text.contains("commands")); - assert!(text.contains("Review current changes")); - } - - #[test] - fn transcript_scroll_offset_pins_tail_when_follow_is_enabled() { - assert_eq!(super::transcript_scroll_offset(48, 10, 3, true), 38); - } - - #[test] - fn transcript_scroll_offset_uses_anchor_when_follow_is_disabled() { - assert_eq!(super::transcript_scroll_offset(48, 10, 3, false), 35); - } -} +pub mod wrap; diff --git a/crates/cli/src/render/wrap.rs b/crates/cli/src/render/wrap.rs new file mode 100644 index 00000000..8e96c3c5 --- /dev/null +++ b/crates/cli/src/render/wrap.rs @@ -0,0 +1,71 @@ +use std::borrow::Cow; + +use textwrap::{Options, WordSeparator, wrap}; +use unicode_segmentation::UnicodeSegmentation; + +pub fn wrap_plain_text(text: &str, width: usize) -> Vec<String> { + let width = width.max(1); + let mut lines = Vec::new(); + for raw_line in text.split('\n') { + if raw_line.trim().is_empty() { + lines.push(String::new()); + continue; + } + + let options = if looks_like_path_or_url(raw_line) { + base_options(width).break_words(true) + } else { + base_options(width) + }; + + let wrapped = wrap(raw_line, options); + if wrapped.is_empty() { + lines.push(String::new()); + } else { + lines.extend(wrapped.into_iter().map(normalize_line)); + } + } + + if lines.is_empty() { + lines.push(String::new()); + } + lines +} + +fn base_options(width: usize) -> Options<'static> { + Options::new(width) + .word_separator(WordSeparator::AsciiSpace) + .break_words(false) +} + +fn looks_like_path_or_url(line: &str) -> bool { + line.contains("://") + || line.contains('\\') + || line.matches('/').count() >= 2 + || line + .split_word_bounds() + .any(|segment| segment.contains('.') && segment.len() > 12) +} + +fn normalize_line(line: Cow<'_, str>) -> String { + line.trim_end_matches([' ', '\t']).to_string() +} + +#[cfg(test)] +mod tests { + use super::wrap_plain_text; + + #[test] + fn wraps_chinese_and_keeps_whitespace_only_line_as_single_row() { + let wrapped = wrap_plain_text("第一行\n\n这一段非常长,需要正确折行。", 8); + assert!(wrapped.len() >= 3); + assert!(wrapped.iter().any(|line| line.is_empty())); + } + + #[test] + fn wraps_long_path_without_dropping_tail() { + let wrapped = wrap_plain_text("C:/very/long/path/to/a/really-large-file-name.txt", 12); + assert!(wrapped.len() >= 2); + assert!(wrapped.last().is_some_and(|line| line.contains("txt"))); + } +} diff --git a/crates/cli/src/state/conversation.rs b/crates/cli/src/state/conversation.rs index cda85cec..2730400a 100644 --- a/crates/cli/src/state/conversation.rs +++ b/crates/cli/src/state/conversation.rs @@ -1,3 +1,5 @@ +use std::collections::{BTreeSet, HashMap}; + use astrcode_client::{ AstrcodeConversationBannerDto, AstrcodeConversationBlockDto, AstrcodeConversationBlockPatchDto, AstrcodeConversationBlockStatusDto, AstrcodeConversationChildSummaryDto, @@ -7,7 +9,7 @@ use astrcode_client::{ AstrcodeConversationStreamEnvelopeDto, AstrcodePhaseDto, AstrcodeSessionListItem, }; -use super::{ChildPaneState, RenderState, TranscriptCell}; +use super::{RenderState, TranscriptCell}; #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct ConversationState { @@ -17,7 +19,7 @@ pub struct ConversationState { pub cursor: Option<AstrcodeConversationCursorDto>, pub control: Option<AstrcodeConversationControlStateDto>, pub transcript: Vec<AstrcodeConversationBlockDto>, - pub transcript_cells: Vec<TranscriptCell>, + pub transcript_index: HashMap<String, usize>, pub child_summaries: Vec<AstrcodeConversationChildSummaryDto>, pub slash_candidates: Vec<AstrcodeConversationSlashCandidateDto>, pub banner: Option<AstrcodeConversationBannerDto>, @@ -38,21 +40,21 @@ impl ConversationState { self.cursor = Some(snapshot.cursor); self.control = Some(snapshot.control); self.transcript = snapshot.blocks; - self.rebuild_transcript_cells(); + self.rebuild_transcript_index(); self.child_summaries = snapshot.child_summaries; self.slash_candidates = snapshot.slash_candidates; self.banner = snapshot.banner; - render.invalidate_transcript_cache(); + render.mark_dirty(); } pub fn apply_stream_envelope( &mut self, envelope: AstrcodeConversationStreamEnvelopeDto, render: &mut RenderState, - child_pane: &mut ChildPaneState, - ) { + expanded_ids: &BTreeSet<String>, + ) -> bool { self.cursor = Some(envelope.cursor); - self.apply_delta(envelope.delta, render, child_pane); + self.apply_delta(envelope.delta, render, expanded_ids) } pub fn set_banner_error(&mut self, error: AstrcodeConversationErrorEnvelopeDto) { @@ -67,64 +69,52 @@ impl ConversationState { self.control.as_ref().map(|control| control.phase) } - pub fn selected_child_summary( - &self, - child_pane: &ChildPaneState, - ) -> Option<&AstrcodeConversationChildSummaryDto> { - self.child_summaries.get(child_pane.selected) - } - - pub fn focused_child_summary( - &self, - child_pane: &ChildPaneState, - ) -> Option<&AstrcodeConversationChildSummaryDto> { - let Some(child_session_id) = child_pane.focused_child_session_id.as_deref() else { - return self.selected_child_summary(child_pane); - }; - self.child_summaries - .iter() - .find(|summary| summary.child_session_id == child_session_id) - } - fn apply_delta( &mut self, delta: AstrcodeConversationDeltaDto, render: &mut RenderState, - child_pane: &mut ChildPaneState, - ) { + _expanded_ids: &BTreeSet<String>, + ) -> bool { match delta { AstrcodeConversationDeltaDto::AppendBlock { block } => { - self.transcript_cells - .push(TranscriptCell::from_block(&block)); self.transcript.push(block); - render.invalidate_transcript_cache(); + if let Some(block) = self.transcript.last() { + self.transcript_index + .insert(block_id_of(block).to_string(), self.transcript.len() - 1); + } + render.mark_dirty(); + false }, AstrcodeConversationDeltaDto::PatchBlock { block_id, patch } => { - if let Some((index, block)) = self - .transcript - .iter_mut() - .enumerate() - .find(|(_, block)| block_id_of(block) == block_id) - { - apply_block_patch(block, patch); - self.transcript_cells[index] = TranscriptCell::from_block(block); - render.invalidate_transcript_cache(); + if let Some((index, block)) = self.find_block_mut(block_id.as_str()) { + let changed = apply_block_patch(block, patch); + let _ = index; + if changed { + render.mark_dirty(); + } + } else { + debug_missing_block("patch", block_id.as_str()); } + false }, AstrcodeConversationDeltaDto::CompleteBlock { block_id, status } => { - if let Some((index, block)) = self - .transcript - .iter_mut() - .enumerate() - .find(|(_, block)| block_id_of(block) == block_id) - { - set_block_status(block, status); - self.transcript_cells[index] = TranscriptCell::from_block(block); - render.invalidate_transcript_cache(); + if let Some((index, block)) = self.find_block_mut(block_id.as_str()) { + let changed = set_block_status(block, status); + let _ = index; + if changed { + render.mark_dirty(); + } + } else { + debug_missing_block("complete", block_id.as_str()); } + false }, AstrcodeConversationDeltaDto::UpdateControlState { control } => { - self.control = Some(control); + if self.control.as_ref() != Some(&control) { + self.control = Some(control); + render.mark_dirty(); + } + false }, AstrcodeConversationDeltaDto::UpsertChildSummary { child } => { if let Some(existing) = self @@ -136,45 +126,70 @@ impl ConversationState { } else { self.child_summaries.push(child); } - if child_pane.selected >= self.child_summaries.len() - && !self.child_summaries.is_empty() - { - child_pane.selected = self.child_summaries.len() - 1; - } + false }, AstrcodeConversationDeltaDto::RemoveChildSummary { child_session_id } => { self.child_summaries .retain(|child| child.child_session_id != child_session_id); - if child_pane.selected >= self.child_summaries.len() { - child_pane.selected = self.child_summaries.len().saturating_sub(1); - } - if child_pane.focused_child_session_id.as_deref() == Some(child_session_id.as_str()) - { - child_pane.focused_child_session_id = None; - } + false }, AstrcodeConversationDeltaDto::ReplaceSlashCandidates { candidates } => { self.slash_candidates = candidates; + true }, AstrcodeConversationDeltaDto::SetBanner { banner } => { - self.banner = Some(banner); + if self.banner.as_ref() != Some(&banner) { + self.banner = Some(banner); + render.mark_dirty(); + } + false }, AstrcodeConversationDeltaDto::ClearBanner => { - self.banner = None; + if self.banner.take().is_some() { + render.mark_dirty(); + } + false }, AstrcodeConversationDeltaDto::RehydrateRequired { error } => { self.set_banner_error(error); + false }, } } - fn rebuild_transcript_cells(&mut self) { - self.transcript_cells = self + fn rebuild_transcript_index(&mut self) { + self.transcript_index = self .transcript .iter() - .map(TranscriptCell::from_block) + .enumerate() + .map(|(index, block)| (block_id_of(block).to_string(), index)) .collect(); } + + fn find_block_mut( + &mut self, + block_id: &str, + ) -> Option<(usize, &mut AstrcodeConversationBlockDto)> { + let index = *self.transcript_index.get(block_id)?; + self.transcript.get_mut(index).map(|block| (index, block)) + } + + pub fn project_transcript_cells(&self, expanded_ids: &BTreeSet<String>) -> Vec<TranscriptCell> { + self.transcript + .iter() + .map(|block| TranscriptCell::from_block(block, expanded_ids)) + .collect() + } + + pub fn project_transcript_cell( + &self, + index: usize, + expanded_ids: &BTreeSet<String>, + ) -> Option<TranscriptCell> { + self.transcript + .get(index) + .map(|block| TranscriptCell::from_block(block, expanded_ids)) + } } fn block_id_of(block: &AstrcodeConversationBlockDto) -> &str { @@ -182,8 +197,8 @@ fn block_id_of(block: &AstrcodeConversationBlockDto) -> &str { AstrcodeConversationBlockDto::User(block) => &block.id, AstrcodeConversationBlockDto::Assistant(block) => &block.id, AstrcodeConversationBlockDto::Thinking(block) => &block.id, + AstrcodeConversationBlockDto::Plan(block) => &block.id, AstrcodeConversationBlockDto::ToolCall(block) => &block.id, - AstrcodeConversationBlockDto::ToolStream(block) => &block.id, AstrcodeConversationBlockDto::Error(block) => &block.id, AstrcodeConversationBlockDto::SystemNote(block) => &block.id, AstrcodeConversationBlockDto::ChildHandoff(block) => &block.id, @@ -193,54 +208,282 @@ fn block_id_of(block: &AstrcodeConversationBlockDto) -> &str { fn apply_block_patch( block: &mut AstrcodeConversationBlockDto, patch: AstrcodeConversationBlockPatchDto, -) { +) -> bool { match patch { AstrcodeConversationBlockPatchDto::AppendMarkdown { markdown } => match block { - AstrcodeConversationBlockDto::Assistant(block) => block.markdown.push_str(&markdown), - AstrcodeConversationBlockDto::Thinking(block) => block.markdown.push_str(&markdown), - AstrcodeConversationBlockDto::SystemNote(block) => block.markdown.push_str(&markdown), - AstrcodeConversationBlockDto::User(block) => block.markdown.push_str(&markdown), - AstrcodeConversationBlockDto::ToolStream(block) => block.content.push_str(&markdown), + AstrcodeConversationBlockDto::Assistant(block) => { + normalize_markdown_append(&mut block.markdown, &markdown) + }, + AstrcodeConversationBlockDto::Thinking(block) => { + normalize_markdown_append(&mut block.markdown, &markdown) + }, + AstrcodeConversationBlockDto::SystemNote(block) => { + normalize_markdown_append(&mut block.markdown, &markdown) + }, + AstrcodeConversationBlockDto::User(block) => { + normalize_markdown_append(&mut block.markdown, &markdown) + }, + AstrcodeConversationBlockDto::Plan(_) => false, AstrcodeConversationBlockDto::ToolCall(_) | AstrcodeConversationBlockDto::Error(_) - | AstrcodeConversationBlockDto::ChildHandoff(_) => {}, + | AstrcodeConversationBlockDto::ChildHandoff(_) => false, }, AstrcodeConversationBlockPatchDto::ReplaceMarkdown { markdown } => match block { - AstrcodeConversationBlockDto::Assistant(block) => block.markdown = markdown, - AstrcodeConversationBlockDto::Thinking(block) => block.markdown = markdown, - AstrcodeConversationBlockDto::SystemNote(block) => block.markdown = markdown, - AstrcodeConversationBlockDto::User(block) => block.markdown = markdown, - AstrcodeConversationBlockDto::ToolStream(block) => block.content = markdown, + AstrcodeConversationBlockDto::Assistant(block) => { + replace_if_changed(&mut block.markdown, markdown) + }, + AstrcodeConversationBlockDto::Thinking(block) => { + replace_if_changed(&mut block.markdown, markdown) + }, + AstrcodeConversationBlockDto::SystemNote(block) => { + replace_if_changed(&mut block.markdown, markdown) + }, + AstrcodeConversationBlockDto::User(block) => { + replace_if_changed(&mut block.markdown, markdown) + }, + AstrcodeConversationBlockDto::Plan(_) => false, AstrcodeConversationBlockDto::ToolCall(_) | AstrcodeConversationBlockDto::Error(_) - | AstrcodeConversationBlockDto::ChildHandoff(_) => {}, + | AstrcodeConversationBlockDto::ChildHandoff(_) => false, }, - AstrcodeConversationBlockPatchDto::AppendToolStream { chunk, .. } => { - if let AstrcodeConversationBlockDto::ToolStream(block) = block { - block.content.push_str(&chunk); + AstrcodeConversationBlockPatchDto::AppendToolStream { stream, chunk } => { + if let AstrcodeConversationBlockDto::ToolCall(block) = block { + if enum_wire_name(&stream).as_deref() == Some("stderr") { + if chunk.is_empty() { + return false; + } + block.streams.stderr.push_str(&chunk); + } else { + if chunk.is_empty() { + return false; + } + block.streams.stdout.push_str(&chunk); + } + true + } else { + false } }, AstrcodeConversationBlockPatchDto::ReplaceSummary { summary } => { if let AstrcodeConversationBlockDto::ToolCall(block) = block { - block.summary = Some(summary); + replace_option_if_changed(&mut block.summary, summary) + } else { + false + } + }, + AstrcodeConversationBlockPatchDto::ReplaceMetadata { metadata } => { + if let AstrcodeConversationBlockDto::ToolCall(block) = block { + replace_option_if_changed(&mut block.metadata, metadata) + } else { + false + } + }, + AstrcodeConversationBlockPatchDto::ReplaceError { error } => { + if let AstrcodeConversationBlockDto::ToolCall(block) = block { + replace_if_changed(&mut block.error, error) + } else { + false + } + }, + AstrcodeConversationBlockPatchDto::ReplaceDuration { duration_ms } => { + if let AstrcodeConversationBlockDto::ToolCall(block) = block { + replace_option_if_changed(&mut block.duration_ms, duration_ms) + } else { + false + } + }, + AstrcodeConversationBlockPatchDto::ReplaceChildRef { child_ref } => { + if let AstrcodeConversationBlockDto::ToolCall(block) = block { + replace_option_if_changed(&mut block.child_ref, child_ref) + } else { + false + } + }, + AstrcodeConversationBlockPatchDto::SetTruncated { truncated } => { + if let AstrcodeConversationBlockDto::ToolCall(block) = block { + if block.truncated != truncated { + block.truncated = truncated; + true + } else { + false + } + } else { + false } }, AstrcodeConversationBlockPatchDto::SetStatus { status } => set_block_status(block, status), } } +fn normalize_markdown_append(current: &mut String, incoming: &str) -> bool { + if incoming.is_empty() { + return false; + } + + if current.is_empty() { + current.push_str(incoming); + return true; + } + + if incoming.starts_with(current.as_str()) { + if current != incoming { + *current = incoming.to_string(); + return true; + } + return false; + } + + if current.ends_with(incoming) { + return false; + } + + if let Some(overlap) = longest_suffix_prefix_overlap(current.as_str(), incoming) { + current.push_str(&incoming[overlap..]); + return overlap < incoming.len(); + } + + current.push_str(incoming); + true +} + +fn longest_suffix_prefix_overlap(current: &str, incoming: &str) -> Option<usize> { + let max_overlap = current.len().min(incoming.len()); + incoming + .char_indices() + .map(|(index, _)| index) + .chain(std::iter::once(incoming.len())) + .filter(|index| *index > 0 && *index <= max_overlap) + .rev() + .find(|index| current.ends_with(&incoming[..*index])) +} + +fn enum_wire_name<T>(value: &T) -> Option<String> +where + T: serde::Serialize, +{ + serde_json::to_value(value) + .ok()? + .as_str() + .map(|value| value.trim().to_string()) +} + +fn debug_missing_block(operation: &str, block_id: &str) { + #[cfg(debug_assertions)] + eprintln!("astrcode-cli: ignored {operation} delta for unknown block '{block_id}'"); +} + fn set_block_status( block: &mut AstrcodeConversationBlockDto, status: AstrcodeConversationBlockStatusDto, -) { +) -> bool { match block { - AstrcodeConversationBlockDto::Assistant(block) => block.status = status, - AstrcodeConversationBlockDto::Thinking(block) => block.status = status, - AstrcodeConversationBlockDto::ToolCall(block) => block.status = status, - AstrcodeConversationBlockDto::ToolStream(block) => block.status = status, + AstrcodeConversationBlockDto::Assistant(block) => { + replace_if_changed(&mut block.status, status) + }, + AstrcodeConversationBlockDto::Thinking(block) => { + replace_if_changed(&mut block.status, status) + }, + AstrcodeConversationBlockDto::Plan(_) => false, + AstrcodeConversationBlockDto::ToolCall(block) => { + replace_if_changed(&mut block.status, status) + }, AstrcodeConversationBlockDto::User(_) | AstrcodeConversationBlockDto::Error(_) | AstrcodeConversationBlockDto::SystemNote(_) - | AstrcodeConversationBlockDto::ChildHandoff(_) => {}, + | AstrcodeConversationBlockDto::ChildHandoff(_) => false, + } +} + +fn replace_if_changed<T: PartialEq>(slot: &mut T, next: T) -> bool { + if *slot == next { + false + } else { + *slot = next; + true + } +} + +fn replace_option_if_changed<T: PartialEq>(slot: &mut Option<T>, next: T) -> bool { + if slot.as_ref() == Some(&next) { + false + } else { + *slot = Some(next); + true + } +} + +#[cfg(test)] +mod tests { + use astrcode_client::{ + AstrcodeConversationAssistantBlockDto, AstrcodeConversationBlockDto, + AstrcodeConversationBlockPatchDto, AstrcodeConversationBlockStatusDto, + AstrcodeConversationCursorDto, AstrcodeConversationDeltaDto, + AstrcodeConversationStreamEnvelopeDto, + }; + + use super::{ConversationState, normalize_markdown_append}; + use crate::state::RenderState; + + #[test] + fn append_markdown_replaces_with_cumulative_body() { + let mut current = "你好".to_string(); + normalize_markdown_append(&mut current, "你好,世界"); + assert_eq!(current, "你好,世界"); + } + + #[test] + fn append_markdown_ignores_replayed_suffix() { + let mut current = "你好,世界".to_string(); + normalize_markdown_append(&mut current, "世界"); + assert_eq!(current, "你好,世界"); + } + + #[test] + fn append_markdown_appends_only_non_overlapping_suffix() { + let mut current = "你好,世".to_string(); + normalize_markdown_append(&mut current, "世界"); + assert_eq!(current, "你好,世界"); + } + + #[test] + fn append_markdown_keeps_true_incremental_append() { + let mut current = "你好".to_string(); + normalize_markdown_append(&mut current, ",世界"); + assert_eq!(current, "你好,世界"); + } + + #[test] + fn duplicate_markdown_replay_does_not_mark_surface_dirty() { + let mut conversation = ConversationState { + transcript: vec![AstrcodeConversationBlockDto::Assistant( + AstrcodeConversationAssistantBlockDto { + id: "assistant-1".to_string(), + turn_id: Some("turn-1".to_string()), + status: AstrcodeConversationBlockStatusDto::Streaming, + markdown: "你好,世界".to_string(), + }, + )], + transcript_index: [("assistant-1".to_string(), 0)].into_iter().collect(), + ..Default::default() + }; + let mut render = RenderState::default(); + render.take_frame_dirty(); + + conversation.apply_stream_envelope( + AstrcodeConversationStreamEnvelopeDto { + session_id: "session-1".to_string(), + cursor: AstrcodeConversationCursorDto("1.1".to_string()), + delta: AstrcodeConversationDeltaDto::PatchBlock { + block_id: "assistant-1".to_string(), + patch: AstrcodeConversationBlockPatchDto::AppendMarkdown { + markdown: "世界".to_string(), + }, + }, + }, + &mut render, + &Default::default(), + ); + + assert!(!render.take_frame_dirty()); } } diff --git a/crates/cli/src/state/interaction.rs b/crates/cli/src/state/interaction.rs index a07d1351..9a867d49 100644 --- a/crates/cli/src/state/interaction.rs +++ b/crates/cli/src/state/interaction.rs @@ -1,17 +1,21 @@ -use astrcode_client::{AstrcodeConversationSlashCandidateDto, AstrcodeSessionListItem}; +use std::collections::BTreeSet; + +use astrcode_client::{ + AstrcodeConversationSlashCandidateDto, AstrcodeModelOptionDto, AstrcodeSessionListItem, +}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum PaneFocus { - Transcript, - ChildPane, #[default] Composer, - Overlay, + Palette, + Browser, } #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct ComposerState { pub input: String, + pub cursor: usize, } impl ComposerState { @@ -22,40 +26,135 @@ impl ComposerState { pub fn is_empty(&self) -> bool { self.input.is_empty() } + + pub fn as_str(&self) -> &str { + self.input.as_str() + } + + pub fn insert_char(&mut self, ch: char) { + self.input.insert(self.cursor, ch); + self.cursor += ch.len_utf8(); + } + + pub fn insert_str(&mut self, value: &str) { + self.input.insert_str(self.cursor, value); + self.cursor += value.len(); + } + + pub fn insert_newline(&mut self) { + self.insert_char('\n'); + } + + pub fn backspace(&mut self) { + let Some(start) = previous_boundary(self.input.as_str(), self.cursor) else { + return; + }; + self.input.drain(start..self.cursor); + self.cursor = start; + } + + pub fn delete_forward(&mut self) { + let Some(end) = next_boundary(self.input.as_str(), self.cursor) else { + return; + }; + self.input.drain(self.cursor..end); + } + + pub fn move_left(&mut self) { + if let Some(cursor) = previous_boundary(self.input.as_str(), self.cursor) { + self.cursor = cursor; + } + } + + pub fn move_right(&mut self) { + if let Some(cursor) = next_boundary(self.input.as_str(), self.cursor) { + self.cursor = cursor; + } + } + + pub fn move_home(&mut self) { + let line_start = self + .input + .get(..self.cursor) + .and_then(|value| value.rfind('\n').map(|index| index + 1)) + .unwrap_or(0); + self.cursor = line_start; + } + + pub fn move_end(&mut self) { + let line_end = self + .input + .get(self.cursor..) + .and_then(|value| value.find('\n').map(|index| self.cursor + index)) + .unwrap_or(self.input.len()); + self.cursor = line_end; + } + + pub fn replace(&mut self, input: impl Into<String>) { + self.input = input.into(); + self.cursor = self.input.len(); + } + + pub fn take(&mut self) -> String { + self.cursor = 0; + std::mem::take(&mut self.input) + } } -#[derive(Debug, Clone, PartialEq, Eq, Default)] -pub struct ResumeOverlayState { - pub query: String, - pub items: Vec<AstrcodeSessionListItem>, - pub selected: usize, +fn previous_boundary(input: &str, cursor: usize) -> Option<usize> { + if cursor == 0 { + return None; + } + input + .get(..cursor)? + .char_indices() + .last() + .map(|(index, _)| index) } -#[derive(Debug, Clone, PartialEq, Eq, Default)] +fn next_boundary(input: &str, cursor: usize) -> Option<usize> { + if cursor >= input.len() { + return None; + } + let ch = input.get(cursor..)?.chars().next()?; + Some(cursor + ch.len_utf8()) +} + +#[derive(Debug, Clone, PartialEq, Eq)] pub struct SlashPaletteState { pub query: String, pub items: Vec<AstrcodeConversationSlashCandidateDto>, pub selected: usize, } -#[derive(Debug, Clone, PartialEq, Eq, Default)] -pub struct DebugOverlayState { - pub scroll: usize, +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResumePaletteState { + pub query: String, + pub items: Vec<AstrcodeSessionListItem>, + pub selected: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ModelPaletteState { + pub query: String, + pub items: Vec<AstrcodeModelOptionDto>, + pub selected: usize, } #[derive(Debug, Clone, PartialEq, Eq, Default)] -pub enum OverlayState { +pub enum PaletteState { #[default] - None, - Resume(ResumeOverlayState), - SlashPalette(SlashPaletteState), - DebugLogs(DebugOverlayState), + Closed, + Slash(SlashPaletteState), + Resume(ResumePaletteState), + Model(ModelPaletteState), } #[derive(Debug, Clone, PartialEq, Eq)] -pub enum OverlaySelection { +pub enum PaletteSelection { ResumeSession(String), SlashCandidate(AstrcodeConversationSlashCandidateDto), + ModelOption(AstrcodeModelOptionDto), } #[derive(Debug, Clone, PartialEq, Eq)] @@ -74,34 +173,27 @@ impl Default for StatusLine { } #[derive(Debug, Clone, PartialEq, Eq, Default)] -pub struct ChildPaneState { - pub selected: usize, - pub focused_child_session_id: Option<String>, +pub struct TranscriptState { + pub selected_cell: usize, + pub expanded_cells: BTreeSet<String>, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct BrowserState { + pub open: bool, + pub selected_cell: usize, + pub last_seen_cell_count: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct InteractionState { pub status: StatusLine, - pub scroll_anchor: u16, - pub follow_transcript_tail: bool, pub pane_focus: PaneFocus, + pub last_non_palette_focus: PaneFocus, pub composer: ComposerState, - pub overlay: OverlayState, - pub child_pane: ChildPaneState, -} - -impl Default for InteractionState { - fn default() -> Self { - Self { - status: StatusLine::default(), - scroll_anchor: 0, - follow_transcript_tail: true, - pane_focus: PaneFocus::default(), - composer: ComposerState::default(), - overlay: OverlayState::default(), - child_pane: ChildPaneState::default(), - } - } + pub palette: PaletteState, + pub transcript: TranscriptState, + pub browser: BrowserState, } impl InteractionState { @@ -120,121 +212,192 @@ impl InteractionState { } pub fn push_input(&mut self, ch: char) { - self.composer.input.push(ch); + self.set_focus(PaneFocus::Composer); + self.composer.insert_char(ch); } pub fn append_input(&mut self, value: &str) { - self.composer.input.push_str(value); + self.set_focus(PaneFocus::Composer); + self.composer.insert_str(value); } pub fn insert_newline(&mut self) { - self.composer.input.push('\n'); + self.set_focus(PaneFocus::Composer); + self.composer.insert_newline(); } pub fn pop_input(&mut self) { - self.composer.input.pop(); + self.composer.backspace(); + } + + pub fn delete_input(&mut self) { + self.composer.delete_forward(); + } + + pub fn move_cursor_left(&mut self) { + self.composer.move_left(); + } + + pub fn move_cursor_right(&mut self) { + self.composer.move_right(); + } + + pub fn move_cursor_home(&mut self) { + self.composer.move_home(); + } + + pub fn move_cursor_end(&mut self) { + self.composer.move_end(); } pub fn replace_input(&mut self, input: impl Into<String>) { - self.composer.input = input.into(); + self.set_focus(PaneFocus::Composer); + self.composer.replace(input); } pub fn take_input(&mut self) -> String { - std::mem::take(&mut self.composer.input) + self.composer.take() } - pub fn scroll_up(&mut self) { - self.follow_transcript_tail = false; - self.scroll_anchor = self.scroll_anchor.saturating_add(1); + pub fn cycle_focus_forward(&mut self) { + self.set_focus(match self.pane_focus { + PaneFocus::Composer => PaneFocus::Composer, + PaneFocus::Palette => PaneFocus::Palette, + PaneFocus::Browser => PaneFocus::Browser, + }); } - pub fn scroll_down(&mut self) { - self.scroll_anchor = self.scroll_anchor.saturating_sub(1); - self.follow_transcript_tail = self.scroll_anchor == 0; + pub fn cycle_focus_backward(&mut self) { + self.set_focus(match self.pane_focus { + PaneFocus::Composer => PaneFocus::Composer, + PaneFocus::Palette => PaneFocus::Palette, + PaneFocus::Browser => PaneFocus::Browser, + }); } - pub fn reset_scroll(&mut self) { - self.scroll_anchor = 0; - self.follow_transcript_tail = true; + pub fn set_focus(&mut self, focus: PaneFocus) { + self.pane_focus = focus; + if !matches!(focus, PaneFocus::Palette | PaneFocus::Browser) { + self.last_non_palette_focus = focus; + } } - pub fn cycle_focus_forward(&mut self, has_children: bool) { - self.pane_focus = match self.pane_focus { - PaneFocus::Transcript => { - if has_children { - PaneFocus::ChildPane - } else { - PaneFocus::Composer - } - }, - PaneFocus::ChildPane => PaneFocus::Composer, - PaneFocus::Composer => PaneFocus::Transcript, - PaneFocus::Overlay => PaneFocus::Overlay, - }; + pub fn transcript_next(&mut self, cell_count: usize) { + if cell_count == 0 { + return; + } + self.transcript.selected_cell = (self.transcript.selected_cell + 1) % cell_count; } - pub fn cycle_focus_backward(&mut self, has_children: bool) { - self.pane_focus = match self.pane_focus { - PaneFocus::Transcript => PaneFocus::Composer, - PaneFocus::ChildPane => PaneFocus::Transcript, - PaneFocus::Composer => { - if has_children { - PaneFocus::ChildPane - } else { - PaneFocus::Transcript - } - }, - PaneFocus::Overlay => PaneFocus::Overlay, - }; + pub fn transcript_prev(&mut self, cell_count: usize) { + if cell_count == 0 { + return; + } + self.transcript.selected_cell = + (self.transcript.selected_cell + cell_count - 1) % cell_count; } - pub fn child_next(&mut self, child_count: usize) { - if child_count == 0 { + pub fn sync_transcript_cells(&mut self, cell_count: usize) { + if cell_count == 0 { + self.transcript.selected_cell = 0; + self.transcript.expanded_cells.clear(); return; } - self.child_pane.selected = (self.child_pane.selected + 1) % child_count; + if self.transcript.selected_cell >= cell_count { + self.transcript.selected_cell = cell_count - 1; + } } - pub fn child_prev(&mut self, child_count: usize) { - if child_count == 0 { + pub fn toggle_cell_expanded(&mut self, cell_id: &str) { + if !self.transcript.expanded_cells.insert(cell_id.to_string()) { + self.transcript.expanded_cells.remove(cell_id); + } + } + + pub fn is_cell_expanded(&self, cell_id: &str) -> bool { + self.transcript.expanded_cells.contains(cell_id) + } + + pub fn reset_for_snapshot(&mut self) { + self.palette = PaletteState::Closed; + self.transcript = TranscriptState::default(); + self.browser = BrowserState::default(); + self.set_focus(PaneFocus::Composer); + } + + pub fn open_browser(&mut self, cell_count: usize) { + self.browser.open = true; + if cell_count == 0 { + self.browser.selected_cell = 0; + } else if self.browser.selected_cell >= cell_count { + self.browser.selected_cell = cell_count - 1; + } + self.browser.last_seen_cell_count = cell_count; + self.transcript.selected_cell = self.browser.selected_cell; + self.pane_focus = PaneFocus::Browser; + } + + pub fn close_browser(&mut self) { + self.browser.open = false; + self.set_focus(PaneFocus::Composer); + } + + pub fn browser_next(&mut self, cell_count: usize, page_size: usize) { + if cell_count == 0 { return; } - self.child_pane.selected = (self.child_pane.selected + child_count - 1) % child_count; + self.open_browser(cell_count); + self.browser.selected_cell = + (self.browser.selected_cell + page_size.max(1)).min(cell_count - 1); + if self.browser.selected_cell == cell_count - 1 { + self.browser.last_seen_cell_count = cell_count; + } + self.transcript.selected_cell = self.browser.selected_cell; } - pub fn toggle_child_focus(&mut self, selected_child_session_id: Option<&str>) { - let Some(selected_child_session_id) = selected_child_session_id else { + pub fn browser_prev(&mut self, cell_count: usize, page_size: usize) { + if cell_count == 0 { return; - }; - if self.child_pane.focused_child_session_id.as_deref() == Some(selected_child_session_id) { - self.child_pane.focused_child_session_id = None; - } else { - self.child_pane.focused_child_session_id = Some(selected_child_session_id.to_string()); } + self.open_browser(cell_count); + self.browser.selected_cell = self.browser.selected_cell.saturating_sub(page_size.max(1)); + self.transcript.selected_cell = self.browser.selected_cell; } - pub fn reset_for_snapshot(&mut self) { - self.reset_scroll(); - self.overlay = OverlayState::None; - self.pane_focus = PaneFocus::Composer; - self.child_pane = ChildPaneState::default(); + pub fn browser_first(&mut self, cell_count: usize) { + if cell_count == 0 { + return; + } + self.open_browser(cell_count); + self.browser.selected_cell = 0; + self.transcript.selected_cell = 0; + } + + pub fn browser_last(&mut self, cell_count: usize) { + if cell_count == 0 { + return; + } + self.open_browser(cell_count); + self.browser.selected_cell = cell_count - 1; + self.browser.last_seen_cell_count = cell_count; + self.transcript.selected_cell = self.browser.selected_cell; } - pub fn set_resume_query( + pub fn set_resume_palette( &mut self, query: impl Into<String>, items: Vec<AstrcodeSessionListItem>, ) { - self.pane_focus = PaneFocus::Overlay; - self.overlay = OverlayState::Resume(ResumeOverlayState { + self.palette = PaletteState::Resume(ResumePaletteState { query: query.into(), items, selected: 0, }); + self.pane_focus = PaneFocus::Palette; } pub fn sync_resume_items(&mut self, items: Vec<AstrcodeSessionListItem>) { - if let OverlayState::Resume(resume) = &mut self.overlay { + if let PaletteState::Resume(resume) = &mut self.palette { resume.items = items; if resume.selected >= resume.items.len() { resume.selected = 0; @@ -242,21 +405,34 @@ impl InteractionState { } } - pub fn set_slash_query( + pub fn set_slash_palette( &mut self, query: impl Into<String>, items: Vec<AstrcodeConversationSlashCandidateDto>, ) { - self.pane_focus = PaneFocus::Overlay; - self.overlay = OverlayState::SlashPalette(SlashPaletteState { + self.palette = PaletteState::Slash(SlashPaletteState { query: query.into(), items, selected: 0, }); + self.pane_focus = PaneFocus::Palette; } - pub fn sync_slash_items(&mut self, items: Vec<AstrcodeConversationSlashCandidateDto>) { - if let OverlayState::SlashPalette(palette) = &mut self.overlay { + pub fn set_model_palette( + &mut self, + query: impl Into<String>, + items: Vec<AstrcodeModelOptionDto>, + ) { + self.palette = PaletteState::Model(ModelPaletteState { + query: query.into(), + items, + selected: 0, + }); + self.pane_focus = PaneFocus::Palette; + } + + pub fn sync_model_items(&mut self, items: Vec<AstrcodeModelOptionDto>) { + if let PaletteState::Model(palette) = &mut self.palette { palette.items = items; if palette.selected >= palette.items.len() { palette.selected = 0; @@ -264,100 +440,177 @@ impl InteractionState { } } - pub fn overlay_query_push(&mut self, ch: char) { - match &mut self.overlay { - OverlayState::Resume(resume) => resume.query.push(ch), - OverlayState::SlashPalette(palette) => palette.query.push(ch), - OverlayState::DebugLogs(_) => {}, - OverlayState::None => self.push_input(ch), - } - } - - pub fn overlay_query_append(&mut self, value: &str) { - match &mut self.overlay { - OverlayState::Resume(resume) => resume.query.push_str(value), - OverlayState::SlashPalette(palette) => palette.query.push_str(value), - OverlayState::DebugLogs(_) => {}, - OverlayState::None => self.append_input(value), - } - } - - pub fn overlay_query_pop(&mut self) { - match &mut self.overlay { - OverlayState::Resume(resume) => { - resume.query.pop(); - }, - OverlayState::SlashPalette(palette) => { - palette.query.pop(); - }, - OverlayState::DebugLogs(_) => {}, - OverlayState::None => self.pop_input(), + pub fn sync_slash_items(&mut self, items: Vec<AstrcodeConversationSlashCandidateDto>) { + if let PaletteState::Slash(palette) = &mut self.palette { + palette.items = items; + if palette.selected >= palette.items.len() { + palette.selected = 0; + } } } - pub fn close_overlay(&mut self) { - self.overlay = OverlayState::None; - self.pane_focus = PaneFocus::Composer; + pub fn close_palette(&mut self) { + self.palette = PaletteState::Closed; + self.set_focus(self.last_non_palette_focus); } - pub fn has_overlay(&self) -> bool { - !matches!(self.overlay, OverlayState::None) + pub fn has_palette(&self) -> bool { + !matches!(self.palette, PaletteState::Closed) } - pub fn overlay_next(&mut self) { - match &mut self.overlay { - OverlayState::Resume(resume) if !resume.items.is_empty() => { + pub fn palette_next(&mut self) { + match &mut self.palette { + PaletteState::Resume(resume) if !resume.items.is_empty() => { resume.selected = (resume.selected + 1) % resume.items.len(); }, - OverlayState::SlashPalette(palette) if !palette.items.is_empty() => { + PaletteState::Slash(palette) if !palette.items.is_empty() => { palette.selected = (palette.selected + 1) % palette.items.len(); }, - OverlayState::DebugLogs(debug) => { - debug.scroll = debug.scroll.saturating_add(1); + PaletteState::Model(palette) if !palette.items.is_empty() => { + palette.selected = (palette.selected + 1) % palette.items.len(); }, - _ => {}, + PaletteState::Closed + | PaletteState::Resume(_) + | PaletteState::Slash(_) + | PaletteState::Model(_) => {}, } } - pub fn overlay_prev(&mut self) { - match &mut self.overlay { - OverlayState::Resume(resume) if !resume.items.is_empty() => { + pub fn palette_prev(&mut self) { + match &mut self.palette { + PaletteState::Resume(resume) if !resume.items.is_empty() => { resume.selected = (resume.selected + resume.items.len().saturating_sub(1)) % resume.items.len(); }, - OverlayState::SlashPalette(palette) if !palette.items.is_empty() => { + PaletteState::Slash(palette) if !palette.items.is_empty() => { palette.selected = (palette.selected + palette.items.len().saturating_sub(1)) % palette.items.len(); }, - OverlayState::DebugLogs(debug) => { - debug.scroll = debug.scroll.saturating_sub(1); + PaletteState::Model(palette) if !palette.items.is_empty() => { + palette.selected = (palette.selected + palette.items.len().saturating_sub(1)) + % palette.items.len(); }, - _ => {}, + PaletteState::Closed + | PaletteState::Resume(_) + | PaletteState::Slash(_) + | PaletteState::Model(_) => {}, } } - pub fn selected_overlay(&self) -> Option<OverlaySelection> { - match &self.overlay { - OverlayState::Resume(resume) => resume + pub fn selected_palette(&self) -> Option<PaletteSelection> { + match &self.palette { + PaletteState::Resume(resume) => resume .items .get(resume.selected) - .map(|item| OverlaySelection::ResumeSession(item.session_id.clone())), - OverlayState::SlashPalette(palette) => palette + .map(|item| PaletteSelection::ResumeSession(item.session_id.clone())), + PaletteState::Slash(palette) => palette .items .get(palette.selected) .cloned() - .map(OverlaySelection::SlashCandidate), - OverlayState::DebugLogs(_) => None, - OverlayState::None => None, + .map(PaletteSelection::SlashCandidate), + PaletteState::Model(palette) => palette + .items + .get(palette.selected) + .cloned() + .map(PaletteSelection::ModelOption), + PaletteState::Closed => None, } } - pub fn toggle_debug_overlay(&mut self) { - if matches!(self.overlay, OverlayState::DebugLogs(_)) { - self.close_overlay(); - } else { - self.pane_focus = PaneFocus::Overlay; - self.overlay = OverlayState::DebugLogs(DebugOverlayState::default()); + pub fn clear_surface_state(&mut self) { + match self.pane_focus { + PaneFocus::Composer => { + self.status = StatusLine::default(); + self.transcript.expanded_cells.clear(); + }, + PaneFocus::Palette => self.close_palette(), + PaneFocus::Browser => self.close_browser(), } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tab_flow_cycles_two_surfaces() { + let mut state = InteractionState::default(); + state.cycle_focus_forward(); + assert_eq!(state.pane_focus, PaneFocus::Composer); + state.cycle_focus_forward(); + assert_eq!(state.pane_focus, PaneFocus::Composer); + } + + #[test] + fn close_palette_restores_previous_focus() { + let mut state = InteractionState::default(); + state.set_slash_palette("", Vec::new()); + assert_eq!(state.pane_focus, PaneFocus::Palette); + state.close_palette(); + assert_eq!(state.pane_focus, PaneFocus::Composer); + } + + #[test] + fn transcript_expansion_toggles_by_cell_id() { + let mut state = InteractionState::default(); + state.toggle_cell_expanded("assistant-1"); + assert!(state.is_cell_expanded("assistant-1")); + state.toggle_cell_expanded("assistant-1"); + assert!(!state.is_cell_expanded("assistant-1")); + } + + #[test] + fn browser_open_close_and_navigation_track_selected_cell() { + let mut state = InteractionState::default(); + state.open_browser(4); + assert!(state.browser.open); + assert_eq!(state.pane_focus, PaneFocus::Browser); + + state.browser_next(4, 1); + assert_eq!(state.browser.selected_cell, 1); + state.browser_next(4, 5); + assert_eq!(state.browser.selected_cell, 3); + state.browser_prev(4, 2); + assert_eq!(state.browser.selected_cell, 1); + state.browser_first(4); + assert_eq!(state.browser.selected_cell, 0); + state.browser_last(4); + assert_eq!(state.browser.selected_cell, 3); + + state.close_browser(); + assert!(!state.browser.open); + assert_eq!(state.pane_focus, PaneFocus::Composer); + } + + #[test] + fn composer_backspace_respects_cursor_position() { + let mut state = InteractionState::default(); + state.replace_input("abcd"); + state.move_cursor_left(); + state.move_cursor_left(); + state.pop_input(); + assert_eq!(state.composer.as_str(), "acd"); + assert_eq!(state.composer.cursor, 1); + } + + #[test] + fn composer_delete_forward_respects_cursor_position() { + let mut state = InteractionState::default(); + state.replace_input("abcd"); + state.move_cursor_left(); + state.move_cursor_left(); + state.delete_input(); + assert_eq!(state.composer.as_str(), "abd"); + assert_eq!(state.composer.cursor, 2); + } + + #[test] + fn transcript_navigation_updates_selected_cell() { + let mut state = InteractionState::default(); + state.transcript_next(4); + assert_eq!(state.transcript.selected_cell, 1); + state.transcript_prev(4); + assert_eq!(state.transcript.selected_cell, 0); + } +} diff --git a/crates/cli/src/state/mod.rs b/crates/cli/src/state/mod.rs index 430ce759..31dbcc9e 100644 --- a/crates/cli/src/state/mod.rs +++ b/crates/cli/src/state/mod.rs @@ -3,23 +3,29 @@ mod debug; mod interaction; mod render; mod shell; +mod thinking; mod transcript_cell; use std::{path::PathBuf, time::Duration}; use astrcode_client::{ - AstrcodeConversationChildSummaryDto, AstrcodeConversationErrorEnvelopeDto, - AstrcodeConversationSlashCandidateDto, AstrcodeConversationSnapshotResponseDto, - AstrcodeConversationStreamEnvelopeDto, AstrcodePhaseDto, AstrcodeSessionListItem, + AstrcodeConversationErrorEnvelopeDto, AstrcodeConversationSlashCandidateDto, + AstrcodeConversationSnapshotResponseDto, AstrcodeConversationStreamEnvelopeDto, + AstrcodeCurrentModelInfoDto, AstrcodeModeSummaryDto, AstrcodeModelOptionDto, AstrcodePhaseDto, + AstrcodeSessionListItem, }; pub use conversation::ConversationState; pub use debug::DebugChannelState; pub use interaction::{ - ChildPaneState, ComposerState, DebugOverlayState, InteractionState, OverlaySelection, - OverlayState, PaneFocus, ResumeOverlayState, SlashPaletteState, StatusLine, + ComposerState, InteractionState, PaletteSelection, PaletteState, PaneFocus, ResumePaletteState, + SlashPaletteState, StatusLine, +}; +pub use render::{ + ActiveOverlay, RenderState, StreamViewState, WrappedLine, WrappedLineRewrapPolicy, + WrappedLineStyle, WrappedSpan, WrappedSpanStyle, }; -pub use render::{RenderState, StreamViewState, TranscriptRenderCache}; pub use shell::ShellState; +pub use thinking::{ThinkingPlaybackDriver, ThinkingPresentationState, ThinkingSnippetPool}; pub use transcript_cell::{TranscriptCell, TranscriptCellKind, TranscriptCellStatus}; use crate::capability::TerminalCapabilities; @@ -31,27 +37,6 @@ pub enum StreamRenderMode { CatchUp, } -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct WrappedLine { - pub style: WrappedLineStyle, - pub content: String, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum WrappedLineStyle { - Plain, - Dim, - Accent, - Success, - Warning, - Error, - User, - Header, - Footer, - Selection, - Border, -} - #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct CliState { pub shell: ShellState, @@ -60,6 +45,8 @@ pub struct CliState { pub render: RenderState, pub stream_view: StreamViewState, pub debug: DebugChannelState, + pub thinking_pool: ThinkingSnippetPool, + pub thinking_playback: ThinkingPlaybackDriver, } impl CliState { @@ -70,104 +57,194 @@ impl CliState { ) -> Self { Self { shell: ShellState::new(connection_origin, working_dir, capabilities), + thinking_pool: ThinkingSnippetPool::default(), + thinking_playback: ThinkingPlaybackDriver::default(), ..Default::default() } } pub fn set_status(&mut self, message: impl Into<String>) { self.interaction.set_status(message); + self.render.mark_dirty(); } pub fn set_error_status(&mut self, message: impl Into<String>) { self.interaction.set_error_status(message); + self.render.mark_dirty(); } - pub fn set_stream_mode(&mut self, mode: StreamRenderMode, pending: usize, oldest: Duration) { + pub fn set_stream_mode( + &mut self, + mode: StreamRenderMode, + pending: usize, + oldest: Duration, + ) -> bool { + let changed = self.stream_view.mode != mode || self.stream_view.pending_chunks != pending; self.stream_view.update(mode, pending, oldest); + changed } - pub fn set_viewport_size(&mut self, width: u16, height: u16) { - self.render.set_viewport_size(width, height); - } - - pub fn update_transcript_cache(&mut self, width: u16, lines: Vec<WrappedLine>) { - self.render.update_transcript_cache(width, lines); + pub fn note_terminal_resize(&mut self, width: u16, height: u16) { + self.render.note_terminal_resize(width, height); } pub fn push_input(&mut self, ch: char) { self.interaction.push_input(ch); + self.render.mark_dirty(); } pub fn append_input(&mut self, value: &str) { self.interaction.append_input(value); + self.render.mark_dirty(); } pub fn insert_newline(&mut self) { self.interaction.insert_newline(); + self.render.mark_dirty(); } pub fn pop_input(&mut self) { self.interaction.pop_input(); + self.render.mark_dirty(); + } + + pub fn delete_input(&mut self) { + self.interaction.delete_input(); + self.render.mark_dirty(); + } + + pub fn move_cursor_left(&mut self) { + self.interaction.move_cursor_left(); + self.render.mark_dirty(); + } + + pub fn move_cursor_right(&mut self) { + self.interaction.move_cursor_right(); + self.render.mark_dirty(); + } + + pub fn move_cursor_home(&mut self) { + self.interaction.move_cursor_home(); + self.render.mark_dirty(); + } + + pub fn move_cursor_end(&mut self) { + self.interaction.move_cursor_end(); + self.render.mark_dirty(); } pub fn replace_input(&mut self, input: impl Into<String>) { self.interaction.replace_input(input); + self.render.mark_dirty(); } pub fn take_input(&mut self) -> String { - self.interaction.take_input() + let input = self.interaction.take_input(); + self.render.mark_dirty(); + input } - pub fn scroll_up(&mut self) { - self.interaction.scroll_up(); + pub fn cycle_focus_forward(&mut self) { + self.interaction.cycle_focus_forward(); + self.render.mark_dirty(); } - pub fn scroll_down(&mut self) { - self.interaction.scroll_down(); + pub fn cycle_focus_backward(&mut self) { + self.interaction.cycle_focus_backward(); + self.render.mark_dirty(); } - pub fn cycle_focus_forward(&mut self) { + pub fn transcript_next(&mut self) { self.interaction - .cycle_focus_forward(!self.conversation.child_summaries.is_empty()); + .transcript_next(self.conversation.transcript.len()); + self.render.mark_dirty(); } - pub fn cycle_focus_backward(&mut self) { + pub fn transcript_prev(&mut self) { self.interaction - .cycle_focus_backward(!self.conversation.child_summaries.is_empty()); + .transcript_prev(self.conversation.transcript.len()); + self.render.mark_dirty(); } - pub fn child_next(&mut self) { - self.interaction - .child_next(self.conversation.child_summaries.len()); + pub fn transcript_cells(&self) -> Vec<TranscriptCell> { + self.conversation + .project_transcript_cells(&self.interaction.transcript.expanded_cells) } - pub fn child_prev(&mut self) { - self.interaction - .child_prev(self.conversation.child_summaries.len()); + pub fn browser_transcript_cells(&self) -> Vec<TranscriptCell> { + self.transcript_cells() + .into_iter() + .filter(transcript_cell_visible_in_browser) + .collect() } - pub fn toggle_child_focus(&mut self) { - let selected_child_session_id = self - .selected_child_summary() - .map(|summary| summary.child_session_id.clone()); - self.interaction - .toggle_child_focus(selected_child_session_id.as_deref()); + pub fn selected_transcript_cell(&self) -> Option<TranscriptCell> { + self.conversation.project_transcript_cell( + self.interaction.transcript.selected_cell, + &self.interaction.transcript.expanded_cells, + ) } - pub fn selected_child_summary(&self) -> Option<&AstrcodeConversationChildSummaryDto> { - self.conversation - .selected_child_summary(&self.interaction.child_pane) + pub fn selected_browser_cell(&self) -> Option<TranscriptCell> { + self.browser_transcript_cells() + .into_iter() + .nth(self.interaction.browser.selected_cell) } - pub fn focused_child_summary(&self) -> Option<&AstrcodeConversationChildSummaryDto> { - self.conversation - .focused_child_summary(&self.interaction.child_pane) + pub fn is_cell_expanded(&self, cell_id: &str) -> bool { + self.interaction.is_cell_expanded(cell_id) + } + + pub fn selected_cell_is_thinking(&self) -> bool { + self.selected_transcript_cell() + .is_some_and(|cell| matches!(cell.kind, TranscriptCellKind::Thinking { .. })) + } + + pub fn toggle_selected_cell_expanded(&mut self) { + let selected = if self.interaction.browser.open { + self.selected_browser_cell() + } else { + self.selected_transcript_cell() + }; + if let Some(cell_id) = selected.map(|cell| cell.id.clone()) { + self.interaction.toggle_cell_expanded(cell_id.as_str()); + self.render.mark_dirty(); + } + } + + pub fn clear_surface_state(&mut self) { + self.interaction.clear_surface_state(); + self.sync_overlay_state(); + self.render.mark_dirty(); } pub fn update_sessions(&mut self, sessions: Vec<AstrcodeSessionListItem>) { self.conversation.update_sessions(sessions); self.interaction .sync_resume_items(self.conversation.sessions.clone()); + self.render.mark_dirty(); + } + + pub fn update_current_model(&mut self, current_model: AstrcodeCurrentModelInfoDto) { + if self.shell.current_model.as_ref() != Some(¤t_model) { + self.shell.current_model = Some(current_model); + self.render.mark_dirty(); + } + } + + pub fn update_model_options(&mut self, model_options: Vec<AstrcodeModelOptionDto>) { + if self.shell.model_options != model_options { + self.shell.model_options = model_options.clone(); + self.interaction.sync_model_items(model_options); + self.render.mark_dirty(); + } + } + + pub fn update_modes(&mut self, modes: Vec<AstrcodeModeSummaryDto>) { + if self.shell.available_modes != modes { + self.shell.available_modes = modes; + self.render.mark_dirty(); + } } pub fn set_resume_query( @@ -175,7 +252,8 @@ impl CliState { query: impl Into<String>, items: Vec<AstrcodeSessionListItem>, ) { - self.interaction.set_resume_query(query, items); + self.interaction.set_resume_palette(query, items); + self.render.mark_dirty(); } pub fn set_slash_query( @@ -183,60 +261,108 @@ impl CliState { query: impl Into<String>, items: Vec<AstrcodeConversationSlashCandidateDto>, ) { - self.interaction.set_slash_query(query, items); + self.interaction.set_slash_palette(query, items); + self.render.mark_dirty(); } - pub fn overlay_query_push(&mut self, ch: char) { - self.interaction.overlay_query_push(ch); + pub fn set_model_query( + &mut self, + query: impl Into<String>, + items: Vec<AstrcodeModelOptionDto>, + ) { + self.interaction.set_model_palette(query, items); + self.render.mark_dirty(); + } + + pub fn close_palette(&mut self) { + self.interaction.close_palette(); + self.render.mark_dirty(); + } + + pub fn toggle_browser(&mut self) { + if self.interaction.browser.open { + self.interaction.close_browser(); + } else { + self.interaction + .open_browser(self.browser_transcript_cells().len()); + } + self.sync_overlay_state(); + self.render.mark_dirty(); } - pub fn overlay_query_append(&mut self, value: &str) { - self.interaction.overlay_query_append(value); + pub fn browser_next(&mut self, page_size: usize) { + self.interaction + .browser_next(self.browser_transcript_cells().len(), page_size); + self.render.mark_dirty(); } - pub fn overlay_query_pop(&mut self) { - self.interaction.overlay_query_pop(); + pub fn browser_prev(&mut self, page_size: usize) { + self.interaction + .browser_prev(self.browser_transcript_cells().len(), page_size); + self.render.mark_dirty(); } - pub fn close_overlay(&mut self) { - self.interaction.close_overlay(); + pub fn browser_first(&mut self) { + self.interaction + .browser_first(self.browser_transcript_cells().len()); + self.render.mark_dirty(); } - pub fn overlay_next(&mut self) { - self.interaction.overlay_next(); + pub fn browser_last(&mut self) { + self.interaction + .browser_last(self.browser_transcript_cells().len()); + self.render.mark_dirty(); } - pub fn overlay_prev(&mut self) { - self.interaction.overlay_prev(); + pub fn palette_next(&mut self) { + self.interaction.palette_next(); + self.render.mark_dirty(); } - pub fn selected_overlay(&self) -> Option<OverlaySelection> { - self.interaction.selected_overlay() + pub fn palette_prev(&mut self) { + self.interaction.palette_prev(); + self.render.mark_dirty(); + } + + pub fn selected_palette(&self) -> Option<PaletteSelection> { + self.interaction.selected_palette() } pub fn activate_snapshot(&mut self, snapshot: AstrcodeConversationSnapshotResponseDto) { self.conversation .activate_snapshot(snapshot, &mut self.render); self.interaction.reset_for_snapshot(); + self.interaction + .sync_transcript_cells(self.conversation.transcript.len()); + self.thinking_playback + .sync_session(self.conversation.active_session_id.as_deref()); + self.sync_overlay_state(); + self.render.mark_dirty(); } pub fn apply_stream_envelope(&mut self, envelope: AstrcodeConversationStreamEnvelopeDto) { - self.conversation.apply_stream_envelope( - envelope, - &mut self.render, - &mut self.interaction.child_pane, - ); + let expanded_ids = &self.interaction.transcript.expanded_cells; + let slash_candidates_changed = + self.conversation + .apply_stream_envelope(envelope, &mut self.render, expanded_ids); self.interaction - .sync_slash_items(self.conversation.slash_candidates.clone()); + .sync_transcript_cells(self.conversation.transcript.len()); + if slash_candidates_changed { + self.interaction + .sync_slash_items(self.conversation.slash_candidates.clone()); + } + self.render.mark_dirty(); } pub fn set_banner_error(&mut self, error: AstrcodeConversationErrorEnvelopeDto) { self.conversation.set_banner_error(error); - self.interaction.pane_focus = PaneFocus::Composer; + self.interaction.set_focus(PaneFocus::Composer); + self.render.mark_dirty(); } pub fn clear_banner(&mut self) { self.conversation.clear_banner(); + self.render.mark_dirty(); } pub fn active_phase(&self) -> Option<AstrcodePhaseDto> { @@ -247,8 +373,81 @@ impl CliState { self.debug.push(line); } - pub fn toggle_debug_overlay(&mut self) { - self.interaction.toggle_debug_overlay(); + pub fn advance_thinking_playback(&mut self) -> bool { + if self.should_animate_thinking_playback() { + self.thinking_playback.advance(); + self.render.mark_dirty(); + return true; + } + false + } + + fn sync_overlay_state(&mut self) { + let overlay = if self.interaction.browser.open { + ActiveOverlay::Browser + } else { + ActiveOverlay::None + }; + self.render.set_active_overlay(overlay); + } + + fn should_animate_thinking_playback(&self) -> bool { + if self.transcript_cells().iter().any(|cell| { + matches!( + cell.kind, + TranscriptCellKind::Thinking { + status: TranscriptCellStatus::Streaming, + .. + } + ) + }) { + return true; + } + + let Some(control) = &self.conversation.control else { + return false; + }; + if control.active_turn_id.is_none() { + return false; + } + if !matches!( + control.phase, + AstrcodePhaseDto::Thinking + | AstrcodePhaseDto::CallingTool + | AstrcodePhaseDto::Streaming + ) { + return false; + } + + !self.transcript_cells().iter().any(|cell| match &cell.kind { + TranscriptCellKind::Thinking { status, .. } => { + matches!( + status, + TranscriptCellStatus::Streaming | TranscriptCellStatus::Complete + ) + }, + TranscriptCellKind::Assistant { status, body } => { + matches!(status, TranscriptCellStatus::Streaming) && !body.trim().is_empty() + }, + TranscriptCellKind::ToolCall { status, .. } => { + matches!(status, TranscriptCellStatus::Streaming) + }, + _ => false, + }) + } +} + +fn transcript_cell_visible_in_browser(cell: &TranscriptCell) -> bool { + match &cell.kind { + TranscriptCellKind::Assistant { status, .. } + | TranscriptCellKind::Thinking { status, .. } + | TranscriptCellKind::ToolCall { status, .. } => { + !matches!(status, TranscriptCellStatus::Streaming) + }, + TranscriptCellKind::User { .. } + | TranscriptCellKind::Error { .. } + | TranscriptCellKind::SystemNote { .. } + | TranscriptCellKind::ChildHandoff { .. } => true, } } @@ -275,7 +474,12 @@ mod tests { can_submit_prompt: true, can_request_compact: true, compact_pending: false, + compacting: false, + current_mode_id: "code".to_string(), active_turn_id: None, + last_compact_meta: None, + active_plan: None, + active_tasks: None, }, blocks: vec![AstrcodeConversationBlockDto::Assistant( AstrcodeConversationAssistantBlockDto { @@ -287,12 +491,12 @@ mod tests { )], child_summaries: Vec::new(), slash_candidates: vec![AstrcodeConversationSlashCandidateDto { - id: "skill-review".to_string(), + id: "review".to_string(), title: "Review".to_string(), description: "review skill".to_string(), keywords: vec!["review".to_string()], action_kind: AstrcodeConversationSlashActionKindDto::InsertText, - action_value: "/skill review".to_string(), + action_value: "/review".to_string(), }], banner: None, } @@ -361,67 +565,96 @@ mod tests { } #[test] - fn overlay_selection_tracks_resume_and_slash_items() { + fn palette_selection_tracks_resume_and_slash_items() { let mut state = CliState::new("http://127.0.0.1:5529".to_string(), None, capabilities()); state.set_slash_query( "review", vec![AstrcodeConversationSlashCandidateDto { - id: "skill-review".to_string(), + id: "review".to_string(), title: "Review".to_string(), description: "review skill".to_string(), keywords: vec!["review".to_string()], action_kind: AstrcodeConversationSlashActionKindDto::InsertText, - action_value: "/skill review".to_string(), + action_value: "/review".to_string(), }], ); assert!(matches!( - state.selected_overlay(), - Some(OverlaySelection::SlashCandidate(_)) + state.selected_palette(), + Some(PaletteSelection::SlashCandidate(_)) )); - state.close_overlay(); - assert!(matches!(state.interaction.overlay, OverlayState::None)); + state.set_resume_query("repo", Vec::new()); + assert!(matches!(state.interaction.palette, PaletteState::Resume(_))); } #[test] fn resize_invalidates_wrap_cache() { let mut state = CliState::new("http://127.0.0.1:5529".to_string(), None, capabilities()); - state.update_transcript_cache( - 80, - vec![WrappedLine { - style: WrappedLineStyle::Plain, - content: "cached".to_string(), - }], - ); - state.scroll_up(); - state.set_viewport_size(100, 40); + state.note_terminal_resize(100, 40); - assert_eq!(state.render.transcript_cache.lines.len(), 0); - assert_eq!(state.interaction.scroll_anchor, 1); - assert!(!state.interaction.follow_transcript_tail); + assert!(state.render.frame_dirty); } #[test] - fn manual_scroll_disables_follow_until_returning_to_tail() { + fn viewport_resize_marks_surface_dirty() { let mut state = CliState::new("http://127.0.0.1:5529".to_string(), None, capabilities()); + state.render.take_frame_dirty(); + state.note_terminal_resize(80, 6); + assert!(state.render.take_frame_dirty()); + } + + #[test] + fn browser_toggle_marks_surface_dirty() { + let mut state = CliState::new("http://127.0.0.1:5529".to_string(), None, capabilities()); + state.activate_snapshot(sample_snapshot()); + state.render.take_frame_dirty(); - state.scroll_up(); - assert_eq!(state.interaction.scroll_anchor, 1); - assert!(!state.interaction.follow_transcript_tail); + state.toggle_browser(); - state.scroll_down(); - assert_eq!(state.interaction.scroll_anchor, 0); - assert!(state.interaction.follow_transcript_tail); + assert!(state.interaction.browser.open); + assert!(state.render.take_frame_dirty()); } #[test] - fn activating_snapshot_resets_transcript_follow_state() { + fn ticking_advances_streaming_thinking() { let mut state = CliState::new("http://127.0.0.1:5529".to_string(), None, capabilities()); - state.scroll_up(); - - state.activate_snapshot(sample_snapshot()); + state.conversation.control = Some(AstrcodeConversationControlStateDto { + phase: AstrcodePhaseDto::Thinking, + can_submit_prompt: true, + can_request_compact: true, + compact_pending: false, + compacting: false, + current_mode_id: "code".to_string(), + active_turn_id: Some("turn-1".to_string()), + last_compact_meta: None, + active_plan: None, + active_tasks: None, + }); + let frame = state.thinking_playback.frame; + state.advance_thinking_playback(); + assert_eq!(state.thinking_playback.frame, frame.wrapping_add(1)); + } - assert_eq!(state.interaction.scroll_anchor, 0); - assert!(state.interaction.follow_transcript_tail); + #[test] + fn browser_filters_out_streaming_cells() { + let mut state = CliState::new("http://127.0.0.1:5529".to_string(), None, capabilities()); + state.conversation.transcript = vec![ + AstrcodeConversationBlockDto::Assistant(AstrcodeConversationAssistantBlockDto { + id: "assistant-streaming".to_string(), + turn_id: Some("turn-1".to_string()), + status: AstrcodeConversationBlockStatusDto::Streaming, + markdown: "draft".to_string(), + }), + AstrcodeConversationBlockDto::Assistant(AstrcodeConversationAssistantBlockDto { + id: "assistant-complete".to_string(), + turn_id: Some("turn-1".to_string()), + status: AstrcodeConversationBlockStatusDto::Complete, + markdown: "done".to_string(), + }), + ]; + + let browser_cells = state.browser_transcript_cells(); + assert_eq!(browser_cells.len(), 1); + assert_eq!(browser_cells[0].id, "assistant-complete"); } } diff --git a/crates/cli/src/state/render.rs b/crates/cli/src/state/render.rs index 517bc809..150a92ca 100644 --- a/crates/cli/src/state/render.rs +++ b/crates/cli/src/state/render.rs @@ -1,46 +1,162 @@ use std::time::Duration; -use super::{StreamRenderMode, WrappedLine}; +use super::StreamRenderMode; -#[derive(Debug, Clone, PartialEq, Eq, Default)] -pub struct TranscriptRenderCache { - pub width: u16, - pub revision: u64, - pub lines: Vec<WrappedLine>, +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WrappedLine { + pub style: WrappedLineStyle, + pub rewrap_policy: WrappedLineRewrapPolicy, + pub spans: Vec<WrappedSpan>, +} + +impl WrappedLine { + pub fn plain(style: WrappedLineStyle, content: impl Into<String>) -> Self { + let content = content.into(); + let spans = if content.is_empty() { + Vec::new() + } else { + vec![WrappedSpan::plain(content)] + }; + Self { + style, + rewrap_policy: WrappedLineRewrapPolicy::Reflow, + spans, + } + } + + pub fn from_spans(style: WrappedLineStyle, spans: Vec<WrappedSpan>) -> Self { + Self { + style, + rewrap_policy: WrappedLineRewrapPolicy::Reflow, + spans, + } + } + + pub fn with_rewrap_policy(mut self, rewrap_policy: WrappedLineRewrapPolicy) -> Self { + self.rewrap_policy = rewrap_policy; + self + } + + pub fn text(&self) -> String { + self.spans + .iter() + .map(|span| span.content.as_str()) + .collect::<String>() + } + + pub fn is_blank(&self) -> bool { + self.spans.is_empty() || self.text().is_empty() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WrappedLineRewrapPolicy { + Reflow, + PreserveAndCrop, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WrappedSpan { + pub style: Option<WrappedSpanStyle>, + pub content: String, +} + +impl WrappedSpan { + pub fn plain(content: impl Into<String>) -> Self { + Self { + style: None, + content: content.into(), + } + } + + pub fn styled(style: WrappedSpanStyle, content: impl Into<String>) -> Self { + Self { + style: Some(style), + content: content.into(), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WrappedSpanStyle { + Strong, + Emphasis, + Heading, + HeadingRule, + TableBorder, + TableHeader, + InlineCode, + Link, + ListMarker, + QuoteMarker, + CodeFence, + CodeText, + TextArt, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WrappedLineStyle { + Plain, + Muted, + Selection, + PromptEcho, + ThinkingLabel, + ThinkingPreview, + ThinkingBody, + ToolLabel, + ToolBody, + Notice, + ErrorText, + PaletteItem, + PaletteSelected, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ActiveOverlay { + #[default] + None, + Browser, +} + +impl ActiveOverlay { + pub fn is_open(self) -> bool { + !matches!(self, Self::None) + } } #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct RenderState { - pub viewport_width: u16, - pub viewport_height: u16, - pub transcript_revision: u64, - pub wrap_cache_revision: u64, - pub transcript_cache: TranscriptRenderCache, + pub frame_dirty: bool, + pub active_overlay: ActiveOverlay, + pub last_known_terminal_size: Option<(u16, u16)>, } impl RenderState { - pub fn set_viewport_size(&mut self, width: u16, height: u16) -> bool { - if self.viewport_width == width && self.viewport_height == height { + pub fn note_terminal_resize(&mut self, width: u16, height: u16) -> bool { + let next = Some((width, height)); + let changed = self.last_known_terminal_size != next; + self.last_known_terminal_size = next; + if changed { + self.frame_dirty = true; + } + changed + } + + pub fn set_active_overlay(&mut self, overlay: ActiveOverlay) -> bool { + if self.active_overlay == overlay { return false; } - self.viewport_width = width; - self.viewport_height = height; - self.wrap_cache_revision = self.wrap_cache_revision.saturating_add(1); - self.transcript_cache = TranscriptRenderCache::default(); + self.active_overlay = overlay; + self.frame_dirty = true; true } - pub fn update_transcript_cache(&mut self, width: u16, lines: Vec<WrappedLine>) { - self.transcript_cache = TranscriptRenderCache { - width, - revision: self.transcript_revision, - lines, - }; + pub fn mark_dirty(&mut self) { + self.frame_dirty = true; } - pub fn invalidate_transcript_cache(&mut self) { - self.transcript_revision = self.transcript_revision.saturating_add(1); - self.transcript_cache = TranscriptRenderCache::default(); + pub fn take_frame_dirty(&mut self) -> bool { + std::mem::take(&mut self.frame_dirty) } } diff --git a/crates/cli/src/state/shell.rs b/crates/cli/src/state/shell.rs index 7d5cb2ee..045c0e95 100644 --- a/crates/cli/src/state/shell.rs +++ b/crates/cli/src/state/shell.rs @@ -1,5 +1,9 @@ use std::path::PathBuf; +use astrcode_client::{ + AstrcodeCurrentModelInfoDto, AstrcodeModeSummaryDto, AstrcodeModelOptionDto, +}; + use crate::capability::TerminalCapabilities; #[derive(Debug, Clone, PartialEq, Eq)] @@ -7,6 +11,9 @@ pub struct ShellState { pub connection_origin: String, pub working_dir: Option<PathBuf>, pub capabilities: TerminalCapabilities, + pub current_model: Option<AstrcodeCurrentModelInfoDto>, + pub model_options: Vec<AstrcodeModelOptionDto>, + pub available_modes: Vec<AstrcodeModeSummaryDto>, } impl Default for ShellState { @@ -14,7 +21,16 @@ impl Default for ShellState { Self { connection_origin: String::new(), working_dir: None, - capabilities: TerminalCapabilities::detect(), + capabilities: TerminalCapabilities { + color: crate::capability::ColorLevel::None, + glyphs: crate::capability::GlyphMode::Ascii, + alt_screen: false, + mouse: false, + bracketed_paste: false, + }, + current_model: None, + model_options: Vec::new(), + available_modes: Vec::new(), } } } @@ -29,6 +45,9 @@ impl ShellState { connection_origin, working_dir, capabilities, + current_model: None, + model_options: Vec::new(), + available_modes: Vec::new(), } } } diff --git a/crates/cli/src/state/thinking.rs b/crates/cli/src/state/thinking.rs new file mode 100644 index 00000000..04154d7d --- /dev/null +++ b/crates/cli/src/state/thinking.rs @@ -0,0 +1,211 @@ +use super::TranscriptCellStatus; + +const DEFAULT_THINKING_SNIPPETS: &[&str] = &[ + "先确认当前会话状态,再开始改动。", + "把变更收敛到最小但完整的一步。", + "优先复用已有抽象,而不是新增一层。", + "先压测实现里风险最高的分支。", + "让这次交互更容易验证和回归。", + "最终输出保持紧凑且可执行。", +]; + +const DEFAULT_THINKING_VERBS: &[&str] = &[ + "思考中", + "整理中", + "推敲中", + "拆解中", + "校准中", + "交叉检查中", +]; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ThinkingPresentationState { + pub verb: String, + pub summary: String, + pub hint: String, + pub preview: String, + pub expanded_body: String, + pub is_playing: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ThinkingSnippetPool { + snippets: &'static [&'static str], + verbs: &'static [&'static str], +} + +impl Default for ThinkingSnippetPool { + fn default() -> Self { + Self { + snippets: DEFAULT_THINKING_SNIPPETS, + verbs: DEFAULT_THINKING_VERBS, + } + } +} + +impl ThinkingSnippetPool { + pub fn sequence(&self, seed: u64, count: usize) -> Vec<&'static str> { + if self.snippets.is_empty() { + return vec!["thinking"]; + } + + (0..count.max(1)) + .map(|offset| { + let index = ((seed as usize).wrapping_add(offset * 3)) % self.snippets.len(); + self.snippets[index] + }) + .collect() + } + + pub fn sample(&self, seed: u64, frame: u64) -> &'static str { + if self.snippets.is_empty() { + return "thinking"; + } + let index = ((seed as usize).wrapping_add(frame as usize)) % self.snippets.len(); + self.snippets[index] + } + + pub fn verb(&self, seed: u64, frame: u64) -> &'static str { + if self.verbs.is_empty() { + return "思考中"; + } + let index = ((seed as usize).wrapping_add(frame as usize * 2)) % self.verbs.len(); + self.verbs[index] + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct ThinkingPlaybackDriver { + pub session_seed: u64, + pub frame: u64, +} + +impl ThinkingPlaybackDriver { + pub fn sync_session(&mut self, session_id: Option<&str>) { + self.session_seed = session_id.map(stable_hash).unwrap_or_default(); + self.frame = 0; + } + + pub fn advance(&mut self) { + self.frame = self.frame.wrapping_add(1); + } + + pub fn present( + &self, + pool: &ThinkingSnippetPool, + cell_id: &str, + raw_body: &str, + status: TranscriptCellStatus, + expanded: bool, + ) -> ThinkingPresentationState { + let seed = stable_hash(cell_id) ^ self.session_seed; + let playlist = pool.sequence(seed, 4); + let scripted_body = playlist.join("\n"); + let summary = first_non_empty_line(raw_body) + .map(str::to_string) + .unwrap_or_else(|| playlist[0].to_string()); + let verb = pool.verb(seed, self.frame).to_string(); + + let is_streaming = matches!(status, TranscriptCellStatus::Streaming); + let summary_line = if expanded { + format!("{verb} · Ctrl+O 收起") + } else if is_streaming { + format!("{verb}… · Ctrl+O 展开") + } else { + format!("{verb} · Ctrl+O 展开") + }; + + let preview = if is_streaming { + pool.sample(seed, self.frame).to_string() + } else { + summary + }; + let hint = if is_streaming { + "提示:让当前任务自然完成,不要中断流式过程。".to_string() + } else { + "Ctrl+O 可展开完整思考内容。".to_string() + }; + + let expanded_body = if raw_body.trim().is_empty() { + scripted_body + } else { + raw_body.trim().to_string() + }; + + ThinkingPresentationState { + verb, + summary: summary_line, + hint, + preview, + expanded_body, + is_playing: is_streaming, + } + } +} + +fn first_non_empty_line(text: &str) -> Option<&str> { + text.lines().map(str::trim).find(|line| !line.is_empty()) +} + +fn stable_hash(input: &str) -> u64 { + let mut hash = 0xcbf29ce484222325_u64; + for byte in input.as_bytes() { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x100000001b3); + } + hash +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sequence_is_stable_for_same_seed() { + let pool = ThinkingSnippetPool::default(); + assert_eq!(pool.sequence(42, 4), pool.sequence(42, 4)); + } + + #[test] + fn sequence_differs_for_different_seed() { + let pool = ThinkingSnippetPool::default(); + assert_ne!(pool.sequence(1, 4), pool.sequence(2, 4)); + } + + #[test] + fn streaming_preview_advances_with_frame() { + let pool = ThinkingSnippetPool::default(); + let mut driver = ThinkingPlaybackDriver::default(); + driver.sync_session(Some("session-1")); + let first = driver.present( + &pool, + "thinking-1", + "", + TranscriptCellStatus::Streaming, + false, + ); + driver.advance(); + let second = driver.present( + &pool, + "thinking-1", + "", + TranscriptCellStatus::Streaming, + false, + ); + assert_ne!(first.preview, second.preview); + } + + #[test] + fn complete_state_prefers_raw_summary() { + let pool = ThinkingSnippetPool::default(); + let driver = ThinkingPlaybackDriver::default(); + let presentation = driver.present( + &pool, + "thinking-1", + "先读取代码\n再收敛改动", + TranscriptCellStatus::Complete, + false, + ); + assert_eq!(presentation.preview, "先读取代码"); + } +} diff --git a/crates/cli/src/state/transcript_cell.rs b/crates/cli/src/state/transcript_cell.rs index 0b7b03b7..a74a61e7 100644 --- a/crates/cli/src/state/transcript_cell.rs +++ b/crates/cli/src/state/transcript_cell.rs @@ -1,3 +1,5 @@ +use std::collections::BTreeSet; + use astrcode_client::{ AstrcodeConversationAgentLifecycleDto, AstrcodeConversationBlockDto, AstrcodeConversationBlockStatusDto, @@ -6,6 +8,7 @@ use astrcode_client::{ #[derive(Debug, Clone, PartialEq, Eq)] pub struct TranscriptCell { pub id: String, + pub expanded: bool, pub kind: TranscriptCellKind, } @@ -34,11 +37,12 @@ pub enum TranscriptCellKind { tool_name: String, summary: String, status: TranscriptCellStatus, - }, - ToolStream { - stream: String, - content: String, - status: TranscriptCellStatus, + stdout: String, + stderr: String, + error: Option<String>, + duration_ms: Option<u64>, + truncated: bool, + child_session_id: Option<String>, }, Error { code: String, @@ -59,65 +63,118 @@ pub enum TranscriptCellKind { } impl TranscriptCell { - pub fn from_block(block: &AstrcodeConversationBlockDto) -> Self { + pub fn from_block( + block: &AstrcodeConversationBlockDto, + expanded_ids: &BTreeSet<String>, + ) -> Self { + let id = match block { + AstrcodeConversationBlockDto::User(block) => block.id.clone(), + AstrcodeConversationBlockDto::Assistant(block) => block.id.clone(), + AstrcodeConversationBlockDto::Thinking(block) => block.id.clone(), + AstrcodeConversationBlockDto::Plan(block) => block.id.clone(), + AstrcodeConversationBlockDto::ToolCall(block) => block.id.clone(), + AstrcodeConversationBlockDto::Error(block) => block.id.clone(), + AstrcodeConversationBlockDto::SystemNote(block) => block.id.clone(), + AstrcodeConversationBlockDto::ChildHandoff(block) => block.id.clone(), + }; + let expanded = expanded_ids.contains(&id) + || matches!( + block, + AstrcodeConversationBlockDto::Thinking(thinking) + if matches!(thinking.status, AstrcodeConversationBlockStatusDto::Streaming) + ); match block { AstrcodeConversationBlockDto::User(block) => Self { - id: block.id.clone(), + id, + expanded, kind: TranscriptCellKind::User { body: block.markdown.clone(), }, }, AstrcodeConversationBlockDto::Assistant(block) => Self { - id: block.id.clone(), + id, + expanded, kind: TranscriptCellKind::Assistant { body: block.markdown.clone(), status: block.status.into(), }, }, AstrcodeConversationBlockDto::Thinking(block) => Self { - id: block.id.clone(), + id, + expanded, kind: TranscriptCellKind::Thinking { body: block.markdown.clone(), status: block.status.into(), }, }, + AstrcodeConversationBlockDto::Plan(block) => Self { + id, + expanded, + kind: TranscriptCellKind::SystemNote { + note_kind: format!( + "plan:{}", + enum_wire_name(&block.event_kind).unwrap_or_else(|| "saved".to_string()) + ), + markdown: block + .summary + .clone() + .or_else(|| block.content.clone()) + .unwrap_or_else(|| format!("{} ({})", block.title, block.plan_path)), + }, + }, AstrcodeConversationBlockDto::ToolCall(block) => Self { - id: block.id.clone(), + id, + expanded, kind: TranscriptCellKind::ToolCall { tool_name: block.tool_name.clone(), summary: block .summary .clone() + .or_else(|| block.error.clone()) + .or_else(|| { + if block.streams.stdout.is_empty() && block.streams.stderr.is_empty() { + None + } else { + Some("工具输出已更新".to_string()) + } + }) .unwrap_or_else(|| "正在执行工具调用".to_string()), status: block.status.into(), - }, - }, - AstrcodeConversationBlockDto::ToolStream(block) => Self { - id: block.id.clone(), - kind: TranscriptCellKind::ToolStream { - stream: format!("{:?}", block.stream), - content: block.content.clone(), - status: block.status.into(), + stdout: block.streams.stdout.clone(), + stderr: block.streams.stderr.clone(), + error: block.error.clone(), + duration_ms: block.duration_ms, + truncated: block.truncated, + child_session_id: block + .child_ref + .as_ref() + .map(|child_ref| child_ref.open_session_id.clone()), }, }, AstrcodeConversationBlockDto::Error(block) => Self { - id: block.id.clone(), + id, + expanded, kind: TranscriptCellKind::Error { - code: format!("{:?}", block.code), + code: enum_wire_name(&block.code) + .unwrap_or_else(|| "unknown_error".to_string()), message: block.message.clone(), }, }, AstrcodeConversationBlockDto::SystemNote(block) => Self { - id: block.id.clone(), + id, + expanded, kind: TranscriptCellKind::SystemNote { - note_kind: format!("{:?}", block.note_kind), + note_kind: enum_wire_name(&block.note_kind) + .unwrap_or_else(|| "system_note".to_string()), markdown: block.markdown.clone(), }, }, AstrcodeConversationBlockDto::ChildHandoff(block) => Self { - id: block.id.clone(), + id, + expanded, kind: TranscriptCellKind::ChildHandoff { - handoff_kind: format!("{:?}", block.handoff_kind), + handoff_kind: enum_wire_name(&block.handoff_kind) + .unwrap_or_else(|| "delegated".to_string()), title: block.child.title.clone(), lifecycle: block.child.lifecycle, message: block @@ -142,3 +199,13 @@ impl From<AstrcodeConversationBlockStatusDto> for TranscriptCellStatus { } } } + +fn enum_wire_name<T>(value: &T) -> Option<String> +where + T: serde::Serialize, +{ + serde_json::to_value(value) + .ok()? + .as_str() + .map(|value| value.trim().to_string()) +} diff --git a/crates/cli/src/tui/mod.rs b/crates/cli/src/tui/mod.rs new file mode 100644 index 00000000..aa6a94ae --- /dev/null +++ b/crates/cli/src/tui/mod.rs @@ -0,0 +1,3 @@ +pub mod runtime; + +pub use runtime::TuiRuntime; diff --git a/crates/cli/src/tui/runtime.rs b/crates/cli/src/tui/runtime.rs new file mode 100644 index 00000000..861db289 --- /dev/null +++ b/crates/cli/src/tui/runtime.rs @@ -0,0 +1,141 @@ +use std::{io, io::Write}; + +use ratatui::{ + backend::Backend, + layout::{Offset, Rect, Size}, +}; + +use crate::ui::{ + HistoryLine, + custom_terminal::{Frame, Terminal}, + insert_history::insert_history_lines, +}; + +#[derive(Debug)] +pub struct TuiRuntime<B> +where + B: Backend<Error = io::Error> + Write, +{ + terminal: Terminal<B>, + pending_history_lines: Vec<HistoryLine>, + deferred_history_lines: Vec<HistoryLine>, + overlay_open: bool, +} + +impl<B> TuiRuntime<B> +where + B: Backend<Error = io::Error> + Write, +{ + pub fn with_backend(backend: B) -> io::Result<Self> { + let terminal = Terminal::with_options(backend)?; + Ok(Self { + terminal, + pending_history_lines: Vec::new(), + deferred_history_lines: Vec::new(), + overlay_open: false, + }) + } + + pub fn terminal(&self) -> &Terminal<B> { + &self.terminal + } + + pub fn terminal_mut(&mut self) -> &mut Terminal<B> { + &mut self.terminal + } + + pub fn screen_size(&self) -> io::Result<Size> { + self.terminal.size() + } + + pub fn stage_history_lines<I>(&mut self, lines: I) + where + I: IntoIterator<Item = HistoryLine>, + { + self.pending_history_lines.extend(lines); + } + + pub fn draw<F>(&mut self, viewport_height: u16, overlay_open: bool, render: F) -> io::Result<()> + where + F: FnOnce(&mut Frame<'_>, Rect), + { + if let Some(area) = self.pending_viewport_area()? { + self.terminal.set_viewport_area(area); + self.terminal.clear()?; + } + + let mut needs_full_repaint = self.update_inline_viewport(viewport_height)?; + + if overlay_open { + if !self.pending_history_lines.is_empty() { + self.deferred_history_lines + .append(&mut self.pending_history_lines); + } + } else { + if self.overlay_open && !self.deferred_history_lines.is_empty() { + self.pending_history_lines + .append(&mut self.deferred_history_lines); + } + needs_full_repaint |= self.flush_pending_history_lines()?; + } + self.overlay_open = overlay_open; + + if needs_full_repaint { + self.terminal.invalidate_viewport(); + } + + self.terminal.draw(|frame| { + let area = frame.area(); + render(frame, area); + }) + } + + fn flush_pending_history_lines(&mut self) -> io::Result<bool> { + if self.pending_history_lines.is_empty() { + return Ok(false); + } + let lines = std::mem::take(&mut self.pending_history_lines); + insert_history_lines(&mut self.terminal, lines)?; + Ok(true) + } + + fn update_inline_viewport(&mut self, height: u16) -> io::Result<bool> { + let size = self.terminal.size()?; + let mut area = self.terminal.viewport_area; + area.height = height.min(size.height).max(1); + area.width = size.width; + + if area.bottom() > size.height { + let scroll_by = area.bottom() - size.height; + self.terminal + .backend_mut() + .scroll_region_up(0..area.top(), scroll_by)?; + area.y = size.height.saturating_sub(area.height); + } + + if area != self.terminal.viewport_area { + self.terminal.clear()?; + self.terminal.set_viewport_area(area); + return Ok(true); + } + + Ok(false) + } + + fn pending_viewport_area(&mut self) -> io::Result<Option<Rect>> { + let screen_size = self.terminal.size()?; + let last_known_screen_size = self.terminal.last_known_screen_size; + if screen_size != last_known_screen_size { + let cursor_pos = self.terminal.get_cursor_position()?; + let last_known_cursor_pos = self.terminal.last_known_cursor_pos; + if cursor_pos.y != last_known_cursor_pos.y { + let offset = Offset { + x: 0, + y: cursor_pos.y as i32 - last_known_cursor_pos.y as i32, + }; + return Ok(Some(self.terminal.viewport_area.offset(offset))); + } + } + Ok(None) + } +} diff --git a/crates/cli/src/ui/bottom_pane.rs b/crates/cli/src/ui/bottom_pane.rs deleted file mode 100644 index dd85edf2..00000000 --- a/crates/cli/src/ui/bottom_pane.rs +++ /dev/null @@ -1,231 +0,0 @@ -use super::{ - cells::wrap_text, - theme::{CodexTheme, ThemePalette}, -}; -use crate::{ - capability::TerminalCapabilities, - state::{CliState, OverlayState, PaneFocus, WrappedLine, WrappedLineStyle}, -}; - -pub trait BottomPaneView { - fn desired_height(&self, width: u16) -> u16; - fn lines(&self, width: u16) -> Vec<WrappedLine>; -} - -pub struct ComposerPane<'a> { - state: &'a CliState, - theme: CodexTheme, -} - -impl<'a> ComposerPane<'a> { - pub fn new(state: &'a CliState) -> Self { - Self { - state, - theme: CodexTheme::new(state.shell.capabilities), - } - } - - fn status_row(&self, width: usize) -> WrappedLine { - let phase = self - .state - .active_phase() - .map(super::phase_label) - .unwrap_or("idle"); - let busy = if self.state.stream_view.pending_chunks > 0 { - format!( - "stream {:?} / {}", - self.state.stream_view.mode, self.state.stream_view.pending_chunks - ) - } else { - "ready".to_string() - }; - let mut segments = vec![format!("{} {phase}", self.theme.glyph("●", "*")), busy]; - if let Some(banner) = &self.state.conversation.banner { - segments.push(format!("error {}", banner.error.message)); - } else if !self.state.interaction.status.message.is_empty() { - segments.push(self.state.interaction.status.message.clone()); - } - - WrappedLine { - style: if self.state.conversation.banner.is_some() - || self.state.interaction.status.is_error - { - WrappedLineStyle::Error - } else { - WrappedLineStyle::Dim - }, - content: truncate_text( - segments.join(" · ").as_str(), - width, - self.state.shell.capabilities, - ), - } - } - - fn composer_lines(&self, width: usize) -> Vec<WrappedLine> { - let prefix = self.theme.glyph("›", ">"); - let draft = if self.state.interaction.composer.is_empty() { - "Find and fix a bug in @filename".to_string() - } else { - self.state.interaction.composer.input.clone() - }; - let body_style = if self.state.interaction.composer.is_empty() { - WrappedLineStyle::Dim - } else if matches!(self.state.interaction.pane_focus, PaneFocus::Composer) { - WrappedLineStyle::Accent - } else { - WrappedLineStyle::Plain - }; - - wrap_text( - draft.as_str(), - width.saturating_sub(2).max(8), - self.state.shell.capabilities, - ) - .into_iter() - .enumerate() - .map(|(index, line)| WrappedLine { - style: body_style, - content: if index == 0 { - format!("{prefix} {line}") - } else { - format!(" {line}") - }, - }) - .collect() - } - - fn slash_popup_lines( - &self, - width: usize, - capabilities: TerminalCapabilities, - ) -> Vec<WrappedLine> { - let OverlayState::SlashPalette(palette) = &self.state.interaction.overlay else { - return Vec::new(); - }; - - let mut lines = vec![ - WrappedLine { - style: WrappedLineStyle::Border, - content: self.theme.divider().repeat(width.max(16)), - }, - WrappedLine { - style: WrappedLineStyle::Warning, - content: format!( - "{} commands {}", - self.theme.glyph("↳", ">"), - if palette.query.is_empty() { - "<all>" - } else { - palette.query.as_str() - } - ), - }, - ]; - - if palette.items.is_empty() { - lines.push(WrappedLine { - style: WrappedLineStyle::Dim, - content: " 没有匹配的 commands 或 skills。".to_string(), - }); - return lines; - } - - for (index, item) in palette.items.iter().take(6).enumerate() { - let marker = if index == palette.selected { - self.theme.glyph("›", ">") - } else { - " " - }; - lines.push(WrappedLine { - style: if index == palette.selected { - WrappedLineStyle::Selection - } else { - WrappedLineStyle::Plain - }, - content: format!("{marker} {}", item.title), - }); - for line in wrap_text( - item.description.as_str(), - width.saturating_sub(2).max(8), - capabilities, - ) { - lines.push(WrappedLine { - style: WrappedLineStyle::Dim, - content: format!(" {line}"), - }); - } - } - lines - } - - fn footer_line(&self, width: usize) -> WrappedLine { - let focus = match self.state.interaction.pane_focus { - PaneFocus::Transcript => "transcript", - PaneFocus::ChildPane => "children", - PaneFocus::Composer => "composer", - PaneFocus::Overlay => "overlay", - }; - let parts = [ - format!("focus {focus}"), - "Enter send".to_string(), - "Shift+Enter newline".to_string(), - "Tab commands".to_string(), - "F2 logs".to_string(), - ]; - WrappedLine { - style: WrappedLineStyle::Footer, - content: truncate_text( - parts.join(" · ").as_str(), - width, - self.state.shell.capabilities, - ), - } - } -} - -impl BottomPaneView for ComposerPane<'_> { - fn desired_height(&self, width: u16) -> u16 { - self.lines(width).len().try_into().unwrap_or(u16::MAX) - } - - fn lines(&self, width: u16) -> Vec<WrappedLine> { - let width = usize::from(width.max(24)); - let mut lines = vec![self.status_row(width)]; - lines.push(WrappedLine { - style: WrappedLineStyle::Border, - content: self.theme.divider().repeat(width), - }); - lines.extend(self.composer_lines(width)); - lines.extend(self.slash_popup_lines(width, self.state.shell.capabilities)); - lines.push(WrappedLine { - style: WrappedLineStyle::Border, - content: self.theme.divider().repeat(width), - }); - lines.push(self.footer_line(width)); - lines - } -} - -fn truncate_text(text: &str, width: usize, capabilities: TerminalCapabilities) -> String { - if width == 0 { - return String::new(); - } - - let mut out = String::new(); - let mut current_width = 0; - for ch in text.chars() { - let ch = ch.to_string(); - let ch_width = if capabilities.ascii_only() { - 1 - } else { - unicode_width::UnicodeWidthStr::width(ch.as_str()).max(1) - }; - if current_width + ch_width > width { - break; - } - current_width += ch_width; - out.push_str(ch.as_str()); - } - out -} diff --git a/crates/cli/src/ui/cells.rs b/crates/cli/src/ui/cells.rs index e8cf157b..7ba16905 100644 --- a/crates/cli/src/ui/cells.rs +++ b/crates/cli/src/ui/cells.rs @@ -1,14 +1,24 @@ use unicode_width::UnicodeWidthStr; -use super::theme::ThemePalette; +use super::{ + markdown::{render_literal_text, render_markdown_lines, render_preformatted_block}, + theme::ThemePalette, + truncate_to_width, +}; use crate::{ capability::TerminalCapabilities, state::{ - TranscriptCell, TranscriptCellKind, TranscriptCellStatus, WrappedLine, WrappedLineStyle, + ThinkingPresentationState, TranscriptCell, TranscriptCellKind, TranscriptCellStatus, + WrappedLine, WrappedLineStyle, WrappedSpan, }, }; -const LIVE_PREFIX: usize = 2; +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct TranscriptCellView { + pub selected: bool, + pub expanded: bool, + pub thinking: Option<ThinkingPresentationState>, +} pub trait RenderableCell { fn render_lines( @@ -16,6 +26,7 @@ pub trait RenderableCell { width: usize, capabilities: TerminalCapabilities, theme: &dyn ThemePalette, + view: &TranscriptCellView, ) -> Vec<WrappedLine>; } @@ -25,310 +36,399 @@ impl RenderableCell for TranscriptCell { width: usize, capabilities: TerminalCapabilities, theme: &dyn ThemePalette, + view: &TranscriptCellView, ) -> Vec<WrappedLine> { - let width = width.max(20); + let width = width.max(28); match &self.kind { - TranscriptCellKind::User { body } => render_user_cell(body, width, capabilities, theme), - TranscriptCellKind::Assistant { body, status } => render_labeled_cell( - width, - capabilities, - theme, - &format!( - "{} Astrcode{}", - theme.glyph("●", "*"), - status_suffix(*status) - ), - body, - WrappedLineStyle::Header, - WrappedLineStyle::Plain, - ), - TranscriptCellKind::Thinking { body, status } => render_labeled_cell( - width, - capabilities, - theme, - &format!( - "{} thinking{}", - theme.glyph("◌", "o"), - status_suffix(*status) - ), - body, - WrappedLineStyle::Dim, - WrappedLineStyle::Dim, - ), + TranscriptCellKind::User { body } => { + render_message(body, width, capabilities, theme, view, true) + }, + TranscriptCellKind::Assistant { body, status } => { + let content = if matches!(status, TranscriptCellStatus::Streaming) { + format!("{body}{}", status_suffix(*status)) + } else { + body.clone() + }; + render_message(content.as_str(), width, capabilities, theme, view, false) + }, + TranscriptCellKind::Thinking { .. } => { + render_thinking_cell(width, capabilities, theme, view) + }, TranscriptCellKind::ToolCall { tool_name, summary, status, - } => render_labeled_cell( + stdout, + stderr, + error, + duration_ms, + truncated, + child_session_id, + } => render_tool_call_cell( + ToolCallView { + tool_name, + summary, + status: *status, + stdout, + stderr, + error: error.as_deref(), + duration_ms: *duration_ms, + truncated: *truncated, + child_session_id: child_session_id.as_deref(), + }, width, capabilities, theme, - &format!( - "{} tool {}{}", - theme.glyph("↳", ">"), - tool_name, - status_suffix(*status) - ), - summary, - WrappedLineStyle::Warning, - WrappedLineStyle::Dim, + view, ), - TranscriptCellKind::ToolStream { - stream, - content, - status, - } => render_labeled_cell( + TranscriptCellKind::Error { code, message } => render_secondary_line( + &format!("{code} {message}"), width, capabilities, theme, - &format!( - "{} {}{}", - theme.glyph("│", "|"), - stream.to_lowercase(), - status_suffix(*status) - ), - content, - WrappedLineStyle::Warning, - WrappedLineStyle::Plain, + view, + WrappedLineStyle::ErrorText, + MarkdownRenderMode::Literal, ), - TranscriptCellKind::Error { code, message } => render_labeled_cell( + TranscriptCellKind::SystemNote { markdown, .. } => render_secondary_line( + markdown, width, capabilities, theme, - &format!("{} error {code}", theme.glyph("✕", "x")), - message, - WrappedLineStyle::Error, - WrappedLineStyle::Error, + view, + WrappedLineStyle::Notice, + MarkdownRenderMode::Display, ), - TranscriptCellKind::SystemNote { - note_kind, - markdown, - } => render_labeled_cell( + TranscriptCellKind::ChildHandoff { title, message, .. } => render_secondary_line( + &format!("{title} · {message}"), width, capabilities, theme, - &format!("{} {note_kind}", theme.glyph("·", "-")), - markdown, - WrappedLineStyle::Dim, - WrappedLineStyle::Dim, + view, + WrappedLineStyle::Notice, + MarkdownRenderMode::Literal, ), - TranscriptCellKind::ChildHandoff { - handoff_kind, - title, - lifecycle, - message, - child_session_id, - child_agent_id, - } => { - let mut lines = render_labeled_cell( - width, - capabilities, - theme, - &format!( - "{} child {} [{} / {}]", - theme.glyph("◇", "*"), - title, - handoff_kind, - lifecycle_label(*lifecycle) - ), - message, - WrappedLineStyle::Accent, - WrappedLineStyle::Plain, - ); - lines.extend([ - prefixed_line( - WrappedLineStyle::Dim, - &format!("session {child_session_id}"), - capabilities, - width, - ), - prefixed_line( - WrappedLineStyle::Dim, - &format!("agent {child_agent_id}"), - capabilities, - width, - ), - WrappedLine { - style: WrappedLineStyle::Plain, - content: String::new(), - }, - ]); - lines - }, } } } -fn render_user_cell( +impl TranscriptCellView { + fn resolve_style(&self, base: WrappedLineStyle) -> WrappedLineStyle { + if self.selected { + WrappedLineStyle::Selection + } else { + base + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum MarkdownRenderMode { + Literal, + Display, +} + +fn render_message( body: &str, width: usize, capabilities: TerminalCapabilities, theme: &dyn ThemePalette, + view: &TranscriptCellView, + is_user: bool, ) -> Vec<WrappedLine> { - let prefix = format!("{} ", theme.glyph("▌", "|")); - let body_width = width.saturating_sub(display_width(prefix.as_str(), capabilities)); - let wrapped = wrap_text(body, body_width.max(8), capabilities); - let mut lines = Vec::with_capacity(wrapped.len() + 1); - for line in wrapped { - lines.push(WrappedLine { - style: WrappedLineStyle::User, - content: format!("{prefix}{line}"), - }); + let first_prefix = if is_user { + format!("{} ", prompt_marker(theme)) + } else { + format!("{} ", assistant_marker(theme)) + }; + let subsequent_prefix = " ".repeat(display_width(first_prefix.as_str())); + let wrapped = if is_user { + render_literal_lines( + body, + width.saturating_sub(display_width(first_prefix.as_str())), + capabilities, + view.resolve_style(WrappedLineStyle::PromptEcho), + ) + } else { + render_markdown_lines( + body, + width.saturating_sub(display_width(first_prefix.as_str())), + capabilities, + view.resolve_style(WrappedLineStyle::Plain), + ) + }; + + let mut lines = Vec::new(); + for (index, line) in wrapped.into_iter().enumerate() { + lines.push(prepend_prefix( + line, + if index == 0 { + plain_prefix(first_prefix.as_str()) + } else { + plain_prefix(subsequent_prefix.as_str()) + }, + )); } - lines.push(WrappedLine { - style: WrappedLineStyle::Plain, - content: String::new(), - }); + lines.push(blank_line()); lines } -fn render_labeled_cell( +fn render_thinking_cell( width: usize, capabilities: TerminalCapabilities, _theme: &dyn ThemePalette, - title: &str, - body: &str, - title_style: WrappedLineStyle, - body_style: WrappedLineStyle, + view: &TranscriptCellView, ) -> Vec<WrappedLine> { - let mut lines = vec![prefixed_line(title_style, title, capabilities, width)]; - for line in wrap_text(body, width.saturating_sub(LIVE_PREFIX).max(8), capabilities) { - lines.push(prefixed_line( - body_style, - line.as_str(), - capabilities, - width, - )); + let Some(thinking) = view.thinking.as_ref() else { + return vec![blank_line()]; + }; + if !view.expanded { + return vec![ + plain_line( + view.resolve_style(WrappedLineStyle::ThinkingLabel), + truncate_to_width( + format!("{} {}", thinking_marker(_theme), thinking.summary).as_str(), + width, + ), + ), + plain_line( + view.resolve_style(WrappedLineStyle::ThinkingPreview), + truncate_to_width( + format!(" {} {}", thinking_preview_prefix(_theme), thinking.preview).as_str(), + width, + ), + ), + blank_line(), + ]; + } + + let mut lines = vec![plain_line( + view.resolve_style(WrappedLineStyle::ThinkingLabel), + format!("{} {}", thinking_marker(_theme), thinking.summary), + )]; + lines.push(plain_line( + view.resolve_style(WrappedLineStyle::ThinkingPreview), + format!(" {}", thinking.hint), + )); + for line in render_markdown_lines( + thinking.expanded_body.as_str(), + width.saturating_sub(2), + capabilities, + view.resolve_style(WrappedLineStyle::ThinkingBody), + ) { + lines.push(prepend_prefix(line, plain_prefix(" "))); } - lines.push(WrappedLine { - style: WrappedLineStyle::Plain, - content: String::new(), - }); + lines.push(blank_line()); lines } -fn prefixed_line( - style: WrappedLineStyle, - content: &str, - capabilities: TerminalCapabilities, - width: usize, -) -> WrappedLine { - let prefix = if matches!(style, WrappedLineStyle::Header | WrappedLineStyle::Accent) { - " " - } else { - " " - }; - let available = width.saturating_sub(display_width(prefix, capabilities)); - let text = truncate_to_width(content, available.max(1), capabilities); - WrappedLine { - style, - content: format!("{prefix}{text}"), - } +#[derive(Debug, Clone, Copy)] +struct ToolCallView<'a> { + tool_name: &'a str, + summary: &'a str, + status: TranscriptCellStatus, + stdout: &'a str, + stderr: &'a str, + error: Option<&'a str>, + duration_ms: Option<u64>, + truncated: bool, + child_session_id: Option<&'a str>, } -pub fn wrap_text(text: &str, width: usize, capabilities: TerminalCapabilities) -> Vec<String> { - let width = width.max(1); - let mut out = Vec::new(); - let normalized = if text.trim().is_empty() { - vec![String::new()] - } else { - text.lines().map(ToString::to_string).collect::<Vec<_>>() - }; +fn render_tool_call_cell( + tool: ToolCallView<'_>, + width: usize, + capabilities: TerminalCapabilities, + theme: &dyn ThemePalette, + view: &TranscriptCellView, +) -> Vec<WrappedLine> { + let mut lines = vec![plain_line( + view.resolve_style(WrappedLineStyle::ToolLabel), + truncate_to_width( + format!( + "{} tool {}{} · {}", + tool_marker(theme), + tool.tool_name, + if tool.truncated { " · truncated" } else { "" }, + tool.summary + ) + .as_str(), + width, + ), + )]; - for raw_line in normalized { - if raw_line.is_empty() { - out.push(String::new()); - continue; + if view.expanded { + let mut metadata = Vec::new(); + metadata.push(match tool.status { + TranscriptCellStatus::Streaming => "streaming".to_string(), + TranscriptCellStatus::Complete => "complete".to_string(), + TranscriptCellStatus::Failed => "failed".to_string(), + TranscriptCellStatus::Cancelled => "cancelled".to_string(), + }); + if let Some(duration_ms) = tool.duration_ms { + metadata.push(format!("{duration_ms}ms")); } - - let words = raw_line.split_whitespace().collect::<Vec<_>>(); - if words.len() <= 1 { - wrap_by_width(raw_line.as_str(), width, capabilities, &mut out); - continue; + if let Some(child_session_id) = tool.child_session_id { + metadata.push(format!("child session {child_session_id}")); } - - let mut current = String::new(); - for word in words { - let candidate = if current.is_empty() { - word.to_string() - } else { - format!("{current} {word}") - }; - if display_width(candidate.as_str(), capabilities) > width && !current.is_empty() { - out.push(current); - current = String::new(); - } - - if current.is_empty() && display_width(word, capabilities) > width { - wrap_by_width(word, width, capabilities, &mut out); - } else if current.is_empty() { - current = word.to_string(); - } else { - current = format!("{current} {word}"); - } + if !metadata.is_empty() { + lines.push(plain_line( + view.resolve_style(WrappedLineStyle::ToolBody), + format!(" meta {}", metadata.join(" · ")), + )); } - if !current.is_empty() { - out.push(current); + if !tool.stdout.trim().is_empty() { + append_preformatted_tool_section( + &mut lines, + "stdout", + tool.stdout, + width, + capabilities, + theme, + view, + ); + } + if !tool.stderr.trim().is_empty() { + append_preformatted_tool_section( + &mut lines, + "stderr", + tool.stderr, + width, + capabilities, + theme, + view, + ); + } + if let Some(error) = tool.error { + append_preformatted_tool_section( + &mut lines, + "error", + error, + width, + capabilities, + theme, + view, + ); } } - out + lines.push(blank_line()); + lines } -fn wrap_by_width( - text: &str, +fn append_preformatted_tool_section( + lines: &mut Vec<WrappedLine>, + label: &str, + body: &str, width: usize, capabilities: TerminalCapabilities, - out: &mut Vec<String>, + theme: &dyn ThemePalette, + view: &TranscriptCellView, ) { - let mut current = String::new(); - let mut current_width = 0; - for ch in text.chars() { - let ch_width = display_width(ch.to_string().as_str(), capabilities).max(1); - if current_width + ch_width > width && !current.is_empty() { - out.push(std::mem::take(&mut current)); - current_width = 0; - } - current.push(ch); - current_width += ch_width; - } - if !current.is_empty() { - out.push(current); + let section_style = view.resolve_style(WrappedLineStyle::ToolBody); + lines.push(plain_line(section_style, format!(" {label}"))); + for line in render_preformatted_block(body, width.saturating_sub(4), capabilities) { + lines.push(plain_line( + section_style, + format!(" {} {line}", tool_block_marker(theme)), + )); } } -fn truncate_to_width(text: &str, width: usize, capabilities: TerminalCapabilities) -> String { - let mut out = String::new(); - let mut current_width = 0; - for ch in text.chars() { - let ch_width = display_width(ch.to_string().as_str(), capabilities).max(1); - if current_width + ch_width > width { - break; - } - current_width += ch_width; - out.push(ch); +fn render_secondary_line( + body: &str, + width: usize, + capabilities: TerminalCapabilities, + theme: &dyn ThemePalette, + view: &TranscriptCellView, + style: WrappedLineStyle, + render_mode: MarkdownRenderMode, +) -> Vec<WrappedLine> { + let base_style = view.resolve_style(style); + let rendered = match render_mode { + MarkdownRenderMode::Literal => { + render_literal_lines(body, width.saturating_sub(2), capabilities, base_style) + }, + MarkdownRenderMode::Display => { + render_markdown_lines(body, width.saturating_sub(2), capabilities, base_style) + }, + }; + + let mut lines = Vec::new(); + for line in rendered { + lines.push(prepend_prefix( + line, + plain_prefix(format!("{} ", secondary_marker(theme)).as_str()), + )); } - out + lines.push(blank_line()); + lines } -fn display_width(text: &str, capabilities: TerminalCapabilities) -> usize { - if capabilities.ascii_only() { - text.chars().count() +fn render_literal_lines( + text: &str, + width: usize, + capabilities: TerminalCapabilities, + style: WrappedLineStyle, +) -> Vec<WrappedLine> { + render_literal_text(text, width, capabilities) + .into_iter() + .map(|line| plain_line(style, line)) + .collect() +} + +fn plain_line(style: WrappedLineStyle, content: impl Into<String>) -> WrappedLine { + WrappedLine::plain(style, content) +} + +fn plain_prefix(prefix: &str) -> Vec<WrappedSpan> { + if prefix.is_empty() { + Vec::new() } else { - UnicodeWidthStr::width(text) + vec![WrappedSpan::plain(prefix.to_string())] } } -fn lifecycle_label( - lifecycle: astrcode_client::AstrcodeConversationAgentLifecycleDto, -) -> &'static str { - match lifecycle { - astrcode_client::AstrcodeConversationAgentLifecycleDto::Pending => "pending", - astrcode_client::AstrcodeConversationAgentLifecycleDto::Running => "running", - astrcode_client::AstrcodeConversationAgentLifecycleDto::Idle => "idle", - astrcode_client::AstrcodeConversationAgentLifecycleDto::Terminated => "terminated", +fn prepend_prefix(mut line: WrappedLine, mut prefix: Vec<WrappedSpan>) -> WrappedLine { + if prefix.is_empty() { + return line; } + prefix.extend(line.spans); + line.spans = prefix; + line +} + +fn prompt_marker(theme: &dyn ThemePalette) -> &'static str { + theme.glyph("›", ">") +} + +fn assistant_marker(theme: &dyn ThemePalette) -> &'static str { + theme.glyph("•", "*") +} + +fn thinking_marker(theme: &dyn ThemePalette) -> &'static str { + theme.glyph("◦", "o") +} + +fn thinking_preview_prefix(theme: &dyn ThemePalette) -> &'static str { + theme.glyph("↳", ">") +} + +fn tool_marker(theme: &dyn ThemePalette) -> &'static str { + theme.glyph("◆", "+") +} + +fn secondary_marker(theme: &dyn ThemePalette) -> &'static str { + theme.glyph("·", "-") +} + +fn tool_block_marker(theme: &dyn ThemePalette) -> &'static str { + theme.glyph("│", "|") +} + +fn blank_line() -> WrappedLine { + plain_line(WrappedLineStyle::Plain, String::new()) } fn status_suffix(status: TranscriptCellStatus) -> &'static str { @@ -339,3 +439,99 @@ fn status_suffix(status: TranscriptCellStatus) -> &'static str { TranscriptCellStatus::Cancelled => " · cancelled", } } + +fn display_width(text: &str) -> usize { + UnicodeWidthStr::width(text) +} + +#[cfg(test)] +mod tests { + use super::{ + RenderableCell, TranscriptCellView, assistant_marker, secondary_marker, thinking_marker, + tool_marker, + }; + use crate::{ + capability::{ColorLevel, GlyphMode, TerminalCapabilities}, + state::{TranscriptCell, TranscriptCellKind, TranscriptCellStatus}, + ui::CodexTheme, + }; + + fn unicode_capabilities() -> TerminalCapabilities { + TerminalCapabilities { + color: ColorLevel::TrueColor, + glyphs: GlyphMode::Unicode, + alt_screen: false, + mouse: false, + bracketed_paste: false, + } + } + + fn ascii_capabilities() -> TerminalCapabilities { + TerminalCapabilities { + color: ColorLevel::None, + glyphs: GlyphMode::Ascii, + alt_screen: false, + mouse: false, + bracketed_paste: false, + } + } + + #[test] + fn ascii_markers_remain_distinct_by_cell_type() { + let theme = CodexTheme::new(ascii_capabilities()); + assert_ne!(assistant_marker(&theme), thinking_marker(&theme)); + assert_ne!(tool_marker(&theme), secondary_marker(&theme)); + } + + #[test] + fn assistant_wrapped_lines_use_hanging_indent() { + let theme = CodexTheme::new(unicode_capabilities()); + let cell = TranscriptCell { + id: "assistant-1".to_string(), + expanded: false, + kind: TranscriptCellKind::Assistant { + body: "你好!我是 AstrCode,你的本地 AI \ + 编码助手。我可以帮你处理代码编写、文件编辑、终端命令、\ + 代码审查等各种开发任务。" + .to_string(), + status: TranscriptCellStatus::Complete, + }, + }; + + let lines = cell.render_lines( + 36, + unicode_capabilities(), + &theme, + &TranscriptCellView::default(), + ); + + assert!(lines.len() >= 3); + assert!(lines[0].text().starts_with("• ")); + assert!(lines[1].text().starts_with(" ")); + assert!(!lines[1].text().starts_with(" ")); + } + + #[test] + fn assistant_rendering_preserves_markdown_line_breaks() { + let theme = CodexTheme::new(unicode_capabilities()); + let cell = TranscriptCell { + id: "assistant-2".to_string(), + expanded: false, + kind: TranscriptCellKind::Assistant { + body: "你好!\n\n- 第一项\n- 第二项".to_string(), + status: TranscriptCellStatus::Complete, + }, + }; + + let lines = cell.render_lines( + 36, + unicode_capabilities(), + &theme, + &TranscriptCellView::default(), + ); + + assert!(lines.iter().any(|line| line.text() == " ")); + assert!(lines.iter().any(|line| line.text().contains("- 第一项"))); + assert!(lines.iter().any(|line| line.text().contains("- 第二项"))); + } +} diff --git a/crates/cli/src/ui/composer.rs b/crates/cli/src/ui/composer.rs new file mode 100644 index 00000000..416356c2 --- /dev/null +++ b/crates/cli/src/ui/composer.rs @@ -0,0 +1,68 @@ +use ratatui::text::Line; +use unicode_width::UnicodeWidthStr; + +use crate::{render::wrap::wrap_plain_text, state::ComposerState}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ComposerRenderState { + pub lines: Vec<Line<'static>>, + pub cursor: Option<(u16, u16)>, +} + +pub fn render_composer( + composer: &ComposerState, + width: u16, + height: u16, + focused: bool, +) -> ComposerRenderState { + let width = usize::from(width.max(1)); + let height = usize::from(height.max(1)); + if composer.as_str().is_empty() { + return ComposerRenderState { + lines: vec![Line::from("")], + cursor: focused.then_some((0, 0)), + }; + } + + let wrapped_all = wrap_plain_text(composer.as_str(), width); + let prefix = &composer.as_str()[..composer.cursor.min(composer.as_str().len())]; + let wrapped_prefix = wrap_plain_text(prefix, width); + let cursor_row = wrapped_prefix.len().saturating_sub(1); + let cursor_col = wrapped_prefix + .last() + .map(|line| UnicodeWidthStr::width(line.as_str()) as u16) + .unwrap_or(0); + let visible_start = cursor_row.saturating_add(1).saturating_sub(height); + let visible_lines = wrapped_all + .iter() + .skip(visible_start) + .take(height) + .cloned() + .map(Line::from) + .collect::<Vec<_>>(); + let cursor = focused.then_some((cursor_col, cursor_row.saturating_sub(visible_start) as u16)); + + ComposerRenderState { + lines: if visible_lines.is_empty() { + vec![Line::from("")] + } else { + visible_lines + }, + cursor, + } +} + +#[cfg(test)] +mod tests { + use super::render_composer; + use crate::state::ComposerState; + + #[test] + fn empty_focused_composer_keeps_blank_canvas() { + let composer = ComposerState::default(); + let rendered = render_composer(&composer, 24, 2, true); + assert_eq!(rendered.lines.len(), 1); + assert!(rendered.lines[0].to_string().is_empty()); + assert_eq!(rendered.cursor, Some((0, 0))); + } +} diff --git a/crates/cli/src/ui/custom_terminal.rs b/crates/cli/src/ui/custom_terminal.rs new file mode 100644 index 00000000..8e5cef21 --- /dev/null +++ b/crates/cli/src/ui/custom_terminal.rs @@ -0,0 +1,458 @@ +use std::{io, io::Write}; + +use crossterm::{ + cursor::MoveTo, + queue, + style::{Colors, Print, SetAttribute, SetBackgroundColor, SetColors, SetForegroundColor}, + terminal::Clear, +}; +use ratatui::{ + backend::{Backend, ClearType}, + buffer::Buffer, + layout::{Position, Rect, Size}, + style::{Color, Modifier}, + widgets::Widget, +}; +use unicode_width::UnicodeWidthStr; + +fn display_width(s: &str) -> usize { + if !s.contains('\x1B') { + return s.width(); + } + + let mut visible = String::with_capacity(s.len()); + let mut chars = s.chars(); + while let Some(ch) = chars.next() { + if ch == '\x1B' && chars.clone().next() == Some(']') { + chars.next(); + for c in chars.by_ref() { + if c == '\x07' { + break; + } + } + continue; + } + visible.push(ch); + } + visible.width() +} + +#[derive(Debug)] +pub struct Frame<'a> { + pub(crate) cursor_position: Option<Position>, + pub(crate) viewport_area: Rect, + pub(crate) buffer: &'a mut Buffer, +} + +impl Frame<'_> { + pub const fn area(&self) -> Rect { + self.viewport_area + } + + pub fn set_cursor_position<P: Into<Position>>(&mut self, position: P) { + self.cursor_position = Some(position.into()); + } + + pub fn buffer_mut(&mut self) -> &mut Buffer { + self.buffer + } + + pub fn render_widget<W: Widget>(&mut self, widget: W, area: Rect) { + widget.render(area, self.buffer); + } +} + +#[derive(Debug)] +pub struct Terminal<B> +where + B: Backend<Error = io::Error> + Write, +{ + backend: B, + buffers: [Buffer; 2], + current: usize, + hidden_cursor: bool, + pub viewport_area: Rect, + pub last_known_screen_size: Size, + pub last_known_cursor_pos: Position, + visible_history_rows: u16, +} + +impl<B> Drop for Terminal<B> +where + B: Backend<Error = io::Error> + Write, +{ + fn drop(&mut self) { + let _ = self.show_cursor(); + } +} + +impl<B> Terminal<B> +where + B: Backend<Error = io::Error> + Write, +{ + pub fn with_options(mut backend: B) -> io::Result<Self> { + let screen_size = backend.size()?; + let cursor_pos = backend + .get_cursor_position() + .unwrap_or(Position { x: 0, y: 0 }); + Ok(Self { + backend, + buffers: [Buffer::empty(Rect::ZERO), Buffer::empty(Rect::ZERO)], + current: 0, + hidden_cursor: false, + viewport_area: Rect::new(0, cursor_pos.y, 0, 0), + last_known_screen_size: screen_size, + last_known_cursor_pos: cursor_pos, + visible_history_rows: 0, + }) + } + + pub fn backend(&self) -> &B { + &self.backend + } + + pub fn backend_mut(&mut self) -> &mut B { + &mut self.backend + } + + pub fn size(&self) -> io::Result<Size> { + self.backend.size() + } + + pub fn get_cursor_position(&mut self) -> io::Result<Position> { + self.backend.get_cursor_position() + } + + pub fn set_cursor_position<P: Into<Position>>(&mut self, position: P) -> io::Result<()> { + let position = position.into(); + self.backend.set_cursor_position(position)?; + self.last_known_cursor_pos = position; + Ok(()) + } + + pub fn hide_cursor(&mut self) -> io::Result<()> { + self.backend.hide_cursor()?; + self.hidden_cursor = true; + Ok(()) + } + + pub fn show_cursor(&mut self) -> io::Result<()> { + self.backend.show_cursor()?; + self.hidden_cursor = false; + Ok(()) + } + + pub fn set_viewport_area(&mut self, area: Rect) { + self.current_buffer_mut().resize(area); + self.previous_buffer_mut().resize(area); + self.viewport_area = area; + self.visible_history_rows = self.visible_history_rows.min(area.top()); + } + + pub fn clear(&mut self) -> io::Result<()> { + if self.viewport_area.is_empty() { + return Ok(()); + } + self.backend + .set_cursor_position(self.viewport_area.as_position())?; + self.backend.clear_region(ClearType::AfterCursor)?; + self.previous_buffer_mut().reset(); + Ok(()) + } + + pub fn invalidate_viewport(&mut self) { + self.previous_buffer_mut().reset(); + } + + pub fn visible_history_rows(&self) -> u16 { + self.visible_history_rows + } + + pub fn note_history_rows_inserted(&mut self, inserted_rows: u16) { + self.visible_history_rows = self + .visible_history_rows + .saturating_add(inserted_rows) + .min(self.viewport_area.top()); + } + + pub fn autoresize(&mut self) -> io::Result<()> { + let screen_size = self.size()?; + if screen_size != self.last_known_screen_size { + self.last_known_screen_size = screen_size; + } + Ok(()) + } + + pub fn draw<F>(&mut self, render_callback: F) -> io::Result<()> + where + F: FnOnce(&mut Frame<'_>), + { + self.autoresize()?; + + let mut frame = Frame { + cursor_position: None, + viewport_area: self.viewport_area, + buffer: self.current_buffer_mut(), + }; + render_callback(&mut frame); + let cursor_position = frame.cursor_position; + + self.flush()?; + match cursor_position { + None => self.hide_cursor()?, + Some(position) => { + self.show_cursor()?; + self.set_cursor_position(position)?; + }, + } + self.swap_buffers(); + Backend::flush(&mut self.backend)?; + Ok(()) + } + + fn current_buffer(&self) -> &Buffer { + &self.buffers[self.current] + } + + fn current_buffer_mut(&mut self) -> &mut Buffer { + &mut self.buffers[self.current] + } + + fn previous_buffer(&self) -> &Buffer { + &self.buffers[1 - self.current] + } + + fn previous_buffer_mut(&mut self) -> &mut Buffer { + &mut self.buffers[1 - self.current] + } + + fn flush(&mut self) -> io::Result<()> { + let updates = diff_buffers(self.previous_buffer(), self.current_buffer()); + let mut last_put = None; + for command in &updates { + if let DrawCommand::Put { x, y, .. } = command { + last_put = Some(Position { x: *x, y: *y }); + } + } + if let Some(position) = last_put { + self.last_known_cursor_pos = position; + } + draw_commands(&mut self.backend, updates.into_iter()) + } + + fn swap_buffers(&mut self) { + self.previous_buffer_mut().reset(); + self.current = 1 - self.current; + } +} + +#[derive(Debug)] +enum DrawCommand { + Put { + x: u16, + y: u16, + cell: ratatui::buffer::Cell, + }, + ClearToEnd { + x: u16, + y: u16, + bg: Color, + }, +} + +fn diff_buffers(a: &Buffer, b: &Buffer) -> Vec<DrawCommand> { + let previous_buffer = &a.content; + let next_buffer = &b.content; + + let mut updates = Vec::new(); + let mut last_nonblank_columns = vec![0; a.area.height as usize]; + for y in 0..a.area.height { + let row_start = y as usize * a.area.width as usize; + let row_end = row_start + a.area.width as usize; + let row = &next_buffer[row_start..row_end]; + let bg = row.last().map(|cell| cell.bg).unwrap_or(Color::Reset); + + let mut last_nonblank_column = 0usize; + let mut column = 0usize; + while column < row.len() { + let cell = &row[column]; + let width = display_width(cell.symbol()); + if cell.symbol() != " " || cell.bg != bg || cell.modifier != Modifier::empty() { + last_nonblank_column = column + width.saturating_sub(1); + } + column += width.max(1); + } + + if last_nonblank_column + 1 < row.len() { + let (x, y) = a.pos_of(row_start + last_nonblank_column + 1); + updates.push(DrawCommand::ClearToEnd { x, y, bg }); + } + last_nonblank_columns[y as usize] = last_nonblank_column as u16; + } + + let mut invalidated = 0usize; + let mut to_skip = 0usize; + for (index, (current, previous)) in next_buffer.iter().zip(previous_buffer.iter()).enumerate() { + if !current.skip && (current != previous || invalidated > 0) && to_skip == 0 { + let (x, y) = a.pos_of(index); + let row = index / a.area.width as usize; + if x <= last_nonblank_columns[row] { + updates.push(DrawCommand::Put { + x, + y, + cell: current.clone(), + }); + } + } + to_skip = display_width(current.symbol()).saturating_sub(1); + let affected_width = display_width(current.symbol()).max(display_width(previous.symbol())); + invalidated = affected_width.max(invalidated).saturating_sub(1); + } + updates +} + +fn draw_commands<I>(writer: &mut impl Write, commands: I) -> io::Result<()> +where + I: Iterator<Item = DrawCommand>, +{ + let mut fg = Color::Reset; + let mut bg = Color::Reset; + let mut modifier = Modifier::empty(); + let mut last_pos: Option<Position> = None; + + for command in commands { + let (x, y) = match &command { + DrawCommand::Put { x, y, .. } | DrawCommand::ClearToEnd { x, y, .. } => (*x, *y), + }; + if !matches!(last_pos, Some(p) if x == p.x + 1 && y == p.y) { + queue!(writer, MoveTo(x, y))?; + } + last_pos = Some(Position { x, y }); + match command { + DrawCommand::Put { cell, .. } => { + if cell.modifier != modifier { + let diff = ModifierDiff { + from: modifier, + to: cell.modifier, + }; + diff.queue(writer)?; + modifier = cell.modifier; + } + if cell.fg != fg || cell.bg != bg { + queue!( + writer, + SetColors(Colors::new( + to_crossterm_color(cell.fg), + to_crossterm_color(cell.bg) + )) + )?; + fg = cell.fg; + bg = cell.bg; + } + queue!(writer, Print(cell.symbol()))?; + }, + DrawCommand::ClearToEnd { bg: clear_bg, .. } => { + queue!(writer, SetAttribute(crossterm::style::Attribute::Reset))?; + modifier = Modifier::empty(); + queue!(writer, SetBackgroundColor(to_crossterm_color(clear_bg)))?; + bg = clear_bg; + queue!(writer, Clear(crossterm::terminal::ClearType::UntilNewLine))?; + }, + } + } + + queue!( + writer, + SetForegroundColor(crossterm::style::Color::Reset), + SetBackgroundColor(crossterm::style::Color::Reset), + SetAttribute(crossterm::style::Attribute::Reset), + )?; + Ok(()) +} + +fn to_crossterm_color(color: Color) -> crossterm::style::Color { + match color { + Color::Reset => crossterm::style::Color::Reset, + Color::Black => crossterm::style::Color::Black, + Color::Red => crossterm::style::Color::DarkRed, + Color::Green => crossterm::style::Color::DarkGreen, + Color::Yellow => crossterm::style::Color::DarkYellow, + Color::Blue => crossterm::style::Color::DarkBlue, + Color::Magenta => crossterm::style::Color::DarkMagenta, + Color::Cyan => crossterm::style::Color::DarkCyan, + Color::Gray => crossterm::style::Color::Grey, + Color::DarkGray => crossterm::style::Color::DarkGrey, + Color::LightRed => crossterm::style::Color::Red, + Color::LightGreen => crossterm::style::Color::Green, + Color::LightYellow => crossterm::style::Color::Yellow, + Color::LightBlue => crossterm::style::Color::Blue, + Color::LightMagenta => crossterm::style::Color::Magenta, + Color::LightCyan => crossterm::style::Color::Cyan, + Color::White => crossterm::style::Color::White, + Color::Rgb(r, g, b) => crossterm::style::Color::Rgb { r, g, b }, + Color::Indexed(index) => crossterm::style::Color::AnsiValue(index), + } +} + +struct ModifierDiff { + from: Modifier, + to: Modifier, +} + +impl ModifierDiff { + fn queue<W: io::Write>(self, writer: &mut W) -> io::Result<()> { + use crossterm::style::Attribute as CAttribute; + let removed = self.from - self.to; + if removed.contains(Modifier::REVERSED) { + queue!(writer, SetAttribute(CAttribute::NoReverse))?; + } + if removed.contains(Modifier::BOLD) { + queue!(writer, SetAttribute(CAttribute::NormalIntensity))?; + if self.to.contains(Modifier::DIM) { + queue!(writer, SetAttribute(CAttribute::Dim))?; + } + } + if removed.contains(Modifier::ITALIC) { + queue!(writer, SetAttribute(CAttribute::NoItalic))?; + } + if removed.contains(Modifier::UNDERLINED) { + queue!(writer, SetAttribute(CAttribute::NoUnderline))?; + } + if removed.contains(Modifier::DIM) { + queue!(writer, SetAttribute(CAttribute::NormalIntensity))?; + } + if removed.contains(Modifier::CROSSED_OUT) { + queue!(writer, SetAttribute(CAttribute::NotCrossedOut))?; + } + if removed.contains(Modifier::SLOW_BLINK) || removed.contains(Modifier::RAPID_BLINK) { + queue!(writer, SetAttribute(CAttribute::NoBlink))?; + } + + let added = self.to - self.from; + if added.contains(Modifier::REVERSED) { + queue!(writer, SetAttribute(CAttribute::Reverse))?; + } + if added.contains(Modifier::BOLD) { + queue!(writer, SetAttribute(CAttribute::Bold))?; + } + if added.contains(Modifier::ITALIC) { + queue!(writer, SetAttribute(CAttribute::Italic))?; + } + if added.contains(Modifier::UNDERLINED) { + queue!(writer, SetAttribute(CAttribute::Underlined))?; + } + if added.contains(Modifier::DIM) { + queue!(writer, SetAttribute(CAttribute::Dim))?; + } + if added.contains(Modifier::CROSSED_OUT) { + queue!(writer, SetAttribute(CAttribute::CrossedOut))?; + } + if added.contains(Modifier::SLOW_BLINK) { + queue!(writer, SetAttribute(CAttribute::SlowBlink))?; + } + if added.contains(Modifier::RAPID_BLINK) { + queue!(writer, SetAttribute(CAttribute::RapidBlink))?; + } + Ok(()) + } +} diff --git a/crates/cli/src/ui/insert_history.rs b/crates/cli/src/ui/insert_history.rs new file mode 100644 index 00000000..7912bd6b --- /dev/null +++ b/crates/cli/src/ui/insert_history.rs @@ -0,0 +1,377 @@ +use std::{fmt, io, io::Write}; + +use crossterm::{ + Command, + cursor::{MoveTo, MoveToColumn, RestorePosition, SavePosition}, + queue, + style::{ + Color as CColor, Colors, Print, SetAttribute, SetBackgroundColor, SetColors, + SetForegroundColor, + }, + terminal::{Clear, ClearType}, +}; +use ratatui::{ + backend::Backend, + layout::Size, + style::{Color, Modifier}, + text::{Line, Span}, +}; + +use super::custom_terminal::Terminal; +use crate::ui::{HistoryLine, materialize_history_line}; + +pub fn insert_history_lines<B>( + terminal: &mut Terminal<B>, + lines: Vec<HistoryLine>, +) -> io::Result<()> +where + B: Backend<Error = io::Error> + Write, +{ + let screen_size = terminal.backend().size().unwrap_or(Size::new(0, 0)); + let mut area = terminal.viewport_area; + let last_cursor_pos = terminal.last_known_cursor_pos; + let mut should_update_area = false; + let writer = terminal.backend_mut(); + + let wrap_width = area.width.max(1) as usize; + let mut wrapped = Vec::new(); + let mut wrapped_rows = 0usize; + for line in &lines { + let parts = wrap_line(line, wrap_width); + wrapped_rows += parts + .iter() + .map(|wrapped_line| wrapped_line.width().max(1).div_ceil(wrap_width)) + .sum::<usize>(); + wrapped.extend(parts); + } + let wrapped_lines = wrapped_rows as u16; + + let cursor_top = if area.bottom() < screen_size.height { + let scroll_amount = wrapped_lines.min(screen_size.height - area.bottom()); + let top_1based = area.top() + 1; + queue!(writer, SetScrollRegion(top_1based..screen_size.height))?; + queue!(writer, MoveTo(0, area.top()))?; + for _ in 0..scroll_amount { + queue!(writer, Print("\x1bM"))?; + } + queue!(writer, ResetScrollRegion)?; + + let cursor_top = area.top().saturating_sub(1); + area.y += scroll_amount; + should_update_area = true; + cursor_top + } else { + area.top().saturating_sub(1) + }; + + queue!(writer, SetScrollRegion(1..area.top()))?; + queue!(writer, MoveTo(0, cursor_top))?; + for line in &wrapped { + queue!(writer, Print("\r\n"))?; + write_history_line(writer, line, wrap_width)?; + } + queue!(writer, ResetScrollRegion)?; + queue!(writer, MoveTo(last_cursor_pos.x, last_cursor_pos.y))?; + let _ = writer; + if should_update_area { + terminal.set_viewport_area(area); + } + + if wrapped_lines > 0 { + terminal.note_history_rows_inserted(wrapped_lines); + } + Ok(()) +} + +fn wrap_line(line: &HistoryLine, width: usize) -> Vec<Line<'static>> { + materialize_history_line(line, width) +} + +fn write_history_line<W: Write>( + writer: &mut W, + line: &Line<'static>, + wrap_width: usize, +) -> io::Result<()> { + let physical_rows = line.width().max(1).div_ceil(wrap_width) as u16; + if physical_rows > 1 { + queue!(writer, SavePosition)?; + for _ in 1..physical_rows { + queue!(writer, crossterm::cursor::MoveDown(1), MoveToColumn(0))?; + queue!(writer, Clear(ClearType::UntilNewLine))?; + } + queue!(writer, RestorePosition)?; + } + queue!( + writer, + SetColors(Colors::new( + line.style + .fg + .map(to_crossterm_color) + .unwrap_or(CColor::Reset), + line.style + .bg + .map(to_crossterm_color) + .unwrap_or(CColor::Reset) + )) + )?; + queue!(writer, Clear(ClearType::UntilNewLine))?; + let merged_spans: Vec<Span<'_>> = line + .spans + .iter() + .map(|span| Span { + style: span.style.patch(line.style), + content: span.content.clone(), + }) + .collect(); + write_spans(writer, merged_spans.iter()) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct SetScrollRegion(pub std::ops::Range<u16>); + +impl Command for SetScrollRegion { + fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { + write!(f, "\x1b[{};{}r", self.0.start, self.0.end) + } + + #[cfg(windows)] + fn execute_winapi(&self) -> io::Result<()> { + Err(io::Error::other( + "tried to execute SetScrollRegion using WinAPI; use ANSI instead", + )) + } + + #[cfg(windows)] + fn is_ansi_code_supported(&self) -> bool { + true + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct ResetScrollRegion; + +impl Command for ResetScrollRegion { + fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { + write!(f, "\x1b[r") + } + + #[cfg(windows)] + fn execute_winapi(&self) -> io::Result<()> { + Err(io::Error::other( + "tried to execute ResetScrollRegion using WinAPI; use ANSI instead", + )) + } + + #[cfg(windows)] + fn is_ansi_code_supported(&self) -> bool { + true + } +} + +struct ModifierDiff { + from: Modifier, + to: Modifier, +} + +impl ModifierDiff { + fn queue<W: io::Write>(self, writer: &mut W) -> io::Result<()> { + use crossterm::style::Attribute as CAttribute; + let removed = self.from - self.to; + if removed.contains(Modifier::REVERSED) { + queue!(writer, SetAttribute(CAttribute::NoReverse))?; + } + if removed.contains(Modifier::BOLD) { + queue!(writer, SetAttribute(CAttribute::NormalIntensity))?; + if self.to.contains(Modifier::DIM) { + queue!(writer, SetAttribute(CAttribute::Dim))?; + } + } + if removed.contains(Modifier::ITALIC) { + queue!(writer, SetAttribute(CAttribute::NoItalic))?; + } + if removed.contains(Modifier::UNDERLINED) { + queue!(writer, SetAttribute(CAttribute::NoUnderline))?; + } + if removed.contains(Modifier::DIM) { + queue!(writer, SetAttribute(CAttribute::NormalIntensity))?; + } + if removed.contains(Modifier::CROSSED_OUT) { + queue!(writer, SetAttribute(CAttribute::NotCrossedOut))?; + } + if removed.contains(Modifier::SLOW_BLINK) || removed.contains(Modifier::RAPID_BLINK) { + queue!(writer, SetAttribute(CAttribute::NoBlink))?; + } + + let added = self.to - self.from; + if added.contains(Modifier::REVERSED) { + queue!(writer, SetAttribute(CAttribute::Reverse))?; + } + if added.contains(Modifier::BOLD) { + queue!(writer, SetAttribute(CAttribute::Bold))?; + } + if added.contains(Modifier::ITALIC) { + queue!(writer, SetAttribute(CAttribute::Italic))?; + } + if added.contains(Modifier::UNDERLINED) { + queue!(writer, SetAttribute(CAttribute::Underlined))?; + } + if added.contains(Modifier::DIM) { + queue!(writer, SetAttribute(CAttribute::Dim))?; + } + if added.contains(Modifier::CROSSED_OUT) { + queue!(writer, SetAttribute(CAttribute::CrossedOut))?; + } + if added.contains(Modifier::SLOW_BLINK) { + queue!(writer, SetAttribute(CAttribute::SlowBlink))?; + } + if added.contains(Modifier::RAPID_BLINK) { + queue!(writer, SetAttribute(CAttribute::RapidBlink))?; + } + Ok(()) + } +} + +fn write_spans<'a, I>(writer: &mut impl Write, content: I) -> io::Result<()> +where + I: IntoIterator<Item = &'a Span<'a>>, +{ + let mut fg = Color::Reset; + let mut bg = Color::Reset; + let mut modifier = Modifier::empty(); + + for span in content { + let mut next_modifier = Modifier::empty(); + next_modifier.insert(span.style.add_modifier); + next_modifier.remove(span.style.sub_modifier); + if next_modifier != modifier { + let diff = ModifierDiff { + from: modifier, + to: next_modifier, + }; + diff.queue(writer)?; + modifier = next_modifier; + } + + let next_fg = span.style.fg.unwrap_or(Color::Reset); + let next_bg = span.style.bg.unwrap_or(Color::Reset); + if next_fg != fg || next_bg != bg { + queue!( + writer, + SetColors(Colors::new( + to_crossterm_color(next_fg), + to_crossterm_color(next_bg) + )) + )?; + fg = next_fg; + bg = next_bg; + } + + queue!(writer, Print(span.content.clone()))?; + } + + queue!( + writer, + SetForegroundColor(CColor::Reset), + SetBackgroundColor(CColor::Reset), + SetAttribute(crossterm::style::Attribute::Reset), + ) +} + +fn to_crossterm_color(color: Color) -> CColor { + match color { + Color::Reset => CColor::Reset, + Color::Black => CColor::Black, + Color::Red => CColor::DarkRed, + Color::Green => CColor::DarkGreen, + Color::Yellow => CColor::DarkYellow, + Color::Blue => CColor::DarkBlue, + Color::Magenta => CColor::DarkMagenta, + Color::Cyan => CColor::DarkCyan, + Color::Gray => CColor::Grey, + Color::DarkGray => CColor::DarkGrey, + Color::LightRed => CColor::Red, + Color::LightGreen => CColor::Green, + Color::LightYellow => CColor::Yellow, + Color::LightBlue => CColor::Blue, + Color::LightMagenta => CColor::Magenta, + Color::LightCyan => CColor::Cyan, + Color::White => CColor::White, + Color::Rgb(r, g, b) => CColor::Rgb { r, g, b }, + Color::Indexed(index) => CColor::AnsiValue(index), + } +} + +#[cfg(test)] +mod tests { + use ratatui::{ + style::{Color, Modifier, Style}, + text::{Line, Span}, + }; + + use super::wrap_line; + use crate::{state::WrappedLineRewrapPolicy, ui::HistoryLine}; + + #[test] + fn wrap_line_preserves_span_styles_after_rewrap() { + let link_style = Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::UNDERLINED); + let line = Line::from(vec![ + Span::raw("> "), + Span::styled("alpha beta", link_style), + ]); + + let wrapped = wrap_line( + &HistoryLine { + line, + rewrap_policy: WrappedLineRewrapPolicy::Reflow, + }, + 8, + ); + + assert_eq!(wrapped.len(), 2); + assert_eq!(wrapped[0].to_string(), "> alpha"); + assert_eq!(wrapped[1].to_string(), "beta"); + assert!(wrapped[0].spans.iter().any(|span| span.style == link_style)); + assert!(wrapped[1].spans.iter().any(|span| span.style == link_style)); + } + + #[test] + fn wrap_line_preserves_style_across_multiple_wrapped_rows() { + let code_style = Style::default().bg(Color::DarkGray); + let line = Line::from(vec![Span::styled("alpha beta gamma", code_style)]); + + let wrapped = wrap_line( + &HistoryLine { + line, + rewrap_policy: WrappedLineRewrapPolicy::Reflow, + }, + 5, + ); + + assert_eq!(wrapped.len(), 3); + assert_eq!(wrapped[0].to_string(), "alpha"); + assert_eq!(wrapped[1].to_string(), "beta"); + assert_eq!(wrapped[2].to_string(), "gamma"); + assert!( + wrapped + .iter() + .all(|line| line.spans.iter().all(|span| span.style == code_style)) + ); + } + + #[test] + fn preserve_and_crop_keeps_single_row() { + let line = Line::from("abcdefghijklmnopqrstuvwxyz"); + let wrapped = wrap_line( + &HistoryLine { + line, + rewrap_policy: WrappedLineRewrapPolicy::PreserveAndCrop, + }, + 8, + ); + + assert_eq!(wrapped.len(), 1); + assert_eq!(wrapped[0].to_string(), "abcdefg…"); + } +} diff --git a/crates/cli/src/ui/markdown.rs b/crates/cli/src/ui/markdown.rs new file mode 100644 index 00000000..e140c1b2 --- /dev/null +++ b/crates/cli/src/ui/markdown.rs @@ -0,0 +1,1879 @@ +use std::borrow::Cow; + +use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag}; +use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; + +use crate::{ + capability::TerminalCapabilities, + state::{ + WrappedLine, WrappedLineRewrapPolicy, WrappedLineStyle, WrappedSpan, WrappedSpanStyle, + }, +}; + +#[derive(Debug, Clone, PartialEq, Eq)] +enum InlineChunk { + Plain(String), + Styled(WrappedSpanStyle, String), +} + +impl InlineChunk { + fn plain(text: impl Into<String>) -> Self { + Self::Plain(text.into()) + } + + fn styled(style: WrappedSpanStyle, text: impl Into<String>) -> Self { + Self::Styled(style, text.into()) + } + + fn style(&self) -> Option<WrappedSpanStyle> { + match self { + Self::Plain(_) => None, + Self::Styled(style, _) => Some(*style), + } + } + + fn text(&self) -> &str { + match self { + Self::Plain(text) | Self::Styled(_, text) => text.as_str(), + } + } + + fn append_text(&mut self, text: &str) { + match self { + Self::Plain(current) | Self::Styled(_, current) => current.push_str(text), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum InlineNode { + Text(String), + Code(String), + SoftBreak, + HardBreak, + Styled(WrappedSpanStyle, Vec<InlineNode>), + Link { + label: Vec<InlineNode>, + destination: String, + }, + Image { + alt: Vec<InlineNode>, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum BlockNode { + Paragraph(Vec<InlineNode>), + Heading { + level: usize, + content: Vec<InlineNode>, + }, + BlockQuote(Vec<BlockNode>), + List { + start: Option<u64>, + items: Vec<Vec<BlockNode>>, + }, + CodeBlock { + info: Option<String>, + content: String, + }, + Table { + headers: Vec<Vec<InlineNode>>, + rows: Vec<Vec<Vec<InlineNode>>>, + }, + Rule, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum InlineTerminator { + Paragraph, + Heading, + Emphasis, + Strong, + Link, + Image, + TableCell, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum BlockTerminator { + BlockQuote, + Item, +} + +#[derive(Debug, Clone, Copy)] +struct TableChars<'a> { + top_left: &'a str, + top_mid: &'a str, + top_right: &'a str, + mid_left: &'a str, + mid_mid: &'a str, + mid_right: &'a str, + bottom_left: &'a str, + bottom_mid: &'a str, + bottom_right: &'a str, + horizontal: &'a str, + vertical: &'a str, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct RichTableRow { + cells: Vec<Vec<InlineChunk>>, + is_separator: bool, + is_header: bool, +} + +pub(crate) fn render_markdown_lines( + text: &str, + width: usize, + capabilities: TerminalCapabilities, + style: WrappedLineStyle, +) -> Vec<WrappedLine> { + if width == 0 { + return vec![WrappedLine::plain(style, String::new())]; + } + + let segments = split_markdown_segments(text); + let mut output = Vec::new(); + + for segment in segments { + match segment { + MarkdownSegment::Blank => output.push(WrappedLine::plain(style, String::new())), + MarkdownSegment::Text(block) => { + let nodes = parse_markdown_to_blocks(block.as_str()); + output.extend(layout_blocks(&nodes, width, capabilities, style)); + }, + MarkdownSegment::Preformatted(lines) => output.extend(render_preserved_lines( + &strip_pseudo_fence_lines(lines), + style, + WrappedSpanStyle::TextArt, + )), + } + } + + if output.is_empty() { + output.push(WrappedLine::plain(style, String::new())); + } + output +} + +#[cfg(test)] +pub(crate) fn wrap_text( + text: &str, + width: usize, + capabilities: TerminalCapabilities, +) -> Vec<String> { + render_markdown_lines(text, width, capabilities, WrappedLineStyle::Plain) + .into_iter() + .map(|line| line.text()) + .collect() +} + +pub(crate) fn render_literal_text( + text: &str, + width: usize, + capabilities: TerminalCapabilities, +) -> Vec<String> { + if width == 0 { + return vec![String::new()]; + } + + let mut output = Vec::new(); + for line in text.lines() { + let trimmed = line.trim_end(); + if trimmed.is_empty() { + output.push(String::new()); + continue; + } + output.extend(wrap_paragraph(trimmed, width, capabilities)); + } + + if output.is_empty() { + output.push(String::new()); + } + output +} + +pub(crate) fn render_preformatted_block( + body: &str, + _width: usize, + _capabilities: TerminalCapabilities, +) -> Vec<String> { + let mut lines = body + .lines() + .map(|line| line.trim_end().to_string()) + .collect::<Vec<_>>(); + if lines.is_empty() { + lines.push(String::new()); + } + lines +} + +#[cfg(test)] +pub(crate) fn flatten_inline_text(text: &str) -> String { + flatten_inline_markdown(text) +} + +fn render_blocks( + blocks: &[BlockNode], + width: usize, + capabilities: TerminalCapabilities, + style: WrappedLineStyle, +) -> Vec<WrappedLine> { + let mut output = Vec::new(); + for block in blocks { + output.extend(render_block(block, width, capabilities, style)); + } + output +} + +fn parse_markdown_to_blocks(text: &str) -> Vec<BlockNode> { + parse_markdown_segment(text) +} + +fn layout_blocks( + blocks: &[BlockNode], + width: usize, + capabilities: TerminalCapabilities, + style: WrappedLineStyle, +) -> Vec<WrappedLine> { + render_blocks(blocks, width, capabilities, style) +} + +#[cfg(test)] +fn flatten_inline_markdown(text: &str) -> String { + flatten_inline_chunks(render_inline_chunks(&parse_inline_segment(text))) +} + +fn render_block( + block: &BlockNode, + width: usize, + capabilities: TerminalCapabilities, + style: WrappedLineStyle, +) -> Vec<WrappedLine> { + match block { + BlockNode::Paragraph(content) => render_inline_block(content, width, capabilities, style), + BlockNode::Heading { level, content } => { + render_heading_block(*level, content, width, capabilities, style) + }, + BlockNode::BlockQuote(blocks) => { + let quoted = render_blocks( + blocks, + width + .saturating_sub(display_width(quote_prefix(capabilities))) + .max(1), + capabilities, + style, + ); + prefix_lines( + quoted, + vec![InlineChunk::styled( + WrappedSpanStyle::QuoteMarker, + quote_prefix(capabilities), + )], + vec![InlineChunk::styled( + WrappedSpanStyle::QuoteMarker, + quote_prefix(capabilities), + )], + style, + ) + }, + BlockNode::List { start, items } => { + render_list_block(items, *start, width, capabilities, style) + }, + BlockNode::CodeBlock { info, content } => render_code_block( + info.as_deref(), + content.as_str(), + width, + capabilities, + style, + ), + BlockNode::Table { headers, rows } => { + render_table_from_nodes(headers, rows, width, capabilities, style) + }, + BlockNode::Rule => vec![ + rich_line( + style, + vec![InlineChunk::styled( + WrappedSpanStyle::HeadingRule, + render_horizontal_rule(width, capabilities), + )], + ) + .with_rewrap_policy(WrappedLineRewrapPolicy::PreserveAndCrop), + ], + } +} + +fn render_inline_block( + content: &[InlineNode], + width: usize, + capabilities: TerminalCapabilities, + style: WrappedLineStyle, +) -> Vec<WrappedLine> { + split_inline_sections(content) + .into_iter() + .flat_map(|section| { + wrap_inline_chunks(render_inline_chunks(§ion), width, capabilities) + .into_iter() + .map(|chunks| rich_line(style, chunks)) + .collect::<Vec<_>>() + }) + .collect() +} + +fn render_heading_block( + level: usize, + content: &[InlineNode], + width: usize, + capabilities: TerminalCapabilities, + style: WrappedLineStyle, +) -> Vec<WrappedLine> { + let mut chunks = render_inline_chunks(content); + apply_style_to_plain_chunks(&mut chunks, WrappedSpanStyle::Heading); + let text = flatten_inline_chunks(chunks.clone()); + let mut lines = wrap_inline_chunks(chunks, width, capabilities) + .into_iter() + .map(|chunks| rich_line(style, chunks)) + .collect::<Vec<_>>(); + if level <= 2 { + let underline = render_heading_rule(level, text.as_str(), width, capabilities); + lines.push(rich_line( + style, + vec![InlineChunk::styled( + WrappedSpanStyle::HeadingRule, + underline, + )], + )); + } + lines +} + +fn render_list_block( + items: &[Vec<BlockNode>], + start: Option<u64>, + width: usize, + capabilities: TerminalCapabilities, + style: WrappedLineStyle, +) -> Vec<WrappedLine> { + let mut output = Vec::new(); + for (index, item) in items.iter().enumerate() { + let marker = match start { + Some(value) => format!("{}. ", value + index as u64), + None => "- ".to_string(), + }; + let indent = " ".repeat(display_width(marker.as_str())); + let rendered = render_blocks( + item, + width.saturating_sub(display_width(marker.as_str())).max(1), + capabilities, + style, + ); + output.extend(prefix_lines( + rendered, + vec![InlineChunk::styled( + WrappedSpanStyle::ListMarker, + marker.clone(), + )], + vec![InlineChunk::plain(indent)], + style, + )); + } + output +} + +fn render_code_block( + _info: Option<&str>, + content: &str, + _width: usize, + _capabilities: TerminalCapabilities, + style: WrappedLineStyle, +) -> Vec<WrappedLine> { + let lines = split_preserved_block_lines(content); + let text_art = should_use_preformatted_fallback(content); + render_preserved_lines( + &lines, + style, + if text_art { + WrappedSpanStyle::TextArt + } else { + WrappedSpanStyle::CodeText + }, + ) +} + +fn render_table_from_nodes( + headers: &[Vec<InlineNode>], + rows: &[Vec<Vec<InlineNode>>], + width: usize, + capabilities: TerminalCapabilities, + style: WrappedLineStyle, +) -> Vec<WrappedLine> { + render_rich_table_rows(headers, rows, width, capabilities, style) +} + +fn split_inline_sections(content: &[InlineNode]) -> Vec<Vec<InlineNode>> { + let mut sections = Vec::new(); + let mut current = Vec::new(); + + for node in content { + if matches!(node, InlineNode::HardBreak) { + sections.push(std::mem::take(&mut current)); + continue; + } + current.push(node.clone()); + } + + if !current.is_empty() || sections.is_empty() { + sections.push(current); + } + sections +} + +fn render_inline_chunks(nodes: &[InlineNode]) -> Vec<InlineChunk> { + let mut output = Vec::new(); + for node in nodes { + match node { + InlineNode::Text(text) => push_chunk(&mut output, InlineChunk::plain(text.clone())), + InlineNode::Code(text) => push_chunk( + &mut output, + InlineChunk::styled(WrappedSpanStyle::InlineCode, text.clone()), + ), + InlineNode::SoftBreak => push_chunk(&mut output, InlineChunk::plain(" ")), + InlineNode::HardBreak => {}, + InlineNode::Styled(style, children) => { + let mut nested = render_inline_chunks(children); + apply_style_to_plain_chunks(&mut nested, *style); + output.extend(nested); + }, + InlineNode::Link { label, destination } => { + let label = flatten_inline_chunks(render_inline_chunks(label)); + let rendered = + if destination.is_empty() || label.is_empty() || label == *destination { + label + } else { + format!("{label} ({destination})") + }; + push_chunk( + &mut output, + InlineChunk::styled(WrappedSpanStyle::Link, rendered), + ); + }, + InlineNode::Image { alt } => { + let alt = flatten_inline_chunks(render_inline_chunks(alt)); + let rendered = if alt.is_empty() { + "[image]".to_string() + } else { + alt + }; + push_chunk(&mut output, InlineChunk::plain(rendered)); + }, + } + } + output +} + +fn prefix_lines( + lines: Vec<WrappedLine>, + first_prefix: Vec<InlineChunk>, + rest_prefix: Vec<InlineChunk>, + style: WrappedLineStyle, +) -> Vec<WrappedLine> { + if lines.is_empty() { + return vec![rich_line(style, first_prefix)]; + } + + lines + .into_iter() + .enumerate() + .map(|(index, line)| { + let prefix = if index == 0 { + first_prefix.clone() + } else { + rest_prefix.clone() + }; + prepend_inline_chunks(line, prefix) + }) + .collect() +} + +fn rich_line(style: WrappedLineStyle, chunks: Vec<InlineChunk>) -> WrappedLine { + WrappedLine::from_spans( + style, + chunks + .into_iter() + .filter_map(|chunk| match chunk { + InlineChunk::Plain(text) if text.is_empty() => None, + InlineChunk::Plain(text) => Some(WrappedSpan::plain(text)), + InlineChunk::Styled(_, text) if text.is_empty() => None, + InlineChunk::Styled(span_style, text) => { + Some(WrappedSpan::styled(span_style, text)) + }, + }) + .collect(), + ) +} + +fn prepend_inline_chunks(mut line: WrappedLine, prefix: Vec<InlineChunk>) -> WrappedLine { + if prefix.is_empty() { + return line; + } + let mut spans = prefix + .into_iter() + .filter_map(|chunk| match chunk { + InlineChunk::Plain(text) if text.is_empty() => None, + InlineChunk::Plain(text) => Some(WrappedSpan::plain(text)), + InlineChunk::Styled(_, text) if text.is_empty() => None, + InlineChunk::Styled(style, text) => Some(WrappedSpan::styled(style, text)), + }) + .collect::<Vec<_>>(); + spans.extend(line.spans); + line.spans = spans; + line +} + +fn push_chunk(output: &mut Vec<InlineChunk>, chunk: InlineChunk) { + if chunk.text().is_empty() { + return; + } + if let Some(last) = output.last_mut() { + if last.style() == chunk.style() { + last.append_text(chunk.text()); + return; + } + } + output.push(chunk); +} + +fn apply_style_to_plain_chunks(chunks: &mut [InlineChunk], style: WrappedSpanStyle) { + for chunk in chunks { + if let InlineChunk::Plain(text) = chunk { + *chunk = InlineChunk::styled(style, text.clone()); + } + } +} + +fn render_heading_rule( + level: usize, + text: &str, + width: usize, + capabilities: TerminalCapabilities, +) -> String { + let glyph = match (level, capabilities.ascii_only()) { + (1, false) => "═", + (1, true) => "=", + (_, false) => "─", + (_, true) => "-", + }; + glyph.repeat(display_width(text).clamp(3, width.clamp(3, 48))) +} + +fn quote_prefix(capabilities: TerminalCapabilities) -> &'static str { + if capabilities.ascii_only() { + "| " + } else { + "│ " + } +} + +fn render_horizontal_rule(width: usize, capabilities: TerminalCapabilities) -> String { + let glyph = if capabilities.ascii_only() { + "-" + } else { + "─" + }; + glyph.repeat(width.clamp(3, 48)) +} + +fn parse_markdown_segment(text: &str) -> Vec<BlockNode> { + let options = + Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TASKLISTS; + let events = Parser::new_ext(text, options).collect::<Vec<_>>(); + let mut index = 0; + parse_blocks_until(&events, &mut index, None) +} + +fn parse_blocks_until<'a>( + events: &[Event<'a>], + index: &mut usize, + terminator: Option<BlockTerminator>, +) -> Vec<BlockNode> { + let mut blocks = Vec::new(); + + while *index < events.len() { + match &events[*index] { + Event::End(tag) + if terminator.is_some_and(|term| matches_block_terminator(tag, term)) => + { + *index += 1; + break; + }, + Event::Start(Tag::Paragraph) => { + *index += 1; + blocks.push(BlockNode::Paragraph(parse_inlines_until( + events, + index, + InlineTerminator::Paragraph, + ))); + }, + Event::Start(Tag::Heading(level, ..)) => { + let level = heading_level(*level); + *index += 1; + blocks.push(BlockNode::Heading { + level, + content: parse_inlines_until(events, index, InlineTerminator::Heading), + }); + }, + Event::Start(Tag::BlockQuote) => { + *index += 1; + blocks.push(BlockNode::BlockQuote(parse_blocks_until( + events, + index, + Some(BlockTerminator::BlockQuote), + ))); + }, + Event::Start(Tag::List(start)) => { + let start = *start; + *index += 1; + let mut items = Vec::new(); + while *index < events.len() { + match &events[*index] { + Event::Start(Tag::Item) => { + *index += 1; + items.push(parse_blocks_until( + events, + index, + Some(BlockTerminator::Item), + )); + }, + Event::End(Tag::List(_)) => { + *index += 1; + break; + }, + _ => *index += 1, + } + } + blocks.push(BlockNode::List { start, items }); + }, + Event::Start(Tag::CodeBlock(kind)) => { + let info = match kind { + CodeBlockKind::Fenced(info) => Some(info.to_string()), + CodeBlockKind::Indented => None, + }; + *index += 1; + blocks.push(BlockNode::CodeBlock { + info, + content: parse_code_block(events, index), + }); + }, + Event::Start(Tag::Table(_)) => { + *index += 1; + blocks.push(parse_table(events, index)); + }, + Event::Rule => { + blocks.push(BlockNode::Rule); + *index += 1; + }, + Event::Html(html) | Event::Text(html) => { + let text = html.to_string(); + *index += 1; + if !text.trim().is_empty() { + blocks.push(BlockNode::Paragraph(vec![InlineNode::Text(text)])); + } + }, + Event::Code(_) + | Event::SoftBreak + | Event::HardBreak + | Event::TaskListMarker(_) + | Event::Start(Tag::Emphasis) + | Event::Start(Tag::Strong) + | Event::Start(Tag::Strikethrough) + | Event::Start(Tag::Link(..)) + | Event::Start(Tag::Image(..)) => blocks.push(BlockNode::Paragraph( + parse_inline_flow_until_block_boundary(events, index, terminator), + )), + _ => *index += 1, + } + } + + blocks +} + +fn parse_inlines_until<'a>( + events: &[Event<'a>], + index: &mut usize, + terminator: InlineTerminator, +) -> Vec<InlineNode> { + let mut nodes = Vec::new(); + + while *index < events.len() { + match &events[*index] { + Event::End(tag) if matches_inline_terminator(tag, terminator) => { + *index += 1; + break; + }, + Event::Text(text) => { + push_inline_text(&mut nodes, text.as_ref()); + *index += 1; + }, + Event::Code(text) => { + nodes.push(InlineNode::Code(text.to_string())); + *index += 1; + }, + Event::SoftBreak => { + nodes.push(InlineNode::SoftBreak); + *index += 1; + }, + Event::HardBreak => { + nodes.push(InlineNode::HardBreak); + *index += 1; + }, + Event::TaskListMarker(checked) => { + push_inline_text(&mut nodes, if *checked { "[x] " } else { "[ ] " }); + *index += 1; + }, + Event::Start(Tag::Emphasis) => { + *index += 1; + nodes.push(InlineNode::Styled( + WrappedSpanStyle::Emphasis, + parse_inlines_until(events, index, InlineTerminator::Emphasis), + )); + }, + Event::Start(Tag::Strong) => { + *index += 1; + nodes.push(InlineNode::Styled( + WrappedSpanStyle::Strong, + parse_inlines_until(events, index, InlineTerminator::Strong), + )); + }, + Event::Start(Tag::Strikethrough) => { + *index += 1; + nodes.push(InlineNode::Styled( + WrappedSpanStyle::Emphasis, + parse_inlines_until(events, index, InlineTerminator::Emphasis), + )); + }, + Event::Start(Tag::Link(_, destination, _)) => { + let destination = destination.to_string(); + *index += 1; + nodes.push(InlineNode::Link { + label: parse_inlines_until(events, index, InlineTerminator::Link), + destination, + }); + }, + Event::Start(Tag::Image(_, _, _)) => { + *index += 1; + nodes.push(InlineNode::Image { + alt: parse_inlines_until(events, index, InlineTerminator::Image), + }); + }, + Event::Html(html) => { + push_inline_text(&mut nodes, html.as_ref()); + *index += 1; + }, + _ => *index += 1, + } + } + + nodes +} + +fn parse_inline_flow_until_block_boundary<'a>( + events: &[Event<'a>], + index: &mut usize, + terminator: Option<BlockTerminator>, +) -> Vec<InlineNode> { + let mut nodes = Vec::new(); + + while *index < events.len() { + match &events[*index] { + Event::End(tag) + if terminator.is_some_and(|term| matches_block_terminator(tag, term)) => + { + break; + }, + Event::Start(Tag::Paragraph) + | Event::Start(Tag::Heading(..)) + | Event::Start(Tag::BlockQuote) + | Event::Start(Tag::List(_)) + | Event::Start(Tag::CodeBlock(_)) + | Event::Start(Tag::Table(_)) + | Event::Rule => break, + Event::Text(text) => { + push_inline_text(&mut nodes, text.as_ref()); + *index += 1; + }, + Event::Code(text) => { + nodes.push(InlineNode::Code(text.to_string())); + *index += 1; + }, + Event::SoftBreak => { + nodes.push(InlineNode::SoftBreak); + *index += 1; + }, + Event::HardBreak => { + nodes.push(InlineNode::HardBreak); + *index += 1; + }, + Event::TaskListMarker(checked) => { + push_inline_text(&mut nodes, if *checked { "[x] " } else { "[ ] " }); + *index += 1; + }, + Event::Start(Tag::Emphasis) => { + *index += 1; + nodes.push(InlineNode::Styled( + WrappedSpanStyle::Emphasis, + parse_inlines_until(events, index, InlineTerminator::Emphasis), + )); + }, + Event::Start(Tag::Strong) => { + *index += 1; + nodes.push(InlineNode::Styled( + WrappedSpanStyle::Strong, + parse_inlines_until(events, index, InlineTerminator::Strong), + )); + }, + Event::Start(Tag::Strikethrough) => { + *index += 1; + nodes.push(InlineNode::Styled( + WrappedSpanStyle::Emphasis, + parse_inlines_until(events, index, InlineTerminator::Emphasis), + )); + }, + Event::Start(Tag::Link(_, destination, _)) => { + let destination = destination.to_string(); + *index += 1; + nodes.push(InlineNode::Link { + label: parse_inlines_until(events, index, InlineTerminator::Link), + destination, + }); + }, + Event::Start(Tag::Image(_, _, _)) => { + *index += 1; + nodes.push(InlineNode::Image { + alt: parse_inlines_until(events, index, InlineTerminator::Image), + }); + }, + Event::Html(html) => { + push_inline_text(&mut nodes, html.as_ref()); + *index += 1; + }, + _ => *index += 1, + } + } + + nodes +} + +fn parse_code_block<'a>(events: &[Event<'a>], index: &mut usize) -> String { + let mut content = String::new(); + + while *index < events.len() { + match &events[*index] { + Event::End(Tag::CodeBlock(_)) => { + *index += 1; + break; + }, + Event::Text(text) | Event::Html(text) => { + content.push_str(text.as_ref()); + *index += 1; + }, + Event::SoftBreak | Event::HardBreak => { + content.push('\n'); + *index += 1; + }, + _ => *index += 1, + } + } + + content +} + +fn parse_table<'a>(events: &[Event<'a>], index: &mut usize) -> BlockNode { + let mut headers = Vec::new(); + let mut rows = Vec::new(); + + while *index < events.len() { + match &events[*index] { + Event::Start(Tag::TableHead) => { + *index += 1; + headers = parse_table_head(events, index); + }, + Event::Start(Tag::TableRow) => rows.push(parse_table_row(events, index)), + Event::End(Tag::Table(_)) => { + *index += 1; + break; + }, + _ => *index += 1, + } + } + + BlockNode::Table { headers, rows } +} + +fn parse_table_head<'a>(events: &[Event<'a>], index: &mut usize) -> Vec<Vec<InlineNode>> { + let mut cells = Vec::new(); + + while *index < events.len() { + match &events[*index] { + Event::Start(Tag::TableCell) => { + *index += 1; + cells.push(parse_inlines_until( + events, + index, + InlineTerminator::TableCell, + )); + }, + Event::End(Tag::TableHead) => { + *index += 1; + break; + }, + _ => *index += 1, + } + } + + cells +} + +fn parse_table_row<'a>(events: &[Event<'a>], index: &mut usize) -> Vec<Vec<InlineNode>> { + let mut cells = Vec::new(); + *index += 1; + + while *index < events.len() { + match &events[*index] { + Event::Start(Tag::TableCell) => { + *index += 1; + cells.push(parse_inlines_until( + events, + index, + InlineTerminator::TableCell, + )); + }, + Event::End(Tag::TableRow) => { + *index += 1; + break; + }, + _ => *index += 1, + } + } + + cells +} + +fn push_inline_text(nodes: &mut Vec<InlineNode>, text: &str) { + if text.is_empty() { + return; + } + if let Some(InlineNode::Text(existing)) = nodes.last_mut() { + existing.push_str(text); + } else { + nodes.push(InlineNode::Text(text.to_string())); + } +} + +fn matches_inline_terminator(tag: &Tag<'_>, terminator: InlineTerminator) -> bool { + match terminator { + InlineTerminator::Paragraph => matches!(tag, Tag::Paragraph), + InlineTerminator::Heading => matches!(tag, Tag::Heading(..)), + InlineTerminator::Emphasis => matches!(tag, Tag::Emphasis | Tag::Strikethrough), + InlineTerminator::Strong => matches!(tag, Tag::Strong), + InlineTerminator::Link => matches!(tag, Tag::Link(..)), + InlineTerminator::Image => matches!(tag, Tag::Image(..)), + InlineTerminator::TableCell => matches!(tag, Tag::TableCell), + } +} + +fn matches_block_terminator(tag: &Tag<'_>, terminator: BlockTerminator) -> bool { + match terminator { + BlockTerminator::BlockQuote => matches!(tag, Tag::BlockQuote), + BlockTerminator::Item => matches!(tag, Tag::Item), + } +} + +fn heading_level(level: HeadingLevel) -> usize { + match level { + HeadingLevel::H1 => 1, + HeadingLevel::H2 => 2, + HeadingLevel::H3 => 3, + HeadingLevel::H4 => 4, + HeadingLevel::H5 => 5, + HeadingLevel::H6 => 6, + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum MarkdownSegment { + Blank, + Text(String), + Preformatted(Vec<String>), +} + +fn split_markdown_segments(text: &str) -> Vec<MarkdownSegment> { + let mut segments = Vec::new(); + let mut current = Vec::new(); + let mut in_fence = false; + let mut fence_marker = ""; + + for line in text.lines() { + let trimmed = line.trim_end(); + let fence = fence_delimiter(trimmed); + if !in_fence && trimmed.is_empty() { + if !current.is_empty() { + segments.push(segment_from_lines(¤t)); + current.clear(); + } + segments.push(MarkdownSegment::Blank); + continue; + } + + current.push(trimmed.to_string()); + if let Some(marker) = fence { + if in_fence && trimmed.trim_start().starts_with(fence_marker) { + in_fence = false; + fence_marker = ""; + } else if !in_fence { + in_fence = true; + fence_marker = marker; + } + } + } + + if !current.is_empty() { + segments.push(segment_from_lines(¤t)); + } + + if segments.is_empty() { + segments.push(MarkdownSegment::Text(String::new())); + } + + segments +} + +fn segment_from_lines(lines: &[String]) -> MarkdownSegment { + let block = lines.join("\n"); + if should_use_preformatted_fallback(block.as_str()) { + MarkdownSegment::Preformatted(lines.to_vec()) + } else { + MarkdownSegment::Text(block) + } +} + +fn should_use_preformatted_fallback(block: &str) -> bool { + let lines = block + .lines() + .map(str::trim_end) + .filter(|line| !line.is_empty()) + .collect::<Vec<_>>(); + if lines.is_empty() || looks_like_markdown_table(&lines) { + return false; + } + + let boxy = lines + .iter() + .filter(|line| contains_box_drawing(line)) + .count(); + let treey = lines + .iter() + .filter(|line| contains_tree_connectors(line)) + .count(); + let shelly = lines + .iter() + .filter(|line| { + line.contains("=>") || line.contains("::") || line.contains(" ") || line.contains('\t') + }) + .count(); + + boxy >= 2 + || treey >= 2 + || lines + .iter() + .any(|line| contains_tree_connectors(line) && line.split_whitespace().count() >= 4) + || (boxy >= 1 && shelly >= 2) +} + +fn looks_like_markdown_table(lines: &[&str]) -> bool { + if lines.len() < 2 { + return false; + } + is_table_line(lines[0]) && is_table_line(lines[1]) +} + +fn contains_box_drawing(line: &str) -> bool { + line.chars().any(|ch| { + matches!( + ch, + '│' | '─' + | '┌' + | '┐' + | '└' + | '┘' + | '├' + | '┤' + | '┬' + | '┴' + | '┼' + | '═' + | '╭' + | '╮' + | '╰' + | '╯' + ) + }) +} + +fn contains_tree_connectors(line: &str) -> bool { + line.contains("├") + || line.contains("└") + || line.contains("│") + || line.contains("┬") + || line.contains("┴") + || line.contains("┼") + || line.contains("──") + || line.contains("|--") + || line.contains("+-") +} + +fn strip_pseudo_fence_lines(mut lines: Vec<String>) -> Vec<String> { + if lines.len() >= 3 + && is_pseudo_fence_line(&lines[0]) + && lines.last().is_some_and(|line| is_pseudo_fence_line(line)) + { + lines.remove(0); + lines.pop(); + } + if lines.is_empty() { + vec![String::new()] + } else { + lines + } +} + +fn is_pseudo_fence_line(line: &str) -> bool { + let trimmed = line.trim(); + trimmed.len() >= 2 && trimmed.chars().all(|ch| ch == '`' || ch == '~') +} + +fn fence_delimiter(line: &str) -> Option<&'static str> { + let trimmed = line.trim_start(); + if trimmed.starts_with("```") { + Some("```") + } else if trimmed.starts_with("~~~") { + Some("~~~") + } else { + None + } +} + +fn is_table_line(line: &str) -> bool { + let trimmed = line.trim(); + trimmed.starts_with('|') && trimmed.ends_with('|') && trimmed.matches('|').count() >= 2 +} + +#[cfg(test)] +fn parse_inline_segment(text: &str) -> Vec<InlineNode> { + let options = + Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TASKLISTS; + let markdown = format!("{text}\n"); + let events = Parser::new_ext(markdown.as_str(), options).collect::<Vec<_>>(); + let mut index = 0; + + while index < events.len() { + if matches!(events[index], Event::Start(Tag::Paragraph)) { + index += 1; + return parse_inlines_until(&events, &mut index, InlineTerminator::Paragraph); + } + index += 1; + } + + vec![InlineNode::Text(text.to_string())] +} + +fn wrap_paragraph(text: &str, width: usize, capabilities: TerminalCapabilities) -> Vec<String> { + wrap_with_prefix(text, width, capabilities, "", "") +} + +fn wrap_with_prefix( + text: &str, + width: usize, + capabilities: TerminalCapabilities, + first_prefix: &str, + subsequent_prefix: &str, +) -> Vec<String> { + let tokens = text.split_whitespace().collect::<Vec<_>>(); + let mut lines = Vec::new(); + let mut current_prefix = first_prefix; + let mut current = current_prefix.to_string(); + let mut current_width = display_width(current_prefix); + let first_prefix_width = display_width(first_prefix); + let subsequent_prefix_width = display_width(subsequent_prefix); + let first_available = width.saturating_sub(first_prefix_width).max(1); + let subsequent_available = width.saturating_sub(subsequent_prefix_width).max(1); + let mut current_available = first_available; + + for token in tokens { + for chunk in split_token_by_width(token, current_available.max(1), capabilities) { + let chunk_width = display_width(chunk.as_ref()); + let needs_space = current_width > display_width(current_prefix); + let space_width = usize::from(needs_space); + + if current_width > display_width(current_prefix) + && current_width + space_width + chunk_width > width + { + lines.push(current); + current_prefix = subsequent_prefix; + current = current_prefix.to_string(); + current_width = display_width(current_prefix); + current_available = subsequent_available; + } + + if current_width > display_width(current_prefix) { + current.push(' '); + current_width += 1; + } + + current.push_str(chunk.as_ref()); + current_width += chunk_width; + + if current_width == display_width(current_prefix) { + current_available = subsequent_available; + } + } + } + + if current_width > display_width(current_prefix) || lines.is_empty() { + lines.push(current); + } + lines +} + +fn render_preserved_lines( + lines: &[String], + style: WrappedLineStyle, + span_style: WrappedSpanStyle, +) -> Vec<WrappedLine> { + let lines = if lines.is_empty() { + vec![String::new()] + } else { + lines.to_vec() + }; + lines + .into_iter() + .map(|line| { + rich_line(style, vec![InlineChunk::styled(span_style, line)]) + .with_rewrap_policy(WrappedLineRewrapPolicy::PreserveAndCrop) + }) + .collect() +} + +fn split_preserved_block_lines(content: &str) -> Vec<String> { + if content.is_empty() { + vec![String::new()] + } else { + content + .trim_end_matches('\n') + .split('\n') + .map(ToString::to_string) + .collect() + } +} + +fn render_rich_table_rows( + headers: &[Vec<InlineNode>], + rows: &[Vec<Vec<InlineNode>>], + width: usize, + capabilities: TerminalCapabilities, + style: WrappedLineStyle, +) -> Vec<WrappedLine> { + let mut rich_rows = Vec::new(); + if !headers.is_empty() { + rich_rows.push(RichTableRow { + cells: headers + .iter() + .map(|cell| render_inline_chunks(cell)) + .collect(), + is_separator: false, + is_header: true, + }); + rich_rows.push(RichTableRow { + cells: vec![Vec::new(); headers.len()], + is_separator: true, + is_header: false, + }); + } + rich_rows.extend(rows.iter().map(|row| RichTableRow { + cells: row.iter().map(|cell| render_inline_chunks(cell)).collect(), + is_separator: false, + is_header: false, + })); + + let col_count = rich_rows + .iter() + .map(|row| row.cells.len()) + .max() + .unwrap_or(0); + if col_count == 0 { + return Vec::new(); + } + + let col_widths = compute_rich_table_widths(&rich_rows, width); + let chars = table_chars(capabilities); + let mut rendered = vec![ + rich_line( + style, + border_chunks( + &col_widths, + chars.top_left, + chars.top_mid, + chars.top_right, + chars.horizontal, + ), + ) + .with_rewrap_policy(WrappedLineRewrapPolicy::PreserveAndCrop), + ]; + for row in &rich_rows { + if row.is_separator { + rendered.push( + rich_line( + style, + border_chunks( + &col_widths, + chars.mid_left, + chars.mid_mid, + chars.mid_right, + chars.horizontal, + ), + ) + .with_rewrap_policy(WrappedLineRewrapPolicy::PreserveAndCrop), + ); + continue; + } + + rendered.push( + rich_line( + style, + boxed_rich_table_row_chunks(row, &col_widths, chars.vertical), + ) + .with_rewrap_policy(WrappedLineRewrapPolicy::PreserveAndCrop), + ); + } + + rendered.push( + rich_line( + style, + border_chunks( + &col_widths, + chars.bottom_left, + chars.bottom_mid, + chars.bottom_right, + chars.horizontal, + ), + ) + .with_rewrap_policy(WrappedLineRewrapPolicy::PreserveAndCrop), + ); + rendered +} + +fn compute_rich_table_widths(rows: &[RichTableRow], width: usize) -> Vec<usize> { + let col_count = rows.iter().map(|row| row.cells.len()).max().unwrap_or(0); + let mut col_widths = vec![3usize; col_count]; + for row in rows { + if row.is_separator { + continue; + } + for (index, cell) in row.cells.iter().enumerate() { + col_widths[index] = col_widths[index].max(inline_chunks_width(cell).min(40)); + } + } + + let min_widths = vec![3usize; col_count]; + let separator_width = col_count * 3 + 1; + let max_budget = width.saturating_sub(separator_width); + while col_widths.iter().sum::<usize>() > max_budget { + let Some((index, _)) = col_widths + .iter() + .enumerate() + .filter(|(index, value)| **value > min_widths[*index]) + .max_by_key(|(_, value)| **value) + else { + break; + }; + col_widths[index] = col_widths[index].saturating_sub(1); + } + col_widths +} + +fn border_chunks( + col_widths: &[usize], + left: &str, + middle: &str, + right: &str, + horizontal: &str, +) -> Vec<InlineChunk> { + let mut chunks = vec![InlineChunk::styled( + WrappedSpanStyle::TableBorder, + left.to_string(), + )]; + for (index, width) in col_widths.iter().enumerate() { + chunks.push(InlineChunk::styled( + WrappedSpanStyle::TableBorder, + horizontal.repeat(width.saturating_add(2)), + )); + chunks.push(InlineChunk::styled( + WrappedSpanStyle::TableBorder, + if index + 1 == col_widths.len() { + right.to_string() + } else { + middle.to_string() + }, + )); + } + chunks +} + +fn boxed_rich_table_row_chunks( + row: &RichTableRow, + col_widths: &[usize], + vertical: &str, +) -> Vec<InlineChunk> { + let mut chunks = vec![InlineChunk::styled( + WrappedSpanStyle::TableBorder, + vertical.to_string(), + )]; + for (index, width) in col_widths.iter().enumerate() { + let cell = row.cells.get(index).cloned().unwrap_or_default(); + chunks.push(InlineChunk::plain(" ")); + chunks.extend(crop_inline_chunks_to_width( + &cell, + *width, + if row.is_header { + Some(WrappedSpanStyle::TableHeader) + } else { + None + }, + )); + let used = inline_chunks_width(&crop_inline_chunks_to_width( + &cell, + *width, + if row.is_header { + Some(WrappedSpanStyle::TableHeader) + } else { + None + }, + )); + if used < *width { + chunks.push(InlineChunk::plain(" ".repeat(*width - used))); + } + chunks.push(InlineChunk::plain(" ")); + chunks.push(InlineChunk::styled( + WrappedSpanStyle::TableBorder, + vertical.to_string(), + )); + } + chunks +} + +fn crop_inline_chunks_to_width( + chunks: &[InlineChunk], + width: usize, + force_style: Option<WrappedSpanStyle>, +) -> Vec<InlineChunk> { + if width == 0 { + return Vec::new(); + } + + let text_width = inline_chunks_width(chunks); + let budget = if text_width > width && width > 1 { + width - 1 + } else { + width + }; + let mut output = Vec::new(); + let mut used = 0usize; + + for chunk in chunks { + let style = force_style.or(chunk.style()); + let mut current = String::new(); + for ch in chunk.text().chars() { + let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0).max(1); + if used + ch_width > budget { + break; + } + current.push(ch); + used += ch_width; + } + if !current.is_empty() { + push_chunk( + &mut output, + match style { + Some(style) => InlineChunk::styled(style, current), + None => InlineChunk::plain(current), + }, + ); + } + if used >= budget { + break; + } + } + + if text_width > width { + if let Some(last) = output.last_mut() { + last.append_text("…"); + } else { + output.push(match force_style { + Some(style) => InlineChunk::styled(style, "…"), + None => InlineChunk::plain("…"), + }); + } + } + + output +} + +fn inline_chunks_width(chunks: &[InlineChunk]) -> usize { + chunks.iter().map(|chunk| display_width(chunk.text())).sum() +} + +fn table_chars(capabilities: TerminalCapabilities) -> TableChars<'static> { + if capabilities.ascii_only() { + TableChars { + top_left: "+", + top_mid: "+", + top_right: "+", + mid_left: "+", + mid_mid: "+", + mid_right: "+", + bottom_left: "+", + bottom_mid: "+", + bottom_right: "+", + horizontal: "-", + vertical: "|", + } + } else { + TableChars { + top_left: "┌", + top_mid: "┬", + top_right: "┐", + mid_left: "├", + mid_mid: "┼", + mid_right: "┤", + bottom_left: "└", + bottom_mid: "┴", + bottom_right: "┘", + horizontal: "─", + vertical: "│", + } + } +} + +fn wrap_inline_chunks( + chunks: Vec<InlineChunk>, + width: usize, + capabilities: TerminalCapabilities, +) -> Vec<Vec<InlineChunk>> { + wrap_inline_chunks_with_widths(chunks, width, width, capabilities) +} + +fn wrap_inline_chunks_with_widths( + chunks: Vec<InlineChunk>, + first_width: usize, + subsequent_width: usize, + capabilities: TerminalCapabilities, +) -> Vec<Vec<InlineChunk>> { + let mut lines = Vec::new(); + let mut current = Vec::new(); + let mut current_width = 0usize; + let mut current_limit = first_width.max(1); + let mut pending_space = false; + + for token in tokenize_inline_chunks(chunks) { + match token { + InlineToken::Whitespace => pending_space = true, + InlineToken::Chunk(chunk) => { + for piece in split_inline_chunk_to_width(&chunk, current_limit.max(1), capabilities) + { + let piece_width = display_width(piece.text()); + let space_width = usize::from(pending_space && current_width > 0); + if current_width > 0 + && current_width + space_width + piece_width > current_limit + { + lines.push(current); + current = Vec::new(); + current_width = 0; + current_limit = subsequent_width.max(1); + pending_space = false; + } + + if pending_space && current_width > 0 { + current.push(InlineChunk::plain(" ")); + current_width += 1; + } + pending_space = false; + + current_width += piece_width; + current.push(piece); + } + }, + } + } + + if !current.is_empty() || lines.is_empty() { + lines.push(current); + } + lines +} + +enum InlineToken { + Whitespace, + Chunk(InlineChunk), +} + +fn tokenize_inline_chunks(chunks: Vec<InlineChunk>) -> Vec<InlineToken> { + let mut tokens = Vec::new(); + for chunk in chunks { + if matches!( + chunk.style(), + Some(WrappedSpanStyle::InlineCode | WrappedSpanStyle::Link) + ) { + tokens.push(InlineToken::Chunk(chunk)); + continue; + } + + let mut current = String::new(); + let mut whitespace = None; + for ch in chunk.text().chars() { + let is_whitespace = ch.is_whitespace(); + match whitespace { + Some(flag) if flag == is_whitespace => current.push(ch), + Some(flag) => { + if flag { + tokens.push(InlineToken::Whitespace); + } else { + tokens.push(InlineToken::Chunk(match chunk.style() { + Some(style) => InlineChunk::styled(style, current.clone()), + None => InlineChunk::plain(current.clone()), + })); + } + current.clear(); + current.push(ch); + whitespace = Some(is_whitespace); + }, + None => { + current.push(ch); + whitespace = Some(is_whitespace); + }, + } + } + if !current.is_empty() { + if whitespace == Some(true) { + tokens.push(InlineToken::Whitespace); + } else { + tokens.push(InlineToken::Chunk(match chunk.style() { + Some(style) => InlineChunk::styled(style, current), + None => InlineChunk::plain(current), + })); + } + } + } + tokens +} + +fn split_inline_chunk_to_width( + chunk: &InlineChunk, + width: usize, + capabilities: TerminalCapabilities, +) -> Vec<InlineChunk> { + split_token_by_width(chunk.text(), width.max(1), capabilities) + .into_iter() + .map(|piece| match chunk.style() { + Some(style) => InlineChunk::styled(style, piece.into_owned()), + None => InlineChunk::plain(piece.into_owned()), + }) + .collect() +} + +fn flatten_inline_chunks(chunks: Vec<InlineChunk>) -> String { + chunks + .into_iter() + .map(|chunk| chunk.text().to_string()) + .collect::<String>() +} + +fn split_token_by_width<'a>( + token: &'a str, + width: usize, + _capabilities: TerminalCapabilities, +) -> Vec<Cow<'a, str>> { + let width = width.max(1); + if display_width(token) <= width { + return vec![Cow::Borrowed(token)]; + } + split_preserving_width(token, width) +} + +fn split_preserving_width<'a>(text: &'a str, width: usize) -> Vec<Cow<'a, str>> { + let mut chunks = Vec::new(); + let mut current = String::new(); + let mut current_width = 0; + + for ch in text.chars() { + let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0).max(1); + if current_width + ch_width > width && !current.is_empty() { + chunks.push(Cow::Owned(current)); + current = String::new(); + current_width = 0; + } + current.push(ch); + current_width += ch_width; + } + + if !current.is_empty() { + chunks.push(Cow::Owned(current)); + } + chunks +} + +fn display_width(text: &str) -> usize { + UnicodeWidthStr::width(text) +} + +#[cfg(test)] +mod tests { + use super::{flatten_inline_text, render_literal_text, render_markdown_lines, wrap_text}; + use crate::{ + capability::{ColorLevel, GlyphMode, TerminalCapabilities}, + state::{WrappedLineStyle, WrappedSpanStyle}, + }; + + fn unicode_capabilities() -> TerminalCapabilities { + TerminalCapabilities { + color: ColorLevel::TrueColor, + glyphs: GlyphMode::Unicode, + alt_screen: false, + mouse: false, + bracketed_paste: false, + } + } + + #[test] + fn wrap_text_preserves_hanging_indent_for_lists() { + let lines = wrap_text( + "- 第一项需要被正确换行,并且后续行要和正文对齐", + 18, + unicode_capabilities(), + ); + assert!(lines[0].starts_with("- ")); + assert!(lines[1].starts_with(" ")); + } + + #[test] + fn wrap_text_formats_markdown_tables() { + let lines = wrap_text( + "| 工具 | 说明 |\n| --- | --- |\n| **reviewnow** | 代码审查 |\n| `git-commit` | \ + 自动提交 |", + 32, + unicode_capabilities(), + ); + assert!(lines.iter().any(|line| line.contains("┌"))); + assert!(lines.iter().any(|line| line.contains("│ 工具"))); + assert!(lines.iter().any(|line| line.contains("reviewnow"))); + assert!(lines.iter().any(|line| line.contains("git-commit"))); + assert!(lines.iter().all(|line| !line.contains("**reviewnow**"))); + assert!(lines.iter().all(|line| !line.contains("`git-commit`"))); + } + + #[test] + fn inline_markdown_keeps_emphasis_body_before_cjk_punctuation() { + assert_eq!(flatten_inline_text("**writeFile**。"), "writeFile。"); + } + + #[test] + fn wrap_literal_text_preserves_user_markdown_markers() { + let lines = render_literal_text( + "## 用户原文\n请保留 **readFile** 和 [link](https://example.com)。", + 120, + unicode_capabilities(), + ); + let joined = lines.join("\n"); + assert!(joined.contains("## 用户原文")); + assert!(joined.contains("**readFile**")); + assert!(joined.contains("[link](https://example.com)")); + } + + #[test] + fn parser_marks_rich_span_styles() { + let lines = render_markdown_lines( + "## 标题\n\n使用 `writeFile`\n\n- [readFile](https://example.com)\n> \ + 引用\n\n```rs\nlet x = 1;\n```\n\n| 工具 | 说明 |\n| --- | --- |\n| writeFile | 保存 \ + |", + 64, + unicode_capabilities(), + WrappedLineStyle::Plain, + ); + let span_styles = lines + .iter() + .flat_map(|line| line.spans.iter().filter_map(|span| span.style)) + .collect::<Vec<_>>(); + assert!(span_styles.contains(&WrappedSpanStyle::Heading)); + assert!(span_styles.contains(&WrappedSpanStyle::Link)); + assert!(span_styles.contains(&WrappedSpanStyle::ListMarker)); + assert!(span_styles.contains(&WrappedSpanStyle::QuoteMarker)); + assert!(span_styles.contains(&WrappedSpanStyle::CodeText)); + assert!(span_styles.contains(&WrappedSpanStyle::TableHeader)); + assert!(span_styles.contains(&WrappedSpanStyle::InlineCode)); + } + + #[test] + fn fenced_ascii_tree_block_preserves_structure() { + let lines = wrap_text( + "```\nfrontend/src/\n├─ components/\n│ ├─ Chat/\n│ └─ Sidebar/\n└─ store/\n```", + 18, + unicode_capabilities(), + ); + assert_eq!(lines.len(), 5); + assert_eq!(lines[0], "frontend/src/"); + assert!(lines.iter().any(|line| line.contains("├─"))); + assert!(lines.iter().any(|line| line.contains("│"))); + } + + #[test] + fn unfenced_ascii_diagram_uses_preformatted_fallback() { + let lines = wrap_text( + "┌──────────────┐\n│ server │\n├──────┬───────┤\n│ core │ cli \ + │\n└──────┴───────┘", + 18, + unicode_capabilities(), + ); + assert_eq!(lines.len(), 5); + assert!(lines.iter().all(|line| !line.contains("…"))); + assert!(lines.iter().any(|line| line.contains("┌"))); + assert!(lines.iter().any(|line| line.contains("┴"))); + } + + #[test] + fn fenced_ascii_diagram_uses_text_art_without_fence_markers() { + let lines = render_markdown_lines( + "```\n ┌──────────┐\n │ diagram │\n └──────────┘\n```", + 18, + unicode_capabilities(), + WrappedLineStyle::Plain, + ); + let joined = lines.iter().map(|line| line.text()).collect::<Vec<_>>(); + let span_styles = lines + .iter() + .flat_map(|line| line.spans.iter().filter_map(|span| span.style)) + .collect::<Vec<_>>(); + + assert!(joined.iter().all(|line| !line.contains("```"))); + assert!(joined.iter().any(|line| line.contains("┌"))); + assert!(span_styles.contains(&WrappedSpanStyle::TextArt)); + } + + #[test] + fn pseudo_fence_ascii_diagram_drops_fence_lines() { + let lines = wrap_text( + "``\n┌──────┐\n│ test │\n└──────┘\n``", + 18, + unicode_capabilities(), + ); + assert_eq!(lines, vec!["┌──────┐", "│ test │", "└──────┘"]); + } +} diff --git a/crates/cli/src/ui/mod.rs b/crates/cli/src/ui/mod.rs index 44b44a6a..fea9a4d4 100644 --- a/crates/cli/src/ui/mod.rs +++ b/crates/cli/src/ui/mod.rs @@ -1,240 +1,250 @@ -mod bottom_pane; -mod cells; -mod overlay; +pub mod cells; +pub mod composer; +pub mod custom_terminal; +pub mod insert_history; +mod markdown; +pub mod overlay; +mod palette; +mod text; mod theme; -pub use bottom_pane::{BottomPaneView, ComposerPane}; -pub use cells::{RenderableCell, wrap_text}; -pub use overlay::{OverlayView, overlay_title}; +pub use palette::palette_lines; use ratatui::text::{Line, Span}; +pub use text::truncate_to_width; pub use theme::{CodexTheme, ThemePalette}; +use unicode_segmentation::UnicodeSegmentation; use crate::{ - capability::TerminalCapabilities, - state::{CliState, OverlayState, WrappedLine, WrappedLineStyle}, + render::wrap::wrap_plain_text, + state::{WrappedLine, WrappedLineRewrapPolicy}, }; -pub fn transcript_lines(state: &CliState, width: u16) -> Vec<WrappedLine> { - if state.conversation.transcript_cells.is_empty() { - return empty_state_lines(state, width); - } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HistoryLine { + pub line: Line<'static>, + pub rewrap_policy: WrappedLineRewrapPolicy, +} + +pub fn line_to_ratatui(line: &WrappedLine, theme: &CodexTheme) -> Line<'static> { + let base = theme.line_style(line.style); + let spans = if line.spans.is_empty() { + vec![Span::styled(String::new(), base)] + } else { + line.spans + .iter() + .map(|span| { + let style = span + .style + .map(|style| base.patch(theme.span_style(style))) + .unwrap_or(base); + Span::styled(span.content.clone(), style) + }) + .collect::<Vec<_>>() + }; + Line::from(spans).style(base) +} - let theme = CodexTheme::new(state.shell.capabilities); - let mut lines = Vec::new(); - for cell in &state.conversation.transcript_cells { - lines.extend(cell.render_lines( - usize::from(width.max(18)), - state.shell.capabilities, - &theme, - )); +pub fn history_line_to_ratatui(line: WrappedLine, theme: &CodexTheme) -> HistoryLine { + HistoryLine { + rewrap_policy: line.rewrap_policy, + line: line_to_ratatui(&line, theme), } +} + +pub(crate) fn materialize_wrapped_line( + line: &WrappedLine, + width: usize, + theme: &CodexTheme, +) -> Vec<Line<'static>> { + let history_line = history_line_to_ratatui(line.clone(), theme); + materialize_history_line(&history_line, width) +} + +pub(crate) fn materialize_wrapped_lines( + lines: &[WrappedLine], + width: usize, + theme: &CodexTheme, +) -> Vec<Line<'static>> { lines + .iter() + .flat_map(|line| materialize_wrapped_line(line, width, theme)) + .collect() } -pub fn child_pane_lines(state: &CliState, width: u16) -> Vec<WrappedLine> { - let theme = CodexTheme::new(state.shell.capabilities); - let width = usize::from(width.max(18)); - let mut lines = vec![ - WrappedLine { - style: WrappedLineStyle::Header, - content: "child sessions".to_string(), - }, - WrappedLine { - style: WrappedLineStyle::Dim, - content: format!( - "{} active · {} total", - state - .conversation - .child_summaries - .iter() - .filter(|child| { - matches!( - child.lifecycle, - astrcode_client::AstrcodeConversationAgentLifecycleDto::Running - | astrcode_client::AstrcodeConversationAgentLifecycleDto::Pending - ) - }) - .count(), - state.conversation.child_summaries.len() - ), - }, - WrappedLine { - style: WrappedLineStyle::Border, - content: theme.divider().repeat(width), - }, - ]; - - for (index, child) in state.conversation.child_summaries.iter().enumerate() { - let focused = state - .interaction - .child_pane - .focused_child_session_id - .as_deref() - == Some(child.child_session_id.as_str()); - let selected = index == state.interaction.child_pane.selected; - let marker = if focused { - theme.glyph("◆", "*") - } else if selected { - theme.glyph("›", ">") - } else { - " " - }; - lines.push(WrappedLine { - style: if focused || selected { - WrappedLineStyle::Selection - } else { - WrappedLineStyle::Plain - }, - content: format!( - "{marker} {} [{}]", - child.title, - lifecycle_label(child.lifecycle) - ), - }); - if let Some(summary) = child.latest_output_summary.as_deref() { - for line in wrap_text( - summary, - width.saturating_sub(2).max(8), - state.shell.capabilities, - ) { - lines.push(WrappedLine { - style: WrappedLineStyle::Dim, - content: format!(" {line}"), - }); +pub(crate) fn materialize_history_line(line: &HistoryLine, width: usize) -> Vec<Line<'static>> { + match line.rewrap_policy { + WrappedLineRewrapPolicy::Reflow => wrap_reflow_line(&line.line, width), + WrappedLineRewrapPolicy::PreserveAndCrop => vec![crop_line(&line.line, width.max(1))], + } +} + +fn wrap_reflow_line(line: &Line<'static>, width: usize) -> Vec<Line<'static>> { + let content = line.to_string(); + let wrapped = wrap_plain_text(content.as_str(), width.max(1)); + if line.spans.is_empty() { + return wrapped + .into_iter() + .map(|item| Line::from(item).style(line.style)) + .collect(); + } + + let mut cursor = StyledGraphemeCursor::new(&line.spans); + wrapped + .into_iter() + .enumerate() + .map(|(index, item)| { + if index > 0 && !starts_with_whitespace(item.as_str()) { + cursor.skip_boundary_whitespace(); } + let spans = cursor.consume_text(item.as_str()); + Line::from(spans).style(line.style) + }) + .collect() +} + +fn crop_line(line: &Line<'static>, width: usize) -> Line<'static> { + let width = width.max(1); + if line.width() <= width { + return line.clone(); + } + + let ellipsis = if width == 1 { "" } else { "…" }; + let budget = width.saturating_sub(display_width(ellipsis)); + let mut visible = Vec::new(); + let mut used = 0usize; + + for span in &line.spans { + let cropped = crop_span_to_width(span, budget.saturating_sub(used)); + if cropped.content.is_empty() { + break; + } + used += display_width(cropped.content.as_ref()); + visible.push(cropped); + if used >= budget { + break; } } - lines -} + if !ellipsis.is_empty() { + if let Some(last) = visible.last_mut() { + last.content = format!("{}{}", last.content, ellipsis).into(); + } else { + visible.push(Span::raw(ellipsis.to_string())); + } + } -pub fn header_lines(state: &CliState, width: u16) -> Vec<WrappedLine> { - let theme = CodexTheme::new(state.shell.capabilities); - let title = state - .conversation - .active_session_title - .as_deref() - .unwrap_or("Astrcode workspace"); - let model = "gpt-5.4 medium"; - let phase = phase_label( - state - .active_phase() - .unwrap_or(astrcode_client::AstrcodePhaseDto::Idle), - ); - let working_dir = state - .shell - .working_dir - .as_deref() - .map(|path| path.display().to_string()) - .unwrap_or_else(|| "~".to_string()); - - let meta = format!("model {model} · phase {phase} · {working_dir}"); - let meta_width = usize::from(width.max(20)).saturating_sub(2); - - vec![ - WrappedLine { - style: WrappedLineStyle::Header, - content: title.to_string(), - }, - WrappedLine { - style: WrappedLineStyle::Dim, - content: truncate_text(meta.as_str(), meta_width, state.shell.capabilities), - }, - WrappedLine { - style: WrappedLineStyle::Border, - content: theme.divider().repeat(usize::from(width.max(1))), - }, - ] + Line::from(visible).style(line.style) } -pub fn centered_overlay_lines(state: &CliState, width: u16) -> Vec<WrappedLine> { - let theme = CodexTheme::new(state.shell.capabilities); - match &state.interaction.overlay { - OverlayState::Resume(resume) => { - resume.lines(usize::from(width.max(20)), state.shell.capabilities, &theme) - }, - OverlayState::DebugLogs(debug) => debug.lines( - usize::from(width.max(20)), - state.shell.capabilities, - &theme, - &state.debug, - ), - OverlayState::SlashPalette(_) | OverlayState::None => Vec::new(), +fn crop_span_to_width(span: &Span<'static>, width: usize) -> Span<'static> { + if width == 0 { + return Span::styled(String::new(), span.style); + } + + let mut content = String::new(); + let mut used = 0usize; + for grapheme in UnicodeSegmentation::graphemes(span.content.as_ref(), true) { + let grapheme_width = display_width(grapheme); + if used + grapheme_width > width { + break; + } + content.push_str(grapheme); + used += grapheme_width; } + Span::styled(content, span.style) } -pub fn line_to_ratatui(line: &WrappedLine, capabilities: TerminalCapabilities) -> Line<'static> { - let theme = CodexTheme::new(capabilities); - Line::from(Span::styled( - line.content.clone(), - theme.line_style(line.style), - )) +fn display_width(text: &str) -> usize { + UnicodeSegmentation::graphemes(text, true) + .map(unicode_width::UnicodeWidthStr::width) + .sum() } -pub fn phase_label(phase: astrcode_client::AstrcodePhaseDto) -> &'static str { - match phase { - astrcode_client::AstrcodePhaseDto::Idle => "idle", - astrcode_client::AstrcodePhaseDto::Thinking => "thinking", - astrcode_client::AstrcodePhaseDto::CallingTool => "calling_tool", - astrcode_client::AstrcodePhaseDto::Streaming => "streaming", - astrcode_client::AstrcodePhaseDto::Interrupted => "interrupted", - astrcode_client::AstrcodePhaseDto::Done => "done", - } +#[derive(Debug, Clone, PartialEq, Eq)] +struct StyledGrapheme { + style: ratatui::style::Style, + text: String, } -fn empty_state_lines(state: &CliState, width: u16) -> Vec<WrappedLine> { - let theme = CodexTheme::new(state.shell.capabilities); - let divider = theme.divider().repeat(usize::from(width.min(56))); - vec![ - WrappedLine { - style: WrappedLineStyle::Header, - content: "OpenAI Codex style workspace".to_string(), - }, - WrappedLine { - style: WrappedLineStyle::Dim, - content: "fresh session 已准备好。主区只显示会话语义内容,启动噪音已移出。".to_string(), - }, - WrappedLine { - style: WrappedLineStyle::Accent, - content: "› 输入 prompt 开始;Tab 打开 commands;F2 查看 debug logs。".to_string(), - }, - WrappedLine { - style: WrappedLineStyle::Border, - content: divider, - }, - ] +struct StyledGraphemeCursor { + graphemes: Vec<StyledGrapheme>, + index: usize, } -fn lifecycle_label( - lifecycle: astrcode_client::AstrcodeConversationAgentLifecycleDto, -) -> &'static str { - match lifecycle { - astrcode_client::AstrcodeConversationAgentLifecycleDto::Pending => "pending", - astrcode_client::AstrcodeConversationAgentLifecycleDto::Running => "running", - astrcode_client::AstrcodeConversationAgentLifecycleDto::Idle => "idle", - astrcode_client::AstrcodeConversationAgentLifecycleDto::Terminated => "terminated", +impl StyledGraphemeCursor { + fn new(spans: &[Span<'static>]) -> Self { + let graphemes = spans + .iter() + .flat_map(|span| { + UnicodeSegmentation::graphemes(span.content.as_ref(), true).map(|grapheme| { + StyledGrapheme { + style: span.style, + text: grapheme.to_string(), + } + }) + }) + .collect(); + Self { + graphemes, + index: 0, + } } -} -fn truncate_text(text: &str, width: usize, capabilities: TerminalCapabilities) -> String { - if width == 0 { - return String::new(); + fn consume_text(&mut self, text: &str) -> Vec<Span<'static>> { + if text.is_empty() { + return Vec::new(); + } + + let mut spans = Vec::new(); + let mut current_style = None; + let mut current_text = String::new(); + + for grapheme in UnicodeSegmentation::graphemes(text, true) { + let Some(next) = self.next_matching(grapheme) else { + return vec![Span::raw(text.to_string())]; + }; + match current_style { + Some(style) if style == next.style => current_text.push_str(next.text.as_str()), + Some(style) => { + spans.push(Span::styled(std::mem::take(&mut current_text), style)); + current_text.push_str(next.text.as_str()); + current_style = Some(next.style); + }, + None => { + current_text.push_str(next.text.as_str()); + current_style = Some(next.style); + }, + } + } + + if let Some(style) = current_style { + spans.push(Span::styled(current_text, style)); + } + + spans } - let mut out = String::new(); - let mut current_width = 0; - for ch in text.chars() { - let ch = ch.to_string(); - let ch_width = if capabilities.ascii_only() { - 1 + fn skip_boundary_whitespace(&mut self) { + while self + .graphemes + .get(self.index) + .is_some_and(|grapheme| grapheme.text.chars().all(char::is_whitespace)) + { + self.index += 1; + } + } + + fn next_matching(&mut self, expected: &str) -> Option<StyledGrapheme> { + let grapheme = self.graphemes.get(self.index)?.clone(); + if grapheme.text == expected { + self.index += 1; + Some(grapheme) } else { - unicode_width::UnicodeWidthStr::width(ch.as_str()).max(1) - }; - if current_width + ch_width > width { - break; + None } - current_width += ch_width; - out.push_str(ch.as_str()); } - out +} + +fn starts_with_whitespace(text: &str) -> bool { + text.chars().next().is_some_and(char::is_whitespace) } diff --git a/crates/cli/src/ui/overlay.rs b/crates/cli/src/ui/overlay.rs index c3189a47..2f74ff91 100644 --- a/crates/cli/src/ui/overlay.rs +++ b/crates/cli/src/ui/overlay.rs @@ -1,137 +1,178 @@ -use super::{cells::wrap_text, theme::ThemePalette}; +use ratatui::{ + layout::{Constraint, Direction, Layout, Rect}, + widgets::{Block, Borders, Clear, Paragraph}, +}; + use crate::{ - capability::TerminalCapabilities, - state::{ - DebugChannelState, DebugOverlayState, OverlayState, ResumeOverlayState, WrappedLine, - WrappedLineStyle, + state::CliState, + ui::{ + CodexTheme, + cells::{RenderableCell, TranscriptCellView}, + custom_terminal::Frame, + materialize_wrapped_lines, }, }; -pub trait OverlayView { - fn title(&self) -> &'static str; - fn lines( - &self, - width: usize, - capabilities: TerminalCapabilities, - theme: &dyn ThemePalette, - ) -> Vec<WrappedLine>; +pub fn render_browser_overlay(frame: &mut Frame<'_>, state: &CliState, theme: &CodexTheme) { + let area = centered_rect(frame.area()); + frame.render_widget(Clear, area); + + let title = state + .conversation + .active_session_title + .clone() + .map(|title| format!("History · {title}")) + .unwrap_or_else(|| "History".to_string()); + let block = Block::default().borders(Borders::ALL).title(title); + let inner = block.inner(area); + frame.render_widget(block, area); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(1), Constraint::Length(1)]) + .split(inner); + + let rendered = browser_lines(state, chunks[0].width, theme); + let content_height = usize::from(chunks[0].height.max(1)); + let scroll = overlay_scroll_offset( + rendered.lines.len(), + content_height, + rendered.selected_line_range, + ); + + frame.render_widget( + Paragraph::new(rendered.lines.clone()).scroll((scroll as u16, 0)), + chunks[0], + ); + + frame.render_widget( + Paragraph::new(browser_footer(state, rendered.total_cells)), + chunks[1], + ); } -impl OverlayView for ResumeOverlayState { - fn title(&self) -> &'static str { - "Resume Session" - } +#[derive(Debug, Clone, PartialEq, Eq)] +struct BrowserRenderOutput { + lines: Vec<ratatui::text::Line<'static>>, + selected_line_range: Option<(usize, usize)>, + total_cells: usize, +} - fn lines( - &self, - width: usize, - capabilities: TerminalCapabilities, - theme: &dyn ThemePalette, - ) -> Vec<WrappedLine> { - let mut lines = vec![WrappedLine { - style: WrappedLineStyle::Dim, - content: format!( - "{} query {}", - theme.glyph("·", "-"), - if self.query.is_empty() { - "<all>" - } else { - self.query.as_str() - } - ), - }]; - if self.items.is_empty() { - lines.push(WrappedLine { - style: WrappedLineStyle::Dim, - content: "没有匹配的会话。".to_string(), - }); - return lines; - } +fn browser_lines(state: &CliState, width: u16, theme: &CodexTheme) -> BrowserRenderOutput { + let width = usize::from(width.max(28)); + let mut lines = Vec::new(); + let mut selected_line_range = None; + let transcript_cells = state.browser_transcript_cells(); - for (index, item) in self.items.iter().take(10).enumerate() { - lines.push(WrappedLine { - style: if index == self.selected { - WrappedLineStyle::Selection - } else { - WrappedLineStyle::Plain - }, - content: format!( - "{} {}", - if index == self.selected { - theme.glyph("›", ">") - } else { - " " - }, - item.title + if let Some(banner) = &state.conversation.banner { + lines.extend(materialize_wrapped_lines( + &[ + crate::state::WrappedLine::plain( + crate::state::WrappedLineStyle::ErrorText, + format!("! {}", banner.error.message), ), - }); - for line in wrap_text( - item.working_dir.as_str(), - width.saturating_sub(2).max(8), - capabilities, - ) { - lines.push(WrappedLine { - style: WrappedLineStyle::Dim, - content: format!(" {line}"), - }); - } - } - lines + crate::state::WrappedLine::plain( + crate::state::WrappedLineStyle::Muted, + " stream 需要重新同步,继续操作前建议等待恢复。".to_string(), + ), + crate::state::WrappedLine::plain( + crate::state::WrappedLineStyle::Plain, + String::new(), + ), + ], + width, + theme, + )); } -} -impl DebugOverlayState { - pub fn lines( - &self, - width: usize, - capabilities: TerminalCapabilities, - theme: &dyn ThemePalette, - debug: &DebugChannelState, - ) -> Vec<WrappedLine> { - let mut lines = vec![ - WrappedLine { - style: WrappedLineStyle::Dim, - content: format!( - "{} launcher/server debug output · Esc close", - theme.glyph("·", "-") - ), - }, - WrappedLine { - style: WrappedLineStyle::Border, - content: theme.divider().repeat(width.max(16)), + for (index, cell) in transcript_cells.iter().enumerate() { + let line_start = lines.len(); + let view = TranscriptCellView { + selected: state.interaction.browser.selected_cell == index, + expanded: state.is_cell_expanded(cell.id.as_str()) || cell.expanded, + thinking: match &cell.kind { + crate::state::TranscriptCellKind::Thinking { body, status } => { + Some(state.thinking_playback.present( + &state.thinking_pool, + cell.id.as_str(), + body.as_str(), + *status, + state.is_cell_expanded(cell.id.as_str()) || cell.expanded, + )) + }, + _ => None, }, - ]; - - if debug.is_empty() { - lines.push(WrappedLine { - style: WrappedLineStyle::Dim, - content: "暂无 debug logs。".to_string(), - }); - return lines; + }; + let rendered = cell.render_lines(width, state.shell.capabilities, theme, &view); + lines.extend(materialize_wrapped_lines(&rendered, width, theme)); + if view.selected { + selected_line_range = Some((line_start, lines.len().saturating_sub(1))); } + } - let entries = debug - .entries() - .rev() - .skip(self.scroll) - .take(18) - .collect::<Vec<_>>(); - for entry in entries.into_iter().rev() { - for line in wrap_text(entry, width.saturating_sub(2).max(8), capabilities) { - lines.push(WrappedLine { - style: WrappedLineStyle::Plain, - content: line, - }); - } - } - lines + BrowserRenderOutput { + lines, + selected_line_range, + total_cells: transcript_cells.len(), + } +} + +fn overlay_scroll_offset( + total_lines: usize, + viewport_height: usize, + selected_line_range: Option<(usize, usize)>, +) -> usize { + let max_scroll = total_lines.saturating_sub(viewport_height); + let Some((selected_start, selected_end)) = selected_line_range else { + return max_scroll; + }; + if selected_end < viewport_height { + 0 + } else { + selected_end + .saturating_add(1) + .saturating_sub(viewport_height) + .min(max_scroll) + .min(selected_start) + } +} + +fn browser_footer(state: &CliState, total_cells: usize) -> String { + let unseen = total_cells > state.interaction.browser.last_seen_cell_count + && state.interaction.browser.selected_cell + 1 < total_cells; + if unseen { + "Esc close · ↑↓ browse · Home/End jump · PgUp/PgDn page · End 查看新消息".to_string() + } else { + "Esc close · ↑↓ browse · Home/End jump · PgUp/PgDn page · Enter expand".to_string() + } +} + +fn centered_rect(area: Rect) -> Rect { + let horizontal_margin = area.width.saturating_div(12).max(1); + let vertical_margin = area.height.saturating_div(10).max(1); + Rect { + x: area.x.saturating_add(horizontal_margin), + y: area.y.saturating_add(vertical_margin), + width: area + .width + .saturating_sub(horizontal_margin.saturating_mul(2)), + height: area + .height + .saturating_sub(vertical_margin.saturating_mul(2)), } } -pub fn overlay_title(overlay: &OverlayState) -> Option<&'static str> { - match overlay { - OverlayState::None | OverlayState::SlashPalette(_) => None, - OverlayState::Resume(state) => Some(state.title()), - OverlayState::DebugLogs(_) => Some("Debug Logs"), +#[cfg(test)] +mod tests { + use super::overlay_scroll_offset; + + #[test] + fn overlay_scroll_defaults_to_bottom_when_nothing_selected() { + assert_eq!(overlay_scroll_offset(20, 5, None), 15); + } + + #[test] + fn overlay_scroll_keeps_selected_range_visible() { + assert_eq!(overlay_scroll_offset(30, 6, Some((20, 22))), 17); } } diff --git a/crates/cli/src/ui/palette.rs b/crates/cli/src/ui/palette.rs new file mode 100644 index 00000000..99590304 --- /dev/null +++ b/crates/cli/src/ui/palette.rs @@ -0,0 +1,190 @@ +use super::{ThemePalette, truncate_to_width}; +use crate::state::{PaletteState, WrappedLine, WrappedLineStyle}; + +const MAX_VISIBLE_ITEMS: usize = 5; + +pub fn palette_lines( + palette: &PaletteState, + width: usize, + theme: &dyn ThemePalette, +) -> Vec<WrappedLine> { + match palette { + PaletteState::Closed => Vec::new(), + PaletteState::Slash(slash) => render_palette_items( + &slash.items, + slash.selected, + width, + theme, + " 没有匹配的命令", + |item| { + ( + item.title.clone(), + compact_slash_subtitle(item.description.as_str()).to_string(), + ) + }, + ), + PaletteState::Resume(resume) => render_palette_items( + &resume.items, + resume.selected, + width, + theme, + " 没有匹配的会话", + |item| (item.title.clone(), item.working_dir.clone()), + ), + PaletteState::Model(model) => render_palette_items( + &model.items, + model.selected, + width, + theme, + " 没有匹配的模型", + |item| { + ( + item.model.clone(), + format!("{} · {}", item.profile_name, item.provider_kind), + ) + }, + ), + } +} + +fn visible_window<T>(items: &[T], selected: usize, max_items: usize) -> Vec<(usize, &T)> { + if items.is_empty() || max_items == 0 { + return Vec::new(); + } + let total = items.len(); + let start = if total <= max_items { + 0 + } else { + selected + .saturating_sub(max_items / 2) + .min(total - max_items) + }; + items[start..(start + max_items).min(total)] + .iter() + .enumerate() + .map(|(offset, item)| (start + offset, item)) + .collect() +} + +fn candidate_line(prefix: &str, title: &str, meta: &str, width: usize) -> String { + let available = width.saturating_sub(2); + if meta.trim().is_empty() { + return truncate_to_width(format!("{prefix} {title}").as_str(), available); + } + + let available_meta = available.saturating_mul(3) / 5; + let meta_text = truncate_to_width(meta.trim(), available_meta.max(8)); + let title_budget = available + .saturating_sub(unicode_width::UnicodeWidthStr::width(meta_text.as_str())) + .saturating_sub(3) + .max(10); + let title_text = truncate_to_width(title.trim(), title_budget); + truncate_to_width( + format!("{prefix} {title_text} — {meta_text}").as_str(), + available, + ) +} + +fn compact_slash_subtitle(description: &str) -> &str { + let trimmed = description.trim(); + if trimmed.is_empty() { + return trimmed; + } + + for marker in [" Use when ", " Trigger when ", " 适用场景", " TRIGGER when"] { + if let Some((head, _)) = trimmed.split_once(marker) { + let compact = head + .trim() + .trim_end_matches(['.', '。', ';', ';', ':', ':']); + if !compact.is_empty() { + return compact; + } + } + } + + trimmed + .split(['.', '。']) + .map(str::trim) + .find(|segment| !segment.is_empty()) + .unwrap_or(trimmed) +} + +fn render_palette_items<T, F>( + items: &[T], + selected: usize, + width: usize, + theme: &dyn ThemePalette, + empty_message: &str, + meta: F, +) -> Vec<WrappedLine> +where + F: Fn(&T) -> (String, String), +{ + if items.is_empty() { + return vec![WrappedLine::plain( + WrappedLineStyle::Muted, + empty_message.to_string(), + )]; + } + + visible_window(items, selected, MAX_VISIBLE_ITEMS) + .into_iter() + .map(|(absolute_index, item)| { + let (title, details) = meta(item); + WrappedLine::plain( + if absolute_index == selected { + WrappedLineStyle::PaletteSelected + } else { + WrappedLineStyle::PaletteItem + }, + candidate_line( + if absolute_index == selected { + theme.glyph("›", ">") + } else { + " " + }, + title.as_str(), + details.as_str(), + width, + ), + ) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::{candidate_line, compact_slash_subtitle, visible_window}; + + #[test] + fn visible_window_tracks_selected_item() { + let items = (0..12).collect::<Vec<_>>(); + let window = visible_window(&items, 10, 4); + let indexes = window + .into_iter() + .map(|(index, _)| index) + .collect::<Vec<_>>(); + assert_eq!(indexes, vec![8, 9, 10, 11]); + } + + #[test] + fn candidate_line_stays_single_row() { + let line = candidate_line( + ">", + "Issue Fixer", + "automatically fix GitHub issues and create pull requests", + 48, + ); + assert!(!line.contains('\n')); + assert!(line.contains("Issue Fixer")); + } + + #[test] + fn compact_slash_subtitle_drops_use_when_tail() { + let subtitle = compact_slash_subtitle( + "Fast-forward through OpenSpec artifact creation. Use when the user wants to quickly \ + create all artifacts needed for implementation.", + ); + assert_eq!(subtitle, "Fast-forward through OpenSpec artifact creation"); + } +} diff --git a/crates/cli/src/ui/text.rs b/crates/cli/src/ui/text.rs new file mode 100644 index 00000000..69cfc16c --- /dev/null +++ b/crates/cli/src/ui/text.rs @@ -0,0 +1,26 @@ +use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; + +pub fn truncate_to_width(text: &str, width: usize) -> String { + if UnicodeWidthStr::width(text) <= width { + return text.to_string(); + } + if width == 0 { + return String::new(); + } + if width == 1 { + return "…".to_string(); + } + + let mut truncated = String::new(); + let mut used = 0; + for ch in text.chars() { + let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0); + if used + ch_width > width.saturating_sub(1) { + break; + } + truncated.push(ch); + used += ch_width; + } + truncated.push('…'); + truncated +} diff --git a/crates/cli/src/ui/theme.rs b/crates/cli/src/ui/theme.rs index 4d8cd856..a39167a6 100644 --- a/crates/cli/src/ui/theme.rs +++ b/crates/cli/src/ui/theme.rs @@ -2,14 +2,14 @@ use ratatui::style::{Color, Modifier, Style}; use crate::{ capability::{ColorLevel, TerminalCapabilities}, - state::WrappedLineStyle, + state::{WrappedLineStyle, WrappedSpanStyle}, }; pub trait ThemePalette { fn line_style(&self, style: WrappedLineStyle) -> Style; + fn span_style(&self, style: WrappedSpanStyle) -> Style; fn glyph(&self, unicode: &'static str, ascii: &'static str) -> &'static str; fn divider(&self) -> &'static str; - fn vertical_divider(&self) -> &'static str; } #[derive(Debug, Clone, Copy)] @@ -22,61 +22,72 @@ impl CodexTheme { Self { capabilities } } - pub fn muted_block_style(self) -> Style { + pub fn menu_block_style(&self) -> Style { + Style::default().fg(self.text_primary()) + } + + fn surface_alt(&self) -> Color { match self.capabilities.color { - ColorLevel::TrueColor => Style::default().bg(Color::Rgb(26, 28, 33)), - ColorLevel::Ansi16 => Style::default().bg(Color::Black), - ColorLevel::None => Style::default(), + ColorLevel::TrueColor => Color::Rgb(56, 52, 48), + ColorLevel::Ansi16 => Color::DarkGray, + ColorLevel::None => Color::Reset, } } - pub fn overlay_border_style(self) -> Style { + fn accent(&self) -> Color { match self.capabilities.color { - ColorLevel::TrueColor => Style::default().fg(Color::Rgb(90, 96, 110)), - ColorLevel::Ansi16 => Style::default().fg(Color::DarkGray), - ColorLevel::None => Style::default(), + ColorLevel::TrueColor => Color::Rgb(224, 128, 82), + _ => Color::Yellow, } } - fn accent(self) -> Color { + fn accent_soft(&self) -> Color { match self.capabilities.color { - ColorLevel::TrueColor => Color::Rgb(72, 196, 255), - _ => Color::Cyan, + ColorLevel::TrueColor => Color::Rgb(196, 124, 88), + _ => Color::Yellow, } } - fn magenta(self) -> Color { + fn thinking(&self) -> Color { match self.capabilities.color { - ColorLevel::TrueColor => Color::Rgb(221, 183, 255), - _ => Color::Magenta, + ColorLevel::TrueColor => Color::Rgb(241, 151, 104), + _ => Color::Yellow, } } - fn dim(self) -> Color { + fn text_primary(&self) -> Color { match self.capabilities.color { - ColorLevel::TrueColor => Color::Rgb(123, 129, 142), - _ => Color::DarkGray, + ColorLevel::TrueColor => Color::Rgb(237, 229, 219), + _ => Color::White, } } - fn warning(self) -> Color { + fn text_secondary(&self) -> Color { match self.capabilities.color { - ColorLevel::TrueColor => Color::Rgb(245, 201, 104), - _ => Color::Yellow, + ColorLevel::TrueColor => Color::Rgb(196, 186, 173), + _ => Color::Gray, } } - fn error(self) -> Color { + fn text_muted(&self) -> Color { match self.capabilities.color { - ColorLevel::TrueColor => Color::Rgb(255, 122, 122), + ColorLevel::TrueColor => Color::Rgb(136, 126, 114), + _ => Color::DarkGray, + } + } + + fn error(&self) -> Color { + match self.capabilities.color { + ColorLevel::TrueColor => Color::Rgb(227, 111, 111), _ => Color::Red, } } - fn success(self) -> Color { + fn selection(&self) -> Color { match self.capabilities.color { - ColorLevel::TrueColor => Color::Rgb(121, 214, 121), - _ => Color::Green, + ColorLevel::TrueColor => Color::Rgb(70, 65, 60), + ColorLevel::Ansi16 => Color::DarkGray, + ColorLevel::None => Color::Reset, } } } @@ -86,42 +97,50 @@ impl ThemePalette for CodexTheme { let base = Style::default(); if matches!(self.capabilities.color, ColorLevel::None) { return match style { - WrappedLineStyle::Accent - | WrappedLineStyle::Success - | WrappedLineStyle::Warning - | WrappedLineStyle::Error - | WrappedLineStyle::Selection - | WrappedLineStyle::Header => base.add_modifier(Modifier::BOLD), - WrappedLineStyle::Dim | WrappedLineStyle::Footer | WrappedLineStyle::Border => { + WrappedLineStyle::Plain + | WrappedLineStyle::ThinkingBody + | WrappedLineStyle::ToolBody + | WrappedLineStyle::Notice + | WrappedLineStyle::PaletteItem => base, + WrappedLineStyle::Selection + | WrappedLineStyle::PromptEcho + | WrappedLineStyle::ToolLabel + | WrappedLineStyle::ErrorText + | WrappedLineStyle::PaletteSelected => base.add_modifier(Modifier::BOLD), + WrappedLineStyle::ThinkingLabel => { + base.add_modifier(Modifier::BOLD | Modifier::ITALIC) + }, + WrappedLineStyle::Muted | WrappedLineStyle::ThinkingPreview => { base.add_modifier(Modifier::DIM) }, - WrappedLineStyle::User => base.add_modifier(Modifier::REVERSED), - WrappedLineStyle::Plain => base, }; } match style { - WrappedLineStyle::Plain => base.fg(Color::White), - WrappedLineStyle::Dim | WrappedLineStyle::Border => { - base.fg(self.dim()).add_modifier(Modifier::DIM) + WrappedLineStyle::Plain => base.fg(self.text_primary()), + WrappedLineStyle::Muted | WrappedLineStyle::ThinkingPreview => { + base.fg(self.text_muted()) }, - WrappedLineStyle::Footer => base.fg(self.dim()), - WrappedLineStyle::Accent => base.fg(self.accent()).add_modifier(Modifier::BOLD), - WrappedLineStyle::Success => base.fg(self.success()).add_modifier(Modifier::BOLD), - WrappedLineStyle::Warning => base.fg(self.warning()), - WrappedLineStyle::Error => base.fg(self.error()).add_modifier(Modifier::BOLD), - WrappedLineStyle::User => base.fg(Color::White).bg(match self.capabilities.color { - ColorLevel::TrueColor => Color::Rgb(35, 40, 52), - _ => Color::Black, - }), WrappedLineStyle::Selection => base - .fg(Color::White) - .bg(match self.capabilities.color { - ColorLevel::TrueColor => Color::Rgb(34, 74, 99), - _ => Color::DarkGray, - }) + .fg(self.text_primary()) + .bg(self.selection()) .add_modifier(Modifier::BOLD), - WrappedLineStyle::Header => base.fg(self.magenta()).add_modifier(Modifier::BOLD), + WrappedLineStyle::PromptEcho => base + .fg(self.text_primary()) + .bg(self.surface_alt()) + .add_modifier(Modifier::BOLD), + WrappedLineStyle::ThinkingLabel => base + .fg(self.thinking()) + .add_modifier(Modifier::ITALIC | Modifier::BOLD), + WrappedLineStyle::ThinkingBody => base.fg(self.text_secondary()), + WrappedLineStyle::ToolLabel => base.fg(self.accent_soft()).add_modifier(Modifier::BOLD), + WrappedLineStyle::ToolBody => base.fg(self.text_secondary()), + WrappedLineStyle::Notice => base.fg(self.text_secondary()), + WrappedLineStyle::ErrorText => base.fg(self.error()).add_modifier(Modifier::BOLD), + WrappedLineStyle::PaletteItem => base.fg(self.text_secondary()), + WrappedLineStyle::PaletteSelected => { + base.fg(self.accent()).add_modifier(Modifier::BOLD) + }, } } @@ -137,7 +156,43 @@ impl ThemePalette for CodexTheme { self.glyph("─", "-") } - fn vertical_divider(&self) -> &'static str { - self.glyph("│", "|") + fn span_style(&self, style: WrappedSpanStyle) -> Style { + let base = Style::default(); + if matches!(self.capabilities.color, ColorLevel::None) { + return match style { + WrappedSpanStyle::Strong + | WrappedSpanStyle::Heading + | WrappedSpanStyle::TableHeader => base.add_modifier(Modifier::BOLD), + WrappedSpanStyle::Emphasis => base.add_modifier(Modifier::ITALIC), + WrappedSpanStyle::Link => base.add_modifier(Modifier::UNDERLINED), + WrappedSpanStyle::InlineCode + | WrappedSpanStyle::CodeFence + | WrappedSpanStyle::CodeText + | WrappedSpanStyle::TextArt + | WrappedSpanStyle::TableBorder + | WrappedSpanStyle::ListMarker + | WrappedSpanStyle::QuoteMarker + | WrappedSpanStyle::HeadingRule => base.add_modifier(Modifier::DIM), + }; + } + + match style { + WrappedSpanStyle::Strong => base.add_modifier(Modifier::BOLD), + WrappedSpanStyle::Emphasis => base.add_modifier(Modifier::ITALIC), + WrappedSpanStyle::Heading => base.fg(self.accent()).add_modifier(Modifier::BOLD), + WrappedSpanStyle::HeadingRule => base.fg(self.text_muted()), + WrappedSpanStyle::TableBorder => base.fg(self.text_muted()), + WrappedSpanStyle::TableHeader => { + base.fg(self.accent_soft()).add_modifier(Modifier::BOLD) + }, + WrappedSpanStyle::InlineCode => base.fg(self.accent_soft()), + WrappedSpanStyle::Link => base.fg(Color::Cyan).add_modifier(Modifier::UNDERLINED), + WrappedSpanStyle::ListMarker | WrappedSpanStyle::QuoteMarker => { + base.fg(self.accent_soft()).add_modifier(Modifier::BOLD) + }, + WrappedSpanStyle::CodeFence => base.fg(self.text_muted()).add_modifier(Modifier::DIM), + WrappedSpanStyle::CodeText => base.fg(self.text_primary()), + WrappedSpanStyle::TextArt => base.fg(self.text_primary()), + } } } diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index a196c89e..b1d1d1aa 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -5,8 +5,9 @@ use std::sync::Arc; use astrcode_protocol::http::{ AuthExchangeRequest, AuthExchangeResponse, CompactSessionRequest, CompactSessionResponse, - CreateSessionRequest, ExecutionControlDto, PromptAcceptedResponse, PromptRequest, - SessionListItem, + CreateSessionRequest, CurrentModelInfoDto, ExecutionControlDto, ModeSummaryDto, ModelOptionDto, + PromptAcceptedResponse, PromptRequest, SaveActiveSelectionRequest, SessionListItem, + SessionModeStateDto, SwitchModeRequest, conversation::v1::{ ConversationCursorDto, ConversationDeltaDto, ConversationErrorEnvelopeDto, ConversationSlashCandidatesResponseDto, ConversationSnapshotResponseDto, @@ -35,9 +36,14 @@ pub use astrcode_protocol::http::{ CompactSessionRequest as AstrcodeCompactSessionRequest, CompactSessionResponse as AstrcodeCompactSessionResponse, CreateSessionRequest as AstrcodeCreateSessionRequest, - ExecutionControlDto as AstrcodeExecutionControlDto, PhaseDto as AstrcodePhaseDto, + CurrentModelInfoDto as AstrcodeCurrentModelInfoDto, + ExecutionControlDto as AstrcodeExecutionControlDto, ModeSummaryDto as AstrcodeModeSummaryDto, + ModelOptionDto as AstrcodeModelOptionDto, PhaseDto as AstrcodePhaseDto, PromptAcceptedResponse as AstrcodePromptAcceptedResponse, - PromptRequest as AstrcodePromptRequest, SessionListItem as AstrcodeSessionListItem, + PromptRequest as AstrcodePromptRequest, PromptSkillInvocation as AstrcodePromptSkillInvocation, + SaveActiveSelectionRequest as AstrcodeSaveActiveSelectionRequest, + SessionListItem as AstrcodeSessionListItem, SessionModeStateDto as AstrcodeSessionModeStateDto, + SwitchModeRequest as AstrcodeSwitchModeRequest, conversation::v1::{ ConversationAssistantBlockDto as AstrcodeConversationAssistantBlockDto, ConversationBannerDto as AstrcodeConversationBannerDto, @@ -234,6 +240,46 @@ where .await } + pub async fn list_modes(&self) -> Result<Vec<ModeSummaryDto>, ClientError> { + self.send_json::<Vec<ModeSummaryDto>, Value>( + TransportMethod::Get, + "/api/modes", + Vec::new(), + None, + true, + ) + .await + } + + pub async fn get_session_mode( + &self, + session_id: &str, + ) -> Result<SessionModeStateDto, ClientError> { + self.send_json::<SessionModeStateDto, Value>( + TransportMethod::Get, + &format!("/api/sessions/{session_id}/mode"), + Vec::new(), + None, + true, + ) + .await + } + + pub async fn switch_mode( + &self, + session_id: &str, + request: SwitchModeRequest, + ) -> Result<SessionModeStateDto, ClientError> { + self.send_json( + TransportMethod::Post, + &format!("/api/sessions/{session_id}/mode"), + Vec::new(), + Some(request), + true, + ) + .await + } + pub async fn submit_prompt( &self, session_id: &str, @@ -249,6 +295,62 @@ where .await } + pub async fn get_current_model(&self) -> Result<CurrentModelInfoDto, ClientError> { + self.send_json::<CurrentModelInfoDto, Value>( + TransportMethod::Get, + "/api/models/current", + Vec::new(), + None, + true, + ) + .await + } + + pub async fn list_models(&self) -> Result<Vec<ModelOptionDto>, ClientError> { + self.send_json::<Vec<ModelOptionDto>, Value>( + TransportMethod::Get, + "/api/models", + Vec::new(), + None, + true, + ) + .await + } + + pub async fn save_active_selection( + &self, + request: SaveActiveSelectionRequest, + ) -> Result<(), ClientError> { + let response = self + .transport + .execute(TransportRequest { + method: TransportMethod::Post, + url: self.url("/api/config/active-selection"), + auth_token: Some(self.require_api_token().await?), + query: Vec::new(), + json_body: Some(serde_json::to_value(request).map_err(|error| { + ClientError::new( + ClientErrorKind::UnexpectedResponse, + format!("serialize request body failed: {error}"), + ) + })?), + }) + .await + .map_err(ClientError::from_transport)?; + if response.status == 204 { + Ok(()) + } else { + Err(ClientError::new( + ClientErrorKind::UnexpectedResponse, + format!( + "save active selection expected 204 response, got {}", + response.status + ), + ) + .with_status(response.status)) + } + } + pub async fn request_compact( &self, session_id: &str, @@ -515,8 +617,8 @@ mod tests { use astrcode_protocol::http::{ CompactSessionRequest, CompactSessionResponse, ConversationSlashActionKindDto, - ConversationSlashCandidateDto, ConversationSlashCandidatesResponseDto, ExecutionControlDto, - PhaseDto, + ConversationSlashCandidateDto, ConversationSlashCandidatesResponseDto, CurrentModelInfoDto, + ExecutionControlDto, PhaseDto, SaveActiveSelectionRequest, }; use async_trait::async_trait; use serde_json::json; @@ -665,8 +767,10 @@ mod tests { "control": { "phase": "idle", "canSubmitPrompt": true, - "canRequestCompact": true, - "compactPending": false + "canRequestCompact": true, + "compactPending": false, + "compacting": false, + "currentModeId": "default" } }) .to_string(), @@ -795,12 +899,12 @@ mod tests { status: 200, body: json!({ "items": [{ - "id": "skill-review", + "id": "review", "title": "Review skill", "description": "插入 review skill", "keywords": ["skill", "review"], "actionKind": "insert_text", - "actionValue": "/skill review" + "actionValue": "/review" }] }) .to_string(), @@ -823,12 +927,12 @@ mod tests { candidates, ConversationSlashCandidatesResponseDto { items: vec![ConversationSlashCandidateDto { - id: "skill-review".to_string(), + id: "review".to_string(), title: "Review skill".to_string(), description: "插入 review skill".to_string(), keywords: vec!["skill".to_string(), "review".to_string()], action_kind: ConversationSlashActionKindDto::InsertText, - action_value: "/skill review".to_string(), + action_value: "/review".to_string(), }] } ); @@ -898,7 +1002,13 @@ mod tests { ) .await; let compact_error = client - .request_compact("session-1", CompactSessionRequest { control: None }) + .request_compact( + "session-1", + CompactSessionRequest { + control: None, + instructions: None, + }, + ) .await .expect_err("compact should fail"); assert_eq!(compact_error.kind, ClientErrorKind::AuthExpired); @@ -969,7 +1079,13 @@ mod tests { transport, ); let response = client - .request_compact("session-1", CompactSessionRequest { control: None }) + .request_compact( + "session-1", + CompactSessionRequest { + control: None, + instructions: None, + }, + ) .await .expect("compact should succeed"); assert_eq!( @@ -994,7 +1110,8 @@ mod tests { json_body: Some(json!({ "control": { "manualCompact": true - } + }, + "instructions": "保留错误和文件路径" })), }, result: Ok(TransportResponse { @@ -1025,10 +1142,95 @@ mod tests { max_steps: None, manual_compact: None, }), + instructions: Some("保留错误和文件路径".to_string()), }, ) .await .expect("compact should succeed"); assert!(response.deferred); } + + #[tokio::test] + async fn save_active_selection_accepts_no_content_response() { + let transport = MockTransport::default(); + transport.push(MockCall::Request { + expected: TransportRequest { + method: TransportMethod::Post, + url: "http://localhost:5529/api/config/active-selection".to_string(), + auth_token: Some("session-token".to_string()), + query: Vec::new(), + json_body: Some(json!({ + "activeProfile": "anthropic", + "activeModel": "claude-sonnet" + })), + }, + result: Ok(TransportResponse { + status: 204, + body: String::new(), + }), + }); + + let client = AstrcodeClient::with_transport( + ClientConfig { + origin: "http://localhost:5529".to_string(), + api_token: Some("session-token".to_string()), + api_token_expires_at_ms: Some(super::current_timestamp_ms() + 30_000), + stream_buffer: 8, + }, + transport, + ); + + client + .save_active_selection(SaveActiveSelectionRequest { + active_profile: "anthropic".to_string(), + active_model: "claude-sonnet".to_string(), + }) + .await + .expect("save active selection should succeed"); + } + + #[tokio::test] + async fn get_current_model_decodes_model_summary() { + let transport = MockTransport::default(); + transport.push(MockCall::Request { + expected: TransportRequest { + method: TransportMethod::Get, + url: "http://localhost:5529/api/models/current".to_string(), + auth_token: Some("session-token".to_string()), + query: Vec::new(), + json_body: None, + }, + result: Ok(TransportResponse { + status: 200, + body: json!({ + "profileName": "anthropic", + "model": "claude-sonnet", + "providerKind": "anthropic" + }) + .to_string(), + }), + }); + + let client = AstrcodeClient::with_transport( + ClientConfig { + origin: "http://localhost:5529".to_string(), + api_token: Some("session-token".to_string()), + api_token_expires_at_ms: Some(super::current_timestamp_ms() + 30_000), + stream_buffer: 8, + }, + transport, + ); + + assert_eq!( + client + .get_current_model() + .await + .expect("current model should decode"), + CurrentModelInfoDto { + profile_name: "anthropic".to_string(), + model: "claude-sonnet".to_string(), + provider_kind: "anthropic".to_string(), + } + ); + } } diff --git a/crates/core/src/action.rs b/crates/core/src/action.rs index d62ce2f3..7a1f1e71 100644 --- a/crates/core/src/action.rs +++ b/crates/core/src/action.rs @@ -12,6 +12,8 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; +use crate::{ChildAgentRef, ExecutionResultCommon}; + /// LLM 推理/思考内容。 /// /// 用于支持扩展思考模型(如 Claude extended thinking), @@ -73,6 +75,9 @@ pub struct ToolExecutionResult { /// 额外元数据(如 diff 信息、终端显示提示等) #[serde(skip_serializing_if = "Option::is_none")] pub metadata: Option<Value>, + /// 工具结果关联的稳定 child reference。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub child_ref: Option<ChildAgentRef>, /// 执行耗时(毫秒) pub duration_ms: u64, /// 输出是否因大小限制被截断 @@ -110,10 +115,34 @@ pub struct ToolOutputDelta { } impl ToolExecutionResult { + /// 用公共执行结果字段一次性构造工具结果,避免先写占位值再二次覆盖。 + pub fn from_common( + tool_call_id: impl Into<String>, + tool_name: impl Into<String>, + ok: bool, + output: impl Into<String>, + child_ref: Option<ChildAgentRef>, + common: ExecutionResultCommon, + ) -> Self { + Self { + tool_call_id: tool_call_id.into(), + tool_name: tool_name.into(), + ok, + output: output.into(), + error: common.error, + metadata: common.metadata, + child_ref, + duration_ms: common.duration_ms, + truncated: common.truncated, + } + } + /// 生成面向模型的工具结果内容。 /// /// 成功时直接返回输出;失败时拼接错误信息和输出, /// 确保 LLM 能理解工具执行的结果。 + /// 如果关联了子 agent(child_ref),追加精确引用提示, + /// 防止 LLM 自作主张改写 agentId。 pub fn model_content(&self) -> String { let base = if self.ok { self.output.clone() @@ -137,33 +166,32 @@ impl ToolExecutionResult { } fn child_agent_reference_hint(&self) -> Option<String> { - let metadata = self.metadata.as_ref()?.as_object()?; - let agent_ref = metadata.get("agentRef")?.as_object()?; - let agent_id = agent_ref.get("agentId")?.as_str()?; + let child_ref = self.child_ref.as_ref()?; let mut lines = vec![ "Child agent reference:".to_string(), - format!("- agentId: {agent_id}"), + format!("- agentId: {}", child_ref.agent_id()), ]; - if let Some(sub_run_id) = agent_ref.get("subRunId").and_then(Value::as_str) { - lines.push(format!("- subRunId: {sub_run_id}")); - } - if let Some(session_id) = agent_ref.get("sessionId").and_then(Value::as_str) { - lines.push(format!("- sessionId: {session_id}")); - } - if let Some(open_session_id) = agent_ref.get("openSessionId").and_then(Value::as_str) { - lines.push(format!("- openSessionId: {open_session_id}")); - } - if let Some(status) = agent_ref.get("status").and_then(Value::as_str) { - lines.push(format!("- status: {status}")); - } + lines.push(format!("- subRunId: {}", child_ref.sub_run_id())); + lines.push(format!("- sessionId: {}", child_ref.session_id())); + lines.push(format!("- openSessionId: {}", child_ref.open_session_id)); + lines.push(format!("- status: {:?}", child_ref.status).to_lowercase()); // 这里显式强调“精确复用原值”,避免模型把 `agent-1` 自作主张改写成 // `agent-01` 之类的展示型编号,导致后续协作工具命中不存在的 agent。 lines.push("Use this exact `agentId` value in later send/observe/close calls.".to_string()); Some(lines.join("\n")) } + + pub fn common(&self) -> ExecutionResultCommon { + ExecutionResultCommon { + error: self.error.clone(), + metadata: self.metadata.clone(), + duration_ms: self.duration_ms, + truncated: self.truncated, + } + } } /// 用户消息的来源。 @@ -176,12 +204,18 @@ pub enum UserMessageOrigin { /// 用户直接输入 #[default] User, + /// 从 durable 输入队列恢复并注入的内部输入。 + QueuedInput, /// turn 内 budget 允许继续时注入的内部续写提示。 AutoContinueNudge, /// assistant 输出被截断后,为同一 turn 续写而注入的内部提示。 ContinuationPrompt, /// 子会话交付后用于唤醒父会话继续决策的内部提示。 ReactivationPrompt, + /// compact 后为最近真实用户消息生成的极短目的摘要。 + RecentUserContextDigest, + /// compact 后重新注入的最近真实用户消息原文。 + RecentUserContext, /// 压缩摘要(上下文压缩后插入的摘要消息) CompactSummary, } @@ -338,6 +372,7 @@ mod tests { use serde_json::json; use super::{ToolExecutionResult, split_assistant_content}; + use crate::{AgentId, ExecutionResultCommon, SessionId, SubRunId}; #[test] fn split_assistant_content_extracts_inline_thinking_blocks() { @@ -354,16 +389,16 @@ mod tests { } #[test] - fn split_assistant_content_prefers_explicit_reasoning_and_strips_legacy_tags() { + fn split_assistant_content_prefers_explicit_reasoning_and_strips_inline_think_tags() { let parts = split_assistant_content( - "<think>legacy</think>\nvisible", + "<think>hidden</think>\nvisible", Some("persisted reasoning"), ); assert_eq!(parts.visible_content, "visible"); assert_eq!( parts.reasoning_content.as_deref(), - Some("persisted reasoning\n\nlegacy") + Some("persisted reasoning\n\nhidden") ); } @@ -376,22 +411,25 @@ mod tests { } #[test] - fn model_content_appends_exact_child_agent_reference_from_metadata() { + fn model_content_appends_exact_child_agent_reference_from_child_ref() { let result = ToolExecutionResult { tool_call_id: "call-1".to_string(), tool_name: "spawn".to_string(), ok: true, output: "spawn 已在后台启动。".to_string(), error: None, - metadata: Some(json!({ - "agentRef": { - "agentId": "agent-1", - "subRunId": "subrun-1", - "sessionId": "session-parent", - "openSessionId": "session-parent", - "status": "running" - } - })), + metadata: Some(json!({ "schema": "subRunResult" })), + child_ref: Some(crate::ChildAgentRef { + identity: crate::ChildExecutionIdentity { + agent_id: AgentId::from("agent-1"), + session_id: SessionId::from("session-parent"), + sub_run_id: SubRunId::from("subrun-1"), + }, + parent: crate::ParentExecutionRef::default(), + lineage_kind: crate::ChildSessionLineageKind::Spawn, + status: crate::AgentLifecycleStatus::Running, + open_session_id: SessionId::from("session-parent"), + }), duration_ms: 0, truncated: false, }; @@ -402,4 +440,27 @@ mod tests { assert!(content.contains("- subRunId: subrun-1")); assert!(content.contains("Use this exact `agentId` value")); } + + #[test] + fn from_common_preserves_failure_fields_without_placeholder_override() { + let result = ToolExecutionResult::from_common( + "call-1", + "spawn", + false, + "", + None, + ExecutionResultCommon::failure( + "spawn failed", + Some(json!({ "schema": "subRunResult" })), + 17, + true, + ), + ); + + assert!(!result.ok); + assert_eq!(result.error.as_deref(), Some("spawn failed")); + assert_eq!(result.metadata, Some(json!({ "schema": "subRunResult" }))); + assert_eq!(result.duration_ms, 17); + assert!(result.truncated); + } } diff --git a/crates/core/src/agent/mailbox.rs b/crates/core/src/agent/input_queue.rs similarity index 66% rename from crates/core/src/agent/mailbox.rs rename to crates/core/src/agent/input_queue.rs index 14a97f42..c440becd 100644 --- a/crates/core/src/agent/mailbox.rs +++ b/crates/core/src/agent/input_queue.rs @@ -1,6 +1,6 @@ -//! # Mailbox 持久化类型 +//! # Input Queue 持久化类型 //! -//! 定义四工具协作模型下的 mailbox 消息、批次、durable 事件载荷和 observe 快照。 +//! 定义四工具协作模型下的 input queue 消息、批次、durable 事件载荷和 observe 快照。 //! //! 所有类型都是纯 DTO,不含运行时策略或状态机逻辑。 //! 事件载荷由 `core` 定义结构,由 `runtime` 负责实际写入 session event log。 @@ -11,8 +11,8 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use super::{ - DelegationMetadata, lifecycle::{AgentLifecycleStatus, AgentTurnOutcome}, + require_non_empty_trimmed, }; use crate::StoredEvent; @@ -20,7 +20,7 @@ use crate::StoredEvent; /// /// 在 at-least-once 语义下用于去重:crash 恢复后相同 delivery_id 重新出现 /// 应被视为上一轮的延续,而不是全新任务。 -pub type DeliveryId = String; +pub type DeliveryId = crate::ids::DeliveryId; /// 固定批次标识。 /// @@ -28,16 +28,16 @@ pub type DeliveryId = String; /// batch_id 在 turn 的 durable 生命周期内保持不变。 pub type BatchId = String; -// ── Mailbox 消息信封 ────────────────────────────────────────────── +// ── Input Queue 消息信封 ────────────────────────────────────────── -/// 一条 durable 协作消息,是 mailbox 的最小可恢复单元。 +/// 一条 durable 协作消息,是 input queue 的最小可恢复单元。 /// /// 入队时捕获发送方的状态快照(enqueue-time snapshot), /// 后续注入 prompt 或 observe 时继续使用这些快照值, /// 而不是注入时现查——保证因果链可追溯。 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] -pub struct AgentMailboxEnvelope { +pub struct QueuedInputEnvelope { pub delivery_id: DeliveryId, pub from_agent_id: String, pub to_agent_id: String, @@ -51,53 +51,53 @@ pub struct AgentMailboxEnvelope { pub sender_open_session_id: String, } -// ── Durable Mailbox 事件载荷 ────────────────────────────────────── +// ── Durable input queue 事件载荷 ────────────────────────────────────── -/// `AgentMailboxQueued` 事件载荷。 +/// `AgentInputQueued` 事件载荷。 /// -/// 记录一条刚成功进入 mailbox 的协作消息。 +/// 记录一条刚成功进入 input queue 的协作消息。 /// live inbox 只能在 Queued append 成功后更新,顺序不能反过来。 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] -pub struct MailboxQueuedPayload { +pub struct InputQueuedPayload { #[serde(flatten)] - pub envelope: AgentMailboxEnvelope, + pub envelope: QueuedInputEnvelope, } -/// `AgentMailboxBatchStarted` 事件载荷。 +/// `AgentInputBatchStarted` 事件载荷。 /// /// 记录某个 agent 在本轮开始时通过 snapshot drain 接管了哪些消息。 -/// 必须是 mailbox-wake turn 的第一条 durable 事件, +/// 必须是 input-queue drain turn 的第一条 durable 事件, /// 以确保 replay 时能准确恢复"本轮接管了什么"这一 durable 事实。 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] -pub struct MailboxBatchStartedPayload { +pub struct InputBatchStartedPayload { pub target_agent_id: String, pub turn_id: String, pub batch_id: BatchId, pub delivery_ids: Vec<DeliveryId>, } -/// `AgentMailboxBatchAcked` 事件载荷。 +/// `AgentInputBatchAcked` 事件载荷。 /// /// 记录某轮在 durable turn completion 后确认处理完成。 /// 不允许在模型流结束但 turn 尚未 durable 提交时提前 ack。 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] -pub struct MailboxBatchAckedPayload { +pub struct InputBatchAckedPayload { pub target_agent_id: String, pub turn_id: String, pub batch_id: BatchId, pub delivery_ids: Vec<DeliveryId>, } -/// `AgentMailboxDiscarded` 事件载荷。 +/// `AgentInputDiscarded` 事件载荷。 /// -/// 记录 close 时主动丢弃的 pending mailbox 消息。 +/// 记录 close 时主动丢弃的 pending input queue 消息。 /// replay 时这些消息不再重建为 pending。 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] -pub struct MailboxDiscardedPayload { +pub struct InputDiscardedPayload { pub target_agent_id: String, pub delivery_ids: Vec<DeliveryId>, } @@ -119,16 +119,8 @@ pub struct SendParams { impl SendParams { pub fn validate(&self) -> crate::error::Result<()> { - if self.agent_id.trim().is_empty() { - return Err(crate::error::AstrError::Validation( - "agentId 不能为空".to_string(), - )); - } - if self.message.trim().is_empty() { - return Err(crate::error::AstrError::Validation( - "message 不能为空".to_string(), - )); - } + require_non_empty_trimmed("agentId", &self.agent_id)?; + require_non_empty_trimmed("message", &self.message)?; Ok(()) } } @@ -146,11 +138,7 @@ pub struct ObserveParams { impl ObserveParams { pub fn validate(&self) -> crate::error::Result<()> { - if self.agent_id.trim().is_empty() { - return Err(crate::error::AstrError::Validation( - "agentId 不能为空".to_string(), - )); - } + require_non_empty_trimmed("agentId", &self.agent_id)?; Ok(()) } } @@ -168,20 +156,16 @@ pub struct CloseParams { impl CloseParams { pub fn validate(&self) -> crate::error::Result<()> { - if self.agent_id.trim().is_empty() { - return Err(crate::error::AstrError::Validation( - "agentId 不能为空".to_string(), - )); - } + require_non_empty_trimmed("agentId", &self.agent_id)?; Ok(()) } } // ── Observe 快照结果 ────────────────────────────────────────────── -// ── Mailbox Projection(派生读模型)────────────────────────────── +// ── Input Queue Projection(派生读模型)─────────────────────────── -/// Mailbox 的派生读模型,从 durable 事件重建。 +/// Input queue 的派生读模型,从 durable 事件重建。 /// /// 唯一 durable 真相仍是 event log,此结构只是 replay 后的缓存视图。 /// 用于 `observe`、wake 调度决策和恢复。 @@ -193,7 +177,7 @@ impl CloseParams { /// - `Discarded` → 标记为丢弃,停止重建 #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] -pub struct MailboxProjection { +pub struct InputQueueProjection { /// 待处理消息 ID(Queued - Acked - Discarded 后剩余)。 pub pending_delivery_ids: Vec<DeliveryId>, /// 当前 started-but-not-acked 的批次 ID。 @@ -204,10 +188,10 @@ pub struct MailboxProjection { pub discarded_delivery_ids: Vec<DeliveryId>, } -impl MailboxProjection { - /// 从 durable 事件流重建指定 agent 的 MailboxProjection。 +impl InputQueueProjection { + /// 从 durable 事件流重建指定 agent 的 InputQueueProjection。 /// - /// 遍历所有事件,只处理与 `target_agent_id` 相关的 mailbox 事件: + /// 遍历所有事件,只处理与 `target_agent_id` 相关的 input queue 事件: /// - `Queued` 按 `to_agent_id` 过滤(消息是发给谁的) /// - `BatchStarted/BatchAcked/Discarded` 按 `target_agent_id` 过滤(谁在消费/丢弃) pub fn replay_for_agent(events: &[StoredEvent], target_agent_id: &str) -> Self { @@ -219,33 +203,33 @@ impl MailboxProjection { projection } - /// 从完整 durable 事件流重建按目标 agent 组织的 mailbox 投影索引。 - pub fn replay_index(events: &[StoredEvent]) -> HashMap<String, MailboxProjection> { + /// 从完整 durable 事件流重建按目标 agent 组织的 input queue 投影索引。 + pub fn replay_index(events: &[StoredEvent]) -> HashMap<String, InputQueueProjection> { let mut index = HashMap::new(); for stored in events { match &stored.event.payload { - crate::StorageEventPayload::AgentMailboxQueued { payload } => { + crate::StorageEventPayload::AgentInputQueued { payload } => { let projection = index .entry(payload.envelope.to_agent_id.clone()) - .or_insert_with(MailboxProjection::default); + .or_insert_with(InputQueueProjection::default); Self::apply_event_for_agent(projection, stored, &payload.envelope.to_agent_id); }, - crate::StorageEventPayload::AgentMailboxBatchStarted { payload } => { + crate::StorageEventPayload::AgentInputBatchStarted { payload } => { let projection = index .entry(payload.target_agent_id.clone()) - .or_insert_with(MailboxProjection::default); + .or_insert_with(InputQueueProjection::default); Self::apply_event_for_agent(projection, stored, &payload.target_agent_id); }, - crate::StorageEventPayload::AgentMailboxBatchAcked { payload } => { + crate::StorageEventPayload::AgentInputBatchAcked { payload } => { let projection = index .entry(payload.target_agent_id.clone()) - .or_insert_with(MailboxProjection::default); + .or_insert_with(InputQueueProjection::default); Self::apply_event_for_agent(projection, stored, &payload.target_agent_id); }, - crate::StorageEventPayload::AgentMailboxDiscarded { payload } => { + crate::StorageEventPayload::AgentInputDiscarded { payload } => { let projection = index .entry(payload.target_agent_id.clone()) - .or_insert_with(MailboxProjection::default); + .or_insert_with(InputQueueProjection::default); Self::apply_event_for_agent(projection, stored, &payload.target_agent_id); }, _ => {}, @@ -254,16 +238,16 @@ impl MailboxProjection { index } - /// 将单条 durable mailbox 事件应用到指定目标 agent 的投影。 + /// 将单条 durable input queue 事件应用到指定目标 agent 的投影。 pub fn apply_event_for_agent( - projection: &mut MailboxProjection, + projection: &mut InputQueueProjection, stored: &StoredEvent, target_agent_id: &str, ) { use crate::StorageEventPayload; match &stored.event.payload { - StorageEventPayload::AgentMailboxQueued { payload } => { + StorageEventPayload::AgentInputQueued { payload } => { if payload.envelope.to_agent_id != target_agent_id { return; } @@ -274,14 +258,14 @@ impl MailboxProjection { projection.pending_delivery_ids.push(id.clone()); } }, - StorageEventPayload::AgentMailboxBatchStarted { payload } => { + StorageEventPayload::AgentInputBatchStarted { payload } => { if payload.target_agent_id != target_agent_id { return; } projection.active_batch_id = Some(payload.batch_id.clone()); projection.active_delivery_ids = payload.delivery_ids.clone(); }, - StorageEventPayload::AgentMailboxBatchAcked { payload } => { + StorageEventPayload::AgentInputBatchAcked { payload } => { if payload.target_agent_id != target_agent_id { return; } @@ -294,7 +278,7 @@ impl MailboxProjection { projection.active_delivery_ids.clear(); } }, - StorageEventPayload::AgentMailboxDiscarded { payload } => { + StorageEventPayload::AgentInputDiscarded { payload } => { if payload.target_agent_id != target_agent_id { return; } @@ -322,7 +306,7 @@ impl MailboxProjection { } /// 返回当前待处理消息数量。 - pub fn pending_message_count(&self) -> usize { + pub fn pending_input_count(&self) -> usize { self.pending_delivery_ids.len() } } @@ -331,16 +315,13 @@ impl MailboxProjection { /// `observe` 工具返回的目标 Agent 查询结果。 /// -/// 融合 live control state、对话投影和 mailbox 派生信息。 +/// 融合 live control state 与对话投影。 /// 是读模型而非领域实体。 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] -pub struct ObserveAgentResult { +pub struct ObserveSnapshot { pub agent_id: String, - pub sub_run_id: String, pub session_id: String, - pub open_session_id: String, - pub parent_agent_id: String, /// 当前生命周期状态。 pub lifecycle_status: AgentLifecycleStatus, /// 最近一轮执行结果。 @@ -349,31 +330,15 @@ pub struct ObserveAgentResult { pub phase: String, /// 当前轮次数。 pub turn_count: u32, - /// durable replay 为准的待处理消息数量。 - pub pending_message_count: usize, /// 当前正在处理的任务摘要。 + #[serde(default, skip_serializing_if = "Option::is_none")] pub active_task: Option<String>, - /// 下一条待处理任务摘要。 - pub pending_task: Option<String>, - /// 最近几条 mailbox 消息摘要,仅用于帮助判断最近协作上下文。 - /// - /// 这是 tail view,不是全量 mailbox dump,避免 observe 结果过长。 - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub recent_mailbox_messages: Vec<String>, - /// 最近 assistant 输出摘要。 - pub last_output: Option<String>, - /// responsibility continuity / restricted-child 的轻量元数据。 + /// 最近 assistant 输出尾部。 #[serde(default, skip_serializing_if = "Option::is_none")] - pub delegation: Option<DelegationMetadata>, - /// 面向下一步决策的建议动作。 - /// - /// 这是 advisory projection,不是新的业务真相; - /// 调用方仍应以 lifecycle/outcome 等原始事实为准。 - pub recommended_next_action: String, - /// 对建议动作的简短说明。 - pub recommended_reason: String, - /// 交付新鲜度投影,帮助调用方判断是继续等待还是立即处理。 - pub delivery_freshness: String, + pub last_output_tail: Option<String>, + /// 最后一个 turn 的尾部内容。 + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub last_turn_tail: Vec<String>, } #[cfg(test)] @@ -420,74 +385,73 @@ mod tests { } #[test] - fn mailbox_projection_replay_tracks_full_lifecycle() { + fn input_queue_projection_replay_tracks_full_lifecycle() { use crate::{StorageEvent, StorageEventPayload, StoredEvent}; let agent = crate::AgentEventContext::default(); - let events = vec![ - StoredEvent { - storage_seq: 1, - event: StorageEvent { - turn_id: Some("t1".into()), - agent: agent.clone(), - payload: StorageEventPayload::AgentMailboxQueued { - payload: MailboxQueuedPayload { - envelope: AgentMailboxEnvelope { - delivery_id: "d1".into(), - from_agent_id: "parent".into(), - to_agent_id: "child".into(), - message: "hello".into(), - queued_at: chrono::Utc::now(), - sender_lifecycle_status: - crate::agent::lifecycle::AgentLifecycleStatus::Running, - sender_last_turn_outcome: None, - sender_open_session_id: "s-parent".into(), - }, + let queued = StoredEvent { + storage_seq: 1, + event: StorageEvent { + turn_id: Some("t1".into()), + agent: agent.clone(), + payload: StorageEventPayload::AgentInputQueued { + payload: InputQueuedPayload { + envelope: QueuedInputEnvelope { + delivery_id: "d1".into(), + from_agent_id: "parent".into(), + to_agent_id: "child".into(), + message: "hello".into(), + queued_at: chrono::Utc::now(), + sender_lifecycle_status: + crate::agent::lifecycle::AgentLifecycleStatus::Running, + sender_last_turn_outcome: None, + sender_open_session_id: "s-parent".into(), }, }, }, }, - StoredEvent { - storage_seq: 2, - event: StorageEvent { - turn_id: Some("t2".into()), - agent: agent.clone(), - payload: StorageEventPayload::AgentMailboxBatchStarted { - payload: MailboxBatchStartedPayload { - target_agent_id: "child".into(), - turn_id: "t2".into(), - batch_id: "b1".into(), - delivery_ids: vec!["d1".into()], - }, + }; + let started = StoredEvent { + storage_seq: 2, + event: StorageEvent { + turn_id: Some("t2".into()), + agent: agent.clone(), + payload: StorageEventPayload::AgentInputBatchStarted { + payload: InputBatchStartedPayload { + target_agent_id: "child".into(), + turn_id: "t2".into(), + batch_id: "b1".into(), + delivery_ids: vec!["d1".into()], }, }, }, - StoredEvent { - storage_seq: 3, - event: StorageEvent { - turn_id: Some("t2".into()), - agent: agent.clone(), - payload: StorageEventPayload::AgentMailboxBatchAcked { - payload: MailboxBatchAckedPayload { - target_agent_id: "child".into(), - turn_id: "t2".into(), - batch_id: "b1".into(), - delivery_ids: vec!["d1".into()], - }, + }; + let acked = StoredEvent { + storage_seq: 3, + event: StorageEvent { + turn_id: Some("t2".into()), + agent, + payload: StorageEventPayload::AgentInputBatchAcked { + payload: InputBatchAckedPayload { + target_agent_id: "child".into(), + turn_id: "t2".into(), + batch_id: "b1".into(), + delivery_ids: vec!["d1".into()], }, }, }, - ]; + }; + let events = vec![queued, started, acked]; - let projection = MailboxProjection::replay_for_agent(&events, "child"); + let projection = InputQueueProjection::replay_for_agent(&events, "child"); assert!(projection.pending_delivery_ids.is_empty()); assert!(projection.active_batch_id.is_none()); assert!(projection.active_delivery_ids.is_empty()); - assert_eq!(projection.pending_message_count(), 0); + assert_eq!(projection.pending_input_count(), 0); } #[test] - fn mailbox_projection_replay_tracks_discarded() { + fn input_queue_projection_replay_tracks_discarded() { use crate::{StorageEvent, StorageEventPayload, StoredEvent}; let agent = crate::AgentEventContext::default(); @@ -497,9 +461,9 @@ mod tests { event: StorageEvent { turn_id: Some("t1".into()), agent: agent.clone(), - payload: StorageEventPayload::AgentMailboxQueued { - payload: MailboxQueuedPayload { - envelope: AgentMailboxEnvelope { + payload: StorageEventPayload::AgentInputQueued { + payload: InputQueuedPayload { + envelope: QueuedInputEnvelope { delivery_id: "d1".into(), from_agent_id: "parent".into(), to_agent_id: "child".into(), @@ -519,8 +483,8 @@ mod tests { event: StorageEvent { turn_id: Some("t1".into()), agent: agent.clone(), - payload: StorageEventPayload::AgentMailboxDiscarded { - payload: MailboxDiscardedPayload { + payload: StorageEventPayload::AgentInputDiscarded { + payload: InputDiscardedPayload { target_agent_id: "child".into(), delivery_ids: vec!["d1".into()], }, @@ -529,17 +493,13 @@ mod tests { }, ]; - let projection = MailboxProjection::replay_for_agent(&events, "child"); + let projection = InputQueueProjection::replay_for_agent(&events, "child"); assert!(projection.pending_delivery_ids.is_empty()); - assert!( - projection - .discarded_delivery_ids - .contains(&"d1".to_string()) - ); + assert!(projection.discarded_delivery_ids.contains(&"d1".into())); } #[test] - fn mailbox_projection_started_but_not_acked_keeps_pending() { + fn input_queue_projection_started_but_not_acked_keeps_pending() { use crate::{StorageEvent, StorageEventPayload, StoredEvent}; let agent = crate::AgentEventContext::default(); @@ -549,9 +509,9 @@ mod tests { event: StorageEvent { turn_id: Some("t1".into()), agent: agent.clone(), - payload: StorageEventPayload::AgentMailboxQueued { - payload: MailboxQueuedPayload { - envelope: AgentMailboxEnvelope { + payload: StorageEventPayload::AgentInputQueued { + payload: InputQueuedPayload { + envelope: QueuedInputEnvelope { delivery_id: "d1".into(), from_agent_id: "parent".into(), to_agent_id: "child".into(), @@ -571,8 +531,8 @@ mod tests { event: StorageEvent { turn_id: Some("t2".into()), agent: agent.clone(), - payload: StorageEventPayload::AgentMailboxBatchStarted { - payload: MailboxBatchStartedPayload { + payload: StorageEventPayload::AgentInputBatchStarted { + payload: InputBatchStartedPayload { target_agent_id: "child".into(), turn_id: "t2".into(), batch_id: "b1".into(), @@ -583,15 +543,15 @@ mod tests { }, ]; - let projection = MailboxProjection::replay_for_agent(&events, "child"); + let projection = InputQueueProjection::replay_for_agent(&events, "child"); // Started 但未 Acked,d1 仍在 pending 中(at-least-once 语义) - assert!(projection.pending_delivery_ids.contains(&"d1".to_string())); + assert!(projection.pending_delivery_ids.contains(&"d1".into())); assert_eq!(projection.active_batch_id.as_deref(), Some("b1")); - assert_eq!(projection.pending_message_count(), 1); + assert_eq!(projection.pending_input_count(), 1); } #[test] - fn mailbox_projection_per_agent_filtering_isolates_agents() { + fn input_queue_projection_per_agent_filtering_isolates_agents() { use crate::{StorageEvent, StorageEventPayload, StoredEvent}; let agent = crate::AgentEventContext::default(); @@ -602,9 +562,9 @@ mod tests { event: StorageEvent { turn_id: Some("t1".into()), agent: agent.clone(), - payload: StorageEventPayload::AgentMailboxQueued { - payload: MailboxQueuedPayload { - envelope: AgentMailboxEnvelope { + payload: StorageEventPayload::AgentInputQueued { + payload: InputQueuedPayload { + envelope: QueuedInputEnvelope { delivery_id: "d-a".into(), from_agent_id: "parent".into(), to_agent_id: "agent-a".into(), @@ -624,9 +584,9 @@ mod tests { event: StorageEvent { turn_id: Some("t1".into()), agent: agent.clone(), - payload: StorageEventPayload::AgentMailboxQueued { - payload: MailboxQueuedPayload { - envelope: AgentMailboxEnvelope { + payload: StorageEventPayload::AgentInputQueued { + payload: InputQueuedPayload { + envelope: QueuedInputEnvelope { delivery_id: "d-b".into(), from_agent_id: "parent".into(), to_agent_id: "agent-b".into(), @@ -643,15 +603,15 @@ mod tests { }, ]; - let projection_a = MailboxProjection::replay_for_agent(&events, "agent-a"); - assert_eq!(projection_a.pending_delivery_ids, vec!["d-a".to_string()]); - assert_eq!(projection_a.pending_message_count(), 1); + let projection_a = InputQueueProjection::replay_for_agent(&events, "agent-a"); + assert_eq!(projection_a.pending_delivery_ids, vec!["d-a".into()]); + assert_eq!(projection_a.pending_input_count(), 1); - let projection_b = MailboxProjection::replay_for_agent(&events, "agent-b"); - assert_eq!(projection_b.pending_delivery_ids, vec!["d-b".to_string()]); - assert_eq!(projection_b.pending_message_count(), 1); + let projection_b = InputQueueProjection::replay_for_agent(&events, "agent-b"); + assert_eq!(projection_b.pending_delivery_ids, vec!["d-b".into()]); + assert_eq!(projection_b.pending_input_count(), 1); - let projection_c = MailboxProjection::replay_for_agent(&events, "agent-c"); - assert_eq!(projection_c.pending_message_count(), 0); + let projection_c = InputQueueProjection::replay_for_agent(&events, "agent-c"); + assert_eq!(projection_c.pending_input_count(), 0); } } diff --git a/crates/core/src/agent/mod.rs b/crates/core/src/agent/mod.rs index 0bf5f6a9..270f264f 100644 --- a/crates/core/src/agent/mod.rs +++ b/crates/core/src/agent/mod.rs @@ -6,15 +6,33 @@ //! //! 子模块划分: //! - `lifecycle`:AgentLifecycleStatus + AgentTurnOutcome(四工具模型的状态拆层) -//! - `mailbox`:durable mailbox 信封、事件载荷、四工具参数和 observe 快照 +//! - `input queue`:durable input queue 信封、事件载荷、四工具参数和 observe 快照 pub mod executor; +pub mod input_queue; pub mod lifecycle; -pub mod mailbox; -use serde::{Deserialize, Deserializer, Serialize}; +use serde::{Deserialize, Serialize}; -use crate::error::{AstrError, Result}; +use crate::{ + AgentId, DeliveryId, SessionId, SubRunId, TurnId, + error::{AstrError, Result}, +}; + +fn require_non_empty_trimmed(field: &str, value: impl AsRef<str>) -> Result<()> { + if value.as_ref().trim().is_empty() { + return Err(AstrError::Validation(format!("{field} 不能为空"))); + } + Ok(()) +} + +fn require_not_whitespace_only(field: &str, value: impl AsRef<str>) -> Result<()> { + let value = value.as_ref(); + if !value.is_empty() && value.trim().is_empty() { + return Err(AstrError::Validation(format!("{field} 不能为纯空白"))); + } + Ok(()) +} /// 归一化一个非空白、无重复的字符串列表,并保留首次出现顺序。 pub fn normalize_non_empty_unique_string_list( @@ -68,8 +86,7 @@ pub enum InvocationKind { /// Fork 上下文继承模式。 /// -/// TODO: 当前仅定义枚举,runtime 侧未完整消费。 -/// 未来 compact agent 将使用此字段决定子 agent 继承多少父对话上下文。 +/// runtime 会用它裁剪子 agent 继承的父对话 tail。 /// 参考 Codex 的 SpawnAgentForkMode 设计。 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] @@ -142,16 +159,10 @@ impl SpawnAgentParams { pub fn validate(&self) -> Result<()> { // prompt 是子 Agent 收到的指令主体,不能为空; // 否则 runtime 只能启动一个没有任务语义的空会话。 - if self.prompt.trim().is_empty() { - return Err(AstrError::Validation("prompt 不能为空".to_string())); - } + require_non_empty_trimmed("prompt", &self.prompt)?; // description 只承担可观测性职责; // 允许空串兼容模型输出,但纯空白会污染标题与日志。 - if !self.description.is_empty() && self.description.trim().is_empty() { - return Err(AstrError::Validation( - "description 不能为纯空白".to_string(), - )); - } + require_not_whitespace_only("description", &self.description)?; if let Some(grant) = &self.capability_grant { grant.validate()?; } @@ -160,6 +171,10 @@ impl SpawnAgentParams { } /// 子会话事件写入的存储模式。 +/// +/// TODO: 当前只有 `IndependentSession` 一个变体。 +/// 如果未来真的要支持共享 session / 嵌套持久化域等模式,再扩展枚举; +/// 在那之前保留 enum 形状,避免过早把潜在扩展点压成单态值对象。 #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub enum SubRunStorageMode { @@ -303,101 +318,13 @@ pub struct ParentDelivery { pub payload: ParentDeliveryPayload, } -fn legacy_trimmed(value: Option<&str>) -> Option<String> { - value - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(ToString::to_string) -} - -fn legacy_message_id_suffix(message: &str) -> String { - let prefix: String = message - .chars() - .take(24) - .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '_' }) - .collect(); - format!("{}:{prefix}", message.chars().count()) -} - -fn legacy_handoff_delivery( - summary: Option<&str>, - findings: Vec<String>, - artifacts: Vec<ArtifactRef>, -) -> Option<ParentDelivery> { - let message = legacy_trimmed(summary)?; - Some(ParentDelivery { - idempotency_key: format!("legacy-handoff:{}", legacy_message_id_suffix(&message)), - origin: ParentDeliveryOrigin::Fallback, - terminal_semantics: ParentDeliveryTerminalSemantics::Terminal, - source_turn_id: None, - payload: ParentDeliveryPayload::Completed(CompletedParentDeliveryPayload { - message, - findings, - artifacts, - }), - }) -} - -fn legacy_notification_delivery( - notification_id: &str, - kind: ChildSessionNotificationKind, - summary: Option<&str>, - final_reply_excerpt: Option<&str>, -) -> Option<ParentDelivery> { - let message = legacy_trimmed(final_reply_excerpt).or_else(|| legacy_trimmed(summary))?; - let payload = match kind { - ChildSessionNotificationKind::Delivered => { - ParentDeliveryPayload::Completed(CompletedParentDeliveryPayload { - message, - findings: Vec::new(), - artifacts: Vec::new(), - }) - }, - ChildSessionNotificationKind::Failed => { - ParentDeliveryPayload::Failed(FailedParentDeliveryPayload { - message, - code: SubRunFailureCode::Internal, - technical_message: None, - retryable: false, - }) - }, - ChildSessionNotificationKind::Closed => { - ParentDeliveryPayload::CloseRequest(CloseRequestParentDeliveryPayload { - message, - reason: Some("legacy_child_notification".to_string()), - }) - }, - ChildSessionNotificationKind::Started - | ChildSessionNotificationKind::ProgressSummary - | ChildSessionNotificationKind::Waiting - | ChildSessionNotificationKind::Resumed => { - ParentDeliveryPayload::Progress(ProgressParentDeliveryPayload { message }) - }, - }; - - Some(ParentDelivery { - idempotency_key: notification_id.to_string(), - origin: ParentDeliveryOrigin::Fallback, - terminal_semantics: match kind { - ChildSessionNotificationKind::Started - | ChildSessionNotificationKind::ProgressSummary - | ChildSessionNotificationKind::Waiting - | ChildSessionNotificationKind::Resumed => ParentDeliveryTerminalSemantics::NonTerminal, - ChildSessionNotificationKind::Delivered - | ChildSessionNotificationKind::Closed - | ChildSessionNotificationKind::Failed => ParentDeliveryTerminalSemantics::Terminal, - }, - source_turn_id: None, - payload, - }) -} - /// 子执行传递给父会话的业务结果。 /// /// 该结构只承载“父 Agent 后续决策真正需要消费的内容”, /// 明确排除 transport/provider/internal diagnostics。 -#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] +#[serde(deny_unknown_fields)] pub struct SubRunHandoff { #[serde(default, skip_serializing_if = "Vec::is_empty")] pub findings: Vec<String>, @@ -407,40 +334,6 @@ pub struct SubRunHandoff { pub delivery: Option<ParentDelivery>, } -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct SubRunHandoffWire { - #[serde(default)] - summary: Option<String>, - #[serde(default)] - findings: Vec<String>, - #[serde(default)] - artifacts: Vec<ArtifactRef>, - #[serde(default)] - delivery: Option<ParentDelivery>, -} - -impl<'de> Deserialize<'de> for SubRunHandoff { - fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error> - where - D: Deserializer<'de>, - { - let wire = SubRunHandoffWire::deserialize(deserializer)?; - let delivery = wire.delivery.or_else(|| { - legacy_handoff_delivery( - wire.summary.as_deref(), - wire.findings.clone(), - wire.artifacts.clone(), - ) - }); - Ok(Self { - findings: wire.findings, - artifacts: wire.artifacts, - delivery, - }) - } -} - /// 子执行失败的结构化信息。 /// /// `display_message` 面向父 Agent / UI 主视图,要求短且稳定; @@ -457,16 +350,143 @@ pub struct SubRunFailure { use lifecycle::AgentLifecycleStatus; /// 子执行结构化结果。 +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum CompletedSubRunOutcome { + Completed, + TokenExceeded, +} + +impl CompletedSubRunOutcome { + pub fn as_turn_outcome(self) -> lifecycle::AgentTurnOutcome { + match self { + Self::Completed => lifecycle::AgentTurnOutcome::Completed, + Self::TokenExceeded => lifecycle::AgentTurnOutcome::TokenExceeded, + } + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum FailedSubRunOutcome { + Failed, + Cancelled, +} + +impl FailedSubRunOutcome { + pub fn as_turn_outcome(self) -> lifecycle::AgentTurnOutcome { + match self { + Self::Failed => lifecycle::AgentTurnOutcome::Failed, + Self::Cancelled => lifecycle::AgentTurnOutcome::Cancelled, + } + } +} + +/// 子执行对外可观察的正式状态。 +/// +/// 这是 `SubRunResult` 的 canonical status projection,避免外围再组合 +/// `lifecycle + last_turn_outcome` 反推业务语义。 +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum SubRunStatus { + Running, + Completed, + TokenExceeded, + Failed, + Cancelled, +} + +impl SubRunStatus { + pub fn lifecycle(self) -> AgentLifecycleStatus { + match self { + Self::Running => AgentLifecycleStatus::Running, + Self::Completed | Self::TokenExceeded | Self::Failed | Self::Cancelled => { + AgentLifecycleStatus::Idle + }, + } + } + + pub fn last_turn_outcome(self) -> Option<lifecycle::AgentTurnOutcome> { + match self { + Self::Running => None, + Self::Completed => Some(lifecycle::AgentTurnOutcome::Completed), + Self::TokenExceeded => Some(lifecycle::AgentTurnOutcome::TokenExceeded), + Self::Failed => Some(lifecycle::AgentTurnOutcome::Failed), + Self::Cancelled => Some(lifecycle::AgentTurnOutcome::Cancelled), + } + } + + pub fn is_failed(self) -> bool { + matches!(self, Self::Failed) + } + + pub fn label(self) -> &'static str { + match self { + Self::Running => "running", + Self::Completed => "completed", + Self::TokenExceeded => "token_exceeded", + Self::Failed => "failed", + Self::Cancelled => "cancelled", + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct SubRunResult { - pub lifecycle: AgentLifecycleStatus, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub last_turn_outcome: Option<lifecycle::AgentTurnOutcome>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub handoff: Option<SubRunHandoff>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub failure: Option<SubRunFailure>, +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum SubRunResult { + Running { + handoff: SubRunHandoff, + }, + Completed { + outcome: CompletedSubRunOutcome, + handoff: SubRunHandoff, + }, + Failed { + outcome: FailedSubRunOutcome, + failure: SubRunFailure, + }, +} + +impl SubRunResult { + pub fn status(&self) -> SubRunStatus { + match self { + Self::Running { .. } => SubRunStatus::Running, + Self::Completed { outcome, .. } => match outcome { + CompletedSubRunOutcome::Completed => SubRunStatus::Completed, + CompletedSubRunOutcome::TokenExceeded => SubRunStatus::TokenExceeded, + }, + Self::Failed { outcome, .. } => match outcome { + FailedSubRunOutcome::Failed => SubRunStatus::Failed, + FailedSubRunOutcome::Cancelled => SubRunStatus::Cancelled, + }, + } + } + + pub fn lifecycle(&self) -> AgentLifecycleStatus { + self.status().lifecycle() + } + + pub fn last_turn_outcome(&self) -> Option<lifecycle::AgentTurnOutcome> { + self.status().last_turn_outcome() + } + + pub fn handoff(&self) -> Option<&SubRunHandoff> { + match self { + Self::Running { handoff } | Self::Completed { handoff, .. } => Some(handoff), + Self::Failed { .. } => None, + } + } + + pub fn failure(&self) -> Option<&SubRunFailure> { + match self { + Self::Failed { failure, .. } => Some(failure), + Self::Running { .. } | Self::Completed { .. } => None, + } + } + + pub fn is_failed(&self) -> bool { + self.status().is_failed() + } } /// 调用侧可传入的子会话上下文 override。 @@ -628,24 +648,24 @@ pub trait AgentProfileCatalog: Send + Sync { #[serde(rename_all = "camelCase")] pub struct SubRunHandle { /// 稳定的子执行域 ID。 - pub sub_run_id: String, + pub sub_run_id: SubRunId, /// 运行时分配的 agent 实例 ID。 - pub agent_id: String, + pub agent_id: AgentId, /// 子会话写入所在的 session。 - pub session_id: String, + pub session_id: SessionId, /// 若使用独立子会话,这里记录 child session id。 #[serde(default, skip_serializing_if = "Option::is_none")] - pub child_session_id: Option<String>, + pub child_session_id: Option<SessionId>, /// 当前子 Agent 在父子树中的深度。 pub depth: usize, /// 触发该子会话的父 turn。必填:lineage 核心事实,不为 downgrade 保持 optional。 - pub parent_turn_id: String, + pub parent_turn_id: TurnId, /// 触发该子会话的父 agent。 #[serde(default, skip_serializing_if = "Option::is_none")] - pub parent_agent_id: Option<String>, + pub parent_agent_id: Option<AgentId>, /// 触发该子会话的父 sub-run。 #[serde(default, skip_serializing_if = "Option::is_none")] - pub parent_sub_run_id: Option<String>, + pub parent_sub_run_id: Option<SubRunId>, /// 当前执行实例的谱系来源。 #[serde(default = "default_child_session_lineage_kind")] pub lineage_kind: ChildSessionLineageKind, @@ -666,6 +686,43 @@ pub struct SubRunHandle { pub delegation: Option<DelegationMetadata>, } +impl SubRunHandle { + pub fn child_identity(&self) -> ChildExecutionIdentity { + ChildExecutionIdentity { + agent_id: self.agent_id.clone(), + session_id: self.session_id.clone(), + sub_run_id: self.sub_run_id.clone(), + } + } + + pub fn parent_ref(&self) -> ParentExecutionRef { + ParentExecutionRef { + parent_agent_id: self.parent_agent_id.clone(), + parent_sub_run_id: self.parent_sub_run_id.clone(), + } + } + + pub fn open_session_id(&self) -> SessionId { + self.child_session_id + .clone() + .unwrap_or_else(|| self.session_id.clone()) + } + + pub fn child_ref(&self) -> ChildAgentRef { + self.child_ref_with_status(self.lifecycle) + } + + pub fn child_ref_with_status(&self, status: AgentLifecycleStatus) -> ChildAgentRef { + ChildAgentRef { + identity: self.child_identity(), + parent: self.parent_ref(), + lineage_kind: self.lineage_kind, + status, + open_session_id: self.open_session_id(), + } + } +} + /// 子会话 lineage 来源。 #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] @@ -687,6 +744,25 @@ pub enum ChildSessionStatusSource { Durable, } +/// 共享的 child execution identity。 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ChildExecutionIdentity { + pub agent_id: AgentId, + pub session_id: SessionId, + pub sub_run_id: SubRunId, +} + +/// 共享的 parent lineage 指针。 +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ParentExecutionRef { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parent_agent_id: Option<AgentId>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parent_sub_run_id: Option<SubRunId>, +} + /// 父/子协作面暴露的稳定子会话引用。 /// /// 只承载 child identity、lineage、status 和唯一 canonical open target。 @@ -694,17 +770,57 @@ pub enum ChildSessionStatusSource { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct ChildAgentRef { - pub agent_id: String, - pub session_id: String, - pub sub_run_id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub parent_agent_id: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub parent_sub_run_id: Option<String>, + #[serde(flatten)] + pub identity: ChildExecutionIdentity, + #[serde(flatten)] + pub parent: ParentExecutionRef, pub lineage_kind: ChildSessionLineageKind, pub status: AgentLifecycleStatus, /// 唯一 canonical child open target。通知、DTO 与其他外层载荷不得重复持有同值字段。 - pub open_session_id: String, + pub open_session_id: SessionId, +} + +impl ChildAgentRef { + pub fn agent_id(&self) -> &AgentId { + &self.identity.agent_id + } + + pub fn session_id(&self) -> &SessionId { + &self.identity.session_id + } + + pub fn sub_run_id(&self) -> &SubRunId { + &self.identity.sub_run_id + } + + pub fn parent_agent_id(&self) -> Option<&AgentId> { + self.parent.parent_agent_id.as_ref() + } + + pub fn parent_sub_run_id(&self) -> Option<&SubRunId> { + self.parent.parent_sub_run_id.as_ref() + } + + pub fn to_child_session_node( + &self, + parent_turn_id: TurnId, + status_source: ChildSessionStatusSource, + created_by_tool_call_id: Option<DeliveryId>, + lineage_snapshot: Option<LineageSnapshot>, + ) -> ChildSessionNode { + ChildSessionNode { + identity: self.identity.clone(), + child_session_id: self.open_session_id.clone(), + parent_session_id: self.session_id().clone(), + parent: self.parent.clone(), + parent_turn_id, + lineage_kind: self.lineage_kind, + status: self.status, + status_source, + created_by_tool_call_id, + lineage_snapshot, + } + } } /// 子会话 lineage 快照元数据。 @@ -716,49 +832,63 @@ pub struct ChildAgentRef { #[serde(rename_all = "camelCase")] pub struct LineageSnapshot { /// 谱系来源 agent ID(fork 时为源 agent,resume 时为原始 agent)。 - pub source_agent_id: String, + pub source_agent_id: AgentId, /// 谱系来源 session ID。 - pub source_session_id: String, + pub source_session_id: SessionId, /// 谱系来源 sub_run_id(如果适用)。 #[serde(default, skip_serializing_if = "Option::is_none")] - pub source_sub_run_id: Option<String>, + pub source_sub_run_id: Option<SubRunId>, } /// durable 子会话节点。 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct ChildSessionNode { - pub agent_id: String, - pub session_id: String, - pub child_session_id: String, - pub sub_run_id: String, - pub parent_session_id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub parent_agent_id: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub parent_sub_run_id: Option<String>, - pub parent_turn_id: String, + #[serde(flatten)] + pub identity: ChildExecutionIdentity, + pub child_session_id: SessionId, + pub parent_session_id: SessionId, + #[serde(flatten)] + pub parent: ParentExecutionRef, + pub parent_turn_id: TurnId, pub lineage_kind: ChildSessionLineageKind, pub status: AgentLifecycleStatus, pub status_source: ChildSessionStatusSource, #[serde(default, skip_serializing_if = "Option::is_none")] - pub created_by_tool_call_id: Option<String>, + pub created_by_tool_call_id: Option<DeliveryId>, /// 谱系来源快照。fork/resume 时记录来源上下文,spawn 时为 None。 #[serde(default, skip_serializing_if = "Option::is_none")] pub lineage_snapshot: Option<LineageSnapshot>, } impl ChildSessionNode { + pub fn agent_id(&self) -> &AgentId { + &self.identity.agent_id + } + + pub fn session_id(&self) -> &SessionId { + &self.identity.session_id + } + + pub fn sub_run_id(&self) -> &SubRunId { + &self.identity.sub_run_id + } + + pub fn parent_agent_id(&self) -> Option<&AgentId> { + self.parent.parent_agent_id.as_ref() + } + + pub fn parent_sub_run_id(&self) -> Option<&SubRunId> { + self.parent.parent_sub_run_id.as_ref() + } + /// 将 durable 节点转换为可返回给调用方的稳定 child ref。 /// /// 只返回正式 child 事实,不注入额外 UI 派生值。 pub fn child_ref(&self) -> ChildAgentRef { ChildAgentRef { - agent_id: self.agent_id.clone(), - session_id: self.session_id.clone(), - sub_run_id: self.sub_run_id.clone(), - parent_agent_id: self.parent_agent_id.clone(), - parent_sub_run_id: self.parent_sub_run_id.clone(), + identity: self.identity.clone(), + parent: self.parent.clone(), lineage_kind: self.lineage_kind, status: self.status, open_session_id: self.child_session_id.clone(), @@ -782,61 +912,19 @@ pub enum ChildSessionNotificationKind { /// durable 子会话通知。 /// /// open target 统一从 `child_ref.open_session_id` 读取,不再在外层重复存放。 -#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] +#[serde(deny_unknown_fields)] pub struct ChildSessionNotification { - pub notification_id: String, + pub notification_id: DeliveryId, pub child_ref: ChildAgentRef, pub kind: ChildSessionNotificationKind, - pub status: AgentLifecycleStatus, #[serde(default, skip_serializing_if = "Option::is_none")] - pub source_tool_call_id: Option<String>, + pub source_tool_call_id: Option<DeliveryId>, #[serde(default, skip_serializing_if = "Option::is_none")] pub delivery: Option<ParentDelivery>, } -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct ChildSessionNotificationWire { - notification_id: String, - child_ref: ChildAgentRef, - kind: ChildSessionNotificationKind, - #[serde(default)] - summary: Option<String>, - status: AgentLifecycleStatus, - #[serde(default)] - source_tool_call_id: Option<String>, - #[serde(default)] - final_reply_excerpt: Option<String>, - #[serde(default)] - delivery: Option<ParentDelivery>, -} - -impl<'de> Deserialize<'de> for ChildSessionNotification { - fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error> - where - D: Deserializer<'de>, - { - let wire = ChildSessionNotificationWire::deserialize(deserializer)?; - let delivery = wire.delivery.or_else(|| { - legacy_notification_delivery( - &wire.notification_id, - wire.kind, - wire.summary.as_deref(), - wire.final_reply_excerpt.as_deref(), - ) - }); - Ok(Self { - notification_id: wire.notification_id, - child_ref: wire.child_ref, - kind: wire.kind, - status: wire.status, - source_tool_call_id: wire.source_tool_call_id, - delivery, - }) - } -} - /// `send` 的稳定调用参数。 /// /// 统一承载 parent -> child 与 child -> direct parent 两个方向的协作消息。 @@ -844,7 +932,7 @@ impl<'de> Deserialize<'de> for ChildSessionNotification { #[serde(rename_all = "camelCase")] pub struct SendToChildParams { /// 目标子 Agent 的稳定 ID。 - pub agent_id: String, + pub agent_id: AgentId, /// 追加给子 Agent 的消息内容。 pub message: String, /// 可选补充上下文。 @@ -854,12 +942,8 @@ pub struct SendToChildParams { impl SendToChildParams { pub fn validate(&self) -> Result<()> { - if self.agent_id.trim().is_empty() { - return Err(AstrError::Validation("agentId 不能为空".to_string())); - } - if self.message.trim().is_empty() { - return Err(AstrError::Validation("message 不能为空".to_string())); - } + require_non_empty_trimmed("agentId", &self.agent_id)?; + require_non_empty_trimmed("message", &self.message)?; Ok(()) } } @@ -873,20 +957,20 @@ pub struct SendToParentParams { impl SendToParentParams { pub fn validate(&self) -> Result<()> { - if self.payload.message().trim().is_empty() { - return Err(AstrError::Validation("message 不能为空".to_string())); - } + require_non_empty_trimmed("message", self.payload.message())?; Ok(()) } } /// `send` 的稳定调用参数。 /// -/// 通过 untagged 联合同时承载下行委派和上行交付。 +/// 通过显式方向标记承载下行委派和上行交付,避免 untagged 反序列化歧义。 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(untagged)] +#[serde(tag = "direction", rename_all = "snake_case")] pub enum SendAgentParams { + #[serde(rename = "child")] ToChild(SendToChildParams), + #[serde(rename = "parent")] ToParent(SendToParentParams), } @@ -906,62 +990,104 @@ impl SendAgentParams { #[serde(rename_all = "camelCase")] pub struct CloseAgentParams { /// 目标子 Agent 的稳定 ID。 - pub agent_id: String, + pub agent_id: AgentId, } impl CloseAgentParams { /// 校验参数合法性。 pub fn validate(&self) -> Result<()> { - if self.agent_id.trim().is_empty() { - return Err(AstrError::Validation("agentId 不能为空".to_string())); - } + require_non_empty_trimmed("agentId", &self.agent_id)?; Ok(()) } } /// 协作工具的统一执行结果。 /// -/// 所有协作工具共享此结果结构,通过 `kind` 区分具体语义。 +/// 结果本身携带动作语义,避免再额外维护一套并行 kind + option 矩阵。 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct CollaborationResult { - /// 操作是否被接受。 - pub accepted: bool, - /// 结果类型区分。 - pub kind: CollaborationResultKind, - /// 目标 agent 的稳定引用(若可用)。 - #[serde(default, skip_serializing_if = "Option::is_none")] - pub agent_ref: Option<ChildAgentRef>, - /// 交付 ID(仅 send 场景)。 - #[serde(default, skip_serializing_if = "Option::is_none")] - pub delivery_id: Option<String>, - /// 状态摘要。 - #[serde(default, skip_serializing_if = "Option::is_none")] - pub summary: Option<String>, - /// observe 的结构化结果。 - #[serde(default, skip_serializing_if = "Option::is_none")] - pub observe_result: Option<mailbox::ObserveAgentResult>, - /// responsibility continuity / restricted-child 的轻量元数据。 - #[serde(default, skip_serializing_if = "Option::is_none")] - pub delegation: Option<DelegationMetadata>, - /// 是否级联关闭(仅 close 场景)。 - #[serde(default, skip_serializing_if = "Option::is_none")] - pub cascade: Option<bool>, - /// 已关闭的根 agent ID(仅 close 场景)。 - #[serde(default, skip_serializing_if = "Option::is_none")] - pub closed_root_agent_id: Option<String>, - /// 失败原因。 - #[serde(default, skip_serializing_if = "Option::is_none")] - pub failure: Option<String>, -} +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum CollaborationResult { + Sent { + #[serde(default, skip_serializing_if = "Option::is_none")] + agent_ref: Option<ChildAgentRef>, + #[serde(default, skip_serializing_if = "Option::is_none")] + delivery_id: Option<DeliveryId>, + #[serde(default, skip_serializing_if = "Option::is_none")] + summary: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + delegation: Option<DelegationMetadata>, + }, + Observed { + agent_ref: ChildAgentRef, + summary: String, + observe_result: Box<input_queue::ObserveSnapshot>, + #[serde(default, skip_serializing_if = "Option::is_none")] + delegation: Option<DelegationMetadata>, + }, + Closed { + #[serde(default, skip_serializing_if = "Option::is_none")] + summary: Option<String>, + cascade: bool, + closed_root_agent_id: AgentId, + }, +} + +impl CollaborationResult { + pub fn agent_ref(&self) -> Option<&ChildAgentRef> { + match self { + Self::Sent { agent_ref, .. } => agent_ref.as_ref(), + Self::Observed { agent_ref, .. } => Some(agent_ref), + Self::Closed { .. } => None, + } + } -/// 协作结果类型。 -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum CollaborationResultKind { - Sent, - Observed, - Closed, + pub fn delivery_id(&self) -> Option<&DeliveryId> { + match self { + Self::Sent { delivery_id, .. } => delivery_id.as_ref(), + Self::Observed { .. } | Self::Closed { .. } => None, + } + } + + pub fn summary(&self) -> Option<&str> { + match self { + Self::Sent { summary, .. } => summary.as_deref(), + Self::Observed { summary, .. } => Some(summary.as_str()), + Self::Closed { summary, .. } => summary.as_deref(), + } + } + + pub fn observe_result(&self) -> Option<&input_queue::ObserveSnapshot> { + match self { + Self::Observed { observe_result, .. } => Some(observe_result.as_ref()), + Self::Sent { .. } | Self::Closed { .. } => None, + } + } + + pub fn delegation(&self) -> Option<&DelegationMetadata> { + match self { + Self::Sent { delegation, .. } | Self::Observed { delegation, .. } => { + delegation.as_ref() + }, + Self::Closed { .. } => None, + } + } + + pub fn cascade(&self) -> Option<bool> { + match self { + Self::Closed { cascade, .. } => Some(*cascade), + Self::Sent { .. } | Self::Observed { .. } => None, + } + } + + pub fn closed_root_agent_id(&self) -> Option<&AgentId> { + match self { + Self::Closed { + closed_root_agent_id, + .. + } => Some(closed_root_agent_id), + Self::Sent { .. } | Self::Observed { .. } => None, + } + } } /// 协作动作类型。 @@ -1006,21 +1132,17 @@ pub struct AgentCollaborationPolicyContext { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct AgentCollaborationFact { - pub fact_id: String, + pub fact_id: DeliveryId, pub action: AgentCollaborationActionKind, pub outcome: AgentCollaborationOutcomeKind, - pub parent_session_id: String, - pub turn_id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub parent_agent_id: Option<String>, + pub parent_session_id: SessionId, + pub turn_id: TurnId, #[serde(default, skip_serializing_if = "Option::is_none")] - pub child_agent_id: Option<String>, + pub parent_agent_id: Option<AgentId>, #[serde(default, skip_serializing_if = "Option::is_none")] - pub child_session_id: Option<String>, + pub child_identity: Option<ChildExecutionIdentity>, #[serde(default, skip_serializing_if = "Option::is_none")] - pub child_sub_run_id: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub delivery_id: Option<String>, + pub delivery_id: Option<DeliveryId>, #[serde(default, skip_serializing_if = "Option::is_none")] pub reason_code: Option<String>, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -1028,10 +1150,34 @@ pub struct AgentCollaborationFact { #[serde(default, skip_serializing_if = "Option::is_none")] pub latency_ms: Option<u64>, #[serde(default, skip_serializing_if = "Option::is_none")] - pub source_tool_call_id: Option<String>, + pub source_tool_call_id: Option<DeliveryId>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mode_id: Option<crate::ModeId>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub governance_revision: Option<String>, pub policy: AgentCollaborationPolicyContext, } +impl AgentCollaborationFact { + pub fn child_agent_id(&self) -> Option<&AgentId> { + self.child_identity + .as_ref() + .map(|identity| &identity.agent_id) + } + + pub fn child_session_id(&self) -> Option<&SessionId> { + self.child_identity + .as_ref() + .map(|identity| &identity.session_id) + } + + pub fn child_sub_run_id(&self) -> Option<&SubRunId> { + self.child_identity + .as_ref() + .map(|identity| &identity.sub_run_id) + } +} + /// Agent 收件箱信封。 /// /// 记录一次协作消息投递(send / 父子交付产出的信封), @@ -1082,19 +1228,19 @@ pub enum InboxEnvelopeKind { pub struct AgentEventContext { /// 事件所属的 agent 实例 ID。 #[serde(default, skip_serializing_if = "Option::is_none")] - pub agent_id: Option<String>, + pub agent_id: Option<AgentId>, /// 父 turn ID。 #[serde(default, skip_serializing_if = "Option::is_none")] - pub parent_turn_id: Option<String>, + pub parent_turn_id: Option<TurnId>, /// 使用的 profile ID。 #[serde(default, skip_serializing_if = "Option::is_none")] pub agent_profile: Option<String>, /// 受控子会话执行域 ID。 #[serde(default, skip_serializing_if = "Option::is_none")] - pub sub_run_id: Option<String>, + pub sub_run_id: Option<SubRunId>, /// 父 sub-run ID。 #[serde(default, skip_serializing_if = "Option::is_none")] - pub parent_sub_run_id: Option<String>, + pub parent_sub_run_id: Option<SubRunId>, /// 执行来源。 #[serde(default, skip_serializing_if = "Option::is_none")] pub invocation_kind: Option<InvocationKind>, @@ -1103,19 +1249,19 @@ pub struct AgentEventContext { pub storage_mode: Option<SubRunStorageMode>, /// 独立子会话 ID(若存在)。 #[serde(default, skip_serializing_if = "Option::is_none")] - pub child_session_id: Option<String>, + pub child_session_id: Option<SessionId>, } impl AgentEventContext { /// 构造一个子会话事件上下文。 pub fn sub_run( - agent_id: impl Into<String>, - parent_turn_id: impl Into<String>, + agent_id: impl Into<AgentId>, + parent_turn_id: impl Into<TurnId>, agent_profile: impl Into<String>, - sub_run_id: impl Into<String>, - parent_sub_run_id: Option<String>, + sub_run_id: impl Into<SubRunId>, + parent_sub_run_id: Option<SubRunId>, storage_mode: SubRunStorageMode, - child_session_id: Option<String>, + child_session_id: Option<SessionId>, ) -> Self { let child_session_id = match storage_mode { SubRunStorageMode::IndependentSession => { @@ -1144,7 +1290,7 @@ impl AgentEventContext { } /// 为根执行构造事件上下文。 - pub fn root_execution(agent_id: impl Into<String>, agent_profile: impl Into<String>) -> Self { + pub fn root_execution(agent_id: impl Into<AgentId>, agent_profile: impl Into<String>) -> Self { Self { agent_id: Some(agent_id.into()), parent_turn_id: None, @@ -1185,6 +1331,12 @@ impl AgentEventContext { } /// 校验该上下文是否适合作为 durable StorageEvent 的 agent 头部。 + /// + /// 校验规则: + /// - RootExecution:必须有 agent_id + agent_profile,不能有任何 sub-run 字段 + /// - SubRun:必须有 agent_id + parent_turn_id + agent_profile + sub_run_id, 且必须是带 + /// child_session_id 的 IndependentSession + /// - 非空上下文必须声明 invocation_kind pub fn validate_for_storage_event(&self) -> Result<()> { if self.is_empty() { return Ok(()); @@ -1268,10 +1420,11 @@ impl From<&SubRunHandle> for AgentEventContext { #[cfg(test)] mod tests { use super::{ - AgentLifecycleStatus, ChildSessionLineageKind, ChildSessionNode, ChildSessionNotification, - ChildSessionStatusSource, SpawnAgentParams, SpawnCapabilityGrant, SubRunHandoff, - SubRunStorageMode, + AgentLifecycleStatus, ChildExecutionIdentity, ChildSessionLineageKind, ChildSessionNode, + ChildSessionNotification, ChildSessionStatusSource, ParentExecutionRef, SpawnAgentParams, + SpawnCapabilityGrant, SubRunHandoff, SubRunStorageMode, }; + use crate::{AgentId, DeliveryId, SessionId, SubRunId, TurnId}; #[test] fn spawn_agent_params_reject_empty_prompt() { @@ -1306,27 +1459,34 @@ mod tests { #[test] fn child_session_node_can_build_stable_child_ref() { let node = ChildSessionNode { - agent_id: "agent-child".to_string(), - session_id: "session-parent".to_string(), - child_session_id: "session-child".to_string(), - sub_run_id: "subrun-1".to_string(), - parent_session_id: "session-parent".to_string(), - parent_agent_id: Some("agent-parent".to_string()), - parent_sub_run_id: Some("subrun-parent".to_string()), - parent_turn_id: "turn-parent".to_string(), + identity: ChildExecutionIdentity { + agent_id: AgentId::from("agent-child"), + session_id: SessionId::from("session-parent"), + sub_run_id: SubRunId::from("subrun-1"), + }, + child_session_id: SessionId::from("session-child"), + parent_session_id: SessionId::from("session-parent"), + parent: ParentExecutionRef { + parent_agent_id: Some(AgentId::from("agent-parent")), + parent_sub_run_id: Some(SubRunId::from("subrun-parent")), + }, + parent_turn_id: TurnId::from("turn-parent"), lineage_kind: ChildSessionLineageKind::Spawn, status: AgentLifecycleStatus::Running, status_source: ChildSessionStatusSource::Durable, - created_by_tool_call_id: Some("call-1".to_string()), + created_by_tool_call_id: Some(DeliveryId::from("call-1")), lineage_snapshot: None, }; let child_ref = node.child_ref(); - assert_eq!(child_ref.agent_id, "agent-child"); - assert_eq!(child_ref.sub_run_id, "subrun-1"); - assert_eq!(child_ref.open_session_id, "session-child"); - assert_eq!(child_ref.parent_agent_id.as_deref(), Some("agent-parent")); + assert_eq!(child_ref.agent_id().as_str(), "agent-child"); + assert_eq!(child_ref.sub_run_id().as_str(), "subrun-1"); + assert_eq!(child_ref.open_session_id.as_str(), "session-child"); + assert_eq!( + child_ref.parent_agent_id().map(AgentId::as_str), + Some("agent-parent") + ); } #[test] @@ -1368,32 +1528,22 @@ mod tests { } #[test] - fn legacy_subrun_handoff_deserialize_upgrades_summary_into_delivery() { - let handoff: SubRunHandoff = serde_json::from_value(serde_json::json!({ - "summary": "legacy handoff", + fn subrun_handoff_deserialize_rejects_summary_shape() { + let handoff = serde_json::from_value::<SubRunHandoff>(serde_json::json!({ + "summary": "removed handoff field", "findings": ["done"], "artifacts": [], - })) - .expect("legacy handoff should deserialize"); + })); - let delivery = handoff.delivery.expect("legacy handoff should upgrade"); - assert_eq!(delivery.origin, super::ParentDeliveryOrigin::Fallback); - assert_eq!( - delivery.terminal_semantics, - super::ParentDeliveryTerminalSemantics::Terminal + assert!( + handoff.is_err(), + "summary-only handoff shape should fail fast" ); - match delivery.payload { - super::ParentDeliveryPayload::Completed(payload) => { - assert_eq!(payload.message, "legacy handoff"); - assert_eq!(payload.findings, vec!["done"]); - }, - payload => panic!("unexpected payload: {payload:?}"), - } } #[test] - fn legacy_child_notification_deserialize_upgrades_excerpt_into_delivery() { - let notification: ChildSessionNotification = serde_json::from_value(serde_json::json!({ + fn child_notification_deserialize_rejects_excerpt_shape() { + let notification = serde_json::from_value::<ChildSessionNotification>(serde_json::json!({ "notificationId": "delivery-1", "childRef": { "agentId": "agent-child", @@ -1404,28 +1554,20 @@ mod tests { "openSessionId": "session-child" }, "kind": "delivered", - "summary": "legacy summary", - "finalReplyExcerpt": "legacy final", + "summary": "removed summary field", + "finalReplyExcerpt": "removed final field", "status": "idle" - })) - .expect("legacy notification should deserialize"); - - let delivery = notification - .delivery - .expect("legacy notification should upgrade"); - assert_eq!(delivery.idempotency_key, "delivery-1"); - assert_eq!(delivery.origin, super::ParentDeliveryOrigin::Fallback); - match delivery.payload { - super::ParentDeliveryPayload::Completed(payload) => { - assert_eq!(payload.message, "legacy final"); - }, - payload => panic!("unexpected payload: {payload:?}"), - } + })); + + assert!( + notification.is_err(), + "summary/excerpt notification shape should fail fast" + ); } #[test] - fn legacy_child_notification_deserialize_upgrades_failed_into_delivery() { - let notification: ChildSessionNotification = serde_json::from_value(serde_json::json!({ + fn child_notification_deserialize_rejects_failed_shape() { + let notification = serde_json::from_value::<ChildSessionNotification>(serde_json::json!({ "notificationId": "delivery-failed", "childRef": { "agentId": "agent-child", @@ -1436,32 +1578,19 @@ mod tests { "openSessionId": "session-child" }, "kind": "failed", - "summary": "legacy failure", + "summary": "removed failure field", "status": "idle" - })) - .expect("legacy failed notification should deserialize"); + })); - let delivery = notification - .delivery - .expect("legacy failed notification should upgrade"); - assert_eq!( - delivery.terminal_semantics, - super::ParentDeliveryTerminalSemantics::Terminal + assert!( + notification.is_err(), + "summary-only failed notification shape should fail fast" ); - match delivery.payload { - super::ParentDeliveryPayload::Failed(payload) => { - assert_eq!(payload.message, "legacy failure"); - assert_eq!(payload.code, super::SubRunFailureCode::Internal); - assert!(!payload.retryable); - assert_eq!(payload.technical_message, None); - }, - payload => panic!("unexpected payload: {payload:?}"), - } } #[test] - fn legacy_child_notification_deserialize_upgrades_closed_into_delivery() { - let notification: ChildSessionNotification = serde_json::from_value(serde_json::json!({ + fn child_notification_deserialize_rejects_closed_shape() { + let notification = serde_json::from_value::<ChildSessionNotification>(serde_json::json!({ "notificationId": "delivery-closed", "childRef": { "agentId": "agent-child", @@ -1472,30 +1601,19 @@ mod tests { "openSessionId": "session-child" }, "kind": "closed", - "summary": "legacy close request", + "summary": "removed close-request field", "status": "idle" - })) - .expect("legacy closed notification should deserialize"); + })); - let delivery = notification - .delivery - .expect("legacy closed notification should upgrade"); - assert_eq!( - delivery.terminal_semantics, - super::ParentDeliveryTerminalSemantics::Terminal + assert!( + notification.is_err(), + "summary-only closed notification shape should fail fast" ); - match delivery.payload { - super::ParentDeliveryPayload::CloseRequest(payload) => { - assert_eq!(payload.message, "legacy close request"); - assert_eq!(payload.reason.as_deref(), Some("legacy_child_notification")); - }, - payload => panic!("unexpected payload: {payload:?}"), - } } #[test] - fn legacy_child_notification_deserialize_upgrades_summary_only_progress_into_delivery() { - let notification: ChildSessionNotification = serde_json::from_value(serde_json::json!({ + fn child_notification_deserialize_rejects_summary_only_progress_shape() { + let notification = serde_json::from_value::<ChildSessionNotification>(serde_json::json!({ "notificationId": "delivery-progress", "childRef": { "agentId": "agent-child", @@ -1506,23 +1624,13 @@ mod tests { "openSessionId": "session-child" }, "kind": "waiting", - "summary": "legacy progress only", + "summary": "removed progress field", "status": "running" - })) - .expect("legacy progress notification should deserialize"); + })); - let delivery = notification - .delivery - .expect("legacy progress notification should upgrade"); - assert_eq!( - delivery.terminal_semantics, - super::ParentDeliveryTerminalSemantics::NonTerminal + assert!( + notification.is_err(), + "summary-only progress notification shape should fail fast" ); - match delivery.payload { - super::ParentDeliveryPayload::Progress(payload) => { - assert_eq!(payload.message, "legacy progress only"); - }, - payload => panic!("unexpected payload: {payload:?}"), - } } } diff --git a/crates/core/src/capability.rs b/crates/core/src/capability.rs index 989a9644..7f9d3977 100644 --- a/crates/core/src/capability.rs +++ b/crates/core/src/capability.rs @@ -1,6 +1,6 @@ //! 运行时能力语义模型。 //! -//! `CapabilitySpec` 是 runtime 内部唯一能力模型;协议层 `CapabilityDescriptor` +//! `CapabilitySpec` 是 runtime 内部唯一能力模型;协议层 `CapabilityWireDescriptor` //! 只应作为边界 DTO 使用。 use std::fmt; @@ -28,6 +28,42 @@ pub enum CapabilityKind { } impl CapabilityKind { + pub fn new(value: impl Into<String>) -> Self { + Self::from(value.into()) + } + + pub fn tool() -> Self { + Self::Tool + } + + pub fn agent() -> Self { + Self::Agent + } + + pub fn context_provider() -> Self { + Self::ContextProvider + } + + pub fn memory_provider() -> Self { + Self::MemoryProvider + } + + pub fn policy_hook() -> Self { + Self::PolicyHook + } + + pub fn renderer() -> Self { + Self::Renderer + } + + pub fn resource() -> Self { + Self::Resource + } + + pub fn prompt() -> Self { + Self::Prompt + } + pub fn as_str(&self) -> &str { match self { Self::Tool => "tool", @@ -96,6 +132,7 @@ impl<'de> Deserialize<'de> for CapabilityKind { /// 调用模式。 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "snake_case")] +// TODO: 未来可以扩展为支持更多调用模式,如批量调用、双向流等 pub enum InvocationMode { #[default] Unary, @@ -298,6 +335,11 @@ impl CapabilitySpecBuilder { self } + pub fn profile(mut self, profile: impl Into<String>) -> Self { + self.profiles.push(profile.into()); + self + } + pub fn tags<I, S>(mut self, tags: I) -> Self where I: IntoIterator<Item = S>, @@ -307,11 +349,36 @@ impl CapabilitySpecBuilder { self } + pub fn tag(mut self, tag: impl Into<String>) -> Self { + self.tags.push(tag.into()); + self + } + pub fn permissions(mut self, permissions: Vec<PermissionSpec>) -> Self { self.permissions.extend(permissions); self } + pub fn permission(mut self, name: impl Into<String>) -> Self { + self.permissions.push(PermissionSpec { + name: name.into(), + rationale: None, + }); + self + } + + pub fn permission_with_rationale( + mut self, + name: impl Into<String>, + rationale: impl Into<String>, + ) -> Self { + self.permissions.push(PermissionSpec { + name: name.into(), + rationale: Some(rationale.into()), + }); + self + } + pub fn side_effect(mut self, side_effect: SideEffect) -> Self { self.side_effect = side_effect; self diff --git a/crates/core/src/compact_summary.rs b/crates/core/src/compact_summary.rs index c0e9b4ff..6eba56cf 100644 --- a/crates/core/src/compact_summary.rs +++ b/crates/core/src/compact_summary.rs @@ -6,6 +6,10 @@ /// compact 摘要消息的人类可读前缀。 pub const COMPACT_SUMMARY_PREFIX: &str = "[Auto-compact summary]\n"; +/// compact 摘要正文里的旧历史回读提示。 +pub const COMPACT_SUMMARY_HISTORY_NOTE_PREFIX: &str = + "\n\nIf more detail from before compaction is needed, read the earlier session event log at:\n"; + /// compact 摘要消息尾部的继续提示。 pub const COMPACT_SUMMARY_CONTINUATION: &str = "\n\nContinue from this summary without repeating it to the user."; @@ -14,12 +18,35 @@ pub const COMPACT_SUMMARY_CONTINUATION: &str = #[derive(Debug, Clone, PartialEq, Eq)] pub struct CompactSummaryEnvelope { pub summary: String, + pub history_path: Option<String>, } impl CompactSummaryEnvelope { pub fn new(summary: impl Into<String>) -> Self { Self { summary: summary.into().trim().to_string(), + history_path: None, + } + } + + pub fn with_history_path(mut self, history_path: impl Into<String>) -> Self { + let history_path = history_path.into().trim().to_string(); + if !history_path.is_empty() { + self.history_path = Some(history_path); + } + self + } + + /// 渲染 compact 摘要正文(不含外层前后缀)。 + pub fn render_body(&self) -> String { + match self.history_path.as_deref() { + Some(history_path) => { + format!( + "{}{COMPACT_SUMMARY_HISTORY_NOTE_PREFIX}{history_path}", + self.summary + ) + }, + None => self.summary.clone(), } } @@ -30,7 +57,7 @@ impl CompactSummaryEnvelope { pub fn render(&self) -> String { format!( "{COMPACT_SUMMARY_PREFIX}{}{COMPACT_SUMMARY_CONTINUATION}", - self.summary + self.render_body() ) } } @@ -46,11 +73,31 @@ pub fn format_compact_summary(summary: &str) -> String { /// 返回 `None`,让调用方自行决定是否继续走其他分支。 pub fn parse_compact_summary_message(content: &str) -> Option<CompactSummaryEnvelope> { let summary_with_suffix = content.strip_prefix(COMPACT_SUMMARY_PREFIX)?; - let summary = summary_with_suffix + let summary_body = summary_with_suffix .strip_suffix(COMPACT_SUMMARY_CONTINUATION) .unwrap_or(summary_with_suffix) .trim(); - (!summary.is_empty()).then(|| CompactSummaryEnvelope::new(summary)) + if summary_body.is_empty() { + return None; + } + + let (summary, history_path) = if let Some((summary, history_path)) = summary_body + .rsplit_once(COMPACT_SUMMARY_HISTORY_NOTE_PREFIX) + .filter(|(_, history_path)| !history_path.trim().is_empty()) + { + ( + summary.trim().to_string(), + Some(history_path.trim().to_string()), + ) + } else { + (summary_body.to_string(), None) + }; + + let mut envelope = CompactSummaryEnvelope::new(summary); + if let Some(history_path) = history_path { + envelope = envelope.with_history_path(history_path); + } + Some(envelope) } #[cfg(test)] @@ -63,6 +110,21 @@ mod tests { let parsed = parse_compact_summary_message(&rendered).expect("summary should parse"); assert_eq!(parsed.summary, "summary body"); + assert_eq!(parsed.history_path, None); + } + + #[test] + fn compact_summary_round_trip_preserves_history_path() { + let rendered = CompactSummaryEnvelope::new("summary body") + .with_history_path("~/.astrcode/projects/demo/sessions/abc/session-abc.jsonl") + .render(); + let parsed = parse_compact_summary_message(&rendered).expect("summary should parse"); + + assert_eq!(parsed.summary, "summary body"); + assert_eq!( + parsed.history_path.as_deref(), + Some("~/.astrcode/projects/demo/sessions/abc/session-abc.jsonl") + ); } #[test] diff --git a/crates/core/src/composer.rs b/crates/core/src/composer.rs new file mode 100644 index 00000000..4b95c362 --- /dev/null +++ b/crates/core/src/composer.rs @@ -0,0 +1,40 @@ +//! # 输入候选项模型 +//! +//! 定义 Composer 输入面板的候选项数据结构。 +//! 候选项可以来自命令、技能或能力声明,用户选择后执行对应的插入或命令动作。 + +use serde::{Deserialize, Serialize}; + +/// 输入候选项的来源类别。 +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[serde(rename_all = "snake_case")] +pub enum ComposerOptionKind { + Command, + Skill, + Capability, +} + +/// 输入候选项被选择后的动作类型。 +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[serde(rename_all = "snake_case")] +pub enum ComposerOptionActionKind { + InsertText, + ExecuteCommand, +} + +/// 单个输入候选项。 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ComposerOption { + pub kind: ComposerOptionKind, + pub id: String, + pub title: String, + pub description: String, + pub insert_text: String, + pub action_kind: ComposerOptionActionKind, + pub action_value: String, + #[serde(default)] + pub badges: Vec<String>, + #[serde(default)] + pub keywords: Vec<String>, +} diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index 34ab48c8..a6e56144 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -18,7 +18,7 @@ const ENV_REFERENCE_PREFIX: &str = "env:"; /// 默认受控子会话最大深度。 pub const DEFAULT_MAX_SUBRUN_DEPTH: usize = 2; /// 默认单轮最多新建的子代理数量。 -pub const DEFAULT_MAX_SPAWN_PER_TURN: usize = 3; +pub const DEFAULT_MAX_SPAWN_PER_TURN: usize = 6; /// 默认最大安全工具并发数。 pub const DEFAULT_MAX_TOOL_CONCURRENCY: usize = 10; @@ -32,9 +32,12 @@ pub const DEFAULT_LLM_READ_TIMEOUT_SECS: u64 = 90; pub const DEFAULT_LLM_MAX_RETRIES: u32 = 2; pub const DEFAULT_LLM_RETRY_BASE_DELAY_MS: u64 = 250; pub const DEFAULT_MAX_REACTIVE_COMPACT_ATTEMPTS: u8 = 3; +pub const DEFAULT_RESERVED_CONTEXT_SIZE: usize = 20_000; pub const DEFAULT_MAX_OUTPUT_CONTINUATION_ATTEMPTS: u8 = 3; pub const DEFAULT_MAX_CONTINUATIONS: u8 = 3; pub const DEFAULT_SUMMARY_RESERVE_TOKENS: usize = 20_000; +pub const DEFAULT_COMPACT_KEEP_RECENT_USER_MESSAGES: u8 = 8; +pub const DEFAULT_COMPACT_MAX_OUTPUT_TOKENS: usize = 20_000; pub const DEFAULT_MAX_TRACKED_FILES: usize = 10; pub const DEFAULT_MAX_RECOVERED_FILES: usize = 5; pub const DEFAULT_RECOVERY_TOKEN_BUDGET: usize = 50_000; @@ -105,6 +108,8 @@ pub struct RuntimeConfig { pub tool_result_max_bytes: Option<usize>, #[serde(skip_serializing_if = "Option::is_none")] pub compact_keep_recent_turns: Option<u8>, + #[serde(skip_serializing_if = "Option::is_none")] + pub compact_keep_recent_user_messages: Option<u8>, #[serde(skip_serializing_if = "Option::is_none")] pub agent: Option<AgentConfig>, @@ -126,7 +131,9 @@ pub struct RuntimeConfig { pub llm_retry_base_delay_ms: Option<u64>, #[serde(skip_serializing_if = "Option::is_none")] - pub max_reactive_compact_attempts: Option<u8>, + pub compact_max_retry_attempts: Option<u8>, + #[serde(skip_serializing_if = "Option::is_none")] + pub reserved_context_size: Option<usize>, #[serde(skip_serializing_if = "Option::is_none")] pub max_output_continuation_attempts: Option<u8>, #[serde(skip_serializing_if = "Option::is_none")] @@ -134,6 +141,8 @@ pub struct RuntimeConfig { #[serde(skip_serializing_if = "Option::is_none")] pub summary_reserve_tokens: Option<usize>, #[serde(skip_serializing_if = "Option::is_none")] + pub compact_max_output_tokens: Option<usize>, + #[serde(skip_serializing_if = "Option::is_none")] pub max_tracked_files: Option<usize>, #[serde(skip_serializing_if = "Option::is_none")] pub max_recovered_files: Option<usize>, @@ -162,7 +171,6 @@ pub struct RuntimeConfig { pub micro_compact_gap_threshold_secs: Option<u64>, #[serde(skip_serializing_if = "Option::is_none")] pub micro_compact_keep_recent_results: Option<usize>, - #[serde(skip_serializing_if = "Option::is_none")] pub api_session_ttl_hours: Option<i64>, } @@ -206,6 +214,7 @@ pub struct ResolvedRuntimeConfig { pub compact_threshold_percent: u8, pub tool_result_max_bytes: usize, pub compact_keep_recent_turns: u8, + pub compact_keep_recent_user_messages: u8, pub agent: ResolvedAgentConfig, pub max_consecutive_failures: usize, pub recovery_truncate_bytes: usize, @@ -214,10 +223,12 @@ pub struct ResolvedRuntimeConfig { pub llm_read_timeout_secs: u64, pub llm_max_retries: u32, pub llm_retry_base_delay_ms: u64, - pub max_reactive_compact_attempts: u8, + pub compact_max_retry_attempts: u8, + pub reserved_context_size: usize, pub max_output_continuation_attempts: u8, pub max_continuations: u8, pub summary_reserve_tokens: usize, + pub compact_max_output_tokens: usize, pub max_tracked_files: usize, pub max_recovered_files: usize, pub recovery_token_budget: usize, @@ -268,6 +279,7 @@ impl Default for ResolvedRuntimeConfig { compact_threshold_percent: DEFAULT_COMPACT_THRESHOLD_PERCENT, tool_result_max_bytes: DEFAULT_TOOL_RESULT_MAX_BYTES, compact_keep_recent_turns: DEFAULT_COMPACT_KEEP_RECENT_TURNS, + compact_keep_recent_user_messages: DEFAULT_COMPACT_KEEP_RECENT_USER_MESSAGES, agent: ResolvedAgentConfig::default(), max_consecutive_failures: DEFAULT_MAX_CONSECUTIVE_FAILURES, recovery_truncate_bytes: DEFAULT_RECOVERY_TRUNCATE_BYTES, @@ -276,10 +288,12 @@ impl Default for ResolvedRuntimeConfig { llm_read_timeout_secs: DEFAULT_LLM_READ_TIMEOUT_SECS, llm_max_retries: DEFAULT_LLM_MAX_RETRIES, llm_retry_base_delay_ms: DEFAULT_LLM_RETRY_BASE_DELAY_MS, - max_reactive_compact_attempts: DEFAULT_MAX_REACTIVE_COMPACT_ATTEMPTS, + compact_max_retry_attempts: DEFAULT_MAX_REACTIVE_COMPACT_ATTEMPTS, + reserved_context_size: DEFAULT_RESERVED_CONTEXT_SIZE, max_output_continuation_attempts: DEFAULT_MAX_OUTPUT_CONTINUATION_ATTEMPTS, max_continuations: DEFAULT_MAX_CONTINUATIONS, summary_reserve_tokens: DEFAULT_SUMMARY_RESERVE_TOKENS, + compact_max_output_tokens: DEFAULT_COMPACT_MAX_OUTPUT_TOKENS, max_tracked_files: DEFAULT_MAX_TRACKED_FILES, max_recovered_files: DEFAULT_MAX_RECOVERED_FILES, recovery_token_budget: DEFAULT_RECOVERY_TOKEN_BUDGET, @@ -372,19 +386,41 @@ pub struct ActiveSelection { } /// 运行时当前将使用的有效模型信息。 -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct CurrentModelSelection { +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ModelSelection { pub profile_name: String, pub model: String, pub provider_kind: String, } +impl ModelSelection { + pub fn new( + profile_name: impl Into<String>, + model: impl Into<String>, + provider_kind: impl Into<String>, + ) -> Self { + Self { + profile_name: profile_name.into(), + model: model.into(), + provider_kind: provider_kind.into(), + } + } +} + +pub type CurrentModelSelection = ModelSelection; + /// 扁平化的模型选项。 -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ModelOption { - pub profile_name: String, +pub type ModelOption = ModelSelection; + +/// 模型连通性测试结果。 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct TestConnectionResult { + pub success: bool, + pub provider: String, pub model: String, - pub provider_kind: String, + pub error: Option<String>, } impl fmt::Debug for Config { @@ -407,6 +443,10 @@ impl fmt::Debug for RuntimeConfig { .field("compact_threshold_percent", &self.compact_threshold_percent) .field("tool_result_max_bytes", &self.tool_result_max_bytes) .field("compact_keep_recent_turns", &self.compact_keep_recent_turns) + .field( + "compact_keep_recent_user_messages", + &self.compact_keep_recent_user_messages, + ) .field("agent", &self.agent) .field("max_consecutive_failures", &self.max_consecutive_failures) .field("recovery_truncate_bytes", &self.recovery_truncate_bytes) @@ -415,15 +455,17 @@ impl fmt::Debug for RuntimeConfig { .field("llm_read_timeout_secs", &self.llm_read_timeout_secs) .field("llm_max_retries", &self.llm_max_retries) .field( - "max_reactive_compact_attempts", - &self.max_reactive_compact_attempts, + "compact_max_retry_attempts", + &self.compact_max_retry_attempts, ) + .field("reserved_context_size", &self.reserved_context_size) .field( "max_output_continuation_attempts", &self.max_output_continuation_attempts, ) .field("max_continuations", &self.max_continuations) .field("summary_reserve_tokens", &self.summary_reserve_tokens) + .field("compact_max_output_tokens", &self.compact_max_output_tokens) .field("max_tracked_files", &self.max_tracked_files) .field("max_recovered_files", &self.max_recovered_files) .field("recovery_token_budget", &self.recovery_token_budget) @@ -569,6 +611,10 @@ pub fn max_tool_concurrency() -> usize { .max(1) } +/// 从 `Option<AgentConfig>` 解析出完整的 `ResolvedAgentConfig`。 +/// +/// 逐字段用用户配置覆盖默认值,缺失字段使用默认值。 +/// 所有数值型字段都有 `.max(1)` 保护,防止配置为 0 导致除零或无限循环。 pub fn resolve_agent_config(agent: Option<&AgentConfig>) -> ResolvedAgentConfig { let defaults = ResolvedAgentConfig::default(); ResolvedAgentConfig { @@ -598,6 +644,10 @@ pub fn resolve_agent_config(agent: Option<&AgentConfig>) -> ResolvedAgentConfig } } +/// 从 `RuntimeConfig` 解析出完整的 `ResolvedRuntimeConfig`。 +/// +/// 与 `resolve_agent_config` 类似,逐字段覆盖 + 默认值兜底 + 下界保护。 +/// `compact_threshold_percent` 额外 `.clamp(1, 100)` 防止百分比越界。 pub fn resolve_runtime_config(runtime: &RuntimeConfig) -> ResolvedRuntimeConfig { let defaults = ResolvedRuntimeConfig::default(); ResolvedRuntimeConfig { @@ -620,6 +670,10 @@ pub fn resolve_runtime_config(runtime: &RuntimeConfig) -> ResolvedRuntimeConfig .compact_keep_recent_turns .unwrap_or(defaults.compact_keep_recent_turns) .max(1), + compact_keep_recent_user_messages: runtime + .compact_keep_recent_user_messages + .unwrap_or(defaults.compact_keep_recent_user_messages) + .max(1), agent: resolve_agent_config(runtime.agent.as_ref()), max_consecutive_failures: runtime .max_consecutive_failures @@ -643,9 +697,13 @@ pub fn resolve_runtime_config(runtime: &RuntimeConfig) -> ResolvedRuntimeConfig .llm_retry_base_delay_ms .unwrap_or(defaults.llm_retry_base_delay_ms) .max(1), - max_reactive_compact_attempts: runtime - .max_reactive_compact_attempts - .unwrap_or(defaults.max_reactive_compact_attempts) + compact_max_retry_attempts: runtime + .compact_max_retry_attempts + .unwrap_or(defaults.compact_max_retry_attempts) + .max(1), + reserved_context_size: runtime + .reserved_context_size + .unwrap_or(defaults.reserved_context_size) .max(1), max_output_continuation_attempts: runtime .max_output_continuation_attempts @@ -658,6 +716,10 @@ pub fn resolve_runtime_config(runtime: &RuntimeConfig) -> ResolvedRuntimeConfig summary_reserve_tokens: runtime .summary_reserve_tokens .unwrap_or(defaults.summary_reserve_tokens), + compact_max_output_tokens: runtime + .compact_max_output_tokens + .unwrap_or(defaults.compact_max_output_tokens) + .max(1), max_tracked_files: runtime .max_tracked_files .unwrap_or(defaults.max_tracked_files) @@ -733,6 +795,14 @@ mod tests { resolved.tool_result_inline_limit, DEFAULT_TOOL_RESULT_INLINE_LIMIT ); + assert_eq!( + resolved.compact_keep_recent_user_messages, + DEFAULT_COMPACT_KEEP_RECENT_USER_MESSAGES + ); + assert_eq!( + resolved.compact_max_output_tokens, + DEFAULT_COMPACT_MAX_OUTPUT_TOKENS + ); } #[test] diff --git a/crates/core/src/event/domain.rs b/crates/core/src/event/domain.rs index da4e1940..75d858a4 100644 --- a/crates/core/src/event/domain.rs +++ b/crates/core/src/event/domain.rs @@ -6,9 +6,9 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use crate::{ - AgentEventContext, ChildSessionNotification, CompactTrigger, PromptMetricsPayload, - ResolvedExecutionLimitsSnapshot, ResolvedSubagentContextOverrides, SubRunResult, - ToolExecutionResult, ToolOutputStream, + AgentEventContext, ChildSessionNotification, CompactAppliedMeta, CompactTrigger, + PromptMetricsPayload, ResolvedExecutionLimitsSnapshot, ResolvedSubagentContextOverrides, + SubRunResult, ToolExecutionResult, ToolOutputStream, }; /// 会话阶段 @@ -120,6 +120,7 @@ pub enum AgentEvent { agent: AgentEventContext, trigger: CompactTrigger, summary: String, + meta: CompactAppliedMeta, preserved_recent_turns: u32, }, /// 受控子会话开始。 @@ -150,33 +151,33 @@ pub enum AgentEvent { turn_id: String, agent: AgentEventContext, }, - /// Durable mailbox 消息入队(前端可见,用于 UI 渲染)。 - AgentMailboxQueued { + /// Durable input queue 消息入队(前端可见,用于 UI 渲染)。 + AgentInputQueued { turn_id: Option<String>, agent: AgentEventContext, #[serde(flatten)] - payload: crate::MailboxQueuedPayload, + payload: crate::InputQueuedPayload, }, - /// Mailbox 批次开始消费。 - AgentMailboxBatchStarted { + /// input queue 批次开始消费。 + AgentInputBatchStarted { turn_id: Option<String>, agent: AgentEventContext, #[serde(flatten)] - payload: crate::MailboxBatchStartedPayload, + payload: crate::InputBatchStartedPayload, }, - /// Mailbox 批次确认完成。 - AgentMailboxBatchAcked { + /// input queue 批次确认完成。 + AgentInputBatchAcked { turn_id: Option<String>, agent: AgentEventContext, #[serde(flatten)] - payload: crate::MailboxBatchAckedPayload, + payload: crate::InputBatchAckedPayload, }, - /// Mailbox 消息丢弃。 - AgentMailboxDiscarded { + /// input queue 消息丢弃。 + AgentInputDiscarded { turn_id: Option<String>, agent: AgentEventContext, #[serde(flatten)] - payload: crate::MailboxDiscardedPayload, + payload: crate::InputDiscardedPayload, }, /// 错误事件 Error { diff --git a/crates/core/src/event/mod.rs b/crates/core/src/event/mod.rs index 52193ccf..98e68ed6 100644 --- a/crates/core/src/event/mod.rs +++ b/crates/core/src/event/mod.rs @@ -28,7 +28,10 @@ pub use self::{ domain::{AgentEvent, Phase}, phase::{PhaseTracker, normalize_recovered_phase, target_phase as phase_of_storage_event}, translate::{EventTranslator, replay_records}, - types::{CompactTrigger, PromptMetricsPayload, StorageEvent, StorageEventPayload, StoredEvent}, + types::{ + CompactAppliedMeta, CompactMode, CompactTrigger, PromptMetricsPayload, StorageEvent, + StorageEventPayload, StoredEvent, + }, }; /// 生成全局唯一的会话 ID,格式为 `YYYY-MM-DDTHH-MM-SS-xxxxxxxx`。 @@ -45,6 +48,16 @@ pub fn generate_session_id() -> String { format!("{dt}-{short}") } +/// 生成全局唯一的 turn ID。 +/// +/// turn 会在极短时间内连续创建,单纯依赖毫秒时间戳会撞 ID, +/// 从而让后续提交错误复用前一个 turn 的终态快照。 +pub fn generate_turn_id() -> String { + let uuid = Uuid::new_v4().simple().to_string(); + let short = &uuid[..8]; + format!("turn-{}-{short}", Utc::now().timestamp_millis()) +} + /// 会话元数据 /// /// 包含会话的基本信息和当前状态,用于会话列表展示。 diff --git a/crates/core/src/event/phase.rs b/crates/core/src/event/phase.rs index a156795a..78de015f 100644 --- a/crates/core/src/event/phase.rs +++ b/crates/core/src/event/phase.rs @@ -33,10 +33,11 @@ pub fn target_phase(event: &StorageEvent) -> Phase { | StorageEventPayload::SubRunFinished { .. } | StorageEventPayload::ChildSessionNotification { .. } | StorageEventPayload::AgentCollaborationFact { .. } - | StorageEventPayload::AgentMailboxQueued { .. } - | StorageEventPayload::AgentMailboxBatchStarted { .. } - | StorageEventPayload::AgentMailboxBatchAcked { .. } - | StorageEventPayload::AgentMailboxDiscarded { .. } => Phase::Idle, + | StorageEventPayload::ModeChanged { .. } + | StorageEventPayload::AgentInputQueued { .. } + | StorageEventPayload::AgentInputBatchStarted { .. } + | StorageEventPayload::AgentInputBatchAcked { .. } + | StorageEventPayload::AgentInputDiscarded { .. } => Phase::Idle, StorageEventPayload::AssistantDelta { .. } | StorageEventPayload::ThinkingDelta { .. } | StorageEventPayload::AssistantFinal { .. } => Phase::Streaming, @@ -66,9 +67,14 @@ pub fn normalize_recovered_phase(phase: Phase) -> Phase { /// Stateful phase tracker. /// -/// Call [`Self::on_event`] whenever a new `StorageEvent` arrives. If the event -/// causes a phase transition you'll get back `Some(AgentEvent::PhaseChanged)` -/// and should push it *before* the primary event record. +/// 维护当前会话阶段状态,在阶段实际变更时才发出 `PhaseChanged` 事件。 +/// 这是 SSE 推送和前端状态指示器的唯一 phase 来源。 +/// +/// 关键设计: +/// - 内部唤醒消息(AutoContinueNudge / QueuedInput / ContinuationPrompt / ReactivationPrompt / +/// RecentUserContextDigest / RecentUserContext / CompactSummary)不触发 phase 变更,避免 UI 闪烁 +/// - 辅助事件(PromptMetrics / CompactApplied / SubRun 等)也不触发 phase 变更 +/// - `force_to` 用于 SessionStart → Idle 和 TurnDone → Idle 这类必须变更的场景 pub struct PhaseTracker { current: Phase, } @@ -78,8 +84,10 @@ impl PhaseTracker { Self { current: initial } } - /// Process a storage event and return a `PhaseChanged` event if the phase - /// actually changed. + /// 处理存储事件,若阶段实际变更则返回 `PhaseChanged` 事件。 + /// + /// 返回的事件应在主事件之前推送(before-push), + /// 这样前端先收到 PhaseChanged 再收到实际内容,保证状态指示器及时更新。 pub fn on_event( &mut self, event: &StorageEvent, @@ -90,8 +98,11 @@ impl PhaseTracker { &event.payload, StorageEventPayload::UserMessage { origin: UserMessageOrigin::AutoContinueNudge + | UserMessageOrigin::QueuedInput | UserMessageOrigin::ContinuationPrompt | UserMessageOrigin::ReactivationPrompt + | UserMessageOrigin::RecentUserContextDigest + | UserMessageOrigin::RecentUserContext | UserMessageOrigin::CompactSummary, .. } @@ -107,10 +118,11 @@ impl PhaseTracker { | StorageEventPayload::SubRunFinished { .. } | StorageEventPayload::ChildSessionNotification { .. } | StorageEventPayload::AgentCollaborationFact { .. } - | StorageEventPayload::AgentMailboxQueued { .. } - | StorageEventPayload::AgentMailboxBatchStarted { .. } - | StorageEventPayload::AgentMailboxBatchAcked { .. } - | StorageEventPayload::AgentMailboxDiscarded { .. } + | StorageEventPayload::ModeChanged { .. } + | StorageEventPayload::AgentInputQueued { .. } + | StorageEventPayload::AgentInputBatchStarted { .. } + | StorageEventPayload::AgentInputBatchAcked { .. } + | StorageEventPayload::AgentInputDiscarded { .. } ) { return None; } @@ -134,8 +146,10 @@ impl PhaseTracker { self.current } - /// Force a phase transition. Used by SessionStart and TurnDone where the - /// phase must change regardless of the event type alone. + /// 强制切换到指定阶段,无视事件类型推断。 + /// + /// 用于 SessionStart(必须到 Idle)和 TurnDone(必须回到 Idle)等 + /// 不依赖事件类型就能确定目标阶段的场景。 pub fn force_to( &mut self, phase: Phase, diff --git a/crates/core/src/event/translate.rs b/crates/core/src/event/translate.rs index fe4bcbfd..0a90a042 100644 --- a/crates/core/src/event/translate.rs +++ b/crates/core/src/event/translate.rs @@ -17,13 +17,18 @@ use std::collections::HashMap; +use serde_json::json; + use super::phase::PhaseTracker; use crate::{ AgentEvent, AgentEventContext, Phase, StorageEvent, StorageEventPayload, StoredEvent, ToolExecutionResult, UserMessageOrigin, session::SessionEventRecord, split_assistant_content, }; -/// 回放存储事件为会话事件记录 +/// 批量回放存储事件为会话事件记录。 +/// +/// 用于 `/history` 端点和冷启动恢复:将持久化的 `StoredEvent` 序列 +/// 经过 `EventTranslator` 转换为前端可消费的 `AgentEvent` 记录。 /// /// ## 断点续传 /// @@ -197,6 +202,7 @@ impl EventTranslator { StorageEventPayload::CompactApplied { trigger, summary, + meta, preserved_recent_turns, .. } => { @@ -205,6 +211,7 @@ impl EventTranslator { agent: agent.clone(), trigger: *trigger, summary: summary.clone(), + meta: meta.clone(), preserved_recent_turns: *preserved_recent_turns, }); }, @@ -246,6 +253,7 @@ impl EventTranslator { }); }, StorageEventPayload::AgentCollaborationFact { .. } => {}, + StorageEventPayload::ModeChanged { .. } => {}, StorageEventPayload::AssistantDelta { token, .. } => { if let Some(turn_id) = turn_id_ref { push(AgentEvent::ModelDelta { @@ -344,6 +352,7 @@ impl EventTranslator { success, error, metadata, + child_ref, duration_ms, .. } => { @@ -364,6 +373,7 @@ impl EventTranslator { output: output.clone(), error: error.clone(), metadata: metadata.clone(), + child_ref: child_ref.clone(), duration_ms: *duration_ms, truncated: false, }, @@ -372,7 +382,39 @@ impl EventTranslator { warn_missing_turn_id(stored.storage_seq, "toolCallResult"); } }, - StorageEventPayload::ToolResultReferenceApplied { .. } => {}, + StorageEventPayload::ToolResultReferenceApplied { + tool_call_id, + persisted_output, + replacement, + .. + } => { + if let Some(turn_id) = turn_id_ref { + push(AgentEvent::ToolCallResult { + turn_id: turn_id.clone(), + agent: agent.clone(), + result: ToolExecutionResult { + tool_call_id: tool_call_id.clone(), + tool_name: self + .tool_call_names + .get(tool_call_id) + .cloned() + .unwrap_or_default(), + ok: true, + output: replacement.clone(), + error: None, + metadata: Some(json!({ + "persistedOutput": persisted_output, + "truncated": true, + })), + child_ref: None, + duration_ms: 0, + truncated: true, + }, + }); + } else { + warn_missing_turn_id(stored.storage_seq, "toolResultReferenceApplied"); + } + }, StorageEventPayload::TurnDone { .. } => { if let Some(turn_id) = turn_id_ref { push(AgentEvent::TurnDone { @@ -402,29 +444,29 @@ impl EventTranslator { .force_to(Phase::Interrupted, turn_id, agent); } }, - StorageEventPayload::AgentMailboxQueued { payload, .. } => { - push(AgentEvent::AgentMailboxQueued { + StorageEventPayload::AgentInputQueued { payload, .. } => { + push(AgentEvent::AgentInputQueued { turn_id: turn_id.clone(), agent: agent.clone(), payload: payload.clone(), }); }, - StorageEventPayload::AgentMailboxBatchStarted { payload, .. } => { - push(AgentEvent::AgentMailboxBatchStarted { + StorageEventPayload::AgentInputBatchStarted { payload, .. } => { + push(AgentEvent::AgentInputBatchStarted { turn_id: turn_id.clone(), agent: agent.clone(), payload: payload.clone(), }); }, - StorageEventPayload::AgentMailboxBatchAcked { payload, .. } => { - push(AgentEvent::AgentMailboxBatchAcked { + StorageEventPayload::AgentInputBatchAcked { payload, .. } => { + push(AgentEvent::AgentInputBatchAcked { turn_id: turn_id.clone(), agent: agent.clone(), payload: payload.clone(), }); }, - StorageEventPayload::AgentMailboxDiscarded { payload, .. } => { - push(AgentEvent::AgentMailboxDiscarded { + StorageEventPayload::AgentInputDiscarded { payload, .. } => { + push(AgentEvent::AgentInputDiscarded { turn_id: turn_id.clone(), agent: agent.clone(), payload: payload.clone(), @@ -433,6 +475,15 @@ impl EventTranslator { } } + /// 从存储事件中提取或推断 turn_id。 + /// + /// 策略: + /// - 事件自身携带 turn_id → 直接使用并缓存为当前 turn + /// - SessionStart → 返回 None(会话启动事件不属于任何 turn) + /// - 其他无 turn_id 的事件 → 复用上一条事件的 turn_id + /// + /// 这样即使部分辅助事件(如 CompactApplied)未携带 turn_id, + /// 也能正确归属到当前 turn 上下文中。 fn turn_id_for(&mut self, event: &StorageEvent) -> Option<String> { if let Some(turn_id) = event.turn_id() { let turn_id = turn_id.to_string(); @@ -453,8 +504,9 @@ mod tests { use super::*; use crate::{ - AgentEvent, AgentEventContext, PromptMetricsPayload, StoredEvent, ToolOutputStream, - UserMessageOrigin, format_compact_summary, phase_of_storage_event, + AgentEvent, AgentEventContext, CompactAppliedMeta, CompactMode, PromptMetricsPayload, + StoredEvent, ToolOutputStream, UserMessageOrigin, format_compact_summary, + phase_of_storage_event, }; #[test] @@ -525,6 +577,8 @@ mod tests { UserMessageOrigin::CompactSummary, UserMessageOrigin::AutoContinueNudge, UserMessageOrigin::ContinuationPrompt, + UserMessageOrigin::RecentUserContextDigest, + UserMessageOrigin::RecentUserContext, ] { let records = replay_records( &[StoredEvent { @@ -589,6 +643,7 @@ mod tests { success: true, error: None, metadata: None, + child_ref: None, duration_ms: 12, }, }, @@ -673,6 +728,14 @@ mod tests { payload: StorageEventPayload::CompactApplied { trigger: crate::CompactTrigger::Manual, summary: "保留最近上下文".to_string(), + meta: CompactAppliedMeta { + mode: CompactMode::Incremental, + instructions_present: true, + fallback_used: false, + retry_count: 1, + input_units: 4, + output_summary_chars: 24, + }, preserved_recent_turns: 2, pre_tokens: 200, post_tokens_estimate: 80, @@ -693,9 +756,17 @@ mod tests { turn_id: None, trigger: crate::CompactTrigger::Manual, summary, + meta, preserved_recent_turns, .. - } if summary == "保留最近上下文" && *preserved_recent_turns == 2 + } if summary == "保留最近上下文" + && meta.mode == CompactMode::Incremental + && meta.instructions_present + && !meta.fallback_used + && meta.retry_count == 1 + && meta.input_units == 4 + && meta.output_summary_chars == 24 + && *preserved_recent_turns == 2 )); } @@ -722,6 +793,7 @@ mod tests { provider_cache_metrics_supported: true, prompt_cache_reuse_hits: 3, prompt_cache_reuse_misses: 1, + prompt_cache_unchanged_layers: Vec::new(), }, }, }, diff --git a/crates/core/src/event/types.rs b/crates/core/src/event/types.rs index 8c4eebc6..30298def 100644 --- a/crates/core/src/event/types.rs +++ b/crates/core/src/event/types.rs @@ -13,10 +13,10 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use crate::{ - AgentCollaborationFact, AgentEventContext, AstrError, ChildSessionNotification, - MailboxBatchAckedPayload, MailboxBatchStartedPayload, MailboxDiscardedPayload, - MailboxQueuedPayload, ResolvedExecutionLimitsSnapshot, ResolvedSubagentContextOverrides, - Result, SubRunResult, ToolOutputStream, UserMessageOrigin, + AgentCollaborationFact, AgentEventContext, AstrError, ChildAgentRef, ChildSessionNotification, + InputBatchAckedPayload, InputBatchStartedPayload, InputDiscardedPayload, InputQueuedPayload, + ModeId, PersistedToolOutput, ResolvedExecutionLimitsSnapshot, ResolvedSubagentContextOverrides, + Result, SubRunResult, SystemPromptLayer, ToolOutputStream, UserMessageOrigin, }; /// Prompt/缓存指标共享载荷。 @@ -49,6 +49,8 @@ pub struct PromptMetricsPayload { pub prompt_cache_reuse_hits: u32, #[serde(default)] pub prompt_cache_reuse_misses: u32, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub prompt_cache_unchanged_layers: Vec<SystemPromptLayer>, } /// 上下文压缩的触发方式。 @@ -59,6 +61,40 @@ pub enum CompactTrigger { Auto, /// 手动触发(用户主动请求) Manual, + /// 手动请求登记后在当前 turn 结束时执行。 + Deferred, +} + +/// 上下文压缩的执行模式。 +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum CompactMode { + /// 标准全量 compact。 + Full, + /// 基于历史 compact summary 的滚动 incremental compact。 + Incremental, + /// 为了从 PTL/超窗中恢复而触发的裁剪重试 compact。 + RetrySalvage, +} + +/// compact 执行元数据。 +/// +/// Why: compact 不再只是“有一段 summary”,还要暴露触发方式、回退路径和 +/// 产出质量,让前端、调试面板和后续治理逻辑使用同一份事实。 +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CompactAppliedMeta { + pub mode: CompactMode, + #[serde(default)] + pub instructions_present: bool, + #[serde(default)] + pub fallback_used: bool, + #[serde(default)] + pub retry_count: u32, + #[serde(default)] + pub input_units: u32, + #[serde(default)] + pub output_summary_chars: u32, } /// 存储事件载荷。 @@ -131,12 +167,14 @@ pub enum StorageEventPayload { error: Option<String>, #[serde(default, skip_serializing_if = "Option::is_none")] metadata: Option<Value>, + #[serde(default, skip_serializing_if = "Option::is_none")] + child_ref: Option<ChildAgentRef>, duration_ms: u64, }, /// 将大型工具结果替换为 `<persisted-output>` 引用后的 durable 决策。 ToolResultReferenceApplied { tool_call_id: String, - persisted_relative_path: String, + persisted_output: PersistedToolOutput, replacement: String, original_bytes: u64, }, @@ -149,6 +187,8 @@ pub enum StorageEventPayload { CompactApplied { trigger: CompactTrigger, summary: String, + #[serde(flatten)] + meta: CompactAppliedMeta, preserved_recent_turns: u32, pre_tokens: u32, post_tokens_estimate: u32, @@ -207,6 +247,13 @@ pub enum StorageEventPayload { )] timestamp: Option<DateTime<Utc>>, }, + /// 会话治理模式变更。 + ModeChanged { + from: ModeId, + to: ModeId, + #[serde(with = "crate::local_rfc3339")] + timestamp: DateTime<Utc>, + }, /// Turn 完成(一轮 Agent 循环结束)。 TurnDone { #[serde(with = "crate::local_rfc3339")] @@ -214,35 +261,35 @@ pub enum StorageEventPayload { #[serde(default, skip_serializing_if = "Option::is_none")] reason: Option<String>, }, - /// Durable mailbox 消息入队。 + /// Durable input queue 消息入队。 /// - /// 记录一条协作消息成功进入目标 agent 的 mailbox。 + /// 记录一条协作消息成功进入目标 agent 的 input queue。 /// live inbox 只能在该事件 append 成功后更新。 - AgentMailboxQueued { + AgentInputQueued { #[serde(flatten)] - payload: MailboxQueuedPayload, + payload: InputQueuedPayload, }, - /// Mailbox 批次开始消费。 + /// input queue 批次开始消费。 /// /// snapshot drain 时写入,记录本轮接管了哪些 delivery_ids。 - /// 必须是 mailbox-wake turn 的第一条 durable 事件。 - AgentMailboxBatchStarted { + /// 必须是 input-queue turn 的第一条 durable 事件。 + AgentInputBatchStarted { #[serde(flatten)] - payload: MailboxBatchStartedPayload, + payload: InputBatchStartedPayload, }, - /// Mailbox 批次确认完成。 + /// input queue 批次确认完成。 /// /// durable turn completion 后写入,标记对应 delivery_ids 已被消费。 - AgentMailboxBatchAcked { + AgentInputBatchAcked { #[serde(flatten)] - payload: MailboxBatchAckedPayload, + payload: InputBatchAckedPayload, }, - /// Mailbox 消息丢弃。 + /// input queue 消息丢弃。 /// /// close 时写入,记录被主动丢弃的 pending delivery_ids。 - AgentMailboxDiscarded { + AgentInputDiscarded { #[serde(flatten)] - payload: MailboxDiscardedPayload, + payload: InputDiscardedPayload, }, /// 错误事件。 Error { @@ -346,10 +393,13 @@ mod tests { use chrono::{TimeZone, Utc}; use serde_json::Value; - use super::{CompactTrigger, PromptMetricsPayload, StorageEvent, StorageEventPayload}; + use super::{ + CompactAppliedMeta, CompactMode, CompactTrigger, PromptMetricsPayload, StorageEvent, + StorageEventPayload, + }; use crate::{ - AgentEventContext, AgentLifecycleStatus, ResolvedExecutionLimitsSnapshot, - ResolvedSubagentContextOverrides, SubRunResult, SubRunStorageMode, format_local_rfc3339, + AgentEventContext, ResolvedExecutionLimitsSnapshot, ResolvedSubagentContextOverrides, + SubRunStorageMode, format_local_rfc3339, }; #[test] @@ -412,6 +462,7 @@ mod tests { provider_cache_metrics_supported: true, prompt_cache_reuse_hits: 2, prompt_cache_reuse_misses: 1, + prompt_cache_unchanged_layers: Vec::new(), }, }, }; @@ -476,6 +527,14 @@ mod tests { payload: StorageEventPayload::CompactApplied { trigger: CompactTrigger::Manual, summary: "condensed work".to_string(), + meta: CompactAppliedMeta { + mode: CompactMode::Full, + instructions_present: true, + fallback_used: true, + retry_count: 2, + input_units: 9, + output_summary_chars: 14, + }, preserved_recent_turns: 2, pre_tokens: 2_000, post_tokens_estimate: 600, @@ -496,6 +555,7 @@ mod tests { StorageEventPayload::CompactApplied { trigger, summary, + meta, preserved_recent_turns, pre_tokens, post_tokens_estimate, @@ -508,6 +568,12 @@ mod tests { assert_eq!(turn_id.as_deref(), Some("turn-2")); assert_eq!(trigger, CompactTrigger::Manual); assert_eq!(summary, "condensed work"); + assert_eq!(meta.mode, CompactMode::Full); + assert!(meta.instructions_present); + assert!(meta.fallback_used); + assert_eq!(meta.retry_count, 2); + assert_eq!(meta.input_units, 9); + assert_eq!(meta.output_summary_chars, 14); assert_eq!(preserved_recent_turns, 2); assert_eq!(pre_tokens, 2_000); assert_eq!(post_tokens_estimate, 600); @@ -525,6 +591,12 @@ mod tests { "turn_id": "turn-2", "trigger": "manual", "summary": "condensed work", + "mode": "full", + "instructionsPresent": true, + "fallbackUsed": true, + "retryCount": 2, + "inputUnits": 9, + "outputSummaryChars": 14, "preserved_recent_turns": 2, "pre_tokens": 2000, "post_tokens_estimate": 600, @@ -571,7 +643,7 @@ mod tests { "subrun-1", None, SubRunStorageMode::IndependentSession, - Some("child-session".to_string()), + Some("child-session".into()), ), payload: StorageEventPayload::SubRunStarted { tool_call_id: Some("call-1".to_string()), @@ -589,15 +661,17 @@ mod tests { "subrun-1", None, SubRunStorageMode::IndependentSession, - Some("child-session".to_string()), + Some("child-session".into()), ), payload: StorageEventPayload::SubRunFinished { tool_call_id: Some("call-1".to_string()), - result: SubRunResult { - lifecycle: AgentLifecycleStatus::Idle, - last_turn_outcome: Some(crate::AgentTurnOutcome::Completed), - handoff: None, - failure: None, + result: crate::SubRunResult::Completed { + outcome: crate::CompletedSubRunOutcome::Completed, + handoff: crate::SubRunHandoff { + findings: Vec::new(), + artifacts: Vec::new(), + delivery: None, + }, }, step_count: 3, estimated_tokens: 99, @@ -652,10 +726,10 @@ mod tests { let error = StorageEvent { turn_id: Some("turn-parent".to_string()), agent: AgentEventContext { - agent_id: Some("agent-child".to_string()), - parent_turn_id: Some("turn-parent".to_string()), + agent_id: Some("agent-child".into()), + parent_turn_id: Some("turn-parent".into()), agent_profile: Some("review".to_string()), - sub_run_id: Some("subrun-1".to_string()), + sub_run_id: Some("subrun-1".into()), parent_sub_run_id: None, invocation_kind: Some(crate::InvocationKind::SubRun), storage_mode: Some(crate::SubRunStorageMode::IndependentSession), @@ -686,14 +760,18 @@ mod tests { ("resume", crate::ChildSessionLineageKind::Resume), ] { let child_ref = crate::ChildAgentRef { - agent_id: "agent-child".to_string(), - session_id: "session-parent".to_string(), - sub_run_id: "subrun-1".to_string(), - parent_agent_id: Some("agent-parent".to_string()), - parent_sub_run_id: Some("subrun-parent".to_string()), + identity: crate::ChildExecutionIdentity { + agent_id: "agent-child".into(), + session_id: "session-parent".into(), + sub_run_id: "subrun-1".into(), + }, + parent: crate::ParentExecutionRef { + parent_agent_id: Some("agent-parent".into()), + parent_sub_run_id: Some("subrun-parent".into()), + }, lineage_kind: kind, status: crate::AgentLifecycleStatus::Running, - open_session_id: "session-child".to_string(), + open_session_id: "session-child".into(), }; let json = serde_json::to_value(&child_ref).expect("serialize child ref"); @@ -720,14 +798,18 @@ mod tests { crate::ChildSessionLineageKind::Resume, ] { let node = crate::ChildSessionNode { - agent_id: "agent-child".to_string(), - session_id: "session-parent".to_string(), - child_session_id: "session-child".to_string(), - sub_run_id: "subrun-1".to_string(), - parent_session_id: "session-parent".to_string(), - parent_agent_id: Some("agent-parent".to_string()), - parent_sub_run_id: Some("subrun-parent".to_string()), - parent_turn_id: "turn-1".to_string(), + identity: crate::ChildExecutionIdentity { + agent_id: "agent-child".into(), + session_id: "session-parent".into(), + sub_run_id: "subrun-1".into(), + }, + child_session_id: "session-child".into(), + parent_session_id: "session-parent".into(), + parent: crate::ParentExecutionRef { + parent_agent_id: Some("agent-parent".into()), + parent_sub_run_id: Some("subrun-parent".into()), + }, + parent_turn_id: "turn-1".into(), lineage_kind: kind, status: crate::AgentLifecycleStatus::Idle, status_source: crate::ChildSessionStatusSource::Durable, diff --git a/crates/core/src/execution_control.rs b/crates/core/src/execution_control.rs new file mode 100644 index 00000000..7d367341 --- /dev/null +++ b/crates/core/src/execution_control.rs @@ -0,0 +1,28 @@ +//! 共享执行控制输入。 +//! +//! 为什么放在 core: +//! `ExecutionControl` 已经被 application、server、client 和 protocol 共同消费, +//! 它描述的是稳定执行语义,而不是某个具体 HTTP route 的 request 壳。 + +use crate::error::AstrError; + +/// 执行控制输入。 +#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExecutionControl { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_steps: Option<u32>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub manual_compact: Option<bool>, +} + +impl ExecutionControl { + pub fn validate(&self) -> std::result::Result<(), AstrError> { + if matches!(self.max_steps, Some(0)) { + return Err(AstrError::Validation( + "field 'maxSteps' must be greater than 0".to_string(), + )); + } + Ok(()) + } +} diff --git a/crates/core/src/execution_result.rs b/crates/core/src/execution_result.rs new file mode 100644 index 00000000..460cf912 --- /dev/null +++ b/crates/core/src/execution_result.rs @@ -0,0 +1,52 @@ +//! # 执行结果公共字段 +//! +//! 提取工具结果与能力结果中共享的 `error / metadata / duration_ms / truncated` 字段, +//! 避免两套结果类型平行复制相同字段。 + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// 执行结果中与调用类型无关的公共字段。 +/// +/// 该结构只承载通用执行元数据,避免工具结果与能力结果继续平行复制 +/// `error / metadata / duration_ms / truncated` 四组字段。 +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Default)] +#[serde(rename_all = "camelCase")] +pub struct ExecutionResultCommon { + /// 错误信息(仅在失败时设置) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option<String>, + /// 额外元数据(如 diff 信息、终端显示提示等) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub metadata: Option<Value>, + /// 执行耗时(毫秒) + pub duration_ms: u64, + /// 输出是否因大小限制被截断 + #[serde(default)] + pub truncated: bool, +} + +impl ExecutionResultCommon { + pub fn success(metadata: Option<Value>, duration_ms: u64, truncated: bool) -> Self { + Self { + error: None, + metadata, + duration_ms, + truncated, + } + } + + pub fn failure( + error: impl Into<String>, + metadata: Option<Value>, + duration_ms: u64, + truncated: bool, + ) -> Self { + Self { + error: Some(error.into()), + metadata, + duration_ms, + truncated, + } + } +} diff --git a/crates/core/src/execution_task.rs b/crates/core/src/execution_task.rs new file mode 100644 index 00000000..b3cf9f47 --- /dev/null +++ b/crates/core/src/execution_task.rs @@ -0,0 +1,138 @@ +//! 执行期 task 领域模型。 +//! +//! 这套类型专门表达执行阶段的工作清单,与 canonical session plan 严格分层。 + +use std::fmt; + +use serde::{Deserialize, Serialize}; + +/// durable tool result metadata 中使用的稳定 schema 名称。 +pub const EXECUTION_TASK_SNAPSHOT_SCHEMA: &str = "executionTaskSnapshot"; + +/// 执行期 task 状态。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ExecutionTaskStatus { + Pending, + InProgress, + Completed, +} + +impl ExecutionTaskStatus { + pub fn as_str(self) -> &'static str { + match self { + Self::Pending => "pending", + Self::InProgress => "in_progress", + Self::Completed => "completed", + } + } + + pub fn is_active(self) -> bool { + matches!(self, Self::Pending | Self::InProgress) + } +} + +impl fmt::Display for ExecutionTaskStatus { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// 单条执行期 task。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExecutionTaskItem { + /// 面向用户与面板展示的祈使句标题。 + pub content: String, + /// 当前任务状态。 + pub status: ExecutionTaskStatus, + /// 面向 prompt 注入与进行中展示的动词短语。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub active_form: Option<String>, +} + +impl ExecutionTaskItem { + pub fn is_active(&self) -> bool { + self.status.is_active() + } +} + +/// 单个 owner 的最新 task 快照。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TaskSnapshot { + pub owner: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub items: Vec<ExecutionTaskItem>, +} + +impl TaskSnapshot { + pub fn has_active_items(&self) -> bool { + self.items.iter().any(ExecutionTaskItem::is_active) + } + + pub fn active_items(&self) -> Vec<ExecutionTaskItem> { + self.items + .iter() + .filter(|item| item.is_active()) + .cloned() + .collect() + } + + pub fn should_clear(&self) -> bool { + self.items.is_empty() + || self + .items + .iter() + .all(|item| item.status == ExecutionTaskStatus::Completed) + } +} + +/// `taskWrite` durable tool result metadata。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExecutionTaskSnapshotMetadata { + pub schema: String, + pub owner: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub items: Vec<ExecutionTaskItem>, + pub cleared: bool, +} + +impl ExecutionTaskSnapshotMetadata { + pub fn from_snapshot(snapshot: &TaskSnapshot) -> Self { + Self { + schema: EXECUTION_TASK_SNAPSHOT_SCHEMA.to_string(), + owner: snapshot.owner.clone(), + items: snapshot.items.clone(), + cleared: snapshot.should_clear(), + } + } + + pub fn into_snapshot(self) -> TaskSnapshot { + TaskSnapshot { + owner: self.owner, + items: self.items, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn task_snapshot_metadata_marks_completed_snapshots_as_cleared() { + let metadata = ExecutionTaskSnapshotMetadata::from_snapshot(&TaskSnapshot { + owner: "owner-1".to_string(), + items: vec![ExecutionTaskItem { + content: "收尾验证".to_string(), + status: ExecutionTaskStatus::Completed, + active_form: Some("正在收尾验证".to_string()), + }], + }); + + assert_eq!(metadata.schema, EXECUTION_TASK_SNAPSHOT_SCHEMA); + assert!(metadata.cleared); + } +} diff --git a/crates/core/src/hook.rs b/crates/core/src/hook.rs index 89df469d..b5414e0a 100644 --- a/crates/core/src/hook.rs +++ b/crates/core/src/hook.rs @@ -24,7 +24,7 @@ use async_trait::async_trait; use serde::{Deserialize, Serialize}; use serde_json::Value; -use crate::{LlmMessage, Result, ToolDefinition, ToolExecutionResult}; +use crate::{CompactTrigger, LlmMessage, Result, ToolDefinition, ToolExecutionResult}; /// 可被外部扩展拦截的生命周期事件。 #[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)] @@ -37,15 +37,6 @@ pub enum HookEvent { PostCompact, } -/// Hook 视角下的压缩触发原因。 -#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)] -#[serde(rename_all = "camelCase")] -pub enum HookCompactionReason { - Auto, - Reactive, - Manual, -} - /// 工具调用的公共上下文。 #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] @@ -76,7 +67,7 @@ pub struct ToolHookResultContext { pub struct CompactionHookContext { pub session_id: String, pub working_dir: PathBuf, - pub reason: HookCompactionReason, + pub reason: CompactTrigger, pub keep_recent_turns: usize, pub message_count: usize, /// 当前对话中的消息(序列化形式)。 diff --git a/crates/core/src/ids.rs b/crates/core/src/ids.rs index dbce441e..15f423b1 100644 --- a/crates/core/src/ids.rs +++ b/crates/core/src/ids.rs @@ -1,7 +1,10 @@ -//! 强类型标识定义。 +//! # 强类型标识定义 //! -//! 这些 newtype 负责把会话、turn、agent、能力名称从裸字符串中剥离出来, -//! 避免跨层 API 继续依赖脆弱的字符串约定。 +//! 通过宏批量生成 newtype 包装,将 SessionId / TurnId / AgentId / SubRunId / +//! DeliveryId / CapabilityName 从裸字符串中剥离,避免跨层 API 依赖脆弱的字符串约定。 +//! +//! 每个 ID 类型实现了 Display、Deref、From<String/&str>、Serialize、Deserialize +//! 等标准 trait,可直接用于格式化、比较和序列化。 use std::fmt; @@ -80,4 +83,6 @@ macro_rules! typed_id { typed_id!(SessionId); typed_id!(TurnId); typed_id!(AgentId); +typed_id!(SubRunId); +typed_id!(DeliveryId); typed_id!(CapabilityName); diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index c1719b3d..55d7deef 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -4,29 +4,67 @@ //! //! ## 主要模块 //! +//! ### 领域模型 +//! +//! - [`agent`][]: Agent 协作模型、子运行管理、输入队列 +//! - [`capability`][]: 能力规格定义(CapabilitySpec 等) +//! - [`ids`][]: 核心标识符类型(AgentId, SessionId, TurnId 等) +//! - [`action`][]: LLM 消息与工具调用相关的数据结构 +//! +//! ### 事件与会话 +//! //! - [`event`][]: 事件存储与回放系统(JSONL append-only 日志) -//! - [`session`][]: 会话管理与持久化 -//! - [`tool`][]: Tool trait 定义(插件系统的基础抽象) +//! - [`session`][]: 会话元数据 +//! - [`store`][]: 会话存储与事件日志写入 +//! - [`projection`][]: Agent 状态投影(从事件流推导状态) +//! +//! ### 治理与策略 +//! +//! - [`mode`][]: 治理模式(Code/Plan/Review 模式与策略规则) //! - [`policy`][]: 策略引擎 trait(审批与模型/工具请求检查) +//! +//! ### 扩展点 +//! +//! - [`ports`][]: 核心 port trait 定义(LlmProvider, PromptProvider, EventStore 等) +//! - [`tool`][]: Tool trait 定义(插件系统的基础抽象) //! - [`plugin`][]: 插件清单与注册表 //! - [`registry`][]: 能力路由器(将能力调用分派到具体的 invoker) +//! - [`hook`][]: 钩子系统(工具/压缩钩子) +//! +//! ### 运行时与配置 +//! //! - [`runtime`][]: 运行时协调器接口 -//! - [`projection`][]: Agent 状态投影(从事件流推导状态) -//! - `action`: LLM 消息与工具调用相关的数据结构 +//! - [`config`][]: 配置模型(Agent/Model/Runtime 配置) +//! - [`observability`][]: 运行时可观测性指标 +//! +//! ### 基础设施 +//! +//! - [`env`][]: 环境变量解析 +//! - [`home`][]: 主目录管理 +//! - [`local_server`][]: 本地服务器信息 +//! - [`project`][]: 项目信息 +//! - [`shell`][]: Shell 检测与解析 +//! - [`tool_result_persist`][]: 工具结果持久化 mod action; pub mod agent; mod cancel; pub mod capability; mod compact_summary; +mod composer; pub mod config; pub mod env; mod error; pub mod event; +mod execution_control; +mod execution_result; +mod execution_task; pub mod home; pub mod hook; pub mod ids; pub mod local_server; +mod mcp; +pub mod mode; pub mod observability; pub mod plugin; pub mod policy; @@ -36,7 +74,10 @@ pub mod projection; pub mod registry; pub mod runtime; pub mod session; +mod session_catalog; +mod session_plan; mod shell; +mod skill; pub mod store; mod time; // test_support 通过 feature gate "test-support" 守卫。 @@ -56,65 +97,88 @@ pub use action::{ pub use agent::{ AgentCollaborationActionKind, AgentCollaborationFact, AgentCollaborationOutcomeKind, AgentCollaborationPolicyContext, AgentEventContext, AgentInboxEnvelope, AgentMode, - AgentProfile, AgentProfileCatalog, ArtifactRef, ChildAgentRef, ChildSessionLineageKind, - ChildSessionNode, ChildSessionNotification, ChildSessionNotificationKind, - ChildSessionStatusSource, CloseAgentParams, CloseRequestParentDeliveryPayload, - CollaborationResult, CollaborationResultKind, CompletedParentDeliveryPayload, - DelegationMetadata, FailedParentDeliveryPayload, ForkMode, InboxEnvelopeKind, InvocationKind, - LineageSnapshot, ParentDelivery, ParentDeliveryKind, ParentDeliveryOrigin, - ParentDeliveryPayload, ParentDeliveryTerminalSemantics, ProgressParentDeliveryPayload, + AgentProfile, AgentProfileCatalog, ArtifactRef, ChildAgentRef, ChildExecutionIdentity, + ChildSessionLineageKind, ChildSessionNode, ChildSessionNotification, + ChildSessionNotificationKind, ChildSessionStatusSource, CloseAgentParams, + CloseRequestParentDeliveryPayload, CollaborationResult, CompletedParentDeliveryPayload, + CompletedSubRunOutcome, DelegationMetadata, FailedParentDeliveryPayload, FailedSubRunOutcome, + ForkMode, InboxEnvelopeKind, InvocationKind, LineageSnapshot, ParentDelivery, + ParentDeliveryKind, ParentDeliveryOrigin, ParentDeliveryPayload, + ParentDeliveryTerminalSemantics, ParentExecutionRef, ProgressParentDeliveryPayload, ResolvedExecutionLimitsSnapshot, ResolvedSubagentContextOverrides, SendAgentParams, SendToChildParams, SendToParentParams, SpawnAgentParams, SpawnCapabilityGrant, SubRunFailure, - SubRunFailureCode, SubRunHandle, SubRunHandoff, SubRunResult, SubRunStorageMode, + SubRunFailureCode, SubRunHandle, SubRunHandoff, SubRunResult, SubRunStatus, SubRunStorageMode, SubagentContextOverrides, executor::{CollaborationExecutor, SubAgentExecutor}, - lifecycle::{AgentLifecycleStatus, AgentTurnOutcome}, - mailbox::{ - AgentMailboxEnvelope, BatchId, CloseParams, DeliveryId, MailboxBatchAckedPayload, - MailboxBatchStartedPayload, MailboxDiscardedPayload, MailboxProjection, - MailboxQueuedPayload, ObserveAgentResult, ObserveParams, SendParams, + input_queue::{ + BatchId, CloseParams, DeliveryId, InputBatchAckedPayload, InputBatchStartedPayload, + InputDiscardedPayload, InputQueueProjection, InputQueuedPayload, ObserveParams, + ObserveSnapshot, QueuedInputEnvelope, SendParams, }, + lifecycle::{AgentLifecycleStatus, AgentTurnOutcome}, normalize_non_empty_unique_string_list, }; pub use cancel::CancelToken; pub use capability::{ - CapabilityKind, CapabilitySpec, CapabilitySpecBuildError, InvocationMode, PermissionSpec, - SideEffect, Stability, + CapabilityKind, CapabilitySpec, CapabilitySpecBuildError, CapabilitySpecBuilder, + InvocationMode, PermissionSpec, SideEffect, Stability, }; pub use compact_summary::{ COMPACT_SUMMARY_CONTINUATION, COMPACT_SUMMARY_PREFIX, CompactSummaryEnvelope, format_compact_summary, parse_compact_summary_message, }; +pub use composer::{ComposerOption, ComposerOptionActionKind, ComposerOptionKind}; pub use config::{ ActiveSelection, AgentConfig, Config, ConfigOverlay, CurrentModelSelection, ModelConfig, - ModelOption, Profile, ResolvedAgentConfig, ResolvedRuntimeConfig, RuntimeConfig, - max_tool_concurrency, resolve_agent_config, resolve_runtime_config, + ModelOption, ModelSelection, Profile, ResolvedAgentConfig, ResolvedRuntimeConfig, + RuntimeConfig, TestConnectionResult, max_tool_concurrency, resolve_agent_config, + resolve_runtime_config, }; pub use error::{AstrError, Result, ResultExt}; pub use event::{ - AgentEvent, CompactTrigger, EventTranslator, Phase, PromptMetricsPayload, StorageEvent, - StorageEventPayload, StoredEvent, generate_session_id, normalize_recovered_phase, - phase_of_storage_event, replay_records, + AgentEvent, CompactAppliedMeta, CompactMode, CompactTrigger, EventTranslator, Phase, + PromptMetricsPayload, StorageEvent, StorageEventPayload, StoredEvent, generate_session_id, + generate_turn_id, normalize_recovered_phase, phase_of_storage_event, replay_records, +}; +pub use execution_control::ExecutionControl; +pub use execution_result::ExecutionResultCommon; +pub use execution_task::{ + EXECUTION_TASK_SNAPSHOT_SCHEMA, ExecutionTaskItem, ExecutionTaskSnapshotMetadata, + ExecutionTaskStatus, TaskSnapshot, }; pub use hook::{ - CompactionHookContext, CompactionHookResultContext, HookCompactionReason, HookEvent, - HookHandler, HookInput, HookOutcome, ToolHookContext, ToolHookResultContext, + CompactionHookContext, CompactionHookResultContext, HookEvent, HookHandler, HookInput, + HookOutcome, ToolHookContext, ToolHookResultContext, }; -pub use ids::{AgentId, CapabilityName, SessionId, TurnId}; +pub use ids::{AgentId, CapabilityName, SessionId, SubRunId, TurnId}; pub use local_server::{LOCAL_SERVER_READY_PREFIX, LocalServerInfo}; -pub use observability::{RuntimeMetricsRecorder, SubRunExecutionOutcome}; +pub use mcp::{McpApprovalData, McpApprovalStatus}; +pub use mode::{ + ActionPolicies, ActionPolicyEffect, ActionPolicyRule, BUILTIN_MODE_CODE_ID, + BUILTIN_MODE_PLAN_ID, BUILTIN_MODE_REVIEW_ID, CapabilitySelector, ChildPolicySpec, + GovernanceModeSpec, ModeExecutionPolicySpec, ModeId, PromptProgramEntry, ResolvedChildPolicy, + ResolvedTurnEnvelope, SubmitBusyPolicy, TransitionPolicySpec, +}; +pub use observability::{ + AgentCollaborationScorecardSnapshot, ExecutionDiagnosticsSnapshot, OperationMetricsSnapshot, + ReplayMetricsSnapshot, ReplayPath, RuntimeMetricsRecorder, RuntimeObservabilitySnapshot, + SubRunExecutionMetricsSnapshot, +}; pub use plugin::{PluginHealth, PluginManifest, PluginRegistry, PluginState, PluginType}; pub use policy::{ AllowAllPolicyEngine, ApprovalDefault, ApprovalPending, ApprovalRequest, ApprovalResolution, - CapabilityCall, ContextDecisionInput, ContextStrategy, ModelRequest, PolicyContext, - PolicyEngine, PolicyVerdict, SystemPromptBlock, SystemPromptLayer, + CapabilityCall, ModelRequest, PolicyContext, PolicyEngine, PolicyVerdict, SystemPromptBlock, + SystemPromptLayer, }; pub use ports::{ EventStore, LlmEvent, LlmEventSink, LlmFinishReason, LlmOutput, LlmProvider, LlmRequest, - LlmUsage, ModelLimits, PromptAgentProfileSummary, PromptBuildOutput, PromptBuildRequest, - PromptDeclaration, PromptDeclarationKind, PromptDeclarationRenderTarget, - PromptDeclarationSource, PromptFacts, PromptFactsProvider, PromptFactsRequest, PromptProvider, - PromptSkillSummary, ResourceProvider, ResourceReadResult, ResourceRequestContext, + LlmUsage, McpSettingsStore, ModelLimits, PromptAgentProfileSummary, PromptBuildCacheMetrics, + PromptBuildOutput, PromptBuildRequest, PromptCacheHints, PromptDeclaration, + PromptDeclarationKind, PromptDeclarationRenderTarget, PromptDeclarationSource, + PromptEntrySummary, PromptFacts, PromptFactsProvider, PromptFactsRequest, + PromptGovernanceContext, PromptLayerFingerprints, PromptProvider, PromptSkillSummary, + RecoveredSessionState, ResourceProvider, ResourceReadResult, ResourceRequestContext, + SessionRecoveryCheckpoint, SkillCatalog, }; pub use projection::{AgentState, AgentStateProjector, project}; pub use registry::{CapabilityContext, CapabilityExecutionResult, CapabilityInvoker}; @@ -124,9 +188,12 @@ pub use runtime::{ SessionTruthBoundary, }; pub use session::{DeleteProjectResult, SessionEventRecord, SessionMeta}; +pub use session_catalog::SessionCatalogEvent; +pub use session_plan::{SessionPlanState, SessionPlanStatus, session_plan_content_digest}; pub use shell::{ ResolvedShell, ShellFamily, default_shell_label, detect_shell_family, resolve_shell, }; +pub use skill::{SkillSource, SkillSpec, is_valid_skill_name, normalize_skill_name}; pub use store::{ EventLogWriter, SessionManager, SessionTurnAcquireResult, SessionTurnBusy, SessionTurnLease, StoreError, StoreResult, @@ -139,6 +206,7 @@ pub use tool::{ ToolEventSink, ToolPromptMetadata, }; pub use tool_result_persist::{ - DEFAULT_TOOL_RESULT_INLINE_LIMIT, TOOL_RESULT_PREVIEW_LIMIT, TOOL_RESULTS_DIR, - is_persisted_output, maybe_persist_tool_result, persist_tool_result, + DEFAULT_TOOL_RESULT_INLINE_LIMIT, PersistedToolOutput, PersistedToolResult, + TOOL_RESULT_PREVIEW_LIMIT, TOOL_RESULTS_DIR, is_persisted_output, maybe_persist_tool_result, + persist_tool_result, persisted_output_absolute_path, }; diff --git a/crates/core/src/local_server.rs b/crates/core/src/local_server.rs index f6d015cc..54daed8c 100644 --- a/crates/core/src/local_server.rs +++ b/crates/core/src/local_server.rs @@ -1,3 +1,8 @@ +//! # Local Server 引导协议 +//! +//! 定义 Desktop shell 与 sidecar server 之间的发现和引导 DTO。 +//! 统一 `run.json` 文件写入和 stdout ready 行协议,避免两套近似字段随时间漂移。 + use serde::{Deserialize, Serialize}; /// Desktop shell 和 sidecar server 之间用于发现和引导的共享载荷。 @@ -7,7 +12,7 @@ use serde::{Deserialize, Serialize}; /// - `astrcode-server` 通过 stdout 发出 ready 事件,供桌面端进程同步等待 /// /// 统一 DTO 的目的是避免两个进程各自维护一套近似字段,随着时间漂移后 -/// 出现“run.json 能读、ready 行却解析失败”之类的隐蔽兼容问题。 +/// 出现”run.json 能读、ready 行却解析失败”之类的隐蔽兼容问题。 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct LocalServerInfo { diff --git a/crates/core/src/mcp.rs b/crates/core/src/mcp.rs new file mode 100644 index 00000000..d00d9d92 --- /dev/null +++ b/crates/core/src/mcp.rs @@ -0,0 +1,21 @@ +//! MCP 审批共享模型。 + +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct McpApprovalData { + pub server_signature: String, + pub status: McpApprovalStatus, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub approved_at: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub approved_by: Option<String>, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum McpApprovalStatus { + Pending, + Approved, + Rejected, +} diff --git a/crates/core/src/mode/mod.rs b/crates/core/src/mode/mod.rs new file mode 100644 index 00000000..e407081d --- /dev/null +++ b/crates/core/src/mode/mod.rs @@ -0,0 +1,454 @@ +//! # 声明式治理模式系统 +//! +//! 定义运行时治理模式(Governance Mode)的完整 DSL: +//! +//! - **ModeId**: 模式唯一标识(内置 code / plan / review) +//! - **CapabilitySelector**: 能力选择器 DSL(支持 AllTools / Name / Kind / Tag / Union / +//! Intersection / Difference) +//! - **ActionPolicies**: 动作策略规则集(Allow / Deny / Ask 三种裁决效果) +//! - **GovernanceModeSpec**: 完整模式定义(能力表面 + 动作策略 + 子策略 + 执行策略 + 提示词程序 + +//! 转换策略) +//! - **ResolvedTurnEnvelope**: 运行时 turn 级解析后的完整治理信封 +//! +//! 模式由声明式配置文件加载,运行时通过 `GovernanceModeSpec::validate()` 校验后, +//! 由治理层解析为 `ResolvedTurnEnvelope` 注入每个 turn 的执行上下文。 + +use serde::{Deserialize, Serialize}; + +use crate::{ + AstrError, CapabilityKind, ForkMode, PromptDeclaration, Result, SideEffect, + normalize_non_empty_unique_string_list, +}; + +pub const BUILTIN_MODE_CODE_ID: &str = "code"; +pub const BUILTIN_MODE_PLAN_ID: &str = "plan"; +pub const BUILTIN_MODE_REVIEW_ID: &str = "review"; + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct ModeId(String); + +impl ModeId { + pub fn new(value: impl Into<String>) -> Result<Self> { + let value = value.into(); + let trimmed = value.trim(); + if trimmed.is_empty() { + return Err(AstrError::Validation("modeId 不能为空".to_string())); + } + Ok(Self(trimmed.to_string())) + } + + pub fn code() -> Self { + Self(BUILTIN_MODE_CODE_ID.to_string()) + } + + pub fn plan() -> Self { + Self(BUILTIN_MODE_PLAN_ID.to_string()) + } + + pub fn review() -> Self { + Self(BUILTIN_MODE_REVIEW_ID.to_string()) + } + + pub fn as_str(&self) -> &str { + self.0.as_str() + } +} + +impl Default for ModeId { + fn default() -> Self { + Self::code() + } +} + +impl From<&str> for ModeId { + fn from(value: &str) -> Self { + Self(value.trim().to_string()) + } +} + +impl From<String> for ModeId { + fn from(value: String) -> Self { + Self(value.trim().to_string()) + } +} + +impl std::fmt::Display for ModeId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum SubmitBusyPolicy { + #[default] + BranchOnBusy, + RejectOnBusy, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum CapabilitySelector { + AllTools, + Name(String), + Kind(CapabilityKind), + SideEffect(SideEffect), + Tag(String), + Union(Vec<CapabilitySelector>), + Intersection(Vec<CapabilitySelector>), + Difference { + base: Box<CapabilitySelector>, + subtract: Box<CapabilitySelector>, + }, +} + +impl CapabilitySelector { + pub fn validate(&self) -> Result<()> { + match self { + Self::AllTools => Ok(()), + Self::Name(name) => validate_non_empty_trimmed("capabilitySelector.name", name), + Self::Kind(_) | Self::SideEffect(_) => Ok(()), + Self::Tag(tag) => validate_non_empty_trimmed("capabilitySelector.tag", tag), + Self::Union(selectors) | Self::Intersection(selectors) => { + if selectors.is_empty() { + return Err(AstrError::Validation( + "capabilitySelector 组合操作不能为空".to_string(), + )); + } + for selector in selectors { + selector.validate()?; + } + Ok(()) + }, + Self::Difference { base, subtract } => { + base.validate()?; + subtract.validate()?; + Ok(()) + }, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum ActionPolicyEffect { + #[default] + Allow, + Deny, + Ask, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ActionPolicyRule { + pub selector: CapabilitySelector, + pub effect: ActionPolicyEffect, +} + +impl ActionPolicyRule { + pub fn validate(&self) -> Result<()> { + self.selector.validate() + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct ActionPolicies { + #[serde(default = "default_action_policy_effect")] + pub default_effect: ActionPolicyEffect, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub rules: Vec<ActionPolicyRule>, +} + +impl ActionPolicies { + pub fn validate(&self) -> Result<()> { + for rule in &self.rules { + rule.validate()?; + } + Ok(()) + } + + pub fn requires_approval(&self) -> bool { + self.rules + .iter() + .any(|rule| matches!(rule.effect, ActionPolicyEffect::Ask)) + || matches!(self.default_effect, ActionPolicyEffect::Ask) + } +} + +fn default_action_policy_effect() -> ActionPolicyEffect { + ActionPolicyEffect::Allow +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct ChildPolicySpec { + #[serde(default = "default_true")] + pub allow_delegation: bool, + #[serde(default = "default_true")] + pub allow_recursive_delegation: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_mode_id: Option<ModeId>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub capability_selector: Option<CapabilitySelector>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub allowed_profile_ids: Vec<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fork_mode: Option<ForkMode>, + #[serde(default)] + pub restricted: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reuse_scope_summary: Option<String>, +} + +impl ChildPolicySpec { + pub fn validate(&self) -> Result<()> { + if let Some(selector) = &self.capability_selector { + selector.validate()?; + } + normalize_non_empty_unique_string_list( + &self.allowed_profile_ids, + "childPolicy.allowedProfileIds", + )?; + if let Some(summary) = &self.reuse_scope_summary { + validate_non_empty_trimmed("childPolicy.reuseScopeSummary", summary)?; + } + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct ModeExecutionPolicySpec { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub submit_busy_policy: Option<SubmitBusyPolicy>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fork_mode: Option<ForkMode>, +} + +impl ModeExecutionPolicySpec { + pub fn validate(&self) -> Result<()> { + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PromptProgramEntry { + pub block_id: String, + pub title: String, + pub content: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub priority_hint: Option<i32>, +} + +impl PromptProgramEntry { + pub fn validate(&self) -> Result<()> { + validate_non_empty_trimmed("promptProgram.blockId", &self.block_id)?; + validate_non_empty_trimmed("promptProgram.title", &self.title)?; + validate_non_empty_trimmed("promptProgram.content", &self.content)?; + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct TransitionPolicySpec { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub allowed_targets: Vec<ModeId>, +} + +impl TransitionPolicySpec { + pub fn validate(&self) -> Result<()> { + let values = self + .allowed_targets + .iter() + .map(|mode_id| mode_id.as_str().to_string()) + .collect::<Vec<_>>(); + normalize_non_empty_unique_string_list(&values, "transitionPolicy.allowedTargets")?; + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GovernanceModeSpec { + pub id: ModeId, + pub name: String, + pub description: String, + pub capability_selector: CapabilitySelector, + #[serde(default)] + pub action_policies: ActionPolicies, + #[serde(default)] + pub child_policy: ChildPolicySpec, + #[serde(default)] + pub execution_policy: ModeExecutionPolicySpec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub prompt_program: Vec<PromptProgramEntry>, + #[serde(default)] + pub transition_policy: TransitionPolicySpec, +} + +impl GovernanceModeSpec { + pub fn validate(&self) -> Result<()> { + validate_non_empty_trimmed("mode.id", self.id.as_str())?; + validate_non_empty_trimmed("mode.name", &self.name)?; + validate_non_empty_trimmed("mode.description", &self.description)?; + self.capability_selector.validate()?; + self.action_policies.validate()?; + self.child_policy.validate()?; + self.execution_policy.validate()?; + self.transition_policy.validate()?; + for entry in &self.prompt_program { + entry.validate()?; + } + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct ResolvedChildPolicy { + pub mode_id: ModeId, + #[serde(default = "default_true")] + pub allow_delegation: bool, + #[serde(default = "default_true")] + pub allow_recursive_delegation: bool, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub allowed_profile_ids: Vec<String>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub allowed_tools: Vec<String>, + #[serde(default)] + pub restricted: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fork_mode: Option<ForkMode>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reuse_scope_summary: Option<String>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ResolvedTurnEnvelope { + pub mode_id: ModeId, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub allowed_tools: Vec<String>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub prompt_declarations: Vec<PromptDeclaration>, + #[serde(default)] + pub action_policies: ActionPolicies, + #[serde(default)] + pub child_policy: ResolvedChildPolicy, + #[serde(default)] + pub submit_busy_policy: SubmitBusyPolicy, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fork_mode: Option<ForkMode>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub diagnostics: Vec<String>, +} + +impl ResolvedTurnEnvelope { + pub fn approval_mode(&self) -> String { + if self.action_policies.requires_approval() { + "required".to_string() + } else { + "inherit".to_string() + } + } +} + +fn validate_non_empty_trimmed(field: &str, value: impl AsRef<str>) -> Result<()> { + if value.as_ref().trim().is_empty() { + return Err(AstrError::Validation(format!("{field} 不能为空"))); + } + Ok(()) +} + +const fn default_true() -> bool { + true +} + +#[cfg(test)] +mod tests { + use super::{ + ActionPolicies, BUILTIN_MODE_CODE_ID, CapabilitySelector, GovernanceModeSpec, ModeId, + PromptProgramEntry, ResolvedTurnEnvelope, SubmitBusyPolicy, + }; + use crate::{CapabilityKind, PromptDeclaration, SideEffect, SystemPromptLayer}; + + #[test] + fn mode_id_defaults_to_builtin_code() { + assert_eq!(ModeId::default().as_str(), BUILTIN_MODE_CODE_ID); + } + + #[test] + fn capability_selector_validation_rejects_empty_union() { + let error = CapabilitySelector::Union(Vec::new()) + .validate() + .expect_err("empty union should be rejected"); + assert!(error.to_string().contains("不能为空")); + } + + #[test] + fn governance_mode_spec_round_trips_and_validates() { + let spec = GovernanceModeSpec { + id: ModeId::plan(), + name: "Plan".to_string(), + description: "只读规划".to_string(), + capability_selector: CapabilitySelector::Intersection(vec![ + CapabilitySelector::Kind(CapabilityKind::Tool), + CapabilitySelector::Difference { + base: Box::new(CapabilitySelector::AllTools), + subtract: Box::new(CapabilitySelector::SideEffect(SideEffect::Workspace)), + }, + ]), + action_policies: ActionPolicies::default(), + child_policy: Default::default(), + execution_policy: Default::default(), + prompt_program: vec![PromptProgramEntry { + block_id: "mode.plan".to_string(), + title: "Plan".to_string(), + content: "plan first".to_string(), + priority_hint: Some(600), + }], + transition_policy: Default::default(), + }; + + spec.validate().expect("spec should be valid"); + let encoded = serde_json::to_string(&spec).expect("spec should serialize"); + let decoded: GovernanceModeSpec = + serde_json::from_str(&encoded).expect("spec should deserialize"); + assert_eq!(decoded.id, ModeId::plan()); + } + + #[test] + fn resolved_turn_envelope_reports_required_approval_mode_when_rule_asks() { + let envelope = ResolvedTurnEnvelope { + mode_id: ModeId::review(), + allowed_tools: vec!["readFile".to_string()], + prompt_declarations: vec![PromptDeclaration { + block_id: "mode.review".to_string(), + title: "Review".to_string(), + content: "review only".to_string(), + render_target: crate::PromptDeclarationRenderTarget::System, + layer: SystemPromptLayer::Dynamic, + kind: crate::PromptDeclarationKind::ExtensionInstruction, + priority_hint: Some(600), + always_include: true, + source: crate::PromptDeclarationSource::Builtin, + capability_name: None, + origin: None, + }], + action_policies: ActionPolicies { + default_effect: crate::ActionPolicyEffect::Ask, + rules: Vec::new(), + }, + child_policy: Default::default(), + submit_busy_policy: SubmitBusyPolicy::RejectOnBusy, + fork_mode: None, + diagnostics: Vec::new(), + }; + + assert_eq!(envelope.approval_mode(), "required"); + } +} diff --git a/crates/core/src/observability.rs b/crates/core/src/observability.rs index 2473ffdd..50926799 100644 --- a/crates/core/src/observability.rs +++ b/crates/core/src/observability.rs @@ -1,4 +1,149 @@ -use crate::{AgentCollaborationFact, SubRunStorageMode}; +//! # 运行时可观测性 +//! +//! 定义运行时指标快照和记录接口,用于监控运行时健康状况和性能。 +//! +//! ## 快照类型 +//! +//! - `OperationMetricsSnapshot`: 单一操作的计数/耗时/失败率 +//! - `ReplayMetricsSnapshot`: SSE 回放操作的缓存命中/磁盘回退 +//! - `SubRunExecutionMetricsSnapshot`: 子执行域的完成/取消/token 超限统计 +//! - `ExecutionDiagnosticsSnapshot`: 子会话生命周期和缓存切换的结构化诊断 +//! - `AgentCollaborationScorecardSnapshot`: agent-tool 协作效果的评估读模型 +//! - `RuntimeObservabilitySnapshot`: 聚合所有指标的顶层快照 +//! +//! `RuntimeMetricsRecorder` 是窄写入接口,业务层只通过它记录事实, +//! 不反向依赖具体快照实现。 + +use crate::{AgentCollaborationFact, AgentTurnOutcome, SubRunStorageMode}; + +/// 回放路径:优先缓存,不足时回退到磁盘。 +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ReplayPath { + /// 从内存缓存读取(快速路径) + Cache, + /// 从磁盘 JSONL 文件加载(慢速回退路径) + DiskFallback, +} + +/// 单一操作的指标快照。 +#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OperationMetricsSnapshot { + /// 总操作次数 + pub total: u64, + /// 失败次数 + pub failures: u64, + /// 累计耗时(毫秒) + pub total_duration_ms: u64, + /// 最近一次操作的耗时(毫秒) + pub last_duration_ms: u64, + /// 历史最大单次操作耗时(毫秒) + pub max_duration_ms: u64, +} + +/// SSE 回放操作的指标快照。 +#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReplayMetricsSnapshot { + /// 基础操作指标(总次数、失败率、耗时等) + pub totals: OperationMetricsSnapshot, + /// 缓存命中次数 + pub cache_hits: u64, + /// 磁盘回退次数(说明缓存不足的情况) + pub disk_fallbacks: u64, + /// 成功恢复的事件总数 + pub recovered_events: u64, +} + +/// 子执行域共享观测指标快照。 +#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SubRunExecutionMetricsSnapshot { + pub total: u64, + pub failures: u64, + pub completed: u64, + pub cancelled: u64, + pub token_exceeded: u64, + pub independent_session_total: u64, + pub total_duration_ms: u64, + pub last_duration_ms: u64, + pub total_steps: u64, + pub last_step_count: u64, + pub total_estimated_tokens: u64, + pub last_estimated_tokens: u64, +} + +/// 子会话与缓存切换相关的结构化观测指标快照。 +#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExecutionDiagnosticsSnapshot { + pub child_spawned: u64, + pub child_started_persisted: u64, + pub child_terminal_persisted: u64, + pub parent_reactivation_requested: u64, + pub parent_reactivation_succeeded: u64, + pub parent_reactivation_failed: u64, + pub lineage_mismatch_parent_agent: u64, + pub lineage_mismatch_parent_session: u64, + pub lineage_mismatch_child_session: u64, + pub lineage_mismatch_descriptor_missing: u64, + pub cache_reuse_hits: u64, + pub cache_reuse_misses: u64, + pub delivery_buffer_queued: u64, + pub delivery_buffer_dequeued: u64, + pub delivery_buffer_wake_requested: u64, + pub delivery_buffer_wake_succeeded: u64, + pub delivery_buffer_wake_failed: u64, +} + +/// Agent collaboration 评估读模型。 +/// +/// 这些字段全部由 raw collaboration facts 派生, +/// 用于判断 agent-tool 是否真的创造了协作价值。 +#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentCollaborationScorecardSnapshot { + pub total_facts: u64, + pub spawn_accepted: u64, + pub spawn_rejected: u64, + pub send_reused: u64, + pub send_queued: u64, + pub send_rejected: u64, + pub observe_calls: u64, + pub observe_rejected: u64, + pub observe_followed_by_action: u64, + pub close_calls: u64, + pub close_rejected: u64, + pub delivery_delivered: u64, + pub delivery_consumed: u64, + pub delivery_replayed: u64, + pub orphan_child_count: u64, + pub child_reuse_ratio_bps: Option<u64>, + pub observe_to_action_ratio_bps: Option<u64>, + pub spawn_to_delivery_ratio_bps: Option<u64>, + pub orphan_child_ratio_bps: Option<u64>, + pub avg_delivery_latency_ms: Option<u64>, + pub max_delivery_latency_ms: Option<u64>, +} + +/// 运行时可观测性快照,包含各类操作的指标。 +#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RuntimeObservabilitySnapshot { + /// 会话重水合(从磁盘加载已有会话)的指标 + pub session_rehydrate: OperationMetricsSnapshot, + /// SSE 追赶(客户端重连时回放历史)的指标 + pub sse_catch_up: ReplayMetricsSnapshot, + /// Turn 执行的指标 + pub turn_execution: OperationMetricsSnapshot, + /// 子执行域共享观测指标 + pub subrun_execution: SubRunExecutionMetricsSnapshot, + /// 子会话与缓存切换相关的结构化观测指标 + pub execution_diagnostics: ExecutionDiagnosticsSnapshot, + /// agent-tool 协作效果评估读模型 + pub agent_collaboration: AgentCollaborationScorecardSnapshot, +} /// 统一的运行时观测记录接口。 /// @@ -19,7 +164,7 @@ pub trait RuntimeMetricsRecorder: Send + Sync { fn record_subrun_execution( &self, duration_ms: u64, - outcome: SubRunExecutionOutcome, + outcome: AgentTurnOutcome, step_count: Option<u32>, estimated_tokens: Option<u64>, storage_mode: Option<SubRunStorageMode>, @@ -39,10 +184,128 @@ pub trait RuntimeMetricsRecorder: Send + Sync { fn record_agent_collaboration_fact(&self, fact: &AgentCollaborationFact); } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SubRunExecutionOutcome { - Completed, - Failed, - Aborted, - TokenExceeded, +#[cfg(test)] +mod tests { + use super::{ + AgentCollaborationScorecardSnapshot, ExecutionDiagnosticsSnapshot, + OperationMetricsSnapshot, ReplayMetricsSnapshot, RuntimeObservabilitySnapshot, + SubRunExecutionMetricsSnapshot, + }; + + #[test] + fn operation_metrics_snapshot_uses_camel_case_wire_shape() { + let value = serde_json::to_value(OperationMetricsSnapshot { + total: 1, + failures: 2, + total_duration_ms: 3, + last_duration_ms: 4, + max_duration_ms: 5, + }) + .expect("operation metrics should serialize"); + + assert_eq!( + value, + serde_json::json!({ + "total": 1, + "failures": 2, + "totalDurationMs": 3, + "lastDurationMs": 4, + "maxDurationMs": 5, + }) + ); + } + + #[test] + fn runtime_observability_snapshot_round_trips_with_nested_metrics() { + let snapshot = RuntimeObservabilitySnapshot { + session_rehydrate: OperationMetricsSnapshot { + total: 1, + failures: 0, + total_duration_ms: 2, + last_duration_ms: 3, + max_duration_ms: 4, + }, + sse_catch_up: ReplayMetricsSnapshot { + totals: OperationMetricsSnapshot { + total: 5, + failures: 1, + total_duration_ms: 6, + last_duration_ms: 7, + max_duration_ms: 8, + }, + cache_hits: 9, + disk_fallbacks: 10, + recovered_events: 11, + }, + turn_execution: OperationMetricsSnapshot { + total: 12, + failures: 2, + total_duration_ms: 13, + last_duration_ms: 14, + max_duration_ms: 15, + }, + subrun_execution: SubRunExecutionMetricsSnapshot { + total: 16, + failures: 3, + completed: 17, + cancelled: 18, + token_exceeded: 19, + independent_session_total: 20, + total_duration_ms: 21, + last_duration_ms: 22, + total_steps: 23, + last_step_count: 24, + total_estimated_tokens: 25, + last_estimated_tokens: 26, + }, + execution_diagnostics: ExecutionDiagnosticsSnapshot { + child_spawned: 27, + child_started_persisted: 28, + child_terminal_persisted: 29, + parent_reactivation_requested: 30, + parent_reactivation_succeeded: 31, + parent_reactivation_failed: 32, + lineage_mismatch_parent_agent: 33, + lineage_mismatch_parent_session: 34, + lineage_mismatch_child_session: 35, + lineage_mismatch_descriptor_missing: 36, + cache_reuse_hits: 37, + cache_reuse_misses: 38, + delivery_buffer_queued: 39, + delivery_buffer_dequeued: 40, + delivery_buffer_wake_requested: 41, + delivery_buffer_wake_succeeded: 42, + delivery_buffer_wake_failed: 43, + }, + agent_collaboration: AgentCollaborationScorecardSnapshot { + total_facts: 44, + spawn_accepted: 45, + spawn_rejected: 46, + send_reused: 47, + send_queued: 48, + send_rejected: 49, + observe_calls: 50, + observe_rejected: 51, + observe_followed_by_action: 52, + close_calls: 53, + close_rejected: 54, + delivery_delivered: 55, + delivery_consumed: 56, + delivery_replayed: 57, + orphan_child_count: 58, + child_reuse_ratio_bps: Some(59), + observe_to_action_ratio_bps: Some(60), + spawn_to_delivery_ratio_bps: Some(61), + orphan_child_ratio_bps: Some(62), + avg_delivery_latency_ms: Some(63), + max_delivery_latency_ms: Some(64), + }, + }; + + let json = serde_json::to_string(&snapshot).expect("snapshot should serialize"); + let decoded: RuntimeObservabilitySnapshot = + serde_json::from_str(&json).expect("snapshot should deserialize"); + + assert_eq!(decoded, snapshot); + } } diff --git a/crates/core/src/plugin/manifest.rs b/crates/core/src/plugin/manifest.rs index ece4c428..3b5d628b 100644 --- a/crates/core/src/plugin/manifest.rs +++ b/crates/core/src/plugin/manifest.rs @@ -28,6 +28,7 @@ pub enum PluginType { /// 从 `Plugin.toml` 解析,描述插件的元数据和能力声明。 /// `name` 字段必须与插件目录名一致(kebab-case)。 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] pub struct PluginManifest { /// 插件名称(必须与目录名一致,kebab-case) pub name: String, diff --git a/crates/core/src/plugin/registry.rs b/crates/core/src/plugin/registry.rs index 6392023a..607e237a 100644 --- a/crates/core/src/plugin/registry.rs +++ b/crates/core/src/plugin/registry.rs @@ -14,7 +14,8 @@ use std::{collections::BTreeMap, sync::RwLock}; use crate::{CapabilitySpec, PluginManifest}; /// 插件生命周期状态。 -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] pub enum PluginState { /// 已发现(清单已加载,但尚未初始化) Discovered, @@ -28,7 +29,8 @@ pub enum PluginState { /// /// 与 `PluginState` 不同,健康状态反映运行时状况, /// 一个已初始化的插件可能因网络问题变为 `Degraded`。 -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] pub enum PluginHealth { /// 尚未检查 Unknown, @@ -188,8 +190,10 @@ impl PluginRegistry { /// 记录插件运行时失败事件。 /// - /// 失败计数递增;连续失败 3 次及以上时健康状态降级为 `Unavailable`, - /// 否则标记为 `Degraded`。用于实现渐进式健康度评估。 + /// 实现渐进式健康度评估: + /// - 1~2 次失败 → `Degraded`(降级但不完全禁用,后续成功可恢复) + /// - 3 次及以上 → `Unavailable`(完全禁用,需要人工或自动恢复机制介入) + /// - 非 Initialized 状态的插件 → 直接 `Unavailable` pub fn record_runtime_failure( &self, name: &str, diff --git a/crates/core/src/policy/engine.rs b/crates/core/src/policy/engine.rs index 27dc486f..c569b145 100644 --- a/crates/core/src/policy/engine.rs +++ b/crates/core/src/policy/engine.rs @@ -22,7 +22,7 @@ use crate::{CapabilitySpec, LlmMessage, Result, ToolDefinition}; /// 系统提示词块所属层级。 /// /// provider 可以利用该层级决定缓存边界,从而在分层 prompt 下尽量保住稳定前缀。 -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] #[serde(rename_all = "snake_case")] pub enum SystemPromptLayer { Stable, @@ -123,39 +123,6 @@ pub struct PolicyContext { pub metadata: Value, } -/// 上下文策略输入。 -/// -/// Loop 在构建完完整请求快照后,将上下文压力和局部建议封装为只读输入, -/// 交给策略层做最终裁决。这样 compact 等局部策略不会绕过全局护栏。 -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ContextDecisionInput { - /// 当前请求预估 token 数。 - pub estimated_tokens: usize, - /// 模型上下文窗口大小。 - pub context_window: usize, - /// 扣除保留区后的有效窗口大小。 - pub effective_window: usize, - /// 当前配置下触发策略的阈值 token 数。 - pub threshold_tokens: usize, - /// 本轮 microcompact 截断的 tool result 数量。 - pub truncated_tool_results: usize, - /// Loop 的局部建议策略。 - pub suggested_strategy: ContextStrategy, -} - -/// 上下文策略裁决。 -/// -/// 这是 loop 在“是否 compact / summarize / truncate / ignore”上的统一决策结果。 -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ContextStrategy { - Compact, - Summarize, - Truncate, - Ignore, -} - /// 审批默认值 /// /// 用于 UI 展示默认选项。 @@ -311,13 +278,6 @@ pub trait PolicyEngine: Send + Sync { call: CapabilityCall, ctx: &PolicyContext, ) -> Result<PolicyVerdict<CapabilityCall>>; - - /// 根据完整请求快照裁决上下文策略。 - async fn decide_context_strategy( - &self, - input: &ContextDecisionInput, - _ctx: &PolicyContext, - ) -> Result<ContextStrategy>; } /// 允许所有操作的策略引擎。 @@ -343,17 +303,6 @@ impl PolicyEngine for AllowAllPolicyEngine { ) -> Result<PolicyVerdict<CapabilityCall>> { Ok(PolicyVerdict::Allow(call)) } - - async fn decide_context_strategy( - &self, - input: &ContextDecisionInput, - _ctx: &PolicyContext, - ) -> Result<ContextStrategy> { - // AllowAll 必须显式选择循环的建议,以便上下文策略行为 - // 始终在实现站点可见,而不是隐藏在特性级别的兼容性之后 - // default. - Ok(input.suggested_strategy) - } } #[cfg(test)] @@ -361,8 +310,8 @@ mod tests { use serde_json::{Value, json}; use super::{ - AllowAllPolicyEngine, ApprovalDefault, ApprovalRequest, ContextDecisionInput, - ContextStrategy, PolicyContext, PolicyEngine, PolicyVerdict, + AllowAllPolicyEngine, ApprovalDefault, ApprovalRequest, PolicyContext, PolicyEngine, + PolicyVerdict, }; use crate::{ CapabilityKind, CapabilitySpec, InvocationMode, ModelRequest, SideEffect, Stability, @@ -429,23 +378,6 @@ mod tests { .expect("call should pass"), PolicyVerdict::Allow(call) ); - assert_eq!( - policy - .decide_context_strategy( - &ContextDecisionInput { - estimated_tokens: 90_000, - context_window: 128_000, - effective_window: 100_000, - threshold_tokens: 90_000, - truncated_tool_results: 1, - suggested_strategy: ContextStrategy::Compact, - }, - &policy_context(), - ) - .await - .expect("context strategy should pass"), - ContextStrategy::Compact - ); } #[test] diff --git a/crates/core/src/policy/mod.rs b/crates/core/src/policy/mod.rs index 6f68c9d1..90add1c4 100644 --- a/crates/core/src/policy/mod.rs +++ b/crates/core/src/policy/mod.rs @@ -12,6 +12,6 @@ mod engine; pub use engine::{ AllowAllPolicyEngine, ApprovalDefault, ApprovalPending, ApprovalRequest, ApprovalResolution, - CapabilityCall, ContextDecisionInput, ContextStrategy, ModelRequest, PolicyContext, - PolicyEngine, PolicyVerdict, SystemPromptBlock, SystemPromptLayer, + CapabilityCall, ModelRequest, PolicyContext, PolicyEngine, PolicyVerdict, SystemPromptBlock, + SystemPromptLayer, }; diff --git a/crates/core/src/ports.rs b/crates/core/src/ports.rs index 4ff83f6f..5ae0f5b0 100644 --- a/crates/core/src/ports.rs +++ b/crates/core/src/ports.rs @@ -4,18 +4,22 @@ //! 通过依赖倒置消费,避免上层再反向依赖具体实现 crate。 use std::{ + collections::HashMap, path::{Path, PathBuf}, sync::Arc, }; use async_trait::async_trait; +use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use serde_json::Value; use crate::{ - CancelToken, CapabilitySpec, Config, ConfigOverlay, DeleteProjectResult, LlmMessage, - ReasoningContent, Result, SessionId, SessionMeta, SessionTurnAcquireResult, StorageEvent, - StoredEvent, SystemPromptBlock, SystemPromptLayer, ToolCallRequest, ToolDefinition, TurnId, + AgentState, CancelToken, CapabilitySpec, ChildSessionNode, Config, ConfigOverlay, + DeleteProjectResult, InputQueueProjection, LlmMessage, McpApprovalData, Phase, + ReasoningContent, Result, SessionId, SessionMeta, SessionTurnAcquireResult, SkillSpec, + StorageEvent, StoredEvent, SystemPromptBlock, SystemPromptLayer, TaskSnapshot, ToolCallRequest, + ToolDefinition, TurnId, }; /// MCP 配置文件作用域。 @@ -33,6 +37,19 @@ pub trait EventStore: Send + Sync { async fn ensure_session(&self, session_id: &SessionId, working_dir: &Path) -> Result<()>; async fn append(&self, session_id: &SessionId, event: &StorageEvent) -> Result<StoredEvent>; async fn replay(&self, session_id: &SessionId) -> Result<Vec<StoredEvent>>; + async fn recover_session(&self, session_id: &SessionId) -> Result<RecoveredSessionState> { + Ok(RecoveredSessionState { + checkpoint: None, + tail_events: self.replay(session_id).await?, + }) + } + async fn checkpoint_session( + &self, + _session_id: &SessionId, + _checkpoint: &SessionRecoveryCheckpoint, + ) -> Result<()> { + Ok(()) + } async fn try_acquire_turn( &self, session_id: &SessionId, @@ -47,6 +64,33 @@ pub trait EventStore: Send + Sync { ) -> Result<DeleteProjectResult>; } +/// durable 恢复基线。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionRecoveryCheckpoint { + pub agent_state: AgentState, + pub phase: Phase, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_mode_changed_at: Option<DateTime<Utc>>, + #[serde(default)] + pub child_nodes: HashMap<String, ChildSessionNode>, + #[serde(default)] + pub active_tasks: HashMap<String, TaskSnapshot>, + #[serde(default)] + pub input_queue_projection_index: HashMap<String, InputQueueProjection>, + pub checkpoint_storage_seq: u64, +} + +/// 会话恢复结果:最近 checkpoint + checkpoint 之后的 tail events。 +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct RecoveredSessionState { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub checkpoint: Option<SessionRecoveryCheckpoint>, + #[serde(default)] + pub tail_events: Vec<StoredEvent>, +} + /// 模型能力限制。 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub struct ModelLimits { @@ -80,6 +124,22 @@ pub enum LlmFinishReason { Other(String), } +impl LlmFinishReason { + pub fn is_max_tokens(&self) -> bool { + matches!(self, Self::MaxTokens) + } + + /// 从 OpenAI / Anthropic 返回的 finish reason 字符串解析统一枚举。 + pub fn from_api_value(value: &str) -> Self { + match value { + "stop" | "end_turn" | "stop_sequence" => Self::Stop, + "max_tokens" | "length" => Self::MaxTokens, + "tool_calls" | "tool_use" => Self::ToolCalls, + other => Self::Other(other.to_string()), + } + } +} + /// 流式增量事件。 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -97,6 +157,28 @@ pub enum LlmEvent { pub type LlmEventSink = Arc<dyn Fn(LlmEvent) + Send + Sync>; +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct PromptLayerFingerprints { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stable: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub semi_stable: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub inherited: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub dynamic: Option<String>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct PromptCacheHints { + #[serde(default)] + pub layer_fingerprints: PromptLayerFingerprints, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub unchanged_layers: Vec<SystemPromptLayer>, +} + /// 模型调用请求。 #[derive(Debug, Clone)] pub struct LlmRequest { @@ -105,6 +187,8 @@ pub struct LlmRequest { pub cancel: CancelToken, pub system_prompt: Option<String>, pub system_prompt_blocks: Vec<SystemPromptBlock>, + pub prompt_cache_hints: Option<PromptCacheHints>, + pub max_output_tokens_override: Option<usize>, } impl LlmRequest { @@ -119,6 +203,8 @@ impl LlmRequest { cancel, system_prompt: None, system_prompt_blocks: Vec::new(), + prompt_cache_hints: None, + max_output_tokens_override: None, } } @@ -126,6 +212,23 @@ impl LlmRequest { self.system_prompt = Some(prompt.into()); self } + + pub fn with_max_output_tokens_override(mut self, max_output_tokens: usize) -> Self { + self.max_output_tokens_override = Some(max_output_tokens.max(1)); + self + } + + pub fn from_model_request(request: crate::ModelRequest, cancel: CancelToken) -> Self { + Self { + messages: request.messages, + tools: request.tools.into(), + cancel, + system_prompt: request.system_prompt, + system_prompt_blocks: request.system_prompt_blocks, + prompt_cache_hints: None, + max_output_tokens_override: None, + } + } } /// 模型调用输出。 @@ -151,19 +254,25 @@ pub trait LlmProvider: Send + Sync { /// Prompt 组装请求。 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] -pub struct PromptSkillSummary { +pub struct PromptEntrySummary { pub id: String, pub description: String, } -/// Prompt 侧的轻量 agent profile 摘要。 -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -pub struct PromptAgentProfileSummary { - pub id: String, - pub description: String, +impl PromptEntrySummary { + pub fn new(id: impl Into<String>, description: impl Into<String>) -> Self { + Self { + id: id.into(), + description: description.into(), + } + } } +pub type PromptSkillSummary = PromptEntrySummary; + +/// Prompt 侧的轻量 agent profile 摘要。 +pub type PromptAgentProfileSummary = PromptEntrySummary; + /// Prompt 声明来源。 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "snake_case")] @@ -221,6 +330,23 @@ pub struct PromptDeclaration { } /// Prompt 事实查询请求。 +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct PromptGovernanceContext { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub allowed_capability_names: Vec<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mode_id: Option<crate::ModeId>, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub approval_mode: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub policy_revision: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_subrun_depth: Option<usize>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_spawn_per_turn: Option<usize>, +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] pub struct PromptFactsRequest { @@ -231,6 +357,8 @@ pub struct PromptFactsRequest { pub working_dir: PathBuf, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub allowed_capability_names: Vec<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub governance: Option<PromptGovernanceContext>, } /// Prompt 组装前的已解析事实。 @@ -301,6 +429,16 @@ pub struct PromptBuildRequest { pub metadata: Value, } +/// Prompt 组装结果。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct PromptBuildCacheMetrics { + pub reuse_hits: u32, + pub reuse_misses: u32, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub unchanged_layers: Vec<SystemPromptLayer>, +} + /// Prompt 组装结果。 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -309,6 +447,10 @@ pub struct PromptBuildOutput { #[serde(default)] pub system_prompt_blocks: Vec<SystemPromptBlock>, #[serde(default)] + pub prompt_cache_hints: PromptCacheHints, + #[serde(default)] + pub cache_metrics: PromptBuildCacheMetrics, + #[serde(default)] pub metadata: Value, } @@ -352,6 +494,27 @@ pub trait ResourceProvider: Send + Sync { ) -> Result<ResourceReadResult>; } +/// Skill 查询端口。 +pub trait SkillCatalog: Send + Sync { + fn resolve_for_working_dir(&self, working_dir: &str) -> Vec<SkillSpec>; +} + +/// MCP settings 持久化端口。 +pub trait McpSettingsStore: Send + Sync { + fn load_approvals( + &self, + project_path: &str, + ) -> std::result::Result<Vec<McpApprovalData>, String>; + + fn save_approval( + &self, + project_path: &str, + data: &McpApprovalData, + ) -> std::result::Result<(), String>; + + fn clear_approvals(&self, project_path: &str) -> std::result::Result<(), String>; +} + /// 配置存储端口。 /// /// 将配置文件 IO 从 application 层剥离,由 adapter 层实现。 diff --git a/crates/core/src/project.rs b/crates/core/src/project.rs index b5d967ef..66058192 100644 --- a/crates/core/src/project.rs +++ b/crates/core/src/project.rs @@ -86,14 +86,13 @@ fn normalize_project_identity(path: &Path) -> PathBuf { } } -/// 将路径组件转换为 kebab-case slug +/// 将路径组件转换为 kebab-case slug。 /// -/// ## 处理规则 -/// -/// - **Prefix**(Windows 盘符): `D:` → `D`,UNC 路径 → `server-share` -/// - **RootDir**: 根目录 → `root`(Windows 下后续会移除) -/// - **Normal**: 清理非法字符,连续非法字符合并为单个 `-` -/// - **CurDir/ParentDir**: 忽略(`.` 和 `..`) +/// 处理各种操作系统的路径怪异情况: +/// - **Prefix**(Windows 盘符): `D:` → `D`,UNC `\\server\share` → `server-share` +/// - **RootDir**: 根目录标记 → `root`(Windows 下后续会移除,因为盘符已够用) +/// - **Normal**: 将非法文件名字符替换为 `-`,连续非法字符合并为单个 `-` +/// - **CurDir/ParentDir**: 忽略(`.` 和 `..` 不参与 slug) fn components_to_slug(path: &Path) -> String { let mut segments = Vec::new(); @@ -185,6 +184,10 @@ fn sanitize_component(value: &str) -> String { .collect::<String>() } +/// 基于路径生成稳定的 8 字符 hex hash。 +/// +/// 使用 UUID v5(namespace URL + 路径内容), +/// 相同路径始终产生相同 hash,用于超长路径名的唯一截断标识。 fn stable_project_hash(path: &Path) -> String { let source = path.to_string_lossy(); Uuid::new_v5(&Uuid::NAMESPACE_URL, source.as_bytes()) diff --git a/crates/core/src/projection/agent_state.rs b/crates/core/src/projection/agent_state.rs index aa21782b..7fd083a0 100644 --- a/crates/core/src/projection/agent_state.rs +++ b/crates/core/src/projection/agent_state.rs @@ -20,9 +20,11 @@ use std::path::PathBuf; use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; use crate::{ - InvocationKind, LlmMessage, Phase, ReasoningContent, ToolCallRequest, UserMessageOrigin, + InvocationKind, LlmMessage, ModeId, Phase, ReasoningContent, ToolCallRequest, + UserMessageOrigin, event::{StorageEvent, StorageEventPayload}, format_compact_summary, split_assistant_content, }; @@ -31,7 +33,7 @@ use crate::{ /// /// 由事件流投影而来,包含完整的消息历史和当前阶段。 /// 用于在 turn 之间保持上下文,以及断线重连后恢复状态。 -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct AgentState { /// 会话 ID pub session_id: String, @@ -41,6 +43,8 @@ pub struct AgentState { pub messages: Vec<LlmMessage>, /// 当前执行阶段 pub phase: Phase, + /// 当前治理模式 ID。 + pub mode_id: ModeId, /// 已完成的 turn 数量 pub turn_count: usize, /// 最后一条 assistant 消息的时间戳。 @@ -56,6 +60,7 @@ impl Default for AgentState { working_dir: PathBuf::new(), messages: Vec::new(), phase: Phase::Idle, + mode_id: ModeId::default(), turn_count: 0, last_assistant_at: None, } @@ -88,6 +93,15 @@ pub struct AgentStateProjector { } impl AgentStateProjector { + pub fn from_snapshot(state: AgentState) -> Self { + Self { + state, + pending_content: None, + pending_reasoning: None, + pending_tool_calls: Vec::new(), + } + } + pub fn from_events(events: &[StorageEvent]) -> Self { let mut projector = Self::default(); for event in events { @@ -170,6 +184,7 @@ impl AgentStateProjector { success, error, metadata, + child_ref, duration_ms, .. } => { @@ -181,6 +196,7 @@ impl AgentStateProjector { output: output.clone(), error: error.clone(), metadata: metadata.clone(), + child_ref: child_ref.clone(), duration_ms: *duration_ms, truncated: false, }; @@ -219,6 +235,10 @@ impl AgentStateProjector { ); }, + StorageEventPayload::ModeChanged { to, .. } => { + self.state.mode_id = to.clone(); + }, + StorageEventPayload::TurnDone { .. } => { self.flush_pending_assistant(); self.state.phase = Phase::Idle; @@ -233,10 +253,10 @@ impl AgentStateProjector { | StorageEventPayload::SubRunFinished { .. } | StorageEventPayload::ChildSessionNotification { .. } | StorageEventPayload::AgentCollaborationFact { .. } - | StorageEventPayload::AgentMailboxQueued { .. } - | StorageEventPayload::AgentMailboxBatchStarted { .. } - | StorageEventPayload::AgentMailboxBatchAcked { .. } - | StorageEventPayload::AgentMailboxDiscarded { .. } + | StorageEventPayload::AgentInputQueued { .. } + | StorageEventPayload::AgentInputBatchStarted { .. } + | StorageEventPayload::AgentInputBatchAcked { .. } + | StorageEventPayload::AgentInputDiscarded { .. } | StorageEventPayload::Error { .. } => {}, } } @@ -247,6 +267,11 @@ impl AgentStateProjector { clone.state } + /// 将累积中的 assistant 内容刷入消息历史。 + /// + /// 在遇到 UserMessage / ToolResult / TurnDone / CompactApplied 时调用, + /// 确保前一轮 assistant 的文本和工具调用先落袋为安, + /// 再处理新消息类型的开始。 fn flush_pending_assistant(&mut self) { if self.pending_content.is_some() || !self.pending_tool_calls.is_empty() { let content = self.pending_content.take().unwrap_or_default(); @@ -258,6 +283,13 @@ impl AgentStateProjector { } } + /// 应用上下文压缩:将旧消息前缀替换为摘要,保留最近 N 轮。 + /// + /// 执行步骤: + /// 1. 确定裁剪位置(优先使用 `messages_removed` 精确回放,兼容旧日志回退到 + /// `preserved_recent_turns`) + /// 2. `split_off` 切分:前半段丢弃,后半段保留 + /// 3. 在头部插入 compact summary 消息作为上下文衔接 fn apply_compaction( &mut self, summary: &str, @@ -300,7 +332,10 @@ impl AgentStateProjector { } /// 从消息列表末尾向前扫描,找到第 N 个 User-origin 消息的位置。 -/// 用途:定义"保留最近 N 轮"的裁剪边界,User-origin 消息视为 turn 起点。 +/// +/// 用于定义"保留最近 N 轮"的裁剪边界: +/// 从末尾往前数第 `preserved_recent_turns` 个 User 消息即为保留起点。 +/// 如果不足 N 个 User 消息,返回第一个 User 消息的位置。 fn recent_turn_start_index( messages: &[LlmMessage], preserved_recent_turns: usize, @@ -327,8 +362,10 @@ fn recent_turn_start_index( last_index } -/// Pure function: project an event sequence into an AgentState. -/// No IO, no side effects. +/// 纯函数:将事件序列投影为 AgentState。 +/// +/// 无 IO、无副作用——相同输入总是产生相同输出。 +/// 适用于冷启动恢复、`/history` 回放和状态快照获取。 pub fn project(events: &[StorageEvent]) -> AgentState { AgentStateProjector::from_events(events).snapshot() } @@ -339,7 +376,8 @@ mod tests { use super::*; use crate::{ - AgentEventContext, CompactTrigger, StorageEvent, StorageEventPayload, SubRunStorageMode, + AgentEventContext, CompactAppliedMeta, CompactMode, CompactTrigger, StorageEvent, + StorageEventPayload, SubRunStorageMode, }; fn ts() -> chrono::DateTime<chrono::Utc> { @@ -358,7 +396,7 @@ mod tests { "subrun-1", None, SubRunStorageMode::IndependentSession, - Some(session_id.to_string()), + Some(session_id.into()), ) } @@ -460,6 +498,7 @@ mod tests { success: true, error: None, metadata: None, + child_ref: None, duration_ms, }, ) @@ -476,7 +515,14 @@ mod tests { agent, StorageEventPayload::ToolResultReferenceApplied { tool_call_id: tool_call_id.into(), - persisted_relative_path: "tool-results/sample.txt".to_string(), + persisted_output: crate::PersistedToolOutput { + storage_kind: "toolResult".to_string(), + absolute_path: "~/.astrcode/tool-results/sample.txt".to_string(), + relative_path: "tool-results/sample.txt".to_string(), + total_bytes: 120, + preview_text: "preview".to_string(), + preview_bytes: 7, + }, replacement: replacement.to_string(), original_bytes: 120, }, @@ -523,6 +569,14 @@ mod tests { StorageEventPayload::CompactApplied { trigger: CompactTrigger::Manual, summary: summary.into(), + meta: CompactAppliedMeta { + mode: CompactMode::Full, + instructions_present: false, + fallback_used: false, + retry_count: 0, + input_units: 3, + output_summary_chars: 15, + }, preserved_recent_turns, pre_tokens: 400, post_tokens_estimate: 120, @@ -885,8 +939,12 @@ mod tests { None, root_agent(), "tc1", - "<persisted-output>\nOutput too large (120 bytes). Full output saved to: \ - tool-results/sample.txt\n</persisted-output>", + "<persisted-output>\nLarge tool output was saved to a file instead of being \ + inlined.\nPath: ~/.astrcode/tool-results/sample.txt\nBytes: 120\nRead the file \ + with `readFile`.\nIf you only need a section, read a smaller chunk instead of \ + the whole file.\nStart from the first chunk when you do not yet know the right \ + section.\nSuggested first read: { path: \"~/.astrcode/tool-results/sample.txt\", \ + charOffset: 0, maxChars: 20000 }\n</persisted-output>", ), turn_done(None, root_agent(), "completed"), ]; @@ -895,7 +953,7 @@ mod tests { assert!(matches!( &state.messages[2], - LlmMessage::Tool { content, .. } if content.contains("tool-results/sample.txt") + LlmMessage::Tool { content, .. } if content.contains("~/.astrcode/tool-results/sample.txt") )); } diff --git a/crates/core/src/registry/router.rs b/crates/core/src/registry/router.rs index 074116c7..d59cdc6f 100644 --- a/crates/core/src/registry/router.rs +++ b/crates/core/src/registry/router.rs @@ -1,6 +1,6 @@ //! # 能力路由契约 //! -//! core 仅保留能力调用相关的契约与 DTO,具体路由实现下沉到 runtime-registry。 +//! core 仅保留能力调用相关的契约与 DTO,具体路由实现下沉到 adapter 层 use std::{fmt, path::PathBuf, sync::Arc}; @@ -10,8 +10,8 @@ use serde_json::Value; use tokio::sync::mpsc::UnboundedSender; use crate::{ - AgentEventContext, CancelToken, CapabilitySpec, ExecutionOwner, Result, SessionId, - ToolEventSink, ToolExecutionResult, ToolOutputDelta, + AgentEventContext, CancelToken, CapabilitySpec, ExecutionOwner, ExecutionResultCommon, ModeId, + Result, SessionId, ToolEventSink, ToolExecutionResult, ToolOutputDelta, }; /// 能力调用的上下文信息。 @@ -31,6 +31,8 @@ pub struct CapabilityContext { pub turn_id: Option<String>, /// 当前调用所属 Agent 元数据。 pub agent: AgentEventContext, + /// 当前调用开始时的治理 mode。 + pub current_mode_id: ModeId, /// 当前调用所属执行 owner。 pub execution_owner: Option<ExecutionOwner>, /// 当前使用的 profile 名称 @@ -55,6 +57,7 @@ impl fmt::Debug for CapabilityContext { .field("cancel", &self.cancel) .field("turn_id", &self.turn_id) .field("agent", &self.agent) + .field("current_mode_id", &self.current_mode_id) .field("execution_owner", &self.execution_owner) .field("profile", &self.profile) .field("profile_context", &self.profile_context) @@ -95,6 +98,24 @@ pub struct CapabilityExecutionResult { } impl CapabilityExecutionResult { + /// 用公共执行结果字段一次性构造能力结果,避免二段式覆盖。 + pub fn from_common( + capability_name: impl Into<String>, + success: bool, + output: Value, + common: ExecutionResultCommon, + ) -> Self { + Self { + capability_name: capability_name.into(), + success, + output, + error: common.error, + metadata: common.metadata, + duration_ms: common.duration_ms, + truncated: common.truncated, + } + } + /// 构造成功结果。 pub fn ok(capability_name: impl Into<String>, output: Value) -> Self { Self { @@ -136,10 +157,10 @@ impl CapabilityExecutionResult { } } - /// 转换为 LLM 工具执行结果。 + /// 将通用能力执行结果转换为 LLM 工具执行结果。 /// - /// 将通用的能力执行结果映射为 `ToolExecutionResult`, - /// 以便前端渲染工具调用卡片。 + /// 填充 tool_call_id 并将 JSON 输出序列化为可读文本, + /// 使结果能直接用于前端工具卡片渲染和 LLM 上下文回传。 pub fn into_tool_execution_result(self, tool_call_id: String) -> ToolExecutionResult { let output = self.output_text(); ToolExecutionResult { @@ -149,6 +170,16 @@ impl CapabilityExecutionResult { output, error: self.error, metadata: self.metadata, + child_ref: None, + duration_ms: self.duration_ms, + truncated: self.truncated, + } + } + + pub fn common(&self) -> ExecutionResultCommon { + ExecutionResultCommon { + error: self.error.clone(), + metadata: self.metadata.clone(), duration_ms: self.duration_ms, truncated: self.truncated, } @@ -172,3 +203,32 @@ pub trait CapabilityInvoker: Send + Sync { ctx: &CapabilityContext, ) -> Result<CapabilityExecutionResult>; } + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::CapabilityExecutionResult; + use crate::ExecutionResultCommon; + + #[test] + fn from_common_preserves_failure_fields_without_placeholder_override() { + let result = CapabilityExecutionResult::from_common( + "plugin.read", + false, + json!(null), + ExecutionResultCommon::failure( + "transport failed", + Some(json!({ "streamEvents": [] })), + 23, + false, + ), + ); + + assert!(!result.success); + assert_eq!(result.error.as_deref(), Some("transport failed")); + assert_eq!(result.metadata, Some(json!({ "streamEvents": [] }))); + assert_eq!(result.duration_ms, 23); + assert!(!result.truncated); + } +} diff --git a/crates/core/src/runtime/coordinator.rs b/crates/core/src/runtime/coordinator.rs index 78292a71..e3a6d6c3 100644 --- a/crates/core/src/runtime/coordinator.rs +++ b/crates/core/src/runtime/coordinator.rs @@ -14,7 +14,7 @@ use std::sync::{Arc, RwLock}; use crate::{ AstrError, CapabilitySpec, ManagedRuntimeComponent, PluginRegistry, Result, RuntimeHandle, - plugin::PluginEntry, + plugin::PluginEntry, support, }; /// 运行时协调器。 @@ -68,10 +68,11 @@ impl RuntimeCoordinator { self, managed_components: Vec<Arc<dyn ManagedRuntimeComponent>>, ) -> Self { - *self - .managed_components - .write() - .expect("runtime coordinator managed components lock poisoned") = managed_components; + support::with_write_lock_recovery( + &self.managed_components, + "runtime coordinator managed components", + |components| *components = managed_components, + ); self } @@ -87,26 +88,29 @@ impl RuntimeCoordinator { /// 获取当前可用能力描述符列表的副本。 pub fn capabilities(&self) -> Vec<CapabilitySpec> { - self.capabilities - .read() - .expect("runtime coordinator capabilities lock poisoned") - .iter() - .cloned() - .collect() + support::with_read_lock_recovery( + &self.capabilities, + "runtime coordinator capabilities", + |capabilities| capabilities.iter().cloned().collect(), + ) } pub fn managed_components(&self) -> Vec<Arc<dyn ManagedRuntimeComponent>> { - self.managed_components - .read() - .expect("runtime coordinator managed components lock poisoned") - .clone() + support::with_read_lock_recovery( + &self.managed_components, + "runtime coordinator managed components", + Clone::clone, + ) } - /// 原子替换运行时表面。 + /// 原子替换运行时表面(插件热重载核心方法)。 + /// + /// 一次性替换三样东西:插件注册表快照、能力描述符列表、托管组件列表。 + /// 返回旧的托管组件列表,调用方负责逐个关闭它们。 /// - /// 一次性更新插件注册表快照、能力列表和托管组件列表, - /// 用于插件热重载或运行时切换。返回旧的托管组件列表, - /// 调用方负责关闭这些旧组件。 + /// 为什么需要原子替换:如果逐项更新,中间状态会导致: + /// - 新插件已注册但旧能力描述符还在 → 路由找不到能力 + /// - 旧插件已清空但旧组件还在引用 → 悬垂引用 pub fn replace_runtime_surface( &self, plugin_entries: Vec<PluginEntry>, @@ -114,21 +118,23 @@ impl RuntimeCoordinator { managed_components: Vec<Arc<dyn ManagedRuntimeComponent>>, ) -> Vec<Arc<dyn ManagedRuntimeComponent>> { self.plugin_registry.replace_snapshot(plugin_entries); - *self - .capabilities - .write() - .expect("runtime coordinator capabilities lock poisoned") = Arc::from(capabilities); - let mut guard = self - .managed_components - .write() - .expect("runtime coordinator managed components lock poisoned"); - std::mem::replace(&mut *guard, managed_components) + support::with_write_lock_recovery( + &self.capabilities, + "runtime coordinator capabilities", + |current_capabilities| *current_capabilities = Arc::from(capabilities), + ); + support::with_write_lock_recovery( + &self.managed_components, + "runtime coordinator managed components", + |current_components| std::mem::replace(current_components, managed_components), + ) } /// 关闭运行时和所有托管组件。 /// - /// 按确定顺序执行:先关闭运行时句柄,再逐个关闭托管组件。 - /// 所有失败会被收集并合并为单个错误返回。 + /// 关闭顺序是确定性的:先关闭运行时句柄(停止接收新请求), + /// 再逐个关闭托管组件(释放资源)。所有失败会被收集并合并 + /// 为单个错误返回——即使某个组件关闭失败,仍会尝试关闭剩余组件。 pub async fn shutdown(&self, timeout_secs: u64) -> Result<()> { let mut failures = Vec::new(); @@ -148,11 +154,11 @@ impl RuntimeCoordinator { // Keep the shutdown order deterministic so tests and operational logs can explain // exactly which managed component was closed after the runtime stopped accepting work. - let managed_components = self - .managed_components - .read() - .expect("runtime coordinator managed components lock poisoned") - .clone(); + let managed_components = support::with_read_lock_recovery( + &self.managed_components, + "runtime coordinator managed components", + Clone::clone, + ); for component in managed_components { if let Err(error) = component.shutdown_component().await { diff --git a/crates/core/src/session_catalog.rs b/crates/core/src/session_catalog.rs new file mode 100644 index 00000000..33504578 --- /dev/null +++ b/crates/core/src/session_catalog.rs @@ -0,0 +1,25 @@ +//! # Session Catalog 事件 +//! +//! 定义会话目录变更通知事件,用于向前端和其他订阅者广播 +//! session 的创建、删除、分支等生命周期变化。 + +use serde::{Deserialize, Serialize}; + +/// Session catalog 变更事件,用于通知外部订阅者 session 列表变化。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "event", content = "data")] +pub enum SessionCatalogEvent { + SessionCreated { + session_id: String, + }, + SessionDeleted { + session_id: String, + }, + ProjectDeleted { + working_dir: String, + }, + SessionBranched { + session_id: String, + source_session_id: String, + }, +} diff --git a/crates/core/src/session_plan.rs b/crates/core/src/session_plan.rs new file mode 100644 index 00000000..acb22522 --- /dev/null +++ b/crates/core/src/session_plan.rs @@ -0,0 +1,80 @@ +//! session plan 领域模型。 +//! +//! application 与 adapter-tools 需要读写同一份 `state.json`,状态结构和内容摘要算法 +//! 必须保持单一真相,避免跨 crate 漂移。 + +use std::fmt; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SessionPlanStatus { + Draft, + AwaitingApproval, + Approved, + Completed, + Superseded, +} + +impl SessionPlanStatus { + pub fn as_str(&self) -> &'static str { + match self { + Self::Draft => "draft", + Self::AwaitingApproval => "awaiting_approval", + Self::Approved => "approved", + Self::Completed => "completed", + Self::Superseded => "superseded", + } + } +} + +impl fmt::Display for SessionPlanStatus { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionPlanState { + pub active_plan_slug: String, + pub title: String, + pub status: SessionPlanStatus, + pub created_at: DateTime<Utc>, + pub updated_at: DateTime<Utc>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reviewed_plan_digest: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub approved_at: Option<DateTime<Utc>>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub archived_plan_digest: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub archived_at: Option<DateTime<Utc>>, +} + +pub fn session_plan_content_digest(content: &str) -> String { + const FNV_OFFSET_BASIS: u64 = 0xcbf29ce484222325; + const FNV_PRIME: u64 = 0x100000001b3; + + let mut hash = FNV_OFFSET_BASIS; + for byte in content.as_bytes() { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(FNV_PRIME); + } + format!("fnv1a64:{hash:016x}") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn session_plan_digest_is_stable_and_versioned() { + assert_eq!( + session_plan_content_digest("plan body"), + "fnv1a64:58c14a8c5354d2ea" + ); + } +} diff --git a/crates/core/src/shell.rs b/crates/core/src/shell.rs index 5b300497..5e8ceef9 100644 --- a/crates/core/src/shell.rs +++ b/crates/core/src/shell.rs @@ -1,3 +1,21 @@ +//! # Shell 检测与解析 +//! +//! 自动检测当前平台的默认 Shell,并支持用户指定的 Shell 覆盖。 +//! +//! ## 支持的 Shell 类型 +//! +//! - **PowerShell**: `pwsh` / `powershell` +//! - **Cmd**: `cmd` +//! - **Posix**: `bash` / `zsh` / `sh` +//! - **Wsl**: Windows WSL bash +//! +//! ## 检测策略(Windows 优先级) +//! +//! 1. `$env:SHELL` 环境变量(支持 Git Bash / WSL 环境检测) +//! 2. Git Bash 磁盘路径探测 +//! 3. `wsl.exe` / `wsl` 命令探测 +//! 4. `pwsh` / `powershell` 兜底 + #[cfg(windows)] use std::path::PathBuf; use std::{env, path::Path, process::Command, sync::OnceLock}; diff --git a/crates/core/src/skill.rs b/crates/core/src/skill.rs new file mode 100644 index 00000000..e6207aa4 --- /dev/null +++ b/crates/core/src/skill.rs @@ -0,0 +1,112 @@ +//! Skill 领域模型与查询端口共享的稳定类型。 + +use serde::{Deserialize, Serialize}; + +/// Skill 的来源。 +/// +/// 用于追踪 skill 是从哪里加载的,影响覆盖优先级和诊断标签。 +/// 优先级顺序:Builtin < Mcp < Plugin < User < Project(后者覆盖前者)。 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum SkillSource { + #[default] + Builtin, + User, + Project, + Plugin, + Mcp, +} + +impl SkillSource { + pub fn as_tag(&self) -> &'static str { + match self { + Self::Builtin => "source:builtin", + Self::User => "source:user", + Self::Project => "source:project", + Self::Plugin => "source:plugin", + Self::Mcp => "source:mcp", + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SkillSpec { + pub id: String, + pub name: String, + pub description: String, + pub guide: String, + pub skill_root: Option<String>, + pub asset_files: Vec<String>, + pub allowed_tools: Vec<String>, + pub source: SkillSource, +} + +impl SkillSpec { + pub fn matches_requested_name(&self, requested_name: &str) -> bool { + let requested_name = normalize_skill_name(requested_name); + requested_name == normalize_skill_name(&self.id) + } +} + +pub fn is_valid_skill_name(name: &str) -> bool { + !name.is_empty() + && name + .chars() + .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-') +} + +pub fn normalize_skill_name(value: &str) -> String { + value + .trim() + .trim_start_matches('/') + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || (!ch.is_ascii() && ch.is_alphanumeric()) { + ch.to_ascii_lowercase() + } else { + ' ' + } + }) + .collect::<String>() + .split_whitespace() + .collect::<Vec<_>>() + .join(" ") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn project_skill(id: &str, name: &str, description: &str) -> SkillSpec { + SkillSpec { + id: id.to_string(), + name: name.to_string(), + description: description.to_string(), + guide: "guide".to_string(), + skill_root: None, + asset_files: Vec::new(), + allowed_tools: Vec::new(), + source: SkillSource::Project, + } + } + + #[test] + fn skill_name_matching_is_case_insensitive_and_slash_tolerant() { + let skill = project_skill("repo-search", "repo-search", "Search the repo"); + + assert!(skill.matches_requested_name("repo-search")); + assert!(skill.matches_requested_name("/repo-search")); + assert!(skill.matches_requested_name("REPO SEARCH")); + assert!(!skill.matches_requested_name("edit-file")); + } + + #[test] + fn validates_claude_style_skill_names() { + assert!(is_valid_skill_name("git-commit")); + assert!(is_valid_skill_name("pdf2")); + assert!(!is_valid_skill_name("Git-Commit")); + assert!(!is_valid_skill_name("git_commit")); + assert!(!is_valid_skill_name("")); + } +} diff --git a/crates/core/src/store.rs b/crates/core/src/store.rs index 76656c56..f29669c1 100644 --- a/crates/core/src/store.rs +++ b/crates/core/src/store.rs @@ -76,16 +76,21 @@ pub trait EventLogWriter: Send + Sync { fn append(&mut self, event: &StorageEvent) -> StoreResult<StoredEvent>; } -/// 跨进程 session turn 执行租约。 +/// 跨进程 session turn 执行租约(RAII 语义)。 /// -/// 该 trait 故意不暴露行为方法,只依赖 RAII 语义:当租约对象被 drop 时, -/// 底层实现必须释放对应的跨进程 session 锁。这样 runtime 无需了解 -/// 文件锁、命名锁等具体机制,只需要持有租约直到 turn 结束。 +/// 该 trait 故意不暴露行为方法,完全依赖 RAII 语义: +/// 当租约对象被 drop 时,底层实现必须释放对应的跨进程 session 锁。 +/// 这样 runtime 无需了解文件锁、命名锁等具体机制, +/// 只需要持有租约直到 turn 结束即可保证互斥。 pub trait SessionTurnLease: Send + Sync {} /// 另一个执行者已经持有该 session 的 turn 执行权。 /// -/// `turn_id` 是 branch 逻辑的关键输入:后发请求需要从「最后一个稳定完成的 turn」 +/// 这是 **正常并发竞争**(不是错误):当多个进程或 tab 同时向同一 session +/// 发送 prompt 时,只有第一个获得锁,后续请求拿到 `Busy` 后 +/// 可以选择自动分叉新 session 继续执行。 +/// +/// `turn_id` 是分支逻辑的关键输入:后发请求需要从「最后一个稳定完成的 turn」 /// 分叉,所以必须知道当前正在进行的是哪个 turn,才能在复制历史时排除其事件。 #[derive(Debug, Clone, PartialEq, Eq)] pub struct SessionTurnBusy { @@ -94,6 +99,7 @@ pub struct SessionTurnBusy { pub acquired_at: DateTime<Utc>, } +/// Turn 获取结果:要么成功拿到锁(Acquired),要么被另一个执行者占着(Busy)。 pub enum SessionTurnAcquireResult { Acquired(Box<dyn SessionTurnLease>), Busy(SessionTurnBusy), @@ -119,6 +125,7 @@ pub trait SessionManager: Send + Sync { /// 尝试获取某个 session 的 turn 执行权。 /// /// 获取失败不算错误,而是返回 `Busy`,让调用方可以选择自动分叉新 session。 + /// 这是实现多 tab 并发 prompt 的核心机制。 fn try_acquire_turn( &self, session_id: &str, diff --git a/crates/core/src/test_support.rs b/crates/core/src/test_support.rs index edd85b7b..7090ab0b 100644 --- a/crates/core/src/test_support.rs +++ b/crates/core/src/test_support.rs @@ -35,7 +35,8 @@ use tempfile::TempDir; pub use crate::env::ASTRCODE_TEST_HOME_ENV as TEST_HOME_ENV; use crate::{ - AgentLifecycleStatus, ChildSessionLineageKind, ChildSessionNode, ChildSessionStatusSource, + AgentLifecycleStatus, ChildExecutionIdentity, ChildSessionLineageKind, ChildSessionNode, + ChildSessionStatusSource, ParentExecutionRef, }; /// 全局环境变量互斥锁 @@ -63,18 +64,22 @@ pub fn child_session_node_fixture(seed: &str) -> ChildSessionNode { seed }; ChildSessionNode { - agent_id: format!("agent-{normalized}"), - session_id: format!("session-parent-{normalized}"), - child_session_id: format!("session-child-{normalized}"), - sub_run_id: format!("subrun-{normalized}"), - parent_session_id: format!("session-parent-{normalized}"), - parent_agent_id: Some(format!("agent-parent-{normalized}")), - parent_sub_run_id: Some(format!("subrun-parent-{normalized}")), - parent_turn_id: format!("turn-parent-{normalized}"), + identity: ChildExecutionIdentity { + agent_id: format!("agent-{normalized}").into(), + session_id: format!("session-parent-{normalized}").into(), + sub_run_id: format!("subrun-{normalized}").into(), + }, + child_session_id: format!("session-child-{normalized}").into(), + parent_session_id: format!("session-parent-{normalized}").into(), + parent: ParentExecutionRef { + parent_agent_id: Some(format!("agent-parent-{normalized}").into()), + parent_sub_run_id: Some(format!("subrun-parent-{normalized}").into()), + }, + parent_turn_id: format!("turn-parent-{normalized}").into(), lineage_kind: ChildSessionLineageKind::Spawn, status: AgentLifecycleStatus::Running, status_source: ChildSessionStatusSource::Durable, - created_by_tool_call_id: Some(format!("tool-call-{normalized}")), + created_by_tool_call_id: Some(format!("tool-call-{normalized}").into()), lineage_snapshot: None, } } diff --git a/crates/core/src/tool.rs b/crates/core/src/tool.rs index 1da7bf6c..bd41bb9f 100644 --- a/crates/core/src/tool.rs +++ b/crates/core/src/tool.rs @@ -17,9 +17,9 @@ use tokio::sync::mpsc::UnboundedSender; use crate::{ AgentEventContext, CancelToken, CapabilityKind, CapabilitySpec, CapabilitySpecBuildError, - InvocationKind, InvocationMode, PermissionSpec, Result, SessionId, SideEffect, Stability, - StorageEvent, ToolDefinition, ToolExecutionResult, ToolOutputDelta, ToolOutputStream, - tool_result_persist::DEFAULT_TOOL_RESULT_INLINE_LIMIT, + InvocationKind, InvocationMode, ModeId, PermissionSpec, Result, SessionId, SideEffect, + Stability, StorageEvent, ToolDefinition, ToolExecutionResult, ToolOutputDelta, + ToolOutputStream, TurnId, tool_result_persist::DEFAULT_TOOL_RESULT_INLINE_LIMIT, }; /// 工具执行的默认最大输出大小(1 MB) @@ -38,7 +38,7 @@ pub struct ExecutionOwner { /// 根执行所在的 session。 pub root_session_id: SessionId, /// 根执行所在的 turn。 - pub root_turn_id: String, + pub root_turn_id: TurnId, /// 当前工具调用若属于子执行域,则记录 sub-run id。 #[serde(default, skip_serializing_if = "Option::is_none")] pub sub_run_id: Option<String>, @@ -50,7 +50,7 @@ impl ExecutionOwner { /// 为顶层执行构造 owner。 pub fn root( root_session_id: impl Into<SessionId>, - root_turn_id: impl Into<String>, + root_turn_id: impl Into<TurnId>, invocation_kind: InvocationKind, ) -> Self { Self { @@ -109,6 +109,8 @@ pub struct ToolContext { /// /// 使用 Arc 避免 ToolContext 在高频 clone 时反复复制整块 AgentEventContext。 agent: Arc<AgentEventContext>, + /// 工具执行开始时当前会话的治理 mode。 + current_mode_id: ModeId, /// Maximum output size in bytes. Defaults to 1MB. max_output_size: usize, /// Optional override for session-scoped persisted tool artifacts. @@ -150,6 +152,7 @@ impl ToolContext { turn_id: None, tool_call_id: None, agent: Arc::new(AgentEventContext::default()), + current_mode_id: ModeId::default(), max_output_size: DEFAULT_MAX_OUTPUT_SIZE, session_storage_root: None, tool_output_sender: None, @@ -201,6 +204,12 @@ impl ToolContext { self } + /// 为工具上下文注入当前治理 mode。 + pub fn with_current_mode_id(mut self, current_mode_id: ModeId) -> Self { + self.current_mode_id = current_mode_id; + self + } + /// 为工具上下文注入 turn 事件发射器。 pub fn with_event_sink(mut self, event_sink: Arc<dyn ToolEventSink>) -> Self { self.event_sink = Some(event_sink); @@ -251,6 +260,11 @@ impl ToolContext { self.agent.as_ref() } + /// 返回工具执行开始时当前会话的治理 mode。 + pub fn current_mode_id(&self) -> &ModeId { + &self.current_mode_id + } + /// Returns the maximum output size in bytes. pub fn max_output_size(&self) -> usize { self.max_output_size @@ -326,6 +340,7 @@ impl Clone for ToolContext { turn_id: self.turn_id.clone(), tool_call_id: self.tool_call_id.clone(), agent: self.agent.clone(), + current_mode_id: self.current_mode_id.clone(), max_output_size: self.max_output_size, session_storage_root: self.session_storage_root.clone(), tool_output_sender: self.tool_output_sender.clone(), @@ -344,6 +359,7 @@ impl fmt::Debug for ToolContext { .field("cancel", &self.cancel) .field("turn_id", &self.turn_id) .field("agent", self.agent.as_ref()) + .field("current_mode_id", &self.current_mode_id) .field("max_output_size", &self.max_output_size) .field("session_storage_root", &self.session_storage_root) .field( diff --git a/crates/core/src/tool_result_persist.rs b/crates/core/src/tool_result_persist.rs index e8e9f4eb..ab71c891 100644 --- a/crates/core/src/tool_result_persist.rs +++ b/crates/core/src/tool_result_persist.rs @@ -13,7 +13,9 @@ //! 磁盘写入失败时降级为截断预览,不 panic、不返回错误。 //! 这保证了即使文件系统不可用,工具结果仍然能以截断形式传递给 LLM。 -use std::path::Path; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; /// 工具结果存盘目录名(相对于 session 目录)。 pub const TOOL_RESULTS_DIR: &str = "tool-results"; @@ -27,14 +29,37 @@ pub const TOOL_RESULT_PREVIEW_LIMIT: usize = 2 * 1024; /// 覆盖为 per-tool 值。 pub const DEFAULT_TOOL_RESULT_INLINE_LIMIT: usize = 32 * 1024; +/// 已持久化工具结果的结构化元数据。 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct PersistedToolOutput { + pub storage_kind: String, + pub absolute_path: String, + pub relative_path: String, + pub total_bytes: u64, + pub preview_text: String, + pub preview_bytes: u64, +} + +/// 工具结果经落盘决策后的统一返回值。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PersistedToolResult { + pub output: String, + pub persisted: Option<PersistedToolOutput>, +} + /// 无条件将工具结果持久化到磁盘。 /// /// 不管内容大小,一律写入 `session_dir/tool-results/<id>.txt`, -/// 返回 `<persisted-output>` 格式的引用 + 预览。 +/// 返回 `<persisted-output>` 格式的短引用。 /// 写入失败时降级为截断预览。 /// /// 供管线聚合预算层调用:当聚合预算超限时,选中的结果不管多大都需要落盘。 -pub fn persist_tool_result(session_dir: &Path, tool_call_id: &str, content: &str) -> String { +pub fn persist_tool_result( + session_dir: &Path, + tool_call_id: &str, + content: &str, +) -> PersistedToolResult { write_to_disk(session_dir, tool_call_id, content) } @@ -48,9 +73,12 @@ pub fn maybe_persist_tool_result( tool_call_id: &str, content: &str, inline_limit: usize, -) -> String { +) -> PersistedToolResult { if content.len() <= inline_limit { - return content.to_string(); + return PersistedToolResult { + output: content.to_string(), + persisted: None, + }; } write_to_disk(session_dir, tool_call_id, content) } @@ -60,6 +88,14 @@ pub fn is_persisted_output(content: &str) -> bool { content.contains("<persisted-output>") } +/// 从 persisted wrapper 文本中提取绝对路径。 +pub fn persisted_output_absolute_path(content: &str) -> Option<String> { + content.lines().find_map(|line| { + line.split_once("Path: ") + .map(|(_, path)| path.trim().to_string()) + }) +} + /// 解析工具结果内联阈值,支持环境变量覆盖。 /// /// 优先级(从高到低): @@ -137,11 +173,14 @@ fn camel_to_screaming_snake(s: &str) -> String { /// 实际写磁盘操作。 /// -/// 包含完整的降级链路: -/// 1. `create_dir_all` 失败 → 截断预览 -/// 2. `fs::write` 失败 → 截断预览 -/// 3. 成功 → 生成 `<persisted-output>` 引用 + 预览 -fn write_to_disk(session_dir: &Path, tool_call_id: &str, content: &str) -> String { +/// 包含完整的降级链路——任何一步失败都不会 panic: +/// 1. `create_dir_all` 失败 → 降级为截断预览 +/// 2. `fs::write` 失败 → 降级为截断预览 +/// 3. 成功 → 生成 `<persisted-output>` 短引用 + 结构化 persisted metadata +/// +/// 工具调用 ID 会被清洗(只保留字母数字和 `-_`,取前 64 字符), +/// 防止路径穿越攻击(如 `../../etc/passwd`)。 +fn write_to_disk(session_dir: &Path, tool_call_id: &str, content: &str) -> PersistedToolResult { let content_bytes = content.len(); let results_dir = session_dir.join(TOOL_RESULTS_DIR); @@ -150,7 +189,10 @@ fn write_to_disk(session_dir: &Path, tool_call_id: &str, content: &str) -> Strin "tool-result: failed to create dir '{}', falling back to truncation", results_dir.display() ); - return truncate_with_notice(content); + return PersistedToolResult { + output: truncate_with_notice(content), + persisted: None, + }; } let safe_id: String = tool_call_id @@ -165,7 +207,10 @@ fn write_to_disk(session_dir: &Path, tool_call_id: &str, content: &str) -> Strin "tool-result: failed to write '{}', falling back to truncation", path.display() ); - return truncate_with_notice(content); + return PersistedToolResult { + output: truncate_with_notice(content), + persisted: None, + }; } let relative_path = path @@ -173,21 +218,63 @@ fn write_to_disk(session_dir: &Path, tool_call_id: &str, content: &str) -> Strin .unwrap_or(&path) .to_string_lossy() .replace('\\', "/"); + let persisted = PersistedToolOutput { + storage_kind: "toolResult".to_string(), + absolute_path: normalize_absolute_path(&path), + relative_path, + total_bytes: content_bytes as u64, + preview_text: build_preview_text(content), + preview_bytes: TOOL_RESULT_PREVIEW_LIMIT.min(content.len()) as u64, + }; + + PersistedToolResult { + output: format_persisted_output(&persisted), + persisted: Some(persisted), + } +} - format_persisted_output(&relative_path, content_bytes, content) +/// 生成 `<persisted-output>` 格式的短引用文本。 +/// +/// 该文本会替换原始工具结果进入消息历史,LLM 看到的是这段引用 +/// 而非完整内容。引用中包含路径、大小和建议的首次读取参数, +/// 引导 LLM 使用 readFile 按需读取。 +fn format_persisted_output(persisted: &PersistedToolOutput) -> String { + format!( + "<persisted-output>\nLarge tool output was saved to a file instead of being \ + inlined.\nPath: {}\nBytes: {}\nRead the file with `readFile`.\nIf you only need a \ + section, read a smaller chunk instead of the whole file.\nStart from the first chunk \ + when you do not yet know the right section.\nSuggested first read: {{ path: {:?}, \ + charOffset: 0, maxChars: 20000 }}\n</persisted-output>", + persisted.absolute_path, persisted.total_bytes, persisted.absolute_path + ) } -/// 生成 `<persisted-output>` 格式的引用 + 预览。 -fn format_persisted_output(relative_path: &str, total_bytes: usize, content: &str) -> String { +fn build_preview_text(content: &str) -> String { let preview_limit = TOOL_RESULT_PREVIEW_LIMIT.min(content.len()); let truncated_at = content.floor_char_boundary(preview_limit); - let preview = &content[..truncated_at]; + content[..truncated_at].to_string() +} - format!( - "<persisted-output>\nOutput too large ({total_bytes} bytes). Full output saved to: \ - {relative_path}\n\nPreview (first {preview_limit} bytes):\n{preview}\n...\nUse \ - `readFile` with the persisted path to view the full content.\n</persisted-output>" - ) +fn normalize_absolute_path(path: &Path) -> String { + normalize_verbatim_path(path.to_path_buf()) + .to_string_lossy() + .to_string() +} + +fn normalize_verbatim_path(path: PathBuf) -> PathBuf { + #[cfg(windows)] + { + if let Some(rendered) = path.to_str() { + if let Some(stripped) = rendered.strip_prefix(r"\\?\UNC\") { + return PathBuf::from(format!(r"\\{}", stripped)); + } + if let Some(stripped) = rendered.strip_prefix(r"\\?\") { + return PathBuf::from(stripped); + } + } + } + + path } /// 截断内容并附加通知。 @@ -213,13 +300,22 @@ mod tests { let content = "x".repeat(100); let result = persist_tool_result(dir.path(), "call-abc123", &content); - assert!(result.contains("<persisted-output>")); - assert!(result.contains("tool-results/call-abc123.txt")); - assert!(result.contains("100 bytes")); + assert!(result.output.contains("<persisted-output>")); + assert!(result.output.contains("Large tool output was saved")); + let persisted = result.persisted.expect("persisted metadata should exist"); + assert!(result.output.contains(&persisted.absolute_path)); + assert!(result.output.contains("Bytes: 100")); + assert_eq!(persisted.relative_path, "tool-results/call-abc123.txt"); + assert_eq!(persisted.total_bytes, 100); + assert_eq!(persisted.preview_text, content); + assert_eq!(persisted.preview_bytes, 100); let file_path = dir.path().join("tool-results/call-abc123.txt"); assert!(file_path.exists()); - assert_eq!(fs::read_to_string(&file_path).unwrap(), content); + assert_eq!( + fs::read_to_string(&file_path).expect("persisted file should be readable"), + content + ); } #[test] @@ -228,7 +324,8 @@ mod tests { let content = "small".to_string(); let result = maybe_persist_tool_result(dir.path(), "call-1", &content, 1024); - assert_eq!(result, "small"); + assert_eq!(result.output, "small"); + assert!(result.persisted.is_none()); assert!(!dir.path().join("tool-results/call-1.txt").exists()); } @@ -238,7 +335,8 @@ mod tests { let content = "x".repeat(100); let result = maybe_persist_tool_result(dir.path(), "call-1", &content, 50); - assert!(result.contains("<persisted-output>")); + assert!(result.output.contains("<persisted-output>")); + assert!(result.persisted.is_some()); assert!(dir.path().join("tool-results/call-1.txt").exists()); } @@ -256,7 +354,10 @@ mod tests { let content = "x".repeat(100); let result = persist_tool_result(Path::new("/nonexistent/path"), "call-1", &content); // 降级为截断预览或成功写入(取决于平台) - assert!(result.contains("[output truncated") || result.contains("<persisted-output>")); + assert!( + result.output.contains("[output truncated") + || result.output.contains("<persisted-output>") + ); } #[test] @@ -272,6 +373,17 @@ mod tests { assert!(file.exists()); } + #[test] + fn persisted_output_absolute_path_extracts_new_wrapper_path() { + let wrapper = "<persisted-output>\nLarge tool output was saved to a file instead of being \ + inlined.\nPath: ~/.astrcode/tool-results/call-1.txt\nBytes: \ + 42\n</persisted-output>"; + assert_eq!( + persisted_output_absolute_path(wrapper), + Some("~/.astrcode/tool-results/call-1.txt".to_string()) + ); + } + #[test] fn camel_to_screaming_snake_converts_correctly() { assert_eq!(camel_to_screaming_snake("readFile"), "READ_FILE"); diff --git a/crates/debug-workbench/Cargo.toml b/crates/debug-workbench/Cargo.toml deleted file mode 100644 index 59e06372..00000000 --- a/crates/debug-workbench/Cargo.toml +++ /dev/null @@ -1,16 +0,0 @@ -[package] -name = "astrcode-debug-workbench" -version = "0.1.0" -edition.workspace = true -license.workspace = true -authors.workspace = true - -[dependencies] -astrcode-application = { path = "../application" } -astrcode-core = { path = "../core" } -chrono.workspace = true -serde.workspace = true - -[dev-dependencies] -tempfile.workspace = true -tokio.workspace = true diff --git a/crates/debug-workbench/src/lib.rs b/crates/debug-workbench/src/lib.rs deleted file mode 100644 index 05f49320..00000000 --- a/crates/debug-workbench/src/lib.rs +++ /dev/null @@ -1,19 +0,0 @@ -//! Debug Workbench 后端读模型。 -//! -//! 该 crate 只承载 debug 查询与聚合逻辑: -//! - runtime overview -//! - 最近时间窗口趋势 -//! - session trace -//! - session agent tree -//! -//! HTTP、Tauri 和前端都不在这里实现,server 仍然是唯一组合根。 - -mod models; -mod service; - -pub use models::{ - DebugAgentNodeKind, RuntimeDebugOverview, RuntimeDebugTimeline, RuntimeDebugTimelineSample, - SessionDebugAgentNode, SessionDebugAgents, SessionDebugTrace, SessionDebugTraceItem, - SessionDebugTraceItemKind, -}; -pub use service::DebugWorkbenchService; diff --git a/crates/debug-workbench/src/models.rs b/crates/debug-workbench/src/models.rs deleted file mode 100644 index e6145013..00000000 --- a/crates/debug-workbench/src/models.rs +++ /dev/null @@ -1,104 +0,0 @@ -use astrcode_application::RuntimeObservabilitySnapshot; -use astrcode_core::{ - AgentLifecycleStatus, AgentTurnOutcome, ChildSessionLineageKind, ChildSessionStatusSource, - Phase, -}; -use chrono::{DateTime, Utc}; - -#[derive(Debug, Clone)] -pub struct RuntimeDebugOverview { - pub collected_at: DateTime<Utc>, - pub metrics: RuntimeObservabilitySnapshot, - pub spawn_rejection_ratio_bps: Option<u64>, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct RuntimeDebugTimelineSample { - pub collected_at: DateTime<Utc>, - pub spawn_rejection_ratio_bps: Option<u64>, - pub observe_to_action_ratio_bps: Option<u64>, - pub child_reuse_ratio_bps: Option<u64>, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct RuntimeDebugTimeline { - pub window_started_at: DateTime<Utc>, - pub window_ended_at: DateTime<Utc>, - pub samples: Vec<RuntimeDebugTimelineSample>, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SessionDebugTraceItemKind { - ToolCall, - ToolResult, - PromptMetrics, - SubRunStarted, - SubRunFinished, - ChildNotification, - CollaborationFact, - MailboxQueued, - MailboxBatchStarted, - MailboxBatchAcked, - MailboxDiscarded, - TurnDone, - Error, -} - -#[derive(Debug, Clone)] -pub struct SessionDebugTraceItem { - pub id: String, - pub storage_seq: u64, - pub turn_id: Option<String>, - pub recorded_at: Option<DateTime<Utc>>, - pub kind: SessionDebugTraceItemKind, - pub title: String, - pub summary: String, - pub agent_id: Option<String>, - pub sub_run_id: Option<String>, - pub child_agent_id: Option<String>, - pub delivery_id: Option<String>, - pub tool_call_id: Option<String>, - pub tool_name: Option<String>, - pub lifecycle: Option<AgentLifecycleStatus>, - pub last_turn_outcome: Option<AgentTurnOutcome>, -} - -#[derive(Debug, Clone)] -pub struct SessionDebugTrace { - pub session_id: String, - pub title: String, - pub phase: Phase, - pub parent_session_id: Option<String>, - pub items: Vec<SessionDebugTraceItem>, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum DebugAgentNodeKind { - SessionRoot, - ChildAgent, -} - -#[derive(Debug, Clone)] -pub struct SessionDebugAgentNode { - pub node_id: String, - pub kind: DebugAgentNodeKind, - pub title: String, - pub agent_id: String, - pub session_id: String, - pub child_session_id: Option<String>, - pub sub_run_id: Option<String>, - pub parent_agent_id: Option<String>, - pub parent_session_id: Option<String>, - pub depth: usize, - pub lifecycle: AgentLifecycleStatus, - pub last_turn_outcome: Option<AgentTurnOutcome>, - pub status_source: Option<ChildSessionStatusSource>, - pub lineage_kind: Option<ChildSessionLineageKind>, -} - -#[derive(Debug, Clone)] -pub struct SessionDebugAgents { - pub session_id: String, - pub title: String, - pub nodes: Vec<SessionDebugAgentNode>, -} diff --git a/crates/debug-workbench/src/service.rs b/crates/debug-workbench/src/service.rs deleted file mode 100644 index c5d1221c..00000000 --- a/crates/debug-workbench/src/service.rs +++ /dev/null @@ -1,619 +0,0 @@ -use std::{ - cmp::Reverse, - collections::{HashMap, VecDeque}, - sync::{Arc, Mutex}, - time::Duration, -}; - -use astrcode_application::{App, AppGovernance, ApplicationError}; -use astrcode_core::{ - AgentLifecycleStatus, ChildSessionNode, Phase, StorageEventPayload, StoredEvent, -}; -use chrono::{DateTime, Utc}; - -use crate::models::{ - DebugAgentNodeKind, RuntimeDebugOverview, RuntimeDebugTimeline, RuntimeDebugTimelineSample, - SessionDebugAgentNode, SessionDebugAgents, SessionDebugTrace, SessionDebugTraceItem, - SessionDebugTraceItemKind, -}; - -const DEFAULT_TIMELINE_WINDOW: Duration = Duration::from_secs(5 * 60); - -#[derive(Debug, Default)] -struct TimelineStore { - samples: VecDeque<RuntimeDebugTimelineSample>, -} - -impl TimelineStore { - fn record( - &mut self, - collected_at: DateTime<Utc>, - spawn_rejection_ratio_bps: Option<u64>, - observe_to_action_ratio_bps: Option<u64>, - child_reuse_ratio_bps: Option<u64>, - window: Duration, - ) { - self.samples.push_back(RuntimeDebugTimelineSample { - collected_at, - spawn_rejection_ratio_bps, - observe_to_action_ratio_bps, - child_reuse_ratio_bps, - }); - self.trim(collected_at, window); - } - - fn snapshot( - &mut self, - now: DateTime<Utc>, - window: Duration, - ) -> Vec<RuntimeDebugTimelineSample> { - self.trim(now, window); - self.samples.iter().cloned().collect() - } - - fn trim(&mut self, now: DateTime<Utc>, window: Duration) { - let Ok(window_delta) = chrono::Duration::from_std(window) else { - return; - }; - let cutoff = now - window_delta; - while self - .samples - .front() - .is_some_and(|sample| sample.collected_at < cutoff) - { - self.samples.pop_front(); - } - } -} - -pub struct DebugWorkbenchService { - app: Arc<App>, - governance: Arc<AppGovernance>, - timeline: Mutex<TimelineStore>, - timeline_window: Duration, -} - -impl DebugWorkbenchService { - pub fn new(app: Arc<App>, governance: Arc<AppGovernance>) -> Self { - Self { - app, - governance, - timeline: Mutex::new(TimelineStore::default()), - timeline_window: DEFAULT_TIMELINE_WINDOW, - } - } - - pub fn runtime_overview(&self) -> RuntimeDebugOverview { - let collected_at = Utc::now(); - let metrics = self.governance.observability_snapshot(); - let spawn_rejection_ratio_bps = derive_spawn_rejection_ratio_bps(&metrics); - self.timeline - .lock() - .expect("debug workbench timeline mutex") - .record( - collected_at, - spawn_rejection_ratio_bps, - metrics.agent_collaboration.observe_to_action_ratio_bps, - metrics.agent_collaboration.child_reuse_ratio_bps, - self.timeline_window, - ); - RuntimeDebugOverview { - collected_at, - metrics, - spawn_rejection_ratio_bps, - } - } - - pub fn runtime_timeline(&self) -> RuntimeDebugTimeline { - let overview = self.runtime_overview(); - let mut timeline = self - .timeline - .lock() - .expect("debug workbench timeline mutex"); - let samples = timeline.snapshot(overview.collected_at, self.timeline_window); - let window_started_at = samples - .first() - .map(|sample| sample.collected_at) - .unwrap_or(overview.collected_at); - RuntimeDebugTimeline { - window_started_at, - window_ended_at: overview.collected_at, - samples, - } - } - - pub async fn session_trace( - &self, - session_id: &str, - ) -> Result<SessionDebugTrace, ApplicationError> { - let meta = find_session_meta(&self.app, session_id).await?; - let stored_events = self.app.session_stored_events(session_id).await?; - let phase = self - .app - .session_control_state(session_id) - .await - .map(|control| control.phase) - .unwrap_or(meta.phase); - - let mut items = stored_events - .iter() - .filter_map(build_trace_item) - .collect::<Vec<_>>(); - items.sort_by_key(|item| Reverse(item.storage_seq)); - - Ok(SessionDebugTrace { - session_id: meta.session_id, - title: meta.title, - phase, - parent_session_id: meta.parent_session_id, - items, - }) - } - - pub async fn session_agents( - &self, - session_id: &str, - ) -> Result<SessionDebugAgents, ApplicationError> { - let meta = find_session_meta(&self.app, session_id).await?; - let root_status = self.app.get_root_agent_status(session_id).await?; - let child_nodes = self.app.session_child_nodes(session_id).await?; - let child_depths = compute_child_depths(&child_nodes); - let mut nodes = Vec::new(); - - let (root_agent_id, root_lifecycle, root_outcome) = root_status - .map(|status| (status.agent_id, status.lifecycle, status.last_turn_outcome)) - .unwrap_or_else(|| { - ( - format!("session-root:{}", meta.session_id), - phase_to_lifecycle(meta.phase), - None, - ) - }); - - nodes.push(SessionDebugAgentNode { - node_id: format!("root:{}", meta.session_id), - kind: DebugAgentNodeKind::SessionRoot, - title: meta.title.clone(), - agent_id: root_agent_id, - session_id: meta.session_id.clone(), - child_session_id: Some(meta.session_id.clone()), - sub_run_id: None, - parent_agent_id: None, - parent_session_id: meta.parent_session_id.clone(), - depth: 0, - lifecycle: root_lifecycle, - last_turn_outcome: root_outcome, - status_source: None, - lineage_kind: None, - }); - - for node in child_nodes { - let depth = child_depths.get(&node.agent_id).copied().unwrap_or(1); - nodes.push(map_child_session_node(node, depth)); - } - - nodes.sort_by(|left, right| { - left.depth - .cmp(&right.depth) - .then_with(|| left.title.cmp(&right.title)) - }); - - Ok(SessionDebugAgents { - session_id: meta.session_id, - title: meta.title, - nodes, - }) - } -} - -async fn find_session_meta( - app: &Arc<App>, - session_id: &str, -) -> Result<astrcode_core::SessionMeta, ApplicationError> { - let target_session_id = session_id.trim(); - app.list_sessions() - .await? - .into_iter() - .find(|meta| meta.session_id == target_session_id) - .ok_or_else(|| { - ApplicationError::NotFound(format!("session '{}' not found", target_session_id)) - }) -} - -fn compute_child_depths(nodes: &[ChildSessionNode]) -> HashMap<String, usize> { - let subrun_to_agent = nodes - .iter() - .map(|node| (node.sub_run_id.clone(), node.agent_id.clone())) - .collect::<HashMap<_, _>>(); - let parent_agent_ids = nodes - .iter() - .filter_map(|node| { - let parent_sub_run_id = node.parent_sub_run_id.as_ref()?; - let parent_agent_id = subrun_to_agent.get(parent_sub_run_id)?; - Some((node.agent_id.clone(), parent_agent_id.clone())) - }) - .collect::<HashMap<_, _>>(); - let mut depths = HashMap::new(); - for node in nodes { - let mut depth = 1usize; - let mut cursor = node.agent_id.clone(); - while let Some(parent_agent_id) = parent_agent_ids.get(&cursor) { - depth += 1; - cursor = parent_agent_id.clone(); - } - depths.insert(node.agent_id.clone(), depth); - } - depths -} - -fn map_child_session_node(node: ChildSessionNode, depth: usize) -> SessionDebugAgentNode { - SessionDebugAgentNode { - node_id: format!("child:{}", node.sub_run_id), - kind: DebugAgentNodeKind::ChildAgent, - title: format!("{} · {}", node.sub_run_id, node.child_session_id), - agent_id: node.agent_id, - session_id: node.session_id, - child_session_id: Some(node.child_session_id), - sub_run_id: Some(node.sub_run_id), - parent_agent_id: node.parent_agent_id, - parent_session_id: Some(node.parent_session_id), - depth, - lifecycle: node.status, - last_turn_outcome: None, - status_source: Some(node.status_source), - lineage_kind: Some(node.lineage_kind), - } -} - -fn phase_to_lifecycle(phase: Phase) -> AgentLifecycleStatus { - match phase { - Phase::Idle | Phase::Done => AgentLifecycleStatus::Idle, - Phase::Interrupted => AgentLifecycleStatus::Terminated, - Phase::Thinking | Phase::CallingTool | Phase::Streaming => AgentLifecycleStatus::Running, - } -} - -fn derive_spawn_rejection_ratio_bps( - metrics: &astrcode_application::RuntimeObservabilitySnapshot, -) -> Option<u64> { - let denominator = - metrics.agent_collaboration.spawn_accepted + metrics.agent_collaboration.spawn_rejected; - if denominator == 0 { - return None; - } - Some((metrics.agent_collaboration.spawn_rejected * 10_000) / denominator) -} - -fn build_trace_item(stored: &StoredEvent) -> Option<SessionDebugTraceItem> { - let event = &stored.event; - let agent_id = event.agent.agent_id.clone(); - let sub_run_id = event.agent.sub_run_id.clone(); - let turn_id = event.turn_id.clone(); - match &event.payload { - StorageEventPayload::ToolCall { - tool_call_id, - tool_name, - .. - } => Some(SessionDebugTraceItem { - id: format!("trace:{}", stored.storage_seq), - storage_seq: stored.storage_seq, - turn_id, - recorded_at: None, - kind: SessionDebugTraceItemKind::ToolCall, - title: tool_name.clone(), - summary: format!("tool call started: {tool_name}"), - agent_id, - sub_run_id, - child_agent_id: None, - delivery_id: None, - tool_call_id: Some(tool_call_id.clone()), - tool_name: Some(tool_name.clone()), - lifecycle: None, - last_turn_outcome: None, - }), - StorageEventPayload::ToolResult { - tool_call_id, - tool_name, - success, - error, - duration_ms, - .. - } => Some(SessionDebugTraceItem { - id: format!("trace:{}", stored.storage_seq), - storage_seq: stored.storage_seq, - turn_id, - recorded_at: None, - kind: SessionDebugTraceItemKind::ToolResult, - title: tool_name.clone(), - summary: if *success { - format!("tool call completed in {duration_ms}ms") - } else { - format!( - "tool call failed in {duration_ms}ms{}", - error - .as_ref() - .map(|value| format!(": {value}")) - .unwrap_or_default() - ) - }, - agent_id, - sub_run_id, - child_agent_id: None, - delivery_id: None, - tool_call_id: Some(tool_call_id.clone()), - tool_name: Some(tool_name.clone()), - lifecycle: None, - last_turn_outcome: None, - }), - StorageEventPayload::PromptMetrics { metrics } => Some(SessionDebugTraceItem { - id: format!("trace:{}", stored.storage_seq), - storage_seq: stored.storage_seq, - turn_id, - recorded_at: None, - kind: SessionDebugTraceItemKind::PromptMetrics, - title: "prompt metrics".to_string(), - summary: format!( - "step={} tokens={} truncatedToolResults={}", - metrics.step_index, metrics.estimated_tokens, metrics.truncated_tool_results - ), - agent_id, - sub_run_id, - child_agent_id: None, - delivery_id: None, - tool_call_id: None, - tool_name: None, - lifecycle: None, - last_turn_outcome: None, - }), - StorageEventPayload::SubRunStarted { - tool_call_id, - timestamp, - .. - } => Some(SessionDebugTraceItem { - id: format!("trace:{}", stored.storage_seq), - storage_seq: stored.storage_seq, - turn_id, - recorded_at: *timestamp, - kind: SessionDebugTraceItemKind::SubRunStarted, - title: "sub-run started".to_string(), - summary: "child execution accepted".to_string(), - agent_id, - sub_run_id, - child_agent_id: None, - delivery_id: None, - tool_call_id: tool_call_id.clone(), - tool_name: None, - lifecycle: None, - last_turn_outcome: None, - }), - StorageEventPayload::SubRunFinished { - tool_call_id, - result, - step_count, - estimated_tokens, - timestamp, - } => Some(SessionDebugTraceItem { - id: format!("trace:{}", stored.storage_seq), - storage_seq: stored.storage_seq, - turn_id, - recorded_at: *timestamp, - kind: SessionDebugTraceItemKind::SubRunFinished, - title: "sub-run finished".to_string(), - summary: format!( - "lifecycle={:?} outcome={:?} steps={} estTokens={}", - result.lifecycle, result.last_turn_outcome, step_count, estimated_tokens - ), - agent_id, - sub_run_id, - child_agent_id: None, - delivery_id: None, - tool_call_id: tool_call_id.clone(), - tool_name: None, - lifecycle: Some(result.lifecycle), - last_turn_outcome: result.last_turn_outcome, - }), - StorageEventPayload::ChildSessionNotification { - notification, - timestamp, - } => Some(SessionDebugTraceItem { - id: format!("trace:{}", stored.storage_seq), - storage_seq: stored.storage_seq, - turn_id, - recorded_at: *timestamp, - kind: SessionDebugTraceItemKind::ChildNotification, - title: format!("{:?}", notification.kind).to_lowercase(), - summary: notification - .delivery - .as_ref() - .map(|delivery| delivery.payload.message().to_string()) - .unwrap_or_else(|| "child notification without delivery".to_string()), - agent_id, - sub_run_id, - child_agent_id: Some(notification.child_ref.agent_id.clone()), - delivery_id: None, - tool_call_id: notification.source_tool_call_id.clone(), - tool_name: None, - lifecycle: Some(notification.status), - last_turn_outcome: None, - }), - StorageEventPayload::AgentCollaborationFact { fact, timestamp } => { - Some(SessionDebugTraceItem { - id: format!("trace:{}", stored.storage_seq), - storage_seq: stored.storage_seq, - turn_id, - recorded_at: *timestamp, - kind: SessionDebugTraceItemKind::CollaborationFact, - title: format!("{:?}", fact.action).to_lowercase(), - summary: collaboration_summary(fact), - agent_id, - sub_run_id, - child_agent_id: fact.child_agent_id.clone(), - delivery_id: fact.delivery_id.clone(), - tool_call_id: fact.source_tool_call_id.clone(), - tool_name: None, - lifecycle: None, - last_turn_outcome: None, - }) - }, - StorageEventPayload::AgentMailboxQueued { payload } => Some(SessionDebugTraceItem { - id: format!("trace:{}", stored.storage_seq), - storage_seq: stored.storage_seq, - turn_id, - recorded_at: Some(payload.envelope.queued_at), - kind: SessionDebugTraceItemKind::MailboxQueued, - title: "mailbox queued".to_string(), - summary: payload.envelope.message.clone(), - agent_id, - sub_run_id, - child_agent_id: Some(payload.envelope.to_agent_id.clone()), - delivery_id: Some(payload.envelope.delivery_id.clone()), - tool_call_id: None, - tool_name: None, - lifecycle: Some(payload.envelope.sender_lifecycle_status), - last_turn_outcome: payload.envelope.sender_last_turn_outcome, - }), - StorageEventPayload::AgentMailboxBatchStarted { payload } => Some(SessionDebugTraceItem { - id: format!("trace:{}", stored.storage_seq), - storage_seq: stored.storage_seq, - turn_id, - recorded_at: None, - kind: SessionDebugTraceItemKind::MailboxBatchStarted, - title: "mailbox batch started".to_string(), - summary: format!("{} deliveries", payload.delivery_ids.len()), - agent_id, - sub_run_id, - child_agent_id: Some(payload.target_agent_id.clone()), - delivery_id: payload.delivery_ids.first().cloned(), - tool_call_id: None, - tool_name: None, - lifecycle: None, - last_turn_outcome: None, - }), - StorageEventPayload::AgentMailboxBatchAcked { payload } => Some(SessionDebugTraceItem { - id: format!("trace:{}", stored.storage_seq), - storage_seq: stored.storage_seq, - turn_id, - recorded_at: None, - kind: SessionDebugTraceItemKind::MailboxBatchAcked, - title: "mailbox batch acked".to_string(), - summary: format!("{} deliveries", payload.delivery_ids.len()), - agent_id, - sub_run_id, - child_agent_id: Some(payload.target_agent_id.clone()), - delivery_id: payload.delivery_ids.first().cloned(), - tool_call_id: None, - tool_name: None, - lifecycle: None, - last_turn_outcome: None, - }), - StorageEventPayload::AgentMailboxDiscarded { payload } => Some(SessionDebugTraceItem { - id: format!("trace:{}", stored.storage_seq), - storage_seq: stored.storage_seq, - turn_id, - recorded_at: None, - kind: SessionDebugTraceItemKind::MailboxDiscarded, - title: "mailbox discarded".to_string(), - summary: format!("{} deliveries", payload.delivery_ids.len()), - agent_id, - sub_run_id, - child_agent_id: Some(payload.target_agent_id.clone()), - delivery_id: payload.delivery_ids.first().cloned(), - tool_call_id: None, - tool_name: None, - lifecycle: None, - last_turn_outcome: None, - }), - StorageEventPayload::TurnDone { timestamp, reason } => Some(SessionDebugTraceItem { - id: format!("trace:{}", stored.storage_seq), - storage_seq: stored.storage_seq, - turn_id, - recorded_at: Some(*timestamp), - kind: SessionDebugTraceItemKind::TurnDone, - title: "turn done".to_string(), - summary: reason - .clone() - .unwrap_or_else(|| "turn completed".to_string()), - agent_id, - sub_run_id, - child_agent_id: None, - delivery_id: None, - tool_call_id: None, - tool_name: None, - lifecycle: None, - last_turn_outcome: None, - }), - StorageEventPayload::Error { message, timestamp } => Some(SessionDebugTraceItem { - id: format!("trace:{}", stored.storage_seq), - storage_seq: stored.storage_seq, - turn_id, - recorded_at: *timestamp, - kind: SessionDebugTraceItemKind::Error, - title: "error".to_string(), - summary: message.clone(), - agent_id, - sub_run_id, - child_agent_id: None, - delivery_id: None, - tool_call_id: None, - tool_name: None, - lifecycle: None, - last_turn_outcome: None, - }), - _ => None, - } -} - -fn collaboration_summary(fact: &astrcode_core::AgentCollaborationFact) -> String { - let mut parts = vec![format!("{:?}->{:?}", fact.action, fact.outcome).to_lowercase()]; - if let Some(summary) = fact.summary.as_deref() { - parts.push(summary.to_string()); - } - if let Some(reason_code) = fact.reason_code.as_deref() { - parts.push(format!("reason={reason_code}")); - } - if let Some(latency_ms) = fact.latency_ms { - parts.push(format!("latency={}ms", latency_ms)); - } - parts.join(" · ") -} - -#[cfg(test)] -mod tests { - use std::time::Duration; - - use chrono::{Duration as ChronoDuration, Utc}; - - use super::{RuntimeDebugTimelineSample, TimelineStore}; - - #[test] - fn timeline_store_discards_samples_outside_window() { - let mut store = TimelineStore::default(); - let now = Utc::now(); - let old = now - ChronoDuration::minutes(10); - store.record( - old, - Some(1_000), - Some(2_000), - Some(3_000), - Duration::from_secs(300), - ); - store.record( - now, - Some(1_100), - Some(2_100), - Some(3_100), - Duration::from_secs(300), - ); - - let samples = store.snapshot(now, Duration::from_secs(300)); - assert_eq!( - samples, - vec![RuntimeDebugTimelineSample { - collected_at: now, - spawn_rejection_ratio_bps: Some(1_100), - observe_to_action_ratio_bps: Some(2_100), - child_reuse_ratio_bps: Some(3_100), - }] - ); - } -} diff --git a/crates/kernel/src/agent_surface.rs b/crates/kernel/src/agent_surface.rs index fe7cfc3a..70b1423a 100644 --- a/crates/kernel/src/agent_surface.rs +++ b/crates/kernel/src/agent_surface.rs @@ -28,12 +28,12 @@ pub struct SubRunStatusView { impl SubRunStatusView { pub fn from_handle(handle: &SubRunHandle) -> Self { Self { - sub_run_id: handle.sub_run_id.clone(), - agent_id: handle.agent_id.clone(), - session_id: handle.session_id.clone(), - child_session_id: handle.child_session_id.clone(), + sub_run_id: handle.sub_run_id.to_string(), + agent_id: handle.agent_id.to_string(), + session_id: handle.session_id.to_string(), + child_session_id: handle.child_session_id.clone().map(Into::into), depth: handle.depth, - parent_agent_id: handle.parent_agent_id.clone(), + parent_agent_id: handle.parent_agent_id.clone().map(Into::into), agent_profile: handle.agent_profile.clone(), lifecycle: handle.lifecycle, last_turn_outcome: handle.last_turn_outcome, @@ -215,8 +215,11 @@ impl<'a> KernelAgentSurface<'a> { .await .into_iter() .filter(|handle| { - handle.parent_turn_id == parent_turn_id - && handle.parent_agent_id.as_deref() == Some(parent_agent_id) + handle.parent_turn_id.as_str() == parent_turn_id + && handle + .parent_agent_id + .as_ref() + .is_some_and(|id| id.as_str() == parent_agent_id) && matches!( handle.lineage_kind, astrcode_core::ChildSessionLineageKind::Spawn @@ -236,7 +239,12 @@ impl<'a> KernelAgentSurface<'a> { .agent_control() .terminate_subtree_and_collect_handles(agent_id) .await - .map(|handles| handles.into_iter().map(|handle| handle.agent_id).collect()) + .map(|handles| { + handles + .into_iter() + .map(|handle| handle.agent_id.to_string()) + .collect() + }) .ok_or(AgentControlError::ParentAgentNotFound { agent_id: agent_id.to_string(), })?; @@ -328,7 +336,7 @@ impl<'a> KernelAgentSurface<'a> { .cancel_for_parent_turn(parent_turn_id) .await .into_iter() - .map(|handle| handle.agent_id) + .map(|handle| handle.agent_id.to_string()) .collect() } } diff --git a/crates/kernel/src/agent_tree/delivery_queue.rs b/crates/kernel/src/agent_tree/delivery_queue.rs index 5f643d18..7229a698 100644 --- a/crates/kernel/src/agent_tree/delivery_queue.rs +++ b/crates/kernel/src/agent_tree/delivery_queue.rs @@ -15,7 +15,7 @@ fn mark_parent_deliveries_queued( ) -> usize { let mut updated = 0usize; for entry in &mut queue.deliveries { - if delivery_ids.contains(&entry.delivery.delivery_id) { + if delivery_ids.contains(&entry.delivery.delivery_id.to_string()) { entry.state = PendingParentDeliveryState::Queued; updated += 1; } @@ -30,13 +30,13 @@ fn consume_front_deliveries(queue: &mut ParentDeliveryQueue, delivery_ids: &[Str let Some(front) = queue.deliveries.front() else { return false; }; - if front.delivery.delivery_id != *delivery_id { + if front.delivery.delivery_id.as_str() != delivery_id { return false; } if let Some(removed) = queue.deliveries.pop_front() { queue .known_delivery_ids - .remove(&removed.delivery.delivery_id); + .remove(removed.delivery.delivery_id.as_str()); } } true @@ -56,7 +56,7 @@ pub(super) fn enqueue_parent_delivery_locked( .parent_delivery_queues .entry(parent_session_id.clone()) .or_default(); - if !queue.known_delivery_ids.insert(delivery_id.clone()) { + if !queue.known_delivery_ids.insert(delivery_id.to_string()) { return false; } if queue.deliveries.len() >= parent_delivery_capacity { @@ -66,12 +66,12 @@ pub(super) fn enqueue_parent_delivery_locked( parent_delivery_capacity, delivery_id ); - queue.known_delivery_ids.remove(&delivery_id); + queue.known_delivery_ids.remove(delivery_id.as_str()); return false; } queue.deliveries.push_back(PendingParentDeliveryEntry { delivery: PendingParentDelivery { - delivery_id, + delivery_id: delivery_id.into(), parent_session_id, parent_turn_id, queued_at_ms: std::time::SystemTime::now() @@ -115,14 +115,21 @@ pub(super) fn checkout_parent_delivery_batch_locked( .delivery .notification .child_ref - .parent_agent_id - .clone(); + .parent_agent_id() + .cloned(); let mut batch_len = 0usize; for entry in &queue.deliveries { if !matches!(entry.state, PendingParentDeliveryState::Queued) { break; } - if entry.delivery.notification.child_ref.parent_agent_id != target_parent_agent_id { + if entry + .delivery + .notification + .child_ref + .parent_agent_id() + .cloned() + != target_parent_agent_id + { break; } batch_len += 1; @@ -152,7 +159,7 @@ pub(super) fn requeue_parent_delivery_locked( let Some(entry) = queue .deliveries .iter_mut() - .find(|entry| entry.delivery.delivery_id == delivery_id) + .find(|entry| entry.delivery.delivery_id.as_str() == delivery_id) else { return false; }; diff --git a/crates/kernel/src/agent_tree/mod.rs b/crates/kernel/src/agent_tree/mod.rs index 565d3a7c..401fd39f 100644 --- a/crates/kernel/src/agent_tree/mod.rs +++ b/crates/kernel/src/agent_tree/mod.rs @@ -291,14 +291,14 @@ impl AgentControl { .and_then(|parent_agent_id| state.agent_index.get(parent_agent_id)) .cloned(); let handle = SubRunHandle { - sub_run_id: sub_run_id.clone(), - agent_id: agent_id.clone(), - session_id, - child_session_id, + sub_run_id: sub_run_id.clone().into(), + agent_id: agent_id.clone().into(), + session_id: session_id.into(), + child_session_id: child_session_id.map(Into::into), depth, - parent_turn_id, - parent_agent_id: parent_agent_id.clone(), - parent_sub_run_id, + parent_turn_id: parent_turn_id.into(), + parent_agent_id: parent_agent_id.clone().map(Into::into), + parent_sub_run_id: parent_sub_run_id.map(Into::into), lineage_kind: ChildSessionLineageKind::Spawn, agent_profile: profile.id.clone(), storage_mode, @@ -360,12 +360,12 @@ impl AgentControl { // 根 agent 没有真实 sub_run_id,使用 agent_id 等价 let sub_run_id = format!("root-{agent_id}"); let handle = SubRunHandle { - sub_run_id: sub_run_id.clone(), - agent_id: agent_id.clone(), - session_id, + sub_run_id: sub_run_id.clone().into(), + agent_id: agent_id.clone().into(), + session_id: session_id.into(), child_session_id: None, depth: 0, - parent_turn_id: String::new(), + parent_turn_id: String::new().into(), parent_agent_id: None, parent_sub_run_id: None, lineage_kind: ChildSessionLineageKind::Spawn, @@ -516,7 +516,7 @@ impl AgentControl { state .entries .values() - .find(|entry| entry.handle.depth == 0 && entry.handle.session_id == session_id) + .find(|entry| entry.handle.depth == 0 && entry.handle.session_id.as_str() == session_id) .map(|entry| entry.handle.clone()) } @@ -557,8 +557,8 @@ impl AgentControl { let next_id = self.next_id.fetch_add(1, Ordering::SeqCst) + 1; let new_sub_run_id = format!("subrun-{next_id}"); let mut new_handle = old_handle.clone(); - new_handle.sub_run_id = new_sub_run_id.clone(); - new_handle.parent_turn_id = parent_turn_id.into(); + new_handle.sub_run_id = new_sub_run_id.clone().into(); + new_handle.parent_turn_id = parent_turn_id.into().into(); new_handle.lineage_kind = ChildSessionLineageKind::Resume; new_handle.lifecycle = AgentLifecycleStatus::Running; new_handle.last_turn_outcome = None; @@ -585,7 +585,7 @@ impl AgentControl { ); state .agent_index - .insert(new_handle.agent_id.clone(), new_sub_run_id.clone()); + .insert(new_handle.agent_id.to_string(), new_sub_run_id.clone()); if let Some(parent_agent_id) = parent_agent_id { if let Some(parent_sub_run_id) = state.agent_index.get(&parent_agent_id).cloned() { @@ -621,7 +621,7 @@ impl AgentControl { let mut roots = state .entries .values() - .filter(|entry| entry.handle.parent_turn_id == parent_turn_id) + .filter(|entry| entry.handle.parent_turn_id.as_str() == parent_turn_id) .filter(|entry| { !entry .parent_agent_id @@ -631,7 +631,9 @@ impl AgentControl { .agent_index .get(parent_agent_id) .and_then(|parent_sub_run_id| state.entries.get(parent_sub_run_id)) - .is_some_and(|parent| parent.handle.parent_turn_id == parent_turn_id) + .is_some_and(|parent| { + parent.handle.parent_turn_id.as_str() == parent_turn_id + }) }) }) .map(|entry| entry.handle.sub_run_id.clone()) diff --git a/crates/kernel/src/agent_tree/tests.rs b/crates/kernel/src/agent_tree/tests.rs index b7beb649..f5788de2 100644 --- a/crates/kernel/src/agent_tree/tests.rs +++ b/crates/kernel/src/agent_tree/tests.rs @@ -2,10 +2,10 @@ use std::time::Duration; use astrcode_core::{ AgentInboxEnvelope, AgentLifecycleStatus, AgentMode, AgentProfile, AgentTurnOutcome, - ChildAgentRef, ChildSessionLineageKind, ChildSessionNotification, ChildSessionNotificationKind, - CompletedParentDeliveryPayload, LiveSubRunControlBoundary, ParentDelivery, - ParentDeliveryOrigin, ParentDeliveryPayload, ParentDeliveryTerminalSemantics, SessionId, - SubRunHandle, + ChildAgentRef, ChildExecutionIdentity, ChildSessionLineageKind, ChildSessionNotification, + ChildSessionNotificationKind, CompletedParentDeliveryPayload, LiveSubRunControlBoundary, + ParentDelivery, ParentDeliveryOrigin, ParentDeliveryPayload, ParentDeliveryTerminalSemantics, + ParentExecutionRef, SessionId, SubRunHandle, }; use super::{ @@ -45,6 +45,31 @@ fn explore_profile() -> AgentProfile { } } +fn build_child_ref( + agent_id: impl Into<astrcode_core::AgentId>, + session_id: impl Into<astrcode_core::SessionId>, + sub_run_id: impl Into<astrcode_core::SubRunId>, + parent_agent_id: Option<impl Into<astrcode_core::AgentId>>, + parent_sub_run_id: Option<impl Into<astrcode_core::SubRunId>>, + status: AgentLifecycleStatus, + open_session_id: impl Into<astrcode_core::SessionId>, +) -> ChildAgentRef { + ChildAgentRef { + identity: ChildExecutionIdentity { + agent_id: agent_id.into(), + session_id: session_id.into(), + sub_run_id: sub_run_id.into(), + }, + parent: ParentExecutionRef { + parent_agent_id: parent_agent_id.map(Into::into), + parent_sub_run_id: parent_sub_run_id.map(Into::into), + }, + lineage_kind: ChildSessionLineageKind::Spawn, + status, + open_session_id: open_session_id.into(), + } +} + fn sample_parent_delivery( notification_id: &str, parent_session_id: &str, @@ -54,19 +79,17 @@ fn sample_parent_delivery( parent_session_id.to_string(), parent_turn_id.to_string(), ChildSessionNotification { - notification_id: notification_id.to_string(), - child_ref: ChildAgentRef { - agent_id: format!("agent-{notification_id}"), - session_id: parent_session_id.to_string(), - sub_run_id: format!("subrun-{notification_id}"), - parent_agent_id: None, - parent_sub_run_id: None, - lineage_kind: ChildSessionLineageKind::Spawn, - status: AgentLifecycleStatus::Idle, - open_session_id: format!("child-session-{notification_id}"), - }, + notification_id: notification_id.into(), + child_ref: build_child_ref( + format!("agent-{notification_id}"), + parent_session_id, + format!("subrun-{notification_id}"), + None::<astrcode_core::AgentId>, + None::<astrcode_core::SubRunId>, + AgentLifecycleStatus::Idle, + format!("child-session-{notification_id}"), + ), kind: ChildSessionNotificationKind::Delivered, - status: AgentLifecycleStatus::Idle, source_tool_call_id: None, delivery: Some(completed_delivery( notification_id, @@ -83,22 +106,20 @@ fn sample_parent_delivery_for_child( child: &SubRunHandle, ) -> ChildSessionNotification { ChildSessionNotification { - notification_id: notification_id.to_string(), - child_ref: ChildAgentRef { - agent_id: child.agent_id.clone(), - session_id: parent_session_id.to_string(), - sub_run_id: child.sub_run_id.clone(), - parent_agent_id: child.parent_agent_id.clone(), - parent_sub_run_id: child.parent_sub_run_id.clone(), - lineage_kind: ChildSessionLineageKind::Spawn, - status: child.lifecycle, - open_session_id: child + notification_id: notification_id.into(), + child_ref: build_child_ref( + child.agent_id.clone(), + parent_session_id, + child.sub_run_id.clone(), + child.parent_agent_id.clone(), + child.parent_sub_run_id.clone(), + child.lifecycle, + child .child_session_id .clone() .unwrap_or_else(|| child.session_id.clone()), - }, + ), kind: ChildSessionNotificationKind::Delivered, - status: child.lifecycle, source_tool_call_id: None, delivery: Some(completed_delivery( notification_id, @@ -178,7 +199,7 @@ async fn cancelling_parent_turn_cascades_to_children() { &explore_profile(), "session-child", "turn-root".to_string(), - Some(parent.agent_id.clone()), + Some(parent.agent_id.to_string()), ) .await .expect("spawn should succeed"); @@ -249,7 +270,7 @@ async fn failed_spawn_does_not_consume_agent_id() { .await .expect("first successful spawn should still get the first id"); - assert_eq!(handle.agent_id, "agent-1"); + assert_eq!(handle.agent_id.as_str(), "agent-1"); } #[tokio::test] @@ -270,7 +291,7 @@ async fn cancel_directly_cascades_to_child_tree() { &explore_profile(), "session-child", "turn-root".to_string(), - Some(parent.agent_id.clone()), + Some(parent.agent_id.to_string()), ) .await .expect("child spawn should succeed"); @@ -279,7 +300,7 @@ async fn cancel_directly_cascades_to_child_tree() { &explore_profile(), "session-grandchild", "turn-root".to_string(), - Some(child.agent_id.clone()), + Some(child.agent_id.to_string()), ) .await .expect("grandchild spawn should succeed"); @@ -403,7 +424,7 @@ async fn spawn_rejects_agents_that_exceed_max_depth() { &explore_profile(), "session-child", "turn-root".to_string(), - Some(root.agent_id.clone()), + Some(root.agent_id.to_string()), ) .await .expect("child should fit within depth 2"); @@ -415,7 +436,7 @@ async fn spawn_rejects_agents_that_exceed_max_depth() { &explore_profile(), "session-grandchild", "turn-root".to_string(), - Some(child.agent_id.clone()), + Some(child.agent_id.to_string()), ) .await .expect_err("grandchild should exceed max depth"); @@ -568,7 +589,7 @@ async fn resume_mints_new_execution_for_completed_agent() { .expect("resume should succeed"); assert_eq!(resumed.lifecycle, AgentLifecycleStatus::Running); assert_eq!(resumed.agent_id, handle.agent_id); - assert_eq!(resumed.parent_turn_id, "turn-2"); + assert_eq!(resumed.parent_turn_id, "turn-2".into()); assert_eq!(resumed.lineage_kind, ChildSessionLineageKind::Resume); assert_ne!( resumed.sub_run_id, handle.sub_run_id, @@ -622,7 +643,7 @@ async fn close_cascades_to_entire_subtree_but_not_siblings() { &explore_profile(), "session-1", "turn-1".to_string(), - Some(tree_a_parent.agent_id.clone()), + Some(tree_a_parent.agent_id.to_string()), ) .await .expect("tree A child spawn should succeed"); @@ -636,7 +657,7 @@ async fn close_cascades_to_entire_subtree_but_not_siblings() { &explore_profile(), "session-1", "turn-1".to_string(), - Some(tree_b_parent.agent_id.clone()), + Some(tree_b_parent.agent_id.to_string()), ) .await .expect("tree B child spawn should succeed"); @@ -699,7 +720,7 @@ async fn terminate_subtree_and_collect_handles_survives_pruning_closed_branch() &explore_profile(), "session-1", "turn-1".to_string(), - Some(parent.agent_id.clone()), + Some(parent.agent_id.to_string()), ) .await .expect("child spawn should succeed"); @@ -708,7 +729,7 @@ async fn terminate_subtree_and_collect_handles_survives_pruning_closed_branch() &explore_profile(), "session-1", "turn-1".to_string(), - Some(child.agent_id.clone()), + Some(child.agent_id.to_string()), ) .await .expect("grandchild spawn should succeed"); @@ -947,7 +968,7 @@ async fn terminate_subtree_clears_pending_inbox_messages() { &explore_profile(), "session-1", "turn-1".to_string(), - Some(parent.agent_id.clone()), + Some(parent.agent_id.to_string()), ) .await .expect("child spawn should succeed"); @@ -999,7 +1020,7 @@ async fn terminate_subtree_discards_pending_parent_deliveries_for_closed_branch( &explore_profile(), "session-child-a", "turn-root".to_string(), - Some(root.agent_id.clone()), + Some(root.agent_id.to_string()), ) .await .expect("child spawn should succeed"); @@ -1008,7 +1029,7 @@ async fn terminate_subtree_discards_pending_parent_deliveries_for_closed_branch( &explore_profile(), "session-child-b", "turn-root".to_string(), - Some(root.agent_id.clone()), + Some(root.agent_id.to_string()), ) .await .expect("sibling spawn should succeed"); @@ -1045,7 +1066,10 @@ async fn terminate_subtree_discards_pending_parent_deliveries_for_closed_branch( .checkout_parent_delivery(&session_id) .await .expect("sibling delivery should remain queued"); - assert_eq!(remaining.notification.child_ref.agent_id, sibling.agent_id); + assert_eq!( + remaining.notification.child_ref.agent_id(), + &sibling.agent_id + ); } #[tokio::test] @@ -1184,19 +1208,17 @@ async fn parent_delivery_batch_checkout_uses_turn_start_snapshot_for_same_parent let turn_id = "turn-parent".to_string(); let make_delivery = |delivery_id: &str, child_id: &str, parent_agent_id: &str| ChildSessionNotification { - notification_id: delivery_id.to_string(), - child_ref: ChildAgentRef { - agent_id: child_id.to_string(), - session_id: session_id.clone(), - sub_run_id: format!("subrun-{delivery_id}"), - parent_agent_id: Some(parent_agent_id.to_string()), - parent_sub_run_id: Some(format!("subrun-{parent_agent_id}")), - lineage_kind: ChildSessionLineageKind::Spawn, - status: AgentLifecycleStatus::Idle, - open_session_id: format!("child-session-{delivery_id}"), - }, + notification_id: delivery_id.into(), + child_ref: build_child_ref( + child_id, + session_id.clone(), + format!("subrun-{delivery_id}"), + Some(parent_agent_id), + Some(format!("subrun-{parent_agent_id}")), + AgentLifecycleStatus::Idle, + format!("child-session-{delivery_id}"), + ), kind: ChildSessionNotificationKind::Delivered, - status: AgentLifecycleStatus::Idle, source_tool_call_id: None, delivery: Some(completed_delivery( delivery_id, @@ -1277,19 +1299,17 @@ async fn parent_delivery_batch_requeue_restores_started_snapshot_for_retry() { let turn_id = "turn-parent".to_string(); let make_delivery = |delivery_id: &str, child_id: &str, parent_agent_id: &str| ChildSessionNotification { - notification_id: delivery_id.to_string(), - child_ref: ChildAgentRef { - agent_id: child_id.to_string(), - session_id: session_id.clone(), - sub_run_id: format!("subrun-{delivery_id}"), - parent_agent_id: Some(parent_agent_id.to_string()), - parent_sub_run_id: Some(format!("subrun-{parent_agent_id}")), - lineage_kind: ChildSessionLineageKind::Spawn, - status: AgentLifecycleStatus::Idle, - open_session_id: format!("child-session-{delivery_id}"), - }, + notification_id: delivery_id.into(), + child_ref: build_child_ref( + child_id, + session_id.clone(), + format!("subrun-{delivery_id}"), + Some(parent_agent_id), + Some(format!("subrun-{parent_agent_id}")), + AgentLifecycleStatus::Idle, + format!("child-session-{delivery_id}"), + ), kind: ChildSessionNotificationKind::Delivered, - status: AgentLifecycleStatus::Idle, source_tool_call_id: None, delivery: Some(completed_delivery( delivery_id, @@ -1372,7 +1392,7 @@ async fn leaf_first_cascade_cancels_deepest_child_before_parent() { &explore_profile(), "session-middle", "turn-1".to_string(), - Some(root.agent_id.clone()), + Some(root.agent_id.to_string()), ) .await .expect("middle spawn should succeed"); @@ -1381,7 +1401,7 @@ async fn leaf_first_cascade_cancels_deepest_child_before_parent() { &explore_profile(), "session-leaf", "turn-1".to_string(), - Some(middle.agent_id.clone()), + Some(middle.agent_id.to_string()), ) .await .expect("leaf spawn should succeed"); @@ -1453,7 +1473,7 @@ async fn subtree_isolation_closing_one_branch_does_not_affect_sibling_branch() { &explore_profile(), "session-middle-a", "turn-1".to_string(), - Some(root.agent_id.clone()), + Some(root.agent_id.to_string()), ) .await .expect("middle_a spawn should succeed"); @@ -1462,7 +1482,7 @@ async fn subtree_isolation_closing_one_branch_does_not_affect_sibling_branch() { &explore_profile(), "session-leaf-a", "turn-1".to_string(), - Some(middle_a.agent_id.clone()), + Some(middle_a.agent_id.to_string()), ) .await .expect("leaf_a spawn should succeed"); @@ -1471,7 +1491,7 @@ async fn subtree_isolation_closing_one_branch_does_not_affect_sibling_branch() { &explore_profile(), "session-middle-b", "turn-1".to_string(), - Some(root.agent_id.clone()), + Some(root.agent_id.to_string()), ) .await .expect("middle_b spawn should succeed"); @@ -1480,7 +1500,7 @@ async fn subtree_isolation_closing_one_branch_does_not_affect_sibling_branch() { &explore_profile(), "session-leaf-b", "turn-1".to_string(), - Some(middle_b.agent_id.clone()), + Some(middle_b.agent_id.to_string()), ) .await .expect("leaf_b spawn should succeed"); @@ -1576,7 +1596,7 @@ async fn deliver_to_parent_only_reaches_direct_parent_not_grandparent() { &explore_profile(), "session-middle", "turn-1".to_string(), - Some(root.agent_id.clone()), + Some(root.agent_id.to_string()), ) .await .expect("middle spawn should succeed"); @@ -1585,7 +1605,7 @@ async fn deliver_to_parent_only_reaches_direct_parent_not_grandparent() { &explore_profile(), "session-leaf", "turn-1".to_string(), - Some(middle.agent_id.clone()), + Some(middle.agent_id.to_string()), ) .await .expect("leaf spawn should succeed"); @@ -1602,8 +1622,8 @@ async fn deliver_to_parent_only_reaches_direct_parent_not_grandparent() { // leaf 向直接父 (middle) 投递 let leaf_delivery = AgentInboxEnvelope { delivery_id: "delivery-leaf-to-middle".to_string(), - from_agent_id: leaf.agent_id.clone(), - to_agent_id: middle.agent_id.clone(), + from_agent_id: leaf.agent_id.to_string(), + to_agent_id: middle.agent_id.to_string(), kind: astrcode_core::InboxEnvelopeKind::ChildDelivery, message: "leaf 的结果".to_string(), context: None, @@ -1624,7 +1644,7 @@ async fn deliver_to_parent_only_reaches_direct_parent_not_grandparent() { .await .expect("drain middle inbox should succeed"); assert_eq!(middle_inbox.len(), 1); - assert_eq!(middle_inbox[0].from_agent_id, leaf.agent_id); + assert_eq!(middle_inbox[0].from_agent_id, leaf.agent_id.to_string()); assert_eq!( middle_inbox[0].kind, astrcode_core::InboxEnvelopeKind::ChildDelivery @@ -1656,7 +1676,7 @@ async fn wait_for_inbox_resolves_on_terminate_subtree() { &explore_profile(), "session-1", "turn-1".to_string(), - Some(parent.agent_id.clone()), + Some(parent.agent_id.to_string()), ) .await .expect("child spawn should succeed"); @@ -1689,7 +1709,7 @@ async fn wait_for_inbox_resolves_on_terminate_subtree() { result.is_some(), "wait_for_inbox should return Some after terminate" ); - let handle = result.unwrap(); + let handle = result.expect("result should be Some after terminate"); assert!( handle.lifecycle.is_final(), "handle should be in final state after terminate, got {:?}", diff --git a/crates/kernel/src/agent_tree/tree_ops.rs b/crates/kernel/src/agent_tree/tree_ops.rs index a82600e9..9e9fc43c 100644 --- a/crates/kernel/src/agent_tree/tree_ops.rs +++ b/crates/kernel/src/agent_tree/tree_ops.rs @@ -208,7 +208,7 @@ pub(super) fn cancel_tree_collect( /// 如果 queue 被清空则移除整个 session 的 queue 条目,避免空 map 条目累积。 pub(super) fn discard_parent_deliveries_locked( state: &mut AgentRegistryState, - terminated_agent_ids: &HashSet<String>, + terminated_agent_ids: &HashSet<astrcode_core::AgentId>, ) -> usize { if terminated_agent_ids.is_empty() { return 0; @@ -221,7 +221,7 @@ pub(super) fn discard_parent_deliveries_locked( let mut removed_delivery_ids = Vec::new(); while let Some(entry) = queue.deliveries.pop_front() { - if terminated_agent_ids.contains(&entry.delivery.notification.child_ref.agent_id) { + if terminated_agent_ids.contains(entry.delivery.notification.child_ref.agent_id()) { removed_delivery_ids.push(entry.delivery.delivery_id.clone()); } else { retained.push_back(entry); @@ -229,7 +229,7 @@ pub(super) fn discard_parent_deliveries_locked( } for delivery_id in &removed_delivery_ids { - queue.known_delivery_ids.remove(delivery_id); + queue.known_delivery_ids.remove(delivery_id.as_str()); } removed_count += removed_delivery_ids.len(); queue.deliveries = retained; @@ -289,7 +289,7 @@ pub(super) fn prune_finalized_agents_locked( } } if let Some(entry) = state.entries.remove(&agent_id) { - state.agent_index.remove(&entry.handle.agent_id); + state.agent_index.remove(entry.handle.agent_id.as_str()); } } } diff --git a/crates/kernel/src/gateway/mod.rs b/crates/kernel/src/gateway/mod.rs index b3b1ebbc..dfe239ba 100644 --- a/crates/kernel/src/gateway/mod.rs +++ b/crates/kernel/src/gateway/mod.rs @@ -58,6 +58,10 @@ impl KernelGateway { self.llm.model_limits() } + pub fn supports_cache_metrics(&self) -> bool { + self.llm.supports_cache_metrics() + } + pub async fn invoke_tool( &self, call: &ToolCallRequest, diff --git a/crates/kernel/src/registry/router.rs b/crates/kernel/src/registry/router.rs index 6c192faa..d72c738b 100644 --- a/crates/kernel/src/registry/router.rs +++ b/crates/kernel/src/registry/router.rs @@ -292,6 +292,7 @@ impl CapabilityRouter { output: String::new(), error: Some(format!("unknown tool '{}'", call.name)), metadata: None, + child_ref: None, duration_ms: 0, truncated: false, }; @@ -306,6 +307,7 @@ impl CapabilityRouter { output: String::new(), error: Some(format!("capability '{}' is not tool-callable", call.name)), metadata: None, + child_ref: None, duration_ms: 0, truncated: false, }; @@ -322,6 +324,7 @@ impl CapabilityRouter { output: String::new(), error: Some(error.to_string()), metadata: None, + child_ref: None, duration_ms: 0, truncated: false, }, diff --git a/crates/kernel/src/registry/tool.rs b/crates/kernel/src/registry/tool.rs index 810e5f17..2afe9daa 100644 --- a/crates/kernel/src/registry/tool.rs +++ b/crates/kernel/src/registry/tool.rs @@ -73,15 +73,15 @@ impl CapabilityInvoker for ToolCapabilityInvoker { .await; match result { - Ok(result) => Ok(CapabilityExecutionResult { - capability_name: result.tool_name, - success: result.ok, - output: Value::String(result.output), - error: result.error, - metadata: result.metadata, - duration_ms: result.duration_ms, - truncated: result.truncated, - }), + Ok(result) => { + let common = result.common(); + Ok(CapabilityExecutionResult::from_common( + result.tool_name, + result.ok, + Value::String(result.output), + common, + )) + }, Err(error) => Ok(CapabilityExecutionResult::failure( self.capability_spec.name.to_string(), error.to_string(), @@ -110,6 +110,7 @@ struct ToolBridgeContext { turn_id: Option<String>, request_id: Option<String>, agent: AgentEventContext, + current_mode_id: astrcode_core::ModeId, execution_owner: Option<ExecutionOwner>, tool_output_sender: Option<UnboundedSender<ToolOutputDelta>>, event_sink: Option<Arc<dyn ToolEventSink>>, @@ -124,6 +125,7 @@ impl ToolBridgeContext { turn_id: ctx.turn_id().map(ToString::to_string), request_id: None, agent: ctx.agent_context().clone(), + current_mode_id: ctx.current_mode_id().clone(), execution_owner: ctx.execution_owner().cloned(), tool_output_sender: ctx.tool_output_sender(), event_sink: ctx.event_sink(), @@ -138,6 +140,7 @@ impl ToolBridgeContext { turn_id: ctx.turn_id.clone(), request_id: ctx.request_id.clone(), agent: ctx.agent.clone(), + current_mode_id: ctx.current_mode_id.clone(), execution_owner: ctx.execution_owner.clone(), tool_output_sender: ctx.tool_output_sender.clone(), event_sink: ctx.event_sink.clone(), @@ -155,6 +158,7 @@ impl ToolBridgeContext { cancel: self.cancel, turn_id: self.turn_id, agent: self.agent, + current_mode_id: self.current_mode_id, execution_owner: self.execution_owner, profile: default_tool_capability_profile().to_string(), profile_context, @@ -173,6 +177,7 @@ impl ToolBridgeContext { tool_ctx = tool_ctx.with_tool_call_id(tool_call_id); } tool_ctx = tool_ctx.with_agent_context(self.agent); + tool_ctx = tool_ctx.with_current_mode_id(self.current_mode_id); if let Some(sender) = self.tool_output_sender { tool_ctx = tool_ctx.with_tool_output_sender(sender); } diff --git a/crates/plugin/src/capability_mapping.rs b/crates/plugin/src/capability_mapping.rs index 45d651cc..35b595b6 100644 --- a/crates/plugin/src/capability_mapping.rs +++ b/crates/plugin/src/capability_mapping.rs @@ -1,115 +1,31 @@ //! 插件协议描述符与宿主内部 `CapabilitySpec` 的边界映射。 //! //! Why: `protocol` crate 只承载 wire types,不负责宿主内部模型转换。 +//! `CapabilitySpec` 是宿主内部语义真相,`CapabilityWireDescriptor` +//! 只是握手/传输使用的 DTO 名称。 -use astrcode_core::{ - CapabilityKind as CoreCapabilityKind, CapabilitySpec, CapabilitySpecBuildError, - InvocationMode as CoreInvocationMode, PermissionSpec, SideEffect as CoreSideEffect, - Stability as CoreStability, -}; -use astrcode_protocol::plugin::{ - CapabilityDescriptor, CapabilityKind, DescriptorBuildError, PermissionHint, SideEffectLevel, - StabilityLevel, -}; +use astrcode_core::{CapabilitySpec, CapabilitySpecBuildError}; +use astrcode_protocol::plugin::CapabilityWireDescriptor; use thiserror::Error; #[derive(Debug, Error)] pub enum CapabilityMappingError { - #[error("invalid capability descriptor: {0}")] - InvalidDescriptor(#[from] CapabilitySpecBuildError), - #[error("invalid capability spec: {0}")] - InvalidSpec(#[from] DescriptorBuildError), + #[error("invalid capability payload: {0}")] + InvalidCapability(#[from] CapabilitySpecBuildError), } -pub fn descriptor_to_spec( - descriptor: &CapabilityDescriptor, +pub fn wire_descriptor_to_spec( + descriptor: &CapabilityWireDescriptor, ) -> std::result::Result<CapabilitySpec, CapabilityMappingError> { - let mut builder = CapabilitySpec::builder( - descriptor.name.clone(), - CoreCapabilityKind::from(descriptor.kind.as_str()), - ) - .description(descriptor.description.clone()) - .schema( - descriptor.input_schema.clone(), - descriptor.output_schema.clone(), - ) - .invocation_mode(if descriptor.streaming { - CoreInvocationMode::Streaming - } else { - CoreInvocationMode::Unary - }) - .concurrency_safe(descriptor.concurrency_safe) - .compact_clearable(descriptor.compact_clearable) - .profiles(descriptor.profiles.clone()) - .tags(descriptor.tags.clone()) - .permissions( - descriptor - .permissions - .iter() - .map(|permission| PermissionSpec { - name: permission.name.clone(), - rationale: permission.rationale.clone(), - }) - .collect(), - ) - .side_effect(match descriptor.side_effect { - SideEffectLevel::None => CoreSideEffect::None, - SideEffectLevel::Local => CoreSideEffect::Local, - SideEffectLevel::Workspace => CoreSideEffect::Workspace, - SideEffectLevel::External => CoreSideEffect::External, - }) - .stability(match descriptor.stability { - StabilityLevel::Experimental => CoreStability::Experimental, - StabilityLevel::Stable => CoreStability::Stable, - StabilityLevel::Deprecated => CoreStability::Deprecated, - }) - .metadata(descriptor.metadata.clone()); - if let Some(size) = descriptor.max_result_inline_size { - builder = builder.max_result_inline_size(size); - } - Ok(builder.build()?) + descriptor.validate()?; + Ok(descriptor.clone()) } -pub fn spec_to_descriptor( +pub fn spec_to_wire_descriptor( spec: &CapabilitySpec, -) -> std::result::Result<CapabilityDescriptor, CapabilityMappingError> { - let mut builder = - CapabilityDescriptor::builder(spec.name.as_str(), CapabilityKind::new(spec.kind.as_str())) - .description(spec.description.clone()) - .schema(spec.input_schema.clone(), spec.output_schema.clone()) - .streaming(matches!( - spec.invocation_mode, - CoreInvocationMode::Streaming - )) - .concurrency_safe(spec.concurrency_safe) - .compact_clearable(spec.compact_clearable) - .profiles(spec.profiles.clone()) - .tags(spec.tags.clone()) - .permissions( - spec.permissions - .iter() - .map(|permission| PermissionHint { - name: permission.name.clone(), - rationale: permission.rationale.clone(), - }) - .collect(), - ) - .side_effect(match spec.side_effect { - CoreSideEffect::None => SideEffectLevel::None, - CoreSideEffect::Local => SideEffectLevel::Local, - CoreSideEffect::Workspace => SideEffectLevel::Workspace, - CoreSideEffect::External => SideEffectLevel::External, - }) - .stability(match spec.stability { - CoreStability::Experimental => StabilityLevel::Experimental, - CoreStability::Stable => StabilityLevel::Stable, - CoreStability::Deprecated => StabilityLevel::Deprecated, - }) - .metadata(spec.metadata.clone()); - if let Some(size) = spec.max_result_inline_size { - builder = builder.max_result_inline_size(size); - } - Ok(builder.build()?) +) -> std::result::Result<CapabilityWireDescriptor, CapabilityMappingError> { + spec.validate()?; + Ok(spec.clone()) } #[cfg(test)] @@ -117,7 +33,7 @@ mod tests { use astrcode_core::{CapabilityKind, CapabilitySpec, InvocationMode, SideEffect, Stability}; use serde_json::json; - use super::{descriptor_to_spec, spec_to_descriptor}; + use super::{spec_to_wire_descriptor, wire_descriptor_to_spec}; fn sample_spec() -> CapabilitySpec { CapabilitySpec { @@ -142,8 +58,9 @@ mod tests { #[test] fn round_trip_between_spec_and_descriptor() { let spec = sample_spec(); - let descriptor = spec_to_descriptor(&spec).expect("spec->descriptor should pass"); - let mapped = descriptor_to_spec(&descriptor).expect("descriptor->spec should pass"); + let descriptor = spec_to_wire_descriptor(&spec).expect("spec->wire descriptor should pass"); + let mapped = + wire_descriptor_to_spec(&descriptor).expect("wire descriptor->spec should pass"); assert_eq!(mapped, spec); } } diff --git a/crates/plugin/src/capability_router.rs b/crates/plugin/src/capability_router.rs index 0a1acb7e..6892b7eb 100644 --- a/crates/plugin/src/capability_router.rs +++ b/crates/plugin/src/capability_router.rs @@ -19,11 +19,11 @@ use std::{collections::BTreeMap, sync::Arc}; use astrcode_core::{AstrError, CancelToken, CapabilitySpec, Result}; -use astrcode_protocol::plugin::{CapabilityDescriptor, InvocationContext}; +use astrcode_protocol::plugin::{CapabilityWireDescriptor, InvocationContext}; use async_trait::async_trait; use serde_json::Value; -use crate::{EventEmitter, capability_mapping::spec_to_descriptor}; +use crate::{EventEmitter, capability_mapping::spec_to_wire_descriptor}; /// 能力处理器 trait。 /// @@ -150,14 +150,14 @@ impl CapabilityRouter { /// 获取所有已注册能力的描述符列表。 /// /// 返回顺序由内部 `BTreeMap` 的键顺序决定(按能力名称字典序)。 - pub fn capabilities(&self) -> Result<Vec<CapabilityDescriptor>> { + pub fn capabilities(&self) -> Result<Vec<CapabilityWireDescriptor>> { self.handlers .values() .map(|handler| { let spec = handler.capability_spec(); - spec_to_descriptor(&spec).map_err(|error| { + spec_to_wire_descriptor(&spec).map_err(|error| { AstrError::Validation(format!( - "failed to project capability spec '{}' to descriptor: {}", + "failed to project capability spec '{}' to wire descriptor: {}", spec.name, error )) }) diff --git a/crates/plugin/src/invoker.rs b/crates/plugin/src/invoker.rs index f81699bd..9dce63e2 100644 --- a/crates/plugin/src/invoker.rs +++ b/crates/plugin/src/invoker.rs @@ -16,13 +16,13 @@ use astrcode_core::{ InvocationMode, Result, }; use astrcode_protocol::plugin::{ - CapabilityDescriptor, EventPhase, InvocationContext, WorkspaceRef, + CapabilityWireDescriptor, EventPhase, InvocationContext, WorkspaceRef, }; use async_trait::async_trait; use serde_json::{Value, json}; use uuid::Uuid; -use crate::{Peer, StreamExecution, Supervisor, capability_mapping::descriptor_to_spec}; +use crate::{Peer, StreamExecution, Supervisor, capability_mapping::wire_descriptor_to_spec}; /// 插件能力的调用器实现。 /// @@ -46,15 +46,15 @@ impl PluginCapabilityInvoker { /// /// `remote_name` 保存原始的能力名称,因为 `descriptor.name` 可能在 /// 适配过程中被修改(如添加命名空间前缀)。 - pub fn from_protocol_descriptor(peer: Peer, descriptor: CapabilityDescriptor) -> Result<Self> { - let capability_spec = descriptor_to_spec(&descriptor).map_err(|error| { + pub fn from_wire_descriptor(peer: Peer, descriptor: CapabilityWireDescriptor) -> Result<Self> { + let capability_spec = wire_descriptor_to_spec(&descriptor).map_err(|error| { AstrError::Validation(format!( - "invalid protocol capability descriptor '{}': {}", + "invalid protocol capability wire descriptor '{}': {}", descriptor.name, error )) })?; Ok(Self { - remote_name: descriptor.name.clone(), + remote_name: descriptor.name.to_string(), capability_spec, peer, }) @@ -127,15 +127,17 @@ impl CapabilityInvoker for PluginCapabilityInvoker { .unwrap_or_else(|| "plugin invocation failed".to_string()); (false, Some(error)) }; - Ok(CapabilityExecutionResult { - capability_name: self.capability_spec.name.to_string(), + Ok(CapabilityExecutionResult::from_common( + self.capability_spec.name.to_string(), success, - output: result.output, - error, - metadata: Some(result.metadata), - duration_ms: started_at.elapsed().as_millis() as u64, - truncated: false, - }) + result.output, + astrcode_core::ExecutionResultCommon { + error, + metadata: Some(result.metadata), + duration_ms: started_at.elapsed().as_millis() as u64, + truncated: false, + }, + )) } } } @@ -151,10 +153,10 @@ impl Supervisor { .iter() .cloned() .filter_map(|descriptor| { - match PluginCapabilityInvoker::from_protocol_descriptor(self.peer(), descriptor) { + match PluginCapabilityInvoker::from_wire_descriptor(self.peer(), descriptor) { Ok(invoker) => Some(Arc::new(invoker) as Arc<dyn CapabilityInvoker>), Err(error) => { - log::error!("failed to adapt plugin capability descriptor: {error}"); + log::error!("failed to adapt plugin capability wire descriptor: {error}"); None }, } @@ -162,11 +164,11 @@ impl Supervisor { .collect() } - /// 获取此插件声明的核心能力描述符列表。 + /// 获取此插件声明的 wire 能力描述符列表。 /// /// 与 `capability_invokers()` 不同,此方法返回原始的描述符, /// 不包装为调用器。用于向宿主展示插件提供了哪些能力。 - pub fn core_capabilities(&self) -> Vec<CapabilityDescriptor> { + pub fn wire_capabilities(&self) -> Vec<CapabilityWireDescriptor> { self.remote_initialize().capabilities.clone() } @@ -177,6 +179,14 @@ impl Supervisor { pub fn declared_skills(&self) -> Vec<astrcode_protocol::plugin::SkillDescriptor> { self.remote_initialize().skills.clone() } + + /// 获取此插件声明的治理 mode 列表。 + /// + /// 返回插件在握手阶段通过 `InitializeResultData.modes` 声明的 mode。 + /// 调用方负责决定如何校验并注册这些 mode。 + pub fn declared_modes(&self) -> Vec<astrcode_core::GovernanceModeSpec> { + self.remote_initialize().modes.clone() + } } /// 完成流式调用并收集结果。 @@ -206,30 +216,33 @@ async fn finish_stream_invocation( })); }, EventPhase::Completed => { - return Ok(CapabilityExecutionResult { + return Ok(CapabilityExecutionResult::from_common( capability_name, - success: true, - output: event.payload, - error: None, - metadata: Some(json!({ "streamEvents": deltas })), - duration_ms: started_at.elapsed().as_millis() as u64, - truncated: false, - }); + true, + event.payload, + astrcode_core::ExecutionResultCommon::success( + Some(json!({ "streamEvents": deltas })), + started_at.elapsed().as_millis() as u64, + false, + ), + )); }, EventPhase::Failed => { let error = event .error .map(|value| value.message) .unwrap_or_else(|| "stream invocation failed".to_string()); - return Ok(CapabilityExecutionResult { + return Ok(CapabilityExecutionResult::from_common( capability_name, - success: false, - output: Value::Null, - error: Some(error), - metadata: Some(json!({ "streamEvents": deltas })), - duration_ms: started_at.elapsed().as_millis() as u64, - truncated: false, - }); + false, + Value::Null, + astrcode_core::ExecutionResultCommon::failure( + error, + Some(json!({ "streamEvents": deltas })), + started_at.elapsed().as_millis() as u64, + false, + ), + )); }, } } diff --git a/crates/plugin/src/lib.rs b/crates/plugin/src/lib.rs index e4ec99db..e4341445 100644 --- a/crates/plugin/src/lib.rs +++ b/crates/plugin/src/lib.rs @@ -67,7 +67,7 @@ mod supervisor; pub mod transport; mod worker; -pub use capability_mapping::{descriptor_to_spec, spec_to_descriptor}; +pub use capability_mapping::{spec_to_wire_descriptor, wire_descriptor_to_spec}; pub use capability_router::{ AllowAllPermissionChecker, CapabilityHandler, CapabilityRouter, PermissionChecker, }; diff --git a/crates/plugin/src/peer.rs b/crates/plugin/src/peer.rs index ff333713..4178b912 100644 --- a/crates/plugin/src/peer.rs +++ b/crates/plugin/src/peer.rs @@ -520,6 +520,7 @@ impl PeerInner { handlers, profiles, skills: vec![], + modes: vec![], metadata, }; *self.remote_initialize.lock().await = Some(negotiated.clone()); @@ -789,6 +790,7 @@ impl PeerInner { handlers: self.local_initialize.handlers.clone(), profiles: self.local_initialize.profiles.clone(), skills: vec![], + modes: vec![], metadata: self.local_initialize.metadata.clone(), } } diff --git a/crates/plugin/src/supervisor.rs b/crates/plugin/src/supervisor.rs index 15c3c729..ef53df5a 100644 --- a/crates/plugin/src/supervisor.rs +++ b/crates/plugin/src/supervisor.rs @@ -22,8 +22,8 @@ use std::sync::Arc; use astrcode_core::{ManagedRuntimeComponent, PluginManifest, Result}; use astrcode_protocol::plugin::{ - CapabilityDescriptor, InitializeMessage, InitializeResultData, InvokeMessage, PROTOCOL_VERSION, - PeerDescriptor, ProfileDescriptor, ResultMessage, + CapabilityWireDescriptor, InitializeMessage, InitializeResultData, InvokeMessage, + PROTOCOL_VERSION, PeerDescriptor, ProfileDescriptor, ResultMessage, }; use async_trait::async_trait; use serde_json::{Value, json}; @@ -274,7 +274,7 @@ impl ManagedRuntimeComponent for Supervisor { /// * `profiles` - 支持的 profile 列表 pub fn default_initialize_message( local_peer: PeerDescriptor, - capabilities: Vec<CapabilityDescriptor>, + capabilities: Vec<CapabilityWireDescriptor>, profiles: Vec<ProfileDescriptor>, ) -> InitializeMessage { InitializeMessage { diff --git a/crates/plugin/tests/v4_stdio_e2e.rs b/crates/plugin/tests/v4_stdio_e2e.rs index 4e8e2c94..3ff9b4b6 100644 --- a/crates/plugin/tests/v4_stdio_e2e.rs +++ b/crates/plugin/tests/v4_stdio_e2e.rs @@ -78,7 +78,7 @@ async fn stdio_supervisor_initializes_and_invokes_unary_capability() -> Result<( .remote_initialize() .capabilities .iter() - .any(|capability| capability.name == "tool.echo") + .any(|capability| capability.name.as_str() == "tool.echo") ); let response = supervisor diff --git a/crates/protocol/Cargo.toml b/crates/protocol/Cargo.toml index f0303cca..72913e5f 100644 --- a/crates/protocol/Cargo.toml +++ b/crates/protocol/Cargo.toml @@ -6,6 +6,7 @@ license.workspace = true authors.workspace = true [dependencies] +astrcode-core = { path = "../core" } serde.workspace = true serde_json.workspace = true thiserror.workspace = true diff --git a/crates/protocol/src/capability/descriptors.rs b/crates/protocol/src/capability/descriptors.rs index b67abf22..469e57ba 100644 --- a/crates/protocol/src/capability/descriptors.rs +++ b/crates/protocol/src/capability/descriptors.rs @@ -1,811 +1,147 @@ -//! 能力描述符与调用上下文 +//! 能力 wire 描述与调用上下文。 //! -//! 定义插件系统核心的元数据描述结构,是 host 与插件之间 -//! 能力注册、路由、策略决策的基础协议。 -//! -//! ## 主要类型 -//! -//! - **PeerDescriptor**: 通信对等方的身份信息(ID、角色、版本、支持的 profile) -//! - **CapabilityDescriptor**: 能力的完整描述(名称、类型、schema、权限、副作用级别等) -//! - **CapabilityKind**: 能力类型的强类型包装,避免拼写错误导致路由失败 -//! - **HandlerDescriptor**: 事件处理器的描述(触发条件、过滤规则) -//! - **InvocationContext**: 调用时的上下文(调用方、工作区、预算限制等) -//! - **CapabilityDescriptorBuilder**: 构建器模式,用于安全地构造能力描述符 -//! -//! ## 设计原则 -//! -//! - 能力描述符在插件握手时由插件发送给 host,host 据此进行路由和策略决策 -//! - `CapabilityKind` 虽然是字符串包装,但提供了强类型的构造函数(`tool()`, `agent()` 等) -//! - Builder 在 `build()` 时执行完整校验,确保描述符的完整性 -//! - 所有字段都有明确的默认值和 serde 注解,保证序列化兼容性 - -use std::fmt; - -use serde::{Deserialize, Deserializer, Serialize}; +//! `CapabilityWireDescriptor` 是插件协议中的 transport 载荷名称; +//! 真正的 canonical 能力模型在 `astrcode_core::CapabilitySpec`。 +//! 这里不再维护第二套语义重复的枚举、builder 与校验逻辑。 + +/// 插件握手阶段交换的能力 wire 描述。 +pub use astrcode_core::CapabilitySpec as CapabilityWireDescriptor; +/// `CapabilityWireDescriptor` 的校验错误,直接复用 core 校验错误。 +pub use astrcode_core::CapabilitySpecBuildError as CapabilityWireDescriptorBuildError; +/// `CapabilityWireDescriptor` 的构建器,直接复用 core builder。 +pub use astrcode_core::CapabilitySpecBuilder as CapabilityWireDescriptorBuilder; +pub use astrcode_core::{CapabilityKind, InvocationMode, PermissionSpec, SideEffect, Stability}; +use serde::{Deserialize, Serialize}; use serde_json::Value; /// 通信对等方的角色类型。 -/// -/// 用于握手阶段标识 peer 的身份,影响能力路由和权限策略。 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum PeerRole { - /// 核心运行时(host 侧) Core, - /// 插件进程 Plugin, - /// 运行时服务 Runtime, - /// 工作进程 Worker, - /// 监督进程 Supervisor, } /// 通信对等方的描述信息。 -/// -/// 在握手阶段交换,用于标识 peer 的身份、版本和支持的能力 profile。 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct PeerDescriptor { - /// peer 的唯一标识 pub id: String, - /// peer 的显示名称 pub name: String, - /// peer 的角色类型 pub role: PeerRole, - /// peer 的版本号 pub version: String, - /// 此 peer 支持的 profile 列表 #[serde(default)] pub supported_profiles: Vec<String>, - /// 扩展元数据 #[serde(default)] pub metadata: Value, } -/// 能力类型的强类型包装。 -/// -/// 虽然底层是字符串,但通过此类型可以避免拼写错误导致的路由失败。 -/// 提供了常见能力类型的构造函数(`tool()`, `agent()`, `context_provider()` 等), -/// 同时也支持通过 `new()` 创建用户自定义类型。 -/// -/// ## 设计动机 -/// -/// 此类型帮助 host 和插件进行路由、策略和展示决策。 -/// 它不应被视为在 `{descriptor, invoke}` 之上叠加的第二调用协议—— -/// 能力传输本身仍然是描述符 + 调用两阶段模型。 -#[derive(Debug, Clone, Serialize, PartialEq, Eq, Hash)] -#[serde(transparent)] -pub struct CapabilityKind(String); - -impl CapabilityKind { - /// 从字符串创建能力类型,自动去除首尾空白。 - pub fn new(kind: impl Into<String>) -> Self { - Self(kind.into().trim().to_string()) - } - - /// 工具类型——可被 Agent 调用的功能。 - pub fn tool() -> Self { - Self::new("tool") - } - - /// Agent 类型——可自主执行多步操作的代理能力。 - pub fn agent() -> Self { - Self::new("agent") - } - - /// 上下文提供者——为 Agent 提供额外上下文信息。 - pub fn context_provider() -> Self { - Self::new("context_provider") - } - - /// 记忆提供者——为 Agent 提供持久化记忆能力。 - pub fn memory_provider() -> Self { - Self::new("memory_provider") - } - - /// 策略钩子——在 Agent 决策流程中插入策略检查。 - pub fn policy_hook() -> Self { - Self::new("policy_hook") - } - - /// 渲染器——负责将内容渲染为特定展示格式。 - pub fn renderer() -> Self { - Self::new("renderer") - } - - /// 资源——提供可被 Agent 访问的资源。 - pub fn resource() -> Self { - Self::new("resource") - } - - /// Prompt 模板——可被 Agent 使用的预定义 prompt。 - pub fn prompt() -> Self { - Self::new("prompt") - } - - /// 获取底层字符串引用。 - pub fn as_str(&self) -> &str { - &self.0 - } - - /// 判断是否为工具类型。 - pub fn is_tool(&self) -> bool { - self.as_str() == "tool" - } -} - -impl Default for CapabilityKind { - fn default() -> Self { - Self::tool() - } -} - -impl From<&str> for CapabilityKind { - fn from(value: &str) -> Self { - Self::new(value) - } -} - -impl From<String> for CapabilityKind { - fn from(value: String) -> Self { - Self::new(value) - } -} - -impl<'de> Deserialize<'de> for CapabilityKind { - fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> - where - D: Deserializer<'de>, - { - Ok(Self::new(String::deserialize(deserializer)?)) - } -} - -impl fmt::Display for CapabilityKind { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(self.as_str()) - } -} - -/// 副作用级别。 -/// -/// 用于描述能力执行时对外部环境的影响程度,host 可据此进行权限策略决策。 -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] -#[serde(rename_all = "snake_case")] -pub enum SideEffectLevel { - /// 无副作用(纯读取) - #[default] - None, - /// 仅影响本地状态(如内存缓存) - Local, - /// 影响工作区(如修改文件) - Workspace, - /// 影响外部环境(如网络请求、系统命令) - External, -} - -/// 能力稳定性级别。 -/// -/// 用于前端展示和策略决策,标记能力是否处于实验阶段或已废弃。 -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] -#[serde(rename_all = "snake_case")] -pub enum StabilityLevel { - /// 实验性功能,API 可能变更 - Experimental, - /// 稳定版本 - #[default] - Stable, - /// 已废弃,建议使用替代方案 - Deprecated, -} - -/// 权限提示。 -/// -/// 描述能力执行时需要的权限,`rationale` 用于向用户解释为什么需要此权限。 -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct PermissionHint { - /// 权限名称 - pub name: String, - /// 请求此权限的理由(向用户展示) - #[serde(default, skip_serializing_if = "Option::is_none")] - pub rationale: Option<String>, -} - -/// 能力的完整描述符。 -/// -/// 这是插件协议的核心数据结构,在握手阶段由插件发送给 host。 -/// Host 据此进行能力注册、路由配置、策略决策和前端展示。 -/// -/// ## 字段说明 -/// -/// - `kind`: 能力类型,影响路由和展示(如 tool 类型会展示为工具调用 UI) -/// - `input_schema` / `output_schema`: JSON Schema 格式的输入输出定义 -/// - `streaming`: 是否支持流式输出(通过 `EventMessage` 返回中间结果) -/// - `concurrency_safe`: 是否可安全并发调用 -/// - `compact_clearable`: 在上下文压缩时是否可以被清除 -/// - `profiles`: 此能力可用的 profile 列表 -/// - `side_effect`: 副作用级别,用于权限策略 -/// - `stability`: 稳定性级别,用于前端展示 -/// -/// ## 设计注意 -/// -/// Host 可能将某些 kind 投影到特定展示面(如 tool-call UI), -/// 但能力传输本身仍然是 descriptor + invoke 两阶段模型。 -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct CapabilityDescriptor { - /// 能力名称,支持点号命名空间(如 `tool.read_file`) - pub name: String, - /// 能力类型,影响路由和展示 - #[serde(default)] - pub kind: CapabilityKind, - /// 能力描述 - pub description: String, - /// 输入参数的 JSON Schema - pub input_schema: Value, - /// 输出结果的 JSON Schema - pub output_schema: Value, - /// 是否支持流式输出 - #[serde(default)] - pub streaming: bool, - /// 是否可安全并发调用 - #[serde(default, skip_serializing_if = "is_false")] - pub concurrency_safe: bool, - /// 在上下文压缩时是否可被清除 - #[serde(default, skip_serializing_if = "is_false")] - pub compact_clearable: bool, - /// 此能力可用的 profile 列表 - #[serde(default)] - pub profiles: Vec<String>, - /// 能力标签,用于分类和搜索 - #[serde(default)] - pub tags: Vec<String>, - /// 需要的权限列表 - #[serde(default)] - pub permissions: Vec<PermissionHint>, - /// 副作用级别 - #[serde(default)] - pub side_effect: SideEffectLevel, - /// 稳定性级别 - #[serde(default)] - pub stability: StabilityLevel, - /// 扩展元数据 - #[serde(default, skip_serializing_if = "Value::is_null")] - pub metadata: Value, - /// 工具结果内联阈值(字节)。超过此大小的结果在执行时持久化到磁盘。 - /// None 表示使用系统默认阈值(DEFAULT_TOOL_RESULT_INLINE_LIMIT = 32KB)。 - /// 借鉴 Claude Code 各工具的 maxResultSizeChars。 - #[serde(default, skip_serializing_if = "Option::is_none")] - pub max_result_inline_size: Option<usize>, -} - -impl CapabilityDescriptor { - /// 创建构建器,用于逐步构造能力描述符。 - pub fn builder( - name: impl Into<String>, - kind: impl Into<CapabilityKind>, - ) -> CapabilityDescriptorBuilder { - CapabilityDescriptorBuilder::new(name, kind) - } - - /// 从能力名称中提取命名空间。 - /// - /// 命名空间是名称中第一个点号之前的部分。 - /// 例如 `"tool.read_file"` → `"tool"`,`"shell"` → `"shell"`。 - /// - /// 运行时和插件注册路径会调用此方法,确保插件作者即使不使用 Builder API - /// 也能获得与 Builder 相同的校验保证。 - pub fn namespace(&self) -> &str { - self.name - .split_once('.') - .map_or(self.name.as_str(), |(ns, _)| ns) - } - - /// 验证描述符的完整性。 - /// - /// 校验包括:必填字段非空、schema 为 JSON 对象、列表无重复值等。 - /// 运行时和插件注册路径会调用此方法。 - pub fn validate(&self) -> Result<(), DescriptorBuildError> { - validate_non_empty("name", self.name.clone())?; - validate_kind(self.kind.clone())?; - validate_non_empty("description", self.description.clone())?; - validate_schema("input_schema", self.input_schema.clone())?; - validate_schema("output_schema", self.output_schema.clone())?; - validate_string_list("profiles", self.profiles.clone())?; - validate_string_list("tags", self.tags.clone())?; - validate_permissions(self.permissions.clone())?; - Ok(()) - } -} - -/// 描述符构建期间的校验错误。 -/// -/// 包含字段为空、缺失、schema 格式错误、列表重复等错误类型。 -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum DescriptorBuildError { - /// 必填字段为空字符串 - EmptyField(&'static str), - /// 必填字段缺失 - MissingField(&'static str), - /// Schema 不是有效的 JSON 对象 - InvalidSchema(&'static str), - /// 列表中存在重复值 - DuplicateValue { field: &'static str, value: String }, -} - -impl fmt::Display for DescriptorBuildError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::EmptyField(field) => write!(f, "descriptor field '{field}' cannot be empty"), - Self::MissingField(field) => write!(f, "descriptor field '{field}' is required"), - Self::InvalidSchema(field) => { - write!(f, "descriptor field '{field}' must be a JSON object schema") - }, - Self::DuplicateValue { field, value } => { - write!( - f, - "descriptor field '{field}' contains duplicate value '{value}'" - ) - }, - } - } -} - -impl std::error::Error for DescriptorBuildError {} - -/// `CapabilityDescriptor` 的构建器。 -/// -/// 采用 builder pattern 逐步构造能力描述符,在 `build()` 时执行完整校验。 -/// 相比直接构造 `CapabilityDescriptor`,builder 可以确保所有必填字段都已设置。 -#[derive(Debug, Clone)] -pub struct CapabilityDescriptorBuilder { - name: String, - kind: CapabilityKind, - description: Option<String>, - input_schema: Option<Value>, - output_schema: Option<Value>, - streaming: bool, - concurrency_safe: bool, - compact_clearable: bool, - profiles: Vec<String>, - tags: Vec<String>, - permissions: Vec<PermissionHint>, - side_effect: SideEffectLevel, - stability: StabilityLevel, - metadata: Value, - max_result_inline_size: Option<usize>, -} - -impl CapabilityDescriptorBuilder { - /// 创建新的构建器,仅需名称和能力类型。 - pub fn new(name: impl Into<String>, kind: impl Into<CapabilityKind>) -> Self { - Self { - name: name.into(), - kind: kind.into(), - description: None, - input_schema: None, - output_schema: None, - streaming: false, - concurrency_safe: false, - compact_clearable: false, - profiles: Vec::new(), - tags: Vec::new(), - permissions: Vec::new(), - side_effect: SideEffectLevel::default(), - stability: StabilityLevel::default(), - metadata: Value::Null, - max_result_inline_size: None, - } - } - - /// 设置能力描述。 - pub fn description(mut self, description: impl Into<String>) -> Self { - self.description = Some(description.into()); - self - } - - /// 设置输入 JSON Schema。 - pub fn input_schema(mut self, input_schema: Value) -> Self { - self.input_schema = Some(input_schema); - self - } - - /// 设置输出 JSON Schema。 - pub fn output_schema(mut self, output_schema: Value) -> Self { - self.output_schema = Some(output_schema); - self - } - - /// 同时设置输入和输出 JSON Schema。 - pub fn schema(mut self, input_schema: Value, output_schema: Value) -> Self { - self.input_schema = Some(input_schema); - self.output_schema = Some(output_schema); - self - } - - /// 设置是否支持流式输出。 - pub fn streaming(mut self, streaming: bool) -> Self { - self.streaming = streaming; - self - } - - /// 设置是否可安全并发调用。 - pub fn concurrency_safe(mut self, concurrency_safe: bool) -> Self { - self.concurrency_safe = concurrency_safe; - self - } - - /// 设置在上下文压缩时是否可被清除。 - pub fn compact_clearable(mut self, compact_clearable: bool) -> Self { - self.compact_clearable = compact_clearable; - self - } - - /// 添加单个 profile。 - pub fn profile(mut self, profile: impl Into<String>) -> Self { - self.profiles.push(profile.into()); - self - } - - /// 批量添加 profiles。 - pub fn profiles<I, S>(mut self, profiles: I) -> Self - where - I: IntoIterator<Item = S>, - S: Into<String>, - { - self.profiles.extend(profiles.into_iter().map(Into::into)); - self - } - - /// 添加单个标签。 - pub fn tag(mut self, tag: impl Into<String>) -> Self { - self.tags.push(tag.into()); - self - } - - /// 批量添加标签。 - pub fn tags<I, S>(mut self, tags: I) -> Self - where - I: IntoIterator<Item = S>, - S: Into<String>, - { - self.tags.extend(tags.into_iter().map(Into::into)); - self - } - - /// 添加权限(不含理由)。 - pub fn permission(mut self, name: impl Into<String>) -> Self { - self.permissions.push(PermissionHint { - name: name.into(), - rationale: None, - }); - self - } - - /// 添加权限并附带理由。 - pub fn permission_with_rationale( - mut self, - name: impl Into<String>, - rationale: impl Into<String>, - ) -> Self { - self.permissions.push(PermissionHint { - name: name.into(), - rationale: Some(rationale.into()), - }); - self - } - - /// 批量添加权限。 - pub fn permissions(mut self, permissions: Vec<PermissionHint>) -> Self { - self.permissions.extend(permissions); - self - } - - /// 设置副作用级别。 - pub fn side_effect(mut self, side_effect: SideEffectLevel) -> Self { - self.side_effect = side_effect; - self - } - - /// 设置稳定性级别。 - pub fn stability(mut self, stability: StabilityLevel) -> Self { - self.stability = stability; - self - } - - /// 设置扩展元数据。 - pub fn metadata(mut self, metadata: Value) -> Self { - self.metadata = metadata; - self - } - - /// 设置工具结果内联阈值(字节)。 - pub fn max_result_inline_size(mut self, size: usize) -> Self { - self.max_result_inline_size = Some(size); - self - } - - /// 构建并校验能力描述符。 - /// - /// 校验所有必填字段和格式约束,失败时返回 `DescriptorBuildError`。 - pub fn build(self) -> Result<CapabilityDescriptor, DescriptorBuildError> { - let name = validate_non_empty("name", self.name)?; - let kind = validate_kind(self.kind)?; - let description = validate_non_empty( - "description", - self.description - .ok_or(DescriptorBuildError::MissingField("description"))?, - )?; - let input_schema = validate_schema( - "input_schema", - self.input_schema - .ok_or(DescriptorBuildError::MissingField("input_schema"))?, - )?; - let output_schema = validate_schema( - "output_schema", - self.output_schema - .ok_or(DescriptorBuildError::MissingField("output_schema"))?, - )?; - let profiles = validate_string_list("profiles", self.profiles)?; - let tags = validate_string_list("tags", self.tags)?; - let permissions = validate_permissions(self.permissions)?; - - Ok(CapabilityDescriptor { - name, - kind, - description, - input_schema, - output_schema, - streaming: self.streaming, - concurrency_safe: self.concurrency_safe, - compact_clearable: self.compact_clearable, - profiles, - tags, - permissions, - side_effect: self.side_effect, - stability: self.stability, - metadata: self.metadata, - max_result_inline_size: self.max_result_inline_size, - }) - } -} - -/// serde 辅助函数:判断 bool 是否为 false,用于 `skip_serializing_if`。 -fn is_false(value: &bool) -> bool { - !*value -} - -/// 校验字符串字段非空,返回 trim 后的副本。 -fn validate_non_empty(field: &'static str, value: String) -> Result<String, DescriptorBuildError> { - let trimmed = value.trim(); - if trimmed.is_empty() { - return Err(DescriptorBuildError::EmptyField(field)); - } - Ok(trimmed.to_string()) -} - -/// 校验能力类型非空。 -fn validate_kind(value: CapabilityKind) -> Result<CapabilityKind, DescriptorBuildError> { - Ok(CapabilityKind(validate_non_empty("kind", value.0)?)) -} - -/// 校验 schema 为 JSON 对象类型。 -fn validate_schema(field: &'static str, value: Value) -> Result<Value, DescriptorBuildError> { - if value.is_object() { - Ok(value) - } else { - Err(DescriptorBuildError::InvalidSchema(field)) - } -} - -/// 校验字符串列表:每项非空且无重复。 -/// -/// 使用 `BTreeSet` 进行去重检测,保证确定性排序。 -fn validate_string_list( - field: &'static str, - values: Vec<String>, -) -> Result<Vec<String>, DescriptorBuildError> { - let mut seen = std::collections::BTreeSet::new(); - let mut normalized = Vec::with_capacity(values.len()); - for value in values { - let value = validate_non_empty(field, value)?; - if !seen.insert(value.clone()) { - return Err(DescriptorBuildError::DuplicateValue { field, value }); - } - normalized.push(value); - } - Ok(normalized) -} - -/// 校验权限列表:名称非空且无重复。 -fn validate_permissions( - permissions: Vec<PermissionHint>, -) -> Result<Vec<PermissionHint>, DescriptorBuildError> { - let mut seen = std::collections::BTreeSet::new(); - let mut normalized = Vec::with_capacity(permissions.len()); - for permission in permissions { - let name = validate_non_empty("permissions", permission.name)?; - if !seen.insert(name.clone()) { - return Err(DescriptorBuildError::DuplicateValue { - field: "permissions", - value: name, - }); - } - normalized.push(PermissionHint { - name, - rationale: permission - .rationale - .map(|rationale| rationale.trim().to_string()) - .filter(|rationale| !rationale.is_empty()), - }); - } - Ok(normalized) -} - /// 触发器描述符。 -/// -/// 用于 `HandlerDescriptor` 中定义什么事件会触发此处理器。 -/// `kind` 标识触发器类型(如 `file_change`, `session_start`),`value` 为匹配值。 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct TriggerDescriptor { - /// 触发器类型 pub kind: String, - /// 匹配值 pub value: String, - /// 扩展元数据 #[serde(default)] pub metadata: Value, } /// 过滤器描述符。 -/// -/// 用于 `HandlerDescriptor` 中对事件进行条件过滤。 -/// `field` 为要检查的字段名,`op` 为操作符(如 `eq`, `contains`),`value` 为匹配值。 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct FilterDescriptor { - /// 要过滤的字段名 pub field: String, - /// 过滤操作符 pub op: String, - /// 匹配值 pub value: String, } /// 事件处理器描述符。 -/// -/// 描述一个可以响应特定事件的处理器的触发条件、输入 schema 和权限。 -/// 与 `CapabilityDescriptor` 不同,handler 是被动触发而非主动调用。 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct HandlerDescriptor { - /// 处理器唯一标识 pub id: String, - /// 触发条件 pub trigger: TriggerDescriptor, - /// 输入参数的 JSON Schema pub input_schema: Value, - /// 此处理器可用的 profile 列表 #[serde(default)] pub profiles: Vec<String>, - /// 事件过滤规则列表(所有规则必须满足) #[serde(default)] pub filters: Vec<FilterDescriptor>, - /// 需要的权限列表 #[serde(default)] - pub permissions: Vec<PermissionHint>, + pub permissions: Vec<PermissionSpec>, } /// Profile 描述符。 -/// -/// 描述一个能力 profile 的元数据,包括版本、上下文 schema 等。 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct ProfileDescriptor { - /// profile 名称 pub name: String, - /// profile 版本 pub version: String, - /// profile 描述 pub description: String, - /// 上下文数据的 JSON Schema #[serde(default)] pub context_schema: Value, - /// 扩展元数据 #[serde(default)] pub metadata: Value, } /// 调用方引用。 -/// -/// 在 `InvocationContext` 中标识发起调用的对等方。 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct CallerRef { - /// 调用方 ID pub id: String, - /// 调用方角色 pub role: String, - /// 扩展元数据 #[serde(default)] pub metadata: Value, } /// 工作区引用。 -/// -/// 在 `InvocationContext` 中提供工作区上下文信息。 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct WorkspaceRef { - /// 当前工作目录 #[serde(default, skip_serializing_if = "Option::is_none")] pub working_dir: Option<String>, - /// Git 仓库根目录 #[serde(default, skip_serializing_if = "Option::is_none")] pub repo_root: Option<String>, - /// 当前 Git 分支 #[serde(default, skip_serializing_if = "Option::is_none")] pub branch: Option<String>, - /// 扩展元数据 #[serde(default)] pub metadata: Value, } /// 预算提示。 -/// -/// 在 `InvocationContext` 中为插件提供资源限制建议。 -/// 这些是提示值而非硬性限制,插件应尽力遵守。 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct BudgetHint { - /// 建议的最大执行时间(毫秒) #[serde(default, skip_serializing_if = "Option::is_none")] pub max_duration_ms: Option<u64>, - /// 建议的最大事件数量 #[serde(default, skip_serializing_if = "Option::is_none")] pub max_events: Option<u64>, - /// 建议的最大输出字节数 #[serde(default, skip_serializing_if = "Option::is_none")] pub max_bytes: Option<u64>, } /// 调用上下文。 -/// -/// 每次 `InvokeMessage` 都携带此上下文,为插件提供调用方身份、工作区信息、 -/// 预算限制等元数据,使插件可以做出更智能的决策。 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct InvocationContext { - /// 请求唯一标识 pub request_id: String, - /// 分布式追踪 ID(可选) #[serde(default, skip_serializing_if = "Option::is_none")] pub trace_id: Option<String>, - /// 关联的会话 ID #[serde(default, skip_serializing_if = "Option::is_none")] pub session_id: Option<String>, - /// 调用方信息 #[serde(default, skip_serializing_if = "Option::is_none")] pub caller: Option<CallerRef>, - /// 工作区上下文 #[serde(default, skip_serializing_if = "Option::is_none")] pub workspace: Option<WorkspaceRef>, - /// 超时时间(毫秒),从请求发出开始计算 #[serde(default, skip_serializing_if = "Option::is_none")] pub deadline_ms: Option<u64>, - /// 资源预算建议 #[serde(default, skip_serializing_if = "Option::is_none")] pub budget: Option<BudgetHint>, - /// 当前活跃的 profile 名称 pub profile: String, - /// profile 相关的上下文数据 #[serde(default)] pub profile_context: Value, - /// 扩展元数据 #[serde(default)] pub metadata: Value, } diff --git a/crates/protocol/src/capability/mod.rs b/crates/protocol/src/capability/mod.rs index a66d1ed5..51733213 100644 --- a/crates/protocol/src/capability/mod.rs +++ b/crates/protocol/src/capability/mod.rs @@ -1,29 +1,14 @@ -//! 能力描述符与调用上下文 +//! 能力 wire 描述与调用上下文。 //! -//! 定义插件系统核心的元数据描述结构,是 host 与插件之间 -//! 能力注册、路由、策略决策的基础协议。 -//! -//! ## 主要类型 -//! -//! - **PeerDescriptor**: 通信对等方的身份信息(ID、角色、版本、支持的 profile) -//! - **CapabilityDescriptor**: 能力的完整描述(名称、类型、schema、权限、副作用级别等) -//! - **CapabilityKind**: 能力类型的强类型包装,避免拼写错误导致路由失败 -//! - **HandlerDescriptor**: 事件处理器的描述(触发条件、过滤规则) -//! - **InvocationContext**: 调用时的上下文(调用方、工作区、预算限制等) -//! - **CapabilityDescriptorBuilder**: 构建器模式,用于安全地构造能力描述符 -//! -//! ## 设计原则 -//! -//! - 能力描述符在插件握手时由插件发送给 host,host 据此进行路由和策略决策 -//! - `CapabilityKind` 虽然是字符串包装,但提供了强类型的构造函数(`tool()`, `agent()` 等) -//! - Builder 在 `build()` 时执行完整校验,确保描述符的完整性 -//! - 所有字段都有明确的默认值和 serde 注解,保证序列化兼容性 +//! `CapabilitySpec` 是 canonical owner,也是运行时内部唯一能力语义真相; +//! `protocol` 只保留 `CapabilityWireDescriptor` 这一 transport 名称和 +//! protocol-owned 的上下文字段。 mod descriptors; pub use descriptors::{ - BudgetHint, CallerRef, CapabilityDescriptor, CapabilityDescriptorBuilder, CapabilityKind, - DescriptorBuildError, FilterDescriptor, HandlerDescriptor, InvocationContext, PeerDescriptor, - PeerRole, PermissionHint, ProfileDescriptor, SideEffectLevel, StabilityLevel, - TriggerDescriptor, WorkspaceRef, + BudgetHint, CallerRef, CapabilityKind, CapabilityWireDescriptor, + CapabilityWireDescriptorBuildError, CapabilityWireDescriptorBuilder, FilterDescriptor, + HandlerDescriptor, InvocationContext, InvocationMode, PeerDescriptor, PeerRole, PermissionSpec, + ProfileDescriptor, SideEffect, Stability, TriggerDescriptor, WorkspaceRef, }; diff --git a/crates/protocol/src/http/agent.rs b/crates/protocol/src/http/agent.rs index bad404be..e969b19b 100644 --- a/crates/protocol/src/http/agent.rs +++ b/crates/protocol/src/http/agent.rs @@ -3,27 +3,18 @@ //! 定义 Agent profile 查询、执行、子执行域(sub-run)状态查询等接口的请求/响应结构。 //! 这些 DTO 用于前端展示和管理 Agent 配置、触发 Agent 执行任务以及监控 sub-run 状态。 +pub use astrcode_core::{ + AgentLifecycleStatus as AgentLifecycleDto, AgentProfile as AgentProfileDto, + AgentTurnOutcome as AgentTurnOutcomeDto, ChildSessionLineageKind as ChildSessionLineageKindDto, + ChildSessionNotificationKind as ChildSessionNotificationKindDto, + SubagentContextOverrides as SubagentContextOverridesDto, +}; use serde::{Deserialize, Serialize}; use crate::http::{ ExecutionControlDto, ResolvedSubagentContextOverridesDto, SubRunResultDto, SubRunStorageModeDto, }; -/// 对外暴露的 Agent Profile 摘要。 -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct AgentProfileDto { - pub id: String, - pub name: String, - pub description: String, - pub mode: String, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub allowed_tools: Vec<String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub disallowed_tools: Vec<String>, - // TODO: 未来可能需要添加更多 agent 级执行限制摘要 -} - /// `POST /api/v1/agents/{id}/execute` 请求体。 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] @@ -53,51 +44,6 @@ pub struct AgentExecuteResponseDto { pub agent_id: Option<String>, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct SubagentContextOverridesDto { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub storage_mode: Option<SubRunStorageModeDto>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub inherit_system_instructions: Option<bool>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub inherit_project_instructions: Option<bool>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub inherit_working_dir: Option<bool>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub inherit_policy_upper_bound: Option<bool>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub inherit_cancel_token: Option<bool>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub include_compact_summary: Option<bool>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub include_recent_tail: Option<bool>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub include_recovery_refs: Option<bool>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub include_parent_findings: Option<bool>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub fork_mode: Option<super::event::ForkModeDto>, -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum AgentLifecycleDto { - Pending, - Running, - Idle, - Terminated, -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum AgentTurnOutcomeDto { - Completed, - Failed, - Cancelled, - TokenExceeded, -} - #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub enum SubRunStatusSourceDto { @@ -142,14 +88,6 @@ pub struct SubRunStatusDto { pub resolved_limits: Option<crate::http::ResolvedExecutionLimitsDto>, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum ChildSessionLineageKindDto { - Spawn, - Fork, - Resume, -} - /// 谱系来源快照 DTO,fork/resume 时记录来源上下文。 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] @@ -174,28 +112,3 @@ pub struct ChildAgentRefDto { pub status: AgentLifecycleDto, pub open_session_id: String, } - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum ChildSessionNotificationKindDto { - Started, - ProgressSummary, - Delivered, - Waiting, - Resumed, - Closed, - Failed, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct ChildSessionNotificationDto { - pub notification_id: String, - pub child_ref: ChildAgentRefDto, - pub kind: ChildSessionNotificationKindDto, - pub status: AgentLifecycleDto, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub source_tool_call_id: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub delivery: Option<super::event::ParentDeliveryDto>, -} diff --git a/crates/protocol/src/http/composer.rs b/crates/protocol/src/http/composer.rs index 4f3e99b2..e52c3da7 100644 --- a/crates/protocol/src/http/composer.rs +++ b/crates/protocol/src/http/composer.rs @@ -1,51 +1,14 @@ //! 输入候选(composer options)相关 DTO。 //! -//! 这些 DTO 服务于前端输入框的候选面板,而不是运行时内部的 prompt 组装。 -//! 单独建模的原因是:UI 需要一个稳定、轻量的“可选项投影视图”, -//! 不能直接复用 `SkillSpec` / `CapabilityDescriptor` 这类内部结构。 +//! 单个候选项已经是跨层共享的 canonical 读模型,协议层直接复用 `core`; +//! 外层响应壳仍由 protocol 拥有。 +pub use astrcode_core::{ + ComposerOption as ComposerOptionDto, ComposerOptionActionKind as ComposerOptionActionKindDto, + ComposerOptionKind as ComposerOptionKindDto, +}; use serde::{Deserialize, Serialize}; -/// 输入候选项的来源类别。 -/// -/// `skill` 表示具体的 skill 条目,而不是 `Skill` 加载器 capability 本身。 -/// 保留独立枚举可以明确区分“prompt 资源”与“可调用 capability”两个层次。 -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] -#[serde(rename_all = "snake_case")] -pub enum ComposerOptionKindDto { - Command, - Skill, - Capability, -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] -#[serde(rename_all = "snake_case")] -pub enum ComposerOptionActionKindDto { - InsertText, - ExecuteCommand, -} - -/// 单个输入候选项。 -/// -/// `insert_text` 是前端选择该项后建议写回输入框的文本。 -/// `badges` / `keywords` 让 UI 和本地搜索可以使用统一载荷, -/// 避免前端再去推断来源、标签或 profile。 -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct ComposerOptionDto { - pub kind: ComposerOptionKindDto, - pub id: String, - pub title: String, - pub description: String, - pub insert_text: String, - pub action_kind: ComposerOptionActionKindDto, - pub action_value: String, - #[serde(default)] - pub badges: Vec<String>, - #[serde(default)] - pub keywords: Vec<String>, -} - /// 输入候选列表响应。 /// /// 预留响应外层对象而非直接返回数组,是为了后续在不破坏协议的前提下 diff --git a/crates/protocol/src/http/config.rs b/crates/protocol/src/http/config.rs index 1680db6a..50ca294c 100644 --- a/crates/protocol/src/http/config.rs +++ b/crates/protocol/src/http/config.rs @@ -3,6 +3,7 @@ //! 定义配置查看、保存、连接测试的请求/响应结构。 //! 配置数据包括 profile(提供商配置)、活跃模型选择等。 +pub use astrcode_core::TestConnectionResult as TestResultDto; use serde::{Deserialize, Serialize}; use crate::http::RuntimeStatusDto; @@ -74,17 +75,3 @@ pub struct TestConnectionRequest { /// 要测试的模型 ID pub model: String, } - -/// `POST /api/config/test-connection` 响应体——连接测试结果。 -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct TestResultDto { - /// 连接测试是否成功 - pub success: bool, - /// 实际测试的提供商类型 - pub provider: String, - /// 实际测试的模型 ID - pub model: String, - /// 失败时的错误信息 - pub error: Option<String>, -} diff --git a/crates/protocol/src/http/conversation/v1.rs b/crates/protocol/src/http/conversation/v1.rs index 13c4c55d..3954c73f 100644 --- a/crates/protocol/src/http/conversation/v1.rs +++ b/crates/protocol/src/http/conversation/v1.rs @@ -1,31 +1,450 @@ //! Conversation v1 HTTP / SSE DTO。 //! -//! 当前 authoritative conversation read model 线缆形状与既有 terminal surface -//! 保持一致,但协议职责已经提升为跨 Web/Desktop/TUI 的统一读取面。 - -pub use crate::http::terminal::v1::{ - TerminalAssistantBlockDto as ConversationAssistantBlockDto, - TerminalBannerDto as ConversationBannerDto, - TerminalBannerErrorCodeDto as ConversationBannerErrorCodeDto, - TerminalBlockDto as ConversationBlockDto, TerminalBlockPatchDto as ConversationBlockPatchDto, - TerminalBlockStatusDto as ConversationBlockStatusDto, - TerminalChildHandoffBlockDto as ConversationChildHandoffBlockDto, - TerminalChildHandoffKindDto as ConversationChildHandoffKindDto, - TerminalChildSummaryDto as ConversationChildSummaryDto, - TerminalControlStateDto as ConversationControlStateDto, - TerminalCursorDto as ConversationCursorDto, TerminalDeltaDto as ConversationDeltaDto, - TerminalErrorBlockDto as ConversationErrorBlockDto, - TerminalErrorEnvelopeDto as ConversationErrorEnvelopeDto, - TerminalSlashActionKindDto as ConversationSlashActionKindDto, - TerminalSlashCandidateDto as ConversationSlashCandidateDto, - TerminalSlashCandidatesResponseDto as ConversationSlashCandidatesResponseDto, - TerminalSnapshotResponseDto as ConversationSnapshotResponseDto, - TerminalStreamEnvelopeDto as ConversationStreamEnvelopeDto, - TerminalSystemNoteBlockDto as ConversationSystemNoteBlockDto, - TerminalSystemNoteKindDto as ConversationSystemNoteKindDto, - TerminalThinkingBlockDto as ConversationThinkingBlockDto, - TerminalToolCallBlockDto as ConversationToolCallBlockDto, - TerminalToolStreamBlockDto as ConversationToolStreamBlockDto, - TerminalTranscriptErrorCodeDto as ConversationTranscriptErrorCodeDto, - TerminalUserBlockDto as ConversationUserBlockDto, +//! conversation 是 authoritative hydration / delta 合同,直接表达后端收敛后的 +//! conversation/tool display 语义,不再借 terminal alias 维持假性独立。 + +pub use astrcode_core::{ + CompactAppliedMeta as ConversationCompactMetaDto, + CompactTrigger as ConversationCompactTriggerDto, }; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::http::{AgentLifecycleDto, ChildAgentRefDto, PhaseDto, ToolOutputStreamDto}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(transparent)] +pub struct ConversationCursorDto(pub String); + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ConversationSnapshotResponseDto { + pub session_id: String, + pub session_title: String, + pub cursor: ConversationCursorDto, + pub phase: PhaseDto, + pub control: ConversationControlStateDto, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub blocks: Vec<ConversationBlockDto>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub child_summaries: Vec<ConversationChildSummaryDto>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub slash_candidates: Vec<ConversationSlashCandidateDto>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub banner: Option<ConversationBannerDto>, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ConversationSlashCandidatesResponseDto { + pub items: Vec<ConversationSlashCandidateDto>, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ConversationStreamEnvelopeDto { + pub session_id: String, + pub cursor: ConversationCursorDto, + #[serde(flatten)] + pub delta: ConversationDeltaDto, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde( + tag = "kind", + rename_all = "snake_case", + rename_all_fields = "camelCase" +)] +pub enum ConversationDeltaDto { + AppendBlock { + block: ConversationBlockDto, + }, + PatchBlock { + block_id: String, + patch: ConversationBlockPatchDto, + }, + CompleteBlock { + block_id: String, + status: ConversationBlockStatusDto, + }, + UpdateControlState { + control: ConversationControlStateDto, + }, + UpsertChildSummary { + child: ConversationChildSummaryDto, + }, + RemoveChildSummary { + child_session_id: String, + }, + ReplaceSlashCandidates { + candidates: Vec<ConversationSlashCandidateDto>, + }, + SetBanner { + banner: ConversationBannerDto, + }, + ClearBanner, + RehydrateRequired { + error: ConversationErrorEnvelopeDto, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde( + tag = "kind", + rename_all = "snake_case", + rename_all_fields = "camelCase" +)] +pub enum ConversationBlockPatchDto { + AppendMarkdown { + markdown: String, + }, + ReplaceMarkdown { + markdown: String, + }, + AppendToolStream { + stream: ToolOutputStreamDto, + chunk: String, + }, + ReplaceSummary { + summary: String, + }, + ReplaceMetadata { + metadata: Value, + }, + ReplaceError { + error: Option<String>, + }, + ReplaceDuration { + duration_ms: u64, + }, + ReplaceChildRef { + child_ref: ChildAgentRefDto, + }, + SetTruncated { + truncated: bool, + }, + SetStatus { + status: ConversationBlockStatusDto, + }, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ConversationBlockStatusDto { + Streaming, + Complete, + Failed, + Cancelled, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum ConversationBlockDto { + User(ConversationUserBlockDto), + Assistant(ConversationAssistantBlockDto), + Thinking(ConversationThinkingBlockDto), + Plan(ConversationPlanBlockDto), + ToolCall(ConversationToolCallBlockDto), + Error(ConversationErrorBlockDto), + SystemNote(ConversationSystemNoteBlockDto), + ChildHandoff(ConversationChildHandoffBlockDto), +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ConversationUserBlockDto { + pub id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub turn_id: Option<String>, + pub markdown: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ConversationAssistantBlockDto { + pub id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub turn_id: Option<String>, + pub status: ConversationBlockStatusDto, + pub markdown: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ConversationThinkingBlockDto { + pub id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub turn_id: Option<String>, + pub status: ConversationBlockStatusDto, + pub markdown: String, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ConversationPlanEventKindDto { + Saved, + ReviewPending, + Presented, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ConversationPlanReviewKindDto { + RevisePlan, + FinalReview, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ConversationPlanReviewDto { + pub kind: ConversationPlanReviewKindDto, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub checklist: Vec<String>, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "camelCase")] +pub struct ConversationPlanBlockersDto { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub missing_headings: Vec<String>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub invalid_sections: Vec<String>, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ConversationPlanBlockDto { + pub id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub turn_id: Option<String>, + pub tool_call_id: String, + pub event_kind: ConversationPlanEventKindDto, + pub title: String, + pub plan_path: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub summary: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub status: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub slug: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub updated_at: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub content: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub review: Option<ConversationPlanReviewDto>, + #[serde(default, skip_serializing_if = "is_empty_plan_blockers")] + pub blockers: ConversationPlanBlockersDto, +} + +fn is_empty_plan_blockers(blockers: &ConversationPlanBlockersDto) -> bool { + blockers.missing_headings.is_empty() && blockers.invalid_sections.is_empty() +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "camelCase")] +pub struct ConversationToolStreamsDto { + #[serde(default, skip_serializing_if = "String::is_empty")] + pub stdout: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub stderr: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ConversationToolCallBlockDto { + pub id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub turn_id: Option<String>, + pub tool_call_id: String, + pub tool_name: String, + pub status: ConversationBlockStatusDto, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub input: Option<Value>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub summary: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub duration_ms: Option<u64>, + #[serde(default)] + pub truncated: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub metadata: Option<Value>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub child_ref: Option<ChildAgentRefDto>, + #[serde(default, skip_serializing_if = "is_default_tool_streams")] + pub streams: ConversationToolStreamsDto, +} + +fn is_default_tool_streams(streams: &ConversationToolStreamsDto) -> bool { + streams == &ConversationToolStreamsDto::default() +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ConversationTranscriptErrorCodeDto { + ProviderError, + ContextWindowExceeded, + ToolFatal, + RateLimit, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ConversationErrorBlockDto { + pub id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub turn_id: Option<String>, + pub code: ConversationTranscriptErrorCodeDto, + pub message: String, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ConversationSystemNoteKindDto { + Compact, + SystemNote, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ConversationSystemNoteBlockDto { + pub id: String, + pub note_kind: ConversationSystemNoteKindDto, + pub markdown: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub compact_meta: Option<ConversationLastCompactMetaDto>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub preserved_recent_turns: Option<u32>, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ConversationChildHandoffKindDto { + Delegated, + Progress, + Returned, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ConversationChildHandoffBlockDto { + pub id: String, + pub handoff_kind: ConversationChildHandoffKindDto, + pub child: ConversationChildSummaryDto, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub message: Option<String>, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ConversationChildSummaryDto { + pub child_session_id: String, + pub child_agent_id: String, + pub title: String, + pub lifecycle: AgentLifecycleDto, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub latest_output_summary: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub child_ref: Option<ChildAgentRefDto>, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ConversationSlashActionKindDto { + InsertText, + ExecuteCommand, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ConversationSlashCandidateDto { + pub id: String, + pub title: String, + pub description: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub keywords: Vec<String>, + pub action_kind: ConversationSlashActionKindDto, + pub action_value: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ConversationControlStateDto { + pub phase: PhaseDto, + pub can_submit_prompt: bool, + pub can_request_compact: bool, + #[serde(default)] + pub compact_pending: bool, + #[serde(default)] + pub compacting: bool, + pub current_mode_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub active_turn_id: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_compact_meta: Option<ConversationLastCompactMetaDto>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub active_plan: Option<ConversationPlanReferenceDto>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub active_tasks: Option<Vec<ConversationTaskItemDto>>, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ConversationLastCompactMetaDto { + pub trigger: ConversationCompactTriggerDto, + #[serde(flatten)] + pub meta: ConversationCompactMetaDto, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ConversationPlanReferenceDto { + pub slug: String, + pub path: String, + pub status: String, + pub title: String, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ConversationTaskStatusDto { + Pending, + InProgress, + Completed, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ConversationTaskItemDto { + pub content: String, + pub status: ConversationTaskStatusDto, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub active_form: Option<String>, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ConversationBannerErrorCodeDto { + AuthExpired, + CursorExpired, + StreamDisconnected, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ConversationErrorEnvelopeDto { + pub code: ConversationBannerErrorCodeDto, + pub message: String, + #[serde(default)] + pub rehydrate_required: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub details: Option<Value>, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ConversationBannerDto { + pub error: ConversationErrorEnvelopeDto, +} diff --git a/crates/protocol/src/http/debug.rs b/crates/protocol/src/http/debug.rs deleted file mode 100644 index 0ccd5532..00000000 --- a/crates/protocol/src/http/debug.rs +++ /dev/null @@ -1,128 +0,0 @@ -use serde::{Deserialize, Serialize}; - -use super::{AgentLifecycleDto, AgentTurnOutcomeDto, PhaseDto, RuntimeMetricsDto}; - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct RuntimeDebugOverviewDto { - pub collected_at: String, - pub spawn_rejection_ratio_bps: Option<u64>, - pub metrics: RuntimeMetricsDto, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct RuntimeDebugTimelineSampleDto { - pub collected_at: String, - pub spawn_rejection_ratio_bps: Option<u64>, - pub observe_to_action_ratio_bps: Option<u64>, - pub child_reuse_ratio_bps: Option<u64>, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct RuntimeDebugTimelineDto { - pub window_started_at: String, - pub window_ended_at: String, - pub samples: Vec<RuntimeDebugTimelineSampleDto>, -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub enum SessionDebugTraceItemKindDto { - ToolCall, - ToolResult, - PromptMetrics, - SubRunStarted, - SubRunFinished, - ChildNotification, - CollaborationFact, - MailboxQueued, - MailboxBatchStarted, - MailboxBatchAcked, - MailboxDiscarded, - TurnDone, - Error, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct SessionDebugTraceItemDto { - pub id: String, - pub storage_seq: u64, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub turn_id: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub recorded_at: Option<String>, - pub kind: SessionDebugTraceItemKindDto, - pub title: String, - pub summary: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub agent_id: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub sub_run_id: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub child_agent_id: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub delivery_id: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub tool_call_id: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub tool_name: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub lifecycle: Option<AgentLifecycleDto>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub last_turn_outcome: Option<AgentTurnOutcomeDto>, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct SessionDebugTraceDto { - pub session_id: String, - pub title: String, - pub phase: PhaseDto, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub parent_session_id: Option<String>, - pub items: Vec<SessionDebugTraceItemDto>, -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub enum DebugAgentNodeKindDto { - SessionRoot, - ChildAgent, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct SessionDebugAgentNodeDto { - pub node_id: String, - pub kind: DebugAgentNodeKindDto, - pub title: String, - pub agent_id: String, - pub session_id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub child_session_id: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub sub_run_id: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub parent_agent_id: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub parent_session_id: Option<String>, - pub depth: usize, - pub lifecycle: AgentLifecycleDto, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub last_turn_outcome: Option<AgentTurnOutcomeDto>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub status_source: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub lineage_kind: Option<String>, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct SessionDebugAgentsDto { - pub session_id: String, - pub title: String, - pub nodes: Vec<SessionDebugAgentNodeDto>, -} diff --git a/crates/protocol/src/http/event.rs b/crates/protocol/src/http/event.rs index ec5e8ab6..79f9b0e6 100644 --- a/crates/protocol/src/http/event.rs +++ b/crates/protocol/src/http/event.rs @@ -1,88 +1,34 @@ -//! Agent 事件流 DTO +//! HTTP 事件与执行控制相关 DTO。 //! -//! 定义 Agent 运行期间产生的各类事件的协议格式,用于 SSE 实时推送和会话回放。 -//! 事件采用 `tagged enum` 序列化(`#[serde(tag = "event", content = "data")]`), -//! 前端通过 `event` 字段区分事件类型,`data` 字段获取具体数据。 -//! -//! ## 事件生命周期 -//! -//! 一个完整的 turn 通常产生以下事件序列: -//! `SessionStarted` → `UserMessage` → `PhaseChanged(Thinking)` → `ModelDelta`* → -//! `ToolCallStart` → `ToolCallDelta`* → `ToolCallResult` → `PhaseChanged(Done)` → `TurnDone` +//! 本模块保留仍被真实传输链路消费的阶段、子运行结果、父子交付与执行控制 DTO。 +//! 已删除未落生产链路的 Agent 事件包装层,避免 `protocol` 继续维护一整套空转镜像。 use serde::{Deserialize, Serialize}; -use super::{AgentLifecycleDto, ChildAgentRefDto, ChildSessionNotificationKindDto}; - /// 协议版本号,用于事件格式的版本控制。 /// /// 每个 `AgentEventEnvelope` 都携带此版本号,前端可根据版本号决定如何解析事件。 pub const PROTOCOL_VERSION: u32 = 1; -/// Agent 当前执行阶段。 -/// -/// 前端根据阶段切换 UI 状态(如加载动画、终端视图等)。 -/// 阶段转换通过 `PhaseChanged` 事件通知。 -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub enum PhaseDto { - /// 空闲状态,无活跃 turn - Idle, - /// 模型正在思考(生成 reasoning content) - Thinking, - /// 正在执行工具调用 - CallingTool, - /// 正在流式输出文本内容 - Streaming, - /// 用户中断了当前 turn - Interrupted, - /// 当前 turn 已完成 - Done, -} - -/// 工具输出流类型,区分 stdout 和 stderr。 -/// -/// 用于 `ToolCallDelta` 事件中指示增量输出来自哪个流。 -/// 前端根据此字段将输出渲染到终端视图的不同区域。 -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub enum ToolOutputStreamDto { - /// 标准输出 - Stdout, - /// 标准错误 - Stderr, -} - -/// 上下文压缩触发方式。 -/// -/// 保持协议层独立枚举,避免前端直接依赖 core crate。 -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum CompactTriggerDto { - Auto, - Manual, -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub enum InvocationKindDto { - SubRun, - RootExecution, -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub enum SubRunStorageModeDto { - IndependentSession, -} - -/// Fork 上下文继承模式 DTO。 -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub enum ForkModeDto { - FullHistory, - LastNTurns(usize), -} +/// TODO: 暂时保留 enum 形状与 core 对齐。 +/// 等真正出现第二种存储模式时,再一起设计协议面的判别语义, +/// 不要为了“当前只有一个值”提前把它压扁成难扩展的常量字段。 +pub use astrcode_core::SubRunStorageMode as SubRunStorageModeDto; +pub use astrcode_core::{ + ArtifactRef as ArtifactRefDto, + CloseRequestParentDeliveryPayload as CloseRequestParentDeliveryPayloadDto, + CompletedParentDeliveryPayload as CompletedParentDeliveryPayloadDto, + ExecutionControl as ExecutionControlDto, + FailedParentDeliveryPayload as FailedParentDeliveryPayloadDto, ForkMode as ForkModeDto, + ParentDelivery as ParentDeliveryDto, ParentDeliveryOrigin as ParentDeliveryOriginDto, + ParentDeliveryPayload as ParentDeliveryPayloadDto, + ParentDeliveryTerminalSemantics as ParentDeliveryTerminalSemanticsDto, Phase as PhaseDto, + ProgressParentDeliveryPayload as ProgressParentDeliveryPayloadDto, + ResolvedExecutionLimitsSnapshot as ResolvedExecutionLimitsDto, + ResolvedSubagentContextOverrides as ResolvedSubagentContextOverridesDto, + SubRunFailure as SubRunFailureDto, SubRunFailureCode as SubRunFailureCodeDto, + SubRunHandoff as SubRunHandoffDto, ToolOutputStream as ToolOutputStreamDto, +}; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] @@ -90,515 +36,16 @@ pub enum SubRunOutcomeDto { Running, Completed, Failed, - Aborted, + Cancelled, TokenExceeded, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct ArtifactRefDto { - pub kind: String, - pub id: String, - pub label: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub session_id: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub storage_seq: Option<u64>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub uri: Option<String>, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum SubRunFailureCodeDto { - Transport, - ProviderHttp, - StreamParse, - Interrupted, - Internal, -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum ParentDeliveryOriginDto { - Explicit, - Fallback, -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum ParentDeliveryTerminalSemanticsDto { - NonTerminal, - Terminal, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct ProgressParentDeliveryPayloadDto { - pub message: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct CompletedParentDeliveryPayloadDto { - pub message: String, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub findings: Vec<String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub artifacts: Vec<ArtifactRefDto>, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct FailedParentDeliveryPayloadDto { - pub message: String, - pub code: SubRunFailureCodeDto, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub technical_message: Option<String>, - pub retryable: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct CloseRequestParentDeliveryPayloadDto { - pub message: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub reason: Option<String>, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(tag = "kind", content = "payload", rename_all = "snake_case")] -pub enum ParentDeliveryPayloadDto { - Progress(ProgressParentDeliveryPayloadDto), - Completed(CompletedParentDeliveryPayloadDto), - Failed(FailedParentDeliveryPayloadDto), - CloseRequest(CloseRequestParentDeliveryPayloadDto), -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct ParentDeliveryDto { - pub idempotency_key: String, - pub origin: ParentDeliveryOriginDto, - pub terminal_semantics: ParentDeliveryTerminalSemanticsDto, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub source_turn_id: Option<String>, - #[serde(flatten)] - pub payload: ParentDeliveryPayloadDto, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct SubRunHandoffDto { - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub findings: Vec<String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub artifacts: Vec<ArtifactRefDto>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub delivery: Option<ParentDeliveryDto>, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct SubRunFailureDto { - pub code: SubRunFailureCodeDto, - pub display_message: String, - pub technical_message: String, - pub retryable: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct SubRunResultDto { - pub status: SubRunOutcomeDto, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub handoff: Option<SubRunHandoffDto>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub failure: Option<SubRunFailureDto>, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct ExecutionControlDto { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub max_steps: Option<u32>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub manual_compact: Option<bool>, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct ResolvedSubagentContextOverridesDto { - pub storage_mode: SubRunStorageModeDto, - pub inherit_system_instructions: bool, - pub inherit_project_instructions: bool, - pub inherit_working_dir: bool, - pub inherit_policy_upper_bound: bool, - pub inherit_cancel_token: bool, - pub include_compact_summary: bool, - pub include_recent_tail: bool, - pub include_recovery_refs: bool, - pub include_parent_findings: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub fork_mode: Option<ForkModeDto>, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct ResolvedExecutionLimitsDto { - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub allowed_tools: Vec<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub max_steps: Option<u32>, -} - -/// Agent 父子关系元数据。 -#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct AgentContextDto { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub agent_id: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub parent_turn_id: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub parent_sub_run_id: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub agent_profile: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub sub_run_id: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub invocation_kind: Option<InvocationKindDto>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub storage_mode: Option<SubRunStorageModeDto>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub child_session_id: Option<String>, -} - -impl AgentContextDto { - pub fn is_empty(&self) -> bool { - self.agent_id.is_none() - && self.parent_turn_id.is_none() - && self.parent_sub_run_id.is_none() - && self.agent_profile.is_none() - && self.sub_run_id.is_none() - && self.invocation_kind.is_none() - && self.storage_mode.is_none() - && self.child_session_id.is_none() - } -} - -/// 工具调用的最终结果。 -/// -/// 包含工具执行的完整输出、耗时、是否被截断等信息。 -/// `metadata` 字段携带展示相关的额外信息(如 diff 数据、终端展示提示等)。 -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct ToolCallResultDto { - /// 工具调用的唯一标识,与 `ToolCallStart` 中的 `tool_call_id` 对应 - pub tool_call_id: String, - /// 工具名称 - pub tool_name: String, - /// 工具调用是否成功 - pub ok: bool, - /// 工具的输出内容(成功时为正常输出,失败时为错误摘要) - pub output: String, - /// 失败时的详细错误信息 - pub error: Option<String>, - /// 展示相关的元数据(如 diff 信息、终端展示提示等) - pub metadata: Option<serde_json::Value>, - /// 工具调用耗时(毫秒) - /// - /// 使用 `u64` 而非 `u128`,因为 `u64` 已可表示约 5.8 亿年的毫秒数, - /// 足够覆盖任何合理的工具执行时间。 - pub duration_ms: u64, - /// 输出是否被截断(超出最大长度限制) - pub truncated: bool, -} - -/// Mailbox 消息入队事件载荷。 -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct MailboxQueuedDto { - pub delivery_id: String, - pub from_agent_id: String, - pub to_agent_id: String, - pub message: String, - pub queued_at: String, - pub sender_lifecycle_status: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub sender_last_turn_outcome: Option<String>, - pub sender_open_session_id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub summary: Option<String>, -} - -/// Mailbox 批次开始/确认事件载荷。 -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct MailboxBatchDto { - pub target_agent_id: String, - pub turn_id: String, - pub batch_id: String, - pub delivery_ids: Vec<String>, -} - -/// Mailbox 消息丢弃事件载荷。 -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct MailboxDiscardedDto { - pub target_agent_id: String, - pub delivery_ids: Vec<String>, -} - -/// Agent 事件载荷的 tagged enum。 -/// -/// 采用 `#[serde(tag = "event", content = "data")]` 序列化策略, -/// 每个变体对应一种事件类型。前端通过 `event` 字段路由到不同的处理器。 -/// -/// ## 事件分类 -/// -/// - **会话级**: `SessionStarted` -/// - **用户交互**: `UserMessage` -/// - **阶段变更**: `PhaseChanged` -/// - **模型输出**: `ModelDelta`, `ThinkingDelta`, `AssistantMessage` -/// - **工具调用**: `ToolCallStart`, `ToolCallDelta`, `ToolCallResult` -/// - **生命周期**: `TurnDone` -/// - **错误**: `Error` -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde( - tag = "event", - content = "data", - rename_all = "camelCase", - rename_all_fields = "camelCase" -)] -pub enum AgentEventPayload { - /// 会话开始事件,携带新会话的 ID。 - SessionStarted { session_id: String }, - /// 用户发送消息事件,携带 turn ID 和用户输入内容。 - UserMessage { - turn_id: String, - #[serde(default, flatten, skip_serializing_if = "AgentContextDto::is_empty")] - agent: AgentContextDto, - content: String, - }, - /// Agent 执行阶段变更事件。 - /// - /// `turn_id` 在会话初始阶段可能为 None(如全局阶段切换)。 - PhaseChanged { - turn_id: Option<String>, - #[serde(default, flatten, skip_serializing_if = "AgentContextDto::is_empty")] - agent: AgentContextDto, - phase: PhaseDto, - }, - /// 模型正常输出的增量文本片段。 - /// - /// 前端需将多个 `ModelDelta` 的 `delta` 拼接成完整回复。 - ModelDelta { - turn_id: String, - #[serde(default, flatten, skip_serializing_if = "AgentContextDto::is_empty")] - agent: AgentContextDto, - delta: String, - }, - /// 模型推理过程(thinking/reasoning)的增量输出。 - /// - /// 此内容通常不直接展示给用户,但可用于调试或特殊 UI。 - ThinkingDelta { - turn_id: String, - #[serde(default, flatten, skip_serializing_if = "AgentContextDto::is_empty")] - agent: AgentContextDto, - delta: String, - }, - /// 助手完整消息事件,在模型输出完成后发送。 - /// - /// 包含完整的回复内容和可选的 reasoning content。 - AssistantMessage { - turn_id: String, - #[serde(default, flatten, skip_serializing_if = "AgentContextDto::is_empty")] - agent: AgentContextDto, - content: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - reasoning_content: Option<String>, - }, - /// 工具调用开始事件。 - /// - /// 标记一个工具调用的开始,携带工具名称和完整输入参数。 - /// 输入参数序列化为 `args` 以匹配前端事件格式约定。 - ToolCallStart { - turn_id: String, - #[serde(default, flatten, skip_serializing_if = "AgentContextDto::is_empty")] - agent: AgentContextDto, - tool_call_id: String, - tool_name: String, - #[serde(rename = "args")] - input: serde_json::Value, - }, - /// 工具调用的增量输出事件。 - /// - /// 用于长耗时工具(如 shell 命令)的流式输出。 - /// `stream` 字段区分 stdout/stderr,`delta` 为本次增量内容。 - ToolCallDelta { - turn_id: String, - #[serde(default, flatten, skip_serializing_if = "AgentContextDto::is_empty")] - agent: AgentContextDto, - tool_call_id: String, - tool_name: String, - stream: ToolOutputStreamDto, - delta: String, - }, - /// 工具调用完成事件,携带完整的执行结果。 - ToolCallResult { - turn_id: String, - #[serde(default, flatten, skip_serializing_if = "AgentContextDto::is_empty")] - agent: AgentContextDto, - result: ToolCallResultDto, - }, - /// 上下文压缩已应用。 - CompactApplied { - #[serde(default, skip_serializing_if = "Option::is_none")] - turn_id: Option<String>, - #[serde(default, flatten, skip_serializing_if = "AgentContextDto::is_empty")] - agent: AgentContextDto, - trigger: CompactTriggerDto, - summary: String, - preserved_recent_turns: u32, - }, - SubRunStarted { - #[serde(default, skip_serializing_if = "Option::is_none")] - turn_id: Option<String>, - #[serde(default, flatten, skip_serializing_if = "AgentContextDto::is_empty")] - agent: AgentContextDto, - #[serde(default, skip_serializing_if = "Option::is_none")] - tool_call_id: Option<String>, - resolved_overrides: ResolvedSubagentContextOverridesDto, - resolved_limits: ResolvedExecutionLimitsDto, - }, - SubRunFinished { - #[serde(default, skip_serializing_if = "Option::is_none")] - turn_id: Option<String>, - #[serde(default, flatten, skip_serializing_if = "AgentContextDto::is_empty")] - agent: AgentContextDto, - #[serde(default, skip_serializing_if = "Option::is_none")] - tool_call_id: Option<String>, - result: SubRunResultDto, - step_count: u32, - estimated_tokens: u64, - }, - /// 父会话可消费的子会话摘要通知。 - ChildSessionNotification { - #[serde(default, skip_serializing_if = "Option::is_none")] - turn_id: Option<String>, - #[serde(default, flatten, skip_serializing_if = "AgentContextDto::is_empty")] - agent: AgentContextDto, - child_ref: ChildAgentRefDto, - kind: ChildSessionNotificationKindDto, - status: AgentLifecycleDto, - #[serde(default, skip_serializing_if = "Option::is_none")] - source_tool_call_id: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - delivery: Option<ParentDeliveryDto>, - }, - /// 当前 turn 完成事件。 - TurnDone { - turn_id: String, - #[serde(default, flatten, skip_serializing_if = "AgentContextDto::is_empty")] - agent: AgentContextDto, - }, - /// 错误事件。 - /// - /// `turn_id` 为 None 时表示会话级错误(如连接断开)。 - Error { - turn_id: Option<String>, - #[serde(default, flatten, skip_serializing_if = "AgentContextDto::is_empty")] - agent: AgentContextDto, - code: String, - message: String, - }, - /// Prompt/缓存指标快照,用于前端展示 token 用量等信息。 - PromptMetrics { - #[serde(default, skip_serializing_if = "Option::is_none")] - turn_id: Option<String>, - #[serde(default, flatten, skip_serializing_if = "AgentContextDto::is_empty")] - agent: AgentContextDto, - step_index: u32, - estimated_tokens: u32, - context_window: u32, - effective_window: u32, - threshold_tokens: u32, - truncated_tool_results: u32, - #[serde(default, skip_serializing_if = "Option::is_none")] - provider_input_tokens: Option<u32>, - #[serde(default, skip_serializing_if = "Option::is_none")] - provider_output_tokens: Option<u32>, - #[serde(default, skip_serializing_if = "Option::is_none")] - cache_creation_input_tokens: Option<u32>, - #[serde(default, skip_serializing_if = "Option::is_none")] - cache_read_input_tokens: Option<u32>, - #[serde(default)] - provider_cache_metrics_supported: bool, - #[serde(default)] - prompt_cache_reuse_hits: u32, - #[serde(default)] - prompt_cache_reuse_misses: u32, - }, - /// Mailbox 消息入队事件。 - AgentMailboxQueued { - #[serde(default, skip_serializing_if = "Option::is_none")] - turn_id: Option<String>, - #[serde(default, flatten, skip_serializing_if = "AgentContextDto::is_empty")] - agent: AgentContextDto, - #[serde(flatten)] - payload: MailboxQueuedDto, - }, - /// Mailbox 批次开始消费事件。 - AgentMailboxBatchStarted { - #[serde(default, skip_serializing_if = "Option::is_none")] - turn_id: Option<String>, - #[serde(default, flatten, skip_serializing_if = "AgentContextDto::is_empty")] - agent: AgentContextDto, - #[serde(flatten)] - payload: MailboxBatchDto, - }, - /// Mailbox 批次确认完成事件。 - AgentMailboxBatchAcked { - #[serde(default, skip_serializing_if = "Option::is_none")] - turn_id: Option<String>, - #[serde(default, flatten, skip_serializing_if = "AgentContextDto::is_empty")] - agent: AgentContextDto, - #[serde(flatten)] - payload: MailboxBatchDto, - }, - /// Mailbox 消息丢弃事件。 - AgentMailboxDiscarded { - #[serde(default, skip_serializing_if = "Option::is_none")] - turn_id: Option<String>, - #[serde(default, flatten, skip_serializing_if = "AgentContextDto::is_empty")] - agent: AgentContextDto, - #[serde(flatten)] - payload: MailboxDiscardedDto, - }, -} - -/// Agent 事件信封,为事件载荷添加协议版本等元数据。 -/// -/// 信封结构确保前端可以验证协议版本兼容性。 -/// `#[serde(flatten)]` 使内部 `AgentEventPayload` 的 tagged 字段直接暴露在 JSON 顶层, -/// 即序列化后 `protocol_version`、`event`、`data` 处于同一层级。 -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct AgentEventEnvelope { - /// 协议版本号,用于向前/向后兼容判断 - pub protocol_version: u32, - /// 事件载荷,序列化后其 tag/content 字段会扁平化到信封层级 - #[serde(flatten)] - pub event: AgentEventPayload, -} - -impl AgentEventEnvelope { - /// 创建新的事件信封,自动设置协议版本。 - pub fn new(event: AgentEventPayload) -> Self { - Self { - protocol_version: PROTOCOL_VERSION, - event, - } - } +#[serde(tag = "status", rename_all = "snake_case")] +pub enum SubRunResultDto { + Running { handoff: SubRunHandoffDto }, + Completed { handoff: SubRunHandoffDto }, + TokenExceeded { handoff: SubRunHandoffDto }, + Failed { failure: SubRunFailureDto }, + Cancelled { failure: SubRunFailureDto }, } diff --git a/crates/protocol/src/http/mod.rs b/crates/protocol/src/http/mod.rs index 063dc329..bf0fb277 100644 --- a/crates/protocol/src/http/mod.rs +++ b/crates/protocol/src/http/mod.rs @@ -19,7 +19,6 @@ mod auth; mod composer; mod config; pub mod conversation; -mod debug; mod event; mod model; mod runtime; @@ -30,7 +29,7 @@ mod tool; pub use agent::{ AgentExecuteRequestDto, AgentExecuteResponseDto, AgentLifecycleDto, AgentProfileDto, - AgentTurnOutcomeDto, ChildAgentRefDto, ChildSessionLineageKindDto, ChildSessionNotificationDto, + AgentTurnOutcomeDto, ChildAgentRefDto, ChildSessionLineageKindDto, ChildSessionNotificationKindDto, LineageSnapshotDto, SubRunStatusDto, SubRunStatusSourceDto, SubagentContextOverridesDto, }; @@ -48,26 +47,23 @@ pub use conversation::v1::{ ConversationBlockDto, ConversationBlockPatchDto, ConversationBlockStatusDto, ConversationChildHandoffBlockDto, ConversationChildHandoffKindDto, ConversationChildSummaryDto, ConversationControlStateDto, ConversationCursorDto, ConversationDeltaDto, - ConversationErrorBlockDto, ConversationErrorEnvelopeDto, ConversationSlashActionKindDto, - ConversationSlashCandidateDto, ConversationSlashCandidatesResponseDto, - ConversationSnapshotResponseDto, ConversationStreamEnvelopeDto, ConversationSystemNoteBlockDto, - ConversationSystemNoteKindDto, ConversationThinkingBlockDto, ConversationToolCallBlockDto, - ConversationToolStreamBlockDto, ConversationTranscriptErrorCodeDto, ConversationUserBlockDto, -}; -pub use debug::{ - DebugAgentNodeKindDto, RuntimeDebugOverviewDto, RuntimeDebugTimelineDto, - RuntimeDebugTimelineSampleDto, SessionDebugAgentNodeDto, SessionDebugAgentsDto, - SessionDebugTraceDto, SessionDebugTraceItemDto, SessionDebugTraceItemKindDto, + ConversationErrorBlockDto, ConversationErrorEnvelopeDto, ConversationLastCompactMetaDto, + ConversationPlanBlockDto, ConversationPlanBlockersDto, ConversationPlanEventKindDto, + ConversationPlanReferenceDto, ConversationPlanReviewDto, ConversationPlanReviewKindDto, + ConversationSlashActionKindDto, ConversationSlashCandidateDto, + ConversationSlashCandidatesResponseDto, ConversationSnapshotResponseDto, + ConversationStreamEnvelopeDto, ConversationSystemNoteBlockDto, ConversationSystemNoteKindDto, + ConversationTaskItemDto, ConversationTaskStatusDto, ConversationThinkingBlockDto, + ConversationToolCallBlockDto, ConversationToolStreamsDto, ConversationTranscriptErrorCodeDto, + ConversationUserBlockDto, }; pub use event::{ - AgentContextDto, AgentEventEnvelope, AgentEventPayload, ArtifactRefDto, - CloseRequestParentDeliveryPayloadDto, CompactTriggerDto, CompletedParentDeliveryPayloadDto, - ExecutionControlDto, FailedParentDeliveryPayloadDto, ForkModeDto, InvocationKindDto, - MailboxBatchDto, MailboxDiscardedDto, MailboxQueuedDto, PROTOCOL_VERSION, ParentDeliveryDto, - ParentDeliveryOriginDto, ParentDeliveryPayloadDto, ParentDeliveryTerminalSemanticsDto, - PhaseDto, ProgressParentDeliveryPayloadDto, ResolvedExecutionLimitsDto, - ResolvedSubagentContextOverridesDto, SubRunFailureCodeDto, SubRunFailureDto, SubRunHandoffDto, - SubRunOutcomeDto, SubRunResultDto, SubRunStorageModeDto, ToolCallResultDto, + ArtifactRefDto, CloseRequestParentDeliveryPayloadDto, CompletedParentDeliveryPayloadDto, + ExecutionControlDto, FailedParentDeliveryPayloadDto, ForkModeDto, PROTOCOL_VERSION, + ParentDeliveryDto, ParentDeliveryOriginDto, ParentDeliveryPayloadDto, + ParentDeliveryTerminalSemanticsDto, PhaseDto, ProgressParentDeliveryPayloadDto, + ResolvedExecutionLimitsDto, ResolvedSubagentContextOverridesDto, SubRunFailureCodeDto, + SubRunFailureDto, SubRunHandoffDto, SubRunOutcomeDto, SubRunResultDto, SubRunStorageModeDto, ToolOutputStreamDto, }; pub use model::{CurrentModelInfoDto, ModelOptionDto}; @@ -78,7 +74,8 @@ pub use runtime::{ }; pub use session::{ CompactSessionRequest, CompactSessionResponse, CreateSessionRequest, DeleteProjectResultDto, - PromptAcceptedResponse, PromptRequest, SessionListItem, + ForkSessionRequest, ModeSummaryDto, PromptAcceptedResponse, PromptRequest, + PromptSkillInvocation, SessionListItem, SessionModeStateDto, SwitchModeRequest, }; pub use session_event::{SessionCatalogEventEnvelope, SessionCatalogEventPayload}; pub use terminal::v1::{ diff --git a/crates/protocol/src/http/model.rs b/crates/protocol/src/http/model.rs index fc480ae2..c99b0c21 100644 --- a/crates/protocol/src/http/model.rs +++ b/crates/protocol/src/http/model.rs @@ -1,29 +1,8 @@ -//! 模型信息相关 DTO +//! 模型信息相关 DTO。 //! -//! 定义模型查询接口的请求/响应结构,包括当前活跃模型信息和可用模型列表。 +//! 当前模型信息和模型选项已经是 `core::config::ModelSelection` 的共享语义, +//! 协议层直接复用 canonical owner。 -use serde::{Deserialize, Serialize}; - -/// GET /api/models/current 响应体——当前活跃的模型信息。 -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct CurrentModelInfoDto { - /// 配置文件中的 profile 名称 - pub profile_name: String, - /// 当前使用的模型 ID(如 "claude-3-5-sonnet") - pub model: String, - /// 提供商类型("anthropic" 或 "openai") - pub provider_kind: String, -} - -/// GET /api/models 响应体中的单个模型选项。 -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct ModelOptionDto { - /// 此模型所属的 profile 名称 - pub profile_name: String, - /// 模型 ID - pub model: String, - /// 提供商类型 - pub provider_kind: String, -} +pub use astrcode_core::{ + CurrentModelSelection as CurrentModelInfoDto, ModelOption as ModelOptionDto, +}; diff --git a/crates/protocol/src/http/runtime.rs b/crates/protocol/src/http/runtime.rs index e63c7629..8de733f0 100644 --- a/crates/protocol/src/http/runtime.rs +++ b/crates/protocol/src/http/runtime.rs @@ -3,6 +3,14 @@ //! 定义运行时健康检查、指标查询、插件状态等接口的响应结构。 //! 这些数据用于前端展示系统运行状态、性能指标和插件健康度。 +pub use astrcode_core::{ + AgentCollaborationScorecardSnapshot as AgentCollaborationScorecardDto, + ExecutionDiagnosticsSnapshot as ExecutionDiagnosticsDto, + OperationMetricsSnapshot as OperationMetricsDto, PluginHealth as PluginHealthDto, + PluginState as PluginRuntimeStateDto, ReplayMetricsSnapshot as ReplayMetricsDto, + RuntimeObservabilitySnapshot as RuntimeMetricsDto, + SubRunExecutionMetricsSnapshot as SubRunExecutionMetricsDto, +}; use serde::{Deserialize, Serialize}; /// 运行时能力的摘要信息。 @@ -24,156 +32,7 @@ pub struct RuntimeCapabilityDto { pub streaming: bool, } -/// 操作级别的指标统计。 -/// -/// 记录某类操作的总次数、失败次数、耗时等,用于性能监控。 -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct OperationMetricsDto { - /// 总操作次数 - pub total: u64, - /// 失败次数 - pub failures: u64, - /// 累计耗时(毫秒) - pub total_duration_ms: u64, - /// 最近一次操作耗时(毫秒) - pub last_duration_ms: u64, - /// 最大单次操作耗时(毫秒) - pub max_duration_ms: u64, -} - -/// 事件回放相关的指标。 -/// -/// 记录 SSE 断线重连时从磁盘/缓存恢复事件的统计信息。 -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct ReplayMetricsDto { - /// 回放操作的总体指标 - pub totals: OperationMetricsDto, - /// 缓存命中次数 - pub cache_hits: u64, - /// 回退到磁盘读取的次数 - pub disk_fallbacks: u64, - /// 成功恢复的事件数量 - pub recovered_events: u64, -} - -/// 运行时整体指标。 -/// -/// 包含会话重连、SSE 追赶回放、turn 执行三个维度的指标。 -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct RuntimeMetricsDto { - /// 会话重连(rehydrate)指标 - pub session_rehydrate: OperationMetricsDto, - /// SSE 断线重连后的回放指标 - pub sse_catch_up: ReplayMetricsDto, - /// turn 执行指标 - pub turn_execution: OperationMetricsDto, - /// 子执行域共享观测指标 - pub subrun_execution: SubRunExecutionMetricsDto, - /// delivery / lineage / cache 诊断指标 - pub execution_diagnostics: ExecutionDiagnosticsDto, - /// agent-tool 协作效果评估读模型 - pub agent_collaboration: AgentCollaborationScorecardDto, -} - -/// 子执行域共享观测指标。 -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct SubRunExecutionMetricsDto { - pub total: u64, - pub failures: u64, - pub completed: u64, - pub aborted: u64, - pub token_exceeded: u64, - pub independent_session_total: u64, - pub total_duration_ms: u64, - pub last_duration_ms: u64, - pub total_steps: u64, - pub last_step_count: u64, - pub total_estimated_tokens: u64, - pub last_estimated_tokens: u64, -} - -/// 结构化执行诊断指标。 -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct ExecutionDiagnosticsDto { - pub child_spawned: u64, - pub child_started_persisted: u64, - pub child_terminal_persisted: u64, - pub parent_reactivation_requested: u64, - pub parent_reactivation_succeeded: u64, - pub parent_reactivation_failed: u64, - pub lineage_mismatch_parent_agent: u64, - pub lineage_mismatch_parent_session: u64, - pub lineage_mismatch_child_session: u64, - pub lineage_mismatch_descriptor_missing: u64, - pub cache_reuse_hits: u64, - pub cache_reuse_misses: u64, - pub delivery_buffer_queued: u64, - pub delivery_buffer_dequeued: u64, - pub delivery_buffer_wake_requested: u64, - pub delivery_buffer_wake_succeeded: u64, - pub delivery_buffer_wake_failed: u64, -} - -/// agent-tool 协作效果评估 DTO。 -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct AgentCollaborationScorecardDto { - pub total_facts: u64, - pub spawn_accepted: u64, - pub spawn_rejected: u64, - pub send_reused: u64, - pub send_queued: u64, - pub send_rejected: u64, - pub observe_calls: u64, - pub observe_rejected: u64, - pub observe_followed_by_action: u64, - pub close_calls: u64, - pub close_rejected: u64, - pub delivery_delivered: u64, - pub delivery_consumed: u64, - pub delivery_replayed: u64, - pub orphan_child_count: u64, - pub child_reuse_ratio_bps: Option<u64>, - pub observe_to_action_ratio_bps: Option<u64>, - pub spawn_to_delivery_ratio_bps: Option<u64>, - pub orphan_child_ratio_bps: Option<u64>, - pub avg_delivery_latency_ms: Option<u64>, - pub max_delivery_latency_ms: Option<u64>, -} - /// 插件运行时状态。 -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub enum PluginRuntimeStateDto { - /// 已发现但尚未初始化 - Discovered, - /// 已初始化并可用 - Initialized, - /// 初始化或运行期间失败 - Failed, -} - -/// 插件健康度。 -/// -/// 用于前端展示插件的可用性状态。 -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub enum PluginHealthDto { - /// 尚未进行健康检查 - Unknown, - /// 正常运行 - Healthy, - /// 部分功能降级 - Degraded, - /// 不可用 - Unavailable, -} - /// 运行时中单个插件的状态摘要。 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] diff --git a/crates/protocol/src/http/session.rs b/crates/protocol/src/http/session.rs index dff748b7..9d24498f 100644 --- a/crates/protocol/src/http/session.rs +++ b/crates/protocol/src/http/session.rs @@ -3,6 +3,7 @@ //! 定义会话创建、列表、提示词提交、消息历史等接口的请求/响应结构。 //! 会话是 Astrcode 的核心概念,代表一次独立的 AI 辅助编程交互。 +pub use astrcode_core::DeleteProjectResult as DeleteProjectResultDto; use serde::{Deserialize, Serialize}; use super::{ExecutionControlDto, PhaseDto}; @@ -17,6 +18,16 @@ pub struct CreateSessionRequest { pub working_dir: String, } +/// `POST /api/sessions/:id/fork` 请求体——从稳定前缀分叉新会话。 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ForkSessionRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub turn_id: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub storage_seq: Option<u64>, +} + /// 会话列表中的单个会话摘要。 /// /// 用于 `GET /api/sessions` 响应,返回所有会话的概览信息。 @@ -46,12 +57,51 @@ pub struct SessionListItem { pub phase: PhaseDto, } +/// 可供 session 使用的 mode 摘要。 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ModeSummaryDto { + pub id: String, + pub name: String, + pub description: String, +} + +/// session 当前治理 mode 状态。 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SessionModeStateDto { + pub current_mode_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_mode_changed_at: Option<String>, +} + +/// `POST /api/sessions/:id/mode` 请求体。 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SwitchModeRequest { + pub mode_id: String, +} + +/// `POST /api/sessions/:id/prompt` 请求体——向会话提交用户提示词。 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct PromptSkillInvocation { + /// 用户显式选择的 skill id(kebab-case)。 + pub skill_id: String, + /// slash 命令头之后剩余的用户提示词。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub user_prompt: Option<String>, +} + /// `POST /api/sessions/:id/prompt` 请求体——向会话提交用户提示词。 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct PromptRequest { /// 用户输入的文本内容 pub text: String, + /// 用户通过一级 slash 命令显式点名的 skill。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub skill_invocation: Option<PromptSkillInvocation>, #[serde(default, skip_serializing_if = "Option::is_none")] pub control: Option<ExecutionControlDto>, } @@ -80,6 +130,8 @@ pub struct PromptAcceptedResponse { pub struct CompactSessionRequest { #[serde(default, skip_serializing_if = "Option::is_none")] pub control: Option<ExecutionControlDto>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub instructions: Option<String>, } /// `POST /api/sessions/:id/compact` 响应体。 @@ -90,16 +142,3 @@ pub struct CompactSessionResponse { pub deferred: bool, pub message: String, } - -/// `DELETE /api/projects/:working_dir` 响应体——项目删除结果。 -/// -/// 由于项目下可能有多个会话,删除是批量操作。 -/// `failed_session_ids` 列出删除失败的会话 ID(如文件被锁定)。 -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct DeleteProjectResultDto { - /// 成功删除的会话数量 - pub success_count: usize, - /// 删除失败的会话 ID 列表 - pub failed_session_ids: Vec<String>, -} diff --git a/crates/protocol/src/http/session_event.rs b/crates/protocol/src/http/session_event.rs index 7db6d464..1fe04370 100644 --- a/crates/protocol/src/http/session_event.rs +++ b/crates/protocol/src/http/session_event.rs @@ -1,37 +1,13 @@ -//! 会话目录事件 DTO +//! 会话目录事件 DTO。 //! -//! 定义会话生命周期中的目录级事件(创建、删除、分支), -//! 通过 SSE 广播通知前端更新会话列表视图。 -//! 与 `event.rs` 中的 Agent 事件不同,这些事件关注会话管理而非 Agent 执行。 +//! 目录事件载荷已经是共享语义,协议层直接复用 `core`; +//! 外层信封仍由 protocol 拥有。 +pub use astrcode_core::SessionCatalogEvent as SessionCatalogEventPayload; use serde::{Deserialize, Serialize}; use crate::http::PROTOCOL_VERSION; -/// 会话目录事件载荷的 tagged enum。 -/// -/// 采用 `#[serde(tag = "event", content = "data")]` 序列化策略。 -/// 这些事件由 server 在会话生命周期变更时广播,前端据此更新会话列表。 -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(tag = "event", content = "data", rename_all = "camelCase")] -pub enum SessionCatalogEventPayload { - /// 新会话创建事件。 - SessionCreated { session_id: String }, - /// 会话删除事件。 - SessionDeleted { session_id: String }, - /// 整个项目(工作目录)删除事件。 - /// - /// 删除项目会级联删除其下所有会话。 - ProjectDeleted { working_dir: String }, - /// 会话分支事件。 - /// - /// 当从一个现有会话分支出新会话时触发。 - SessionBranched { - session_id: String, - source_session_id: String, - }, -} - /// 会话目录事件信封。 /// /// 为事件载荷添加协议版本号,确保前端可以验证兼容性。 diff --git a/crates/protocol/src/http/terminal/v1.rs b/crates/protocol/src/http/terminal/v1.rs index 4935dcb3..8287c64e 100644 --- a/crates/protocol/src/http/terminal/v1.rs +++ b/crates/protocol/src/http/terminal/v1.rs @@ -108,6 +108,9 @@ pub enum TerminalBlockPatchDto { ReplaceSummary { summary: String, }, + ReplaceMetadata { + metadata: Value, + }, SetStatus { status: TerminalBlockStatusDto, }, @@ -175,7 +178,11 @@ pub struct TerminalToolCallBlockDto { pub tool_name: String, pub status: TerminalBlockStatusDto, #[serde(default, skip_serializing_if = "Option::is_none")] + pub input: Option<Value>, + #[serde(default, skip_serializing_if = "Option::is_none")] pub summary: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub metadata: Option<Value>, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] diff --git a/crates/protocol/src/plugin/handshake.rs b/crates/protocol/src/plugin/handshake.rs index cf10777f..934a4009 100644 --- a/crates/protocol/src/plugin/handshake.rs +++ b/crates/protocol/src/plugin/handshake.rs @@ -10,17 +10,18 @@ //! //! 握手完成后,双方进入正常的调用/事件流阶段。 +use astrcode_core::GovernanceModeSpec; use serde::{Deserialize, Serialize}; use serde_json::Value; use super::{ - CapabilityDescriptor, HandlerDescriptor, PeerDescriptor, ProfileDescriptor, SkillDescriptor, + CapabilityWireDescriptor, HandlerDescriptor, PeerDescriptor, ProfileDescriptor, SkillDescriptor, }; /// 插件协议版本号。 /// -/// 当前版本为 "4",与 ADR 0001 中定义的 wire model 版本一致。 -pub const PROTOCOL_VERSION: &str = "4"; +/// 当前版本为 "5",与 capability wire shape 的 `invocationMode` 收口一致。 +pub const PROTOCOL_VERSION: &str = "5"; /// 握手初始化消息,由 host 发送给插件。 /// @@ -41,7 +42,7 @@ pub struct InitializeMessage { pub peer: PeerDescriptor, /// host 暴露的能力列表 #[serde(default)] - pub capabilities: Vec<CapabilityDescriptor>, + pub capabilities: Vec<CapabilityWireDescriptor>, /// host 注册的事件处理器列表 #[serde(default)] pub handlers: Vec<HandlerDescriptor>, @@ -63,6 +64,11 @@ pub struct InitializeMessage { /// 插件可以通过 `skills` 字段声明自己提供的 skill。Host 将这些声明解析为 /// `SkillSpec`(来源标记为 `Plugin`),并统一纳入 `SkillCatalog` 管理。 /// Skill 资产文件会在初始化时被物化到 runtime 缓存目录。 +/// +/// ## Mode 声明 +/// +/// 插件也可以通过 `modes` 字段声明治理 mode。Host 会在 bootstrap / reload 时 +/// 把这些 mode 注册到 `ModeCatalog`,并在后续 turn 编译阶段纳入统一治理装配。 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct InitializeResultData { @@ -72,7 +78,7 @@ pub struct InitializeResultData { pub peer: PeerDescriptor, /// 插件注册的能力列表 #[serde(default)] - pub capabilities: Vec<CapabilityDescriptor>, + pub capabilities: Vec<CapabilityWireDescriptor>, /// 插件注册的事件处理器列表 #[serde(default)] pub handlers: Vec<HandlerDescriptor>, @@ -85,6 +91,12 @@ pub struct InitializeResultData { /// Skill 资产文件会被物化到 runtime 缓存目录供运行时访问。 #[serde(default, skip_serializing_if = "Vec::is_empty")] pub skills: Vec<SkillDescriptor>, + /// 插件声明的治理 mode 列表。 + /// + /// 这些 mode 会被 host 校验后注册到 `ModeCatalog`,供 session 切换与 turn + /// governance 编译复用。 + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub modes: Vec<GovernanceModeSpec>, /// 扩展元数据 #[serde(default)] pub metadata: Value, diff --git a/crates/protocol/src/plugin/mod.rs b/crates/protocol/src/plugin/mod.rs index 1264789c..260c0d74 100644 --- a/crates/protocol/src/plugin/mod.rs +++ b/crates/protocol/src/plugin/mod.rs @@ -31,8 +31,8 @@ pub use messages::{ pub use skill_descriptor::{SkillAssetDescriptor, SkillDescriptor}; pub use crate::capability::{ - BudgetHint, CallerRef, CapabilityDescriptor, CapabilityDescriptorBuilder, CapabilityKind, - DescriptorBuildError, FilterDescriptor, HandlerDescriptor, InvocationContext, PeerDescriptor, - PeerRole, PermissionHint, ProfileDescriptor, SideEffectLevel, StabilityLevel, - TriggerDescriptor, WorkspaceRef, + BudgetHint, CallerRef, CapabilityKind, CapabilityWireDescriptor, + CapabilityWireDescriptorBuildError, CapabilityWireDescriptorBuilder, FilterDescriptor, + HandlerDescriptor, InvocationContext, InvocationMode, PeerDescriptor, PeerRole, PermissionSpec, + ProfileDescriptor, SideEffect, Stability, TriggerDescriptor, WorkspaceRef, }; diff --git a/crates/protocol/src/plugin/tests.rs b/crates/protocol/src/plugin/tests.rs index 83eb4c15..38aac0c7 100644 --- a/crates/protocol/src/plugin/tests.rs +++ b/crates/protocol/src/plugin/tests.rs @@ -3,14 +3,18 @@ //! 验证各类消息(初始化、调用、事件、结果等)的序列化/反序列化 //! 是否正确,确保 JSON 格式与协议版本兼容。 +use astrcode_core::{ + ActionPolicies, CapabilitySelector, ChildPolicySpec, GovernanceModeSpec, + ModeExecutionPolicySpec, ModeId, TransitionPolicySpec, +}; use serde_json::json; use super::{ - CancelMessage, CapabilityDescriptor, CapabilityKind, DescriptorBuildError, ErrorPayload, - EventMessage, EventPhase, FilterDescriptor, HandlerDescriptor, InitializeMessage, - InitializeResultData, InvocationContext, PROTOCOL_VERSION, PeerDescriptor, PeerRole, - PermissionHint, PluginMessage, ProfileDescriptor, ResultMessage, SideEffectLevel, - StabilityLevel, TriggerDescriptor, WorkspaceRef, + CancelMessage, CapabilityKind, CapabilityWireDescriptor, CapabilityWireDescriptorBuildError, + ErrorPayload, EventMessage, EventPhase, FilterDescriptor, HandlerDescriptor, InitializeMessage, + InitializeResultData, InvocationContext, InvocationMode, PROTOCOL_VERSION, PeerDescriptor, + PeerRole, PermissionSpec, PluginMessage, ProfileDescriptor, ResultMessage, SideEffect, + Stability, TriggerDescriptor, WorkspaceRef, }; fn sample_peer() -> PeerDescriptor { @@ -24,25 +28,25 @@ fn sample_peer() -> PeerDescriptor { } } -fn sample_capability() -> CapabilityDescriptor { - CapabilityDescriptor::builder("tool.echo", CapabilityKind::tool()) +fn sample_capability() -> CapabilityWireDescriptor { + CapabilityWireDescriptor::builder("tool.echo", CapabilityKind::tool()) .description("Echo the input") .schema(json!({ "type": "object" }), json!({ "type": "object" })) - .streaming(true) + .invocation_mode(InvocationMode::Streaming) .profile("coding") .tag("test") - .permissions(vec![PermissionHint { + .permissions(vec![PermissionSpec { name: "filesystem.read".to_string(), rationale: Some("reads fixtures".to_string()), }]) - .side_effect(SideEffectLevel::Local) - .stability(StabilityLevel::Stable) + .side_effect(SideEffect::Local) + .stability(Stability::Stable) .build() .expect("sample capability should build") } #[test] -fn plugin_messages_roundtrip_as_v4_json() { +fn plugin_messages_roundtrip_as_v5_json() { let init = PluginMessage::Initialize(InitializeMessage { id: "init-1".to_string(), protocol_version: PROTOCOL_VERSION.to_string(), @@ -117,6 +121,7 @@ fn plugin_messages_roundtrip_as_v4_json() { handlers: vec![], profiles: vec![], skills: vec![], + modes: vec![], metadata: json!({}), }) .expect("serialize initialize result"), @@ -158,6 +163,7 @@ fn initialize_result_uses_result_kind_payload() { handlers: vec![], profiles: vec![], skills: vec![], + modes: vec![], metadata: json!({ "mode": "stdio" }), }) .expect("serialize initialize result"), @@ -168,7 +174,41 @@ fn initialize_result_uses_result_kind_payload() { let decoded: InitializeResultData = result.parse_output().expect("parse output"); assert_eq!(decoded.protocol_version, PROTOCOL_VERSION); assert_eq!(decoded.peer.role, PeerRole::Worker); - assert_eq!(decoded.capabilities[0].name, "tool.echo"); + assert_eq!(decoded.capabilities[0].name.as_str(), "tool.echo"); +} + +#[test] +fn initialize_result_serializes_declared_modes() { + let mode = GovernanceModeSpec { + id: ModeId::from("plugin.plan-lite"), + name: "Plan Lite".to_string(), + description: "Plugin-provided planning mode.".to_string(), + capability_selector: CapabilitySelector::Tag("read-only".to_string()), + action_policies: ActionPolicies::default(), + child_policy: ChildPolicySpec::default(), + execution_policy: ModeExecutionPolicySpec::default(), + prompt_program: vec![], + transition_policy: TransitionPolicySpec { + allowed_targets: vec![ModeId::code()], + }, + }; + let result = InitializeResultData { + protocol_version: PROTOCOL_VERSION.to_string(), + peer: sample_peer(), + capabilities: vec![], + handlers: vec![], + profiles: vec![], + skills: vec![], + modes: vec![mode.clone()], + metadata: json!({}), + }; + + let encoded = serde_json::to_value(&result).expect("initialize result should serialize"); + assert_eq!(encoded["modes"][0]["id"], "plugin.plan-lite"); + + let decoded: InitializeResultData = + serde_json::from_value(encoded).expect("initialize result should deserialize"); + assert_eq!(decoded.modes, vec![mode]); } #[test] @@ -235,19 +275,22 @@ fn result_message_preserves_error_payload_details() { #[test] fn capability_builder_rejects_invalid_fields() { - let error = CapabilityDescriptor::builder("tool.echo", CapabilityKind::tool()) + let error = CapabilityWireDescriptor::builder("tool.echo", CapabilityKind::tool()) .description("Echo the input") .schema(json!({ "type": "object" }), json!("not-a-schema")) .profile("coding") .build() .expect_err("invalid output schema should fail"); - assert_eq!(error, DescriptorBuildError::InvalidSchema("output_schema")); + assert_eq!( + error, + CapabilityWireDescriptorBuildError::InvalidSchema("output_schema") + ); } #[test] fn capability_builder_accepts_custom_kind_strings() { - let descriptor = CapabilityDescriptor::builder("workspace.index", "lsp.indexer") + let descriptor = CapabilityWireDescriptor::builder("workspace.index", "lsp.indexer") .description("Indexes workspace symbols") .schema(json!({ "type": "object" }), json!({ "type": "object" })) .build() @@ -262,13 +305,16 @@ fn capability_builder_accepts_custom_kind_strings() { #[test] fn capability_builder_rejects_blank_custom_kind() { - let error = CapabilityDescriptor::builder("workspace.index", CapabilityKind::new(" ")) + let error = CapabilityWireDescriptor::builder("workspace.index", CapabilityKind::new(" ")) .description("Indexes workspace symbols") .schema(json!({ "type": "object" }), json!({ "type": "object" })) .build() .expect_err("blank kind should fail"); - assert_eq!(error, DescriptorBuildError::EmptyField("kind")); + assert_eq!( + error, + CapabilityWireDescriptorBuildError::EmptyField("kind") + ); } #[test] @@ -281,20 +327,20 @@ fn capability_kind_deserialization_trims_whitespace() { #[test] fn capability_validate_rejects_direct_blank_kind() { - let descriptor = CapabilityDescriptor { - name: "workspace.index".to_string(), + let descriptor = CapabilityWireDescriptor { + name: "workspace.index".into(), kind: CapabilityKind::new(" "), description: "Indexes workspace symbols".to_string(), input_schema: json!({ "type": "object" }), output_schema: json!({ "type": "object" }), - streaming: false, + invocation_mode: InvocationMode::Unary, concurrency_safe: false, compact_clearable: false, profiles: vec![], tags: vec![], permissions: vec![], - side_effect: SideEffectLevel::None, - stability: StabilityLevel::Stable, + side_effect: SideEffect::None, + stability: Stability::Stable, metadata: json!({}), max_result_inline_size: None, }; @@ -303,6 +349,6 @@ fn capability_validate_rejects_direct_blank_kind() { descriptor .validate() .expect_err("direct descriptor validation should reject blank kind"), - DescriptorBuildError::EmptyField("kind") + CapabilityWireDescriptorBuildError::EmptyField("kind") ); } diff --git a/crates/protocol/tests/conformance.rs b/crates/protocol/tests/conformance.rs index e1eb41fb..70881754 100644 --- a/crates/protocol/tests/conformance.rs +++ b/crates/protocol/tests/conformance.rs @@ -1,20 +1,20 @@ use astrcode_protocol::plugin::{ - BudgetHint, CallerRef, CancelMessage, CapabilityDescriptor, CapabilityKind, ErrorPayload, + BudgetHint, CallerRef, CancelMessage, CapabilityKind, CapabilityWireDescriptor, ErrorPayload, EventMessage, EventPhase, FilterDescriptor, HandlerDescriptor, InitializeMessage, - InitializeResultData, InvocationContext, PROTOCOL_VERSION, PeerDescriptor, PeerRole, - PermissionHint, PluginMessage, ProfileDescriptor, ResultMessage, SideEffectLevel, - StabilityLevel, TriggerDescriptor, WorkspaceRef, + InitializeResultData, InvocationContext, InvocationMode, PROTOCOL_VERSION, PeerDescriptor, + PeerRole, PermissionSpec, PluginMessage, ProfileDescriptor, ResultMessage, SideEffect, + Stability, TriggerDescriptor, WorkspaceRef, }; use serde_json::{Value, json}; fn fixture(name: &str) -> Value { let path = match name { - "initialize" => include_str!("fixtures/v4/initialize.json"), - "invoke" => include_str!("fixtures/v4/invoke.json"), - "result_initialize" => include_str!("fixtures/v4/result_initialize.json"), - "result_error" => include_str!("fixtures/v4/result_error.json"), - "event_delta" => include_str!("fixtures/v4/event_delta.json"), - "cancel" => include_str!("fixtures/v4/cancel.json"), + "initialize" => include_str!("fixtures/v5/initialize.json"), + "invoke" => include_str!("fixtures/v5/invoke.json"), + "result_initialize" => include_str!("fixtures/v5/result_initialize.json"), + "result_error" => include_str!("fixtures/v5/result_error.json"), + "event_delta" => include_str!("fixtures/v5/event_delta.json"), + "cancel" => include_str!("fixtures/v5/cancel.json"), other => panic!("unknown fixture {other}"), }; serde_json::from_str(path).expect("fixture should be valid JSON") @@ -31,9 +31,9 @@ fn sample_peer() -> PeerDescriptor { } } -fn sample_capability() -> CapabilityDescriptor { - CapabilityDescriptor { - name: "workspace.summary".to_string(), +fn sample_capability() -> CapabilityWireDescriptor { + CapabilityWireDescriptor { + name: "workspace.summary".into(), kind: CapabilityKind::tool(), description: "Summarize the active coding workspace.".to_string(), input_schema: json!({ @@ -41,17 +41,17 @@ fn sample_capability() -> CapabilityDescriptor { "properties": {} }), output_schema: json!({ "type": "object" }), - streaming: false, + invocation_mode: InvocationMode::Unary, concurrency_safe: false, compact_clearable: false, profiles: vec!["coding".to_string()], tags: vec!["workspace".to_string(), "summary".to_string()], - permissions: vec![PermissionHint { + permissions: vec![PermissionSpec { name: "filesystem.read".to_string(), rationale: Some("Need to inspect the active repository.".to_string()), }], - side_effect: SideEffectLevel::None, - stability: StabilityLevel::Stable, + side_effect: SideEffect::None, + stability: Stability::Stable, metadata: Value::Null, max_result_inline_size: None, } @@ -81,7 +81,7 @@ fn sample_profile() -> ProfileDescriptor { } #[test] -fn initialize_fixture_matches_protocol_v4_shape() { +fn initialize_fixture_matches_protocol_v5_shape() { let expected = PluginMessage::Initialize(InitializeMessage { id: "init-1".to_string(), protocol_version: PROTOCOL_VERSION.to_string(), @@ -102,7 +102,7 @@ fn initialize_fixture_matches_protocol_v4_shape() { op: "eq".to_string(), value: "coding".to_string(), }], - permissions: vec![PermissionHint { + permissions: vec![PermissionSpec { name: "filesystem.read".to_string(), rationale: Some("Reads workspace files.".to_string()), }], @@ -193,6 +193,7 @@ fn result_initialize_fixture_freezes_handshake_response_shape() { handlers: vec![], profiles: vec![sample_profile()], skills: vec![], + modes: vec![], metadata: json!({ "transport": "stdio" }), }) .expect("initialize result should serialize"), @@ -214,7 +215,7 @@ fn result_initialize_fixture_freezes_handshake_response_shape() { }; let handshake: InitializeResultData = result.parse_output().expect("output should parse"); assert_eq!(handshake.protocol_version, PROTOCOL_VERSION); - assert_eq!(handshake.capabilities[0].name, "workspace.summary"); + assert_eq!(handshake.capabilities[0].name.as_str(), "workspace.summary"); } #[test] diff --git a/crates/protocol/tests/conversation_conformance.rs b/crates/protocol/tests/conversation_conformance.rs new file mode 100644 index 00000000..f79681f8 --- /dev/null +++ b/crates/protocol/tests/conversation_conformance.rs @@ -0,0 +1,222 @@ +use astrcode_core::{CompactAppliedMeta, CompactMode, CompactTrigger}; +use astrcode_protocol::http::{ + AgentLifecycleDto, ChildAgentRefDto, ChildSessionLineageKindDto, + ConversationBannerErrorCodeDto, ConversationBlockDto, ConversationBlockPatchDto, + ConversationBlockStatusDto, ConversationControlStateDto, ConversationCursorDto, + ConversationDeltaDto, ConversationErrorEnvelopeDto, ConversationLastCompactMetaDto, + ConversationPlanBlockDto, ConversationPlanBlockersDto, ConversationPlanEventKindDto, + ConversationPlanReviewDto, ConversationPlanReviewKindDto, ConversationSnapshotResponseDto, + ConversationStreamEnvelopeDto, ConversationSystemNoteBlockDto, ConversationSystemNoteKindDto, + ConversationTaskItemDto, ConversationTaskStatusDto, ConversationToolCallBlockDto, + ConversationToolStreamsDto, PhaseDto, +}; +use serde_json::json; + +fn fixture(name: &str) -> serde_json::Value { + let path = match name { + "snapshot" => include_str!("fixtures/conversation/v1/snapshot.json"), + "delta_patch_tool_stream" => { + include_str!("fixtures/conversation/v1/delta_patch_tool_stream.json") + }, + "delta_rehydrate_required" => { + include_str!("fixtures/conversation/v1/delta_rehydrate_required.json") + }, + other => panic!("unknown fixture {other}"), + }; + serde_json::from_str(path).expect("fixture should be valid JSON") +} + +#[test] +fn conversation_snapshot_fixture_freezes_authoritative_tool_block_shape() { + let expected = ConversationSnapshotResponseDto { + session_id: "session-root".to_string(), + session_title: "Conversation session".to_string(), + cursor: ConversationCursorDto("cursor:opaque:v1:session-root/42==".to_string()), + phase: PhaseDto::CallingTool, + control: ConversationControlStateDto { + phase: PhaseDto::CallingTool, + can_submit_prompt: false, + can_request_compact: true, + compact_pending: false, + compacting: false, + current_mode_id: "code".to_string(), + active_turn_id: Some("turn-42".to_string()), + last_compact_meta: None, + active_plan: None, + active_tasks: Some(vec![ + ConversationTaskItemDto { + content: "实现 authoritative task panel".to_string(), + status: ConversationTaskStatusDto::InProgress, + active_form: Some("正在实现 authoritative task panel".to_string()), + }, + ConversationTaskItemDto { + content: "补充前端 hydration 测试".to_string(), + status: ConversationTaskStatusDto::Pending, + active_form: None, + }, + ]), + }, + blocks: vec![ConversationBlockDto::ToolCall( + ConversationToolCallBlockDto { + id: "block-tool-call-1".to_string(), + turn_id: Some("turn-42".to_string()), + tool_call_id: "tool-call-1".to_string(), + tool_name: "spawn_agent".to_string(), + status: ConversationBlockStatusDto::Failed, + input: Some(json!({ "task": "inspect repo" })), + summary: Some("permission denied".to_string()), + error: Some("permission denied".to_string()), + duration_ms: Some(88), + truncated: true, + metadata: Some(json!({ + "display": { + "kind": "terminal", + "command": "python worker.py" + } + })), + child_ref: Some(ChildAgentRefDto { + agent_id: "agent-child-1".to_string(), + session_id: "session-root".to_string(), + sub_run_id: "subrun-child-1".to_string(), + parent_agent_id: Some("agent-root".to_string()), + parent_sub_run_id: Some("subrun-root".to_string()), + lineage_kind: ChildSessionLineageKindDto::Spawn, + status: AgentLifecycleDto::Running, + open_session_id: "session-child-1".to_string(), + }), + streams: ConversationToolStreamsDto { + stdout: "searching repo\n".to_string(), + stderr: "permission denied\n".to_string(), + }, + }, + )], + child_summaries: Vec::new(), + slash_candidates: Vec::new(), + banner: None, + }; + + let fixture = fixture("snapshot"); + let decoded: ConversationSnapshotResponseDto = + serde_json::from_value(fixture.clone()).expect("fixture should decode"); + assert_eq!(decoded, expected); + assert_eq!( + serde_json::to_value(&decoded).expect("snapshot should encode"), + fixture + ); +} + +#[test] +fn conversation_delta_fixtures_freeze_tool_patch_and_rehydrate_shapes() { + let patch_fixture = fixture("delta_patch_tool_stream"); + let patch_decoded: ConversationStreamEnvelopeDto = + serde_json::from_value(patch_fixture.clone()).expect("patch fixture should decode"); + assert_eq!( + patch_decoded, + ConversationStreamEnvelopeDto { + session_id: "session-root".to_string(), + cursor: ConversationCursorDto("cursor:opaque:v1:session-root/44==".to_string()), + delta: ConversationDeltaDto::PatchBlock { + block_id: "block-tool-call-1".to_string(), + patch: ConversationBlockPatchDto::AppendToolStream { + stream: astrcode_protocol::http::ToolOutputStreamDto::Stderr, + chunk: "line 1\nline 2".to_string(), + }, + }, + } + ); + assert_eq!( + serde_json::to_value(&patch_decoded).expect("patch should encode"), + patch_fixture + ); + + let rehydrate_fixture = fixture("delta_rehydrate_required"); + let rehydrate_decoded: ConversationStreamEnvelopeDto = + serde_json::from_value(rehydrate_fixture.clone()).expect("rehydrate fixture should decode"); + assert_eq!( + rehydrate_decoded, + ConversationStreamEnvelopeDto { + session_id: "session-root".to_string(), + cursor: ConversationCursorDto("cursor:opaque:v1:session-root/45==".to_string()), + delta: ConversationDeltaDto::RehydrateRequired { + error: ConversationErrorEnvelopeDto { + code: ConversationBannerErrorCodeDto::CursorExpired, + message: "cursor 已失效,请重新获取 snapshot".to_string(), + rehydrate_required: true, + details: Some(json!({ "cursor": "cursor:opaque:v1:session-root/12==" })), + }, + }, + } + ); + assert_eq!( + serde_json::to_value(&rehydrate_decoded).expect("rehydrate should encode"), + rehydrate_fixture + ); +} + +#[test] +fn conversation_plan_block_round_trips_with_review_details() { + let block = ConversationBlockDto::Plan(ConversationPlanBlockDto { + id: "plan-block-1".to_string(), + turn_id: Some("turn-42".to_string()), + tool_call_id: "call-plan-exit".to_string(), + event_kind: ConversationPlanEventKindDto::ReviewPending, + title: "Cleanup crates".to_string(), + plan_path: "D:/demo/.astrcode/projects/demo/sessions/session-1/plan/cleanup-crates.md" + .to_string(), + summary: Some("正在做退出前自审".to_string()), + status: None, + slug: None, + updated_at: None, + content: None, + review: Some(ConversationPlanReviewDto { + kind: ConversationPlanReviewKindDto::FinalReview, + checklist: vec![ + "Re-check assumptions against the code you already inspected.".to_string(), + ], + }), + blockers: ConversationPlanBlockersDto { + missing_headings: vec!["## Verification".to_string()], + invalid_sections: vec![ + "session plan section '## Verification' must contain concrete actionable items" + .to_string(), + ], + }, + }); + + let encoded = serde_json::to_value(&block).expect("plan block should encode"); + let decoded: ConversationBlockDto = + serde_json::from_value(encoded.clone()).expect("plan block should decode"); + + assert_eq!(decoded, block); + assert_eq!(encoded["kind"], "plan"); + assert_eq!(encoded["eventKind"], "review_pending"); +} + +#[test] +fn conversation_system_note_round_trips_preserved_recent_turns() { + let block = ConversationBlockDto::SystemNote(ConversationSystemNoteBlockDto { + id: "system-compact-1".to_string(), + note_kind: ConversationSystemNoteKindDto::Compact, + markdown: "压缩摘要".to_string(), + compact_meta: Some(ConversationLastCompactMetaDto { + trigger: CompactTrigger::Auto, + meta: CompactAppliedMeta { + mode: CompactMode::Incremental, + instructions_present: false, + fallback_used: false, + retry_count: 0, + input_units: 3, + output_summary_chars: 42, + }, + }), + preserved_recent_turns: Some(4), + }); + + let encoded = serde_json::to_value(&block).expect("system note should encode"); + let decoded: ConversationBlockDto = + serde_json::from_value(encoded.clone()).expect("system note should decode"); + + assert_eq!(decoded, block); + assert_eq!(encoded["kind"], "system_note"); + assert_eq!(encoded["preservedRecentTurns"], 4); +} diff --git a/crates/protocol/tests/fixtures/README.md b/crates/protocol/tests/fixtures/README.md index 45c4228a..e3299c06 100644 --- a/crates/protocol/tests/fixtures/README.md +++ b/crates/protocol/tests/fixtures/README.md @@ -2,7 +2,16 @@ This directory records protocol fixture coverage used by conformance tests. -## v4 Baseline Fixtures +## v5 Baseline Fixtures + +- v5/initialize.json: Session initialize payload baseline. +- v5/invoke.json: Tool invocation payload baseline. +- v5/event_delta.json: Streaming delta payload baseline. +- v5/cancel.json: Cancellation payload baseline. +- v5/result_initialize.json: Successful initialize result payload baseline. +- v5/result_error.json: Error result payload baseline. + +## Historical v4 Fixtures - v4/initialize.json: Session initialize payload baseline. - v4/invoke.json: Tool invocation payload baseline. @@ -19,11 +28,11 @@ This directory records protocol fixture coverage used by conformance tests. - terminal/v1/delta_rehydrate_required.json: Cursor 失效后的 rehydrate-required delta baseline. - terminal/v1/error_envelope.json: Terminal banner/status error envelope baseline. -## Legacy History Coverage Note +## Historical History Coverage Note -Legacy durable subrun lineage behavior is currently validated by runtime/server regression tests +Historical durable subrun lineage behavior is currently validated by runtime/server regression tests that seed StorageEvent history directly. This fixture directory tracks wire-format payload samples; -legacy lineage degradation semantics are tracked in: +historical lineage degradation semantics are tracked in: - specs/001-runtime-boundary-refactor/quickstart.md (Scenario C) - crates/server/src/tests/runtime_routes_tests.rs diff --git a/crates/protocol/tests/fixtures/conversation/v1/delta_patch_tool_stream.json b/crates/protocol/tests/fixtures/conversation/v1/delta_patch_tool_stream.json new file mode 100644 index 00000000..0df3bfa1 --- /dev/null +++ b/crates/protocol/tests/fixtures/conversation/v1/delta_patch_tool_stream.json @@ -0,0 +1,11 @@ +{ + "sessionId": "session-root", + "cursor": "cursor:opaque:v1:session-root/44==", + "kind": "patch_block", + "blockId": "block-tool-call-1", + "patch": { + "kind": "append_tool_stream", + "stream": "stderr", + "chunk": "line 1\nline 2" + } +} diff --git a/crates/protocol/tests/fixtures/conversation/v1/delta_rehydrate_required.json b/crates/protocol/tests/fixtures/conversation/v1/delta_rehydrate_required.json new file mode 100644 index 00000000..6ee31df3 --- /dev/null +++ b/crates/protocol/tests/fixtures/conversation/v1/delta_rehydrate_required.json @@ -0,0 +1,13 @@ +{ + "sessionId": "session-root", + "cursor": "cursor:opaque:v1:session-root/45==", + "kind": "rehydrate_required", + "error": { + "code": "cursor_expired", + "message": "cursor 已失效,请重新获取 snapshot", + "rehydrateRequired": true, + "details": { + "cursor": "cursor:opaque:v1:session-root/12==" + } + } +} diff --git a/crates/protocol/tests/fixtures/conversation/v1/snapshot.json b/crates/protocol/tests/fixtures/conversation/v1/snapshot.json new file mode 100644 index 00000000..14f330de --- /dev/null +++ b/crates/protocol/tests/fixtures/conversation/v1/snapshot.json @@ -0,0 +1,63 @@ +{ + "sessionId": "session-root", + "sessionTitle": "Conversation session", + "cursor": "cursor:opaque:v1:session-root/42==", + "phase": "callingTool", + "control": { + "phase": "callingTool", + "canSubmitPrompt": false, + "canRequestCompact": true, + "compactPending": false, + "compacting": false, + "currentModeId": "code", + "activeTurnId": "turn-42", + "activeTasks": [ + { + "content": "实现 authoritative task panel", + "status": "in_progress", + "activeForm": "正在实现 authoritative task panel" + }, + { + "content": "补充前端 hydration 测试", + "status": "pending" + } + ] + }, + "blocks": [ + { + "kind": "tool_call", + "id": "block-tool-call-1", + "turnId": "turn-42", + "toolCallId": "tool-call-1", + "toolName": "spawn_agent", + "status": "failed", + "input": { + "task": "inspect repo" + }, + "summary": "permission denied", + "error": "permission denied", + "durationMs": 88, + "truncated": true, + "metadata": { + "display": { + "kind": "terminal", + "command": "python worker.py" + } + }, + "childRef": { + "agentId": "agent-child-1", + "sessionId": "session-root", + "subRunId": "subrun-child-1", + "parentAgentId": "agent-root", + "parentSubRunId": "subrun-root", + "lineageKind": "spawn", + "status": "running", + "openSessionId": "session-child-1" + }, + "streams": { + "stdout": "searching repo\n", + "stderr": "permission denied\n" + } + } + ] +} diff --git a/crates/protocol/tests/fixtures/terminal/v1/delta_patch_tool_metadata.json b/crates/protocol/tests/fixtures/terminal/v1/delta_patch_tool_metadata.json new file mode 100644 index 00000000..f4908011 --- /dev/null +++ b/crates/protocol/tests/fixtures/terminal/v1/delta_patch_tool_metadata.json @@ -0,0 +1,17 @@ +{ + "sessionId": "session-root", + "cursor": "cursor:opaque:v1:session-root/44.5==", + "kind": "patch_block", + "blockId": "block-tool-call-1", + "patch": { + "kind": "replace_metadata", + "metadata": { + "openSessionId": "session-child-1", + "agentRef": { + "agentId": "agent-child-1", + "subRunId": "subrun-1", + "openSessionId": "session-child-1" + } + } + } +} diff --git a/crates/protocol/tests/fixtures/terminal/v1/snapshot.json b/crates/protocol/tests/fixtures/terminal/v1/snapshot.json index 7891bced..e24d28c3 100644 --- a/crates/protocol/tests/fixtures/terminal/v1/snapshot.json +++ b/crates/protocol/tests/fixtures/terminal/v1/snapshot.json @@ -38,6 +38,9 @@ "toolCallId": "tool-call-1", "toolName": "shell_command", "status": "complete", + "input": { + "command": "rg terminal" + }, "summary": "读取 protocol 上下文" }, { @@ -117,15 +120,15 @@ "actionValue": "new_session" }, { - "id": "slash-skill", - "title": "/skill", - "description": "插入 skill 引用", + "id": "review", + "title": "/review", + "description": "调用 review skill", "keywords": [ "skill", - "insert" + "review" ], "actionKind": "insert_text", - "actionValue": "/skill " + "actionValue": "/review" } ], "banner": { diff --git a/crates/protocol/tests/fixtures/v5/cancel.json b/crates/protocol/tests/fixtures/v5/cancel.json new file mode 100644 index 00000000..96ae3dc8 --- /dev/null +++ b/crates/protocol/tests/fixtures/v5/cancel.json @@ -0,0 +1,5 @@ +{ + "type": "cancel", + "id": "req-1", + "reason": "user interrupted" +} diff --git a/crates/protocol/tests/fixtures/v5/event_delta.json b/crates/protocol/tests/fixtures/v5/event_delta.json new file mode 100644 index 00000000..e7a81b2d --- /dev/null +++ b/crates/protocol/tests/fixtures/v5/event_delta.json @@ -0,0 +1,11 @@ +{ + "type": "event", + "id": "req-1", + "phase": "delta", + "event": "artifact.patch", + "payload": { + "path": "src/lib.rs", + "patch": "@@ -1 +1 @@\n-pub fn old() {}\n+pub fn new() {}\n" + }, + "seq": 2 +} diff --git a/crates/protocol/tests/fixtures/v5/initialize.json b/crates/protocol/tests/fixtures/v5/initialize.json new file mode 100644 index 00000000..557c25f7 --- /dev/null +++ b/crates/protocol/tests/fixtures/v5/initialize.json @@ -0,0 +1,123 @@ +{ + "type": "initialize", + "id": "init-1", + "protocolVersion": "5", + "supportedProtocolVersions": [ + "5" + ], + "peer": { + "id": "worker-1", + "name": "repo-inspector", + "role": "worker", + "version": "0.1.0", + "supportedProfiles": [ + "coding" + ], + "metadata": { + "transport": "stdio" + } + }, + "capabilities": [ + { + "name": "workspace.summary", + "kind": "tool", + "description": "Summarize the active coding workspace.", + "inputSchema": { + "type": "object", + "properties": {} + }, + "outputSchema": { + "type": "object" + }, + "invocationMode": "unary", + "profiles": [ + "coding" + ], + "tags": [ + "workspace", + "summary" + ], + "permissions": [ + { + "name": "filesystem.read", + "rationale": "Need to inspect the active repository." + } + ], + "sideEffect": "none", + "stability": "stable" + } + ], + "handlers": [ + { + "id": "command.workspace.summary", + "trigger": { + "kind": "command", + "value": "/workspace-summary", + "metadata": { + "aliases": [ + "/ws" + ] + } + }, + "inputSchema": { + "type": "object" + }, + "profiles": [ + "coding" + ], + "filters": [ + { + "field": "profile", + "op": "eq", + "value": "coding" + } + ], + "permissions": [ + { + "name": "filesystem.read", + "rationale": "Reads workspace files." + } + ] + } + ], + "profiles": [ + { + "name": "coding", + "version": "1", + "description": "Coding workflow profile.", + "contextSchema": { + "type": "object", + "properties": { + "workingDir": { + "type": "string" + }, + "repoRoot": { + "type": "string" + }, + "openFiles": { + "type": "array", + "items": { + "type": "string" + } + }, + "activeFile": { + "type": "string" + }, + "selection": { + "type": "object" + }, + "approvalMode": { + "type": "string" + } + } + }, + "metadata": { + "firstClass": true + } + } + ], + "metadata": { + "bootstrap": true, + "transport": "stdio" + } +} diff --git a/crates/protocol/tests/fixtures/v5/invoke.json b/crates/protocol/tests/fixtures/v5/invoke.json new file mode 100644 index 00000000..d41ce26c --- /dev/null +++ b/crates/protocol/tests/fixtures/v5/invoke.json @@ -0,0 +1,55 @@ +{ + "type": "invoke", + "id": "req-1", + "capability": "workspace.summary", + "input": { + "scope": "workspace" + }, + "context": { + "requestId": "req-1", + "traceId": "trace-1", + "sessionId": "session-1", + "caller": { + "id": "astrcode-runtime", + "role": "runtime", + "metadata": { + "origin": "server" + } + }, + "workspace": { + "workingDir": "/repo", + "repoRoot": "/repo", + "branch": "main", + "metadata": { + "provider": "git" + } + }, + "deadlineMs": 5000, + "budget": { + "maxDurationMs": 5000, + "maxEvents": 64, + "maxBytes": 65536 + }, + "profile": "coding", + "profileContext": { + "workingDir": "/repo", + "repoRoot": "/repo", + "openFiles": [ + "/repo/src/lib.rs", + "/repo/README.md" + ], + "activeFile": "/repo/src/lib.rs", + "selection": { + "startLine": 10, + "startColumn": 1, + "endLine": 12, + "endColumn": 4 + }, + "approvalMode": "on-request" + }, + "metadata": { + "requestSource": "chat" + } + }, + "stream": true +} diff --git a/crates/protocol/tests/fixtures/v5/result_error.json b/crates/protocol/tests/fixtures/v5/result_error.json new file mode 100644 index 00000000..eff3f13e --- /dev/null +++ b/crates/protocol/tests/fixtures/v5/result_error.json @@ -0,0 +1,17 @@ +{ + "type": "result", + "id": "req-2", + "success": false, + "output": null, + "error": { + "code": "permission_denied", + "message": "filesystem.write requires approval", + "details": { + "permission": "filesystem.write" + }, + "retriable": false + }, + "metadata": { + "source": "policy" + } +} diff --git a/crates/protocol/tests/fixtures/v5/result_initialize.json b/crates/protocol/tests/fixtures/v5/result_initialize.json new file mode 100644 index 00000000..f6cf1b84 --- /dev/null +++ b/crates/protocol/tests/fixtures/v5/result_initialize.json @@ -0,0 +1,94 @@ +{ + "type": "result", + "id": "init-1", + "kind": "initialize", + "success": true, + "output": { + "protocolVersion": "5", + "peer": { + "id": "worker-1", + "name": "repo-inspector", + "role": "worker", + "version": "0.1.0", + "supportedProfiles": [ + "coding" + ], + "metadata": { + "transport": "stdio" + } + }, + "capabilities": [ + { + "name": "workspace.summary", + "kind": "tool", + "description": "Summarize the active coding workspace.", + "inputSchema": { + "type": "object", + "properties": {} + }, + "outputSchema": { + "type": "object" + }, + "invocationMode": "unary", + "profiles": [ + "coding" + ], + "tags": [ + "workspace", + "summary" + ], + "permissions": [ + { + "name": "filesystem.read", + "rationale": "Need to inspect the active repository." + } + ], + "sideEffect": "none", + "stability": "stable" + } + ], + "handlers": [], + "profiles": [ + { + "name": "coding", + "version": "1", + "description": "Coding workflow profile.", + "contextSchema": { + "type": "object", + "properties": { + "workingDir": { + "type": "string" + }, + "repoRoot": { + "type": "string" + }, + "openFiles": { + "type": "array", + "items": { + "type": "string" + } + }, + "activeFile": { + "type": "string" + }, + "selection": { + "type": "object" + }, + "approvalMode": { + "type": "string" + } + } + }, + "metadata": { + "firstClass": true + } + } + ], + "metadata": { + "transport": "stdio" + } + }, + "metadata": { + "acceptedVersion": "5" + } +} diff --git a/crates/protocol/tests/http_dto_contracts.rs b/crates/protocol/tests/http_dto_contracts.rs new file mode 100644 index 00000000..224d41f1 --- /dev/null +++ b/crates/protocol/tests/http_dto_contracts.rs @@ -0,0 +1,105 @@ +use astrcode_protocol::http::{ + ForkModeDto, ResolvedSubagentContextOverridesDto, SubRunExecutionMetricsDto, + SubRunFailureCodeDto, SubRunFailureDto, SubRunHandoffDto, SubRunResultDto, + SubRunStorageModeDto, +}; +use serde_json::json; + +#[test] +fn subrun_result_dto_roundtrip_preserves_tagged_union_shape() { + let completed = SubRunResultDto::Completed { + handoff: SubRunHandoffDto { + findings: vec!["checked".to_string()], + artifacts: Vec::new(), + delivery: None, + }, + }; + let encoded = serde_json::to_value(&completed).expect("serialize completed result"); + assert_eq!(encoded.get("status"), Some(&json!("completed"))); + assert!(encoded.get("handoff").is_some()); + assert!(encoded.get("failure").is_none()); + let decoded: SubRunResultDto = + serde_json::from_value(encoded).expect("deserialize completed result"); + assert_eq!(decoded, completed); + + let token_exceeded = SubRunResultDto::TokenExceeded { + handoff: SubRunHandoffDto { + findings: vec!["partial".to_string()], + artifacts: Vec::new(), + delivery: None, + }, + }; + let encoded = serde_json::to_value(&token_exceeded).expect("serialize token exceeded result"); + assert_eq!(encoded.get("status"), Some(&json!("token_exceeded"))); + assert!(encoded.get("handoff").is_some()); + assert!(encoded.get("failure").is_none()); + let decoded: SubRunResultDto = + serde_json::from_value(encoded).expect("deserialize token exceeded result"); + assert_eq!(decoded, token_exceeded); + + let cancelled = SubRunResultDto::Cancelled { + failure: SubRunFailureDto { + code: SubRunFailureCodeDto::Interrupted, + display_message: "cancelled by parent".to_string(), + technical_message: "parent requested shutdown".to_string(), + retryable: true, + }, + }; + let encoded = serde_json::to_value(&cancelled).expect("serialize cancelled result"); + assert_eq!(encoded.get("status"), Some(&json!("cancelled"))); + assert!(encoded.get("failure").is_some()); + assert!(encoded.get("handoff").is_none()); + let decoded: SubRunResultDto = + serde_json::from_value(encoded).expect("deserialize cancelled result"); + assert_eq!(decoded, cancelled); +} + +#[test] +fn resolved_subagent_context_overrides_roundtrip_preserves_fork_mode() { + let overrides = ResolvedSubagentContextOverridesDto { + storage_mode: SubRunStorageModeDto::IndependentSession, + inherit_system_instructions: true, + inherit_project_instructions: true, + inherit_working_dir: true, + inherit_policy_upper_bound: true, + inherit_cancel_token: true, + include_compact_summary: true, + include_recent_tail: false, + include_recovery_refs: false, + include_parent_findings: false, + fork_mode: Some(ForkModeDto::LastNTurns(7)), + }; + + let encoded = serde_json::to_value(&overrides).expect("serialize overrides"); + assert_eq!(encoded.get("forkMode"), Some(&json!({ "lastNTurns": 7 }))); + + let decoded: ResolvedSubagentContextOverridesDto = + serde_json::from_value(encoded).expect("deserialize overrides"); + assert_eq!(decoded, overrides); +} + +#[test] +fn subrun_execution_metrics_serialize_cancelled_field_name() { + let metrics = SubRunExecutionMetricsDto { + total: 12, + failures: 2, + completed: 7, + cancelled: 1, + token_exceeded: 2, + independent_session_total: 9, + total_duration_ms: 1200, + last_duration_ms: 80, + total_steps: 42, + last_step_count: 3, + total_estimated_tokens: 4096, + last_estimated_tokens: 256, + }; + + let encoded = serde_json::to_value(&metrics).expect("serialize metrics"); + assert_eq!(encoded.get("cancelled"), Some(&json!(1))); + assert!(encoded.get("aborted").is_none()); + + let decoded: SubRunExecutionMetricsDto = + serde_json::from_value(encoded).expect("deserialize metrics"); + assert_eq!(decoded, metrics); +} diff --git a/crates/protocol/tests/subrun_event_serialization.rs b/crates/protocol/tests/subrun_event_serialization.rs deleted file mode 100644 index 580c27c8..00000000 --- a/crates/protocol/tests/subrun_event_serialization.rs +++ /dev/null @@ -1,344 +0,0 @@ -use astrcode_protocol::http::{ - AgentContextDto, AgentEventEnvelope, AgentEventPayload, AgentLifecycleDto, ChildAgentRefDto, - ChildSessionLineageKindDto, ChildSessionNotificationDto, ChildSessionNotificationKindDto, - CompletedParentDeliveryPayloadDto, InvocationKindDto, ParentDeliveryDto, - ParentDeliveryOriginDto, ParentDeliveryPayloadDto, ParentDeliveryTerminalSemanticsDto, - ProgressParentDeliveryPayloadDto, ResolvedExecutionLimitsDto, - ResolvedSubagentContextOverridesDto, SubRunFailureCodeDto, SubRunFailureDto, SubRunHandoffDto, - SubRunOutcomeDto, SubRunResultDto, SubRunStorageModeDto, -}; - -#[test] -fn sub_run_started_serializes_contract_fields_in_camel_case() { - let payload = AgentEventPayload::SubRunStarted { - turn_id: Some("turn-1".to_string()), - agent: AgentContextDto { - agent_id: Some("agent-1".to_string()), - parent_turn_id: Some("parent-turn".to_string()), - parent_sub_run_id: Some("subrun-parent".to_string()), - agent_profile: Some("explore".to_string()), - sub_run_id: Some("subrun-1".to_string()), - invocation_kind: Some(InvocationKindDto::SubRun), - storage_mode: Some(SubRunStorageModeDto::IndependentSession), - child_session_id: None, - }, - tool_call_id: None, - resolved_overrides: ResolvedSubagentContextOverridesDto { - storage_mode: SubRunStorageModeDto::IndependentSession, - inherit_system_instructions: true, - inherit_project_instructions: true, - inherit_working_dir: true, - inherit_policy_upper_bound: true, - inherit_cancel_token: true, - include_compact_summary: true, - include_recent_tail: false, - include_recovery_refs: false, - include_parent_findings: false, - fork_mode: None, - }, - resolved_limits: ResolvedExecutionLimitsDto { - allowed_tools: vec!["readFile".to_string(), "grep".to_string()], - max_steps: None, - }, - }; - - let value = serde_json::to_value(AgentEventEnvelope::new(payload)).expect("serialize"); - let data = value.get("data").expect("envelope should contain data"); - - assert_eq!( - value.get("event"), - Some(&serde_json::json!("subRunStarted")) - ); - assert!(data.get("resolvedOverrides").is_some()); - assert!(data.get("resolvedLimits").is_some()); - assert!(data.get("resolved_overrides").is_none()); - assert!(data.get("resolved_limits").is_none()); - // descriptor 不再存在于 DTO 中 - assert!(data.get("descriptor").is_none()); -} - -#[test] -fn sub_run_started_rejects_snake_case_fields() { - let payload = serde_json::json!({ - "event": "subRunStarted", - "data": { - "turnId": "turn-1", - "agentId": "agent-1", - "parentTurnId": "parent-turn", - "agentProfile": "explore", - "subRunId": "subrun-1", - "invocationKind": "subRun", - "storageMode": "independentSession", - "resolved_overrides": { - "storageMode": "independentSession", - "inheritSystemInstructions": true, - "inheritProjectInstructions": false, - "inheritWorkingDir": true, - "inheritPolicyUpperBound": false, - "inheritCancelToken": true, - "includeCompactSummary": true, - "includeRecentTail": false, - "includeRecoveryRefs": false, - "includeParentFindings": false - }, - "resolved_limits": { - "allowedTools": ["readFile"] - } - } - }); - - let result: Result<AgentEventPayload, _> = serde_json::from_value(payload); - - assert!(result.is_err(), "snake_case 字段应被拒绝"); -} - -#[test] -fn sub_run_finished_payload_roundtrip_without_descriptor() { - let original = AgentEventPayload::SubRunFinished { - turn_id: Some("turn-1".to_string()), - agent: AgentContextDto { - agent_id: Some("agent-1".to_string()), - parent_turn_id: Some("parent-turn".to_string()), - parent_sub_run_id: Some("subrun-parent".to_string()), - agent_profile: Some("explore".to_string()), - sub_run_id: Some("subrun-1".to_string()), - invocation_kind: Some(InvocationKindDto::SubRun), - storage_mode: Some(SubRunStorageModeDto::IndependentSession), - child_session_id: None, - }, - tool_call_id: Some("call-1".to_string()), - result: SubRunResultDto { - status: SubRunOutcomeDto::Failed, - handoff: None, - failure: Some(SubRunFailureDto { - code: SubRunFailureCodeDto::Transport, - display_message: "子 Agent 调用模型时网络连接中断,未完成任务。".to_string(), - technical_message: "HTTP request error: failed to read anthropic response stream" - .to_string(), - retryable: true, - }), - }, - step_count: 2, - estimated_tokens: 123, - }; - - let json = serde_json::to_value(&original).expect("serialize payload"); - let roundtripped: AgentEventPayload = - serde_json::from_value(json).expect("deserialize payload"); - - assert_eq!(original, roundtripped); -} - -#[test] -fn sub_run_finished_omits_parent_handoff_on_failure() { - let payload = AgentEventPayload::SubRunFinished { - turn_id: Some("turn-1".to_string()), - agent: AgentContextDto::default(), - tool_call_id: None, - result: SubRunResultDto { - status: SubRunOutcomeDto::Completed, - handoff: Some(SubRunHandoffDto { - findings: vec!["checked".to_string()], - artifacts: Vec::new(), - delivery: Some(ParentDeliveryDto { - idempotency_key: "handoff-1".to_string(), - origin: ParentDeliveryOriginDto::Explicit, - terminal_semantics: ParentDeliveryTerminalSemanticsDto::Terminal, - source_turn_id: Some("turn-1".to_string()), - payload: ParentDeliveryPayloadDto::Completed( - CompletedParentDeliveryPayloadDto { - message: "done".to_string(), - findings: vec!["checked".to_string()], - artifacts: Vec::new(), - }, - ), - }), - }), - failure: None, - }, - step_count: 1, - estimated_tokens: 12, - }; - - let json = serde_json::to_value(AgentEventEnvelope::new(payload)).expect("serialize"); - let data = json.get("data").expect("data"); - let result = data.get("result").expect("result"); - - assert!(result.get("handoff").is_some()); - assert!(result.get("failure").is_none()); -} - -#[test] -fn child_session_notification_roundtrip_keeps_projection_fields() { - let notification = ChildSessionNotificationDto { - notification_id: "note-1".to_string(), - child_ref: ChildAgentRefDto { - agent_id: "agent-child".to_string(), - session_id: "session-parent".to_string(), - sub_run_id: "subrun-1".to_string(), - parent_agent_id: Some("agent-parent".to_string()), - parent_sub_run_id: Some("subrun-parent".to_string()), - lineage_kind: ChildSessionLineageKindDto::Spawn, - status: AgentLifecycleDto::Running, - open_session_id: "session-child".to_string(), - }, - kind: ChildSessionNotificationKindDto::Started, - status: AgentLifecycleDto::Running, - source_tool_call_id: Some("call-1".to_string()), - delivery: Some(ParentDeliveryDto { - idempotency_key: "notification-1".to_string(), - origin: ParentDeliveryOriginDto::Explicit, - terminal_semantics: ParentDeliveryTerminalSemanticsDto::NonTerminal, - source_turn_id: Some("turn-child".to_string()), - payload: ParentDeliveryPayloadDto::Progress(ProgressParentDeliveryPayloadDto { - message: "child started".to_string(), - }), - }), - }; - - let encoded = serde_json::to_value(¬ification).expect("serialize notification"); - let decoded: ChildSessionNotificationDto = - serde_json::from_value(encoded.clone()).expect("deserialize notification"); - - assert_eq!(decoded, notification); - assert_eq!( - encoded.get("notificationId"), - Some(&serde_json::json!("note-1")) - ); - assert_eq!( - encoded - .get("childRef") - .and_then(|value| value.get("openSessionId")), - Some(&serde_json::json!("session-child")) - ); -} - - -#[test] -fn child_session_notification_event_payload_roundtrip() { - let payload = AgentEventPayload::ChildSessionNotification { - turn_id: Some("turn-parent".to_string()), - agent: AgentContextDto { - agent_id: Some("agent-parent".to_string()), - parent_turn_id: Some("turn-parent".to_string()), - parent_sub_run_id: None, - agent_profile: Some("planner".to_string()), - sub_run_id: Some("subrun-parent".to_string()), - invocation_kind: Some(InvocationKindDto::SubRun), - storage_mode: Some(SubRunStorageModeDto::IndependentSession), - child_session_id: None, - }, - child_ref: ChildAgentRefDto { - agent_id: "agent-child".to_string(), - session_id: "session-parent".to_string(), - sub_run_id: "subrun-1".to_string(), - parent_agent_id: Some("agent-parent".to_string()), - parent_sub_run_id: Some("subrun-parent".to_string()), - lineage_kind: ChildSessionLineageKindDto::Spawn, - status: AgentLifecycleDto::Running, - open_session_id: "session-child".to_string(), - }, - kind: ChildSessionNotificationKindDto::Started, - status: AgentLifecycleDto::Running, - source_tool_call_id: Some("call-1".to_string()), - delivery: Some(ParentDeliveryDto { - idempotency_key: "notification-event-1".to_string(), - origin: ParentDeliveryOriginDto::Explicit, - terminal_semantics: ParentDeliveryTerminalSemanticsDto::NonTerminal, - source_turn_id: Some("turn-child".to_string()), - payload: ParentDeliveryPayloadDto::Progress(ProgressParentDeliveryPayloadDto { - message: "child started".to_string(), - }), - }), - }; - - let encoded = - serde_json::to_value(AgentEventEnvelope::new(payload.clone())).expect("serialize"); - let decoded: AgentEventEnvelope = serde_json::from_value(encoded).expect("deserialize"); - assert_eq!(decoded.event, payload); -} - -#[test] -fn child_session_notification_roundtrip_keeps_typed_delivery() { - let notification = ChildSessionNotificationDto { - notification_id: "note-typed".to_string(), - child_ref: ChildAgentRefDto { - agent_id: "agent-child".to_string(), - session_id: "session-parent".to_string(), - sub_run_id: "subrun-typed".to_string(), - parent_agent_id: Some("agent-parent".to_string()), - parent_sub_run_id: Some("subrun-parent".to_string()), - lineage_kind: ChildSessionLineageKindDto::Spawn, - status: AgentLifecycleDto::Idle, - open_session_id: "session-child".to_string(), - }, - kind: ChildSessionNotificationKindDto::Delivered, - status: AgentLifecycleDto::Idle, - source_tool_call_id: Some("call-typed".to_string()), - delivery: Some(ParentDeliveryDto { - idempotency_key: "delivery-typed".to_string(), - origin: ParentDeliveryOriginDto::Fallback, - terminal_semantics: ParentDeliveryTerminalSemanticsDto::Terminal, - source_turn_id: Some("turn-typed".to_string()), - payload: ParentDeliveryPayloadDto::Completed(CompletedParentDeliveryPayloadDto { - message: "child completed".to_string(), - findings: vec!["checked".to_string()], - artifacts: Vec::new(), - }), - }), - }; - - let encoded = serde_json::to_value(¬ification).expect("serialize notification"); - let decoded: ChildSessionNotificationDto = - serde_json::from_value(encoded.clone()).expect("deserialize notification"); - - assert_eq!(decoded, notification); - assert_eq!( - encoded - .get("delivery") - .and_then(|value| value.get("origin")), - Some(&serde_json::json!("fallback")) - ); - assert_eq!( - encoded.get("delivery").and_then(|value| value.get("kind")), - Some(&serde_json::json!("completed")) - ); -} - -// ─── 谱系兼容性测试 ────────────────────────────── - -/// 验证 spawn/fork/resume 三种 lineage kind 在 ChildAgentRefDto 中均可序列化和反序列化, -/// 且 JSON 输出使用 snake_case 值("spawn"/"fork"/"resume")。 -#[test] -fn lineage_kind_spawn_fork_resume_all_roundtrip_through_child_ref() { - for (label, kind) in [ - ("spawn", ChildSessionLineageKindDto::Spawn), - ("fork", ChildSessionLineageKindDto::Fork), - ("resume", ChildSessionLineageKindDto::Resume), - ] { - let child_ref = ChildAgentRefDto { - agent_id: "agent-child".to_string(), - session_id: "session-parent".to_string(), - sub_run_id: "subrun-1".to_string(), - parent_agent_id: Some("agent-parent".to_string()), - parent_sub_run_id: Some("subrun-parent".to_string()), - lineage_kind: kind.clone(), - status: AgentLifecycleDto::Running, - open_session_id: "session-child".to_string(), - }; - - let json = serde_json::to_value(&child_ref).expect("serialize child ref"); - // 验证 JSON 中 lineageKind 值为 snake_case 字符串 - assert_eq!( - json.get("lineageKind"), - Some(&serde_json::json!(label)), - "lineage_kind {label} should serialize as snake_case" - ); - - let back: ChildAgentRefDto = serde_json::from_value(json).expect("deserialize child ref"); - assert_eq!( - back.lineage_kind, kind, - "roundtrip for {label} should match" - ); - } -} diff --git a/crates/protocol/tests/terminal_conformance.rs b/crates/protocol/tests/terminal_conformance.rs index dbee946e..8d4807f2 100644 --- a/crates/protocol/tests/terminal_conformance.rs +++ b/crates/protocol/tests/terminal_conformance.rs @@ -18,6 +18,9 @@ fn fixture(name: &str) -> Value { "snapshot" => include_str!("fixtures/terminal/v1/snapshot.json"), "delta_append_block" => include_str!("fixtures/terminal/v1/delta_append_block.json"), "delta_patch_block" => include_str!("fixtures/terminal/v1/delta_patch_block.json"), + "delta_patch_tool_metadata" => { + include_str!("fixtures/terminal/v1/delta_patch_tool_metadata.json") + }, "delta_patch_replace_markdown" => { include_str!("fixtures/terminal/v1/delta_patch_replace_markdown.json") }, @@ -88,7 +91,9 @@ fn terminal_snapshot_fixture_freezes_v1_hydration_shape() { tool_call_id: Some("tool-call-1".to_string()), tool_name: "shell_command".to_string(), status: TerminalBlockStatusDto::Complete, + input: Some(json!({ "command": "rg terminal" })), summary: Some("读取 protocol 上下文".to_string()), + metadata: None, }), TerminalBlockDto::ToolStream(TerminalToolStreamBlockDto { id: "block-tool-stream-1".to_string(), @@ -126,12 +131,12 @@ fn terminal_snapshot_fixture_freezes_v1_hydration_shape() { action_value: "new_session".to_string(), }, TerminalSlashCandidateDto { - id: "slash-skill".to_string(), - title: "/skill".to_string(), - description: "插入 skill 引用".to_string(), - keywords: vec!["skill".to_string(), "insert".to_string()], + id: "review".to_string(), + title: "/review".to_string(), + description: "调用 review skill".to_string(), + keywords: vec!["skill".to_string(), "review".to_string()], action_kind: TerminalSlashActionKindDto::InsertText, - action_value: "/skill ".to_string(), + action_value: "/review".to_string(), }, ], banner: Some(TerminalBannerDto { @@ -202,6 +207,35 @@ fn terminal_delta_fixtures_freeze_append_patch_and_rehydrate_shapes() { patch_fixture ); + let tool_metadata_fixture = fixture("delta_patch_tool_metadata"); + let tool_metadata_decoded: TerminalStreamEnvelopeDto = + serde_json::from_value(tool_metadata_fixture.clone()) + .expect("tool metadata patch fixture should decode"); + assert_eq!( + tool_metadata_decoded, + TerminalStreamEnvelopeDto { + session_id: "session-root".to_string(), + cursor: TerminalCursorDto("cursor:opaque:v1:session-root/44.5==".to_string()), + delta: TerminalDeltaDto::PatchBlock { + block_id: "block-tool-call-1".to_string(), + patch: TerminalBlockPatchDto::ReplaceMetadata { + metadata: json!({ + "openSessionId": "session-child-1", + "agentRef": { + "agentId": "agent-child-1", + "subRunId": "subrun-1", + "openSessionId": "session-child-1" + } + }), + }, + }, + } + ); + assert_eq!( + serde_json::to_value(&tool_metadata_decoded).expect("tool metadata patch should encode"), + tool_metadata_fixture + ); + let replace_fixture = fixture("delta_patch_replace_markdown"); let replace_decoded: TerminalStreamEnvelopeDto = serde_json::from_value(replace_fixture.clone()).expect("replace fixture should decode"); diff --git a/crates/sdk/Cargo.toml b/crates/sdk/Cargo.toml index 535565c7..08ab77c4 100644 --- a/crates/sdk/Cargo.toml +++ b/crates/sdk/Cargo.toml @@ -6,6 +6,7 @@ license.workspace = true authors.workspace = true [dependencies] +astrcode-core = { path = "../core" } astrcode-protocol = { path = "../protocol" } serde.workspace = true serde_json.workspace = true diff --git a/crates/sdk/src/hook.rs b/crates/sdk/src/hook.rs index dabac16e..0308a24d 100644 --- a/crates/sdk/src/hook.rs +++ b/crates/sdk/src/hook.rs @@ -13,7 +13,7 @@ use std::sync::Arc; -use astrcode_protocol::plugin::CapabilityDescriptor; +use astrcode_core::CapabilitySpec; use serde_json::Value; use crate::{PluginContext, SdkError}; @@ -69,11 +69,8 @@ impl PolicyDecision { /// Use this for plugin-local validation and gating. Host-level approval, sandbox, or runtime /// policy should stay in the host runtime rather than being reimplemented here. pub trait PolicyHook: Send + Sync { - fn before_invoke( - &self, - capability: &CapabilityDescriptor, - context: &PluginContext, - ) -> PolicyDecision; + fn before_invoke(&self, capability: &CapabilitySpec, context: &PluginContext) + -> PolicyDecision; } /// 钩子短路策略。 @@ -270,7 +267,7 @@ impl PolicyHookChain { impl PolicyHook for PolicyHookChain { fn before_invoke( &self, - capability: &CapabilityDescriptor, + capability: &CapabilitySpec, context: &PluginContext, ) -> PolicyDecision { let mut final_decision = PolicyDecision::allow(); diff --git a/crates/sdk/src/lib.rs b/crates/sdk/src/lib.rs index 638c0da5..fed4d5c8 100644 --- a/crates/sdk/src/lib.rs +++ b/crates/sdk/src/lib.rs @@ -19,7 +19,7 @@ //! //! ```ignore //! use astrcode_sdk::{ToolHandler, ToolRegistration, ToolFuture, PluginContext, StreamWriter}; -//! use astrcode_sdk::{CapabilityDescriptor, CapabilityKind, SideEffectLevel}; +//! use astrcode_sdk::{CapabilityKind, CapabilitySpec, SideEffect}; //! use serde::{Deserialize, Serialize}; //! //! // 1. 定义工具的输入/输出类型 @@ -33,12 +33,11 @@ //! struct GreetTool; //! //! impl ToolHandler<GreetInput, GreetOutput> for GreetTool { -//! fn descriptor(&self) -> CapabilityDescriptor { -//! CapabilityDescriptor::builder() -//! .name("greet") -//! .kind(CapabilityKind::Tool) +//! fn descriptor(&self) -> CapabilitySpec { +//! CapabilitySpec::builder("greet", CapabilityKind::Tool) //! .description("向指定用户打招呼") -//! .side_effect_level(SideEffectLevel::None) +//! .schema(serde_json::json!({ "type": "object" }), serde_json::json!({ "type": "object" })) +//! .side_effect(SideEffect::None) //! .build() //! .unwrap() //! } @@ -67,12 +66,12 @@ mod stream; mod tests; mod tool; -// Re-export protocol types that plugin authors commonly need. -pub use astrcode_protocol::plugin::{ - CapabilityDescriptor, CapabilityDescriptorBuilder, CapabilityKind, DescriptorBuildError, - PermissionHint, SideEffectLevel, StabilityLevel, +// Re-export canonical capability types for plugin authors. +pub use astrcode_core::{ + CapabilityKind, CapabilitySpec, CapabilitySpecBuildError, CapabilitySpecBuilder, + InvocationMode, PermissionSpec, SideEffect, Stability, }; -// Re-export SDK core types. +// Re-export SDK-local types. pub use context::PluginContext; pub use error::{SdkError, ToolSerdeStage}; pub use hook::{ diff --git a/crates/sdk/src/tests.rs b/crates/sdk/src/tests.rs index 9eb100a9..4ffdad66 100644 --- a/crates/sdk/src/tests.rs +++ b/crates/sdk/src/tests.rs @@ -9,8 +9,8 @@ use serde::{Deserialize, Serialize}; use serde_json::json; use crate::{ - CapabilityDescriptor, CapabilityKind, HookRegistry, PluginContext, PolicyDecision, PolicyHook, - PolicyHookChain, SdkError, SideEffectLevel, StreamWriter, ToolFuture, ToolHandler, + CapabilityKind, CapabilitySpec, HookRegistry, InvocationMode, PluginContext, PolicyDecision, + PolicyHook, PolicyHookChain, SdkError, SideEffect, StreamWriter, ToolFuture, ToolHandler, ToolRegistration, ToolSerdeStage, }; @@ -53,16 +53,16 @@ struct SampleOutput { } impl ToolHandler<SampleInput, SampleOutput> for SampleTool { - fn descriptor(&self) -> CapabilityDescriptor { - CapabilityDescriptor::builder("tool.sample", CapabilityKind::tool()) + fn descriptor(&self) -> CapabilitySpec { + CapabilitySpec::builder("tool.sample", CapabilityKind::tool()) .description("Sample tool") .schema(json!({ "type": "object" }), json!({ "type": "object" })) - .streaming(true) + .invocation_mode(InvocationMode::Streaming) .profile("coding") .tag("sample") - .side_effect(SideEffectLevel::None) + .side_effect(SideEffect::None) .build() - .expect("sample descriptor should build") + .expect("sample capability spec should build") } fn execute( @@ -89,7 +89,7 @@ struct TrackingPolicyHook { impl PolicyHook for TrackingPolicyHook { fn before_invoke( &self, - _capability: &CapabilityDescriptor, + _capability: &CapabilitySpec, _context: &PluginContext, ) -> PolicyDecision { self.calls diff --git a/crates/sdk/src/tool.rs b/crates/sdk/src/tool.rs index e167270e..651bbc12 100644 --- a/crates/sdk/src/tool.rs +++ b/crates/sdk/src/tool.rs @@ -22,7 +22,7 @@ //! struct MyTool; //! //! impl ToolHandler<MyInput, MyOutput> for MyTool { -//! fn descriptor(&self) -> CapabilityDescriptor { /* ... */ } +//! fn descriptor(&self) -> CapabilitySpec { /* ... */ } //! //! fn execute(&self, input: MyInput, context: PluginContext, stream: StreamWriter) -> ToolFuture<'_, MyOutput> { //! Box::pin(async move { @@ -37,7 +37,7 @@ use std::{future::Future, pin::Pin}; -use astrcode_protocol::plugin::CapabilityDescriptor; +use astrcode_core::CapabilitySpec; use serde::{Serialize, de::DeserializeOwned}; use serde_json::Value; @@ -78,7 +78,7 @@ pub trait ToolHandler<I = Value, O = Value>: Send + Sync { /// /// 描述包含工具名称、文档、副作用级别等元数据, /// 用于 LLM 决定是否调用此工具,以及前端如何渲染工具卡片。 - fn descriptor(&self) -> CapabilityDescriptor; + fn descriptor(&self) -> CapabilitySpec; /// 执行工具逻辑。 /// @@ -103,7 +103,7 @@ impl<T, I, O> ToolHandler<I, O> for Box<T> where T: ToolHandler<I, O> + ?Sized, { - fn descriptor(&self) -> CapabilityDescriptor { + fn descriptor(&self) -> CapabilitySpec { (**self).descriptor() } @@ -124,7 +124,7 @@ where /// 无法统一存储。类型擦除后所有工具都实现同一个 trait,可放入同一集合。 pub trait DynToolHandler: Send + Sync { /// 返回工具的能力描述。 - fn descriptor(&self) -> CapabilityDescriptor; + fn descriptor(&self) -> CapabilitySpec; /// 以 `Value` 作为输入/输出执行工具。 /// @@ -167,7 +167,7 @@ where I: DeserializeOwned + Send + 'static, O: Serialize + Send + 'static, { - fn descriptor(&self) -> CapabilityDescriptor { + fn descriptor(&self) -> CapabilitySpec { ToolHandler::<I, O>::descriptor(&self.inner) } @@ -177,8 +177,8 @@ where context: PluginContext, stream: StreamWriter, ) -> ToolFuture<'_, Value> { - let descriptor = ToolHandler::<I, O>::descriptor(&self.inner); - let capability_name = descriptor.name; + let capability_spec = ToolHandler::<I, O>::descriptor(&self.inner); + let capability_name = capability_spec.name.to_string(); let typed_input = serde_json::from_value::<I>(input).map_err(|source| SdkError::Serde { capability: capability_name.clone(), stage: ToolSerdeStage::DecodeInput, @@ -219,7 +219,7 @@ where /// 将泛型 `ToolHandler<I, O>` 转换为 `dyn DynToolHandler`, /// 使运行时可用统一接口调用所有工具。 pub struct ToolRegistration { - descriptor: CapabilityDescriptor, + descriptor: CapabilitySpec, handler: Box<dyn DynToolHandler>, } @@ -253,7 +253,7 @@ impl ToolRegistration { /// /// 运行时用此信息向 LLM 暴露工具列表, /// 前端用此信息渲染工具卡片。 - pub fn descriptor(&self) -> &CapabilityDescriptor { + pub fn descriptor(&self) -> &CapabilitySpec { &self.descriptor } diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index c7dd4870..a5349b50 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -5,10 +5,6 @@ edition.workspace = true license.workspace = true authors.workspace = true -[features] -default = [] -debug-workbench = ["dep:astrcode-debug-workbench"] - [dependencies] astrcode-application = { path = "../application" } astrcode-adapter-agents = { path = "../adapter-agents" } @@ -19,7 +15,6 @@ astrcode-adapter-skills = { path = "../adapter-skills" } astrcode-adapter-storage = { path = "../adapter-storage" } astrcode-adapter-tools = { path = "../adapter-tools" } astrcode-core = { path = "../core" } -astrcode-debug-workbench = { path = "../debug-workbench", optional = true } astrcode-kernel = { path = "../kernel" } astrcode-plugin = { path = "../plugin" } astrcode-protocol = { path = "../protocol" } diff --git a/crates/server/src/bootstrap/capabilities.rs b/crates/server/src/bootstrap/capabilities.rs index 51e9fb32..0ee3c1e9 100644 --- a/crates/server/src/bootstrap/capabilities.rs +++ b/crates/server/src/bootstrap/capabilities.rs @@ -9,23 +9,28 @@ use std::{path::Path, sync::Arc}; -use astrcode_adapter_skills::{SkillCatalog, SkillSpec, load_builtin_skills}; +use astrcode_adapter_skills::{LayeredSkillCatalog, load_builtin_skills}; use astrcode_adapter_tools::{ agent_tools::{CloseAgentTool, ObserveAgentTool, SendAgentTool, SpawnAgentTool}, builtin_tools::{ apply_patch::ApplyPatchTool, edit_file::EditFileTool, + enter_plan_mode::EnterPlanModeTool, + exit_plan_mode::ExitPlanModeTool, find_files::FindFilesTool, grep::GrepTool, list_dir::ListDirTool, read_file::ReadFileTool, shell::ShellTool, skill_tool::SkillTool, + task_write::TaskWriteTool, tool_search::{ToolSearchIndex, ToolSearchTool}, + upsert_session_plan::UpsertSessionPlanTool, write_file::WriteFileTool, }, }; use astrcode_application::AgentOrchestrationService; +use astrcode_core::{SkillCatalog, SkillSpec}; use super::deps::{ core::{CapabilityInvoker, Result, Tool}, @@ -39,7 +44,7 @@ use super::deps::{ /// 因为它们依赖 `AgentOrchestrationService`,必须在更晚的组合根阶段装配。 pub(crate) fn build_core_tool_invokers( tool_search_index: Arc<ToolSearchIndex>, - skill_catalog: Arc<SkillCatalog>, + skill_catalog: Arc<dyn SkillCatalog>, ) -> Result<Vec<Arc<dyn CapabilityInvoker>>> { let tools: Vec<Arc<dyn Tool>> = vec![ Arc::new(ReadFileTool), @@ -50,8 +55,12 @@ pub(crate) fn build_core_tool_invokers( Arc::new(FindFilesTool), Arc::new(GrepTool), Arc::new(ShellTool), + Arc::new(EnterPlanModeTool), + Arc::new(ExitPlanModeTool), + Arc::new(TaskWriteTool), Arc::new(ToolSearchTool::new(tool_search_index)), Arc::new(SkillTool::new(skill_catalog)), + Arc::new(UpsertSessionPlanTool), ]; let invokers = tools @@ -75,10 +84,13 @@ pub(crate) fn build_core_tool_invokers( pub(crate) fn build_skill_catalog( home_dir: &Path, mut external_base_skills: Vec<SkillSpec>, -) -> Arc<SkillCatalog> { +) -> Arc<LayeredSkillCatalog> { let mut base_skills = load_builtin_skills(); base_skills.append(&mut external_base_skills); - Arc::new(SkillCatalog::new_with_home_dir(base_skills, home_dir)) + Arc::new(LayeredSkillCatalog::new_with_home_dir( + base_skills, + home_dir, + )) } /// 让 tool_search 索引与当前外部能力事实源保持同步。 @@ -203,7 +215,10 @@ mod tests { use async_trait::async_trait; use serde_json::{Value, json}; - use super::{CapabilitySurfaceSync, build_stable_local_invokers}; + use super::{ + CapabilitySurfaceSync, build_core_tool_invokers, build_skill_catalog, + build_stable_local_invokers, + }; use crate::bootstrap::{ capabilities::sync_external_tool_search_index, deps::{ @@ -253,6 +268,7 @@ mod tests { tool_name: self.name.to_string(), ok: true, output: String::new(), + child_ref: None, error: None, metadata: None, duration_ms: 0, @@ -293,6 +309,8 @@ mod tests { Ok(PromptBuildOutput { system_prompt: "noop".to_string(), system_prompt_blocks: Vec::new(), + prompt_cache_hints: Default::default(), + cache_metrics: Default::default(), metadata: Value::Null, }) } @@ -419,4 +437,20 @@ mod tests { assert!(names.iter().any(|name| name == "spawn")); assert!(names.iter().any(|name| name == "mcp__demo__search")); } + + #[test] + fn build_core_tool_invokers_registers_task_write() { + let temp = tempfile::tempdir().expect("tempdir should exist"); + let tool_search_index = Arc::new(ToolSearchIndex::new()); + let skill_catalog = build_skill_catalog(temp.path(), Vec::new()); + + let invokers = build_core_tool_invokers(tool_search_index, skill_catalog) + .expect("core tool invokers should build"); + let names = invokers + .into_iter() + .map(|invoker| invoker.capability_spec().name.to_string()) + .collect::<Vec<_>>(); + + assert!(names.iter().any(|name| name == "taskWrite")); + } } diff --git a/crates/server/src/bootstrap/composer_skills.rs b/crates/server/src/bootstrap/composer_skills.rs index 76a91f69..0d142b03 100644 --- a/crates/server/src/bootstrap/composer_skills.rs +++ b/crates/server/src/bootstrap/composer_skills.rs @@ -1,15 +1,15 @@ use std::{path::Path, sync::Arc}; -use astrcode_adapter_skills::SkillCatalog; -use astrcode_application::{ComposerSkillPort, ComposerSkillSummary}; +use astrcode_application::{ComposerResolvedSkill, ComposerSkillPort, ComposerSkillSummary}; +use astrcode_core::SkillCatalog; #[derive(Clone)] pub(crate) struct RuntimeComposerSkillPort { - skill_catalog: Arc<SkillCatalog>, + skill_catalog: Arc<dyn SkillCatalog>, } impl RuntimeComposerSkillPort { - pub(crate) fn new(skill_catalog: Arc<SkillCatalog>) -> Self { + pub(crate) fn new(skill_catalog: Arc<dyn SkillCatalog>) -> Self { Self { skill_catalog } } } @@ -29,4 +29,16 @@ impl ComposerSkillPort for RuntimeComposerSkillPort { .map(|skill| ComposerSkillSummary::new(skill.id, skill.description)) .collect() } + + fn resolve_skill(&self, working_dir: &Path, skill_id: &str) -> Option<ComposerResolvedSkill> { + self.skill_catalog + .resolve_for_working_dir(&working_dir.to_string_lossy()) + .into_iter() + .find(|skill| skill.matches_requested_name(skill_id)) + .map(|skill| ComposerResolvedSkill { + id: skill.id, + description: skill.description, + guide: skill.guide, + }) + } } diff --git a/crates/server/src/bootstrap/governance.rs b/crates/server/src/bootstrap/governance.rs index 414c0c94..94f9592d 100644 --- a/crates/server/src/bootstrap/governance.rs +++ b/crates/server/src/bootstrap/governance.rs @@ -6,10 +6,10 @@ use std::{path::PathBuf, sync::Arc}; use astrcode_adapter_mcp::manager::McpConnectionManager; -use astrcode_adapter_skills::{SkillCatalog, load_builtin_skills}; +use astrcode_adapter_skills::{LayeredSkillCatalog, load_builtin_skills}; use astrcode_application::{ - AppGovernance, ApplicationError, ConfigService, RuntimeGovernancePort, - RuntimeGovernanceSnapshot, RuntimeObservabilityCollector, RuntimeReloader, SessionInfoProvider, + AppGovernance, ApplicationError, ModeCatalog, RuntimeGovernancePort, RuntimeGovernanceSnapshot, + RuntimeObservabilityCollector, RuntimeReloader, SessionInfoProvider, config::ConfigService, lifecycle::TaskRegistry, }; use astrcode_plugin::Supervisor; @@ -33,11 +33,12 @@ pub(crate) struct GovernanceBuildInput { pub observability: Arc<RuntimeObservabilityCollector>, pub mcp_manager: Arc<McpConnectionManager>, pub capability_sync: CapabilitySurfaceSync, - pub skill_catalog: Arc<SkillCatalog>, + pub skill_catalog: Arc<LayeredSkillCatalog>, pub plugin_search_paths: Vec<PathBuf>, pub plugin_skill_root: PathBuf, pub plugin_supervisors: Vec<Arc<Supervisor>>, pub working_dir: PathBuf, + pub mode_catalog: Option<Arc<ModeCatalog>>, } pub(crate) fn build_app_governance(input: GovernanceBuildInput) -> Arc<AppGovernance> { @@ -56,6 +57,7 @@ pub(crate) fn build_app_governance(input: GovernanceBuildInput) -> Arc<AppGovern plugin_search_paths: input.plugin_search_paths.clone(), plugin_skill_root: input.plugin_skill_root.clone(), working_dir: input.working_dir.clone(), + mode_catalog: input.mode_catalog, }); let managed_components: Vec<Arc<dyn ManagedRuntimeComponent>> = input .plugin_supervisors @@ -160,10 +162,11 @@ struct ServerRuntimeReloader { coordinator: Arc<RuntimeCoordinator>, mcp_manager: Arc<McpConnectionManager>, capability_sync: CapabilitySurfaceSync, - skill_catalog: Arc<SkillCatalog>, + skill_catalog: Arc<LayeredSkillCatalog>, plugin_search_paths: Vec<PathBuf>, plugin_skill_root: PathBuf, working_dir: PathBuf, + mode_catalog: Option<Arc<ModeCatalog>>, } impl std::fmt::Debug for ServerRuntimeReloader { @@ -194,6 +197,11 @@ impl RuntimeReloader for ServerRuntimeReloader { let previous_base_skills = self.skill_catalog.base_skills(); let mut next_base_skills = load_builtin_skills(); next_base_skills.extend(plugin_bootstrap.skills.clone()); + if let Some(mode_catalog) = &self.mode_catalog { + mode_catalog + .replace_plugin_modes(plugin_bootstrap.modes.clone()) + .map_err(ApplicationError::from)?; + } let previous_capabilities = self.capability_sync.current_capabilities(); let previous_plugins = self.coordinator.plugin_registry().snapshot(); diff --git a/crates/server/src/bootstrap/mcp.rs b/crates/server/src/bootstrap/mcp.rs index 642c4cf5..a659d973 100644 --- a/crates/server/src/bootstrap/mcp.rs +++ b/crates/server/src/bootstrap/mcp.rs @@ -16,8 +16,8 @@ use astrcode_adapter_mcp::{ }; use astrcode_adapter_storage::mcp_settings_store::FileMcpSettingsStore; use astrcode_application::{ - ApplicationError, ConfigService, McpConfigFileScope, McpPort, McpServerStatusView, - RegisterMcpServerInput, + ApplicationError, McpPort, McpServerStatusView, RegisterMcpServerInput, + config::{ConfigService, McpConfigFileScope}, }; use async_trait::async_trait; diff --git a/crates/server/src/bootstrap/mod.rs b/crates/server/src/bootstrap/mod.rs index 3bd9083c..cc82a5c4 100644 --- a/crates/server/src/bootstrap/mod.rs +++ b/crates/server/src/bootstrap/mod.rs @@ -194,14 +194,9 @@ pub(crate) fn load_frontend_build( server_origin, token, )?); - #[cfg(feature = "debug-workbench")] - let debug_html = - load_optional_frontend_entry(&dist_dir.join("debug.html"), server_origin, token)?; Ok(Some(FrontendBuild { dist_dir, index_html: injected_index, - #[cfg(feature = "debug-workbench")] - debug_html, })) } @@ -225,14 +220,6 @@ pub(crate) async fn serve_frontend_build( } let request_path = request.uri().path().trim_start_matches('/').to_string(); - #[cfg(feature = "debug-workbench")] - if request_path == "debug.html" { - return frontend_build - .debug_html - .as_deref() - .map(|html| browser_index_response(html.as_str())) - .unwrap_or_else(|| StatusCode::NOT_FOUND.into_response()); - } let looks_like_asset = request_path .rsplit('/') .next() @@ -323,25 +310,6 @@ fn browser_index_response(index_html: &str) -> Response { response } -#[cfg(feature = "debug-workbench")] -fn load_optional_frontend_entry( - path: &FsPath, - server_origin: &str, - token: &str, -) -> AnyhowResult<Option<std::sync::Arc<String>>> { - if !path.is_file() { - return Ok(None); - } - - let raw_html = std::fs::read_to_string(path) - .with_context(|| format!("failed to read frontend entry '{}'", path.display()))?; - Ok(Some(std::sync::Arc::new(inject_browser_bootstrap_html( - &raw_html, - server_origin, - token, - )?))) -} - /// 构建 CORS 层。 /// /// 允许的来源: @@ -358,8 +326,8 @@ fn load_optional_frontend_entry( pub(crate) fn build_cors_layer() -> CorsLayer { CorsLayer::new() .allow_origin([ - "http://localhost:5173".parse().unwrap(), - "http://127.0.0.1:5173".parse().unwrap(), + HeaderValue::from_static("http://localhost:5173"), + HeaderValue::from_static("http://127.0.0.1:5173"), ]) .allow_methods([Method::GET, Method::POST, Method::DELETE, Method::OPTIONS]) .allow_headers([ diff --git a/crates/server/src/bootstrap/plugins.rs b/crates/server/src/bootstrap/plugins.rs index 1f22840c..fe11dc30 100644 --- a/crates/server/src/bootstrap/plugins.rs +++ b/crates/server/src/bootstrap/plugins.rs @@ -15,7 +15,8 @@ use std::{ sync::Arc, }; -use astrcode_adapter_skills::{SkillSource, SkillSpec, collect_asset_files, is_valid_skill_name}; +use astrcode_adapter_skills::collect_asset_files; +use astrcode_core::{GovernanceModeSpec, SkillSource, SkillSpec, is_valid_skill_name}; use astrcode_plugin::{PluginLoader, Supervisor, default_initialize_message, default_profiles}; use astrcode_protocol::plugin::{PeerDescriptor, SkillDescriptor}; use log::warn; @@ -30,6 +31,8 @@ pub(crate) struct PluginBootstrapResult { pub invokers: Vec<Arc<dyn CapabilityInvoker>>, /// 物化后的插件 skill。 pub skills: Vec<SkillSpec>, + /// 插件声明的治理 mode。 + pub modes: Vec<GovernanceModeSpec>, /// 插件注册表引用(治理视图使用)。 pub registry: Arc<PluginRegistry>, /// 活跃的插件 supervisor 列表(shutdown 时需要关闭)。 @@ -69,6 +72,7 @@ pub(crate) async fn bootstrap_plugins_with_skill_root( return PluginBootstrapResult { invokers: Vec::new(), skills: Vec::new(), + modes: Vec::new(), registry, supervisors: Vec::new(), search_paths, @@ -91,6 +95,7 @@ pub(crate) async fn bootstrap_plugins_with_skill_root( let mut all_invokers: Vec<Arc<dyn CapabilityInvoker>> = Vec::new(); let mut all_skills = Vec::new(); + let mut all_modes = Vec::new(); let mut supervisors = Vec::new(); for manifest in manifests { @@ -116,16 +121,19 @@ pub(crate) async fn bootstrap_plugins_with_skill_root( &name, supervisor.declared_skills(), ); + let modes = supervisor.declared_modes(); log::info!( - "plugin '{name}' initialized with {} capabilities and {} skills", + "plugin '{name}' initialized with {} capabilities, {} skills and {} modes", capabilities.len(), - skills.len() + skills.len(), + modes.len() ); registry.record_initialized(manifest, capabilities, warnings); all_invokers.extend(invokers); all_skills.extend(skills); + all_modes.extend(modes); supervisors.push(supervisor); }, Err(error) => { @@ -143,6 +151,7 @@ pub(crate) async fn bootstrap_plugins_with_skill_root( PluginBootstrapResult { invokers: all_invokers, skills: all_skills, + modes: all_modes, registry, supervisors, search_paths, @@ -356,6 +365,7 @@ mod tests { let result = bootstrap_plugins(vec![]).await; assert!(result.invokers.is_empty()); assert!(result.skills.is_empty()); + assert!(result.modes.is_empty()); assert!(result.supervisors.is_empty()); assert!(result.registry.snapshot().is_empty()); } @@ -365,6 +375,7 @@ mod tests { let result = bootstrap_plugins(vec![PathBuf::from("/nonexistent/path")]).await; assert!(result.invokers.is_empty()); assert!(result.skills.is_empty()); + assert!(result.modes.is_empty()); assert!(result.supervisors.is_empty()); } diff --git a/crates/server/src/bootstrap/prompt_facts.rs b/crates/server/src/bootstrap/prompt_facts.rs index 4fbfb512..1863b748 100644 --- a/crates/server/src/bootstrap/prompt_facts.rs +++ b/crates/server/src/bootstrap/prompt_facts.rs @@ -10,19 +10,19 @@ use std::{ use astrcode_adapter_agents::AgentProfileLoader; use astrcode_adapter_mcp::manager::McpConnectionManager; -use astrcode_adapter_skills::SkillCatalog; -use astrcode_application::{ConfigService, resolve_current_model}; +use astrcode_application::config::{ConfigService, resolve_current_model}; +use astrcode_core::SkillCatalog; use async_trait::async_trait; use super::deps::core::{ - AstrError, PromptAgentProfileSummary, PromptDeclaration, PromptDeclarationKind, - PromptDeclarationRenderTarget, PromptDeclarationSource, PromptFacts, PromptFactsProvider, - PromptFactsRequest, PromptSkillSummary, Result, SystemPromptLayer, resolve_runtime_config, + AstrError, PromptDeclaration, PromptDeclarationKind, PromptDeclarationRenderTarget, + PromptDeclarationSource, PromptEntrySummary, PromptFacts, PromptFactsProvider, + PromptFactsRequest, Result, SystemPromptLayer, resolve_runtime_config, }; pub(crate) fn build_prompt_facts_provider( config_service: Arc<ConfigService>, - skill_catalog: Arc<SkillCatalog>, + skill_catalog: Arc<dyn SkillCatalog>, mcp_manager: Arc<McpConnectionManager>, agent_loader: AgentProfileLoader, ) -> Result<Arc<dyn PromptFactsProvider>> { @@ -36,7 +36,7 @@ pub(crate) fn build_prompt_facts_provider( struct RuntimePromptFactsProvider { config_service: Arc<ConfigService>, - skill_catalog: Arc<SkillCatalog>, + skill_catalog: Arc<dyn SkillCatalog>, agent_loader: AgentProfileLoader, mcp_manager: Arc<McpConnectionManager>, } @@ -57,16 +57,14 @@ impl PromptFactsProvider for RuntimePromptFactsProvider { .load_overlayed_config(Some(working_dir.as_path())) .map_err(|error| AstrError::Internal(error.to_string()))?; let runtime = resolve_runtime_config(&config.runtime); + let governance = request.governance.clone().unwrap_or_default(); let selection = resolve_current_model(&config) .map_err(|error| AstrError::Internal(error.to_string()))?; let skill_summaries = self .skill_catalog .resolve_for_working_dir(&working_dir.to_string_lossy()) .into_iter() - .map(|skill| PromptSkillSummary { - id: skill.id, - description: skill.description, - }) + .map(|skill| PromptEntrySummary::new(skill.id, skill.description)) .collect(); let agent_profiles = self .agent_loader @@ -74,10 +72,7 @@ impl PromptFactsProvider for RuntimePromptFactsProvider { .map_err(|error| AstrError::Internal(error.to_string()))? .list_subagent_profiles() .into_iter() - .map(|profile| PromptAgentProfileSummary { - id: profile.id.clone(), - description: profile.description.clone(), - }) + .map(|profile| PromptEntrySummary::new(profile.id.clone(), profile.description.clone())) .collect(); let prompt_declarations = self .mcp_manager @@ -85,7 +80,12 @@ impl PromptFactsProvider for RuntimePromptFactsProvider { .await .prompt_declarations .into_iter() - .filter(|declaration| prompt_declaration_is_visible(request, declaration)) + .filter(|declaration| { + prompt_declaration_is_visible( + governance.allowed_capability_names.as_slice(), + declaration, + ) + }) .map(convert_prompt_declaration) .collect(); @@ -95,13 +95,16 @@ impl PromptFactsProvider for RuntimePromptFactsProvider { working_dir.as_path(), request.session_id.as_ref().map(ToString::to_string), request.turn_id.as_ref().map(ToString::to_string), + governance.approval_mode.as_str(), ), metadata: serde_json::json!({ "configVersion": config.version, "activeProfile": config.active_profile, "activeModel": config.active_model, - "agentMaxSubrunDepth": runtime.agent.max_subrun_depth, - "agentMaxSpawnPerTurn": runtime.agent.max_spawn_per_turn, + "modeId": governance.mode_id, + "agentMaxSubrunDepth": governance.max_subrun_depth.unwrap_or(runtime.agent.max_subrun_depth), + "agentMaxSpawnPerTurn": governance.max_spawn_per_turn.unwrap_or(runtime.agent.max_spawn_per_turn), + "governancePolicyRevision": governance.policy_revision, }), skills: skill_summaries, agent_profiles, @@ -114,6 +117,7 @@ fn build_profile_context( working_dir: &Path, session_id: Option<String>, turn_id: Option<String>, + approval_mode: &str, ) -> serde_json::Value { let working_dir = normalize_context_path(working_dir); let mut context = serde_json::Map::new(); @@ -127,7 +131,11 @@ fn build_profile_context( ); context.insert( "approvalMode".to_string(), - serde_json::Value::String("inherit".to_string()), + serde_json::Value::String(if approval_mode.trim().is_empty() { + "inherit".to_string() + } else { + approval_mode.to_string() + }), ); if let Some(session_id) = session_id { context.insert( @@ -204,15 +212,14 @@ fn convert_prompt_declaration( } fn prompt_declaration_is_visible( - request: &PromptFactsRequest, + allowed_capability_names: &[String], declaration: &astrcode_adapter_prompt::PromptDeclaration, ) -> bool { declaration .capability_name .as_ref() .is_none_or(|capability_name| { - request - .allowed_capability_names + allowed_capability_names .iter() .any(|allowed| allowed == capability_name) }) @@ -255,13 +262,24 @@ mod tests { .iter() .map(|name| (*name).to_string()) .collect(), + governance: Some(astrcode_core::PromptGovernanceContext { + allowed_capability_names: allowed_capability_names + .iter() + .map(|name| (*name).to_string()) + .collect(), + mode_id: Some(astrcode_core::ModeId::code()), + approval_mode: "inherit".to_string(), + policy_revision: "governance-surface-v1".to_string(), + max_subrun_depth: Some(3), + max_spawn_per_turn: Some(2), + }), } } #[test] fn prompt_declaration_visibility_keeps_capabilityless_declarations() { assert!(prompt_declaration_is_visible( - &request(&[]), + &request(&[]).governance.unwrap().allowed_capability_names, &declaration(None) )); } @@ -269,7 +287,10 @@ mod tests { #[test] fn prompt_declaration_visibility_filters_out_ungranted_capabilities() { assert!(!prompt_declaration_is_visible( - &request(&["readFile"]), + &request(&["readFile"]) + .governance + .unwrap() + .allowed_capability_names, &declaration(Some("spawn")) )); } @@ -277,7 +298,10 @@ mod tests { #[test] fn prompt_declaration_visibility_keeps_granted_capabilities() { assert!(prompt_declaration_is_visible( - &request(&["spawn"]), + &request(&["spawn"]) + .governance + .unwrap() + .allowed_capability_names, &declaration(Some("spawn")) )); } diff --git a/crates/server/src/bootstrap/providers.rs b/crates/server/src/bootstrap/providers.rs index 9815c98f..561c0957 100644 --- a/crates/server/src/bootstrap/providers.rs +++ b/crates/server/src/bootstrap/providers.rs @@ -11,8 +11,7 @@ use std::{ use astrcode_adapter_agents::AgentProfileLoader; use astrcode_adapter_llm::{ - LlmClientConfig, ModelLimits as AdapterModelLimits, anthropic::AnthropicProvider, - openai::OpenAiProvider, + LlmClientConfig, ModelLimits, anthropic::AnthropicProvider, openai::OpenAiProvider, }; use astrcode_adapter_mcp::{core_port::McpResourceProvider, manager::McpConnectionManager}; use astrcode_adapter_prompt::{ @@ -20,17 +19,17 @@ use astrcode_adapter_prompt::{ }; use astrcode_adapter_storage::config_store::FileConfigStore; use astrcode_application::{ - ApplicationError, ConfigService, ProfileResolutionService, + ApplicationError, ProfileResolutionService, config::{ - api_key, resolve_anthropic_messages_api_url, resolve_openai_chat_completions_api_url, + ConfigService, api_key, resolve_anthropic_messages_api_url, resolve_current_model, + resolve_openai_chat_completions_api_url, }, execution::ProfileProvider, }; use super::deps::core::{ AgentProfile, AstrError, LlmEventSink, LlmOutput, LlmProvider, LlmRequest, ModelConfig, - ModelLimits, PromptProvider, ResolvedRuntimeConfig, ResourceProvider, Result, - resolve_runtime_config, + PromptProvider, ResolvedRuntimeConfig, ResourceProvider, Result, resolve_runtime_config, }; pub(crate) fn build_llm_provider( @@ -114,7 +113,7 @@ impl ConfigBackedLlmProvider { let config = self .config_service .load_overlayed_config(Some(self.working_dir.as_path()))?; - let selection = astrcode_application::resolve_current_model(&config)?; + let selection = resolve_current_model(&config)?; let profile = config .profiles .iter() @@ -191,20 +190,14 @@ impl ConfigBackedLlmProvider { spec.endpoint.clone(), spec.api_key.clone(), spec.model.clone(), - AdapterModelLimits { - context_window: spec.limits.context_window, - max_output_tokens: spec.limits.max_output_tokens, - }, + spec.limits, spec.client_config, )?), "anthropic" => Arc::new(AnthropicProvider::new( spec.endpoint.clone(), spec.api_key.clone(), spec.model.clone(), - AdapterModelLimits { - context_window: spec.limits.context_window, - max_output_tokens: spec.limits.max_output_tokens, - }, + spec.limits, spec.client_config, )?), other => { diff --git a/crates/server/src/bootstrap/runtime.rs b/crates/server/src/bootstrap/runtime.rs index e4278ff6..e154ef66 100644 --- a/crates/server/src/bootstrap/runtime.rs +++ b/crates/server/src/bootstrap/runtime.rs @@ -8,11 +8,11 @@ use std::{ sync::Arc, }; -use astrcode_adapter_storage::core_port::FsEventStore; +use astrcode_adapter_storage::session::FileSystemSessionRepository; use astrcode_adapter_tools::builtin_tools::tool_search::ToolSearchIndex; use astrcode_application::{ - AgentOrchestrationService, App, AppGovernance, RuntimeObservabilityCollector, WatchService, - lifecycle::TaskRegistry, + AgentOrchestrationService, App, AppGovernance, GovernanceSurfaceAssembler, + RuntimeObservabilityCollector, WatchService, builtin_mode_catalog, lifecycle::TaskRegistry, }; use super::{ @@ -157,6 +157,7 @@ pub async fn bootstrap_server_runtime_with_options( let PluginBootstrapResult { invokers: plugin_invokers, skills: plugin_skills, + modes: plugin_modes, registry: plugin_registry, supervisors: plugin_supervisors, search_paths: plugin_search_paths, @@ -193,10 +194,15 @@ pub async fn bootstrap_server_runtime_with_options( )?); let observability = Arc::new(RuntimeObservabilityCollector::new()); let task_registry = Arc::new(TaskRegistry::new()); - - let event_store: Arc<dyn EventStore> = Arc::new(FsEventStore::new_with_projects_root( - paths.projects_root.clone(), + let mode_catalog = Arc::new(builtin_mode_catalog()?); + mode_catalog.replace_plugin_modes(plugin_modes.clone())?; + let governance_surface = Arc::new(GovernanceSurfaceAssembler::new( + mode_catalog.as_ref().clone(), )); + + let event_store: Arc<dyn EventStore> = Arc::new( + FileSystemSessionRepository::new_with_projects_root(paths.projects_root.clone()), + ); let prompt_facts_provider = build_prompt_facts_provider( config_service.clone(), skill_catalog.clone(), @@ -220,6 +226,7 @@ pub async fn bootstrap_server_runtime_with_options( session_runtime.clone(), config_service.clone(), profiles.clone(), + Arc::clone(&governance_surface), task_registry.clone(), observability.clone(), )); @@ -253,6 +260,8 @@ pub async fn bootstrap_server_runtime_with_options( profiles, config_service.clone(), Arc::new(RuntimeComposerSkillPort::new(skill_catalog.clone())), + Arc::clone(&governance_surface), + Arc::clone(&mode_catalog), mcp_service, agent_service, )); @@ -270,6 +279,7 @@ pub async fn bootstrap_server_runtime_with_options( plugin_skill_root: paths.plugin_skill_root.clone(), plugin_supervisors, working_dir: working_dir.clone(), + mode_catalog: Some(mode_catalog), }); let profile_watch_runtime = if options.enable_profile_watch { Some( diff --git a/crates/server/src/http/auth.rs b/crates/server/src/http/auth.rs index b85f74f2..c0fa63c4 100644 --- a/crates/server/src/http/auth.rs +++ b/crates/server/src/http/auth.rs @@ -73,6 +73,14 @@ pub(crate) struct IssuedAuthToken { pub expires_at_ms: i64, } +/// auth exchange 的共享摘要输入。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct AuthExchangeSummary { + pub ok: bool, + pub token: String, + pub expires_at_ms: i64, +} + /// API 会话 token 管理器。 /// /// 维护一个线程安全的 token 映射,支持签发和验证。 @@ -90,6 +98,15 @@ impl AuthSessionManager { self.issue_named_token(random_hex_token(), API_SESSION_TTL_HOURS) } + pub(crate) fn issue_exchange_summary(&self) -> AuthExchangeSummary { + let issued = self.issue_token(); + AuthExchangeSummary { + ok: true, + token: issued.token, + expires_at_ms: issued.expires_at_ms, + } + } + /// 验证 token 是否有效且未过期。 /// /// 验证前会先清理所有过期 token。 diff --git a/crates/server/src/http/mapper.rs b/crates/server/src/http/mapper.rs index d8d95753..b4727a28 100644 --- a/crates/server/src/http/mapper.rs +++ b/crates/server/src/http/mapper.rs @@ -6,8 +6,8 @@ //! //! - **协议层映射**:配置选择和 fallback 规则已下沉到 `runtime-config`,这里只做纯映射, //! 避免服务端入口悄悄长出另一套配置业务逻辑。 -//! - **集中化**:所有 protocol 映射逻辑集中在此,`protocol` crate 保持独立, 不依赖 -//! `core`/`runtime` 的内部类型。 +//! - **集中化**:所有 protocol 映射逻辑集中在此;纯镜像类型已在 `protocol` 中直接复用 `core` +//! 定义,这里只保留真正的协议投影。 //! - **容错序列化**:SSE 事件序列化失败时返回结构化错误载荷而非断开连接。 //! //! ## 映射分类 @@ -20,75 +20,95 @@ //! - **SSE 工具**:事件 ID 解析/格式化(`{storage_seq}.{subindex}` 格式) use astrcode_application::{ - AgentCollaborationScorecardSnapshot, AgentMode, ApplicationError, ArtifactRef, CapabilitySpec, - ComposerOption, ComposerOptionKind, Config, ExecutionDiagnosticsSnapshot, ForkMode, - GovernanceSnapshot, InvocationMode, OperationMetricsSnapshot, Phase, PluginEntry, PluginHealth, - PluginState, ReplayMetricsSnapshot, RuntimeObservabilitySnapshot, SessionCatalogEvent, - SessionMeta, SubRunExecutionMetricsSnapshot, SubRunFailureCode, SubRunHandoff, - SubRunStorageMode, SubagentContextOverrides, format_local_rfc3339, is_env_var_name, - list_model_options as resolve_model_options, resolve_active_selection, - resolve_current_model as resolve_runtime_current_model, + AgentExecuteSummary, ApplicationError, ComposerOption, Config, ResolvedRuntimeStatusSummary, + SessionCatalogEvent, SessionListSummary, SubRunStatusSourceSummary, SubRunStatusSummary, + SubagentContextOverrides, + config::{ + ResolvedConfigSummary, list_model_options as resolve_model_options, + resolve_current_model as resolve_runtime_current_model, + }, }; -#[cfg(feature = "debug-workbench")] -use astrcode_application::{AgentLifecycleStatus, AgentTurnOutcome}; -use astrcode_core::{ - ParentDelivery, ParentDeliveryOrigin, ParentDeliveryPayload, ParentDeliveryTerminalSemantics, -}; -#[cfg(feature = "debug-workbench")] -use astrcode_debug_workbench::{ - DebugAgentNodeKind, RuntimeDebugOverview, RuntimeDebugTimeline, RuntimeDebugTimelineSample, - SessionDebugAgentNode, SessionDebugAgents, SessionDebugTrace, SessionDebugTraceItem, - SessionDebugTraceItemKind, -}; -use astrcode_protocol::http::{ - AgentCollaborationScorecardDto, AgentProfileDto, ArtifactRefDto, - CloseRequestParentDeliveryPayloadDto, CompletedParentDeliveryPayloadDto, - ComposerOptionActionKindDto, ComposerOptionDto, ComposerOptionKindDto, - ComposerOptionsResponseDto, ConfigView, CurrentModelInfoDto, ExecutionDiagnosticsDto, - FailedParentDeliveryPayloadDto, ForkModeDto, ModelOptionDto, OperationMetricsDto, - PROTOCOL_VERSION, ParentDeliveryDto, ParentDeliveryOriginDto, ParentDeliveryPayloadDto, - ParentDeliveryTerminalSemanticsDto, PhaseDto, PluginHealthDto, PluginRuntimeStateDto, - ProfileView, ProgressParentDeliveryPayloadDto, ReplayMetricsDto, RuntimeCapabilityDto, - RuntimeMetricsDto, RuntimePluginDto, RuntimeStatusDto, SessionCatalogEventEnvelope, - SessionCatalogEventPayload, SessionListItem, SubRunExecutionMetricsDto, SubRunFailureCodeDto, - SubRunHandoffDto, SubRunStorageModeDto, SubagentContextOverridesDto, -}; -#[cfg(feature = "debug-workbench")] use astrcode_protocol::http::{ - AgentLifecycleDto, AgentTurnOutcomeDto, DebugAgentNodeKindDto, RuntimeDebugOverviewDto, - RuntimeDebugTimelineDto, RuntimeDebugTimelineSampleDto, SessionDebugAgentNodeDto, - SessionDebugAgentsDto, SessionDebugTraceDto, SessionDebugTraceItemDto, - SessionDebugTraceItemKindDto, + AgentExecuteResponseDto, ComposerOptionsResponseDto, ConfigView, CurrentModelInfoDto, + ModelOptionDto, PROTOCOL_VERSION, ProfileView, ResolvedExecutionLimitsDto, + RuntimeCapabilityDto, RuntimePluginDto, RuntimeStatusDto, SessionCatalogEventEnvelope, + SessionListItem, SubRunResultDto, SubRunStatusDto, SubRunStatusSourceDto, + SubagentContextOverridesDto, }; use axum::{http::StatusCode, response::sse::Event}; use crate::ApiError; -#[derive(Debug, Clone)] -pub(crate) struct AgentProfileSummary { - pub id: String, - pub name: String, - pub description: String, - pub mode: AgentMode, - pub allowed_tools: Vec<String>, - pub disallowed_tools: Vec<String>, +fn to_runtime_capability_dto( + capability: astrcode_application::RuntimeCapabilitySummary, +) -> RuntimeCapabilityDto { + RuntimeCapabilityDto { + name: capability.name, + kind: capability.kind, + description: capability.description, + profiles: capability.profiles, + streaming: capability.streaming, + } } -/// 将会话元数据映射为列表项 DTO。 +/// 将会话摘要输入映射为列表项 DTO。 /// /// 用于 `GET /api/sessions` 和 `POST /api/sessions` 的响应, -/// 将时间戳转换为 RFC3339 字符串格式。 -pub(crate) fn to_session_list_item(meta: SessionMeta) -> SessionListItem { +/// server 只负责协议包装,不再自行格式化时间字段。 +pub(crate) fn to_session_list_item(summary: SessionListSummary) -> SessionListItem { SessionListItem { - session_id: meta.session_id, - working_dir: meta.working_dir, - display_name: meta.display_name, - title: meta.title, - created_at: format_local_rfc3339(meta.created_at), - updated_at: format_local_rfc3339(meta.updated_at), - parent_session_id: meta.parent_session_id, - parent_storage_seq: meta.parent_storage_seq, - phase: to_phase_dto(meta.phase), + session_id: summary.session_id, + working_dir: summary.working_dir, + display_name: summary.display_name, + title: summary.title, + created_at: summary.created_at, + updated_at: summary.updated_at, + parent_session_id: summary.parent_session_id, + parent_storage_seq: summary.parent_storage_seq, + phase: summary.phase, + } +} + +pub(crate) fn to_agent_execute_response_dto( + summary: AgentExecuteSummary, +) -> AgentExecuteResponseDto { + AgentExecuteResponseDto { + accepted: summary.accepted, + message: summary.message, + session_id: summary.session_id, + turn_id: summary.turn_id, + agent_id: summary.agent_id, + } +} + +pub(crate) fn to_subrun_status_dto(summary: SubRunStatusSummary) -> SubRunStatusDto { + SubRunStatusDto { + sub_run_id: summary.sub_run_id, + tool_call_id: summary.tool_call_id, + source: match summary.source { + SubRunStatusSourceSummary::Live => SubRunStatusSourceDto::Live, + SubRunStatusSourceSummary::Durable => SubRunStatusSourceDto::Durable, + }, + agent_id: summary.agent_id, + agent_profile: summary.agent_profile, + session_id: summary.session_id, + child_session_id: summary.child_session_id, + depth: summary.depth, + parent_agent_id: summary.parent_agent_id, + parent_sub_run_id: summary.parent_sub_run_id, + storage_mode: summary.storage_mode, + lifecycle: summary.lifecycle, + last_turn_outcome: summary.last_turn_outcome, + result: summary.result.map(to_subrun_result_dto), + step_count: summary.step_count, + estimated_tokens: summary.estimated_tokens, + resolved_overrides: summary.resolved_overrides, + resolved_limits: summary + .resolved_limits + .map(|limits| ResolvedExecutionLimitsDto { + allowed_tools: limits.allowed_tools, + max_steps: limits.max_steps, + }), } } @@ -96,70 +116,46 @@ pub(crate) fn to_session_list_item(meta: SessionMeta) -> SessionListItem { /// /// 包含运行时名称、类型、已加载会话数、运行中的会话 ID、 /// 插件搜索路径、运行时指标、能力描述和插件状态。 -pub(crate) fn to_runtime_status_dto(snapshot: GovernanceSnapshot) -> RuntimeStatusDto { +pub(crate) fn to_runtime_status_dto(summary: ResolvedRuntimeStatusSummary) -> RuntimeStatusDto { RuntimeStatusDto { - runtime_name: snapshot.runtime_name, - runtime_kind: snapshot.runtime_kind, - loaded_session_count: snapshot.loaded_session_count, - running_session_ids: snapshot.running_session_ids, - plugin_search_paths: snapshot - .plugin_search_paths - .into_iter() - .map(|path| path.display().to_string()) - .collect(), - metrics: to_runtime_metrics_dto(snapshot.metrics), - capabilities: snapshot + runtime_name: summary.runtime_name, + runtime_kind: summary.runtime_kind, + loaded_session_count: summary.loaded_session_count, + running_session_ids: summary.running_session_ids, + plugin_search_paths: summary.plugin_search_paths, + metrics: summary.metrics, + capabilities: summary .capabilities .into_iter() .map(to_runtime_capability_dto) .collect(), - plugins: snapshot + plugins: summary .plugins .into_iter() - .map(to_runtime_plugin_dto) + .map(|plugin| RuntimePluginDto { + name: plugin.name, + version: plugin.version, + description: plugin.description, + state: plugin.state, + health: plugin.health, + failure_count: plugin.failure_count, + failure: plugin.failure, + warnings: plugin.warnings, + last_checked_at: plugin.last_checked_at, + capabilities: plugin + .capabilities + .into_iter() + .map(to_runtime_capability_dto) + .collect(), + }) .collect(), } } -pub(crate) fn to_agent_profile_dto(profile: AgentProfileSummary) -> AgentProfileDto { - AgentProfileDto { - id: profile.id, - name: profile.name, - description: profile.description, - mode: match profile.mode { - AgentMode::Primary => "primary".to_string(), - AgentMode::SubAgent => "subAgent".to_string(), - AgentMode::All => "all".to_string(), - }, - allowed_tools: profile.allowed_tools, - disallowed_tools: profile.disallowed_tools, - // TODO: 未来可能需要添加更多 agent 级执行限制摘要 - } -} - pub(crate) fn from_subagent_context_overrides_dto( dto: Option<SubagentContextOverridesDto>, ) -> Option<SubagentContextOverrides> { - dto.map(|dto| SubagentContextOverrides { - storage_mode: dto.storage_mode.map(from_subrun_storage_mode_dto), - inherit_system_instructions: dto.inherit_system_instructions, - inherit_project_instructions: dto.inherit_project_instructions, - inherit_working_dir: dto.inherit_working_dir, - inherit_policy_upper_bound: dto.inherit_policy_upper_bound, - inherit_cancel_token: dto.inherit_cancel_token, - include_compact_summary: dto.include_compact_summary, - include_recent_tail: dto.include_recent_tail, - include_recovery_refs: dto.include_recovery_refs, - include_parent_findings: dto.include_parent_findings, - fork_mode: dto.fork_mode.map(from_fork_mode_dto), - }) -} - -fn from_fork_mode_dto(dto: ForkModeDto) -> ForkMode { - match dto { - ForkModeDto::FullHistory => ForkMode::FullHistory, - ForkModeDto::LastNTurns(n) => ForkMode::LastNTurns(n), - } + dto } /// 将会话目录事件转换为 SSE 事件。 @@ -168,569 +164,49 @@ fn from_fork_mode_dto(dto: ForkModeDto) -> ForkMode { /// 序列化失败时返回 `projectDeleted` 事件并携带错误信息, /// 保证 SSE 流不会中断。 pub(crate) fn to_session_catalog_sse_event(event: SessionCatalogEvent) -> Event { - let payload = serde_json::to_string(&SessionCatalogEventEnvelope::new( - to_session_catalog_event_dto(event), - )) - .unwrap_or_else(|error| { - serde_json::json!({ - "protocolVersion": PROTOCOL_VERSION, - "event": "projectDeleted", - "data": { - "workingDir": format!("serialization-error: {error}") - } - }) - .to_string() - }); + let payload = + serde_json::to_string(&SessionCatalogEventEnvelope::new(event)).unwrap_or_else(|error| { + serde_json::json!({ + "protocolVersion": PROTOCOL_VERSION, + "event": "projectDeleted", + "data": { + "workingDir": format!("serialization-error: {error}") + } + }) + .to_string() + }); Event::default().data(payload) } -/// 将内部 `Phase` 枚举映射为协议层 `PhaseDto`。 +/// 构建配置视图 DTO。 /// -/// 阶段枚举用于前端渲染会话状态指示器(如思考中、工具调用中、流式输出等)。 -pub(crate) fn to_phase_dto(phase: Phase) -> PhaseDto { - match phase { - Phase::Idle => PhaseDto::Idle, - Phase::Thinking => PhaseDto::Thinking, - Phase::CallingTool => PhaseDto::CallingTool, - Phase::Streaming => PhaseDto::Streaming, - Phase::Interrupted => PhaseDto::Interrupted, - Phase::Done => PhaseDto::Done, - } -} - -fn to_artifact_ref_dto(artifact: ArtifactRef) -> ArtifactRefDto { - ArtifactRefDto { - kind: artifact.kind, - id: artifact.id, - label: artifact.label, - session_id: artifact.session_id, - storage_seq: artifact.storage_seq, - uri: artifact.uri, - } -} - -fn from_subrun_storage_mode_dto(mode: SubRunStorageModeDto) -> SubRunStorageMode { - match mode { - SubRunStorageModeDto::IndependentSession => SubRunStorageMode::IndependentSession, - } -} - -fn upgraded_handoff_delivery( - handoff: &SubRunHandoff, - _sub_run_id: Option<&str>, - _turn_id: Option<&str>, -) -> Option<ParentDelivery> { - handoff.delivery.clone() -} - -pub(crate) fn to_subrun_handoff_dto( - handoff: SubRunHandoff, - sub_run_id: Option<&str>, - turn_id: Option<&str>, -) -> SubRunHandoffDto { - let delivery = upgraded_handoff_delivery(&handoff, sub_run_id, turn_id); - SubRunHandoffDto { - findings: handoff.findings, - artifacts: handoff - .artifacts +/// server 只负责补充 `config_path` 和协议外层壳, +/// 已解析选择、profile 摘要与 API key 预览均由 application 统一提供。 +pub(crate) fn build_config_view(summary: ResolvedConfigSummary, config_path: String) -> ConfigView { + ConfigView { + config_path, + active_profile: summary.active_profile, + active_model: summary.active_model, + profiles: summary + .profiles .into_iter() - .map(to_artifact_ref_dto) - .collect(), - delivery: delivery.map(to_parent_delivery_dto), - } -} - -fn to_parent_delivery_origin_dto(origin: ParentDeliveryOrigin) -> ParentDeliveryOriginDto { - match origin { - ParentDeliveryOrigin::Explicit => ParentDeliveryOriginDto::Explicit, - ParentDeliveryOrigin::Fallback => ParentDeliveryOriginDto::Fallback, - } -} - -fn to_parent_delivery_terminal_semantics_dto( - semantics: ParentDeliveryTerminalSemantics, -) -> ParentDeliveryTerminalSemanticsDto { - match semantics { - ParentDeliveryTerminalSemantics::NonTerminal => { - ParentDeliveryTerminalSemanticsDto::NonTerminal - }, - ParentDeliveryTerminalSemantics::Terminal => ParentDeliveryTerminalSemanticsDto::Terminal, - } -} - -fn to_parent_delivery_payload_dto(payload: ParentDeliveryPayload) -> ParentDeliveryPayloadDto { - match payload { - ParentDeliveryPayload::Progress(payload) => { - ParentDeliveryPayloadDto::Progress(ProgressParentDeliveryPayloadDto { - message: payload.message, - }) - }, - ParentDeliveryPayload::Completed(payload) => { - ParentDeliveryPayloadDto::Completed(CompletedParentDeliveryPayloadDto { - message: payload.message, - findings: payload.findings, - artifacts: payload - .artifacts - .into_iter() - .map(to_artifact_ref_dto) - .collect(), - }) - }, - ParentDeliveryPayload::Failed(payload) => { - ParentDeliveryPayloadDto::Failed(FailedParentDeliveryPayloadDto { - message: payload.message, - code: to_subrun_failure_code_dto(payload.code), - technical_message: payload.technical_message, - retryable: payload.retryable, - }) - }, - ParentDeliveryPayload::CloseRequest(payload) => { - ParentDeliveryPayloadDto::CloseRequest(CloseRequestParentDeliveryPayloadDto { - message: payload.message, - reason: payload.reason, + .map(|profile| ProfileView { + name: profile.name, + base_url: profile.base_url, + api_key_preview: profile.api_key_preview, + models: profile.models, }) - }, - } -} - -pub(crate) fn to_parent_delivery_dto(delivery: ParentDelivery) -> ParentDeliveryDto { - ParentDeliveryDto { - idempotency_key: delivery.idempotency_key, - origin: to_parent_delivery_origin_dto(delivery.origin), - terminal_semantics: to_parent_delivery_terminal_semantics_dto(delivery.terminal_semantics), - source_turn_id: delivery.source_turn_id, - payload: to_parent_delivery_payload_dto(delivery.payload), - } -} - -fn to_subrun_failure_code_dto(code: SubRunFailureCode) -> SubRunFailureCodeDto { - match code { - SubRunFailureCode::Transport => SubRunFailureCodeDto::Transport, - SubRunFailureCode::ProviderHttp => SubRunFailureCodeDto::ProviderHttp, - SubRunFailureCode::StreamParse => SubRunFailureCodeDto::StreamParse, - SubRunFailureCode::Interrupted => SubRunFailureCodeDto::Interrupted, - SubRunFailureCode::Internal => SubRunFailureCodeDto::Internal, - } -} - -/// 将能力描述符映射为 DTO。 -/// -/// `kind` 字段通过 serde_json 序列化后取字符串表示, -/// 反序列化失败时降级为 "unknown",避免协议层崩溃。 -fn to_runtime_capability_dto(spec: CapabilitySpec) -> RuntimeCapabilityDto { - RuntimeCapabilityDto { - name: spec.name.to_string(), - kind: spec.kind.as_str().to_string(), - description: spec.description, - profiles: spec.profiles, - streaming: matches!(spec.invocation_mode, InvocationMode::Streaming), - } -} - -/// 将插件条目映射为 DTO。 -/// -/// 包含插件清单信息(名称、版本、描述)、运行时状态、健康度、 -/// 失败计数和最后检查时间,以及插件暴露的所有能力。 -fn to_runtime_plugin_dto(entry: PluginEntry) -> RuntimePluginDto { - RuntimePluginDto { - name: entry.manifest.name, - version: entry.manifest.version, - description: entry.manifest.description, - state: match entry.state { - PluginState::Discovered => PluginRuntimeStateDto::Discovered, - PluginState::Initialized => PluginRuntimeStateDto::Initialized, - PluginState::Failed => PluginRuntimeStateDto::Failed, - }, - health: match entry.health { - PluginHealth::Unknown => PluginHealthDto::Unknown, - PluginHealth::Healthy => PluginHealthDto::Healthy, - PluginHealth::Degraded => PluginHealthDto::Degraded, - PluginHealth::Unavailable => PluginHealthDto::Unavailable, - }, - failure_count: entry.failure_count, - failure: entry.failure, - warnings: entry.warnings, - last_checked_at: entry.last_checked_at, - capabilities: entry - .capabilities - .into_iter() - .map(to_runtime_capability_dto) - .collect(), - } -} - -/// 将运行时观测指标快照映射为 DTO。 -/// -/// 包含三个维度的指标:会话重连(session_rehydrate)、 -/// SSE 追赶(sse_catch_up)、轮次执行(turn_execution)和子执行域观测(subrun_execution)。 -pub(crate) fn to_runtime_metrics_dto(snapshot: RuntimeObservabilitySnapshot) -> RuntimeMetricsDto { - RuntimeMetricsDto { - session_rehydrate: to_operation_metrics_dto(snapshot.session_rehydrate), - sse_catch_up: to_replay_metrics_dto(snapshot.sse_catch_up), - turn_execution: to_operation_metrics_dto(snapshot.turn_execution), - subrun_execution: to_subrun_execution_metrics_dto(snapshot.subrun_execution), - execution_diagnostics: to_execution_diagnostics_dto(snapshot.execution_diagnostics), - agent_collaboration: to_agent_collaboration_scorecard_dto(snapshot.agent_collaboration), - } -} - -#[cfg(feature = "debug-workbench")] -pub(crate) fn to_runtime_debug_overview_dto( - overview: RuntimeDebugOverview, -) -> RuntimeDebugOverviewDto { - RuntimeDebugOverviewDto { - collected_at: format_local_rfc3339(overview.collected_at), - spawn_rejection_ratio_bps: overview.spawn_rejection_ratio_bps, - metrics: to_runtime_metrics_dto(overview.metrics), - } -} - -#[cfg(feature = "debug-workbench")] -pub(crate) fn to_runtime_debug_timeline_dto( - timeline: RuntimeDebugTimeline, -) -> RuntimeDebugTimelineDto { - RuntimeDebugTimelineDto { - window_started_at: format_local_rfc3339(timeline.window_started_at), - window_ended_at: format_local_rfc3339(timeline.window_ended_at), - samples: timeline - .samples - .into_iter() - .map(to_runtime_debug_timeline_sample_dto) - .collect(), - } -} - -#[cfg(feature = "debug-workbench")] -pub(crate) fn to_session_debug_trace_dto(trace: SessionDebugTrace) -> SessionDebugTraceDto { - SessionDebugTraceDto { - session_id: trace.session_id, - title: trace.title, - phase: to_phase_dto(trace.phase), - parent_session_id: trace.parent_session_id, - items: trace - .items - .into_iter() - .map(to_session_debug_trace_item_dto) .collect(), + warning: summary.warning, } } -#[cfg(feature = "debug-workbench")] -pub(crate) fn to_session_debug_agents_dto(agents: SessionDebugAgents) -> SessionDebugAgentsDto { - SessionDebugAgentsDto { - session_id: agents.session_id, - title: agents.title, - nodes: agents - .nodes - .into_iter() - .map(to_session_debug_agent_node_dto) - .collect(), - } -} - -/// 将操作指标快照映射为 DTO。 -/// -/// 记录总执行次数、失败次数、总耗时、最近一次耗时和最大耗时, -/// 用于前端展示运行时性能面板。 -fn to_operation_metrics_dto(snapshot: OperationMetricsSnapshot) -> OperationMetricsDto { - OperationMetricsDto { - total: snapshot.total, - failures: snapshot.failures, - total_duration_ms: snapshot.total_duration_ms, - last_duration_ms: snapshot.last_duration_ms, - max_duration_ms: snapshot.max_duration_ms, - } -} - -/// 将回放指标快照映射为 DTO。 -/// -/// 在操作指标基础上增加缓存命中数、磁盘回退数和已恢复事件数, -/// 用于衡量 SSE 断线重连后的事件恢复效率。 -fn to_replay_metrics_dto(snapshot: ReplayMetricsSnapshot) -> ReplayMetricsDto { - ReplayMetricsDto { - totals: to_operation_metrics_dto(snapshot.totals), - cache_hits: snapshot.cache_hits, - disk_fallbacks: snapshot.disk_fallbacks, - recovered_events: snapshot.recovered_events, - } -} - -fn to_subrun_execution_metrics_dto( - snapshot: SubRunExecutionMetricsSnapshot, -) -> SubRunExecutionMetricsDto { - SubRunExecutionMetricsDto { - total: snapshot.total, - failures: snapshot.failures, - completed: snapshot.completed, - aborted: snapshot.aborted, - token_exceeded: snapshot.token_exceeded, - independent_session_total: snapshot.independent_session_total, - total_duration_ms: snapshot.total_duration_ms, - last_duration_ms: snapshot.last_duration_ms, - total_steps: snapshot.total_steps, - last_step_count: snapshot.last_step_count, - total_estimated_tokens: snapshot.total_estimated_tokens, - last_estimated_tokens: snapshot.last_estimated_tokens, - } -} - -fn to_execution_diagnostics_dto(snapshot: ExecutionDiagnosticsSnapshot) -> ExecutionDiagnosticsDto { - ExecutionDiagnosticsDto { - child_spawned: snapshot.child_spawned, - child_started_persisted: snapshot.child_started_persisted, - child_terminal_persisted: snapshot.child_terminal_persisted, - parent_reactivation_requested: snapshot.parent_reactivation_requested, - parent_reactivation_succeeded: snapshot.parent_reactivation_succeeded, - parent_reactivation_failed: snapshot.parent_reactivation_failed, - lineage_mismatch_parent_agent: snapshot.lineage_mismatch_parent_agent, - lineage_mismatch_parent_session: snapshot.lineage_mismatch_parent_session, - lineage_mismatch_child_session: snapshot.lineage_mismatch_child_session, - lineage_mismatch_descriptor_missing: snapshot.lineage_mismatch_descriptor_missing, - cache_reuse_hits: snapshot.cache_reuse_hits, - cache_reuse_misses: snapshot.cache_reuse_misses, - delivery_buffer_queued: snapshot.delivery_buffer_queued, - delivery_buffer_dequeued: snapshot.delivery_buffer_dequeued, - delivery_buffer_wake_requested: snapshot.delivery_buffer_wake_requested, - delivery_buffer_wake_succeeded: snapshot.delivery_buffer_wake_succeeded, - delivery_buffer_wake_failed: snapshot.delivery_buffer_wake_failed, - } -} - -fn to_agent_collaboration_scorecard_dto( - snapshot: AgentCollaborationScorecardSnapshot, -) -> AgentCollaborationScorecardDto { - AgentCollaborationScorecardDto { - total_facts: snapshot.total_facts, - spawn_accepted: snapshot.spawn_accepted, - spawn_rejected: snapshot.spawn_rejected, - send_reused: snapshot.send_reused, - send_queued: snapshot.send_queued, - send_rejected: snapshot.send_rejected, - observe_calls: snapshot.observe_calls, - observe_rejected: snapshot.observe_rejected, - observe_followed_by_action: snapshot.observe_followed_by_action, - close_calls: snapshot.close_calls, - close_rejected: snapshot.close_rejected, - delivery_delivered: snapshot.delivery_delivered, - delivery_consumed: snapshot.delivery_consumed, - delivery_replayed: snapshot.delivery_replayed, - orphan_child_count: snapshot.orphan_child_count, - child_reuse_ratio_bps: snapshot.child_reuse_ratio_bps, - observe_to_action_ratio_bps: snapshot.observe_to_action_ratio_bps, - spawn_to_delivery_ratio_bps: snapshot.spawn_to_delivery_ratio_bps, - orphan_child_ratio_bps: snapshot.orphan_child_ratio_bps, - avg_delivery_latency_ms: snapshot.avg_delivery_latency_ms, - max_delivery_latency_ms: snapshot.max_delivery_latency_ms, - } -} - -#[cfg(feature = "debug-workbench")] -fn to_runtime_debug_timeline_sample_dto( - sample: RuntimeDebugTimelineSample, -) -> RuntimeDebugTimelineSampleDto { - RuntimeDebugTimelineSampleDto { - collected_at: format_local_rfc3339(sample.collected_at), - spawn_rejection_ratio_bps: sample.spawn_rejection_ratio_bps, - observe_to_action_ratio_bps: sample.observe_to_action_ratio_bps, - child_reuse_ratio_bps: sample.child_reuse_ratio_bps, - } -} - -#[cfg(feature = "debug-workbench")] -fn to_session_debug_trace_item_dto(item: SessionDebugTraceItem) -> SessionDebugTraceItemDto { - SessionDebugTraceItemDto { - id: item.id, - storage_seq: item.storage_seq, - turn_id: item.turn_id, - recorded_at: item.recorded_at.map(format_local_rfc3339), - kind: to_session_debug_trace_item_kind_dto(item.kind), - title: item.title, - summary: item.summary, - agent_id: item.agent_id, - sub_run_id: item.sub_run_id, - child_agent_id: item.child_agent_id, - delivery_id: item.delivery_id, - tool_call_id: item.tool_call_id, - tool_name: item.tool_name, - lifecycle: item.lifecycle.map(to_agent_lifecycle_dto), - last_turn_outcome: item.last_turn_outcome.map(to_agent_turn_outcome_dto), - } -} - -#[cfg(feature = "debug-workbench")] -fn to_session_debug_trace_item_kind_dto( - kind: SessionDebugTraceItemKind, -) -> SessionDebugTraceItemKindDto { - match kind { - SessionDebugTraceItemKind::ToolCall => SessionDebugTraceItemKindDto::ToolCall, - SessionDebugTraceItemKind::ToolResult => SessionDebugTraceItemKindDto::ToolResult, - SessionDebugTraceItemKind::PromptMetrics => SessionDebugTraceItemKindDto::PromptMetrics, - SessionDebugTraceItemKind::SubRunStarted => SessionDebugTraceItemKindDto::SubRunStarted, - SessionDebugTraceItemKind::SubRunFinished => SessionDebugTraceItemKindDto::SubRunFinished, - SessionDebugTraceItemKind::ChildNotification => { - SessionDebugTraceItemKindDto::ChildNotification - }, - SessionDebugTraceItemKind::CollaborationFact => { - SessionDebugTraceItemKindDto::CollaborationFact - }, - SessionDebugTraceItemKind::MailboxQueued => SessionDebugTraceItemKindDto::MailboxQueued, - SessionDebugTraceItemKind::MailboxBatchStarted => { - SessionDebugTraceItemKindDto::MailboxBatchStarted - }, - SessionDebugTraceItemKind::MailboxBatchAcked => { - SessionDebugTraceItemKindDto::MailboxBatchAcked - }, - SessionDebugTraceItemKind::MailboxDiscarded => { - SessionDebugTraceItemKindDto::MailboxDiscarded - }, - SessionDebugTraceItemKind::TurnDone => SessionDebugTraceItemKindDto::TurnDone, - SessionDebugTraceItemKind::Error => SessionDebugTraceItemKindDto::Error, - } -} - -#[cfg(feature = "debug-workbench")] -fn to_session_debug_agent_node_dto(node: SessionDebugAgentNode) -> SessionDebugAgentNodeDto { - SessionDebugAgentNodeDto { - node_id: node.node_id, - kind: to_debug_agent_node_kind_dto(node.kind), - title: node.title, - agent_id: node.agent_id, - session_id: node.session_id, - child_session_id: node.child_session_id, - sub_run_id: node.sub_run_id, - parent_agent_id: node.parent_agent_id, - parent_session_id: node.parent_session_id, - depth: node.depth, - lifecycle: to_agent_lifecycle_dto(node.lifecycle), - last_turn_outcome: node.last_turn_outcome.map(to_agent_turn_outcome_dto), - status_source: node.status_source.map(|value| format!("{value:?}")), - lineage_kind: node - .lineage_kind - .map(|value| format!("{value:?}").to_lowercase()), - } -} - -#[cfg(feature = "debug-workbench")] -fn to_debug_agent_node_kind_dto(kind: DebugAgentNodeKind) -> DebugAgentNodeKindDto { - match kind { - DebugAgentNodeKind::SessionRoot => DebugAgentNodeKindDto::SessionRoot, - DebugAgentNodeKind::ChildAgent => DebugAgentNodeKindDto::ChildAgent, - } -} - -#[cfg(feature = "debug-workbench")] -fn to_agent_lifecycle_dto(status: AgentLifecycleStatus) -> AgentLifecycleDto { - match status { - AgentLifecycleStatus::Pending => AgentLifecycleDto::Pending, - AgentLifecycleStatus::Running => AgentLifecycleDto::Running, - AgentLifecycleStatus::Idle => AgentLifecycleDto::Idle, - AgentLifecycleStatus::Terminated => AgentLifecycleDto::Terminated, - } -} - -#[cfg(feature = "debug-workbench")] -fn to_agent_turn_outcome_dto(outcome: AgentTurnOutcome) -> AgentTurnOutcomeDto { - match outcome { - AgentTurnOutcome::Completed => AgentTurnOutcomeDto::Completed, - AgentTurnOutcome::Failed => AgentTurnOutcomeDto::Failed, - AgentTurnOutcome::Cancelled => AgentTurnOutcomeDto::Cancelled, - AgentTurnOutcome::TokenExceeded => AgentTurnOutcomeDto::TokenExceeded, - } -} - -/// 将会话目录事件映射为协议层载荷。 -/// -/// 目录事件用于前端同步会话列表变更,包括会话创建/删除、 -/// 项目删除(级联删除该工作目录下所有会话)、会话分支。 -pub(crate) fn to_session_catalog_event_dto( - event: SessionCatalogEvent, -) -> SessionCatalogEventPayload { - match event { - SessionCatalogEvent::SessionCreated { session_id } => { - SessionCatalogEventPayload::SessionCreated { session_id } - }, - SessionCatalogEvent::SessionDeleted { session_id } => { - SessionCatalogEventPayload::SessionDeleted { session_id } - }, - SessionCatalogEvent::ProjectDeleted { working_dir } => { - SessionCatalogEventPayload::ProjectDeleted { working_dir } - }, - SessionCatalogEvent::SessionBranched { - session_id, - source_session_id, - } => SessionCatalogEventPayload::SessionBranched { - session_id, - source_session_id, - }, - } -} - -/// 构建配置视图 DTO。 -/// -/// 将内部 `Config` 转换为前端可展示的配置视图,包括: -/// - 配置文件路径 -/// - 当前激活的 profile 和 model -/// - 所有 profile 列表(API key 做脱敏预览) -/// - 配置警告(如无 profile 时提示) -/// -/// Profile 为空时直接返回带警告的视图,不走活跃选择解析。 -pub(crate) fn build_config_view( - config: &Config, - config_path: String, -) -> Result<ConfigView, ApiError> { - if config.profiles.is_empty() { - return Ok(ConfigView { - config_path, - active_profile: String::new(), - active_model: String::new(), - profiles: Vec::new(), - warning: Some("no profiles configured".to_string()), - }); - } - - let profiles = config - .profiles - .iter() - .map(|profile| ProfileView { - name: profile.name.clone(), - base_url: profile.base_url.clone(), - api_key_preview: api_key_preview(profile.api_key.as_deref()), - models: profile - .models - .iter() - .map(|model| model.id.clone()) - .collect(), - }) - .collect::<Vec<_>>(); - - let selection = resolve_active_selection( - &config.active_profile, - &config.active_model, - &config.profiles, - ) - .map_err(config_selection_error)?; - - Ok(ConfigView { - config_path, - active_profile: selection.active_profile, - active_model: selection.active_model, - profiles, - warning: selection.warning, - }) -} - /// 解析当前激活的模型信息。 /// /// 从配置中提取当前使用的 profile 名称、模型名称和提供者类型, /// 用于 `GET /api/models/current` 响应。 pub(crate) fn resolve_current_model(config: &Config) -> Result<CurrentModelInfoDto, ApiError> { - let selection = resolve_runtime_current_model(config).map_err(config_selection_error)?; - - Ok(CurrentModelInfoDto { - profile_name: selection.profile_name, - model: selection.model, - provider_kind: selection.provider_kind, - }) + resolve_runtime_current_model(config).map_err(config_selection_error) } /// 列出所有可用的模型选项。 @@ -739,13 +215,6 @@ pub(crate) fn resolve_current_model(config: &Config) -> Result<CurrentModelInfoD /// 用于 `GET /api/models` 响应,前端据此渲染模型选择器。 pub(crate) fn list_model_options(config: &Config) -> Vec<ModelOptionDto> { resolve_model_options(config) - .into_iter() - .map(|option| ModelOptionDto { - profile_name: option.profile_name, - model: option.model, - provider_kind: option.provider_kind, - }) - .collect() } /// 将 runtime 输入候选项映射为协议 DTO。 @@ -754,34 +223,7 @@ pub(crate) fn list_model_options(config: &Config) -> Vec<ModelOptionDto> { pub(crate) fn to_composer_options_response( items: Vec<ComposerOption>, ) -> ComposerOptionsResponseDto { - ComposerOptionsResponseDto { - items: items.into_iter().map(to_composer_option_dto).collect(), - } -} - -fn to_composer_option_dto(item: ComposerOption) -> ComposerOptionDto { - ComposerOptionDto { - kind: match item.kind { - ComposerOptionKind::Command => ComposerOptionKindDto::Command, - ComposerOptionKind::Skill => ComposerOptionKindDto::Skill, - ComposerOptionKind::Capability => ComposerOptionKindDto::Capability, - }, - id: item.id, - title: item.title, - description: item.description, - insert_text: item.insert_text, - action_kind: match item.action_kind { - astrcode_application::ComposerOptionActionKind::InsertText => { - ComposerOptionActionKindDto::InsertText - }, - astrcode_application::ComposerOptionActionKind::ExecuteCommand => { - ComposerOptionActionKindDto::ExecuteCommand - }, - }, - action_value: item.action_value, - badges: item.badges, - keywords: item.keywords, - } + ComposerOptionsResponseDto { items } } fn config_selection_error(error: ApplicationError) -> ApiError { @@ -791,64 +233,22 @@ fn config_selection_error(error: ApplicationError) -> ApiError { } } -/// 生成 API key 的安全预览字符串。 -/// -/// 规则: -/// - `None` 或空字符串 → "未配置" -/// - `env:VAR_NAME` 前缀 → "环境变量: VAR_NAME"(不读取实际值) -/// - `literal:KEY` 前缀 → 显示 **** + 最后 4 个字符 -/// - 纯大写+下划线且是有效环境变量名 → "环境变量: NAME" -/// - 长度 > 4 → 显示 "****" + 最后 4 个字符 -/// - 其他 → "****" -pub(crate) fn api_key_preview(api_key: Option<&str>) -> String { - match api_key.map(str::trim) { - None | Some("") => "未配置".to_string(), - Some(value) if value.starts_with("env:") => { - let env_name = value.trim_start_matches("env:").trim(); - if env_name.is_empty() { - "未配置".to_string() - } else { - format!("环境变量: {}", env_name) - } +fn to_subrun_result_dto(result: astrcode_application::SubRunResult) -> SubRunResultDto { + match result { + astrcode_application::SubRunResult::Running { handoff } => { + SubRunResultDto::Running { handoff } }, - Some(value) if value.starts_with("literal:") => { - let key = value.trim_start_matches("literal:").trim(); - masked_key_preview(key) + astrcode_application::SubRunResult::Completed { outcome, handoff } => match outcome { + astrcode_core::CompletedSubRunOutcome::Completed => { + SubRunResultDto::Completed { handoff } + }, + astrcode_core::CompletedSubRunOutcome::TokenExceeded => { + SubRunResultDto::TokenExceeded { handoff } + }, }, - Some(value) if is_env_var_name(value) && std::env::var_os(value).is_some() => { - format!("环境变量: {}", value) + astrcode_application::SubRunResult::Failed { outcome, failure } => match outcome { + astrcode_core::FailedSubRunOutcome::Failed => SubRunResultDto::Failed { failure }, + astrcode_core::FailedSubRunOutcome::Cancelled => SubRunResultDto::Cancelled { failure }, }, - Some(value) => masked_key_preview(value), - } -} - -fn masked_key_preview(value: &str) -> String { - let char_starts: Vec<usize> = value.char_indices().map(|(index, _)| index).collect(); - - if char_starts.len() <= 4 { - "****".to_string() - } else { - // 预览语义是“最后 4 个字符”而不是“最后 4 个字节”, - // 用字符起始位置切片可以避免多字节 UTF-8 密钥在预览时 panic。 - let suffix_start = char_starts[char_starts.len() - 4]; - format!("****{}", &value[suffix_start..]) - } -} - -#[cfg(test)] -mod tests { - use super::api_key_preview; - - #[test] - fn api_key_preview_masks_utf8_literal_without_panicking() { - assert_eq!( - api_key_preview(Some("literal:令牌甲乙丙丁")), - "****甲乙丙丁" - ); - } - - #[test] - fn api_key_preview_masks_utf8_plain_value_without_panicking() { - assert_eq!(api_key_preview(Some("令牌甲乙丙丁戊")), "****乙丙丁戊"); } } diff --git a/crates/server/src/http/routes/agents.rs b/crates/server/src/http/routes/agents.rs index 07a0c7dc..66370d45 100644 --- a/crates/server/src/http/routes/agents.rs +++ b/crates/server/src/http/routes/agents.rs @@ -5,16 +5,10 @@ use std::path::PathBuf; -use astrcode_application::{ - AgentEventContext, AgentLifecycleStatus, AgentTurnOutcome, InvocationKind, - ResolvedExecutionLimitsSnapshot, ResolvedSubagentContextOverrides, StorageEventPayload, - StoredEvent, SubRunFailure, SubRunFailureCode, SubRunResult, SubRunStatusView, -}; +use astrcode_application::{AgentExecuteSummary, RootExecutionRequest}; use astrcode_protocol::http::{ - AgentExecuteRequestDto, AgentExecuteResponseDto, AgentLifecycleDto, AgentProfileDto, - AgentTurnOutcomeDto, ExecutionControlDto, ResolvedExecutionLimitsDto, - ResolvedSubagentContextOverridesDto, SubRunFailureCodeDto, SubRunFailureDto, SubRunOutcomeDto, - SubRunResultDto, SubRunStatusDto, SubRunStatusSourceDto, SubRunStorageModeDto, + AgentExecuteRequestDto, AgentExecuteResponseDto, AgentProfileDto, ExecutionControlDto, + SubRunStatusDto, }; use axum::{ Json, @@ -23,15 +17,19 @@ use axum::{ }; use serde::Serialize; -use crate::{ApiError, AppState, auth::require_auth, routes::sessions}; +use crate::{ + ApiError, AppState, + auth::require_auth, + mapper::{ + from_subagent_context_overrides_dto, to_agent_execute_response_dto, to_subrun_status_dto, + }, + routes::sessions, +}; fn to_execution_control( control: Option<ExecutionControlDto>, ) -> Option<astrcode_application::ExecutionControl> { - control.map(|control| astrcode_application::ExecutionControl { - max_steps: control.max_steps, - manual_compact: control.manual_compact, - }) + control } pub(crate) async fn list_agents( @@ -44,16 +42,6 @@ pub(crate) async fn list_agents( .list_global_agent_profiles() .map_err(ApiError::from)? .into_iter() - .map(|profile| { - crate::mapper::to_agent_profile_dto(crate::mapper::AgentProfileSummary { - id: profile.id, - name: profile.name, - description: profile.description, - mode: profile.mode, - allowed_tools: profile.allowed_tools, - disallowed_tools: profile.disallowed_tools, - }) - }) .collect(); Ok(Json(profiles)) } @@ -69,33 +57,21 @@ pub(crate) async fn execute_agent( .working_dir .map(PathBuf::from) .ok_or_else(|| ApiError::bad_request("workingDir is required".to_string()))?; - let accepted = state + let summary: AgentExecuteSummary = state .app - .execute_root_agent(astrcode_application::RootExecutionRequest { + .execute_root_agent_summary(RootExecutionRequest { agent_id: agent_id.clone(), working_dir: working_dir.to_string_lossy().to_string(), task: request.task, context: request.context, control: to_execution_control(request.control), - context_overrides: crate::mapper::from_subagent_context_overrides_dto( - request.context_overrides, - ), + context_overrides: from_subagent_context_overrides_dto(request.context_overrides), }) .await .map_err(ApiError::from)?; Ok(( StatusCode::ACCEPTED, - Json(AgentExecuteResponseDto { - accepted: true, - message: format!( - "agent '{}' execution accepted; subscribe to \ - /api/v1/conversation/sessions/{}/stream for progress", - agent_id, accepted.session_id - ), - session_id: Some(accepted.session_id.to_string()), - turn_id: Some(accepted.turn_id.to_string()), - agent_id: accepted.agent_id.map(|value| value.to_string()), - }), + Json(to_agent_execute_response_dto(summary)), )) } @@ -106,97 +82,12 @@ pub(crate) async fn get_subrun_status( ) -> Result<Json<SubRunStatusDto>, ApiError> { require_auth(&state, &headers, None)?; let session_id = sessions::validate_session_path_id(&session_id)?; - - // 先尝试通过 agent_id 查询稳定视图 - if let Some(view) = state - .app - .get_subrun_status(&sub_run_id) - .await - .map_err(ApiError::from)? - { - return Ok(Json(to_subrun_status_dto(view, session_id))); - } - - // 再尝试通过 session 查找根 agent - if let Some(view) = state - .app - .get_root_agent_status(&session_id) - .await - .map_err(ApiError::from)? - { - if view.sub_run_id == sub_run_id { - return Ok(Json(to_subrun_status_dto(view, session_id))); - } - return Err(ApiError { - status: StatusCode::NOT_FOUND, - message: format!( - "subrun '{}' not found in session '{}'", - sub_run_id, session_id - ), - }); - } - - if let Some(view) = durable_subrun_status(&state, &session_id, &sub_run_id).await? { - return Ok(Json(view)); - } - - // 兜底:返回默认值(兼容无 agent 的 session) - Ok(Json(SubRunStatusDto { - sub_run_id, - tool_call_id: None, - source: SubRunStatusSourceDto::Live, - agent_id: "root-agent".to_string(), - agent_profile: "default".to_string(), - session_id, - child_session_id: None, - depth: 0, - parent_agent_id: None, - parent_sub_run_id: None, - storage_mode: SubRunStorageModeDto::IndependentSession, - lifecycle: AgentLifecycleDto::Idle, - last_turn_outcome: None, - result: None, - step_count: None, - estimated_tokens: None, - resolved_overrides: None, - resolved_limits: Some(ResolvedExecutionLimitsDto { - allowed_tools: Vec::new(), - max_steps: None, - }), - })) -} - -async fn durable_subrun_status( - state: &AppState, - parent_session_id: &str, - requested_subrun_id: &str, -) -> Result<Option<SubRunStatusDto>, ApiError> { - let child_sessions = state + let summary = state .app - .list_sessions() + .get_subrun_status_summary(&session_id, &sub_run_id) .await - .map_err(ApiError::from)? - .into_iter() - .filter(|meta| meta.parent_session_id.as_deref() == Some(parent_session_id)) - .collect::<Vec<_>>(); - - for child_session in child_sessions { - let stored_events = state - .app - .session_stored_events(&child_session.session_id) - .await - .map_err(ApiError::from)?; - if let Some(snapshot) = project_durable_subrun_status( - parent_session_id, - &child_session.session_id, - requested_subrun_id, - &stored_events, - ) { - return Ok(Some(snapshot)); - } - } - - Ok(None) + .map_err(ApiError::from)?; + Ok(Json(to_subrun_status_dto(summary))) } /// 关闭指定 agent 及其子树。 @@ -219,367 +110,8 @@ pub(crate) async fn close_agent( })) } -/// 将稳定视图转换为协议 DTO。 -fn to_subrun_status_dto(view: SubRunStatusView, session_id: String) -> SubRunStatusDto { - SubRunStatusDto { - sub_run_id: view.sub_run_id, - tool_call_id: None, - source: SubRunStatusSourceDto::Live, - agent_id: view.agent_id, - agent_profile: view.agent_profile, - session_id, - child_session_id: view.child_session_id, - depth: view.depth, - parent_agent_id: view.parent_agent_id, - parent_sub_run_id: None, - storage_mode: SubRunStorageModeDto::IndependentSession, - lifecycle: to_lifecycle_dto(view.lifecycle), - last_turn_outcome: view.last_turn_outcome.map(to_turn_outcome_dto), - result: None, - step_count: None, - estimated_tokens: None, - resolved_overrides: None, - resolved_limits: Some(ResolvedExecutionLimitsDto { - allowed_tools: view.resolved_limits.allowed_tools, - max_steps: view.resolved_limits.max_steps, - }), - } -} - -#[derive(Debug, Clone)] -struct DurableSubRunStatusProjection { - sub_run_id: String, - tool_call_id: Option<String>, - agent_id: String, - agent_profile: String, - child_session_id: String, - depth: usize, - parent_agent_id: Option<String>, - parent_sub_run_id: Option<String>, - lifecycle: AgentLifecycleStatus, - last_turn_outcome: Option<AgentTurnOutcome>, - result: Option<SubRunResult>, - step_count: Option<u32>, - estimated_tokens: Option<u64>, - resolved_overrides: Option<ResolvedSubagentContextOverrides>, - resolved_limits: ResolvedExecutionLimitsSnapshot, -} - -fn project_durable_subrun_status( - parent_session_id: &str, - child_session_id: &str, - requested_subrun_id: &str, - stored_events: &[StoredEvent], -) -> Option<SubRunStatusDto> { - let mut projection: Option<DurableSubRunStatusProjection> = None; - - for stored in stored_events { - let agent = &stored.event.agent; - if !matches_requested_subrun(agent, requested_subrun_id) { - continue; - } - - match &stored.event.payload { - StorageEventPayload::SubRunStarted { - tool_call_id, - resolved_overrides, - resolved_limits, - .. - } => { - projection = Some(DurableSubRunStatusProjection { - sub_run_id: agent - .sub_run_id - .clone() - .unwrap_or_else(|| requested_subrun_id.to_string()), - tool_call_id: tool_call_id.clone(), - agent_id: agent - .agent_id - .clone() - .unwrap_or_else(|| requested_subrun_id.to_string()), - agent_profile: agent - .agent_profile - .clone() - .unwrap_or_else(|| "unknown".to_string()), - child_session_id: child_session_id.to_string(), - depth: 1, - parent_agent_id: None, - parent_sub_run_id: agent.parent_sub_run_id.clone(), - lifecycle: AgentLifecycleStatus::Running, - last_turn_outcome: None, - result: None, - step_count: None, - estimated_tokens: None, - resolved_overrides: Some(resolved_overrides.clone()), - resolved_limits: resolved_limits.clone(), - }); - }, - StorageEventPayload::SubRunFinished { - tool_call_id, - result, - step_count, - estimated_tokens, - .. - } => { - let entry = projection.get_or_insert_with(|| DurableSubRunStatusProjection { - sub_run_id: agent - .sub_run_id - .clone() - .unwrap_or_else(|| requested_subrun_id.to_string()), - tool_call_id: None, - agent_id: agent - .agent_id - .clone() - .unwrap_or_else(|| requested_subrun_id.to_string()), - agent_profile: agent - .agent_profile - .clone() - .unwrap_or_else(|| "unknown".to_string()), - child_session_id: child_session_id.to_string(), - depth: 1, - parent_agent_id: None, - parent_sub_run_id: agent.parent_sub_run_id.clone(), - lifecycle: result.lifecycle, - last_turn_outcome: result.last_turn_outcome, - result: None, - step_count: None, - estimated_tokens: None, - resolved_overrides: None, - resolved_limits: ResolvedExecutionLimitsSnapshot::default(), - }); - entry.tool_call_id = tool_call_id.clone().or_else(|| entry.tool_call_id.clone()); - entry.lifecycle = result.lifecycle; - entry.last_turn_outcome = result.last_turn_outcome; - entry.result = Some(result.clone()); - entry.step_count = Some(*step_count); - entry.estimated_tokens = Some(*estimated_tokens); - }, - _ => {}, - } - } - - projection.map(|projection| { - let sub_run_id = projection.sub_run_id.clone(); - SubRunStatusDto { - sub_run_id: projection.sub_run_id, - tool_call_id: projection.tool_call_id, - source: SubRunStatusSourceDto::Durable, - agent_id: projection.agent_id, - agent_profile: projection.agent_profile, - session_id: parent_session_id.to_string(), - child_session_id: Some(projection.child_session_id), - depth: projection.depth, - parent_agent_id: projection.parent_agent_id, - parent_sub_run_id: projection.parent_sub_run_id, - storage_mode: SubRunStorageModeDto::IndependentSession, - lifecycle: to_lifecycle_dto(projection.lifecycle), - last_turn_outcome: projection.last_turn_outcome.map(to_turn_outcome_dto), - result: projection - .result - .map(|result| to_subrun_result_dto(result, Some(sub_run_id.as_str()))), - step_count: projection.step_count, - estimated_tokens: projection.estimated_tokens, - resolved_overrides: projection.resolved_overrides.map(to_resolved_overrides_dto), - resolved_limits: Some(ResolvedExecutionLimitsDto { - allowed_tools: projection.resolved_limits.allowed_tools, - max_steps: projection.resolved_limits.max_steps, - }), - } - }) -} - -fn matches_requested_subrun(agent: &AgentEventContext, requested_subrun_id: &str) -> bool { - if agent.invocation_kind != Some(InvocationKind::SubRun) { - return false; - } - - agent.sub_run_id.as_deref() == Some(requested_subrun_id) - || agent.agent_id.as_deref() == Some(requested_subrun_id) -} - -fn to_resolved_overrides_dto( - overrides: ResolvedSubagentContextOverrides, -) -> ResolvedSubagentContextOverridesDto { - ResolvedSubagentContextOverridesDto { - storage_mode: SubRunStorageModeDto::IndependentSession, - inherit_system_instructions: overrides.inherit_system_instructions, - inherit_project_instructions: overrides.inherit_project_instructions, - inherit_working_dir: overrides.inherit_working_dir, - inherit_policy_upper_bound: overrides.inherit_policy_upper_bound, - inherit_cancel_token: overrides.inherit_cancel_token, - include_compact_summary: overrides.include_compact_summary, - include_recent_tail: overrides.include_recent_tail, - include_recovery_refs: overrides.include_recovery_refs, - include_parent_findings: overrides.include_parent_findings, - fork_mode: None, - } -} - -fn to_subrun_result_dto(result: SubRunResult, sub_run_id: Option<&str>) -> SubRunResultDto { - SubRunResultDto { - status: to_subrun_outcome_dto(result.lifecycle, result.last_turn_outcome), - handoff: result - .handoff - .map(|handoff| crate::mapper::to_subrun_handoff_dto(handoff, sub_run_id, None)), - failure: result.failure.map(to_subrun_failure_dto), - } -} - -fn to_subrun_outcome_dto( - lifecycle: AgentLifecycleStatus, - last_turn_outcome: Option<AgentTurnOutcome>, -) -> SubRunOutcomeDto { - match last_turn_outcome { - Some(AgentTurnOutcome::Completed) => SubRunOutcomeDto::Completed, - Some(AgentTurnOutcome::Failed) => SubRunOutcomeDto::Failed, - Some(AgentTurnOutcome::Cancelled) => SubRunOutcomeDto::Aborted, - Some(AgentTurnOutcome::TokenExceeded) => SubRunOutcomeDto::TokenExceeded, - None => match lifecycle { - AgentLifecycleStatus::Terminated => SubRunOutcomeDto::Aborted, - _ => SubRunOutcomeDto::Running, - }, - } -} - -fn to_subrun_failure_dto(failure: SubRunFailure) -> SubRunFailureDto { - SubRunFailureDto { - code: match failure.code { - SubRunFailureCode::Transport => SubRunFailureCodeDto::Transport, - SubRunFailureCode::ProviderHttp => SubRunFailureCodeDto::ProviderHttp, - SubRunFailureCode::StreamParse => SubRunFailureCodeDto::StreamParse, - SubRunFailureCode::Interrupted => SubRunFailureCodeDto::Interrupted, - SubRunFailureCode::Internal => SubRunFailureCodeDto::Internal, - }, - display_message: failure.display_message, - technical_message: failure.technical_message, - retryable: failure.retryable, - } -} - -fn to_lifecycle_dto(status: AgentLifecycleStatus) -> AgentLifecycleDto { - match status { - AgentLifecycleStatus::Pending => AgentLifecycleDto::Pending, - AgentLifecycleStatus::Running => AgentLifecycleDto::Running, - AgentLifecycleStatus::Idle => AgentLifecycleDto::Idle, - AgentLifecycleStatus::Terminated => AgentLifecycleDto::Terminated, - } -} - -fn to_turn_outcome_dto(outcome: AgentTurnOutcome) -> AgentTurnOutcomeDto { - match outcome { - AgentTurnOutcome::Completed => AgentTurnOutcomeDto::Completed, - AgentTurnOutcome::Cancelled => AgentTurnOutcomeDto::Cancelled, - AgentTurnOutcome::TokenExceeded => AgentTurnOutcomeDto::TokenExceeded, - AgentTurnOutcome::Failed => AgentTurnOutcomeDto::Failed, - } -} - #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct CloseAgentResponse { closed_agent_ids: Vec<String>, } - -#[cfg(test)] -mod tests { - use astrcode_application::{ - AgentEventContext, AgentLifecycleStatus, AgentTurnOutcome, StoredEvent, SubRunHandoff, - SubRunResult, SubRunStorageMode, - }; - use astrcode_core::{ - ArtifactRef, CompletedParentDeliveryPayload, ParentDelivery, ParentDeliveryOrigin, - ParentDeliveryPayload, ParentDeliveryTerminalSemantics, StorageEvent, StorageEventPayload, - }; - - use super::project_durable_subrun_status; - - #[test] - fn durable_subrun_projection_preserves_typed_handoff_delivery() { - let child_agent = AgentEventContext::sub_run( - "agent-child", - "turn-parent", - "reviewer", - "subrun-child", - Some("subrun-parent".to_string()), - SubRunStorageMode::IndependentSession, - Some("session-child".to_string()), - ); - let explicit_delivery = ParentDelivery { - idempotency_key: "delivery-explicit".to_string(), - origin: ParentDeliveryOrigin::Explicit, - terminal_semantics: ParentDeliveryTerminalSemantics::Terminal, - source_turn_id: Some("turn-child".to_string()), - payload: ParentDeliveryPayload::Completed(CompletedParentDeliveryPayload { - message: "显式交付".to_string(), - findings: vec!["finding-1".to_string()], - artifacts: vec![ArtifactRef { - kind: "session".to_string(), - id: "session-child".to_string(), - label: "Child Session".to_string(), - session_id: Some("session-child".to_string()), - storage_seq: None, - uri: None, - }], - }), - }; - let stored_events = vec![StoredEvent { - storage_seq: 1, - event: StorageEvent { - turn_id: Some("turn-child".to_string()), - agent: child_agent.clone(), - payload: StorageEventPayload::SubRunFinished { - tool_call_id: Some("call-1".to_string()), - result: SubRunResult { - lifecycle: AgentLifecycleStatus::Idle, - last_turn_outcome: Some(AgentTurnOutcome::Completed), - handoff: Some(SubRunHandoff { - findings: vec!["finding-1".to_string()], - artifacts: vec![ArtifactRef { - kind: "session".to_string(), - id: "session-child".to_string(), - label: "Child Session".to_string(), - session_id: Some("session-child".to_string()), - storage_seq: None, - uri: None, - }], - delivery: Some(explicit_delivery.clone()), - }), - failure: None, - }, - timestamp: Some(chrono::Utc::now()), - step_count: 3, - estimated_tokens: 120, - }, - }, - }]; - - let projection = project_durable_subrun_status( - "session-parent", - "session-child", - "subrun-child", - &stored_events, - ) - .expect("projection should exist"); - - let result = projection.result.expect("durable result should exist"); - let handoff = result.handoff.expect("handoff should exist"); - let delivery = handoff - .delivery - .expect("typed delivery should survive durable projection"); - assert_eq!(delivery.idempotency_key, "delivery-explicit"); - assert_eq!( - delivery.origin, - astrcode_protocol::http::ParentDeliveryOriginDto::Explicit - ); - assert_eq!( - delivery.terminal_semantics, - astrcode_protocol::http::ParentDeliveryTerminalSemanticsDto::Terminal - ); - match delivery.payload { - astrcode_protocol::http::ParentDeliveryPayloadDto::Completed(payload) => { - assert_eq!(payload.message, "显式交付"); - assert_eq!(payload.findings, vec!["finding-1".to_string()]); - }, - payload => panic!("unexpected delivery payload: {payload:?}"), - } - } -} diff --git a/crates/server/src/http/routes/composer.rs b/crates/server/src/http/routes/composer.rs index f22220f3..ed95ce2b 100644 --- a/crates/server/src/http/routes/composer.rs +++ b/crates/server/src/http/routes/composer.rs @@ -1,7 +1,7 @@ //! 输入候选(composer options)路由。 //! //! 该接口服务于前端输入框的自动展开面板,返回已经过 runtime 统一投影的候选项。 -//! 它故意不直接暴露 `SkillSpec` / `CapabilityDescriptor`,避免 UI 反向理解内部装配细节。 +//! 它故意不直接暴露 `SkillSpec` / `CapabilityWireDescriptor`,避免 UI 反向理解内部装配细节。 use astrcode_application::{ComposerOptionKind, ComposerOptionsRequest}; use astrcode_protocol::http::ComposerOptionsResponseDto; diff --git a/crates/server/src/http/routes/config.rs b/crates/server/src/http/routes/config.rs index fadd7170..b289611d 100644 --- a/crates/server/src/http/routes/config.rs +++ b/crates/server/src/http/routes/config.rs @@ -4,7 +4,9 @@ //! - `GET /api/config` — 获取配置视图(含 profile 列表和当前选择) //! - `POST /api/config/active-selection` — 保存活跃的 profile/model 选择 -use astrcode_application::format_local_rfc3339; +use astrcode_application::{ + config::resolve_config_summary, format_local_rfc3339, resolve_runtime_status_summary, +}; use astrcode_protocol::http::{ConfigReloadResponse, ConfigView, SaveActiveSelectionRequest}; use axum::{ Json, @@ -34,7 +36,8 @@ pub(crate) async fn get_config( .config_path() .to_string_lossy() .to_string(); - Ok(Json(build_config_view(&config, config_path)?)) + let summary = resolve_config_summary(&config).map_err(ApiError::from)?; + Ok(Json(build_config_view(summary, config_path))) } /// 保存活跃的 profile 和 model 选择。 @@ -68,20 +71,21 @@ pub(crate) async fn reload_config( require_auth(&state, &headers, None)?; let reloaded = state.governance.reload().await.map_err(ApiError::from)?; let config = state.app.config().get_config().await; + let summary = resolve_config_summary(&config).map_err(ApiError::from)?; let config_path = state .app .config() .config_path() .to_string_lossy() .to_string(); - let config_view = build_config_view(&config, config_path)?; + let config_view = build_config_view(summary, config_path); Ok(( StatusCode::ACCEPTED, Json(ConfigReloadResponse { reloaded_at: format_local_rfc3339(reloaded.reloaded_at), config: config_view, - status: to_runtime_status_dto(reloaded.snapshot), + status: to_runtime_status_dto(resolve_runtime_status_summary(reloaded.snapshot)), }), )) } diff --git a/crates/server/src/http/routes/conversation.rs b/crates/server/src/http/routes/conversation.rs index b9cdba34..f11c94b1 100644 --- a/crates/server/src/http/routes/conversation.rs +++ b/crates/server/src/http/routes/conversation.rs @@ -1,14 +1,19 @@ use std::{convert::Infallible, pin::Pin, time::Duration}; use astrcode_application::{ - ApplicationError, ConversationFocus, TerminalChildSummaryFacts, TerminalControlFacts, - TerminalSlashCandidateFacts, TerminalStreamFacts, TerminalStreamReplayFacts, + ApplicationError, + terminal::{ + ConversationAuthoritativeSummary, ConversationChildSummarySummary, + ConversationControlSummary, ConversationFocus, ConversationSlashCandidateSummary, + TerminalStreamFacts, TerminalStreamReplayFacts, summarize_conversation_authoritative, + }, }; -use astrcode_core::{AgentEvent, SessionEventRecord}; +use astrcode_core::AgentEvent; use astrcode_protocol::http::conversation::v1::{ ConversationDeltaDto, ConversationSlashCandidatesResponseDto, ConversationSnapshotResponseDto, ConversationStreamEnvelopeDto, }; +use astrcode_session_runtime::ConversationStreamProjector as RuntimeConversationStreamProjector; use async_stream::stream; use axum::{ Json, @@ -27,10 +32,10 @@ use crate::{ auth::is_authorized, routes::sessions::validate_session_path_id, terminal_projection::{ - TerminalDeltaProjector, project_terminal_child_summary_deltas, - project_terminal_control_delta, project_terminal_rehydrate_envelope, - project_terminal_slash_candidates, project_terminal_snapshot, - project_terminal_stream_replay, seeded_terminal_stream_projector, + child_summary_summary_lookup, project_conversation_child_summary_summary_deltas, + project_conversation_control_summary_delta, project_conversation_frame, + project_conversation_rehydrate_envelope, project_conversation_slash_candidate_summaries, + project_conversation_slash_candidates, project_conversation_snapshot, }, }; @@ -153,7 +158,7 @@ pub(crate) async fn conversation_snapshot( .await .map_err(ConversationRouteError::from)?; - Ok(Json(project_terminal_snapshot(&facts))) + Ok(Json(project_conversation_snapshot(&facts))) } pub(crate) async fn conversation_stream( @@ -183,7 +188,7 @@ pub(crate) async fn conversation_stream( state, session_id, cursor, focus, *facts, )), TerminalStreamFacts::RehydrateRequired(rehydrate) => Ok(single_envelope_stream( - project_terminal_rehydrate_envelope(&rehydrate), + project_conversation_rehydrate_envelope(&rehydrate), )), } } @@ -203,7 +208,7 @@ pub(crate) async fn conversation_slash_candidates( .await .map_err(ConversationRouteError::from)?; - Ok(Json(project_terminal_slash_candidates(&candidates))) + Ok(Json(project_conversation_slash_candidates(&candidates))) } fn require_conversation_auth( @@ -228,8 +233,8 @@ fn build_conversation_stream( let mut stream_state = ConversationStreamProjectorState::new(session_id.clone(), cursor, &facts); let initial_envelopes = stream_state.seed_initial_replay(&facts); - let mut durable_receiver = facts.replay.receiver; - let mut live_receiver = facts.replay.live_receiver; + let mut durable_receiver = facts.replay.replay.receiver; + let mut live_receiver = facts.replay.replay.live_receiver; let app = state.app.clone(); let session_id_for_stream = session_id.clone(); let mut live_receiver_open = true; @@ -285,13 +290,13 @@ fn build_conversation_stream( for envelope in stream_state.recover_from(&recovered) { yield Ok::<Event, Infallible>(to_conversation_sse_event(envelope)); } - durable_receiver = recovered.replay.receiver; - live_receiver = recovered.replay.live_receiver; + durable_receiver = recovered.replay.replay.receiver; + live_receiver = recovered.replay.replay.live_receiver; live_receiver_open = true; } Ok(TerminalStreamFacts::RehydrateRequired(rehydrate)) => { yield Ok::<Event, Infallible>(to_conversation_sse_event( - project_terminal_rehydrate_envelope(&rehydrate), + project_conversation_rehydrate_envelope(&rehydrate), )); break; } @@ -337,44 +342,35 @@ fn build_conversation_stream( ) } -fn project_terminal_control_deltas( - previous: &TerminalControlFacts, - current: &TerminalControlFacts, -) -> Vec<ConversationDeltaDto> { - let previous = control_state_delta(previous); - let current = control_state_delta(current); - if previous == current { - Vec::new() - } else { - vec![current] - } -} - #[derive(Debug, Clone)] struct ConversationAuthoritativeFacts { - control: TerminalControlFacts, - child_summaries: Vec<TerminalChildSummaryFacts>, - slash_candidates: Vec<TerminalSlashCandidateFacts>, + control: ConversationControlSummary, + child_summaries: Vec<ConversationChildSummarySummary>, + slash_candidates: Vec<ConversationSlashCandidateSummary>, } impl ConversationAuthoritativeFacts { fn from_replay(facts: &TerminalStreamReplayFacts) -> Self { + Self::from_summary(summarize_conversation_authoritative( + &facts.control, + &facts.child_summaries, + &facts.slash_candidates, + )) + } + + fn from_summary(summary: ConversationAuthoritativeSummary) -> Self { Self { - control: facts.control.clone(), - child_summaries: facts.child_summaries.clone(), - slash_candidates: facts.slash_candidates.clone(), + control: summary.control, + child_summaries: summary.child_summaries, + slash_candidates: summary.slash_candidates, } } } struct ConversationStreamProjectorState { session_id: String, - projector: TerminalDeltaProjector, - last_sent_cursor: Option<String>, - fallback_live_cursor: Option<String>, - control: TerminalControlFacts, - child_summaries: Vec<TerminalChildSummaryFacts>, - slash_candidates: Vec<TerminalSlashCandidateFacts>, + projector: RuntimeConversationStreamProjector, + authoritative: ConversationAuthoritativeFacts, } impl ConversationStreamProjectorState { @@ -385,43 +381,52 @@ impl ConversationStreamProjectorState { ) -> Self { Self { session_id, - projector: seeded_terminal_stream_projector(facts), - last_sent_cursor, - fallback_live_cursor: fallback_live_cursor(facts), - control: facts.control.clone(), - child_summaries: facts.child_summaries.clone(), - slash_candidates: facts.slash_candidates.clone(), + projector: RuntimeConversationStreamProjector::new(last_sent_cursor, &facts.replay), + authoritative: ConversationAuthoritativeFacts::from_replay(facts), } } fn last_sent_cursor(&self) -> Option<&str> { - self.last_sent_cursor.as_deref() + self.projector.last_sent_cursor() } fn seed_initial_replay( &mut self, facts: &TerminalStreamReplayFacts, ) -> Vec<ConversationStreamEnvelopeDto> { - let envelopes = project_terminal_stream_replay(facts, self.last_sent_cursor.as_deref()); - self.observe_durable_envelopes(&envelopes); + let child_lookup = child_summary_summary_lookup(&self.authoritative.child_summaries); + let envelopes = self + .projector + .seed_initial_replay(&facts.replay) + .into_iter() + .map(|frame| project_conversation_frame(self.session_id.as_str(), frame, &child_lookup)) + .collect::<Vec<_>>(); + let _ = self.projector.recover_from(&facts.replay); envelopes } fn project_durable_record( &mut self, - record: &SessionEventRecord, + record: &astrcode_core::SessionEventRecord, ) -> Vec<ConversationStreamEnvelopeDto> { - let deltas = self.projector.project_record(record); - self.wrap_durable_deltas(record.event_id.as_str(), deltas) + let child_lookup = child_summary_summary_lookup(&self.authoritative.child_summaries); + self.projector + .project_durable_record(record) + .into_iter() + .map(|frame| project_conversation_frame(self.session_id.as_str(), frame, &child_lookup)) + .collect() } fn project_live_event(&mut self, event: &AgentEvent) -> Vec<ConversationStreamEnvelopeDto> { - let cursor = self.live_cursor(); self.projector .project_live_event(event) .into_iter() - .map(|delta| { - make_conversation_envelope(self.session_id.as_str(), cursor.as_str(), delta) + .map(|frame| { + project_conversation_frame( + self.session_id.as_str(), + frame, + &child_summary_summary_lookup(&self.authoritative.child_summaries), + ) }) .collect() } @@ -431,20 +436,27 @@ impl ConversationStreamProjectorState { cursor: &str, refreshed: ConversationAuthoritativeFacts, ) -> Vec<ConversationStreamEnvelopeDto> { - let mut deltas = project_terminal_control_deltas(&self.control, &refreshed.control); - deltas.extend(project_terminal_child_summary_deltas( - &self.child_summaries, + let mut deltas = if self.authoritative.control == refreshed.control { + Vec::new() + } else { + vec![project_conversation_control_summary_delta( + &refreshed.control, + )] + }; + deltas.extend(project_conversation_child_summary_summary_deltas( + &self.authoritative.child_summaries, &refreshed.child_summaries, )); - if self.slash_candidates != refreshed.slash_candidates { + if self.authoritative.slash_candidates != refreshed.slash_candidates { deltas.push(ConversationDeltaDto::ReplaceSlashCandidates { - candidates: project_terminal_slash_candidates(&refreshed.slash_candidates).items, + candidates: project_conversation_slash_candidate_summaries( + &refreshed.slash_candidates, + ) + .items, }); } - self.control = refreshed.control; - self.child_summaries = refreshed.child_summaries; - self.slash_candidates = refreshed.slash_candidates; + self.authoritative = refreshed; self.wrap_durable_deltas(cursor, deltas) } @@ -452,17 +464,21 @@ impl ConversationStreamProjectorState { &mut self, recovered: &TerminalStreamReplayFacts, ) -> Vec<ConversationStreamEnvelopeDto> { - let mut envelopes = - project_terminal_stream_replay(recovered, self.last_sent_cursor.as_deref()); - self.observe_durable_envelopes(&envelopes); - self.projector = seeded_terminal_stream_projector(recovered); - self.fallback_live_cursor = fallback_live_cursor(recovered); - - let recovery_cursor = self.live_cursor(); - envelopes.extend(self.apply_authoritative_refresh( - recovery_cursor.as_str(), - ConversationAuthoritativeFacts::from_replay(recovered), - )); + let refreshed = ConversationAuthoritativeFacts::from_replay(recovered); + let child_lookup = child_summary_summary_lookup(&refreshed.child_summaries); + let mut envelopes = self + .projector + .recover_from(&recovered.replay) + .into_iter() + .map(|frame| project_conversation_frame(self.session_id.as_str(), frame, &child_lookup)) + .collect::<Vec<_>>(); + + let recovery_cursor = self + .projector + .last_sent_cursor() + .unwrap_or("0.0") + .to_string(); + envelopes.extend(self.apply_authoritative_refresh(recovery_cursor.as_str(), refreshed)); envelopes } @@ -475,7 +491,6 @@ impl ConversationStreamProjectorState { return Vec::new(); } let cursor_owned = cursor.to_string(); - self.last_sent_cursor = Some(cursor_owned.clone()); deltas .into_iter() .map(|delta| { @@ -483,33 +498,6 @@ impl ConversationStreamProjectorState { }) .collect() } - - fn observe_durable_envelopes(&mut self, envelopes: &[ConversationStreamEnvelopeDto]) { - if let Some(cursor) = envelopes.last().map(|envelope| envelope.cursor.0.clone()) { - self.last_sent_cursor = Some(cursor); - } - } - - fn live_cursor(&self) -> String { - self.last_sent_cursor - .clone() - .or_else(|| self.fallback_live_cursor.clone()) - .unwrap_or_else(|| "0.0".to_string()) - } -} - -fn fallback_live_cursor(facts: &TerminalStreamReplayFacts) -> Option<String> { - facts - .seed_records - .last() - .map(|record| record.event_id.clone()) - .or_else(|| { - facts - .replay - .history - .last() - .map(|record| record.event_id.clone()) - }) } async fn refresh_conversation_authoritative_facts( @@ -517,15 +505,10 @@ async fn refresh_conversation_authoritative_facts( session_id: &str, focus: &ConversationFocus, ) -> Result<ConversationAuthoritativeFacts, ApplicationError> { - Ok(ConversationAuthoritativeFacts { - control: app.terminal_control_facts(session_id).await?, - child_summaries: app.conversation_child_summaries(session_id, focus).await?, - slash_candidates: app.terminal_slash_candidates(session_id, None).await?, - }) -} - -fn control_state_delta(control: &TerminalControlFacts) -> ConversationDeltaDto { - project_terminal_control_delta(control) + Ok(ConversationAuthoritativeFacts::from_summary( + app.conversation_authoritative_summary(session_id, focus) + .await?, + )) } fn single_envelope_stream(envelope: ConversationStreamEnvelopeDto) -> ConversationSse { @@ -598,3 +581,343 @@ fn parse_focus_query(raw: Option<&str>) -> Result<ConversationFocus, Conversatio type ConversationEventStream = Pin<Box<dyn futures_util::Stream<Item = Result<Event, Infallible>> + Send>>; type ConversationSse = Sse<axum::response::sse::KeepAliveStream<ConversationEventStream>>; + +#[cfg(test)] +mod tests { + use astrcode_application::terminal::{ + TaskItemFacts, TerminalChildSummaryFacts, TerminalControlFacts, TerminalStreamReplayFacts, + summarize_conversation_authoritative, + }; + use astrcode_core::{ + AgentEventContext, AgentLifecycleStatus, ChildExecutionIdentity, ChildSessionLineageKind, + ChildSessionNode, ChildSessionStatusSource, ExecutionTaskStatus, ParentExecutionRef, Phase, + SessionEventRecord, ToolExecutionResult, ToolOutputStream, + }; + use astrcode_session_runtime::{ + ConversationBlockPatchFacts, ConversationDeltaFacts, ConversationDeltaFrameFacts, + ConversationStreamReplayFacts as RuntimeConversationStreamReplayFacts, SessionReplay, + }; + use serde_json::json; + use tokio::sync::broadcast; + + use super::{AgentEvent, ConversationAuthoritativeFacts, ConversationStreamProjectorState}; + + #[test] + fn recover_from_replays_only_missing_records_and_advances_cursor() { + let initial = sample_stream_facts( + vec![record( + "1.1", + AgentEvent::UserMessage { + turn_id: "turn-1".to_string(), + agent: sample_agent_context(), + content: "check pipeline".to_string(), + }, + )], + vec![record( + "1.2", + AgentEvent::ToolCallStart { + turn_id: "turn-1".to_string(), + agent: sample_agent_context(), + tool_call_id: "call-1".to_string(), + tool_name: "shell_command".to_string(), + input: json!({ "command": "pwd" }), + }, + )], + ); + let mut state = ConversationStreamProjectorState::new( + "session-root".to_string(), + Some("1.1".to_string()), + &initial, + ); + + let initial_envelopes = state.seed_initial_replay(&initial); + assert_eq!(initial_envelopes.len(), 1); + assert_eq!(initial_envelopes[0].cursor.0, "1.2"); + + let recovered = sample_stream_facts( + vec![ + record( + "1.1", + AgentEvent::UserMessage { + turn_id: "turn-1".to_string(), + agent: sample_agent_context(), + content: "check pipeline".to_string(), + }, + ), + record( + "1.2", + AgentEvent::ToolCallStart { + turn_id: "turn-1".to_string(), + agent: sample_agent_context(), + tool_call_id: "call-1".to_string(), + tool_name: "shell_command".to_string(), + input: json!({ "command": "pwd" }), + }, + ), + ], + vec![record( + "1.3", + AgentEvent::ToolCallDelta { + turn_id: "turn-1".to_string(), + agent: sample_agent_context(), + tool_call_id: "call-1".to_string(), + tool_name: "shell_command".to_string(), + stream: ToolOutputStream::Stdout, + delta: "D:/GitObjectsOwn/Astrcode\n".to_string(), + }, + )], + ); + + let recovered_envelopes = state.recover_from(&recovered); + assert_eq!(recovered_envelopes.len(), 1); + assert_eq!(recovered_envelopes[0].cursor.0, "1.3"); + assert_eq!( + serde_json::to_value(&recovered_envelopes[0]) + .expect("recovered envelope should encode"), + json!({ + "sessionId": "session-root", + "cursor": "1.3", + "kind": "patch_block", + "blockId": "tool:call-1:call", + "patch": { + "kind": "append_tool_stream", + "stream": "stdout", + "chunk": "D:/GitObjectsOwn/Astrcode\n" + } + }) + ); + + let live_envelopes = state.project_live_event(&AgentEvent::ToolCallResult { + turn_id: "turn-1".to_string(), + agent: sample_agent_context(), + result: ToolExecutionResult { + tool_call_id: "call-1".to_string(), + tool_name: "shell_command".to_string(), + ok: true, + output: "D:/GitObjectsOwn/Astrcode\n".to_string(), + child_ref: None, + error: None, + metadata: None, + duration_ms: 8, + truncated: false, + }, + }); + assert!( + live_envelopes + .iter() + .all(|envelope| envelope.cursor.0 == "1.3"), + "live cursor should stay anchored to last durable cursor after recovery" + ); + } + + #[test] + fn authoritative_refresh_emits_child_summary_delta_on_current_cursor() { + let facts = sample_stream_facts(Vec::new(), Vec::new()); + let mut state = ConversationStreamProjectorState::new( + "session-root".to_string(), + Some("1.4".to_string()), + &facts, + ); + + let refreshed = + ConversationAuthoritativeFacts::from_summary(summarize_conversation_authoritative( + &facts.control, + &[sample_child_summary()], + &facts.slash_candidates, + )); + + let envelopes = state.apply_authoritative_refresh("1.4", refreshed); + assert_eq!(envelopes.len(), 1); + assert_eq!( + serde_json::to_value(&envelopes[0]).expect("child summary envelope should encode"), + json!({ + "sessionId": "session-root", + "cursor": "1.4", + "kind": "upsert_child_summary", + "child": { + "childSessionId": "session-child-1", + "childAgentId": "agent-child-1", + "title": "Repo inspector", + "lifecycle": "running", + "latestOutputSummary": "正在检查 conversation projector", + "childRef": { + "agentId": "agent-child-1", + "sessionId": "session-root", + "subRunId": "subrun-child-1", + "parentAgentId": "agent-root", + "parentSubRunId": "subrun-root", + "lineageKind": "spawn", + "status": "running", + "openSessionId": "session-child-1" + } + } + }) + ); + } + + #[test] + fn authoritative_refresh_emits_control_delta_for_active_tasks() { + let facts = sample_stream_facts(Vec::new(), Vec::new()); + let mut state = ConversationStreamProjectorState::new( + "session-root".to_string(), + Some("1.4".to_string()), + &facts, + ); + + let mut refreshed_control = facts.control.clone(); + refreshed_control.active_tasks = Some(vec![ + TaskItemFacts { + content: "实现 authoritative task panel".to_string(), + status: ExecutionTaskStatus::InProgress, + active_form: Some("正在实现 authoritative task panel".to_string()), + }, + TaskItemFacts { + content: "补充前端 hydration 测试".to_string(), + status: ExecutionTaskStatus::Pending, + active_form: None, + }, + ]); + + let refreshed = + ConversationAuthoritativeFacts::from_summary(summarize_conversation_authoritative( + &refreshed_control, + &facts.child_summaries, + &facts.slash_candidates, + )); + + let envelopes = state.apply_authoritative_refresh("1.4", refreshed); + assert_eq!(envelopes.len(), 1); + assert_eq!( + serde_json::to_value(&envelopes[0]).expect("control envelope should encode"), + json!({ + "sessionId": "session-root", + "cursor": "1.4", + "kind": "update_control_state", + "control": { + "phase": "callingTool", + "canSubmitPrompt": false, + "canRequestCompact": true, + "compactPending": false, + "compacting": false, + "currentModeId": "code", + "activeTurnId": "turn-1", + "activeTasks": [ + { + "content": "实现 authoritative task panel", + "status": "in_progress", + "activeForm": "正在实现 authoritative task panel" + }, + { + "content": "补充前端 hydration 测试", + "status": "pending" + } + ] + } + }) + ); + } + + fn sample_stream_facts( + seed_records: Vec<SessionEventRecord>, + history: Vec<SessionEventRecord>, + ) -> TerminalStreamReplayFacts { + let (_, receiver) = broadcast::channel(8); + let (_, live_receiver) = broadcast::channel(8); + + TerminalStreamReplayFacts { + active_session_id: "session-root".to_string(), + replay: RuntimeConversationStreamReplayFacts { + cursor: history.last().map(|record| record.event_id.clone()), + phase: Phase::CallingTool, + seed_records: seed_records.clone(), + replay_frames: history + .iter() + .map(|record| ConversationDeltaFrameFacts { + cursor: record.event_id.clone(), + delta: match &record.event { + AgentEvent::ToolCallDelta { + tool_call_id, + stream, + delta, + .. + } => ConversationDeltaFacts::PatchBlock { + block_id: format!("tool:{tool_call_id}:call"), + patch: ConversationBlockPatchFacts::AppendToolStream { + stream: *stream, + chunk: delta.clone(), + }, + }, + _ => ConversationDeltaFacts::AppendBlock { + block: Box::new( + astrcode_session_runtime::ConversationBlockFacts::User( + astrcode_session_runtime::ConversationUserBlockFacts { + id: "noop".to_string(), + turn_id: None, + markdown: String::new(), + }, + ), + ), + }, + }, + }) + .collect(), + replay: SessionReplay { + history, + receiver, + live_receiver, + }, + }, + control: TerminalControlFacts { + phase: Phase::CallingTool, + active_turn_id: Some("turn-1".to_string()), + manual_compact_pending: false, + compacting: false, + last_compact_meta: None, + current_mode_id: "code".to_string(), + active_plan: None, + active_tasks: None, + }, + child_summaries: Vec::new(), + slash_candidates: Vec::new(), + } + } + + fn sample_child_summary() -> TerminalChildSummaryFacts { + TerminalChildSummaryFacts { + node: ChildSessionNode { + identity: ChildExecutionIdentity { + agent_id: "agent-child-1".into(), + session_id: "session-root".into(), + sub_run_id: "subrun-child-1".into(), + }, + child_session_id: "session-child-1".into(), + parent_session_id: "session-root".into(), + parent: ParentExecutionRef { + parent_agent_id: Some("agent-root".into()), + parent_sub_run_id: Some("subrun-root".into()), + }, + parent_turn_id: "turn-1".into(), + lineage_kind: ChildSessionLineageKind::Spawn, + status: AgentLifecycleStatus::Running, + status_source: ChildSessionStatusSource::Durable, + created_by_tool_call_id: Some("call-2".into()), + lineage_snapshot: None, + }, + phase: Phase::CallingTool, + title: Some("Repo inspector".to_string()), + display_name: Some("repo-inspector".to_string()), + recent_output: Some("正在检查 conversation projector".to_string()), + } + } + + fn sample_agent_context() -> AgentEventContext { + AgentEventContext::root_execution("agent-root", "default") + } + + fn record(event_id: &str, event: AgentEvent) -> SessionEventRecord { + SessionEventRecord { + event_id: event_id.to_string(), + event, + } + } +} diff --git a/crates/server/src/http/routes/debug.rs b/crates/server/src/http/routes/debug.rs deleted file mode 100644 index 5e2b33a6..00000000 --- a/crates/server/src/http/routes/debug.rs +++ /dev/null @@ -1,62 +0,0 @@ -//! # Debug-only Debug Workbench 读取面 -//! -//! 仅在 debug 构建中暴露,供独立 Debug Workbench 读取全局 overview、timeline、 -//! session trace 与 agent tree。 - -use astrcode_protocol::http::{ - RuntimeDebugOverviewDto, RuntimeDebugTimelineDto, SessionDebugAgentsDto, SessionDebugTraceDto, -}; -use axum::{ - Json, - extract::{Path, State}, - http::HeaderMap, -}; - -use crate::{ - ApiError, AppState, - auth::require_auth, - mapper::{ - to_runtime_debug_overview_dto, to_runtime_debug_timeline_dto, to_session_debug_agents_dto, - to_session_debug_trace_dto, - }, -}; - -pub(crate) async fn get_runtime_overview( - State(state): State<AppState>, - headers: HeaderMap, -) -> Result<Json<RuntimeDebugOverviewDto>, ApiError> { - require_auth(&state, &headers, None)?; - Ok(Json(to_runtime_debug_overview_dto( - state.debug_workbench.runtime_overview(), - ))) -} - -pub(crate) async fn get_runtime_timeline( - State(state): State<AppState>, - headers: HeaderMap, -) -> Result<Json<RuntimeDebugTimelineDto>, ApiError> { - require_auth(&state, &headers, None)?; - Ok(Json(to_runtime_debug_timeline_dto( - state.debug_workbench.runtime_timeline(), - ))) -} - -pub(crate) async fn get_session_trace( - State(state): State<AppState>, - headers: HeaderMap, - Path(session_id): Path<String>, -) -> Result<Json<SessionDebugTraceDto>, ApiError> { - require_auth(&state, &headers, None)?; - let trace = state.debug_workbench.session_trace(&session_id).await?; - Ok(Json(to_session_debug_trace_dto(trace))) -} - -pub(crate) async fn get_session_agents( - State(state): State<AppState>, - headers: HeaderMap, - Path(session_id): Path<String>, -) -> Result<Json<SessionDebugAgentsDto>, ApiError> { - require_auth(&state, &headers, None)?; - let agents = state.debug_workbench.session_agents(&session_id).await?; - Ok(Json(to_session_debug_agents_dto(agents))) -} diff --git a/crates/server/src/http/routes/mcp.rs b/crates/server/src/http/routes/mcp.rs index 2ea530b8..f415795c 100644 --- a/crates/server/src/http/routes/mcp.rs +++ b/crates/server/src/http/routes/mcp.rs @@ -2,7 +2,9 @@ //! //! 提供 MCP 状态查询、审批,以及服务端配置管理入口。 -use astrcode_application::{McpConfigScope, McpServerStatusView, RegisterMcpServerInput}; +use astrcode_application::{ + McpActionSummary, McpConfigScope, McpServerStatusSummary, RegisterMcpServerInput, +}; use axum::{ Json, extract::State, @@ -92,7 +94,7 @@ pub(crate) async fn get_mcp_status( let servers = state .app .mcp() - .list_status() + .list_status_summary() .await .into_iter() .map(McpServerStatus::from) @@ -214,11 +216,12 @@ pub(crate) async fn set_mcp_server_enabled( } fn ok_response(status: StatusCode) -> (StatusCode, Json<McpActionResponse>) { + let summary = McpActionSummary::ok(); ( status, Json(McpActionResponse { - ok: true, - message: None, + ok: summary.ok, + message: summary.message, }), ) } @@ -235,13 +238,13 @@ fn parse_scope(scope: &str) -> Result<McpConfigScope, ApiError> { } } -impl From<McpServerStatusView> for McpServerStatus { - fn from(value: McpServerStatusView) -> Self { +impl From<McpServerStatusSummary> for McpServerStatus { + fn from(value: McpServerStatusSummary) -> Self { Self { name: value.name, scope: value.scope, enabled: value.enabled, - status: value.state, + status: value.status, error: value.error, tool_count: value.tool_count, prompt_count: value.prompt_count, diff --git a/crates/server/src/http/routes/mod.rs b/crates/server/src/http/routes/mod.rs index fe7b795e..6985335c 100644 --- a/crates/server/src/http/routes/mod.rs +++ b/crates/server/src/http/routes/mod.rs @@ -19,8 +19,6 @@ pub(crate) mod agents; pub(crate) mod composer; pub(crate) mod config; pub(crate) mod conversation; -#[cfg(feature = "debug-workbench")] -pub(crate) mod debug; pub(crate) mod logs; pub(crate) mod mcp; pub(crate) mod model; @@ -51,11 +49,14 @@ use crate::{ApiError, AppState, bootstrap::serve_run_info}; /// ### 会话 /// - `POST /api/sessions` — 创建新会话 /// - `GET /api/sessions` — 列出所有会话 +/// - `GET /api/modes` — 列出所有可用治理 mode /// - `GET /api/session-events` — 订阅会话目录事件(SSE) /// - `GET /api/sessions/:id/composer/options` — 获取输入框候选列表 /// - `POST /api/sessions/:id/prompts` — 提交用户提示 /// - `POST /api/sessions/:id/compact` — 压缩会话上下文 /// - `POST /api/sessions/:id/interrupt` — 中断会话执行 +/// - `GET /api/sessions/:id/mode` — 查询当前 session mode +/// - `POST /api/sessions/:id/mode` — 切换当前 session mode(下一 turn 生效) /// - `DELETE /api/sessions/:id` — 删除单个会话 /// - `DELETE /api/projects` — 删除整个项目(级联删除所有会话) /// @@ -83,13 +84,14 @@ use crate::{ApiError, AppState, bootstrap::serve_run_info}; /// - `GET /api/v1/sessions/{id}/subruns/{sub_run_id}` — 查询子会话执行状态 /// - `POST /api/v1/sessions/{id}/agents/{agent_id}/close` — 关闭 agent 及其子树 pub(crate) fn build_api_router() -> Router<AppState> { - let router = Router::<AppState>::new() + Router::<AppState>::new() .route("/__astrcode__/run-info", get(serve_run_info)) .route("/api/auth/exchange", post(exchange_auth)) .route( "/api/sessions", post(sessions::create_session).get(sessions::list_sessions), ) + .route("/api/modes", get(sessions::list_modes)) .route("/api/session-events", get(sessions::session_catalog_events)) .route( "/api/sessions/{id}/composer/options", @@ -100,10 +102,15 @@ pub(crate) fn build_api_router() -> Router<AppState> { "/api/sessions/{id}/compact", post(sessions::compact_session), ) + .route("/api/sessions/{id}/fork", post(sessions::fork_session)) .route( "/api/sessions/{id}/interrupt", post(sessions::interrupt_session), ) + .route( + "/api/sessions/{id}/mode", + get(sessions::get_session_mode).post(sessions::switch_mode), + ) .route("/api/sessions/{id}", delete(sessions::delete_session)) .route("/api/projects", delete(sessions::delete_project)) .route("/api/config", get(config::get_config)) @@ -149,28 +156,7 @@ pub(crate) fn build_api_router() -> Router<AppState> { ) .route("/api/mcp/server", post(mcp::upsert_mcp_server)) .route("/api/mcp/server/remove", post(mcp::remove_mcp_server)) - .route("/api/mcp/server/enabled", post(mcp::set_mcp_server_enabled)); - - #[cfg(feature = "debug-workbench")] - let router = router - .route( - "/api/debug/runtime/overview", - get(debug::get_runtime_overview), - ) - .route( - "/api/debug/runtime/timeline", - get(debug::get_runtime_timeline), - ) - .route( - "/api/debug/sessions/{id}/trace", - get(debug::get_session_trace), - ) - .route( - "/api/debug/sessions/{id}/agents", - get(debug::get_session_agents), - ); - - router + .route("/api/mcp/server/enabled", post(mcp::set_mcp_server_enabled)) } async fn exchange_auth( @@ -181,10 +167,10 @@ async fn exchange_auth( return Err(ApiError::unauthorized()); } - let issued = state.auth_sessions.issue_token(); + let summary = state.auth_sessions.issue_exchange_summary(); Ok(Json(AuthExchangeResponse { - ok: true, - token: issued.token, - expires_at_ms: issued.expires_at_ms, + ok: summary.ok, + token: summary.token, + expires_at_ms: summary.expires_at_ms, })) } diff --git a/crates/server/src/http/routes/model.rs b/crates/server/src/http/routes/model.rs index 385c6c14..969da3fa 100644 --- a/crates/server/src/http/routes/model.rs +++ b/crates/server/src/http/routes/model.rs @@ -57,10 +57,5 @@ pub(crate) async fn test_model_connection( .test_connection(&request.profile_name, &request.model) .await .map_err(ApiError::from)?; - Ok(Json(TestResultDto { - success: result.success, - provider: result.provider, - model: result.model, - error: result.error, - })) + Ok(Json(result)) } diff --git a/crates/server/src/http/routes/sessions/mod.rs b/crates/server/src/http/routes/sessions/mod.rs index dd27382e..b60d275f 100644 --- a/crates/server/src/http/routes/sessions/mod.rs +++ b/crates/server/src/http/routes/sessions/mod.rs @@ -9,10 +9,10 @@ mod stream; use axum::http::StatusCode; pub(crate) use mutation::{ - compact_session, create_session, delete_project, delete_session, interrupt_session, - submit_prompt, + compact_session, create_session, delete_project, delete_session, fork_session, + interrupt_session, submit_prompt, switch_mode, }; -pub(crate) use query::list_sessions; +pub(crate) use query::{get_session_mode, list_modes, list_sessions}; pub(crate) use stream::session_catalog_events; use crate::ApiError; diff --git a/crates/server/src/http/routes/sessions/mutation.rs b/crates/server/src/http/routes/sessions/mutation.rs index e71545b9..7d957c75 100644 --- a/crates/server/src/http/routes/sessions/mutation.rs +++ b/crates/server/src/http/routes/sessions/mutation.rs @@ -1,7 +1,7 @@ -use astrcode_application::ExecutionAccepted; use astrcode_protocol::http::{ CompactSessionRequest, CompactSessionResponse, CreateSessionRequest, DeleteProjectResultDto, - ExecutionControlDto, PromptAcceptedResponse, PromptRequest, SessionListItem, + ForkSessionRequest, PromptAcceptedResponse, PromptRequest, SessionListItem, + SessionModeStateDto, SwitchModeRequest, }; use axum::{ Json, @@ -15,26 +15,6 @@ use crate::{ routes::sessions::validate_session_path_id, }; -fn to_execution_control( - control: Option<ExecutionControlDto>, -) -> Option<astrcode_application::ExecutionControl> { - control.map(|control| astrcode_application::ExecutionControl { - max_steps: control.max_steps, - manual_compact: control.manual_compact, - }) -} - -fn normalize_compact_control(control: Option<ExecutionControlDto>) -> ExecutionControlDto { - let mut control = control.unwrap_or(ExecutionControlDto { - max_steps: None, - manual_compact: None, - }); - if control.manual_compact.is_none() { - control.manual_compact = Some(true); - } - control -} - #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub(crate) struct DeleteProjectQuery { @@ -52,7 +32,9 @@ pub(crate) async fn create_session( .create_session(request.working_dir) .await .map_err(ApiError::from)?; - Ok(Json(to_session_list_item(meta))) + Ok(Json(to_session_list_item( + astrcode_application::summarize_session_meta(meta), + ))) } pub(crate) async fn submit_prompt( @@ -63,22 +45,28 @@ pub(crate) async fn submit_prompt( ) -> Result<(StatusCode, Json<PromptAcceptedResponse>), ApiError> { require_auth(&state, &headers, None)?; let session_id = validate_session_path_id(&session_id)?; - let accepted: ExecutionAccepted = state + let summary = state .app - .submit_prompt_with_control( + .submit_prompt_summary( &session_id, request.text, - to_execution_control(request.control.clone()), + request.control, + request.skill_invocation.map(|invocation| { + astrcode_application::PromptSkillInvocation { + skill_id: invocation.skill_id, + user_prompt: invocation.user_prompt, + } + }), ) .await .map_err(ApiError::from)?; Ok(( StatusCode::ACCEPTED, Json(PromptAcceptedResponse { - turn_id: accepted.turn_id.to_string(), - session_id: accepted.session_id.to_string(), - branched_from_session_id: accepted.branched_from_session_id, - accepted_control: request.control, + turn_id: summary.turn_id, + session_id: summary.session_id, + branched_from_session_id: summary.branched_from_session_id, + accepted_control: summary.accepted_control, }), )) } @@ -106,22 +94,81 @@ pub(crate) async fn compact_session( ) -> Result<(StatusCode, Json<CompactSessionResponse>), ApiError> { require_auth(&state, &headers, None)?; let session_id = validate_session_path_id(&session_id)?; - let control = normalize_compact_control(request.and_then(|request| request.0.control)); - let accepted = state + let request = request.map(|request| request.0); + let summary = state .app - .compact_session_with_control(&session_id, to_execution_control(Some(control))) + .compact_session_summary( + &session_id, + request.as_ref().and_then(|request| request.control.clone()), + request.and_then(|request| request.instructions), + ) .await .map_err(ApiError::from)?; Ok(( StatusCode::ACCEPTED, Json(CompactSessionResponse { - accepted: true, - deferred: accepted.deferred, - message: if accepted.deferred { - "手动 compact 已登记,会在当前 turn 完成后执行。".to_string() - } else { - "手动 compact 已执行。".to_string() - }, + accepted: summary.accepted, + deferred: summary.deferred, + message: summary.message, + }), + )) +} + +pub(crate) async fn fork_session( + State(state): State<AppState>, + headers: HeaderMap, + Path(session_id): Path<String>, + request: Option<Json<ForkSessionRequest>>, +) -> Result<Json<SessionListItem>, ApiError> { + require_auth(&state, &headers, None)?; + let session_id = validate_session_path_id(&session_id)?; + let request = request + .map(|request| request.0) + .unwrap_or(ForkSessionRequest { + turn_id: None, + storage_seq: None, + }); + if request.turn_id.is_some() && request.storage_seq.is_some() { + return Err(ApiError::bad_request( + "turnId and storageSeq are mutually exclusive".to_string(), + )); + } + let fork_point = match (request.turn_id, request.storage_seq) { + (Some(turn_id), None) => astrcode_session_runtime::ForkPoint::TurnEnd(turn_id), + (None, Some(storage_seq)) => astrcode_session_runtime::ForkPoint::StorageSeq(storage_seq), + (None, None) => astrcode_session_runtime::ForkPoint::Latest, + (Some(_), Some(_)) => unreachable!("validated above"), + }; + let meta = state + .app + .fork_session(&session_id, fork_point) + .await + .map_err(ApiError::from)?; + Ok(Json(to_session_list_item( + astrcode_application::summarize_session_meta(meta), + ))) +} + +pub(crate) async fn switch_mode( + State(state): State<AppState>, + headers: HeaderMap, + Path(session_id): Path<String>, + Json(request): Json<SwitchModeRequest>, +) -> Result<(StatusCode, Json<SessionModeStateDto>), ApiError> { + require_auth(&state, &headers, None)?; + let session_id = validate_session_path_id(&session_id)?; + let mode = state + .app + .switch_mode(&session_id, request.mode_id.into()) + .await + .map_err(ApiError::from)?; + Ok(( + StatusCode::ACCEPTED, + Json(SessionModeStateDto { + current_mode_id: mode.current_mode_id.to_string(), + last_mode_changed_at: mode + .last_mode_changed_at + .map(astrcode_application::format_local_rfc3339), }), )) } @@ -152,8 +199,5 @@ pub(crate) async fn delete_project( .delete_project(&query.working_dir) .await .map_err(ApiError::from)?; - Ok(Json(DeleteProjectResultDto { - success_count: result.success_count, - failed_session_ids: result.failed_session_ids, - })) + Ok(Json(result)) } diff --git a/crates/server/src/http/routes/sessions/query.rs b/crates/server/src/http/routes/sessions/query.rs index 9c0785ce..bbc3b1eb 100644 --- a/crates/server/src/http/routes/sessions/query.rs +++ b/crates/server/src/http/routes/sessions/query.rs @@ -1,7 +1,14 @@ -use astrcode_protocol::http::SessionListItem; -use axum::{Json, extract::State, http::HeaderMap}; +use astrcode_protocol::http::{ModeSummaryDto, SessionListItem, SessionModeStateDto}; +use axum::{ + Json, + extract::{Path, State}, + http::HeaderMap, +}; -use crate::{ApiError, AppState, auth::require_auth, mapper::to_session_list_item}; +use crate::{ + ApiError, AppState, auth::require_auth, mapper::to_session_list_item, + routes::sessions::validate_session_path_id, +}; pub(crate) async fn list_sessions( State(state): State<AppState>, @@ -14,7 +21,48 @@ pub(crate) async fn list_sessions( .await .map_err(ApiError::from)? .into_iter() + .map(astrcode_application::summarize_session_meta) .map(to_session_list_item) .collect(); Ok(Json(sessions)) } + +pub(crate) async fn list_modes( + State(state): State<AppState>, + headers: HeaderMap, +) -> Result<Json<Vec<ModeSummaryDto>>, ApiError> { + require_auth(&state, &headers, None)?; + let modes = state + .app + .list_modes() + .await + .map_err(ApiError::from)? + .into_iter() + .map(|summary| ModeSummaryDto { + id: summary.id.to_string(), + name: summary.name, + description: summary.description, + }) + .collect(); + Ok(Json(modes)) +} + +pub(crate) async fn get_session_mode( + State(state): State<AppState>, + headers: HeaderMap, + Path(session_id): Path<String>, +) -> Result<Json<SessionModeStateDto>, ApiError> { + require_auth(&state, &headers, None)?; + let session_id = validate_session_path_id(&session_id)?; + let mode = state + .app + .session_mode_state(&session_id) + .await + .map_err(ApiError::from)?; + Ok(Json(SessionModeStateDto { + current_mode_id: mode.current_mode_id.to_string(), + last_mode_changed_at: mode + .last_mode_changed_at + .map(astrcode_application::format_local_rfc3339), + })) +} diff --git a/crates/server/src/http/terminal_projection.rs b/crates/server/src/http/terminal_projection.rs index 5d80a7b9..beb0373b 100644 --- a/crates/server/src/http/terminal_projection.rs +++ b/crates/server/src/http/terminal_projection.rs @@ -1,71 +1,93 @@ use std::collections::HashMap; -use astrcode_application::{ - TerminalChildSummaryFacts, TerminalControlFacts, TerminalFacts, TerminalRehydrateFacts, - TerminalSlashAction, TerminalSlashCandidateFacts, TerminalStreamReplayFacts, - terminal::truncate_terminal_summary, -}; -use astrcode_core::{ - AgentEvent, AgentLifecycleStatus, ChildAgentRef, ChildSessionLineageKind, SessionEventRecord, - ToolExecutionResult, ToolOutputStream, +use astrcode_application::terminal::{ + ConversationChildSummarySummary, ConversationControlSummary, ConversationSlashActionSummary, + ConversationSlashCandidateSummary, TerminalChildSummaryFacts, TerminalFacts, + TerminalRehydrateFacts, TerminalSlashCandidateFacts, summarize_conversation_child_ref, + summarize_conversation_child_summary, summarize_conversation_control, + summarize_conversation_slash_candidate, }; +use astrcode_core::ChildAgentRef; use astrcode_protocol::http::{ - AgentLifecycleDto, ChildAgentRefDto, ChildSessionLineageKindDto, PhaseDto, - TerminalAssistantBlockDto, TerminalBannerDto, TerminalBannerErrorCodeDto, TerminalBlockDto, - TerminalBlockPatchDto, TerminalBlockStatusDto, TerminalChildHandoffBlockDto, - TerminalChildHandoffKindDto, TerminalChildSummaryDto, TerminalControlStateDto, - TerminalCursorDto, TerminalDeltaDto, TerminalErrorBlockDto, TerminalErrorEnvelopeDto, - TerminalSlashActionKindDto, TerminalSlashCandidateDto, TerminalSlashCandidatesResponseDto, - TerminalSnapshotResponseDto, TerminalStreamEnvelopeDto, TerminalSystemNoteBlockDto, - TerminalSystemNoteKindDto, TerminalThinkingBlockDto, TerminalToolCallBlockDto, - TerminalToolStreamBlockDto, TerminalTranscriptErrorCodeDto, TerminalUserBlockDto, - ToolOutputStreamDto, + ChildAgentRefDto, ConversationAssistantBlockDto, ConversationBannerDto, + ConversationBannerErrorCodeDto, ConversationBlockDto, ConversationBlockPatchDto, + ConversationBlockStatusDto, ConversationChildHandoffBlockDto, ConversationChildHandoffKindDto, + ConversationChildSummaryDto, ConversationControlStateDto, ConversationCursorDto, + ConversationDeltaDto, ConversationErrorBlockDto, ConversationErrorEnvelopeDto, + ConversationLastCompactMetaDto, ConversationPlanBlockDto, ConversationPlanBlockersDto, + ConversationPlanEventKindDto, ConversationPlanReferenceDto, ConversationPlanReviewDto, + ConversationPlanReviewKindDto, ConversationSlashActionKindDto, ConversationSlashCandidateDto, + ConversationSlashCandidatesResponseDto, ConversationSnapshotResponseDto, + ConversationStreamEnvelopeDto, ConversationSystemNoteBlockDto, ConversationSystemNoteKindDto, + ConversationTaskItemDto, ConversationTaskStatusDto, ConversationThinkingBlockDto, + ConversationToolCallBlockDto, ConversationToolStreamsDto, ConversationTranscriptErrorCodeDto, + ConversationUserBlockDto, +}; +use astrcode_session_runtime::{ + ConversationBlockFacts, ConversationBlockPatchFacts, ConversationBlockStatus, + ConversationChildHandoffBlockFacts, ConversationChildHandoffKind, ConversationDeltaFacts, + ConversationDeltaFrameFacts, ConversationPlanBlockFacts, ConversationPlanEventKind, + ConversationPlanReviewKind, ConversationSystemNoteKind, ConversationTranscriptErrorKind, + ToolCallBlockFacts, }; -pub(crate) fn project_terminal_snapshot(facts: &TerminalFacts) -> TerminalSnapshotResponseDto { +pub(crate) fn project_conversation_snapshot( + facts: &TerminalFacts, +) -> ConversationSnapshotResponseDto { let child_lookup = child_summary_lookup(&facts.child_summaries); - let mut projector = TerminalDeltaProjector::new(child_lookup); - projector.seed(&facts.transcript.records); - TerminalSnapshotResponseDto { + ConversationSnapshotResponseDto { session_id: facts.active_session_id.clone(), session_title: facts.session_title.clone(), - cursor: TerminalCursorDto( + cursor: ConversationCursorDto( facts .transcript .cursor .clone() .unwrap_or_else(|| "0.0".to_string()), ), - phase: to_phase_dto(facts.control.phase), - control: project_control_state(&facts.control), - blocks: projector.blocks, + phase: facts.control.phase, + control: to_conversation_control_state_dto(summarize_conversation_control(&facts.control)), + blocks: facts + .transcript + .blocks + .iter() + .map(|block| project_block(block, &child_lookup)) + .collect(), child_summaries: facts .child_summaries .iter() - .map(project_child_summary) + .map(summarize_conversation_child_summary) + .map(to_conversation_child_summary_dto) .collect(), slash_candidates: facts .slash_candidates .iter() - .map(project_slash_candidate) + .map(summarize_conversation_slash_candidate) + .map(to_conversation_slash_candidate_dto) .collect(), banner: None, } } -pub(crate) fn project_terminal_control_delta(control: &TerminalControlFacts) -> TerminalDeltaDto { - TerminalDeltaDto::UpdateControlState { - control: project_control_state(control), +pub(crate) fn project_conversation_frame( + session_id: &str, + frame: ConversationDeltaFrameFacts, + child_lookup: &HashMap<String, ConversationChildSummaryDto>, +) -> ConversationStreamEnvelopeDto { + ConversationStreamEnvelopeDto { + session_id: session_id.to_string(), + cursor: ConversationCursorDto(frame.cursor), + delta: project_delta(frame.delta, child_lookup), } } -pub(crate) fn project_terminal_rehydrate_banner( +pub(crate) fn project_conversation_rehydrate_banner( rehydrate: &TerminalRehydrateFacts, -) -> TerminalBannerDto { - TerminalBannerDto { - error: TerminalErrorEnvelopeDto { - code: TerminalBannerErrorCodeDto::CursorExpired, +) -> ConversationBannerDto { + ConversationBannerDto { + error: ConversationErrorEnvelopeDto { + code: ConversationBannerErrorCodeDto::CursorExpired, message: format!( "cursor '{}' is no longer valid for session '{}'", rehydrate.requested_cursor, rehydrate.session_id @@ -80,131 +102,45 @@ pub(crate) fn project_terminal_rehydrate_banner( } } -pub(crate) fn project_terminal_rehydrate_envelope( +pub(crate) fn project_conversation_rehydrate_envelope( rehydrate: &TerminalRehydrateFacts, -) -> TerminalStreamEnvelopeDto { - TerminalStreamEnvelopeDto { +) -> ConversationStreamEnvelopeDto { + ConversationStreamEnvelopeDto { session_id: rehydrate.session_id.clone(), - cursor: TerminalCursorDto( + cursor: ConversationCursorDto( rehydrate .latest_cursor .clone() .unwrap_or_else(|| rehydrate.requested_cursor.clone()), ), - delta: TerminalDeltaDto::RehydrateRequired { - error: project_terminal_rehydrate_banner(rehydrate).error, + delta: ConversationDeltaDto::RehydrateRequired { + error: project_conversation_rehydrate_banner(rehydrate).error, }, } } -pub(crate) fn project_terminal_slash_candidates( +pub(crate) fn project_conversation_slash_candidates( candidates: &[TerminalSlashCandidateFacts], -) -> TerminalSlashCandidatesResponseDto { - TerminalSlashCandidatesResponseDto { - items: candidates.iter().map(project_slash_candidate).collect(), - } -} - -pub(crate) fn project_terminal_stream_replay( - facts: &TerminalStreamReplayFacts, - last_event_id: Option<&str>, -) -> Vec<TerminalStreamEnvelopeDto> { - let mut projector = TerminalDeltaProjector::new(child_summary_lookup(&facts.child_summaries)); - let seed_records = transcript_before_cursor(&facts.seed_records, last_event_id); - projector.seed(seed_records); - - let mut deltas = Vec::new(); - for record in &facts.replay.history { - for delta in projector.project_record(record) { - deltas.push(TerminalStreamEnvelopeDto { - session_id: facts.active_session_id.clone(), - cursor: TerminalCursorDto(record.event_id.clone()), - delta, - }); - } - } - deltas -} - -pub(crate) fn seeded_terminal_stream_projector( - facts: &TerminalStreamReplayFacts, -) -> TerminalDeltaProjector { - let mut projector = TerminalDeltaProjector::new(child_summary_lookup(&facts.child_summaries)); - projector.seed(&facts.seed_records); - projector -} - -fn transcript_before_cursor<'a>( - records: &'a [SessionEventRecord], - last_event_id: Option<&str>, -) -> &'a [SessionEventRecord] { - let Some(last_event_id) = last_event_id else { - return &[]; - }; - let Some(index) = records - .iter() - .position(|record| record.event_id == last_event_id) - else { - return &[]; - }; - &records[..=index] -} - -fn child_summary_lookup( - summaries: &[TerminalChildSummaryFacts], -) -> HashMap<String, TerminalChildSummaryDto> { - summaries - .iter() - .map(|summary| { - ( - summary.node.child_session_id.clone(), - project_child_summary(summary), - ) - }) - .collect() -} - -fn project_control_state(control: &TerminalControlFacts) -> TerminalControlStateDto { - let can_submit_prompt = matches!( - control.phase, - astrcode_core::Phase::Idle | astrcode_core::Phase::Done | astrcode_core::Phase::Interrupted - ); - TerminalControlStateDto { - phase: to_phase_dto(control.phase), - can_submit_prompt, - can_request_compact: !control.manual_compact_pending, - compact_pending: control.manual_compact_pending, - active_turn_id: control.active_turn_id.clone(), - } -} - -pub(crate) fn project_child_summary( - summary: &TerminalChildSummaryFacts, -) -> TerminalChildSummaryDto { - TerminalChildSummaryDto { - child_session_id: summary.node.child_session_id.clone(), - child_agent_id: summary.node.agent_id.clone(), - title: summary - .title - .clone() - .or_else(|| summary.display_name.clone()) - .unwrap_or_else(|| summary.node.child_session_id.clone()), - lifecycle: to_lifecycle_dto(summary.node.status), - latest_output_summary: summary.recent_output.clone(), - child_ref: Some(to_child_ref_dto(summary.node.child_ref())), +) -> ConversationSlashCandidatesResponseDto { + ConversationSlashCandidatesResponseDto { + items: candidates + .iter() + .map(summarize_conversation_slash_candidate) + .map(to_conversation_slash_candidate_dto) + .collect(), } } -pub(crate) fn project_terminal_child_summary_deltas( - previous: &[TerminalChildSummaryFacts], - current: &[TerminalChildSummaryFacts], -) -> Vec<TerminalDeltaDto> { +pub(crate) fn project_conversation_child_summary_summary_deltas( + previous: &[ConversationChildSummarySummary], + current: &[ConversationChildSummarySummary], +) -> Vec<ConversationDeltaDto> { let previous_by_id = previous .iter() .map(|summary| { ( - summary.node.child_session_id.clone(), - project_child_summary(summary), + summary.child_session_id.clone(), + to_conversation_child_summary_dto(summary.clone()), ) }) .collect::<HashMap<_, _>>(); @@ -212,8 +148,8 @@ pub(crate) fn project_terminal_child_summary_deltas( .iter() .map(|summary| { ( - summary.node.child_session_id.clone(), - project_child_summary(summary), + summary.child_session_id.clone(), + to_conversation_child_summary_dto(summary.clone()), ) }) .collect::<HashMap<_, _>>(); @@ -226,7 +162,9 @@ pub(crate) fn project_terminal_child_summary_deltas( .collect::<Vec<_>>(); removed_ids.sort(); for child_session_id in removed_ids { - deltas.push(TerminalDeltaDto::RemoveChildSummary { child_session_id }); + deltas.push(ConversationDeltaDto::RemoveChildSummary { + child_session_id: child_session_id.to_string(), + }); } let mut current_ids = current_by_id.keys().cloned().collect::<Vec<_>>(); @@ -236,7 +174,7 @@ pub(crate) fn project_terminal_child_summary_deltas( .get(&child_session_id) .expect("current child summary should exist"); if previous_by_id.get(&child_session_id) != Some(current_child) { - deltas.push(TerminalDeltaDto::UpsertChildSummary { + deltas.push(ConversationDeltaDto::UpsertChildSummary { child: current_child.clone(), }); } @@ -245,1771 +183,384 @@ pub(crate) fn project_terminal_child_summary_deltas( deltas } -fn project_slash_candidate(candidate: &TerminalSlashCandidateFacts) -> TerminalSlashCandidateDto { - let (action_kind, action_value) = match &candidate.action { - TerminalSlashAction::CreateSession => ( - TerminalSlashActionKindDto::ExecuteCommand, - "/new".to_string(), - ), - TerminalSlashAction::OpenResume => ( - TerminalSlashActionKindDto::ExecuteCommand, - "/resume".to_string(), - ), - TerminalSlashAction::RequestCompact => ( - TerminalSlashActionKindDto::ExecuteCommand, - "/compact".to_string(), - ), - TerminalSlashAction::OpenSkillPalette => ( - TerminalSlashActionKindDto::ExecuteCommand, - "/skill".to_string(), - ), - TerminalSlashAction::InsertText { text } => { - (TerminalSlashActionKindDto::InsertText, text.clone()) - }, - }; - - let _ = candidate.kind; - TerminalSlashCandidateDto { - id: candidate.id.clone(), - title: candidate.title.clone(), - description: candidate.description.clone(), - keywords: candidate.keywords.clone(), - action_kind, - action_value, - } -} - -fn to_phase_dto(phase: astrcode_core::Phase) -> PhaseDto { - match phase { - astrcode_core::Phase::Idle => PhaseDto::Idle, - astrcode_core::Phase::Thinking => PhaseDto::Thinking, - astrcode_core::Phase::CallingTool => PhaseDto::CallingTool, - astrcode_core::Phase::Streaming => PhaseDto::Streaming, - astrcode_core::Phase::Interrupted => PhaseDto::Interrupted, - astrcode_core::Phase::Done => PhaseDto::Done, +pub(crate) fn project_conversation_control_summary_delta( + summary: &ConversationControlSummary, +) -> ConversationDeltaDto { + ConversationDeltaDto::UpdateControlState { + control: to_conversation_control_state_dto(summary.clone()), } } -fn to_lifecycle_dto(status: AgentLifecycleStatus) -> AgentLifecycleDto { - match status { - AgentLifecycleStatus::Pending => AgentLifecycleDto::Pending, - AgentLifecycleStatus::Running => AgentLifecycleDto::Running, - AgentLifecycleStatus::Idle => AgentLifecycleDto::Idle, - AgentLifecycleStatus::Terminated => AgentLifecycleDto::Terminated, - } -} - -fn to_stream_dto(stream: ToolOutputStream) -> ToolOutputStreamDto { - match stream { - ToolOutputStream::Stdout => ToolOutputStreamDto::Stdout, - ToolOutputStream::Stderr => ToolOutputStreamDto::Stderr, +pub(crate) fn project_conversation_slash_candidate_summaries( + candidates: &[ConversationSlashCandidateSummary], +) -> ConversationSlashCandidatesResponseDto { + ConversationSlashCandidatesResponseDto { + items: candidates + .iter() + .cloned() + .map(to_conversation_slash_candidate_dto) + .collect(), } } -fn to_child_ref_dto(child_ref: ChildAgentRef) -> ChildAgentRefDto { - ChildAgentRefDto { - agent_id: child_ref.agent_id, - session_id: child_ref.session_id, - sub_run_id: child_ref.sub_run_id, - parent_agent_id: child_ref.parent_agent_id, - parent_sub_run_id: child_ref.parent_sub_run_id, - lineage_kind: match child_ref.lineage_kind { - ChildSessionLineageKind::Spawn => ChildSessionLineageKindDto::Spawn, - ChildSessionLineageKind::Fork => ChildSessionLineageKindDto::Fork, - ChildSessionLineageKind::Resume => ChildSessionLineageKindDto::Resume, +fn project_delta( + delta: ConversationDeltaFacts, + child_lookup: &HashMap<String, ConversationChildSummaryDto>, +) -> ConversationDeltaDto { + match delta { + ConversationDeltaFacts::AppendBlock { block } => ConversationDeltaDto::AppendBlock { + block: project_block(block.as_ref(), child_lookup), + }, + ConversationDeltaFacts::PatchBlock { block_id, patch } => { + ConversationDeltaDto::PatchBlock { + block_id, + patch: project_patch(patch), + } + }, + ConversationDeltaFacts::CompleteBlock { block_id, status } => { + ConversationDeltaDto::CompleteBlock { + block_id, + status: to_block_status_dto(status), + } }, - status: to_lifecycle_dto(child_ref.status), - open_session_id: child_ref.open_session_id, - } -} - -#[derive(Default)] -pub(crate) struct TerminalDeltaProjector { - blocks: Vec<TerminalBlockDto>, - block_index: HashMap<String, usize>, - turn_blocks: HashMap<String, TurnBlockRefs>, - tool_blocks: HashMap<String, ToolBlockRefs>, - child_lookup: HashMap<String, TerminalChildSummaryDto>, -} - -#[derive(Default, Clone)] -struct TurnBlockRefs { - thinking: Option<String>, - assistant: Option<String>, -} - -#[derive(Default, Clone)] -struct ToolBlockRefs { - turn_id: Option<String>, - call: Option<String>, - stdout: Option<String>, - stderr: Option<String>, - pending_live_stdout_bytes: usize, - pending_live_stderr_bytes: usize, -} - -#[derive(Clone, Copy)] -enum BlockKind { - Thinking, - Assistant, -} - -impl ToolBlockRefs { - fn reconcile_tool_chunk( - &mut self, - stream: ToolOutputStream, - delta: &str, - source: ProjectionSource, - ) -> String { - let pending_live_bytes = match stream { - ToolOutputStream::Stdout => &mut self.pending_live_stdout_bytes, - ToolOutputStream::Stderr => &mut self.pending_live_stderr_bytes, - }; - - if matches!(source, ProjectionSource::Live) { - *pending_live_bytes += delta.len(); - return delta.to_string(); - } - - if *pending_live_bytes == 0 { - return delta.to_string(); - } - - let consumed = (*pending_live_bytes).min(delta.len()); - *pending_live_bytes -= consumed; - delta[consumed..].to_string() } } -impl TerminalDeltaProjector { - pub(crate) fn new(child_lookup: HashMap<String, TerminalChildSummaryDto>) -> Self { - Self { - child_lookup, - ..Default::default() - } - } - - pub(crate) fn seed(&mut self, history: &[SessionEventRecord]) { - for record in history { - let _ = self.project_record(record); - } - } - - pub(crate) fn project_record(&mut self, record: &SessionEventRecord) -> Vec<TerminalDeltaDto> { - self.project_event( - &record.event, - ProjectionSource::Durable, - Some(&record.event_id), - ) - } - - pub(crate) fn project_live_event(&mut self, event: &AgentEvent) -> Vec<TerminalDeltaDto> { - self.project_event(event, ProjectionSource::Live, None) - } - - fn project_event( - &mut self, - event: &AgentEvent, - source: ProjectionSource, - durable_event_id: Option<&str>, - ) -> Vec<TerminalDeltaDto> { - match event { - AgentEvent::UserMessage { - turn_id, content, .. - } if source.is_durable() => { - let block_id = format!("turn:{turn_id}:user"); - self.append_user_block(&block_id, turn_id, content) - }, - AgentEvent::ThinkingDelta { turn_id, delta, .. } => { - let block_id = format!("turn:{turn_id}:thinking"); - self.turn_blocks - .entry(turn_id.clone()) - .or_default() - .thinking = Some(block_id.clone()); - self.append_markdown_streaming_block(&block_id, turn_id, delta, BlockKind::Thinking) - }, - AgentEvent::ModelDelta { turn_id, delta, .. } => { - let block_id = format!("turn:{turn_id}:assistant"); - self.turn_blocks - .entry(turn_id.clone()) - .or_default() - .assistant = Some(block_id.clone()); - self.append_markdown_streaming_block( - &block_id, - turn_id, - delta, - BlockKind::Assistant, - ) - }, - AgentEvent::AssistantMessage { - turn_id, - content, - reasoning_content, - .. - } if source.is_durable() => { - self.finalize_assistant_block(turn_id, content, reasoning_content.as_deref()) - }, - AgentEvent::ToolCallStart { - turn_id, - tool_call_id, - tool_name, - .. - } => self.start_tool_call(turn_id, tool_call_id, tool_name), - AgentEvent::ToolCallDelta { - turn_id, - tool_call_id, - tool_name, - stream, - delta, - .. - } => self.append_tool_stream(turn_id, tool_call_id, tool_name, *stream, delta, source), - AgentEvent::ToolCallResult { - turn_id, result, .. - } => self.complete_tool_call(turn_id.as_str(), result, source), - AgentEvent::CompactApplied { - turn_id, summary, .. - } if source.is_durable() => { - let block_id = format!( - "system:compact:{}", - turn_id - .clone() - .or_else(|| durable_event_id.map(ToString::to_string)) - .unwrap_or_else(|| "session".to_string()) - ); - self.append_system_note(&block_id, TerminalSystemNoteKindDto::Compact, summary) - }, - AgentEvent::ChildSessionNotification { notification, .. } => { - if source.is_durable() { - self.append_child_handoff(notification) - } else { - Vec::new() - } - }, - AgentEvent::Error { - turn_id, - code, - message, - .. - } if source.is_durable() => self.append_error(turn_id.as_deref(), code, message), - AgentEvent::TurnDone { turn_id, .. } if source.is_durable() => { - self.complete_turn(turn_id) - }, - AgentEvent::PhaseChanged { .. } - | AgentEvent::SessionStarted { .. } - | AgentEvent::PromptMetrics { .. } - | AgentEvent::SubRunStarted { .. } - | AgentEvent::SubRunFinished { .. } - | AgentEvent::AgentMailboxQueued { .. } - | AgentEvent::AgentMailboxBatchStarted { .. } - | AgentEvent::AgentMailboxBatchAcked { .. } - | AgentEvent::AgentMailboxDiscarded { .. } - | AgentEvent::UserMessage { .. } - | AgentEvent::AssistantMessage { .. } - | AgentEvent::CompactApplied { .. } - | AgentEvent::Error { .. } - | AgentEvent::TurnDone { .. } => Vec::new(), - } - } - - fn append_user_block( - &mut self, - block_id: &str, - turn_id: &str, - content: &str, - ) -> Vec<TerminalDeltaDto> { - if self.block_index.contains_key(block_id) { - return Vec::new(); - } - self.push_block(TerminalBlockDto::User(TerminalUserBlockDto { - id: block_id.to_string(), - turn_id: Some(turn_id.to_string()), - markdown: content.to_string(), - })) - } - - fn append_markdown_streaming_block( - &mut self, - block_id: &str, - turn_id: &str, - delta: &str, - kind: BlockKind, - ) -> Vec<TerminalDeltaDto> { - if let Some(index) = self.block_index.get(block_id).copied() { - self.append_markdown(index, delta); - return vec![TerminalDeltaDto::PatchBlock { - block_id: block_id.to_string(), - patch: TerminalBlockPatchDto::AppendMarkdown { - markdown: delta.to_string(), - }, - }]; - } - - let block = match kind { - BlockKind::Thinking => TerminalBlockDto::Thinking(TerminalThinkingBlockDto { - id: block_id.to_string(), - turn_id: Some(turn_id.to_string()), - status: TerminalBlockStatusDto::Streaming, - markdown: delta.to_string(), - }), - BlockKind::Assistant => TerminalBlockDto::Assistant(TerminalAssistantBlockDto { - id: block_id.to_string(), - turn_id: Some(turn_id.to_string()), - status: TerminalBlockStatusDto::Streaming, - markdown: delta.to_string(), - }), - }; - self.push_block(block) - } - - fn finalize_assistant_block( - &mut self, - turn_id: &str, - content: &str, - reasoning_content: Option<&str>, - ) -> Vec<TerminalDeltaDto> { - let assistant_id = format!("turn:{turn_id}:assistant"); - let thinking_id = format!("turn:{turn_id}:thinking"); - let mut deltas = Vec::new(); - - if let Some(reasoning_content) = reasoning_content.filter(|value| !value.trim().is_empty()) - { - deltas.extend(self.ensure_full_markdown_block( - &thinking_id, - turn_id, - reasoning_content, - BlockKind::Thinking, - )); - if let Some(delta) = self.complete_block(&thinking_id, TerminalBlockStatusDto::Complete) - { - deltas.push(delta); +fn project_patch(patch: ConversationBlockPatchFacts) -> ConversationBlockPatchDto { + match patch { + ConversationBlockPatchFacts::AppendMarkdown { markdown } => { + ConversationBlockPatchDto::AppendMarkdown { markdown } + }, + ConversationBlockPatchFacts::ReplaceMarkdown { markdown } => { + ConversationBlockPatchDto::ReplaceMarkdown { markdown } + }, + ConversationBlockPatchFacts::AppendToolStream { stream, chunk } => { + ConversationBlockPatchDto::AppendToolStream { stream, chunk } + }, + ConversationBlockPatchFacts::ReplaceSummary { summary } => { + ConversationBlockPatchDto::ReplaceSummary { summary } + }, + ConversationBlockPatchFacts::ReplaceMetadata { metadata } => { + ConversationBlockPatchDto::ReplaceMetadata { metadata } + }, + ConversationBlockPatchFacts::ReplaceError { error } => { + ConversationBlockPatchDto::ReplaceError { error } + }, + ConversationBlockPatchFacts::ReplaceDuration { duration_ms } => { + ConversationBlockPatchDto::ReplaceDuration { duration_ms } + }, + ConversationBlockPatchFacts::ReplaceChildRef { child_ref } => { + ConversationBlockPatchDto::ReplaceChildRef { + child_ref: to_child_ref_dto(child_ref), } - } - - deltas.extend(self.ensure_full_markdown_block( - &assistant_id, - turn_id, - content, - BlockKind::Assistant, - )); - if let Some(delta) = self.complete_block(&assistant_id, TerminalBlockStatusDto::Complete) { - deltas.push(delta); - } - deltas + }, + ConversationBlockPatchFacts::SetTruncated { truncated } => { + ConversationBlockPatchDto::SetTruncated { truncated } + }, + ConversationBlockPatchFacts::SetStatus { status } => ConversationBlockPatchDto::SetStatus { + status: to_block_status_dto(status), + }, } +} - fn ensure_full_markdown_block( - &mut self, - block_id: &str, - turn_id: &str, - content: &str, - kind: BlockKind, - ) -> Vec<TerminalDeltaDto> { - if let Some(index) = self.block_index.get(block_id).copied() { - let existing = self.block_markdown(index); - self.replace_markdown(index, content); - if content.starts_with(&existing) { - let suffix = &content[existing.len()..]; - if suffix.is_empty() { - return Vec::new(); - } - return vec![TerminalDeltaDto::PatchBlock { - block_id: block_id.to_string(), - patch: TerminalBlockPatchDto::AppendMarkdown { - markdown: suffix.to_string(), +fn project_block( + block: &ConversationBlockFacts, + child_lookup: &HashMap<String, ConversationChildSummaryDto>, +) -> ConversationBlockDto { + match block { + ConversationBlockFacts::User(block) => { + ConversationBlockDto::User(ConversationUserBlockDto { + id: block.id.clone(), + turn_id: block.turn_id.clone(), + markdown: block.markdown.clone(), + }) + }, + ConversationBlockFacts::Assistant(block) => { + ConversationBlockDto::Assistant(ConversationAssistantBlockDto { + id: block.id.clone(), + turn_id: block.turn_id.clone(), + status: to_block_status_dto(block.status), + markdown: block.markdown.clone(), + }) + }, + ConversationBlockFacts::Thinking(block) => { + ConversationBlockDto::Thinking(ConversationThinkingBlockDto { + id: block.id.clone(), + turn_id: block.turn_id.clone(), + status: to_block_status_dto(block.status), + markdown: block.markdown.clone(), + }) + }, + ConversationBlockFacts::Plan(block) => { + ConversationBlockDto::Plan(project_plan_block(block.as_ref())) + }, + ConversationBlockFacts::ToolCall(block) => { + ConversationBlockDto::ToolCall(project_tool_call_block(block)) + }, + ConversationBlockFacts::Error(block) => { + ConversationBlockDto::Error(ConversationErrorBlockDto { + id: block.id.clone(), + turn_id: block.turn_id.clone(), + code: to_error_code_dto(block.code), + message: block.message.clone(), + }) + }, + ConversationBlockFacts::SystemNote(block) => { + ConversationBlockDto::SystemNote(ConversationSystemNoteBlockDto { + id: block.id.clone(), + note_kind: match block.note_kind { + ConversationSystemNoteKind::Compact => ConversationSystemNoteKindDto::Compact, + ConversationSystemNoteKind::SystemNote => { + ConversationSystemNoteKindDto::SystemNote }, - }]; - } - return vec![TerminalDeltaDto::PatchBlock { - block_id: block_id.to_string(), - patch: TerminalBlockPatchDto::ReplaceMarkdown { - markdown: content.to_string(), }, - }]; - } - - let block = match kind { - BlockKind::Thinking => TerminalBlockDto::Thinking(TerminalThinkingBlockDto { - id: block_id.to_string(), - turn_id: Some(turn_id.to_string()), - status: TerminalBlockStatusDto::Streaming, - markdown: content.to_string(), - }), - BlockKind::Assistant => TerminalBlockDto::Assistant(TerminalAssistantBlockDto { - id: block_id.to_string(), - turn_id: Some(turn_id.to_string()), - status: TerminalBlockStatusDto::Streaming, - markdown: content.to_string(), - }), - }; - self.push_block(block) - } - - fn start_tool_call( - &mut self, - turn_id: &str, - tool_call_id: &str, - tool_name: &str, - ) -> Vec<TerminalDeltaDto> { - let block_id = format!("tool:{tool_call_id}:call"); - let refs = self - .tool_blocks - .entry(tool_call_id.to_string()) - .or_default(); - refs.turn_id = Some(turn_id.to_string()); - refs.call = Some(block_id.clone()); - if self.block_index.contains_key(&block_id) { - return Vec::new(); - } - self.push_block(TerminalBlockDto::ToolCall(TerminalToolCallBlockDto { - id: block_id, - turn_id: Some(turn_id.to_string()), - tool_call_id: Some(tool_call_id.to_string()), - tool_name: tool_name.to_string(), - status: TerminalBlockStatusDto::Streaming, - summary: None, - })) - } - - fn append_tool_stream( - &mut self, - turn_id: &str, - tool_call_id: &str, - tool_name: &str, - stream: ToolOutputStream, - delta: &str, - source: ProjectionSource, - ) -> Vec<TerminalDeltaDto> { - let mut deltas = self.start_tool_call(turn_id, tool_call_id, tool_name); - let refs = self - .tool_blocks - .entry(tool_call_id.to_string()) - .or_default(); - let chunk = refs.reconcile_tool_chunk(stream, delta, source); - if chunk.is_empty() { - return deltas; - } - let block_id = match stream { - ToolOutputStream::Stdout => refs - .stdout - .get_or_insert_with(|| format!("tool:{tool_call_id}:stdout")) - .clone(), - ToolOutputStream::Stderr => refs - .stderr - .get_or_insert_with(|| format!("tool:{tool_call_id}:stderr")) - .clone(), - }; - if let Some(index) = self.block_index.get(&block_id).copied() { - self.append_tool_stream_content(index, &chunk); - deltas.push(TerminalDeltaDto::PatchBlock { - block_id, - patch: TerminalBlockPatchDto::AppendToolStream { - stream: to_stream_dto(stream), - chunk, - }, - }); - return deltas; - } - deltas.extend( - self.push_block(TerminalBlockDto::ToolStream(TerminalToolStreamBlockDto { - id: block_id, - parent_tool_call_id: Some(tool_call_id.to_string()), - stream: to_stream_dto(stream), - status: TerminalBlockStatusDto::Streaming, - content: chunk, - })), - ); - deltas - } - - fn complete_tool_call( - &mut self, - turn_id: &str, - result: &ToolExecutionResult, - source: ProjectionSource, - ) -> Vec<TerminalDeltaDto> { - let mut deltas = self.start_tool_call(turn_id, &result.tool_call_id, &result.tool_name); - let status = if result.ok { - TerminalBlockStatusDto::Complete - } else { - TerminalBlockStatusDto::Failed - }; - let summary = tool_result_summary(result); - let refs = self - .tool_blocks - .entry(result.tool_call_id.clone()) - .or_default(); - if source.is_durable() { - refs.pending_live_stdout_bytes = 0; - refs.pending_live_stderr_bytes = 0; - } - let refs = refs.clone(); - - if let Some(call_block_id) = refs.call { - if let Some(index) = self.block_index.get(&call_block_id).copied() { - if self.replace_tool_summary(index, &summary) { - deltas.push(TerminalDeltaDto::PatchBlock { - block_id: call_block_id.clone(), - patch: TerminalBlockPatchDto::ReplaceSummary { - summary: summary.clone(), - }, - }); - } - if let Some(delta) = self.complete_block(&call_block_id, status) { - deltas.push(delta); - } - } - } - - if let Some(stdout) = refs.stdout { - if let Some(delta) = self.complete_block(&stdout, status) { - deltas.push(delta); - } - } - if let Some(stderr) = refs.stderr { - if let Some(delta) = self.complete_block(&stderr, status) { - deltas.push(delta); - } - } - - deltas - } - - fn append_system_note( - &mut self, - block_id: &str, - note_kind: TerminalSystemNoteKindDto, - markdown: &str, - ) -> Vec<TerminalDeltaDto> { - if self.block_index.contains_key(block_id) { - return Vec::new(); - } - self.push_block(TerminalBlockDto::SystemNote(TerminalSystemNoteBlockDto { - id: block_id.to_string(), - note_kind, - markdown: markdown.to_string(), - })) + markdown: block.markdown.clone(), + compact_meta: block.compact_meta.as_ref().and_then(|meta| { + block + .compact_trigger + .map(|trigger| ConversationLastCompactMetaDto { + trigger, + meta: meta.clone(), + }) + }), + preserved_recent_turns: block.compact_preserved_recent_turns, + }) + }, + ConversationBlockFacts::ChildHandoff(block) => { + ConversationBlockDto::ChildHandoff(project_child_handoff_block(block, child_lookup)) + }, } +} - fn append_child_handoff( - &mut self, - notification: &astrcode_core::ChildSessionNotification, - ) -> Vec<TerminalDeltaDto> { - let block_id = format!("child:{}", notification.notification_id); - if self.block_index.contains_key(&block_id) { - return Vec::new(); - } - let child = self - .child_lookup - .get(¬ification.child_ref.open_session_id) - .cloned() - .unwrap_or_else(|| TerminalChildSummaryDto { - child_session_id: notification.child_ref.open_session_id.clone(), - child_agent_id: notification.child_ref.agent_id.clone(), - title: notification.child_ref.open_session_id.clone(), - lifecycle: to_lifecycle_dto(notification.status), - latest_output_summary: notification - .delivery - .as_ref() - .map(|delivery| delivery.payload.message().to_string()), - child_ref: Some(to_child_ref_dto(notification.child_ref.clone())), - }); - self.push_block(TerminalBlockDto::ChildHandoff( - TerminalChildHandoffBlockDto { - id: block_id, - handoff_kind: match notification.kind { - astrcode_core::ChildSessionNotificationKind::Started - | astrcode_core::ChildSessionNotificationKind::Resumed => { - TerminalChildHandoffKindDto::Delegated - }, - astrcode_core::ChildSessionNotificationKind::ProgressSummary - | astrcode_core::ChildSessionNotificationKind::Waiting => { - TerminalChildHandoffKindDto::Progress +fn project_plan_block(block: &ConversationPlanBlockFacts) -> ConversationPlanBlockDto { + ConversationPlanBlockDto { + id: block.id.clone(), + turn_id: block.turn_id.clone(), + tool_call_id: block.tool_call_id.clone(), + event_kind: match block.event_kind { + ConversationPlanEventKind::Saved => ConversationPlanEventKindDto::Saved, + ConversationPlanEventKind::ReviewPending => ConversationPlanEventKindDto::ReviewPending, + ConversationPlanEventKind::Presented => ConversationPlanEventKindDto::Presented, + }, + title: block.title.clone(), + plan_path: block.plan_path.clone(), + summary: block.summary.clone(), + status: block.status.clone(), + slug: block.slug.clone(), + updated_at: block.updated_at.clone(), + content: block.content.clone(), + review: block + .review + .as_ref() + .map(|review| ConversationPlanReviewDto { + kind: match review.kind { + ConversationPlanReviewKind::RevisePlan => { + ConversationPlanReviewKindDto::RevisePlan }, - astrcode_core::ChildSessionNotificationKind::Delivered - | astrcode_core::ChildSessionNotificationKind::Closed - | astrcode_core::ChildSessionNotificationKind::Failed => { - TerminalChildHandoffKindDto::Returned + ConversationPlanReviewKind::FinalReview => { + ConversationPlanReviewKindDto::FinalReview }, }, - child, - message: notification - .delivery - .as_ref() - .map(|delivery| delivery.payload.message().to_string()), - }, - )) - } - - fn append_error( - &mut self, - turn_id: Option<&str>, - code: &str, - message: &str, - ) -> Vec<TerminalDeltaDto> { - if code == "interrupted" { - return Vec::new(); - } - let block_id = format!("turn:{}:error", turn_id.unwrap_or("session")); - if self.block_index.contains_key(&block_id) { - return Vec::new(); - } - self.push_block(TerminalBlockDto::Error(TerminalErrorBlockDto { - id: block_id, - turn_id: turn_id.map(ToString::to_string), - code: classify_transcript_error(message), - message: message.to_string(), - })) - } - - fn complete_turn(&mut self, turn_id: &str) -> Vec<TerminalDeltaDto> { - let Some(refs) = self.turn_blocks.get(turn_id).cloned() else { - return Vec::new(); - }; - let mut deltas = Vec::new(); - if let Some(thinking) = refs.thinking { - if let Some(delta) = self.complete_block(&thinking, TerminalBlockStatusDto::Complete) { - deltas.push(delta); - } - } - if let Some(assistant) = refs.assistant { - if let Some(delta) = self.complete_block(&assistant, TerminalBlockStatusDto::Complete) { - deltas.push(delta); - } - } - let tool_blocks = self - .tool_blocks - .values() - .filter(|tool| tool.turn_id.as_deref() == Some(turn_id)) - .cloned() - .collect::<Vec<_>>(); - for tool in tool_blocks { - if let Some(call) = &tool.call { - if let Some(delta) = self.complete_block(call, TerminalBlockStatusDto::Complete) { - deltas.push(delta); - } - } - if let Some(stdout) = &tool.stdout { - if let Some(delta) = self.complete_block(stdout, TerminalBlockStatusDto::Complete) { - deltas.push(delta); - } - } - if let Some(stderr) = &tool.stderr { - if let Some(delta) = self.complete_block(stderr, TerminalBlockStatusDto::Complete) { - deltas.push(delta); - } - } - } - deltas - } - - fn push_block(&mut self, block: TerminalBlockDto) -> Vec<TerminalDeltaDto> { - let id = block_id(&block).to_string(); - self.block_index.insert(id, self.blocks.len()); - self.blocks.push(block.clone()); - vec![TerminalDeltaDto::AppendBlock { block }] - } - - fn complete_block( - &mut self, - block_id: &str, - status: TerminalBlockStatusDto, - ) -> Option<TerminalDeltaDto> { - if let Some(index) = self.block_index.get(block_id).copied() { - if self.block_status(index) == Some(status) { - return None; - } - self.set_status(index, status); - return Some(TerminalDeltaDto::CompleteBlock { - block_id: block_id.to_string(), - status, - }); - } - None - } - - fn append_markdown(&mut self, index: usize, markdown: &str) { - match &mut self.blocks[index] { - TerminalBlockDto::Thinking(block) => block.markdown.push_str(markdown), - TerminalBlockDto::Assistant(block) => block.markdown.push_str(markdown), - _ => {}, - } - } - - fn replace_markdown(&mut self, index: usize, markdown: &str) { - match &mut self.blocks[index] { - TerminalBlockDto::Thinking(block) => block.markdown = markdown.to_string(), - TerminalBlockDto::Assistant(block) => block.markdown = markdown.to_string(), - _ => {}, - } - } - - fn append_tool_stream_content(&mut self, index: usize, chunk: &str) { - if let TerminalBlockDto::ToolStream(block) = &mut self.blocks[index] { - block.content.push_str(chunk); - } - } - - fn replace_tool_summary(&mut self, index: usize, summary: &str) -> bool { - if let TerminalBlockDto::ToolCall(block) = &mut self.blocks[index] { - if block.summary.as_deref() == Some(summary) { - return false; - } - block.summary = Some(summary.to_string()); - return true; - } - false - } - - fn set_status(&mut self, index: usize, status: TerminalBlockStatusDto) { - match &mut self.blocks[index] { - TerminalBlockDto::Thinking(block) => block.status = status, - TerminalBlockDto::Assistant(block) => block.status = status, - TerminalBlockDto::ToolCall(block) => block.status = status, - TerminalBlockDto::ToolStream(block) => block.status = status, - _ => {}, - } - } - - fn block_markdown(&self, index: usize) -> String { - match &self.blocks[index] { - TerminalBlockDto::Thinking(block) => block.markdown.clone(), - TerminalBlockDto::Assistant(block) => block.markdown.clone(), - _ => String::new(), - } + checklist: review.checklist.clone(), + }), + blockers: ConversationPlanBlockersDto { + missing_headings: block.blockers.missing_headings.clone(), + invalid_sections: block.blockers.invalid_sections.clone(), + }, } +} - fn block_status(&self, index: usize) -> Option<TerminalBlockStatusDto> { - match &self.blocks[index] { - TerminalBlockDto::Thinking(block) => Some(block.status), - TerminalBlockDto::Assistant(block) => Some(block.status), - TerminalBlockDto::ToolCall(block) => Some(block.status), - TerminalBlockDto::ToolStream(block) => Some(block.status), - _ => None, - } +fn project_tool_call_block(block: &ToolCallBlockFacts) -> ConversationToolCallBlockDto { + ConversationToolCallBlockDto { + id: block.id.clone(), + turn_id: block.turn_id.clone(), + tool_call_id: block.tool_call_id.clone(), + tool_name: block.tool_name.clone(), + status: to_block_status_dto(block.status), + input: block.input.clone(), + summary: block.summary.clone(), + error: block.error.clone(), + duration_ms: block.duration_ms, + truncated: block.truncated, + metadata: block.metadata.clone(), + child_ref: block.child_ref.clone().map(to_child_ref_dto), + streams: ConversationToolStreamsDto { + stdout: block.streams.stdout.clone(), + stderr: block.streams.stderr.clone(), + }, } } -#[derive(Clone, Copy)] -enum ProjectionSource { - Durable, - Live, -} +fn project_child_handoff_block( + block: &ConversationChildHandoffBlockFacts, + child_lookup: &HashMap<String, ConversationChildSummaryDto>, +) -> ConversationChildHandoffBlockDto { + let child = child_lookup + .get(block.child_ref.open_session_id.as_str()) + .cloned() + .or_else(|| { + child_lookup + .get(block.child_ref.session_id().as_str()) + .cloned() + }) + .unwrap_or_else(|| { + to_conversation_child_summary_dto(summarize_conversation_child_ref(&block.child_ref)) + }); -impl ProjectionSource { - fn is_durable(self) -> bool { - matches!(self, Self::Durable) + ConversationChildHandoffBlockDto { + id: block.id.clone(), + handoff_kind: match block.handoff_kind { + ConversationChildHandoffKind::Delegated => ConversationChildHandoffKindDto::Delegated, + ConversationChildHandoffKind::Progress => ConversationChildHandoffKindDto::Progress, + ConversationChildHandoffKind::Returned => ConversationChildHandoffKindDto::Returned, + }, + child, + message: block.message.clone(), } } -fn block_id(block: &TerminalBlockDto) -> &str { - match block { - TerminalBlockDto::User(block) => &block.id, - TerminalBlockDto::Assistant(block) => &block.id, - TerminalBlockDto::Thinking(block) => &block.id, - TerminalBlockDto::ToolCall(block) => &block.id, - TerminalBlockDto::ToolStream(block) => &block.id, - TerminalBlockDto::Error(block) => &block.id, - TerminalBlockDto::SystemNote(block) => &block.id, - TerminalBlockDto::ChildHandoff(block) => &block.id, - } +fn child_summary_lookup( + summaries: &[TerminalChildSummaryFacts], +) -> HashMap<String, ConversationChildSummaryDto> { + child_summary_summary_lookup( + &summaries + .iter() + .map(summarize_conversation_child_summary) + .collect::<Vec<_>>(), + ) } -fn tool_result_summary(result: &ToolExecutionResult) -> String { - if result.ok { - if !result.output.trim().is_empty() { - truncate_terminal_summary(&result.output) - } else { - format!("{} completed", result.tool_name) +pub(crate) fn child_summary_summary_lookup( + summaries: &[ConversationChildSummarySummary], +) -> HashMap<String, ConversationChildSummaryDto> { + let mut lookup = HashMap::new(); + for summary in summaries { + let dto = to_conversation_child_summary_dto(summary.clone()); + lookup.insert(summary.child_session_id.clone(), dto.clone()); + if let Some(child_ref) = &dto.child_ref { + lookup.insert(child_ref.open_session_id.clone(), dto.clone()); + lookup.insert(child_ref.session_id.clone(), dto.clone()); } - } else if let Some(error) = &result.error { - truncate_terminal_summary(error) - } else if !result.output.trim().is_empty() { - truncate_terminal_summary(&result.output) - } else { - format!("{} failed", result.tool_name) } + lookup } -fn classify_transcript_error(message: &str) -> TerminalTranscriptErrorCodeDto { - let lower = message.to_lowercase(); - if lower.contains("context window") || lower.contains("token limit") { - TerminalTranscriptErrorCodeDto::ContextWindowExceeded - } else if lower.contains("rate limit") { - TerminalTranscriptErrorCodeDto::RateLimit - } else if lower.contains("tool") { - TerminalTranscriptErrorCodeDto::ToolFatal - } else { - TerminalTranscriptErrorCodeDto::ProviderError +fn to_conversation_child_summary_dto( + summary: ConversationChildSummarySummary, +) -> ConversationChildSummaryDto { + ConversationChildSummaryDto { + child_session_id: summary.child_session_id, + child_agent_id: summary.child_agent_id, + title: summary.title, + lifecycle: summary.lifecycle, + latest_output_summary: summary.latest_output_summary, + child_ref: summary.child_ref.map(to_child_ref_dto), } } -#[cfg(test)] -mod tests { - use astrcode_application::{ - ComposerOptionKind, SessionReplay, SessionTranscriptSnapshot, TerminalChildSummaryFacts, - TerminalControlFacts, TerminalFacts, TerminalRehydrateFacts, TerminalRehydrateReason, - TerminalSlashAction, TerminalSlashCandidateFacts, TerminalStreamReplayFacts, - }; - use astrcode_core::{ - AgentEventContext, AgentLifecycleStatus, ChildSessionLineageKind, ChildSessionNode, - ChildSessionNotification, ChildSessionNotificationKind, ChildSessionStatusSource, - CompactTrigger, ParentDelivery, ParentDeliveryOrigin, ParentDeliveryPayload, - ParentDeliveryTerminalSemantics, Phase, ProgressParentDeliveryPayload, SessionEventRecord, - ToolExecutionResult, ToolOutputStream, - }; - use serde_json::json; - use tokio::sync::broadcast; - - use super::{ - AgentEvent, TerminalDeltaProjector, TerminalTranscriptErrorCodeDto, - classify_transcript_error, project_terminal_control_delta, - project_terminal_rehydrate_banner, project_terminal_snapshot, - project_terminal_stream_replay, - }; - - #[test] - fn project_terminal_snapshot_freezes_terminal_block_mapping() { - let snapshot = project_terminal_snapshot(&sample_terminal_facts()); - - assert_eq!( - serde_json::to_value(snapshot).expect("snapshot should encode"), - json!({ - "sessionId": "session-root", - "sessionTitle": "Terminal session", - "cursor": "1.11", - "phase": "streaming", - "control": { - "phase": "streaming", - "canSubmitPrompt": false, - "canRequestCompact": true, - "compactPending": false, - "activeTurnId": "turn-1" - }, - "blocks": [ - { - "kind": "user", - "id": "turn:turn-1:user", - "turnId": "turn-1", - "markdown": "实现 terminal v1" - }, - { - "kind": "thinking", - "id": "turn:turn-1:thinking", - "turnId": "turn-1", - "status": "complete", - "markdown": "先整理协议" - }, - { - "kind": "assistant", - "id": "turn:turn-1:assistant", - "turnId": "turn-1", - "status": "complete", - "markdown": "正在实现 terminal v1。" - }, - { - "kind": "tool_call", - "id": "tool:call-1:call", - "turnId": "turn-1", - "toolCallId": "call-1", - "toolName": "shell_command", - "status": "complete", - "summary": "read files" - }, - { - "kind": "tool_stream", - "id": "tool:call-1:stdout", - "parentToolCallId": "call-1", - "stream": "stdout", - "status": "complete", - "content": "read files" - }, - { - "kind": "system_note", - "id": "system:compact:turn-1", - "noteKind": "compact", - "markdown": "已压缩上下文" - }, - { - "kind": "child_handoff", - "id": "child:child-note-1", - "handoffKind": "returned", - "child": child_summary_json("子任务已完成"), - "message": "子任务已完成" - }, - { - "kind": "error", - "id": "turn:turn-1:error", - "turnId": "turn-1", - "code": "rate_limit", - "message": "provider rate limit on this turn" - } - ], - "childSummaries": [child_summary_json("子任务已完成")], - "slashCandidates": [ - { - "id": "slash-compact", - "title": "/compact", - "description": "压缩当前会话", - "keywords": ["compact", "summary"], - "actionKind": "execute_command", - "actionValue": "/compact" - }, - { - "id": "slash-skill-review", - "title": "Review skill", - "description": "插入 review skill", - "keywords": ["skill", "review"], - "actionKind": "insert_text", - "actionValue": "/skill review" - } - ] - }) - ); - } - - #[test] - fn project_terminal_stream_replay_freezes_patch_and_completion_semantics() { - let deltas = project_terminal_stream_replay(&sample_stream_replay_facts(), Some("1.2")); - - assert_eq!( - serde_json::to_value(deltas).expect("stream deltas should encode"), - json!([ - { - "sessionId": "session-root", - "cursor": "1.3", - "kind": "patch_block", - "blockId": "turn:turn-1:thinking", - "patch": { - "kind": "append_markdown", - "markdown": "整理" - } - }, - { - "sessionId": "session-root", - "cursor": "1.4", - "kind": "append_block", - "block": { - "kind": "assistant", - "id": "turn:turn-1:assistant", - "turnId": "turn-1", - "status": "streaming", - "markdown": "执行中" - } - }, - { - "sessionId": "session-root", - "cursor": "1.5", - "kind": "append_block", - "block": { - "kind": "tool_call", - "id": "tool:call-1:call", - "turnId": "turn-1", - "toolCallId": "call-1", - "toolName": "shell_command", - "status": "streaming" - } - }, - { - "sessionId": "session-root", - "cursor": "1.6", - "kind": "append_block", - "block": { - "kind": "tool_stream", - "id": "tool:call-1:stdout", - "parentToolCallId": "call-1", - "stream": "stdout", - "status": "streaming", - "content": "line 1\n" - } - }, - { - "sessionId": "session-root", - "cursor": "1.7", - "kind": "patch_block", - "blockId": "tool:call-1:call", - "patch": { - "kind": "replace_summary", - "summary": "line 1" - } - }, - { - "sessionId": "session-root", - "cursor": "1.7", - "kind": "complete_block", - "blockId": "tool:call-1:call", - "status": "complete" - }, - { - "sessionId": "session-root", - "cursor": "1.7", - "kind": "complete_block", - "blockId": "tool:call-1:stdout", - "status": "complete" - }, - { - "sessionId": "session-root", - "cursor": "1.8", - "kind": "append_block", - "block": { - "kind": "child_handoff", - "id": "child:child-note-2", - "handoffKind": "progress", - "child": child_summary_json("子任务进行中"), - "message": "子任务进行中" - } - }, - { - "sessionId": "session-root", - "cursor": "1.9", - "kind": "append_block", - "block": { - "kind": "error", - "id": "turn:turn-1:error", - "turnId": "turn-1", - "code": "tool_fatal", - "message": "tool fatal: shell exited" - } - }, - { - "sessionId": "session-root", - "cursor": "1.10", - "kind": "complete_block", - "blockId": "turn:turn-1:thinking", - "status": "complete" - }, - { - "sessionId": "session-root", - "cursor": "1.10", - "kind": "complete_block", - "blockId": "turn:turn-1:assistant", - "status": "complete" - } - ]) - ); - } - - #[test] - fn finalize_assistant_block_emits_replace_markdown_when_final_content_diverges() { - let mut projector = TerminalDeltaProjector::default(); - let thinking_delta = record( - "1.1", - AgentEvent::ThinkingDelta { - turn_id: "turn-1".to_string(), - agent: sample_agent_context(), - delta: "旧前缀".to_string(), - }, - ); - projector.seed(&[thinking_delta]); - - let assistant_message = record( - "1.2", - AgentEvent::AssistantMessage { - turn_id: "turn-1".to_string(), - agent: sample_agent_context(), - content: "最终回复".to_string(), - reasoning_content: Some("全新推理".to_string()), - }, - ); - - assert_eq!( - serde_json::to_value(projector.project_record(&assistant_message)) - .expect("deltas should encode"), - json!([ - { - "kind": "patch_block", - "blockId": "turn:turn-1:thinking", - "patch": { - "kind": "replace_markdown", - "markdown": "全新推理" - } - }, - { - "kind": "complete_block", - "blockId": "turn:turn-1:thinking", - "status": "complete" - }, - { - "kind": "append_block", - "block": { - "kind": "assistant", - "id": "turn:turn-1:assistant", - "turnId": "turn-1", - "status": "streaming", - "markdown": "最终回复" - } - }, - { - "kind": "complete_block", - "blockId": "turn:turn-1:assistant", - "status": "complete" - } - ]) - ); - } - - #[test] - fn live_events_project_streaming_reasoning_and_assistant_blocks() { - let mut projector = TerminalDeltaProjector::default(); - let agent = sample_agent_context(); - - assert_eq!( - serde_json::to_value(projector.project_live_event(&AgentEvent::ThinkingDelta { - turn_id: "turn-1".to_string(), - agent: agent.clone(), - delta: "先".to_string(), - })) - .expect("thinking live deltas should encode"), - json!([ - { - "kind": "append_block", - "block": { - "kind": "thinking", - "id": "turn:turn-1:thinking", - "turnId": "turn-1", - "status": "streaming", - "markdown": "先" - } - } - ]) - ); - - assert_eq!( - serde_json::to_value(projector.project_live_event(&AgentEvent::ThinkingDelta { - turn_id: "turn-1".to_string(), - agent: agent.clone(), - delta: "整理协议".to_string(), - })) - .expect("thinking append deltas should encode"), - json!([ - { - "kind": "patch_block", - "blockId": "turn:turn-1:thinking", - "patch": { - "kind": "append_markdown", - "markdown": "整理协议" - } - } - ]) - ); - - assert_eq!( - serde_json::to_value(projector.project_live_event(&AgentEvent::ModelDelta { - turn_id: "turn-1".to_string(), - agent, - delta: "正在输出".to_string(), - })) - .expect("assistant live deltas should encode"), - json!([ - { - "kind": "append_block", - "block": { - "kind": "assistant", - "id": "turn:turn-1:assistant", - "turnId": "turn-1", - "status": "streaming", - "markdown": "正在输出" - } - } - ]) - ); - } - - #[test] - fn live_tool_streams_render_immediately_and_durable_replay_does_not_duplicate_chunks() { - let mut projector = TerminalDeltaProjector::default(); - let agent = sample_agent_context(); - - assert_eq!( - serde_json::to_value(projector.project_live_event(&AgentEvent::ToolCallStart { - turn_id: "turn-1".to_string(), - agent: agent.clone(), - tool_call_id: "call-1".to_string(), - tool_name: "web".to_string(), - input: serde_json::json!({}), - })) - .expect("tool call start should encode"), - json!([ - { - "kind": "append_block", - "block": { - "kind": "tool_call", - "id": "tool:call-1:call", - "turnId": "turn-1", - "toolCallId": "call-1", - "toolName": "web", - "status": "streaming" - } - } - ]) - ); - - assert_eq!( - serde_json::to_value(projector.project_live_event(&AgentEvent::ToolCallDelta { - turn_id: "turn-1".to_string(), - agent: agent.clone(), - tool_call_id: "call-1".to_string(), - tool_name: "web".to_string(), - stream: ToolOutputStream::Stdout, - delta: "first chunk\n".to_string(), - })) - .expect("tool stream delta should encode"), - json!([ - { - "kind": "append_block", - "block": { - "kind": "tool_stream", - "id": "tool:call-1:stdout", - "parentToolCallId": "call-1", - "stream": "stdout", - "status": "streaming", - "content": "first chunk\n" - } - } - ]) - ); - - assert_eq!( - serde_json::to_value(projector.project_live_event(&AgentEvent::ToolCallResult { - turn_id: "turn-1".to_string(), - agent: sample_agent_context(), - result: ToolExecutionResult { - tool_call_id: "call-1".to_string(), - tool_name: "web".to_string(), - ok: true, - output: "first chunk\n".to_string(), - error: None, - metadata: None, - duration_ms: 0, - truncated: false, - }, - })) - .expect("live tool result should encode"), - json!([ - { - "kind": "patch_block", - "blockId": "tool:call-1:call", - "patch": { - "kind": "replace_summary", - "summary": "first chunk" - } - }, - { - "kind": "complete_block", - "blockId": "tool:call-1:call", - "status": "complete" - }, - { - "kind": "complete_block", - "blockId": "tool:call-1:stdout", - "status": "complete" - } - ]) - ); - - assert_eq!( - serde_json::to_value(projector.project_record(&record( - "1.1", - AgentEvent::ToolCallStart { - turn_id: "turn-1".to_string(), - agent: sample_agent_context(), - tool_call_id: "call-1".to_string(), - tool_name: "web".to_string(), - input: serde_json::json!({}), - } - ))) - .expect("durable tool call start should encode"), - json!([]) - ); - - assert_eq!( - serde_json::to_value(projector.project_record(&record( - "1.2", - AgentEvent::ToolCallDelta { - turn_id: "turn-1".to_string(), - agent: sample_agent_context(), - tool_call_id: "call-1".to_string(), - tool_name: "web".to_string(), - stream: ToolOutputStream::Stdout, - delta: "first chunk\n".to_string(), - } - ))) - .expect("durable delta after live completion should still skip duplicated chunk"), - json!([]) - ); - - assert_eq!( - serde_json::to_value(projector.project_record(&record( - "1.3", - AgentEvent::ToolCallResult { - turn_id: "turn-1".to_string(), - agent: sample_agent_context(), - result: ToolExecutionResult { - tool_call_id: "call-1".to_string(), - tool_name: "web".to_string(), - ok: true, - output: "first chunk\n".to_string(), - error: None, - metadata: None, - duration_ms: 0, - truncated: false, - }, - } - ))) - .expect("durable result should collapse to no-op after matching live completion"), - json!([]) - ); - } - - #[test] - fn classify_transcript_error_covers_all_supported_buckets() { - assert_eq!( - classify_transcript_error("context window exceeded"), - TerminalTranscriptErrorCodeDto::ContextWindowExceeded - ); - assert_eq!( - classify_transcript_error("provider rate limit hit"), - TerminalTranscriptErrorCodeDto::RateLimit - ); - assert_eq!( - classify_transcript_error("tool process exited with failure"), - TerminalTranscriptErrorCodeDto::ToolFatal - ); - assert_eq!( - classify_transcript_error("unexpected provider response"), - TerminalTranscriptErrorCodeDto::ProviderError - ); - } - - #[test] - fn project_terminal_control_and_rehydrate_errors_use_terminal_contract() { - let control_delta = project_terminal_control_delta(&TerminalControlFacts { - phase: Phase::Idle, - active_turn_id: None, - manual_compact_pending: true, - }); - assert_eq!( - serde_json::to_value(control_delta).expect("control delta should encode"), - json!({ - "kind": "update_control_state", - "control": { - "phase": "idle", - "canSubmitPrompt": true, - "canRequestCompact": false, - "compactPending": true - } - }) - ); - - let banner = project_terminal_rehydrate_banner(&TerminalRehydrateFacts { - session_id: "session-root".to_string(), - requested_cursor: "1.2".to_string(), - latest_cursor: Some("1.10".to_string()), - reason: TerminalRehydrateReason::CursorExpired, - }); - assert_eq!( - serde_json::to_value(banner).expect("banner should encode"), - json!({ - "error": { - "code": "cursor_expired", - "message": "cursor '1.2' is no longer valid for session 'session-root'", - "rehydrateRequired": true, - "details": { - "requestedCursor": "1.2", - "latestCursor": "1.10", - "reason": "CursorExpired" - } - } - }) - ); - } - - fn sample_terminal_facts() -> TerminalFacts { - TerminalFacts { - active_session_id: "session-root".to_string(), - session_title: "Terminal session".to_string(), - transcript: SessionTranscriptSnapshot { - records: vec![ - record( - "1.1", - AgentEvent::UserMessage { - turn_id: "turn-1".to_string(), - agent: sample_agent_context(), - content: "实现 terminal v1".to_string(), - }, - ), - record( - "1.2", - AgentEvent::ThinkingDelta { - turn_id: "turn-1".to_string(), - agent: sample_agent_context(), - delta: "先整理协议".to_string(), - }, - ), - record( - "1.3", - AgentEvent::ModelDelta { - turn_id: "turn-1".to_string(), - agent: sample_agent_context(), - delta: "正在实现".to_string(), - }, - ), - record( - "1.4", - AgentEvent::AssistantMessage { - turn_id: "turn-1".to_string(), - agent: sample_agent_context(), - content: "正在实现 terminal v1。".to_string(), - reasoning_content: Some("先整理协议".to_string()), - }, - ), - record( - "1.5", - AgentEvent::ToolCallStart { - turn_id: "turn-1".to_string(), - agent: sample_agent_context(), - tool_call_id: "call-1".to_string(), - tool_name: "shell_command".to_string(), - input: json!({ "command": "rg terminal" }), - }, - ), - record( - "1.6", - AgentEvent::ToolCallDelta { - turn_id: "turn-1".to_string(), - agent: sample_agent_context(), - tool_call_id: "call-1".to_string(), - tool_name: "shell_command".to_string(), - stream: ToolOutputStream::Stdout, - delta: "read files".to_string(), - }, - ), - record( - "1.7", - AgentEvent::ToolCallResult { - turn_id: "turn-1".to_string(), - agent: sample_agent_context(), - result: ToolExecutionResult { - tool_call_id: "call-1".to_string(), - tool_name: "shell_command".to_string(), - ok: true, - output: "read files".to_string(), - error: None, - metadata: None, - duration_ms: 12, - truncated: false, - }, - }, - ), - record( - "1.8", - AgentEvent::CompactApplied { - turn_id: Some("turn-1".to_string()), - agent: sample_agent_context(), - trigger: CompactTrigger::Manual, - summary: "已压缩上下文".to_string(), - preserved_recent_turns: 4, - }, - ), - record( - "1.9", - AgentEvent::ChildSessionNotification { - turn_id: Some("turn-1".to_string()), - agent: sample_agent_context(), - notification: sample_child_notification( - "child-note-1", - ChildSessionNotificationKind::Delivered, - AgentLifecycleStatus::Idle, - "子任务已完成", - ), +fn to_conversation_control_state_dto( + summary: ConversationControlSummary, +) -> ConversationControlStateDto { + ConversationControlStateDto { + phase: summary.phase, + can_submit_prompt: summary.can_submit_prompt, + can_request_compact: summary.can_request_compact, + compact_pending: summary.compact_pending, + compacting: summary.compacting, + current_mode_id: summary.current_mode_id, + active_turn_id: summary.active_turn_id, + last_compact_meta: summary + .last_compact_meta + .map(|meta| ConversationLastCompactMetaDto { + trigger: meta.trigger, + meta: meta.meta, + }), + active_plan: summary.active_plan.map(to_plan_reference_dto), + active_tasks: summary.active_tasks.map(|tasks| { + tasks + .into_iter() + .map(|task| ConversationTaskItemDto { + content: task.content, + status: match task.status { + astrcode_core::ExecutionTaskStatus::Pending => { + ConversationTaskStatusDto::Pending }, - ), - record( - "1.10", - AgentEvent::Error { - turn_id: Some("turn-1".to_string()), - agent: sample_agent_context(), - code: "provider_error".to_string(), - message: "provider rate limit on this turn".to_string(), + astrcode_core::ExecutionTaskStatus::InProgress => { + ConversationTaskStatusDto::InProgress }, - ), - record( - "1.11", - AgentEvent::TurnDone { - turn_id: "turn-1".to_string(), - agent: sample_agent_context(), + astrcode_core::ExecutionTaskStatus::Completed => { + ConversationTaskStatusDto::Completed }, - ), - ], - cursor: Some("1.11".to_string()), - phase: Phase::Streaming, - }, - control: TerminalControlFacts { - phase: Phase::Streaming, - active_turn_id: Some("turn-1".to_string()), - manual_compact_pending: false, - }, - child_summaries: vec![sample_child_summary_facts("子任务已完成")], - slash_candidates: vec![ - TerminalSlashCandidateFacts { - kind: ComposerOptionKind::Command, - id: "slash-compact".to_string(), - title: "/compact".to_string(), - description: "压缩当前会话".to_string(), - keywords: vec!["compact".to_string(), "summary".to_string()], - badges: Vec::new(), - action: TerminalSlashAction::RequestCompact, - }, - TerminalSlashCandidateFacts { - kind: ComposerOptionKind::Skill, - id: "slash-skill-review".to_string(), - title: "Review skill".to_string(), - description: "插入 review skill".to_string(), - keywords: vec!["skill".to_string(), "review".to_string()], - badges: Vec::new(), - action: TerminalSlashAction::InsertText { - text: "/skill review".to_string(), }, - }, - ], - } + active_form: task.active_form, + }) + .collect() + }), } +} - fn sample_stream_replay_facts() -> TerminalStreamReplayFacts { - let (_, receiver) = broadcast::channel(8); - let (_, live_receiver) = broadcast::channel(8); +fn to_plan_reference_dto( + plan: astrcode_application::terminal::PlanReferenceFacts, +) -> ConversationPlanReferenceDto { + ConversationPlanReferenceDto { + slug: plan.slug, + path: plan.path, + status: plan.status, + title: plan.title, + } +} - TerminalStreamReplayFacts { - active_session_id: "session-root".to_string(), - seed_records: vec![ - record( - "1.1", - AgentEvent::UserMessage { - turn_id: "turn-1".to_string(), - agent: sample_agent_context(), - content: "实现 terminal v1".to_string(), - }, - ), - record( - "1.2", - AgentEvent::ThinkingDelta { - turn_id: "turn-1".to_string(), - agent: sample_agent_context(), - delta: "先".to_string(), - }, - ), - ], - replay: SessionReplay { - history: vec![ - record( - "1.3", - AgentEvent::ThinkingDelta { - turn_id: "turn-1".to_string(), - agent: sample_agent_context(), - delta: "整理".to_string(), - }, - ), - record( - "1.4", - AgentEvent::ModelDelta { - turn_id: "turn-1".to_string(), - agent: sample_agent_context(), - delta: "执行中".to_string(), - }, - ), - record( - "1.5", - AgentEvent::ToolCallStart { - turn_id: "turn-1".to_string(), - agent: sample_agent_context(), - tool_call_id: "call-1".to_string(), - tool_name: "shell_command".to_string(), - input: json!({ "command": "pwd" }), - }, - ), - record( - "1.6", - AgentEvent::ToolCallDelta { - turn_id: "turn-1".to_string(), - agent: sample_agent_context(), - tool_call_id: "call-1".to_string(), - tool_name: "shell_command".to_string(), - stream: ToolOutputStream::Stdout, - delta: "line 1\n".to_string(), - }, - ), - record( - "1.7", - AgentEvent::ToolCallResult { - turn_id: "turn-1".to_string(), - agent: sample_agent_context(), - result: ToolExecutionResult { - tool_call_id: "call-1".to_string(), - tool_name: "shell_command".to_string(), - ok: true, - output: "line 1\n".to_string(), - error: None, - metadata: None, - duration_ms: 8, - truncated: false, - }, - }, - ), - record( - "1.8", - AgentEvent::ChildSessionNotification { - turn_id: Some("turn-1".to_string()), - agent: sample_agent_context(), - notification: sample_child_notification( - "child-note-2", - ChildSessionNotificationKind::Waiting, - AgentLifecycleStatus::Running, - "子任务进行中", - ), - }, - ), - record( - "1.9", - AgentEvent::Error { - turn_id: Some("turn-1".to_string()), - agent: sample_agent_context(), - code: "tool_error".to_string(), - message: "tool fatal: shell exited".to_string(), - }, - ), - record( - "1.10", - AgentEvent::TurnDone { - turn_id: "turn-1".to_string(), - agent: sample_agent_context(), - }, - ), - ], - receiver, - live_receiver, +fn to_conversation_slash_candidate_dto( + summary: ConversationSlashCandidateSummary, +) -> ConversationSlashCandidateDto { + ConversationSlashCandidateDto { + id: summary.id, + title: summary.title, + description: summary.description, + keywords: summary.keywords, + action_kind: match summary.action_kind { + ConversationSlashActionSummary::InsertText => { + ConversationSlashActionKindDto::InsertText }, - control: TerminalControlFacts { - phase: Phase::Streaming, - active_turn_id: Some("turn-1".to_string()), - manual_compact_pending: false, + ConversationSlashActionSummary::ExecuteCommand => { + ConversationSlashActionKindDto::ExecuteCommand }, - child_summaries: vec![sample_child_summary_facts("子任务进行中")], - slash_candidates: vec![ - TerminalSlashCandidateFacts { - kind: ComposerOptionKind::Command, - id: "slash-compact".to_string(), - title: "/compact".to_string(), - description: "压缩当前会话".to_string(), - keywords: vec!["compact".to_string(), "summary".to_string()], - badges: Vec::new(), - action: TerminalSlashAction::RequestCompact, - }, - TerminalSlashCandidateFacts { - kind: ComposerOptionKind::Skill, - id: "slash-skill-review".to_string(), - title: "Review skill".to_string(), - description: "插入 review skill".to_string(), - keywords: vec!["skill".to_string(), "review".to_string()], - badges: Vec::new(), - action: TerminalSlashAction::InsertText { - text: "/skill review".to_string(), - }, - }, - ], - } - } - - fn sample_child_summary_facts(recent_output: &str) -> TerminalChildSummaryFacts { - TerminalChildSummaryFacts { - node: sample_child_node(), - phase: Phase::Streaming, - title: Some("Repo inspector".to_string()), - display_name: Some("repo-inspector".to_string()), - recent_output: Some(recent_output.to_string()), - } - } - - fn sample_child_node() -> ChildSessionNode { - ChildSessionNode { - agent_id: "agent-child".to_string(), - session_id: "session-root".to_string(), - child_session_id: "session-child".to_string(), - sub_run_id: "subrun-child".to_string(), - parent_session_id: "session-root".to_string(), - parent_agent_id: Some("agent-root".to_string()), - parent_sub_run_id: Some("subrun-root".to_string()), - parent_turn_id: "turn-1".to_string(), - lineage_kind: ChildSessionLineageKind::Spawn, - status: AgentLifecycleStatus::Running, - status_source: ChildSessionStatusSource::Durable, - created_by_tool_call_id: Some("call-1".to_string()), - lineage_snapshot: None, - } - } - - fn sample_child_notification( - notification_id: &str, - kind: ChildSessionNotificationKind, - status: AgentLifecycleStatus, - message: &str, - ) -> ChildSessionNotification { - ChildSessionNotification { - notification_id: notification_id.to_string(), - child_ref: sample_child_node().child_ref(), - kind, - status, - source_tool_call_id: Some("call-1".to_string()), - delivery: Some(ParentDelivery { - idempotency_key: notification_id.to_string(), - origin: ParentDeliveryOrigin::Explicit, - terminal_semantics: match kind { - ChildSessionNotificationKind::Started - | ChildSessionNotificationKind::ProgressSummary - | ChildSessionNotificationKind::Waiting - | ChildSessionNotificationKind::Resumed => { - ParentDeliveryTerminalSemantics::NonTerminal - }, - ChildSessionNotificationKind::Delivered - | ChildSessionNotificationKind::Closed - | ChildSessionNotificationKind::Failed => { - ParentDeliveryTerminalSemantics::Terminal - }, - }, - source_turn_id: Some("turn-1".to_string()), - payload: ParentDeliveryPayload::Progress(ProgressParentDeliveryPayload { - message: message.to_string(), - }), - }), - } + }, + action_value: summary.action_value, } +} - fn sample_agent_context() -> AgentEventContext { - AgentEventContext::root_execution("agent-root", "default") +fn to_child_ref_dto(child_ref: ChildAgentRef) -> ChildAgentRefDto { + ChildAgentRefDto { + agent_id: child_ref.agent_id().to_string(), + session_id: child_ref.session_id().to_string(), + sub_run_id: child_ref.sub_run_id().to_string(), + parent_agent_id: child_ref.parent_agent_id().map(|id| id.to_string()), + parent_sub_run_id: child_ref.parent_sub_run_id().map(|id| id.to_string()), + lineage_kind: child_ref.lineage_kind, + status: child_ref.status, + open_session_id: child_ref.open_session_id.to_string(), } +} - fn record(event_id: &str, event: AgentEvent) -> SessionEventRecord { - SessionEventRecord { - event_id: event_id.to_string(), - event, - } +fn to_block_status_dto(status: ConversationBlockStatus) -> ConversationBlockStatusDto { + match status { + ConversationBlockStatus::Streaming => ConversationBlockStatusDto::Streaming, + ConversationBlockStatus::Complete => ConversationBlockStatusDto::Complete, + ConversationBlockStatus::Failed => ConversationBlockStatusDto::Failed, + ConversationBlockStatus::Cancelled => ConversationBlockStatusDto::Cancelled, } +} - fn child_summary_json(latest_output_summary: &str) -> serde_json::Value { - json!({ - "childSessionId": "session-child", - "childAgentId": "agent-child", - "title": "Repo inspector", - "lifecycle": "running", - "latestOutputSummary": latest_output_summary, - "childRef": { - "agentId": "agent-child", - "sessionId": "session-root", - "subRunId": "subrun-child", - "parentAgentId": "agent-root", - "parentSubRunId": "subrun-root", - "lineageKind": "spawn", - "status": "running", - "openSessionId": "session-child" - } - }) +fn to_error_code_dto(code: ConversationTranscriptErrorKind) -> ConversationTranscriptErrorCodeDto { + match code { + ConversationTranscriptErrorKind::ProviderError => { + ConversationTranscriptErrorCodeDto::ProviderError + }, + ConversationTranscriptErrorKind::ContextWindowExceeded => { + ConversationTranscriptErrorCodeDto::ContextWindowExceeded + }, + ConversationTranscriptErrorKind::ToolFatal => ConversationTranscriptErrorCodeDto::ToolFatal, + ConversationTranscriptErrorKind::RateLimit => ConversationTranscriptErrorCodeDto::RateLimit, } } diff --git a/crates/server/src/logging.rs b/crates/server/src/logging.rs index c6cc42f3..a8ea776f 100644 --- a/crates/server/src/logging.rs +++ b/crates/server/src/logging.rs @@ -385,20 +385,20 @@ mod tests { #[test] fn cleanup_removes_oldest_archives_over_limit() { - let dir = tempfile::tempdir().unwrap(); + let dir = tempfile::tempdir().expect("tempdir should be created"); // 创建超过上限的归档文件 for i in 0..12 { let name = format!("server-2026-04-12-{i:02}0000-000-pid12345.log"); - File::create(dir.path().join(&name)).unwrap(); + File::create(dir.path().join(&name)).expect("archive file should be created"); } - File::create(dir.path().join("server-current.log")).unwrap(); - File::create(dir.path().join("server-latest.txt")).unwrap(); - File::create(dir.path().join("other.txt")).unwrap(); + File::create(dir.path().join("server-current.log")).expect("create server-current.log"); + File::create(dir.path().join("server-latest.txt")).expect("create server-latest.txt"); + File::create(dir.path().join("other.txt")).expect("create other.txt"); cleanup_old_archives(dir.path()); let archive_count = fs::read_dir(dir.path()) - .unwrap() + .expect("read_dir should succeed") .filter(|e| { e.as_ref() .ok() @@ -412,14 +412,14 @@ mod tests { #[test] fn cleanup_ignores_current_latest_and_unrelated_files() { - let dir = tempfile::tempdir().unwrap(); + let dir = tempfile::tempdir().expect("tempdir should be created"); for i in 0..12 { let name = format!("server-2026-04-12-{i:02}0000-000-pid12345.log"); - File::create(dir.path().join(&name)).unwrap(); + File::create(dir.path().join(&name)).expect("archive file should be created"); } - File::create(dir.path().join("server-current.log")).unwrap(); - File::create(dir.path().join("server-latest.txt")).unwrap(); - File::create(dir.path().join("other.txt")).unwrap(); + File::create(dir.path().join("server-current.log")).expect("create server-current.log"); + File::create(dir.path().join("server-latest.txt")).expect("create server-latest.txt"); + File::create(dir.path().join("other.txt")).expect("create other.txt"); cleanup_old_archives(dir.path()); @@ -430,18 +430,23 @@ mod tests { #[test] fn cleanup_does_nothing_when_under_limit() { - let dir = tempfile::tempdir().unwrap(); + let dir = tempfile::tempdir().expect("tempdir should be created"); for i in 0..5 { let name = format!("server-2026-04-12-{i:02}0000-000-pid12345.log"); - File::create(dir.path().join(&name)).unwrap(); + File::create(dir.path().join(&name)).expect("archive file should be created"); } cleanup_old_archives(dir.path()); - assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 5); + assert_eq!( + fs::read_dir(dir.path()) + .expect("read_dir should succeed") + .count(), + 5 + ); } #[test] fn cleanup_handles_missing_dir_gracefully() { - let dir = tempfile::tempdir().unwrap(); + let dir = tempfile::tempdir().expect("tempdir should be created"); let missing = dir.path().join("missing"); // 不应 panic cleanup_old_archives(&missing); @@ -449,7 +454,7 @@ mod tests { #[test] fn archive_file_retries_on_collision() { - let dir = tempfile::tempdir().unwrap(); + let dir = tempfile::tempdir().expect("tempdir should be created"); let pid = std::process::id(); let now = Local::now(); // 覆盖当前秒和接下来 3 秒的所有毫秒候选文件名, @@ -459,11 +464,12 @@ mod tests { let timestamp = t.format("%Y-%m-%d-%H%M%S"); for ms in 0..1000u32 { let name = format!("server-{timestamp}-{ms:03}-pid{pid}.log"); - File::create(dir.path().join(&name)).unwrap(); + File::create(dir.path().join(&name)).expect("archive file should be created"); } } // 所有首选候选都被占,create_archive_file 必须走 -N 后缀重试 - let (_file, retried_name) = create_archive_file(dir.path()).unwrap(); + let (_file, retried_name) = + create_archive_file(dir.path()).expect("archive file should be created after retry"); assert!( retried_name.contains("-1.log") || retried_name.contains("-2.log") @@ -474,13 +480,14 @@ mod tests { #[test] fn current_file_is_truncated_on_restart() { - let dir = tempfile::tempdir().unwrap(); + let dir = tempfile::tempdir().expect("tempdir should be created"); // 先写入旧内容 - fs::write(dir.path().join(CURRENT_LOG_NAME), "old content").unwrap(); + fs::write(dir.path().join(CURRENT_LOG_NAME), "old content").expect("write old content"); // 重新创建应截断 - let file = create_current_file(dir.path()).unwrap(); + let file = create_current_file(dir.path()).expect("create current file"); drop(file); - let content = fs::read_to_string(dir.path().join(CURRENT_LOG_NAME)).unwrap(); + let content = + fs::read_to_string(dir.path().join(CURRENT_LOG_NAME)).expect("read current file"); assert!(content.is_empty()); } } diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs index 3b374c7a..b67a9d51 100644 --- a/crates/server/src/main.rs +++ b/crates/server/src/main.rs @@ -18,9 +18,6 @@ windows_subsystem = "windows" )] -#[cfg(all(feature = "debug-workbench", not(debug_assertions)))] -compile_error!("feature `debug-workbench` 仅允许在 debug/dev 构建中启用"); - #[cfg(test)] #[path = "tests/agent_routes_tests.rs"] mod agent_routes_tests; @@ -56,8 +53,6 @@ use std::{net::SocketAddr, path::PathBuf, sync::Arc}; use anyhow::{Result as AnyhowResult, anyhow}; use astrcode_application::{App, AppGovernance, ApplicationError, AstrError}; -#[cfg(feature = "debug-workbench")] -use astrcode_debug_workbench::DebugWorkbenchService; use axum::{ Json, Router, http::StatusCode, @@ -92,9 +87,6 @@ pub(crate) struct AppState { app: Arc<App>, /// 新治理层(快照/shutdown/reload,替代旧 RuntimeGovernance) governance: Arc<AppGovernance>, - /// Debug Workbench 后端读模型。 - #[cfg(feature = "debug-workbench")] - debug_workbench: Arc<DebugWorkbenchService>, /// 认证会话管理器 auth_sessions: Arc<AuthSessionManager>, /// Bootstrap 阶段的认证(短期 token) @@ -115,9 +107,6 @@ pub(crate) struct FrontendBuild { dist_dir: PathBuf, /// index.html 内容(已注入 bootstrap token 脚本) index_html: Arc<String>, - /// debug.html 内容(已注入 bootstrap token 脚本) - #[cfg(feature = "debug-workbench")] - debug_html: Option<Arc<String>>, } /// 错误响应载荷。 @@ -230,11 +219,6 @@ async fn main() -> AnyhowResult<()> { let state = AppState { app: Arc::clone(&app_service), governance: Arc::clone(&runtime.governance), - #[cfg(feature = "debug-workbench")] - debug_workbench: Arc::new(DebugWorkbenchService::new( - Arc::clone(&app_service), - Arc::clone(&runtime.governance), - )), auth_sessions: Arc::new(AuthSessionManager::default()), bootstrap_auth: prepared_launch.bootstrap_auth, frontend_build: prepared_launch.frontend_build.clone(), diff --git a/crates/server/src/tests/agent_routes_tests.rs b/crates/server/src/tests/agent_routes_tests.rs index b9aa2676..b4f42a0e 100644 --- a/crates/server/src/tests/agent_routes_tests.rs +++ b/crates/server/src/tests/agent_routes_tests.rs @@ -306,7 +306,7 @@ async fn subagent_launch_uses_resolved_profile_and_inherits_parent_working_dir() .expect("subagent should launch"); let artifacts = &result - .handoff + .handoff() .as_ref() .expect("handoff should exist") .artifacts; @@ -491,7 +491,7 @@ async fn get_subrun_status_falls_back_to_durable_snapshot_with_resolved_limits() .expect("subagent should launch"); let child_agent_id = result - .handoff + .handoff() .as_ref() .expect("handoff should exist") .artifacts @@ -522,11 +522,6 @@ async fn get_subrun_status_falls_back_to_durable_snapshot_with_resolved_limits() let app = build_api_router().with_state(AppState { app: std::sync::Arc::clone(&reloaded_runtime.app), governance: std::sync::Arc::clone(&reloaded_runtime.governance), - #[cfg(feature = "debug-workbench")] - debug_workbench: std::sync::Arc::new(astrcode_debug_workbench::DebugWorkbenchService::new( - std::sync::Arc::clone(&reloaded_runtime.app), - std::sync::Arc::clone(&reloaded_runtime.governance), - )), auth_sessions, bootstrap_auth: BootstrapAuth::new( "browser-token".to_string(), diff --git a/crates/server/src/tests/config_routes_tests.rs b/crates/server/src/tests/config_routes_tests.rs index 52325bc6..1764eda9 100644 --- a/crates/server/src/tests/config_routes_tests.rs +++ b/crates/server/src/tests/config_routes_tests.rs @@ -4,10 +4,6 @@ use astrcode_core::{SessionId, StorageEventPayload}; use astrcode_protocol::http::{ CompactSessionResponse, ConfigReloadResponse, PromptAcceptedResponse, }; -#[cfg(feature = "debug-workbench")] -use astrcode_protocol::http::{ - RuntimeDebugOverviewDto, RuntimeDebugTimelineDto, SessionDebugAgentsDto, SessionDebugTraceDto, -}; use axum::{ body::{Body, to_bytes}, http::{Request, StatusCode}, @@ -46,61 +42,10 @@ async fn config_reload_returns_runtime_status_when_idle() { assert_eq!(payload.status.runtime_name, "astrcode-application"); } -#[cfg(feature = "debug-workbench")] #[tokio::test] -async fn debug_runtime_overview_route_returns_workbench_overview() { - let (state, _guard) = test_state(None).await; - let app = build_api_router().with_state(state.clone()); - - let response = app - .oneshot( - Request::builder() - .method("GET") - .uri("/api/debug/runtime/overview") - .header(AUTH_HEADER_NAME, "browser-token") - .body(Body::empty()) - .expect("request should be valid"), - ) - .await - .expect("response should be returned"); - - assert_eq!(response.status(), StatusCode::OK); - let payload: RuntimeDebugOverviewDto = json_body(response).await; - assert!(!payload.collected_at.is_empty()); -} - -#[cfg(feature = "debug-workbench")] -#[tokio::test] -async fn debug_runtime_timeline_route_returns_server_window_samples() { - let (state, _guard) = test_state(None).await; - let app = build_api_router().with_state(state.clone()); - let _ = state.debug_workbench.runtime_overview(); - - let response = app - .oneshot( - Request::builder() - .method("GET") - .uri("/api/debug/runtime/timeline") - .header(AUTH_HEADER_NAME, "browser-token") - .body(Body::empty()) - .expect("request should be valid"), - ) - .await - .expect("response should be returned"); - - assert_eq!(response.status(), StatusCode::OK); - let payload: RuntimeDebugTimelineDto = json_body(response).await; - assert!( - !payload.samples.is_empty(), - "timeline should contain at least the freshly recorded overview sample" - ); -} - -#[cfg(feature = "debug-workbench")] -#[tokio::test] -async fn debug_session_trace_route_is_scoped_to_requested_session() { +async fn config_reload_rejects_when_session_is_running() { let (state, _guard) = test_state(None).await; - let session_a = state + let session = state .app .create_session( tempfile::tempdir() @@ -111,27 +56,20 @@ async fn debug_session_trace_route_is_scoped_to_requested_session() { ) .await .expect("session should be created"); - let session_b = state - .app - .create_session( - tempfile::tempdir() - .expect("tempdir") - .path() - .display() - .to_string(), - ) + let session_state = state + ._runtime_handles + .session_runtime + .get_session_state(&session.session_id.clone().into()) .await - .expect("session should be created"); - let app = build_api_router().with_state(state.clone()); + .expect("session state should load"); + session_state.running.store(true, Ordering::SeqCst); + let app = build_api_router().with_state(state); let response = app .oneshot( Request::builder() - .method("GET") - .uri(format!( - "/api/debug/sessions/{}/trace", - session_a.session_id - )) + .method("POST") + .uri("/api/config/reload") .header(AUTH_HEADER_NAME, "browser-token") .body(Body::empty()) .expect("request should be valid"), @@ -139,17 +77,13 @@ async fn debug_session_trace_route_is_scoped_to_requested_session() { .await .expect("response should be returned"); - assert_eq!(response.status(), StatusCode::OK); - let payload: SessionDebugTraceDto = json_body(response).await; - assert_eq!(payload.session_id, session_a.session_id); - assert_ne!(payload.session_id, session_b.session_id); + assert_eq!(response.status(), StatusCode::CONFLICT); } -#[cfg(feature = "debug-workbench")] #[tokio::test] -async fn debug_session_agents_route_is_scoped_to_requested_session() { +async fn compact_route_defers_when_session_is_busy() { let (state, _guard) = test_state(None).await; - let session_a = state + let session = state .app .create_session( tempfile::tempdir() @@ -160,67 +94,56 @@ async fn debug_session_agents_route_is_scoped_to_requested_session() { ) .await .expect("session should be created"); - let session_b = state - .app - .create_session( - tempfile::tempdir() - .expect("tempdir") - .path() - .display() - .to_string(), - ) + let session_state = state + ._runtime_handles + .session_runtime + .get_session_state(&session.session_id.clone().into()) .await - .expect("session should be created"); - let app = build_api_router().with_state(state); + .expect("session state should load"); + session_state.running.store(true, Ordering::SeqCst); + let app = build_api_router().with_state(state.clone()); let response = app .oneshot( Request::builder() - .method("GET") - .uri(format!( - "/api/debug/sessions/{}/agents", - session_a.session_id - )) + .method("POST") + .uri(format!("/api/sessions/{}/compact", session.session_id)) .header(AUTH_HEADER_NAME, "browser-token") - .body(Body::empty()) + .header("content-type", "application/json") + .body(Body::from( + serde_json::json!({ + "control": { + "manualCompact": true + } + }) + .to_string(), + )) .expect("request should be valid"), ) .await .expect("response should be returned"); - assert_eq!(response.status(), StatusCode::OK); - let payload: SessionDebugAgentsDto = json_body(response).await; - assert_eq!(payload.session_id, session_a.session_id); - assert_ne!(payload.session_id, session_b.session_id); - assert_eq!( - payload.nodes.first().map(|node| node.session_id.as_str()), - Some(session_a.session_id.as_str()) - ); -} - -#[cfg(feature = "debug-workbench")] -#[tokio::test] -async fn debug_session_trace_route_returns_not_found_for_unknown_session() { - let (state, _guard) = test_state(None).await; - let app = build_api_router().with_state(state); - - let response = app - .oneshot( - Request::builder() - .method("GET") - .uri("/api/debug/sessions/unknown-session/trace") - .header(AUTH_HEADER_NAME, "browser-token") - .body(Body::empty()) - .expect("request should be valid"), - ) + assert_eq!(response.status(), StatusCode::ACCEPTED); + let payload: CompactSessionResponse = json_body(response).await; + assert!(payload.accepted); + assert!(payload.deferred); + let terminal_facts = state + .app + .terminal_snapshot_facts(&session.session_id) .await - .expect("response should be returned"); - - assert_eq!(response.status(), StatusCode::NOT_FOUND); + .expect("terminal facts should reflect pending compact"); + assert!(terminal_facts.control.manual_compact_pending); + assert!( + terminal_facts + .slash_candidates + .iter() + .all(|candidate| candidate.id != "compact"), + "pending compact should be observed through terminal discovery facts" + ); } #[tokio::test] -async fn config_reload_rejects_when_session_is_running() { +async fn prompt_route_roundtrips_accepted_execution_control() { let (state, _guard) = test_state(None).await; let session = state .app @@ -233,32 +156,40 @@ async fn config_reload_rejects_when_session_is_running() { ) .await .expect("session should be created"); - let session_state = state - ._runtime_handles - .session_runtime - .get_session_state(&session.session_id.clone().into()) - .await - .expect("session state should load"); - session_state.running.store(true, Ordering::SeqCst); let app = build_api_router().with_state(state); let response = app .oneshot( Request::builder() .method("POST") - .uri("/api/config/reload") + .uri(format!("/api/sessions/{}/prompts", session.session_id)) .header(AUTH_HEADER_NAME, "browser-token") - .body(Body::empty()) + .header("content-type", "application/json") + .body(Body::from( + serde_json::json!({ + "text": "hello", + "control": { + "maxSteps": 7 + } + }) + .to_string(), + )) .expect("request should be valid"), ) .await .expect("response should be returned"); - assert_eq!(response.status(), StatusCode::CONFLICT); + assert_eq!(response.status(), StatusCode::ACCEPTED); + let payload: PromptAcceptedResponse = json_body(response).await; + let accepted_control = payload + .accepted_control + .expect("accepted control should be returned"); + assert_eq!(accepted_control.max_steps, Some(7)); + assert_eq!(accepted_control.manual_compact, None); } #[tokio::test] -async fn compact_route_defers_when_session_is_busy() { +async fn prompt_route_accepts_structured_skill_invocation() { let (state, _guard) = test_state(None).await; let session = state .app @@ -271,26 +202,21 @@ async fn compact_route_defers_when_session_is_busy() { ) .await .expect("session should be created"); - let session_state = state - ._runtime_handles - .session_runtime - .get_session_state(&session.session_id.clone().into()) - .await - .expect("session state should load"); - session_state.running.store(true, Ordering::SeqCst); let app = build_api_router().with_state(state.clone()); let response = app .oneshot( Request::builder() .method("POST") - .uri(format!("/api/sessions/{}/compact", session.session_id)) + .uri(format!("/api/sessions/{}/prompts", session.session_id)) .header(AUTH_HEADER_NAME, "browser-token") .header("content-type", "application/json") .body(Body::from( serde_json::json!({ - "control": { - "manualCompact": true + "text": "提交当前修改", + "skillInvocation": { + "skillId": "git-commit", + "userPrompt": "提交当前修改" } }) .to_string(), @@ -301,26 +227,12 @@ async fn compact_route_defers_when_session_is_busy() { .expect("response should be returned"); assert_eq!(response.status(), StatusCode::ACCEPTED); - let payload: CompactSessionResponse = json_body(response).await; - assert!(payload.accepted); - assert!(payload.deferred); - let terminal_facts = state - .app - .terminal_snapshot_facts(&session.session_id) - .await - .expect("terminal facts should reflect pending compact"); - assert!(terminal_facts.control.manual_compact_pending); - assert!( - terminal_facts - .slash_candidates - .iter() - .all(|candidate| candidate.id != "compact"), - "pending compact should be observed through terminal discovery facts" - ); + let payload: PromptAcceptedResponse = json_body(response).await; + assert!(!payload.turn_id.is_empty()); } #[tokio::test] -async fn prompt_route_roundtrips_accepted_execution_control() { +async fn prompt_route_rejects_unknown_skill_invocation() { let (state, _guard) = test_state(None).await; let session = state .app @@ -344,9 +256,9 @@ async fn prompt_route_roundtrips_accepted_execution_control() { .header("content-type", "application/json") .body(Body::from( serde_json::json!({ - "text": "hello", - "control": { - "maxSteps": 7 + "text": "", + "skillInvocation": { + "skillId": "missing-skill" } }) .to_string(), @@ -356,13 +268,7 @@ async fn prompt_route_roundtrips_accepted_execution_control() { .await .expect("response should be returned"); - assert_eq!(response.status(), StatusCode::ACCEPTED); - let payload: PromptAcceptedResponse = json_body(response).await; - let accepted_control = payload - .accepted_control - .expect("accepted control should be returned"); - assert_eq!(accepted_control.max_steps, Some(7)); - assert_eq!(accepted_control.manual_compact, None); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); } #[tokio::test] diff --git a/crates/server/src/tests/session_contract_tests.rs b/crates/server/src/tests/session_contract_tests.rs index 12ac23cf..89ba22b8 100644 --- a/crates/server/src/tests/session_contract_tests.rs +++ b/crates/server/src/tests/session_contract_tests.rs @@ -1,7 +1,8 @@ use astrcode_core::{ - AgentEventContext, CancelToken, SpawnAgentParams, ToolContext, - agent::executor::SubAgentExecutor, + AgentEventContext, CancelToken, EventTranslator, SessionId, SpawnAgentParams, StorageEvent, + StorageEventPayload, ToolContext, UserMessageOrigin, agent::executor::SubAgentExecutor, }; +use astrcode_session_runtime::append_and_broadcast; use axum::{ body::{Body, to_bytes}, http::{Request, StatusCode}, @@ -13,6 +14,86 @@ use crate::{AUTH_HEADER_NAME, routes::build_api_router, test_support::test_state // Why: 这些契约测试是 API 接口稳定性的核心保障, // 防止 server 在重构后回退到隐式容错或启发式行为。 +async fn append_root_event(state: &crate::AppState, session_id: &str, event: StorageEvent) { + let session_state = state + ._runtime_handles + .session_runtime + .get_session_state(&SessionId::from(session_id.to_string())) + .await + .expect("session state should load"); + let mut translator = EventTranslator::new( + session_state + .current_phase() + .expect("session phase should be readable"), + ); + append_and_broadcast(&session_state, &event, &mut translator) + .await + .expect("event should persist"); +} + +async fn seed_completed_root_turn(state: &crate::AppState, session_id: &str, turn_id: &str) { + let agent = AgentEventContext::root_execution("root-agent", "test-profile"); + append_root_event( + state, + session_id, + StorageEvent { + turn_id: Some(turn_id.to_string()), + agent: agent.clone(), + payload: StorageEventPayload::UserMessage { + content: "hello".to_string(), + origin: UserMessageOrigin::User, + timestamp: chrono::Utc::now(), + }, + }, + ) + .await; + append_root_event( + state, + session_id, + StorageEvent { + turn_id: Some(turn_id.to_string()), + agent: agent.clone(), + payload: StorageEventPayload::AssistantFinal { + content: "world".to_string(), + reasoning_content: None, + reasoning_signature: None, + timestamp: Some(chrono::Utc::now()), + }, + }, + ) + .await; + append_root_event( + state, + session_id, + StorageEvent { + turn_id: Some(turn_id.to_string()), + agent, + payload: StorageEventPayload::TurnDone { + timestamp: chrono::Utc::now(), + reason: Some("completed".to_string()), + }, + }, + ) + .await; +} + +async fn seed_unfinished_root_turn(state: &crate::AppState, session_id: &str, turn_id: &str) { + append_root_event( + state, + session_id, + StorageEvent { + turn_id: Some(turn_id.to_string()), + agent: AgentEventContext::root_execution("root-agent", "test-profile"), + payload: StorageEventPayload::UserMessage { + content: "still running".to_string(), + origin: UserMessageOrigin::User, + timestamp: chrono::Utc::now(), + }, + }, + ) + .await; +} + async fn spawn_test_child_agent( state: &crate::AppState, session_id: &str, @@ -55,13 +136,13 @@ async fn spawn_test_child_agent( ) .await .expect("agent should launch") - .handoff + .handoff() .and_then(|handoff| { handoff .artifacts - .into_iter() + .iter() .find(|artifact| artifact.kind == "agent") - .map(|artifact| artifact.id) + .map(|artifact| artifact.id.clone()) }) .expect("spawned child should return agent artifact") } @@ -106,6 +187,172 @@ async fn submit_prompt_contract_returns_accepted_shape() { assert!(!payload["turnId"].as_str().unwrap_or_default().is_empty()); } +#[tokio::test] +async fn fork_session_contract_returns_new_session_meta() { + let (state, _guard) = test_state(None).await; + let temp_dir = tempfile::tempdir().expect("tempdir should be created"); + let created = state + .app + .create_session(temp_dir.path().display().to_string()) + .await + .expect("session should be created"); + state + .app + .submit_prompt(&created.session_id, "hello".to_string()) + .await + .expect("prompt should be accepted"); + let app = build_api_router().with_state(state); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri(format!("/api/sessions/{}/fork", created.session_id)) + .header(AUTH_HEADER_NAME, "browser-token") + .header("content-type", "application/json") + .body(Body::from("{}")) + .expect("request should be valid"), + ) + .await + .expect("response should be returned"); + + assert_eq!(response.status(), StatusCode::OK); + let payload: serde_json::Value = serde_json::from_slice( + &to_bytes(response.into_body(), usize::MAX) + .await + .expect("body should be readable"), + ) + .expect("payload should deserialize"); + assert_eq!(payload["parentSessionId"], created.session_id); + assert_ne!(payload["sessionId"], created.session_id); +} + +#[tokio::test] +async fn fork_session_contract_accepts_completed_turn_id() { + let (state, _guard) = test_state(None).await; + let temp_dir = tempfile::tempdir().expect("tempdir should be created"); + let created = state + .app + .create_session(temp_dir.path().display().to_string()) + .await + .expect("session should be created"); + seed_completed_root_turn(&state, &created.session_id, "turn-completed").await; + let app = build_api_router().with_state(state); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri(format!("/api/sessions/{}/fork", created.session_id)) + .header(AUTH_HEADER_NAME, "browser-token") + .header("content-type", "application/json") + .body(Body::from(r#"{"turnId":"turn-completed"}"#)) + .expect("request should be valid"), + ) + .await + .expect("response should be returned"); + + assert_eq!(response.status(), StatusCode::OK); + let payload: serde_json::Value = serde_json::from_slice( + &to_bytes(response.into_body(), usize::MAX) + .await + .expect("body should be readable"), + ) + .expect("payload should deserialize"); + assert_eq!(payload["parentSessionId"], created.session_id); + assert_eq!(payload["parentStorageSeq"], 4); + assert_ne!(payload["sessionId"], created.session_id); +} + +#[tokio::test] +async fn fork_session_contract_rejects_unfinished_turn_id() { + let (state, _guard) = test_state(None).await; + let temp_dir = tempfile::tempdir().expect("tempdir should be created"); + let created = state + .app + .create_session(temp_dir.path().display().to_string()) + .await + .expect("session should be created"); + seed_unfinished_root_turn(&state, &created.session_id, "turn-running").await; + let app = build_api_router().with_state(state); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri(format!("/api/sessions/{}/fork", created.session_id)) + .header(AUTH_HEADER_NAME, "browser-token") + .header("content-type", "application/json") + .body(Body::from(r#"{"turnId":"turn-running"}"#)) + .expect("request should be valid"), + ) + .await + .expect("response should be returned"); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let payload: serde_json::Value = serde_json::from_slice( + &to_bytes(response.into_body(), usize::MAX) + .await + .expect("body should be readable"), + ) + .expect("payload should deserialize"); + assert!( + payload["error"] + .as_str() + .unwrap_or_default() + .contains("has not completed"), + "unfinished turn should return a specific validation error" + ); +} + +#[tokio::test] +async fn fork_session_contract_rejects_mutually_exclusive_request() { + let (state, _guard) = test_state(None).await; + let temp_dir = tempfile::tempdir().expect("tempdir should be created"); + let created = state + .app + .create_session(temp_dir.path().display().to_string()) + .await + .expect("session should be created"); + let app = build_api_router().with_state(state); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri(format!("/api/sessions/{}/fork", created.session_id)) + .header(AUTH_HEADER_NAME, "browser-token") + .header("content-type", "application/json") + .body(Body::from(r#"{"turnId":"turn-1","storageSeq":42}"#)) + .expect("request should be valid"), + ) + .await + .expect("response should be returned"); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn fork_session_contract_returns_not_found_for_missing_session() { + let (state, _guard) = test_state(None).await; + let app = build_api_router().with_state(state); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/sessions/nonexistent/fork") + .header(AUTH_HEADER_NAME, "browser-token") + .header("content-type", "application/json") + .body(Body::from("{}")) + .expect("request should be valid"), + ) + .await + .expect("response should be returned"); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} + // ─── SubRun 状态查询契约 ────────────────────────────────── #[tokio::test] @@ -145,10 +392,19 @@ async fn subrun_status_contract_returns_default_for_missing_subrun() { assert_eq!(payload["subRunId"], "missing-subrun"); // lifecycle 和 source 序列化为小写枚举值 assert_eq!( - payload["lifecycle"].as_str().unwrap().to_lowercase(), + payload["lifecycle"] + .as_str() + .expect("lifecycle should be a string") + .to_lowercase(), "idle" ); - assert_eq!(payload["source"].as_str().unwrap().to_lowercase(), "live"); + assert_eq!( + payload["source"] + .as_str() + .expect("source should be a string") + .to_lowercase(), + "live" + ); } #[tokio::test] @@ -274,7 +530,7 @@ async fn subrun_cancel_route_returns_not_found_after_removal() { .await .expect("response should be returned"); - // legacy cancel route 已删除,统一走 close + // 旧 cancel route 已删除,统一走 close assert_eq!(response.status(), StatusCode::NOT_FOUND); } diff --git a/crates/server/src/tests/test_support.rs b/crates/server/src/tests/test_support.rs index ce20c90a..f4381eb7 100644 --- a/crates/server/src/tests/test_support.rs +++ b/crates/server/src/tests/test_support.rs @@ -6,8 +6,6 @@ use std::{ }; use astrcode_application::{ApplicationError, WatchEvent, WatchPort, WatchService, WatchSource}; -#[cfg(feature = "debug-workbench")] -use astrcode_debug_workbench::DebugWorkbenchService; use tokio::sync::broadcast; use crate::{ @@ -185,11 +183,6 @@ pub(crate) async fn test_state_with_options( AppState { app, governance, - #[cfg(feature = "debug-workbench")] - debug_workbench: Arc::new(DebugWorkbenchService::new( - Arc::clone(&runtime.app), - Arc::clone(&runtime.governance), - )), auth_sessions, bootstrap_auth: BootstrapAuth::new( "browser-token".to_string(), diff --git a/crates/session-runtime/src/actor/mod.rs b/crates/session-runtime/src/actor/mod.rs index fee2e109..582f6ed6 100644 --- a/crates/session-runtime/src/actor/mod.rs +++ b/crates/session-runtime/src/actor/mod.rs @@ -8,8 +8,8 @@ use std::sync::Arc; use astrcode_core::{ - AgentId, AgentStateProjector, EventStore, EventTranslator, Phase, SessionId, StorageEvent, - StoredEvent, TurnId, normalize_recovered_phase, replay_records, + AgentId, AgentStateProjector, EventStore, EventTranslator, Phase, RecoveredSessionState, + SessionId, StorageEvent, StoredEvent, TurnId, normalize_recovered_phase, replay_records, }; #[cfg(test)] use astrcode_core::{EventLogWriter, StoreResult}; @@ -37,26 +37,11 @@ pub struct SessionActor { state: Arc<SessionState>, session_id: SessionId, working_dir: String, - root_agent_id: AgentId, } impl SessionActor { - /// 创建 actor,包装一个已有的 live session state。 - pub fn new( - session_id: SessionId, - working_dir: impl Into<String>, - root_agent_id: AgentId, - state: Arc<SessionState>, - ) -> Self { - Self { - state, - session_id, - working_dir: working_dir.into(), - root_agent_id, - } - } - /// 创建一个带 durable writer 的 actor。 + #[cfg(test)] pub async fn new_persistent( session_id: SessionId, working_dir: impl Into<String>, @@ -78,7 +63,7 @@ impl SessionActor { pub async fn new_persistent_with_lineage( session_id: SessionId, working_dir: impl Into<String>, - root_agent_id: AgentId, + _root_agent_id: AgentId, event_store: Arc<dyn EventStore>, parent_session_id: Option<String>, parent_storage_seq: Option<u64>, @@ -111,7 +96,6 @@ impl SessionActor { state: Arc::new(state), session_id, working_dir, - root_agent_id, }) } @@ -122,7 +106,7 @@ impl SessionActor { pub fn from_replay( session_id: SessionId, working_dir: impl Into<String>, - root_agent_id: AgentId, + _root_agent_id: AgentId, event_store: Arc<dyn EventStore>, stored_events: Vec<StoredEvent>, ) -> astrcode_core::Result<Self> { @@ -149,7 +133,40 @@ impl SessionActor { state: Arc::new(state), session_id, working_dir, - root_agent_id, + }) + } + + pub fn from_recovery( + session_id: SessionId, + working_dir: impl Into<String>, + root_agent_id: AgentId, + event_store: Arc<dyn EventStore>, + recovered: RecoveredSessionState, + ) -> astrcode_core::Result<Self> { + let RecoveredSessionState { + checkpoint, + tail_events, + } = recovered; + let working_dir = working_dir.into(); + let Some(checkpoint) = checkpoint else { + return Self::from_replay( + session_id, + working_dir, + root_agent_id, + event_store, + tail_events, + ); + }; + let writer = Arc::new(SessionWriter::from_event_store( + event_store, + session_id.clone(), + )); + let state = SessionState::from_recovery(writer, &checkpoint, tail_events)?; + + Ok(Self { + state: Arc::new(state), + session_id, + working_dir, }) } @@ -160,7 +177,7 @@ impl SessionActor { pub fn new_idle( session_id: SessionId, working_dir: impl Into<String>, - root_agent_id: AgentId, + _root_agent_id: AgentId, ) -> Self { let writer = Arc::new(SessionWriter::new(Box::new(NopEventLogWriter))); let state = SessionState::new( @@ -174,7 +191,6 @@ impl SessionActor { state: Arc::new(state), session_id, working_dir: working_dir.into(), - root_agent_id, } } @@ -200,24 +216,10 @@ impl SessionActor { } } - /// 标记 turn 完成。 - pub fn mark_turn_completed(&self, _turn_id: TurnId) { - // Turn 完成由 SessionState.complete_execution_state 驱动, - // 此处仅作为外部标记入口保留。 - } - - pub fn root_agent_id(&self) -> &AgentId { - &self.root_agent_id - } - pub fn state(&self) -> &Arc<SessionState> { &self.state } - pub fn session_id(&self) -> &SessionId { - &self.session_id - } - pub fn working_dir(&self) -> &str { &self.working_dir } @@ -316,7 +318,7 @@ mod tests { "subrun-1", None, SubRunStorageMode::IndependentSession, - Some("session-child".to_string()), + Some("session-child".to_string().into()), ); let event = StorageEvent { turn_id: Some("turn-child".to_string()), @@ -350,10 +352,10 @@ mod tests { event: StorageEvent { turn_id: Some("turn-child".to_string()), agent: AgentEventContext { - agent_id: Some("agent-child".to_string()), - parent_turn_id: Some("turn-root".to_string()), + agent_id: Some("agent-child".to_string().into()), + parent_turn_id: Some("turn-root".to_string().into()), agent_profile: Some("explore".to_string()), - sub_run_id: Some("subrun-1".to_string()), + sub_run_id: Some("subrun-1".to_string().into()), parent_sub_run_id: None, invocation_kind: Some(InvocationKind::SubRun), storage_mode: Some(SubRunStorageMode::IndependentSession), diff --git a/crates/session-runtime/src/catalog/mod.rs b/crates/session-runtime/src/catalog/mod.rs index f7361831..67bf362a 100644 --- a/crates/session-runtime/src/catalog/mod.rs +++ b/crates/session-runtime/src/catalog/mod.rs @@ -1,26 +1,6 @@ //! Session catalog 事件与生命周期协调。 //! -//! 从 `runtime/service/session/` 迁入核心 catalog 事件定义。 -//! 实际的磁盘 create/load/delete 由 adapter-storage 实现, -//! session-runtime 只编排生命周期和广播。 +//! catalog 事件的 canonical owner 已下沉到 `astrcode_core`; +//! session-runtime 这里只保留 re-export,负责生命周期编排与广播。 -use serde::{Deserialize, Serialize}; - -/// Session catalog 变更事件,用于通知外部订阅者 session 列表变化。 -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", tag = "type")] -pub enum SessionCatalogEvent { - SessionCreated { - session_id: String, - }, - SessionDeleted { - session_id: String, - }, - ProjectDeleted { - working_dir: String, - }, - SessionBranched { - session_id: String, - source_session_id: String, - }, -} +pub use astrcode_core::SessionCatalogEvent; diff --git a/crates/session-runtime/src/command/mod.rs b/crates/session-runtime/src/command/mod.rs index 55ee7e27..2a45564b 100644 --- a/crates/session-runtime/src/command/mod.rs +++ b/crates/session-runtime/src/command/mod.rs @@ -2,82 +2,85 @@ use std::path::Path; use astrcode_core::{ AgentCollaborationFact, AgentEventContext, ChildSessionNotification, EventTranslator, - MailboxBatchAckedPayload, MailboxBatchStartedPayload, MailboxDiscardedPayload, - MailboxQueuedPayload, Result, StorageEvent, StorageEventPayload, StoredEvent, + InputBatchAckedPayload, InputBatchStartedPayload, InputDiscardedPayload, InputQueuedPayload, + ModeId, Result, StorageEvent, StorageEventPayload, StoredEvent, }; use chrono::Utc; -use crate::{MailboxEventAppend, SessionRuntime, append_and_broadcast, append_mailbox_event}; +use crate::{ + InputQueueEventAppend, SessionRuntime, append_and_broadcast, append_input_queue_event, + state::checkpoint_if_compacted, +}; -pub struct SessionCommands<'a> { +pub(crate) struct SessionCommands<'a> { runtime: &'a SessionRuntime, } impl<'a> SessionCommands<'a> { - pub fn new(runtime: &'a SessionRuntime) -> Self { + pub(crate) fn new(runtime: &'a SessionRuntime) -> Self { Self { runtime } } - pub async fn append_agent_mailbox_queued( + pub async fn append_agent_input_queued( &self, session_id: &str, turn_id: &str, agent: AgentEventContext, - payload: MailboxQueuedPayload, + payload: InputQueuedPayload, ) -> Result<StoredEvent> { - self.append_agent_mailbox_event( + self.append_agent_input_event( session_id, turn_id, agent, - MailboxEventAppend::Queued(payload), + InputQueueEventAppend::Queued(payload), ) .await } - pub async fn append_agent_mailbox_discarded( + pub async fn append_agent_input_discarded( &self, session_id: &str, turn_id: &str, agent: AgentEventContext, - payload: MailboxDiscardedPayload, + payload: InputDiscardedPayload, ) -> Result<StoredEvent> { - self.append_agent_mailbox_event( + self.append_agent_input_event( session_id, turn_id, agent, - MailboxEventAppend::Discarded(payload), + InputQueueEventAppend::Discarded(payload), ) .await } - pub async fn append_agent_mailbox_batch_started( + pub async fn append_agent_input_batch_started( &self, session_id: &str, turn_id: &str, agent: AgentEventContext, - payload: MailboxBatchStartedPayload, + payload: InputBatchStartedPayload, ) -> Result<StoredEvent> { - self.append_agent_mailbox_event( + self.append_agent_input_event( session_id, turn_id, agent, - MailboxEventAppend::BatchStarted(payload), + InputQueueEventAppend::BatchStarted(payload), ) .await } - pub async fn append_agent_mailbox_batch_acked( + pub async fn append_agent_input_batch_acked( &self, session_id: &str, turn_id: &str, agent: AgentEventContext, - payload: MailboxBatchAckedPayload, + payload: InputBatchAckedPayload, ) -> Result<StoredEvent> { - self.append_agent_mailbox_event( + self.append_agent_input_event( session_id, turn_id, agent, - MailboxEventAppend::BatchAcked(payload), + InputQueueEventAppend::BatchAcked(payload), ) .await } @@ -136,6 +139,7 @@ impl<'a> SessionCommands<'a> { &self, session_id: &str, runtime: &astrcode_core::ResolvedRuntimeConfig, + instructions: Option<&str>, ) -> Result<bool> { let session_id = astrcode_core::SessionId::from(crate::normalize_session_id(session_id)); let actor = self.runtime.ensure_loaded_session(&session_id).await?; @@ -144,11 +148,17 @@ impl<'a> SessionCommands<'a> { .running .load(std::sync::atomic::Ordering::SeqCst) { - actor.state().request_manual_compact(runtime.clone())?; + actor + .state() + .request_manual_compact(crate::state::PendingManualCompactRequest { + runtime: runtime.clone(), + instructions: instructions.map(str::to_string), + })?; return Ok(true); } let mut translator = EventTranslator::new(actor.state().current_phase()?); - if let Some(events) = crate::turn::manual_compact::build_manual_compact_events( + actor.state().set_compacting(true); + let built = crate::turn::manual_compact::build_manual_compact_events( crate::turn::manual_compact::ManualCompactRequest { gateway: self.runtime.kernel.gateway(), prompt_facts_provider: self.runtime.prompt_facts_provider.as_ref(), @@ -156,27 +166,63 @@ impl<'a> SessionCommands<'a> { session_id: session_id.as_str(), working_dir: Path::new(actor.working_dir()), runtime, + trigger: astrcode_core::CompactTrigger::Manual, + instructions, }, ) - .await? - { + .await; + actor.state().set_compacting(false); + if let Some(events) = built? { + let mut persisted = Vec::with_capacity(events.len()); for event in &events { - append_and_broadcast(actor.state(), event, &mut translator).await?; + persisted.push(append_and_broadcast(actor.state(), event, &mut translator).await?); } + checkpoint_if_compacted( + &self.runtime.event_store, + &session_id, + actor.state(), + &persisted, + ) + .await; } Ok(false) } - async fn append_agent_mailbox_event( + pub async fn switch_mode( + &self, + session_id: &str, + from: ModeId, + to: ModeId, + ) -> Result<StoredEvent> { + let session_id = astrcode_core::SessionId::from(crate::normalize_session_id(session_id)); + let session_state = self.runtime.query().session_state(&session_id).await?; + let mut translator = EventTranslator::new(session_state.current_phase()?); + append_and_broadcast( + &session_state, + &StorageEvent { + turn_id: None, + agent: AgentEventContext::default(), + payload: StorageEventPayload::ModeChanged { + from, + to, + timestamp: Utc::now(), + }, + }, + &mut translator, + ) + .await + } + + async fn append_agent_input_event( &self, session_id: &str, turn_id: &str, agent: AgentEventContext, - event: MailboxEventAppend, + event: InputQueueEventAppend, ) -> Result<StoredEvent> { let session_id = astrcode_core::SessionId::from(crate::normalize_session_id(session_id)); let session_state = self.runtime.query().session_state(&session_id).await?; let mut translator = EventTranslator::new(session_state.current_phase()?); - append_mailbox_event(&session_state, turn_id, agent, event, &mut translator).await + append_input_queue_event(&session_state, turn_id, agent, event, &mut translator).await } } diff --git a/crates/session-runtime/src/context/mod.rs b/crates/session-runtime/src/context/mod.rs deleted file mode 100644 index 4a7b1fc4..00000000 --- a/crates/session-runtime/src/context/mod.rs +++ /dev/null @@ -1,26 +0,0 @@ -//! 子 Agent 上下文快照。 -//! -//! 从 `runtime-execution/context.rs` 迁入。 -//! 描述子 Agent 执行时可继承的父上下文。 -//! -//! 边界约束: -//! - 这里只表达“有哪些上下文来源、继承了哪些结果” -//! - 不负责 token 裁剪 -//! - 不负责最终 request / prompt 组装 - -use serde::{Deserialize, Serialize}; - -/// 子 Agent 执行前的结构化上下文快照。 -#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ResolvedContextSnapshot { - /// 任务主体(上层已经解析好的 task payload)。 - #[serde(default)] - pub task_payload: String, - /// 从父会话继承的 compact summary。 - #[serde(default)] - pub inherited_compact_summary: Option<String>, - /// 从父会话继承的最近 N 轮对话 tail。 - #[serde(default)] - pub inherited_recent_tail: Vec<String>, -} diff --git a/crates/session-runtime/src/context_window/compaction.rs b/crates/session-runtime/src/context_window/compaction.rs index fc7f1b00..7170bc77 100644 --- a/crates/session-runtime/src/context_window/compaction.rs +++ b/crates/session-runtime/src/context_window/compaction.rs @@ -13,11 +13,13 @@ //! 如果压缩请求本身超出上下文窗口,会逐步丢弃最旧的 compact unit 并重试, //! 最多重试 3 次。 -use std::sync::OnceLock; +use std::{collections::HashSet, sync::OnceLock}; use astrcode_core::{ - AstrError, CancelToken, LlmMessage, LlmRequest, ModelLimits, Result, UserMessageOrigin, - format_compact_summary, parse_compact_summary_message, + AstrError, CancelToken, CompactAppliedMeta, CompactMode, CompactSummaryEnvelope, LlmMessage, + LlmRequest, ModelLimits, Result, UserMessageOrigin, format_compact_summary, + parse_compact_summary_message, + tool_result_persist::{is_persisted_output, persisted_output_absolute_path}, }; use astrcode_kernel::KernelGateway; use chrono::{DateTime, Utc}; @@ -28,25 +30,42 @@ use super::token_usage::{effective_context_window, estimate_request_tokens}; const BASE_COMPACT_PROMPT_TEMPLATE: &str = include_str!("templates/compact/base.md"); const INCREMENTAL_COMPACT_PROMPT_TEMPLATE: &str = include_str!("templates/compact/incremental.md"); -/// 最大 reactive compact 重试次数。 -const MAX_COMPACT_ATTEMPTS: usize = 3; +#[path = "compaction/protocol.rs"] +mod protocol; +use protocol::*; /// 压缩配置。 -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct CompactConfig { +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct CompactConfig { /// 保留最近的用户 turn 数量。 pub keep_recent_turns: usize, + /// 额外保留最近真实用户消息的数量。 + pub keep_recent_user_messages: usize, /// 压缩触发方式。 pub trigger: astrcode_core::CompactTrigger, + /// compact 请求自身保留的输出预算。 + pub summary_reserve_tokens: usize, + /// compact 请求的最大输出 token 上限。 + pub max_output_tokens: usize, + /// compact 允许的最大裁剪重试次数。 + pub max_retry_attempts: usize, + /// compact 后注入给模型的旧历史 event log 路径提示。 + pub history_path: Option<String>, + /// 仅对手动 compact 生效的附加指令。 + pub custom_instructions: Option<String>, } /// 压缩执行结果。 #[derive(Debug, Clone)] -pub struct CompactResult { +pub(crate) struct CompactResult { /// 压缩后的完整消息列表。 pub messages: Vec<LlmMessage>, /// 压缩生成的摘要文本。 pub summary: String, + /// 最近真实用户消息的极短目的摘要。 + pub recent_user_context_digest: Option<String>, + /// compact 后重新注入的最近真实用户消息原文。 + pub recent_user_context_messages: Vec<String>, /// 保留的最近 turn 数。 pub preserved_recent_turns: usize, /// 压缩前估算 token 数。 @@ -59,6 +78,8 @@ pub struct CompactResult { pub tokens_freed: usize, /// 压缩时间戳。 pub timestamp: DateTime<Utc>, + /// compact 执行元数据。 + pub meta: CompactAppliedMeta, } /// compact 输入的边界类型。 @@ -75,22 +96,92 @@ struct CompactionUnit { boundary: CompactionBoundary, } -#[derive(Debug, Clone, PartialEq, Eq)] -struct ParsedCompactOutput { - summary: String, - has_analysis: bool, -} - #[derive(Debug, Clone, PartialEq, Eq)] enum CompactPromptMode { Fresh, Incremental { previous_summary: String }, } +impl CompactPromptMode { + fn compact_mode(&self, retry_count: usize) -> CompactMode { + if retry_count > 0 { + CompactMode::RetrySalvage + } else if matches!(self, Self::Incremental { .. }) { + CompactMode::Incremental + } else { + CompactMode::Full + } + } +} + +#[derive(Debug, Clone)] +struct PreparedCompactInput { + messages: Vec<LlmMessage>, + prompt_mode: CompactPromptMode, + input_units: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct CompactContractViolation { + detail: String, +} + +impl CompactContractViolation { + fn from_parsed_output(parsed: &ParsedCompactOutput) -> Option<Self> { + if parsed.used_fallback { + return Some(Self { + detail: "response did not contain a strict <summary> XML block and required \ + fallback parsing" + .to_string(), + }); + } + if !parsed.has_analysis { + return Some(Self { + detail: "response omitted the required <analysis> block".to_string(), + }); + } + if !parsed.has_recent_user_context_digest_block { + return Some(Self { + detail: "response omitted the required <recent_user_context_digest> block" + .to_string(), + }); + } + None + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +struct CompactRetryState { + salvage_attempts: usize, + contract_retry_count: usize, + contract_repair_feedback: Option<String>, +} + +impl CompactRetryState { + fn schedule_contract_retry(&mut self, detail: String) { + self.contract_retry_count = self.contract_retry_count.saturating_add(1); + self.contract_repair_feedback = Some(detail); + } + + fn note_salvage_attempt(&mut self) { + self.salvage_attempts = self.salvage_attempts.saturating_add(1); + } +} + +#[derive(Debug, Clone)] +struct CompactExecutionResult { + parsed_output: ParsedCompactOutput, + prepared_input: PreparedCompactInput, + retry_state: CompactRetryState, +} + /// 执行自动压缩。 /// /// 通过 `gateway` 调用 LLM 对历史前缀生成摘要,替换为压缩后的消息。 /// 返回 `None` 表示没有可压缩的内容。 +/// +/// 当前系统只有这一套真实 compact 流程。若未来需要按 mode 调整行为,应扩展 +/// `CompactConfig` / `ContextWindowSettings` 这类显式参数,而不是恢复未消费的粗粒度策略枚举。 pub async fn auto_compact( gateway: &KernelGateway, messages: &[LlmMessage], @@ -98,105 +189,70 @@ pub async fn auto_compact( config: CompactConfig, cancel: CancelToken, ) -> Result<Option<CompactResult>> { - let preserved_recent_turns = config.keep_recent_turns.max(1); + let recent_user_context_messages = + collect_recent_user_context_messages(messages, config.keep_recent_user_messages); + let preserved_recent_turns = config + .keep_recent_turns + .max(config.keep_recent_user_messages) + .max(1); let Some(mut split) = split_for_compaction(messages, preserved_recent_turns) else { return Ok(None); }; let pre_tokens = estimate_request_tokens(messages, compact_prompt_context); - let mut attempts = 0usize; - let summary = loop { - if !trim_prefix_until_compact_request_fits( - &mut split.prefix, - compact_prompt_context, - gateway.model_limits(), - ) { - return Err(AstrError::Internal( - "compact request could not fit within summarization window".to_string(), - )); - } + let effective_max_output_tokens = config + .max_output_tokens + .min(gateway.model_limits().max_output_tokens) + .max(1); + let Some(execution) = execute_compact_request_with_retries( + gateway, + &mut split, + compact_prompt_context, + &config, + &recent_user_context_messages, + effective_max_output_tokens, + cancel, + ) + .await? + else { + return Ok(None); + }; - let request_messages = compact_input_messages(&split.prefix); - if request_messages.is_empty() { - return Ok(None); - } - - let prompt_mode = latest_previous_summary(&split.prefix) - .map(|previous_summary| CompactPromptMode::Incremental { previous_summary }) - .unwrap_or(CompactPromptMode::Fresh); - let request = LlmRequest::new(request_messages, Vec::new(), cancel.clone()).with_system( - render_compact_system_prompt(compact_prompt_context, prompt_mode), - ); - match gateway.call_llm(request, None).await { - Ok(output) => break parse_compact_output(&output.content)?.summary, - Err(error) if is_prompt_too_long(&error) && attempts < MAX_COMPACT_ATTEMPTS => { - attempts += 1; - if !drop_oldest_compaction_unit(&mut split.prefix) { - return Err(AstrError::Internal(error.to_string())); - } - split.keep_start = split.prefix.len(); - }, - Err(error) => return Err(AstrError::Internal(error.to_string())), + let summary = { + let summary = sanitize_compact_summary(&execution.parsed_output.summary); + if let Some(history_path) = config.history_path.as_deref() { + CompactSummaryEnvelope::new(summary) + .with_history_path(history_path) + .render_body() + } else { + summary } }; - - let compacted_messages = compacted_messages(&summary, split.suffix); - let post_tokens_estimate = estimate_request_tokens(&compacted_messages, compact_prompt_context); - Ok(Some(CompactResult { - messages: compacted_messages, + let recent_user_context_digest = execution + .parsed_output + .recent_user_context_digest + .as_deref() + .map(sanitize_recent_user_context_digest) + .filter(|value| !value.is_empty()); + let compacted_messages = compacted_messages( + &summary, + recent_user_context_digest.as_deref(), + &recent_user_context_messages, + split.keep_start, + split.suffix, + ); + Ok(Some(build_compact_result( + compacted_messages, summary, + recent_user_context_digest, + recent_user_context_messages, preserved_recent_turns, pre_tokens, - post_tokens_estimate, - messages_removed: split.keep_start, - tokens_freed: pre_tokens.saturating_sub(post_tokens_estimate), - timestamp: Utc::now(), - })) -} - -/// 合并 compact 使用的 prompt 上下文。 -pub fn merge_compact_prompt_context( - runtime_system_prompt: Option<&str>, - additional_system_prompt: Option<&str>, -) -> Option<String> { - let runtime_system_prompt = runtime_system_prompt.filter(|v| !v.trim().is_empty()); - let additional_system_prompt = additional_system_prompt.filter(|v| !v.trim().is_empty()); - - match (runtime_system_prompt, additional_system_prompt) { - (None, None) => None, - (Some(base), None) => Some(base.to_string()), - (None, Some(additional)) => Some(additional.to_string()), - (Some(base), Some(additional)) => Some(format!("{base}\n\n{additional}")), - } -} - -/// 判断错误是否为 prompt too long。 -pub fn is_prompt_too_long(error: &astrcode_kernel::KernelError) -> bool { - let message = error.to_string(); - // 检查常见 prompt-too-long 错误模式 - contains_ascii_case_insensitive(&message, "prompt too long") - || contains_ascii_case_insensitive(&message, "context length") - || contains_ascii_case_insensitive(&message, "maximum context") - || contains_ascii_case_insensitive(&message, "too many tokens") -} - -fn render_compact_system_prompt( - compact_prompt_context: Option<&str>, - mode: CompactPromptMode, -) -> String { - let incremental_block = match mode { - CompactPromptMode::Fresh => String::new(), - CompactPromptMode::Incremental { previous_summary } => INCREMENTAL_COMPACT_PROMPT_TEMPLATE - .replace("{{PREVIOUS_SUMMARY}}", previous_summary.trim()), - }; - let runtime_context = compact_prompt_context - .filter(|v| !v.trim().is_empty()) - .map(|v| format!("\nCurrent runtime system prompt for context:\n{v}")) - .unwrap_or_default(); - - BASE_COMPACT_PROMPT_TEMPLATE - .replace("{{INCREMENTAL_MODE}}", incremental_block.trim()) - .replace("{{RUNTIME_CONTEXT}}", runtime_context.trim_end()) + split.keep_start, + compact_prompt_context, + &config, + execution, + ))) } #[derive(Debug, Clone)] @@ -205,36 +261,9 @@ struct CompactionSplit { suffix: Vec<LlmMessage>, keep_start: usize, } - -fn compact_input_messages(messages: &[LlmMessage]) -> Vec<LlmMessage> { - messages - .iter() - .filter(|message| { - !matches!( - message, - LlmMessage::User { - origin: UserMessageOrigin::CompactSummary - | UserMessageOrigin::ReactivationPrompt, - .. - } - ) - }) - .cloned() - .collect() -} - -fn latest_previous_summary(messages: &[LlmMessage]) -> Option<String> { - messages.iter().rev().find_map(|message| match message { - LlmMessage::User { - content, - origin: UserMessageOrigin::CompactSummary, - } => parse_compact_summary_message(content).map(|envelope| envelope.summary), - _ => None, - }) -} - /// 检查消息是否可以被压缩。 -pub fn can_compact(messages: &[LlmMessage], keep_recent_turns: usize) -> bool { +#[cfg(test)] +fn can_compact(messages: &[LlmMessage], keep_recent_turns: usize) -> bool { split_for_compaction(messages, keep_recent_turns).is_some() } @@ -337,18 +366,32 @@ fn trim_prefix_until_compact_request_fits( prefix: &mut Vec<LlmMessage>, compact_prompt_context: Option<&str>, limits: ModelLimits, + config: &CompactConfig, + recent_user_context_messages: &[RecentUserContextMessage], ) -> bool { loop { - let request_messages = compact_input_messages(prefix); - if request_messages.is_empty() { + let prepared_input = prepare_compact_input(prefix); + if prepared_input.messages.is_empty() { return false; } - let prompt_mode = latest_previous_summary(prefix) - .map(|previous_summary| CompactPromptMode::Incremental { previous_summary }) - .unwrap_or(CompactPromptMode::Fresh); - let system_prompt = render_compact_system_prompt(compact_prompt_context, prompt_mode); - if compact_request_fits_window(&request_messages, &system_prompt, limits) { + let system_prompt = render_compact_system_prompt( + compact_prompt_context, + prepared_input.prompt_mode, + config + .max_output_tokens + .min(limits.max_output_tokens) + .max(1), + recent_user_context_messages, + config.custom_instructions.as_deref(), + None, + ); + if compact_request_fits_window( + &prepared_input.messages, + &system_prompt, + limits, + config.summary_reserve_tokens, + ) { return true; } @@ -358,423 +401,211 @@ fn trim_prefix_until_compact_request_fits( } } -fn compact_request_fits_window( - request_messages: &[LlmMessage], - system_prompt: &str, - limits: ModelLimits, -) -> bool { - estimate_request_tokens(request_messages, Some(system_prompt)) - <= effective_context_window(limits) -} - -fn compacted_messages(summary: &str, suffix: Vec<LlmMessage>) -> Vec<LlmMessage> { - let mut messages = vec![LlmMessage::User { - content: format_compact_summary(summary), - origin: UserMessageOrigin::CompactSummary, - }]; - messages.extend(suffix); - messages -} +async fn execute_compact_request_with_retries( + gateway: &KernelGateway, + split: &mut CompactionSplit, + compact_prompt_context: Option<&str>, + config: &CompactConfig, + recent_user_context_messages: &[RecentUserContextMessage], + effective_max_output_tokens: usize, + cancel: CancelToken, +) -> Result<Option<CompactExecutionResult>> { + let mut retry_state = CompactRetryState::default(); + loop { + if !trim_prefix_until_compact_request_fits( + &mut split.prefix, + compact_prompt_context, + gateway.model_limits(), + config, + recent_user_context_messages, + ) { + return Err(AstrError::Internal( + "compact request could not fit within summarization window".to_string(), + )); + } -fn parse_compact_output(content: &str) -> Result<ParsedCompactOutput> { - let has_analysis = extract_xml_block(content, "analysis").is_some(); - if !has_analysis { - log::warn!("compact: missing <analysis> block in LLM response"); - } + let prepared_input = prepare_compact_input(&split.prefix); + if prepared_input.messages.is_empty() { + return Ok(None); + } - if has_opening_xml_tag(content, "summary") && !has_closing_xml_tag(content, "summary") { - return Err(AstrError::LlmStreamError( - "compact response missing closing </summary> tag".to_string(), - )); - } + let request = build_compact_request( + prepared_input.messages.clone(), + compact_prompt_context, + &prepared_input.prompt_mode, + effective_max_output_tokens, + recent_user_context_messages, + config.custom_instructions.as_deref(), + retry_state.contract_repair_feedback.as_deref(), + cancel.clone(), + ); - let summary = extract_xml_block(content, "summary") - .map(str::to_string) - .or_else(|| { - let fallback = strip_xml_block(content, "analysis"); - let fallback = strip_markdown_code_fence(&fallback); - if fallback.is_empty() { - None - } else { - log::warn!("compact: missing <summary> block, falling back to raw content"); - Some(fallback) - } - }) - .ok_or_else(|| { - AstrError::LlmStreamError("compact response missing <summary> block".to_string()) - })?; - if summary.is_empty() { - return Err(AstrError::LlmStreamError( - "compact summary response was empty".to_string(), - )); + match gateway.call_llm(request, None).await { + Ok(output) => match parse_compact_output(&output.content) { + Ok(parsed_output) => { + if let Some(violation) = + CompactContractViolation::from_parsed_output(&parsed_output) + { + if retry_state.contract_retry_count < config.max_retry_attempts { + retry_state.schedule_contract_retry(violation.detail); + continue; + } + } + return Ok(Some(CompactExecutionResult { + parsed_output, + prepared_input, + retry_state, + })); + }, + Err(error) if retry_state.contract_retry_count < config.max_retry_attempts => { + retry_state.schedule_contract_retry(error.to_string()); + continue; + }, + Err(error) => return Err(error), + }, + Err(error) + if is_prompt_too_long(&error) + && retry_state.salvage_attempts < config.max_retry_attempts => + { + retry_state.note_salvage_attempt(); + if !drop_oldest_compaction_unit(&mut split.prefix) { + return Err(AstrError::Internal(error.to_string())); + } + split.keep_start = split.prefix.len(); + }, + Err(error) => return Err(AstrError::Internal(error.to_string())), + } } - - Ok(ParsedCompactOutput { - summary, - has_analysis, - }) -} - -fn extract_xml_block<'a>(content: &'a str, tag: &str) -> Option<&'a str> { - xml_block_regex(tag) - .captures(content) - .and_then(|captures| captures.name("body")) - .map(|body| body.as_str().trim()) } -fn strip_xml_block(content: &str, tag: &str) -> String { - xml_block_regex(tag).replace(content, "").into_owned() -} - -fn has_opening_xml_tag(content: &str, tag: &str) -> bool { - xml_opening_tag_regex(tag).is_match(content) -} - -fn has_closing_xml_tag(content: &str, tag: &str) -> bool { - xml_closing_tag_regex(tag).is_match(content) +fn build_compact_request( + messages: Vec<LlmMessage>, + compact_prompt_context: Option<&str>, + prompt_mode: &CompactPromptMode, + effective_max_output_tokens: usize, + recent_user_context_messages: &[RecentUserContextMessage], + custom_instructions: Option<&str>, + contract_repair_feedback: Option<&str>, + cancel: CancelToken, +) -> LlmRequest { + LlmRequest::new(messages, Vec::new(), cancel) + .with_system(render_compact_system_prompt( + compact_prompt_context, + prompt_mode.clone(), + effective_max_output_tokens, + recent_user_context_messages, + custom_instructions, + contract_repair_feedback, + )) + .with_max_output_tokens_override(effective_max_output_tokens) } -fn strip_markdown_code_fence(content: &str) -> String { - let trimmed = content.trim(); - if !trimmed.starts_with("```") { - return trimmed.to_string(); - } +fn build_compact_result( + compacted_messages: Vec<LlmMessage>, + summary: String, + recent_user_context_digest: Option<String>, + recent_user_context_messages: Vec<RecentUserContextMessage>, + preserved_recent_turns: usize, + pre_tokens: usize, + messages_removed: usize, + compact_prompt_context: Option<&str>, + config: &CompactConfig, + execution: CompactExecutionResult, +) -> CompactResult { + let CompactExecutionResult { + parsed_output, + prepared_input, + retry_state, + } = execution; + let post_tokens_estimate = estimate_request_tokens(&compacted_messages, compact_prompt_context); + let output_summary_chars = summary.chars().count().min(u32::MAX as usize) as u32; - let mut lines = trimmed.lines(); - let Some(first_line) = lines.next() else { - return trimmed.to_string(); - }; - if !first_line.trim_start().starts_with("```") { - return trimmed.to_string(); + CompactResult { + messages: compacted_messages, + summary, + recent_user_context_digest, + recent_user_context_messages: recent_user_context_messages + .into_iter() + .map(|message| message.content) + .collect(), + preserved_recent_turns, + pre_tokens, + post_tokens_estimate, + messages_removed, + tokens_freed: pre_tokens.saturating_sub(post_tokens_estimate), + timestamp: Utc::now(), + meta: CompactAppliedMeta { + mode: prepared_input + .prompt_mode + .compact_mode(retry_state.salvage_attempts), + instructions_present: config + .custom_instructions + .as_deref() + .is_some_and(|value| !value.trim().is_empty()), + fallback_used: parsed_output.used_fallback || retry_state.salvage_attempts > 0, + retry_count: retry_state.salvage_attempts.min(u32::MAX as usize) as u32, + input_units: prepared_input.input_units.min(u32::MAX as usize) as u32, + output_summary_chars, + }, } - - let body = lines.collect::<Vec<_>>().join("\n"); - let body = body.trim_end(); - body.strip_suffix("```").unwrap_or(body).trim().to_string() } -fn xml_block_regex(tag: &str) -> &'static Regex { - static SUMMARY_REGEX: OnceLock<Regex> = OnceLock::new(); - static ANALYSIS_REGEX: OnceLock<Regex> = OnceLock::new(); - - match tag { - "summary" => SUMMARY_REGEX.get_or_init(|| { - Regex::new(r"(?is)<summary\s*>(?P<body>.*?)</summary\s*>") - .expect("summary regex should compile") - }), - "analysis" => ANALYSIS_REGEX.get_or_init(|| { - Regex::new(r"(?is)<analysis\s*>(?P<body>.*?)</analysis\s*>") - .expect("analysis regex should compile") - }), - other => panic!("unsupported compact xml tag: {other}"), - } +fn compact_request_fits_window( + request_messages: &[LlmMessage], + system_prompt: &str, + limits: ModelLimits, + summary_reserve_tokens: usize, +) -> bool { + estimate_request_tokens(request_messages, Some(system_prompt)) + <= effective_context_window(limits, summary_reserve_tokens) } -fn xml_opening_tag_regex(tag: &str) -> &'static Regex { - static SUMMARY_REGEX: OnceLock<Regex> = OnceLock::new(); - static ANALYSIS_REGEX: OnceLock<Regex> = OnceLock::new(); - - match tag { - "summary" => SUMMARY_REGEX.get_or_init(|| { - Regex::new(r"(?i)<summary\s*>").expect("summary opening regex should compile") - }), - "analysis" => ANALYSIS_REGEX.get_or_init(|| { - Regex::new(r"(?i)<analysis\s*>").expect("analysis opening regex should compile") - }), - other => panic!("unsupported compact xml tag: {other}"), +fn compacted_messages( + summary: &str, + recent_user_context_digest: Option<&str>, + recent_user_context_messages: &[RecentUserContextMessage], + keep_start: usize, + suffix: Vec<LlmMessage>, +) -> Vec<LlmMessage> { + let recent_user_context_indices = recent_user_context_messages + .iter() + .map(|message| message.index) + .collect::<HashSet<_>>(); + let mut messages = vec![LlmMessage::User { + content: format_compact_summary(summary), + origin: UserMessageOrigin::CompactSummary, + }]; + if let Some(digest) = recent_user_context_digest.filter(|value| !value.trim().is_empty()) { + messages.push(LlmMessage::User { + content: digest.trim().to_string(), + origin: UserMessageOrigin::RecentUserContextDigest, + }); } -} - -fn xml_closing_tag_regex(tag: &str) -> &'static Regex { - static SUMMARY_REGEX: OnceLock<Regex> = OnceLock::new(); - static ANALYSIS_REGEX: OnceLock<Regex> = OnceLock::new(); - - match tag { - "summary" => SUMMARY_REGEX.get_or_init(|| { - Regex::new(r"(?i)</summary\s*>").expect("summary closing regex should compile") - }), - "analysis" => ANALYSIS_REGEX.get_or_init(|| { - Regex::new(r"(?i)</analysis\s*>").expect("analysis closing regex should compile") - }), - other => panic!("unsupported compact xml tag: {other}"), + for message in recent_user_context_messages { + messages.push(LlmMessage::User { + content: message.content.clone(), + origin: UserMessageOrigin::RecentUserContext, + }); } -} - -fn contains_ascii_case_insensitive(haystack: &str, needle: &str) -> bool { - let needle = needle.as_bytes(); - haystack - .as_bytes() - .windows(needle.len()) - .any(|window| window.eq_ignore_ascii_case(needle)) + messages.extend( + suffix + .into_iter() + .enumerate() + .filter(|(offset, message)| { + let is_reinjected_real_user_message = matches!( + message, + LlmMessage::User { + origin: UserMessageOrigin::User, + .. + } + ) && recent_user_context_indices + .contains(&(keep_start + offset)); + !is_reinjected_real_user_message + }) + .map(|(_, message)| message), + ); + messages } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn render_compact_system_prompt_keeps_do_not_continue_instruction_intact() { - let prompt = render_compact_system_prompt(None, CompactPromptMode::Fresh); - - assert!( - prompt.contains("**Do NOT continue the conversation.**"), - "compact prompt must explicitly instruct the summarizer not to continue the session" - ); - } - - #[test] - fn render_compact_system_prompt_renders_incremental_block() { - let prompt = render_compact_system_prompt( - None, - CompactPromptMode::Incremental { - previous_summary: "older summary".to_string(), - }, - ); - - assert!(prompt.contains("## Incremental Mode")); - assert!(prompt.contains("<previous-summary>")); - assert!(prompt.contains("older summary")); - } - - #[test] - fn merge_compact_prompt_context_appends_hook_suffix_after_runtime_prompt() { - let merged = merge_compact_prompt_context(Some("base"), Some("hook")) - .expect("merged compact prompt context should exist"); - - assert_eq!(merged, "base\n\nhook"); - } - - #[test] - fn merge_compact_prompt_context_returns_none_when_both_empty() { - assert!(merge_compact_prompt_context(None, None).is_none()); - assert!(merge_compact_prompt_context(Some(" "), Some(" \n\t ")).is_none()); - } - - #[test] - fn parse_compact_output_requires_non_empty_content() { - let error = parse_compact_output(" ").expect_err("empty compact output should fail"); - assert!(error.to_string().contains("missing <summary> block")); - } - - #[test] - fn parse_compact_output_requires_closed_summary_block() { - let error = - parse_compact_output("<summary>open").expect_err("unclosed summary should fail"); - assert!(error.to_string().contains("closing </summary>")); - } - - #[test] - fn parse_compact_output_prefers_summary_block() { - let parsed = - parse_compact_output("<analysis>draft</analysis><summary>\nSection\n</summary>") - .expect("summary should parse"); - - assert_eq!(parsed.summary, "Section"); - assert!(parsed.has_analysis); - } - - #[test] - fn parse_compact_output_accepts_case_insensitive_summary_block() { - let parsed = parse_compact_output("<ANALYSIS>draft</ANALYSIS><SUMMARY>Section</SUMMARY>") - .expect("summary should parse"); - - assert_eq!(parsed.summary, "Section"); - assert!(parsed.has_analysis); - } - - #[test] - fn parse_compact_output_falls_back_to_plain_text_summary() { - let parsed = parse_compact_output("## Goal\n- preserve current task") - .expect("plain text summary should parse"); - - assert_eq!(parsed.summary, "## Goal\n- preserve current task"); - assert!(!parsed.has_analysis); - } - - #[test] - fn parse_compact_output_does_not_treat_analysis_only_as_summary() { - let error = parse_compact_output("<analysis>draft</analysis>") - .expect_err("analysis-only output should still fail"); - - assert!(error.to_string().contains("missing <summary> block")); - } - - #[test] - fn split_for_compaction_preserves_recent_real_user_turns() { - let messages = vec![ - LlmMessage::User { - content: "older".to_string(), - origin: UserMessageOrigin::User, - }, - LlmMessage::Assistant { - content: "ack".to_string(), - tool_calls: Vec::new(), - reasoning: None, - }, - LlmMessage::User { - content: format_compact_summary("older"), - origin: UserMessageOrigin::CompactSummary, - }, - LlmMessage::User { - content: "newer".to_string(), - origin: UserMessageOrigin::User, - }, - ]; - - let split = split_for_compaction(&messages, 1).expect("split should exist"); - - assert_eq!(split.keep_start, 3); - assert_eq!(split.prefix.len(), 3); - assert_eq!(split.suffix.len(), 1); - } - - #[test] - fn split_for_compaction_falls_back_to_assistant_boundary_for_single_turn() { - let messages = vec![ - LlmMessage::User { - content: "task".to_string(), - origin: UserMessageOrigin::User, - }, - LlmMessage::Assistant { - content: "step 1".to_string(), - tool_calls: Vec::new(), - reasoning: None, - }, - LlmMessage::Assistant { - content: "step 2".to_string(), - tool_calls: Vec::new(), - reasoning: None, - }, - ]; - - let split = split_for_compaction(&messages, 1).expect("single turn should still split"); - assert_eq!(split.keep_start, 2); - } - - #[test] - fn compacted_messages_inserts_summary_as_compact_user_message() { - let compacted = compacted_messages("Older history", Vec::new()); - - assert!(matches!( - &compacted[0], - LlmMessage::User { - origin: UserMessageOrigin::CompactSummary, - .. - } - )); - assert_eq!(compacted.len(), 1); - } - - #[test] - fn compact_input_messages_skips_synthetic_user_messages() { - let filtered = compact_input_messages(&[ - LlmMessage::User { - content: "summary".to_string(), - origin: UserMessageOrigin::CompactSummary, - }, - LlmMessage::User { - content: "wake up".to_string(), - origin: UserMessageOrigin::ReactivationPrompt, - }, - LlmMessage::User { - content: "real user".to_string(), - origin: UserMessageOrigin::User, - }, - ]); - - assert_eq!(filtered.len(), 1); - assert!(matches!( - &filtered[0], - LlmMessage::User { - content, - origin: UserMessageOrigin::User - } if content == "real user" - )); - } - - #[test] - fn drop_oldest_compaction_unit_is_deterministic() { - let mut prefix = vec![ - LlmMessage::User { - content: "task".to_string(), - origin: UserMessageOrigin::User, - }, - LlmMessage::Assistant { - content: "step-1".to_string(), - tool_calls: Vec::new(), - reasoning: None, - }, - LlmMessage::Assistant { - content: "step-2".to_string(), - tool_calls: Vec::new(), - reasoning: None, - }, - ]; - - assert!(drop_oldest_compaction_unit(&mut prefix)); - assert!(matches!( - &prefix[0], - LlmMessage::Assistant { content, .. } if content == "step-1" - )); - } - - #[test] - fn trim_prefix_until_compact_request_fits_drops_oldest_units_before_calling_llm() { - let mut prefix = vec![ - LlmMessage::User { - content: "very old request ".repeat(1200), - origin: UserMessageOrigin::User, - }, - LlmMessage::Assistant { - content: "first step".repeat(1200), - tool_calls: Vec::new(), - reasoning: None, - }, - LlmMessage::Assistant { - content: "latest step".to_string(), - tool_calls: Vec::new(), - reasoning: None, - }, - ]; - - let trimmed = trim_prefix_until_compact_request_fits( - &mut prefix, - None, - ModelLimits { - context_window: 23_000, - max_output_tokens: 2_000, - }, - ); - - assert!(trimmed); - assert!(matches!( - prefix.as_slice(), - [LlmMessage::Assistant { content, .. }] if content == "latest step" - )); - } - - #[test] - fn can_compact_returns_false_for_empty_messages() { - assert!(!can_compact(&[], 2)); - } - - #[test] - fn can_compact_returns_true_when_enough_turns() { - let messages = vec![ - LlmMessage::User { - content: "turn-1".to_string(), - origin: UserMessageOrigin::User, - }, - LlmMessage::Assistant { - content: "reply".to_string(), - tool_calls: Vec::new(), - reasoning: None, - }, - LlmMessage::User { - content: "turn-2".to_string(), - origin: UserMessageOrigin::User, - }, - ]; - assert!(can_compact(&messages, 1)); - } -} +mod tests; diff --git a/crates/session-runtime/src/context_window/compaction/protocol.rs b/crates/session-runtime/src/context_window/compaction/protocol.rs new file mode 100644 index 00000000..d22b2edc --- /dev/null +++ b/crates/session-runtime/src/context_window/compaction/protocol.rs @@ -0,0 +1,274 @@ +use super::*; + +mod sanitize; +mod xml_parsing; +use sanitize as sanitize_impl; +use xml_parsing as xml_parsing_impl; + +/// 合并 compact 使用的 prompt 上下文。 +#[cfg(test)] +pub(super) fn merge_compact_prompt_context( + runtime_system_prompt: Option<&str>, + additional_system_prompt: Option<&str>, +) -> Option<String> { + let runtime_system_prompt = runtime_system_prompt.filter(|v| !v.trim().is_empty()); + let additional_system_prompt = additional_system_prompt.filter(|v| !v.trim().is_empty()); + + match (runtime_system_prompt, additional_system_prompt) { + (None, None) => None, + (Some(base), None) => Some(base.to_string()), + (None, Some(additional)) => Some(additional.to_string()), + (Some(base), Some(additional)) => Some(format!("{base}\n\n{additional}")), + } +} + +/// 判断错误是否为 prompt too long。 +pub(super) fn is_prompt_too_long(error: &astrcode_kernel::KernelError) -> bool { + let message = error.to_string(); + xml_parsing_impl::contains_ascii_case_insensitive(&message, "prompt too long") + || xml_parsing_impl::contains_ascii_case_insensitive(&message, "context length") + || xml_parsing_impl::contains_ascii_case_insensitive(&message, "maximum context") + || xml_parsing_impl::contains_ascii_case_insensitive(&message, "too many tokens") +} + +pub(super) fn render_compact_system_prompt( + compact_prompt_context: Option<&str>, + mode: CompactPromptMode, + effective_max_output_tokens: usize, + recent_user_context_messages: &[RecentUserContextMessage], + custom_instructions: Option<&str>, + contract_repair_feedback: Option<&str>, +) -> String { + let incremental_block = match mode { + CompactPromptMode::Fresh => String::new(), + CompactPromptMode::Incremental { previous_summary } => INCREMENTAL_COMPACT_PROMPT_TEMPLATE + .replace("{{PREVIOUS_SUMMARY}}", previous_summary.trim()), + }; + let runtime_context = compact_prompt_context + .filter(|v| !v.trim().is_empty()) + .map(|v| format!("\nCurrent runtime system prompt for context:\n{v}")) + .unwrap_or_default(); + let custom_instruction_block = custom_instructions + .filter(|value| !value.trim().is_empty()) + .map(|value| { + format!( + "\n## Manual Compact Instructions\nFollow these extra requirements for this \ + compact only:\n{value}" + ) + }) + .unwrap_or_default(); + let contract_repair_block = contract_repair_feedback + .filter(|value| !value.trim().is_empty()) + .map(|value| { + format!( + "\n## Contract Repair\nThe previous compact response violated the required XML \ + contract.\nReturn all three XML blocks exactly as specified and do not add any \ + preamble, explanation, or Markdown fence.\nViolation details:\n{value}" + ) + }) + .unwrap_or_default(); + let recent_user_context_block = + render_recent_user_context_candidates(recent_user_context_messages); + + BASE_COMPACT_PROMPT_TEMPLATE + .replace("{{INCREMENTAL_MODE}}", incremental_block.trim()) + .replace("{{CUSTOM_INSTRUCTIONS}}", custom_instruction_block.trim()) + .replace("{{CONTRACT_REPAIR}}", contract_repair_block.trim()) + .replace( + "{{COMPACT_OUTPUT_TOKEN_CAP}}", + &effective_max_output_tokens.to_string(), + ) + .replace( + "{{RECENT_USER_CONTEXT_MESSAGES}}", + recent_user_context_block.trim_end(), + ) + .replace("{{RUNTIME_CONTEXT}}", runtime_context.trim_end()) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct RecentUserContextMessage { + pub(super) index: usize, + pub(super) content: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct ParsedCompactOutput { + pub(super) summary: String, + pub(super) recent_user_context_digest: Option<String>, + pub(super) has_analysis: bool, + pub(super) has_recent_user_context_digest_block: bool, + pub(super) used_fallback: bool, +} + +fn render_recent_user_context_candidates(messages: &[RecentUserContextMessage]) -> String { + if messages.is_empty() { + return "(none)".to_string(); + } + + messages + .iter() + .enumerate() + .map(|(position, message)| format!("Message {}:\n{}", position + 1, message.content.trim())) + .collect::<Vec<_>>() + .join("\n\n") +} + +pub(super) fn collect_recent_user_context_messages( + messages: &[LlmMessage], + keep_recent_user_messages: usize, +) -> Vec<RecentUserContextMessage> { + if keep_recent_user_messages == 0 { + return Vec::new(); + } + + let mut collected = messages + .iter() + .enumerate() + .filter_map(|(index, message)| match message { + LlmMessage::User { + content, + origin: UserMessageOrigin::User, + } => Some(RecentUserContextMessage { + index, + content: content.clone(), + }), + _ => None, + }) + .collect::<Vec<_>>(); + let keep_start = collected + .len() + .saturating_sub(keep_recent_user_messages.max(1)); + collected.drain(..keep_start); + collected +} + +pub(super) fn prepare_compact_input(messages: &[LlmMessage]) -> PreparedCompactInput { + let prompt_mode = latest_previous_summary(messages) + .map(|previous_summary| CompactPromptMode::Incremental { previous_summary }) + .unwrap_or(CompactPromptMode::Fresh); + let messages = messages + .iter() + .filter_map(normalize_compaction_message) + .collect::<Vec<_>>(); + let input_units = compaction_units(&messages).len().max(1); + PreparedCompactInput { + messages, + prompt_mode, + input_units, + } +} + +fn latest_previous_summary(messages: &[LlmMessage]) -> Option<String> { + messages.iter().rev().find_map(|message| match message { + LlmMessage::User { + content, + origin: UserMessageOrigin::CompactSummary, + } => parse_compact_summary_message(content) + .map(|envelope| sanitize_impl::sanitize_compact_summary(&envelope.summary)), + _ => None, + }) +} + +fn normalize_compaction_message(message: &LlmMessage) -> Option<LlmMessage> { + match message { + LlmMessage::User { + content, + origin: UserMessageOrigin::User, + } => Some(LlmMessage::User { + content: content.trim().to_string(), + origin: UserMessageOrigin::User, + }), + LlmMessage::User { .. } => None, + LlmMessage::Assistant { + content, + tool_calls, + .. + } => { + let mut lines = Vec::new(); + let visible = collapse_compaction_whitespace(content); + if !visible.is_empty() { + lines.push(visible); + } + if !tool_calls.is_empty() { + let names = tool_calls + .iter() + .map(|call| call.name.trim()) + .filter(|name| !name.is_empty()) + .collect::<Vec<_>>(); + if !names.is_empty() { + lines.push(format!("Requested tools: {}", names.join(", "))); + } + } + let normalized = lines.join("\n"); + if normalized.trim().is_empty() { + None + } else { + Some(LlmMessage::Assistant { + content: normalized, + tool_calls: Vec::new(), + reasoning: None, + }) + } + }, + LlmMessage::Tool { + tool_call_id, + content, + } => { + let normalized = normalize_compaction_tool_content(content); + if normalized.is_empty() { + None + } else { + Some(LlmMessage::Tool { + tool_call_id: tool_call_id.clone(), + content: normalized, + }) + } + }, + } +} + +fn collapse_compaction_whitespace(content: &str) -> String { + content + .lines() + .map(str::trim) + .collect::<Vec<_>>() + .join("\n") + .split("\n\n\n") + .collect::<Vec<_>>() + .join("\n\n") + .trim() + .to_string() +} + +pub(super) fn normalize_compaction_tool_content(content: &str) -> String { + let stripped_child_ref = sanitize_impl::strip_child_agent_reference_hint(content); + let collapsed = collapse_compaction_whitespace(&stripped_child_ref); + if collapsed.is_empty() { + return String::new(); + } + if is_persisted_output(&collapsed) { + return summarize_persisted_tool_output(&collapsed); + } + collapsed +} + +pub(super) fn sanitize_compact_summary(summary: &str) -> String { + sanitize_impl::sanitize_compact_summary(summary) +} + +pub(super) fn sanitize_recent_user_context_digest(digest: &str) -> String { + sanitize_impl::sanitize_recent_user_context_digest(digest) +} + +pub(super) fn parse_compact_output(content: &str) -> Result<ParsedCompactOutput> { + xml_parsing_impl::parse_compact_output(content) +} + +fn summarize_persisted_tool_output(content: &str) -> String { + let persisted_path = persisted_output_absolute_path(content) + .unwrap_or_else(|| "unknown persisted path".to_string()); + format!( + "Large tool output was persisted instead of inlined.\nPersisted path: \ + {persisted_path}\nPreserve only the conclusion, referenced path, and any error." + ) +} diff --git a/crates/session-runtime/src/context_window/compaction/sanitize.rs b/crates/session-runtime/src/context_window/compaction/sanitize.rs new file mode 100644 index 00000000..ab011744 --- /dev/null +++ b/crates/session-runtime/src/context_window/compaction/sanitize.rs @@ -0,0 +1,243 @@ +use super::*; + +type RegexAccessor = fn() -> &'static Regex; + +#[derive(Clone, Copy)] +struct ReplacementRule { + regex: RegexAccessor, + replacement: &'static str, +} + +#[derive(Clone, Copy)] +struct RouteKeyRule { + key: &'static str, + replacement: &'static str, +} + +const SANITIZE_REPLACEMENT_RULES: &[ReplacementRule] = &[ + ReplacementRule { + regex: direct_child_validation_regex, + replacement: "direct-child validation rejected a stale child reference; use the live \ + direct-child snapshot or the latest live tool result instead.", + }, + ReplacementRule { + regex: child_agent_reference_block_regex, + replacement: "Child agent reference metadata existed earlier, but compacted history is \ + not an authoritative routing source.", + }, + ReplacementRule { + regex: exact_agent_instruction_regex, + replacement: "Use only the latest live child snapshot or tool result for agent routing.", + }, + ReplacementRule { + regex: raw_root_agent_id_regex, + replacement: "<agent-id>", + }, + ReplacementRule { + regex: raw_agent_id_regex, + replacement: "<agent-id>", + }, + ReplacementRule { + regex: raw_subrun_id_regex, + replacement: "<subrun-id>", + }, + ReplacementRule { + regex: raw_session_id_regex, + replacement: "<session-id>", + }, +]; + +const ROUTE_KEY_RULES: &[RouteKeyRule] = &[ + RouteKeyRule { + key: "agentId", + replacement: "${key}<latest-direct-child-agentId>", + }, + RouteKeyRule { + key: "childAgentId", + replacement: "${key}<latest-direct-child-agentId>", + }, + RouteKeyRule { + key: "parentAgentId", + replacement: "${key}<parent-agentId>", + }, + RouteKeyRule { + key: "subRunId", + replacement: "${key}<direct-child-subRunId>", + }, + RouteKeyRule { + key: "parentSubRunId", + replacement: "${key}<parent-subRunId>", + }, + RouteKeyRule { + key: "sessionId", + replacement: "${key}<session-id>", + }, + RouteKeyRule { + key: "childSessionId", + replacement: "${key}<child-session-id>", + }, + RouteKeyRule { + key: "openSessionId", + replacement: "${key}<child-session-id>", + }, +]; + +struct CompiledRouteKeyRule { + replacement: &'static str, + regex: Regex, +} + +pub(super) fn sanitize_compact_summary(summary: &str) -> String { + let had_route_sensitive_content = summary_has_route_sensitive_content(summary); + let mut sanitized = summary.trim().to_string(); + for rule in SANITIZE_REPLACEMENT_RULES { + sanitized = (rule.regex)() + .replace_all(&sanitized, rule.replacement) + .into_owned(); + } + for rule in route_key_rules() { + sanitized = rule + .regex + .replace_all(&sanitized, rule.replacement) + .into_owned(); + } + sanitized = collapse_compaction_whitespace(&sanitized); + if had_route_sensitive_content { + ensure_compact_boundary_section(&sanitized) + } else { + sanitized + } +} + +pub(super) fn sanitize_recent_user_context_digest(digest: &str) -> String { + collapse_compaction_whitespace(digest) +} + +fn ensure_compact_boundary_section(summary: &str) -> String { + if summary.contains("## Compact Boundary") { + return summary.to_string(); + } + format!( + "## Compact Boundary\n- Historical `agentId`, `subRunId`, and `sessionId` values from \ + compacted history are non-authoritative.\n- Use the live direct-child snapshot or the \ + latest live tool result / child notification for routing.\n\n{}", + summary.trim() + ) +} + +fn summary_has_route_sensitive_content(summary: &str) -> bool { + SANITIZE_REPLACEMENT_RULES + .iter() + .any(|rule| (rule.regex)().is_match(summary)) + || route_key_rules() + .iter() + .any(|rule| rule.regex.is_match(summary)) +} + +fn child_agent_reference_block_regex() -> &'static Regex { + static REGEX: OnceLock<Regex> = OnceLock::new(); + REGEX.get_or_init(|| { + Regex::new(r"(?is)Child agent reference:\s*(?:\n- .*)+") + .expect("child agent reference regex should compile") + }) +} + +fn direct_child_validation_regex() -> &'static Regex { + static REGEX: OnceLock<Regex> = OnceLock::new(); + REGEX.get_or_init(|| { + Regex::new(r"(?i)not a direct child of caller[^\n]*") + .expect("direct child validation regex should compile") + }) +} + +fn route_key_rules() -> &'static [CompiledRouteKeyRule] { + static RULES: OnceLock<Vec<CompiledRouteKeyRule>> = OnceLock::new(); + RULES.get_or_init(|| { + ROUTE_KEY_RULES + .iter() + .map(|rule| CompiledRouteKeyRule { + replacement: rule.replacement, + regex: compile_route_key_regex(rule.key), + }) + .collect() + }) +} + +fn compile_route_key_regex(key: &str) -> Regex { + Regex::new(&format!( + r"(?i)(?P<key>`?{key}`?\s*[:=]\s*`?)[^`\s,;\])]+`?" + )) + .expect("route key regex should compile") +} + +fn exact_agent_instruction_regex() -> &'static Regex { + static REGEX: OnceLock<Regex> = OnceLock::new(); + REGEX.get_or_init(|| { + Regex::new( + r"(?i)(use this exact `agentid` value[^\n]*|copy it byte-for-byte[^\n]*|keep `agentid` exact[^\n]*)", + ) + .expect("exact agent instruction regex should compile") + }) +} + +fn raw_root_agent_id_regex() -> &'static Regex { + static REGEX: OnceLock<Regex> = OnceLock::new(); + REGEX.get_or_init(|| { + Regex::new(r"\broot-agent:[A-Za-z0-9._:-]+\b") + .expect("raw root agent id regex should compile") + }) +} + +fn raw_agent_id_regex() -> &'static Regex { + static REGEX: OnceLock<Regex> = OnceLock::new(); + REGEX.get_or_init(|| { + Regex::new(r"\bagent-[A-Za-z0-9._:-]+\b").expect("raw agent id regex should compile") + }) +} + +fn raw_subrun_id_regex() -> &'static Regex { + static REGEX: OnceLock<Regex> = OnceLock::new(); + REGEX.get_or_init(|| { + Regex::new(r"\bsubrun-[A-Za-z0-9._:-]+\b").expect("raw subrun regex should compile") + }) +} + +fn raw_session_id_regex() -> &'static Regex { + static REGEX: OnceLock<Regex> = OnceLock::new(); + REGEX.get_or_init(|| { + Regex::new(r"\bsession-[A-Za-z0-9._:-]+\b").expect("raw session regex should compile") + }) +} + +pub(super) fn strip_child_agent_reference_hint(content: &str) -> String { + let Some((prefix, child_ref_block)) = content.split_once("\n\nChild agent reference:") else { + return content.to_string(); + }; + let mut has_reference_fields = false; + for line in child_ref_block.lines() { + let trimmed = line.trim(); + if trimmed.starts_with("- agentId:") + || trimmed.starts_with("- subRunId:") + || trimmed.starts_with("- openSessionId:") + || trimmed.starts_with("- status:") + { + has_reference_fields = true; + } + } + let child_ref_summary = if has_reference_fields { + "Child agent reference existed in the original tool result. Do not reuse any agentId, \ + subRunId, or sessionId from compacted history; rely on the latest live tool result or \ + current direct-child snapshot instead." + .to_string() + } else { + "Child agent reference metadata existed in the original tool result, but compacted history \ + is not an authoritative source for later agent routing." + .to_string() + }; + let prefix = prefix.trim(); + if prefix.is_empty() { + child_ref_summary + } else { + format!("{prefix}\n\n{child_ref_summary}") + } +} diff --git a/crates/session-runtime/src/context_window/compaction/tests.rs b/crates/session-runtime/src/context_window/compaction/tests.rs new file mode 100644 index 00000000..e5eafc32 --- /dev/null +++ b/crates/session-runtime/src/context_window/compaction/tests.rs @@ -0,0 +1,515 @@ +use super::*; + +fn test_compact_config() -> CompactConfig { + CompactConfig { + keep_recent_turns: 1, + keep_recent_user_messages: 8, + trigger: astrcode_core::CompactTrigger::Manual, + summary_reserve_tokens: 20_000, + max_output_tokens: 20_000, + max_retry_attempts: 3, + history_path: None, + custom_instructions: None, + } +} + +#[test] +fn render_compact_system_prompt_keeps_do_not_continue_instruction_intact() { + let prompt = + render_compact_system_prompt(None, CompactPromptMode::Fresh, 20_000, &[], None, None); + + assert!( + prompt.contains("**Do NOT continue the conversation.**"), + "compact prompt must explicitly instruct the summarizer not to continue the session" + ); +} + +#[test] +fn render_compact_system_prompt_renders_incremental_block() { + let prompt = render_compact_system_prompt( + None, + CompactPromptMode::Incremental { + previous_summary: "older summary".to_string(), + }, + 20_000, + &[], + None, + None, + ); + + assert!(prompt.contains("## Incremental Mode")); + assert!(prompt.contains("<previous-summary>")); + assert!(prompt.contains("older summary")); +} + +#[test] +fn render_compact_system_prompt_includes_output_cap_and_recent_user_context_messages() { + let prompt = render_compact_system_prompt( + None, + CompactPromptMode::Fresh, + 12_345, + &[RecentUserContextMessage { + index: 7, + content: "保留这条约束".to_string(), + }], + None, + None, + ); + + assert!(prompt.contains("12345")); + assert!(prompt.contains("Recently Preserved Real User Messages")); + assert!(prompt.contains("保留这条约束")); + assert!(prompt.contains("<recent_user_context_digest>")); +} + +#[test] +fn render_compact_system_prompt_includes_contract_repair_feedback() { + let prompt = render_compact_system_prompt( + None, + CompactPromptMode::Fresh, + 12_345, + &[], + None, + Some("missing <recent_user_context_digest>"), + ); + + assert!(prompt.contains("## Contract Repair")); + assert!(prompt.contains("missing <recent_user_context_digest>")); +} + +#[test] +fn merge_compact_prompt_context_appends_hook_suffix_after_runtime_prompt() { + let merged = merge_compact_prompt_context(Some("base"), Some("hook")) + .expect("merged compact prompt context should exist"); + + assert_eq!(merged, "base\n\nhook"); +} + +#[test] +fn merge_compact_prompt_context_returns_none_when_both_empty() { + assert!(merge_compact_prompt_context(None, None).is_none()); + assert!(merge_compact_prompt_context(Some(" "), Some(" \n\t ")).is_none()); +} + +#[test] +fn parse_compact_output_requires_non_empty_content() { + let error = parse_compact_output(" ").expect_err("empty compact output should fail"); + assert!(error.to_string().contains("missing <summary> block")); +} + +#[test] +fn parse_compact_output_requires_closed_summary_block() { + let error = parse_compact_output("<summary>open").expect_err("unclosed summary should fail"); + assert!(error.to_string().contains("closing </summary>")); +} + +#[test] +fn parse_compact_output_prefers_summary_block() { + let parsed = parse_compact_output( + "<analysis>draft</analysis><summary>\nSection\n</\ + summary><recent_user_context_digest>(none)</recent_user_context_digest>", + ) + .expect("summary should parse"); + + assert_eq!(parsed.summary, "Section"); + assert_eq!(parsed.recent_user_context_digest.as_deref(), Some("(none)")); + assert!(parsed.has_analysis); + assert!(parsed.has_recent_user_context_digest_block); +} + +#[test] +fn parse_compact_output_accepts_case_insensitive_summary_block() { + let parsed = parse_compact_output( + "<ANALYSIS>draft</ANALYSIS><SUMMARY>Section</SUMMARY><RECENT_USER_CONTEXT_DIGEST>digest</\ + RECENT_USER_CONTEXT_DIGEST>", + ) + .expect("summary should parse"); + + assert_eq!(parsed.summary, "Section"); + assert_eq!(parsed.recent_user_context_digest.as_deref(), Some("digest")); + assert!(parsed.has_analysis); + assert!(parsed.has_recent_user_context_digest_block); +} + +#[test] +fn parse_compact_output_falls_back_to_plain_text_summary() { + let parsed = parse_compact_output("## Goal\n- preserve current task") + .expect("plain text summary should parse"); + + assert_eq!(parsed.summary, "## Goal\n- preserve current task"); + assert!(!parsed.has_analysis); + assert!(!parsed.has_recent_user_context_digest_block); +} + +#[test] +fn parse_compact_output_strips_outer_code_fence_before_parsing() { + let parsed = + parse_compact_output("```xml\n<analysis>draft</analysis>\n<summary>Section</summary>\n```") + .expect("fenced xml summary should parse"); + + assert_eq!(parsed.summary, "Section"); + assert!(parsed.has_analysis); + assert!(!parsed.has_recent_user_context_digest_block); +} + +#[test] +fn compact_contract_violation_flags_missing_digest_block() { + let violation = CompactContractViolation::from_parsed_output(&ParsedCompactOutput { + summary: "Section".to_string(), + recent_user_context_digest: None, + has_analysis: true, + has_recent_user_context_digest_block: false, + used_fallback: false, + }) + .expect("missing digest block should violate contract"); + + assert!(violation.detail.contains("recent_user_context_digest")); +} + +#[test] +fn parse_compact_output_strips_common_summary_preamble_in_fallback() { + let parsed = parse_compact_output("Summary:\n## Goal\n- preserve current task") + .expect("summary preamble fallback should parse"); + + assert_eq!(parsed.summary, "## Goal\n- preserve current task"); +} + +#[test] +fn parse_compact_output_accepts_summary_tag_attributes() { + let parsed = parse_compact_output( + "<analysis class=\"draft\">draft</analysis><summary format=\"markdown\">Section</summary>", + ) + .expect("tag attributes should parse"); + + assert_eq!(parsed.summary, "Section"); + assert!(parsed.has_analysis); +} + +#[test] +fn parse_compact_output_does_not_treat_analysis_only_as_summary() { + let error = parse_compact_output("<analysis>draft</analysis>") + .expect_err("analysis-only output should still fail"); + + assert!(error.to_string().contains("missing <summary> block")); +} + +#[test] +fn split_for_compaction_preserves_recent_real_user_turns() { + let messages = vec![ + LlmMessage::User { + content: "older".to_string(), + origin: UserMessageOrigin::User, + }, + LlmMessage::Assistant { + content: "ack".to_string(), + tool_calls: Vec::new(), + reasoning: None, + }, + LlmMessage::User { + content: format_compact_summary("older"), + origin: UserMessageOrigin::CompactSummary, + }, + LlmMessage::User { + content: "newer".to_string(), + origin: UserMessageOrigin::User, + }, + ]; + + let split = split_for_compaction(&messages, 1).expect("split should exist"); + + assert_eq!(split.keep_start, 3); + assert_eq!(split.prefix.len(), 3); + assert_eq!(split.suffix.len(), 1); +} + +#[test] +fn split_for_compaction_falls_back_to_assistant_boundary_for_single_turn() { + let messages = vec![ + LlmMessage::User { + content: "task".to_string(), + origin: UserMessageOrigin::User, + }, + LlmMessage::Assistant { + content: "step 1".to_string(), + tool_calls: Vec::new(), + reasoning: None, + }, + LlmMessage::Assistant { + content: "step 2".to_string(), + tool_calls: Vec::new(), + reasoning: None, + }, + ]; + + let split = split_for_compaction(&messages, 1).expect("single turn should still split"); + assert_eq!(split.keep_start, 2); +} + +#[test] +fn compacted_messages_inserts_summary_as_compact_user_message() { + let compacted = compacted_messages("Older history", None, &[], 0, Vec::new()); + + assert!(matches!( + &compacted[0], + LlmMessage::User { + origin: UserMessageOrigin::CompactSummary, + .. + } + )); + assert_eq!(compacted.len(), 1); +} + +#[test] +fn prepare_compact_input_strips_history_note_from_previous_summary() { + let filtered = prepare_compact_input(&[LlmMessage::User { + content: CompactSummaryEnvelope::new("older summary") + .with_history_path("~/.astrcode/projects/demo/sessions/abc/session-abc.jsonl") + .render(), + origin: UserMessageOrigin::CompactSummary, + }]); + + assert!(matches!( + filtered.prompt_mode, + CompactPromptMode::Incremental { ref previous_summary } + if previous_summary == "older summary" + )); +} + +#[test] +fn prepare_compact_input_skips_synthetic_user_messages() { + let filtered = prepare_compact_input(&[ + LlmMessage::User { + content: "summary".to_string(), + origin: UserMessageOrigin::CompactSummary, + }, + LlmMessage::User { + content: "wake up".to_string(), + origin: UserMessageOrigin::ReactivationPrompt, + }, + LlmMessage::User { + content: "digest".to_string(), + origin: UserMessageOrigin::RecentUserContextDigest, + }, + LlmMessage::User { + content: "preserved".to_string(), + origin: UserMessageOrigin::RecentUserContext, + }, + LlmMessage::User { + content: "real user".to_string(), + origin: UserMessageOrigin::User, + }, + ]); + + assert_eq!(filtered.messages.len(), 1); + assert!(matches!( + &filtered.messages[0], + LlmMessage::User { + content, + origin: UserMessageOrigin::User + } if content == "real user" + )); +} + +#[test] +fn normalize_compaction_tool_content_removes_exact_child_identifiers() { + let normalized = normalize_compaction_tool_content( + "spawn 已在后台启动。\n\nChild agent reference:\n- agentId: agent-1\n- subRunId: \ + subrun-1\n- sessionId: session-parent\n- openSessionId: session-child\n- status: \ + running\nUse this exact `agentId` value in later send/observe/close calls.", + ); + + assert!(normalized.contains("spawn 已在后台启动。")); + assert!(normalized.contains("Do not reuse any agentId")); + assert!(!normalized.contains("agent-1")); + assert!(!normalized.contains("subrun-1")); + assert!(!normalized.contains("session-child")); +} + + +#[test] +fn sanitize_compact_summary_replaces_stale_route_identifiers_with_boundary_guidance() { + let sanitized = sanitize_compact_summary( + "## Progress\n- Spawned agent-3 and later called observe(agent-2).\n- Error: agent \ + 'agent-2' is not a direct child of caller 'agent-root:session-parent' (actual parent: \ + agent-1); send/observe/close only support direct children.\n- Child ref payload: \ + agentId=agent-2 subRunId=subrun-2 openSessionId=session-child-2", + ); + + assert!(sanitized.contains("## Compact Boundary")); + assert!(sanitized.contains("live direct-child snapshot")); + assert!(sanitized.contains("<agent-id>")); + assert!(sanitized.contains("<subrun-id>") || sanitized.contains("<direct-child-subRunId>")); + assert!(sanitized.contains("<child-session-id>") || sanitized.contains("<session-id>")); + assert!(!sanitized.contains("agent-2")); + assert!(!sanitized.contains("subrun-2")); + assert!(!sanitized.contains("session-child-2")); + assert!(!sanitized.contains("not a direct child of caller")); +} + +#[test] +fn drop_oldest_compaction_unit_is_deterministic() { + let mut prefix = vec![ + LlmMessage::User { + content: "task".to_string(), + origin: UserMessageOrigin::User, + }, + LlmMessage::Assistant { + content: "step-1".to_string(), + tool_calls: Vec::new(), + reasoning: None, + }, + LlmMessage::Assistant { + content: "step-2".to_string(), + tool_calls: Vec::new(), + reasoning: None, + }, + ]; + + assert!(drop_oldest_compaction_unit(&mut prefix)); + assert!(matches!( + &prefix[0], + LlmMessage::Assistant { content, .. } if content == "step-1" + )); +} + +#[test] +fn trim_prefix_until_compact_request_fits_drops_oldest_units_before_calling_llm() { + let mut prefix = vec![ + LlmMessage::User { + content: "very old request ".repeat(1200), + origin: UserMessageOrigin::User, + }, + LlmMessage::Assistant { + content: "first step".repeat(1200), + tool_calls: Vec::new(), + reasoning: None, + }, + LlmMessage::Assistant { + content: "latest step".to_string(), + tool_calls: Vec::new(), + reasoning: None, + }, + ]; + + let trimmed = trim_prefix_until_compact_request_fits( + &mut prefix, + None, + ModelLimits { + context_window: 23_000, + max_output_tokens: 2_000, + }, + &test_compact_config(), + &[], + ); + + assert!(trimmed); + assert!(matches!( + prefix.as_slice(), + [LlmMessage::Assistant { content, .. }] if content == "latest step" + )); +} + +#[test] +fn can_compact_returns_false_for_empty_messages() { + assert!(!can_compact(&[], 2)); +} + +#[test] +fn can_compact_returns_true_when_enough_turns() { + let messages = vec![ + LlmMessage::User { + content: "turn-1".to_string(), + origin: UserMessageOrigin::User, + }, + LlmMessage::Assistant { + content: "reply".to_string(), + tool_calls: Vec::new(), + reasoning: None, + }, + LlmMessage::User { + content: "turn-2".to_string(), + origin: UserMessageOrigin::User, + }, + ]; + assert!(can_compact(&messages, 1)); +} + +#[test] +fn collect_recent_user_context_messages_only_keeps_real_users() { + let recent = collect_recent_user_context_messages( + &[ + LlmMessage::User { + content: "summary".to_string(), + origin: UserMessageOrigin::CompactSummary, + }, + LlmMessage::User { + content: "recent".to_string(), + origin: UserMessageOrigin::User, + }, + LlmMessage::User { + content: "digest".to_string(), + origin: UserMessageOrigin::RecentUserContextDigest, + }, + LlmMessage::User { + content: "latest".to_string(), + origin: UserMessageOrigin::User, + }, + ], + 8, + ); + + assert_eq!(recent.len(), 2); + assert_eq!(recent[0].content, "recent"); + assert_eq!(recent[1].content, "latest"); +} + +#[test] +fn compacted_messages_put_recent_user_context_before_suffix_without_duplicates() { + let messages = compacted_messages( + "Older history", + Some("- keep current objective"), + &[RecentUserContextMessage { + index: 1, + content: "latest user".to_string(), + }], + 1, + vec![ + LlmMessage::User { + content: "latest user".to_string(), + origin: UserMessageOrigin::User, + }, + LlmMessage::Assistant { + content: "latest assistant".to_string(), + tool_calls: Vec::new(), + reasoning: None, + }, + ], + ); + + assert!(matches!( + &messages[0], + LlmMessage::User { + origin: UserMessageOrigin::CompactSummary, + .. + } + )); + assert!(matches!( + &messages[1], + LlmMessage::User { + origin: UserMessageOrigin::RecentUserContextDigest, + .. + } + )); + assert!(matches!( + &messages[2], + LlmMessage::User { + origin: UserMessageOrigin::RecentUserContext, + content, + } if content == "latest user" + )); + assert!(matches!( + &messages[3], + LlmMessage::Assistant { content, .. } if content == "latest assistant" + )); + assert_eq!(messages.len(), 4); +} diff --git a/crates/session-runtime/src/context_window/compaction/xml_parsing.rs b/crates/session-runtime/src/context_window/compaction/xml_parsing.rs new file mode 100644 index 00000000..77b549b2 --- /dev/null +++ b/crates/session-runtime/src/context_window/compaction/xml_parsing.rs @@ -0,0 +1,236 @@ +use super::*; + +pub(super) fn parse_compact_output(content: &str) -> Result<ParsedCompactOutput> { + let normalized = strip_outer_markdown_code_fence(content); + let has_analysis = extract_xml_block(&normalized, "analysis").is_some(); + let recent_user_context_digest = extract_xml_block(&normalized, "recent_user_context_digest"); + let has_recent_user_context_digest_block = recent_user_context_digest.is_some(); + if !has_analysis { + log::warn!("compact: missing <analysis> block in LLM response"); + } + + if has_opening_xml_tag(&normalized, "summary") && !has_closing_xml_tag(&normalized, "summary") { + return Err(AstrError::LlmStreamError( + "compact response missing closing </summary> tag".to_string(), + )); + } + if has_opening_xml_tag(&normalized, "recent_user_context_digest") + && !has_closing_xml_tag(&normalized, "recent_user_context_digest") + { + return Err(AstrError::LlmStreamError( + "compact response missing closing </recent_user_context_digest> tag".to_string(), + )); + } + + let mut used_fallback = false; + let summary = if let Some(summary) = extract_xml_block(&normalized, "summary") { + summary.to_string() + } else if let Some(structured) = extract_structured_summary_fallback(&normalized) { + used_fallback = true; + structured + } else { + let fallback = strip_xml_block(&normalized, "analysis"); + let fallback = clean_compact_fallback_text(&fallback); + if fallback.is_empty() { + return Err(AstrError::LlmStreamError( + "compact response missing <summary> block".to_string(), + )); + } + log::warn!("compact: missing <summary> block, falling back to raw content"); + used_fallback = true; + fallback + }; + if summary.is_empty() { + return Err(AstrError::LlmStreamError( + "compact summary response was empty".to_string(), + )); + } + + Ok(ParsedCompactOutput { + summary, + recent_user_context_digest: recent_user_context_digest.map(str::to_string), + has_analysis, + has_recent_user_context_digest_block, + used_fallback, + }) +} + +fn extract_structured_summary_fallback(content: &str) -> Option<String> { + let cleaned = clean_compact_fallback_text(content); + let lower = cleaned.to_ascii_lowercase(); + let candidates = ["## summary", "# summary", "summary:"]; + for marker in candidates { + if let Some(start) = lower.find(marker) { + let body = cleaned[start + marker.len()..].trim(); + if !body.is_empty() { + return Some(body.to_string()); + } + } + } + None +} + +fn extract_xml_block<'a>(content: &'a str, tag: &str) -> Option<&'a str> { + xml_block_regex(tag) + .captures(content) + .and_then(|captures| captures.name("body")) + .map(|body| body.as_str().trim()) +} + +fn strip_xml_block(content: &str, tag: &str) -> String { + xml_block_regex(tag).replace(content, "").into_owned() +} + +fn has_opening_xml_tag(content: &str, tag: &str) -> bool { + xml_opening_tag_regex(tag).is_match(content) +} + +fn has_closing_xml_tag(content: &str, tag: &str) -> bool { + xml_closing_tag_regex(tag).is_match(content) +} + +fn strip_markdown_code_fence(content: &str) -> String { + let trimmed = content.trim(); + if !trimmed.starts_with("```") { + return trimmed.to_string(); + } + + let mut lines = trimmed.lines(); + let Some(first_line) = lines.next() else { + return trimmed.to_string(); + }; + if !first_line.trim_start().starts_with("```") { + return trimmed.to_string(); + } + + let body = lines.collect::<Vec<_>>().join("\n"); + let body = body.trim_end(); + body.strip_suffix("```").unwrap_or(body).trim().to_string() +} + +fn strip_outer_markdown_code_fence(content: &str) -> String { + let mut current = content.trim().to_string(); + loop { + let stripped = strip_markdown_code_fence(¤t); + if stripped == current { + return current; + } + current = stripped; + } +} + +fn clean_compact_fallback_text(content: &str) -> String { + let without_code_fence = strip_outer_markdown_code_fence(content); + let lines = without_code_fence + .lines() + .map(str::trim_end) + .collect::<Vec<_>>(); + let first_meaningful = lines + .iter() + .position(|line| !line.trim().is_empty()) + .unwrap_or(lines.len()); + let cleaned = lines + .into_iter() + .skip(first_meaningful) + .collect::<Vec<_>>() + .join("\n") + .trim() + .to_string(); + strip_leading_summary_preamble(&cleaned) +} + +fn strip_leading_summary_preamble(content: &str) -> String { + let mut lines = content.lines(); + let Some(first_line) = lines.next() else { + return String::new(); + }; + let trimmed_first_line = first_line.trim(); + if is_summary_preamble_line(trimmed_first_line) { + return lines.collect::<Vec<_>>().join("\n").trim().to_string(); + } + content.trim().to_string() +} + +fn is_summary_preamble_line(line: &str) -> bool { + let normalized = line + .trim_matches(|ch: char| matches!(ch, '*' | '#' | '-' | ':' | ' ')) + .trim(); + normalized.eq_ignore_ascii_case("summary") + || normalized.eq_ignore_ascii_case("here is the summary") + || normalized.eq_ignore_ascii_case("compact summary") + || normalized.eq_ignore_ascii_case("here's the summary") +} + +fn xml_block_regex(tag: &str) -> &'static Regex { + static SUMMARY_REGEX: OnceLock<Regex> = OnceLock::new(); + static ANALYSIS_REGEX: OnceLock<Regex> = OnceLock::new(); + static RECENT_USER_CONTEXT_DIGEST_REGEX: OnceLock<Regex> = OnceLock::new(); + + match tag { + "summary" => SUMMARY_REGEX.get_or_init(|| { + Regex::new(r"(?is)<summary(?:\s+[^>]*)?\s*>(?P<body>.*?)</summary\s*>") + .expect("summary regex should compile") + }), + "analysis" => ANALYSIS_REGEX.get_or_init(|| { + Regex::new(r"(?is)<analysis(?:\s+[^>]*)?\s*>(?P<body>.*?)</analysis\s*>") + .expect("analysis regex should compile") + }), + "recent_user_context_digest" => RECENT_USER_CONTEXT_DIGEST_REGEX.get_or_init(|| { + Regex::new( + r"(?is)<recent_user_context_digest(?:\s+[^>]*)?\s*>(?P<body>.*?)</recent_user_context_digest\s*>", + ) + .expect("recent user context digest regex should compile") + }), + other => panic!("unsupported compact xml tag: {other}"), + } +} + +fn xml_opening_tag_regex(tag: &str) -> &'static Regex { + static SUMMARY_REGEX: OnceLock<Regex> = OnceLock::new(); + static ANALYSIS_REGEX: OnceLock<Regex> = OnceLock::new(); + static RECENT_USER_CONTEXT_DIGEST_REGEX: OnceLock<Regex> = OnceLock::new(); + + match tag { + "summary" => SUMMARY_REGEX.get_or_init(|| { + Regex::new(r"(?i)<summary(?:\s+[^>]*)?\s*>") + .expect("summary opening regex should compile") + }), + "analysis" => ANALYSIS_REGEX.get_or_init(|| { + Regex::new(r"(?i)<analysis(?:\s+[^>]*)?\s*>") + .expect("analysis opening regex should compile") + }), + "recent_user_context_digest" => RECENT_USER_CONTEXT_DIGEST_REGEX.get_or_init(|| { + Regex::new(r"(?i)<recent_user_context_digest(?:\s+[^>]*)?\s*>") + .expect("recent user context digest opening regex should compile") + }), + other => panic!("unsupported compact xml tag: {other}"), + } +} + +fn xml_closing_tag_regex(tag: &str) -> &'static Regex { + static SUMMARY_REGEX: OnceLock<Regex> = OnceLock::new(); + static ANALYSIS_REGEX: OnceLock<Regex> = OnceLock::new(); + static RECENT_USER_CONTEXT_DIGEST_REGEX: OnceLock<Regex> = OnceLock::new(); + + match tag { + "summary" => SUMMARY_REGEX.get_or_init(|| { + Regex::new(r"(?i)</summary\s*>").expect("summary closing regex should compile") + }), + "analysis" => ANALYSIS_REGEX.get_or_init(|| { + Regex::new(r"(?i)</analysis\s*>").expect("analysis closing regex should compile") + }), + "recent_user_context_digest" => RECENT_USER_CONTEXT_DIGEST_REGEX.get_or_init(|| { + Regex::new(r"(?i)</recent_user_context_digest\s*>") + .expect("recent user context digest closing regex should compile") + }), + other => panic!("unsupported compact xml tag: {other}"), + } +} + +pub(super) fn contains_ascii_case_insensitive(haystack: &str, needle: &str) -> bool { + let needle = needle.as_bytes(); + haystack + .as_bytes() + .windows(needle.len()) + .any(|window| window.eq_ignore_ascii_case(needle)) +} diff --git a/crates/session-runtime/src/context_window/file_access.rs b/crates/session-runtime/src/context_window/file_access.rs index 59aa30dd..a23b842f 100644 --- a/crates/session-runtime/src/context_window/file_access.rs +++ b/crates/session-runtime/src/context_window/file_access.rs @@ -15,7 +15,7 @@ use serde::Deserialize; use super::token_usage::estimate_text_tokens; #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct FileRecoveryConfig { +pub(crate) struct FileRecoveryConfig { pub max_tracked_files: usize, pub max_recovered_files: usize, pub recovery_token_budget: usize, @@ -29,7 +29,7 @@ struct TrackedFileAccess { } #[derive(Debug, Clone, Default)] -pub struct FileAccessTracker { +pub(crate) struct FileAccessTracker { accesses: VecDeque<TrackedFileAccess>, max_tracked_files: usize, } @@ -329,6 +329,7 @@ mod tests { output: "fn nested() {}".to_string(), error: None, metadata: Some(json!({"path": absolute.to_string_lossy()})), + child_ref: None, duration_ms: 1, truncated: false, }; diff --git a/crates/session-runtime/src/context_window/micro_compact.rs b/crates/session-runtime/src/context_window/micro_compact.rs index 930d8ea7..bec86dfe 100644 --- a/crates/session-runtime/src/context_window/micro_compact.rs +++ b/crates/session-runtime/src/context_window/micro_compact.rs @@ -14,20 +14,14 @@ use chrono::{DateTime, Utc}; use super::tool_results::tool_call_name_map; #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct MicroCompactConfig { +pub(crate) struct MicroCompactConfig { pub gap_threshold: Duration, pub keep_recent_results: usize, } -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct MicroCompactStats { - pub cleared_tool_results: usize, -} - #[derive(Debug, Clone)] -pub struct MicroCompactOutcome { +pub(crate) struct MicroCompactOutcome { pub messages: Vec<LlmMessage>, - pub stats: MicroCompactStats, } #[derive(Debug, Clone)] @@ -37,7 +31,7 @@ struct TrackedToolResult { } #[derive(Debug, Clone, Default)] -pub struct MicroCompactState { +pub(crate) struct MicroCompactState { tracked_results: VecDeque<TrackedToolResult>, last_prompt_activity: Option<Instant>, } @@ -102,14 +96,12 @@ impl MicroCompactState { let Some(last_activity) = self.last_prompt_activity else { return MicroCompactOutcome { messages: messages.to_vec(), - stats: MicroCompactStats::default(), }; }; if now.duration_since(last_activity) < config.gap_threshold { return MicroCompactOutcome { messages: messages.to_vec(), - stats: MicroCompactStats::default(), }; } @@ -117,7 +109,6 @@ impl MicroCompactState { if self.tracked_results.len() <= keep_recent_results { return MicroCompactOutcome { messages: messages.to_vec(), - stats: MicroCompactStats::default(), }; } @@ -146,12 +137,10 @@ impl MicroCompactState { if stale_ids.is_empty() { return MicroCompactOutcome { messages: messages.to_vec(), - stats: MicroCompactStats::default(), }; } let mut compacted = messages.to_vec(); - let mut cleared = 0usize; for message in &mut compacted { let LlmMessage::Tool { tool_call_id, @@ -173,14 +162,10 @@ impl MicroCompactState { "[micro-compacted stale tool result from '{tool_name}' after idle gap; rerun the \ tool if exact output is needed]" ); - cleared += 1; } MicroCompactOutcome { messages: compacted, - stats: MicroCompactStats { - cleared_tool_results: cleared, - }, } } @@ -263,7 +248,6 @@ mod tests { now + Duration::from_secs(31), ); - assert_eq!(outcome.stats.cleared_tool_results, 1); assert!(matches!( &outcome.messages[2], LlmMessage::Tool { content, .. } if content.contains("micro-compacted") @@ -309,7 +293,6 @@ mod tests { now + Duration::from_secs(5), ); - assert_eq!(outcome.stats.cleared_tool_results, 0); assert_eq!(outcome.messages, messages); } @@ -361,7 +344,7 @@ mod tests { config, now + Duration::from_secs(35), ); - assert_eq!(early.stats.cleared_tool_results, 0); + assert_eq!(early.messages, messages); let late = state.apply_if_idle( &messages, @@ -369,7 +352,10 @@ mod tests { config, now + Duration::from_secs(41), ); - assert_eq!(late.stats.cleared_tool_results, 1); + assert!(matches!( + &late.messages[1], + LlmMessage::Tool { content, .. } if content.contains("micro-compacted") + )); } #[test] @@ -419,7 +405,7 @@ mod tests { config, now + Duration::from_secs(15), ); - assert_eq!(early.stats.cleared_tool_results, 0); + assert_eq!(early.messages, messages); let mut late_state = state; let late = late_state.apply_if_idle( @@ -428,6 +414,9 @@ mod tests { config, now + Duration::from_secs(25), ); - assert_eq!(late.stats.cleared_tool_results, 1); + assert!(matches!( + &late.messages[1], + LlmMessage::Tool { content, .. } if content.contains("micro-compacted") + )); } } diff --git a/crates/session-runtime/src/context_window/mod.rs b/crates/session-runtime/src/context_window/mod.rs index 36578e9d..ca3cd72b 100644 --- a/crates/session-runtime/src/context_window/mod.rs +++ b/crates/session-runtime/src/context_window/mod.rs @@ -11,12 +11,12 @@ //! Final request assembly must not be implemented here. //! That flow is implemented in `session-runtime::turn::request`. -pub mod compaction; -pub mod file_access; -pub mod micro_compact; -pub mod prune_pass; -pub mod settings; -pub mod token_usage; +pub(crate) mod compaction; +pub(crate) mod file_access; +pub(crate) mod micro_compact; +pub(crate) mod prune_pass; +pub(crate) mod settings; +pub(crate) mod token_usage; pub(crate) mod tool_results; -pub use settings::ContextWindowSettings; +pub(crate) use settings::ContextWindowSettings; diff --git a/crates/session-runtime/src/context_window/prune_pass.rs b/crates/session-runtime/src/context_window/prune_pass.rs index 35cdc7c1..63add324 100644 --- a/crates/session-runtime/src/context_window/prune_pass.rs +++ b/crates/session-runtime/src/context_window/prune_pass.rs @@ -21,14 +21,14 @@ use super::tool_results::tool_call_name_map; /// Prune pass 执行统计。 #[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct PruneStats { +pub(crate) struct PruneStats { pub truncated_tool_results: usize, pub cleared_tool_results: usize, } /// Prune pass 执行结果。 #[derive(Debug, Clone)] -pub struct PruneOutcome { +pub(crate) struct PruneOutcome { pub messages: Vec<LlmMessage>, pub stats: PruneStats, } diff --git a/crates/session-runtime/src/context_window/settings.rs b/crates/session-runtime/src/context_window/settings.rs index 7eacfc95..13e67d17 100644 --- a/crates/session-runtime/src/context_window/settings.rs +++ b/crates/session-runtime/src/context_window/settings.rs @@ -6,8 +6,13 @@ use astrcode_core::ResolvedRuntimeConfig; pub struct ContextWindowSettings { pub auto_compact_enabled: bool, pub compact_threshold_percent: u8, + pub reserved_context_size: usize, + pub summary_reserve_tokens: usize, + pub compact_max_output_tokens: usize, + pub compact_max_retry_attempts: usize, pub tool_result_max_bytes: usize, pub compact_keep_recent_turns: usize, + pub compact_keep_recent_user_messages: usize, pub max_tracked_files: usize, pub max_recovered_files: usize, pub recovery_token_budget: usize, @@ -35,11 +40,20 @@ impl ContextWindowSettings { impl From<&ResolvedRuntimeConfig> for ContextWindowSettings { fn from(config: &ResolvedRuntimeConfig) -> Self { + // TODO: 如果未来需要 mode 感知的上下文压缩,请在 compact 参数模型上做显式覆盖, + // 而不是重新引入 summarize/truncate/ignore 这类未落地的策略枚举。 Self { auto_compact_enabled: config.auto_compact_enabled, compact_threshold_percent: config.compact_threshold_percent, + reserved_context_size: config.reserved_context_size.max(1), + summary_reserve_tokens: config.summary_reserve_tokens.max(1), + compact_max_output_tokens: config.compact_max_output_tokens.max(1), + compact_max_retry_attempts: usize::from(config.compact_max_retry_attempts.max(1)), tool_result_max_bytes: config.tool_result_max_bytes, compact_keep_recent_turns: usize::from(config.compact_keep_recent_turns), + compact_keep_recent_user_messages: usize::from( + config.compact_keep_recent_user_messages.max(1), + ), max_tracked_files: config.max_tracked_files, max_recovered_files: config.max_recovered_files.max(1), recovery_token_budget: config.recovery_token_budget.max(1), diff --git a/crates/session-runtime/src/context_window/templates/compact/base.md b/crates/session-runtime/src/context_window/templates/compact/base.md index 186665d8..32a332fc 100644 --- a/crates/session-runtime/src/context_window/templates/compact/base.md +++ b/crates/session-runtime/src/context_window/templates/compact/base.md @@ -1,29 +1,43 @@ You are a context summarization assistant for a coding-agent session. -Your summary will replace earlier conversation history so another agent can continue seamlessly. +Your summary will be placed at the start of a continuing session so another agent can continue seamlessly. ## CRITICAL RULES **DO NOT CALL ANY TOOLS.** This is for summary generation only. **Do NOT continue the conversation.** Only output the structured summary. +**Do NOT wrap the answer in Markdown code fences.** +**Even if context is incomplete, still return `<analysis>`, `<summary>`, and `<recent_user_context_digest>` blocks.** +**The entire output must stay within {{COMPACT_OUTPUT_TOKEN_CAP}} tokens.** ## Compression Priorities (highest -> lowest) -1. **Current Task State** - What's being worked on, exact status, immediate next steps -2. **Errors & Solutions** - Stack traces, error messages, and how they were resolved -3. **User Requests** - All user messages verbatim in order -4. **Code Changes** - Final working versions; for code < 15 lines keep all, otherwise signatures + key logic only -5. **Key Decisions** - The "why" behind choices, not just "what" -6. **Discoveries** - Important learnings about the codebase, APIs, or constraints -7. **Environment** - Config/setup only if relevant to continuing work +1. Current task state and exact next step +2. Errors, failures, and how they were resolved +3. User constraints and corrections +4. Code changes, exact file paths, and exact function/type names +5. Important decisions and why they were made +6. Discoveries about the codebase or environment that matter for continuation ## Compression Rules **MUST KEEP:** Error messages, stack traces, working solutions, current task, exact file paths, function names +**DO NOT PRESERVE AS AUTHORITATIVE FACTS:** Historical `agentId`, `subRunId`, `sessionId`, copied child reference payloads, or stale direct-child ownership errors from compacted history **MERGE:** Similar discussions into single summary points **REMOVE:** Redundant explanations, failed attempts (keep only lessons learned), boilerplate code **CONDENSE:** Long code blocks -> signatures + key logic; long explanations -> bullet points +**FOR RECENT USER CONTEXT DIGEST:** Focus only on current goal, newly added constraints/corrections, and the most recent explicit next step. +**IGNORE AS NOISE:** Tool outputs, tool echoes, file recovery content, internal helper prompts, and repeated restatements of the recent user messages. {{INCREMENTAL_MODE}} +{{CUSTOM_INSTRUCTIONS}} + +{{CONTRACT_REPAIR}} + +## Recently Preserved Real User Messages +These messages will be preserved verbatim after compaction. Do not restate them in full inside the main summary. + +{{RECENT_USER_CONTEXT_MESSAGES}} + ## Output Format -Return exactly two XML blocks: +Return exactly three XML blocks: <analysis> [Self-check before writing] @@ -36,7 +50,7 @@ Return exactly two XML blocks: <summary> ## Goal -[What the user is trying to accomplish - can be multiple items] +- [What the user is trying to accomplish] ## Constraints & Preferences - [User-specified constraints, preferences, requirements] @@ -70,7 +84,7 @@ Return exactly two XML blocks: - **Cause**: [Root cause] - **Fix**: [How it was resolved] -## Next Steps +## Context for Continuing Work 1. [Ordered list of what should happen next] ## Critical Context @@ -78,12 +92,22 @@ Return exactly two XML blocks: </summary> +<recent_user_context_digest> +- [Very short digest of the recent real user messages, ideally 2-4 bullets total] +- [If there are no recent user messages, write "(none)"] +</recent_user_context_digest> + ## Rules -- Output **only** the <analysis> and <summary> blocks - no preamble, no closing remarks. +- Output **only** the <analysis>, <summary>, and <recent_user_context_digest> blocks - no preamble, no closing remarks. - Be concise. Prefer bullet points over paragraphs. - Ignore synthetic compact-summary helper messages. - Write in third-person, factual tone. Do not address the end user. - Preserve exact file paths, function names, error messages - never paraphrase these. +- Keep `<analysis>` extremely short. +- Keep `<recent_user_context_digest>` extremely short and do not quote the preserved messages verbatim unless unavoidable. +- Preserve child-agent routing state semantically, but redact exact historical `agentId`, `subRunId`, and `sessionId` values from compacted history. +- If child-agent routing matters, say that the next agent must rely on the latest live child snapshot or tool result instead of historical IDs. +- If a value is unknown, write a short best-effort placeholder instead of omitting the section. - If a section has no content, write "(none)" rather than omitting it. {{RUNTIME_CONTEXT}} diff --git a/crates/session-runtime/src/context_window/token_usage.rs b/crates/session-runtime/src/context_window/token_usage.rs index b83c281a..63b75640 100644 --- a/crates/session-runtime/src/context_window/token_usage.rs +++ b/crates/session-runtime/src/context_window/token_usage.rs @@ -19,14 +19,14 @@ use astrcode_core::{LlmMessage, LlmUsage, ModelLimits, UserMessageOrigin}; -use crate::heuristics::{MESSAGE_BASE_TOKENS, SUMMARY_RESERVE_TOKENS, TOOL_CALL_BASE_TOKENS}; +use crate::heuristics::{MESSAGE_BASE_TOKENS, TOOL_CALL_BASE_TOKENS}; const REQUEST_ESTIMATE_PADDING_NUMERATOR: usize = 4; const REQUEST_ESTIMATE_PADDING_DENOMINATOR: usize = 3; /// Prompt token 使用快照。 #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct PromptTokenSnapshot { +pub(crate) struct PromptTokenSnapshot { /// 估算的上下文 token 数。 pub context_tokens: usize, /// 已确认的预算 token 数(优先使用 Provider 报告值)。 @@ -37,6 +37,10 @@ pub struct PromptTokenSnapshot { pub effective_window: usize, /// 触发压缩的阈值 token 数。 pub threshold_tokens: usize, + /// 剩余可用 token 数(已经扣除 compact 输出预留)。 + pub remaining_context_tokens: usize, + /// 当剩余空间低于该值时应触发 compact。 + pub reserved_context_size: usize, } /// Token 使用跟踪器。 @@ -44,7 +48,7 @@ pub struct PromptTokenSnapshot { /// 优先使用 Provider 报告的 usage 数据(最接近计费 Token), /// 若 Provider 未报告则回退到估算值。 #[derive(Debug, Default, Clone, Copy)] -pub struct TokenUsageTracker { +pub(crate) struct TokenUsageTracker { anchored_budget_tokens: usize, } @@ -76,27 +80,32 @@ pub fn build_prompt_snapshot( system_prompt: Option<&str>, limits: ModelLimits, threshold_percent: u8, + summary_reserve_tokens: usize, + reserved_context_size: usize, ) -> PromptTokenSnapshot { let context_tokens = estimate_request_tokens(messages, system_prompt); + let effective_window = effective_context_window(limits, summary_reserve_tokens); PromptTokenSnapshot { context_tokens, budget_tokens: tracker.budget_tokens(context_tokens), context_window: limits.context_window, - effective_window: effective_context_window(limits), - threshold_tokens: compact_threshold_tokens(limits, threshold_percent), + effective_window, + threshold_tokens: compact_threshold_tokens(effective_window, threshold_percent), + remaining_context_tokens: effective_window.saturating_sub(context_tokens), + reserved_context_size, } } /// 计算有效上下文窗口(扣除压缩预留)。 -pub fn effective_context_window(limits: ModelLimits) -> usize { +pub fn effective_context_window(limits: ModelLimits, summary_reserve_tokens: usize) -> usize { limits .context_window - .saturating_sub(SUMMARY_RESERVE_TOKENS.min(limits.context_window)) + .saturating_sub(summary_reserve_tokens.min(limits.context_window)) } /// 计算压缩阈值 token 数。 -pub fn compact_threshold_tokens(limits: ModelLimits, threshold_percent: u8) -> usize { - effective_context_window(limits) +pub fn compact_threshold_tokens(effective_window: usize, threshold_percent: u8) -> usize { + effective_window .saturating_mul(threshold_percent as usize) .saturating_div(100) } @@ -104,6 +113,7 @@ pub fn compact_threshold_tokens(limits: ModelLimits, threshold_percent: u8) -> u /// 判断是否需要触发压缩。 pub fn should_compact(snapshot: PromptTokenSnapshot) -> bool { snapshot.context_tokens >= snapshot.threshold_tokens + || snapshot.remaining_context_tokens <= snapshot.reserved_context_size } /// 估算完整 LLM 请求的 token 数(messages + system prompt)。 @@ -123,9 +133,12 @@ pub fn estimate_message_tokens(message: &LlmMessage) -> usize { + estimate_text_tokens(content) + match origin { UserMessageOrigin::User => 0, + UserMessageOrigin::QueuedInput => 8, UserMessageOrigin::AutoContinueNudge => 6, UserMessageOrigin::ContinuationPrompt => 10, UserMessageOrigin::ReactivationPrompt => 8, + UserMessageOrigin::RecentUserContextDigest => 8, + UserMessageOrigin::RecentUserContext => 8, UserMessageOrigin::CompactSummary => 16, } }, @@ -207,8 +220,21 @@ mod tests { max_output_tokens: 8_000, }; - assert_eq!(effective_context_window(limits), 80_000); - assert_eq!(compact_threshold_tokens(limits, 90), 72_000); + assert_eq!(effective_context_window(limits, 20_000), 80_000); + assert_eq!(compact_threshold_tokens(80_000, 90), 72_000); + } + + #[test] + fn should_compact_when_remaining_context_is_below_reserved_size() { + assert!(should_compact(PromptTokenSnapshot { + context_tokens: 40_000, + budget_tokens: 40_000, + context_window: 100_000, + effective_window: 80_000, + threshold_tokens: 72_000, + remaining_context_tokens: 10_000, + reserved_context_size: 20_000, + })); } #[test] diff --git a/crates/session-runtime/src/factory/mod.rs b/crates/session-runtime/src/factory/mod.rs deleted file mode 100644 index 7bbb9b7c..00000000 --- a/crates/session-runtime/src/factory/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! 单 session 执行对象构造辅助。 -//! -//! Why: `factory` 只保留“构造执行对象”这类无状态职责, -//! 状态读取应通过 `query` 完成,避免 factory 膨胀成杂项入口。 - -use astrcode_core::SessionTurnLease; - -#[derive(Debug)] -pub struct NoopSessionTurnLease; - -impl SessionTurnLease for NoopSessionTurnLease {} diff --git a/crates/session-runtime/src/heuristics.rs b/crates/session-runtime/src/heuristics.rs index 5454e454..1f24b128 100644 --- a/crates/session-runtime/src/heuristics.rs +++ b/crates/session-runtime/src/heuristics.rs @@ -1,19 +1,10 @@ //! 运行时启发式常量。 //! -//! 这些值当前服务于 prompt 预算估算和只读观察摘要。 +//! 这些值当前服务于 prompt 预算估算。 //! 它们不是用户配置项,但应集中管理,避免 magic number 分散。 -/// 压缩时为 summary 预留的 token 数。 -pub(crate) const SUMMARY_RESERVE_TOKENS: usize = 20_000; - /// 单条消息的固定估算开销。 pub(crate) const MESSAGE_BASE_TOKENS: usize = 6; /// 单个工具调用元数据的固定估算开销。 pub(crate) const TOOL_CALL_BASE_TOKENS: usize = 12; - -/// agent observe 返回的最近 mailbox 消息条数。 -pub(crate) const MAX_RECENT_MAILBOX_MESSAGES: usize = 3; - -/// task/mailbox 摘要的最大字符数。 -pub(crate) const MAX_TASK_SUMMARY_CHARS: usize = 120; diff --git a/crates/session-runtime/src/lib.rs b/crates/session-runtime/src/lib.rs index 95eaead3..6c7b49fa 100644 --- a/crates/session-runtime/src/lib.rs +++ b/crates/session-runtime/src/lib.rs @@ -5,8 +5,8 @@ use std::{ use astrcode_core::{ AgentCollaborationFact, AgentEventContext, AgentId, AgentLifecycleStatus, ChildSessionNode, - ChildSessionNotification, DeleteProjectResult, EventStore, MailboxBatchAckedPayload, - MailboxBatchStartedPayload, MailboxDiscardedPayload, MailboxQueuedPayload, Phase, + ChildSessionNotification, DeleteProjectResult, EventStore, InputBatchAckedPayload, + InputBatchStartedPayload, InputDiscardedPayload, InputQueuedPayload, Phase, PromptFactsProvider, ResolvedRuntimeConfig, Result, RuntimeMetricsRecorder, SessionId, SessionMeta, StoredEvent, event::generate_session_id, }; @@ -16,45 +16,48 @@ use dashmap::DashMap; use thiserror::Error; use tokio::sync::broadcast; -pub mod actor; -pub mod catalog; -pub mod command; -pub mod context; -pub mod context_window; -pub mod factory; +mod actor; +mod catalog; +mod command; +mod context_window; mod heuristics; -pub mod observe; -pub mod query; -pub mod state; -pub mod turn; +mod observe; +mod query; +mod state; +mod turn; use actor::SessionActor; pub use catalog::SessionCatalogEvent; -pub use command::SessionCommands; -pub use context::ResolvedContextSnapshot; -use observe::SessionObserveSnapshot; pub use observe::{ - SessionEventFilterSpec, SubRunEventScope, SubRunStatusSnapshot, SubRunStatusSource, + SessionEventFilterSpec, SessionObserveSnapshot, SubRunEventScope, SubRunStatusSnapshot, + SubRunStatusSource, }; pub use query::{ - AgentObserveSnapshot, ProjectedTurnOutcome, SessionControlStateSnapshot, SessionReplay, - SessionTranscriptSnapshot, TurnTerminalSnapshot, build_agent_observe_snapshot, - current_turn_messages, has_terminal_turn_signal, project_turn_outcome, - recoverable_parent_deliveries, + AgentObserveSnapshot, ConversationAssistantBlockFacts, ConversationBlockFacts, + ConversationBlockPatchFacts, ConversationBlockStatus, ConversationChildHandoffBlockFacts, + ConversationChildHandoffKind, ConversationDeltaFacts, ConversationDeltaFrameFacts, + ConversationDeltaProjector, ConversationErrorBlockFacts, ConversationPlanBlockFacts, + ConversationPlanBlockersFacts, ConversationPlanEventKind, ConversationPlanReviewFacts, + ConversationPlanReviewKind, ConversationSnapshotFacts, ConversationStreamProjector, + ConversationStreamReplayFacts, ConversationSystemNoteBlockFacts, ConversationSystemNoteKind, + ConversationThinkingBlockFacts, ConversationTranscriptErrorKind, ConversationUserBlockFacts, + LastCompactMetaSnapshot, ProjectedTurnOutcome, SessionControlStateSnapshot, + SessionModeSnapshot, SessionReplay, SessionTranscriptSnapshot, ToolCallBlockFacts, + ToolCallStreamsFacts, TurnTerminalSnapshot, recoverable_parent_deliveries, }; +pub(crate) use state::{InputQueueEventAppend, SessionStateEventSink, append_input_queue_event}; pub use state::{ - MailboxEventAppend, SessionSnapshot, SessionState, SessionStateEventSink, SessionWriter, - append_and_broadcast, append_batch_acked, append_batch_started, append_mailbox_discarded, - append_mailbox_event, append_mailbox_queued, complete_session_execution, + SessionSnapshot, SessionState, append_and_broadcast, complete_session_execution, display_name_from_working_dir, normalize_session_id, normalize_working_dir, - prepare_session_execution, recent_turn_event_tail, should_record_compaction_tail_event, + prepare_session_execution, }; pub use turn::{ - AgentPromptSubmission, TurnCollaborationSummary, TurnFinishReason, TurnOutcome, TurnRunRequest, - TurnRunResult, TurnSummary, run_turn, + AgentPromptSubmission, ForkPoint, ForkResult, TurnCollaborationSummary, TurnFinishReason, + TurnSummary, }; +pub(crate) use turn::{TurnOutcome, TurnRunResult, run_turn}; -const ROOT_AGENT_ID: &str = "root-agent"; +pub const ROOT_AGENT_ID: &str = "root-agent"; #[derive(Debug)] struct LoadedSession { @@ -256,7 +259,7 @@ impl SessionRuntime { /// 按需加载 session 并返回内部状态引用。 /// /// 用于 agent 编排层需要直接操作 SessionState 的场景 - /// (如 mailbox 追加、对话投影读取等)。 + /// (如 input queue 追加、对话投影读取等)。 pub async fn get_session_state(&self, session_id: &SessionId) -> Result<Arc<SessionState>> { self.query().session_state(session_id).await } @@ -269,11 +272,40 @@ impl SessionRuntime { self.query().session_control_state(session_id).await } + pub async fn conversation_snapshot( + &self, + session_id: &str, + ) -> Result<ConversationSnapshotFacts> { + self.query().conversation_snapshot(session_id).await + } + + pub async fn conversation_stream_replay( + &self, + session_id: &str, + last_event_id: Option<&str>, + ) -> Result<ConversationStreamReplayFacts> { + self.query() + .conversation_stream_replay(session_id, last_event_id) + .await + } + /// 返回当前 session durable 可见的 direct child lineage 节点。 pub async fn session_child_nodes(&self, session_id: &str) -> Result<Vec<ChildSessionNode>> { self.query().session_child_nodes(session_id).await } + pub async fn session_mode_state(&self, session_id: &str) -> Result<SessionModeSnapshot> { + self.query().session_mode_state(session_id).await + } + + pub async fn active_task_snapshot( + &self, + session_id: &str, + owner: &str, + ) -> Result<Option<astrcode_core::TaskSnapshot>> { + self.query().active_task_snapshot(session_id, owner).await + } + /// 读取指定 session 的工作目录。 pub async fn get_session_working_dir(&self, session_id: &str) -> Result<String> { self.query().session_working_dir(session_id).await @@ -281,7 +313,7 @@ impl SessionRuntime { /// 回放指定 session 的全部持久化事件。 /// - /// 用于 agent 编排层需要从 durable 事件中提取 mailbox 信封等场景。 + /// 用于 agent 编排层需要从 durable 事件中提取 input queue 信封等场景。 pub async fn replay_stored_events(&self, session_id: &SessionId) -> Result<Vec<StoredEvent>> { self.query().stored_events(session_id).await } @@ -309,7 +341,7 @@ impl SessionRuntime { .await } - /// 读取指定 agent 当前 mailbox durable 投影中的待处理 delivery id。 + /// 读取指定 agent 当前 input queue durable 投影中的待处理 delivery id。 pub async fn pending_delivery_ids_for_agent( &self, session_id: &str, @@ -320,51 +352,51 @@ impl SessionRuntime { .await } - pub async fn append_agent_mailbox_queued( + pub async fn append_agent_input_queued( &self, session_id: &str, turn_id: &str, agent: AgentEventContext, - payload: MailboxQueuedPayload, + payload: InputQueuedPayload, ) -> Result<StoredEvent> { self.command() - .append_agent_mailbox_queued(session_id, turn_id, agent, payload) + .append_agent_input_queued(session_id, turn_id, agent, payload) .await } - pub async fn append_agent_mailbox_discarded( + pub async fn append_agent_input_discarded( &self, session_id: &str, turn_id: &str, agent: AgentEventContext, - payload: MailboxDiscardedPayload, + payload: InputDiscardedPayload, ) -> Result<StoredEvent> { self.command() - .append_agent_mailbox_discarded(session_id, turn_id, agent, payload) + .append_agent_input_discarded(session_id, turn_id, agent, payload) .await } - pub async fn append_agent_mailbox_batch_started( + pub async fn append_agent_input_batch_started( &self, session_id: &str, turn_id: &str, agent: AgentEventContext, - payload: MailboxBatchStartedPayload, + payload: InputBatchStartedPayload, ) -> Result<StoredEvent> { self.command() - .append_agent_mailbox_batch_started(session_id, turn_id, agent, payload) + .append_agent_input_batch_started(session_id, turn_id, agent, payload) .await } - pub async fn append_agent_mailbox_batch_acked( + pub async fn append_agent_input_batch_acked( &self, session_id: &str, turn_id: &str, agent: AgentEventContext, - payload: MailboxBatchAckedPayload, + payload: InputBatchAckedPayload, ) -> Result<StoredEvent> { self.command() - .append_agent_mailbox_batch_acked(session_id, turn_id, agent, payload) + .append_agent_input_batch_acked(session_id, turn_id, agent, payload) .await } @@ -394,7 +426,7 @@ impl SessionRuntime { .await } - /// 从 durable mailbox + child notification 中恢复仍可重试的父级 delivery。 + /// 从 durable input queue + child notification 中恢复仍可重试的父级 delivery。 pub async fn recoverable_parent_deliveries( &self, parent_session_id: &str, @@ -457,8 +489,20 @@ impl SessionRuntime { &self, session_id: &str, runtime: ResolvedRuntimeConfig, + instructions: Option<String>, ) -> Result<bool> { - self.command().compact_session(session_id, &runtime).await + self.command() + .compact_session(session_id, &runtime, instructions.as_deref()) + .await + } + + pub async fn switch_mode( + &self, + session_id: &str, + from: astrcode_core::ModeId, + to: astrcode_core::ModeId, + ) -> Result<StoredEvent> { + self.command().switch_mode(session_id, from, to).await } async fn session_phase(&self, session_id: &SessionId) -> Result<Phase> { @@ -489,13 +533,13 @@ impl SessionRuntime { .into_iter() .find(|meta| normalize_session_id(&meta.session_id) == session_id.as_str()) .ok_or_else(|| SessionRuntimeError::SessionNotFound(session_id.to_string()))?; - let stored = self.event_store.replay(session_id).await?; - let actor = Arc::new(SessionActor::from_replay( + let recovered = self.event_store.recover_session(session_id).await?; + let actor = Arc::new(SessionActor::from_recovery( session_id.clone(), meta.working_dir, AgentId::from(ROOT_AGENT_ID.to_string()), Arc::clone(&self.event_store), - stored, + recovered, )?); let loaded = Arc::new(LoadedSession { actor: Arc::clone(&actor), diff --git a/crates/session-runtime/src/query/agent.rs b/crates/session-runtime/src/query/agent.rs index 07358b87..04c0841d 100644 --- a/crates/session-runtime/src/query/agent.rs +++ b/crates/session-runtime/src/query/agent.rs @@ -1,309 +1,191 @@ //! Agent 只读观察投影。 //! -//! Why: `observe` 负责订阅语义,`agent query` 负责生成同步快照, -//! 两者不要再混在同一个模块里。 +//! Why: `observe` 只负责读侧快照,不再暴露 input queue 的建议字段。 -use std::collections::{HashMap, HashSet}; +use astrcode_core::{AgentLifecycleStatus, AgentState, InputQueueProjection, LlmMessage}; -use astrcode_core::{ - AgentLifecycleStatus, AgentMailboxEnvelope, AgentState, LlmMessage, MailboxProjection, - StorageEventPayload, StoredEvent, UserMessageOrigin, -}; - -use crate::heuristics::{MAX_RECENT_MAILBOX_MESSAGES, MAX_TASK_SUMMARY_CHARS}; +use crate::query::text::{summarize_inline_text, truncate_text}; #[derive(Debug, Clone)] pub struct AgentObserveSnapshot { pub phase: astrcode_core::Phase, pub turn_count: u32, - pub pending_message_count: usize, pub active_task: Option<String>, - pub pending_task: Option<String>, - pub recent_mailbox_messages: Vec<String>, - pub last_output: Option<String>, + pub last_output_tail: Option<String>, + pub last_turn_tail: Vec<String>, } -pub fn build_agent_observe_snapshot( +pub(crate) fn build_agent_observe_snapshot( lifecycle_status: AgentLifecycleStatus, projected: &AgentState, - mailbox_projection: &MailboxProjection, - stored_events: &[StoredEvent], - target_agent_id: &str, + input_queue_projection: &InputQueueProjection, ) -> AgentObserveSnapshot { - let mailbox_messages = collect_mailbox_messages(stored_events, target_agent_id); - let pending_message_count = mailbox_projection.pending_delivery_ids.len(); - AgentObserveSnapshot { phase: projected.phase, turn_count: projected.turn_count as u32, - pending_message_count, - active_task: active_task_summary( - lifecycle_status, - &projected.messages, - mailbox_projection, - &mailbox_messages, - ), - pending_task: pending_task_summary(mailbox_projection, &mailbox_messages), - recent_mailbox_messages: recent_mailbox_message_summaries(stored_events, target_agent_id), - last_output: extract_last_output(&projected.messages), + active_task: active_task_summary(lifecycle_status, projected, input_queue_projection), + last_output_tail: extract_last_output(&projected.messages), + last_turn_tail: extract_last_turn_tail(&projected.messages), } } fn extract_last_output(messages: &[LlmMessage]) -> Option<String> { messages.iter().rev().find_map(|msg| match msg { - LlmMessage::Assistant { content, .. } if !content.is_empty() => { - let char_count = content.chars().count(); - if char_count > 200 { - Some(content.chars().take(200).collect::<String>() + "...") - } else { - Some(content.clone()) - } - }, + LlmMessage::Assistant { content, .. } if !content.is_empty() => truncate_text(content, 200), _ => None, }) } fn active_task_summary( lifecycle_status: AgentLifecycleStatus, - messages: &[LlmMessage], - mailbox_projection: &MailboxProjection, - mailbox_messages: &HashMap<String, AgentMailboxEnvelope>, + projected: &AgentState, + input_queue_projection: &InputQueueProjection, ) -> Option<String> { - if let Some(summary) = first_delivery_summary( - mailbox_projection.active_delivery_ids.iter(), - mailbox_messages, - ) { - return Some(summary); + if !input_queue_projection.active_delivery_ids.is_empty() { + return extract_last_turn_tail(&projected.messages) + .into_iter() + .next(); } if matches!( lifecycle_status, AgentLifecycleStatus::Pending | AgentLifecycleStatus::Running ) { - return latest_user_task_summary(messages); + return projected + .messages + .iter() + .rev() + .find_map(|message| match message { + LlmMessage::User { + content, + origin: astrcode_core::UserMessageOrigin::User, + } => summarize_inline_text(content, 120), + _ => None, + }); } None } -fn pending_task_summary( - mailbox_projection: &MailboxProjection, - mailbox_messages: &HashMap<String, AgentMailboxEnvelope>, -) -> Option<String> { - let active_ids: HashSet<_> = mailbox_projection - .active_delivery_ids - .iter() - .cloned() - .collect(); - - first_delivery_summary( - mailbox_projection - .pending_delivery_ids - .iter() - .filter(|delivery_id| !active_ids.contains(*delivery_id)), - mailbox_messages, - ) -} - -fn first_delivery_summary<'a>( - delivery_ids: impl IntoIterator<Item = &'a String>, - mailbox_messages: &HashMap<String, AgentMailboxEnvelope>, -) -> Option<String> { - delivery_ids.into_iter().find_map(|delivery_id| { - mailbox_messages - .get(delivery_id) - .and_then(|envelope| summarize_task_text(&envelope.message)) - }) -} - -fn latest_user_task_summary(messages: &[LlmMessage]) -> Option<String> { - messages.iter().rev().find_map(|message| match message { - LlmMessage::User { content, origin } if *origin == UserMessageOrigin::User => { - summarize_task_text(content) - }, - _ => None, - }) -} - -fn collect_mailbox_messages( - stored_events: &[StoredEvent], - target_agent_id: &str, -) -> HashMap<String, AgentMailboxEnvelope> { - let mut messages = HashMap::new(); - for stored in stored_events { - if let StorageEventPayload::AgentMailboxQueued { payload } = &stored.event.payload { - if payload.envelope.to_agent_id == target_agent_id { - messages.insert( - payload.envelope.delivery_id.clone(), - payload.envelope.clone(), - ); - } - } - } +fn extract_last_turn_tail(messages: &[LlmMessage]) -> Vec<String> { messages -} - -fn recent_mailbox_message_summaries( - stored_events: &[StoredEvent], - target_agent_id: &str, -) -> Vec<String> { - stored_events .iter() - .filter_map(|stored| match &stored.event.payload { - StorageEventPayload::AgentMailboxQueued { payload } - if payload.envelope.to_agent_id == target_agent_id => - { - summarize_task_text(&payload.envelope.message) - }, - _ => None, - }) .rev() - .take(MAX_RECENT_MAILBOX_MESSAGES) + .filter_map(|message| match message { + LlmMessage::User { content, .. } => summarize_inline_text(content, 120), + LlmMessage::Assistant { content, .. } => summarize_inline_text(content, 120), + LlmMessage::Tool { content, .. } => summarize_inline_text(content, 120), + }) + .take(3) .collect::<Vec<_>>() .into_iter() .rev() .collect() } -fn summarize_task_text(text: &str) -> Option<String> { - let normalized = text.split_whitespace().collect::<Vec<_>>().join(" "); - let trimmed = normalized.trim(); - if trimmed.is_empty() { - return None; - } - - let char_count = trimmed.chars().count(); - if char_count <= MAX_TASK_SUMMARY_CHARS { - return Some(trimmed.to_string()); - } - - Some( - trimmed - .chars() - .take(MAX_TASK_SUMMARY_CHARS) - .collect::<String>() - + "...", - ) -} - #[cfg(test)] mod tests { use std::path::PathBuf; - use astrcode_core::{ - AgentEventContext, AgentLifecycleStatus, AgentMailboxEnvelope, AgentState, LlmMessage, - MailboxProjection, MailboxQueuedPayload, Phase, StorageEvent, StorageEventPayload, - StoredEvent, - }; + use astrcode_core::{AgentState, LlmMessage, ModeId, Phase, UserMessageOrigin}; - use super::{ - build_agent_observe_snapshot, extract_last_output, recent_mailbox_message_summaries, - summarize_task_text, - }; + use super::{build_agent_observe_snapshot, extract_last_turn_tail}; - fn queued_mailbox_event( - storage_seq: u64, - delivery_id: &str, - target_agent_id: &str, - message: &str, - ) -> StoredEvent { - StoredEvent { - storage_seq, - event: StorageEvent { - turn_id: Some("turn-parent".to_string()), - agent: AgentEventContext::default(), - payload: StorageEventPayload::AgentMailboxQueued { - payload: MailboxQueuedPayload { - envelope: AgentMailboxEnvelope { - delivery_id: delivery_id.to_string(), - from_agent_id: "parent".to_string(), - to_agent_id: target_agent_id.to_string(), - message: message.to_string(), - queued_at: chrono::Utc::now(), - sender_lifecycle_status: AgentLifecycleStatus::Idle, - sender_last_turn_outcome: None, - sender_open_session_id: "session-parent".to_string(), - }, - }, - }, - }, + fn projected(messages: Vec<LlmMessage>, phase: Phase) -> AgentState { + AgentState { + session_id: "session-1".into(), + working_dir: PathBuf::from("/tmp"), + phase, + turn_count: 2, + mode_id: ModeId::code(), + messages, + last_assistant_at: None, } } #[test] - fn summarize_task_text_trims_and_truncates() { - assert_eq!( - summarize_task_text(" review the mailbox state "), - Some("review the mailbox state".to_string()) - ); - assert!(summarize_task_text(" ").is_none()); - let long = "a".repeat(150); - assert_eq!( - summarize_task_text(&long), - Some(format!("{}...", "a".repeat(120))) - ); - } - - #[test] - fn extract_last_output_ignores_empty_assistant() { - let messages = vec![ + fn extract_last_turn_tail_returns_recent_message_tail() { + let tail = extract_last_turn_tail(&[ + LlmMessage::User { + content: "first".to_string(), + origin: UserMessageOrigin::User, + }, LlmMessage::Assistant { - content: String::new(), + content: "second".to_string(), tool_calls: Vec::new(), reasoning: None, }, + LlmMessage::Tool { + tool_call_id: "call-1".to_string(), + content: "third".to_string(), + }, LlmMessage::Assistant { - content: "最后输出".to_string(), + content: "fourth".to_string(), tool_calls: Vec::new(), reasoning: None, }, - ]; - assert_eq!(extract_last_output(&messages), Some("最后输出".to_string())); + ]); + + assert_eq!(tail, vec!["second", "third", "fourth"]); } #[test] - fn recent_mailbox_message_summaries_returns_only_tail() { - let stored_events = vec![ - queued_mailbox_event(1, "d1", "child-1", "第一条"), - queued_mailbox_event(2, "d2", "child-1", "第二条"), - queued_mailbox_event(3, "d3", "child-1", "第三条"), - queued_mailbox_event(4, "d4", "child-1", "第四条"), - ]; + fn build_agent_observe_snapshot_prefers_latest_user_task_for_running_agent() { + let snapshot = build_agent_observe_snapshot( + astrcode_core::AgentLifecycleStatus::Running, + &projected( + vec![ + LlmMessage::User { + content: "请检查 input queue 路径".to_string(), + origin: UserMessageOrigin::User, + }, + LlmMessage::Assistant { + content: "处理中".to_string(), + tool_calls: Vec::new(), + reasoning: None, + }, + ], + Phase::Streaming, + ), + &astrcode_core::InputQueueProjection::default(), + ); assert_eq!( - recent_mailbox_message_summaries(&stored_events, "child-1"), - vec![ - "第二条".to_string(), - "第三条".to_string(), - "第四条".to_string() - ] + snapshot.active_task.as_deref(), + Some("请检查 input queue 路径") ); + assert_eq!(snapshot.last_output_tail.as_deref(), Some("处理中")); } #[test] - fn build_agent_observe_snapshot_includes_recent_mailbox_tail() { - let stored_events = vec![ - queued_mailbox_event(1, "d1", "child-1", "第一条"), - queued_mailbox_event(2, "d2", "child-1", "第二条"), - ]; + fn build_agent_observe_snapshot_uses_turn_tail_when_active_delivery_exists() { + let input_queue_projection = astrcode_core::InputQueueProjection { + active_delivery_ids: vec!["delivery-1".into()], + ..Default::default() + }; + let snapshot = build_agent_observe_snapshot( - AgentLifecycleStatus::Idle, - &AgentState { - session_id: "session-child".to_string(), - working_dir: PathBuf::from("/workspace/demo"), - messages: Vec::new(), - phase: Phase::Idle, - turn_count: 2, - last_assistant_at: None, - }, - &MailboxProjection::default(), - &stored_events, - "child-1", + astrcode_core::AgentLifecycleStatus::Idle, + &projected( + vec![ + LlmMessage::User { + content: "继续整理父级需要的结论".to_string(), + origin: UserMessageOrigin::QueuedInput, + }, + LlmMessage::Assistant { + content: "正在合并结果".to_string(), + tool_calls: Vec::new(), + reasoning: None, + }, + ], + Phase::Thinking, + ), + &input_queue_projection, ); assert_eq!( - snapshot.recent_mailbox_messages, - vec!["第一条".to_string(), "第二条".to_string()] + snapshot.active_task.as_deref(), + Some("继续整理父级需要的结论") ); } } diff --git a/crates/session-runtime/src/query/conversation.rs b/crates/session-runtime/src/query/conversation.rs new file mode 100644 index 00000000..0e3cae58 --- /dev/null +++ b/crates/session-runtime/src/query/conversation.rs @@ -0,0 +1,962 @@ +//! authoritative conversation / tool display 读模型。 +//! +//! Why: 工具展示的聚合语义属于单 session query 真相,不应该继续滞留在 +//! `server` route/projector 或前端 regroup 逻辑里。 + +use std::collections::HashMap; + +use astrcode_core::{ + AgentEvent, ChildAgentRef, ChildSessionNotification, ChildSessionNotificationKind, + CompactAppliedMeta, CompactTrigger, Phase, SessionEventRecord, ToolExecutionResult, + ToolOutputStream, +}; +use serde_json::Value; + +mod facts; +#[path = "conversation/projection_support.rs"] +mod projection_support; + +pub use facts::*; +use projection_support::*; +pub(crate) use projection_support::{ + build_conversation_replay_frames, project_conversation_snapshot, +}; + +#[derive(Default)] +pub struct ConversationDeltaProjector { + blocks: Vec<ConversationBlockFacts>, + block_index: HashMap<String, usize>, + turn_blocks: HashMap<String, TurnBlockRefs>, + tool_blocks: HashMap<String, ToolBlockRefs>, +} + +#[derive(Default)] +pub struct ConversationStreamProjector { + projector: ConversationDeltaProjector, + last_sent_cursor: Option<String>, + fallback_live_cursor: Option<String>, +} + +#[derive(Default, Clone)] +struct TurnBlockRefs { + current_thinking: Option<String>, + current_assistant: Option<String>, + historical_thinking: Vec<String>, + historical_assistant: Vec<String>, + pending_thinking: Vec<String>, + pending_assistant: Vec<String>, + thinking_count: usize, + assistant_count: usize, +} + +#[derive(Default, Clone)] +struct ToolBlockRefs { + turn_id: Option<String>, + call: Option<String>, + pending_live_stdout_bytes: usize, + pending_live_stderr_bytes: usize, +} + +#[derive(Clone, Copy)] +enum BlockKind { + Thinking, + Assistant, +} + +#[derive(Clone, Copy)] +enum ProjectionSource { + Durable, + Live, +} + +impl ProjectionSource { + fn is_durable(self) -> bool { + matches!(self, Self::Durable) + } + + fn is_live(self) -> bool { + matches!(self, Self::Live) + } +} + +impl ToolBlockRefs { + fn reconcile_tool_chunk( + &mut self, + stream: ToolOutputStream, + delta: &str, + source: ProjectionSource, + ) -> String { + let pending_live_bytes = match stream { + ToolOutputStream::Stdout => &mut self.pending_live_stdout_bytes, + ToolOutputStream::Stderr => &mut self.pending_live_stderr_bytes, + }; + + if matches!(source, ProjectionSource::Live) { + *pending_live_bytes += delta.len(); + return delta.to_string(); + } + + if *pending_live_bytes == 0 { + return delta.to_string(); + } + + let consumed = (*pending_live_bytes).min(delta.len()); + *pending_live_bytes -= consumed; + delta[consumed..].to_string() + } +} + +impl TurnBlockRefs { + fn current_or_next_block_id(&mut self, turn_id: &str, kind: BlockKind) -> String { + match kind { + BlockKind::Thinking => { + if let Some(block_id) = &self.current_thinking { + return block_id.clone(); + } + self.thinking_count += 1; + let block_id = turn_scoped_block_id(turn_id, "thinking", self.thinking_count); + self.current_thinking = Some(block_id.clone()); + block_id + }, + BlockKind::Assistant => { + if let Some(block_id) = &self.current_assistant { + return block_id.clone(); + } + self.assistant_count += 1; + let block_id = turn_scoped_block_id(turn_id, "assistant", self.assistant_count); + self.current_assistant = Some(block_id.clone()); + block_id + }, + } + } + + fn block_id_for_finalize(&mut self, turn_id: &str, kind: BlockKind) -> String { + match kind { + BlockKind::Thinking => { + if let Some(block_id) = self.pending_thinking.first().cloned() { + self.pending_thinking.remove(0); + return block_id; + } + self.current_or_next_block_id(turn_id, kind) + }, + BlockKind::Assistant => { + if let Some(block_id) = self.pending_assistant.first().cloned() { + self.pending_assistant.remove(0); + return block_id; + } + self.current_or_next_block_id(turn_id, kind) + }, + } + } + + fn split_after_live_tool_boundary(&mut self) { + if let Some(block_id) = self.current_thinking.take() { + self.pending_thinking.push(block_id); + } + if let Some(block_id) = self.current_assistant.take() { + self.pending_assistant.push(block_id); + } + } + + fn split_after_durable_tool_boundary(&mut self) { + if let Some(block_id) = self.current_thinking.take() { + self.historical_thinking.push(block_id); + } + if let Some(block_id) = self.current_assistant.take() { + self.historical_assistant.push(block_id); + } + } + + fn all_block_ids(&self) -> Vec<String> { + let mut ids = Vec::new(); + ids.extend(self.historical_thinking.iter().cloned()); + ids.extend(self.historical_assistant.iter().cloned()); + ids.extend(self.pending_thinking.iter().cloned()); + ids.extend(self.pending_assistant.iter().cloned()); + if let Some(block_id) = &self.current_thinking { + ids.push(block_id.clone()); + } + if let Some(block_id) = &self.current_assistant { + ids.push(block_id.clone()); + } + ids + } +} + +fn turn_scoped_block_id(turn_id: &str, role: &str, ordinal: usize) -> String { + if ordinal <= 1 { + format!("turn:{turn_id}:{role}") + } else { + format!("turn:{turn_id}:{role}:{ordinal}") + } +} + +impl ConversationDeltaProjector { + pub fn new() -> Self { + Self::default() + } + + pub fn seed(&mut self, history: &[SessionEventRecord]) { + for record in history { + let _ = self.project_record(record); + } + } + + pub fn blocks(&self) -> &[ConversationBlockFacts] { + &self.blocks + } + + pub fn into_blocks(self) -> Vec<ConversationBlockFacts> { + self.blocks + } + + pub fn project_record(&mut self, record: &SessionEventRecord) -> Vec<ConversationDeltaFacts> { + self.project_event( + &record.event, + ProjectionSource::Durable, + Some(record.event_id.as_str()), + ) + } + + pub fn project_live_event(&mut self, event: &AgentEvent) -> Vec<ConversationDeltaFacts> { + self.project_event(event, ProjectionSource::Live, None) + } + + fn project_event( + &mut self, + event: &AgentEvent, + source: ProjectionSource, + durable_event_id: Option<&str>, + ) -> Vec<ConversationDeltaFacts> { + match event { + AgentEvent::UserMessage { + turn_id, content, .. + } if source.is_durable() => { + self.append_user_block(&format!("turn:{turn_id}:user"), turn_id, content) + }, + AgentEvent::ThinkingDelta { turn_id, delta, .. } => { + self.append_markdown_streaming_block(turn_id, delta, BlockKind::Thinking) + }, + AgentEvent::ModelDelta { turn_id, delta, .. } => { + self.append_markdown_streaming_block(turn_id, delta, BlockKind::Assistant) + }, + AgentEvent::AssistantMessage { + turn_id, + content, + reasoning_content, + .. + } if source.is_durable() => { + self.finalize_assistant_block(turn_id, content, reasoning_content.as_deref()) + }, + AgentEvent::ToolCallStart { + turn_id, + tool_call_id, + tool_name, + input, + .. + } => { + if should_suppress_tool_call_block(tool_name, Some(input)) { + Vec::new() + } else { + self.start_tool_call(turn_id, tool_call_id, tool_name, Some(input), source) + } + }, + AgentEvent::ToolCallDelta { + turn_id, + tool_call_id, + tool_name, + stream, + delta, + .. + } => self.append_tool_stream(turn_id, tool_call_id, tool_name, *stream, delta, source), + AgentEvent::ToolCallResult { + turn_id, result, .. + } => { + if let Some(block) = plan_block_from_tool_result(turn_id, result) { + self.push_block(ConversationBlockFacts::Plan(Box::new(block))) + } else { + self.complete_tool_call(turn_id, result, source) + } + }, + AgentEvent::CompactApplied { + turn_id, + trigger, + summary, + meta, + preserved_recent_turns, + .. + } if source.is_durable() => { + let block_id = format!( + "system:compact:{}", + turn_id + .clone() + .or_else(|| durable_event_id.map(ToString::to_string)) + .unwrap_or_else(|| "session".to_string()) + ); + self.append_system_note( + &block_id, + ConversationSystemNoteKind::Compact, + summary, + Some(*trigger), + Some(meta.clone()), + Some(*preserved_recent_turns), + ) + }, + AgentEvent::ChildSessionNotification { notification, .. } => { + self.apply_child_notification(notification, source) + }, + AgentEvent::Error { + turn_id, + code, + message, + .. + } if source.is_durable() => self.append_error(turn_id.as_deref(), code, message), + AgentEvent::TurnDone { turn_id, .. } if source.is_durable() => { + self.complete_turn(turn_id) + }, + AgentEvent::PhaseChanged { .. } + | AgentEvent::SessionStarted { .. } + | AgentEvent::PromptMetrics { .. } + | AgentEvent::SubRunStarted { .. } + | AgentEvent::SubRunFinished { .. } + | AgentEvent::AgentInputQueued { .. } + | AgentEvent::AgentInputBatchStarted { .. } + | AgentEvent::AgentInputBatchAcked { .. } + | AgentEvent::AgentInputDiscarded { .. } + | AgentEvent::UserMessage { .. } + | AgentEvent::AssistantMessage { .. } + | AgentEvent::CompactApplied { .. } + | AgentEvent::Error { .. } + | AgentEvent::TurnDone { .. } => Vec::new(), + } + } + + fn append_user_block( + &mut self, + block_id: &str, + turn_id: &str, + content: &str, + ) -> Vec<ConversationDeltaFacts> { + if self.block_index.contains_key(block_id) { + return Vec::new(); + } + self.push_block(ConversationBlockFacts::User(ConversationUserBlockFacts { + id: block_id.to_string(), + turn_id: Some(turn_id.to_string()), + markdown: content.to_string(), + })) + } + + fn append_markdown_streaming_block( + &mut self, + turn_id: &str, + delta: &str, + kind: BlockKind, + ) -> Vec<ConversationDeltaFacts> { + let block_id = self + .turn_blocks + .entry(turn_id.to_string()) + .or_default() + .current_or_next_block_id(turn_id, kind); + if let Some(index) = self.block_index.get(&block_id).copied() { + self.append_markdown(index, delta); + return vec![ConversationDeltaFacts::PatchBlock { + block_id, + patch: ConversationBlockPatchFacts::AppendMarkdown { + markdown: delta.to_string(), + }, + }]; + } + + let block = match kind { + BlockKind::Thinking => { + ConversationBlockFacts::Thinking(ConversationThinkingBlockFacts { + id: block_id.clone(), + turn_id: Some(turn_id.to_string()), + status: ConversationBlockStatus::Streaming, + markdown: delta.to_string(), + }) + }, + BlockKind::Assistant => { + ConversationBlockFacts::Assistant(ConversationAssistantBlockFacts { + id: block_id, + turn_id: Some(turn_id.to_string()), + status: ConversationBlockStatus::Streaming, + markdown: delta.to_string(), + }) + }, + }; + self.push_block(block) + } + + fn finalize_assistant_block( + &mut self, + turn_id: &str, + content: &str, + reasoning_content: Option<&str>, + ) -> Vec<ConversationDeltaFacts> { + let (assistant_id, thinking_id) = { + let turn_refs = self.turn_blocks.entry(turn_id.to_string()).or_default(); + ( + turn_refs.block_id_for_finalize(turn_id, BlockKind::Assistant), + reasoning_content + .filter(|value| !value.trim().is_empty()) + .map(|_| turn_refs.block_id_for_finalize(turn_id, BlockKind::Thinking)), + ) + }; + let mut deltas = Vec::new(); + + if let (Some(reasoning), Some(thinking_id)) = ( + reasoning_content.filter(|value| !value.trim().is_empty()), + thinking_id, + ) { + deltas.extend(self.ensure_full_markdown_block( + &thinking_id, + turn_id, + reasoning, + BlockKind::Thinking, + )); + if let Some(delta) = + self.complete_block(&thinking_id, ConversationBlockStatus::Complete) + { + deltas.push(delta); + } + } + + deltas.extend(self.ensure_full_markdown_block( + &assistant_id, + turn_id, + content, + BlockKind::Assistant, + )); + if let Some(delta) = self.complete_block(&assistant_id, ConversationBlockStatus::Complete) { + deltas.push(delta); + } + deltas + } + + fn ensure_full_markdown_block( + &mut self, + block_id: &str, + turn_id: &str, + content: &str, + kind: BlockKind, + ) -> Vec<ConversationDeltaFacts> { + if let Some(index) = self.block_index.get(block_id).copied() { + let existing = self.block_markdown(index); + self.replace_markdown(index, content); + if content.starts_with(&existing) { + let suffix = &content[existing.len()..]; + if suffix.is_empty() { + return Vec::new(); + } + return vec![ConversationDeltaFacts::PatchBlock { + block_id: block_id.to_string(), + patch: ConversationBlockPatchFacts::AppendMarkdown { + markdown: suffix.to_string(), + }, + }]; + } + return vec![ConversationDeltaFacts::PatchBlock { + block_id: block_id.to_string(), + patch: ConversationBlockPatchFacts::ReplaceMarkdown { + markdown: content.to_string(), + }, + }]; + } + + let block = match kind { + BlockKind::Thinking => { + ConversationBlockFacts::Thinking(ConversationThinkingBlockFacts { + id: block_id.to_string(), + turn_id: Some(turn_id.to_string()), + status: ConversationBlockStatus::Streaming, + markdown: content.to_string(), + }) + }, + BlockKind::Assistant => { + ConversationBlockFacts::Assistant(ConversationAssistantBlockFacts { + id: block_id.to_string(), + turn_id: Some(turn_id.to_string()), + status: ConversationBlockStatus::Streaming, + markdown: content.to_string(), + }) + }, + }; + self.push_block(block) + } + + fn start_tool_call( + &mut self, + turn_id: &str, + tool_call_id: &str, + tool_name: &str, + input: Option<&Value>, + source: ProjectionSource, + ) -> Vec<ConversationDeltaFacts> { + let block_id = format!("tool:{tool_call_id}:call"); + let refs = self + .tool_blocks + .entry(tool_call_id.to_string()) + .or_default(); + refs.turn_id = Some(turn_id.to_string()); + refs.call = Some(block_id.clone()); + if self.block_index.contains_key(&block_id) { + return Vec::new(); + } + + let turn_refs = self.turn_blocks.entry(turn_id.to_string()).or_default(); + if source.is_live() { + turn_refs.split_after_live_tool_boundary(); + } else { + turn_refs.split_after_durable_tool_boundary(); + } + + self.push_block(ConversationBlockFacts::ToolCall(Box::new( + ToolCallBlockFacts { + id: block_id, + turn_id: Some(turn_id.to_string()), + tool_call_id: tool_call_id.to_string(), + tool_name: tool_name.to_string(), + status: ConversationBlockStatus::Streaming, + input: input.cloned(), + summary: None, + error: None, + duration_ms: None, + truncated: false, + metadata: None, + child_ref: None, + streams: ToolCallStreamsFacts::default(), + }, + ))) + } + + fn append_tool_stream( + &mut self, + turn_id: &str, + tool_call_id: &str, + tool_name: &str, + stream: ToolOutputStream, + delta: &str, + source: ProjectionSource, + ) -> Vec<ConversationDeltaFacts> { + let mut deltas = self.start_tool_call(turn_id, tool_call_id, tool_name, None, source); + let refs = self + .tool_blocks + .entry(tool_call_id.to_string()) + .or_default(); + let chunk = refs.reconcile_tool_chunk(stream, delta, source); + if chunk.is_empty() { + return deltas; + } + + let Some(call_block_id) = refs.call.clone() else { + return deltas; + }; + let Some(index) = self.block_index.get(&call_block_id).copied() else { + return deltas; + }; + self.append_tool_stream_content(index, stream, &chunk); + deltas.push(ConversationDeltaFacts::PatchBlock { + block_id: call_block_id, + patch: ConversationBlockPatchFacts::AppendToolStream { stream, chunk }, + }); + deltas + } + + fn complete_tool_call( + &mut self, + turn_id: &str, + result: &ToolExecutionResult, + source: ProjectionSource, + ) -> Vec<ConversationDeltaFacts> { + let mut deltas = self.start_tool_call( + turn_id, + &result.tool_call_id, + &result.tool_name, + None, + source, + ); + let status = if result.ok { + ConversationBlockStatus::Complete + } else { + ConversationBlockStatus::Failed + }; + let summary = tool_result_summary(result); + let refs = self + .tool_blocks + .entry(result.tool_call_id.clone()) + .or_default(); + if source.is_durable() { + refs.pending_live_stdout_bytes = 0; + refs.pending_live_stderr_bytes = 0; + } + let Some(call_block_id) = refs.call.clone() else { + return deltas; + }; + + if let Some(index) = self.block_index.get(&call_block_id).copied() { + if self.replace_tool_summary(index, &summary) { + deltas.push(ConversationDeltaFacts::PatchBlock { + block_id: call_block_id.clone(), + patch: ConversationBlockPatchFacts::ReplaceSummary { + summary: summary.clone(), + }, + }); + } + if self.replace_tool_error(index, result.error.as_deref()) { + deltas.push(ConversationDeltaFacts::PatchBlock { + block_id: call_block_id.clone(), + patch: ConversationBlockPatchFacts::ReplaceError { + error: result.error.clone(), + }, + }); + } + if self.replace_tool_duration(index, result.duration_ms) { + deltas.push(ConversationDeltaFacts::PatchBlock { + block_id: call_block_id.clone(), + patch: ConversationBlockPatchFacts::ReplaceDuration { + duration_ms: result.duration_ms, + }, + }); + } + if self.replace_tool_truncated(index, result.truncated) { + deltas.push(ConversationDeltaFacts::PatchBlock { + block_id: call_block_id.clone(), + patch: ConversationBlockPatchFacts::SetTruncated { + truncated: result.truncated, + }, + }); + } + if let Some(metadata) = &result.metadata { + if self.replace_tool_metadata(index, metadata) { + deltas.push(ConversationDeltaFacts::PatchBlock { + block_id: call_block_id.clone(), + patch: ConversationBlockPatchFacts::ReplaceMetadata { + metadata: metadata.clone(), + }, + }); + } + } + if let Some(child_ref) = &result.child_ref { + if self.replace_tool_child_ref(index, child_ref) { + deltas.push(ConversationDeltaFacts::PatchBlock { + block_id: call_block_id.clone(), + patch: ConversationBlockPatchFacts::ReplaceChildRef { + child_ref: child_ref.clone(), + }, + }); + } + } + if let Some(delta) = self.complete_block(&call_block_id, status) { + deltas.push(delta); + } + } + + deltas + } + + fn append_system_note( + &mut self, + block_id: &str, + note_kind: ConversationSystemNoteKind, + markdown: &str, + compact_trigger: Option<CompactTrigger>, + compact_meta: Option<CompactAppliedMeta>, + compact_preserved_recent_turns: Option<u32>, + ) -> Vec<ConversationDeltaFacts> { + if self.block_index.contains_key(block_id) { + return Vec::new(); + } + self.push_block(ConversationBlockFacts::SystemNote( + ConversationSystemNoteBlockFacts { + id: block_id.to_string(), + note_kind, + markdown: markdown.to_string(), + compact_trigger, + compact_meta, + compact_preserved_recent_turns, + }, + )) + } + + fn apply_child_notification( + &mut self, + notification: &ChildSessionNotification, + source: ProjectionSource, + ) -> Vec<ConversationDeltaFacts> { + let mut deltas = Vec::new(); + if let Some(source_tool_call_id) = notification.source_tool_call_id.as_deref() { + if let Some(call_block_id) = self + .tool_blocks + .get(source_tool_call_id) + .and_then(|refs| refs.call.clone()) + { + if let Some(index) = self.block_index.get(&call_block_id).copied() { + if self.replace_tool_child_ref(index, ¬ification.child_ref) { + deltas.push(ConversationDeltaFacts::PatchBlock { + block_id: call_block_id, + patch: ConversationBlockPatchFacts::ReplaceChildRef { + child_ref: notification.child_ref.clone(), + }, + }); + } + } + } + } + + if source.is_durable() { + deltas.extend(self.append_child_handoff(notification)); + } + deltas + } + + fn append_child_handoff( + &mut self, + notification: &ChildSessionNotification, + ) -> Vec<ConversationDeltaFacts> { + let block_id = format!("child:{}", notification.notification_id); + if self.block_index.contains_key(&block_id) { + return Vec::new(); + } + self.push_block(ConversationBlockFacts::ChildHandoff( + ConversationChildHandoffBlockFacts { + id: block_id, + handoff_kind: match notification.kind { + ChildSessionNotificationKind::Started + | ChildSessionNotificationKind::Resumed => { + ConversationChildHandoffKind::Delegated + }, + ChildSessionNotificationKind::ProgressSummary + | ChildSessionNotificationKind::Waiting => { + ConversationChildHandoffKind::Progress + }, + ChildSessionNotificationKind::Delivered + | ChildSessionNotificationKind::Closed + | ChildSessionNotificationKind::Failed => { + ConversationChildHandoffKind::Returned + }, + }, + child_ref: notification.child_ref.clone(), + message: notification + .delivery + .as_ref() + .map(|delivery| delivery.payload.message().to_string()), + }, + )) + } + + fn append_error( + &mut self, + turn_id: Option<&str>, + code: &str, + message: &str, + ) -> Vec<ConversationDeltaFacts> { + if code == "interrupted" { + return Vec::new(); + } + let block_id = format!("turn:{}:error", turn_id.unwrap_or("session")); + if self.block_index.contains_key(&block_id) { + return Vec::new(); + } + self.push_block(ConversationBlockFacts::Error(ConversationErrorBlockFacts { + id: block_id, + turn_id: turn_id.map(ToString::to_string), + code: classify_transcript_error(message), + message: message.to_string(), + })) + } + + fn complete_turn(&mut self, turn_id: &str) -> Vec<ConversationDeltaFacts> { + let Some(refs) = self.turn_blocks.get(turn_id).cloned() else { + return Vec::new(); + }; + let mut deltas = Vec::new(); + for block_id in refs.all_block_ids() { + if let Some(delta) = + self.complete_streaming_block(&block_id, ConversationBlockStatus::Complete) + { + deltas.push(delta); + } + } + let tool_blocks = self + .tool_blocks + .values() + .filter(|tool| tool.turn_id.as_deref() == Some(turn_id)) + .cloned() + .collect::<Vec<_>>(); + for tool in tool_blocks { + if let Some(call) = &tool.call { + if let Some(delta) = + self.complete_streaming_block(call, ConversationBlockStatus::Complete) + { + deltas.push(delta); + } + } + } + deltas + } + + fn push_block(&mut self, block: ConversationBlockFacts) -> Vec<ConversationDeltaFacts> { + let id = block_id(&block).to_string(); + self.block_index.insert(id, self.blocks.len()); + self.blocks.push(block.clone()); + vec![ConversationDeltaFacts::AppendBlock { + block: Box::new(block), + }] + } + + fn complete_block( + &mut self, + block_id: &str, + status: ConversationBlockStatus, + ) -> Option<ConversationDeltaFacts> { + if let Some(index) = self.block_index.get(block_id).copied() { + if self.block_status(index) == Some(status) { + return None; + } + self.set_status(index, status); + return Some(ConversationDeltaFacts::CompleteBlock { + block_id: block_id.to_string(), + status, + }); + } + None + } + + fn complete_streaming_block( + &mut self, + block_id: &str, + status: ConversationBlockStatus, + ) -> Option<ConversationDeltaFacts> { + let index = self.block_index.get(block_id).copied()?; + if self.block_status(index) != Some(ConversationBlockStatus::Streaming) { + return None; + } + self.complete_block(block_id, status) + } + + fn append_markdown(&mut self, index: usize, markdown: &str) { + match &mut self.blocks[index] { + ConversationBlockFacts::Thinking(block) => block.markdown.push_str(markdown), + ConversationBlockFacts::Assistant(block) => block.markdown.push_str(markdown), + _ => {}, + } + } + + fn replace_markdown(&mut self, index: usize, markdown: &str) { + match &mut self.blocks[index] { + ConversationBlockFacts::Thinking(block) => block.markdown = markdown.to_string(), + ConversationBlockFacts::Assistant(block) => block.markdown = markdown.to_string(), + _ => {}, + } + } + + fn append_tool_stream_content(&mut self, index: usize, stream: ToolOutputStream, chunk: &str) { + if let ConversationBlockFacts::ToolCall(block) = &mut self.blocks[index] { + match stream { + ToolOutputStream::Stdout => block.streams.stdout.push_str(chunk), + ToolOutputStream::Stderr => block.streams.stderr.push_str(chunk), + } + } + } + + fn replace_tool_summary(&mut self, index: usize, summary: &str) -> bool { + if let ConversationBlockFacts::ToolCall(block) = &mut self.blocks[index] { + if block.summary.as_deref() == Some(summary) { + return false; + } + block.summary = Some(summary.to_string()); + return true; + } + false + } + + fn replace_tool_error(&mut self, index: usize, error: Option<&str>) -> bool { + if let ConversationBlockFacts::ToolCall(block) = &mut self.blocks[index] { + let new_error = error.map(ToString::to_string); + if block.error == new_error { + return false; + } + block.error = new_error; + return true; + } + false + } + + fn replace_tool_duration(&mut self, index: usize, duration_ms: u64) -> bool { + if let ConversationBlockFacts::ToolCall(block) = &mut self.blocks[index] { + if duration_ms == 0 && block.duration_ms.is_some() { + return false; + } + if block.duration_ms == Some(duration_ms) { + return false; + } + block.duration_ms = Some(duration_ms); + return true; + } + false + } + + fn replace_tool_truncated(&mut self, index: usize, truncated: bool) -> bool { + if let ConversationBlockFacts::ToolCall(block) = &mut self.blocks[index] { + if block.truncated == truncated { + return false; + } + block.truncated = truncated; + return true; + } + false + } + + fn replace_tool_metadata(&mut self, index: usize, metadata: &Value) -> bool { + if let ConversationBlockFacts::ToolCall(block) = &mut self.blocks[index] { + if block.metadata.as_ref() == Some(metadata) { + return false; + } + block.metadata = Some(metadata.clone()); + return true; + } + false + } + + fn replace_tool_child_ref(&mut self, index: usize, child_ref: &ChildAgentRef) -> bool { + if let ConversationBlockFacts::ToolCall(block) = &mut self.blocks[index] { + if block.child_ref.as_ref() == Some(child_ref) { + return false; + } + block.child_ref = Some(child_ref.clone()); + return true; + } + false + } + + fn set_status(&mut self, index: usize, status: ConversationBlockStatus) { + match &mut self.blocks[index] { + ConversationBlockFacts::Thinking(block) => block.status = status, + ConversationBlockFacts::Assistant(block) => block.status = status, + ConversationBlockFacts::ToolCall(block) => block.status = status, + _ => {}, + } + } + + fn block_markdown(&self, index: usize) -> String { + match &self.blocks[index] { + ConversationBlockFacts::Thinking(block) => block.markdown.clone(), + ConversationBlockFacts::Assistant(block) => block.markdown.clone(), + _ => String::new(), + } + } + + fn block_status(&self, index: usize) -> Option<ConversationBlockStatus> { + match &self.blocks[index] { + ConversationBlockFacts::Thinking(block) => Some(block.status), + ConversationBlockFacts::Assistant(block) => Some(block.status), + ConversationBlockFacts::ToolCall(block) => Some(block.status), + _ => None, + } + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/session-runtime/src/query/conversation/facts.rs b/crates/session-runtime/src/query/conversation/facts.rs new file mode 100644 index 00000000..96531fb5 --- /dev/null +++ b/crates/session-runtime/src/query/conversation/facts.rs @@ -0,0 +1,232 @@ +use astrcode_core::{ + ChildAgentRef, CompactAppliedMeta, CompactTrigger, Phase, SessionEventRecord, ToolOutputStream, +}; +use serde_json::Value; + +use crate::SessionReplay; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConversationBlockStatus { + Streaming, + Complete, + Failed, + Cancelled, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConversationSystemNoteKind { + Compact, + SystemNote, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConversationChildHandoffKind { + Delegated, + Progress, + Returned, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConversationTranscriptErrorKind { + ProviderError, + ContextWindowExceeded, + ToolFatal, + RateLimit, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConversationPlanEventKind { + Saved, + ReviewPending, + Presented, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConversationPlanReviewKind { + RevisePlan, + FinalReview, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct ToolCallStreamsFacts { + pub stdout: String, + pub stderr: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConversationUserBlockFacts { + pub id: String, + pub turn_id: Option<String>, + pub markdown: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConversationAssistantBlockFacts { + pub id: String, + pub turn_id: Option<String>, + pub status: ConversationBlockStatus, + pub markdown: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConversationThinkingBlockFacts { + pub id: String, + pub turn_id: Option<String>, + pub status: ConversationBlockStatus, + pub markdown: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConversationPlanReviewFacts { + pub kind: ConversationPlanReviewKind, + pub checklist: Vec<String>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct ConversationPlanBlockersFacts { + pub missing_headings: Vec<String>, + pub invalid_sections: Vec<String>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConversationPlanBlockFacts { + pub id: String, + pub turn_id: Option<String>, + pub tool_call_id: String, + pub event_kind: ConversationPlanEventKind, + pub title: String, + pub plan_path: String, + pub summary: Option<String>, + pub status: Option<String>, + pub slug: Option<String>, + pub updated_at: Option<String>, + pub content: Option<String>, + pub review: Option<ConversationPlanReviewFacts>, + pub blockers: ConversationPlanBlockersFacts, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct ToolCallBlockFacts { + pub id: String, + pub turn_id: Option<String>, + pub tool_call_id: String, + pub tool_name: String, + pub status: ConversationBlockStatus, + pub input: Option<Value>, + pub summary: Option<String>, + pub error: Option<String>, + pub duration_ms: Option<u64>, + pub truncated: bool, + pub metadata: Option<Value>, + pub child_ref: Option<ChildAgentRef>, + pub streams: ToolCallStreamsFacts, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConversationErrorBlockFacts { + pub id: String, + pub turn_id: Option<String>, + pub code: ConversationTranscriptErrorKind, + pub message: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConversationSystemNoteBlockFacts { + pub id: String, + pub note_kind: ConversationSystemNoteKind, + pub markdown: String, + pub compact_trigger: Option<CompactTrigger>, + pub compact_meta: Option<CompactAppliedMeta>, + pub compact_preserved_recent_turns: Option<u32>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConversationChildHandoffBlockFacts { + pub id: String, + pub handoff_kind: ConversationChildHandoffKind, + pub child_ref: ChildAgentRef, + pub message: Option<String>, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum ConversationBlockFacts { + User(ConversationUserBlockFacts), + Assistant(ConversationAssistantBlockFacts), + Thinking(ConversationThinkingBlockFacts), + Plan(Box<ConversationPlanBlockFacts>), + ToolCall(Box<ToolCallBlockFacts>), + Error(ConversationErrorBlockFacts), + SystemNote(ConversationSystemNoteBlockFacts), + ChildHandoff(ConversationChildHandoffBlockFacts), +} + +#[derive(Debug, Clone, PartialEq)] +pub enum ConversationBlockPatchFacts { + AppendMarkdown { + markdown: String, + }, + ReplaceMarkdown { + markdown: String, + }, + AppendToolStream { + stream: ToolOutputStream, + chunk: String, + }, + ReplaceSummary { + summary: String, + }, + ReplaceMetadata { + metadata: Value, + }, + ReplaceError { + error: Option<String>, + }, + ReplaceDuration { + duration_ms: u64, + }, + ReplaceChildRef { + child_ref: ChildAgentRef, + }, + SetTruncated { + truncated: bool, + }, + SetStatus { + status: ConversationBlockStatus, + }, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum ConversationDeltaFacts { + AppendBlock { + block: Box<ConversationBlockFacts>, + }, + PatchBlock { + block_id: String, + patch: ConversationBlockPatchFacts, + }, + CompleteBlock { + block_id: String, + status: ConversationBlockStatus, + }, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct ConversationDeltaFrameFacts { + pub cursor: String, + pub delta: ConversationDeltaFacts, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct ConversationSnapshotFacts { + pub cursor: Option<String>, + pub phase: Phase, + pub blocks: Vec<ConversationBlockFacts>, +} + +#[derive(Debug)] +pub struct ConversationStreamReplayFacts { + pub cursor: Option<String>, + pub phase: Phase, + pub seed_records: Vec<SessionEventRecord>, + pub replay_frames: Vec<ConversationDeltaFrameFacts>, + pub replay: SessionReplay, +} diff --git a/crates/session-runtime/src/query/conversation/plan_projection.rs b/crates/session-runtime/src/query/conversation/plan_projection.rs new file mode 100644 index 00000000..acdfe28a --- /dev/null +++ b/crates/session-runtime/src/query/conversation/plan_projection.rs @@ -0,0 +1,135 @@ +use super::*; + +pub(super) fn plan_block_from_tool_result( + turn_id: &str, + result: &ToolExecutionResult, +) -> Option<ConversationPlanBlockFacts> { + if !result.ok { + return None; + } + + let metadata = result.metadata.as_ref()?.as_object()?; + match result.tool_name.as_str() { + "upsertSessionPlan" => { + let title = json_string(metadata, "title")?; + let plan_path = json_string(metadata, "planPath")?; + Some(ConversationPlanBlockFacts { + id: format!("plan:{}:saved", result.tool_call_id), + turn_id: Some(turn_id.to_string()), + tool_call_id: result.tool_call_id.clone(), + event_kind: ConversationPlanEventKind::Saved, + title, + plan_path, + summary: Some(super::tool_result_summary(result)), + status: json_string(metadata, "status"), + slug: json_string(metadata, "slug"), + updated_at: json_string(metadata, "updatedAt"), + content: None, + review: None, + blockers: ConversationPlanBlockersFacts::default(), + }) + }, + "exitPlanMode" => match json_string(metadata, "schema").as_deref() { + Some("sessionPlanExit") => plan_presented_block(turn_id, result, metadata), + Some("sessionPlanExitReviewPending") | Some("sessionPlanExitBlocked") => { + plan_review_pending_block(turn_id, result, metadata) + }, + _ => None, + }, + _ => None, + } +} + +fn plan_presented_block( + turn_id: &str, + result: &ToolExecutionResult, + metadata: &serde_json::Map<String, Value>, +) -> Option<ConversationPlanBlockFacts> { + let plan = metadata.get("plan")?.as_object()?; + Some(ConversationPlanBlockFacts { + id: format!("plan:{}:presented", result.tool_call_id), + turn_id: Some(turn_id.to_string()), + tool_call_id: result.tool_call_id.clone(), + event_kind: ConversationPlanEventKind::Presented, + title: json_string(plan, "title")?, + plan_path: json_string(plan, "planPath")?, + summary: Some("计划已呈递".to_string()), + status: json_string(plan, "status"), + slug: json_string(plan, "slug"), + updated_at: json_string(plan, "updatedAt"), + content: json_string(plan, "content"), + review: None, + blockers: ConversationPlanBlockersFacts::default(), + }) +} + +fn plan_review_pending_block( + turn_id: &str, + result: &ToolExecutionResult, + metadata: &serde_json::Map<String, Value>, +) -> Option<ConversationPlanBlockFacts> { + let plan = metadata.get("plan")?.as_object()?; + let review = metadata + .get("review") + .and_then(Value::as_object) + .and_then(|review| { + let kind = match json_string(review, "kind").as_deref() { + Some("revise_plan") => ConversationPlanReviewKind::RevisePlan, + Some("final_review") => ConversationPlanReviewKind::FinalReview, + _ => return None, + }; + Some(ConversationPlanReviewFacts { + kind, + checklist: json_string_array(review, "checklist"), + }) + }); + let blockers = metadata + .get("blockers") + .and_then(Value::as_object) + .map(|blockers| ConversationPlanBlockersFacts { + missing_headings: json_string_array(blockers, "missingHeadings"), + invalid_sections: json_string_array(blockers, "invalidSections"), + }) + .unwrap_or_default(); + + Some(ConversationPlanBlockFacts { + id: format!("plan:{}:review-pending", result.tool_call_id), + turn_id: Some(turn_id.to_string()), + tool_call_id: result.tool_call_id.clone(), + event_kind: ConversationPlanEventKind::ReviewPending, + title: json_string(plan, "title")?, + plan_path: json_string(plan, "planPath")?, + summary: Some(match review.as_ref().map(|review| review.kind) { + Some(ConversationPlanReviewKind::RevisePlan) => "正在修计划".to_string(), + Some(ConversationPlanReviewKind::FinalReview) => "正在做退出前自审".to_string(), + None => "继续完善中".to_string(), + }), + status: None, + slug: None, + updated_at: None, + content: None, + review, + blockers, + }) +} + +fn json_string(container: &serde_json::Map<String, Value>, key: &str) -> Option<String> { + container + .get(key) + .and_then(Value::as_str) + .map(ToString::to_string) +} + +fn json_string_array(container: &serde_json::Map<String, Value>, key: &str) -> Vec<String> { + container + .get(key) + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(Value::as_str) + .map(ToString::to_string) + .collect() + }) + .unwrap_or_default() +} diff --git a/crates/session-runtime/src/query/conversation/projection_support.rs b/crates/session-runtime/src/query/conversation/projection_support.rs new file mode 100644 index 00000000..f2d422f7 --- /dev/null +++ b/crates/session-runtime/src/query/conversation/projection_support.rs @@ -0,0 +1,195 @@ +use super::*; + +mod plan_projection; + +impl ConversationStreamProjector { + pub fn new(last_sent_cursor: Option<String>, facts: &ConversationStreamReplayFacts) -> Self { + let mut projector = ConversationDeltaProjector::new(); + projector.seed(&facts.seed_records); + Self { + projector, + last_sent_cursor, + fallback_live_cursor: fallback_live_cursor(facts), + } + } + + pub fn last_sent_cursor(&self) -> Option<&str> { + self.last_sent_cursor.as_deref() + } + + pub fn seed_initial_replay( + &mut self, + facts: &ConversationStreamReplayFacts, + ) -> Vec<ConversationDeltaFrameFacts> { + let frames = facts.replay_frames.clone(); + self.observe_durable_frames(&frames); + frames + } + + pub fn project_durable_record( + &mut self, + record: &SessionEventRecord, + ) -> Vec<ConversationDeltaFrameFacts> { + let deltas = self.projector.project_record(record); + self.wrap_durable_deltas(record.event_id.as_str(), deltas) + } + + pub fn project_live_event(&mut self, event: &AgentEvent) -> Vec<ConversationDeltaFrameFacts> { + let cursor = self.live_cursor(); + self.projector + .project_live_event(event) + .into_iter() + .map(|delta| ConversationDeltaFrameFacts { + cursor: cursor.clone(), + delta, + }) + .collect() + } + + pub fn recover_from( + &mut self, + recovered: &ConversationStreamReplayFacts, + ) -> Vec<ConversationDeltaFrameFacts> { + self.fallback_live_cursor = fallback_live_cursor(recovered); + let mut frames = Vec::new(); + for record in &recovered.replay.history { + frames.extend(self.project_durable_record(record)); + } + frames + } + + fn wrap_durable_deltas( + &mut self, + cursor: &str, + deltas: Vec<ConversationDeltaFacts>, + ) -> Vec<ConversationDeltaFrameFacts> { + if deltas.is_empty() { + return Vec::new(); + } + let cursor_owned = cursor.to_string(); + self.last_sent_cursor = Some(cursor_owned.clone()); + deltas + .into_iter() + .map(|delta| ConversationDeltaFrameFacts { + cursor: cursor_owned.clone(), + delta, + }) + .collect() + } + + fn observe_durable_frames(&mut self, frames: &[ConversationDeltaFrameFacts]) { + if let Some(cursor) = frames.last().map(|frame| frame.cursor.clone()) { + self.last_sent_cursor = Some(cursor); + } + } + + fn live_cursor(&self) -> String { + self.last_sent_cursor + .clone() + .or_else(|| self.fallback_live_cursor.clone()) + .unwrap_or_else(|| "0.0".to_string()) + } +} + +pub(crate) fn project_conversation_snapshot( + records: &[SessionEventRecord], + phase: Phase, +) -> ConversationSnapshotFacts { + let mut projector = ConversationDeltaProjector::new(); + projector.seed(records); + ConversationSnapshotFacts { + cursor: records.last().map(|record| record.event_id.clone()), + phase, + blocks: projector.into_blocks(), + } +} + +pub(crate) fn build_conversation_replay_frames( + seed_records: &[SessionEventRecord], + history: &[SessionEventRecord], +) -> Vec<ConversationDeltaFrameFacts> { + let mut projector = ConversationDeltaProjector::new(); + projector.seed(seed_records); + let mut frames = Vec::new(); + for record in history { + for delta in projector.project_record(record) { + frames.push(ConversationDeltaFrameFacts { + cursor: record.event_id.clone(), + delta, + }); + } + } + frames +} + +pub(crate) fn fallback_live_cursor(facts: &ConversationStreamReplayFacts) -> Option<String> { + facts + .seed_records + .last() + .map(|record| record.event_id.clone()) + .or_else(|| { + facts + .replay + .history + .last() + .map(|record| record.event_id.clone()) + }) +} + +pub(super) fn block_id(block: &ConversationBlockFacts) -> &str { + match block { + ConversationBlockFacts::User(block) => &block.id, + ConversationBlockFacts::Assistant(block) => &block.id, + ConversationBlockFacts::Thinking(block) => &block.id, + ConversationBlockFacts::Plan(block) => &block.id, + ConversationBlockFacts::ToolCall(block) => &block.id, + ConversationBlockFacts::Error(block) => &block.id, + ConversationBlockFacts::SystemNote(block) => &block.id, + ConversationBlockFacts::ChildHandoff(block) => &block.id, + } +} + +pub(super) fn should_suppress_tool_call_block(tool_name: &str, _input: Option<&Value>) -> bool { + matches!(tool_name, "upsertSessionPlan" | "exitPlanMode") +} + +pub(super) fn plan_block_from_tool_result( + turn_id: &str, + result: &ToolExecutionResult, +) -> Option<ConversationPlanBlockFacts> { + plan_projection::plan_block_from_tool_result(turn_id, result) +} + +pub(super) fn tool_result_summary(result: &ToolExecutionResult) -> String { + const MAX_SUMMARY_CHARS: usize = 120; + + if result.ok { + if !result.output.trim().is_empty() { + crate::query::text::summarize_inline_text(&result.output, MAX_SUMMARY_CHARS) + .unwrap_or_else(|| format!("{} completed", result.tool_name)) + } else { + format!("{} completed", result.tool_name) + } + } else if let Some(error) = &result.error { + crate::query::text::summarize_inline_text(error, MAX_SUMMARY_CHARS) + .unwrap_or_else(|| format!("{} failed", result.tool_name)) + } else if !result.output.trim().is_empty() { + crate::query::text::summarize_inline_text(&result.output, MAX_SUMMARY_CHARS) + .unwrap_or_else(|| format!("{} failed", result.tool_name)) + } else { + format!("{} failed", result.tool_name) + } +} + +pub(super) fn classify_transcript_error(message: &str) -> ConversationTranscriptErrorKind { + let lower = message.to_lowercase(); + if lower.contains("context window") || lower.contains("token limit") { + ConversationTranscriptErrorKind::ContextWindowExceeded + } else if lower.contains("rate limit") { + ConversationTranscriptErrorKind::RateLimit + } else if lower.contains("tool") { + ConversationTranscriptErrorKind::ToolFatal + } else { + ConversationTranscriptErrorKind::ProviderError + } +} diff --git a/crates/session-runtime/src/query/conversation/tests.rs b/crates/session-runtime/src/query/conversation/tests.rs new file mode 100644 index 00000000..0f302408 --- /dev/null +++ b/crates/session-runtime/src/query/conversation/tests.rs @@ -0,0 +1,704 @@ +use std::{path::Path, sync::Arc}; + +use astrcode_core::{ + AgentEvent, AgentEventContext, AgentLifecycleStatus, ChildAgentRef, ChildExecutionIdentity, + ChildSessionLineageKind, ChildSessionNotification, ChildSessionNotificationKind, + DeleteProjectResult, EventStore, ParentDelivery, ParentDeliveryOrigin, ParentDeliveryPayload, + ParentDeliveryTerminalSemantics, ParentExecutionRef, Phase, SessionEventRecord, SessionId, + SessionMeta, SessionTurnAcquireResult, StorageEvent, StorageEventPayload, StoredEvent, + ToolExecutionResult, ToolOutputStream, UserMessageOrigin, +}; +use async_trait::async_trait; +use chrono::Utc; +use serde_json::json; +use tokio::sync::broadcast; + +use super::{ + ConversationBlockFacts, ConversationBlockPatchFacts, ConversationBlockStatus, + ConversationChildHandoffKind, ConversationDeltaFacts, ConversationDeltaProjector, + ConversationPlanEventKind, ConversationStreamProjector, ConversationStreamReplayFacts, + build_conversation_replay_frames, fallback_live_cursor, project_conversation_snapshot, +}; +use crate::{ + SessionReplay, SessionRuntime, + turn::test_support::{NoopMetrics, NoopPromptFactsProvider, test_kernel}, +}; + +#[test] +fn snapshot_projects_tool_call_block_with_streams_and_terminal_fields() { + let records = vec![ + record( + "1.1", + AgentEvent::ToolCallStart { + turn_id: "turn-1".to_string(), + agent: sample_agent_context(), + tool_call_id: "call-1".to_string(), + tool_name: "shell_command".to_string(), + input: json!({ "command": "pwd" }), + }, + ), + record( + "1.2", + AgentEvent::ToolCallDelta { + turn_id: "turn-1".to_string(), + agent: sample_agent_context(), + tool_call_id: "call-1".to_string(), + tool_name: "shell_command".to_string(), + stream: ToolOutputStream::Stdout, + delta: "line-1\n".to_string(), + }, + ), + record( + "1.3", + AgentEvent::ToolCallDelta { + turn_id: "turn-1".to_string(), + agent: sample_agent_context(), + tool_call_id: "call-1".to_string(), + tool_name: "shell_command".to_string(), + stream: ToolOutputStream::Stderr, + delta: "warn\n".to_string(), + }, + ), + record( + "1.4", + AgentEvent::ToolCallResult { + turn_id: "turn-1".to_string(), + agent: sample_agent_context(), + result: ToolExecutionResult { + tool_call_id: "call-1".to_string(), + tool_name: "shell_command".to_string(), + ok: false, + output: "line-1\n".to_string(), + error: Some("permission denied".to_string()), + metadata: Some(json!({ "path": "/tmp", "truncated": true })), + child_ref: None, + duration_ms: 42, + truncated: true, + }, + }, + ), + ]; + + let snapshot = project_conversation_snapshot(&records, Phase::CallingTool); + let tool = snapshot + .blocks + .iter() + .find_map(|block| match block { + ConversationBlockFacts::ToolCall(block) => Some(block), + _ => None, + }) + .expect("tool block should exist"); + + assert_eq!(tool.tool_call_id, "call-1"); + assert_eq!(tool.status, ConversationBlockStatus::Failed); + assert_eq!(tool.streams.stdout, "line-1\n"); + assert_eq!(tool.streams.stderr, "warn\n"); + assert_eq!(tool.error.as_deref(), Some("permission denied")); + assert_eq!(tool.duration_ms, Some(42)); + assert!(tool.truncated); +} + +#[test] +fn snapshot_preserves_failed_tool_status_after_turn_done() { + let records = vec![ + record( + "1.1", + AgentEvent::ToolCallStart { + turn_id: "turn-1".to_string(), + agent: sample_agent_context(), + tool_call_id: "call-1".to_string(), + tool_name: "shell_command".to_string(), + input: json!({ "command": "missing-command" }), + }, + ), + record( + "1.2", + AgentEvent::ToolCallResult { + turn_id: "turn-1".to_string(), + agent: sample_agent_context(), + result: ToolExecutionResult { + tool_call_id: "call-1".to_string(), + tool_name: "shell_command".to_string(), + ok: false, + output: String::new(), + error: Some("command not found".to_string()), + metadata: None, + child_ref: None, + duration_ms: 127, + truncated: false, + }, + }, + ), + record( + "1.3", + AgentEvent::TurnDone { + turn_id: "turn-1".to_string(), + agent: sample_agent_context(), + }, + ), + ]; + + let snapshot = project_conversation_snapshot(&records, Phase::Idle); + let tool = snapshot + .blocks + .iter() + .find_map(|block| match block { + ConversationBlockFacts::ToolCall(block) => Some(block), + _ => None, + }) + .expect("tool block should exist"); + + assert_eq!(tool.status, ConversationBlockStatus::Failed); + assert_eq!(tool.error.as_deref(), Some("command not found")); + assert_eq!(tool.duration_ms, Some(127)); +} + +#[test] +fn snapshot_projects_plan_blocks_in_durable_event_order() { + let records = vec![ + record( + "1.1", + AgentEvent::ToolCallStart { + turn_id: "turn-1".to_string(), + agent: sample_agent_context(), + tool_call_id: "call-plan-save".to_string(), + tool_name: "upsertSessionPlan".to_string(), + input: json!({ + "title": "Cleanup crates", + "content": "# Plan: Cleanup crates" + }), + }, + ), + record( + "1.2", + AgentEvent::ToolCallResult { + turn_id: "turn-1".to_string(), + agent: sample_agent_context(), + result: ToolExecutionResult { + tool_call_id: "call-plan-save".to_string(), + tool_name: "upsertSessionPlan".to_string(), + ok: true, + output: "updated session plan".to_string(), + error: None, + metadata: Some(json!({ + "planPath": "C:/Users/demo/.astrcode/projects/demo/sessions/session-1/plan/cleanup-crates.md", + "slug": "cleanup-crates", + "status": "draft", + "title": "Cleanup crates", + "updatedAt": "2026-04-19T09:00:00Z" + })), + child_ref: None, + duration_ms: 7, + truncated: false, + }, + }, + ), + record( + "1.3", + AgentEvent::ToolCallStart { + turn_id: "turn-1".to_string(), + agent: sample_agent_context(), + tool_call_id: "call-shell".to_string(), + tool_name: "shell_command".to_string(), + input: json!({ "command": "pwd" }), + }, + ), + record( + "1.4", + AgentEvent::ToolCallResult { + turn_id: "turn-1".to_string(), + agent: sample_agent_context(), + result: ToolExecutionResult { + tool_call_id: "call-shell".to_string(), + tool_name: "shell_command".to_string(), + ok: true, + output: "D:/GitObjectsOwn/Astrcode".to_string(), + error: None, + metadata: None, + child_ref: None, + duration_ms: 9, + truncated: false, + }, + }, + ), + record( + "1.5", + AgentEvent::ToolCallStart { + turn_id: "turn-1".to_string(), + agent: sample_agent_context(), + tool_call_id: "call-plan-exit".to_string(), + tool_name: "exitPlanMode".to_string(), + input: json!({}), + }, + ), + record( + "1.6", + AgentEvent::ToolCallResult { + turn_id: "turn-1".to_string(), + agent: sample_agent_context(), + result: ToolExecutionResult { + tool_call_id: "call-plan-exit".to_string(), + tool_name: "exitPlanMode".to_string(), + ok: true, + output: "Before exiting plan mode, do one final self-review.".to_string(), + error: None, + metadata: Some(json!({ + "schema": "sessionPlanExitReviewPending", + "plan": { + "title": "Cleanup crates", + "planPath": "C:/Users/demo/.astrcode/projects/demo/sessions/session-1/plan/cleanup-crates.md" + }, + "review": { + "kind": "final_review", + "checklist": [ + "Re-check assumptions against the code you already inspected." + ] + }, + "blockers": { + "missingHeadings": ["## Verification"], + "invalidSections": [] + } + })), + child_ref: None, + duration_ms: 5, + truncated: false, + }, + }, + ), + ]; + + let snapshot = project_conversation_snapshot(&records, Phase::Idle); + assert_eq!(snapshot.blocks.len(), 3); + assert!(matches!( + &snapshot.blocks[0], + ConversationBlockFacts::Plan(block) + if block.tool_call_id == "call-plan-save" + && block.event_kind == ConversationPlanEventKind::Saved + )); + assert!(matches!( + &snapshot.blocks[1], + ConversationBlockFacts::ToolCall(block) if block.tool_call_id == "call-shell" + )); + assert!(matches!( + &snapshot.blocks[2], + ConversationBlockFacts::Plan(block) + if block.tool_call_id == "call-plan-exit" + && block.event_kind == ConversationPlanEventKind::ReviewPending + )); +} + +#[test] +fn snapshot_keeps_task_write_as_normal_tool_call_block() { + let records = vec![ + record( + "1.1", + AgentEvent::ToolCallStart { + turn_id: "turn-1".to_string(), + agent: sample_agent_context(), + tool_call_id: "call-task-write".to_string(), + tool_name: "taskWrite".to_string(), + input: json!({ + "items": [ + { + "content": "实现 authoritative task panel", + "status": "in_progress", + "activeForm": "正在实现 authoritative task panel" + } + ] + }), + }, + ), + record( + "1.2", + AgentEvent::ToolCallResult { + turn_id: "turn-1".to_string(), + agent: sample_agent_context(), + result: ToolExecutionResult { + tool_call_id: "call-task-write".to_string(), + tool_name: "taskWrite".to_string(), + ok: true, + output: "updated execution tasks".to_string(), + error: None, + metadata: Some(json!({ + "schema": "executionTaskSnapshot", + "owner": "root-agent", + "cleared": false, + "items": [ + { + "content": "实现 authoritative task panel", + "status": "in_progress", + "activeForm": "正在实现 authoritative task panel" + } + ] + })), + child_ref: None, + duration_ms: 5, + truncated: false, + }, + }, + ), + ]; + + let snapshot = project_conversation_snapshot(&records, Phase::CallingTool); + assert_eq!(snapshot.blocks.len(), 1); + assert!(matches!( + &snapshot.blocks[0], + ConversationBlockFacts::ToolCall(block) + if block.tool_name == "taskWrite" + && block.tool_call_id == "call-task-write" + && block.summary.as_deref() == Some("updated execution tasks") + )); + assert!( + snapshot + .blocks + .iter() + .all(|block| !matches!(block, ConversationBlockFacts::Plan(_))), + "taskWrite must not be projected onto the canonical plan surface" + ); +} + +#[test] +fn live_then_durable_tool_delta_dedupes_chunk_on_same_tool_block() { + let facts = sample_stream_replay_facts( + vec![record( + "1.1", + AgentEvent::ToolCallStart { + turn_id: "turn-1".to_string(), + agent: sample_agent_context(), + tool_call_id: "call-1".to_string(), + tool_name: "shell_command".to_string(), + input: json!({ "command": "pwd" }), + }, + )], + vec![record( + "1.2", + AgentEvent::ToolCallDelta { + turn_id: "turn-1".to_string(), + agent: sample_agent_context(), + tool_call_id: "call-1".to_string(), + tool_name: "shell_command".to_string(), + stream: ToolOutputStream::Stdout, + delta: "line-1\n".to_string(), + }, + )], + ); + let mut stream = ConversationStreamProjector::new(Some("1.1".to_string()), &facts); + + let live_frames = stream.project_live_event(&AgentEvent::ToolCallDelta { + turn_id: "turn-1".to_string(), + agent: sample_agent_context(), + tool_call_id: "call-1".to_string(), + tool_name: "shell_command".to_string(), + stream: ToolOutputStream::Stdout, + delta: "line-1\n".to_string(), + }); + assert_eq!(live_frames.len(), 1); + + let replayed = stream.recover_from(&facts); + assert!( + replayed.is_empty(), + "durable replay should not duplicate the live-emitted chunk" + ); +} + +#[test] +fn child_notification_patches_tool_block_and_appends_handoff_block() { + let mut projector = ConversationDeltaProjector::new(); + projector.seed(&[record( + "1.1", + AgentEvent::ToolCallStart { + turn_id: "turn-1".to_string(), + agent: sample_agent_context(), + tool_call_id: "call-spawn".to_string(), + tool_name: "spawn_agent".to_string(), + input: json!({ "task": "inspect" }), + }, + )]); + + let deltas = projector.project_record(&record( + "1.2", + AgentEvent::ChildSessionNotification { + turn_id: Some("turn-1".to_string()), + agent: sample_agent_context(), + notification: sample_child_notification(), + }, + )); + + assert!(deltas.iter().any(|delta| matches!( + delta, + ConversationDeltaFacts::PatchBlock { + block_id, + patch: ConversationBlockPatchFacts::ReplaceChildRef { .. }, + } if block_id == "tool:call-spawn:call" + ))); + assert!(deltas.iter().any(|delta| matches!( + delta, + ConversationDeltaFacts::AppendBlock { + block, + } if matches!( + block.as_ref(), + ConversationBlockFacts::ChildHandoff(block) + if block.handoff_kind == ConversationChildHandoffKind::Returned + ) + ))); +} + +#[tokio::test] +async fn runtime_query_builds_snapshot_and_stream_replay_facts() { + let event_store = Arc::new(ReplayOnlyEventStore::new(vec![ + stored( + 1, + storage_event( + Some("turn-1"), + StorageEventPayload::UserMessage { + content: "inspect repo".to_string(), + origin: UserMessageOrigin::User, + timestamp: Utc::now(), + }, + ), + ), + stored( + 2, + storage_event( + Some("turn-1"), + StorageEventPayload::ToolCall { + tool_call_id: "call-1".to_string(), + tool_name: "shell_command".to_string(), + args: json!({ "command": "pwd" }), + }, + ), + ), + stored( + 3, + storage_event( + Some("turn-1"), + StorageEventPayload::ToolCallDelta { + tool_call_id: "call-1".to_string(), + tool_name: "shell_command".to_string(), + stream: ToolOutputStream::Stdout, + delta: "D:/GitObjectsOwn/Astrcode\n".to_string(), + }, + ), + ), + stored( + 4, + storage_event( + Some("turn-1"), + StorageEventPayload::ToolResult { + tool_call_id: "call-1".to_string(), + tool_name: "shell_command".to_string(), + output: "D:/GitObjectsOwn/Astrcode\n".to_string(), + success: true, + error: None, + metadata: None, + child_ref: None, + duration_ms: 7, + }, + ), + ), + stored( + 5, + storage_event( + Some("turn-1"), + StorageEventPayload::AssistantFinal { + content: "done".to_string(), + reasoning_content: Some("think".to_string()), + reasoning_signature: None, + timestamp: None, + }, + ), + ), + ])); + let runtime = SessionRuntime::new( + Arc::new(test_kernel(8192)), + Arc::new(NoopPromptFactsProvider), + event_store, + Arc::new(NoopMetrics), + ); + + let snapshot = runtime + .conversation_snapshot("session-1") + .await + .expect("snapshot should build"); + assert!(snapshot.blocks.iter().any(|block| matches!( + block, + ConversationBlockFacts::ToolCall(block) + if block.tool_call_id == "call-1" + ))); + + let transcript = runtime + .session_transcript_snapshot("session-1") + .await + .expect("transcript snapshot should build"); + assert!(transcript.records.len() > 4); + let cursor = transcript.records[3].event_id.clone(); + + let replay = runtime + .conversation_stream_replay("session-1", Some(cursor.as_str())) + .await + .expect("replay facts should build"); + assert_eq!( + replay + .seed_records + .last() + .map(|record| record.event_id.as_str()), + Some(cursor.as_str()) + ); + assert!(!replay.replay_frames.is_empty()); + assert_eq!( + fallback_live_cursor(&replay).as_deref(), + Some(cursor.as_str()) + ); +} + +fn sample_stream_replay_facts( + seed_records: Vec<SessionEventRecord>, + history: Vec<SessionEventRecord>, +) -> ConversationStreamReplayFacts { + let (_, receiver) = broadcast::channel(8); + let (_, live_receiver) = broadcast::channel(8); + ConversationStreamReplayFacts { + cursor: history.last().map(|record| record.event_id.clone()), + phase: Phase::CallingTool, + seed_records: seed_records.clone(), + replay_frames: build_conversation_replay_frames(&seed_records, &history), + replay: SessionReplay { + history, + receiver, + live_receiver, + }, + } +} + +fn sample_agent_context() -> AgentEventContext { + AgentEventContext::root_execution("agent-root", "default") +} + +fn sample_child_notification() -> ChildSessionNotification { + ChildSessionNotification { + notification_id: "child-note-1".to_string().into(), + child_ref: ChildAgentRef { + identity: ChildExecutionIdentity { + agent_id: "agent-child-1".to_string().into(), + session_id: "session-root".to_string().into(), + sub_run_id: "subrun-child-1".to_string().into(), + }, + parent: ParentExecutionRef { + parent_agent_id: Some("agent-root".to_string().into()), + parent_sub_run_id: Some("subrun-root".to_string().into()), + }, + lineage_kind: ChildSessionLineageKind::Spawn, + status: AgentLifecycleStatus::Running, + open_session_id: "session-child-1".to_string().into(), + }, + kind: ChildSessionNotificationKind::Delivered, + source_tool_call_id: Some("call-spawn".to_string().into()), + delivery: Some(ParentDelivery { + idempotency_key: "delivery-1".to_string(), + origin: ParentDeliveryOrigin::Explicit, + terminal_semantics: ParentDeliveryTerminalSemantics::Terminal, + source_turn_id: Some("turn-1".to_string()), + payload: ParentDeliveryPayload::Progress( + astrcode_core::ProgressParentDeliveryPayload { + message: "child finished".to_string(), + }, + ), + }), + } +} + +fn record(event_id: &str, event: AgentEvent) -> SessionEventRecord { + SessionEventRecord { + event_id: event_id.to_string(), + event, + } +} + +fn stored(storage_seq: u64, event: StorageEvent) -> StoredEvent { + StoredEvent { storage_seq, event } +} + +fn storage_event(turn_id: Option<&str>, payload: StorageEventPayload) -> StorageEvent { + StorageEvent { + turn_id: turn_id.map(ToString::to_string), + agent: sample_agent_context(), + payload, + } +} + +struct ReplayOnlyEventStore { + events: Vec<StoredEvent>, +} + +impl ReplayOnlyEventStore { + fn new(events: Vec<StoredEvent>) -> Self { + Self { events } + } +} + +struct StubTurnLease; + +impl astrcode_core::SessionTurnLease for StubTurnLease {} + +#[async_trait] +impl EventStore for ReplayOnlyEventStore { + async fn ensure_session( + &self, + _session_id: &SessionId, + _working_dir: &Path, + ) -> astrcode_core::Result<()> { + Ok(()) + } + + async fn append( + &self, + _session_id: &SessionId, + _event: &astrcode_core::StorageEvent, + ) -> astrcode_core::Result<StoredEvent> { + panic!("append should not be called in replay-only test store"); + } + + async fn replay(&self, _session_id: &SessionId) -> astrcode_core::Result<Vec<StoredEvent>> { + Ok(self.events.clone()) + } + + async fn try_acquire_turn( + &self, + _session_id: &SessionId, + _turn_id: &str, + ) -> astrcode_core::Result<SessionTurnAcquireResult> { + Ok(SessionTurnAcquireResult::Acquired(Box::new(StubTurnLease))) + } + + async fn list_sessions(&self) -> astrcode_core::Result<Vec<SessionId>> { + Ok(vec![SessionId::from("session-1".to_string())]) + } + + async fn list_session_metas(&self) -> astrcode_core::Result<Vec<SessionMeta>> { + Ok(vec![SessionMeta { + session_id: "session-1".to_string(), + working_dir: ".".to_string(), + display_name: "session-1".to_string(), + title: "session-1".to_string(), + created_at: Utc::now(), + updated_at: Utc::now(), + parent_session_id: None, + parent_storage_seq: None, + phase: Phase::Done, + }]) + } + + async fn delete_session(&self, _session_id: &SessionId) -> astrcode_core::Result<()> { + Ok(()) + } + + async fn delete_sessions_by_working_dir( + &self, + _working_dir: &str, + ) -> astrcode_core::Result<DeleteProjectResult> { + Ok(DeleteProjectResult { + success_count: 0, + failed_session_ids: Vec::new(), + }) + } +} diff --git a/crates/session-runtime/src/query/mailbox.rs b/crates/session-runtime/src/query/input_queue.rs similarity index 72% rename from crates/session-runtime/src/query/mailbox.rs rename to crates/session-runtime/src/query/input_queue.rs index 54d8de29..cfe7c9cb 100644 --- a/crates/session-runtime/src/query/mailbox.rs +++ b/crates/session-runtime/src/query/input_queue.rs @@ -1,15 +1,15 @@ -//! Mailbox 相关只读恢复投影。 +//! input queue 相关只读恢复投影。 //! -//! Why: mailbox 的 durable 恢复规则属于只读投影,不应该散落在 +//! Why: input queue 的 durable 恢复规则属于只读投影,不应该散落在 //! `application` 的父子编排流程里重复实现。 use std::collections::{HashMap, HashSet}; -use astrcode_core::{MailboxProjection, StorageEventPayload, StoredEvent}; +use astrcode_core::{InputQueueProjection, StorageEventPayload, StoredEvent}; use astrcode_kernel::PendingParentDelivery; pub fn recoverable_parent_deliveries(events: &[StoredEvent]) -> Vec<PendingParentDelivery> { - let projection_index = MailboxProjection::replay_index(events); + let projection_index = InputQueueProjection::replay_index(events); let mut recoverable_by_agent = HashMap::<String, HashSet<String>>::new(); for (agent_id, projection) in projection_index { let active_ids = projection @@ -20,9 +20,10 @@ pub fn recoverable_parent_deliveries(events: &[StoredEvent]) -> Vec<PendingParen .pending_delivery_ids .into_iter() .filter(|delivery_id| !active_ids.contains(delivery_id)) + .map(|delivery_id| delivery_id.to_string()) .collect::<HashSet<_>>(); if !recoverable.is_empty() { - recoverable_by_agent.insert(agent_id, recoverable); + recoverable_by_agent.insert(agent_id.to_string(), recoverable); } } @@ -31,7 +32,7 @@ pub fn recoverable_parent_deliveries(events: &[StoredEvent]) -> Vec<PendingParen let queued_at_by_delivery = events .iter() .filter_map(|stored| match &stored.event.payload { - StorageEventPayload::AgentMailboxQueued { payload } => Some(( + StorageEventPayload::AgentInputQueued { payload } => Some(( payload.envelope.delivery_id.clone(), payload.envelope.queued_at, )), @@ -44,13 +45,13 @@ pub fn recoverable_parent_deliveries(events: &[StoredEvent]) -> Vec<PendingParen else { continue; }; - let Some(parent_agent_id) = notification.child_ref.parent_agent_id.as_ref() else { + let Some(parent_agent_id) = notification.child_ref.parent_agent_id() else { continue; }; - let Some(recoverable_ids) = recoverable_by_agent.get(parent_agent_id) else { + let Some(recoverable_ids) = recoverable_by_agent.get(parent_agent_id.as_str()) else { continue; }; - if !recoverable_ids.contains(¬ification.notification_id) { + if !recoverable_ids.contains(notification.notification_id.as_str()) { continue; } if !seen.insert(notification.notification_id.clone()) { @@ -60,8 +61,8 @@ pub fn recoverable_parent_deliveries(events: &[StoredEvent]) -> Vec<PendingParen continue; }; recovered.push(PendingParentDelivery { - delivery_id: notification.notification_id.clone(), - parent_session_id: notification.child_ref.session_id.clone(), + delivery_id: notification.notification_id.to_string(), + parent_session_id: notification.child_ref.session_id().to_string(), parent_turn_id, queued_at_ms: queued_at_by_delivery .get(¬ification.notification_id) @@ -76,10 +77,10 @@ pub fn recoverable_parent_deliveries(events: &[StoredEvent]) -> Vec<PendingParen #[cfg(test)] mod tests { use astrcode_core::{ - AgentEventContext, AgentLifecycleStatus, AgentMailboxEnvelope, AgentTurnOutcome, - ChildAgentRef, ChildSessionLineageKind, ChildSessionNotification, - ChildSessionNotificationKind, MailboxQueuedPayload, StorageEvent, StorageEventPayload, - StoredEvent, + AgentEventContext, AgentLifecycleStatus, AgentTurnOutcome, ChildAgentRef, + ChildExecutionIdentity, ChildSessionLineageKind, ChildSessionNotification, + ChildSessionNotificationKind, InputQueuedPayload, ParentExecutionRef, QueuedInputEnvelope, + StorageEvent, StorageEventPayload, StoredEvent, }; use super::recoverable_parent_deliveries; @@ -87,19 +88,22 @@ mod tests { #[test] fn recoverable_parent_deliveries_skips_active_batch_entries() { let notification = ChildSessionNotification { - notification_id: "delivery-1".to_string(), + notification_id: "delivery-1".to_string().into(), child_ref: ChildAgentRef { - agent_id: "agent-child".to_string(), - session_id: "session-parent".to_string(), - sub_run_id: "subrun-child".to_string(), - parent_agent_id: Some("agent-parent".to_string()), - parent_sub_run_id: Some("subrun-parent".to_string()), + identity: ChildExecutionIdentity { + agent_id: "agent-child".to_string().into(), + session_id: "session-parent".to_string().into(), + sub_run_id: "subrun-child".to_string().into(), + }, + parent: ParentExecutionRef { + parent_agent_id: Some("agent-parent".to_string().into()), + parent_sub_run_id: Some("subrun-parent".to_string().into()), + }, lineage_kind: ChildSessionLineageKind::Spawn, status: AgentLifecycleStatus::Idle, - open_session_id: "session-child".to_string(), + open_session_id: "session-child".to_string().into(), }, kind: ChildSessionNotificationKind::Delivered, - status: AgentLifecycleStatus::Idle, source_tool_call_id: None, delivery: Some(astrcode_core::ParentDelivery { idempotency_key: "delivery-1".to_string(), @@ -132,11 +136,11 @@ mod tests { event: StorageEvent { turn_id: Some("turn-parent".to_string()), agent: AgentEventContext::default(), - payload: StorageEventPayload::AgentMailboxQueued { - payload: MailboxQueuedPayload { - envelope: AgentMailboxEnvelope { + payload: StorageEventPayload::AgentInputQueued { + payload: InputQueuedPayload { + envelope: QueuedInputEnvelope { delivery_id: notification.notification_id.clone(), - from_agent_id: notification.child_ref.agent_id.clone(), + from_agent_id: notification.child_ref.agent_id().to_string(), to_agent_id: "agent-parent".to_string(), message: "done".to_string(), queued_at: chrono::Utc::now(), @@ -145,7 +149,7 @@ mod tests { sender_open_session_id: notification .child_ref .open_session_id - .clone(), + .to_string(), }, }, }, diff --git a/crates/session-runtime/src/query/mod.rs b/crates/session-runtime/src/query/mod.rs index b334509d..87b8fd0d 100644 --- a/crates/session-runtime/src/query/mod.rs +++ b/crates/session-runtime/src/query/mod.rs @@ -3,18 +3,30 @@ //! 这些类型表达的是 session-runtime 对外提供的只读快照, //! 让 `application` 只消费稳定视图,不再自己拼装会话真相。 -pub mod agent; -pub mod mailbox; +mod agent; +mod conversation; +mod input_queue; mod service; -pub mod terminal; -pub mod transcript; -pub mod turn; +mod terminal; +mod text; +mod transcript; +mod turn; -pub use agent::{AgentObserveSnapshot, build_agent_observe_snapshot}; -pub use mailbox::recoverable_parent_deliveries; -pub use service::SessionQueries; -pub use terminal::SessionControlStateSnapshot; -pub use transcript::{SessionReplay, SessionTranscriptSnapshot, current_turn_messages}; -pub use turn::{ - ProjectedTurnOutcome, TurnTerminalSnapshot, has_terminal_turn_signal, project_turn_outcome, +pub use agent::AgentObserveSnapshot; +pub use conversation::{ + ConversationAssistantBlockFacts, ConversationBlockFacts, ConversationBlockPatchFacts, + ConversationBlockStatus, ConversationChildHandoffBlockFacts, ConversationChildHandoffKind, + ConversationDeltaFacts, ConversationDeltaFrameFacts, ConversationDeltaProjector, + ConversationErrorBlockFacts, ConversationPlanBlockFacts, ConversationPlanBlockersFacts, + ConversationPlanEventKind, ConversationPlanReviewFacts, ConversationPlanReviewKind, + ConversationSnapshotFacts, ConversationStreamProjector, ConversationStreamReplayFacts, + ConversationSystemNoteBlockFacts, ConversationSystemNoteKind, ConversationThinkingBlockFacts, + ConversationTranscriptErrorKind, ConversationUserBlockFacts, ToolCallBlockFacts, + ToolCallStreamsFacts, }; +pub use input_queue::recoverable_parent_deliveries; +pub(crate) use service::SessionQueries; +pub use terminal::{LastCompactMetaSnapshot, SessionControlStateSnapshot, SessionModeSnapshot}; +pub(crate) use transcript::current_turn_messages; +pub use transcript::{SessionReplay, SessionTranscriptSnapshot}; +pub use turn::{ProjectedTurnOutcome, TurnTerminalSnapshot}; diff --git a/crates/session-runtime/src/query/service.rs b/crates/session-runtime/src/query/service.rs index a6e0fe09..351955de 100644 --- a/crates/session-runtime/src/query/service.rs +++ b/crates/session-runtime/src/query/service.rs @@ -1,17 +1,24 @@ -use std::{sync::Arc, time::Duration}; +use std::sync::Arc; use astrcode_core::{ - AgentLifecycleStatus, ChildSessionNode, Phase, Result, SessionId, StoredEvent, + AgentEvent, AgentLifecycleStatus, ChildSessionNode, Phase, Result, SessionEventRecord, + SessionId, StorageEventPayload, StoredEvent, TaskSnapshot, }; -use tokio::time::sleep; +use tokio::sync::broadcast::error::RecvError; use crate::{ - AgentObserveSnapshot, ProjectedTurnOutcome, SessionControlStateSnapshot, SessionRuntime, - SessionState, TurnTerminalSnapshot, build_agent_observe_snapshot, has_terminal_turn_signal, - project_turn_outcome, recoverable_parent_deliveries, + AgentObserveSnapshot, ConversationSnapshotFacts, ConversationStreamReplayFacts, + LastCompactMetaSnapshot, ProjectedTurnOutcome, SessionControlStateSnapshot, + SessionModeSnapshot, SessionReplay, SessionRuntime, SessionState, TurnTerminalSnapshot, + query::{ + agent::build_agent_observe_snapshot, + conversation::{build_conversation_replay_frames, project_conversation_snapshot}, + input_queue::recoverable_parent_deliveries, + turn::{has_terminal_turn_signal, project_turn_outcome}, + }, }; -pub struct SessionQueries<'a> { +pub(crate) struct SessionQueries<'a> { runtime: &'a SessionRuntime, } @@ -41,10 +48,25 @@ impl<'a> SessionQueries<'a> { ) -> Result<SessionControlStateSnapshot> { let session_id = SessionId::from(crate::normalize_session_id(session_id)); let actor = self.runtime.ensure_loaded_session(&session_id).await?; + let last_compact_meta = actor + .state() + .snapshot_recent_stored_events()? + .into_iter() + .rev() + .find_map(|stored| match stored.event.payload { + StorageEventPayload::CompactApplied { trigger, meta, .. } => { + Some(LastCompactMetaSnapshot { trigger, meta }) + }, + _ => None, + }); Ok(SessionControlStateSnapshot { phase: actor.state().current_phase()?, active_turn_id: actor.state().active_turn_id_snapshot()?, manual_compact_pending: actor.state().manual_compact_pending()?, + compacting: actor.state().compacting(), + last_compact_meta, + current_mode_id: actor.state().current_mode_id()?, + last_mode_changed_at: actor.state().last_mode_changed_at()?, }) } @@ -54,12 +76,31 @@ impl<'a> SessionQueries<'a> { actor.state().list_child_session_nodes() } + pub async fn session_mode_state(&self, session_id: &str) -> Result<SessionModeSnapshot> { + let session_id = SessionId::from(crate::normalize_session_id(session_id)); + let actor = self.runtime.ensure_loaded_session(&session_id).await?; + Ok(SessionModeSnapshot { + current_mode_id: actor.state().current_mode_id()?, + last_mode_changed_at: actor.state().last_mode_changed_at()?, + }) + } + pub async fn session_working_dir(&self, session_id: &str) -> Result<String> { let session_id = SessionId::from(crate::normalize_session_id(session_id)); let actor = self.runtime.ensure_loaded_session(&session_id).await?; Ok(actor.working_dir().to_string()) } + pub async fn active_task_snapshot( + &self, + session_id: &str, + owner: &str, + ) -> Result<Option<TaskSnapshot>> { + let session_id = SessionId::from(crate::normalize_session_id(session_id)); + let actor = self.runtime.ensure_loaded_session(&session_id).await?; + actor.state().active_tasks_for(owner) + } + pub async fn stored_events(&self, session_id: &SessionId) -> Result<Vec<StoredEvent>> { self.runtime.ensure_session_exists(session_id).await?; self.runtime.event_store.replay(session_id).await @@ -71,21 +112,46 @@ impl<'a> SessionQueries<'a> { turn_id: &str, ) -> Result<TurnTerminalSnapshot> { let session_id = SessionId::from(crate::normalize_session_id(session_id)); + let state = self.session_state(&session_id).await?; + let mut receiver = state.broadcaster.subscribe(); + if let Some(snapshot) = self + .try_turn_terminal_snapshot(&session_id, state.as_ref(), turn_id, true) + .await? + { + return Ok(snapshot); + } loop { - let state = self.session_state(&session_id).await?; - let phase = state.current_phase()?; - if matches!(phase, Phase::Idle | Phase::Interrupted | Phase::Done) { - let events = self - .stored_events(&session_id) - .await? - .into_iter() - .filter(|stored| stored.event.turn_id() == Some(turn_id)) - .collect::<Vec<_>>(); - if has_terminal_turn_signal(&events) || matches!(phase, Phase::Interrupted) { - return Ok(TurnTerminalSnapshot { phase, events }); - } + match receiver.recv().await { + Ok(record) => { + if !record_targets_turn(&record, turn_id) + && !matches!(state.current_phase()?, Phase::Interrupted) + { + continue; + } + if let Some(snapshot) = + try_turn_terminal_snapshot_from_recent(state.as_ref(), turn_id)? + { + return Ok(snapshot); + } + }, + Err(RecvError::Lagged(_)) => { + if let Some(snapshot) = self + .try_turn_terminal_snapshot(&session_id, state.as_ref(), turn_id, true) + .await? + { + return Ok(snapshot); + } + }, + Err(RecvError::Closed) => { + if let Some(snapshot) = self + .try_turn_terminal_snapshot(&session_id, state.as_ref(), turn_id, true) + .await? + { + return Ok(snapshot); + } + receiver = state.broadcaster.subscribe(); + }, } - sleep(Duration::from_millis(20)).await; } } @@ -98,17 +164,49 @@ impl<'a> SessionQueries<'a> { let session_id = SessionId::from(crate::normalize_session_id(open_session_id)); let session_state = self.session_state(&session_id).await?; let projected = session_state.snapshot_projected_state()?; - let mailbox_projection = session_state.mailbox_projection_for_agent(target_agent_id)?; - let stored_events = self.stored_events(&session_id).await?; + let input_queue_projection = + session_state.input_queue_projection_for_agent(target_agent_id)?; Ok(build_agent_observe_snapshot( lifecycle_status, &projected, - &mailbox_projection, - &stored_events, - target_agent_id, + &input_queue_projection, )) } + pub async fn conversation_snapshot( + &self, + session_id: &str, + ) -> Result<ConversationSnapshotFacts> { + let session_id = SessionId::from(crate::normalize_session_id(session_id)); + let records = self.runtime.replay_history(&session_id, None).await?; + let phase = self.runtime.session_phase(&session_id).await?; + Ok(project_conversation_snapshot(&records, phase)) + } + + pub async fn conversation_stream_replay( + &self, + session_id: &str, + last_event_id: Option<&str>, + ) -> Result<ConversationStreamReplayFacts> { + let session_id = SessionId::from(crate::normalize_session_id(session_id)); + let actor = self.runtime.ensure_loaded_session(&session_id).await?; + let full_history = self.runtime.replay_history(&session_id, None).await?; + let (seed_records, replay_history) = split_records_at_cursor(full_history, last_event_id); + let phase = self.runtime.session_phase(&session_id).await?; + + Ok(ConversationStreamReplayFacts { + cursor: replay_history.last().map(|record| record.event_id.clone()), + phase, + replay_frames: build_conversation_replay_frames(&seed_records, &replay_history), + seed_records, + replay: SessionReplay { + history: replay_history, + receiver: actor.state().broadcaster.subscribe(), + live_receiver: actor.state().subscribe_live(), + }, + }) + } + pub async fn pending_delivery_ids_for_agent( &self, session_id: &str, @@ -117,8 +215,11 @@ impl<'a> SessionQueries<'a> { let session_id = SessionId::from(crate::normalize_session_id(session_id)); let session_state = self.session_state(&session_id).await?; Ok(session_state - .mailbox_projection_for_agent(agent_id)? - .pending_delivery_ids) + .input_queue_projection_for_agent(agent_id)? + .pending_delivery_ids + .into_iter() + .map(Into::into) + .collect()) } pub async fn recoverable_parent_deliveries( @@ -140,4 +241,500 @@ impl<'a> SessionQueries<'a> { .await?; Ok(project_turn_outcome(terminal.phase, &terminal.events)) } + + async fn try_turn_terminal_snapshot( + &self, + session_id: &SessionId, + state: &SessionState, + turn_id: &str, + allow_durable_fallback: bool, + ) -> Result<Option<TurnTerminalSnapshot>> { + if let Some(snapshot) = try_turn_terminal_snapshot_from_recent(state, turn_id)? { + return Ok(Some(snapshot)); + } + + if !allow_durable_fallback { + return Ok(None); + } + + let phase = state.current_phase()?; + let events = turn_events(self.stored_events(session_id).await?, turn_id); + if turn_snapshot_is_terminal(phase, &events) { + return Ok(Some(TurnTerminalSnapshot { phase, events })); + } + + Ok(None) + } +} + +fn split_records_at_cursor( + mut records: Vec<SessionEventRecord>, + last_event_id: Option<&str>, +) -> (Vec<SessionEventRecord>, Vec<SessionEventRecord>) { + let Some(last_event_id) = last_event_id else { + return (Vec::new(), records); + }; + + let Some(index) = records + .iter() + .position(|record| record.event_id == last_event_id) + else { + return (Vec::new(), records); + }; + + let replay_records = records.split_off(index + 1); + (records, replay_records) +} + +fn try_turn_terminal_snapshot_from_recent( + state: &SessionState, + turn_id: &str, +) -> Result<Option<TurnTerminalSnapshot>> { + let phase = state.current_phase()?; + let events = turn_events(state.snapshot_recent_stored_events()?, turn_id); + if turn_snapshot_is_terminal(phase, &events) { + return Ok(Some(TurnTerminalSnapshot { phase, events })); + } + + Ok(None) +} + +fn turn_events(stored_events: Vec<StoredEvent>, turn_id: &str) -> Vec<StoredEvent> { + stored_events + .into_iter() + .filter(|stored| stored.event.turn_id() == Some(turn_id)) + .collect() +} + +fn turn_snapshot_is_terminal(phase: Phase, events: &[StoredEvent]) -> bool { + has_terminal_turn_signal(events) || (!events.is_empty() && matches!(phase, Phase::Interrupted)) +} + +fn record_targets_turn(record: &SessionEventRecord, turn_id: &str) -> bool { + match &record.event { + AgentEvent::UserMessage { turn_id: id, .. } + | AgentEvent::ModelDelta { turn_id: id, .. } + | AgentEvent::ThinkingDelta { turn_id: id, .. } + | AgentEvent::AssistantMessage { turn_id: id, .. } + | AgentEvent::ToolCallStart { turn_id: id, .. } + | AgentEvent::ToolCallDelta { turn_id: id, .. } + | AgentEvent::ToolCallResult { turn_id: id, .. } + | AgentEvent::TurnDone { turn_id: id, .. } => id == turn_id, + AgentEvent::PhaseChanged { + turn_id: Some(id), .. + } + | AgentEvent::PromptMetrics { + turn_id: Some(id), .. + } + | AgentEvent::CompactApplied { + turn_id: Some(id), .. + } + | AgentEvent::SubRunStarted { + turn_id: Some(id), .. + } + | AgentEvent::SubRunFinished { + turn_id: Some(id), .. + } + | AgentEvent::ChildSessionNotification { + turn_id: Some(id), .. + } + | AgentEvent::AgentInputQueued { + turn_id: Some(id), .. + } + | AgentEvent::AgentInputBatchStarted { + turn_id: Some(id), .. + } + | AgentEvent::AgentInputBatchAcked { + turn_id: Some(id), .. + } + | AgentEvent::AgentInputDiscarded { + turn_id: Some(id), .. + } + | AgentEvent::Error { + turn_id: Some(id), .. + } => id == turn_id, + _ => false, + } +} + +#[cfg(test)] +mod tests { + use std::{ + path::Path, + sync::{ + Arc, Mutex, + atomic::{AtomicU64, AtomicUsize, Ordering}, + }, + }; + + use astrcode_core::{ + AgentEventContext, DeleteProjectResult, EventStore, EventTranslator, ExecutionTaskItem, + ExecutionTaskStatus, Phase, Result, SessionEventRecord, SessionId, SessionMeta, + SessionTurnAcquireResult, StorageEvent, StorageEventPayload, StoredEvent, + UserMessageOrigin, + }; + use async_trait::async_trait; + use tokio::time::{Duration, timeout}; + + use super::{split_records_at_cursor, turn_snapshot_is_terminal}; + use crate::{ + state::append_and_broadcast, + turn::test_support::{StubEventStore, test_runtime}, + }; + + #[test] + fn split_records_at_cursor_keeps_seed_prefix_and_replay_suffix() { + let records = vec![ + SessionEventRecord { + event_id: "1.0".to_string(), + event: astrcode_core::AgentEvent::SessionStarted { + session_id: "session-1".to_string(), + }, + }, + SessionEventRecord { + event_id: "2.0".to_string(), + event: astrcode_core::AgentEvent::SessionStarted { + session_id: "session-1".to_string(), + }, + }, + SessionEventRecord { + event_id: "3.0".to_string(), + event: astrcode_core::AgentEvent::SessionStarted { + session_id: "session-1".to_string(), + }, + }, + ]; + + let (seed, replay) = split_records_at_cursor(records, Some("2.0")); + + assert_eq!( + seed.iter() + .map(|record| record.event_id.as_str()) + .collect::<Vec<_>>(), + vec!["1.0", "2.0"] + ); + assert_eq!( + replay + .iter() + .map(|record| record.event_id.as_str()) + .collect::<Vec<_>>(), + vec!["3.0"] + ); + } + + #[tokio::test] + async fn wait_for_turn_terminal_snapshot_wakes_on_broadcast_event() { + let runtime = test_runtime(Arc::new(StubEventStore::default())); + let session = runtime + .create_session(".") + .await + .expect("session should be created"); + let session_id = session.session_id.clone(); + let turn_id = "turn-1".to_string(); + + let waiter = { + let runtime = &runtime; + let session_id = session_id.clone(); + let turn_id = turn_id.clone(); + async move { + runtime + .wait_for_turn_terminal_snapshot(&session_id, &turn_id) + .await + } + }; + + let state = runtime + .get_session_state(&session_id.clone().into()) + .await + .expect("state should load"); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(10)).await; + let mut translator = EventTranslator::new(Phase::Idle); + append_and_broadcast( + state.as_ref(), + &StorageEvent { + turn_id: Some(turn_id), + agent: AgentEventContext::default(), + payload: StorageEventPayload::TurnDone { + timestamp: chrono::Utc::now(), + reason: Some("completed".to_string()), + }, + }, + &mut translator, + ) + .await + .expect("turn done should append"); + }); + + let snapshot = timeout(Duration::from_secs(1), waiter) + .await + .expect("wait should complete") + .expect("snapshot should load"); + + assert!(turn_snapshot_is_terminal(snapshot.phase, &snapshot.events)); + assert_eq!(snapshot.events.len(), 1); + assert_eq!(snapshot.events[0].event.turn_id(), Some("turn-1")); + } + + #[tokio::test] + async fn wait_for_turn_terminal_snapshot_replays_only_once_while_waiting() { + let event_store = Arc::new(CountingEventStore::default()); + let runtime = test_runtime(event_store.clone()); + let session = runtime + .create_session(".") + .await + .expect("session should be created"); + let session_id = session.session_id.clone(); + let turn_id = "turn-1".to_string(); + + let waiter = { + let runtime = &runtime; + let session_id = session_id.clone(); + let turn_id = turn_id.clone(); + async move { + runtime + .wait_for_turn_terminal_snapshot(&session_id, &turn_id) + .await + } + }; + + let state = runtime + .get_session_state(&session_id.clone().into()) + .await + .expect("state should load"); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(75)).await; + let mut translator = EventTranslator::new(Phase::Idle); + append_and_broadcast( + state.as_ref(), + &StorageEvent { + turn_id: Some(turn_id), + agent: AgentEventContext::default(), + payload: StorageEventPayload::TurnDone { + timestamp: chrono::Utc::now(), + reason: Some("completed".to_string()), + }, + }, + &mut translator, + ) + .await + .expect("turn done should append"); + }); + + timeout(Duration::from_secs(1), waiter) + .await + .expect("wait should complete") + .expect("snapshot should load"); + + assert_eq!( + event_store.replay_count(), + 1, + "live wait should not repeatedly rescan durable history" + ); + } + + #[tokio::test] + async fn conversation_stream_replay_reuses_single_history_load_when_cache_is_truncated() { + let event_store = Arc::new(CountingEventStore::with_events(build_large_history())); + let runtime = test_runtime(event_store.clone()); + + runtime + .get_session_state(&SessionId::from("1".to_string())) + .await + .expect("state should load from durable history"); + event_store.reset_replay_count(); + + let replay = runtime + .conversation_stream_replay("session-1", Some("1.0")) + .await + .expect("replay facts should build"); + + assert_eq!( + replay + .seed_records + .last() + .map(|record| record.event_id.as_str()), + Some("1.0") + ); + assert_eq!( + event_store.replay_count(), + 1, + "truncated cache should trigger only one durable replay for stream recovery" + ); + } + + #[tokio::test] + async fn active_task_snapshot_reads_authoritative_owner_snapshot() { + let runtime = test_runtime(Arc::new(StubEventStore::default())); + let session = runtime + .create_session(".") + .await + .expect("session should be created"); + let session_id = session.session_id.clone(); + let state = runtime + .get_session_state(&session_id.clone().into()) + .await + .expect("state should load"); + + state + .replace_active_task_snapshot(astrcode_core::TaskSnapshot { + owner: "owner-a".to_string(), + items: vec![ExecutionTaskItem { + content: "实现 prompt 注入".to_string(), + status: ExecutionTaskStatus::InProgress, + active_form: Some("正在实现 prompt 注入".to_string()), + }], + }) + .expect("task snapshot should store"); + + let snapshot = runtime + .query() + .active_task_snapshot(&session_id, "owner-a") + .await + .expect("query should succeed") + .expect("snapshot should exist"); + + assert_eq!(snapshot.owner, "owner-a"); + assert_eq!(snapshot.items[0].content, "实现 prompt 注入"); + } + + fn build_large_history() -> Vec<StoredEvent> { + let mut events = Vec::with_capacity(16_386); + events.push(StoredEvent { + storage_seq: 1, + event: StorageEvent { + turn_id: None, + agent: AgentEventContext::default(), + payload: StorageEventPayload::SessionStart { + session_id: "1".to_string(), + timestamp: chrono::Utc::now(), + working_dir: ".".to_string(), + parent_session_id: None, + parent_storage_seq: None, + }, + }, + }); + for storage_seq in 2..=16_386 { + events.push(StoredEvent { + storage_seq, + event: StorageEvent { + turn_id: Some(format!("turn-{storage_seq}")), + agent: AgentEventContext::default(), + payload: StorageEventPayload::UserMessage { + content: format!("message {storage_seq}"), + origin: UserMessageOrigin::User, + timestamp: chrono::Utc::now(), + }, + }, + }); + } + events + } + + #[derive(Debug, Default)] + struct CountingEventStore { + events: Mutex<Vec<StoredEvent>>, + next_seq: AtomicU64, + replay_count: AtomicUsize, + } + + impl CountingEventStore { + fn with_events(events: Vec<StoredEvent>) -> Self { + let next_seq = events + .last() + .map(|stored| stored.storage_seq) + .unwrap_or_default(); + Self { + events: Mutex::new(events), + next_seq: AtomicU64::new(next_seq), + replay_count: AtomicUsize::new(0), + } + } + + fn replay_count(&self) -> usize { + self.replay_count.load(Ordering::SeqCst) + } + + fn reset_replay_count(&self) { + self.replay_count.store(0, Ordering::SeqCst); + } + } + + struct CountingTurnLease; + + impl astrcode_core::SessionTurnLease for CountingTurnLease {} + + #[async_trait] + impl EventStore for CountingEventStore { + async fn ensure_session(&self, _session_id: &SessionId, _working_dir: &Path) -> Result<()> { + Ok(()) + } + + async fn append( + &self, + _session_id: &SessionId, + event: &StorageEvent, + ) -> Result<StoredEvent> { + let stored = StoredEvent { + storage_seq: self.next_seq.fetch_add(1, Ordering::SeqCst) + 1, + event: event.clone(), + }; + self.events + .lock() + .expect("counting event store should lock") + .push(stored.clone()); + Ok(stored) + } + + async fn replay(&self, _session_id: &SessionId) -> Result<Vec<StoredEvent>> { + self.replay_count.fetch_add(1, Ordering::SeqCst); + Ok(self + .events + .lock() + .expect("counting event store should lock") + .clone()) + } + + async fn try_acquire_turn( + &self, + _session_id: &SessionId, + _turn_id: &str, + ) -> Result<SessionTurnAcquireResult> { + Ok(SessionTurnAcquireResult::Acquired(Box::new( + CountingTurnLease, + ))) + } + + async fn list_sessions(&self) -> Result<Vec<SessionId>> { + Ok(vec![SessionId::from("1".to_string())]) + } + + async fn list_session_metas(&self) -> Result<Vec<SessionMeta>> { + Ok(vec![SessionMeta { + session_id: "1".to_string(), + working_dir: ".".to_string(), + display_name: "session-1".to_string(), + title: "session-1".to_string(), + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + parent_session_id: None, + parent_storage_seq: None, + phase: Phase::Idle, + }]) + } + + async fn delete_session(&self, _session_id: &SessionId) -> Result<()> { + Ok(()) + } + + async fn delete_sessions_by_working_dir( + &self, + _working_dir: &str, + ) -> Result<DeleteProjectResult> { + Ok(DeleteProjectResult { + success_count: 0, + failed_session_ids: Vec::new(), + }) + } + } } diff --git a/crates/session-runtime/src/query/terminal.rs b/crates/session-runtime/src/query/terminal.rs index bb2afbe4..88322f91 100644 --- a/crates/session-runtime/src/query/terminal.rs +++ b/crates/session-runtime/src/query/terminal.rs @@ -1,4 +1,11 @@ -use astrcode_core::Phase; +use astrcode_core::{CompactAppliedMeta, CompactTrigger, Phase}; +use chrono::{DateTime, Utc}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LastCompactMetaSnapshot { + pub trigger: CompactTrigger, + pub meta: CompactAppliedMeta, +} /// terminal / interactive surface 需要的稳定控制态快照。 /// @@ -9,4 +16,14 @@ pub struct SessionControlStateSnapshot { pub phase: Phase, pub active_turn_id: Option<String>, pub manual_compact_pending: bool, + pub compacting: bool, + pub last_compact_meta: Option<LastCompactMetaSnapshot>, + pub current_mode_id: astrcode_core::ModeId, + pub last_mode_changed_at: Option<DateTime<Utc>>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SessionModeSnapshot { + pub current_mode_id: astrcode_core::ModeId, + pub last_mode_changed_at: Option<DateTime<Utc>>, } diff --git a/crates/session-runtime/src/query/text.rs b/crates/session-runtime/src/query/text.rs new file mode 100644 index 00000000..a3c02be2 --- /dev/null +++ b/crates/session-runtime/src/query/text.rs @@ -0,0 +1,45 @@ +//! query 层共享的文本规整与截断规则。 +//! +//! Why: 各类只读快照都需要输出短摘要,统一到这里可以避免 +//! 不同查询面各自复制 whitespace normalize / truncate 逻辑后逐渐漂移。 + +const DEFAULT_ELLIPSIS: &str = "..."; + +pub(crate) fn summarize_inline_text(text: &str, max_chars: usize) -> Option<String> { + let normalized = text.split_whitespace().collect::<Vec<_>>().join(" "); + truncate_text(&normalized, max_chars) +} + +pub(crate) fn truncate_text(text: &str, max_chars: usize) -> Option<String> { + let trimmed = text.trim(); + if trimmed.is_empty() { + return None; + } + + let char_count = trimmed.chars().count(); + if char_count <= max_chars { + return Some(trimmed.to_string()); + } + + Some(trimmed.chars().take(max_chars).collect::<String>() + DEFAULT_ELLIPSIS) +} + +#[cfg(test)] +mod tests { + use super::{summarize_inline_text, truncate_text}; + + #[test] + fn summarize_inline_text_normalizes_whitespace_before_truncating() { + assert_eq!( + summarize_inline_text(" review input queue \n state ", 120), + Some("review input queue state".to_string()) + ); + } + + #[test] + fn truncate_text_trims_and_truncates_with_ascii_ellipsis() { + assert_eq!(truncate_text(" hello ", 10), Some("hello".to_string())); + assert_eq!(truncate_text(" ", 10), None); + assert_eq!(truncate_text(&"a".repeat(5), 3), Some("aaa...".to_string())); + } +} diff --git a/crates/session-runtime/src/query/transcript.rs b/crates/session-runtime/src/query/transcript.rs index d6a03371..e6028e5c 100644 --- a/crates/session-runtime/src/query/transcript.rs +++ b/crates/session-runtime/src/query/transcript.rs @@ -22,7 +22,7 @@ pub struct SessionTranscriptSnapshot { pub phase: Phase, } -pub fn current_turn_messages(session: &SessionState) -> Result<Vec<LlmMessage>> { +pub(crate) fn current_turn_messages(session: &SessionState) -> Result<Vec<LlmMessage>> { Ok(session.snapshot_projected_state()?.messages) } diff --git a/crates/session-runtime/src/query/turn.rs b/crates/session-runtime/src/query/turn.rs index 18dba881..55750a4d 100644 --- a/crates/session-runtime/src/query/turn.rs +++ b/crates/session-runtime/src/query/turn.rs @@ -18,7 +18,7 @@ pub struct ProjectedTurnOutcome { pub technical_message: String, } -pub fn has_terminal_turn_signal(events: &[StoredEvent]) -> bool { +pub(crate) fn has_terminal_turn_signal(events: &[StoredEvent]) -> bool { events.iter().any(|stored| { matches!( stored.event.payload, @@ -27,7 +27,7 @@ pub fn has_terminal_turn_signal(events: &[StoredEvent]) -> bool { }) } -pub fn project_turn_outcome(phase: Phase, events: &[StoredEvent]) -> ProjectedTurnOutcome { +pub(crate) fn project_turn_outcome(phase: Phase, events: &[StoredEvent]) -> ProjectedTurnOutcome { let last_assistant = events .iter() .rev() diff --git a/crates/session-runtime/src/state/child_sessions.rs b/crates/session-runtime/src/state/child_sessions.rs index 3afa5468..56805be1 100644 --- a/crates/session-runtime/src/state/child_sessions.rs +++ b/crates/session-runtime/src/state/child_sessions.rs @@ -8,7 +8,7 @@ pub(crate) fn rebuild_child_nodes(events: &[StoredEvent]) -> HashMap<String, Chi let mut nodes = HashMap::new(); for stored in events { if let Some(node) = child_node_from_stored_event(stored) { - nodes.insert(node.sub_run_id.clone(), node); + nodes.insert(node.sub_run_id().to_string(), node); } } nodes @@ -17,21 +17,12 @@ pub(crate) fn rebuild_child_nodes(events: &[StoredEvent]) -> HashMap<String, Chi pub(crate) fn child_node_from_stored_event(stored: &StoredEvent) -> Option<ChildSessionNode> { match &stored.event.payload { StorageEventPayload::ChildSessionNotification { notification, .. } => { - Some(ChildSessionNode { - agent_id: notification.child_ref.agent_id.clone(), - session_id: notification.child_ref.session_id.clone(), - child_session_id: notification.child_ref.open_session_id.clone(), - sub_run_id: notification.child_ref.sub_run_id.clone(), - parent_session_id: notification.child_ref.session_id.clone(), - parent_agent_id: notification.child_ref.parent_agent_id.clone(), - parent_sub_run_id: notification.child_ref.parent_sub_run_id.clone(), - parent_turn_id: stored.event.turn_id.clone().unwrap_or_default(), - lineage_kind: notification.child_ref.lineage_kind, - status: notification.status, - status_source: astrcode_core::ChildSessionStatusSource::Durable, - created_by_tool_call_id: notification.source_tool_call_id.clone(), - lineage_snapshot: None, - }) + Some(notification.child_ref.to_child_session_node( + stored.event.turn_id.clone().unwrap_or_default().into(), + astrcode_core::ChildSessionStatusSource::Durable, + notification.source_tool_call_id.clone(), + None, + )) }, _ => None, } @@ -41,7 +32,7 @@ impl SessionState { /// 写入或覆盖一个 child-session durable 节点(按 sub_run_id 去重)。 pub fn upsert_child_session_node(&self, node: ChildSessionNode) -> Result<()> { support::lock_anyhow(&self.child_nodes, "session child nodes")? - .insert(node.sub_run_id.clone(), node); + .insert(node.sub_run_id().to_string(), node); Ok(()) } @@ -58,7 +49,7 @@ impl SessionState { pub fn list_child_session_nodes(&self) -> Result<Vec<ChildSessionNode>> { let nodes = support::lock_anyhow(&self.child_nodes, "session child nodes")?; let mut result: Vec<_> = nodes.values().cloned().collect(); - result.sort_by(|a, b| a.sub_run_id.cmp(&b.sub_run_id)); + result.sort_by(|a, b| a.sub_run_id().cmp(b.sub_run_id())); Ok(result) } @@ -67,10 +58,13 @@ impl SessionState { let nodes = support::lock_anyhow(&self.child_nodes, "session child nodes")?; let mut result: Vec<_> = nodes .values() - .filter(|node| node.parent_agent_id.as_deref() == Some(parent_agent_id)) + .filter(|node| { + node.parent_agent_id() + .is_some_and(|id| id.as_str() == parent_agent_id) + }) .cloned() .collect(); - result.sort_by(|a, b| a.sub_run_id.cmp(&b.sub_run_id)); + result.sort_by(|a, b| a.sub_run_id().cmp(b.sub_run_id())); Ok(result) } @@ -82,13 +76,16 @@ impl SessionState { queue.push_back(root_agent_id.to_string()); while let Some(agent_id) = queue.pop_front() { for node in nodes.values() { - if node.parent_agent_id.as_deref() == Some(&agent_id) { - queue.push_back(node.agent_id.clone()); + if node + .parent_agent_id() + .is_some_and(|id| id.as_str() == agent_id) + { + queue.push_back(node.agent_id().to_string()); result.push(node.clone()); } } } - result.sort_by(|a, b| a.sub_run_id.cmp(&b.sub_run_id)); + result.sort_by(|a, b| a.sub_run_id().cmp(b.sub_run_id())); Ok(result) } } @@ -137,8 +134,8 @@ mod tests { .expect("child node lookup should succeed") .expect("child node should exist"); - assert_eq!(node.child_session_id, "session-child"); - assert_eq!(node.parent_session_id, "session-parent"); + assert_eq!(node.child_session_id, "session-child".into()); + assert_eq!(node.parent_session_id, "session-parent".into()); assert_eq!(node.status, AgentLifecycleStatus::Idle); assert_eq!(node.created_by_tool_call_id.as_deref(), Some("call-1")); } diff --git a/crates/session-runtime/src/state/compaction.rs b/crates/session-runtime/src/state/compaction.rs index cfc4889d..5f3bdd35 100644 --- a/crates/session-runtime/src/state/compaction.rs +++ b/crates/session-runtime/src/state/compaction.rs @@ -132,10 +132,10 @@ mod tests { #[test] fn recent_turn_event_tail_excludes_malformed_subrun_events_without_child_session() { let malformed_child_agent = AgentEventContext { - agent_id: Some("agent-child".to_string()), - parent_turn_id: Some("turn-root".to_string()), + agent_id: Some("agent-child".to_string().into()), + parent_turn_id: Some("turn-root".to_string().into()), agent_profile: Some("explore".to_string()), - sub_run_id: Some("subrun-malformed".to_string()), + sub_run_id: Some("subrun-malformed".to_string().into()), parent_sub_run_id: None, invocation_kind: Some(InvocationKind::SubRun), storage_mode: Some(astrcode_core::SubRunStorageMode::IndependentSession), @@ -210,7 +210,7 @@ mod tests { "subrun-independent", None, astrcode_core::SubRunStorageMode::IndependentSession, - Some("session-child".to_string()), + Some("session-child".to_string().into()), ); let events = vec![ stored( diff --git a/crates/session-runtime/src/state/execution.rs b/crates/session-runtime/src/state/execution.rs index 5b6cd5b0..91c54c8d 100644 --- a/crates/session-runtime/src/state/execution.rs +++ b/crates/session-runtime/src/state/execution.rs @@ -1,8 +1,8 @@ use std::sync::Arc; use astrcode_core::{ - CancelToken, EventTranslator, Phase, Result, SessionTurnLease, StorageEvent, StoredEvent, - ToolEventSink, support, + CancelToken, EventStore, EventTranslator, Phase, Result, SessionId, SessionTurnLease, + StorageEvent, StorageEventPayload, StoredEvent, ToolEventSink, support, }; use async_trait::async_trait; use tokio::sync::Mutex; @@ -55,6 +55,47 @@ pub fn complete_session_execution(session: &SessionState, phase: Phase) { session.complete_execution_state(phase); } +pub async fn checkpoint_if_compacted( + event_store: &Arc<dyn EventStore>, + session_id: &SessionId, + session_state: &Arc<SessionState>, + persisted_events: &[StoredEvent], +) { + let Some(checkpoint_storage_seq) = persisted_events.last().map(|stored| stored.storage_seq) + else { + return; + }; + if !persisted_events.iter().any(|stored| { + matches!( + stored.event.payload, + StorageEventPayload::CompactApplied { .. } + ) + }) { + return; + } + let checkpoint = match session_state.recovery_checkpoint(checkpoint_storage_seq) { + Ok(checkpoint) => checkpoint, + Err(error) => { + log::warn!( + "failed to build recovery checkpoint for session '{}': {}", + session_id, + error + ); + return; + }, + }; + if let Err(error) = event_store + .checkpoint_session(session_id, &checkpoint) + .await + { + log::warn!( + "failed to persist recovery checkpoint for session '{}': {}", + session_id, + error + ); + } +} + pub struct SessionStateEventSink { session: Arc<SessionState>, translator: Mutex<EventTranslator>, diff --git a/crates/session-runtime/src/state/input_queue.rs b/crates/session-runtime/src/state/input_queue.rs new file mode 100644 index 00000000..8f8f0d79 --- /dev/null +++ b/crates/session-runtime/src/state/input_queue.rs @@ -0,0 +1,163 @@ +use astrcode_core::{ + EventTranslator, InputBatchAckedPayload, InputBatchStartedPayload, InputDiscardedPayload, + InputQueueProjection, InputQueuedPayload, Result, StorageEvent, StorageEventPayload, + StoredEvent, support, +}; + +use super::{SessionState, append_and_broadcast}; + +/// input queue durable 事件追加命令。 +/// +/// 为什么放在 `session-runtime`:input queue 事件最终都是单 session event log 的追加动作, +/// 由真相层统一决定如何落成 `StorageEventPayload`,可以避免写侧在多处散落拼装。 +#[derive(Debug, Clone)] +pub enum InputQueueEventAppend { + Queued(InputQueuedPayload), + BatchStarted(InputBatchStartedPayload), + BatchAcked(InputBatchAckedPayload), + Discarded(InputDiscardedPayload), +} + +impl InputQueueEventAppend { + pub(crate) fn into_storage_payload(self) -> StorageEventPayload { + match self { + Self::Queued(payload) => StorageEventPayload::AgentInputQueued { payload }, + Self::BatchStarted(payload) => StorageEventPayload::AgentInputBatchStarted { payload }, + Self::BatchAcked(payload) => StorageEventPayload::AgentInputBatchAcked { payload }, + Self::Discarded(payload) => StorageEventPayload::AgentInputDiscarded { payload }, + } + } +} + +pub(crate) fn input_queue_projection_target_agent_id( + payload: &StorageEventPayload, +) -> Option<&str> { + match payload { + StorageEventPayload::AgentInputQueued { payload } => Some(&payload.envelope.to_agent_id), + StorageEventPayload::AgentInputBatchStarted { payload } => Some(&payload.target_agent_id), + StorageEventPayload::AgentInputBatchAcked { payload } => Some(&payload.target_agent_id), + StorageEventPayload::AgentInputDiscarded { payload } => Some(&payload.target_agent_id), + _ => None, + } +} + +impl SessionState { + /// 读取指定 agent 的 input queue durable 投影。 + pub fn input_queue_projection_for_agent(&self, agent_id: &str) -> Result<InputQueueProjection> { + Ok(support::lock_anyhow( + &self.input_queue_projection_index, + "input queue projection index", + )? + .get(agent_id) + .cloned() + .unwrap_or_default()) + } + + /// 增量应用一条 input queue durable 事件到投影索引。 + pub(crate) fn apply_input_queue_event(&self, stored: &StoredEvent) { + let mut index = match support::lock_anyhow( + &self.input_queue_projection_index, + "input queue projection index", + ) { + Ok(index) => index, + Err(_) => return, + }; + apply_input_queue_event_to_index(&mut index, stored); + } +} + +pub(crate) fn apply_input_queue_event_to_index( + index: &mut std::collections::HashMap<String, InputQueueProjection>, + stored: &StoredEvent, +) { + let Some(target_agent_id) = input_queue_projection_target_agent_id(&stored.event.payload) + else { + return; + }; + let projection = index.entry(target_agent_id.to_string()).or_default(); + InputQueueProjection::apply_event_for_agent(projection, stored, target_agent_id); +} + +/// 追加一条 input queue durable 事件。 +pub async fn append_input_queue_event( + session: &SessionState, + turn_id: &str, + agent: astrcode_core::AgentEventContext, + event: InputQueueEventAppend, + translator: &mut EventTranslator, +) -> Result<StoredEvent> { + append_and_broadcast( + session, + &StorageEvent { + turn_id: Some(turn_id.to_string()), + agent, + payload: event.into_storage_payload(), + }, + translator, + ) + .await +} + +#[cfg(test)] +mod tests { + use astrcode_core::{ + AgentLifecycleStatus, InputBatchAckedPayload, InputBatchStartedPayload, + InputDiscardedPayload, InputQueuedPayload, QueuedInputEnvelope, StorageEventPayload, + }; + + use super::*; + + #[test] + fn input_queue_event_append_maps_to_expected_storage_payload() { + let envelope = QueuedInputEnvelope { + delivery_id: "delivery-1".to_string().into(), + from_agent_id: "agent-parent".to_string(), + to_agent_id: "agent-child".to_string(), + message: "hello".to_string(), + queued_at: chrono::Utc::now(), + sender_lifecycle_status: AgentLifecycleStatus::Idle, + sender_last_turn_outcome: None, + sender_open_session_id: "session-parent".to_string(), + }; + + assert!(matches!( + InputQueueEventAppend::Queued(InputQueuedPayload { + envelope: envelope.clone(), + }) + .into_storage_payload(), + StorageEventPayload::AgentInputQueued { payload } + if payload.envelope.delivery_id == "delivery-1".into() + )); + assert!(matches!( + InputQueueEventAppend::BatchStarted(InputBatchStartedPayload { + target_agent_id: "agent-child".to_string(), + turn_id: "turn-1".to_string(), + batch_id: "batch-1".to_string(), + delivery_ids: vec!["delivery-1".to_string().into()], + }) + .into_storage_payload(), + StorageEventPayload::AgentInputBatchStarted { payload } + if payload.batch_id == "batch-1" + )); + assert!(matches!( + InputQueueEventAppend::BatchAcked(InputBatchAckedPayload { + target_agent_id: "agent-child".to_string(), + turn_id: "turn-1".to_string(), + batch_id: "batch-1".to_string(), + delivery_ids: vec!["delivery-1".to_string().into()], + }) + .into_storage_payload(), + StorageEventPayload::AgentInputBatchAcked { payload } + if payload.delivery_ids == vec!["delivery-1".to_string().into()] + )); + assert!(matches!( + InputQueueEventAppend::Discarded(InputDiscardedPayload { + target_agent_id: "agent-child".to_string(), + delivery_ids: vec!["delivery-1".to_string().into()], + }) + .into_storage_payload(), + StorageEventPayload::AgentInputDiscarded { payload } + if payload.target_agent_id == "agent-child" + )); + } +} diff --git a/crates/session-runtime/src/state/mailbox.rs b/crates/session-runtime/src/state/mailbox.rs deleted file mode 100644 index 38a389bd..00000000 --- a/crates/session-runtime/src/state/mailbox.rs +++ /dev/null @@ -1,230 +0,0 @@ -use astrcode_core::{ - EventTranslator, MailboxBatchAckedPayload, MailboxBatchStartedPayload, MailboxDiscardedPayload, - MailboxProjection, MailboxQueuedPayload, Result, StorageEvent, StorageEventPayload, - StoredEvent, support, -}; - -use super::{SessionState, append_and_broadcast}; - -/// mailbox durable 事件追加命令。 -/// -/// 为什么放在 `session-runtime`:mailbox 事件最终都是单 session event log 的追加动作, -/// 由真相层统一决定如何落成 `StorageEventPayload`,可以避免写侧在多处散落拼装。 -#[derive(Debug, Clone)] -pub enum MailboxEventAppend { - Queued(MailboxQueuedPayload), - BatchStarted(MailboxBatchStartedPayload), - BatchAcked(MailboxBatchAckedPayload), - Discarded(MailboxDiscardedPayload), -} - -impl MailboxEventAppend { - pub(crate) fn into_storage_payload(self) -> StorageEventPayload { - match self { - Self::Queued(payload) => StorageEventPayload::AgentMailboxQueued { payload }, - Self::BatchStarted(payload) => { - StorageEventPayload::AgentMailboxBatchStarted { payload } - }, - Self::BatchAcked(payload) => StorageEventPayload::AgentMailboxBatchAcked { payload }, - Self::Discarded(payload) => StorageEventPayload::AgentMailboxDiscarded { payload }, - } - } -} - -pub(crate) fn mailbox_projection_target_agent_id(payload: &StorageEventPayload) -> Option<&str> { - match payload { - StorageEventPayload::AgentMailboxQueued { payload } => Some(&payload.envelope.to_agent_id), - StorageEventPayload::AgentMailboxBatchStarted { payload } => Some(&payload.target_agent_id), - StorageEventPayload::AgentMailboxBatchAcked { payload } => Some(&payload.target_agent_id), - StorageEventPayload::AgentMailboxDiscarded { payload } => Some(&payload.target_agent_id), - _ => None, - } -} - -impl SessionState { - /// 读取指定 agent 的 mailbox durable 投影。 - pub fn mailbox_projection_for_agent(&self, agent_id: &str) -> Result<MailboxProjection> { - Ok( - support::lock_anyhow(&self.mailbox_projection_index, "mailbox projection index")? - .get(agent_id) - .cloned() - .unwrap_or_default(), - ) - } - - /// 增量应用一条 mailbox durable 事件到投影索引。 - pub(crate) fn apply_mailbox_event(&self, stored: &StoredEvent) { - let Some(target_agent_id) = mailbox_projection_target_agent_id(&stored.event.payload) - else { - return; - }; - let mut index = match support::lock_anyhow( - &self.mailbox_projection_index, - "mailbox projection index", - ) { - Ok(index) => index, - Err(_) => return, - }; - let projection = index - .entry(target_agent_id.to_string()) - .or_insert_with(MailboxProjection::default); - MailboxProjection::apply_event_for_agent(projection, stored, target_agent_id); - } -} - -/// 追加一条 mailbox durable 事件。 -pub async fn append_mailbox_event( - session: &SessionState, - turn_id: &str, - agent: astrcode_core::AgentEventContext, - event: MailboxEventAppend, - translator: &mut EventTranslator, -) -> Result<StoredEvent> { - append_and_broadcast( - session, - &StorageEvent { - turn_id: Some(turn_id.to_string()), - agent, - payload: event.into_storage_payload(), - }, - translator, - ) - .await -} - -/// 追加一条 `AgentMailboxQueued` 事件到 session event log。 -pub async fn append_mailbox_queued( - session: &SessionState, - turn_id: &str, - agent: astrcode_core::AgentEventContext, - payload: MailboxQueuedPayload, - translator: &mut EventTranslator, -) -> Result<StoredEvent> { - append_mailbox_event( - session, - turn_id, - agent, - MailboxEventAppend::Queued(payload), - translator, - ) - .await -} - -/// 追加一条 `AgentMailboxBatchStarted` 事件。 -pub async fn append_batch_started( - session: &SessionState, - turn_id: &str, - agent: astrcode_core::AgentEventContext, - payload: MailboxBatchStartedPayload, - translator: &mut EventTranslator, -) -> Result<StoredEvent> { - append_mailbox_event( - session, - turn_id, - agent, - MailboxEventAppend::BatchStarted(payload), - translator, - ) - .await -} - -/// 追加一条 `AgentMailboxBatchAcked` 事件。 -pub async fn append_batch_acked( - session: &SessionState, - turn_id: &str, - agent: astrcode_core::AgentEventContext, - payload: MailboxBatchAckedPayload, - translator: &mut EventTranslator, -) -> Result<StoredEvent> { - append_mailbox_event( - session, - turn_id, - agent, - MailboxEventAppend::BatchAcked(payload), - translator, - ) - .await -} - -/// 追加一条 `AgentMailboxDiscarded` 事件。 -pub async fn append_mailbox_discarded( - session: &SessionState, - turn_id: &str, - agent: astrcode_core::AgentEventContext, - payload: MailboxDiscardedPayload, - translator: &mut EventTranslator, -) -> Result<StoredEvent> { - append_mailbox_event( - session, - turn_id, - agent, - MailboxEventAppend::Discarded(payload), - translator, - ) - .await -} - -#[cfg(test)] -mod tests { - use astrcode_core::{ - AgentLifecycleStatus, AgentMailboxEnvelope, MailboxBatchAckedPayload, - MailboxBatchStartedPayload, MailboxDiscardedPayload, MailboxQueuedPayload, - StorageEventPayload, - }; - - use super::*; - - #[test] - fn mailbox_event_append_maps_to_expected_storage_payload() { - let envelope = AgentMailboxEnvelope { - delivery_id: "delivery-1".to_string(), - from_agent_id: "agent-parent".to_string(), - to_agent_id: "agent-child".to_string(), - message: "hello".to_string(), - queued_at: chrono::Utc::now(), - sender_lifecycle_status: AgentLifecycleStatus::Idle, - sender_last_turn_outcome: None, - sender_open_session_id: "session-parent".to_string(), - }; - - assert!(matches!( - MailboxEventAppend::Queued(MailboxQueuedPayload { - envelope: envelope.clone(), - }) - .into_storage_payload(), - StorageEventPayload::AgentMailboxQueued { payload } - if payload.envelope.delivery_id == "delivery-1" - )); - assert!(matches!( - MailboxEventAppend::BatchStarted(MailboxBatchStartedPayload { - target_agent_id: "agent-child".to_string(), - turn_id: "turn-1".to_string(), - batch_id: "batch-1".to_string(), - delivery_ids: vec!["delivery-1".to_string()], - }) - .into_storage_payload(), - StorageEventPayload::AgentMailboxBatchStarted { payload } - if payload.batch_id == "batch-1" - )); - assert!(matches!( - MailboxEventAppend::BatchAcked(MailboxBatchAckedPayload { - target_agent_id: "agent-child".to_string(), - turn_id: "turn-1".to_string(), - batch_id: "batch-1".to_string(), - delivery_ids: vec!["delivery-1".to_string()], - }) - .into_storage_payload(), - StorageEventPayload::AgentMailboxBatchAcked { payload } - if payload.delivery_ids == vec!["delivery-1".to_string()] - )); - assert!(matches!( - MailboxEventAppend::Discarded(MailboxDiscardedPayload { - target_agent_id: "agent-child".to_string(), - delivery_ids: vec!["delivery-1".to_string()], - }) - .into_storage_payload(), - StorageEventPayload::AgentMailboxDiscarded { payload } - if payload.target_agent_id == "agent-child" - )); - } -} diff --git a/crates/session-runtime/src/state/mod.rs b/crates/session-runtime/src/state/mod.rs index 5b23f558..ac0fa775 100644 --- a/crates/session-runtime/src/state/mod.rs +++ b/crates/session-runtime/src/state/mod.rs @@ -1,14 +1,16 @@ -//! 会话真相状态:事件投影、child-session 节点跟踪、mailbox 投影、turn 生命周期。 +//! 会话真相状态:事件投影、child-session 节点跟踪、input queue 投影、turn 生命周期。 //! //! 从 `runtime-session/session_state.rs` 迁入,去掉了 `anyhow` 依赖, //! 所有 `Result` 统一使用 `astrcode_core::Result`。 mod cache; mod child_sessions; +#[cfg(test)] mod compaction; mod execution; -mod mailbox; +mod input_queue; mod paths; +mod tasks; #[cfg(test)] mod test_support; mod writer; @@ -20,24 +22,27 @@ use std::{ use astrcode_core::{ AgentEvent, AgentState, AgentStateProjector, CancelToken, ChildSessionNode, EventTranslator, - LlmMessage, MailboxProjection, Phase, ResolvedRuntimeConfig, Result, SessionEventRecord, - SessionTurnLease, StorageEventPayload, StoredEvent, UserMessageOrigin, + InputQueueProjection, ModeId, Phase, ResolvedRuntimeConfig, Result, SessionEventRecord, + SessionRecoveryCheckpoint, SessionTurnLease, StorageEventPayload, StoredEvent, TaskSnapshot, + normalize_recovered_phase, support::{self}, }; use cache::{RecentSessionEvents, RecentStoredEvents}; use child_sessions::{child_node_from_stored_event, rebuild_child_nodes}; -pub use compaction::{recent_turn_event_tail, should_record_compaction_tail_event}; +use chrono::{DateTime, Utc}; +pub(crate) use execution::SessionStateEventSink; pub use execution::{ - SessionStateEventSink, append_and_broadcast, complete_session_execution, + append_and_broadcast, checkpoint_if_compacted, complete_session_execution, prepare_session_execution, }; -pub use mailbox::{ - MailboxEventAppend, append_batch_acked, append_batch_started, append_mailbox_discarded, - append_mailbox_event, append_mailbox_queued, +pub(crate) use input_queue::{ + InputQueueEventAppend, append_input_queue_event, apply_input_queue_event_to_index, }; +pub(crate) use paths::compact_history_event_log_path; pub use paths::{display_name_from_working_dir, normalize_session_id, normalize_working_dir}; +use tasks::{apply_snapshot_to_map, rebuild_active_tasks, task_snapshot_from_stored_event}; use tokio::sync::broadcast; -pub use writer::SessionWriter; +pub(crate) use writer::SessionWriter; const SESSION_BROADCAST_CAPACITY: usize = 2048; const SESSION_LIVE_BROADCAST_CAPACITY: usize = 2048; @@ -46,20 +51,22 @@ const SESSION_LIVE_BROADCAST_CAPACITY: usize = 2048; // ── SessionState ────────────────────────────────────────── -/// 会话 live 真相:事件投影、child-session 节点跟踪、mailbox 投影、turn 生命周期。 +/// 会话 live 真相:事件投影、child-session 节点跟踪、input queue 投影、turn 生命周期。 /// /// 使用 per-field `StdMutex` 而非外层 `RwLock`, /// 允许不同字段的并发读写互不阻塞(如 broadcaster 广播不阻塞 projector 读取)。 pub struct SessionState { pub phase: StdMutex<Phase>, pub running: AtomicBool, + pub compacting: AtomicBool, pub cancel: StdMutex<CancelToken>, pub active_turn_id: StdMutex<Option<String>>, pub turn_lease: StdMutex<Option<Box<dyn SessionTurnLease>>>, pub pending_manual_compact: StdMutex<bool>, - pub pending_manual_compact_runtime: StdMutex<Option<ResolvedRuntimeConfig>>, - pending_reactivation_messages: StdMutex<Vec<LlmMessage>>, + pub pending_manual_compact_request: StdMutex<Option<PendingManualCompactRequest>>, pub compact_failure_count: StdMutex<u32>, + pub current_mode: StdMutex<ModeId>, + pub last_mode_changed_at: StdMutex<Option<DateTime<Utc>>>, pub broadcaster: broadcast::Sender<SessionEventRecord>, live_broadcaster: broadcast::Sender<AgentEvent>, pub writer: Arc<SessionWriter>, @@ -67,7 +74,15 @@ pub struct SessionState { recent_records: StdMutex<RecentSessionEvents>, recent_stored: StdMutex<RecentStoredEvents>, child_nodes: StdMutex<HashMap<String, ChildSessionNode>>, - mailbox_projection_index: StdMutex<HashMap<String, MailboxProjection>>, + active_tasks: StdMutex<HashMap<String, TaskSnapshot>>, + input_queue_projection_index: StdMutex<HashMap<String, InputQueueProjection>>, +} + +struct SessionDerivedState { + child_nodes: HashMap<String, ChildSessionNode>, + active_tasks: HashMap<String, TaskSnapshot>, + input_queue_projection_index: HashMap<String, InputQueueProjection>, + last_mode_changed_at: Option<DateTime<Utc>>, } impl std::fmt::Debug for SessionState { @@ -88,6 +103,12 @@ pub struct SessionSnapshot { pub turn_count: usize, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PendingManualCompactRequest { + pub runtime: ResolvedRuntimeConfig, + pub instructions: Option<String>, +} + impl SessionState { pub fn new( phase: Phase, @@ -96,26 +117,105 @@ impl SessionState { recent_records: Vec<SessionEventRecord>, recent_stored: Vec<StoredEvent>, ) -> Self { + let derived = SessionDerivedState { + child_nodes: rebuild_child_nodes(&recent_stored), + active_tasks: rebuild_active_tasks(&recent_stored), + input_queue_projection_index: InputQueueProjection::replay_index(&recent_stored), + last_mode_changed_at: recent_stored.iter().rev().find_map(|stored| { + match &stored.event.payload { + StorageEventPayload::ModeChanged { timestamp, .. } => Some(*timestamp), + _ => None, + } + }), + }; + Self::from_parts( + phase, + writer, + projector, + recent_records, + recent_stored, + derived, + ) + } + + pub fn from_recovery( + writer: Arc<SessionWriter>, + checkpoint: &SessionRecoveryCheckpoint, + tail_events: Vec<StoredEvent>, + ) -> Result<Self> { + let mut projector = AgentStateProjector::from_snapshot(checkpoint.agent_state.clone()); + let mut child_nodes = checkpoint.child_nodes.clone(); + let mut active_tasks = checkpoint.active_tasks.clone(); + let mut input_queue_projection_index = checkpoint.input_queue_projection_index.clone(); + let mut last_mode_changed_at = checkpoint.last_mode_changed_at; + + for stored in &tail_events { + stored.event.validate().map_err(|error| { + astrcode_core::AstrError::Validation(format!( + "session '{}' contains invalid stored event at storage_seq {}: {}", + checkpoint.agent_state.session_id, stored.storage_seq, error + )) + })?; + projector.apply(&stored.event); + if let Some(node) = child_node_from_stored_event(stored) { + child_nodes.insert(node.sub_run_id().to_string(), node); + } + if let Some(snapshot) = task_snapshot_from_stored_event(stored) { + apply_snapshot_to_map(&mut active_tasks, snapshot); + } + apply_input_queue_event_to_index(&mut input_queue_projection_index, stored); + if let StorageEventPayload::ModeChanged { timestamp, .. } = &stored.event.payload { + last_mode_changed_at = Some(*timestamp); + } + } + + Ok(Self::from_parts( + normalize_recovered_phase(projector.snapshot().phase), + writer, + projector, + astrcode_core::replay_records(&tail_events, None), + tail_events, + SessionDerivedState { + child_nodes, + active_tasks, + input_queue_projection_index, + last_mode_changed_at, + }, + )) + } + + fn from_parts( + phase: Phase, + writer: Arc<SessionWriter>, + projector: AgentStateProjector, + recent_records: Vec<SessionEventRecord>, + recent_stored: Vec<StoredEvent>, + derived: SessionDerivedState, + ) -> Self { + let SessionDerivedState { + child_nodes, + active_tasks, + input_queue_projection_index, + last_mode_changed_at, + } = derived; let (broadcaster, _) = broadcast::channel(SESSION_BROADCAST_CAPACITY); let (live_broadcaster, _) = broadcast::channel(SESSION_LIVE_BROADCAST_CAPACITY); let mut cached_records = RecentSessionEvents::default(); cached_records.replace(recent_records); let mut cached_stored = RecentStoredEvents::default(); cached_stored.replace(recent_stored.clone()); - let child_nodes = rebuild_child_nodes(&recent_stored); - let mailbox_projection_index = MailboxProjection::replay_index(&recent_stored); - let pending_reactivation_messages = - pending_reactivation_messages_from_stored(&recent_stored); Self { phase: StdMutex::new(phase), running: AtomicBool::new(false), + compacting: AtomicBool::new(false), cancel: StdMutex::new(CancelToken::new()), active_turn_id: StdMutex::new(None), turn_lease: StdMutex::new(None), pending_manual_compact: StdMutex::new(false), - pending_manual_compact_runtime: StdMutex::new(None), - pending_reactivation_messages: StdMutex::new(pending_reactivation_messages), + pending_manual_compact_request: StdMutex::new(None), compact_failure_count: StdMutex::new(0), + current_mode: StdMutex::new(projector.snapshot().mode_id.clone()), + last_mode_changed_at: StdMutex::new(last_mode_changed_at), broadcaster, live_broadcaster, writer, @@ -123,10 +223,30 @@ impl SessionState { recent_records: StdMutex::new(cached_records), recent_stored: StdMutex::new(cached_stored), child_nodes: StdMutex::new(child_nodes), - mailbox_projection_index: StdMutex::new(mailbox_projection_index), + active_tasks: StdMutex::new(active_tasks), + input_queue_projection_index: StdMutex::new(input_queue_projection_index), } } + pub fn recovery_checkpoint( + &self, + checkpoint_storage_seq: u64, + ) -> Result<SessionRecoveryCheckpoint> { + Ok(SessionRecoveryCheckpoint { + agent_state: self.snapshot_projected_state()?, + phase: self.current_phase()?, + last_mode_changed_at: self.last_mode_changed_at()?, + child_nodes: support::lock_anyhow(&self.child_nodes, "session child nodes")?.clone(), + active_tasks: support::lock_anyhow(&self.active_tasks, "session active tasks")?.clone(), + input_queue_projection_index: support::lock_anyhow( + &self.input_queue_projection_index, + "input queue projection index", + )? + .clone(), + checkpoint_storage_seq, + }) + } + pub fn snapshot_projected_state(&self) -> Result<AgentState> { Ok(support::lock_anyhow(&self.projector, "session projector")?.snapshot()) } @@ -156,6 +276,17 @@ impl SessionState { )?) } + pub fn current_mode_id(&self) -> Result<ModeId> { + Ok(support::lock_anyhow(&self.current_mode, "session current mode")?.clone()) + } + + pub fn last_mode_changed_at(&self) -> Result<Option<DateTime<Utc>>> { + Ok(*support::lock_anyhow( + &self.last_mode_changed_at, + "session last mode changed at", + )?) + } + pub fn complete_execution_state(&self, phase: Phase) { // Why: 先清除 running 标志再设置 phase,避免外部观察者看到 phase=Idle // 但 running 仍为 true 的竞态窗口(如 compact 在 turn 完成后立即被调用)。 @@ -179,51 +310,44 @@ impl SessionState { }); } - pub fn request_manual_compact(&self, runtime: ResolvedRuntimeConfig) -> Result<bool> { + pub fn compacting(&self) -> bool { + self.compacting.load(std::sync::atomic::Ordering::SeqCst) + } + + pub fn set_compacting(&self, compacting: bool) { + self.compacting + .store(compacting, std::sync::atomic::Ordering::SeqCst); + } + + pub fn request_manual_compact(&self, request: PendingManualCompactRequest) -> Result<bool> { let mut guard = support::lock_anyhow( &self.pending_manual_compact, "session pending manual compact", )?; - let mut runtime_guard = support::lock_anyhow( - &self.pending_manual_compact_runtime, - "session pending manual compact runtime", + let mut request_guard = support::lock_anyhow( + &self.pending_manual_compact_request, + "session pending manual compact request", )?; let already_pending = *guard; *guard = true; - *runtime_guard = Some(runtime); + *request_guard = Some(request); Ok(!already_pending) } - pub fn take_pending_manual_compact(&self) -> Result<Option<ResolvedRuntimeConfig>> { + pub fn take_pending_manual_compact(&self) -> Result<Option<PendingManualCompactRequest>> { let mut guard = support::lock_anyhow( &self.pending_manual_compact, "session pending manual compact", )?; - let mut runtime_guard = support::lock_anyhow( - &self.pending_manual_compact_runtime, - "session pending manual compact runtime", + let mut request_guard = support::lock_anyhow( + &self.pending_manual_compact_request, + "session pending manual compact request", )?; - let pending = if *guard { runtime_guard.take() } else { None }; + let pending = if *guard { request_guard.take() } else { None }; *guard = false; Ok(pending) } - pub fn take_pending_reactivation_messages(&self) -> Result<Vec<LlmMessage>> { - let mut guard = support::lock_anyhow( - &self.pending_reactivation_messages, - "session pending reactivation messages", - )?; - Ok(std::mem::take(&mut *guard)) - } - - pub fn pending_reactivation_messages(&self) -> Result<Vec<LlmMessage>> { - Ok(support::lock_anyhow( - &self.pending_reactivation_messages, - "session pending reactivation messages", - )? - .clone()) - } - pub fn translate_store_and_cache( &self, stored: &StoredEvent, @@ -233,6 +357,12 @@ impl SessionState { { let mut projector = support::lock_anyhow(&self.projector, "session projector")?; projector.apply(&stored.event); + *support::lock_anyhow(&self.current_mode, "session current mode")? = + projector.snapshot().mode_id.clone(); + } + if let StorageEventPayload::ModeChanged { timestamp, .. } = &stored.event.payload { + *support::lock_anyhow(&self.last_mode_changed_at, "session last mode changed at")? = + Some(*timestamp); } let records = translator.translate(stored); support::lock_anyhow(&self.recent_records, "session recent records")?.push_batch(&records); @@ -241,35 +371,11 @@ impl SessionState { if let Some(node) = child_node_from_stored_event(stored) { self.upsert_child_session_node(node)?; } - self.apply_mailbox_event(stored); - self.apply_reactivation_event(stored)?; + self.apply_task_snapshot_event(stored)?; + self.apply_input_queue_event(stored); Ok(records) } - fn apply_reactivation_event(&self, stored: &StoredEvent) -> Result<()> { - let mut guard = support::lock_anyhow( - &self.pending_reactivation_messages, - "session pending reactivation messages", - )?; - match &stored.event.payload { - StorageEventPayload::CompactApplied { .. } => guard.clear(), - StorageEventPayload::UserMessage { - content, - origin: UserMessageOrigin::ReactivationPrompt, - .. - } => guard.push(LlmMessage::User { - content: content.clone(), - origin: UserMessageOrigin::ReactivationPrompt, - }), - StorageEventPayload::UserMessage { - origin: UserMessageOrigin::User, - .. - } => guard.clear(), - _ => {}, - } - Ok(()) - } - pub fn recent_records_after( &self, last_event_id: Option<&str>, @@ -285,44 +391,17 @@ impl SessionState { } } -fn pending_reactivation_messages_from_stored(stored_events: &[StoredEvent]) -> Vec<LlmMessage> { - let mut pending = Vec::new(); - for stored in stored_events { - match &stored.event.payload { - StorageEventPayload::CompactApplied { .. } => pending.clear(), - StorageEventPayload::UserMessage { - content, - origin: UserMessageOrigin::ReactivationPrompt, - .. - } => pending.push(LlmMessage::User { - content: content.clone(), - origin: UserMessageOrigin::ReactivationPrompt, - }), - StorageEventPayload::UserMessage { - origin: UserMessageOrigin::User, - .. - } => pending.clear(), - _ => {}, - } - } - pending -} - // ── 辅助函数 ────────────────────────────────────────────── #[cfg(test)] mod tests { use astrcode_core::{ - AgentEventContext, CompactTrigger, InvocationKind, LlmMessage, Phase, StorageEventPayload, - SubRunStorageMode, UserMessageOrigin, + AgentEventContext, InvocationKind, Phase, StorageEventPayload, SubRunStorageMode, + UserMessageOrigin, }; - use chrono::Utc; - use super::{ - pending_reactivation_messages_from_stored, - test_support::{ - event, independent_session_sub_run_agent, root_agent, stored, test_session_state, - }, + use super::test_support::{ + event, independent_session_sub_run_agent, root_agent, stored, test_session_state, }; #[test] @@ -450,10 +529,10 @@ mod tests { event( Some("turn-child"), AgentEventContext { - agent_id: Some("agent-child".to_string()), - parent_turn_id: Some("turn-root".to_string()), + agent_id: Some("agent-child".to_string().into()), + parent_turn_id: Some("turn-root".to_string().into()), agent_profile: Some("explore".to_string()), - sub_run_id: Some("subrun-1".to_string()), + sub_run_id: Some("subrun-1".to_string().into()), parent_sub_run_id: None, invocation_kind: Some(InvocationKind::SubRun), storage_mode: Some(SubRunStorageMode::IndependentSession), @@ -473,124 +552,4 @@ mod tests { assert!(error.to_string().contains("child_session_id")); } - - #[test] - fn pending_reactivation_messages_restore_until_next_real_user_turn() { - let stored_events = vec![ - stored( - 1, - event( - None, - root_agent(), - StorageEventPayload::CompactApplied { - trigger: CompactTrigger::Manual, - summary: "summary".into(), - preserved_recent_turns: 1, - pre_tokens: 100, - post_tokens_estimate: 40, - messages_removed: 2, - tokens_freed: 60, - timestamp: Utc::now(), - }, - ), - ), - stored( - 2, - event( - None, - root_agent(), - StorageEventPayload::UserMessage { - content: "Recovered file context".into(), - origin: UserMessageOrigin::ReactivationPrompt, - timestamp: Utc::now(), - }, - ), - ), - ]; - - assert_eq!( - pending_reactivation_messages_from_stored(&stored_events), - vec![LlmMessage::User { - content: "Recovered file context".into(), - origin: UserMessageOrigin::ReactivationPrompt, - }] - ); - - let mut consumed_events = stored_events.clone(); - consumed_events.push(stored( - 3, - event( - Some("turn-2"), - root_agent(), - StorageEventPayload::UserMessage { - content: "next prompt".into(), - origin: UserMessageOrigin::User, - timestamp: Utc::now(), - }, - ), - )); - assert!( - pending_reactivation_messages_from_stored(&consumed_events).is_empty(), - "once a real user turn starts, prior post-compact recovery prompts should be marked \ - consumed" - ); - } - - #[test] - fn translate_store_and_cache_tracks_pending_reactivation_messages() { - let session = test_session_state(); - let mut translator = astrcode_core::EventTranslator::new(Phase::Idle); - let compact = stored( - 1, - event( - None, - root_agent(), - StorageEventPayload::CompactApplied { - trigger: CompactTrigger::Manual, - summary: "summary".into(), - preserved_recent_turns: 1, - pre_tokens: 100, - post_tokens_estimate: 40, - messages_removed: 2, - tokens_freed: 60, - timestamp: Utc::now(), - }, - ), - ); - let recovery = stored( - 2, - event( - None, - root_agent(), - StorageEventPayload::UserMessage { - content: "Recovered file context".into(), - origin: UserMessageOrigin::ReactivationPrompt, - timestamp: Utc::now(), - }, - ), - ); - - session - .translate_store_and_cache(&compact, &mut translator) - .expect("compact event should apply"); - session - .translate_store_and_cache(&recovery, &mut translator) - .expect("reactivation event should apply"); - - assert_eq!( - session - .take_pending_reactivation_messages() - .expect("pending reactivation messages should be readable"), - vec![LlmMessage::User { - content: "Recovered file context".into(), - origin: UserMessageOrigin::ReactivationPrompt, - }] - ); - assert!( - session - .take_pending_reactivation_messages() - .expect("pending reactivation messages should be drained") - .is_empty() - ); - } } diff --git a/crates/session-runtime/src/state/paths.rs b/crates/session-runtime/src/state/paths.rs index d5effd4f..747aafa0 100644 --- a/crates/session-runtime/src/state/paths.rs +++ b/crates/session-runtime/src/state/paths.rs @@ -2,7 +2,13 @@ use std::path::{Path, PathBuf}; -use astrcode_core::AstrError; +use astrcode_core::{ + AstrError, + home::resolve_home_dir, + project::{project_dir_name, projects_dir}, +}; + +const SESSIONS_DIR_NAME: &str = "sessions"; /// 规范化会话 ID,去除首尾空白并剥离最外层 `session-` 前缀。 pub fn normalize_session_id(session_id: &str) -> String { @@ -13,6 +19,24 @@ pub fn normalize_session_id(session_id: &str) -> String { .to_string() } +/// 生成供 compact 摘要引用的 session event log 路径提示。 +/// +/// Why: 路径协议应该收口在单一 helper 中,而不是散落在 compact prompt 拼接逻辑里。 +pub(crate) fn compact_history_event_log_path( + session_id: &str, + working_dir: &Path, +) -> Result<String, AstrError> { + let session_id = normalize_session_id(session_id); + let path = projects_dir() + .map_err(|error| AstrError::Internal(format!("failed to resolve projects dir: {error}")))? + .join(project_dir_name(working_dir)) + .join(SESSIONS_DIR_NAME) + .join(&session_id) + .join(format!("session-{session_id}.jsonl")); + + Ok(render_home_relative_path(&path)) +} + /// 规范化工作目录路径,要求路径存在且必须是目录。 pub fn normalize_working_dir(working_dir: PathBuf) -> Result<PathBuf, AstrError> { let path = if working_dir.is_absolute() { @@ -56,13 +80,28 @@ pub fn display_name_from_working_dir(path: &Path) -> String { .to_string() } +fn render_home_relative_path(path: &Path) -> String { + let display = resolve_home_dir() + .ok() + .and_then(|home| { + path.strip_prefix(home) + .ok() + .map(|relative| PathBuf::from("~").join(relative)) + }) + .unwrap_or_else(|| path.to_path_buf()); + display.to_string_lossy().replace('\\', "/") +} + #[cfg(test)] mod tests { use std::path::Path; use astrcode_core::AstrError; - use super::{display_name_from_working_dir, normalize_session_id, normalize_working_dir}; + use super::{ + compact_history_event_log_path, display_name_from_working_dir, normalize_session_id, + normalize_working_dir, + }; #[test] fn normalize_session_id_only_removes_outer_prefix() { @@ -79,6 +118,16 @@ mod tests { assert_eq!(normalize_session_id(" abc "), "abc"); } + #[test] + fn compact_history_event_log_path_uses_tilde_and_canonical_session_file_name() { + let temp_dir = tempfile::tempdir().expect("tempdir should be created"); + let path = compact_history_event_log_path("session-abc", temp_dir.path()) + .expect("compact history path should build"); + + assert!(path.starts_with("~/.astrcode/projects/")); + assert!(path.ends_with("/sessions/abc/session-abc.jsonl")); + } + #[test] fn normalize_working_dir_rejects_file_paths() { let temp_dir = tempfile::tempdir().expect("tempdir should be created"); diff --git a/crates/session-runtime/src/state/tasks.rs b/crates/session-runtime/src/state/tasks.rs new file mode 100644 index 00000000..5fbf329b --- /dev/null +++ b/crates/session-runtime/src/state/tasks.rs @@ -0,0 +1,192 @@ +use std::collections::HashMap; + +use astrcode_core::{ + EXECUTION_TASK_SNAPSHOT_SCHEMA, ExecutionTaskSnapshotMetadata, Result, StorageEventPayload, + StoredEvent, TaskSnapshot, support, +}; + +use super::SessionState; + +pub(crate) fn rebuild_active_tasks(events: &[StoredEvent]) -> HashMap<String, TaskSnapshot> { + let mut tasks = HashMap::new(); + for stored in events { + if let Some(snapshot) = task_snapshot_from_stored_event(stored) { + apply_snapshot_to_map(&mut tasks, snapshot); + } + } + tasks +} + +pub(crate) fn task_snapshot_from_stored_event(stored: &StoredEvent) -> Option<TaskSnapshot> { + let StorageEventPayload::ToolResult { + tool_name, + metadata: Some(metadata), + .. + } = &stored.event.payload + else { + return None; + }; + + if tool_name != "taskWrite" { + return None; + } + + let parsed = serde_json::from_value::<ExecutionTaskSnapshotMetadata>(metadata.clone()).ok()?; + if parsed.schema != EXECUTION_TASK_SNAPSHOT_SCHEMA { + return None; + } + + Some(parsed.into_snapshot()) +} + +pub(crate) fn apply_snapshot_to_map( + tasks: &mut HashMap<String, TaskSnapshot>, + snapshot: TaskSnapshot, +) { + if snapshot.should_clear() { + tasks.remove(snapshot.owner.as_str()); + } else { + tasks.insert(snapshot.owner.clone(), snapshot); + } +} + +impl SessionState { + pub(crate) fn apply_task_snapshot_event(&self, stored: &StoredEvent) -> Result<()> { + let Some(snapshot) = task_snapshot_from_stored_event(stored) else { + return Ok(()); + }; + self.replace_active_task_snapshot(snapshot) + } + + pub(crate) fn replace_active_task_snapshot(&self, snapshot: TaskSnapshot) -> Result<()> { + let mut tasks = support::lock_anyhow(&self.active_tasks, "session active tasks")?; + apply_snapshot_to_map(&mut tasks, snapshot); + Ok(()) + } + + pub fn active_tasks_for(&self, owner: &str) -> Result<Option<TaskSnapshot>> { + Ok( + support::lock_anyhow(&self.active_tasks, "session active tasks")? + .get(owner) + .cloned(), + ) + } +} + +#[cfg(test)] +mod tests { + use astrcode_core::{EventTranslator, ExecutionTaskItem, ExecutionTaskStatus, Phase}; + + use super::*; + use crate::state::test_support::{root_task_write_stored, test_session_state}; + + #[test] + fn session_state_rehydrates_active_tasks_from_replay() { + let session = SessionState::new( + Phase::Idle, + test_session_state().writer.clone(), + astrcode_core::AgentStateProjector::default(), + Vec::new(), + vec![root_task_write_stored( + 1, + "owner-a", + vec![ExecutionTaskItem { + content: "补充 task 投影".to_string(), + status: ExecutionTaskStatus::InProgress, + active_form: Some("正在补充 task 投影".to_string()), + }], + )], + ); + + let snapshot = session + .active_tasks_for("owner-a") + .expect("task lookup should succeed") + .expect("task snapshot should exist"); + assert_eq!(snapshot.items.len(), 1); + assert_eq!(snapshot.items[0].content, "补充 task 投影"); + } + + #[test] + fn translate_store_and_cache_clears_task_snapshot_when_latest_snapshot_is_completed_only() { + let session = test_session_state(); + let mut translator = EventTranslator::new(Phase::Idle); + + for stored in [ + root_task_write_stored( + 1, + "owner-a", + vec![ExecutionTaskItem { + content: "实现 runtime 投影".to_string(), + status: ExecutionTaskStatus::InProgress, + active_form: Some("正在实现 runtime 投影".to_string()), + }], + ), + root_task_write_stored( + 2, + "owner-a", + vec![ExecutionTaskItem { + content: "实现 runtime 投影".to_string(), + status: ExecutionTaskStatus::Completed, + active_form: Some("已完成 runtime 投影".to_string()), + }], + ), + ] { + session + .translate_store_and_cache(&stored, &mut translator) + .expect("task event should translate"); + } + + assert!( + session + .active_tasks_for("owner-a") + .expect("task lookup should succeed") + .is_none() + ); + } + + #[test] + fn translate_store_and_cache_isolates_task_snapshots_by_owner() { + let session = test_session_state(); + let mut translator = EventTranslator::new(Phase::Idle); + + let stored_events = [ + root_task_write_stored( + 1, + "owner-a", + vec![ExecutionTaskItem { + content: "任务 A".to_string(), + status: ExecutionTaskStatus::Pending, + active_form: None, + }], + ), + root_task_write_stored( + 2, + "owner-b", + vec![ExecutionTaskItem { + content: "任务 B".to_string(), + status: ExecutionTaskStatus::InProgress, + active_form: Some("正在处理任务 B".to_string()), + }], + ), + root_task_write_stored(3, "owner-a", Vec::new()), + ]; + + for event in stored_events { + session + .translate_store_and_cache(&event, &mut translator) + .expect("task event should translate"); + } + + assert!( + session + .active_tasks_for("owner-a") + .expect("task lookup should succeed") + .is_none() + ); + let owner_b = session + .active_tasks_for("owner-b") + .expect("task lookup should succeed") + .expect("owner-b snapshot should exist"); + assert_eq!(owner_b.items[0].content, "任务 B"); + } +} diff --git a/crates/session-runtime/src/state/test_support.rs b/crates/session-runtime/src/state/test_support.rs index bb6a998d..1da024e9 100644 --- a/crates/session-runtime/src/state/test_support.rs +++ b/crates/session-runtime/src/state/test_support.rs @@ -4,9 +4,10 @@ use std::sync::Arc; use astrcode_core::{ AgentEventContext, AgentLifecycleStatus, AgentStateProjector, ChildAgentRef, - ChildSessionLineageKind, ChildSessionNotification, ChildSessionNotificationKind, - EventLogWriter, InvocationKind, Phase, StorageEvent, StorageEventPayload, StoreResult, - StoredEvent, SubRunStorageMode, + ChildExecutionIdentity, ChildSessionLineageKind, ChildSessionNotification, + ChildSessionNotificationKind, EventLogWriter, ExecutionTaskItem, ExecutionTaskSnapshotMetadata, + InvocationKind, ParentExecutionRef, Phase, StorageEvent, StorageEventPayload, StoreResult, + StoredEvent, SubRunStorageMode, TaskSnapshot, }; use super::{SessionState, SessionWriter}; @@ -35,14 +36,14 @@ pub(crate) fn root_agent() -> AgentEventContext { pub(crate) fn independent_session_sub_run_agent() -> AgentEventContext { AgentEventContext { - agent_id: Some("agent-child".to_string()), - parent_turn_id: Some("turn-root".to_string()), + agent_id: Some("agent-child".to_string().into()), + parent_turn_id: Some("turn-root".to_string().into()), parent_sub_run_id: None, agent_profile: Some("explore".to_string()), - sub_run_id: Some("subrun-1".to_string()), + sub_run_id: Some("subrun-1".to_string().into()), invocation_kind: Some(InvocationKind::SubRun), storage_mode: Some(SubRunStorageMode::IndependentSession), - child_session_id: Some("session-child".to_string()), + child_session_id: Some("session-child".to_string().into()), } } @@ -71,19 +72,22 @@ pub(crate) fn child_notification_event( independent_session_sub_run_agent(), StorageEventPayload::ChildSessionNotification { notification: ChildSessionNotification { - notification_id: format!("child:{kind:?}"), + notification_id: format!("child:{kind:?}").into(), child_ref: ChildAgentRef { - agent_id: "agent-child".into(), - session_id: "session-parent".into(), - sub_run_id: "subrun-1".into(), - parent_agent_id: Some("agent-parent".into()), - parent_sub_run_id: Some("subrun-parent".into()), + identity: ChildExecutionIdentity { + agent_id: "agent-child".into(), + session_id: "session-parent".into(), + sub_run_id: "subrun-1".into(), + }, + parent: ParentExecutionRef { + parent_agent_id: Some("agent-parent".into()), + parent_sub_run_id: Some("subrun-parent".into()), + }, lineage_kind: ChildSessionLineageKind::Spawn, status, open_session_id: "session-child".into(), }, kind, - status, source_tool_call_id: Some("call-1".into()), delivery: Some(astrcode_core::ParentDelivery { idempotency_key: format!("child:{kind:?}"), @@ -113,3 +117,42 @@ pub(crate) fn child_notification_event( }, ) } + +pub(crate) fn root_task_tool_result_event( + turn_id: &str, + owner: &str, + items: Vec<ExecutionTaskItem>, +) -> StorageEvent { + let snapshot = TaskSnapshot { + owner: owner.to_string(), + items, + }; + StorageEvent { + turn_id: Some(turn_id.to_string()), + agent: AgentEventContext::default(), + payload: StorageEventPayload::ToolResult { + tool_call_id: format!("call-{turn_id}"), + tool_name: "taskWrite".to_string(), + output: "updated execution tasks".to_string(), + success: true, + error: None, + metadata: Some( + serde_json::to_value(ExecutionTaskSnapshotMetadata::from_snapshot(&snapshot)) + .expect("task metadata should serialize"), + ), + child_ref: None, + duration_ms: 1, + }, + } +} + +pub(crate) fn root_task_write_stored( + storage_seq: u64, + owner: &str, + items: Vec<ExecutionTaskItem>, +) -> StoredEvent { + stored( + storage_seq, + root_task_tool_result_event(&format!("turn-{storage_seq}"), owner, items), + ) +} diff --git a/crates/session-runtime/src/turn/branch.rs b/crates/session-runtime/src/turn/branch.rs index e3433efa..ae1a1b73 100644 --- a/crates/session-runtime/src/turn/branch.rs +++ b/crates/session-runtime/src/turn/branch.rs @@ -90,15 +90,31 @@ impl SessionRuntime { source_session_id: &SessionId, active_turn_id: &str, ) -> astrcode_core::Result<SessionId> { - let source_actor = self.ensure_loaded_session(source_session_id).await?; - let working_dir = normalize_working_dir(PathBuf::from(source_actor.working_dir()))?; let source_events = self.event_store.replay(source_session_id).await?; let stable_events = stable_events_before_active_turn(&source_events, active_turn_id); + let source_actor = self.ensure_loaded_session(source_session_id).await?; + let working_dir = normalize_working_dir(PathBuf::from(source_actor.working_dir()))?; let parent_storage_seq = stable_events.last().map(|event| event.storage_seq); + self.fork_events_up_to( + source_session_id, + &working_dir, + &stable_events, + parent_storage_seq, + ) + .await + } + + pub(super) async fn fork_events_up_to( + &self, + source_session_id: &SessionId, + working_dir: &std::path::Path, + source_events: &[StoredEvent], + parent_storage_seq: Option<u64>, + ) -> astrcode_core::Result<SessionId> { let branched_session_id: SessionId = generate_session_id().into(); self.event_store - .ensure_session(&branched_session_id, &working_dir) + .ensure_session(&branched_session_id, working_dir) .await?; let session_start = session_start_event( @@ -114,7 +130,7 @@ impl SessionRuntime { // 为什么只复制稳定历史:活跃 turn 的半截输出不应污染新分支, // 否则 replay/context window 会同时看到未完成与新分支的事件。 - for stored in stable_events { + for stored in source_events { if matches!( stored.event.payload, StorageEventPayload::SessionStart { .. } diff --git a/crates/session-runtime/src/turn/compaction_cycle.rs b/crates/session-runtime/src/turn/compaction_cycle.rs index bcfab8dc..5a0d8234 100644 --- a/crates/session-runtime/src/turn/compaction_cycle.rs +++ b/crates/session-runtime/src/turn/compaction_cycle.rs @@ -13,7 +13,7 @@ use astrcode_core::{ AgentEventContext, CancelToken, CompactTrigger, LlmMessage, PromptFactsProvider, Result, - StorageEvent, + StorageEvent, UserMessageOrigin, }; use astrcode_kernel::KernelGateway; @@ -23,17 +23,15 @@ use crate::{ compaction::{CompactConfig, CompactResult, auto_compact}, file_access::FileAccessTracker, }, + state::compact_history_event_log_path, turn::{ - events::{CompactAppliedStats, compact_applied_event}, + events::{CompactAppliedStats, compact_applied_event, user_message_event}, request::{PromptOutputRequest, build_prompt_output}, }, }; -/// reactive compact 最大重试次数。 -pub const MAX_REACTIVE_COMPACT_ATTEMPTS: usize = 3; - /// reactive compact 恢复成功后的结果。 -pub struct RecoveryResult { +pub(crate) struct RecoveryResult { /// 压缩后的消息历史(含文件恢复消息)。 pub messages: Vec<LlmMessage>, /// 压缩期间产生的事件。 @@ -43,7 +41,7 @@ pub struct RecoveryResult { /// reactive compact 调用上下文。 /// /// 将分散的参数聚合为结构体,避免函数签名过长。 -pub struct ReactiveCompactContext<'a> { +pub(crate) struct ReactiveCompactContext<'a> { pub gateway: &'a KernelGateway, pub prompt_facts_provider: &'a dyn PromptFactsProvider, pub messages: &'a [LlmMessage], @@ -68,8 +66,9 @@ fn recovery_result_from_compaction( Some(turn_id), agent, CompactTrigger::Auto, - compaction.summary, + compaction.summary.clone(), CompactAppliedStats { + meta: compaction.meta, preserved_recent_turns: compaction.preserved_recent_turns, pre_tokens: compaction.pre_tokens, post_tokens_estimate: compaction.post_tokens_estimate, @@ -78,6 +77,25 @@ fn recovery_result_from_compaction( }, compaction.timestamp, )]; + let mut events = events; + if let Some(digest) = compaction.recent_user_context_digest.clone() { + events.push(user_message_event( + turn_id, + agent, + digest, + UserMessageOrigin::RecentUserContextDigest, + compaction.timestamp, + )); + } + for content in &compaction.recent_user_context_messages { + events.push(user_message_event( + turn_id, + agent, + content.clone(), + UserMessageOrigin::RecentUserContext, + compaction.timestamp, + )); + } let mut messages = compaction.messages; messages.extend(file_access_tracker.build_recovery_messages(settings.file_recovery_config())); @@ -100,7 +118,10 @@ pub async fn try_reactive_compact( working_dir: ctx.working_dir.as_ref(), step_index: ctx.step_index, messages: ctx.messages, + session_state: None, + current_agent_id: ctx.agent.agent_id.as_ref().map(|id| id.as_str()), submission_prompt_declarations: &[], + prompt_governance: None, }) .await?; @@ -110,7 +131,16 @@ pub async fn try_reactive_compact( Some(&prompt_output.system_prompt), CompactConfig { keep_recent_turns: ctx.settings.compact_keep_recent_turns, + keep_recent_user_messages: ctx.settings.compact_keep_recent_user_messages, trigger: CompactTrigger::Auto, + summary_reserve_tokens: ctx.settings.summary_reserve_tokens, + max_output_tokens: ctx.settings.compact_max_output_tokens, + max_retry_attempts: ctx.settings.compact_max_retry_attempts, + history_path: Some(compact_history_event_log_path( + ctx.session_id, + std::path::Path::new(ctx.working_dir), + )?), + custom_instructions: None, }, ctx.cancel.clone(), ) @@ -132,8 +162,8 @@ mod tests { use std::{fs, time::Duration}; use astrcode_core::{ - AgentEventContext, CompactTrigger, LlmMessage, StorageEventPayload, ToolCallRequest, - UserMessageOrigin, + AgentEventContext, CompactAppliedMeta, CompactMode, CompactTrigger, LlmMessage, + StorageEventPayload, ToolCallRequest, UserMessageOrigin, }; use chrono::{TimeZone, Utc}; use serde_json::json; @@ -146,8 +176,13 @@ mod tests { ContextWindowSettings { auto_compact_enabled: true, compact_threshold_percent: 80, + reserved_context_size: 20_000, + summary_reserve_tokens: 20_000, + compact_max_output_tokens: 20_000, + compact_max_retry_attempts: 3, tool_result_max_bytes: 16_384, compact_keep_recent_turns: 1, + compact_keep_recent_user_messages: 8, max_tracked_files: 8, max_recovered_files: 2, recovery_token_budget: 512, @@ -198,6 +233,16 @@ mod tests { CompactResult { messages: vec![compacted_message.clone()], summary: "older context summary".to_string(), + recent_user_context_digest: None, + recent_user_context_messages: Vec::new(), + meta: CompactAppliedMeta { + mode: CompactMode::RetrySalvage, + instructions_present: false, + fallback_used: true, + retry_count: 2, + input_units: 5, + output_summary_chars: 21, + }, preserved_recent_turns: 2, pre_tokens: 1_500, post_tokens_estimate: 400, @@ -214,6 +259,7 @@ mod tests { StorageEventPayload::CompactApplied { trigger, summary, + meta, preserved_recent_turns, pre_tokens, post_tokens_estimate, @@ -222,6 +268,12 @@ mod tests { timestamp: event_timestamp, } if *trigger == CompactTrigger::Auto && summary == "older context summary" + && meta.mode == CompactMode::RetrySalvage + && !meta.instructions_present + && meta.fallback_used + && meta.retry_count == 2 + && meta.input_units == 5 + && meta.output_summary_chars == 21 && *preserved_recent_turns == 2 && *pre_tokens == 1_500 && *post_tokens_estimate == 400 diff --git a/crates/session-runtime/src/turn/continuation_cycle.rs b/crates/session-runtime/src/turn/continuation_cycle.rs index 676ee824..de81b0c6 100644 --- a/crates/session-runtime/src/turn/continuation_cycle.rs +++ b/crates/session-runtime/src/turn/continuation_cycle.rs @@ -13,7 +13,7 @@ pub const OUTPUT_CONTINUATION_PROMPT: &str = "Continue from the exact point wher apologize."; #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum OutputContinuationDecision { +pub(crate) enum OutputContinuationDecision { Continue, Stop(TurnStopCause), NotNeeded, diff --git a/crates/session-runtime/src/turn/events.rs b/crates/session-runtime/src/turn/events.rs index 5d5ac46c..40890364 100644 --- a/crates/session-runtime/src/turn/events.rs +++ b/crates/session-runtime/src/turn/events.rs @@ -1,8 +1,9 @@ #[cfg(test)] use astrcode_core::ToolOutputStream; use astrcode_core::{ - AgentEventContext, CompactTrigger, PromptMetricsPayload, StorageEvent, StorageEventPayload, - ToolCallRequest, ToolExecutionResult, UserMessageOrigin, + AgentEventContext, CompactAppliedMeta, CompactTrigger, LlmUsage, PromptMetricsPayload, + StorageEvent, StorageEventPayload, ToolCallRequest, ToolExecutionResult, UserMessageOrigin, + ports::PromptBuildCacheMetrics, }; use chrono::{DateTime, Utc}; @@ -13,6 +14,7 @@ fn saturating_u32(value: usize) -> u32 { } pub(crate) struct CompactAppliedStats { + pub meta: CompactAppliedMeta, pub preserved_recent_turns: usize, pub pre_tokens: usize, pub post_tokens_estimate: usize, @@ -118,6 +120,7 @@ pub(crate) fn compact_applied_event( payload: StorageEventPayload::CompactApplied { trigger, summary, + meta: stats.meta, preserved_recent_turns: saturating_u32(stats.preserved_recent_turns), pre_tokens: saturating_u32(stats.pre_tokens), post_tokens_estimate: saturating_u32(stats.post_tokens_estimate), @@ -134,6 +137,8 @@ pub(crate) fn prompt_metrics_event( step_index: usize, snapshot: PromptTokenSnapshot, truncated_tool_results: usize, + cache_metrics: PromptBuildCacheMetrics, + provider_cache_metrics_supported: bool, ) -> StorageEvent { StorageEvent { turn_id: Some(turn_id.to_string()), @@ -150,14 +155,45 @@ pub(crate) fn prompt_metrics_event( provider_output_tokens: None, cache_creation_input_tokens: None, cache_read_input_tokens: None, - provider_cache_metrics_supported: false, - prompt_cache_reuse_hits: 0, - prompt_cache_reuse_misses: 0, + provider_cache_metrics_supported, + prompt_cache_reuse_hits: cache_metrics.reuse_hits, + prompt_cache_reuse_misses: cache_metrics.reuse_misses, + prompt_cache_unchanged_layers: cache_metrics.unchanged_layers, }, }, } } +pub(crate) fn apply_prompt_metrics_usage( + events: &mut [StorageEvent], + step_index: usize, + usage: Option<LlmUsage>, +) { + let Some(usage) = usage else { + return; + }; + + let step_index = saturating_u32(step_index); + let Some(StorageEvent { + payload: StorageEventPayload::PromptMetrics { metrics }, + .. + }) = events.iter_mut().rev().find(|event| { + matches!( + &event.payload, + StorageEventPayload::PromptMetrics { metrics } + if metrics.step_index == step_index + ) + }) + else { + return; + }; + + metrics.provider_input_tokens = Some(saturating_u32(usage.input_tokens)); + metrics.provider_output_tokens = Some(saturating_u32(usage.output_tokens)); + metrics.cache_creation_input_tokens = Some(saturating_u32(usage.cache_creation_input_tokens)); + metrics.cache_read_input_tokens = Some(saturating_u32(usage.cache_read_input_tokens)); +} + pub(crate) fn tool_call_event( turn_id: &str, agent: &AgentEventContext, @@ -210,6 +246,7 @@ pub(crate) fn tool_result_event( success: result.ok, error: result.error.clone(), metadata: result.metadata.clone(), + child_ref: result.child_ref.clone(), duration_ms: result.duration_ms, }, } @@ -219,7 +256,7 @@ pub(crate) fn tool_result_reference_applied_event( turn_id: &str, agent: &AgentEventContext, tool_call_id: &str, - persisted_relative_path: &str, + persisted_output: &astrcode_core::PersistedToolOutput, replacement: &str, original_bytes: u64, ) -> StorageEvent { @@ -228,7 +265,7 @@ pub(crate) fn tool_result_reference_applied_event( agent: agent.clone(), payload: StorageEventPayload::ToolResultReferenceApplied { tool_call_id: tool_call_id.to_string(), - persisted_relative_path: persisted_relative_path.to_string(), + persisted_output: persisted_output.clone(), replacement: replacement.to_string(), original_bytes, }, @@ -238,16 +275,18 @@ pub(crate) fn tool_result_reference_applied_event( #[cfg(test)] mod tests { use astrcode_core::{ - AgentEventContext, CompactTrigger, StorageEventPayload, ToolCallRequest, - ToolExecutionResult, ToolOutputStream, UserMessageOrigin, + AgentEventContext, CompactAppliedMeta, CompactMode, CompactTrigger, LlmUsage, + StorageEventPayload, ToolCallRequest, ToolExecutionResult, ToolOutputStream, + UserMessageOrigin, ports::PromptBuildCacheMetrics, }; use chrono::{TimeZone, Utc}; use serde_json::json; use super::{ - CompactAppliedStats, assistant_final_event, compact_applied_event, error_event, - prompt_metrics_event, session_start_event, tool_call_delta_event, tool_call_event, - tool_result_event, turn_done_event, user_message_event, + CompactAppliedStats, apply_prompt_metrics_usage, assistant_final_event, + compact_applied_event, error_event, prompt_metrics_event, session_start_event, + tool_call_delta_event, tool_call_event, tool_result_event, turn_done_event, + user_message_event, }; use crate::context_window::token_usage::PromptTokenSnapshot; @@ -392,6 +431,14 @@ mod tests { CompactTrigger::Auto, "condensed older work".to_string(), CompactAppliedStats { + meta: CompactAppliedMeta { + mode: CompactMode::RetrySalvage, + instructions_present: true, + fallback_used: true, + retry_count: u32::MAX, + input_units: u32::MAX, + output_summary_chars: u32::MAX, + }, preserved_recent_turns: usize::MAX, pre_tokens: usize::MAX, post_tokens_estimate: 512, @@ -408,6 +455,7 @@ mod tests { StorageEventPayload::CompactApplied { trigger, summary, + meta, preserved_recent_turns, pre_tokens, post_tokens_estimate, @@ -416,6 +464,12 @@ mod tests { timestamp: event_timestamp, } if trigger == CompactTrigger::Auto && summary == "condensed older work" + && meta.mode == CompactMode::RetrySalvage + && meta.instructions_present + && meta.fallback_used + && meta.retry_count == u32::MAX + && meta.input_units == u32::MAX + && meta.output_summary_chars == u32::MAX && preserved_recent_turns == u32::MAX && pre_tokens == u32::MAX && post_tokens_estimate == 512 @@ -438,8 +492,16 @@ mod tests { context_window: 128_000, effective_window: 108_000, threshold_tokens: 97_200, + remaining_context_tokens: 95_655, + reserved_context_size: 20_000, }, 3, + PromptBuildCacheMetrics { + reuse_hits: 4, + reuse_misses: 1, + unchanged_layers: vec![astrcode_core::SystemPromptLayer::Stable], + }, + true, ); assert_eq!(event.turn_id.as_deref(), Some("turn-prompt-1")); @@ -453,13 +515,57 @@ mod tests { && metrics.effective_window == 108_000 && metrics.threshold_tokens == 97_200 && metrics.truncated_tool_results == 3 + && metrics.prompt_cache_unchanged_layers + == vec![astrcode_core::SystemPromptLayer::Stable] && metrics.provider_input_tokens.is_none() && metrics.provider_output_tokens.is_none() && metrics.cache_creation_input_tokens.is_none() && metrics.cache_read_input_tokens.is_none() - && !metrics.provider_cache_metrics_supported - && metrics.prompt_cache_reuse_hits == 0 - && metrics.prompt_cache_reuse_misses == 0 + && metrics.provider_cache_metrics_supported + && metrics.prompt_cache_reuse_hits == 4 + && metrics.prompt_cache_reuse_misses == 1 + )); + } + + #[test] + fn apply_prompt_metrics_usage_backfills_provider_cache_fields() { + let agent = AgentEventContext::root_execution("root-agent", "planner"); + let mut events = vec![prompt_metrics_event( + "turn-prompt-1", + &agent, + 2, + PromptTokenSnapshot { + context_tokens: 1_024, + budget_tokens: 900, + context_window: 128_000, + effective_window: 108_000, + threshold_tokens: 97_200, + remaining_context_tokens: 106_976, + reserved_context_size: 20_000, + }, + 0, + PromptBuildCacheMetrics::default(), + true, + )]; + + apply_prompt_metrics_usage( + &mut events, + 2, + Some(LlmUsage { + input_tokens: 900, + output_tokens: 120, + cache_creation_input_tokens: 700, + cache_read_input_tokens: 650, + }), + ); + + assert!(matches!( + &events[0].payload, + StorageEventPayload::PromptMetrics { metrics } + if metrics.provider_input_tokens == Some(900) + && metrics.provider_output_tokens == Some(120) + && metrics.cache_creation_input_tokens == Some(700) + && metrics.cache_read_input_tokens == Some(650) )); } @@ -534,6 +640,7 @@ mod tests { "path": "/workspace/src/lib.rs", "truncated": true })), + child_ref: None, duration_ms: 88, truncated: true, }; @@ -550,6 +657,7 @@ mod tests { success, error, metadata, + child_ref: _, duration_ms, } if tool_call_id == "call-7" && tool_name == "readFile" diff --git a/crates/session-runtime/src/turn/fork.rs b/crates/session-runtime/src/turn/fork.rs new file mode 100644 index 00000000..4dc93ec1 --- /dev/null +++ b/crates/session-runtime/src/turn/fork.rs @@ -0,0 +1,429 @@ +use std::path::PathBuf; + +use astrcode_core::{AstrError, SessionId, StorageEventPayload, StoredEvent}; + +use crate::{SessionRuntime, state::normalize_working_dir}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ForkPoint { + StorageSeq(u64), + TurnEnd(String), + Latest, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ForkResult { + pub new_session_id: SessionId, + pub fork_point_storage_seq: u64, + pub events_copied: usize, +} + +impl SessionRuntime { + pub async fn fork_session( + &self, + source_session_id: &SessionId, + fork_point: ForkPoint, + ) -> astrcode_core::Result<ForkResult> { + self.ensure_session_exists(source_session_id).await?; + + let source_events = self.event_store.replay(source_session_id).await?; + let fork_point_storage_seq = + resolve_fork_point_storage_seq(source_session_id, &source_events, &fork_point)?; + let events_to_copy = + stable_events_up_to_storage_seq(&source_events, fork_point_storage_seq)?; + + let source_actor = self.ensure_loaded_session(source_session_id).await?; + let working_dir = normalize_working_dir(PathBuf::from(source_actor.working_dir()))?; + let new_session_id = self + .fork_events_up_to( + source_session_id, + &working_dir, + &events_to_copy, + Some(fork_point_storage_seq), + ) + .await?; + + Ok(ForkResult { + new_session_id, + fork_point_storage_seq, + events_copied: events_to_copy + .iter() + .filter(|stored| { + !matches!( + stored.event.payload, + StorageEventPayload::SessionStart { .. } + ) + }) + .count(), + }) + } +} + +fn resolve_fork_point_storage_seq( + source_session_id: &SessionId, + events: &[StoredEvent], + fork_point: &ForkPoint, +) -> astrcode_core::Result<u64> { + match fork_point { + ForkPoint::Latest => latest_stable_storage_seq(events).ok_or_else(|| { + AstrError::Validation(format!( + "session '{}' has no stable fork point", + source_session_id + )) + }), + ForkPoint::StorageSeq(storage_seq) => { + if !events + .iter() + .any(|stored| stored.storage_seq == *storage_seq) + { + return Err(AstrError::Validation(format!( + "storage_seq {} is out of range for session '{}'", + storage_seq, source_session_id + ))); + } + let _ = stable_events_up_to_storage_seq(events, *storage_seq)?; + Ok(*storage_seq) + }, + ForkPoint::TurnEnd(turn_id) => { + resolve_turn_end_storage_seq(source_session_id, events, turn_id) + }, + } +} + +fn resolve_turn_end_storage_seq( + source_session_id: &SessionId, + events: &[StoredEvent], + turn_id: &str, +) -> astrcode_core::Result<u64> { + let turn_exists = events + .iter() + .any(|stored| stored.event.turn_id.as_deref() == Some(turn_id)); + if !turn_exists { + return Err(AstrError::SessionNotFound(format!( + "turn '{}' in session '{}'", + turn_id, source_session_id + ))); + } + + events + .iter() + .find_map(|stored| match &stored.event.payload { + StorageEventPayload::TurnDone { .. } + if stored.event.turn_id.as_deref() == Some(turn_id) => + { + Some(stored.storage_seq) + }, + _ => None, + }) + .ok_or_else(|| { + AstrError::Validation(format!( + "turn '{}' has not completed and cannot be used as a fork point", + turn_id + )) + }) +} + +fn latest_stable_storage_seq(events: &[StoredEvent]) -> Option<u64> { + let mut latest = None; + for stored in events { + if matches!( + stored.event.payload, + StorageEventPayload::SessionStart { .. } + ) { + latest = Some(stored.storage_seq); + } + if matches!( + stored.event.payload, + StorageEventPayload::TurnDone { .. } | StorageEventPayload::Error { .. } + ) { + latest = Some(stored.storage_seq); + } + } + latest +} + +fn stable_events_up_to_storage_seq( + events: &[StoredEvent], + storage_seq: u64, +) -> astrcode_core::Result<Vec<StoredEvent>> { + let cutoff = events + .iter() + .position(|stored| stored.storage_seq == storage_seq) + .ok_or_else(|| { + AstrError::Validation(format!("storage_seq {} is out of range", storage_seq)) + })?; + let candidate = events[..=cutoff].to_vec(); + + if is_stable_prefix(&candidate) { + Ok(candidate) + } else { + Err(AstrError::Validation(format!( + "storage_seq {} is inside an unfinished turn and cannot be used as a fork point", + storage_seq + ))) + } +} + +fn is_stable_prefix(events: &[StoredEvent]) -> bool { + let mut active_turn_id: Option<&str> = None; + for stored in events { + let Some(turn_id) = stored.event.turn_id.as_deref() else { + continue; + }; + match &stored.event.payload { + StorageEventPayload::TurnDone { .. } | StorageEventPayload::Error { .. } => { + if active_turn_id == Some(turn_id) { + active_turn_id = None; + } + }, + _ => { + if active_turn_id.is_none() { + active_turn_id = Some(turn_id); + } + }, + } + } + active_turn_id.is_none() +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use astrcode_core::{AstrError, SessionId, StoredEvent}; + use chrono::Utc; + + use super::{ForkPoint, latest_stable_storage_seq}; + use crate::{ + SessionRuntime, + turn::{ + events::session_start_event, + test_support::{ + BranchingTestEventStore, root_assistant_final_event, root_turn_done_event, + root_turn_event, root_user_message_event, test_runtime, + }, + }, + }; + + fn seed_runtime_with_events( + events: Vec<StoredEvent>, + ) -> (SessionRuntime, Arc<BranchingTestEventStore>) { + let event_store = Arc::new(BranchingTestEventStore::default()); + event_store.seed_session("source", ".", events); + (test_runtime(event_store.clone()), event_store) + } + + #[test] + fn latest_stable_storage_seq_stops_before_active_turn() { + let events = vec![ + StoredEvent { + storage_seq: 1, + event: session_start_event("source", ".", None, None, Utc::now()), + }, + StoredEvent { + storage_seq: 2, + event: root_user_message_event("turn-1", "hello"), + }, + StoredEvent { + storage_seq: 3, + event: root_turn_done_event("turn-1", Some("completed".to_string())), + }, + StoredEvent { + storage_seq: 4, + event: root_user_message_event("turn-2", "still running"), + }, + ]; + + assert_eq!(latest_stable_storage_seq(&events), Some(3)); + } + + #[tokio::test] + async fn fork_session_latest_on_idle_copies_all_stable_events() { + let events = vec![ + StoredEvent { + storage_seq: 1, + event: session_start_event("source", ".", None, None, Utc::now()), + }, + StoredEvent { + storage_seq: 2, + event: root_user_message_event("turn-1", "hello"), + }, + StoredEvent { + storage_seq: 3, + event: root_assistant_final_event("turn-1", "world"), + }, + StoredEvent { + storage_seq: 4, + event: root_turn_done_event("turn-1", Some("completed".to_string())), + }, + ]; + let (runtime, event_store) = seed_runtime_with_events(events); + + let result = runtime + .fork_session(&SessionId::from("source".to_string()), ForkPoint::Latest) + .await + .expect("fork latest should succeed"); + + assert_eq!(result.fork_point_storage_seq, 4); + assert_eq!(result.events_copied, 3); + + let new_events = event_store.stored_events_for(result.new_session_id.as_str()); + assert_eq!(new_events.len(), 4); + let metas = runtime + .list_session_metas() + .await + .expect("metas should be listable"); + let new_meta = metas + .into_iter() + .find(|meta| meta.session_id == result.new_session_id.as_str()) + .expect("new session meta should exist"); + assert_eq!(new_meta.parent_session_id.as_deref(), Some("source")); + assert_eq!(new_meta.parent_storage_seq, Some(4)); + assert_eq!(new_meta.phase, astrcode_core::Phase::Idle); + } + + #[tokio::test] + async fn fork_session_accepts_completed_turn_end_and_stable_storage_seq() { + let events = vec![ + StoredEvent { + storage_seq: 1, + event: session_start_event("source", ".", None, None, Utc::now()), + }, + StoredEvent { + storage_seq: 2, + event: root_user_message_event("turn-1", "hello"), + }, + StoredEvent { + storage_seq: 3, + event: root_assistant_final_event("turn-1", "world"), + }, + StoredEvent { + storage_seq: 4, + event: root_turn_done_event("turn-1", Some("completed".to_string())), + }, + StoredEvent { + storage_seq: 5, + event: root_user_message_event("turn-2", "next"), + }, + StoredEvent { + storage_seq: 6, + event: root_turn_done_event("turn-2", Some("completed".to_string())), + }, + ]; + let (runtime, _) = seed_runtime_with_events(events); + + let from_turn = runtime + .fork_session( + &SessionId::from("source".to_string()), + ForkPoint::TurnEnd("turn-1".to_string()), + ) + .await + .expect("completed turn should be accepted"); + assert_eq!(from_turn.fork_point_storage_seq, 4); + + let from_seq = runtime + .fork_session( + &SessionId::from("source".to_string()), + ForkPoint::StorageSeq(4), + ) + .await + .expect("stable storage seq should be accepted"); + assert_eq!(from_seq.fork_point_storage_seq, 4); + } + + #[tokio::test] + async fn fork_session_latest_on_thinking_truncates_to_last_stable_turn() { + let events = vec![ + StoredEvent { + storage_seq: 1, + event: session_start_event("source", ".", None, None, Utc::now()), + }, + StoredEvent { + storage_seq: 2, + event: root_user_message_event("turn-1", "hello"), + }, + StoredEvent { + storage_seq: 3, + event: root_turn_done_event("turn-1", Some("completed".to_string())), + }, + StoredEvent { + storage_seq: 4, + event: root_user_message_event("turn-2", "unfinished"), + }, + StoredEvent { + storage_seq: 5, + event: root_turn_event( + Some("turn-2"), + astrcode_core::StorageEventPayload::AssistantFinal { + content: "partial".to_string(), + reasoning_content: None, + reasoning_signature: None, + timestamp: Some(Utc::now()), + }, + ), + }, + ]; + let (runtime, event_store) = seed_runtime_with_events(events); + + let result = runtime + .fork_session(&SessionId::from("source".to_string()), ForkPoint::Latest) + .await + .expect("fork latest should succeed"); + + assert_eq!(result.fork_point_storage_seq, 3); + let new_events = event_store.stored_events_for(result.new_session_id.as_str()); + assert_eq!(new_events.len(), 3); + } + + #[tokio::test] + async fn fork_session_rejects_unfinished_turn_and_active_storage_seq() { + let events = vec![ + StoredEvent { + storage_seq: 1, + event: session_start_event("source", ".", None, None, Utc::now()), + }, + StoredEvent { + storage_seq: 2, + event: root_user_message_event("turn-1", "hello"), + }, + ]; + let (runtime, _) = seed_runtime_with_events(events); + + let unfinished_turn = runtime + .fork_session( + &SessionId::from("source".to_string()), + ForkPoint::TurnEnd("turn-1".to_string()), + ) + .await + .expect_err("unfinished turn should be rejected"); + assert!(matches!(unfinished_turn, AstrError::Validation(_))); + + let active_seq = runtime + .fork_session( + &SessionId::from("source".to_string()), + ForkPoint::StorageSeq(2), + ) + .await + .expect_err("active storage seq should be rejected"); + assert!(matches!(active_seq, AstrError::Validation(_))); + } + + #[tokio::test] + async fn fork_session_rejects_unknown_turn_id() { + let events = vec![StoredEvent { + storage_seq: 1, + event: session_start_event("source", ".", None, None, Utc::now()), + }]; + let (runtime, _) = seed_runtime_with_events(events); + + let error = runtime + .fork_session( + &SessionId::from("source".to_string()), + ForkPoint::TurnEnd("turn-missing".to_string()), + ) + .await + .expect_err("missing turn should be rejected"); + + assert!(matches!(error, AstrError::SessionNotFound(_))); + } +} diff --git a/crates/session-runtime/src/turn/interrupt.rs b/crates/session-runtime/src/turn/interrupt.rs index f79cb63c..d0595fea 100644 --- a/crates/session-runtime/src/turn/interrupt.rs +++ b/crates/session-runtime/src/turn/interrupt.rs @@ -1,7 +1,11 @@ use astrcode_core::{AgentEventContext, EventTranslator, Phase, Result, SessionId}; use chrono::Utc; -use crate::{SessionRuntime, state::append_and_broadcast, turn::events::error_event}; +use crate::{ + SessionRuntime, + state::append_and_broadcast, + turn::{events::error_event, submit::persist_pending_manual_compact_if_any}, +}; impl SessionRuntime { pub async fn interrupt_session(&self, session_id: &str) -> Result<()> { @@ -54,6 +58,180 @@ impl SessionRuntime { ); append_and_broadcast(actor.state(), &event, &mut translator).await?; crate::state::complete_session_execution(actor.state(), Phase::Interrupted); + persist_pending_manual_compact_if_any( + self.kernel.gateway(), + self.prompt_facts_provider.as_ref(), + &self.event_store, + actor.working_dir(), + actor.state(), + session_id.as_str(), + ) + .await; Ok(()) } } + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use astrcode_core::{ + LlmFinishReason, LlmOutput, LlmProvider, LlmRequest, ModelLimits, Phase, PromptBuildOutput, + PromptBuildRequest, PromptFacts, PromptFactsProvider, PromptFactsRequest, PromptProvider, + ResolvedRuntimeConfig, ResourceProvider, ResourceReadResult, ResourceRequestContext, + Result, + }; + use astrcode_kernel::Kernel; + use async_trait::async_trait; + + use crate::turn::test_support::{ + BranchingTestEventStore, append_root_turn_event_to_actor, assert_contains_compact_summary, + }; + + #[derive(Debug)] + struct SummaryLlmProvider; + + #[async_trait] + impl LlmProvider for SummaryLlmProvider { + async fn generate( + &self, + _request: LlmRequest, + _sink: Option<astrcode_core::LlmEventSink>, + ) -> Result<LlmOutput> { + Ok(LlmOutput { + content: "<analysis>ok</analysis><summary>manual compact summary</summary>" + .to_string(), + tool_calls: Vec::new(), + reasoning: None, + usage: None, + finish_reason: LlmFinishReason::Stop, + }) + } + + fn model_limits(&self) -> ModelLimits { + ModelLimits { + context_window: 64_000, + max_output_tokens: 8_000, + } + } + } + + #[derive(Debug)] + struct TestPromptProvider; + + #[async_trait] + impl PromptProvider for TestPromptProvider { + async fn build_prompt(&self, _request: PromptBuildRequest) -> Result<PromptBuildOutput> { + Ok(PromptBuildOutput { + system_prompt: "noop".to_string(), + system_prompt_blocks: Vec::new(), + prompt_cache_hints: Default::default(), + cache_metrics: Default::default(), + metadata: serde_json::Value::Null, + }) + } + } + + #[derive(Debug)] + struct TestResourceProvider; + + #[async_trait] + impl ResourceProvider for TestResourceProvider { + async fn read_resource( + &self, + _uri: &str, + _context: &ResourceRequestContext, + ) -> Result<ResourceReadResult> { + Ok(ResourceReadResult { + uri: "noop://resource".to_string(), + content: serde_json::Value::Null, + metadata: serde_json::Value::Null, + }) + } + } + + #[derive(Debug)] + struct NoopPromptFactsProvider; + + #[async_trait] + impl PromptFactsProvider for NoopPromptFactsProvider { + async fn resolve_prompt_facts(&self, _request: &PromptFactsRequest) -> Result<PromptFacts> { + Ok(PromptFacts::default()) + } + } + + fn summary_runtime(event_store: Arc<dyn astrcode_core::EventStore>) -> crate::SessionRuntime { + crate::SessionRuntime::new( + Arc::new( + Kernel::builder() + .with_capabilities(astrcode_kernel::CapabilityRouter::empty()) + .with_llm_provider(Arc::new(SummaryLlmProvider)) + .with_prompt_provider(Arc::new(TestPromptProvider)) + .with_resource_provider(Arc::new(TestResourceProvider)) + .build() + .expect("kernel should build"), + ), + Arc::new(NoopPromptFactsProvider), + event_store, + Arc::new(crate::turn::test_support::NoopMetrics), + ) + } + + #[tokio::test] + async fn interrupt_session_persists_pending_manual_compact() { + let runtime = summary_runtime(Arc::new(BranchingTestEventStore::default())); + let session = runtime + .create_session(".") + .await + .expect("test session should be created"); + let session_id = session.session_id.clone(); + let actor = runtime + .ensure_loaded_session(&astrcode_core::SessionId::from(session_id.clone())) + .await + .expect("session should load"); + append_root_turn_event_to_actor( + &actor, + crate::turn::test_support::root_user_message_event("turn-0", "hello"), + ) + .await; + append_root_turn_event_to_actor( + &actor, + crate::turn::test_support::root_assistant_final_event("turn-0", "latest answer"), + ) + .await; + actor + .state() + .request_manual_compact(crate::state::PendingManualCompactRequest { + runtime: ResolvedRuntimeConfig::default(), + instructions: None, + }) + .expect("manual compact flag should set"); + actor + .state() + .running + .store(true, std::sync::atomic::Ordering::SeqCst); + *actor + .state() + .active_turn_id + .lock() + .expect("active turn lock should work") = Some("turn-1".to_string()); + + runtime + .interrupt_session(&session_id) + .await + .expect("interrupt should succeed"); + + assert_eq!( + actor + .state() + .current_phase() + .expect("phase should be readable"), + Phase::Interrupted + ); + let stored = actor + .state() + .snapshot_recent_stored_events() + .expect("stored events should be available"); + assert_contains_compact_summary(&stored, "manual compact summary"); + } +} diff --git a/crates/session-runtime/src/turn/llm_cycle.rs b/crates/session-runtime/src/turn/llm_cycle.rs index d9b56df1..d6e07fd4 100644 --- a/crates/session-runtime/src/turn/llm_cycle.rs +++ b/crates/session-runtime/src/turn/llm_cycle.rs @@ -22,14 +22,14 @@ use tokio::sync::mpsc; use crate::SessionState; #[derive(Debug, Clone, PartialEq, Eq)] -pub struct StreamedToolCallDelta { +pub(crate) struct StreamedToolCallDelta { pub index: usize, pub id: Option<String>, pub name: Option<String>, pub arguments_delta: String, } -pub type ToolCallDeltaSink = Arc<dyn Fn(StreamedToolCallDelta) + Send + Sync>; +pub(crate) type ToolCallDeltaSink = Arc<dyn Fn(StreamedToolCallDelta) + Send + Sync>; /// 调用 LLM 并收集流式 delta 为 StorageEvent。 /// @@ -84,14 +84,6 @@ pub async fn call_llm_streaming( output.map_err(map_kernel_error) } -/// 检查错误是否为 prompt-too-long 类型。 -/// -/// 不同 provider 使用不同的错误消息描述上下文长度溢出, -/// 此函数覆盖常见的几种表述方式。 -pub fn is_prompt_too_long(error: &astrcode_core::AstrError) -> bool { - error.is_prompt_too_long() -} - /// 将 LLM 流式增量直接发到 live-only 广播通道。 /// /// 为什么不再把 token 级 delta 塞进 turn 结束后的 durable 批量事件: diff --git a/crates/session-runtime/src/turn/loop_control.rs b/crates/session-runtime/src/turn/loop_control.rs index 78b1c20f..1a0c0422 100644 --- a/crates/session-runtime/src/turn/loop_control.rs +++ b/crates/session-runtime/src/turn/loop_control.rs @@ -36,7 +36,7 @@ pub enum TurnStopCause { /// budget 驱动 auto-continue 的判断结果。 #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum BudgetContinuationDecision { +pub(crate) enum BudgetContinuationDecision { Continue, Stop(TurnStopCause), NotNeeded, diff --git a/crates/session-runtime/src/turn/manual_compact.rs b/crates/session-runtime/src/turn/manual_compact.rs index 005caae5..2ce19197 100644 --- a/crates/session-runtime/src/turn/manual_compact.rs +++ b/crates/session-runtime/src/turn/manual_compact.rs @@ -14,6 +14,7 @@ use crate::{ compaction::{CompactConfig, auto_compact}, file_access::FileAccessTracker, }, + state::compact_history_event_log_path, turn::{ events::{CompactAppliedStats, compact_applied_event}, request::{PromptOutputRequest, build_prompt_output}, @@ -27,6 +28,8 @@ pub(crate) struct ManualCompactRequest<'a> { pub session_id: &'a str, pub working_dir: &'a Path, pub runtime: &'a ResolvedRuntimeConfig, + pub trigger: astrcode_core::CompactTrigger, + pub instructions: Option<&'a str>, } pub(crate) async fn build_manual_compact_events( @@ -47,7 +50,10 @@ pub(crate) async fn build_manual_compact_events( working_dir: request.working_dir, step_index: 0, messages: &projected.messages, + session_state: Some(request.session_state), + current_agent_id: None, submission_prompt_declarations: &[], + prompt_governance: None, }) .await?; @@ -57,7 +63,16 @@ pub(crate) async fn build_manual_compact_events( Some(&prompt_output.system_prompt), CompactConfig { keep_recent_turns: settings.compact_keep_recent_turns, - trigger: astrcode_core::CompactTrigger::Manual, + keep_recent_user_messages: settings.compact_keep_recent_user_messages, + trigger: request.trigger, + summary_reserve_tokens: settings.summary_reserve_tokens, + max_output_tokens: settings.compact_max_output_tokens, + max_retry_attempts: settings.compact_max_retry_attempts, + history_path: Some(compact_history_event_log_path( + request.session_id, + request.working_dir, + )?), + custom_instructions: request.instructions.map(str::to_string), }, CancelToken::new(), ) @@ -69,9 +84,10 @@ pub(crate) async fn build_manual_compact_events( let mut events = vec![compact_applied_event( None, &AgentEventContext::default(), - astrcode_core::CompactTrigger::Manual, - compaction.summary, + request.trigger, + compaction.summary.clone(), CompactAppliedStats { + meta: compaction.meta, preserved_recent_turns: compaction.preserved_recent_turns, pre_tokens: compaction.pre_tokens, post_tokens_estimate: compaction.post_tokens_estimate, @@ -81,6 +97,29 @@ pub(crate) async fn build_manual_compact_events( compaction.timestamp, )]; + if let Some(digest) = compaction.recent_user_context_digest { + events.push(StorageEvent { + turn_id: None, + agent: AgentEventContext::default(), + payload: StorageEventPayload::UserMessage { + content: digest, + origin: astrcode_core::UserMessageOrigin::RecentUserContextDigest, + timestamp: compaction.timestamp, + }, + }); + } + for content in compaction.recent_user_context_messages { + events.push(StorageEvent { + turn_id: None, + agent: AgentEventContext::default(), + payload: StorageEventPayload::UserMessage { + content, + origin: astrcode_core::UserMessageOrigin::RecentUserContext, + timestamp: compaction.timestamp, + }, + }); + } + for message in file_access_tracker.build_recovery_messages(settings.file_recovery_config()) { let astrcode_core::LlmMessage::User { content, origin } = message else { continue; @@ -104,10 +143,10 @@ mod tests { use std::sync::Arc; use astrcode_core::{ - EventTranslator, LlmFinishReason, LlmOutput, LlmProvider, LlmRequest, ModelLimits, Phase, - PromptBuildOutput, PromptBuildRequest, PromptFactsProvider, PromptFactsRequest, - PromptProvider, ResourceProvider, ResourceReadResult, ResourceRequestContext, Result, - SessionId, StorageEventPayload, UserMessageOrigin, + CompactMode, EventTranslator, LlmFinishReason, LlmOutput, LlmProvider, LlmRequest, + ModelLimits, Phase, PromptBuildOutput, PromptBuildRequest, PromptFactsProvider, + PromptFactsRequest, PromptProvider, ResourceProvider, ResourceReadResult, + ResourceRequestContext, Result, SessionId, StorageEventPayload, UserMessageOrigin, }; use astrcode_kernel::Kernel; use async_trait::async_trait; @@ -167,6 +206,8 @@ mod tests { Ok(PromptBuildOutput { system_prompt: "noop".to_string(), system_prompt_blocks: Vec::new(), + prompt_cache_hints: Default::default(), + cache_metrics: Default::default(), metadata: serde_json::Value::Null, }) } @@ -257,6 +298,8 @@ mod tests { session_id: "session-1", working_dir: Path::new("."), runtime: &ResolvedRuntimeConfig::default(), + trigger: astrcode_core::CompactTrigger::Manual, + instructions: Some("保留错误和文件路径"), }) .await .expect("manual compact should succeed") @@ -264,7 +307,11 @@ mod tests { assert!(matches!( &events[0].payload, - StorageEventPayload::CompactApplied { summary, .. } if summary == "manual compact summary" + StorageEventPayload::CompactApplied { summary, meta, .. } + if summary.contains("manual compact summary") + && summary.contains("session-1.jsonl") + && meta.mode == CompactMode::Full + && meta.instructions_present )); } } diff --git a/crates/session-runtime/src/turn/mod.rs b/crates/session-runtime/src/turn/mod.rs index 21233320..b8abf395 100644 --- a/crates/session-runtime/src/turn/mod.rs +++ b/crates/session-runtime/src/turn/mod.rs @@ -7,8 +7,9 @@ mod branch; mod compaction_cycle; mod continuation_cycle; mod events; +mod fork; mod interrupt; -pub mod llm_cycle; +pub(crate) mod llm_cycle; mod loop_control; pub(crate) mod manual_compact; mod replay; @@ -18,25 +19,18 @@ mod submit; #[cfg(test)] pub(crate) mod test_support; // pub mod subagent; -pub mod summary; -pub mod tool_cycle; +mod summary; +pub(crate) mod tool_cycle; mod tool_result_budget; -use astrcode_core::{SessionId, TurnId}; +pub use fork::{ForkPoint, ForkResult}; pub use loop_control::{TurnLoopTransition, TurnStopCause}; pub use submit::AgentPromptSubmission; pub use summary::{TurnCollaborationSummary, TurnFinishReason, TurnSummary}; -/// Turn 运行请求参数。 -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct TurnRunRequest { - pub session_id: SessionId, - pub turn_id: TurnId, -} - /// Turn 结束原因。 #[derive(Debug, Clone, PartialEq, Eq)] -pub enum TurnOutcome { +pub(crate) enum TurnOutcome { /// LLM 返回纯文本(无 tool_calls),自然结束。 Completed, /// 用户取消或 CancelToken 触发。 @@ -45,4 +39,4 @@ pub enum TurnOutcome { Error { message: String }, } -pub use runner::{TurnRunRequest as RunnerRequest, TurnRunResult, run_turn}; +pub(crate) use runner::{TurnRunRequest as RunnerRequest, TurnRunResult, run_turn}; diff --git a/crates/session-runtime/src/turn/request.rs b/crates/session-runtime/src/turn/request.rs index e6fbf3b3..2996fe32 100644 --- a/crates/session-runtime/src/turn/request.rs +++ b/crates/session-runtime/src/turn/request.rs @@ -9,7 +9,7 @@ use std::{collections::HashSet, path::Path, sync::Arc, time::Instant}; use astrcode_core::{ AgentEventContext, CompactTrigger, LlmMessage, LlmRequest, PromptBuildOutput, PromptBuildRequest, PromptDeclaration, PromptFacts, PromptFactsProvider, PromptFactsRequest, - Result, StorageEvent, UserMessageOrigin, + PromptGovernanceContext, Result, StorageEvent, UserMessageOrigin, }; use astrcode_kernel::KernelGateway; @@ -21,8 +21,11 @@ use crate::{ micro_compact::MicroCompactState, token_usage::{TokenUsageTracker, build_prompt_snapshot, should_compact}, }, + state::compact_history_event_log_path, turn::{ - events::{CompactAppliedStats, compact_applied_event, prompt_metrics_event}, + events::{ + CompactAppliedStats, compact_applied_event, prompt_metrics_event, user_message_event, + }, tool_result_budget::{ ApplyToolResultBudgetRequest, ToolResultBudgetOutcome, ToolResultBudgetStats, ToolResultReplacementState, apply_tool_result_budget, @@ -49,6 +52,7 @@ pub struct AssemblePromptRequest<'a> { pub session_state: &'a crate::SessionState, pub tool_result_replacement_state: &'a mut ToolResultReplacementState, pub prompt_declarations: &'a [PromptDeclaration], + pub prompt_governance: Option<&'a PromptGovernanceContext>, } pub struct AssemblePromptResult { @@ -67,7 +71,10 @@ pub(crate) struct PromptOutputRequest<'a> { pub working_dir: &'a Path, pub step_index: usize, pub messages: &'a [LlmMessage], + pub session_state: Option<&'a crate::SessionState>, + pub current_agent_id: Option<&'a str>, pub submission_prompt_declarations: &'a [PromptDeclaration], + pub prompt_governance: Option<&'a PromptGovernanceContext>, } /// Why: request assembly 要回答“最终如何形成一次 LLM 请求”, @@ -119,7 +126,10 @@ pub async fn assemble_prompt_request( working_dir: request.working_dir, step_index: request.step_index, messages: &messages, + session_state: Some(request.session_state), + current_agent_id: request.agent.agent_id.as_ref().map(|id| id.as_str()), submission_prompt_declarations: request.prompt_declarations, + prompt_governance: request.prompt_governance, }) .await?; let mut snapshot = build_prompt_snapshot( @@ -128,6 +138,8 @@ pub async fn assemble_prompt_request( Some(&prompt_output.system_prompt), request.gateway.model_limits(), request.settings.compact_threshold_percent, + request.settings.summary_reserve_tokens, + request.settings.reserved_context_size, ); if should_compact(snapshot) { @@ -138,7 +150,16 @@ pub async fn assemble_prompt_request( Some(&prompt_output.system_prompt), CompactConfig { keep_recent_turns: request.settings.compact_keep_recent_turns, + keep_recent_user_messages: request.settings.compact_keep_recent_user_messages, trigger: CompactTrigger::Auto, + summary_reserve_tokens: request.settings.summary_reserve_tokens, + max_output_tokens: request.settings.compact_max_output_tokens, + max_retry_attempts: request.settings.compact_max_retry_attempts, + history_path: Some(compact_history_event_log_path( + request.session_id, + request.working_dir, + )?), + custom_instructions: None, }, request.cancel.clone(), ) @@ -158,8 +179,9 @@ pub async fn assemble_prompt_request( Some(request.turn_id), request.agent, CompactTrigger::Auto, - compaction.summary, + compaction.summary.clone(), CompactAppliedStats { + meta: compaction.meta, preserved_recent_turns: compaction.preserved_recent_turns, pre_tokens: compaction.pre_tokens, post_tokens_estimate: compaction.post_tokens_estimate, @@ -168,6 +190,24 @@ pub async fn assemble_prompt_request( }, compaction.timestamp, )); + if let Some(digest) = compaction.recent_user_context_digest.clone() { + events.push(user_message_event( + request.turn_id, + request.agent, + digest, + UserMessageOrigin::RecentUserContextDigest, + compaction.timestamp, + )); + } + for content in &compaction.recent_user_context_messages { + events.push(user_message_event( + request.turn_id, + request.agent, + content.clone(), + UserMessageOrigin::RecentUserContext, + compaction.timestamp, + )); + } prompt_output = build_prompt_output(PromptOutputRequest { gateway: request.gateway, @@ -177,7 +217,10 @@ pub async fn assemble_prompt_request( working_dir: request.working_dir, step_index: request.step_index, messages: &messages, + session_state: Some(request.session_state), + current_agent_id: request.agent.agent_id.as_ref().map(|id| id.as_str()), submission_prompt_declarations: request.prompt_declarations, + prompt_governance: request.prompt_governance, }) .await?; snapshot = build_prompt_snapshot( @@ -186,6 +229,8 @@ pub async fn assemble_prompt_request( Some(&prompt_output.system_prompt), request.gateway.model_limits(), request.settings.compact_threshold_percent, + request.settings.summary_reserve_tokens, + request.settings.reserved_context_size, ); } } else { @@ -206,11 +251,14 @@ pub async fn assemble_prompt_request( request.step_index, snapshot, prune_outcome.stats.truncated_tool_results, + prompt_output.cache_metrics, + request.gateway.supports_cache_metrics(), )); let mut llm_request = LlmRequest::new(messages.clone(), request.tools, request.cancel.clone()) .with_system(prompt_output.system_prompt); llm_request.system_prompt_blocks = prompt_output.system_prompt_blocks; + llm_request.prompt_cache_hints = Some(prompt_output.prompt_cache_hints.clone()); Ok(AssemblePromptResult { llm_request, @@ -232,7 +280,10 @@ pub(crate) async fn build_prompt_output( working_dir, step_index, messages, + session_state, + current_agent_id, submission_prompt_declarations, + prompt_governance, } = request; let facts = prompt_facts_provider .resolve_prompt_facts(&PromptFactsRequest { @@ -245,6 +296,7 @@ pub(crate) async fn build_prompt_output( .into_iter() .map(|spec| spec.name.to_string()) .collect(), + governance: prompt_governance.cloned(), }) .await?; let turn_index = count_user_turns(messages); @@ -259,6 +311,16 @@ pub(crate) async fn build_prompt_output( agent_profiles, mut prompt_declarations, } = facts; + if let Some(direct_child_snapshot) = + live_direct_child_snapshot_declaration(session_state, current_agent_id)? + { + prompt_declarations.push(direct_child_snapshot); + } + if let Some(task_snapshot) = + live_task_snapshot_declaration(session_state, session_id, current_agent_id)? + { + prompt_declarations.push(task_snapshot); + } prompt_declarations.extend_from_slice(submission_prompt_declarations); gateway .build_prompt(PromptBuildRequest { @@ -279,6 +341,119 @@ pub(crate) async fn build_prompt_output( .map_err(|error| astrcode_core::AstrError::Internal(error.to_string())) } +fn live_direct_child_snapshot_declaration( + session_state: Option<&crate::SessionState>, + current_agent_id: Option<&str>, +) -> Result<Option<PromptDeclaration>> { + let Some(session_state) = session_state else { + return Ok(None); + }; + let Some(current_agent_id) = current_agent_id.filter(|value| !value.trim().is_empty()) else { + return Ok(None); + }; + + let direct_children = session_state.child_nodes_for_parent(current_agent_id)?; + let children_block = if direct_children.is_empty() { + "- (none)\n- If work needs a new branch, use `spawn` instead of guessing an older \ + `agentId`." + .to_string() + } else { + direct_children + .iter() + .map(|node| { + format!( + "- agentId=`{}` status=`{:?}` subRunId=`{}` childSessionId=`{}` lineage=`{:?}`", + node.agent_id(), + node.status, + node.sub_run_id(), + node.child_session_id, + node.lineage_kind + ) + }) + .collect::<Vec<_>>() + .join("\n") + }; + + Ok(Some(PromptDeclaration { + block_id: "agent.live.direct_children".to_string(), + title: "Live Direct Child Snapshot".to_string(), + content: format!( + "Authoritative direct-child snapshot for the current agent.\n\nRouting rules:\n- Only \ + use `agentId` values from this snapshot or from a newer live tool result / child \ + notification in the current prompt tail.\n- Never reuse `agentId`, `subRunId`, or \ + `sessionId` from compact summaries, stale errors, or historical notes.\n- If a child \ + is absent from this snapshot, treat it as unavailable for `send`, `observe`, or \ + `close` at prompt-build time.\n\nDirect children:\n{children_block}" + ), + render_target: astrcode_core::PromptDeclarationRenderTarget::System, + layer: astrcode_core::SystemPromptLayer::Dynamic, + kind: astrcode_core::PromptDeclarationKind::ExtensionInstruction, + priority_hint: Some(592), + always_include: true, + source: astrcode_core::PromptDeclarationSource::Builtin, + capability_name: None, + origin: Some(format!("live-direct-children:{current_agent_id}")), + })) +} + +fn live_task_snapshot_declaration( + session_state: Option<&crate::SessionState>, + session_id: &str, + current_agent_id: Option<&str>, +) -> Result<Option<PromptDeclaration>> { + let Some(session_state) = session_state else { + return Ok(None); + }; + let owner = current_agent_id + .filter(|value| !value.trim().is_empty()) + .unwrap_or(session_id); + let Some(snapshot) = session_state.active_tasks_for(owner)? else { + return Ok(None); + }; + let active_items = snapshot.active_items(); + if active_items.is_empty() { + return Ok(None); + } + + let items_block = active_items + .iter() + .map(|item| match item.status { + astrcode_core::ExecutionTaskStatus::InProgress => format!( + "- in_progress: {}{}", + item.content, + item.active_form + .as_deref() + .map(|value| format!(" ({value})")) + .unwrap_or_default() + ), + astrcode_core::ExecutionTaskStatus::Pending => format!("- pending: {}", item.content), + astrcode_core::ExecutionTaskStatus::Completed => String::new(), + }) + .filter(|line| !line.is_empty()) + .collect::<Vec<_>>() + .join("\n"); + + Ok(Some(PromptDeclaration { + block_id: "task.active_snapshot".to_string(), + title: "Live Task Snapshot".to_string(), + content: format!( + "Authoritative execution-task snapshot for the current owner.\n\nRules:\n- Treat this \ + snapshot as the current execution checklist for this branch of work.\n- Only \ + `in_progress` and `pending` tasks appear here; completed items are intentionally \ + omitted.\n- Update this snapshot with `taskWrite` before changing focus or after \ + completing a task.\n\nActive tasks:\n{items_block}" + ), + render_target: astrcode_core::PromptDeclarationRenderTarget::System, + layer: astrcode_core::SystemPromptLayer::Dynamic, + kind: astrcode_core::PromptDeclarationKind::ExtensionInstruction, + priority_hint: Some(593), + always_include: true, + source: astrcode_core::PromptDeclarationSource::Builtin, + capability_name: None, + origin: Some(format!("live-task-snapshot:{owner}")), + })) +} + pub(crate) fn build_prompt_metadata( session_id: &str, turn_id: &str, @@ -344,7 +519,9 @@ mod tests { use std::sync::{Arc, Mutex}; use astrcode_core::{ - AstrError, LlmOutput, LlmProvider, LlmRequest, ModelLimits, PromptBuildOutput, + AgentLifecycleStatus, AstrError, ChildExecutionIdentity, ChildSessionLineageKind, + ChildSessionNode, ChildSessionStatusSource, ExecutionTaskItem, ExecutionTaskStatus, + LlmOutput, LlmProvider, LlmRequest, ModelLimits, ParentExecutionRef, PromptBuildOutput, PromptBuildRequest, PromptDeclaration, PromptDeclarationKind, PromptDeclarationRenderTarget, PromptDeclarationSource, PromptFacts, PromptFactsProvider, PromptFactsRequest, PromptProvider, ResolvedRuntimeConfig, ResourceProvider, @@ -400,6 +577,7 @@ mod tests { session_state: &session_state, tool_result_replacement_state: &mut replacement_state, prompt_declarations: &[], + prompt_governance: None, }) .await .expect("assembly should succeed"); @@ -412,6 +590,64 @@ mod tests { assert_eq!(result.llm_request.messages.len(), 1); } + #[tokio::test] + async fn assemble_prompt_request_carries_prompt_cache_reuse_counts() { + let base_gateway = test_gateway(64_000); + let gateway = KernelGateway::new( + base_gateway.capabilities().clone(), + Arc::new(LocalNoopLlmProvider), + Arc::new(RecordingPromptProvider { + captured: Arc::new(Mutex::new(Vec::new())), + }), + Arc::new(LocalNoopResourceProvider), + ); + let mut micro_state = crate::context_window::micro_compact::MicroCompactState::default(); + let tracker = crate::context_window::file_access::FileAccessTracker::new(4); + let session_state = test_session_state(); + let mut replacement_state = ToolResultReplacementState::default(); + let settings = ContextWindowSettings::from(&ResolvedRuntimeConfig::default()); + + let result = assemble_prompt_request(AssemblePromptRequest { + gateway: &gateway, + prompt_facts_provider: &NoopPromptFactsProvider, + session_id: "session-1", + turn_id: "turn-1", + working_dir: Path::new("."), + messages: vec![LlmMessage::User { + content: "hello".to_string(), + origin: astrcode_core::UserMessageOrigin::User, + }], + cancel: astrcode_core::CancelToken::new(), + agent: &AgentEventContext::default(), + step_index: 0, + token_tracker: &TokenUsageTracker::default(), + tools: vec![ToolDefinition { + name: "readFile".to_string(), + description: "read".to_string(), + parameters: json!({"type":"object"}), + }] + .into(), + settings: &settings, + clearable_tools: &std::collections::HashSet::new(), + micro_compact_state: &mut micro_state, + file_access_tracker: &tracker, + session_state: &session_state, + tool_result_replacement_state: &mut replacement_state, + prompt_declarations: &[], + prompt_governance: None, + }) + .await + .expect("assembly should succeed"); + + assert!(matches!( + &result.events[0].payload, + StorageEventPayload::PromptMetrics { metrics } + if metrics.prompt_cache_reuse_hits == 2 + && metrics.prompt_cache_reuse_misses == 1 + && !metrics.provider_cache_metrics_supported + )); + } + #[derive(Debug)] struct RecordingPromptProvider { captured: Arc<Mutex<Vec<PromptDeclaration>>>, @@ -428,6 +664,12 @@ mod tests { Ok(PromptBuildOutput { system_prompt: "recorded".to_string(), system_prompt_blocks: Vec::new(), + prompt_cache_hints: Default::default(), + cache_metrics: astrcode_core::PromptBuildCacheMetrics { + reuse_hits: 2, + reuse_misses: 1, + unchanged_layers: Vec::new(), + }, metadata: serde_json::Value::Null, }) } @@ -535,7 +777,10 @@ mod tests { working_dir: Path::new("."), step_index: 0, messages: &[], + session_state: None, + current_agent_id: None, submission_prompt_declarations: &submission_declarations, + prompt_governance: None, }) .await .expect("prompt output should build"); @@ -546,4 +791,178 @@ mod tests { assert_eq!(captured[0].origin.as_deref(), Some("facts-origin")); assert_eq!(captured[1].origin.as_deref(), Some("submission-origin")); } + + #[tokio::test] + async fn build_prompt_output_includes_live_task_snapshot_for_current_owner() { + let captured = Arc::new(Mutex::new(Vec::new())); + let gateway = KernelGateway::new( + CapabilityRouter::empty(), + Arc::new(LocalNoopLlmProvider), + Arc::new(RecordingPromptProvider { + captured: captured.clone(), + }), + Arc::new(LocalNoopResourceProvider), + ); + let session_state = test_session_state(); + session_state + .replace_active_task_snapshot(astrcode_core::TaskSnapshot { + owner: "agent-root".to_string(), + items: vec![ + ExecutionTaskItem { + content: "实现 task prompt 注入".to_string(), + status: ExecutionTaskStatus::InProgress, + active_form: Some("正在实现 task prompt 注入".to_string()), + }, + ExecutionTaskItem { + content: "补充 request 测试".to_string(), + status: ExecutionTaskStatus::Pending, + active_form: None, + }, + ExecutionTaskItem { + content: "完成旧任务".to_string(), + status: ExecutionTaskStatus::Completed, + active_form: Some("已完成旧任务".to_string()), + }, + ], + }) + .expect("task snapshot should store"); + + build_prompt_output(PromptOutputRequest { + gateway: &gateway, + prompt_facts_provider: &RecordingPromptFactsProvider, + session_id: "session-1", + turn_id: "turn-1", + working_dir: Path::new("."), + step_index: 0, + messages: &[], + session_state: Some(session_state.as_ref()), + current_agent_id: Some("agent-root"), + submission_prompt_declarations: &[], + prompt_governance: None, + }) + .await + .expect("prompt output should build"); + + let captured = captured.lock().expect("capture lock should work"); + let declaration = captured + .iter() + .find(|declaration| declaration.block_id == "task.active_snapshot") + .expect("task snapshot declaration should exist"); + assert!( + declaration + .content + .contains("in_progress: 实现 task prompt 注入") + ); + assert!(declaration.content.contains("pending: 补充 request 测试")); + assert!(!declaration.content.contains("完成旧任务")); + } + + #[tokio::test] + async fn build_prompt_output_omits_live_task_snapshot_when_no_active_items_exist() { + let captured = Arc::new(Mutex::new(Vec::new())); + let gateway = KernelGateway::new( + CapabilityRouter::empty(), + Arc::new(LocalNoopLlmProvider), + Arc::new(RecordingPromptProvider { + captured: captured.clone(), + }), + Arc::new(LocalNoopResourceProvider), + ); + let session_state = test_session_state(); + session_state + .replace_active_task_snapshot(astrcode_core::TaskSnapshot { + owner: "session-1".to_string(), + items: vec![ExecutionTaskItem { + content: "已完成任务".to_string(), + status: ExecutionTaskStatus::Completed, + active_form: Some("已完成任务".to_string()), + }], + }) + .expect("task snapshot should store"); + + build_prompt_output(PromptOutputRequest { + gateway: &gateway, + prompt_facts_provider: &RecordingPromptFactsProvider, + session_id: "session-1", + turn_id: "turn-1", + working_dir: Path::new("."), + step_index: 0, + messages: &[], + session_state: Some(session_state.as_ref()), + current_agent_id: None, + submission_prompt_declarations: &[], + prompt_governance: None, + }) + .await + .expect("prompt output should build"); + + let captured = captured.lock().expect("capture lock should work"); + assert!( + captured + .iter() + .all(|declaration| declaration.block_id != "task.active_snapshot") + ); + } + + #[test] + fn live_direct_child_snapshot_declaration_only_uses_current_agents_children() { + let session_state = test_session_state(); + session_state + .upsert_child_session_node(ChildSessionNode { + identity: ChildExecutionIdentity { + agent_id: "agent-child-1".into(), + session_id: "session-parent".into(), + sub_run_id: "subrun-child-1".into(), + }, + child_session_id: "session-child-1".into(), + parent_session_id: "session-parent".into(), + parent: ParentExecutionRef { + parent_agent_id: Some("agent-root".into()), + parent_sub_run_id: Some("subrun-root".into()), + }, + parent_turn_id: "turn-1".into(), + lineage_kind: ChildSessionLineageKind::Spawn, + status: AgentLifecycleStatus::Idle, + status_source: ChildSessionStatusSource::Durable, + created_by_tool_call_id: None, + lineage_snapshot: None, + }) + .expect("direct child should insert"); + session_state + .upsert_child_session_node(ChildSessionNode { + identity: ChildExecutionIdentity { + agent_id: "agent-grandchild-1".into(), + session_id: "session-child-1".into(), + sub_run_id: "subrun-grandchild-1".into(), + }, + child_session_id: "session-grandchild-1".into(), + parent_session_id: "session-child-1".into(), + parent: ParentExecutionRef { + parent_agent_id: Some("agent-child-1".into()), + parent_sub_run_id: Some("subrun-child-1".into()), + }, + parent_turn_id: "turn-2".into(), + lineage_kind: ChildSessionLineageKind::Spawn, + status: AgentLifecycleStatus::Running, + status_source: ChildSessionStatusSource::Durable, + created_by_tool_call_id: None, + lineage_snapshot: None, + }) + .expect("grandchild should insert"); + + let declaration = + live_direct_child_snapshot_declaration(Some(&session_state), Some("agent-root")) + .expect("declaration build should succeed") + .expect("root declaration should exist"); + + assert_eq!(declaration.block_id, "agent.live.direct_children"); + assert!(declaration.content.contains("agentId=`agent-child-1`")); + assert!(declaration.content.contains("subRunId=`subrun-child-1`")); + assert!(!declaration.content.contains("agent-grandchild-1")); + assert!( + declaration + .content + .contains("Never reuse `agentId`, `subRunId`, or `sessionId`") + ); + } } diff --git a/crates/session-runtime/src/turn/runner.rs b/crates/session-runtime/src/turn/runner.rs index c8062d94..0ee041b1 100644 --- a/crates/session-runtime/src/turn/runner.rs +++ b/crates/session-runtime/src/turn/runner.rs @@ -28,8 +28,8 @@ use std::{collections::HashSet, path::Path, sync::Arc, time::Instant}; use astrcode_core::{ AgentEventContext, CancelToken, LlmMessage, PromptDeclaration, PromptFactsProvider, - ResolvedExecutionLimitsSnapshot, ResolvedRuntimeConfig, Result, StorageEvent, - StorageEventPayload, ToolDefinition, + PromptGovernanceContext, ResolvedRuntimeConfig, Result, StorageEvent, StorageEventPayload, + ToolDefinition, }; use astrcode_kernel::{CapabilityRouter, Kernel, KernelGateway}; use chrono::{DateTime, Utc}; @@ -55,7 +55,7 @@ use crate::{ const CLEARABLE_TOOLS: &[&str] = &["readFile", "listDir", "grep", "findFiles"]; /// Turn 执行请求。 -pub struct TurnRunRequest { +pub(crate) struct TurnRunRequest { pub session_id: String, pub working_dir: String, pub turn_id: String, @@ -68,12 +68,11 @@ pub struct TurnRunRequest { pub prompt_facts_provider: Arc<dyn PromptFactsProvider>, pub capability_router: Option<CapabilityRouter>, pub prompt_declarations: Vec<PromptDeclaration>, - pub resolved_limits: Option<ResolvedExecutionLimitsSnapshot>, - pub source_tool_call_id: Option<String>, + pub prompt_governance: Option<PromptGovernanceContext>, } /// Turn 执行结果。 -pub struct TurnRunResult { +pub(crate) struct TurnRunResult { pub outcome: TurnOutcome, /// Turn 结束时的完整消息历史(含本次 turn 新增的)。 pub messages: Vec<LlmMessage>, @@ -94,6 +93,7 @@ struct TurnExecutionResources<'a> { cancel: &'a CancelToken, agent: &'a AgentEventContext, prompt_declarations: &'a [PromptDeclaration], + prompt_governance: Option<&'a PromptGovernanceContext>, tools: Arc<[ToolDefinition]>, settings: ContextWindowSettings, clearable_tools: HashSet<String>, @@ -110,6 +110,7 @@ struct TurnExecutionRequestView<'a> { cancel: &'a CancelToken, agent: &'a AgentEventContext, prompt_declarations: &'a [PromptDeclaration], + prompt_governance: Option<&'a PromptGovernanceContext>, } struct TurnExecutionContext { @@ -157,6 +158,7 @@ impl<'a> TurnExecutionResources<'a> { cancel: request.cancel, agent: request.agent, prompt_declarations: request.prompt_declarations, + prompt_governance: request.prompt_governance, tools: Arc::from(gateway.capabilities().tool_definitions()), settings, clearable_tools: CLEARABLE_TOOLS @@ -288,8 +290,7 @@ pub async fn run_turn(kernel: Arc<Kernel>, request: TurnRunRequest) -> Result<Tu prompt_facts_provider, capability_router, prompt_declarations, - resolved_limits: _, - source_tool_call_id: _, + prompt_governance, } = request; let gateway = scoped_gateway(kernel.gateway(), capability_router)?; let resources = TurnExecutionResources::new( @@ -304,6 +305,7 @@ pub async fn run_turn(kernel: Arc<Kernel>, request: TurnRunRequest) -> Result<Tu cancel: &cancel, agent: &agent, prompt_declarations: &prompt_declarations, + prompt_governance: prompt_governance.as_ref(), }, ); let mut execution = TurnExecutionContext::new(&resources, messages, last_assistant_at); diff --git a/crates/session-runtime/src/turn/runner/step/driver.rs b/crates/session-runtime/src/turn/runner/step/driver.rs index 0f5a618d..cb82fcf3 100644 --- a/crates/session-runtime/src/turn/runner/step/driver.rs +++ b/crates/session-runtime/src/turn/runner/step/driver.rs @@ -69,6 +69,7 @@ impl StepDriver for RuntimeStepDriver { session_state: resources.session_state, tool_result_replacement_state: &mut execution.tool_result_replacement_state, prompt_declarations: resources.prompt_declarations, + prompt_governance: resources.prompt_governance, }) .await?; execution.messages = std::mem::take(&mut assembled.messages); diff --git a/crates/session-runtime/src/turn/runner/step/llm_step.rs b/crates/session-runtime/src/turn/runner/step/llm_step.rs index 87e64d89..5baa1c2e 100644 --- a/crates/session-runtime/src/turn/runner/step/llm_step.rs +++ b/crates/session-runtime/src/turn/runner/step/llm_step.rs @@ -1,7 +1,7 @@ use astrcode_core::{LlmFinishReason, LlmOutput, LlmRequest, Result}; use super::{TurnExecutionContext, TurnExecutionResources, driver::StepDriver}; -use crate::turn::{compaction_cycle, llm_cycle::ToolCallDeltaSink}; +use crate::turn::llm_cycle::ToolCallDeltaSink; pub(super) enum StepLlmResult { Output(LlmOutput), @@ -26,7 +26,7 @@ pub(super) async fn call_llm_for_step( } if error.is_prompt_too_long() && execution.reactive_compact_attempts - < compaction_cycle::MAX_REACTIVE_COMPACT_ATTEMPTS + < resources.settings.compact_max_retry_attempts { execution.reactive_compact_attempts = execution.reactive_compact_attempts.saturating_add(1); @@ -35,7 +35,7 @@ pub(super) async fn call_llm_for_step( resources.turn_id, execution.step_index, execution.reactive_compact_attempts, - compaction_cycle::MAX_REACTIVE_COMPACT_ATTEMPTS, + resources.settings.compact_max_retry_attempts, ); let recovery = driver.try_reactive_compact(execution, resources).await?; diff --git a/crates/session-runtime/src/turn/runner/step/mod.rs b/crates/session-runtime/src/turn/runner/step/mod.rs index dd6c0eaf..95c0f095 100644 --- a/crates/session-runtime/src/turn/runner/step/mod.rs +++ b/crates/session-runtime/src/turn/runner/step/mod.rs @@ -21,7 +21,9 @@ use crate::turn::{ OUTPUT_CONTINUATION_PROMPT, OutputContinuationDecision, continuation_transition, decide_output_continuation, }, - events::{assistant_final_event, turn_done_event, user_message_event}, + events::{ + apply_prompt_metrics_usage, assistant_final_event, turn_done_event, user_message_event, + }, loop_control::{ AUTO_CONTINUE_NUDGE, BudgetContinuationDecision, TurnLoopTransition, TurnStopCause, decide_budget_continuation, @@ -73,6 +75,7 @@ async fn run_single_step_with( let llm_finished_at = Instant::now(); record_llm_usage(execution, &output); + apply_prompt_metrics_usage(&mut execution.events, execution.step_index, output.usage); let has_tool_calls = append_assistant_output(execution, resources, &output); warn_if_output_truncated(resources, execution, &output); @@ -170,6 +173,18 @@ fn append_assistant_output( ) -> bool { let content = output.content.trim().to_string(); let has_tool_calls = !output.tool_calls.is_empty(); + let reasoning_content = output + .reasoning + .as_ref() + .map(|reasoning| reasoning.content.clone()); + let reasoning_signature = output + .reasoning + .as_ref() + .and_then(|reasoning| reasoning.signature.clone()); + let has_persistable_assistant_output = !content.is_empty() + || reasoning_content + .as_deref() + .is_some_and(|value| !value.trim().is_empty()); execution.messages.push(LlmMessage::Assistant { content: content.clone(), tool_calls: output.tool_calls.clone(), @@ -178,20 +193,16 @@ fn append_assistant_output( execution .micro_compact_state .record_assistant_activity(Instant::now()); - execution.events.push(assistant_final_event( - resources.turn_id, - resources.agent, - content, - output - .reasoning - .as_ref() - .map(|reasoning| reasoning.content.clone()), - output - .reasoning - .as_ref() - .and_then(|reasoning| reasoning.signature.clone()), - Some(Utc::now()), - )); + if has_persistable_assistant_output { + execution.events.push(assistant_final_event( + resources.turn_id, + resources.agent, + content, + reasoning_content, + reasoning_signature, + Some(Utc::now()), + )); + } has_tool_calls } diff --git a/crates/session-runtime/src/turn/runner/step/tests.rs b/crates/session-runtime/src/turn/runner/step/tests.rs index 46e855fd..dadfb5ac 100644 --- a/crates/session-runtime/src/turn/runner/step/tests.rs +++ b/crates/session-runtime/src/turn/runner/step/tests.rs @@ -32,8 +32,8 @@ use crate::{ request::AssemblePromptResult, runner::TurnExecutionRequestView, test_support::{ - NoopPromptFactsProvider, assert_contains_compact_summary, assert_has_assistant_final, - assert_has_turn_done, root_compact_applied_event, test_gateway, test_session_state, + NoopPromptFactsProvider, assert_contains_compact_summary, assert_has_turn_done, + root_compact_applied_event, test_gateway, test_session_state, }, tool_cycle::{ToolCycleOutcome, ToolCycleResult, ToolEventEmissionMode}, }, @@ -144,8 +144,12 @@ fn assembled_prompt(messages: Vec<LlmMessage>) -> AssemblePromptResult { context_window: 100, effective_window: 90, threshold_tokens: 80, + remaining_context_tokens: 80, + reserved_context_size: 20, }, 0, + Default::default(), + false, )], auto_compacted: false, tool_result_budget_stats: crate::turn::tool_result_budget::ToolResultBudgetStats::default(), @@ -172,6 +176,7 @@ fn test_resources<'a>( cancel, agent, prompt_declarations: &[], + prompt_governance: None, }, ) } @@ -216,6 +221,7 @@ impl Tool for StreamingSafeProbeTool { output: "streamed safe result".to_string(), error: None, metadata: None, + child_ref: None, duration_ms: 0, truncated: false, }) @@ -259,6 +265,7 @@ impl Tool for StreamingUnsafeProbeTool { output: "unsafe result".to_string(), error: None, metadata: None, + child_ref: None, duration_ms: 0, truncated: false, }) @@ -273,6 +280,7 @@ fn tool_result(tool_call_id: &str, tool_name: &str, output: &str) -> ToolExecuti output: output.to_string(), error: None, metadata: None, + child_ref: None, duration_ms: 0, truncated: false, } @@ -387,7 +395,13 @@ async fn run_single_step_returns_cancelled_when_tool_cycle_interrupts() { )); assert_eq!(execution.step_index, 0); assert_eq!(driver.counts.tool_cycle.load(Ordering::SeqCst), 1); - assert_has_assistant_final(&execution.events); + assert!( + execution + .events + .iter() + .all(|event| !matches!(&event.payload, StorageEventPayload::AssistantFinal { .. })), + "tool-only interrupted step should not persist an empty assistant final" + ); } #[tokio::test] diff --git a/crates/session-runtime/src/turn/submit.rs b/crates/session-runtime/src/turn/submit.rs index a83f1155..6b6dd6f2 100644 --- a/crates/session-runtime/src/turn/submit.rs +++ b/crates/session-runtime/src/turn/submit.rs @@ -1,11 +1,12 @@ use std::{sync::Arc, time::Instant}; use astrcode_core::{ - AgentEventContext, CancelToken, CompletedParentDeliveryPayload, EventTranslator, - ExecutionAccepted, ParentDelivery, ParentDeliveryOrigin, ParentDeliveryPayload, - ParentDeliveryTerminalSemantics, Phase, PromptDeclaration, ResolvedExecutionLimitsSnapshot, + AgentEventContext, ApprovalPending, CancelToken, CapabilityCall, + CompletedParentDeliveryPayload, EventStore, EventTranslator, ExecutionAccepted, LlmMessage, + ParentDelivery, ParentDeliveryOrigin, ParentDeliveryPayload, ParentDeliveryTerminalSemantics, + Phase, PolicyContext, PromptDeclaration, ResolvedExecutionLimitsSnapshot, ResolvedRuntimeConfig, ResolvedSubagentContextOverrides, Result, RuntimeMetricsRecorder, - SessionId, StorageEvent, StorageEventPayload, TurnId, UserMessageOrigin, + SessionId, StorageEvent, StorageEventPayload, StoredEvent, TurnId, UserMessageOrigin, }; use astrcode_kernel::CapabilityRouter; use chrono::Utc; @@ -16,7 +17,7 @@ use crate::{ prepare_session_execution, query::current_turn_messages, run_turn, - state::{append_and_broadcast, complete_session_execution}, + state::{append_and_broadcast, checkpoint_if_compacted, complete_session_execution}, turn::events::{error_event, user_message_event}, }; @@ -26,6 +27,16 @@ enum SubmitBusyPolicy { RejectOnBusy, } +struct SubmitPromptRequest { + session_id: String, + turn_id: Option<TurnId>, + live_user_input: Option<String>, + queued_inputs: Vec<String>, + runtime: ResolvedRuntimeConfig, + busy_policy: SubmitBusyPolicy, + submission: AgentPromptSubmission, +} + struct TurnExecutionTask { kernel: Arc<astrcode_kernel::Kernel>, request: crate::turn::RunnerRequest, @@ -38,7 +49,13 @@ pub struct AgentPromptSubmission { pub capability_router: Option<CapabilityRouter>, pub prompt_declarations: Vec<PromptDeclaration>, pub resolved_limits: Option<ResolvedExecutionLimitsSnapshot>, + pub resolved_overrides: Option<ResolvedSubagentContextOverrides>, + pub injected_messages: Vec<LlmMessage>, pub source_tool_call_id: Option<String>, + pub policy_context: Option<PolicyContext>, + pub governance_revision: Option<String>, + pub approval: Option<Box<ApprovalPending<CapabilityCall>>>, + pub prompt_governance: Option<astrcode_core::PromptGovernanceContext>, } #[derive(Debug, Clone)] @@ -51,6 +68,7 @@ struct PersistedTurnContext { struct TurnFinalizeContext { kernel: Arc<astrcode_kernel::Kernel>, prompt_facts_provider: Arc<dyn astrcode_core::PromptFactsProvider>, + event_store: Arc<dyn EventStore>, metrics: Arc<dyn RuntimeMetricsRecorder>, actor: Arc<SessionActor>, session_id: String, @@ -88,6 +106,7 @@ async fn finalize_turn_execution( match result { Ok(turn_result) => { persist_turn_events( + &finalize.event_store, finalize.actor.state(), &finalize.session_id, &mut translator, @@ -122,25 +141,15 @@ async fn finalize_turn_execution( } complete_session_execution(finalize.actor.state(), terminal_phase); - if terminal_phase == Phase::Idle { - let pending_runtime = finalize - .actor - .state() - .take_pending_manual_compact() - .ok() - .flatten(); - if let Some(runtime) = pending_runtime { - persist_deferred_manual_compact( - finalize.kernel.gateway(), - finalize.prompt_facts_provider.as_ref(), - finalize.actor.working_dir(), - finalize.actor.state(), - &finalize.session_id, - &runtime, - ) - .await; - } - } + persist_pending_manual_compact_if_any( + finalize.kernel.gateway(), + finalize.prompt_facts_provider.as_ref(), + &finalize.event_store, + finalize.actor.working_dir(), + finalize.actor.state(), + &finalize.session_id, + ) + .await; } fn terminal_phase_for_result(result: &Result<crate::TurnRunResult>) -> Phase { @@ -154,20 +163,25 @@ fn terminal_phase_for_result(result: &Result<crate::TurnRunResult>) -> Phase { } async fn persist_turn_events( + event_store: &Arc<dyn EventStore>, session_state: &Arc<crate::SessionState>, session_id: &str, translator: &mut EventTranslator, turn_result: crate::TurnRunResult, persisted: &PersistedTurnContext, ) { + let mut persisted_events = Vec::<StoredEvent>::new(); for event in &turn_result.events { - if let Err(error) = append_and_broadcast(session_state, event, translator).await { - log::error!( - "failed to persist turn event for session '{}': {}", - session_id, - error - ); - break; + match append_and_broadcast(session_state, event, translator).await { + Ok(stored) => persisted_events.push(stored), + Err(error) => { + log::error!( + "failed to persist turn event for session '{}': {}", + session_id, + error + ); + break; + }, } } if let Some(event) = subrun_finished_event( @@ -184,6 +198,13 @@ async fn persist_turn_events( ); } } + checkpoint_if_compacted( + event_store, + &SessionId::from(session_id.to_string()), + session_state, + &persisted_events, + ) + .await; } async fn persist_turn_failure( @@ -207,23 +228,28 @@ async fn persist_turn_failure( async fn persist_deferred_manual_compact( gateway: &astrcode_kernel::KernelGateway, prompt_facts_provider: &dyn astrcode_core::PromptFactsProvider, + event_store: &Arc<dyn EventStore>, working_dir: &str, session_state: &Arc<crate::SessionState>, session_id: &str, - runtime: &ResolvedRuntimeConfig, + request: &crate::state::PendingManualCompactRequest, ) { - let events = match crate::turn::manual_compact::build_manual_compact_events( + session_state.set_compacting(true); + let built = crate::turn::manual_compact::build_manual_compact_events( crate::turn::manual_compact::ManualCompactRequest { gateway, prompt_facts_provider, session_state, session_id, working_dir: std::path::Path::new(working_dir), - runtime, + runtime: &request.runtime, + trigger: astrcode_core::CompactTrigger::Deferred, + instructions: request.instructions.as_deref(), }, ) - .await - { + .await; + session_state.set_compacting(false); + let events = match built { Ok(Some(events)) => events, Ok(None) => return, Err(error) => { @@ -237,18 +263,50 @@ async fn persist_deferred_manual_compact( }; let mut compact_translator = EventTranslator::new(session_state.current_phase().unwrap_or(Phase::Idle)); + let mut persisted = Vec::<StoredEvent>::with_capacity(events.len()); for event in &events { - if let Err(error) = - append_and_broadcast(session_state, event, &mut compact_translator).await - { - log::warn!( - "failed to persist deferred compact for session '{}': {}", - session_id, - error - ); - break; + match append_and_broadcast(session_state, event, &mut compact_translator).await { + Ok(stored) => persisted.push(stored), + Err(error) => { + log::warn!( + "failed to persist deferred compact for session '{}': {}", + session_id, + error + ); + break; + }, } } + checkpoint_if_compacted( + event_store, + &SessionId::from(session_id.to_string()), + session_state, + &persisted, + ) + .await; +} + +pub(crate) async fn persist_pending_manual_compact_if_any( + gateway: &astrcode_kernel::KernelGateway, + prompt_facts_provider: &dyn astrcode_core::PromptFactsProvider, + event_store: &Arc<dyn EventStore>, + working_dir: &str, + session_state: &Arc<crate::SessionState>, + session_id: &str, +) { + let pending_runtime = session_state.take_pending_manual_compact().ok().flatten(); + if let Some(request) = pending_runtime { + persist_deferred_manual_compact( + gateway, + prompt_facts_provider, + event_store, + working_dir, + session_state, + session_id, + &request, + ) + .await; + } } impl SessionRuntime { @@ -269,14 +327,15 @@ impl SessionRuntime { runtime: ResolvedRuntimeConfig, submission: AgentPromptSubmission, ) -> Result<ExecutionAccepted> { - self.submit_prompt_inner( - session_id, - None, - text, + self.submit_prompt_inner(SubmitPromptRequest { + session_id: session_id.to_string(), + turn_id: None, + live_user_input: Some(text), + queued_inputs: Vec::new(), runtime, - SubmitBusyPolicy::BranchOnBusy, + busy_policy: SubmitBusyPolicy::BranchOnBusy, submission, - ) + }) .await .and_then(|accepted| { accepted.ok_or_else(|| { @@ -295,14 +354,15 @@ impl SessionRuntime { runtime: ResolvedRuntimeConfig, submission: AgentPromptSubmission, ) -> Result<Option<ExecutionAccepted>> { - self.submit_prompt_inner( - session_id, - None, - text, + self.submit_prompt_inner(SubmitPromptRequest { + session_id: session_id.to_string(), + turn_id: None, + live_user_input: Some(text), + queued_inputs: Vec::new(), runtime, - SubmitBusyPolicy::RejectOnBusy, + busy_policy: SubmitBusyPolicy::RejectOnBusy, submission, - ) + }) .await } @@ -314,14 +374,15 @@ impl SessionRuntime { runtime: ResolvedRuntimeConfig, submission: AgentPromptSubmission, ) -> Result<Option<ExecutionAccepted>> { - self.submit_prompt_inner( - session_id, - Some(turn_id), - text, + self.submit_prompt_inner(SubmitPromptRequest { + session_id: session_id.to_string(), + turn_id: Some(turn_id), + live_user_input: Some(text), + queued_inputs: Vec::new(), runtime, - SubmitBusyPolicy::RejectOnBusy, + busy_policy: SubmitBusyPolicy::RejectOnBusy, submission, - ) + }) .await } @@ -332,14 +393,15 @@ impl SessionRuntime { runtime: ResolvedRuntimeConfig, submission: AgentPromptSubmission, ) -> Result<ExecutionAccepted> { - self.submit_prompt_inner( - session_id, - None, - text, + self.submit_prompt_inner(SubmitPromptRequest { + session_id: session_id.to_string(), + turn_id: None, + live_user_input: Some(text), + queued_inputs: Vec::new(), runtime, - SubmitBusyPolicy::BranchOnBusy, + busy_policy: SubmitBusyPolicy::BranchOnBusy, submission, - ) + }) .await? .ok_or_else(|| { astrcode_core::AstrError::Validation( @@ -348,25 +410,55 @@ impl SessionRuntime { }) } - async fn submit_prompt_inner( + pub async fn submit_queued_inputs_for_agent_with_turn_id( &self, session_id: &str, - turn_id: Option<TurnId>, - text: String, + turn_id: TurnId, + queued_inputs: Vec<String>, runtime: ResolvedRuntimeConfig, - busy_policy: SubmitBusyPolicy, submission: AgentPromptSubmission, ) -> Result<Option<ExecutionAccepted>> { - let text = text.trim().to_string(); - if text.is_empty() { + self.submit_prompt_inner(SubmitPromptRequest { + session_id: session_id.to_string(), + turn_id: Some(turn_id), + live_user_input: None, + queued_inputs, + runtime, + busy_policy: SubmitBusyPolicy::RejectOnBusy, + submission, + }) + .await + } + + async fn submit_prompt_inner( + &self, + request: SubmitPromptRequest, + ) -> Result<Option<ExecutionAccepted>> { + let SubmitPromptRequest { + session_id, + turn_id, + live_user_input, + queued_inputs, + runtime, + busy_policy, + submission, + } = request; + let live_user_input = live_user_input + .map(|text| text.trim().to_string()) + .filter(|text| !text.is_empty()); + let queued_inputs = queued_inputs + .into_iter() + .map(|content| content.trim().to_string()) + .filter(|content| !content.is_empty()) + .collect::<Vec<_>>(); + if live_user_input.is_none() && queued_inputs.is_empty() { return Err(astrcode_core::AstrError::Validation( - "prompt must not be empty".to_string(), + "turn submission must include live user input or queued inputs".to_string(), )); } - let requested_session_id = SessionId::from(crate::state::normalize_session_id(session_id)); - let turn_id = turn_id - .unwrap_or_else(|| TurnId::from(format!("turn-{}", Utc::now().timestamp_millis()))); + let requested_session_id = SessionId::from(crate::state::normalize_session_id(&session_id)); + let turn_id = turn_id.unwrap_or_else(|| TurnId::from(astrcode_core::generate_turn_id())); let cancel = CancelToken::new(); let submit_target = match busy_policy { SubmitBusyPolicy::BranchOnBusy => Some( @@ -389,25 +481,20 @@ impl SessionRuntime { return Ok(None); }; - let pending_reactivation_messages = submit_target - .actor - .state() - .pending_reactivation_messages()?; let AgentPromptSubmission { agent, capability_router, prompt_declarations, resolved_limits, + resolved_overrides, + injected_messages, source_tool_call_id, + policy_context: _, + governance_revision: _, + approval: _, + prompt_governance, } = submission; - let user_message = user_message_event( - turn_id.as_str(), - &agent, - text, - UserMessageOrigin::User, - Utc::now(), - ); prepare_session_execution( submit_target.actor.state(), submit_target.session_id.as_str(), @@ -424,19 +511,45 @@ impl SessionRuntime { Phase::Thinking; let mut translator = EventTranslator::new(submit_target.actor.state().current_phase()?); - append_and_broadcast(submit_target.actor.state(), &user_message, &mut translator).await?; + for content in &queued_inputs { + let queued_event = user_message_event( + turn_id.as_str(), + &agent, + content.clone(), + UserMessageOrigin::QueuedInput, + Utc::now(), + ); + append_and_broadcast(submit_target.actor.state(), &queued_event, &mut translator) + .await?; + } + if let Some(text) = &live_user_input { + let user_message = user_message_event( + turn_id.as_str(), + &agent, + text.clone(), + UserMessageOrigin::User, + Utc::now(), + ); + append_and_broadcast(submit_target.actor.state(), &user_message, &mut translator) + .await?; + } if let Some(event) = subrun_started_event( turn_id.as_str(), &agent, resolved_limits.clone(), + resolved_overrides.clone(), source_tool_call_id.clone(), ) { append_and_broadcast(submit_target.actor.state(), &event, &mut translator).await?; } let mut messages = current_turn_messages(submit_target.actor.state())?; - if !pending_reactivation_messages.is_empty() { - let insert_at = messages.len().saturating_sub(1); - messages.splice(insert_at..insert_at, pending_reactivation_messages); + if !injected_messages.is_empty() { + let insert_at = if live_user_input.is_some() { + messages.len().saturating_sub(1) + } else { + messages.len() + }; + messages.splice(insert_at..insert_at, injected_messages); } tokio::spawn(execute_turn_and_finalize(TurnExecutionTask { @@ -458,12 +571,12 @@ impl SessionRuntime { prompt_facts_provider: Arc::clone(&self.prompt_facts_provider), capability_router, prompt_declarations, - resolved_limits: resolved_limits.clone(), - source_tool_call_id: source_tool_call_id.clone(), + prompt_governance, }, finalize: TurnFinalizeContext { kernel: Arc::clone(&self.kernel), prompt_facts_provider: Arc::clone(&self.prompt_facts_provider), + event_store: Arc::clone(&self.event_store), metrics: Arc::clone(&self.metrics), actor: Arc::clone(&submit_target.actor), session_id: submit_target.session_id.to_string(), @@ -489,6 +602,7 @@ fn subrun_started_event( turn_id: &str, agent: &AgentEventContext, resolved_limits: Option<ResolvedExecutionLimitsSnapshot>, + resolved_overrides: Option<ResolvedSubagentContextOverrides>, source_tool_call_id: Option<String>, ) -> Option<StorageEvent> { if agent.invocation_kind != Some(astrcode_core::InvocationKind::SubRun) { @@ -500,7 +614,7 @@ fn subrun_started_event( agent: agent.clone(), payload: StorageEventPayload::SubRunStarted { tool_call_id: source_tool_call_id, - resolved_overrides: ResolvedSubagentContextOverrides::default(), + resolved_overrides: resolved_overrides.unwrap_or_default(), resolved_limits: resolved_limits.unwrap_or_default(), timestamp: Some(Utc::now()), }, @@ -534,15 +648,14 @@ fn subrun_finished_event( }); let result = match &turn_result.outcome { - crate::TurnOutcome::Completed => astrcode_core::SubRunResult { - lifecycle: astrcode_core::AgentLifecycleStatus::Idle, - last_turn_outcome: Some(astrcode_core::AgentTurnOutcome::Completed), - handoff: Some(astrcode_core::SubRunHandoff { + crate::TurnOutcome::Completed => astrcode_core::SubRunResult::Completed { + outcome: astrcode_core::CompletedSubRunOutcome::Completed, + handoff: astrcode_core::SubRunHandoff { findings: Vec::new(), artifacts: Vec::new(), delivery: Some(ParentDelivery { idempotency_key: format!( - "legacy-subrun-finished:{}:{}", + "subrun-finished:{}:{}", agent.sub_run_id.as_deref().unwrap_or("unknown-subrun"), turn_id ), @@ -555,30 +668,25 @@ fn subrun_finished_event( artifacts: Vec::new(), }), }), - }), - failure: None, + }, }, - crate::TurnOutcome::Cancelled => astrcode_core::SubRunResult { - lifecycle: astrcode_core::AgentLifecycleStatus::Idle, - last_turn_outcome: Some(astrcode_core::AgentTurnOutcome::Cancelled), - handoff: None, - failure: Some(astrcode_core::SubRunFailure { + crate::TurnOutcome::Cancelled => astrcode_core::SubRunResult::Failed { + outcome: astrcode_core::FailedSubRunOutcome::Cancelled, + failure: astrcode_core::SubRunFailure { code: astrcode_core::SubRunFailureCode::Interrupted, display_message: summary, technical_message: "interrupted".to_string(), retryable: false, - }), + }, }, - crate::TurnOutcome::Error { message } => astrcode_core::SubRunResult { - lifecycle: astrcode_core::AgentLifecycleStatus::Idle, - last_turn_outcome: Some(astrcode_core::AgentTurnOutcome::Failed), - handoff: None, - failure: Some(astrcode_core::SubRunFailure { + crate::TurnOutcome::Error { message } => astrcode_core::SubRunResult::Failed { + outcome: astrcode_core::FailedSubRunOutcome::Failed, + failure: astrcode_core::SubRunFailure { code: astrcode_core::SubRunFailureCode::Internal, display_message: summary, technical_message: message.clone(), retryable: true, - }), + }, }, }; @@ -617,8 +725,8 @@ mod tests { TurnLoopTransition, TurnStopCause, test_support::{ BranchingTestEventStore, NoopMetrics, append_root_turn_event_to_actor, - assert_contains_compact_summary, assert_contains_error_message, - root_compact_applied_event, test_actor, test_runtime, + assert_contains_compact_summary, assert_contains_error_message, test_actor, + test_runtime, }, }, }; @@ -660,6 +768,8 @@ mod tests { Ok(PromptBuildOutput { system_prompt: "noop".to_string(), system_prompt_blocks: Vec::new(), + prompt_cache_hints: Default::default(), + cache_metrics: Default::default(), metadata: serde_json::Value::Null, }) } @@ -699,6 +809,7 @@ mod tests { TurnFinalizeContext { kernel: summary_kernel(), prompt_facts_provider: Arc::new(crate::turn::test_support::NoopPromptFactsProvider), + event_store: Arc::new(BranchingTestEventStore::default()), metrics: Arc::new(NoopMetrics), actor, session_id: "session-1".to_string(), @@ -815,7 +926,10 @@ mod tests { .await; actor .state() - .request_manual_compact(ResolvedRuntimeConfig::default()) + .request_manual_compact(crate::state::PendingManualCompactRequest { + runtime: ResolvedRuntimeConfig::default(), + instructions: None, + }) .expect("manual compact flag should set"); finalize_turn_execution( @@ -838,10 +952,53 @@ mod tests { assert_contains_compact_summary(&stored, "manual compact summary"); } + #[tokio::test] + async fn finalize_turn_execution_persists_deferred_manual_compact_after_interrupt() { + let actor = test_actor().await; + append_root_turn_event_to_actor( + &actor, + crate::turn::test_support::root_user_message_event("turn-1", "hello"), + ) + .await; + append_root_turn_event_to_actor( + &actor, + crate::turn::test_support::root_assistant_final_event("turn-1", "latest answer"), + ) + .await; + actor + .state() + .request_manual_compact(crate::state::PendingManualCompactRequest { + runtime: ResolvedRuntimeConfig::default(), + instructions: None, + }) + .expect("manual compact flag should set"); + + finalize_turn_execution( + finalize_context(Arc::clone(&actor)), + Err(astrcode_core::AstrError::Internal("boom".to_string())), + ) + .await; + + assert_eq!( + actor + .state() + .current_phase() + .expect("phase should be readable"), + Phase::Interrupted + ); + let stored = actor + .state() + .snapshot_recent_stored_events() + .expect("stored events should be available"); + assert_contains_error_message(&stored, "internal error: boom"); + assert_contains_compact_summary(&stored, "manual compact summary"); + } + #[test] fn subrun_lifecycle_events_ignore_non_subrun_context() { assert!( - subrun_started_event("turn-1", &AgentEventContext::default(), None, None).is_none() + subrun_started_event("turn-1", &AgentEventContext::default(), None, None, None) + .is_none() ); assert!( subrun_finished_event( @@ -865,14 +1022,15 @@ mod tests { event_store.push_busy("turn-busy"); let result = runtime - .submit_prompt_inner( - &session.session_id, - None, - "hello".to_string(), - ResolvedRuntimeConfig::default(), - SubmitBusyPolicy::RejectOnBusy, - AgentPromptSubmission::default(), - ) + .submit_prompt_inner(SubmitPromptRequest { + session_id: session.session_id.clone(), + turn_id: None, + live_user_input: Some("hello".to_string()), + queued_inputs: Vec::new(), + runtime: ResolvedRuntimeConfig::default(), + busy_policy: SubmitBusyPolicy::RejectOnBusy, + submission: AgentPromptSubmission::default(), + }) .await .expect("submit should not error"); @@ -894,17 +1052,18 @@ mod tests { event_store.push_busy("turn-busy"); let accepted = runtime - .submit_prompt_inner( - &session.session_id, - None, - "hello".to_string(), - ResolvedRuntimeConfig { + .submit_prompt_inner(SubmitPromptRequest { + session_id: session.session_id.clone(), + turn_id: None, + live_user_input: Some("hello".to_string()), + queued_inputs: Vec::new(), + runtime: ResolvedRuntimeConfig { max_concurrent_branch_depth: 2, ..ResolvedRuntimeConfig::default() }, - SubmitBusyPolicy::BranchOnBusy, - AgentPromptSubmission::default(), - ) + busy_policy: SubmitBusyPolicy::BranchOnBusy, + submission: AgentPromptSubmission::default(), + }) .await .expect("submit should not error") .expect("branch-on-busy should always accept"); @@ -948,7 +1107,7 @@ mod tests { } #[tokio::test] - async fn submit_prompt_inner_injects_pending_reactivation_only_once() { + async fn submit_prompt_inner_appends_queued_inputs_before_live_user_prompt() { let requests = Arc::new(Mutex::new(Vec::<Vec<LlmMessage>>::new())); let kernel = Arc::new( Kernel::builder() @@ -972,56 +1131,20 @@ mod tests { .create_session(".") .await .expect("test session should be created"); - let session_state = runtime - .get_session_state(&SessionId::from(session.session_id.clone())) - .await - .expect("session state should load"); - let mut translator = EventTranslator::new(session_state.current_phase().expect("phase")); - - append_and_broadcast( - &session_state, - &crate::turn::test_support::root_user_message_event("turn-0", "older question"), - &mut translator, - ) - .await - .expect("older user event should append"); - append_and_broadcast( - &session_state, - &crate::turn::test_support::root_assistant_final_event("turn-0", "older answer"), - &mut translator, - ) - .await - .expect("older assistant event should append"); - append_and_broadcast( - &session_state, - &root_compact_applied_event("turn-compact", "history summary", 1, 100, 40, 2, 60), - &mut translator, - ) - .await - .expect("compact event should append"); - append_and_broadcast( - &session_state, - &crate::turn::events::user_message_event( - "turn-compact", - &AgentEventContext::default(), - "Recovered file context".to_string(), - UserMessageOrigin::ReactivationPrompt, - Utc::now(), - ), - &mut translator, - ) - .await - .expect("reactivation event should append"); let accepted = runtime - .submit_prompt_inner( - &session.session_id, - None, - "first after compact".to_string(), - ResolvedRuntimeConfig::default(), - SubmitBusyPolicy::RejectOnBusy, - AgentPromptSubmission::default(), - ) + .submit_prompt_inner(SubmitPromptRequest { + session_id: session.session_id.clone(), + turn_id: None, + live_user_input: Some("live user input".to_string()), + queued_inputs: vec![ + "queued child result".to_string(), + "queued reactivation context".to_string(), + ], + runtime: ResolvedRuntimeConfig::default(), + busy_policy: SubmitBusyPolicy::RejectOnBusy, + submission: AgentPromptSubmission::default(), + }) .await .expect("submit should not error") .expect("submit should be accepted"); @@ -1031,45 +1154,140 @@ mod tests { accepted.turn_id.as_str(), ) .await - .expect("first turn should finish"); + .expect("turn should finish"); - let second = runtime - .submit_prompt_inner( - &session.session_id, - None, - "second turn".to_string(), - ResolvedRuntimeConfig::default(), - SubmitBusyPolicy::RejectOnBusy, - AgentPromptSubmission::default(), - ) + let requests = requests.lock().expect("recorded requests lock should work"); + assert_eq!(requests.len(), 1, "expected one model request"); + + assert!(matches!( + requests[0].as_slice(), + [ + LlmMessage::User { + content: first_queued, + origin: UserMessageOrigin::QueuedInput, + }, + LlmMessage::User { + content: second_queued, + origin: UserMessageOrigin::QueuedInput, + }, + LlmMessage::User { + content: user_content, + origin: UserMessageOrigin::User, + } + ] if first_queued == "queued child result" + && second_queued == "queued reactivation context" + && user_content == "live user input" + )); + } + + #[tokio::test] + async fn submit_prompt_inner_inserts_injected_messages_before_live_user_prompt() { + let requests = Arc::new(Mutex::new(Vec::<Vec<LlmMessage>>::new())); + let kernel = Arc::new( + Kernel::builder() + .with_capabilities(astrcode_kernel::CapabilityRouter::empty()) + .with_llm_provider(Arc::new(RecordingLlmProvider { + requests: Arc::clone(&requests), + })) + .with_prompt_provider(Arc::new(TestPromptProvider)) + .with_resource_provider(Arc::new(TestResourceProvider)) + .build() + .expect("kernel should build"), + ); + let event_store = Arc::new(BranchingTestEventStore::default()); + let runtime = SessionRuntime::new( + kernel, + Arc::new(crate::turn::test_support::NoopPromptFactsProvider), + event_store, + Arc::new(NoopMetrics), + ); + let session = runtime + .create_session(".") + .await + .expect("test session should be created"); + + let accepted = runtime + .submit_prompt_inner(SubmitPromptRequest { + session_id: session.session_id.clone(), + turn_id: None, + live_user_input: Some("child task".to_string()), + queued_inputs: Vec::new(), + runtime: ResolvedRuntimeConfig::default(), + busy_policy: SubmitBusyPolicy::RejectOnBusy, + submission: AgentPromptSubmission { + injected_messages: vec![ + LlmMessage::User { + content: "parent turn".to_string(), + origin: UserMessageOrigin::User, + }, + LlmMessage::Assistant { + content: "parent answer".to_string(), + tool_calls: Vec::new(), + reasoning: None, + }, + ], + ..AgentPromptSubmission::default() + }, + }) .await - .expect("second submit should not error") - .expect("second submit should be accepted"); + .expect("submit should not error") + .expect("submit should be accepted"); runtime - .wait_for_turn_terminal_snapshot(second.session_id.as_str(), second.turn_id.as_str()) + .wait_for_turn_terminal_snapshot( + accepted.session_id.as_str(), + accepted.turn_id.as_str(), + ) .await - .expect("second turn should finish"); + .expect("turn should finish"); let requests = requests.lock().expect("recorded requests lock should work"); - assert_eq!(requests.len(), 2, "expected two model requests"); - assert!(matches!( requests[0].as_slice(), [ - LlmMessage::User { origin: UserMessageOrigin::CompactSummary, .. }, - LlmMessage::User { origin: UserMessageOrigin::ReactivationPrompt, content }, - LlmMessage::User { origin: UserMessageOrigin::User, content: user_content }, - ] if content == "Recovered file context" && user_content == "first after compact" - )); - assert!( - requests[1].iter().all(|message| !matches!( - message, LlmMessage::User { - origin: UserMessageOrigin::ReactivationPrompt, - .. - } - )), - "reactivation prompt should only be injected into the first post-compact turn" - ); + content: inherited_user, + origin: UserMessageOrigin::User, + }, + LlmMessage::Assistant { content: inherited_answer, .. }, + LlmMessage::User { + content: child_task, + origin: UserMessageOrigin::User, + }, + ] if inherited_user == "parent turn" + && inherited_answer == "parent answer" + && child_task == "child task" + )); + } + + #[test] + fn subrun_started_event_persists_resolved_overrides_snapshot() { + let event = subrun_started_event( + "turn-1", + &AgentEventContext::sub_run( + "agent-child", + "turn-parent", + "explore", + "subrun-1", + None, + astrcode_core::SubRunStorageMode::IndependentSession, + Some("session-child".into()), + ), + None, + Some(ResolvedSubagentContextOverrides { + include_compact_summary: true, + fork_mode: Some(astrcode_core::ForkMode::LastNTurns(3)), + ..ResolvedSubagentContextOverrides::default() + }), + None, + ) + .expect("subrun event should be built"); + + assert!(matches!( + event.payload, + StorageEventPayload::SubRunStarted { resolved_overrides, .. } + if resolved_overrides.include_compact_summary + && resolved_overrides.fork_mode + == Some(astrcode_core::ForkMode::LastNTurns(3)) + )); } } diff --git a/crates/session-runtime/src/turn/summary.rs b/crates/session-runtime/src/turn/summary.rs index eb02dc3b..6c311b81 100644 --- a/crates/session-runtime/src/turn/summary.rs +++ b/crates/session-runtime/src/turn/summary.rs @@ -172,7 +172,7 @@ impl TurnSummary { mod tests { use astrcode_core::{ AgentCollaborationActionKind, AgentCollaborationFact, AgentCollaborationOutcomeKind, - AgentCollaborationPolicyContext, + AgentCollaborationPolicyContext, ChildExecutionIdentity, }; use super::TurnCollaborationSummary; @@ -184,22 +184,26 @@ mod tests { latency_ms: Option<u64>, ) -> AgentCollaborationFact { AgentCollaborationFact { - fact_id: id.to_string(), + fact_id: id.to_string().into(), action, outcome, - parent_session_id: "session-parent".to_string(), - turn_id: "turn-1".to_string(), - parent_agent_id: Some("agent-root".to_string()), - child_agent_id: Some("agent-child".to_string()), - child_session_id: Some("session-child".to_string()), - child_sub_run_id: Some("subrun-child".to_string()), + parent_session_id: "session-parent".to_string().into(), + turn_id: "turn-1".to_string().into(), + parent_agent_id: Some("agent-root".to_string().into()), + child_identity: Some(ChildExecutionIdentity { + agent_id: "agent-child".to_string().into(), + session_id: "session-child".to_string().into(), + sub_run_id: "subrun-child".to_string().into(), + }), delivery_id: None, reason_code: None, summary: None, latency_ms, source_tool_call_id: None, + governance_revision: Some("governance-surface-v1".to_string()), + mode_id: Some(astrcode_core::ModeId::code()), policy: AgentCollaborationPolicyContext { - policy_revision: "agent-collaboration-v1".to_string(), + policy_revision: "governance-surface-v1".to_string(), max_subrun_depth: 3, max_spawn_per_turn: 3, }, diff --git a/crates/session-runtime/src/turn/test_support.rs b/crates/session-runtime/src/turn/test_support.rs index 32ee1b58..725eb123 100644 --- a/crates/session-runtime/src/turn/test_support.rs +++ b/crates/session-runtime/src/turn/test_support.rs @@ -10,23 +10,24 @@ use std::{ }; use astrcode_core::{ - AgentCollaborationFact, AgentId, AgentStateProjector, AstrError, EventLogWriter, EventStore, - EventTranslator, LlmOutput, LlmProvider, LlmRequest, ModelLimits, Phase, PromptBuildOutput, - PromptBuildRequest, PromptFacts, PromptFactsProvider, PromptFactsRequest, PromptProvider, - ResourceProvider, ResourceReadResult, ResourceRequestContext, Result, RuntimeMetricsRecorder, - SessionMeta, SessionTurnAcquireResult, StorageEvent, StorageEventPayload, StoreResult, - StoredEvent, SubRunExecutionOutcome, Tool, + AgentCollaborationFact, AgentId, AgentStateProjector, AstrError, CompactAppliedMeta, + CompactMode, EventLogWriter, EventStore, EventTranslator, LlmOutput, LlmProvider, LlmRequest, + ModelLimits, Phase, PromptBuildOutput, PromptBuildRequest, PromptFacts, PromptFactsProvider, + PromptFactsRequest, PromptProvider, ResourceProvider, ResourceReadResult, + ResourceRequestContext, Result, RuntimeMetricsRecorder, SessionMeta, SessionTurnAcquireResult, + StorageEvent, StorageEventPayload, StoreResult, StoredEvent, Tool, }; use astrcode_kernel::{Kernel, KernelGateway, ToolCapabilityInvoker}; use async_trait::async_trait; use serde_json::Value; use crate::{ - SessionRuntime, SessionState, SessionWriter, + SessionRuntime, SessionState, actor::SessionActor, - state::append_and_broadcast, + state::{SessionWriter, append_and_broadcast}, turn::events::{ - CompactAppliedStats, assistant_final_event, compact_applied_event, user_message_event, + CompactAppliedStats, assistant_final_event, compact_applied_event, turn_done_event, + user_message_event, }, }; @@ -61,6 +62,8 @@ impl PromptProvider for NoopPromptProvider { Ok(PromptBuildOutput { system_prompt: "noop".to_string(), system_prompt_blocks: Vec::new(), + prompt_cache_hints: Default::default(), + cache_metrics: Default::default(), metadata: Value::Null, }) } @@ -262,7 +265,7 @@ impl RuntimeMetricsRecorder for NoopMetrics { fn record_subrun_execution( &self, _duration_ms: u64, - _outcome: SubRunExecutionOutcome, + _outcome: astrcode_core::AgentTurnOutcome, _step_count: Option<u32>, _estimated_tokens: Option<u64>, _storage_mode: Option<astrcode_core::SubRunStorageMode>, @@ -328,6 +331,15 @@ pub(crate) fn root_assistant_final_event( ) } +pub(crate) fn root_turn_done_event(turn_id: &str, reason: Option<String>) -> StorageEvent { + turn_done_event( + turn_id, + &astrcode_core::AgentEventContext::default(), + reason, + chrono::Utc::now(), + ) +} + pub(crate) fn root_compact_applied_event( turn_id: &str, summary: impl Into<String>, @@ -343,6 +355,14 @@ pub(crate) fn root_compact_applied_event( astrcode_core::CompactTrigger::Auto, summary.into(), CompactAppliedStats { + meta: CompactAppliedMeta { + mode: CompactMode::Full, + instructions_present: false, + fallback_used: false, + retry_count: 0, + input_units: 0, + output_summary_chars: 0, + }, preserved_recent_turns, pre_tokens, post_tokens_estimate, @@ -367,7 +387,7 @@ pub(crate) fn assert_contains_compact_summary(events: &[StoredEvent], expected_s assert!( events.iter().any(|stored| matches!( &stored.event.payload, - StorageEventPayload::CompactApplied { summary, .. } if summary == expected_summary + StorageEventPayload::CompactApplied { summary, .. } if summary.contains(expected_summary) )), "expected stored events to contain CompactApplied('{expected_summary}')" ); @@ -382,15 +402,6 @@ pub(crate) fn assert_has_turn_done(events: &[StorageEvent]) { ); } -pub(crate) fn assert_has_assistant_final(events: &[StorageEvent]) { - assert!( - events - .iter() - .any(|event| matches!(&event.payload, StorageEventPayload::AssistantFinal { .. })), - "expected events to contain AssistantFinal" - ); -} - pub(crate) async fn append_root_turn_event_to_actor( actor: &Arc<SessionActor>, event: StorageEvent, @@ -432,6 +443,58 @@ impl BranchingTestEventStore { .cloned() .unwrap_or_default() } + + pub(crate) fn seed_session( + &self, + session_id: &str, + working_dir: &str, + events: Vec<StoredEvent>, + ) { + let max_seq = events + .iter() + .map(|stored| stored.storage_seq) + .max() + .unwrap_or(0); + self.next_seq + .fetch_max(max_seq, std::sync::atomic::Ordering::SeqCst); + self.events + .lock() + .expect("events lock should work") + .insert(session_id.to_string(), events.clone()); + + let now = chrono::Utc::now(); + let mut meta = SessionMeta { + session_id: session_id.to_string(), + working_dir: working_dir.to_string(), + display_name: crate::display_name_from_working_dir(Path::new(working_dir)), + title: "New Session".to_string(), + created_at: now, + updated_at: now, + parent_session_id: None, + parent_storage_seq: None, + phase: Phase::Idle, + }; + if let Some(stored) = events.iter().find(|stored| { + matches!( + stored.event.payload, + StorageEventPayload::SessionStart { .. } + ) + }) { + if let StorageEventPayload::SessionStart { + parent_session_id, + parent_storage_seq, + .. + } = &stored.event.payload + { + meta.parent_session_id = parent_session_id.clone(); + meta.parent_storage_seq = *parent_storage_seq; + } + } + self.metas + .lock() + .expect("metas lock should work") + .insert(session_id.to_string(), meta); + } } #[async_trait] diff --git a/crates/session-runtime/src/turn/tool_cycle.rs b/crates/session-runtime/src/turn/tool_cycle.rs index 72bfbf57..c38a0885 100644 --- a/crates/session-runtime/src/turn/tool_cycle.rs +++ b/crates/session-runtime/src/turn/tool_cycle.rs @@ -40,7 +40,7 @@ use crate::{ /// 工具执行周期的最终结果。 #[derive(Debug, Clone, PartialEq, Eq)] -pub enum ToolCycleOutcome { +pub(crate) enum ToolCycleOutcome { /// 所有工具调用均已完成。 Completed, /// 工具执行被取消。 @@ -48,7 +48,7 @@ pub enum ToolCycleOutcome { } /// 工具执行周期的完整结果。 -pub struct ToolCycleResult { +pub(crate) struct ToolCycleResult { pub outcome: ToolCycleOutcome, /// 工具结果消息,需要追加到对话历史。 pub tool_messages: Vec<LlmMessage>, @@ -59,13 +59,13 @@ pub struct ToolCycleResult { } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ToolEventEmissionMode { +pub(crate) enum ToolEventEmissionMode { Immediate, Buffered, } /// 工具执行周期的上下文参数,避免函数参数过多。 -pub struct ToolCycleContext<'a> { +pub(crate) struct ToolCycleContext<'a> { pub gateway: &'a KernelGateway, pub session_state: &'a Arc<SessionState>, pub session_id: &'a str, @@ -92,7 +92,7 @@ struct SingleToolInvocation<'a> { event_emission_mode: ToolEventEmissionMode, } -pub struct BufferedToolExecutionRequest { +pub(crate) struct BufferedToolExecutionRequest { pub gateway: KernelGateway, pub session_state: Arc<SessionState>, pub tool_call: ToolCallRequest, @@ -104,8 +104,7 @@ pub struct BufferedToolExecutionRequest { pub tool_result_inline_limit: usize, } -pub struct BufferedToolExecution { - pub tool_call: ToolCallRequest, +pub(crate) struct BufferedToolExecution { pub result: ToolExecutionResult, pub events: Vec<StorageEvent>, pub started_at: Instant, @@ -346,7 +345,6 @@ pub async fn execute_buffered_tool_call( .await; let finished_at = Instant::now(); BufferedToolExecution { - tool_call, result, events, started_at, @@ -397,6 +395,7 @@ async fn invoke_single_tool( "tool start", ) .await; + broadcast_tool_start(&session_state, turn_id, agent, tool_call); // 构建工具上下文 let tool_ctx = ToolContext::new( @@ -416,6 +415,13 @@ async fn invoke_single_tool( tool_result_inline_limit, )) .with_tool_output_sender(tool_output_tx.clone()); + let tool_ctx = match session_state.current_mode_id() { + Ok(current_mode_id) => tool_ctx.with_current_mode_id(current_mode_id), + Err(error) => { + log::warn!("failed to read current mode before tool execution: {error}"); + tool_ctx + }, + }; let tool_ctx = if let Some(sink) = &event_sink { tool_ctx.with_event_sink(Arc::clone(sink)) } else { @@ -447,6 +453,15 @@ async fn invoke_single_tool( "tool result", ) .await; + broadcast_tool_result( + &session_state, + turn_id, + agent, + &ToolExecutionResult { + duration_ms, + ..result.clone() + }, + ); let mut events = buffered_events .lock() @@ -472,6 +487,34 @@ fn broadcast_tool_output_delta( }); } +fn broadcast_tool_start( + session_state: &SessionState, + turn_id: &str, + agent: &AgentEventContext, + tool_call: &ToolCallRequest, +) { + session_state.broadcast_live_event(astrcode_core::AgentEvent::ToolCallStart { + turn_id: turn_id.to_string(), + agent: agent.clone(), + tool_call_id: tool_call.id.clone(), + tool_name: tool_call.name.clone(), + input: tool_call.args.clone(), + }); +} + +fn broadcast_tool_result( + session_state: &SessionState, + turn_id: &str, + agent: &AgentEventContext, + result: &ToolExecutionResult, +) { + session_state.broadcast_live_event(astrcode_core::AgentEvent::ToolCallResult { + turn_id: turn_id.to_string(), + agent: agent.clone(), + result: result.clone(), + }); +} + async fn emit_or_buffer_tool_event( event_sink: &Option<Arc<dyn astrcode_core::ToolEventSink>>, events: &mut Vec<StorageEvent>, @@ -563,7 +606,11 @@ mod tests { .expect("observed lock should work") .push(ObservedToolContext { turn_id: ctx.turn_id().map(ToString::to_string), - agent_id: ctx.agent_context().agent_id.clone(), + agent_id: ctx + .agent_context() + .agent_id + .clone() + .map(|id| id.to_string()), agent_profile: ctx.agent_context().agent_profile.clone(), }); Ok(ToolExecutionResult { @@ -573,6 +620,7 @@ mod tests { output: "ok".to_string(), error: None, metadata: None, + child_ref: None, duration_ms: 0, truncated: false, }) @@ -637,6 +685,76 @@ mod tests { output: "done".to_string(), error: None, metadata: None, + child_ref: None, + duration_ms: 0, + truncated: false, + }) + } + } + + #[derive(Debug)] + struct StreamingStderrProbeTool; + + #[async_trait] + impl Tool for StreamingStderrProbeTool { + fn definition(&self) -> ToolDefinition { + ToolDefinition { + name: "streaming_stderr_probe".to_string(), + description: "emits stderr probe events".to_string(), + parameters: json!({"type": "object"}), + } + } + + fn capability_spec( + &self, + ) -> std::result::Result< + astrcode_core::CapabilitySpec, + astrcode_core::CapabilitySpecBuildError, + > { + astrcode_core::CapabilitySpec::builder("streaming_stderr_probe", CapabilityKind::Tool) + .description("emits stderr probe events") + .schema(json!({"type": "object"}), json!({"type": "string"})) + .build() + } + + async fn execute( + &self, + tool_call_id: String, + _input: Value, + ctx: &ToolContext, + ) -> Result<ToolExecutionResult> { + let turn_id = ctx + .turn_id() + .expect("stderr probe should receive turn id") + .to_string(); + let sink = ctx + .event_sink() + .expect("stderr probe should receive tool event sink"); + sink.emit(crate::turn::events::tool_call_delta_event( + &turn_id, + ctx.agent_context(), + tool_call_id.clone(), + "streaming_stderr_probe".to_string(), + ToolOutputStream::Stderr, + "durable-stderr".to_string(), + )) + .await?; + assert!( + ctx.emit_stderr( + tool_call_id.clone(), + "streaming_stderr_probe", + "live-stderr" + ), + "stderr probe should be able to emit live stderr" + ); + Ok(ToolExecutionResult { + tool_call_id, + tool_name: "streaming_stderr_probe".to_string(), + ok: false, + output: String::new(), + error: Some("stderr failure".to_string()), + metadata: None, + child_ref: None, duration_ms: 0, truncated: false, }) @@ -763,13 +881,35 @@ mod tests { "tool result should be durably recorded immediately" ); - let live_event = timeout(Duration::from_secs(1), live_receiver.recv()) + let live_start = timeout(Duration::from_secs(1), live_receiver.recv()) + .await + .expect("live receiver should get tool start in time") + .expect("live receiver should stay open"); + let live_delta = timeout(Duration::from_secs(1), live_receiver.recv()) .await .expect("live receiver should get stdout delta in time") .expect("live receiver should stay open"); + let live_result = timeout(Duration::from_secs(1), live_receiver.recv()) + .await + .expect("live receiver should get tool result in time") + .expect("live receiver should stay open"); assert!( matches!( - live_event, + live_start, + astrcode_core::AgentEvent::ToolCallStart { + turn_id, + tool_call_id, + tool_name, + .. + } if turn_id == "turn-live" + && tool_call_id == "call-live" + && tool_name == "streaming_probe" + ), + "tool start should go through the live channel immediately" + ); + assert!( + matches!( + live_delta, astrcode_core::AgentEvent::ToolCallDelta { turn_id, tool_call_id, @@ -784,6 +924,17 @@ mod tests { ), "stdout delta should go through the live channel immediately" ); + assert!( + matches!( + live_result, + astrcode_core::AgentEvent::ToolCallResult { turn_id, result, .. } + if turn_id == "turn-live" + && result.tool_call_id == "call-live" + && result.tool_name == "streaming_probe" + && result.output == "done" + ), + "tool result should go through the live channel immediately" + ); } #[tokio::test] @@ -847,13 +998,35 @@ mod tests { "buffered mode should not immediately append durable events" ); - let live_event = timeout(Duration::from_secs(1), live_receiver.recv()) + let live_start = timeout(Duration::from_secs(1), live_receiver.recv()) + .await + .expect("live receiver should get tool start in time") + .expect("live receiver should stay open"); + let live_delta = timeout(Duration::from_secs(1), live_receiver.recv()) .await .expect("live receiver should get stdout delta in time") .expect("live receiver should stay open"); + let live_result = timeout(Duration::from_secs(1), live_receiver.recv()) + .await + .expect("live receiver should get tool result in time") + .expect("live receiver should stay open"); + assert!( + matches!( + live_start, + astrcode_core::AgentEvent::ToolCallStart { + turn_id, + tool_call_id, + tool_name, + .. + } if turn_id == "turn-buffered" + && tool_call_id == "call-buffered" + && tool_name == "streaming_probe" + ), + "buffered mode should still broadcast tool start live" + ); assert!( matches!( - live_event, + live_delta, astrcode_core::AgentEvent::ToolCallDelta { turn_id, tool_call_id, @@ -868,5 +1041,119 @@ mod tests { ), "buffered mode should keep live stdout forwarding" ); + assert!( + matches!( + live_result, + astrcode_core::AgentEvent::ToolCallResult { turn_id, result, .. } + if turn_id == "turn-buffered" + && result.tool_call_id == "call-buffered" + && result.tool_name == "streaming_probe" + && result.output == "done" + ), + "buffered mode should still broadcast tool result live" + ); + } + + #[tokio::test] + async fn invoke_single_tool_forwards_stderr_to_durable_and_live_channels() { + let kernel = test_kernel_with_tool(Arc::new(StreamingStderrProbeTool), 8192); + let tool_call = ToolCallRequest { + id: "call-stderr".to_string(), + name: "streaming_stderr_probe".to_string(), + args: json!({}), + }; + let agent = AgentEventContext::root_execution("root-agent:session-1", "default"); + let session_state = test_session_state(); + let mut live_receiver = session_state.subscribe_live(); + + let cancel = CancelToken::new(); + let (result, fallback_events) = invoke_single_tool(SingleToolInvocation { + gateway: kernel.gateway(), + session_state: Arc::clone(&session_state), + tool_call: &tool_call, + session_id: "session-1", + working_dir: ".", + turn_id: "turn-stderr", + agent: &agent, + cancel: &cancel, + tool_result_inline_limit: 32 * 1024, + event_emission_mode: ToolEventEmissionMode::Immediate, + }) + .await; + + assert!( + !result.ok && result.error.as_deref() == Some("stderr failure"), + "stderr probe should surface failure result: {result:?}" + ); + assert!( + fallback_events.is_empty(), + "immediate stderr event emission should avoid fallback buffering: {fallback_events:?}" + ); + + let stored = session_state + .snapshot_recent_stored_events() + .expect("snapshot recent stored events should work"); + assert!( + stored.iter().any(|event| matches!( + &event.event.payload, + StorageEventPayload::ToolCallDelta { + tool_call_id, + tool_name, + stream: ToolOutputStream::Stderr, + delta, + .. + } if tool_call_id == "call-stderr" + && tool_name == "streaming_stderr_probe" + && delta == "durable-stderr" + )), + "stderr durable delta should be recorded immediately" + ); + + let live_start = timeout(Duration::from_secs(1), live_receiver.recv()) + .await + .expect("live receiver should get tool start in time") + .expect("live receiver should stay open"); + let live_delta = timeout(Duration::from_secs(1), live_receiver.recv()) + .await + .expect("live receiver should get stderr delta in time") + .expect("live receiver should stay open"); + let live_result = timeout(Duration::from_secs(1), live_receiver.recv()) + .await + .expect("live receiver should get tool result in time") + .expect("live receiver should stay open"); + + assert!(matches!( + live_start, + astrcode_core::AgentEvent::ToolCallStart { + turn_id, + tool_call_id, + tool_name, + .. + } if turn_id == "turn-stderr" + && tool_call_id == "call-stderr" + && tool_name == "streaming_stderr_probe" + )); + assert!(matches!( + live_delta, + astrcode_core::AgentEvent::ToolCallDelta { + turn_id, + tool_call_id, + tool_name, + stream: ToolOutputStream::Stderr, + delta, + .. + } if turn_id == "turn-stderr" + && tool_call_id == "call-stderr" + && tool_name == "streaming_stderr_probe" + && delta == "live-stderr" + )); + assert!(matches!( + live_result, + astrcode_core::AgentEvent::ToolCallResult { turn_id, result, .. } + if turn_id == "turn-stderr" + && result.tool_call_id == "call-stderr" + && result.tool_name == "streaming_stderr_probe" + && result.error.as_deref() == Some("stderr failure") + )); } } diff --git a/crates/session-runtime/src/turn/tool_result_budget.rs b/crates/session-runtime/src/turn/tool_result_budget.rs index cc25d184..575213fe 100644 --- a/crates/session-runtime/src/turn/tool_result_budget.rs +++ b/crates/session-runtime/src/turn/tool_result_budget.rs @@ -10,14 +10,15 @@ use std::{ }; use astrcode_core::{ - LlmMessage, Result, StorageEventPayload, is_persisted_output, persist_tool_result, + LlmMessage, PersistedToolOutput, Result, StorageEventPayload, is_persisted_output, + persist_tool_result, }; use crate::{SessionState, turn::events::tool_result_reference_applied_event}; #[derive(Debug, Clone, PartialEq, Eq)] pub struct ToolResultReplacementRecord { - pub persisted_relative_path: String, + pub persisted_output: PersistedToolOutput, pub replacement: String, pub original_bytes: u64, } @@ -60,7 +61,7 @@ impl ToolResultReplacementState { for stored in session_state.snapshot_recent_stored_events()? { if let StorageEventPayload::ToolResultReferenceApplied { tool_call_id, - persisted_relative_path, + persisted_output, replacement, original_bytes, } = stored.event.payload @@ -68,7 +69,7 @@ impl ToolResultReplacementState { state.replacements.insert( tool_call_id.clone(), ToolResultReplacementRecord { - persisted_relative_path, + persisted_output, replacement, original_bytes, }, @@ -176,13 +177,13 @@ pub fn apply_tool_result_budget( continue; }; let replacement = persist_tool_result(&session_dir, &tool_call_id, content); - let Some(persisted_relative_path) = extract_persisted_relative_path(&replacement) else { + let Some(persisted_output) = replacement.persisted.clone() else { continue; }; - let saved_bytes = original_len.saturating_sub(replacement.len()); + let saved_bytes = original_len.saturating_sub(replacement.output.len()); let record = ToolResultReplacementRecord { - persisted_relative_path: persisted_relative_path.clone(), - replacement: replacement.clone(), + persisted_output: persisted_output.clone(), + replacement: replacement.output.clone(), original_bytes: original_len as u64, }; request @@ -190,19 +191,19 @@ pub fn apply_tool_result_budget( .record_replacement(tool_call_id.clone(), record.clone()); messages[index] = LlmMessage::Tool { tool_call_id: tool_call_id.clone(), - content: replacement.clone(), + content: replacement.output.clone(), }; events.push(tool_result_reference_applied_event( request.turn_id, request.agent, &tool_call_id, - &record.persisted_relative_path, + &record.persisted_output, &record.replacement, record.original_bytes, )); total_bytes = total_bytes .saturating_sub(original_len) - .saturating_add(replacement.len()); + .saturating_add(replacement.output.len()); stats.replacement_count = stats.replacement_count.saturating_add(1); stats.bytes_saved = stats.bytes_saved.saturating_add(saved_bytes); replaced.insert(tool_call_id); @@ -253,13 +254,6 @@ fn resolve_session_dir(working_dir: &Path, session_id: &str) -> Result<PathBuf> .join(session_id)) } -fn extract_persisted_relative_path(replacement: &str) -> Option<String> { - replacement.lines().find_map(|line| { - line.split_once("Full output saved to: ") - .map(|(_, path)| path.trim().to_string()) - }) -} - #[cfg(test)] mod tests { use astrcode_core::{AgentEventContext, EventTranslator, StorageEvent, UserMessageOrigin}; @@ -278,8 +272,13 @@ mod tests { let tempdir = tempfile::tempdir().expect("tempdir should exist"); let agent = AgentEventContext::default(); let mut translator = EventTranslator::new(session_state.current_phase().expect("phase")); - let replacement = "<persisted-output>\nOutput too large (999 bytes). Full output saved \ - to: tool-results/call-1.txt\n</persisted-output>"; + let replacement = "<persisted-output>\nLarge tool output was saved to a file instead of \ + being inlined.\nPath: ~/.astrcode/tool-results/call-1.txt\nBytes: \ + 999\nRead the file with `readFile`.\nIf you only need a section, read \ + a smaller chunk instead of the whole file.\nStart from the first chunk \ + when you do not yet know the right section.\nSuggested first read: { \ + path: \"~/.astrcode/tool-results/call-1.txt\", charOffset: 0, \ + maxChars: 20000 }\n</persisted-output>"; append_and_broadcast( &session_state, &StorageEvent { @@ -287,7 +286,14 @@ mod tests { agent: agent.clone(), payload: StorageEventPayload::ToolResultReferenceApplied { tool_call_id: "call-1".to_string(), - persisted_relative_path: "tool-results/call-1.txt".to_string(), + persisted_output: PersistedToolOutput { + storage_kind: "toolResult".to_string(), + absolute_path: "~/.astrcode/tool-results/call-1.txt".to_string(), + relative_path: "tool-results/call-1.txt".to_string(), + total_bytes: 999, + preview_text: "preview".to_string(), + preview_bytes: 7, + }, replacement: replacement.to_string(), original_bytes: 999, }, diff --git a/deny.toml b/deny.toml index 0e348c3e..05e1c361 100644 --- a/deny.toml +++ b/deny.toml @@ -32,16 +32,17 @@ multiple-versions = "allow" deny = [ { name = "tokio", wrappers = [ + "astrcode-application", + "astrcode-cli", + "astrcode-client", "astrcode-core", + "astrcode-kernel", "astrcode-example-plugin", "astrcode-plugin", - "astrcode-runtime", - "astrcode-runtime-agent-control", - "astrcode-runtime-agent-loop", - "astrcode-runtime-config", "astrcode-adapter-llm", - "astrcode-runtime-registry", - "astrcode-runtime-session", + "astrcode-adapter-mcp", + "astrcode-adapter-storage", + "astrcode-session-runtime", "astrcode-server", "axum", "hyper", diff --git a/docs/architecture/crates-dependency-graph.md b/docs/architecture/crates-dependency-graph.md index eaaa437a..bf67c6d3 100644 --- a/docs/architecture/crates-dependency-graph.md +++ b/docs/architecture/crates-dependency-graph.md @@ -19,16 +19,27 @@ graph TD astrcode-application[astrcode-application] --> astrcode-core[astrcode-core] astrcode-application[astrcode-application] --> astrcode-kernel[astrcode-kernel] astrcode-application[astrcode-application] --> astrcode-session-runtime[astrcode-session-runtime] + astrcode-cli[astrcode-cli] --> astrcode-client[astrcode-client] + astrcode-cli[astrcode-cli] --> astrcode-core[astrcode-core] + astrcode-client[astrcode-client] --> astrcode-protocol[astrcode-protocol] astrcode-core[astrcode-core] astrcode-kernel[astrcode-kernel] --> astrcode-core[astrcode-core] astrcode-plugin[astrcode-plugin] --> astrcode-core[astrcode-core] astrcode-plugin[astrcode-plugin] --> astrcode-protocol[astrcode-protocol] astrcode-protocol[astrcode-protocol] --> astrcode-core[astrcode-core] + astrcode-sdk[astrcode-sdk] --> astrcode-core[astrcode-core] astrcode-sdk[astrcode-sdk] --> astrcode-protocol[astrcode-protocol] + astrcode-server[astrcode-server] --> astrcode-adapter-agents[astrcode-adapter-agents] + astrcode-server[astrcode-server] --> astrcode-adapter-llm[astrcode-adapter-llm] + astrcode-server[astrcode-server] --> astrcode-adapter-mcp[astrcode-adapter-mcp] + astrcode-server[astrcode-server] --> astrcode-adapter-prompt[astrcode-adapter-prompt] + astrcode-server[astrcode-server] --> astrcode-adapter-skills[astrcode-adapter-skills] astrcode-server[astrcode-server] --> astrcode-adapter-storage[astrcode-adapter-storage] + astrcode-server[astrcode-server] --> astrcode-adapter-tools[astrcode-adapter-tools] astrcode-server[astrcode-server] --> astrcode-application[astrcode-application] astrcode-server[astrcode-server] --> astrcode-core[astrcode-core] astrcode-server[astrcode-server] --> astrcode-kernel[astrcode-kernel] + astrcode-server[astrcode-server] --> astrcode-plugin[astrcode-plugin] astrcode-server[astrcode-server] --> astrcode-protocol[astrcode-protocol] astrcode-server[astrcode-server] --> astrcode-session-runtime[astrcode-session-runtime] astrcode-session-runtime[astrcode-session-runtime] --> astrcode-core[astrcode-core] @@ -47,10 +58,12 @@ graph TD | astrcode-adapter-storage | crates/adapter-storage | 1 | astrcode-core | | astrcode-adapter-tools | crates/adapter-tools | 1 | astrcode-core | | astrcode-application | crates/application | 3 | astrcode-core, astrcode-kernel, astrcode-session-runtime | +| astrcode-cli | crates/cli | 2 | astrcode-client, astrcode-core | +| astrcode-client | crates/client | 1 | astrcode-protocol | | astrcode-core | crates/core | 0 | - | | astrcode-kernel | crates/kernel | 1 | astrcode-core | | astrcode-plugin | crates/plugin | 2 | astrcode-core, astrcode-protocol | | astrcode-protocol | crates/protocol | 1 | astrcode-core | -| astrcode-sdk | crates/sdk | 1 | astrcode-protocol | -| astrcode-server | crates/server | 6 | astrcode-adapter-storage, astrcode-application, astrcode-core, astrcode-kernel, astrcode-protocol, astrcode-session-runtime | +| astrcode-sdk | crates/sdk | 2 | astrcode-core, astrcode-protocol | +| astrcode-server | crates/server | 13 | astrcode-adapter-agents, astrcode-adapter-llm, astrcode-adapter-mcp, astrcode-adapter-prompt, astrcode-adapter-skills, astrcode-adapter-storage, astrcode-adapter-tools, astrcode-application, astrcode-core, astrcode-kernel, astrcode-plugin, astrcode-protocol, astrcode-session-runtime | | astrcode-session-runtime | crates/session-runtime | 2 | astrcode-core, astrcode-kernel | diff --git a/docs/competitor-analysis-and-roadmap.md b/docs/competitor-analysis-and-roadmap.md new file mode 100644 index 00000000..4b65326b --- /dev/null +++ b/docs/competitor-analysis-and-roadmap.md @@ -0,0 +1,178 @@ +# Coding Agent 竞品对比与 Astrcode 下一步建议 + +> 基于 Claude Code、Codex、OpenCode、KimiCLI、pi-mono 五个项目的源码分析,对比 Astrcode 现状。 + +--- + +## 1. 竞品特性矩阵 + +| 特性领域 | Claude Code | Codex | OpenCode | KimiCLI | pi-mono | Astrcode 现状 | +|---------|------------|-------|----------|---------|---------|-------------| +| **语言/运行时** | TypeScript/Node | Rust + TS | TypeScript/Bun | Python | TypeScript/Bun | Rust + Tauri | +| **工具数量** | 40+ | ~20 | ~20 | ~15 | ~15 | ~10 (内置+MCP) | +| **子代理** | Swarm 协调器模式 | Spawn/Wait/Send v1+v2 | Task 工具 | 劳动力市场系统 | 扩展实现 | AgentTree 多级树 | +| **LSP 集成** | 有 (单工具) | 无 | 一等公民,多语言预配置 | 无 | 无 | **无** | +| **沙箱/安全** | 权限模式 | 三层沙箱 + Guardian AI | 权限系统 (per-tool/agent/glob) | 无 | 无 | Policy Engine (策略模式) | +| **上下文压缩** | 4 级 (full/micro/snip/reactive) | 自动+手动 compaction | 自动 compaction | 自动 compaction (85%阈值) | 自动+手动 compaction | 基础 compaction | +| **记忆系统** | MEMORY.md + Auto-Dream + 会话记忆 | 两阶段流水线 (提取→合并) | 无 | AGENTS.md 层级 | MEMORY.md | 基础 (文件级) | +| **Hooks** | 20+ 生命周期事件 | 无 | 插件生命周期 hooks | 7+ 事件 (可阻塞/注入) | beforeToolCall/afterToolCall | **无** | +| **MCP** | 客户端 (stdio/SSE/OAuth) | 客户端 + 服务端 | 客户端 | 客户端 (stdio/HTTP/OAuth) | 明确拒绝 MCP,用 CLI 工具替代 | 客户端 (adapter-mcp) | +| **ACP 协议** | 无 | 无 | 有 (Zed/JetBrains) | 有 (Zed/JetBrains) | RPC 模式 | **无** | +| **Git Worktree** | 有 (工具级) | 无 | 有 (任务级隔离) | 无 | 无 | **无** | +| **会话分叉** | 无 | 无 | 从任意消息分叉 | Checkpoint + D-Mail 回溯 | 会话树 (JSONL 父指针) | Turn 级分支 (部分) | +| **扩展/插件** | 插件 + 市场 | MCP + Codex Apps | 插件 (生命周期 hooks) | 插件 (目录加载) | 扩展系统 + 包管理器 | 插件框架 (部分) | +| **多 LLM** | 仅 Anthropic | 仅 OpenAI | 20+ 提供商 | 多提供商 | 20+ 提供商 + 跨提供者切换 | Anthropic + OpenAI | +| **SDK/API** | 有 (SDK 模式) | codex exec (CI/CD) | REST API + SSE | Wire 协议 (多前端) | 4 种运行模式 | sdk crate (极简) | +| **计划模式** | Plan 工具 | Plan 工具 | Plan 工具 | 只读研究→计划→自动批准 | 无 | **无** | +| **语音输入** | 有 (STT) | 无 | 无 | 无 | 无 | 无 | +| **Cron 调度** | 有 (AGENT_TRIGGERS) | 无 | 无 | 后台任务 + 心跳 | 自管理调度事件 | 无 | + +--- + +## 2. Astrcode 差距分析 + +### 核心短板 (对用户体验影响最大) + +1. **上下文管理不够精细** — 只有基础 compaction,缺少 micro-compact(轻量清除旧工具结果)和多级策略。Claude Code 的 4 级压缩是长会话的关键。 +2. **无 Hooks 系统** — 用户无法在工具调用前后、会话开始/结束等节点插入自定义逻辑。这是生态扩展的基础。 +3. **SDK/API 不成熟** — sdk crate 几乎为空。无法被外部程序集成或用于 CI/CD 场景。 +4. **无 LSP 集成** — OpenCode 凭借一等公民 LSP 在代码理解上有巨大优势。Astrcode 作为 Rust 项目,接入 rust-analyzer 等是天然优势。 +5. **会话分叉不完整** — 其他项目都支持从任意点分叉/回溯会话,Astrcode 只有 turn 级分支。 + +### 差异化机会 (别人做得少,Astrcode 可以做得好的) + +1. **Guardian AI 审查** — Codex 独有,用 LLM 做二次风险评估。Astrcode 的 Policy Engine 已经有策略模式基础,可以增强为 AI 驱动的安全层。 +2. **ACP (Agent Client Protocol)** — OpenCode 和 KimiCLI 都支持 IDE 集成协议。Astrcode 作为桌面应用天然适合。 +3. **MCP 服务端模式** — Codex 可以作为 MCP 服务端让其他代理调用。Astrcode 的 adapter-mcp 基础可以扩展双向能力。 +4. **跨提供者会话切换** — pi-mono 支持在对话中途切换模型并保留上下文。这在多模型时代很有价值。 + +--- + +## 3. 下一步建议 (优先级排序) + +### P0 — 基础体验完善 (1-2 周) + +#### 3.1 多级上下文压缩策略 + +借鉴 Claude Code 的分级思路: + +- **Micro-compact**: 替换旧工具结果为占位符 `[旧工具结果已清除]`,成本极低 +- **Full compact**: LLM 总结历史对话,保留关键代码片段和错误信息 +- **Budget-aware 触发**: 基于 token 用量自动选择压缩级别 + +实现位置: `session-runtime` 的 turn 执行循环中,在 compaction 触发点分级处理。 + +``` +触发条件: token_usage > context_window - buffer +├─ 轻度超限 → micro-compact (清除旧工具结果) +├─ 中度超限 → micro + 截断早期历史 +└─ 严重超限 → full compact (LLM 总结) +``` + +#### 3.2 Hooks 系统 + +定义生命周期事件和 hook 注册机制: + +``` +事件: PreToolUse, PostToolUse, SessionStart, SessionEnd, + PreCompact, PostCompact, UserPromptSubmit, Stop + +Hook 类型: +├─ Shell hook — 执行 shell 命令,可通过 exit code 阻止 +├─ Transform hook — 修改输入/输出内容 +└─ Notification hook — 异步通知(fire-and-forget) +``` + +实现位置: `core` 定义事件 trait,`kernel` 提供 hook 注册和分发,`session-runtime` 在关键节点触发。 + +### P1 — 生态扩展 (2-4 周) + +#### 3.3 LSP 集成 + +参考 OpenCode 的设计,但利用 Rust 的优势: + +- 定义 `LspClient` port trait (在 `core`) +- 实现 rust-analyzer, typescript-language-server, gopls 等适配器 +- 暴露为工具: `lsp_diagnostics`, `lsp_hover`, `lsp_goto_definition`, `lsp_references` +- 工具级自动管理 LSP server 生命周期 + +实现位置: 新增 `adapter-lsp` crate,工具注册到 `adapter-tools`。 + +#### 3.4 SDK 成熟化 + +让 Astrcode 可嵌入: + +```rust +// 目标 API 示例 +let client = AstrcodeClient::connect("http://localhost:3000").await?; +let session = client.create_session(config).await?; +let mut stream = session.query("帮我重构这个函数").await?; +while let Some(event) = stream.next().await { + // 处理流式事件 +} +``` + +实现位置: `sdk` crate,基于 `protocol` 的 DTO 定义客户端。 + +#### 3.5 ACP (Agent Client Protocol) + +支持 IDE 集成 (Zed, JetBrains, VS Code): + +- JSON-RPC over stdio 协议实现 +- 注册为 Zed 等 IDE 的 agent 后端 +- 复用现有 SSE 事件流,增加 stdio 传输层 + +实现位置: 新增 `server` 的 ACP 端点或独立 `adapter-acp` crate。 + +### P2 — 高级特性 (4-8 周) + +#### 3.6 AI Guardian 安全层 + +在 Policy Engine 基础上增加 LLM 驱动的风险评估: + +``` +工具调用 → Policy Engine (规则匹配) + → Guardian Agent (LLM 评估高风险操作) + ├─ risk_score < 50 → 自动通过 + ├─ 50-80 → 提示用户确认 + └─ >= 80 → 自动拒绝 +``` + +#### 3.7 MCP 双向模式 + +当前只有客户端。扩展为同时支持服务端,让其他 agent 可以调用 Astrcode 的能力。 + +#### 3.8 会话完整分叉 + +支持从任意消息点创建分支会话,独立发展。基于现有 EventStore 的事件溯源能力,自然适合。 + +--- + +## 4. Astrcode 的独特优势 + +不要只看差距,Astrcode 也有自己的优势: + +| 优势 | 说明 | +|-----|------| +| **Rust 性能** | 唯一全 Rust 后端 (Codex 有 Rust 版但非主力),启动快、内存少、无 GC | +| **Tauri 桌面应用** | 唯一原生桌面 GUI,不是纯 CLI/TUI | +| **六边形架构** | 最严格的端口-适配器分离,内核最干净 | +| **事件溯源** | EventStore + Projection 模式,天然适合会话回溯和分叉 | +| **Plugin 进程隔离** | 插件独立进程 + supervisor 管理,安全性最好 | +| **Prompt Assembly** | 分层 builder + cache-aware blocks,prompt 工程最精细 | + +--- + +## 5. 推荐路线图 + +``` +Phase 1 (现在) Phase 2 (1-2 月) Phase 3 (3+ 月) +┌──────────────┐ ┌──────────────┐ ┌──────────────┐ +│ 多级压缩 │ │ LSP 集成 │ │ AI Guardian │ +│ Hooks 系统 │ │ SDK 成熟化 │ │ MCP 服务端 │ +│ 计划模式 │ │ ACP 协议 │ │ 多模型切换 │ +└──────────────┘ │ 会话分叉 │ │ 语音输入 │ + └──────────────┘ └──────────────┘ +``` + +**核心理念**: 先夯实基础体验(压缩、hooks、计划模式),再扩展生态(LSP、SDK、ACP),最后做高级特性(AI 审查、双向 MCP)。 diff --git a/docs/ideas/CompactSystem.md b/docs/ideas/CompactSystem.md deleted file mode 100644 index 145661f1..00000000 --- a/docs/ideas/CompactSystem.md +++ /dev/null @@ -1,210 +0,0 @@ -自动 Compact 最终方案摘要 -核心原则 - -仍然保留 70% 作为自动 compact 启动阈值。 -但 70% 的含义不是“立刻替换上下文”,而是: - -到 70% 时开始自动准备 compact -自动寻找合适关键点再应用 compact -关键点不到,不切 -超过保底阈值时,下一个关键点必须切 - -这样既保留你要的“提前无感准备”,也避免“中途换脑子”的断层风险。 - -你真正要实现的需求 -1. 70% 触发自动预备 compact - -当上下文达到 70%: - -runtime 标记 compact_pending = true -可后台 fork 一个 compact agent -开始为旧历史生成 compact note / handoff note -此时不替换主上下文 - -也就是: - -70% = 开始预热,不是开始切换。 -这保留了你最初“自动、无感、提前准备”的体验目标。 - -2. 自动寻找“合适关键点”再应用 - -只有遇到合适关键点,才真正应用 compact。 - -合适关键点定义 - -我建议你文档里直接写成: - -当前没有 pending tool call -当前没有 pending subagent -当前没有流式输出 -assistant final 已经结束 -loop 进入 AwaitUserInput / IdleSafePoint - -也就是: - -agent loop 停稳点,不是模型“主观觉得完成了”。 -因为你前面也已经意识到,真正危险的是“当前 turn 完成就替换”。 - -3. 自动 compact 只压旧历史,不动最近工作集 - -compact 后的新上下文不是简单: - -摘要 + 后 30% - -而应该是: - -system / rules -compact boundary note -handoff note -recent working set -最近几轮原始对话 -当前新输入 - -因为你们前面的讨论已经指出: -不能粗暴删前 70%,不然会“瞬间失忆”。 - -4. compact 产物不是 JSON,而是高密度自然语言笔记 - -这点你已经想清楚了,应该固定下来。 - -产物形式 - -不要: - -State_Snapshot.json -纯结构化 JSON 摘要 - -要: - -Handoff Note -高密度自然语言交接单 -保留文件指针、关键决策、纠错、未解问题 - -因为 JSON 太硬,语义延续感差; -而自然语言交接单更适合对话续接。 - -5. fork agent 可以写摘要,但必须有强约束模板 - -你前面也已经确认: - -可以直接让 fork agent 去写摘要和笔记。 -但不能只给一句“帮我总结一下”,否则一定会写成流水账。 - -必须写入的内容 - -你的 compact prompt 至少要强制包含: - -当前目标 -已做决定 -改过哪些文件 / 函数 / 行号指针 -用户纠正过什么 -约束 / 禁忌 -当前卡点 -未完成事项 -明确禁止 -不准贴大段代码 -不准写流水账 -不准写空洞总结句 -不准丢文件指针 -6. 应用 compact 时必须插入边界声明 - -真正应用 compact 时,要在 system/rules 后插一个边界说明: - -早期对话已被整理为下方交接笔记; -后续优先参考最近原始消息; -如果笔记缺细节,应重新读文件或询问,而不是猜。 - -这是为了避免模型偷偷把“历史摘要”当成“完整原文”。 -你们前面的讨论里,这个点就是为了解决无感切换导致的断层。 - -7. 手动 compact 和保底 compact 仍然保留 - -你的系统最后应该有三种 compact: - -自动 compact -70% 开始准备 -到 safe point 自动应用 -手动 compact -用户随时点 -若 loop 正在运行,则排队到下一个 safe point 执行 -保底强制 compact -如果已经接近极限 -则下一个 safe point 必须 compact -防止上下文直接爆掉 -推荐状态机 -enum LoopState { - RunningModel, - RunningTool, - RunningSubAgent, - Streaming, - AwaitUserInput, -} - -enum CompactState { - None, - PendingAuto, // 70% 触发 - CandidateReady, // 笔记已生成 - PendingManual, - RequiredHard, // 保底强制 - Applying, - Failed, -} -推荐执行流 -A. 到 70% -标记 PendingAuto -后台 fork compact agent -生成 handoff note -存为 CandidateReady -B. loop 继续跑 -不替换 -不打断 -用户无感 -C. 到合适关键点 - -如果: - -CandidateReady -且 LoopState == AwaitUserInput - -则: - -删除旧历史 -注入 boundary note -注入 handoff note -保留 recent working set -完成 compact -D. 下一轮继续 - -模型看到的是: - -规则层 -compact 边界说明 -交接笔记 -最近原始上下文 -当前用户输入 -文档里可以直接写成这句话 -设计定义 - -系统在上下文使用率达到 70% 时启动自动 compact 预备流程。 -该流程不会立即替换当前上下文,而是先后台生成交接笔记。 -只有当 agent loop 进入安全停稳点(safe point)时,系统才会自动应用 compact。 -若未到安全点,则继续等待;若达到保底阈值,则在下一个安全点强制执行 compact。 - -你的文档结论版 -目标 -保持无感体验 -提前准备 compact -避免中途切换导致失忆 -用自然语言交接笔记承接旧历史 -关键策略 -70% 启动 -关键点应用 -旧历史压缩 -最近工作集保留 -边界声明注入 -手动与保底机制并存 -本质 - -这不是“70% 直接裁历史”,而是: - -70% 开始找机会,在合适关键点自动 compact。 \ No newline at end of file diff --git a/docs/ideas/notes.md b/docs/ideas/notes.md index 617cf7e2..0306f20a 100644 --- a/docs/ideas/notes.md +++ b/docs/ideas/notes.md @@ -1,6 +1,7 @@ +待办事项 1. 关闭对话框可以更好的看llm的排版 2. 语音选项左下角 -3.增加agent可选tools +3. 增加agent可选tools --- name: coordinator description: Coordinates work across specialized agents @@ -8,5 +9,11 @@ --- 4. 我想到了个设计agent company,一个部门审查其他部门的内容,其他部门自己干自己的事情,每个部门都是一个agent team.每个部门的leader会将自己队员做了的事情发在leaders session里面,由leaders自行编排逻辑,只有所有leaders都同意才能完成plan编排部门teammates工作,这样工作流基本就被废弃了,全依靠agent的自己的能力 -5. 终端的输入输出功能 -6. fork agent \ No newline at end of file +5. 终端工具的输入输出功能 +6. fork agent +7. pending messages(完成部分) +8. 更好的compact功能 +9. 多agent共享任务列表 +10. draft → test → review → improve → repeat +11. 更安全更自由的权限,让agent能操控工作区以外的文件 + - TODO: v1 先默认全局放开文件工具对工作区的围栏,后续再补 Claude Code 风格的目录白名单、危险模式、审批规则与受保护路径。 diff --git "a/docs/\347\211\271\347\202\271/capability_governance.md" "b/docs/\347\211\271\347\202\271/capability_governance.md" new file mode 100644 index 00000000..452f1168 --- /dev/null +++ "b/docs/\347\211\271\347\202\271/capability_governance.md" @@ -0,0 +1,347 @@ +# Capability 抽象与治理面 + +Astrcode 将所有可被 Agent 调用的能力统一建模为 **CapabilitySpec**,并通过声明式 DSL(`CapabilitySelector`)在治理模式间灵活分配。这不是简单的"工具白名单",而是一套从元数据声明到运行时执行的完整能力治理体系。 + +## 核心模型 + +### CapabilitySpec — 运行时能力语义定义 + +`CapabilitySpec`(`crates/core/src/capability.rs`)是运行时唯一的能力模型,每个工具、Agent、ContextProvider 都以 CapabilitySpec 描述自身: + +```rust +pub struct CapabilitySpec { + pub name: CapabilityName, // 唯一标识,如 "readFile" + pub kind: CapabilityKind, // 类型:Tool / Agent / ContextProvider / ... + pub description: String, // 人类可读描述 + pub input_schema: Value, // JSON Schema + pub output_schema: Value, + pub invocation_mode: InvocationMode, // Unary / Streaming + pub concurrency_safe: bool, // 是否允许并发执行 + pub compact_clearable: bool, // compaction 时是否可清除结果 + pub profiles: Vec<String>, // 能力画像,如 "coding" + pub tags: Vec<String>, // 自由标签,如 "filesystem", "read" + pub permissions: Vec<PermissionSpec>, // 声明的权限 + pub side_effect: SideEffect, // 副作用级别 + pub stability: Stability, // 稳定性级别 + pub metadata: Value, // 扩展元数据 + pub max_result_inline_size: Option<usize>, // 结果内联阈值 +} +``` + +构造通过类型安全的 Builder 完成(`CapabilitySpecBuilder`),`build()` 时校验所有不变量(名称非空、schema 必须是 JSON Object、无重复标签/权限等)。 + +### 关键枚举 + +| 类型 | 值 | 作用 | +|------|-----|------| +| `CapabilityKind` | Tool, Agent, ContextProvider, MemoryProvider, PolicyHook, Renderer, Resource, Prompt, Custom | 能力类型判别 | +| `SideEffect` | None, Local, Workspace, External | 副作用分级,治理面的核心轴 | +| `Stability` | Experimental, Stable, Deprecated | API 成熟度 | +| `InvocationMode` | Unary, Streaming | 调用模式 | + +其中 `SideEffect` 是治理模式收缩工具面的主要维度: + +- **None** — 纯读操作,无副作用(如 readFile、grep) +- **Local** — 写入本地文件系统(如 writeFile、editFile) +- **Workspace** — 修改工作区状态(大部分内置工具的默认值) +- **External** — 影响外部系统(如 shell) + +### 协议层映射 + +`CapabilityWireDescriptor`(`crates/protocol/src/capability/descriptors.rs`)是协议层对 `CapabilitySpec` 的传输别名,直接 re-export: + +```rust +pub use astrcode_core::CapabilitySpec as CapabilityWireDescriptor; +``` + +不再维护第二个语义冗余的类型。插件 SDK 的 `capability_mapping.rs` 提供恒等转换(`wire_descriptor_to_spec` / `spec_to_wire_descriptor`),只做 validate + clone。 + +## 工具如何声明能力 + +### 三层声明链 + +``` +Tool trait(core) + ├─ fn definition() -> ToolDefinition // 名称、描述、参数 schema + ├─ fn capability_metadata() -> ToolCapabilityMetadata // 能力元数据 + └─ fn capability_spec() -> CapabilitySpec // 合并产物(默认实现) +``` + +`ToolCapabilityMetadata`(`crates/core/src/tool.rs`)携带能力语义: + +```rust +pub struct ToolCapabilityMetadata { + pub profiles: Vec<String>, + pub tags: Vec<String>, + pub permissions: Vec<PermissionSpec>, + pub invocation_mode: InvocationMode, + pub side_effect: SideEffect, + pub concurrency_safe: bool, + pub compact_clearable: bool, + pub stability: Stability, + pub prompt: Option<ToolPromptMetadata>, + pub max_result_inline_size: Option<usize>, +} +``` + +默认值通过 `builtin()` 提供:profile `"coding"`、tag `"builtin"`、`SideEffect::Workspace`、`Stability::Stable`。 + +### 内置工具的能力声明示例 + +| 工具 | tags | side_effect | concurrency_safe | 权限 | +|------|------|-------------|-----------------|------| +| `readFile` | filesystem, read | None | true | filesystem.read | +| `writeFile` | filesystem, write | Local | false | filesystem.write | +| `editFile` | filesystem, write, edit | Local | false | filesystem.write | +| `grep` | filesystem, read, search | None | true | filesystem.read | +| `shell` | process, shell | External | false | shell.exec | + +### 注册到内核 + +`ToolCapabilityInvoker`(`crates/kernel/src/registry/tool.rs`)将 `dyn Tool` 包装为 `dyn CapabilityInvoker`,在构造时调用 `tool.capability_spec()` 获取并缓存 spec。`CapabilityRouter`(`crates/kernel/src/registry/router.rs`)维护 `HashMap<String, Arc<dyn CapabilityInvoker>>` 注册表。 + +## CapabilitySelector — 声明式能力选择 DSL + +`CapabilitySelector`(`crates/core/src/mode/mod.rs`)是一个递归代数 DSL: + +```rust +pub enum CapabilitySelector { + AllTools, // 全部 Tool 类型能力 + Name(String), // 按名称精确匹配 + Kind(CapabilityKind), // 按 CapabilityKind 过滤 + SideEffect(SideEffect), // 按副作用级别过滤 + Tag(String), // 按标签过滤 + Union(Vec<CapabilitySelector>), // 集合并 + Intersection(Vec<CapabilitySelector>), // 集合交 + Difference { base: Box, subtract: Box }, // 集合差 +} +``` + +求值逻辑在 `evaluate_selector()`(`crates/application/src/mode/compiler.rs`):从全量 `CapabilitySpec` 列表开始,递归匹配,最终产出 `BTreeSet<String>`(工具名集合)。 + +这个 DSL 让治理模式可以用声明式表达式定义自己的能力面,而不需要硬编码工具列表。 + +### 内置模式的能力面定义 + +**Code 模式** — 完全访问: + +```rust +capability_selector: CapabilitySelector::AllTools +``` + +**Plan 模式** — 只读 + 两个计划工具: + +```rust +capability_selector: CapabilitySelector::Union(vec![ + // 全部工具 - 副作用工具 - agent 标签 + Difference { + base: AllTools, + subtract: Union([SideEffect(Local), SideEffect(Workspace), + SideEffect(External), Tag("agent")]) + }, + // 显式加入计划专属工具 + Name("exitPlanMode"), + Name("upsertSessionPlan"), +]) +``` + +**Review 模式** — 严格只读: + +```rust +capability_selector: CapabilitySelector::Intersection(vec![ + AllTools, + SideEffect(None), // 只允许 SideEffect::None 的工具 + Difference { base: AllTools, subtract: Tag("agent") }, +]) +``` + +## 编译与装配:从声明到运行时 + +### 完整生命周期 + +``` +工具注册 + Tool::capability_metadata() + Tool::definition() + → ToolCapabilityMetadata::build_spec() + → CapabilitySpec (validated) + → ToolCapabilityInvoker 包装为 CapabilityInvoker + → CapabilityRouter::register_invoker() + + ↓ + +Turn 提交时编译治理面 + GovernanceSurfaceAssembler.session_surface() + → compile_mode_surface() + → compile_mode_envelope(base_router, mode_spec, extra_prompts) + → compile_capability_selector() // 递归求值 CapabilitySelector + → evaluate_selector() → BTreeSet<String> allowed_tools + → child_allowed_tools() // 计算子代理继承的工具白名单 + → subset_router() // 创建过滤后的 CapabilityRouter + → ResolvedTurnEnvelope // 编译产物 + → build_surface() + → ResolvedGovernanceSurface // 最终运行时治理面 + + ↓ + +运行时执行 + execute_tool_calls() + → 查询 capability_spec 判断 concurrency_safe + → safe 调用并发执行(buffer_unordered) + → unsafe 调用串行执行 + → CapabilityRouter::execute_tool() // 只有白名单内的工具可被调用 +``` + +### ResolvedTurnEnvelope — 编译产物 + +`ResolvedTurnEnvelope`(`crates/core/src/mode/mod.rs`)是模式编译的输出: + +```rust +pub struct ResolvedTurnEnvelope { + pub mode_id: ModeId, + pub allowed_tools: Vec<String>, // 编译后的工具白名单 + pub prompt_declarations: Vec<PromptDeclaration>, + pub action_policies: ActionPolicies, // Allow / Deny / Ask 策略 + pub child_policy: ResolvedChildPolicy, // 子代理策略 + pub submit_busy_policy: SubmitBusyPolicy, // BranchOnBusy / RejectOnBusy + pub fork_mode: Option<ForkMode>, + pub diagnostics: Vec<String>, +} +``` + +### ResolvedGovernanceSurface — 运行时治理面 + +`ResolvedGovernanceSurface`(`crates/application/src/governance_surface/mod.rs`)是装配器输出的最终产物,携带: + +- `capability_router` — 过滤后的路由器(只含 allowed_tools) +- `resolved_limits` — 工具白名单 + max_steps +- `policy_context` — 策略引擎上下文 +- `approval` — 审批管线(Ask 模式时触发) +- `prompt_declarations` — 本 turn 的所有 prompt 块 + +它通过 `into_submission()` 转为 `AppAgentPromptSubmission`,传入 session runtime 的 turn 执行。 + +### 子代理的能力继承 + +`compile_mode_envelope_for_child()`(`crates/application/src/mode/compiler.rs`)计算子代理的可用工具: + +``` +子代理工具 = parent_allowed_tools ∩ mode_allowed_tools ∩ SpawnCapabilityGrant +``` + +三层取交集确保子代理的能力严格收缩,不会超出父代理和模式定义的范围。 + +## 运行时强制执行 + +### 并发安全调度 + +`execute_tool_calls()`(`crates/session-runtime/src/turn/tool_cycle.rs`)根据 `concurrency_safe` 字段分两路: + +``` +LLM 返回 tool_calls + │ + ├─ concurrency_safe=true → buffer_unordered 并发执行 + │ (如 readFile、grep) + │ + └─ concurrency_safe=false → 串行执行 + (如 writeFile、shell) +``` + +### 工具路由的过滤生效 + +`CapabilityRouter::subset_for_tools_checked()` 创建一个只含白名单工具的新路由器。LLM 请求的 tool call 若不在白名单中,`execute_tool()` 查找失败返回 "unknown tool" 错误。这是**编译期过滤 + 运行时兜底**的双重保障。 + +### 策略引擎检查点 + +Policy Engine(`crates/core/src/policy/engine.rs`)提供两个检查点: + +- `check_model_request()` — 可重写 LLM 请求(过滤工具列表、修改 system prompt) +- `check_capability_call()` — 对每次能力调用返回 Allow / Deny / Ask + +## 设计优势 + +### 1. 声明式 vs 命令式:模式定义零代码 + +传统做法是在代码里写 `if mode == "plan" { disable_write_tools() }`。Astrcode 用 `CapabilitySelector` DSL 让模式定义完全声明化: + +``` +Plan 模式的工具面 = + (AllTools - SideEffect(Local|Workspace|External) - Tag("agent")) + ∪ Name("exitPlanMode") ∪ Name("upsertSessionPlan") +``` + +添加新模式只需写 spec,不需要改编译器或运行时代码。求值逻辑完全通用。 + +### 2. 元数据驱动:能力自描述 + +每个工具通过 `ToolCapabilityMetadata` 声明自己的语义属性(副作用级别、并发安全性、权限需求),而不是由治理层硬编码"哪些工具危险"。 + +这意味着: +- 新增工具时,只需声明自己的元数据,自动被所有模式的 `CapabilitySelector` 覆盖 +- 修改工具的副作用级别会自动反映到所有引用该级别的模式 +- 不需要维护"危险工具列表"这种容易过时的集中配置 + +### 3. 编译期求值:每次 turn 重新编译,动态生效 + +工具注册表可能因为插件加载/卸载而变化。`CapabilitySelector` 在每个 turn 提交时重新求值(`compile_mode_envelope`),而非在启动时静态绑定。 + +这确保了: +- 插件热加载后,新模式 spec 立刻生效 +- 运行时修改 mode spec 不需要重启 +- 编译产物(`ResolvedGovernanceSurface`)是一次性的,不会被旧状态污染 + +### 4. 子代理能力严格收缩 + +子代理的能力 = 父 ∩ 模式 ∩ 显式授权(`SpawnCapabilityGrant`),三层取交集。这确保了: + +- 子代理永远不会获得超出父代理的能力 +- 模式定义的全局约束不会被 spawn 请求绕过 +- 显式授权进一步收缩,实现最小权限原则 + +### 5. CapabilitySpec 的多维元数据支撑多种运行时决策 + +同一份 `CapabilitySpec` 驱动多个独立的运行时决策: + +| 元数据字段 | 消费者 | 决策 | +|-----------|--------|------| +| `side_effect` | CapabilitySelector | 模式的工具面收缩 | +| `concurrency_safe` | tool_cycle | 并行 vs 串行执行 | +| `compact_clearable` | compaction | compaction 时是否可清除结果 | +| `tags` | CapabilitySelector / prompt contributor | 按标签过滤、prompt 注入 | +| `permissions` | policy engine | 权限检查 | +| `max_result_inline_size` | tool execution | 结果持久化阈值 | +| `stability` | prompt contributor | 向用户展示 API 成熟度 | + +不需要为每个决策维度维护独立的数据源。 + +### 6. 单一规范模型的边界清晰 + +`CapabilitySpec` 在 `core` 层定义,`CapabilityWireDescriptor` 在 `protocol` 层只是 re-export。不存在两套互相转换的模型。插件 SDK 的映射层是恒等转换。 + +这避免了"core 模型和 protocol 模型不一致"的经典问题,同时保持了 crate 依赖方向的正确性(protocol 依赖 core,不是反过来)。 + +## 与同类产品的对比 + +### Claude Code + +Claude Code 没有独立的 capability 抽象。工具以 `Entry`(entry.ts)的形式存储在 JSONL 中,没有结构化的元数据(副作用级别、并发安全性、权限声明)。权限控制通过硬编码的 permission mode(`default`、`plan`、`auto`)在 `AgentLoop` 中判断。 + +工具过滤直接在 prompt 组装时修改 `tools` 数组,没有编译/求值阶段。 + +### Codex + +Codex 的工具定义在 `AgentFunctionDefinition` 中(`agent-functions.ts`),包含 `name`、`description`、`parameters`,但没有副作用分级、并发安全性、权限声明等治理元数据。 + +工具白名单通过 `allowedTools` 数组在 `applyPolicy()` 中硬编码过滤,是命令式的列表匹配。 + +### 对比总结 + +| 维度 | Astrcode | Claude Code | Codex | +|------|----------|-------------|-------| +| 能力模型 | 结构化 `CapabilitySpec`(15+ 字段) | Entry 消息,无独立模型 | `AgentFunctionDefinition`,字段较少 | +| 元数据维度 | 副作用级别、并发安全、权限、稳定性、标签 | 无 | 无 | +| 模式定义 | 声明式 DSL(`CapabilitySelector`) | 硬编码 permission mode | 硬编码 `allowedTools` 列表 | +| 工具过滤 | 编译期求值 + 运行时路由 | prompt 组装时改 tools 数组 | `applyPolicy()` 过滤 | +| 子代理控制 | 三层交集收缩(父 ∩ 模式 ∩ 授权) | 无子代理概念 | 无子代理概念 | +| 新增工具的治理成本 | 声明元数据即可,自动被 DSL 覆盖 | 需要改 permission mode 逻辑 | 需要改 `allowedTools` 列表 | + +本质区别:Astrcode 的 capability 是**自描述的能力单元**,治理层通过声明式规则组合它们;Claude Code 和 Codex 的工具是**扁平的函数定义**,治理层通过命令式代码管理它们。 diff --git "a/docs/\347\211\271\347\202\271/event_log.md" "b/docs/\347\211\271\347\202\271/event_log.md" new file mode 100644 index 00000000..64bce391 --- /dev/null +++ "b/docs/\347\211\271\347\202\271/event_log.md" @@ -0,0 +1,230 @@ +# Event Log 架构 + +Astrcode 采用 **Event Sourcing + CQRS Projection** 模式管理会话状态。本文档描述其设计、实现细节、以及与同类产品(Claude Code、Codex)的对比分析。 + +## 核心模型 + +### 事件存储 + +会话的所有状态变更以 `StorageEventPayload` 枚举变体持久化到 JSONL 文件(`adapter-storage/src/session/event_log.rs`)。 + +``` +~/.astrcode/projects/<project-hash>/<session-id>.jsonl +``` + +每行是一个 `StoredEvent`: + +```rust +// core/src/event/types.rs +pub struct StoredEvent { + pub storage_seq: u64, // 单调递增,由 writer 独占分配 + #[serde(flatten)] + pub event: StorageEvent, // 实际事件 +} + +pub struct StorageEvent { + pub turn_id: Option<String>, + pub agent: AgentEventContext, + #[serde(flatten)] + pub payload: StorageEventPayload, +} +``` + +`StorageEventPayload` 包含约 20 种语义事件变体: + +| 类别 | 事件 | +|------|------| +| 会话生命周期 | `SessionStart`, `TurnDone` | +| 对话消息 | `UserMessage`, `AssistantFinal`, `AssistantDelta` | +| 思考过程 | `ThinkingDelta` | +| 工具交互 | `ToolCall`, `ToolCallDelta`, `ToolResult`, `ToolResultReferenceApplied` | +| 上下文管理 | `CompactApplied`, `PromptMetrics` | +| 子会话编排 | `SubRunStarted`, `SubRunFinished`, `ChildSessionNotification` | +| 协作审计 | `AgentCollaborationFact` | +| 治理模式 | `ModeChanged` | +| 输入队列 | `AgentInputQueued`, `AgentInputBatchStarted`, `AgentInputBatchAcked`, `AgentInputDiscarded` | +| 错误 | `Error` | + +### 写入路径 + +事件写入经过 `append_and_broadcast`(`session-runtime/src/state/execution.rs`): + +``` +append_and_broadcast(event) + ├─ session.writer.append(event) // 1. 持久化到 JSONL(fsync) + └─ session.translate_store_and_cache() // 2. 更新所有内存投影 + ├─ projector.apply(event) // a. AgentState 投影 + ├─ EventTranslator.translate() // b. SSE 事件转换 + ├─ recent_records/stored 缓存 // c. 内存事件缓存 + ├─ child_nodes 更新 // d. 子会话树 + ├─ active_tasks 更新 // e. 任务快照 + └─ input_queue_projection 更新 // f. 输入队列索引 +``` + +关键特性: +- **单写者 + fsync**:`EventLog` 持有独占 writer,每条事件 `flush + sync_all`(`event_log.rs`) +- **Drop 安全**:`Drop` 实现中再次 flush/sync,防止进程退出时遗漏数据 +- **尾部扫描**:打开文件时,大于 64KB 的文件只读取末尾 64KB 定位 `max_storage_seq` + +### 状态投影 + +`AgentStateProjector`(`core/src/projection/agent_state.rs`)从事件流增量推导当前状态: + +```rust +pub struct AgentState { + pub session_id: String, + pub working_dir: PathBuf, + pub messages: Vec<LlmMessage>, // 用于下次 LLM 请求 + pub phase: Phase, + pub mode_id: ModeId, + pub turn_count: usize, + pub last_assistant_at: Option<DateTime<Utc>>, +} +``` + +投影器的核心行为: + +1. **增量 apply**:每次事件到达时调用 `projector.apply(event)`,只更新相关字段 +2. **Pending 聚合**:`AssistantFinal` 和 `ToolCall` 先进入 pending 状态,在遇到 `UserMessage`/`ToolResult`/`TurnDone`/`CompactApplied` 时 flush +3. **子会话隔离**:`should_project()` 确保 SubRun 事件不污染父会话的投影状态 +4. **Compaction 回放**:`CompactApplied` 事件携带 `messages_removed`,投影时精确替换消息前缀 + +```rust +// 纯函数:从事件序列重建完整状态 +pub fn project(events: &[StorageEvent]) -> AgentState { + AgentStateProjector::from_events(events).snapshot() +} +``` + +### Compaction + +上下文压缩(`session-runtime/src/context_window/compaction.rs`)通过 LLM 摘要替换投影中的消息前缀,但 **event log 原文始终保留**。 + +`CompactApplied` 事件记录精确的压缩边界: + +```rust +CompactApplied { + trigger: CompactTrigger, // Auto / Manual / Deferred + summary: String, // LLM 生成的摘要 + meta: CompactAppliedMeta, // 模式、重试次数等 + preserved_recent_turns: u32, // 保留的最近 turn 数 + messages_removed: u32, // 精确移除的消息数(用于回放) + pre_tokens: u32, + post_tokens_estimate: u32, + tokens_freed: u32, + timestamp: DateTime<Utc>, +} +``` + +投影器在 apply `CompactApplied` 时: +1. 从 `messages` 头部移除 `messages_removed` 条消息 +2. 插入一条 `CompactSummary` 消息作为上下文衔接 +3. 保留尾部消息不变 + +--- + +## 与同类产品的对比 + +### 三方架构差异 + +| 维度 | Astrcode | Claude Code | Codex | +|------|----------|-------------|-------| +| **持久化** | JSONL event log,每条 fsync | JSONL + 100ms 批量 flush | JSONL rollout + mpsc channel 异步写 | +| **存储内容** | 语义事件(~20 种 `StorageEventPayload`) | 对话消息 + 元数据 entry | 对话消息 `ResponseItem` 数组 | +| **内存模型** | Event Sourcing + CQRS 投影 | `parentUuid` 链表回溯构建 `Message[]` | `Vec<ResponseItem>` 数组 | +| **状态来源** | `projector.apply(event)` 逐事件投影 | 从链尾回溯 `parentUuid` 链表 | 直接就是数组 | +| **Compaction** | LLM 摘要替换投影,event log 保留 | 4 级管线:snip/microcompact/collapse/autocompact | LLM 摘要 + 反向扫描 checkpoint | +| **Crash 恢复** | 最多丢 1 条事件 | 最多丢 100ms 数据 | 最多丢 channel 内 256 条 | + +本质区别:Claude Code 和 Codex 存的是"说了什么"(消息),Astrcode 存的是"发生了什么"(事件)。 + +### Event Log 的优势 + +#### 1. 多投影:一份事件流驱动多个独立视图 + +`translate_store_and_cache` 一次写入同时维护 6 个独立投影: + +- `AgentState`(消息列表,给 LLM 用) +- `child_nodes`(子 session 树,给 orchestration 用) +- `active_tasks`(任务快照) +- `input_queue_projection_index`(输入队列状态) +- `AgentCollaborationFact`(审计事实,带 mode 上下文) +- `ObservabilitySnapshot`(可观测性快照) + +Claude Code 和 Codex 都是单 agent 单会话模型,状态就是消息列表。Astrcode 有 parent-child 协作树、mode 切换、policy 审计等多个正交关注点,event sourcing 让它们各自独立投影,互不耦合。 + +`ModeChanged` 事件是典型例子:projector 更新 `mode_id`,`current_mode` 和 `last_mode_changed_at` 字段更新,审计系统记录 mode 上下文,全部从同一个事件自然驱动。 + +#### 2. 横切扩展:加状态维度不需要改核心结构 + +添加新状态维度的路径: + +1. 加一个 `StorageEventPayload` 变体 +2. `AgentStateProjector.apply()` 加一个 match arm +3. 完成 + +旧 session 不包含新事件?自动回退到默认值,不需要数据迁移。 + +`ModeChanged` 就是这个路径的验证——governance mode system 从 0 到 89 个 task 的实现没有破坏任何已有状态。对比 Codex 加新状态维度需要改 `SessionState` struct、序列化格式、可能的 rollout 格式;Claude Code 需要新 entry 类型 + 改 `loadTranscriptFile` 解析逻辑 + 改链表构建逻辑。 + +#### 3. 审计链路完整性 + +`AgentCollaborationFact` 记录每次 spawn/send/close 的完整上下文——谁、在什么 mode 下、什么 policy 版本、什么 capability 面。这是事件级审计,不是消息级。 + +当需要回答"为什么那次 spawn 被允许/拒绝"时,event log 能直接给出答案。 + +#### 4. Compaction 可逆性 + +`CompactApplied` 记录精确的 `messages_removed`,投影器可精确回放压缩边界。原始事件从未删除。 + +Codex 通过 `CompactedItem.replacement_history` 存储替换后快照实现类似效果,但多了存储冗余。Claude Code 通过 `parentUuid = null` 截断链表、加载时 pre-compact skip 实现,文件内容仍在但被跳过。 + +三方可逆性都能达到,event sourcing 的方式最自然。 + +#### 5. Crash Recovery 强保证 + +每条事件 fsync 意味着最多丢最后一条事件。这是三者中最强的 crash recovery guarantee。 + +### Event Log 的劣势与优化方向 + +#### 劣势 1:冷启动重放成本随会话长度线性增长 + +`project()` 必须重放所有事件。目前没有 projection snapshot 机制——没有定期保存 `AgentState` 快照。 + +**优化方向**:每次 compaction 后保存 `AgentState` 快照到 `sessions/<id>/snapshots/`,冷启动从最近快照恢复,只 replay 之后的事件。 + +#### 劣势 2:每条 fsync 的 I/O 延迟 + +`append_stored` 每条事件都 `sync_all`,在高频 streaming 场景下是 I/O 瓶颈。 + +Claude Code 用 100ms batch drain 合并写入;Codex 用 mpsc channel(容量 256)异步写磁盘。 + +**优化方向**:引入 write buffer + 定时/定量 flush。关键事件(`SessionStart`、`TurnDone`)立即 flush,高频事件(`AssistantDelta`、`ToolCallDelta`)批量写入。 + +#### 劣势 3:Event Log 只增不减 + +原始消息、delta 事件、tool results 全都保留在 JSONL 中。长 session 的文件会持续增长。 + +**优化方向**:compaction 后截断旧事件,只保留 checkpoint marker。或定期归档冷 session 的 event log。 + +#### 劣势 4:投影与持久化耦合 + +`translate_store_and_cache` 把 persist、project、translate、cache、broadcast 五件事串行做。如果 projector 有 bug,已 fsync 的事件无法撤回,只能追加补偿事件。 + +#### 优势重要性评估 + +| 优势 | 重要程度 | 原因 | +|------|----------|------| +| 多投影 | 高 | 有 mode/policy/child/audit 四个正交关注点 | +| 横切扩展 | 高 | governance mode system 验证了这条路可行 | +| 审计链路 | 中高 | 有治理策略的系统需要可追溯决策 | +| Compaction 可逆 | 中 | 三方都能做到,event sourcing 最自然 | +| Crash recovery | 中 | 强保证好,但批量化后差异缩小 | + +--- + +## 结论 + +Event sourcing 匹配 Astrcode 的系统复杂度——多 agent 协作树 + governance mode + policy engine + capability 收缩,状态维度远多于单 agent 工具。Claude Code 和 Codex 的消息数组在它们的场景下是合理选择。 + +需要优化的是 event log 的性能特征(写批量化、projection snapshot),不是放弃 event sourcing 本身。 diff --git "a/docs/\347\211\271\347\202\271/plan_mode.md" "b/docs/\347\211\271\347\202\271/plan_mode.md" new file mode 100644 index 00000000..fa55f1cb --- /dev/null +++ "b/docs/\347\211\271\347\202\271/plan_mode.md" @@ -0,0 +1,275 @@ +# Plan Mode 工作流 + +Plan mode 是 Astrcode 内置的三种治理模式之一(`code`、`plan`、`review`(未完善)),通过模式切换和计划工件(plan artifact)实现"先规划后执行"的工作流。 + +## 整体流程 + +``` +用户发起任务 + │ + ▼ +LLM 判断需要规划 + │ + ▼ enterPlanMode / /mode plan +ModeChanged: code -> plan + │ + ▼ 注入 plan mode prompt + plan template +LLM 阅读代码 ──> 起草计划 ──> 自审 ──> 修订 + │ │ + │ ◄────────────────────────┘ + │ (循环直到计划可执行) + ▼ +exitPlanMode(第一次) + │ 结构校验 + 内审 checkpoint + ▼ 返回 review pending +exitPlanMode(第二次) + │ status -> awaiting_approval + ▼ ModeChanged: plan -> code +计划展示给用户 + │ + ▼ 用户输入 "同意" / "approved" +status -> approved + │ 创建归档快照 + ▼ 注入 plan exit declaration +LLM 进入 code mode,按计划执行 +``` + +## 模式定义 + +### 能力面约束 + +Plan mode 通过 `CapabilitySelector` 收缩工具面(`crates/application/src/mode/catalog.rs`): + +``` +可见工具 = AllTools + - SideEffect(Local | Workspace | External) + - Tag("agent") + + Name("exitPlanMode") + + Name("upsertSessionPlan") +``` + +即:**只暴露只读工具 + 两个计划专属工具**,禁止文件写入、外部调用和 agent 委派。 + +### 治理策略 + +| 属性 | 值 | +|------|-----| +| 子 session 委派 | 禁止(`allow_delegation: false`) | +| 子 session 约束 | restricted | +| Turn 并发策略 | `RejectOnBusy`(拒绝并发,不分支) | +| 可切换目标 | `code`、`plan`、`review` 均可 | + +## 计划工件 + +### 存储路径 + +``` +<project>/.astrcode/sessions/<session-id>/plan/ + <slug>.md # 计划内容(Markdown) + state.json # 计划状态元数据 +``` + +归档快照存储在: +``` +<project>/.astrcode/plan-archives/<timestamp>-<slug>/ + plan.md # 归档的计划 Markdown + metadata.json # 归档元数据 +``` + +### 状态模型 + +`SessionPlanState`(`crates/core/src/session_plan.rs`): + +```rust +pub enum SessionPlanStatus { + Draft, // 草稿:LLM 编辑中 + AwaitingApproval, // 等待用户审批 + Approved, // 已批准:开始执行 + Completed, // 已完成 + Superseded, // 被新计划取代 +} +``` + +每个 session 有且仅有一个 canonical plan(单一真相)。同一任务反复修订覆盖同一 plan;用户切换任务时,LLM 覆盖旧 plan。 + +### 计划模板 + +计划 Markdown 必须遵循以下结构(`crates/application/src/mode/builtin_prompts/plan_template.md`): + +```markdown +# Plan: <title> + +## Context +(背景与当前状态) + +## Goal +(目标) + +## Scope +(范围) + +## Non-Goals +(不做的事) + +## Existing Code To Reuse +(可复用的现有代码) + +## Implementation Steps +(具体实施步骤) + +## Verification +(验证方法) + +## Open Questions +(待确认问题) +``` + +退出 plan mode 时,系统会校验以下必要章节必须存在: + +| 章节 | 必须存在 | 必须包含可执行项 | +|------|---------|----------------| +| `## Context` | 是 | 否 | +| `## Goal` | 是 | 否 | +| `## Existing Code To Reuse` | 是 | 否 | +| `## Implementation Steps` | 是 | 是 | +| `## Verification` | 是 | 是 | + +"可执行项"指章节内容必须包含以 `- `、`* ` 或数字开头的行。 + +## 两个计划专属工具 + +### `upsertSessionPlan` + +创建或更新计划工件。参数:`{ title, content, status? }`。 + +- 从 title 推导 slug(小写、连字符分隔、最长 48 字符) +- 写入 `<slug>.md` 和 `state.json` +- 每次写入重置 `reviewed_plan_digest`(触发重新审核循环) + +### `exitPlanMode` + +退出 plan mode 的审批门控,包含两阶段校验: + +**第一阶段:结构校验** + +检查计划 Markdown 包含所有必要章节且关键章节包含可执行项。校验失败返回 `sessionPlanExitReviewPending`(kind: `revise_plan`),列出缺失章节或无效章节。 + +**第二阶段:内审 checkpoint** + +计算计划内容的 FNV-1a-64 摘要。如果摘要与 `state.reviewed_plan_digest` 不同: + +1. 保存摘要到 state +2. 返回 `sessionPlanExitReviewPending`(kind: `final_review`) +3. LLM 必须再次调用 `exitPlanMode` + +摘要相同(第二次调用)时: + +1. 设置 `status = AwaitingApproval` +2. 触发 `ModeChanged { from: plan, to: code }` 事件 +3. 返回 `sessionPlanExit`(包含完整计划内容) + +这个两阶段设计确保 LLM 在展示计划给用户之前至少自审了一次。 + +## Prompt 注入 + +Plan mode 通过 prompt program 注入四组声明(`crates/application/src/mode/builtin_prompts/`): + +### Plan Mode Prompt(`plan_mode.md`) + +注入时机:进入 plan mode 后的每一 turn。 + +核心约束: +- 必须先阅读代码再起草计划 +- 必须通过 `upsertSessionPlan` 写入计划 +- 不允许执行任何实现工作 +- 必须在计划可执行后才调用 `exitPlanMode` +- 退出前必须进行内部自审 +- 退出后必须向用户总结计划并请求批准 +- 不允许静默切换到执行模式 + +### Plan Re-entry Prompt(`plan_mode_reentry.md`) + +注入时机:session 已有 plan artifact 且重新进入 plan mode。 + +指导 LLM 先读取当前计划,同一任务则修订,不同任务则覆盖。 + +### Plan Exit Prompt(`plan_mode_exit.md`) + +注入时机:用户批准计划后,LLM 回到 code mode。 + +``` +The session has exited plan mode and is now back in code mode. + +Execution contract: +- Use the approved session plan artifact as the primary implementation reference. +- The user approval already happened; do not ask for plan approval again. +- Start implementation immediately unless the user message clearly requests more planning. +``` + +### Plan Template(`plan_template.md`) + +注入时机:plan mode 首次进入且无现有计划。 + +提供计划 Markdown 的骨架模板。 + +## 用户审批 + +用户审批是文本匹配机制(`crates/application/src/session_use_cases.rs`),当 `status == AwaitingApproval` 时检查用户消息: + +| 语言 | 批准关键词 | +|------|-----------| +| 英文 | `approved`、`go ahead`、`implement it` | +| 中文 | `同意`、`可以`、`按这个做`、`开始实现` | + +匹配后系统: +1. 调用 `mark_active_session_plan_approved()`(status -> `Approved`) +2. 如果仍处于 plan mode,自动切换到 code mode +3. 创建计划归档快照(`write_plan_archive_snapshot`) +4. 注入 `build_plan_exit_declaration` 到 prompt + +没有显式"拒绝"操作——用户直接给出修改意见,LLM 继续留在 plan mode 修订计划。 + +## 模式切换机制 + +### 进入 plan mode + +三种途径: + +1. **LLM 主动切换**:在 code mode 中调用 `enterPlanMode({ reason: "..." })` 工具 +2. **用户命令**:`/mode plan` slash 命令,走统一治理入口校验 +3. **自动切换**:审批边界自动切换(不常见) + +所有路径都通过 `validate_mode_transition()` 校验,然后触发 `ModeChanged` 事件。 + +### 退出 plan mode + +- `exitPlanMode` 工具:`plan -> code`,需要通过两阶段校验 +- 用户审批后自动切换 + +### 事件持久化 + +`ModeChanged` 事件(`crates/core/src/event/types.rs`)记录到 JSONL event log: + +```rust +ModeChanged { + from: ModeId, + to: ModeId, + timestamp: DateTime<Utc>, +} +``` + +`AgentStateProjector` 在 apply 时更新 `mode_id` 字段。旧 session 不含此事件时回退到默认 mode。 + +## 设计要点 + +### 为什么需要两阶段 exitPlanMode + +第一次调用是结构校验 + 强制自审 checkpoint。LLM 可能在自审中发现计划不完善并修订。第二次调用确认计划内容未变(摘要匹配),才真正提交给用户。这避免了 LLM 直接把粗略计划甩给用户的情况。 + +### 为什么不用显式审批事件 + +计划生命周期通过 `state.json` 和工具返回元数据(`sessionPlanExit`、`sessionPlanExitReviewPending`)追踪,不需要独立的 `PlanCreated`/`PlanApproved` 存储事件。审批语义已由 `ModeChanged` + `upsertSessionPlan` 的状态变更覆盖。 + +### 为什么限制为单一 canonical plan + +避免多计划导致的混乱和选择困难。同一任务保持单一 plan,迭代修订。切换任务时覆盖,简洁明确。 diff --git "a/docs/\347\211\271\347\202\271/server.md" "b/docs/\347\211\271\347\202\271/server.md" new file mode 100644 index 00000000..32a0a8cd --- /dev/null +++ "b/docs/\347\211\271\347\202\271/server.md" @@ -0,0 +1,333 @@ +# Server-First 前后端分离架构 + +Astrcode 采用 **Server Is The Truth** 原则:所有业务逻辑(会话管理、LLM 调用、工具执行、治理决策)都在后端 HTTP/SSE 服务器中完成,前端和 Tauri 外壳不绕过服务器直接调用运行时。 + +## 架构总览 + +三个部署形态共用同一个后端: + +``` +┌─────────────────────────────────────────────────────────┐ +│ Rust 后端服务器 │ +│ (crates/server, Axum) │ +│ │ +│ ┌──────────┐ ┌──────────────┐ ┌───────────────────┐ │ +│ │ REST API │ │ SSE 流式端点 │ │ 静态资源托管 │ │ +│ └────┬─────┘ └──────┬───────┘ └───────────────────┘ │ +│ │ │ │ +│ ┌────┴───────────────┴─────────────────────────────┐ │ +│ │ Application Layer (use cases) │ │ +│ │ ┌──────────┐ ┌──────────┐ ┌───────────────┐ │ │ +│ │ │ Session │ │ Agent │ │ Governance │ │ │ +│ │ │ Runtime │ │ Exec │ │ Mode System │ │ │ +│ │ └──────────┘ └──────────┘ └───────────────┘ │ │ +│ └──────────────────────────────────────────────────┘ │ +│ ┌──────────────────────────────────────────────────┐ │ +│ │ Event Log (JSONL) + CQRS Projections │ │ +│ └──────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────┘ + ▲ ▲ ▲ + │ HTTP/SSE │ HTTP/SSE │ HTTP/SSE + ┌──────┴──────┐ ┌────────┴──────┐ ┌───┴────┐ + │ Tauri │ │ Browser │ │ CLI │ + │ Desktop │ │ Dev Mode │ │ Client │ + │ (WebView) │ │ (Vite :5173) │ │ │ + └─────────────┘ └───────────────┘ └────────┘ +``` + +## 后端(Rust / Axum) + +### 启动流程 + +服务器启动时(`crates/server/src/main.rs`): + +1. Bootstrap 运行时(加载插件、初始化 LLM provider、MCP server) +2. 绑定 `127.0.0.1:0`(随机端口) +3. 生成 bootstrap token(32 字节 hex,24 小时 TTL) +4. 写入 `~/.astrcode/run.json`(port、token、pid、startedAt、expiresAtMs) +5. 在 stdout 输出结构化 JSON ready 事件 +6. 启动 HTTP 服务,监听优雅关闭信号 + +### API 路由 + +路由定义在 `crates/server/src/http/routes/mod.rs`: + +**会话管理:** + +| 方法 | 路径 | 用途 | +|------|------|------| +| POST | `/api/sessions` | 创建会话 | +| GET | `/api/sessions` | 列出会话 | +| DELETE | `/api/sessions/{id}` | 删除会话 | +| POST | `/api/sessions/{id}/fork` | 分叉会话 | +| GET | `/api/session-events` | SSE 会话目录事件流 | + +**对话交互:** + +| 方法 | 路径 | 用途 | +|------|------|------| +| POST | `/api/sessions/{id}/prompts` | 提交用户 prompt | +| POST | `/api/sessions/{id}/compact` | 触发上下文压缩 | +| POST | `/api/sessions/{id}/interrupt` | 中断执行 | +| GET/POST | `/api/sessions/{id}/mode` | 查询/切换治理模式 | +| GET | `/api/v1/conversation/sessions/{id}/snapshot` | 获取对话快照 | +| GET | `/api/v1/conversation/sessions/{id}/stream` | SSE 对话增量流 | + +**配置与模型:** + +| 方法 | 路径 | 用途 | +|------|------|------| +| GET/POST | `/api/config` | 获取/更新配置 | +| POST | `/api/config/reload` | 热重载配置 | +| GET | `/api/models` | 列出可用模型 | +| GET | `/api/modes` | 列出治理模式 | + +**Agent 编排:** + +| 方法 | 路径 | 用途 | +|------|------|------| +| POST | `/api/v1/agents/{id}/execute` | 执行 agent | +| GET | `/api/v1/sessions/{id}/subruns/{sub_run_id}` | 子运行状态 | +| POST | `/api/v1/sessions/{id}/agents/{agent_id}/close` | 关闭 agent 树 | + +### 认证 + +三级 token 体系(`crates/server/src/http/auth.rs`): + +``` +Bootstrap Token (24h) + │ + ▼ POST /api/auth/exchange +Session Token + │ + ▼ x-astrcode-token header (或 SSE ?token= 参数) +Per-Request 认证 +``` + +- **Desktop**:Tauri 通过 `window.__ASTRCODE_BOOTSTRAP__` 注入 token +- **Browser dev**:前端从 `GET /__astrcode__/run-info` 获取 token + +## 通信协议 + +### REST + SSE,不使用 WebSocket + +所有通信基于 HTTP。请求-响应操作走 REST JSON,实时推送走 SSE。 + +### 对话协议:Snapshot + Delta + +前端获取对话状态分两步(`crates/protocol/src/http/conversation/v1.rs`): + +**第一步:获取快照** + +``` +GET /api/v1/conversation/sessions/{id}/snapshot +``` + +返回完整的对话状态: + +```rust +pub struct ConversationSnapshotResponseDto { + pub session_id: String, + pub cursor: ConversationCursorDto, // 游标(用于后续 SSE 续接) + pub phase: PhaseDto, // 当前阶段 + pub control: ConversationControlStateDto, // 控制状态 + pub blocks: Vec<ConversationBlockDto>, // 对话块列表 + pub child_summaries: Vec<...>, // 子 agent 摘要 + pub slash_candidates: Vec<...>, // slash 命令候选 + pub banner: Option<...>, // 顶部提示横幅 +} +``` + +**第二步:订阅增量流** + +``` +GET /api/v1/conversation/sessions/{id}/stream +``` + +SSE 推送 `ConversationDeltaDto`,8 种增量类型: + +| Delta 类型 | 用途 | +|-----------|------| +| `append_block` | 新增对话块(用户消息、助手回复、工具调用等) | +| `patch_block` | 增量更新已有块(markdown 追加、工具流式输出) | +| `complete_block` | 块完成(streaming -> complete/failed) | +| `update_control_state` | 阶段变更(thinking、idle 等) | +| `upsert_child_summary` | 子 agent 状态更新 | +| `remove_child_summary` | 子 agent 移除 | +| `set_banner` / `clear_banner` | 提示横幅 | +| `rehydrate_required` | 客户端需要重新获取快照 | + +### 对话块类型 + +`ConversationBlockDto` 是前端渲染的基本单元: + +| 块类型 | 说明 | +|--------|------| +| `User` | 用户消息 | +| `Assistant` | LLM 输出(支持流式追加) | +| `Thinking` | 扩展思考内容 | +| `Plan` | 会话计划引用 | +| `ToolCall` | 工具执行(含流式 stdout/stderr) | +| `Error` | 错误展示 | +| `SystemNote` | 系统备注(如 compact summary) | +| `ChildHandoff` | 子 agent 委派/归还通知 | + +工具调用块的流式输出通过 `patch_block` 的 `AppendToolStream` 增量实现,支持 stdout 和 stderr 两个流。 + +### 游标续接 + +每个 SSE 事件携带 `id:` 字段(格式如 `"1.42"`,即 `{storage_seq}.{subindex}`)。断线重连时,客户端通过 `Last-Event-ID` header 或 `?cursor=` 参数发送上次游标,服务器只回放缺失的事件。 + +### 一次对话 turn 的完整流程 + +``` +前端 后端 + │ │ + │ POST /prompts {text} │ + │ ─────────────────────────────> │ 追加 UserMessage 事件 + │ 202 Accepted {turnId} │ 启动 turn 执行 + │ <───────────────────────────── │ + │ │ + │ SSE: update_control_state │ phase -> Thinking + │ <────────────────────────────── │ + │ SSE: append_block Assistant │ LLM 开始输出 + │ <────────────────────────────── │ + │ SSE: patch_block markdown │ 流式文本追加 + │ <────────────────────────────── │ + │ SSE: append_block ToolCall │ 工具调用开始 + │ <────────────────────────────── │ + │ SSE: patch_block tool_stream │ 工具 stdout/stderr 流 + │ <────────────────────────────── │ + │ SSE: complete_block │ 工具完成 + │ <────────────────────────────── │ + │ SSE: patch_block markdown │ LLM 继续输出 + │ <────────────────────────────── │ + │ SSE: update_control_state │ phase -> Idle + │ <────────────────────────────── │ +``` + +## 前端(React + TypeScript) + +### 技术栈 + +- **框架**:React 18 + TypeScript +- **构建**:Vite +- **样式**:Tailwind CSS +- **状态管理**:原生 `useReducer`(无 Redux/Zustand) +- **SSE 消费**:手动 `ReadableStream` 解析(`frontend/src/lib/sse/consumer.ts`) + +### 核心模块 + +``` +frontend/src/ +├── App.tsx # 根组件,useReducer 状态管理 +├── types.ts # 共享 TypeScript 类型 +├── store/reducer.ts # 中心状态 reducer +├── hooks/ +│ ├── useAgent.ts # 核心编排 hook:对话流连接、API 调用 +│ ├── app/ +│ │ ├── useSessionCoordinator.ts # 会话生命周期管理 +│ │ └── useComposerActions.ts # 用户输入处理 +│ └── useSessionCatalogEvents.ts # 会话目录 SSE 订阅 +├── lib/ +│ ├── api/ +│ │ ├── client.ts # HTTP 客户端(auth header 注入) +│ │ ├── sessions.ts # 会话 CRUD +│ │ ├── conversation.ts # 对话快照/流解析 -> 前端 Message[] +│ │ ├── config.ts # 配置 API +│ │ └── models.ts # 模型 API +│ ├── sse/consumer.ts # SSE 流解析器 +│ ├── serverAuth.ts # Bootstrap token 获取与交换 +│ └── hostBridge.ts # Desktop/Browser 能力抽象 +``` + +### 状态同步 + +遵循 **Authoritative Server / Projected Client** 模型: + +1. **快照全量 + 流式增量**:前端先获取完整快照,再订阅 SSE 增量 +2. **客户端投影**:`applyConversationEnvelope()` 在前端将 delta 应用到本地状态,`projectConversationState()` 推导出扁平的 `Message[]` 数组用于渲染 +3. **指纹去重**:`projectionSignature()` 基于内容哈希跳过冗余状态更新 +4. **渲染批处理**:SSE 事件通过 `requestAnimationFrame` 批量应用,避免 React 渲染洪水 +5. **自动重连**:指数退避(500ms 基础,5s 上限,3 次失败后放弃),使用 `Last-Event-ID` 续接 + +### HostBridge 抽象 + +`hostBridge.ts` 提供跨平台能力抽象: + +- **Desktop**(Tauri):通过 `invoke()` 调用 Tauri 命令(窗口控制、目录选择器) +- **Browser**:降级为 Web API(`window.open`、`<input type="file">`) + +前端通过统一接口调用,不感知运行环境。 + +## Tauri 外壳 + +Tauri 是**纯薄壳**,不含业务逻辑(`src-tauri/src/main.rs`)。 + +### 职责 + +1. **单实例协调**:`DesktopInstanceCoordinator` 确保只有一个桌面实例运行 +2. **Sidecar 启动**:启动 `astrcode-server` 作为子进程 +3. **Bootstrap 注入**:将 token 注入 `window.__ASTRCODE_BOOTSTRAP__` +4. **窗口生命周期**:创建/管理主窗口 + +### 五个 Tauri 命令(`src-tauri/src/commands.rs`) + +全部是 GUI 相关: + +- `minimize_window` / `maximize_window` / `close_window` — 窗口控制 +- `select_directory` — 原生目录选择器 +- `open_config_in_editor` — 在系统编辑器中打开配置文件 + +### Sidecar 通信协议 + +``` +1. Tauri 启动 astrcode-server 子进程 +2. Server 在 stdout 输出 ready JSON:{"ready": true, "port": N, "pid": P} +3. Tauri 解析输出,轮询 HTTP 端口就绪 +4. Server 写入 ~/.astrcode/run.json(供 browser dev 桥接) +5. 退出时 Tauri 释放子进程句柄,server 检测 stdin EOF 优雅关闭 +``` + +## 协议层(crates/protocol) + +`crates/protocol/` 定义所有前后端共享的 wire-format DTO: + +``` +crates/protocol/src/http/ +├── auth.rs # 认证交换 DTO +├── conversation/v1.rs # 对话协议(快照 + delta DTO) +├── session.rs # 会话管理 DTO +├── event.rs # Agent 事件 DTO +├── session_event.rs # 会话目录事件信封 +├── config.rs # 配置 DTO +├── model.rs # 模型信息 DTO +├── composer.rs # 输入补全 DTO +├── agent.rs # Agent profile DTO +├── tool.rs # 工具描述 DTO +├── runtime.rs # 运行时状态/指标 DTO +└── terminal/ # CLI 终端投影 DTO +``` + +协议版本常量 `PROTOCOL_VERSION = 1`,包含在目录事件信封中。 + +## 设计决策与权衡 + +### 为什么选 REST + SSE 而非 WebSocket + +- **SSE 天然支持游标续接**:每个事件有 `id`,断线重连只需发送 `Last-Event-ID`,服务端精确回放缺失事件 +- **单向推送模型匹配实际需求**:对话场景是"客户端请求 -> 服务端长时流式响应",不需要全双工 +- **HTTP 生态友好**:负载均衡、代理、调试工具都原生支持 SSE,WebSocket 需要额外配置 +- **事件溯源天然适配**:event log 中的 `storage_seq` 直接映射为 SSE 游标 + +### 为什么 Tauri 只做薄壳 + +- **部署一致性**:Desktop、Browser、CLI 三个入口走同一个 HTTP API,行为完全一致 +- **独立演进**:前端可以不依赖 Tauri 版本独立更新,后端也可以独立迭代 +- **测试简化**:后端测试只需要 HTTP 测试,不需要 Tauri WebView 环境 + +### 为什么前端状态管理用 useReducer + +- **应用状态维度有限**:会话列表、当前会话、UI 阶段——不需要全局状态库的复杂度 +- **服务端权威**:关键状态在后端 event log 中,前端只是投影,本地状态相对简单 +- **避免间接层**:reducer 的 action 直接映射后端 DTO,调试路径清晰 diff --git a/examples/example-plugin/src/main.rs b/examples/example-plugin/src/main.rs index 9eeb2a76..8b7af1eb 100644 --- a/examples/example-plugin/src/main.rs +++ b/examples/example-plugin/src/main.rs @@ -5,14 +5,12 @@ use std::{ sync::Arc, }; -use astrcode_core::{AstrError, CancelToken, CapabilitySpec, Result}; -use astrcode_plugin::{ - CapabilityHandler, CapabilityRouter, EventEmitter, Worker, descriptor_to_spec, -}; -use astrcode_protocol::plugin::{ - CapabilityDescriptor, CapabilityKind, InvocationContext, PeerDescriptor, PeerRole, - SideEffectLevel, StabilityLevel, +use astrcode_core::{ + AstrError, CancelToken, CapabilityKind, CapabilitySpec, InvocationMode, Result, SideEffect, + Stability, }; +use astrcode_plugin::{CapabilityHandler, CapabilityRouter, EventEmitter, Worker}; +use astrcode_protocol::plugin::{InvocationContext, PeerDescriptor, PeerRole}; use astrcode_sdk::{ DeserializeOwned, PluginContext, Serialize, StreamWriter, ToolHandler, ToolRegistration, ToolResult, @@ -40,8 +38,7 @@ impl RegisteredToolAdapter { #[async_trait] impl CapabilityHandler for RegisteredToolAdapter { fn capability_spec(&self) -> CapabilitySpec { - descriptor_to_spec(self.registration.descriptor()) - .expect("tool descriptor from SDK must map to core capability spec") + self.registration.descriptor().clone() } async fn invoke( @@ -53,7 +50,7 @@ impl CapabilityHandler for RegisteredToolAdapter { ) -> Result<Value> { let plugin_context = PluginContext::from(context); let stream = StreamWriter::default(); - let tool_name = self.registration.descriptor().name.clone(); + let tool_name = self.registration.descriptor().name.to_string(); if cancel.is_cancelled() { return Err(AstrError::Cancelled); } @@ -83,34 +80,31 @@ impl CapabilityHandler for RegisteredToolAdapter { struct WorkspaceSummaryTool; impl ToolHandler for WorkspaceSummaryTool { - fn descriptor(&self) -> CapabilityDescriptor { - CapabilityDescriptor { - name: "workspace.summary".to_string(), - kind: CapabilityKind::tool(), - description: "Summarize the active coding workspace".to_string(), - input_schema: json!({ - "type": "object", - "properties": {} - }), - output_schema: json!({ - "type": "object", - "properties": { - "workspaceRoot": { "type": "string" }, - "entries": { "type": "array" }, - "markerFiles": { "type": "array" } - } - }), - streaming: false, - concurrency_safe: true, - compact_clearable: false, - profiles: vec!["coding".to_string()], - tags: vec!["example".to_string(), "workspace".to_string()], - permissions: vec![], - side_effect: SideEffectLevel::None, - stability: StabilityLevel::Stable, - metadata: Value::Null, - max_result_inline_size: None, - } + fn descriptor(&self) -> CapabilitySpec { + CapabilitySpec::builder("workspace.summary", CapabilityKind::tool()) + .description("Summarize the active coding workspace") + .schema( + json!({ + "type": "object", + "properties": {} + }), + json!({ + "type": "object", + "properties": { + "workspaceRoot": { "type": "string" }, + "entries": { "type": "array" }, + "markerFiles": { "type": "array" } + } + }), + ) + .invocation_mode(InvocationMode::Unary) + .concurrency_safe(true) + .profiles(["coding"]) + .tags(["example", "workspace"]) + .side_effect(SideEffect::None) + .stability(Stability::Stable) + .build() + .expect("workspace summary capability spec should build") } fn execute( @@ -169,38 +163,35 @@ impl ToolHandler for WorkspaceSummaryTool { struct FilePreviewTool; impl ToolHandler for FilePreviewTool { - fn descriptor(&self) -> CapabilityDescriptor { - CapabilityDescriptor { - name: "file.preview".to_string(), - kind: CapabilityKind::tool(), - description: "Read a short preview from a file inside the active workspace".to_string(), - input_schema: json!({ - "type": "object", - "required": ["path"], - "properties": { - "path": { "type": "string" }, - "maxLines": { "type": "integer", "minimum": 1, "maximum": 200 } - } - }), - output_schema: json!({ - "type": "object", - "properties": { - "path": { "type": "string" }, - "preview": { "type": "string" }, - "truncated": { "type": "boolean" } - } - }), - streaming: false, - concurrency_safe: true, - compact_clearable: false, - profiles: vec!["coding".to_string()], - tags: vec!["example".to_string(), "file".to_string()], - permissions: vec![], - side_effect: SideEffectLevel::None, - stability: StabilityLevel::Stable, - metadata: Value::Null, - max_result_inline_size: None, - } + fn descriptor(&self) -> CapabilitySpec { + CapabilitySpec::builder("file.preview", CapabilityKind::tool()) + .description("Read a short preview from a file inside the active workspace") + .schema( + json!({ + "type": "object", + "required": ["path"], + "properties": { + "path": { "type": "string" }, + "maxLines": { "type": "integer", "minimum": 1, "maximum": 200 } + } + }), + json!({ + "type": "object", + "properties": { + "path": { "type": "string" }, + "preview": { "type": "string" }, + "truncated": { "type": "boolean" } + } + }), + ) + .invocation_mode(InvocationMode::Unary) + .concurrency_safe(true) + .profiles(["coding"]) + .tags(["example", "file"]) + .side_effect(SideEffect::None) + .stability(Stability::Stable) + .build() + .expect("file preview capability spec should build") } fn execute( diff --git a/frontend/debug.html b/frontend/debug.html deleted file mode 100644 index e19fedea..00000000 --- a/frontend/debug.html +++ /dev/null @@ -1,53 +0,0 @@ -<!doctype html> -<html lang="zh-CN"> - <head> - <meta charset="UTF-8" /> - <meta name="viewport" content="width=device-width, initial-scale=1.0" /> - <title>AstrCode Debug Workbench - - -
-
-
-
- Debug Only -
-

Debug Workbench

-

- 正在加载调试工作台。如果停留太久,请查看 DevTools Console 或桌面端日志。 -

-
-
-
- - - diff --git a/frontend/index.html b/frontend/index.html index d474a472..4e98ec39 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,6 +3,7 @@ + AstrCode diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico new file mode 100644 index 00000000..246747c3 Binary files /dev/null and b/frontend/public/favicon.ico differ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 926e5b82..e7b24f96 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -11,7 +11,6 @@ import { useComposerActions, type ConfirmDialogState } from './hooks/app/useComp import { useSessionCoordinator } from './hooks/app/useSessionCoordinator'; import { useSubRunNavigation } from './hooks/app/useSubRunNavigation'; import { forgetProject } from './lib/knownProjects'; -import { isDebugWorkbenchEnabled } from './lib/debugWorkbench'; import { logger } from './lib/logger'; import { buildSessionViewLocationHref, readSessionViewLocation } from './lib/sessionView'; import { cn } from './lib/utils'; @@ -24,6 +23,7 @@ export default function App() { const [state, dispatch] = useReducer(reducer, undefined, makeInitialState); const [showSettings, setShowSettings] = useState(false); const [modelRefreshKey, setModelRefreshKey] = useState(0); + const [activeModeId, setActiveModeId] = useState(null); // 确认对话框状态(替代 window.confirm) const [confirmDialog, setConfirmDialog] = useState(null); const activeSessionIdRef = useRef(state.activeSessionId); @@ -64,6 +64,7 @@ export default function App() { const { createSession, + forkSession, listSessionsWithMeta, loadConversationView, connectSession, @@ -72,6 +73,7 @@ export default function App() { interrupt, cancelSubRun, compactSession, + getSessionMode, deleteSession, deleteProject, listComposerOptions, @@ -81,13 +83,19 @@ export default function App() { setModel, getCurrentModel, listAvailableModels, + switchSessionMode, testConnection, openConfigInEditor, selectDirectory, hostBridge, } = useAgent(); - const { activeSubRunChildren, loadAndActivateSession, refreshSessions } = useSessionCoordinator({ + const { + activeSubRunChildren, + activeConversationControl, + loadAndActivateSession, + refreshSessions, + } = useSessionCoordinator({ dispatch, activeSessionIdRef, activeSubRunPathRef, @@ -125,9 +133,41 @@ export default function App() { state.projects.find((project) => project.id === state.activeProjectId) ?? null; const activeSession = activeProject?.sessions.find((session) => session.id === state.activeSessionId) ?? null; - const debugWorkbenchEnabled = isDebugWorkbenchEnabled(); const activeSubRunThreadTree = activeSession?.subRunThreadTree ?? null; + useEffect(() => { + const sessionId = activeSession?.id; + if (!sessionId) { + setActiveModeId(null); + return; + } + + let cancelled = false; + void (async () => { + try { + const mode = await getSessionMode(sessionId); + if (!cancelled) { + setActiveModeId(mode.currentModeId); + } + } catch (error) { + if (!cancelled) { + logger.warn('App', 'Failed to load session mode:', error); + setActiveModeId(null); + } + } + })(); + + return () => { + cancelled = true; + }; + }, [activeSession?.id, getSessionMode]); + + useEffect(() => { + if (activeConversationControl?.currentModeId) { + setActiveModeId(activeConversationControl.currentModeId); + } + }, [activeConversationControl?.currentModeId]); + useEffect(() => { if (!activeSubRunThreadTree) { return; @@ -194,6 +234,22 @@ export default function App() { [refreshSessions] ); + const handleForkFromTurn = useCallback( + async (turnId: string) => { + const sessionId = activeSessionIdRef.current; + if (!sessionId) { + return; + } + try { + const forked = await forkSession(sessionId, { turnId }); + await refreshSessions({ preferredSessionId: forked.sessionId }); + } catch (error) { + logger.error('App', 'Failed to fork session:', error); + } + }, + [forkSession, refreshSessions] + ); + useSessionCatalogEvents({ onEvent: handleSessionCatalogEvent, onResync: () => { @@ -212,6 +268,22 @@ export default function App() { [loadAndActivateSession] ); + const handleSwitchMode = useCallback( + async (modeId: string) => { + const sessionId = activeSessionIdRef.current; + if (!sessionId) { + return; + } + try { + const mode = await switchSessionMode(sessionId, modeId); + setActiveModeId(mode.currentModeId); + } catch (error) { + logger.error('App', 'Failed to switch session mode:', error); + } + }, + [switchSessionMode] + ); + const { handleOpenSubRun, handleCloseSubRun, handleNavigateSubRunPath, handleOpenChildSession } = useSubRunNavigation({ activeProjectId: state.activeProjectId, @@ -250,9 +322,13 @@ export default function App() { projectName: activeProject?.name ?? null, sessionId: activeSession?.id ?? null, sessionTitle: activeSession?.title ?? null, + // Why: mode 切换 API 会立即返回新 mode,但 conversation control 流可能稍后才追上。 + // 这里优先使用本地已确认的 activeModeId,避免右上角 badge 被旧的流式快照短暂覆盖。 + currentModeId: activeModeId ?? activeConversationControl?.currentModeId ?? null, isChildSession: activeSession?.parentSessionId !== undefined, workingDir: activeProject?.workingDir ?? '', phase: state.phase, + conversationControl: activeConversationControl, activeSubRunPath: state.activeSubRunPath, activeSubRunTitle: activeSubRunView?.title ?? null, activeSubRunBreadcrumbs, @@ -262,7 +338,9 @@ export default function App() { onCloseSubRun: handleCloseSubRun, onNavigateSubRunPath: handleNavigateSubRunPath, onOpenChildSession: handleOpenChildSession, + onForkFromTurn: handleForkFromTurn, onSubmitPrompt: handleSubmit, + onSwitchMode: handleSwitchMode, onInterrupt: handleInterrupt, onCancelSubRun: cancelSubRun, listComposerOptions, @@ -274,6 +352,8 @@ export default function App() { [ activeProject?.name, activeProject?.workingDir, + activeConversationControl, + activeModeId, activeSession?.id, activeSession?.parentSessionId, activeSession?.title, @@ -285,8 +365,10 @@ export default function App() { handleInterrupt, handleNavigateSubRunPath, handleOpenChildSession, + handleForkFromTurn, handleOpenSubRun, handleSubmit, + handleSwitchMode, isSidebarOpen, listAvailableModels, listComposerOptions, @@ -331,22 +413,6 @@ export default function App() { handleDeleteSession(sessionId); }} onOpenSettings={() => setShowSettings(true)} - showDebugWorkbenchEntry={debugWorkbenchEnabled && hostBridge.canOpenDebugWorkbench} - onOpenDebugWorkbench={() => { - void hostBridge.openDebugWorkbench(activeSession?.id ?? null).catch((error) => { - const message = error instanceof Error ? error.message : String(error); - logger.error('App', 'Failed to open Debug Workbench:', error); - setConfirmDialog({ - title: '无法打开 Debug Workbench', - message, - confirmLabel: '知道了', - cancelLabel: '关闭', - onConfirm: () => { - setConfirmDialog(null); - }, - }); - }); - }} onNewSession={() => { void handleNewSession(); }} diff --git a/frontend/src/DebugWorkbenchApp.tsx b/frontend/src/DebugWorkbenchApp.tsx deleted file mode 100644 index 1c6c2ca2..00000000 --- a/frontend/src/DebugWorkbenchApp.tsx +++ /dev/null @@ -1,775 +0,0 @@ -import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; -import { - getDebugRuntimeOverview, - getDebugRuntimeTimeline, - getDebugSessionAgents, - getDebugSessionTrace, -} from './lib/api/runtime'; -import { listSessionsWithMeta } from './lib/api/sessions'; -import { - buildGovernanceSparklinePoints, - formatRatioBps, - isDebugWorkbenchEnabled, -} from './lib/debugWorkbench'; -import { getHostBridge } from './lib/hostBridge'; -import { logger } from './lib/logger'; -import { cn } from './lib/utils'; -import type { - RuntimeDebugOverview, - RuntimeDebugTimeline, - SessionDebugAgentNode, - SessionDebugAgents, - SessionDebugTrace, - SessionMeta, -} from './types'; - -const OVERVIEW_POLL_INTERVAL_MS = 2_000; -const SESSION_POLL_INTERVAL_MS = 2_000; -const SESSION_LIST_POLL_INTERVAL_MS = 5_000; -const TREND_CHART_WIDTH = 720; -const TREND_CHART_HEIGHT = 168; - -function ratioTone(value?: number | null): string { - if (value == null) { - return 'bg-surface text-text-secondary'; - } - if (value >= 7_000) { - return 'bg-success-soft text-success'; - } - if (value >= 4_000) { - return 'bg-info-soft text-info'; - } - return 'bg-danger-soft text-danger'; -} - -function lifecycleTone(lifecycle: string): string { - switch (lifecycle) { - case 'running': - return 'bg-info-soft text-info'; - case 'idle': - return 'bg-success-soft text-success'; - case 'terminated': - return 'bg-surface text-text-secondary'; - default: - return 'bg-warning-soft text-warning'; - } -} - -function formatTimestamp(value?: string | null): string { - if (!value) { - return '—'; - } - const date = new Date(value); - if (Number.isNaN(date.getTime())) { - return value; - } - return date.toLocaleString('zh-CN', { - hour12: false, - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - }); -} - -function readInitialSessionId(): string | null { - const query = new URLSearchParams(window.location.search); - const sessionId = query.get('sessionId')?.trim(); - return sessionId || null; -} - -function updateWorkbenchQuery(sessionId: string | null): void { - const url = new URL(window.location.href); - if (sessionId) { - url.searchParams.set('sessionId', sessionId); - } else { - url.searchParams.delete('sessionId'); - } - window.history.replaceState({}, document.title, `${url.pathname}${url.search}${url.hash}`); -} - -function buildSessionListSignature(sessions: SessionMeta[]): string { - return sessions - .map( - (session) => - `${session.sessionId}|${session.updatedAt}|${session.phase}|${session.title}|${session.parentSessionId ?? ''}` - ) - .join('\n'); -} - -function buildSessionTraceSignature(trace: SessionDebugTrace | null): string { - if (!trace) { - return ''; - } - return [ - trace.sessionId, - trace.phase, - trace.parentSessionId ?? '', - ...trace.items.map((item) => - [ - item.id, - item.storageSeq, - item.turnId ?? '', - item.recordedAt ?? '', - item.kind, - item.title, - item.summary, - item.agentId ?? '', - item.subRunId ?? '', - item.childAgentId ?? '', - item.deliveryId ?? '', - item.toolCallId ?? '', - item.toolName ?? '', - item.lifecycle ?? '', - item.lastTurnOutcome ?? '', - ].join('|') - ), - ].join('\n'); -} - -function buildSessionAgentsSignature(agents: SessionDebugAgents | null): string { - if (!agents) { - return ''; - } - return [ - agents.sessionId, - agents.title, - ...agents.nodes.map((node) => - [ - node.nodeId, - node.kind, - node.title, - node.agentId, - node.sessionId, - node.childSessionId ?? '', - node.subRunId ?? '', - node.parentAgentId ?? '', - node.parentSessionId ?? '', - node.depth, - node.lifecycle, - node.lastTurnOutcome ?? '', - node.statusSource ?? '', - node.lineageKind ?? '', - ].join('|') - ), - ].join('\n'); -} - -export default function DebugWorkbenchApp() { - const [overview, setOverview] = useState(null); - const [timeline, setTimeline] = useState(null); - const [sessions, setSessions] = useState([]); - const [selectedSessionId, setSelectedSessionId] = useState(() => - readInitialSessionId() - ); - const [trace, setTrace] = useState(null); - const [agents, setAgents] = useState(null); - const [overviewError, setOverviewError] = useState(null); - const [sessionError, setSessionError] = useState(null); - const [sessionListError, setSessionListError] = useState(null); - const [overviewLoading, setOverviewLoading] = useState(false); - const [sessionLoading, setSessionLoading] = useState(false); - const sessionListSignatureRef = useRef(''); - const traceSignatureRef = useRef(''); - const agentsSignatureRef = useRef(''); - - useEffect(() => { - document.title = 'AstrCode Debug Workbench'; - }, []); - - useEffect(() => { - updateWorkbenchQuery(selectedSessionId); - }, [selectedSessionId]); - - useEffect(() => { - if (!isDebugWorkbenchEnabled()) { - logger.warn('DebugWorkbench', 'debug workbench opened without debug flag'); - } - }, []); - - useEffect(() => { - let cancelled = false; - let unlisten: (() => void) | null = null; - - const bridge = getHostBridge(); - if (!bridge.isDesktopHost) { - return; - } - - void (async () => { - try { - const { listen } = await import('@tauri-apps/api/event'); - unlisten = await listen('debug-workbench:set-session', (event) => { - if (cancelled) { - return; - } - const nextSessionId = typeof event.payload === 'string' ? event.payload.trim() : ''; - if (nextSessionId) { - setSelectedSessionId(nextSessionId); - } - }); - } catch (error) { - logger.warn('DebugWorkbench', 'failed to subscribe to debug workbench session events', { - error: error instanceof Error ? error.message : String(error), - }); - } - })(); - - return () => { - cancelled = true; - unlisten?.(); - }; - }, []); - - useEffect(() => { - let cancelled = false; - let timer: number | null = null; - - const loadSessions = async () => { - try { - const nextSessions = await listSessionsWithMeta(); - if (cancelled) { - return; - } - const nextSignature = buildSessionListSignature(nextSessions); - if (nextSignature !== sessionListSignatureRef.current) { - sessionListSignatureRef.current = nextSignature; - setSessions(nextSessions); - } - setSessionListError(null); - setSelectedSessionId((current) => { - if (current && nextSessions.some((session) => session.sessionId === current)) { - return current; - } - return nextSessions[0]?.sessionId ?? null; - }); - } catch (error) { - if (!cancelled) { - setSessionListError(error instanceof Error ? error.message : String(error)); - } - } finally { - if (!cancelled) { - timer = window.setTimeout(() => { - void loadSessions(); - }, SESSION_LIST_POLL_INTERVAL_MS); - } - } - }; - - void loadSessions(); - - return () => { - cancelled = true; - if (timer !== null) { - window.clearTimeout(timer); - } - }; - }, []); - - useEffect(() => { - let cancelled = false; - let timer: number | null = null; - - const loadOverview = async () => { - setOverviewLoading(true); - try { - const [nextOverview, nextTimeline] = await Promise.all([ - getDebugRuntimeOverview(), - getDebugRuntimeTimeline(), - ]); - if (cancelled) { - return; - } - setOverview(nextOverview); - setTimeline(nextTimeline); - setOverviewError(null); - } catch (error) { - if (!cancelled) { - setOverviewError(error instanceof Error ? error.message : String(error)); - } - } finally { - if (!cancelled) { - setOverviewLoading(false); - timer = window.setTimeout(() => { - void loadOverview(); - }, OVERVIEW_POLL_INTERVAL_MS); - } - } - }; - - void loadOverview(); - - return () => { - cancelled = true; - if (timer !== null) { - window.clearTimeout(timer); - } - }; - }, []); - - useEffect(() => { - if (!selectedSessionId) { - traceSignatureRef.current = ''; - agentsSignatureRef.current = ''; - setTrace(null); - setAgents(null); - setSessionError(null); - setSessionLoading(false); - return; - } - - traceSignatureRef.current = ''; - agentsSignatureRef.current = ''; - setTrace(null); - setAgents(null); - setSessionError(null); - setSessionLoading(true); - let cancelled = false; - let timer: number | null = null; - let isInitialLoad = true; - - const loadSessionDebug = async () => { - try { - const [nextTrace, nextAgents] = await Promise.all([ - getDebugSessionTrace(selectedSessionId), - getDebugSessionAgents(selectedSessionId), - ]); - if (cancelled) { - return; - } - const nextTraceSignature = buildSessionTraceSignature(nextTrace); - if (nextTraceSignature !== traceSignatureRef.current) { - traceSignatureRef.current = nextTraceSignature; - setTrace(nextTrace); - } - const nextAgentsSignature = buildSessionAgentsSignature(nextAgents); - if (nextAgentsSignature !== agentsSignatureRef.current) { - agentsSignatureRef.current = nextAgentsSignature; - setAgents(nextAgents); - } - setSessionError(null); - } catch (error) { - if (!cancelled) { - setSessionError(error instanceof Error ? error.message : String(error)); - } - } finally { - if (!cancelled) { - if (isInitialLoad) { - setSessionLoading(false); - isInitialLoad = false; - } - timer = window.setTimeout(() => { - void loadSessionDebug(); - }, SESSION_POLL_INTERVAL_MS); - } - } - }; - - void loadSessionDebug(); - - return () => { - cancelled = true; - if (timer !== null) { - window.clearTimeout(timer); - } - }; - }, [selectedSessionId]); - - const selectedSessionMeta = useMemo( - () => sessions.find((session) => session.sessionId === selectedSessionId) ?? null, - [selectedSessionId, sessions] - ); - const collaboration = overview?.metrics.agentCollaboration; - const timelineSamples = useMemo( - () => - (timeline?.samples ?? []).map((sample) => ({ - timestamp: new Date(sample.collectedAt).getTime(), - spawnRejectionRatioBps: sample.spawnRejectionRatioBps, - observeToActionRatioBps: sample.observeToActionRatioBps, - childReuseRatioBps: sample.childReuseRatioBps, - })), - [timeline] - ); - const spawnTrendPoints = useMemo( - () => - buildGovernanceSparklinePoints( - timelineSamples, - (sample) => sample.spawnRejectionRatioBps, - TREND_CHART_WIDTH, - TREND_CHART_HEIGHT - ), - [timelineSamples] - ); - const observeTrendPoints = useMemo( - () => - buildGovernanceSparklinePoints( - timelineSamples, - (sample) => sample.observeToActionRatioBps, - TREND_CHART_WIDTH, - TREND_CHART_HEIGHT - ), - [timelineSamples] - ); - const reuseTrendPoints = useMemo( - () => - buildGovernanceSparklinePoints( - timelineSamples, - (sample) => sample.childReuseRatioBps, - TREND_CHART_WIDTH, - TREND_CHART_HEIGHT - ), - [timelineSamples] - ); - const agentNodes = useMemo( - () => - [...(agents?.nodes ?? [])].sort((left, right) => { - if (left.depth !== right.depth) { - return left.depth - right.depth; - } - return left.title.localeCompare(right.title, 'zh-CN'); - }), - [agents] - ); - - return ( -
-
-
-
-
- Debug Only -
-

Debug Workbench

-

- 独立调试窗口直接读取 `/api/debug/*`,用于观察 agent-tool 治理指标、会话 trace 和 agent - tree。 -

-
-
-
{overviewLoading ? '正在同步全局指标…' : '全局指标已同步'}
-
- {overview ? `最新样本 ${formatTimestamp(overview.collectedAt)}` : '尚未读取样本'} -
-
-
-
- -
- - -
- - {overviewError ? : null} -
- - - -
- -
-
-
- Recent 5m Trend -
-
- {timeline - ? `${formatTimestamp(timeline.windowStartedAt)} - ${formatTimestamp(timeline.windowEndedAt)}` - : '等待服务端样本'} -
-
-
- - - - - - - - -
-
- - - -
-
-
- - - {sessionLoading && trace == null ? ( -
正在同步当前会话 trace…
- ) : null} - {trace?.parentSessionId ? ( -
- parent session: {trace.parentSessionId.slice(0, 12)} -
- ) : null} -
- {trace?.items.length ? ( - trace.items.map((item) => ) - ) : ( - - )} -
-
-
-
-
- ); -} - -function Panel({ - title, - subtitle, - children, -}: { - title: string; - subtitle: string; - children: ReactNode; -}) { - return ( -
-
-
- {title} -
-
{subtitle}
-
-
{children}
-
- ); -} - -function MetricCard({ - label, - value, - detail, - tone, -}: { - label: string; - value: string; - detail: string; - tone: string; -}) { - return ( -
-
-
- {label} -
- {value} -
-
{detail}
-
- ); -} - -function LegendPill({ label, color, value }: { label: string; color: string; value: string }) { - return ( - - - ); -} - -function TrendLine({ points, color }: { points: Array<{ x: number; y: number }>; color: string }) { - if (points.length === 0) { - return null; - } - if (points.length === 1) { - const point = points[0]; - return ; - } - return ( - `${point.x},${point.y}`).join(' ')} - /> - ); -} - -function AgentTreeNode({ node }: { node: SessionDebugAgentNode }) { - return ( -
-
-
-
{node.title}
-
- {node.agentId} - {node.subRunId ? ` · ${node.subRunId}` : ''} -
-
- - {node.lifecycle} - -
-
- {node.kind === 'sessionRoot' ? 'session root' : 'child agent'} - {node.lineageKind ? {node.lineageKind} : null} - {node.statusSource ? {node.statusSource} : null} - {node.lastTurnOutcome ? {node.lastTurnOutcome} : null} -
-
- ); -} - -function TraceItemCard({ item }: { item: SessionDebugTrace['items'][number] }) { - return ( -
-
-
-
{item.title}
-
- {formatTimestamp(item.recordedAt)} - {item.storageSeq ? ` · seq ${item.storageSeq}` : ''} - {item.turnId ? ` · turn ${item.turnId}` : ''} -
-
- - {item.kind} - -
-
{item.summary}
-
- {item.agentId ? agent {item.agentId} : null} - {item.subRunId ? subRun {item.subRunId} : null} - {item.childAgentId ? child {item.childAgentId} : null} - {item.deliveryId ? delivery {item.deliveryId} : null} - {item.toolName ? tool {item.toolName} : null} - {item.lifecycle ? {item.lifecycle} : null} - {item.lastTurnOutcome ? {item.lastTurnOutcome} : null} -
-
- ); -} - -function ErrorBanner({ message }: { message: string }) { - return ( -
- {message} -
- ); -} - -function EmptyState({ label }: { label: string }) { - return ( -
- {label} -
- ); -} diff --git a/frontend/src/components/Chat/AssistantMessage.tsx b/frontend/src/components/Chat/AssistantMessage.tsx index ec02a8ce..fa87d9a2 100644 --- a/frontend/src/components/Chat/AssistantMessage.tsx +++ b/frontend/src/components/Chat/AssistantMessage.tsx @@ -11,7 +11,7 @@ import { expandableBody, ghostIconButton, } from '../../lib/styles'; -import { cn, calculateCacheHitRatePercent } from '../../lib/utils'; +import { calculateCacheHitRatePercent, calculatePromptReuseRatePercent, cn } from '../../lib/utils'; interface AssistantMessageProps { message: AssistantMessageType; @@ -208,21 +208,31 @@ function formatTokenCount(value?: number): string { } function getCacheIndicator(metrics?: PromptMetricsMessage): React.ReactNode { - const hitRate = calculateCacheHitRatePercent(metrics); - if (hitRate === null) { - return null; + const providerHitRate = calculateCacheHitRatePercent(metrics); + if (providerHitRate !== null) { + if (providerHitRate >= 80) { + return ( + 🟢 KV 缓存 {providerHitRate}% + ); + } + if (providerHitRate >= 30) { + return ( + 🟡 KV 缓存 {providerHitRate}% + ); + } + if (providerHitRate > 0) { + return 🟠 KV 缓存 {providerHitRate}%; + } + return 🔴 KV 缓存 0%; } - // 缓存命中率分级 - if (hitRate >= 80) { - return 🟢 缓存 {hitRate}%; - } else if (hitRate >= 30) { - return 🟡 缓存 {hitRate}%; - } else if (hitRate > 0) { - return 🟠 缓存 {hitRate}%; - } else { - return 🔴 无缓存; + const promptReuseRate = calculatePromptReuseRatePercent(metrics); + if (promptReuseRate === null) { + return null; } + return ( + 🧩 Prompt 复用 {promptReuseRate}% + ); } function AssistantMessage({ diff --git a/frontend/src/components/Chat/ChatScreenContext.tsx b/frontend/src/components/Chat/ChatScreenContext.tsx index bf9e5b90..3646fd4d 100644 --- a/frontend/src/components/Chat/ChatScreenContext.tsx +++ b/frontend/src/components/Chat/ChatScreenContext.tsx @@ -1,13 +1,21 @@ import { createContext, useContext } from 'react'; -import type { ComposerOption, CurrentModelInfo, ModelOption, Phase } from '../../types'; +import type { + ComposerOption, + ConversationControlState, + CurrentModelInfo, + ModelOption, + Phase, +} from '../../types'; export interface ChatScreenContextValue { projectName: string | null; sessionId: string | null; sessionTitle: string | null; + currentModeId: string | null; isChildSession: boolean; workingDir: string; phase: Phase; + conversationControl: ConversationControlState | null; activeSubRunPath: string[]; activeSubRunTitle: string | null; activeSubRunBreadcrumbs: Array<{ subRunId: string; title: string }>; @@ -17,7 +25,9 @@ export interface ChatScreenContextValue { onCloseSubRun: () => void | Promise; onNavigateSubRunPath: (subRunPath: string[]) => void | Promise; onOpenChildSession: (childSessionId: string) => void | Promise; + onForkFromTurn: (turnId: string) => void | Promise; onSubmitPrompt: (text: string) => void | Promise; + onSwitchMode: (modeId: string) => void | Promise; onInterrupt: () => void | Promise; onCancelSubRun: (sessionId: string, subRunId: string) => void | Promise; listComposerOptions: ( diff --git a/frontend/src/components/Chat/CompactMessage.tsx b/frontend/src/components/Chat/CompactMessage.tsx index b4af5bc0..c0a9ff4d 100644 --- a/frontend/src/components/Chat/CompactMessage.tsx +++ b/frontend/src/components/Chat/CompactMessage.tsx @@ -9,19 +9,48 @@ interface CompactMessageProps { } function CompactMessage({ message }: CompactMessageProps) { - const triggerLabel = message.trigger === 'manual' ? '手动压缩' : '自动压缩'; + const triggerLabel = + message.trigger === 'manual' + ? '手动压缩' + : message.trigger === 'deferred' + ? '延后压缩' + : '自动压缩'; + const modeLabel = + message.meta.mode === 'incremental' + ? '增量' + : message.meta.mode === 'retry_salvage' + ? '抢救回退' + : '全量'; return (
{triggerLabel} + + {modeLabel} + 保留最近 {message.preservedRecentTurns} 个 turn + {message.meta.instructionsPresent ? ( + 含自定义指令 + ) : null} + {message.meta.fallbackUsed ? ( + + fallback + {message.meta.retryCount > 0 ? ` · 重试 ${message.meta.retryCount} 次` : ''} + + ) : message.meta.retryCount > 0 ? ( + 重试 {message.meta.retryCount} 次 + ) : null}
{message.summary}
+
+ input units: {message.meta.inputUnits} + summary chars: {message.meta.outputSummaryChars} +
); } diff --git a/frontend/src/components/Chat/InputBar.tsx b/frontend/src/components/Chat/InputBar.tsx index 342b532e..966f1d07 100644 --- a/frontend/src/components/Chat/InputBar.tsx +++ b/frontend/src/components/Chat/InputBar.tsx @@ -27,7 +27,9 @@ export default function InputBar() { sessionId, workingDir, phase, + currentModeId, onSubmitPrompt, + onSwitchMode, onInterrupt, listComposerOptions, modelRefreshKey, @@ -192,6 +194,15 @@ export default function InputBar() { }; const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Tab' && event.shiftKey) { + event.preventDefault(); + if (!slashTriggerVisible && sessionId) { + const nextModeId = currentModeId === 'plan' ? 'code' : 'plan'; + void onSwitchMode(nextModeId); + } + return; + } + if (slashTriggerVisible) { switch (event.key) { case 'Escape': diff --git a/frontend/src/components/Chat/MessageList.tsx b/frontend/src/components/Chat/MessageList.tsx index 86c04415..34f2949f 100644 --- a/frontend/src/components/Chat/MessageList.tsx +++ b/frontend/src/components/Chat/MessageList.tsx @@ -1,12 +1,19 @@ import React, { Component, useCallback, useEffect, useRef } from 'react'; import type { Message, SubRunViewData, ThreadItem } from '../../types'; -import { emptyStateSurface, errorSurface } from '../../lib/styles'; +import { + contextMenu as contextMenuClass, + emptyStateSurface, + errorSurface, + menuItem, +} from '../../lib/styles'; import { cn } from '../../lib/utils'; +import { useContextMenu } from '../../hooks/useContextMenu'; +import { resolveForkTurnIdFromMessage } from '../../lib/sessionFork'; import AssistantMessage from './AssistantMessage'; import CompactMessage from './CompactMessage'; +import PlanMessage from './PlanMessage'; import SubRunBlock from './SubRunBlock'; import ToolCallBlock from './ToolCallBlock'; -import ToolStreamBlock from './ToolStreamBlock'; import UserMessage from './UserMessage'; import { useChatScreenContext } from './ChatScreenContext'; import { logger } from '../../lib/logger'; @@ -63,23 +70,23 @@ class MessageBoundary extends Component - ) : message.kind === 'toolStream' ? ( + ) : message.kind === 'compact' ? ( +
+              {message.summary}
+            
+ ) : message.kind === 'plan' ? (
               {JSON.stringify(
                 {
                   toolCallId: message.toolCallId,
-                  stream: message.stream,
-                  status: message.status,
-                  contentLength: message.content.length,
+                  eventKind: message.eventKind,
+                  title: message.title,
+                  planPath: message.planPath,
                 },
                 null,
                 2
               )}
             
- ) : message.kind === 'compact' ? ( -
-              {message.summary}
-            
) : message.kind === 'promptMetrics' ? (
               {JSON.stringify(
@@ -147,15 +154,58 @@ class MessageBoundary extends Component{children};
+  }
+
+  return (
+    <>
+      
{children}
+ {contextMenu && ( +
+ +
+ )} + + ); +} + export default function MessageList({ threadItems, childSubRuns, @@ -244,12 +294,12 @@ export default function MessageList({ /> ); } + if (msg.kind === 'plan') { + return ; + } if (msg.kind === 'toolCall') { return ; } - if (msg.kind === 'toolStream') { - return ; - } if (msg.kind === 'promptMetrics') { return null; } @@ -284,18 +334,21 @@ export default function MessageList({ : undefined); return ( -
- - {renderMessageContent(msg, isContinuation, metricsToAttach, options)} - -
+ +
+ + {renderMessageContent(msg, isContinuation, metricsToAttach, options)} + +
+
); }, [renderMessageContent] @@ -307,8 +360,11 @@ export default function MessageList({ options?: { nested?: boolean; } - ): React.ReactNode[] => - items.map((item, index) => { + ): React.ReactNode[] => { + const rendered: React.ReactNode[] = []; + + for (let index = 0; index < items.length; index += 1) { + const item = items[index]; if (item.kind === 'message') { const previousItem = items[index - 1]; const nextItem = items[index + 1]; @@ -316,7 +372,7 @@ export default function MessageList({ const nextMessage = nextItem?.kind === 'message' ? nextItem.message : null; if (item.message.kind === 'promptMetrics') { - return null; + continue; } let metricsToAttach: Message | undefined; @@ -365,21 +421,24 @@ export default function MessageList({ } } - return renderMessageRow( - item.message, - previousMessage, - nextMessage, - { - key: item.message.id, - nested: options?.nested, - }, - metricsToAttach + rendered.push( + renderMessageRow( + item.message, + previousMessage, + nextMessage, + { + key: item.message.id, + nested: options?.nested, + }, + metricsToAttach + ) ); + continue; } const subRunView = subRunViews.get(item.subRunId); if (!subRunView) { - return ( + rendered.push(
); + continue; } const boundaryMessage = @@ -420,7 +480,7 @@ export default function MessageList({ /> ); - return ( + rendered.push(
{boundaryMessage ? ( {subRunBlock} @@ -429,7 +489,10 @@ export default function MessageList({ )}
); - }), + } + + return rendered; + }, [onCancelSubRun, onOpenChildSession, onOpenSubRun, renderMessageRow, sessionId, subRunViews] ); diff --git a/frontend/src/components/Chat/PlanMessage.test.tsx b/frontend/src/components/Chat/PlanMessage.test.tsx new file mode 100644 index 00000000..dfe85ed9 --- /dev/null +++ b/frontend/src/components/Chat/PlanMessage.test.tsx @@ -0,0 +1,61 @@ +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it } from 'vitest'; + +import PlanMessage from './PlanMessage'; + +describe('PlanMessage', () => { + it('renders a presented plan card', () => { + const html = renderToStaticMarkup( + + ); + + expect(html).toContain('计划已呈递'); + expect(html).toContain('待确认'); + expect(html).toContain('Cleanup crates'); + expect(html).toContain('cleanup-crates.md'); + }); + + it('renders a plan update card for saved plan artifacts', () => { + const html = renderToStaticMarkup( + + ); + + expect(html).toContain('计划已更新'); + expect(html).toContain('草稿'); + expect(html).toContain('updated session plan'); + }); +}); diff --git a/frontend/src/components/Chat/PlanMessage.tsx b/frontend/src/components/Chat/PlanMessage.tsx new file mode 100644 index 00000000..41793255 --- /dev/null +++ b/frontend/src/components/Chat/PlanMessage.tsx @@ -0,0 +1,51 @@ +import { memo } from 'react'; + +import type { PlanMessage as PlanMessageType } from '../../types'; +import { pillNeutral } from '../../lib/styles'; +import { PresentedPlanSurface, ReviewPendingPlanSurface, planStatusLabel } from './PlanSurface'; + +interface PlanMessageProps { + message: PlanMessageType; +} + +function PlanMessage({ message }: PlanMessageProps) { + return ( +
+ {message.eventKind === 'presented' && message.content ? ( + + ) : message.eventKind === 'review_pending' ? ( + + ) : ( +
+
+ 计划已更新 + {message.status ? ( + {planStatusLabel(message.status)} + ) : null} +
+
+
{message.title}
+
+ {message.planPath} +
+
+ {message.summary ?? 'canonical session plan 已同步。'} +
+
+
+ )} +
+ ); +} + +export default memo(PlanMessage); diff --git a/frontend/src/components/Chat/PlanSurface.tsx b/frontend/src/components/Chat/PlanSurface.tsx new file mode 100644 index 00000000..79be1ea7 --- /dev/null +++ b/frontend/src/components/Chat/PlanSurface.tsx @@ -0,0 +1,123 @@ +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; + +import { pillNeutral, pillSuccess } from '../../lib/styles'; +import type { PlanReviewState } from '../../types'; + +interface PlanSurfaceModeTransition { + fromModeId: string; + toModeId: string; + modeChanged: boolean; +} + +interface PlanSurfaceBlockers { + missingHeadings: string[]; + invalidSections: string[]; +} + +export function planStatusLabel(status: string): string { + switch (status) { + case 'awaiting_approval': + return '待确认'; + case 'approved': + return '已批准'; + case 'completed': + return '已完成'; + case 'draft': + return '草稿'; + case 'superseded': + return '已替换'; + default: + return status; + } +} + +export function PresentedPlanSurface({ + title, + status, + planPath, + content, + mode, +}: { + title: string; + status?: string; + planPath: string; + content: string; + mode?: PlanSurfaceModeTransition; +}) { + return ( +
+
+ 计划已呈递 + {status ? {planStatusLabel(status)} : null} + {mode?.modeChanged ? ( + + {mode.fromModeId} -> {mode.toModeId} + + ) : null} +
+
+
{title}
+
{planPath}
+
+ 计划已经提交给你审核。你可以直接批准,或者要求继续修订。 +
+
+
+ {content} +
+
+ ); +} + +export function ReviewPendingPlanSurface({ + title, + planPath, + review, + blockers, +}: { + title: string; + planPath: string; + review?: PlanReviewState; + blockers: PlanSurfaceBlockers; +}) { + return ( +
+
+ 继续完善中 + + {review?.kind === 'revise_plan' ? '正在修计划' : '正在做退出前自审'} + +
+
+
{title}
+
{planPath}
+
+ {review?.kind === 'revise_plan' + ? '当前计划还没达到可执行程度。模型会先补强计划,再重新尝试退出 plan mode。' + : '模型正在做退出前的内部最终自审。这是正常流程,不会把 review 段落写进计划正文。'} +
+
+
+ {review?.checklist && review.checklist.length > 0 ? ( +
+
自审检查项
+
{review.checklist.join(';')}
+
+ ) : null} + {blockers.missingHeadings.length > 0 ? ( +
+
缺失章节
+
{blockers.missingHeadings.join(',')}
+
+ ) : null} + {blockers.invalidSections.length > 0 ? ( +
+
需要加强
+
{blockers.invalidSections.join(';')}
+
+ ) : null} +
+
+ ); +} diff --git a/frontend/src/components/Chat/PromptMetricsMessage.tsx b/frontend/src/components/Chat/PromptMetricsMessage.tsx index ce9f4937..cc116020 100644 --- a/frontend/src/components/Chat/PromptMetricsMessage.tsx +++ b/frontend/src/components/Chat/PromptMetricsMessage.tsx @@ -2,7 +2,7 @@ import { memo } from 'react'; import type { PromptMetricsMessage as PromptMetricsMessageType } from '../../types'; import { pillInfo } from '../../lib/styles'; -import { calculateCacheHitRatePercent } from '../../lib/utils'; +import { calculateCacheHitRatePercent, calculatePromptReuseRatePercent } from '../../lib/utils'; interface PromptMetricsMessageProps { message: PromptMetricsMessageType; @@ -16,7 +16,8 @@ function formatTokenCount(value?: number): string { } function PromptMetricsMessage({ message }: PromptMetricsMessageProps) { - const hitRate = calculateCacheHitRatePercent(message); + const providerHitRate = calculateCacheHitRatePercent(message); + const promptReuseRate = calculatePromptReuseRatePercent(message); return (
@@ -45,17 +46,32 @@ function PromptMetricsMessage({ message }: PromptMetricsMessageProps) {
-
Cache 读 / 写
+
KV Cache 读 / 写
{formatTokenCount(message.cacheReadInputTokens)} /{' '} {formatTokenCount(message.cacheCreationInputTokens)}
+
+
Prompt 复用 命中 / 未命中
+
+ {formatTokenCount(message.promptCacheReuseHits)} /{' '} + {formatTokenCount(message.promptCacheReuseMisses)} +
+
压缩阈值 {formatTokenCount(message.thresholdTokens)} 截断工具结果 {message.truncatedToolResults} - {hitRate === null ? null : 缓存命中 {hitRate}% } + + Provider Cache{' '} + {message.providerCacheMetricsSupported + ? providerHitRate === null + ? '已启用,当前 step 无读缓存' + : `命中 ${providerHitRate}%` + : '未上报'} + + {promptReuseRate === null ? null : Prompt 复用 {promptReuseRate}%}
); diff --git a/frontend/src/components/Chat/SubRunBlock.test.tsx b/frontend/src/components/Chat/SubRunBlock.test.tsx index 3323ccb6..c4d919ce 100644 --- a/frontend/src/components/Chat/SubRunBlock.test.tsx +++ b/frontend/src/components/Chat/SubRunBlock.test.tsx @@ -5,6 +5,8 @@ import { describe, expect, it } from 'vitest'; import type { ThreadItem } from '../../lib/subRunView'; import type { ChildSessionNotificationMessage, + ParentDelivery, + SubRunResult, SubRunFinishMessage, SubRunStartMessage, } from '../../types'; @@ -20,6 +22,71 @@ function renderThreadItems(items: ThreadItem[]): ReactNode[] { ); } +function makeCompletedResult( + handoff: { + findings: string[]; + artifacts: { + kind: string; + id: string; + label: string; + sessionId?: string; + storageSeq?: number; + uri?: string; + }[]; + delivery?: ParentDelivery; + } = { + findings: [], + artifacts: [], + } +): SubRunResult { + return { + status: 'completed', + handoff, + }; +} + +function makeFailedResult( + failure: { + code: 'transport' | 'provider_http' | 'stream_parse' | 'interrupted' | 'internal'; + displayMessage: '子 Agent 调用模型时网络连接中断,未完成任务。'; + technicalMessage: 'HTTP request error: failed to read anthropic response stream'; + retryable: true; + } = { + code: 'transport', + displayMessage: '子 Agent 调用模型时网络连接中断,未完成任务。', + technicalMessage: 'HTTP request error: failed to read anthropic response stream', + retryable: true, + } +): SubRunResult { + return { + status: 'failed', + failure, + }; +} + +function makeTokenExceededResult( + handoff: { + findings: string[]; + artifacts: { + kind: string; + id: string; + label: string; + sessionId?: string; + storageSeq?: number; + uri?: string; + }[]; + delivery?: ParentDelivery; + } = { + findings: [], + artifacts: [], + } +): SubRunResult { + return { + status: 'token_exceeded', + handoff, + }; +} + describe('SubRunBlock result rendering', () => { it('renders background running guidance and cancel entry for live sub-runs', () => { const html = renderToStaticMarkup( @@ -45,15 +112,7 @@ describe('SubRunBlock result rendering', () => { id: 'subrun-finish-1', kind: 'subRunFinish', subRunId: 'subrun-1', - result: { - status: 'failed', - failure: { - code: 'transport', - displayMessage: '子 Agent 调用模型时网络连接中断,未完成任务。', - technicalMessage: 'HTTP request error: failed to read anthropic response stream', - retryable: true, - }, - }, + result: makeFailedResult(), stepCount: 3, estimatedTokens: 120, timestamp: Date.now(), @@ -194,24 +253,21 @@ describe('SubRunBlock result rendering', () => { id: 'subrun-finish-2', kind: 'subRunFinish', subRunId: 'subrun-3', - result: { - status: 'completed', - handoff: { - findings: ['问题一', '问题二'], - artifacts: [], - delivery: { - idempotencyKey: 'delivery-directory-summary', - origin: 'explicit', - terminalSemantics: 'terminal', - kind: 'completed', - payload: { - message: '完成了静态分析并整理出两个风险点。', - findings: ['问题一', '问题二'], - artifacts: [], - }, + result: makeCompletedResult({ + findings: ['问题一', '问题二'], + artifacts: [], + delivery: { + idempotencyKey: 'delivery-directory-summary', + origin: 'explicit', + terminalSemantics: 'terminal', + kind: 'completed', + payload: { + message: '完成了静态分析并整理出两个风险点。', + findings: ['问题一', '问题二'], + artifacts: [], }, }, - }, + }), stepCount: 2, estimatedTokens: 80, timestamp: Date.now(), @@ -244,24 +300,21 @@ describe('SubRunBlock result rendering', () => { id: 'subrun-finish-json', kind: 'subRunFinish', subRunId: 'subrun-json', - result: { - status: 'completed', - handoff: { - findings: ['问题一', '问题二'], - artifacts: [], - delivery: { - idempotencyKey: 'delivery-json-summary', - origin: 'explicit', - terminalSemantics: 'terminal', - kind: 'completed', - payload: { - message: '审查完成,发现两个问题。', - findings: ['问题一', '问题二'], - artifacts: [], - }, + result: makeCompletedResult({ + findings: ['问题一', '问题二'], + artifacts: [], + delivery: { + idempotencyKey: 'delivery-json-summary', + origin: 'explicit', + terminalSemantics: 'terminal', + kind: 'completed', + payload: { + message: '审查完成,发现两个问题。', + findings: ['问题一', '问题二'], + artifacts: [], }, }, - }, + }), stepCount: 1, estimatedTokens: 50, timestamp: Date.now(), @@ -294,24 +347,21 @@ describe('SubRunBlock result rendering', () => { id: 'subrun-finish-handoff', kind: 'subRunFinish', subRunId: 'subrun-handoff', - result: { - status: 'completed', - handoff: { - findings: [], - artifacts: [], - delivery: { - idempotencyKey: 'delivery-readable-summary', - origin: 'explicit', - terminalSemantics: 'terminal', - kind: 'completed', - payload: { - message: '代码审查完成:所有模块通过检查。', - findings: [], - artifacts: [], - }, + result: makeCompletedResult({ + findings: [], + artifacts: [], + delivery: { + idempotencyKey: 'delivery-readable-summary', + origin: 'explicit', + terminalSemantics: 'terminal', + kind: 'completed', + payload: { + message: '代码审查完成:所有模块通过检查。', + findings: [], + artifacts: [], }, }, - }, + }), stepCount: 3, estimatedTokens: 120, timestamp: Date.now(), @@ -345,24 +395,21 @@ describe('SubRunBlock result rendering', () => { kind: 'subRunFinish', subRunId: 'subrun-child-session', childSessionId: 'session-child', - result: { - status: 'completed', - handoff: { - findings: ['finding-1'], - artifacts: [], - delivery: { - idempotencyKey: 'delivery-child-session-summary', - origin: 'explicit', - terminalSemantics: 'terminal', - kind: 'completed', - payload: { - message: '这是完整子会话报告,不应该再内嵌在父会话里。', - findings: ['finding-1'], - artifacts: [], - }, + result: makeCompletedResult({ + findings: ['finding-1'], + artifacts: [], + delivery: { + idempotencyKey: 'delivery-child-session-summary', + origin: 'explicit', + terminalSemantics: 'terminal', + kind: 'completed', + payload: { + message: '这是完整子会话报告,不应该再内嵌在父会话里。', + findings: ['finding-1'], + artifacts: [], }, }, - }, + }), stepCount: 2, estimatedTokens: 90, timestamp: Date.now(), @@ -389,6 +436,50 @@ describe('SubRunBlock result rendering', () => { expect(html).toContain('
  • finding-1
  • '); }); + it('renders token-exceeded delivery summary in the parent card', () => { + const finishMessage: SubRunFinishMessage = { + id: 'subrun-finish-token-exceeded', + kind: 'subRunFinish', + subRunId: 'subrun-token-exceeded', + result: makeTokenExceededResult({ + findings: ['partial-finding'], + artifacts: [], + delivery: { + idempotencyKey: 'delivery-token-exceeded-summary', + origin: 'explicit', + terminalSemantics: 'terminal', + kind: 'completed', + payload: { + message: '达到 token 上限,但已返回阶段性结论。', + findings: ['partial-finding'], + artifacts: [], + }, + }, + }), + stepCount: 4, + estimatedTokens: 4096, + timestamp: Date.now(), + }; + + const html = renderToStaticMarkup( + {}} + /> + ); + + expect(html).toContain('达到 token 上限,但已返回阶段性结论。'); + expect(html).toContain('最终回复'); + expect(html).toContain('
  • partial-finding
  • '); + }); + it('renders latest notification delivery when finish message is absent', () => { const latestNotification: ChildSessionNotificationMessage = { id: 'notification-progress-1', diff --git a/frontend/src/components/Chat/SubRunBlock.tsx b/frontend/src/components/Chat/SubRunBlock.tsx index dad69b79..e18dd2e8 100644 --- a/frontend/src/components/Chat/SubRunBlock.tsx +++ b/frontend/src/components/Chat/SubRunBlock.tsx @@ -1,6 +1,7 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { ChildSessionNotificationMessage, + SubRunResult, ParentDelivery, SubRunFinishMessage, SubRunStartMessage, @@ -41,7 +42,7 @@ interface SubRunBlockProps { displayMode?: 'thread' | 'directory'; } -type SubRunStatus = 'running' | 'completed' | 'aborted' | 'token_exceeded' | 'failed'; +type SubRunStatus = 'running' | 'completed' | 'cancelled' | 'token_exceeded' | 'failed'; function toSubRunStatus(finishMessage?: SubRunFinishMessage): SubRunStatus { return finishMessage?.result.status ?? 'running'; @@ -51,8 +52,8 @@ function getStatusLabel(status: SubRunStatus): string { switch (status) { case 'completed': return 'completed'; - case 'aborted': - return 'aborted'; + case 'cancelled': + return 'cancelled'; case 'token_exceeded': return 'token exceeded'; case 'failed': @@ -74,7 +75,7 @@ function getStatusVariant(status: SubRunStatus): string { switch (status) { case 'completed': return pillSuccess; - case 'aborted': + case 'cancelled': return pillNeutral; case 'token_exceeded': return pillWarning; @@ -91,11 +92,7 @@ function isVisibleActivityItem(item: ThreadItem): boolean { return true; } - return ( - item.message.kind === 'assistant' || - item.message.kind === 'toolCall' || - item.message.kind === 'toolStream' - ); + return item.message.kind === 'assistant' || item.message.kind === 'toolCall'; } function getDeliveryMessage(delivery?: ParentDelivery): string | null { @@ -103,6 +100,24 @@ function getDeliveryMessage(delivery?: ParentDelivery): string | null { return message && message.length > 0 ? message : null; } +function getResultHandoff(result?: SubRunResult) { + if (!result) { + return undefined; + } + return result.status === 'failed' || result.status === 'cancelled' ? undefined : result.handoff; +} + +function getResultFailure(result?: SubRunResult) { + if (!result) { + return undefined; + } + return result.status === 'failed' || result.status === 'cancelled' ? result.failure : undefined; +} + +function isSuccessfulTerminalStatus(status: SubRunStatus): boolean { + return status === 'completed' || status === 'token_exceeded'; +} + function SubRunBlock({ subRunId, sessionId, @@ -136,8 +151,8 @@ function SubRunBlock({ finishMessage !== undefined ? `${finishMessage.stepCount} steps` : getStorageModeLabel(startMessage, childSessionId); - const resultHandoff = finishMessage?.result.handoff; - const resultFailure = finishMessage?.result.failure; + const resultHandoff = getResultHandoff(finishMessage?.result); + const resultFailure = getResultFailure(finishMessage?.result); const latestDelivery = latestNotification?.delivery ?? resultHandoff?.delivery; const latestDeliveryMessage = getDeliveryMessage(latestDelivery); const isBackgroundRunning = status === 'running'; @@ -341,7 +356,7 @@ function SubRunBlock({ ? completedDelivery.payload.findings : (resultHandoff?.findings ?? []); - if (!completedSummary || status !== 'completed') { + if (!completedSummary || !isSuccessfulTerminalStatus(status)) { return null; } return ( diff --git a/frontend/src/components/Chat/TaskPanel.tsx b/frontend/src/components/Chat/TaskPanel.tsx new file mode 100644 index 00000000..1784f815 --- /dev/null +++ b/frontend/src/components/Chat/TaskPanel.tsx @@ -0,0 +1,137 @@ +import { useMemo, useState } from 'react'; +import { cn } from '../../lib/utils'; +import { useChatScreenContext } from './ChatScreenContext'; + +function statusLabel(status: 'pending' | 'in_progress' | 'completed'): string { + switch (status) { + case 'in_progress': + return '进行中'; + case 'completed': + return '已完成'; + case 'pending': + default: + return '待处理'; + } +} + +function statusClass(status: 'pending' | 'in_progress' | 'completed'): string { + switch (status) { + case 'in_progress': + return 'border-amber-300/60 bg-amber-100/80 text-amber-900'; + case 'completed': + return 'border-emerald-300/60 bg-emerald-100/80 text-emerald-900'; + case 'pending': + default: + return 'border-slate-300/70 bg-slate-100/80 text-slate-700'; + } +} + +export default function TaskPanel() { + const { conversationControl } = useChatScreenContext(); + // TODO(task-panel): 当前 UI 假设 control.activeTasks 只代表一个 owner 的任务快照。 + // 后续支持多 owner 并行展示时,需要把这里重构成按 owner 分组的多卡片/多分区布局, + // 同时保留每个 owner 自己的 in_progress 与统计摘要。 + const tasks = conversationControl?.activeTasks; + const [collapsed, setCollapsed] = useState(false); + + const summary = useMemo(() => { + if (!tasks || tasks.length === 0) { + return null; + } + const inProgress = tasks.find((task) => task.status === 'in_progress'); + const pendingCount = tasks.filter((task) => task.status === 'pending').length; + const completedCount = tasks.filter((task) => task.status === 'completed').length; + return { + inProgress, + pendingCount, + completedCount, + total: tasks.length, + }; + }, [tasks]); + + if (!tasks || tasks.length === 0 || !summary) { + return null; + } + + return ( +
    +
    + + {!collapsed ? ( +
    +
    + {tasks.map((task, index) => ( +
    +
    +
    + {task.content} +
    + {task.activeForm ? ( +
    + {task.activeForm} +
    + ) : null} +
    + + {statusLabel(task.status)} + +
    + ))} +
    +
    + ) : null} +
    +
    + ); +} diff --git a/frontend/src/components/Chat/ToolCallBlock.test.tsx b/frontend/src/components/Chat/ToolCallBlock.test.tsx new file mode 100644 index 00000000..2a988670 --- /dev/null +++ b/frontend/src/components/Chat/ToolCallBlock.test.tsx @@ -0,0 +1,346 @@ +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it } from 'vitest'; + +import { ChatScreenProvider, type ChatScreenContextValue } from './ChatScreenContext'; +import ToolCallBlock from './ToolCallBlock'; + +const chatContextValue: ChatScreenContextValue = { + projectName: 'Astrcode', + sessionId: 'session-1', + sessionTitle: 'Test Session', + currentModeId: 'code', + isChildSession: false, + workingDir: 'D:/GitObjectsOwn/Astrcode', + phase: 'idle', + conversationControl: null, + activeSubRunPath: [], + activeSubRunTitle: null, + activeSubRunBreadcrumbs: [], + isSidebarOpen: true, + toggleSidebar: () => {}, + onOpenSubRun: () => {}, + onCloseSubRun: () => {}, + onNavigateSubRunPath: () => {}, + onOpenChildSession: () => {}, + onForkFromTurn: () => {}, + onSubmitPrompt: () => {}, + onSwitchMode: () => {}, + onInterrupt: () => {}, + onCancelSubRun: () => {}, + listComposerOptions: () => Promise.resolve([]), + modelRefreshKey: 0, + getCurrentModel: () => + Promise.resolve({ + profileName: 'default', + model: 'test-model', + providerKind: 'openai', + }), + listAvailableModels: () => Promise.resolve([]), + setModel: async () => {}, +}; + +describe('ToolCallBlock', () => { + it('renders real tool args in the collapsed summary and embedded stdout in the body', () => { + const html = renderToStaticMarkup( + + + + ); + + expect(html).toContain('已运行 readFile'); + expect(html).toContain('path="Cargo.toml"'); + expect(html).toContain('limit=220'); + expect(html).toContain('[workspace]'); + expect(html).toContain('调用参数'); + expect(html).toContain('max-h-[min(58vh,560px)]'); + }); + + it('renders fallback result surface when no embedded stream output exists', () => { + const html = renderToStaticMarkup( + + + + ); + + expect(html).toContain('找到 12 个文件'); + expect(html).toContain('结果'); + }); + + it('renders a persisted result card instead of the raw wrapper text', () => { + const html = renderToStaticMarkup( + + \nLarge tool output was saved to a file instead of being inlined.\nPath: ~/.astrcode/tool-results/call-1.txt\n', + metadata: { + persistedOutput: { + storageKind: 'toolResult', + absolutePath: '~/.astrcode/tool-results/call-1.txt', + relativePath: 'tool-results/call-1.txt', + totalBytes: 4096, + previewText: '[{"file":"src/lib.rs"}]', + previewBytes: 23, + }, + truncated: true, + }, + streams: { + stdout: '', + stderr: '', + }, + timestamp: Date.now(), + }} + /> + + ); + + expect(html).toContain('已持久化结果'); + expect(html).toContain('~/.astrcode/tool-results/call-1.txt'); + expect(html).toContain('tool-results/call-1.txt'); + expect(html).toContain( + 'readFile { path: "~/.astrcode/tool-results/call-1.txt", charOffset: 0, maxChars: 20000 }' + ); + expect(html).not.toContain('Large tool output was saved to a file instead of being inlined.'); + }); + + it('renders child session navigation action from explicit child ref', () => { + const html = renderToStaticMarkup( + + + + ); + + expect(html).toContain('打开子会话'); + }); + + it('renders embedded stdout/stderr sections and failure pills for failed tools', () => { + const html = renderToStaticMarkup( + + + + ); + + expect(html).toContain('已运行 shell'); + expect(html).toContain('工具结果'); + expect(html).toContain('错误输出'); + expect(html).toContain('searching workspace'); + expect(html).toContain('missing-symbol'); + expect(html).toContain('88 ms'); + expect(html).toContain('truncated'); + expect(html).toContain('失败'); + }); + + it('renders a dedicated plan review card for exitPlanMode metadata', () => { + const html = renderToStaticMarkup( + + + + ); + + expect(html).toContain('计划已呈递'); + expect(html).toContain('待确认'); + expect(html).toContain('Cleanup crates'); + expect(html).toContain('cleanup-crates.md'); + expect(html).toContain('计划已经提交给你审核'); + }); + + it('renders a dedicated review-pending card when exitPlanMode requires another reflection pass', () => { + const html = renderToStaticMarkup( + + + + ); + + expect(html).toContain('继续完善中'); + expect(html).toContain('正在做退出前自审'); + expect(html).toContain('自审检查项'); + expect(html).toContain('缺失章节'); + expect(html).toContain('## Existing Code To Reuse'); + expect(html).toContain('Re-check assumptions against the code you already inspected.'); + }); + + it('renders explicit tool error even when stdout is present', () => { + const html = renderToStaticMarkup( + + + + ); + + expect(html).toContain('Compiling crate...'); + expect(html).toContain('command exited with code 101'); + expect(html).toContain('错误'); + }); +}); diff --git a/frontend/src/components/Chat/ToolCallBlock.tsx b/frontend/src/components/Chat/ToolCallBlock.tsx index 05d258d0..df24c226 100644 --- a/frontend/src/components/Chat/ToolCallBlock.tsx +++ b/frontend/src/components/Chat/ToolCallBlock.tsx @@ -1,8 +1,22 @@ -import { memo } from 'react'; - +import { memo, useEffect, useRef, useState } from 'react'; import type { ToolCallMessage } from '../../types'; -import { pillDanger, pillNeutral, pillSuccess } from '../../lib/styles'; +import { + extractPersistedToolOutput, + extractSessionPlanExit, + extractSessionPlanExitReviewPending, + extractStructuredArgs, + extractStructuredJsonOutput, + extractToolMetadataSummary, + extractToolShellDisplay, + formatToolCallSummary, +} from '../../lib/toolDisplay'; +import { chevronIcon, infoButton, pillDanger, pillNeutral, pillSuccess } from '../../lib/styles'; import { cn } from '../../lib/utils'; +import { useChatScreenContext } from './ChatScreenContext'; +import { PresentedPlanSurface, ReviewPendingPlanSurface } from './PlanSurface'; +import ToolCodePanel from './ToolCodePanel'; +import ToolJsonView from './ToolJsonView'; +import { useNestedScrollContainment } from './useNestedScrollContainment'; interface ToolCallBlockProps { message: ToolCallMessage; @@ -22,28 +36,383 @@ function statusPill(status: ToolCallMessage['status']): string { function statusLabel(status: ToolCallMessage['status']): string { switch (status) { case 'ok': - return 'completed'; + return '成功'; case 'fail': - return 'failed'; + return '失败'; default: - return 'running'; + return '运行中'; + } +} + +function streamBadge(stream: 'stdout' | 'stderr'): string { + return stream === 'stderr' ? pillDanger : pillNeutral; +} + +function streamTitle( + toolName: string, + stream: 'stdout' | 'stderr', + hasShellCommand: boolean +): string { + if (hasShellCommand && stream === 'stdout') { + return 'Shell'; + } + if (stream === 'stderr') { + return 'stderr'; + } + return toolName; +} + +function resultTextSurface(text: string, tone: 'normal' | 'error') { + const structuredResult = extractStructuredJsonOutput(text); + if (structuredResult) { + return ( + + ); + } + + return ( + + ); +} + +function persistedToolResultSurface( + absolutePath: string, + relativePath: string, + totalBytes: number, + previewText: string +) { + const suggestedRead = `readFile { path: "${absolutePath}", charOffset: 0, maxChars: 20000 }`; + + return ( +
    +
    + 已持久化结果 + {totalBytes} bytes +
    +
    +
    +
    绝对路径
    +
    {absolutePath}
    +
    +
    +
    会话相对路径
    +
    {relativePath}
    +
    + {previewText ? ( + + ) : null} +
    +
    建议读取第一页
    +
    {suggestedRead}
    +
    +
    +
    + ); +} + +function planExitSurface(message: ToolCallMessage) { + const planExit = extractSessionPlanExit(message.metadata); + if (!planExit) { + return null; + } + + return ( + + ); +} + +function planExitReviewPendingSurface(message: ToolCallMessage) { + const pending = extractSessionPlanExitReviewPending(message.metadata); + if (!pending) { + return null; } + + return ( + + ); } function ToolCallBlock({ message }: ToolCallBlockProps) { + const { onOpenChildSession, onOpenSubRun } = useChatScreenContext(); + const viewportRef = useRef(null); + useNestedScrollContainment(viewportRef); + const shellDisplay = extractToolShellDisplay(message.metadata); + const persistedOutput = extractPersistedToolOutput(message.metadata); + const planExit = planExitSurface(message); + const planExitReviewPending = planExitReviewPendingSurface(message); + const summary = formatToolCallSummary( + message.toolName, + message.args, + message.status, + message.metadata + ); + const streamSections = (['stdout', 'stderr'] as const) + .map((stream) => ({ + id: `${message.id}:${stream}`, + stream, + content: message.streams?.[stream] ?? '', + })) + .filter((section) => section.content.trim().length > 0); + const structuredArgs = extractStructuredArgs(message.args); + const metadataSummary = extractToolMetadataSummary(message.metadata); + const fallbackResult = + message.error?.trim() || + (persistedOutput ? '' : message.output?.trim()) || + metadataSummary?.message?.trim() || + ''; + const structuredFallbackResult = extractStructuredJsonOutput(fallbackResult); + const defaultOpen = message.status === 'fail'; + const explicitError = message.error?.trim() || ''; + const [isOpen, setIsOpen] = useState(defaultOpen); + + useEffect(() => { + if (message.status === 'fail') { + setIsOpen(true); + } + }, [message.status]); + return ( -
    -
    +
    { + setIsOpen(event.currentTarget.open); + }} + > + {message.toolName} - {message.output ?? '调用工具'} + {summary} + {message.childRef && ( + + )} {statusLabel(message.status)} -
    - {message.error ? ( -
    - {message.error} + + + + + + +
    +
    +
    + {planExit ? ( + planExit + ) : planExitReviewPending ? ( + planExitReviewPending + ) : streamSections.length > 0 ? ( + <> + {streamSections.map((streamMessage) => ( +
    +
    +
    + + {streamTitle( + message.toolName, + streamMessage.stream, + Boolean(shellDisplay?.command) + )} + + + {streamMessage.stream === 'stderr' ? '错误输出' : '工具结果'} + +
    + + {statusLabel(message.status)} + +
    + {resultTextSurface( + streamMessage.content, + streamMessage.stream === 'stderr' ? 'error' : 'normal' + )} +
    + ))} + {explicitError && ( +
    +
    +
    + 错误 + + {shellDisplay?.command ? `$ ${shellDisplay.command}` : message.toolName} + +
    + + {statusLabel(message.status)} + +
    + {resultTextSurface(explicitError, 'error')} +
    + )} + {persistedOutput && ( +
    +
    +
    + 结果 + persisted output +
    + + {statusLabel(message.status)} + +
    + {persistedToolResultSurface( + persistedOutput.absolutePath, + persistedOutput.relativePath, + persistedOutput.totalBytes, + persistedOutput.previewText + )} +
    + )} + + ) : persistedOutput ? ( +
    +
    +
    + 结果 + persisted output +
    + {statusLabel(message.status)} +
    + {persistedToolResultSurface( + persistedOutput.absolutePath, + persistedOutput.relativePath, + persistedOutput.totalBytes, + persistedOutput.previewText + )} +
    + ) : fallbackResult ? ( + structuredFallbackResult ? ( +
    +
    +
    + 结果 + + {structuredFallbackResult.summary} + +
    + {statusLabel(message.status)} +
    + +
    + ) : ( +
    +
    +
    + + {message.error ? '错误' : '结果'} + + + {shellDisplay?.command ? `$ ${shellDisplay.command}` : message.toolName} + +
    + {statusLabel(message.status)} +
    + {resultTextSurface(fallbackResult, message.error ? 'error' : 'normal')} +
    + ) + ) : ( +
    + {message.status === 'running' ? '等待工具输出...' : '该工具没有可展示的文本结果。'} +
    + )} +
    - ) : null} -
    + + {structuredArgs && ( +
    + + 调用参数 + {structuredArgs.summary} + + + + + + +
    + +
    +
    + )} + + {(metadataSummary?.pills?.length || + message.durationMs !== undefined || + message.truncated) && ( +
    + {metadataSummary?.pills.map((pill) => ( + + {pill} + + ))} + {message.durationMs !== undefined && ( + {message.durationMs} ms + )} + {message.truncated && truncated} +
    + )} +
    + ); } diff --git a/frontend/src/components/Chat/ToolCodePanel.tsx b/frontend/src/components/Chat/ToolCodePanel.tsx new file mode 100644 index 00000000..396c6f6b --- /dev/null +++ b/frontend/src/components/Chat/ToolCodePanel.tsx @@ -0,0 +1,45 @@ +import { memo, useRef } from 'react'; + +import { codeBlockContent, codeBlockHeader, codeBlockShell } from '../../lib/styles'; +import { cn } from '../../lib/utils'; +import { useNestedScrollContainment } from './useNestedScrollContainment'; + +interface ToolCodePanelProps { + title: string; + tone?: 'normal' | 'error'; + content: string; + scrollMode?: 'self' | 'inherit'; +} + +function ToolCodePanel({ + title, + tone = 'normal', + content, + scrollMode = 'self', +}: ToolCodePanelProps) { + const contentRef = useRef(null); + const inactiveRef = useRef(null); + useNestedScrollContainment(scrollMode === 'self' ? contentRef : inactiveRef); + + return ( +
    +
    + {title} +
    +
    +        {content}
    +      
    +
    + ); +} + +export default memo(ToolCodePanel); diff --git a/frontend/src/components/Chat/ToolJsonView.test.tsx b/frontend/src/components/Chat/ToolJsonView.test.tsx new file mode 100644 index 00000000..da56adcf --- /dev/null +++ b/frontend/src/components/Chat/ToolJsonView.test.tsx @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; + +import { summarizeLongString } from './ToolJsonView'; + +describe('summarizeLongString', () => { + it('keeps short strings unchanged after whitespace normalization', () => { + expect(summarizeLongString('alpha beta')).toBe('alpha beta'); + }); + + it('truncates long strings into a compact preview', () => { + const value = `${'x'.repeat(360)} tail`; + const preview = summarizeLongString(value); + + expect(preview.length).toBeLessThan(value.length); + expect(preview.endsWith('...')).toBe(true); + }); + + it('renders empty strings with a stable placeholder', () => { + expect(summarizeLongString(' \n\t ')).toBe('(empty string)'); + }); +}); diff --git a/frontend/src/components/Chat/ToolJsonView.tsx b/frontend/src/components/Chat/ToolJsonView.tsx index c5c9d940..1436c3f5 100644 --- a/frontend/src/components/Chat/ToolJsonView.tsx +++ b/frontend/src/components/Chat/ToolJsonView.tsx @@ -1,13 +1,16 @@ -import { memo } from 'react'; +import { memo, useRef } from 'react'; import type { ReactNode } from 'react'; import type { UnknownRecord } from '../../lib/shared'; +import { useNestedScrollContainment } from './useNestedScrollContainment'; const MAX_CHILDREN_PER_NODE = 200; -const MAX_STRING_PREVIEW = 240; +const MAX_STRING_PREVIEW_CHARS = 320; interface ToolJsonViewProps { value: UnknownRecord | unknown[]; summary: string; + defaultOpen?: boolean; + scrollMode?: 'self' | 'inherit'; } interface JsonNodeProps { @@ -28,19 +31,42 @@ function summarizeContainer(value: UnknownRecord | unknown[]): string { return `Object (${Object.keys(value).length})`; } +export function summarizeLongString(value: string): string { + const normalized = value.replace(/\s+/g, ' ').trim(); + if (!normalized) { + return '(empty string)'; + } + + if (normalized.length <= MAX_STRING_PREVIEW_CHARS) { + return normalized; + } + + return `${normalized.slice(0, MAX_STRING_PREVIEW_CHARS)}...`; +} + function renderPrimitiveValue(value: unknown): ReactNode { if (value === null) { return null; } if (typeof value === 'string') { - const truncated = - value.length > MAX_STRING_PREVIEW - ? `${value.slice(0, MAX_STRING_PREVIEW)}... (${value.length} chars)` - : value; + if (value.length > MAX_STRING_PREVIEW_CHARS) { + return ( +
    + + "{summarizeLongString(value)}" + 展开完整内容 ({value.length}) + +
    + "{value}" +
    +
    + ); + } + return ( - "{truncated}" + "{value}" ); } @@ -100,10 +126,26 @@ function JsonNode({ value, label, path, defaultOpen = false }: JsonNodeProps) { ); } -function ToolJsonView({ value, summary }: ToolJsonViewProps) { +function ToolJsonView({ + value, + summary, + defaultOpen = false, + scrollMode = 'self', +}: ToolJsonViewProps) { + const containerRef = useRef(null); + const inactiveRef = useRef(null); + useNestedScrollContainment(scrollMode === 'self' ? containerRef : inactiveRef); + return ( -
    - +
    +
    {summary}
    ); diff --git a/frontend/src/components/Chat/ToolStreamBlock.tsx b/frontend/src/components/Chat/ToolStreamBlock.tsx deleted file mode 100644 index 4856caad..00000000 --- a/frontend/src/components/Chat/ToolStreamBlock.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { memo } from 'react'; - -import type { ToolStreamMessage } from '../../types'; -import { pillDanger, pillNeutral, pillSuccess, terminalBlock } from '../../lib/styles'; -import { cn } from '../../lib/utils'; - -interface ToolStreamBlockProps { - message: ToolStreamMessage; -} - -function streamLabel(stream: ToolStreamMessage['stream']): string { - return stream === 'stderr' ? 'stderr' : 'stdout'; -} - -function statusPill(status: ToolStreamMessage['status']): string { - switch (status) { - case 'ok': - return pillSuccess; - case 'fail': - return pillDanger; - default: - return pillNeutral; - } -} - -function statusLabel(status: ToolStreamMessage['status']): string { - switch (status) { - case 'ok': - return 'completed'; - case 'fail': - return 'failed'; - default: - return 'running'; - } -} - -function ToolStreamBlock({ message }: ToolStreamBlockProps) { - return ( -
    -
    - {streamLabel(message.stream)} - {statusLabel(message.status)} -
    -
    -
    -          {message.content}
    -        
    -
    -
    - ); -} - -export default memo(ToolStreamBlock); diff --git a/frontend/src/components/Chat/TopBar.tsx b/frontend/src/components/Chat/TopBar.tsx index c23d418e..d380211d 100644 --- a/frontend/src/components/Chat/TopBar.tsx +++ b/frontend/src/components/Chat/TopBar.tsx @@ -6,6 +6,8 @@ export default function TopBar() { const { projectName, sessionTitle, + currentModeId, + conversationControl, activeSubRunPath, activeSubRunBreadcrumbs, isSidebarOpen, @@ -13,6 +15,7 @@ export default function TopBar() { onCloseSubRun, onNavigateSubRunPath, } = useChatScreenContext(); + const activePlan = conversationControl?.activePlan; return (
    @@ -92,6 +95,32 @@ export default function TopBar() { 未选择会话 )}
    + {conversationControl && ( +
    + {currentModeId && ( + + {currentModeId} + + )} + {activePlan ? ( + + 当前计划 · {activePlan.title} + + ) : null} + {conversationControl.compacting ? ( + + 正在 compact + + ) : conversationControl.compactPending ? ( + + compact 已排队 + + ) : null} +
    + )}
    ); } diff --git a/frontend/src/components/Chat/index.tsx b/frontend/src/components/Chat/index.tsx index f9de4f24..b3b3765e 100644 --- a/frontend/src/components/Chat/index.tsx +++ b/frontend/src/components/Chat/index.tsx @@ -3,6 +3,7 @@ import { subRunNotice } from '../../lib/styles'; import InputBar from './InputBar'; import MessageList from './MessageList'; import { ChatScreenProvider, type ChatScreenContextValue } from './ChatScreenContext'; +import TaskPanel from './TaskPanel'; import TopBar from './TopBar'; interface ChatProps { @@ -24,6 +25,7 @@ export default function Chat({
    + { + it('contains wheel events while the nested container can continue scrolling', () => { + expect(resolveNestedScrollContainmentMode(120, 200, 800, 48)).toBe('contain'); + expect(resolveNestedScrollContainmentMode(120, 200, 800, -48)).toBe('contain'); + }); + + it('lets wheel events bubble at the top boundary', () => { + expect(resolveNestedScrollContainmentMode(0, 200, 800, -48)).toBe('bubble'); + }); + + it('lets wheel events bubble at the bottom boundary', () => { + expect(resolveNestedScrollContainmentMode(600, 200, 800, 48)).toBe('bubble'); + }); + + it('lets wheel events bubble when the nested container cannot scroll', () => { + expect(resolveNestedScrollContainmentMode(0, 200, 200, 48)).toBe('bubble'); + }); +}); diff --git a/frontend/src/components/Chat/useNestedScrollContainment.ts b/frontend/src/components/Chat/useNestedScrollContainment.ts new file mode 100644 index 00000000..f12dbbab --- /dev/null +++ b/frontend/src/components/Chat/useNestedScrollContainment.ts @@ -0,0 +1,49 @@ +import { useEffect } from 'react'; +import type { RefObject } from 'react'; + +export type NestedScrollContainmentMode = 'contain' | 'bubble'; + +export function resolveNestedScrollContainmentMode( + scrollTop: number, + clientHeight: number, + scrollHeight: number, + deltaY: number +): NestedScrollContainmentMode { + const canScroll = scrollHeight > clientHeight + 1; + if (!canScroll || deltaY === 0) { + return 'bubble'; + } + + const atTop = scrollTop <= 0 && deltaY < 0; + const atBottom = scrollTop + clientHeight >= scrollHeight - 1 && deltaY > 0; + return atTop || atBottom ? 'bubble' : 'contain'; +} + +export function useNestedScrollContainment(ref: RefObject) { + useEffect(() => { + const container = ref.current; + if (!container) { + return; + } + + const onWheel = (event: WheelEvent) => { + if ( + resolveNestedScrollContainmentMode( + container.scrollTop, + container.clientHeight, + container.scrollHeight, + event.deltaY + ) !== 'contain' + ) { + return; + } + + event.stopPropagation(); + }; + + container.addEventListener('wheel', onWheel, { passive: false }); + return () => { + container.removeEventListener('wheel', onWheel); + }; + }, [ref]); +} diff --git a/frontend/src/components/Sidebar/index.tsx b/frontend/src/components/Sidebar/index.tsx index 132258fc..fc7ae51b 100644 --- a/frontend/src/components/Sidebar/index.tsx +++ b/frontend/src/components/Sidebar/index.tsx @@ -29,8 +29,6 @@ interface SidebarProps { onDeleteSession: (projectId: string, sessionId: string) => void; onOpenSettings: () => void; onNewSession: () => void; - showDebugWorkbenchEntry?: boolean; - onOpenDebugWorkbench?: () => void; } export default function Sidebar({ @@ -48,8 +46,6 @@ export default function Sidebar({ onDeleteSession, onOpenSettings, onNewSession, - showDebugWorkbenchEntry = false, - onOpenDebugWorkbench, }: SidebarProps) { const [showModal, setShowModal] = useState(false); @@ -111,15 +107,6 @@ export default function Sidebar({
    - {showDebugWorkbenchEntry && onOpenDebugWorkbench ? ( - - ) : null}